Skip to content

Commit b0e0a9f

Browse files
committed
✨ feat(products): add digital product management
Changes made in this commit: - Modified: internal/app.go, internal/handlers/private/product.go, internal/queries/products.go - Modified: web/admin/src/lib/components/product/View.svelte, web/admin/src/lib/types/models.ts - Added: web/admin/src/lib/components/product/Digital.svelte Key changes: - Add static route /secrets for digital product files - Update UpdateProduct handler to return updated product data - Fix digital.Filled field handling in product queries - Add reactive product loading in ProductView component - Add Digital component for managing digital product files and data - Add filled field to Product digital type definition - Improve product view loading state and error handling This enables full digital product management with file uploads and data entries that can be sent to customers after purchase.
1 parent 66a1d3c commit b0e0a9f

File tree

6 files changed

+268
-7
lines changed

6 files changed

+268
-7
lines changed

internal/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ func setupFiberApp(noSite bool) (*fiber.App, error) {
9494
// setupRoutes configures application routes.
9595
func setupRoutes(app *fiber.App, noSite bool) {
9696
app.Static("/uploads", "./lc_uploads")
97+
app.Static("/secrets", "./lc_digitals")
9798

9899
// Register API routes before SPA routes to ensure they are processed first
99100
routes.ApiPrivateRoutes(app)

internal/handlers/private/product.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,14 @@ func UpdateProduct(c *fiber.Ctx) error {
9595
return webutil.StatusInternalServerError(c)
9696
}
9797

98-
return webutil.Response(c, fiber.StatusOK, "Product updated", nil)
98+
// Return updated product
99+
product, err := db.Product(c.Context(), true, productID)
100+
if err != nil {
101+
log.ErrorStack(err)
102+
return webutil.StatusInternalServerError(c)
103+
}
104+
105+
return webutil.Response(c, fiber.StatusOK, "Product updated", product)
99106
}
100107

101108
// DeleteProduct deletes a product by ID.

internal/queries/products.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,11 @@ func (q *ProductQueries) ListProducts(ctx context.Context, private bool, idList
101101

102102
product.Digital.Type = digitalType.String
103103
if private && digitalType.Valid {
104-
product.Digital.Filled = digitalFilled.Bool
104+
if digitalFilled.Valid {
105+
product.Digital.Filled = digitalFilled.Bool
106+
} else {
107+
product.Digital.Filled = false
108+
}
105109
}
106110

107111
products.Products = append(products.Products, product)
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte';
3+
import { createEventDispatcher } from 'svelte';
4+
import FormInput from '../form/Input.svelte';
5+
import FormButton from '../form/Button.svelte';
6+
import Upload from '../form/Upload.svelte';
7+
import SvgIcon from '../SvgIcon.svelte';
8+
import { loadData } from '$lib/utils/apiHelpers';
9+
import { apiPost, apiUpdate, apiDelete } from '$lib/utils/api';
10+
import { showMessage } from '$lib/utils';
11+
import type { Product } from '$lib/types/models';
12+
13+
interface Digital {
14+
type: string;
15+
files: Array<{
16+
id: string;
17+
name: string;
18+
ext: string;
19+
orig_name?: string;
20+
}>;
21+
data: Array<{
22+
id: string;
23+
content: string;
24+
cart_id: string | null;
25+
}>;
26+
}
27+
28+
interface DrawerProduct {
29+
product: Product;
30+
index: number;
31+
currency?: string;
32+
}
33+
34+
export let drawer: DrawerProduct;
35+
export let onContentUpdate: (() => void) | undefined = undefined;
36+
37+
const dispatch = createEventDispatcher();
38+
39+
let digital: Digital = {
40+
type: '',
41+
files: [],
42+
data: []
43+
};
44+
let loading = true;
45+
46+
onMount(async () => {
47+
await loadDigital();
48+
});
49+
50+
async function loadDigital() {
51+
loading = true;
52+
const result = await loadData<Digital>(`/api/_/products/${drawer.product.id}/digital`, 'Failed to load digital content');
53+
if (result) {
54+
digital = {
55+
type: result.type || '',
56+
files: result.files || [],
57+
data: result.data || []
58+
};
59+
}
60+
loading = false;
61+
}
62+
63+
function close() {
64+
dispatch('close');
65+
}
66+
67+
async function handleUpload(event: CustomEvent) {
68+
if (event.detail.success && event.detail.result) {
69+
digital.files = [...digital.files, event.detail.result];
70+
showMessage('File uploaded', 'connextSuccess');
71+
if (onContentUpdate) {
72+
onContentUpdate();
73+
}
74+
}
75+
}
76+
77+
async function addDigitalData() {
78+
const result = await apiPost(`/api/_/products/${drawer.product.id}/digital`);
79+
if (result.success && result.result) {
80+
digital.data = [...digital.data, result.result];
81+
showMessage('Data added', 'connextSuccess');
82+
if (onContentUpdate) {
83+
onContentUpdate();
84+
}
85+
} else {
86+
showMessage(result.message || 'Failed to add data', 'connextError');
87+
}
88+
}
89+
90+
async function saveData(index: number) {
91+
const dataItem = digital.data[index];
92+
if (!dataItem || dataItem.cart_id !== null) return;
93+
94+
const update = {
95+
content: dataItem.content
96+
};
97+
const result = await apiUpdate(`/api/_/products/${drawer.product.id}/digital/${dataItem.id}`, update);
98+
if (result.success) {
99+
showMessage('Data saved', 'connextSuccess');
100+
} else {
101+
showMessage(result.message || 'Failed to save data', 'connextError');
102+
}
103+
}
104+
105+
async function deleteDigital(type: 'file' | 'data', index: number) {
106+
const digitalId = type === 'file' ? digital.files[index].id : digital.data[index].id;
107+
const result = await apiDelete(`/api/_/products/${drawer.product.id}/digital/${digitalId}`);
108+
109+
if (result.success) {
110+
if (type === 'file') {
111+
digital.files = digital.files.filter((_, i) => i !== index);
112+
} else {
113+
digital.data = digital.data.filter((_, i) => i !== index);
114+
}
115+
showMessage('Deleted', 'connextSuccess');
116+
if (onContentUpdate) {
117+
onContentUpdate();
118+
}
119+
} else {
120+
showMessage(result.message || 'Failed to delete', 'connextError');
121+
}
122+
}
123+
</script>
124+
125+
<div>
126+
<div class="pb-8">
127+
<div class="flex items-center">
128+
<div class="pr-3">
129+
<h1>Digital {digital.type}</h1>
130+
{#if digital.type === 'file'}
131+
<p class="mt-4">This is the product that the user purchases. Upload the files that will be sent to the buyer after payment to the email address provided during checkout.</p>
132+
{/if}
133+
{#if digital.type === 'data'}
134+
<p class="mt-4">Enter the digital product that you intend to sell. It can be a unique item, such as a license key.</p>
135+
{/if}
136+
</div>
137+
</div>
138+
</div>
139+
140+
{#if loading}
141+
<div class="text-center py-8">Loading...</div>
142+
{:else if digital.type === 'file'}
143+
<!-- File section -->
144+
<div class="flow-root">
145+
<div class="-my-3 mx-auto mb-0 mt-2 space-y-4 text-sm">
146+
{#if digital.files && digital.files.length > 0}
147+
<div class="grid content-start">
148+
{#each digital.files as file, index}
149+
<div class="relative mt-4 flex first:mt-0">
150+
<a href="/secrets/{file.name}.{file.ext}" target="_blank" class="rounded-lg bg-gray-200 px-3 py-3" rel="noopener noreferrer">
151+
{file.orig_name || file.name}.{file.ext}
152+
</a>
153+
<div
154+
class="ml-3 mt-3 cursor-pointer"
155+
role="button"
156+
tabindex="0"
157+
on:click={() => deleteDigital('file', index)}
158+
on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); deleteDigital('file', index); } }}
159+
>
160+
<SvgIcon name="trash" className="h-5 w-5" stroke="currentColor" />
161+
</div>
162+
</div>
163+
{/each}
164+
</div>
165+
{/if}
166+
<Upload
167+
section="digital"
168+
productId={drawer.product.id}
169+
on:added={handleUpload}
170+
/>
171+
</div>
172+
</div>
173+
{:else if digital.type === 'data'}
174+
<!-- Data section -->
175+
<div class="flow-root">
176+
<div class="-my-3 mx-auto mb-0 mt-4 space-y-4 text-sm">
177+
{#if digital.data && digital.data.length > 0}
178+
{#each digital.data as dataItem, index}
179+
<div class="flex">
180+
{#if dataItem.cart_id === null || dataItem.cart_id === ''}
181+
<div class="grow">
182+
<FormInput
183+
id="data-{dataItem.id}"
184+
type="text"
185+
title=""
186+
bind:value={dataItem.content}
187+
on:blur={() => saveData(index)}
188+
/>
189+
</div>
190+
<div
191+
class="flex-none cursor-pointer pl-3 pt-3"
192+
role="button"
193+
tabindex="0"
194+
on:click={() => deleteDigital('data', index)}
195+
on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); deleteDigital('data', index); } }}
196+
>
197+
<SvgIcon name="trash" className="h-5 w-5" stroke="currentColor" />
198+
</div>
199+
{:else}
200+
<div class="grow">
201+
<div class="rounded-lg bg-gray-200 px-3 py-3">
202+
{dataItem.content}
203+
</div>
204+
</div>
205+
{/if}
206+
</div>
207+
{/each}
208+
{/if}
209+
<div class="flex">
210+
<div class="grow"></div>
211+
<div class="mt-2 flex-none">
212+
<button
213+
type="button"
214+
class="shrink-0 rounded-lg bg-gray-200 p-2 text-sm font-medium text-gray-700"
215+
on:click={addDigitalData}
216+
>
217+
Add data
218+
</button>
219+
</div>
220+
</div>
221+
</div>
222+
</div>
223+
{:else}
224+
<div class="mt-4 flow-root">Select digital type</div>
225+
{/if}
226+
227+
<div class="pt-5">
228+
<FormButton type="button" name="Close" color="green" on:click={close} />
229+
</div>
230+
</div>

web/admin/src/lib/components/product/View.svelte

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,39 @@
1818
export let updateActive: ((index: number) => void) | undefined;
1919
2020
let product: Product | null = null;
21+
let loading = true;
22+
let lastProductId: string | null = null;
2123
2224
const dispatch = createEventDispatcher();
2325
24-
onMount(async () => {
26+
async function loadProduct() {
27+
if (!drawer?.product?.id) return;
28+
29+
loading = true;
2530
const result = await loadData<Product>(`/api/_/products/${drawer.product.id}`, 'Failed to load product');
2631
if (result) {
2732
product = result;
33+
lastProductId = drawer.product.id;
2834
}
35+
loading = false;
36+
}
37+
38+
onMount(async () => {
39+
await loadProduct();
2940
});
3041
42+
// Reload product when drawer.product.id changes
43+
$: if (drawer?.product?.id && drawer.product.id !== lastProductId) {
44+
loadProduct();
45+
}
46+
3147
function close() {
3248
dispatch('close');
3349
}
3450
3551
async function active() {
3652
if (updateActive && product) {
37-
await updateActive(drawer.product.index);
53+
await updateActive(drawer.index);
3854
// Update local product state reactively
3955
if (product) {
4056
product = { ...product, active: !product.active };
@@ -57,7 +73,9 @@
5773
</div>
5874
</div>
5975

60-
{#if product}
76+
{#if loading}
77+
<div class="text-center py-8">Loading...</div>
78+
{:else if product}
6179
<div class="flow-root">
6280
<dl class="-my-3 mt-2 divide-y divide-gray-100 text-sm">
6381
<DetailList name="ID">{product.id}</DetailList>
@@ -91,11 +109,11 @@
91109
{/if}
92110
<DetailList name="Brief (short description)">{product.brief}</DetailList>
93111

94-
<div class="pt-3 tiptap" innerHTML={product.description || ''}></div>
112+
<div class="pt-3 tiptap">{@html product.description || ''}</div>
95113
</dl>
96114
</div>
97115
{:else}
98-
<div class="text-center py-8">Loading...</div>
116+
<div class="text-center py-8 text-gray-500">Failed to load product</div>
99117
{/if}
100118

101119
<div class="pt-5">

web/admin/src/lib/types/models.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface Product {
1212
attributes?: string[];
1313
digital?: {
1414
type: "file" | "data" | "api" | "";
15+
filled?: boolean;
1516
};
1617
images?: Array<{
1718
id: string;

0 commit comments

Comments
 (0)