From dd7db5032a5e88dfb0fa6255ec9751d7547436eb Mon Sep 17 00:00:00 2001 From: MatejGhub Date: Sun, 20 Sep 2026 10:48:19 +0200 Subject: [PATCH] polished and testing exports --- .../products/ExportSelectionDialog.svelte | 82 +++++++++++++++-- .../components/products/ProductTable.svelte | 2 +- apps/web/src/lib/export-feeds-helpers.test.ts | 18 ++++ apps/web/src/lib/export-feeds-helpers.ts | 20 ++++ apps/web/src/lib/i18n/messages/de.ts | 4 + apps/web/src/lib/i18n/messages/en.ts | 4 + apps/web/src/lib/i18n/messages/es.ts | 4 + apps/web/src/lib/i18n/messages/fr.ts | 4 + apps/web/src/lib/i18n/messages/it.ts | 4 + apps/web/src/lib/i18n/messages/ja.ts | 4 + apps/web/src/lib/i18n/messages/nl.ts | 4 + apps/web/src/lib/i18n/messages/pl.ts | 4 + apps/web/src/lib/i18n/messages/pt.ts | 4 + apps/web/src/routes/+layout.svelte | 3 +- apps/web/src/routes/admin/users/+page.svelte | 16 +++- apps/web/src/routes/export-feeds/+page.svelte | 91 ++++++++++++------- apps/web/src/routes/settings/+page.svelte | 54 +++++++++-- 17 files changed, 269 insertions(+), 53 deletions(-) diff --git a/apps/web/src/lib/components/products/ExportSelectionDialog.svelte b/apps/web/src/lib/components/products/ExportSelectionDialog.svelte index 7ee9ddc..e9bf7ab 100644 --- a/apps/web/src/lib/components/products/ExportSelectionDialog.svelte +++ b/apps/web/src/lib/components/products/ExportSelectionDialog.svelte @@ -6,6 +6,7 @@ import { unwrapList, OPTION_LIST_LIMIT } from "$lib/list"; import { canConfirmExport, + fieldKeysFromExportTemplate, mapActiveExportFeedOptions, resolveExportDialogBody, type ExportFeedOption @@ -28,6 +29,8 @@ let exportFeeds = $state([]); let selectedFeedId = $state(""); + let selectedFieldKeys = $state([]); + let fieldsLoading = $state(false); let loading = $state(false); let error = $state(""); @@ -42,11 +45,13 @@ feedCount: exportFeeds.length }) ); + const selectedFeed = $derived(exportFeeds.find((f) => f.id === selectedFeedId) ?? null); async function fetchFeeds() { loading = true; error = ""; selectedFeedId = ""; + selectedFieldKeys = []; try { const payload = await api>( `/api/export-feeds?limit=${OPTION_LIST_LIMIT}` @@ -69,9 +74,36 @@ } }); + $effect(() => { + const id = selectedFeedId; + if (!open || !id) { + selectedFieldKeys = []; + fieldsLoading = false; + return; + } + let cancelled = false; + fieldsLoading = true; + void (async () => { + try { + const full = await api<{ template?: unknown }>(`/api/export-feeds/${id}`); + if (cancelled) return; + selectedFieldKeys = fieldKeysFromExportTemplate(full.template); + } catch { + if (cancelled) return; + selectedFieldKeys = []; + } finally { + if (!cancelled) fieldsLoading = false; + } + })(); + return () => { + cancelled = true; + }; + }); + function close() { open = false; selectedFeedId = ""; + selectedFieldKeys = []; error = ""; onClose?.(); } @@ -88,30 +120,35 @@ close(); void goto("/export-feeds"); } + + function goEditFeed() { + close(); + void goto("/export-feeds"); + } {#if open} {#if dialogBody === "loading"} -
- +
+
{:else if dialogBody === "error"} -
+

{error}

{:else if dialogBody === "empty"} -
+

{i18n.t("export.dialog.emptyTitle")}

{i18n.t("export.dialog.emptyMessage")}

{:else} -
+
{#if error} {error} @@ -134,6 +171,39 @@

{i18n.t("export.dialog.help")}

+ {#if selectedFeed} +
+
+

+ {#if fieldsLoading} + {i18n.t("export.dialog.fieldsLoading")} + {:else if selectedFieldKeys.length === 0} + {i18n.t("export.dialog.fieldsEmpty")} + {:else} + {i18n.t("export.dialog.fieldsSummary", { count: selectedFieldKeys.length })} + {/if} +

+ +
+ {#if !fieldsLoading && selectedFieldKeys.length > 0} +
    + {#each selectedFieldKeys as key} +
  • + {key} +
  • + {/each} +
+ {/if} +
+ {/if}
{/if} diff --git a/apps/web/src/lib/components/products/ProductTable.svelte b/apps/web/src/lib/components/products/ProductTable.svelte index 3639024..38acbe5 100644 --- a/apps/web/src/lib/components/products/ProductTable.svelte +++ b/apps/web/src/lib/components/products/ProductTable.svelte @@ -502,7 +502,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte"; {:else} String(product.id)} diff --git a/apps/web/src/lib/export-feeds-helpers.test.ts b/apps/web/src/lib/export-feeds-helpers.test.ts index a40b9ad..3c72ac8 100644 --- a/apps/web/src/lib/export-feeds-helpers.test.ts +++ b/apps/web/src/lib/export-feeds-helpers.test.ts @@ -12,6 +12,7 @@ import { activeFormValue, buildExportFilters, canConfirmExport, + fieldKeysFromExportTemplate, filterExportFeedsBySearch, formatStatusList, isActiveFromForm, @@ -79,6 +80,23 @@ describe("mapActiveExportFeedOptions", () => { }); }); +describe("fieldKeysFromExportTemplate", () => { + it("reads fields[], then mappings, else empty", () => { + assert.deepEqual( + fieldKeysFromExportTemplate({ + fields: [{ key: "id" }, { name: "title" }, { key: " " }] + }), + ["id", "title"] + ); + assert.deepEqual(fieldKeysFromExportTemplate({ mappings: { gtin: "gtin", brand: "brand" } }), [ + "gtin", + "brand" + ]); + assert.deepEqual(fieldKeysFromExportTemplate(null), []); + assert.deepEqual(fieldKeysFromExportTemplate({}), []); + }); +}); + describe("resolveExportDialogBody", () => { it("prefers loading, then error-empty, empty, ready", () => { assert.equal(resolveExportDialogBody({ loading: true, error: "x", feedCount: 0 }), "loading"); diff --git a/apps/web/src/lib/export-feeds-helpers.ts b/apps/web/src/lib/export-feeds-helpers.ts index 223b31e..304eee2 100644 --- a/apps/web/src/lib/export-feeds-helpers.ts +++ b/apps/web/src/lib/export-feeds-helpers.ts @@ -76,6 +76,26 @@ export function mapActiveExportFeedOptions( })); } +/** Column keys from an export feed template (fields[] or legacy mappings). */ +export function fieldKeysFromExportTemplate(template: unknown): string[] { + if (!template || typeof template !== "object") return []; + const t = template as { fields?: unknown; mappings?: unknown }; + if (Array.isArray(t.fields) && t.fields.length > 0) { + const keys: string[] = []; + for (const f of t.fields) { + if (!f || typeof f !== "object") continue; + const row = f as { key?: unknown; name?: unknown }; + const key = String(row.key ?? row.name ?? "").trim(); + if (key) keys.push(key); + } + return keys; + } + if (t.mappings && typeof t.mappings === "object" && !Array.isArray(t.mappings)) { + return Object.keys(t.mappings as Record); + } + return []; +} + /** Which body the product export dialog should show. */ export function resolveExportDialogBody(input: { loading: boolean; diff --git a/apps/web/src/lib/i18n/messages/de.ts b/apps/web/src/lib/i18n/messages/de.ts index 5fec667..d566f24 100644 --- a/apps/web/src/lib/i18n/messages/de.ts +++ b/apps/web/src/lib/i18n/messages/de.ts @@ -3360,6 +3360,10 @@ export const de: MessageDict = { "export.dialog.createFeedCta": "Export-Feed erstellen", "export.dialog.confirmAria": "Export bestätigen", "export.dialog.export": "Exportieren", + "export.dialog.fieldsLoading": "Feed-Felder werden geladen…", + "export.dialog.fieldsSummary": "Dieser Export enthält {count} Felder aus dem Feed:", + "export.dialog.fieldsEmpty": "Für diesen Feed sind noch keine Felder konfiguriert.", + "export.dialog.editFields": "Feed-Felder bearbeiten", "billing.title": "Nutzung & Abrechnung", "billing.loadFailed": "Abrechnung konnte nicht geladen werden", "billing.usageLoadFailed": "Nutzung konnte nicht geladen werden", diff --git a/apps/web/src/lib/i18n/messages/en.ts b/apps/web/src/lib/i18n/messages/en.ts index 5aa2cc6..e9d8318 100644 --- a/apps/web/src/lib/i18n/messages/en.ts +++ b/apps/web/src/lib/i18n/messages/en.ts @@ -4048,6 +4048,10 @@ export const en: MessageDict = { "export.dialog.createFeedCta": "Create export feed", "export.dialog.confirmAria": "Confirm export", "export.dialog.export": "Export", + "export.dialog.fieldsLoading": "Loading feed fields…", + "export.dialog.fieldsSummary": "This export will include {count} fields from the feed:", + "export.dialog.fieldsEmpty": "This feed has no fields configured yet.", + "export.dialog.editFields": "Edit feed fields", "billing.title": "Usage & Billing", "billing.loadFailed": "Failed to load billing", "billing.usageLoadFailed": "Failed to load usage", diff --git a/apps/web/src/lib/i18n/messages/es.ts b/apps/web/src/lib/i18n/messages/es.ts index 92ee493..fa15339 100644 --- a/apps/web/src/lib/i18n/messages/es.ts +++ b/apps/web/src/lib/i18n/messages/es.ts @@ -3360,6 +3360,10 @@ export const es: MessageDict = { "export.dialog.createFeedCta": "Crear feed de exportación", "export.dialog.confirmAria": "Confirmar exportación", "export.dialog.export": "Exportar", + "export.dialog.fieldsLoading": "Cargando campos del feed…", + "export.dialog.fieldsSummary": "Esta exportación incluirá {count} campos del feed:", + "export.dialog.fieldsEmpty": "Este feed aún no tiene campos configurados.", + "export.dialog.editFields": "Editar campos del feed", "billing.title": "Uso y facturación", "billing.loadFailed": "No se pudo cargar la facturación", "billing.usageLoadFailed": "No se pudo cargar el uso", diff --git a/apps/web/src/lib/i18n/messages/fr.ts b/apps/web/src/lib/i18n/messages/fr.ts index 6354ba5..3c8829b 100644 --- a/apps/web/src/lib/i18n/messages/fr.ts +++ b/apps/web/src/lib/i18n/messages/fr.ts @@ -3360,6 +3360,10 @@ export const fr: MessageDict = { "export.dialog.createFeedCta": "Créer un feed d'export", "export.dialog.confirmAria": "Confirmer l'export", "export.dialog.export": "Exporter", + "export.dialog.fieldsLoading": "Chargement des champs du feed…", + "export.dialog.fieldsSummary": "Cet export inclura {count} champs du feed :", + "export.dialog.fieldsEmpty": "Ce feed n’a pas encore de champs configurés.", + "export.dialog.editFields": "Modifier les champs du feed", "billing.title": "Utilisation et facturation", "billing.loadFailed": "Échec du chargement de la facturation", "billing.usageLoadFailed": "Échec du chargement de l'utilisation", diff --git a/apps/web/src/lib/i18n/messages/it.ts b/apps/web/src/lib/i18n/messages/it.ts index d15882d..525b2e3 100644 --- a/apps/web/src/lib/i18n/messages/it.ts +++ b/apps/web/src/lib/i18n/messages/it.ts @@ -3360,6 +3360,10 @@ export const it: MessageDict = { "export.dialog.createFeedCta": "Crea feed di esportazione", "export.dialog.confirmAria": "Conferma esportazione", "export.dialog.export": "Esporta", + "export.dialog.fieldsLoading": "Caricamento campi del feed…", + "export.dialog.fieldsSummary": "Questa esportazione includerà {count} campi dal feed:", + "export.dialog.fieldsEmpty": "Questo feed non ha ancora campi configurati.", + "export.dialog.editFields": "Modifica campi del feed", "billing.title": "Utilizzo e fatturazione", "billing.loadFailed": "Caricamento fatturazione non riuscito", "billing.usageLoadFailed": "Caricamento utilizzo non riuscito", diff --git a/apps/web/src/lib/i18n/messages/ja.ts b/apps/web/src/lib/i18n/messages/ja.ts index 04fde97..1f4fd6b 100644 --- a/apps/web/src/lib/i18n/messages/ja.ts +++ b/apps/web/src/lib/i18n/messages/ja.ts @@ -3360,6 +3360,10 @@ export const ja: MessageDict = { "export.dialog.createFeedCta": "エクスポートフィードを作成", "export.dialog.confirmAria": "エクスポートを確認", "export.dialog.export": "エクスポート", + "export.dialog.fieldsLoading": "Loading feed fields…", + "export.dialog.fieldsSummary": "This export will include {count} fields from the feed:", + "export.dialog.fieldsEmpty": "This feed has no fields configured yet.", + "export.dialog.editFields": "Edit feed fields", "billing.title": "利用状況と請求", "billing.loadFailed": "請求情報の読み込みに失敗しました", "billing.usageLoadFailed": "利用状況の読み込みに失敗しました", diff --git a/apps/web/src/lib/i18n/messages/nl.ts b/apps/web/src/lib/i18n/messages/nl.ts index cc27e71..e4d84f3 100644 --- a/apps/web/src/lib/i18n/messages/nl.ts +++ b/apps/web/src/lib/i18n/messages/nl.ts @@ -3360,6 +3360,10 @@ export const nl: MessageDict = { "export.dialog.createFeedCta": "Exportfeed maken", "export.dialog.confirmAria": "Export bevestigen", "export.dialog.export": "Exporteren", + "export.dialog.fieldsLoading": "Feedvelden laden…", + "export.dialog.fieldsSummary": "Deze export bevat {count} velden uit de feed:", + "export.dialog.fieldsEmpty": "Deze feed heeft nog geen velden geconfigureerd.", + "export.dialog.editFields": "Feedvelden bewerken", "billing.title": "Gebruik & facturering", "billing.loadFailed": "Facturering laden mislukt", "billing.usageLoadFailed": "Gebruik laden mislukt", diff --git a/apps/web/src/lib/i18n/messages/pl.ts b/apps/web/src/lib/i18n/messages/pl.ts index 96bdd70..bec311e 100644 --- a/apps/web/src/lib/i18n/messages/pl.ts +++ b/apps/web/src/lib/i18n/messages/pl.ts @@ -3360,6 +3360,10 @@ export const pl: MessageDict = { "export.dialog.createFeedCta": "Utwórz feed eksportu", "export.dialog.confirmAria": "Potwierdź eksport", "export.dialog.export": "Eksportuj", + "export.dialog.fieldsLoading": "Ładowanie pól feedu…", + "export.dialog.fieldsSummary": "Ten eksport będzie zawierał {count} pól z feedu:", + "export.dialog.fieldsEmpty": "Ten feed nie ma jeszcze skonfigurowanych pól.", + "export.dialog.editFields": "Edytuj pola feedu", "billing.title": "Użycie i rozliczenia", "billing.loadFailed": "Nie udaÅ‚o siÄ™ wczytać rozliczeÅ„", "billing.usageLoadFailed": "Nie udaÅ‚o siÄ™ wczytać użycia", diff --git a/apps/web/src/lib/i18n/messages/pt.ts b/apps/web/src/lib/i18n/messages/pt.ts index 8944e6c..c55e8a7 100644 --- a/apps/web/src/lib/i18n/messages/pt.ts +++ b/apps/web/src/lib/i18n/messages/pt.ts @@ -3360,6 +3360,10 @@ export const pt: MessageDict = { "export.dialog.createFeedCta": "Criar feed de exportação", "export.dialog.confirmAria": "Confirmar exportação", "export.dialog.export": "Exportar", + "export.dialog.fieldsLoading": "A carregar campos do feed…", + "export.dialog.fieldsSummary": "Esta exportação incluirá {count} campos do feed:", + "export.dialog.fieldsEmpty": "Este feed ainda não tem campos configurados.", + "export.dialog.editFields": "Editar campos do feed", "billing.title": "Uso e faturação", "billing.loadFailed": "Falha ao carregar a faturação", "billing.usageLoadFailed": "Falha ao carregar a utilização", diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 273859b..d3b369c 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -550,7 +550,8 @@ {/if} -
+ +
{#key page.url.pathname} diff --git a/apps/web/src/routes/admin/users/+page.svelte b/apps/web/src/routes/admin/users/+page.svelte index b4498e5..8c1763d 100644 --- a/apps/web/src/routes/admin/users/+page.svelte +++ b/apps/web/src/routes/admin/users/+page.svelte @@ -56,7 +56,7 @@ TabsList, TabsTrigger } from "$lib/components/ui"; - import { Building2, Copy, KeyRound, RefreshCw, Search, Shield, UserPlus, Users } from "@lucide/svelte"; + import { Building2, Check, Copy, KeyRound, RefreshCw, Search, Shield, UserPlus, Users } from "@lucide/svelte"; type TabKey = "users" | "companies"; @@ -66,6 +66,7 @@ let busyUserId = $state(null); let error = $state(""); let success = $state(""); + let inviteLinkCopied = $state(false); let inviteAcceptLink = $state(null); let tab = $state("users"); let search = $state(""); @@ -547,6 +548,10 @@ await navigator.clipboard.writeText(inviteAcceptLink); success = i18n.t("admin.users.inviteLinkCopied"); error = ""; + inviteLinkCopied = true; + window.setTimeout(() => { + inviteLinkCopied = false; + }, 1500); } catch { error = i18n.t("flash.settings.copyFailed"); } @@ -647,8 +652,13 @@ aria-label={i18n.t("admin.users.inviteLinkLabel")} />
{/if} diff --git a/apps/web/src/routes/export-feeds/+page.svelte b/apps/web/src/routes/export-feeds/+page.svelte index f7898da..4115385 100644 --- a/apps/web/src/routes/export-feeds/+page.svelte +++ b/apps/web/src/routes/export-feeds/+page.svelte @@ -35,6 +35,7 @@ import { ChevronDown, Copy, + Check, Download, Edit, Eye, @@ -121,6 +122,7 @@ /** Set only by list `load()` — keeps create/save failures from looking like a failed list fetch. */ let listError = $state(""); let success = $state(""); + let copiedId = $state(""); let loading = $state(true); let saving = $state(false); let canAdmin = $state(false); @@ -365,10 +367,19 @@ } async function copyUrl(feed: ExportRow) { + const id = String(feed.id); try { await navigator.clipboard.writeText(publicUrl(feed)); - success = i18n.t("flash.export.urlCopied", { format: String(feed.format ?? "xml").toUpperCase() }); + const msg = i18n.t("flash.export.urlCopied", { + format: String(feed.format ?? "xml").toUpperCase() + }); + success = msg; error = ""; + notifySuccess(msg); + copiedId = id; + window.setTimeout(() => { + if (copiedId === id) copiedId = ""; + }, 1500); } catch { error = i18n.t("flash.export.copyFailed"); } @@ -649,9 +660,15 @@ class="h-8 w-8 shrink-0 p-0" disabled={refreshing} onclick={() => void copyUrl(feed)} - aria-label={i18n.t("exports.copyUrl")} + aria-label={copiedId === id + ? i18n.t("common.copied") + : i18n.t("exports.copyUrl")} > - + {#if copiedId === id} + + {:else} + + {/if}
@@ -727,9 +744,9 @@ description={editingId ? i18n.t("exports.editDescription") : presetHint(activePreset)} - class="max-h-[90vh] max-w-2xl overflow-y-auto" + class="max-w-3xl sm:max-w-4xl" > -
+
{#if !editingId}
@@ -757,7 +774,18 @@
-
+ {#if editingId} +
+ + +
+ {:else} + + {/if} +

{i18n.t("exports.productStatusesHelp")}

- {#if editingId} -
- - -
- {/if}
-
+
- +
- {i18n.t("exports.outputKey")} - {i18n.t("exports.source")} - + {i18n.t("exports.outputKey")} + {i18n.t("exports.source")} + {#each formFields as field, index} - - + + - -
+ +
{#if !SOURCE_OPTIONS.includes(field.source)} {/if}
- +
- -
- - -
+ + {#snippet footer()} + + + {/snippet} diff --git a/apps/web/src/routes/settings/+page.svelte b/apps/web/src/routes/settings/+page.svelte index 03a825d..69f4ac1 100644 --- a/apps/web/src/routes/settings/+page.svelte +++ b/apps/web/src/routes/settings/+page.svelte @@ -4,6 +4,7 @@ import { page } from "$app/state"; import { Building2, + Check, Copy, CreditCard, ExternalLink, @@ -180,6 +181,7 @@ let error = $state(""); let fieldErrors = $state>({}); let success = $state(""); + let copiedKey = $state(""); let saving = $state(false); let user = $state(null); @@ -838,12 +840,16 @@ } } - async function copyText(text: string, okMessage = i18n.t("common.copied")) { + async function copyText(text: string, okMessage = i18n.t("common.copied"), key = "default") { try { await navigator.clipboard.writeText(text); success = okMessage; error = ""; notifySuccess(success); + copiedKey = key; + window.setTimeout(() => { + if (copiedKey === key) copiedKey = ""; + }, 1500); } catch { error = i18n.t("flash.settings.copyFailed"); notifyError(error); @@ -1539,11 +1545,20 @@ {#if sessionSecret} - copyText(sessionSecret, i18n.t("settings.apiKeyCopied")) + copyText( + sessionSecret, + i18n.t("settings.apiKeyCopied"), + `api-key-${key.id}` + ) } > - - {i18n.t("settings.copyApiKey")} + {#if copiedKey === `api-key-${key.id}`} + + {i18n.t("common.copied")} + {:else} + + {i18n.t("settings.copyApiKey")} + {/if} {:else} @@ -1895,10 +1921,18 @@

{i18n.t("settings.storeKeySafe")}