polished and testing exports
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
import { unwrapList, OPTION_LIST_LIMIT } from "$lib/list";
|
import { unwrapList, OPTION_LIST_LIMIT } from "$lib/list";
|
||||||
import {
|
import {
|
||||||
canConfirmExport,
|
canConfirmExport,
|
||||||
|
fieldKeysFromExportTemplate,
|
||||||
mapActiveExportFeedOptions,
|
mapActiveExportFeedOptions,
|
||||||
resolveExportDialogBody,
|
resolveExportDialogBody,
|
||||||
type ExportFeedOption
|
type ExportFeedOption
|
||||||
@@ -28,6 +29,8 @@
|
|||||||
|
|
||||||
let exportFeeds = $state<ExportFeedOption[]>([]);
|
let exportFeeds = $state<ExportFeedOption[]>([]);
|
||||||
let selectedFeedId = $state("");
|
let selectedFeedId = $state("");
|
||||||
|
let selectedFieldKeys = $state<string[]>([]);
|
||||||
|
let fieldsLoading = $state(false);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
|
|
||||||
@@ -42,11 +45,13 @@
|
|||||||
feedCount: exportFeeds.length
|
feedCount: exportFeeds.length
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
const selectedFeed = $derived(exportFeeds.find((f) => f.id === selectedFeedId) ?? null);
|
||||||
|
|
||||||
async function fetchFeeds() {
|
async function fetchFeeds() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = "";
|
error = "";
|
||||||
selectedFeedId = "";
|
selectedFeedId = "";
|
||||||
|
selectedFieldKeys = [];
|
||||||
try {
|
try {
|
||||||
const payload = await api<ListResponse<ExportFeed & { format?: string | null; is_active?: boolean | null }>>(
|
const payload = await api<ListResponse<ExportFeed & { format?: string | null; is_active?: boolean | null }>>(
|
||||||
`/api/export-feeds?limit=${OPTION_LIST_LIMIT}`
|
`/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() {
|
function close() {
|
||||||
open = false;
|
open = false;
|
||||||
selectedFeedId = "";
|
selectedFeedId = "";
|
||||||
|
selectedFieldKeys = [];
|
||||||
error = "";
|
error = "";
|
||||||
onClose?.();
|
onClose?.();
|
||||||
}
|
}
|
||||||
@@ -88,30 +120,35 @@
|
|||||||
close();
|
close();
|
||||||
void goto("/export-feeds");
|
void goto("/export-feeds");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goEditFeed() {
|
||||||
|
close();
|
||||||
|
void goto("/export-feeds");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Mount only while open — avoids nested bind:open / one-way $bindable dropping open. -->
|
<!-- Mount only while open — avoids nested bind:open / one-way $bindable dropping open. -->
|
||||||
{#if open}
|
{#if open}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={true}
|
open={true}
|
||||||
class="sm:max-w-[425px] sm:min-w-0"
|
class="sm:max-w-md sm:min-w-0"
|
||||||
title={i18n.t("products.export.title")}
|
title={i18n.t("products.export.title")}
|
||||||
description={i18n.t("export.dialog.description", { count: selectedProductIds.length })}
|
description={i18n.t("export.dialog.description", { count: selectedProductIds.length })}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
>
|
>
|
||||||
{#if dialogBody === "loading"}
|
{#if dialogBody === "loading"}
|
||||||
<div class="flex items-center justify-center py-8">
|
<div class="flex items-center justify-center py-6">
|
||||||
<Spinner class="h-8 w-8 text-muted-foreground" />
|
<Spinner class="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{:else if dialogBody === "error"}
|
{:else if dialogBody === "error"}
|
||||||
<div class="space-y-3 py-2">
|
<div class="space-y-3">
|
||||||
<p class="text-sm text-destructive">{error}</p>
|
<p class="text-sm text-destructive">{error}</p>
|
||||||
<Button variant="outline" size="sm" onclick={goCreateFeed}>
|
<Button variant="outline" size="sm" onclick={goCreateFeed}>
|
||||||
{i18n.t("export.dialog.createFeedCta")}
|
{i18n.t("export.dialog.createFeedCta")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if dialogBody === "empty"}
|
{:else if dialogBody === "empty"}
|
||||||
<div class="space-y-3 py-2" role="status">
|
<div class="space-y-3" role="status">
|
||||||
<p class="text-sm font-medium text-foreground">{i18n.t("export.dialog.emptyTitle")}</p>
|
<p class="text-sm font-medium text-foreground">{i18n.t("export.dialog.emptyTitle")}</p>
|
||||||
<p class="text-sm leading-relaxed text-muted-foreground">{i18n.t("export.dialog.emptyMessage")}</p>
|
<p class="text-sm leading-relaxed text-muted-foreground">{i18n.t("export.dialog.emptyMessage")}</p>
|
||||||
<Button size="sm" onclick={goCreateFeed}>
|
<Button size="sm" onclick={goCreateFeed}>
|
||||||
@@ -119,7 +156,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-4 py-4">
|
<div class="space-y-3">
|
||||||
{#if error}
|
{#if error}
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
@@ -134,6 +171,39 @@
|
|||||||
</Select>
|
</Select>
|
||||||
<p class="text-xs text-muted-foreground">{i18n.t("export.dialog.help")}</p>
|
<p class="text-xs text-muted-foreground">{i18n.t("export.dialog.help")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{#if selectedFeed}
|
||||||
|
<div class="rounded-md border border-border bg-muted/30 p-3">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<p class="min-w-0 text-xs font-medium leading-snug text-foreground">
|
||||||
|
{#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}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 text-[11px] font-medium text-primary underline-offset-2 hover:underline"
|
||||||
|
onclick={goEditFeed}
|
||||||
|
>
|
||||||
|
{i18n.t("export.dialog.editFields")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if !fieldsLoading && selectedFieldKeys.length > 0}
|
||||||
|
<ul class="mt-2.5 flex flex-wrap gap-1.5">
|
||||||
|
{#each selectedFieldKeys as key}
|
||||||
|
<li
|
||||||
|
class="rounded border border-border/70 bg-background px-1.5 py-0.5 font-mono text-[10px] leading-none text-muted-foreground"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
|
|||||||
{:else}
|
{:else}
|
||||||
<VirtualList
|
<VirtualList
|
||||||
items={products}
|
items={products}
|
||||||
estimateSize={72}
|
estimateSize={57}
|
||||||
overscan={6}
|
overscan={6}
|
||||||
maxHeight={640}
|
maxHeight={640}
|
||||||
getKey={(product) => String(product.id)}
|
getKey={(product) => String(product.id)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
activeFormValue,
|
activeFormValue,
|
||||||
buildExportFilters,
|
buildExportFilters,
|
||||||
canConfirmExport,
|
canConfirmExport,
|
||||||
|
fieldKeysFromExportTemplate,
|
||||||
filterExportFeedsBySearch,
|
filterExportFeedsBySearch,
|
||||||
formatStatusList,
|
formatStatusList,
|
||||||
isActiveFromForm,
|
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", () => {
|
describe("resolveExportDialogBody", () => {
|
||||||
it("prefers loading, then error-empty, empty, ready", () => {
|
it("prefers loading, then error-empty, empty, ready", () => {
|
||||||
assert.equal(resolveExportDialogBody({ loading: true, error: "x", feedCount: 0 }), "loading");
|
assert.equal(resolveExportDialogBody({ loading: true, error: "x", feedCount: 0 }), "loading");
|
||||||
|
|||||||
@@ -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<string, unknown>);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
/** Which body the product export dialog should show. */
|
/** Which body the product export dialog should show. */
|
||||||
export function resolveExportDialogBody(input: {
|
export function resolveExportDialogBody(input: {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
|||||||
@@ -3360,6 +3360,10 @@ export const de: MessageDict = {
|
|||||||
"export.dialog.createFeedCta": "Export-Feed erstellen",
|
"export.dialog.createFeedCta": "Export-Feed erstellen",
|
||||||
"export.dialog.confirmAria": "Export bestätigen",
|
"export.dialog.confirmAria": "Export bestätigen",
|
||||||
"export.dialog.export": "Exportieren",
|
"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.title": "Nutzung & Abrechnung",
|
||||||
"billing.loadFailed": "Abrechnung konnte nicht geladen werden",
|
"billing.loadFailed": "Abrechnung konnte nicht geladen werden",
|
||||||
"billing.usageLoadFailed": "Nutzung konnte nicht geladen werden",
|
"billing.usageLoadFailed": "Nutzung konnte nicht geladen werden",
|
||||||
|
|||||||
@@ -4048,6 +4048,10 @@ export const en: MessageDict = {
|
|||||||
"export.dialog.createFeedCta": "Create export feed",
|
"export.dialog.createFeedCta": "Create export feed",
|
||||||
"export.dialog.confirmAria": "Confirm export",
|
"export.dialog.confirmAria": "Confirm export",
|
||||||
"export.dialog.export": "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.title": "Usage & Billing",
|
||||||
"billing.loadFailed": "Failed to load billing",
|
"billing.loadFailed": "Failed to load billing",
|
||||||
"billing.usageLoadFailed": "Failed to load usage",
|
"billing.usageLoadFailed": "Failed to load usage",
|
||||||
|
|||||||
@@ -3360,6 +3360,10 @@ export const es: MessageDict = {
|
|||||||
"export.dialog.createFeedCta": "Crear feed de exportación",
|
"export.dialog.createFeedCta": "Crear feed de exportación",
|
||||||
"export.dialog.confirmAria": "Confirmar exportación",
|
"export.dialog.confirmAria": "Confirmar exportación",
|
||||||
"export.dialog.export": "Exportar",
|
"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.title": "Uso y facturación",
|
||||||
"billing.loadFailed": "No se pudo cargar la facturación",
|
"billing.loadFailed": "No se pudo cargar la facturación",
|
||||||
"billing.usageLoadFailed": "No se pudo cargar el uso",
|
"billing.usageLoadFailed": "No se pudo cargar el uso",
|
||||||
|
|||||||
@@ -3360,6 +3360,10 @@ export const fr: MessageDict = {
|
|||||||
"export.dialog.createFeedCta": "Créer un feed d'export",
|
"export.dialog.createFeedCta": "Créer un feed d'export",
|
||||||
"export.dialog.confirmAria": "Confirmer l'export",
|
"export.dialog.confirmAria": "Confirmer l'export",
|
||||||
"export.dialog.export": "Exporter",
|
"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.title": "Utilisation et facturation",
|
||||||
"billing.loadFailed": "Échec du chargement de la facturation",
|
"billing.loadFailed": "Échec du chargement de la facturation",
|
||||||
"billing.usageLoadFailed": "Échec du chargement de l'utilisation",
|
"billing.usageLoadFailed": "Échec du chargement de l'utilisation",
|
||||||
|
|||||||
@@ -3360,6 +3360,10 @@ export const it: MessageDict = {
|
|||||||
"export.dialog.createFeedCta": "Crea feed di esportazione",
|
"export.dialog.createFeedCta": "Crea feed di esportazione",
|
||||||
"export.dialog.confirmAria": "Conferma esportazione",
|
"export.dialog.confirmAria": "Conferma esportazione",
|
||||||
"export.dialog.export": "Esporta",
|
"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.title": "Utilizzo e fatturazione",
|
||||||
"billing.loadFailed": "Caricamento fatturazione non riuscito",
|
"billing.loadFailed": "Caricamento fatturazione non riuscito",
|
||||||
"billing.usageLoadFailed": "Caricamento utilizzo non riuscito",
|
"billing.usageLoadFailed": "Caricamento utilizzo non riuscito",
|
||||||
|
|||||||
@@ -3360,6 +3360,10 @@ export const ja: MessageDict = {
|
|||||||
"export.dialog.createFeedCta": "エクスãƒÂÂートフィードを作æˆÂÂ",
|
"export.dialog.createFeedCta": "エクスãƒÂÂートフィードを作æˆÂÂ",
|
||||||
"export.dialog.confirmAria": "エクスãƒÂÂートを確èªÂÂ",
|
"export.dialog.confirmAria": "エクスãƒÂÂートを確èªÂÂ",
|
||||||
"export.dialog.export": "エクスãƒÂÂート",
|
"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.title": "利çâ€Â¨çжæ³ÂÂã¨請求",
|
||||||
"billing.loadFailed": "請求情報ã®èªÂÂã¿込ã¿ã«失æ•â€â€ÃƒÂ£Ã‚Ââ€â€ÃƒÂ£Ã‚¾ãÂÂâ€â€ÃƒÂ£Ã‚Ÿ",
|
"billing.loadFailed": "請求情報ã®èªÂÂã¿込ã¿ã«失æ•â€â€ÃƒÂ£Ã‚Ââ€â€ÃƒÂ£Ã‚¾ãÂÂâ€â€ÃƒÂ£Ã‚Ÿ",
|
||||||
"billing.usageLoadFailed": "利çâ€Â¨çжæ³ÂÂã®èªÂÂã¿込ã¿ã«失æ•â€â€ÃƒÂ£Ã‚Ââ€â€ÃƒÂ£Ã‚¾ãÂÂâ€â€ÃƒÂ£Ã‚Ÿ",
|
"billing.usageLoadFailed": "利çâ€Â¨çжæ³ÂÂã®èªÂÂã¿込ã¿ã«失æ•â€â€ÃƒÂ£Ã‚Ââ€â€ÃƒÂ£Ã‚¾ãÂÂâ€â€ÃƒÂ£Ã‚Ÿ",
|
||||||
|
|||||||
@@ -3360,6 +3360,10 @@ export const nl: MessageDict = {
|
|||||||
"export.dialog.createFeedCta": "Exportfeed maken",
|
"export.dialog.createFeedCta": "Exportfeed maken",
|
||||||
"export.dialog.confirmAria": "Export bevestigen",
|
"export.dialog.confirmAria": "Export bevestigen",
|
||||||
"export.dialog.export": "Exporteren",
|
"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.title": "Gebruik & facturering",
|
||||||
"billing.loadFailed": "Facturering laden mislukt",
|
"billing.loadFailed": "Facturering laden mislukt",
|
||||||
"billing.usageLoadFailed": "Gebruik laden mislukt",
|
"billing.usageLoadFailed": "Gebruik laden mislukt",
|
||||||
|
|||||||
@@ -3360,6 +3360,10 @@ export const pl: MessageDict = {
|
|||||||
"export.dialog.createFeedCta": "Utwórz feed eksportu",
|
"export.dialog.createFeedCta": "Utwórz feed eksportu",
|
||||||
"export.dialog.confirmAria": "Potwierdź eksport",
|
"export.dialog.confirmAria": "Potwierdź eksport",
|
||||||
"export.dialog.export": "Eksportuj",
|
"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.title": "Użycie i rozliczenia",
|
||||||
"billing.loadFailed": "Nie udało się wczytać rozliczeń",
|
"billing.loadFailed": "Nie udało się wczytać rozliczeń",
|
||||||
"billing.usageLoadFailed": "Nie udało się wczytać użycia",
|
"billing.usageLoadFailed": "Nie udało się wczytać użycia",
|
||||||
|
|||||||
@@ -3360,6 +3360,10 @@ export const pt: MessageDict = {
|
|||||||
"export.dialog.createFeedCta": "Criar feed de exportação",
|
"export.dialog.createFeedCta": "Criar feed de exportação",
|
||||||
"export.dialog.confirmAria": "Confirmar exportação",
|
"export.dialog.confirmAria": "Confirmar exportação",
|
||||||
"export.dialog.export": "Exportar",
|
"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.title": "Uso e faturação",
|
||||||
"billing.loadFailed": "Falha ao carregar a faturação",
|
"billing.loadFailed": "Falha ao carregar a faturação",
|
||||||
"billing.usageLoadFailed": "Falha ao carregar a utilização",
|
"billing.usageLoadFailed": "Falha ao carregar a utilização",
|
||||||
|
|||||||
@@ -550,7 +550,8 @@
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{/if}
|
{/if}
|
||||||
</DashboardHeader>
|
</DashboardHeader>
|
||||||
<div class="min-w-0 flex-1 overflow-x-auto overflow-y-auto p-3 sm:p-6">
|
<!-- Extra bottom padding clears the assistant FAB so last-row actions stay clickable. -->
|
||||||
|
<div class="min-w-0 flex-1 overflow-x-auto overflow-y-auto p-3 pb-20 sm:p-6 sm:pb-24">
|
||||||
<!-- Key by pathname so client nav never leaves a prior page shell (e.g. campaign edit) mounted. -->
|
<!-- Key by pathname so client nav never leaves a prior page shell (e.g. campaign edit) mounted. -->
|
||||||
{#key page.url.pathname}
|
{#key page.url.pathname}
|
||||||
<PlanRouteGuard>
|
<PlanRouteGuard>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger
|
TabsTrigger
|
||||||
} from "$lib/components/ui";
|
} 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";
|
type TabKey = "users" | "companies";
|
||||||
|
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
let busyUserId = $state<string | null>(null);
|
let busyUserId = $state<string | null>(null);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let success = $state("");
|
let success = $state("");
|
||||||
|
let inviteLinkCopied = $state(false);
|
||||||
let inviteAcceptLink = $state<string | null>(null);
|
let inviteAcceptLink = $state<string | null>(null);
|
||||||
let tab = $state<TabKey>("users");
|
let tab = $state<TabKey>("users");
|
||||||
let search = $state("");
|
let search = $state("");
|
||||||
@@ -547,6 +548,10 @@
|
|||||||
await navigator.clipboard.writeText(inviteAcceptLink);
|
await navigator.clipboard.writeText(inviteAcceptLink);
|
||||||
success = i18n.t("admin.users.inviteLinkCopied");
|
success = i18n.t("admin.users.inviteLinkCopied");
|
||||||
error = "";
|
error = "";
|
||||||
|
inviteLinkCopied = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
inviteLinkCopied = false;
|
||||||
|
}, 1500);
|
||||||
} catch {
|
} catch {
|
||||||
error = i18n.t("flash.settings.copyFailed");
|
error = i18n.t("flash.settings.copyFailed");
|
||||||
}
|
}
|
||||||
@@ -647,8 +652,13 @@
|
|||||||
aria-label={i18n.t("admin.users.inviteLinkLabel")}
|
aria-label={i18n.t("admin.users.inviteLinkLabel")}
|
||||||
/>
|
/>
|
||||||
<Button type="button" variant="outline" size="sm" onclick={() => copyInviteLink()}>
|
<Button type="button" variant="outline" size="sm" onclick={() => copyInviteLink()}>
|
||||||
<Copy class="mr-2 h-4 w-4" />
|
{#if inviteLinkCopied}
|
||||||
{i18n.t("admin.users.copyInviteLink")}
|
<Check class="mr-2 h-4 w-4 text-success" />
|
||||||
|
{i18n.t("common.copied")}
|
||||||
|
{:else}
|
||||||
|
<Copy class="mr-2 h-4 w-4" />
|
||||||
|
{i18n.t("admin.users.copyInviteLink")}
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Copy,
|
Copy,
|
||||||
|
Check,
|
||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -121,6 +122,7 @@
|
|||||||
/** Set only by list `load()` — keeps create/save failures from looking like a failed list fetch. */
|
/** Set only by list `load()` — keeps create/save failures from looking like a failed list fetch. */
|
||||||
let listError = $state("");
|
let listError = $state("");
|
||||||
let success = $state("");
|
let success = $state("");
|
||||||
|
let copiedId = $state("");
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let canAdmin = $state(false);
|
let canAdmin = $state(false);
|
||||||
@@ -365,10 +367,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyUrl(feed: ExportRow) {
|
async function copyUrl(feed: ExportRow) {
|
||||||
|
const id = String(feed.id);
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(publicUrl(feed));
|
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 = "";
|
error = "";
|
||||||
|
notifySuccess(msg);
|
||||||
|
copiedId = id;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (copiedId === id) copiedId = "";
|
||||||
|
}, 1500);
|
||||||
} catch {
|
} catch {
|
||||||
error = i18n.t("flash.export.copyFailed");
|
error = i18n.t("flash.export.copyFailed");
|
||||||
}
|
}
|
||||||
@@ -649,9 +660,15 @@
|
|||||||
class="h-8 w-8 shrink-0 p-0"
|
class="h-8 w-8 shrink-0 p-0"
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
onclick={() => void copyUrl(feed)}
|
onclick={() => void copyUrl(feed)}
|
||||||
aria-label={i18n.t("exports.copyUrl")}
|
aria-label={copiedId === id
|
||||||
|
? i18n.t("common.copied")
|
||||||
|
: i18n.t("exports.copyUrl")}
|
||||||
>
|
>
|
||||||
<Copy class="h-3.5 w-3.5" />
|
{#if copiedId === id}
|
||||||
|
<Check class="h-3.5 w-3.5 text-success" />
|
||||||
|
{:else}
|
||||||
|
<Copy class="h-3.5 w-3.5" />
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -727,9 +744,9 @@
|
|||||||
description={editingId
|
description={editingId
|
||||||
? i18n.t("exports.editDescription")
|
? i18n.t("exports.editDescription")
|
||||||
: presetHint(activePreset)}
|
: presetHint(activePreset)}
|
||||||
class="max-h-[90vh] max-w-2xl overflow-y-auto"
|
class="max-w-3xl sm:max-w-4xl"
|
||||||
>
|
>
|
||||||
<form class="space-y-4" onsubmit={saveFeed}>
|
<form id="export-feed-form" class="space-y-4" onsubmit={saveFeed}>
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
{#if !editingId}
|
{#if !editingId}
|
||||||
<div class="space-y-1.5 sm:col-span-2">
|
<div class="space-y-1.5 sm:col-span-2">
|
||||||
@@ -757,7 +774,18 @@
|
|||||||
<Label for="export-format">{i18n.t("exports.format")}</Label>
|
<Label for="export-format">{i18n.t("exports.format")}</Label>
|
||||||
<Input id="export-format" value={formFormat.toUpperCase()} disabled />
|
<Input id="export-format" value={formFormat.toUpperCase()} disabled />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
{#if editingId}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="export-active">{i18n.t("exports.status")}</Label>
|
||||||
|
<Select id="export-active" bind:value={formActive}>
|
||||||
|
<option value="active">{i18n.t("exports.active")}</option>
|
||||||
|
<option value="inactive">{i18n.t("exports.inactive")}</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="hidden sm:block" aria-hidden="true"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="space-y-1.5 sm:col-span-2">
|
||||||
<Label for="export-source">{i18n.t("exports.sourceFeed")}</Label>
|
<Label for="export-source">{i18n.t("exports.sourceFeed")}</Label>
|
||||||
<Select id="export-source" bind:value={formSourceFeedId}>
|
<Select id="export-source" bind:value={formSourceFeedId}>
|
||||||
<option value="">{i18n.t("exports.allProducts")}</option>
|
<option value="">{i18n.t("exports.allProducts")}</option>
|
||||||
@@ -782,41 +810,32 @@
|
|||||||
<Input id="export-statuses" class="font-mono" bind:value={formStatuses} />
|
<Input id="export-statuses" class="font-mono" bind:value={formStatuses} />
|
||||||
<p class="text-xs text-muted-foreground">{i18n.t("exports.productStatusesHelp")}</p>
|
<p class="text-xs text-muted-foreground">{i18n.t("exports.productStatusesHelp")}</p>
|
||||||
</div>
|
</div>
|
||||||
{#if editingId}
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label for="export-active">{i18n.t("exports.status")}</Label>
|
|
||||||
<Select id="export-active" bind:value={formActive}>
|
|
||||||
<option value="active">{i18n.t("exports.active")}</option>
|
|
||||||
<option value="inactive">{i18n.t("exports.inactive")}</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Label>{i18n.t("exports.fieldMappings")}</Label>
|
<Label>{i18n.t("exports.fieldMappings")}</Label>
|
||||||
<Button type="button" variant="outline" size="sm" onclick={addField}>
|
<Button type="button" variant="outline" size="sm" onclick={addField}>
|
||||||
<Plus class="mr-1 h-3.5 w-3.5" /> {i18n.t("exports.addField")}
|
<Plus class="mr-1 h-3.5 w-3.5" /> {i18n.t("exports.addField")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-hidden rounded-md border border-border">
|
<div class="overflow-hidden rounded-md border border-border">
|
||||||
<Table>
|
<Table class="md:min-w-0">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{i18n.t("exports.outputKey")}</TableHead>
|
<TableHead class="h-9 w-[32%] px-2.5">{i18n.t("exports.outputKey")}</TableHead>
|
||||||
<TableHead>{i18n.t("exports.source")}</TableHead>
|
<TableHead class="h-9 px-2.5">{i18n.t("exports.source")}</TableHead>
|
||||||
<TableHead class="w-16"></TableHead>
|
<TableHead class="h-9 w-12 px-2"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{#each formFields as field, index}
|
{#each formFields as field, index}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>
|
<TableCell class="p-2 align-top">
|
||||||
<Input class="font-mono" bind:value={field.key} placeholder="name" required />
|
<Input class="h-9 font-mono" bind:value={field.key} placeholder="name" required />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell class="p-2 align-top">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1.5">
|
||||||
<Select
|
<Select
|
||||||
value={SOURCE_OPTIONS.includes(field.source) ? field.source : "__custom__"}
|
value={SOURCE_OPTIONS.includes(field.source) ? field.source : "__custom__"}
|
||||||
onchange={(e) => {
|
onchange={(e) => {
|
||||||
@@ -836,19 +855,19 @@
|
|||||||
</Select>
|
</Select>
|
||||||
{#if !SOURCE_OPTIONS.includes(field.source)}
|
{#if !SOURCE_OPTIONS.includes(field.source)}
|
||||||
<Input
|
<Input
|
||||||
class="font-mono"
|
class="h-9 font-mono"
|
||||||
bind:value={field.source}
|
bind:value={field.source}
|
||||||
placeholder="attr.color or spec.weight"
|
placeholder="attr.color or spec.weight"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right">
|
<TableCell class="p-2 text-right align-top">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 w-8 p-0 text-destructive"
|
class="h-9 w-9 p-0 text-destructive"
|
||||||
onclick={() => removeField(index)}
|
onclick={() => removeField(index)}
|
||||||
aria-label={i18n.t("exports.removeField")}
|
aria-label={i18n.t("exports.removeField")}
|
||||||
>
|
>
|
||||||
@@ -864,10 +883,18 @@
|
|||||||
{i18n.t("exports.fieldHelp")}
|
{i18n.t("exports.fieldHelp")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
||||||
<Button type="button" variant="outline" onclick={() => (dialogOpen = false)}>{i18n.t("common.cancel")}</Button>
|
|
||||||
<Button type="submit" loading={saving}>{saving ? i18n.t("exports.saving") : editingId ? i18n.t("exports.saveChanges") : i18n.t("exports.createFeed")}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<Button type="button" variant="outline" onclick={() => (dialogOpen = false)}
|
||||||
|
>{i18n.t("common.cancel")}</Button
|
||||||
|
>
|
||||||
|
<Button type="submit" form="export-feed-form" loading={saving}
|
||||||
|
>{saving
|
||||||
|
? i18n.t("exports.saving")
|
||||||
|
: editingId
|
||||||
|
? i18n.t("exports.saveChanges")
|
||||||
|
: i18n.t("exports.createFeed")}</Button
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
@@ -180,6 +181,7 @@
|
|||||||
let error = $state("");
|
let error = $state("");
|
||||||
let fieldErrors = $state<Record<string, string>>({});
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
let success = $state("");
|
let success = $state("");
|
||||||
|
let copiedKey = $state("");
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
let user = $state<User | null>(null);
|
let user = $state<User | null>(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 {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
success = okMessage;
|
success = okMessage;
|
||||||
error = "";
|
error = "";
|
||||||
notifySuccess(success);
|
notifySuccess(success);
|
||||||
|
copiedKey = key;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (copiedKey === key) copiedKey = "";
|
||||||
|
}, 1500);
|
||||||
} catch {
|
} catch {
|
||||||
error = i18n.t("flash.settings.copyFailed");
|
error = i18n.t("flash.settings.copyFailed");
|
||||||
notifyError(error);
|
notifyError(error);
|
||||||
@@ -1539,11 +1545,20 @@
|
|||||||
{#if sessionSecret}
|
{#if sessionSecret}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
copyText(sessionSecret, i18n.t("settings.apiKeyCopied"))
|
copyText(
|
||||||
|
sessionSecret,
|
||||||
|
i18n.t("settings.apiKeyCopied"),
|
||||||
|
`api-key-${key.id}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Copy class="mr-2 h-4 w-4" />
|
{#if copiedKey === `api-key-${key.id}`}
|
||||||
{i18n.t("settings.copyApiKey")}
|
<Check class="mr-2 h-4 w-4 text-success" />
|
||||||
|
{i18n.t("common.copied")}
|
||||||
|
{:else}
|
||||||
|
<Copy class="mr-2 h-4 w-4" />
|
||||||
|
{i18n.t("settings.copyApiKey")}
|
||||||
|
{/if}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{:else}
|
{:else}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -1614,10 +1629,21 @@
|
|||||||
<Input value={inviteAcceptLink} readonly autocomplete="off" spellcheck={false} class="font-mono text-xs" aria-label={i18n.t("settings.acceptLinkLabel")} />
|
<Input value={inviteAcceptLink} readonly autocomplete="off" spellcheck={false} class="font-mono text-xs" aria-label={i18n.t("settings.acceptLinkLabel")} />
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => copyText(inviteAcceptLink!, i18n.t("settings.linkCopied"))}
|
onclick={() =>
|
||||||
|
copyText(
|
||||||
|
inviteAcceptLink!,
|
||||||
|
i18n.t("settings.linkCopied"),
|
||||||
|
"invite-link"
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Copy class="mr-2 h-4 w-4" />
|
{#if copiedKey === "invite-link"}
|
||||||
{i18n.t("settings.copyLink")}
|
<Check class="mr-2 h-4 w-4 text-success" />
|
||||||
|
{i18n.t("common.copied")}
|
||||||
|
{:else}
|
||||||
|
<Copy class="mr-2 h-4 w-4" />
|
||||||
|
{i18n.t("settings.copyLink")}
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1895,10 +1921,18 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={i18n.t("settings.copyApiKeyAria")}
|
aria-label={copiedKey === "new-api-key"
|
||||||
onclick={() => copyText(newApiKey!, i18n.t("settings.apiKeyCopied"))}
|
? i18n.t("common.copied")
|
||||||
|
: i18n.t("settings.copyApiKeyAria")}
|
||||||
|
onclick={() =>
|
||||||
|
copyText(newApiKey!, i18n.t("settings.apiKeyCopied"), "new-api-key")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Copy class="h-4 w-4" />
|
{#if copiedKey === "new-api-key"}
|
||||||
|
<Check class="h-4 w-4 text-success" />
|
||||||
|
{:else}
|
||||||
|
<Copy class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground">{i18n.t("settings.storeKeySafe")}</p>
|
<p class="text-sm text-muted-foreground">{i18n.t("settings.storeKeySafe")}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user