Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { CalendarDays, Megaphone, Sparkles, RefreshCw } from "@lucide/svelte";
|
||||
import { api, ApiError, failureMessage } from "$lib/api";
|
||||
import { notifySuccess, notifyApiError } from "$lib/notify";
|
||||
import PageShell from "$lib/components/PageShell.svelte";
|
||||
import Alert from "$lib/components/Alert.svelte";
|
||||
import Spinner from "$lib/components/Spinner.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import { i18n } from "$lib/i18n";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "$lib/components/ui";
|
||||
|
||||
type PresetId = "black_friday" | "christmas";
|
||||
|
||||
type Preset = {
|
||||
id: PresetId;
|
||||
name: string;
|
||||
description: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
year: number;
|
||||
};
|
||||
|
||||
type Prepared = {
|
||||
preset_id: PresetId;
|
||||
name: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
year: number;
|
||||
export_feed_id: string;
|
||||
export_feed_name: string;
|
||||
};
|
||||
|
||||
const year = new Date().getUTCFullYear();
|
||||
|
||||
let presets = $state<Preset[]>([]);
|
||||
let prepared = $state<Prepared[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state("");
|
||||
let pendingPreset = $state<PresetId | null>(null);
|
||||
|
||||
const preparedByKey = $derived(
|
||||
new Map(prepared.map((p) => [`${p.preset_id}:${p.year}`, p] as const))
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
const res = await api<{ year: number; presets: Preset[]; prepared: Prepared[] }>(
|
||||
`/api/marketing/calendar?year=${year}`
|
||||
);
|
||||
presets = res.presets ?? [];
|
||||
prepared = res.prepared ?? [];
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||
await goto("/login");
|
||||
return;
|
||||
}
|
||||
if (err instanceof ApiError && (err.status === 404 || err.status === 503)) {
|
||||
presets = [];
|
||||
prepared = [];
|
||||
error = "";
|
||||
} else {
|
||||
error = failureMessage(err, i18n.t("marketing.loadFailed"));
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function prepare(presetId: PresetId) {
|
||||
pendingPreset = presetId;
|
||||
try {
|
||||
const campaign = await api<Prepared & { created?: boolean }>(
|
||||
"/api/marketing/calendar/prepare",
|
||||
{
|
||||
method: "POST",
|
||||
body: { preset_id: presetId, year, format: "csv" }
|
||||
}
|
||||
);
|
||||
notifySuccess(
|
||||
campaign.created ? i18n.t("marketing.prepared") : i18n.t("marketing.alreadyReady"),
|
||||
i18n.t("marketing.exportFeedsHint", { name: campaign.export_feed_name })
|
||||
);
|
||||
await load();
|
||||
if (campaign.created && campaign.export_feed_id) {
|
||||
await goto(`/export-feeds?highlight=${encodeURIComponent(campaign.export_feed_id)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
notifyApiError(err, i18n.t("marketing.requestFailed"), {
|
||||
title: i18n.t("marketing.prepareFailed")
|
||||
});
|
||||
} finally {
|
||||
pendingPreset = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageShell title={i18n.t("marketing.calendarTitle")} description={i18n.t("marketing.calendarDesc")}>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" onclick={() => void load()} disabled={loading}>
|
||||
<RefreshCw class="mr-2 h-4 w-4" />
|
||||
{i18n.t("common.refresh")}
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-16">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else if error}
|
||||
<Alert message={error} />
|
||||
{:else if presets.length === 0}
|
||||
<EmptyState title={i18n.t("marketing.emptyTitle")} message={i18n.t("marketing.emptyMessage")}>
|
||||
<a href="/campaigns"><Button variant="outline">{i18n.t("marketing.openCampaigns")}</Button></a>
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{#each presets as preset (preset.id)}
|
||||
{@const existing = preparedByKey.get(`${preset.id}:${preset.year}`)}
|
||||
{@const busy = pendingPreset === preset.id}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Megaphone class="h-4 w-4" />
|
||||
{preset.name}
|
||||
</CardTitle>
|
||||
<CardDescription class="mt-2">{preset.description}</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{year}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="text-sm">
|
||||
<span class="text-muted-foreground">{i18n.t("marketing.window")}</span>
|
||||
<span class="ml-1 font-medium">{preset.start_date} → {preset.end_date}</span>
|
||||
</div>
|
||||
{#if existing}
|
||||
<div class="rounded-md border bg-muted/40 p-3 text-sm">
|
||||
{i18n.t("marketing.linkedExport")}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 font-medium underline"
|
||||
onclick={() => void goto(`/export-feeds`)}
|
||||
>
|
||||
{existing.export_feed_name}
|
||||
</button>
|
||||
</div>
|
||||
<Button class="w-full" onclick={() => void goto("/export-feeds")}>
|
||||
{i18n.t("marketing.openPrepared")}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
class="w-full"
|
||||
disabled={pendingPreset !== null}
|
||||
onclick={() => void prepare(preset.id)}
|
||||
>
|
||||
<Sparkles class="mr-2 h-4 w-4" />
|
||||
{busy ? i18n.t("marketing.preparing") : i18n.t("marketing.prepareNamed", { name: preset.name })}
|
||||
</Button>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-6 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<CalendarDays class="h-4 w-4" />
|
||||
<span>
|
||||
{i18n.t("marketing.footerBefore")}
|
||||
<a class="underline" href="/campaigns">/campaigns</a>
|
||||
{i18n.t("marketing.footerAfter")}
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
</PageShell>
|
||||
Reference in New Issue
Block a user