polished and testing exports
This commit is contained in:
@@ -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<ExportFeedOption[]>([]);
|
||||
let selectedFeedId = $state("");
|
||||
let selectedFieldKeys = $state<string[]>([]);
|
||||
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<ListResponse<ExportFeed & { format?: string | null; is_active?: boolean | null }>>(
|
||||
`/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");
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mount only while open — avoids nested bind:open / one-way $bindable dropping open. -->
|
||||
{#if open}
|
||||
<Dialog
|
||||
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")}
|
||||
description={i18n.t("export.dialog.description", { count: selectedProductIds.length })}
|
||||
onClose={close}
|
||||
>
|
||||
{#if dialogBody === "loading"}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<Spinner class="h-8 w-8 text-muted-foreground" />
|
||||
<div class="flex items-center justify-center py-6">
|
||||
<Spinner class="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{:else if dialogBody === "error"}
|
||||
<div class="space-y-3 py-2">
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
<Button variant="outline" size="sm" onclick={goCreateFeed}>
|
||||
{i18n.t("export.dialog.createFeedCta")}
|
||||
</Button>
|
||||
</div>
|
||||
{: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 leading-relaxed text-muted-foreground">{i18n.t("export.dialog.emptyMessage")}</p>
|
||||
<Button size="sm" onclick={goCreateFeed}>
|
||||
@@ -119,7 +156,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="space-y-3">
|
||||
{#if error}
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
@@ -134,6 +171,39 @@
|
||||
</Select>
|
||||
<p class="text-xs text-muted-foreground">{i18n.t("export.dialog.help")}</p>
|
||||
</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>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -502,7 +502,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
|
||||
{:else}
|
||||
<VirtualList
|
||||
items={products}
|
||||
estimateSize={72}
|
||||
estimateSize={57}
|
||||
overscan={6}
|
||||
maxHeight={640}
|
||||
getKey={(product) => String(product.id)}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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. */
|
||||
export function resolveExportDialogBody(input: {
|
||||
loading: boolean;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "利çâ€Â¨çжæ³ÂÂã®èªÂÂã¿込ã¿ã«失æ•â€â€ÃƒÂ£Ã‚Ââ€â€ÃƒÂ£Ã‚¾ãÂÂâ€â€ÃƒÂ£Ã‚Ÿ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -550,7 +550,8 @@
|
||||
</DropdownMenu>
|
||||
{/if}
|
||||
</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 page.url.pathname}
|
||||
<PlanRouteGuard>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
let error = $state("");
|
||||
let success = $state("");
|
||||
let inviteLinkCopied = $state(false);
|
||||
let inviteAcceptLink = $state<string | null>(null);
|
||||
let tab = $state<TabKey>("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")}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onclick={() => copyInviteLink()}>
|
||||
<Copy class="mr-2 h-4 w-4" />
|
||||
{i18n.t("admin.users.copyInviteLink")}
|
||||
{#if inviteLinkCopied}
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -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"
|
||||
>
|
||||
<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">
|
||||
{#if !editingId}
|
||||
<div class="space-y-1.5 sm:col-span-2">
|
||||
@@ -757,7 +774,18 @@
|
||||
<Label for="export-format">{i18n.t("exports.format")}</Label>
|
||||
<Input id="export-format" value={formFormat.toUpperCase()} disabled />
|
||||
</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>
|
||||
<Select id="export-source" bind:value={formSourceFeedId}>
|
||||
<option value="">{i18n.t("exports.allProducts")}</option>
|
||||
@@ -782,41 +810,32 @@
|
||||
<Input id="export-statuses" class="font-mono" bind:value={formStatuses} />
|
||||
<p class="text-xs text-muted-foreground">{i18n.t("exports.productStatusesHelp")}</p>
|
||||
</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 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>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addField}>
|
||||
<Plus class="mr-1 h-3.5 w-3.5" /> {i18n.t("exports.addField")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-md border border-border">
|
||||
<Table>
|
||||
<Table class="md:min-w-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{i18n.t("exports.outputKey")}</TableHead>
|
||||
<TableHead>{i18n.t("exports.source")}</TableHead>
|
||||
<TableHead class="w-16"></TableHead>
|
||||
<TableHead class="h-9 w-[32%] px-2.5">{i18n.t("exports.outputKey")}</TableHead>
|
||||
<TableHead class="h-9 px-2.5">{i18n.t("exports.source")}</TableHead>
|
||||
<TableHead class="h-9 w-12 px-2"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each formFields as field, index}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Input class="font-mono" bind:value={field.key} placeholder="name" required />
|
||||
<TableCell class="p-2 align-top">
|
||||
<Input class="h-9 font-mono" bind:value={field.key} placeholder="name" required />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="space-y-1">
|
||||
<TableCell class="p-2 align-top">
|
||||
<div class="space-y-1.5">
|
||||
<Select
|
||||
value={SOURCE_OPTIONS.includes(field.source) ? field.source : "__custom__"}
|
||||
onchange={(e) => {
|
||||
@@ -836,19 +855,19 @@
|
||||
</Select>
|
||||
{#if !SOURCE_OPTIONS.includes(field.source)}
|
||||
<Input
|
||||
class="font-mono"
|
||||
class="h-9 font-mono"
|
||||
bind:value={field.source}
|
||||
placeholder="attr.color or spec.weight"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<TableCell class="p-2 text-right align-top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0 text-destructive"
|
||||
class="h-9 w-9 p-0 text-destructive"
|
||||
onclick={() => removeField(index)}
|
||||
aria-label={i18n.t("exports.removeField")}
|
||||
>
|
||||
@@ -864,10 +883,18 @@
|
||||
{i18n.t("exports.fieldHelp")}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{#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>
|
||||
|
||||
@@ -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<Record<string, string>>({});
|
||||
let success = $state("");
|
||||
let copiedKey = $state("");
|
||||
let saving = $state(false);
|
||||
|
||||
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 {
|
||||
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}
|
||||
<DropdownMenuItem
|
||||
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" />
|
||||
{i18n.t("settings.copyApiKey")}
|
||||
{#if copiedKey === `api-key-${key.id}`}
|
||||
<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>
|
||||
{:else}
|
||||
<DropdownMenuItem
|
||||
@@ -1614,10 +1629,21 @@
|
||||
<Input value={inviteAcceptLink} readonly autocomplete="off" spellcheck={false} class="font-mono text-xs" aria-label={i18n.t("settings.acceptLinkLabel")} />
|
||||
<Button
|
||||
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" />
|
||||
{i18n.t("settings.copyLink")}
|
||||
{#if copiedKey === "invite-link"}
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1895,10 +1921,18 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={i18n.t("settings.copyApiKeyAria")}
|
||||
onclick={() => copyText(newApiKey!, i18n.t("settings.apiKeyCopied"))}
|
||||
aria-label={copiedKey === "new-api-key"
|
||||
? 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>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{i18n.t("settings.storeKeySafe")}</p>
|
||||
|
||||
Reference in New Issue
Block a user