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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+537
View File
@@ -0,0 +1,537 @@
<script lang="ts">
import "./layout.css";
import favicon from "$lib/assets/favicon.svg";
import Nav from "$lib/components/Nav.svelte";
import CommandPalette from "$lib/components/CommandPalette.svelte";
import AdminNav from "$lib/components/AdminNav.svelte";
import DashboardHeader from "$lib/components/DashboardHeader.svelte";
import { adminNavUi } from "$lib/admin-nav-ui.svelte";
import SupportNotificationBell from "$lib/components/SupportNotificationBell.svelte";
import TaskStatusIndicator from "$lib/components/TaskStatusIndicator.svelte";
import Toaster from "$lib/components/ui/Toaster.svelte";
import TutorialOverlay from "$lib/components/tutorial/TutorialOverlay.svelte";
import AssistantHost from "$lib/components/assistant/AssistantHost.svelte";
import CompanySwitcher from "$lib/components/CompanySwitcher.svelte";
import UserSwitcher from "$lib/components/UserSwitcher.svelte";
import SkipLink from "$lib/components/SkipLink.svelte";
import SystemModeBanner from "$lib/components/SystemModeBanner.svelte";
import CutoverReadinessBanner from "$lib/components/CutoverReadinessBanner.svelte";
import BillingRecoveryBanner from "$lib/components/BillingRecoveryBanner.svelte";
import HypercareReportBanner from "$lib/components/HypercareReportBanner.svelte";
import AnalyticsHost from "$lib/components/AnalyticsHost.svelte";
import CookieConsentBanner from "$lib/components/CookieConsentBanner.svelte";
import { cookieConsent } from "$lib/cookie-consent.svelte";
import { buttonClasses } from "$lib/components/ui";
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api, isUnauthorized } from "$lib/api";
import { safeInternalNext } from "$lib/safe-next";
import { AUTH_PATHS, DEMO_BOOKING_URL, MARKETING_PATHS } from "$lib/site";
import { theme } from "$lib/theme.svelte";
import { ADMIN_SIDEBAR_WIDTH_CLASS, adminPageTitleKey } from "$lib/admin-nav";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import LocaleSwitcher from "$lib/components/LocaleSwitcher.svelte";
import { UI_LOCALE_KEY, i18n } from "$lib/i18n";
import type { MeResponse } from "$lib/types";
import { shouldUnlockAllFeatures } from "$lib/staff-access";
import { authSession } from "$lib/auth-session.svelte";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import PlanRouteGuard from "$lib/components/PlanRouteGuard.svelte";
import { refreshSystemMode } from "$lib/system-mode.svelte";
import {
resetCutoverReadiness,
startCutoverReadinessPolling,
stopCutoverReadinessPolling
} from "$lib/cutover-readiness.svelte";
import {
refreshSupportNotifications,
supportNotifications,
SUPPORT_NOTIFICATIONS_POLL_MS,
SUPPORT_NOTIFICATIONS_RETRY_MS
} from "$lib/support/notifications.svelte";
import { tutorial } from "$lib/tutorial";
import { billingRecovery, type PlanLike } from "$lib/billing-display";
import { isCompanyAdmin } from "$lib/company-admin";
import { GraduationCap, ListTree, Menu } from "@lucide/svelte";
let { children } = $props();
const isAuthPage = $derived(AUTH_PATHS.has(page.url.pathname));
const isMarketingPage = $derived(MARKETING_PATHS.has(page.url.pathname));
const isAdminPage = $derived(page.url.pathname.startsWith("/admin"));
let me = $state<MeResponse | null>(null);
let meRequested = $state(false);
const SYSTEM_MODE_POLL_MS = 60_000;
$effect(() => {
if (!browser || isMarketingPage) return;
const ac = new AbortController();
void refreshSystemMode(ac.signal);
const timer = window.setInterval(() => {
void refreshSystemMode();
}, SYSTEM_MODE_POLL_MS);
return () => {
ac.abort();
window.clearInterval(timer);
};
});
/** Support staff replies: poll support_notifications every 20s (no chat/WS). */
$effect(() => {
if (!browser || isAuthPage || isMarketingPage || !me?.user) {
if (!me?.user) supportNotifications.resetSession();
return;
}
const ac = new AbortController();
let timer: number | undefined;
const schedule = (ms: number) => {
if (timer !== undefined) window.clearInterval(timer);
timer = window.setInterval(() => {
void tick();
}, ms);
};
const tick = async () => {
const ok = await refreshSupportNotifications();
schedule(ok ? SUPPORT_NOTIFICATIONS_POLL_MS : SUPPORT_NOTIFICATIONS_RETRY_MS);
};
void (async () => {
const ok = await refreshSupportNotifications(ac.signal);
if (ac.signal.aborted) return;
schedule(ok ? SUPPORT_NOTIFICATIONS_POLL_MS : SUPPORT_NOTIFICATIONS_RETRY_MS);
})();
return () => {
ac.abort();
if (timer !== undefined) window.clearInterval(timer);
};
});
$effect(() => {
if (isAuthPage || isMarketingPage) {
me = null;
authSession.setMe(null);
planCapabilities.reset();
resetCutoverReadiness();
meRequested = false;
return;
}
if (meRequested) return;
meRequested = true;
void api<MeResponse>("/api/auth/me")
.then((res) => {
me = res;
authSession.setMe(res);
planCapabilities.hydrateFromCredits(res.credits);
void planCapabilities.refresh();
})
.catch(async (err) => {
me = null;
authSession.setMe(null);
planCapabilities.reset();
// Mark error so PlanRouteGuard never waits on idle after a failed /me.
// Without redirect, gated pages previously hung on "Checking plan access…".
if (isUnauthorized(err)) {
const next = safeInternalNext(`${page.url.pathname}${page.url.search}`);
await goto(`/login?next=${encodeURIComponent(next)}`, { replaceState: true });
}
});
});
/** Re-fetch capabilities when returning to the tab (picks up admin gate edits). */
$effect(() => {
if (!browser || isAuthPage || isMarketingPage) return;
const onVis = () => {
if (document.visibilityState === "visible") {
void planCapabilities.refresh();
}
};
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
});
/** Keep html `.dark` / data-theme in sync with storage (FOUC already painted). */
$effect(() => {
if (!browser) return;
theme.syncFromStorage();
i18n.syncFromStorage();
const onStorage = (event: StorageEvent) => {
if (
event.key === "descrybe-theme" ||
event.key === "descrybe-marketing-theme" ||
event.key === "descrybe-ui-theme"
) {
theme.syncFromStorage();
}
if (event.key === UI_LOCALE_KEY) {
i18n.syncFromStorage();
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
});
const userInitial = $derived.by(() => {
const name = me?.user?.name?.trim();
const email = me?.user?.email?.trim();
const source = name || email || "?";
return source.charAt(0).toUpperCase();
});
const showAdminNav = $derived(
Boolean(me?.staff_access?.support_desk || shouldUnlockAllFeatures(me))
);
/** Demo + full platform staff only — never unlock via legacy is_platform_admin when staff_access denies full_admin (A1/support). */
const unlockAllFeatures = $derived(shouldUnlockAllFeatures(me));
/**
* P1-15: platform-admin readiness strip on /admin only (API fail-closed).
* Boolean derived so nested `me` churn does not re-arm an immediate fetch;
* polling lifecycle lives in the shared cutover-readiness singleton.
*/
const shouldPollCutoverReadiness = $derived(
browser && isAdminPage && unlockAllFeatures
);
$effect(() => {
if (!shouldPollCutoverReadiness) {
resetCutoverReadiness();
return;
}
startCutoverReadinessPolling();
return () => stopCutoverReadinessPolling();
});
const adminChromeTitle = $derived(i18n.t(adminPageTitleKey(page.url.pathname)));
const billingRecoveryNotice = $derived.by(() => {
if (!me?.credits) return null;
const planRaw = me.credits.plan;
const plan =
planRaw && typeof planRaw === "object" ? (planRaw as PlanLike) : null;
return billingRecovery({
credits: me.credits,
plan,
canManageBilling: isCompanyAdmin(me)
});
});
const showHypercareAdminTriage = $derived(
Boolean(me?.staff_access?.support_desk || shouldUnlockAllFeatures(me))
);
</script>
<svelte:head>
<link rel="icon" href={favicon} />
{#if !isMarketingPage}
<title>Descrybe</title>
{/if}
</svelte:head>
{#if isAuthPage}
<!-- Auth keeps its own shell id so FOUC / paintDocument never clash with marketing. -->
<div
id="auth-shell"
class="relative flex min-h-screen flex-col bg-background text-text"
class:dark={theme.isDark}
>
<div
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-accent/30 via-background to-muted/60"
aria-hidden="true"
></div>
<div class="relative flex min-h-screen flex-col">
<SkipLink />
<SystemModeBanner />
<header
class="sticky top-0 z-[110] w-full border-b border-border/80 bg-background/90 backdrop-blur-md transition-all duration-200"
>
<div class="container mx-auto max-w-none px-4 py-3 sm:px-6 sm:py-4">
<div class="flex min-w-0 items-center justify-between gap-2">
<a href="/" class="flex min-w-0 items-center gap-2 sm:gap-3" aria-label={i18n.t("app.home")}>
<img src="/descrybe_logo.png" alt="" class="h-8 w-auto shrink-0 sm:h-10" width="40" height="40" />
<span class="truncate text-xl font-bold text-text sm:text-2xl">{i18n.t("app.name")}</span>
</a>
<div class="flex shrink-0 items-center gap-2">
<LocaleSwitcher />
<ThemeToggle />
<a
href={DEMO_BOOKING_URL}
target="_blank"
rel="noopener noreferrer"
class={buttonClasses(
"default",
"default",
"shrink-0 rounded-md px-3 py-1.5 text-sm shadow-sm transition-opacity hover:opacity-90 sm:px-5 sm:py-2 sm:text-base"
)}
>
<span class="sm:hidden">{i18n.t("site.demoShort")}</span>
<span class="hidden sm:inline">{i18n.t("site.bookDemo")}</span>
</a>
</div>
</div>
</div>
</header>
<main
id="main-content"
tabindex="-1"
class="relative flex flex-1 items-center justify-center px-3 py-8 sm:px-0 sm:py-12"
>
<div class="w-full max-w-md min-w-0">
{@render children()}
</div>
</main>
<footer class="w-full border-t border-border bg-surface/40 py-6">
<div class="container mx-auto max-w-none px-4 sm:px-6">
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<div class="text-center md:text-left">
<div class="flex items-center justify-center gap-2 sm:gap-3 md:justify-start">
<img src="/descrybe_logo.png" alt="" class="h-8 w-auto" width="32" height="32" />
<span class="text-lg font-bold text-text sm:text-xl">{i18n.t("app.name")}</span>
</div>
<div class="mt-2 text-xs text-text-muted">
{i18n.t("site.footer.rightsLine", {
year: new Date().getFullYear(),
name: i18n.t("app.name")
})}
</div>
</div>
<nav class="flex flex-wrap justify-center gap-4 text-sm sm:gap-6" aria-label={i18n.t("site.legalNav")}>
<a
href="/privacy"
class="text-text-muted transition-colors hover:text-link"
>
{i18n.t("site.privacyPolicy")}
</a>
<a href="/cookies" class="text-text-muted transition-colors hover:text-link">
{i18n.t("site.cookiePolicy")}
</a>
<button
type="button"
class="text-text-muted transition-colors hover:text-link"
onclick={() => cookieConsent.openPreferences()}
>
{i18n.t("site.cookieSettings")}
</button>
<a href="/terms" class="text-text-muted transition-colors hover:text-link">
{i18n.t("site.termsOfService")}
</a>
</nav>
</div>
</div>
</footer>
</div>
</div>
{:else if isMarketingPage}
<!-- Guest marketing shell — brand semantic tokens; mirrors html theme. -->
<div
id="marketing-shell"
class="min-h-screen bg-background text-text"
class:dark={theme.isDark}
>
{@render children()}
</div>
{:else if isAdminPage}
<!-- Semantic shell (html.dark + tokens). No AdminHeader / migration banner. -->
<div
id="admin-shell"
class="relative flex min-h-screen max-w-[100vw] overflow-x-clip bg-background text-foreground"
>
<SkipLink />
{#if showAdminNav}
<AdminNav />
<div class="flex min-h-screen min-w-0 flex-1 flex-col {ADMIN_SIDEBAR_WIDTH_CLASS}">
<SystemModeBanner />
<CutoverReadinessBanner />
{#if billingRecoveryNotice}
<BillingRecoveryBanner recovery={billingRecoveryNotice} />
{/if}
<HypercareReportBanner showAdminTriage={showHypercareAdminTriage} />
<!-- Mobile drawer hook only — denser chrome, current page title from nav IA. -->
<div
class="sticky top-0 z-[110] flex items-center gap-2 border-b border-border bg-card/90 px-3 py-2 backdrop-blur-sm lg:hidden"
role="navigation"
aria-label={i18n.t("admin.chrome.mobileMenu")}
>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border bg-background text-foreground transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={adminNavUi.mobileOpen ? i18n.t("nav.close") : i18n.t("nav.open")}
aria-expanded={adminNavUi.mobileOpen}
aria-controls="admin-sidebar"
onclick={() => adminNavUi.toggleMobile()}
>
<Menu class="h-4 w-4" aria-hidden="true" />
</button>
<div class="min-w-0 flex-1">
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{i18n.t("admin.chrome.platformOps")}
</p>
<p class="truncate text-sm font-medium text-foreground">{adminChromeTitle}</p>
</div>
<LocaleSwitcher
class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border bg-background text-foreground transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<ThemeToggle
class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border bg-background text-foreground transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<main
id="main-content"
tabindex="-1"
class="min-w-0 flex-1 overflow-x-auto overflow-y-auto bg-background p-3 text-foreground sm:p-6"
data-admin-shell="main"
>
{@render children()}
</main>
</div>
{:else}
<!-- Loading / non-admin / bootstrap: pages enforce requirePlatformAdmin / requireSupportDesk -->
<main
id="main-content"
tabindex="-1"
class="flex min-h-screen flex-1 flex-col items-center justify-center bg-background p-3 text-foreground sm:p-6"
data-admin-shell="main"
>
<SystemModeBanner />
{@render children()}
</main>
{/if}
</div>
{:else}
<!-- User app shell — theme via html.dark tokens. min-w-0 + overflow-x-clip keep tables scrolling inside shells. -->
<div
id="app-shell"
class="relative flex min-h-screen max-w-[100vw] overflow-x-clip bg-background text-foreground"
class:dark={theme.isDark}
>
<SkipLink />
<Nav showAdmin={showAdminNav} unlockAllFeatures={unlockAllFeatures} />
<CommandPalette />
<main id="main-content" tabindex="-1" class="flex min-h-screen min-w-0 flex-1 flex-col lg:ml-64">
<SystemModeBanner />
{#if billingRecoveryNotice}
<BillingRecoveryBanner recovery={billingRecoveryNotice} />
{/if}
<HypercareReportBanner showAdminTriage={showHypercareAdminTriage} />
<DashboardHeader>
{#if me}
<div class="mr-auto w-auto min-w-[9rem] max-w-[14rem] shrink-0 pe-1.5 sm:min-w-[11rem] sm:max-w-[18rem] sm:pe-2 md:max-w-[20rem] lg:max-w-[24rem]">
{#if me.dev_user_switch}
<UserSwitcher {me} />
{:else}
<CompanySwitcher
companies={me.companies ?? (me.company ? [me.company] : [])}
activeCompanyId={me.active_company_id ?? me.company?.id ?? ""}
activeCompanyName={me.company?.name ?? ""}
/>
{/if}
</div>
{#if planCapabilities.can("shell.support_notifications") && supportNotifications.available}
<SupportNotificationBell />
{/if}
{/if}
{#if !tutorial.active}
{#if tutorial.canResume}
<button
type="button"
class="hidden h-8 shrink-0 items-center justify-center rounded-md border border-border px-2.5 text-xs font-medium text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:inline-flex"
data-tour="header-resume-tutorial"
aria-label={i18n.t("dashboard.resumeTutorial")}
title={i18n.t("dashboard.resumeTutorial")}
onclick={() => void tutorial.resume()}
>
<span aria-hidden="true" class="xl:hidden"><GraduationCap class="h-4 w-4" /></span>
<span aria-hidden="true" class="hidden xl:inline">{i18n.t("dashboard.resumeTutorial")}</span>
</button>
{:else if tutorial.canRestart}
<button
type="button"
class="hidden h-8 shrink-0 items-center justify-center rounded-md border border-border px-2.5 text-xs font-medium text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:inline-flex"
data-tour="header-restart-tutorial"
aria-label={i18n.t("dashboard.restartTutorial")}
title={i18n.t("dashboard.restartTutorial")}
onclick={() => void tutorial.restart()}
>
<span aria-hidden="true" class="xl:hidden"><GraduationCap class="h-4 w-4" /></span>
<span aria-hidden="true" class="hidden xl:inline">{i18n.t("dashboard.restartTutorial")}</span>
</button>
{:else}
<button
type="button"
class="hidden h-8 shrink-0 items-center justify-center rounded-md px-2.5 text-xs font-medium text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:inline-flex"
data-tour="header-start-tutorial"
aria-label={i18n.t("dashboard.startTutorial")}
title={i18n.t("dashboard.startTutorial")}
onclick={() => void tutorial.start(true)}
>
<span aria-hidden="true" class="xl:hidden"><GraduationCap class="h-4 w-4" /></span>
<span aria-hidden="true" class="hidden xl:inline">{i18n.t("dashboard.startTutorial")}</span>
</button>
{/if}
<button
type="button"
class="hidden h-8 shrink-0 items-center justify-center rounded-md px-2.5 text-xs font-medium text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:inline-flex"
data-tour="header-browse-tutorial-sections"
aria-label={i18n.t("dashboard.browseTutorialSections")}
title={i18n.t("dashboard.tourTopics")}
onclick={() => void tutorial.openSectionPicker()}
>
<span aria-hidden="true" class="xl:hidden"><ListTree class="h-4 w-4" /></span>
<span aria-hidden="true" class="hidden xl:inline">{i18n.t("dashboard.tourTopics")}</span>
</button>
{/if}
<LocaleSwitcher
class="hidden h-8 w-8 shrink-0 items-center justify-center rounded-md text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:inline-flex"
/>
<ThemeToggle
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{#if me?.user}
{#if me.impersonating || me.dev_user_switch}
<a
href="/settings"
class="hidden max-w-[18rem] shrink-0 truncate text-xs text-text-muted transition hover:text-text lg:inline"
title={me.user.email}
data-tour="header-current-user-email"
>
{me.user.email}
</a>
{/if}
<a
href="/settings"
class="hidden h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground shadow-sm transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 lg:flex"
title={me.user.email}
aria-label={i18n.t("header.accountSettingsAria", { email: me.user.email })}
>
{userInitial}
</a>
{/if}
</DashboardHeader>
<div class="min-w-0 flex-1 overflow-x-auto overflow-y-auto p-3 sm:p-6">
<!-- Key by pathname so client nav never leaves a prior page shell (e.g. campaign edit) mounted. -->
{#key page.url.pathname}
<PlanRouteGuard>
{@render children()}
</PlanRouteGuard>
{/key}
</div>
</main>
</div>
{/if}
<TutorialOverlay />
<AnalyticsHost />
<CookieConsentBanner />
{#if me?.user && !isAuthPage && !isMarketingPage && !isAdminPage}
<AssistantHost />
<TaskStatusIndicator />
{/if}
<Toaster />
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import { PRICING_ENABLED } from "$lib/components/site/data";
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
import SeoHead from "$lib/components/site/SeoHead.svelte";
import NewHeroSection from "$lib/components/site/NewHeroSection.svelte";
import ImageSection from "$lib/components/site/ImageSection.svelte";
import HowItWorksSection from "$lib/components/site/HowItWorksSection.svelte";
import BenefitsSection from "$lib/components/site/BenefitsSection.svelte";
import PricingSection from "$lib/components/site/PricingSection.svelte";
import CtaBanner from "$lib/components/site/CtaBanner.svelte";
import Footer from "$lib/components/site/Footer.svelte";
import { i18n } from "$lib/i18n";
</script>
<SeoHead
title={i18n.t("seo.home.title")}
description={i18n.t("seo.home.description")}
path="/"
/>
<div class="flex min-h-screen flex-col">
<SiteHeader />
<main id="main-content">
<NewHeroSection />
<ImageSection />
<HowItWorksSection />
<BenefitsSection />
{#if PRICING_ENABLED}
<PricingSection />
{/if}
<CtaBanner />
</main>
<Footer />
</div>
@@ -0,0 +1,386 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api, ApiError } from "$lib/api";
import { trackEvent } from "$lib/analytics";
import { writeActivationProgress } from "$lib/activation";
import { i18n } from "$lib/i18n";
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label
} from "$lib/components/ui";
type InvitePreview = {
mode: "invite" | "set-password";
invite_email: string;
session_email?: string;
valid: boolean;
mismatch: boolean;
};
const initialToken = page.url.searchParams.get("token") ?? "";
const initialMode = page.url.searchParams.get("mode");
let token = $state(initialToken);
/** True when the token arrived via ?token=; never show it in a visible input. */
let tokenFromLink = $state(initialToken.length > 0);
let name = $state("");
let password = $state("");
let error = $state("");
let loading = $state(false);
let done = $state(false);
let previewLoading = $state(false);
let mismatch = $state(false);
let inviteEmail = $state("");
let sessionEmail = $state("");
let signingOut = $state(false);
/** Admin HMAC emails use mode=set-password; migrator invites use the invite token path. */
let mode = $state<"invite" | "set-password">(
initialMode === "set-password" ? "set-password" : "invite"
);
const errorId = "accept-invite-form-error";
const passwordHintId = "accept-invite-password-hint";
function apiErrorCode(err: unknown): string {
if (!(err instanceof ApiError) || !err.body || typeof err.body !== "object") return "";
const body = err.body as Record<string, unknown>;
const code = typeof body.code === "string" ? body.code : "";
const errorVal = typeof body.error === "string" ? body.error : "";
return (code || errorVal).trim().toLowerCase();
}
function applyMismatchFromError(err: unknown): boolean {
if (!(err instanceof ApiError) || apiErrorCode(err) !== "email_mismatch") return false;
const body = err.body as Record<string, unknown>;
mismatch = true;
if (typeof body.invite_email === "string") inviteEmail = body.invite_email;
if (typeof body.session_email === "string") sessionEmail = body.session_email;
error =
typeof body.message === "string" && body.message.trim()
? body.message.trim()
: i18n.t("auth.invite.emailMismatchDefault");
return true;
}
async function loadPreview(currentToken: string) {
const trimmed = currentToken.trim();
if (!trimmed) return;
previewLoading = true;
error = "";
try {
const preview = await api<InvitePreview>("/api/auth/invite-preview", {
method: "POST",
body: { token: trimmed, mode }
});
inviteEmail = preview.invite_email ?? "";
sessionEmail = preview.session_email ?? "";
mismatch = Boolean(preview.mismatch);
if (preview.mode === "set-password" || preview.mode === "invite") {
mode = preview.mode;
}
} catch (err) {
mismatch = false;
inviteEmail = "";
sessionEmail = "";
error =
err instanceof ApiError
? err.message
: mode === "set-password"
? i18n.t("auth.invite.verifySetPasswordFailed")
: i18n.t("auth.invite.verifyFailed");
} finally {
previewLoading = false;
}
}
onMount(() => {
if (!tokenFromLink) return;
// Drop the secret from the address bar / history (referrer + shoulder-surf).
const cleaned = new URL(window.location.href);
cleaned.searchParams.delete("token");
history.replaceState(history.state, "", cleaned.pathname + cleaned.search + cleaned.hash);
void loadPreview(token);
});
async function onSubmit(event: Event) {
event.preventDefault();
error = "";
loading = true;
try {
if (mode === "set-password") {
await api("/api/auth/complete-set-password", {
method: "POST",
body: { token, password }
});
// Migrated first-run: skip greenfield activation tour after they sign in.
writeActivationProgress("skipped", "fields");
done = true;
return;
}
await api("/api/auth/accept-invite", {
method: "POST",
body: { token, password, name: name || undefined }
});
trackEvent("accept_invite_success", {
had_existing_session: Boolean(sessionEmail)
});
done = true;
} catch (err) {
if (applyMismatchFromError(err)) return;
const raw = err instanceof ApiError ? err.message : "";
const expired =
/invalid or expired/i.test(raw) || /token invalid or expired/i.test(raw);
if (expired) {
error =
mode === "set-password"
? i18n.t("auth.invite.setPasswordExpired")
: i18n.t("auth.invite.expired");
} else {
error =
raw ||
(mode === "set-password"
? i18n.t("auth.invite.setPasswordFailed")
: i18n.t("auth.invite.acceptFailed"));
}
} finally {
loading = false;
}
}
async function signOutAndContinue() {
if (signingOut) return;
signingOut = true;
error = "";
try {
await api("/api/auth/logout", { method: "POST" });
} catch {
/* session may already be gone */
} finally {
mismatch = false;
sessionEmail = "";
signingOut = false;
}
}
function passwordDescribedBy(): string | undefined {
const ids = [passwordHintId];
if (error) ids.push(errorId);
return ids.join(" ");
}
</script>
<Card>
{#if done}
<CardHeader>
<CardTitle level={1}>
{mode === "set-password"
? i18n.t("auth.invite.doneSetPasswordTitle")
: i18n.t("auth.invite.doneTitle")}
</CardTitle>
<CardDescription>
{mode === "set-password"
? i18n.t("auth.invite.doneSetPasswordDescription")
: i18n.t("auth.invite.doneDescription")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
{#if mode === "set-password"}
<Button class="w-full shadow-sm" onclick={() => goto("/login")}
>{i18n.t("auth.invite.goToSignIn")}</Button
>
<p class="text-center text-sm text-text-muted">
{i18n.t("auth.invite.afterSignInNote")}
</p>
{:else}
<Button class="w-full shadow-sm" onclick={() => goto("/dashboard")}
>{i18n.t("auth.invite.openDashboard")}</Button
>
<Button variant="outline" class="w-full" onclick={() => goto("/settings?tab=company")}>
{i18n.t("auth.invite.companySettings")}
</Button>
{/if}
</CardContent>
{:else if mismatch}
<CardHeader>
<CardTitle level={1}>{i18n.t("auth.invite.mismatchTitle")}</CardTitle>
<CardDescription>
{i18n.t("auth.invite.mismatchDescription")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Alert variant="destructive">
<AlertDescription id={errorId}>
{#if sessionEmail && inviteEmail}
{i18n.t("auth.invite.mismatchDetail", {
session: sessionEmail,
invite: inviteEmail
})}
{:else}
{error || i18n.t("auth.invite.mismatchFallback")}
{/if}
</AlertDescription>
</Alert>
<div class="space-y-3 rounded-md border bg-muted/40 px-3 py-3 text-sm text-text-muted">
<p>
<strong class="text-text">{i18n.t("auth.invite.switchAccountTitle")}</strong>
{i18n.t("auth.invite.switchAccountBody", {
emailSuffix: inviteEmail ? ` (${inviteEmail})` : ""
})}
</p>
<p>
<strong class="text-text">{i18n.t("auth.invite.reissueTitle")}</strong>
{i18n.t("auth.invite.reissueBody")}
</p>
</div>
<Button class="w-full shadow-sm" loading={signingOut} onclick={signOutAndContinue}>
{signingOut ? i18n.t("common.signingOut") : i18n.t("common.signOutAndContinue")}
</Button>
<Button variant="outline" class="w-full" onclick={() => goto("/dashboard")}>
{i18n.t("common.staySignedIn")}
</Button>
<p class="text-center text-sm text-text-muted">
<a href="/login" class="font-medium text-link hover:underline"
>{i18n.t("common.backToSignIn")}</a
>
</p>
</CardContent>
{:else}
<CardHeader>
<CardTitle level={1}
>{mode === "set-password"
? i18n.t("auth.invite.setPasswordTitle")
: i18n.t("auth.invite.title")}</CardTitle
>
<CardDescription>
{mode === "set-password"
? i18n.t("auth.invite.setPasswordDescription")
: i18n.t("auth.invite.description")}
</CardDescription>
</CardHeader>
<CardContent>
{#if previewLoading}
<p class="mb-4 text-sm text-text-muted">{i18n.t("auth.invite.checking")}</p>
{/if}
{#if inviteEmail && !error}
<p class="mb-4 rounded-md border bg-muted/40 px-3 py-2 text-sm text-text-muted">
{i18n.t("auth.invite.forEmail", { email: inviteEmail })}
</p>
{/if}
<form
method="post"
class="space-y-4"
onsubmit={onSubmit}
aria-busy={loading}
aria-describedby={error ? errorId : undefined}
>
{#if error}
<Alert variant="destructive">
<AlertDescription id={errorId}>{error}</AlertDescription>
</Alert>
{/if}
{#if tokenFromLink}
<p class="rounded-md border bg-muted/40 px-3 py-2 text-sm text-text-muted">
{mode === "set-password"
? i18n.t("auth.invite.resetLinkRecognized")
: i18n.t("auth.invite.linkRecognized")}
</p>
{:else}
<div class="space-y-2">
<Label for="token"
>{mode === "set-password"
? i18n.t("auth.invite.resetTokenLabel")
: i18n.t("auth.invite.tokenLabel")}</Label
>
<Input
id="token"
type="password"
name="token"
required
autocomplete="off"
autocapitalize="off"
spellcheck={false}
aria-invalid={error ? "true" : undefined}
aria-describedby={error ? errorId : undefined}
bind:value={token}
onchange={() => void loadPreview(token)}
/>
<p class="text-xs text-text-muted">
{i18n.t("auth.invite.tokenHelp")}
</p>
</div>
{/if}
{#if mode !== "set-password"}
<div class="space-y-2">
<Label for="name">{i18n.t("common.name")}</Label>
<Input
id="name"
type="text"
name="name"
autocomplete="name"
aria-invalid={error ? "true" : undefined}
aria-describedby={error ? errorId : undefined}
bind:value={name}
/>
</div>
{/if}
<div class="space-y-2">
<Label for="password">{i18n.t("common.password")}</Label>
<Input
id="password"
type="password"
name="password"
autocomplete="new-password"
required
minlength={8}
aria-invalid={error ? "true" : undefined}
aria-describedby={passwordDescribedBy()}
bind:value={password}
/>
<p id={passwordHintId} class="text-xs text-text-muted">
{i18n.t("auth.invite.passwordHint")}
</p>
</div>
<Button type="submit" class="w-full shadow-sm" loading={loading} disabled={previewLoading}>
{#if loading}
{mode === "set-password"
? i18n.t("auth.invite.saving")
: i18n.t("auth.invite.accepting")}
{:else}
{mode === "set-password"
? i18n.t("auth.invite.setPasswordSubmit")
: i18n.t("auth.invite.submit")}
{/if}
</Button>
</form>
<p class="mt-5 text-center text-sm text-text-muted">
<a href="/login" class="font-medium text-link hover:underline"
>{i18n.t("common.backToSignIn")}</a
>
</p>
<p class="mt-1.5 text-center text-xs text-text-muted">
{i18n.t("auth.invite.expiredFooter")}
<a href="/admin/users" class="font-medium text-link hover:underline"
>{i18n.t("auth.invite.adminUsersLink")}</a
>.
</p>
</CardContent>
{/if}
</Card>
+521
View File
@@ -0,0 +1,521 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api } from "$lib/api";
import { requireSupportDesk } from "$lib/admin-gate";
import { i18n } from "$lib/i18n";
import { formatCredits } from "$lib/utils";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
type BadgeVariant
} from "$lib/components/ui";
import {
Activity,
ArrowRight,
BarChart3,
BookOpen,
ClipboardList,
LifeBuoy,
Users
} from "@lucide/svelte";
type ProviderBucket = { tokens: number; jobs: number; products: number };
type ProviderBreakdown = {
internal: ProviderBucket;
popular: ProviderBucket;
custom: ProviderBucket;
};
/** Matches GET /api/admin/analytics?summary=1 `summary` payload (live tables only). */
type Summary = {
users: number;
companies: number;
users_period?: number;
companies_period?: number;
credits_allocated: number;
credits_used: number;
credits_remaining: number;
tokens_total: number;
tokens_period: number;
jobs_total: number;
jobs_by_status: Record<string, number>;
jobs_stuck?: number;
jobs_failed_period?: number;
jobs_completed_period?: number;
products_processed: number;
products_raw?: number;
feeds_input?: number;
feeds_export?: number;
api_keys_active?: number;
api_keys_total?: number;
feed_sync_by_status?: Record<string, number>;
tickets_by_status?: Record<string, number>;
};
type SignalTone = "ok" | "info" | "warn" | "critical";
type Signal = {
id: string;
tone: SignalTone;
title: string;
detail: string;
href: string;
cta: string;
};
/** Curated ops shortcuts — not a flat dump of every admin tool. */
const shortcuts = $derived.by(() => [
{
href: "/admin/diagnostics",
title: i18n.t("admin.overview.shortcut.diagnostics.title"),
description: i18n.t("admin.overview.shortcut.diagnostics.desc"),
icon: Activity
},
{
href: "/admin/analytics",
title: i18n.t("admin.overview.shortcut.analytics.title"),
description: i18n.t("admin.overview.shortcut.analytics.desc"),
icon: BarChart3
},
{
href: "/admin/support/knowledge",
title: i18n.t("admin.overview.shortcut.knowledge.title"),
description: i18n.t("admin.overview.shortcut.knowledge.desc"),
icon: BookOpen
},
{
href: "/admin/users",
title: i18n.t("admin.overview.shortcut.users.title"),
description: i18n.t("admin.overview.shortcut.users.desc"),
icon: Users
},
{
href: "/admin/stuck-products",
title: i18n.t("admin.overview.shortcut.stuck.title"),
description: i18n.t("admin.overview.shortcut.stuck.desc"),
icon: ClipboardList
}
]);
let loading = $state(true);
let accessDenied = $state(false);
let error = $state("");
let days = $state(30);
let summary = $state<Summary | null>(null);
let providers = $state<ProviderBreakdown | null>(null);
const ticketsOpen = $derived.by(() => {
const by = summary?.tickets_by_status ?? {};
return Number(by.open ?? 0) + Number(by.pending ?? 0);
});
const failRate = $derived.by(() => {
if (!summary) return null;
const failed = Number(summary.jobs_failed_period ?? 0);
const completed = Number(summary.jobs_completed_period ?? 0);
const denom = failed + completed;
if (denom <= 0) return null;
return Math.round((failed / denom) * 1000) / 10;
});
/** {i18n.t("admin.overview.signals")} from summary only — no second analytics/diagnostics fetch. */
const signals = $derived.by((): Signal[] => {
if (!summary) return [];
const out: Signal[] = [];
const stuck = Number(summary.jobs_stuck ?? 0);
const failed = Number(summary.jobs_failed_period ?? 0);
const running = Number(summary.jobs_by_status?.running ?? 0);
const completed = Number(summary.jobs_completed_period ?? 0);
const openTickets = ticketsOpen;
if (stuck > 0) {
out.push({
id: "stuck",
tone: "critical",
title: i18n.t(
stuck === 1 ? "admin.overview.signal.stuckTitle" : "admin.overview.signal.stuckTitlePlural",
{ count: formatCredits(stuck) }
),
detail: i18n.t("admin.overview.signal.stuckDetail"),
href: "/admin/stuck-products",
cta: i18n.t("admin.overview.signal.stuckCta")
});
}
if (failed > 0) {
out.push({
id: "failed",
tone: "warn",
title: i18n.t("admin.overview.signal.failedTitle", {
count: formatCredits(failed),
days
}),
detail:
failRate === null
? i18n.t("admin.overview.signal.failedDetail", {
completed: formatCredits(completed)
})
: i18n.t("admin.overview.signal.failedDetailRate", {
rate: failRate,
completed: formatCredits(completed)
}),
href: "/admin/diagnostics",
cta: i18n.t("admin.overview.signal.failedCta")
});
}
if (running > 0) {
out.push({
id: "running",
tone: "info",
title: i18n.t(
running === 1
? "admin.overview.signal.runningTitle"
: "admin.overview.signal.runningTitlePlural",
{ count: formatCredits(running) }
),
detail: i18n.t("admin.overview.signal.runningDetail"),
href: "/admin/diagnostics",
cta: i18n.t("admin.overview.signal.runningCta")
});
}
if (openTickets > 0) {
out.push({
id: "tickets",
tone: "info",
title: i18n.t(
openTickets === 1
? "admin.overview.signal.ticketsTitle"
: "admin.overview.signal.ticketsTitlePlural",
{ count: formatCredits(openTickets) }
),
detail: i18n.t("admin.overview.signal.ticketsDetail"),
href: "/admin/support",
cta: i18n.t("admin.overview.signal.ticketsCta")
});
}
if (out.length === 0) {
out.push({
id: "clear",
tone: "ok",
title: i18n.t("admin.overview.signal.clearTitle"),
detail: i18n.t("admin.overview.signal.clearDetail", { days }),
href: "/admin/diagnostics",
cta: i18n.t("admin.overview.signal.clearCta")
});
}
return out;
});
function signalBadge(tone: SignalTone): { label: string; variant: BadgeVariant } {
switch (tone) {
case "critical":
return { label: i18n.t("admin.overview.badge.action"), variant: "destructive" };
case "warn":
return { label: i18n.t("admin.overview.badge.watch"), variant: "warning" };
case "info":
return { label: i18n.t("admin.overview.badge.live"), variant: "secondary" };
default:
return { label: i18n.t("admin.overview.badge.clear"), variant: "success" };
}
}
onMount(async () => {
const gate = await requireSupportDesk();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
// support_staff: Overview is in nav but must land on the desk, not analytics.
if (gate.staff.is_support_only) {
await goto("/admin/support");
return;
}
try {
const res = await api<{
days?: number;
summary: Summary;
providers?: ProviderBreakdown;
}>("/api/admin/analytics?days=30&summary=1");
summary = res.summary ?? null;
providers = res.providers ?? null;
if (typeof res.days === "number" && res.days > 0) days = res.days;
} catch {
/* overview cards optional if analytics fails */
} finally {
loading = false;
}
});
</script>
<PageShell
eyebrow={i18n.t("admin.overview.eyebrow")}
title={i18n.t("admin.overview.title")}
description={i18n.t("admin.overview.description")}
>
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else if error}
<Alert message={error} />
{:else}
{#if summary}
<section class="space-y-3" aria-labelledby="admin-kpi-heading">
<div class="flex flex-wrap items-end justify-between gap-2">
<h2 id="admin-kpi-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.overview.keyMetrics")}
</h2>
<p class="text-xs text-muted-foreground">{i18n.t("admin.overview.summaryWindow", { days })}</p>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.tokens")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.tokensHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-semibold tracking-tight text-card-foreground">
{formatCredits(summary.tokens_total)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.tokensPeriod", {
count: formatCredits(summary.tokens_period),
days
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.credits")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.creditsHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-semibold tracking-tight text-card-foreground">
{formatCredits(summary.credits_used)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.creditsFoot", {
remaining: formatCredits(summary.credits_remaining),
allocated: formatCredits(summary.credits_allocated)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.jobs")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.jobsHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-semibold tracking-tight text-card-foreground">
{formatCredits(summary.jobs_total)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.jobsFoot", {
failed: formatCredits(summary.jobs_by_status?.failed ?? 0),
running: formatCredits(summary.jobs_by_status?.running ?? 0),
stuck: formatCredits(summary.jobs_stuck ?? 0)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.platform")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.platformHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-semibold tracking-tight text-card-foreground">
{formatCredits(summary.companies)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.platformFoot", {
users: formatCredits(summary.users),
processed: formatCredits(summary.products_processed),
feeds: formatCredits(summary.feeds_input ?? 0)
})}
</p>
</CardContent>
</Card>
</div>
</section>
{:else}
<Alert message={i18n.t("admin.overview.summaryLoadFailed")} />
{/if}
<section class="space-y-3" aria-labelledby="admin-signals-heading">
<div class="flex flex-wrap items-end justify-between gap-2">
<h2 id="admin-signals-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.overview.signals")}
</h2>
{#if summary}
<a
href="/admin/diagnostics"
class="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{i18n.t("admin.overview.fullDiagnostics")}
<ArrowRight class="h-3 w-3" aria-hidden="true" />
</a>
{/if}
</div>
{#if signals.length > 0}
<ul class="divide-y divide-border overflow-hidden rounded-lg border border-border bg-card">
{#each signals as signal (signal.id)}
{@const badge = signalBadge(signal.tone)}
<li>
<a
href={signal.href}
class="flex flex-col gap-2 px-4 py-3 transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:flex-row sm:items-center sm:justify-between"
aria-label="{signal.title}. {signal.cta}"
>
<div class="flex min-w-0 items-start gap-3">
<Badge variant={badge.variant}>{badge.label}</Badge>
<div class="min-w-0">
<p class="text-sm font-medium text-card-foreground">{signal.title}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{signal.detail}</p>
</div>
</div>
<span
class="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary sm:pl-4"
>
{signal.cta}
<ArrowRight class="h-3 w-3" aria-hidden="true" />
</span>
</a>
</li>
{/each}
</ul>
{/if}
</section>
<section class="space-y-3" aria-labelledby="admin-shortcuts-heading">
<h2 id="admin-shortcuts-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.overview.shortcuts")}
</h2>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
{#each shortcuts as item}
{@const Icon = item.icon}
<a
href={item.href}
aria-label={item.title}
class="group flex min-h-[5.5rem] flex-col gap-2 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-sm transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-muted text-foreground"
aria-hidden="true"
>
<Icon class="h-4 w-4" />
</span>
<span class="text-sm font-semibold tracking-tight">{item.title}</span>
<span class="text-xs leading-snug text-muted-foreground">{item.description}</span>
</a>
{/each}
</div>
<p class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.also")}
<a
href="/admin/support"
class="inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline"
>
<LifeBuoy class="h-3 w-3" aria-hidden="true" />
{i18n.t("admin.overview.linkSupport")}
</a>
<a href="/admin/billing" class="font-medium text-foreground underline-offset-2 hover:underline"
>{i18n.t("admin.overview.linkBilling")}</a
>
<a
href="/admin/settings"
class="font-medium text-foreground underline-offset-2 hover:underline">{i18n.t("admin.overview.linkSettings")}</a
>
</p>
</section>
{#if providers}
<section class="space-y-3" aria-labelledby="admin-providers-heading">
<div class="flex flex-wrap items-end justify-between gap-2">
<h2 id="admin-providers-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.overview.providers")}
</h2>
<a
href="/admin/analytics"
class="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{i18n.t("admin.overview.charts")}
<ArrowRight class="h-3 w-3" aria-hidden="true" />
</a>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.internalAi")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.internalAiHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-xl font-semibold text-card-foreground">
{formatCredits(providers.internal.tokens)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.providerFoot", {
jobs: formatCredits(providers.internal.jobs),
products: formatCredits(providers.internal.products)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.popularKeys")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.popularKeysHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-xl font-semibold text-card-foreground">
{formatCredits(providers.popular.tokens)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.providerFoot", {
jobs: formatCredits(providers.popular.jobs),
products: formatCredits(providers.popular.products)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.customUrl")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.customUrlHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-xl font-semibold text-card-foreground">
{formatCredits(providers.custom.tokens)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.providerFoot", {
jobs: formatCredits(providers.custom.jobs),
products: formatCredits(providers.custom.products)
})}
</p>
</CardContent>
</Card>
</div>
</section>
{/if}
{/if}
</PageShell>
@@ -0,0 +1,889 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatCredits, formatDate } from "$lib/utils";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import AdminStatusChart from "$lib/components/AdminStatusChart.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell
} from "$lib/components/ui";
import {
Activity,
AlertTriangle,
Building2,
Coins,
Cpu,
KeyRound,
Layers,
Ticket,
UserPlus
} from "@lucide/svelte";
type DayPoint = {
date: string;
tokens?: number;
products?: number;
created?: number;
completed?: number;
failed?: number;
};
type SignupDayPoint = {
date: string;
users: number;
companies: number;
};
type ProviderBucket = {
tokens: number;
jobs: number;
products: number;
};
type ProviderBreakdown = {
internal: ProviderBucket;
popular: ProviderBucket;
custom: ProviderBucket;
};
type ProviderDayPoint = {
date: string;
internal: number;
popular: number;
custom: number;
unknown?: number;
total: number;
};
type ProviderDetail = {
provider: string;
class: string;
tokens: number;
products: number;
};
type Summary = {
users: number;
companies: number;
users_period?: number;
companies_period?: number;
credits_allocated: number;
credits_used: number;
credits_remaining: number;
tokens_total: number;
tokens_period: number;
jobs_total: number;
jobs_by_status: Record<string, number>;
jobs_stuck?: number;
jobs_failed_period?: number;
jobs_completed_period?: number;
products_processed: number;
products_raw: number;
feeds_input?: number;
feeds_export?: number;
api_keys_active?: number;
api_keys_total?: number;
feed_sync_by_status?: Record<string, number>;
tickets_by_status?: Record<string, number>;
};
type CompanyUsage = {
id: string;
name: string;
total_credits: number;
used_credits: number;
credits_remaining: number;
tokens: number;
jobs: number;
providers?: ProviderBreakdown;
};
type CycleRow = {
company_id: string;
company_name: string;
start_date: string;
end_date: string;
credits_used: number;
products_processed: number;
};
type AnalyticsResponse = {
days: number;
summary: Summary;
series: {
tokens_by_day: DayPoint[];
jobs_by_day: DayPoint[];
tokens_by_provider_day?: ProviderDayPoint[];
signups_by_day?: SignupDayPoint[];
};
companies: CompanyUsage[];
billing_cycles: CycleRow[];
providers?: ProviderBreakdown;
tokens_by_provider_detail?: ProviderDetail[];
notes?: string[];
};
let loading = $state(true);
let accessDenied = $state(false);
let error = $state("");
let days = $state(30);
let data = $state<AnalyticsResponse | null>(null);
const tokenPoints = $derived(
(data?.series.tokens_by_day ?? []).map((p) => ({
label: p.date.slice(5),
value: Number(p.tokens ?? 0),
secondary: Number(p.products ?? 0)
}))
);
const jobPoints = $derived(
(data?.series.jobs_by_day ?? []).map((p) => ({
label: p.date.slice(5),
value: Number(p.created ?? 0),
secondary: Number(p.failed ?? 0)
}))
);
const signupPoints = $derived(
(data?.series.signups_by_day ?? []).map((p) => ({
label: p.date.slice(5),
value: Number(p.users ?? 0),
secondary: Number(p.companies ?? 0)
}))
);
const statusEntries = $derived(
Object.entries(data?.summary.jobs_by_status ?? {}).sort((a, b) => b[1] - a[1])
);
const jobStatusChart = $derived(
statusEntries.map(([label, value]) => ({ label, value: Number(value) }))
);
const feedSyncChart = $derived(
Object.entries(data?.summary.feed_sync_by_status ?? {})
.map(([label, value]) => ({ label, value: Number(value) }))
.sort((a, b) => b.value - a.value)
);
const ticketChart = $derived(
Object.entries(data?.summary.tickets_by_status ?? {})
.map(([label, value]) => ({ label, value: Number(value) }))
.sort((a, b) => b.value - a.value)
);
const failRate = $derived.by(() => {
const failed = Number(data?.summary.jobs_failed_period ?? 0);
const completed = Number(data?.summary.jobs_completed_period ?? 0);
const denom = failed + completed;
if (denom <= 0) return null;
return Math.round((failed / denom) * 1000) / 10;
});
const ticketsOpen = $derived(
Number(data?.summary.tickets_by_status?.open ?? 0) +
Number(data?.summary.tickets_by_status?.pending ?? 0)
);
const providers = $derived(
data?.providers ?? {
internal: { tokens: 0, jobs: 0, products: 0 },
popular: { tokens: 0, jobs: 0, products: 0 },
custom: { tokens: 0, jobs: 0, products: 0 }
}
);
const providerSeries = $derived(data?.series.tokens_by_provider_day ?? []);
const providerMax = $derived(
Math.max(1, ...providerSeries.map((p) => Math.max(p.internal, p.popular, p.custom, p.unknown ?? 0)))
);
const providerHasData = $derived(providerSeries.some((p) => p.total > 0));
const providerDetail = $derived(data?.tokens_by_provider_detail ?? []);
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
await load(days);
});
async function load(nextDays: number) {
loading = true;
error = "";
days = nextDays;
try {
data = await api<AnalyticsResponse>(`/api/admin/analytics?days=${nextDays}`);
} catch (err) {
error = failureMessage(err, i18n.t("flash.admin.analyticsLoadFailed"));
data = null;
} finally {
loading = false;
}
}
function barH(v: number, max: number, height: number): number {
return Math.max(v > 0 ? 2 : 0, Math.round((v / max) * (height - 28)));
}
</script>
<PageShell
eyebrow={i18n.t("admin.analytics.eyebrow")}
title={i18n.t("admin.analytics.title")}
description={i18n.t("admin.analytics.description")}
>
{#snippet actions()}
{#if data}
<div
class="flex flex-wrap items-center gap-2"
role="group"
aria-labelledby="analytics-period-label"
aria-busy={loading}
>
<span class="text-sm text-muted-foreground" id="analytics-period-label">{i18n.t("admin.analytics.period")}</span>
{#each [7, 30, 90] as option}
<Button
size="sm"
variant={days === option ? "default" : "outline"}
onclick={() => load(option)}
disabled={loading}
aria-pressed={days === option}
aria-label={i18n.t("admin.analytics.lastNDaysAria", { days: option })}
>
{option}d
</Button>
{/each}
{#if loading}
<span class="text-xs text-muted-foreground">{i18n.t("admin.analytics.refreshing")}</span>
{/if}
</div>
{/if}
{/snippet}
{#if loading && !data}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else if error && !data}
<Alert message={error} />
{:else if data}
<div class="space-y-8">
{#if error}
<Alert message={error} />
{/if}
<section aria-labelledby="analytics-overview-heading" class="space-y-3">
<div>
<h2 id="analytics-overview-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.analytics.overview")}
</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.analytics.overviewHint", { days: data.days })}
</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.llmTokens")}</CardTitle>
<Cpu class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">{formatCredits(data.summary.tokens_total)}</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.tokensInPeriod", {
tokens: formatCredits(data.summary.tokens_period)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.credits")}</CardTitle>
<Coins class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">{formatCredits(data.summary.credits_used)}</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.creditsRemainingOf", {
remaining: formatCredits(data.summary.credits_remaining),
allocated: formatCredits(data.summary.credits_allocated)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.jobs")}</CardTitle>
<Activity class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">{formatCredits(data.summary.jobs_total)}</div>
<p class="mt-1 text-xs text-muted-foreground">
{#if statusEntries.length === 0}
{i18n.t("admin.analytics.noJobsYet")}
{:else}
{statusEntries.map(([s, n]) => `${n} ${s}`).join(" · ")}
{/if}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.volume")}</CardTitle>
<Layers class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(data.summary.products_processed)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.feedsVolume", {
input: formatCredits(data.summary.feeds_input ?? 0),
export: formatCredits(data.summary.feeds_export ?? 0)
})}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.usersCompaniesRaw", {
users: formatCredits(data.summary.users),
companies: formatCredits(data.summary.companies),
raw: formatCredits(data.summary.products_raw)
})}
</p>
</CardContent>
</Card>
</div>
</section>
<section aria-labelledby="analytics-ops-heading" class="space-y-3">
<div>
<h2 id="analytics-ops-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.analytics.operations")}
</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.analytics.operationsHint")}
</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.failureRate")}</CardTitle>
<AlertTriangle class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{failRate === null ? i18n.t("status.emDash") : `${failRate}%`}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.failedCompleted", {
failed: formatCredits(data.summary.jobs_failed_period ?? 0),
completed: formatCredits(data.summary.jobs_completed_period ?? 0)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.stuckJobs")}</CardTitle>
<AlertTriangle class="h-4 w-4 text-chart-amber" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(data.summary.jobs_stuck ?? 0)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.runningOver2h")}
<a class="underline-offset-2 hover:underline" href="/admin/diagnostics">{i18n.t("admin.analytics.linkDiagnostics")}</a>
·
<a class="underline-offset-2 hover:underline" href="/admin/stuck-products"
>{i18n.t("admin.analytics.linkStuck")}</a
>
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.apiKeys")}</CardTitle>
<KeyRound class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(data.summary.api_keys_active ?? 0)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.apiKeysTotal", {
total: formatCredits(data.summary.api_keys_total ?? 0)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.signups")}</CardTitle>
<UserPlus class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(data.summary.users_period ?? 0)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.signupsDetail", {
companies: formatCredits(data.summary.companies_period ?? 0),
tickets: formatCredits(ticketsOpen)
})}
</p>
</CardContent>
</Card>
</div>
</section>
<section aria-labelledby="analytics-providers-heading" class="space-y-3">
<div>
<h2 id="analytics-providers-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.analytics.providerMix")}
</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.analytics.providerMixHint")}
</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Card class="border-l-4 border-l-chart-sky">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.internalAi")}</CardTitle>
<CardDescription>{i18n.t("admin.analytics.internalAiHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(providers.internal.tokens)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.productsJobs", {
products: formatCredits(providers.internal.products),
jobs: formatCredits(providers.internal.jobs)
})}
</p>
</CardContent>
</Card>
<Card class="border-l-4 border-l-chart-emerald">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.popularKeys")}</CardTitle>
<CardDescription>{i18n.t("admin.analytics.popularKeysHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(providers.popular.tokens)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.productsJobs", {
products: formatCredits(providers.popular.products),
jobs: formatCredits(providers.popular.jobs)
})}
</p>
</CardContent>
</Card>
<Card class="border-l-4 border-l-chart-amber">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.customUrl")}</CardTitle>
<CardDescription>{i18n.t("admin.analytics.customUrlHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(providers.custom.tokens)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.productsJobs", {
products: formatCredits(providers.custom.products),
jobs: formatCredits(providers.custom.jobs)
})}
</p>
</CardContent>
</Card>
</div>
</section>
<section aria-labelledby="analytics-trends-heading" class="space-y-3">
<div>
<h2 id="analytics-trends-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.analytics.trends")}
</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.analytics.trendsHint")}
</p>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.analytics.tokensByDay")}</CardTitle>
<CardDescription>
{i18n.t("admin.analytics.tokensByDayHint")}
</CardDescription>
</CardHeader>
<CardContent>
{#await import("$lib/components/AdminSeriesChart.svelte") then mod}
<mod.default
points={tokenPoints}
primaryLabel={i18n.t("admin.analytics.seriesTokens")}
secondaryLabel={i18n.t("admin.analytics.seriesProducts")}
emptyMessage={i18n.t("admin.analytics.emptyTokens")}
/>
{/await}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.analytics.tokensByProvider")}</CardTitle>
<CardDescription>
{i18n.t("admin.analytics.tokensByProviderHint")}
</CardDescription>
</CardHeader>
<CardContent>
{#if !providerHasData}
<div
class="flex h-[180px] items-center justify-center rounded-lg border border-dashed border-border bg-muted/30 text-sm text-muted-foreground"
>
{i18n.t("admin.analytics.emptyProviderTokens")}
</div>
{:else}
<div class="mb-2.5 flex flex-wrap gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1.5">
<span class="h-2.5 w-2.5 rounded-sm bg-chart-sky" aria-hidden="true"></span>
{i18n.t("admin.analytics.internal")}
</span>
<span class="inline-flex items-center gap-1.5">
<span class="h-2.5 w-2.5 rounded-sm bg-chart-emerald" aria-hidden="true"></span>
{i18n.t("admin.analytics.popular")}
</span>
<span class="inline-flex items-center gap-1.5">
<span class="h-2.5 w-2.5 rounded-sm bg-chart-amber" aria-hidden="true"></span>
{i18n.t("admin.analytics.custom")}
</span>
</div>
<div
class="flex items-end gap-px overflow-x-auto rounded-lg border border-border bg-chart-plot px-2.5 pb-1.5 pt-3 shadow-sm shadow-black/5"
style="height: 180px"
role="img"
aria-label={i18n.t("admin.analytics.tokensByProviderAria")}
>
{#each providerSeries as point}
{@const hi = barH(point.internal, providerMax, 180)}
{@const hp = barH(point.popular, providerMax, 180)}
{@const hc = barH(point.custom, providerMax, 180)}
{@const tip = i18n.t("admin.analytics.providerDayTip", {
date: point.date.slice(5),
internal: point.internal.toLocaleString(),
popular: point.popular.toLocaleString(),
custom: point.custom.toLocaleString()
})}
<div
class="group relative flex min-w-[8px] flex-1 flex-col items-center justify-end"
title={tip}
>
<div
class="pointer-events-none absolute bottom-full z-10 mb-1.5 hidden whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-[10px] font-medium text-popover-foreground shadow-md group-hover:block"
>
{tip}
</div>
<div class="flex w-full items-end justify-center gap-px" style="height: 152px">
<div
class="w-[30%] rounded-t-sm bg-chart-sky/90"
style="height: {hi}px"
title={i18n.t("admin.analytics.internalCount", { count: point.internal.toLocaleString() })}
></div>
<div
class="w-[30%] rounded-t-sm bg-chart-emerald/90"
style="height: {hp}px"
title={i18n.t("admin.analytics.popularCount", { count: point.popular.toLocaleString() })}
></div>
<div
class="w-[30%] rounded-t-sm bg-chart-amber/90"
style="height: {hc}px"
title={i18n.t("admin.analytics.customCount", { count: point.custom.toLocaleString() })}
></div>
</div>
</div>
{/each}
</div>
<div class="mt-1.5 flex justify-between text-[10px] tabular-nums text-muted-foreground">
<span>{providerSeries[0]?.date.slice(5) ?? ""}</span>
<span>{providerSeries[providerSeries.length - 1]?.date.slice(5) ?? ""}</span>
</div>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.analytics.jobsByDay")}</CardTitle>
<CardDescription>{i18n.t("admin.analytics.jobsByDayHint")}</CardDescription>
</CardHeader>
<CardContent>
{#await import("$lib/components/AdminSeriesChart.svelte") then mod}
<mod.default
points={jobPoints}
primaryLabel={i18n.t("admin.analytics.seriesCreated")}
secondaryLabel={i18n.t("admin.analytics.seriesFailed")}
emptyMessage={i18n.t("admin.analytics.emptyJobsSeries")}
/>
{/await}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.analytics.signupsByDay")}</CardTitle>
<CardDescription>{i18n.t("admin.analytics.signupsByDayHint")}</CardDescription>
</CardHeader>
<CardContent>
{#await import("$lib/components/AdminSeriesChart.svelte") then mod}
<mod.default
points={signupPoints}
primaryLabel={i18n.t("admin.analytics.seriesUsers")}
secondaryLabel={i18n.t("admin.analytics.seriesCompanies")}
emptyMessage={i18n.t("admin.analytics.emptySignups")}
/>
{/await}
</CardContent>
</Card>
</div>
</section>
<section aria-labelledby="analytics-status-heading" class="space-y-3">
<div>
<h2 id="analytics-status-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.analytics.statusMix")}
</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.analytics.statusMixHint")}
</p>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Activity class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{i18n.t("admin.analytics.jobStatus")}
</CardTitle>
<CardDescription>{i18n.t("admin.analytics.processingJobsStatus")}</CardDescription>
</CardHeader>
<CardContent>
<AdminStatusChart
entries={jobStatusChart}
emptyMessage={i18n.t("admin.analytics.emptyJobStatus")}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Layers class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{i18n.t("admin.analytics.feedSyncQueue")}
</CardTitle>
<CardDescription>{i18n.t("admin.analytics.feedSyncStatus")}</CardDescription>
</CardHeader>
<CardContent>
<AdminStatusChart
entries={feedSyncChart}
emptyMessage={i18n.t("admin.analytics.emptyFeedSync")}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Ticket class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{i18n.t("admin.analytics.supportTickets")}
</CardTitle>
<CardDescription>{i18n.t("admin.analytics.ticketsStatus")}</CardDescription>
</CardHeader>
<CardContent>
<AdminStatusChart
entries={ticketChart}
emptyMessage={i18n.t("admin.analytics.emptyTickets")}
/>
</CardContent>
</Card>
</div>
</section>
<section aria-labelledby="analytics-detail-heading" class="space-y-3">
<div>
<h2 id="analytics-detail-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.analytics.usageDetail")}
</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.analytics.usageDetailHint")}
</p>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.analytics.providerDetail")}</CardTitle>
<CardDescription>{i18n.t("admin.analytics.providerDetailHint")}</CardDescription>
</CardHeader>
<CardContent>
{#if providerDetail.length === 0}
<EmptyState
title={i18n.t("admin.analytics.noProviderRows")}
message={i18n.t("empty.admin.noProviderMessage")}
/>
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.analytics.provider")}</TableHead>
<TableHead>{i18n.t("admin.analytics.class")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.tokensCol")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.productsCol")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each providerDetail as row}
<TableRow>
<TableCell class="font-medium">{row.provider}</TableCell>
<TableCell class="text-muted-foreground">{row.class}</TableCell>
<TableCell class="text-right tabular-nums"
>{formatCredits(row.tokens)}</TableCell
>
<TableCell class="text-right tabular-nums"
>{formatCredits(row.products)}</TableCell
>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Building2 class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{i18n.t("admin.analytics.companyUsage")}
</CardTitle>
<CardDescription>{i18n.t("admin.analytics.companiesHint")}</CardDescription>
</CardHeader>
<CardContent>
{#if data.companies.length === 0}
<EmptyState title={i18n.t("empty.admin.noCompaniesTitle")} message={i18n.t("empty.admin.noCompaniesMessage")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.analytics.company")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.tokensCol")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.internal")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.popular")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.custom")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.jobsCol")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each data.companies as company}
<TableRow>
<TableCell class="font-medium">{company.name}</TableCell>
<TableCell class="text-right tabular-nums"
>{formatCredits(company.tokens)}</TableCell
>
<TableCell class="text-right tabular-nums"
>{formatCredits(company.providers?.internal?.tokens ?? 0)}</TableCell
>
<TableCell class="text-right tabular-nums"
>{formatCredits(company.providers?.popular?.tokens ?? 0)}</TableCell
>
<TableCell class="text-right tabular-nums"
>{formatCredits(company.providers?.custom?.tokens ?? 0)}</TableCell
>
<TableCell class="text-right tabular-nums"
>{formatCredits(company.jobs)}</TableCell
>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.analytics.billingCycles")}</CardTitle>
<CardDescription>
{i18n.t("admin.analytics.billingCyclesHint")}
</CardDescription>
</CardHeader>
<CardContent>
{#if data.billing_cycles.length === 0}
<EmptyState
title={i18n.t("admin.analytics.noBillingCycles")}
message={i18n.t("empty.admin.noCyclesMessage")}
/>
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.analytics.company")}</TableHead>
<TableHead>{i18n.t("admin.analytics.periodCol")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.creditsCol")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.productsCol")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each data.billing_cycles as cycle}
<TableRow>
<TableCell class="font-medium">{cycle.company_name}</TableCell>
<TableCell class="text-xs text-muted-foreground">
{formatDate(cycle.start_date)} {formatDate(cycle.end_date)}
</TableCell>
<TableCell class="text-right tabular-nums"
>{formatCredits(cycle.credits_used)}</TableCell
>
<TableCell class="text-right tabular-nums"
>{formatCredits(cycle.products_processed)}</TableCell
>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
</section>
{#if data.notes?.length}
<section aria-labelledby="analytics-notes-heading">
<Card>
<CardHeader>
<CardTitle id="analytics-notes-heading" class="text-sm"
>{i18n.t("admin.analytics.dataNotes")}</CardTitle
>
</CardHeader>
<CardContent>
<ul class="list-inside list-disc space-y-1 text-sm text-muted-foreground">
{#each data.notes as note}
<li>{note}</li>
{/each}
</ul>
</CardContent>
</Card>
</section>
{/if}
</div>
{/if}
</PageShell>
@@ -0,0 +1,717 @@
<script module lang="ts">
/** Module-scoped so remounted page instances share one cold bootstrap. */
let billingBootstrapInflight: Promise<void> | null = null;
</script>
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import {
assignAdminPlan,
classifyAdminPlanVisibility,
countAdminPlansByVisibility,
fetchAdminCompaniesList,
fetchAdminPlansList,
invalidateAdminBillingLists,
planOptionLabel,
upsertAdminPlan,
type AdminBillingCompany,
type AdminBillingPlan
} from "$lib/admin-billing-plans";
import { formatCredits } from "$lib/utils";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import AdminPlansPanel from "$lib/components/admin/AdminPlansPanel.svelte";
import PlanPermissionsPanel from "$lib/components/admin/PlanPermissionsPanel.svelte";
import GlobalFeatureGatesPanel from "$lib/components/admin/GlobalFeatureGatesPanel.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Checkbox,
Dialog,
Input,
Label,
Select,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea
} from "$lib/components/ui";
import { CreditCard, Coins, UserPlus, Users } from "@lucide/svelte";
type Company = AdminBillingCompany;
type PlanFormState = {
id: number;
name: string;
description: string;
monthly_credits: number;
yearly_credits: string;
max_products: string;
is_custom: boolean;
term: string;
};
const emptyPlanForm = (): PlanFormState => ({
id: 0,
name: "",
description: "",
monthly_credits: 1000,
yearly_credits: "",
max_products: "",
is_custom: true,
term: "monthly"
});
let loading = $state(true);
let accessDenied = $state(false);
let busy = $state(false);
let error = $state("");
let success = $state("");
let tab = $state("plans");
let permissionsPlanId = $state("");
let plans = $state<AdminBillingPlan[]>([]);
let companies = $state<Company[]>([]);
let companySearch = $state("");
let companyPlanFilter = $state<"all" | "with_plan" | "without_plan">("all");
/** Coalesce remount storms so cold load does not double-hit plans/companies. */
// billingBootstrapInflight lives in <script module>
let assignOpen = $state(false);
let creditsOpen = $state(false);
let planFormOpen = $state(false);
let planForm = $state<PlanFormState>(emptyPlanForm());
let assignCompanyId = $state("");
let assignPlanId = $state("");
let assignIsTrial = $state(false);
let assignTrialCredits = $state(0);
let creditCompanyId = $state("");
let creditAmount = $state(100);
const totalAllocated = $derived(companies.reduce((s, c) => s + Number(c.total_credits ?? 0), 0));
const totalUsed = $derived(companies.reduce((s, c) => s + Number(c.used_credits ?? 0), 0));
const visibilityCounts = $derived(countAdminPlansByVisibility(plans));
const withoutPlanCount = $derived(companies.filter((c) => c.has_active_plan === false).length);
const planFormMode = $derived(planForm.id > 0 ? "edit" : "create");
const filteredCompanies = $derived.by(() => {
const q = companySearch.trim().toLowerCase();
return companies.filter((c) => {
if (companyPlanFilter === "with_plan" && c.has_active_plan === false) return false;
if (companyPlanFilter === "without_plan" && c.has_active_plan !== false) return false;
if (!q) return true;
return c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q);
});
});
onMount(() => {
if (!billingBootstrapInflight) {
billingBootstrapInflight = (async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
return;
}
try {
await loadBillingData();
} catch (err) {
error = failureMessage(err, i18n.t("admin.billing.loadFailed"));
}
})().finally(() => {
billingBootstrapInflight = null;
});
}
void (billingBootstrapInflight ?? Promise.resolve())
.then(async () => {
if (accessDenied) return;
// Remounts that joined an in-flight bootstrap still need local state filled.
if (plans.length === 0 || companies.length === 0) {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "forbidden") accessDenied = true;
return;
}
await loadBillingData();
}
})
.catch((err) => {
error = failureMessage(err, i18n.t("admin.billing.loadFailed"));
})
.finally(() => {
loading = false;
});
});
async function loadBillingData(opts: { fresh?: boolean } = {}) {
if (opts.fresh) invalidateAdminBillingLists();
const [p, c] = await Promise.all([fetchAdminPlansList(), fetchAdminCompaniesList()]);
plans = p;
companies = c;
}
async function reload() {
await loadBillingData({ fresh: true });
}
function openCreatePlan() {
planForm = emptyPlanForm();
planFormOpen = true;
}
function openEditPlan(plan: AdminBillingPlan) {
planForm = {
id: Number(plan.id) || 0,
name: plan.name ?? "",
description: plan.description ?? "",
monthly_credits: Number(plan.monthly_credits ?? 0),
yearly_credits:
plan.yearly_credits == null || plan.yearly_credits === undefined
? ""
: String(plan.yearly_credits),
max_products:
plan.max_products == null || plan.max_products === undefined
? ""
: String(plan.max_products),
is_custom: Boolean(plan.is_custom),
term: plan.term || "monthly"
};
planFormOpen = true;
}
function openAssign(plan?: AdminBillingPlan, companyId?: string) {
assignPlanId = plan ? String(plan.id) : assignPlanId || "";
assignCompanyId = companyId ?? assignCompanyId;
assignIsTrial = false;
assignTrialCredits = 0;
assignOpen = true;
}
function openCreditsFor(companyId: string) {
creditCompanyId = companyId;
creditsOpen = true;
}
function openPermissionsFor(planId: number | string) {
permissionsPlanId = String(planId);
tab = "permissions";
}
async function savePlan(event: Event) {
event.preventDefault();
busy = true;
error = "";
success = "";
try {
const yearlyRaw = planForm.yearly_credits.trim();
const maxRaw = planForm.max_products.trim();
await upsertAdminPlan({
id: planForm.id > 0 ? planForm.id : undefined,
name: planForm.name,
description: planForm.description.trim() || null,
monthly_credits: Number(planForm.monthly_credits),
yearly_credits: yearlyRaw === "" ? null : Number(yearlyRaw),
max_products: maxRaw === "" ? null : Number(maxRaw),
is_custom: planForm.is_custom,
term: planForm.term
});
success = planFormMode === "edit" ? i18n.t("admin.billing.planUpdated") : i18n.t("admin.billing.planCreated");
planFormOpen = false;
planForm = emptyPlanForm();
await reload();
} catch (err) {
error = failureMessage(err, planFormMode === "edit" ? i18n.t("admin.billing.updateFailed") : i18n.t("admin.billing.createFailed"));
} finally {
busy = false;
}
}
async function assignPlan(event: Event) {
event.preventDefault();
busy = true;
error = "";
success = "";
try {
await assignAdminPlan({
company_id: assignCompanyId,
plan_id: Number(assignPlanId),
is_trial: assignIsTrial,
trial_credits: assignIsTrial ? Number(assignTrialCredits) : 0
});
success = i18n.t("flash.admin.planAssignedShort");
assignOpen = false;
await reload();
} catch (err) {
error = failureMessage(err, i18n.t("admin.billing.assignFailed"));
} finally {
busy = false;
}
}
async function addCredits(event: Event) {
event.preventDefault();
busy = true;
error = "";
success = "";
try {
await api("/api/admin/credits", {
method: "POST",
body: { company_id: creditCompanyId, amount: creditAmount }
});
success = i18n.t("flash.admin.creditsUpdated");
creditsOpen = false;
await reload();
} catch (err) {
error = failureMessage(err, i18n.t("admin.billing.creditsFailed"));
} finally {
busy = false;
}
}
async function runCycles() {
busy = true;
error = "";
success = "";
try {
const res = await api<{ processed: number }>("/api/admin/billing/run-cycles", {
method: "POST",
body: {}
});
success = i18n.t("flash.admin.cyclesProcessed", { count: res.processed });
} catch (err) {
error = failureMessage(err, i18n.t("admin.billing.cyclesFailed"));
} finally {
busy = false;
}
}
</script>
<PageShell
title={i18n.t("admin.billing.title")}
description={i18n.t("admin.billing.description")}
>
{#snippet actions()}
<Button size="sm" onclick={openCreatePlan}>{i18n.t("admin.billing.createPlan")}</Button>
<Button variant="outline" size="sm" onclick={() => openAssign()}>{i18n.t("admin.billing.assignPlanBtn")}</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Alert message={error} />
<Alert tone="success" message={success} />
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Card class="border-border bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-foreground">{i18n.t("admin.billing.stat.plans")}</CardTitle>
<CreditCard class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums text-foreground">
{formatCredits(plans.length)}
</div>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.billing.stat.catalogHidden", { catalog: visibilityCounts.catalog, hidden: visibilityCounts.hidden })}
</p>
</CardContent>
</Card>
<Card class="border-border bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-foreground"
>{i18n.t("admin.billing.stat.creditsAllocated")}</CardTitle
>
<CreditCard class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums text-foreground">
{formatCredits(totalAllocated)}
</div>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.billing.stat.acrossCompanies")}
</p>
</CardContent>
</Card>
<Card class="border-border bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-foreground">{i18n.t("admin.billing.stat.creditsUsed")}</CardTitle>
<Users class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums text-foreground">
{formatCredits(totalUsed)}
</div>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.billing.stat.creditsConsumed")}
</p>
</CardContent>
</Card>
<Card class="border-border bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-foreground"
>{i18n.t("admin.billing.stat.noActivePlan")}</CardTitle
>
<Users class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums text-foreground">
{formatCredits(withoutPlanCount)}
</div>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.billing.stat.noActivePlanHint")}
</p>
</CardContent>
</Card>
</div>
<Tabs bind:value={tab} class="space-y-4">
<TabsList>
<TabsTrigger value="plans">{i18n.t("admin.billing.tab.plans")}</TabsTrigger>
<TabsTrigger value="permissions">{i18n.t("admin.billing.tab.permissions")}</TabsTrigger>
<TabsTrigger value="global">{i18n.t("admin.billing.tab.global")}</TabsTrigger>
<TabsTrigger value="companies">{i18n.t("admin.billing.tab.companies")}</TabsTrigger>
</TabsList>
<TabsContent value="plans" class="mt-0 space-y-4">
<AdminPlansPanel
{plans}
{busy}
onEdit={openEditPlan}
onAssign={(plan) => openAssign(plan)}
onPermissions={openPermissionsFor}
/>
<Card class="border-border bg-card">
<CardHeader>
<CardTitle class="text-foreground">{i18n.t("admin.billing.cyclesTitle")}</CardTitle>
<CardDescription class="text-muted-foreground">
{i18n.t("admin.billing.cyclesDesc")}
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" loading={busy} onclick={runCycles}
>{i18n.t("admin.billing.runCycles")}</Button
>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="permissions" class="mt-0 space-y-4">
<PlanPermissionsPanel initialPlans={plans} selectedPlanId={permissionsPlanId} />
</TabsContent>
<TabsContent value="global" class="mt-0 space-y-4">
<GlobalFeatureGatesPanel />
</TabsContent>
<TabsContent value="companies" class="mt-0 space-y-4">
<Card class="border-border bg-card">
<CardHeader class="gap-4 space-y-0 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1.5">
<CardTitle class="text-foreground">{i18n.t("admin.billing.companiesTitle")}</CardTitle>
<CardDescription class="text-muted-foreground">
{i18n.t("admin.billing.companiesDesc")}
</CardDescription>
</div>
<div class="flex w-full flex-col gap-2 sm:max-w-md sm:flex-row">
<label class="sr-only" for="admin-company-search">{i18n.t("admin.billing.searchCompaniesAria")}</label>
<Input
id="admin-company-search"
type="search"
placeholder={i18n.t("admin.billing.searchCompany")}
bind:value={companySearch}
autocomplete="off"
/>
<label class="sr-only" for="admin-company-plan-filter">{i18n.t("admin.billing.planStatusAria")}</label>
<Select id="admin-company-plan-filter" bind:value={companyPlanFilter}>
<option value="all">{i18n.t("admin.billing.filter.all")}</option>
<option value="with_plan">{i18n.t("admin.billing.filter.withPlan")}</option>
<option value="without_plan">{i18n.t("admin.billing.filter.withoutPlan")}</option>
</Select>
</div>
</CardHeader>
<CardContent>
{#if companies.length === 0}
<EmptyState message={i18n.t("empty.admin.noCompaniesFound")} />
{:else if filteredCompanies.length === 0}
<EmptyState message={i18n.t("empty.admin.noCompaniesMatch")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.billing.col.company")}</TableHead>
<TableHead>{i18n.t("admin.billing.col.planStatus")}</TableHead>
<TableHead>{i18n.t("admin.billing.col.creditsRemaining")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.billing.col.total")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.billing.col.used")}</TableHead>
<TableHead class="hidden md:table-cell">{i18n.t("admin.billing.col.usagePct")}</TableHead>
<TableHead stickyRight>{i18n.t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each filteredCompanies as company (company.id)}
{@const total = Number(company.total_credits ?? 0)}
{@const used = Number(company.used_credits ?? 0)}
{@const remaining = Math.max(total - used, 0)}
<TableRow>
<TableCell class="font-medium">
<p class="text-foreground">{company.name}</p>
<p class="font-mono text-xs text-muted-foreground">
{company.id}
</p>
</TableCell>
<TableCell>
{#if company.has_active_plan === false}
<Badge variant="warning">{i18n.t("admin.billing.badge.noPlan")}</Badge>
{:else}
<Badge variant="outline">{i18n.t("admin.billing.badge.active")}</Badge>
{/if}
</TableCell>
<TableCell class="tabular-nums text-foreground"
>{formatCredits(remaining)}</TableCell
>
<TableCell class="hidden tabular-nums text-foreground lg:table-cell"
>{formatCredits(total)}</TableCell
>
<TableCell class="hidden tabular-nums text-foreground lg:table-cell"
>{formatCredits(used)}</TableCell
>
<TableCell class="hidden tabular-nums text-foreground md:table-cell"
>{total > 0 ? Math.round((used / total) * 100) : 0}%</TableCell
>
<TableCell stickyRight>
<div class="flex flex-wrap justify-end gap-1">
<Button
variant="ghost"
size="sm"
onclick={() => openAssign(undefined, company.id)}
aria-label={i18n.t("admin.billing.assignPlanAria", { name: company.name })}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.billing.assignPlanBtn")}</span>
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => openCreditsFor(company.id)}
aria-label={i18n.t("admin.billing.addCreditsAria", { name: company.name })}
>
<Coins class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.billing.addCredits")}</span>
</Button>
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
<p class="mt-3 text-xs text-muted-foreground">
{i18n.t("admin.billing.showingCompanies", { filtered: filteredCompanies.length, total: companies.length })}
</p>
{/if}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/if}
</PageShell>
<Dialog
bind:open={planFormOpen}
title={planFormMode === "edit" ? i18n.t("admin.billing.editPlanTitle") : i18n.t("admin.billing.createPlanTitle")}
description={planFormMode === "edit"
? i18n.t("admin.billing.editPlanDesc")
: i18n.t("admin.billing.createPlanDesc")}
>
<form class="space-y-4" onsubmit={savePlan}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2 sm:col-span-2">
<Label for="plan-name">{i18n.t("admin.billing.field.name")}</Label>
<Input id="plan-name" bind:value={planForm.name} required data-dialog-initial-focus />
</div>
<div class="space-y-2 sm:col-span-2">
<Label for="plan-description">{i18n.t("admin.billing.field.description")}</Label>
<Textarea
id="plan-description"
bind:value={planForm.description}
rows={2}
placeholder={i18n.t("admin.billing.field.descriptionPlaceholder")}
/>
</div>
<div class="space-y-2">
<Label for="plan-credits">{i18n.t("admin.billing.field.monthlyCredits")}</Label>
<Input
id="plan-credits"
type="number"
min="0"
bind:value={planForm.monthly_credits}
required
/>
</div>
<div class="space-y-2">
<Label for="plan-yearly">{i18n.t("admin.billing.field.yearlyCredits")}</Label>
<Input
id="plan-yearly"
type="number"
min="0"
bind:value={planForm.yearly_credits}
placeholder={i18n.t("common.optional")}
/>
</div>
<div class="space-y-2">
<Label for="plan-max-products">{i18n.t("admin.billing.field.maxProducts")}</Label>
<Input
id="plan-max-products"
type="number"
min="0"
bind:value={planForm.max_products}
placeholder={i18n.t("admin.billing.field.maxProductsPlaceholder")}
/>
</div>
<div class="space-y-2">
<Label for="plan-term">{i18n.t("admin.billing.field.term")}</Label>
<Select id="plan-term" bind:value={planForm.term}>
<option value="monthly">{i18n.t("admin.billing.term.monthly")}</option>
<option value="yearly">{i18n.t("admin.billing.term.yearly")}</option>
</Select>
</div>
</div>
<div class="flex items-start gap-3 rounded-md border border-border p-3">
<Checkbox id="plan-custom" bind:checked={planForm.is_custom} />
<div class="space-y-1">
<Label for="plan-custom" class="cursor-pointer font-medium">{i18n.t("admin.billing.customPackage")}</Label>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.billing.customPackageHint")}
{#if planForm.name.trim()}
{@const preview = classifyAdminPlanVisibility({
name: planForm.name,
is_custom: planForm.is_custom
})}
{i18n.t("admin.billing.previewBadge", { preview })}
{/if}
</p>
</div>
</div>
<div class="flex flex-wrap justify-end gap-2 pt-1">
<Button type="button" variant="outline" disabled={busy} onclick={() => (planFormOpen = false)}
>{i18n.t("common.cancel")}</Button
>
<Button type="submit" loading={busy}
>{planFormMode === "edit" ? i18n.t("common.save") : i18n.t("admin.billing.createPlan")}</Button
>
</div>
</form>
</Dialog>
<Dialog
bind:open={assignOpen}
title={i18n.t("admin.billing.assignPlanTitle")}
description={i18n.t("admin.billing.assignPlanDesc")}
>
<form class="space-y-4" onsubmit={assignPlan}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
<div class="space-y-2">
<Label for="assign-company">{i18n.t("admin.billing.company")}</Label>
<Select id="assign-company" bind:value={assignCompanyId} required data-dialog-initial-focus>
<option value="">{i18n.t("admin.billing.selectCompany")}</option>
{#each companies as c}
<option value={c.id}>
{c.name}{c.has_active_plan === false ? i18n.t("admin.billing.noPlanSuffix") : ""}
</option>
{/each}
</Select>
</div>
<div class="space-y-2">
<Label for="assign-plan">{i18n.t("admin.billing.plan")}</Label>
<Select id="assign-plan" bind:value={assignPlanId} required>
<option value="">{i18n.t("admin.billing.selectPlan")}</option>
{#each plans as p}
<option value={String(p.id)}>{planOptionLabel(p)}</option>
{/each}
</Select>
</div>
<div class="flex items-start gap-3 rounded-md border border-border p-3">
<Checkbox id="assign-trial" bind:checked={assignIsTrial} />
<div class="space-y-1">
<Label for="assign-trial" class="cursor-pointer font-medium">{i18n.t("admin.billing.trialAssignment")}</Label>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.billing.trialHint")}
</p>
</div>
</div>
{#if assignIsTrial}
<div class="space-y-2">
<Label for="assign-trial-credits">{i18n.t("admin.billing.trialCredits")}</Label>
<Input id="assign-trial-credits" type="number" min="0" bind:value={assignTrialCredits} />
</div>
{/if}
<div class="flex flex-wrap justify-end gap-2 pt-1">
<Button type="button" variant="outline" disabled={busy} onclick={() => (assignOpen = false)}
>{i18n.t("common.cancel")}</Button
>
<Button type="submit" loading={busy}>{i18n.t("admin.billing.assign")}</Button>
</div>
</form>
</Dialog>
<Dialog bind:open={creditsOpen} title={i18n.t("admin.billing.creditsTitle")} description={i18n.t("admin.billing.creditsDesc")}>
<form class="space-y-4" onsubmit={addCredits}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
<div class="space-y-2">
<Label for="credit-company">{i18n.t("admin.billing.company")}</Label>
<Select id="credit-company" bind:value={creditCompanyId} required data-dialog-initial-focus>
<option value="">{i18n.t("admin.billing.selectCompany")}</option>
{#each companies as c}
<option value={c.id}>{c.name}</option>
{/each}
</Select>
</div>
<div class="space-y-2">
<Label for="credit-amount">{i18n.t("admin.billing.amount")}</Label>
<Input id="credit-amount" type="number" bind:value={creditAmount} required />
<p class="text-xs text-muted-foreground">{i18n.t("admin.billing.amountHint")}</p>
</div>
<div class="flex flex-wrap justify-end gap-2 pt-1">
<Button type="button" variant="outline" disabled={busy} onclick={() => (creditsOpen = false)}
>{i18n.t("common.cancel")}</Button
>
<Button type="submit" loading={busy}>{i18n.t("admin.billing.apply")}</Button>
</div>
</form>
</Dialog>
@@ -0,0 +1,94 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage, isForbidden, isUnauthorized } from "$lib/api";
import type { MeResponse } from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "$lib/components/ui";
import { i18n } from "$lib/i18n";
let loading = $state(true);
let accessDenied = $state(false);
let error = $state("");
let me = $state<MeResponse | null>(null);
onMount(async () => {
try {
me = await api<MeResponse>("/api/auth/me");
} catch (err) {
if (isUnauthorized(err)) {
await goto("/login");
return;
}
if (isForbidden(err)) {
accessDenied = true;
return;
}
error = failureMessage(err, i18n.t("admin.bootstrap.loadFailed"));
} finally {
loading = false;
}
});
</script>
<PageShell
title={i18n.t("admin.bootstrap.title")}
description={i18n.t("admin.bootstrap.description")}
>
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Card class="mx-auto max-w-md">
<CardHeader>
<CardTitle>{i18n.t("admin.bootstrap.statusTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.bootstrap.statusDesc")}
</CardDescription>
</CardHeader>
<CardContent>
{#if me?.user?.is_platform_admin}
<div class="rounded-md border border-green-500/30 bg-green-500/10 p-4 text-sm">
<p class="font-medium">{i18n.t("admin.bootstrap.alreadyAdmin")}</p>
<p class="mt-2 text-muted-foreground">
{i18n.t("admin.bootstrap.signedInAs", { email: me.user.email })}
</p>
</div>
{:else if error}
<div class="rounded-md border border-destructive/30 bg-destructive/10 p-4 text-sm">
<p>{error}</p>
</div>
{:else}
<ForbiddenEmptyState
kind="platform"
title={i18n.t("admin.bootstrap.notAdminTitle")}
message={i18n.t("admin.bootstrap.notAdminMsg")}
/>
{#if me?.user}
<p class="mt-3 text-center text-sm text-muted-foreground">{i18n.t("admin.bootstrap.signedInAsShort", { email: me.user.email })}</p>
{/if}
{/if}
</CardContent>
<CardFooter class="flex justify-between gap-2">
{#if me?.user?.is_platform_admin}
<Button class="w-full" onclick={() => goto("/admin")}>{i18n.t("admin.bootstrap.goToPanel")}</Button>
{:else}
<Button variant="outline" class="w-full" onclick={() => goto("/dashboard")}>
{i18n.t("admin.chrome.backToApp")}
</Button>
{/if}
</CardFooter>
</Card>
{/if}
</PageShell>
@@ -0,0 +1,740 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatCredits, formatDate } from "$lib/utils";
import {
checkStatusVariant,
CONFIG_FLAG_LABELS,
isDiagnosticsRateLimited,
isDiagnosticsUnavailable,
isDiagnosticsUnreachable,
loadAdminDiagnostics,
type AdminDiagnostics,
type DiagCheck
} from "$lib/admin-diagnostics";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
type BadgeVariant
} from "$lib/components/ui";
import {
Activity,
ArrowRight,
CreditCard,
Database,
HardDrive,
Mail,
RefreshCw,
Server,
ShieldCheck,
Wrench
} from "@lucide/svelte";
const jobFilters = [
{ value: "failed", labelKey: "status.failed" },
{ value: "running", labelKey: "status.running" },
{ value: "pending", labelKey: "status.pending" },
{ value: "completed", labelKey: "status.completed" },
{ value: "cancelled", labelKey: "status.cancelled" }
] as const;
const tools = [
{
href: "/admin/stuck-products",
titleKey: "admin.diagnostics.toolStuckTitle",
descriptionKey: "admin.diagnostics.toolStuckDesc"
},
{
href: "/admin/orphan-processed",
titleKey: "admin.diagnostics.toolOrphanTitle",
descriptionKey: "admin.diagnostics.toolOrphanDesc"
},
{
href: "/admin/analytics",
titleKey: "admin.diagnostics.toolAnalyticsTitle",
descriptionKey: "admin.diagnostics.toolAnalyticsDesc"
},
{
href: "/admin/bootstrap",
titleKey: "admin.diagnostics.toolBootstrapTitle",
descriptionKey: "admin.diagnostics.toolBootstrapDesc"
},
{
href: "/admin/settings",
titleKey: "admin.diagnostics.toolSettingsTitle",
descriptionKey: "admin.diagnostics.toolSettingsDesc"
}
] as const;
let loading = $state(true);
let refreshing = $state(false);
let accessDenied = $state(false);
let unavailable = $state(false);
let error = $state("");
let statusFilter = $state("failed");
let data = $state<AdminDiagnostics | null>(null);
const overallVariant = $derived(checkStatusVariant(data?.status ?? "outline") as BadgeVariant);
const queueStatuses = $derived.by(() => {
const by = data?.queue?.by_status ?? {};
return Object.entries(by).sort(([a], [b]) => a.localeCompare(b));
});
const stuckCount = $derived(Number(data?.queue?.stuck_running ?? 0));
const queueTotal = $derived(Number(data?.queue?.total ?? 0));
const cutoverGooseRequired = $derived.by(() => {
const required = data?.cutover?.goose?.required ?? {};
return Object.entries(required).sort(([a], [b]) => a.localeCompare(b));
});
const cutoverPlansMissing = $derived(
data?.cutover && "companies_without_plan" in (data.cutover ?? {})
? Number(data.cutover?.companies_without_plan ?? 0)
: null
);
const cutoverApiKeysMissing = $derived(
data?.cutover && "companies_without_api_keys" in (data.cutover ?? {})
? Number(data.cutover?.companies_without_api_keys ?? 0)
: null
);
const migrationInv = $derived(data?.migration_inventory ?? null);
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
await reload();
loading = false;
});
async function reload() {
refreshing = true;
error = "";
unavailable = false;
try {
data = await loadAdminDiagnostics({
status: statusFilter,
failuresLimit: 25
});
} catch (err) {
if (isDiagnosticsRateLimited(err)) {
error = i18n.t("flash.admin.diagnosticsRateLimit");
} else if (isDiagnosticsUnavailable(err)) {
unavailable = true;
error = i18n.t("flash.admin.diagnosticsUnavailable");
} else if (isDiagnosticsUnreachable(err)) {
error = i18n.t("flash.admin.diagnosticsUnreachable");
} else {
error = failureMessage(err, i18n.t("flash.admin.diagnosticsLoadFailed"));
}
data = null;
} finally {
refreshing = false;
}
}
function checkVariant(check: DiagCheck): BadgeVariant {
return checkStatusVariant(check.status);
}
function flagLabel(value: unknown): string {
if (value === true) return i18n.t("common.yes");
if (value === false) return i18n.t("common.no");
return i18n.t("status.emDash");
}
function checkIcon(name: string) {
switch (String(name).toLowerCase()) {
case "database":
return Database;
case "queue":
return Activity;
case "storage":
return HardDrive;
case "mail":
return Mail;
case "cache":
return Server;
case "stripe":
return CreditCard;
case "cutover":
return ShieldCheck;
default:
return Wrench;
}
}
function statusTone(status: string): string {
switch (String(status).toLowerCase()) {
case "ok":
case "ready":
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
case "warn":
case "degraded":
case "warning":
return "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200";
case "fail":
case "failed":
case "error":
return "border-destructive/40 bg-destructive/10 text-destructive";
default:
return "border-border bg-muted/40 text-muted-foreground";
}
}
</script>
<PageShell
eyebrow={i18n.t("admin.diagnostics.eyebrow")}
title={i18n.t("admin.diagnostics.title")}
description={i18n.t("admin.diagnostics.description")}
>
{#snippet actions()}
<div class="flex flex-wrap items-center gap-2" role="group" aria-label={i18n.t("admin.diagnostics.filterAria")}>
{#each jobFilters as filter}
<Button
size="sm"
variant={statusFilter === filter.value ? "default" : "outline"}
aria-pressed={statusFilter === filter.value}
onclick={() => {
statusFilter = filter.value;
void reload();
}}
>
{i18n.t(filter.labelKey)}
</Button>
{/each}
</div>
<Button variant="outline" size="sm" loading={refreshing} onclick={reload}>
<RefreshCw class="mr-2 h-4 w-4" />
{i18n.t("common.refresh")}
</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<div class="space-y-8">
<Alert message={error} />
{#if !data}
<section
aria-labelledby="diagnostics-empty-heading"
class="rounded-xl border border-dashed border-border bg-card/40 px-6 py-12 text-center"
data-admin-slot="diagnostics-panels"
>
<h2 id="diagnostics-empty-heading" class="text-base font-semibold text-foreground">
{unavailable
? i18n.t("admin.diagnostics.endpointNotMounted")
: i18n.t("admin.diagnostics.noPayload")}
</h2>
<p class="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
{#if unavailable}
{i18n.t("admin.diagnostics.unavailableShort")}
{:else if error}
{i18n.t("admin.diagnostics.fixThenRefresh")}
{:else}
{i18n.t("admin.diagnostics.refreshOrConfirm")}
{/if}
</p>
</section>
{:else}
<!-- Status strip -->
<section
aria-labelledby="diagnostics-status-heading"
class="overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm"
data-admin-slot="diagnostics-panels"
>
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-muted/30 px-4 py-3 sm:px-5"
>
<div class="flex flex-wrap items-center gap-3">
<h2 id="diagnostics-status-heading" class="text-sm font-semibold tracking-wide">
{i18n.t("admin.diagnostics.systemStatus")}
</h2>
<span
class={`inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold uppercase tracking-wide ${statusTone(data.status)}`}
>
{data.status}
</span>
</div>
{#if data.generated_at}
<p class="text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.snapshot", { when: formatDate(data.generated_at) })}
</p>
{/if}
</div>
<div class="grid gap-px bg-border sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{#each data.checks ?? [] as check}
{@const Icon = checkIcon(check.name)}
<article class="bg-card p-4">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted/50 text-muted-foreground"
aria-hidden="true"
>
<Icon class="h-4 w-4" />
</span>
<div class="min-w-0">
<h3 class="truncate text-sm font-medium capitalize text-foreground">
{check.name}
</h3>
{#if check.detail}
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{check.detail}
</p>
{/if}
</div>
</div>
<Badge variant={checkVariant(check)}>{check.status}</Badge>
</div>
{#if check.latency_ms != null}
<p class="mt-3 text-[11px] tabular-nums text-muted-foreground">
{check.latency_ms} ms
</p>
{/if}
</article>
{/each}
</div>
</section>
{#if data.cutover}
<section
aria-labelledby="diagnostics-cutover-heading"
class="rounded-xl border border-border bg-card text-card-foreground shadow-sm"
data-testid="diagnostics-cutover"
>
<div class="border-b border-border px-4 py-3 sm:px-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 id="diagnostics-cutover-heading" class="text-sm font-semibold">
{i18n.t("admin.diagnostics.cutoverTitle")}
</h2>
{#if data.cutover.status}
<Badge variant={checkStatusVariant(data.cutover.status)}>{data.cutover.status}</Badge>
{/if}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.cutoverHint")}
</p>
</div>
<div class="grid gap-px bg-border sm:grid-cols-3">
<article class="bg-card p-4">
<h3 class="text-sm font-medium text-foreground">{i18n.t("admin.diagnostics.cutoverGoose")}</h3>
<p class="mt-1 text-xs text-muted-foreground">
{#if data.cutover.goose?.version_max != null}
{i18n.t("admin.diagnostics.cutoverGooseVersion", {
max: data.cutover.goose.version_max,
min: data.cutover.goose.expected_min ?? 41
})}
{:else}
{data.cutover.goose?.detail ?? i18n.t("status.emDash")}
{/if}
</p>
{#if cutoverGooseRequired.length > 0}
<ul class="mt-3 space-y-1.5" aria-label={i18n.t("admin.diagnostics.cutoverGooseRequired")}>
{#each cutoverGooseRequired as [name, applied]}
<li class="flex items-center justify-between gap-2 text-xs">
<span class="truncate font-mono text-muted-foreground">{name}</span>
<span
class={`shrink-0 rounded-md px-2 py-0.5 font-semibold ${
applied
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
: "bg-amber-500/15 text-amber-800 dark:text-amber-200"
}`}
>
{applied
? i18n.t("admin.diagnostics.cutoverApplied")
: i18n.t("admin.diagnostics.cutoverPending")}
</span>
</li>
{/each}
</ul>
{/if}
</article>
<article class="bg-card p-4">
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-medium text-foreground">{i18n.t("admin.diagnostics.cutoverWorker")}</h3>
{#if data.cutover.worker?.status}
<Badge variant={checkStatusVariant(data.cutover.worker.status)}>
{data.cutover.worker.status}
</Badge>
{/if}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{#if data.cutover.worker?.last_seen_age_s != null}
{i18n.t("admin.diagnostics.cutoverWorkerAge", {
age: data.cutover.worker.last_seen_age_s,
stale: data.cutover.worker.stale_after_s ?? 60
})}
{:else}
{i18n.t("admin.diagnostics.cutoverWorkerAgeUnknown", {
stale: data.cutover.worker?.stale_after_s ?? 60
})}
{/if}
</p>
</article>
<article class="bg-card p-4">
<h3 class="text-sm font-medium text-foreground">
{i18n.t("admin.diagnostics.cutoverPlansMissing")}
</h3>
{#if cutoverPlansMissing != null}
<p class="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{formatCredits(cutoverPlansMissing)}
</p>
{:else}
<p class="mt-2 text-sm text-muted-foreground">
{i18n.t("admin.diagnostics.cutoverPlansUnavailable")}
</p>
{/if}
</article>
<article class="bg-card p-4" data-testid="diagnostics-cutover-api-keys">
<h3 class="text-sm font-medium text-foreground">
{i18n.t("admin.diagnostics.cutoverApiKeysMissing")}
</h3>
{#if cutoverApiKeysMissing != null}
<p class="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{formatCredits(cutoverApiKeysMissing)}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.cutoverApiKeysHint")}
</p>
<a
class="mt-2 inline-block text-sm underline-offset-2 hover:underline"
href="/admin/users?tab=companies&without_api_keys=1"
>
{i18n.t("admin.diagnostics.cutoverApiKeysLink")}
</a>
{:else}
<p class="mt-2 text-sm text-muted-foreground">
{i18n.t("admin.diagnostics.cutoverApiKeysUnavailable")}
</p>
{/if}
</article>
</div>
</section>
{/if}
{#if migrationInv}
<section
aria-labelledby="diagnostics-etl-inventory-heading"
class="rounded-xl border border-border bg-card text-card-foreground shadow-sm"
data-testid="diagnostics-migration-inventory"
>
<div class="border-b border-border px-4 py-3 sm:px-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 id="diagnostics-etl-inventory-heading" class="text-sm font-semibold">
{i18n.t("admin.diagnostics.etlInventoryTitle")}
</h2>
{#if migrationInv.status}
<Badge variant={checkStatusVariant(migrationInv.status)}>{migrationInv.status}</Badge>
{/if}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.etlInventoryHint")}
</p>
</div>
<div class="grid gap-px bg-border sm:grid-cols-2 lg:grid-cols-3">
<article class="bg-card p-4">
<h3 class="text-sm font-medium text-foreground">
{i18n.t("admin.diagnostics.etlInventoryFiles")}
</h3>
<p class="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{formatCredits(Number(migrationInv.files_metadata_only ?? 0))}
<span class="text-sm font-normal text-muted-foreground">
/ {formatCredits(Number(migrationInv.files_total ?? 0))}
</span>
</p>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.etlInventoryFilesDetail")}
</p>
</article>
<article class="bg-card p-4">
<h3 class="text-sm font-medium text-foreground">
{i18n.t("admin.diagnostics.etlInventoryJobs")}
</h3>
<p class="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{formatCredits(Number(migrationInv.processing_jobs_migrated ?? 0))}
<span class="text-sm font-normal text-muted-foreground">
/ {formatCredits(Number(migrationInv.processing_jobs_total ?? 0))}
</span>
</p>
<p class="mt-1 text-xs text-muted-foreground">
{migrationInv.jobs_domain_ran
? i18n.t("admin.diagnostics.etlInventoryJobsRan")
: i18n.t("admin.diagnostics.etlInventoryJobsDefault")}
</p>
</article>
<article class="bg-card p-4">
<h3 class="text-sm font-medium text-foreground">
{i18n.t("admin.diagnostics.etlInventoryTasks")}
</h3>
<p class="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{formatCredits(Number(migrationInv.tasks_total ?? 0))}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.etlInventoryTasksDetail")}
</p>
</article>
</div>
</section>
{/if}
<div class="grid gap-6 lg:grid-cols-5">
<!-- Queue -->
<section
aria-labelledby="diagnostics-queue-heading"
class="lg:col-span-2 rounded-xl border border-border bg-card text-card-foreground shadow-sm"
>
<div class="border-b border-border px-4 py-3 sm:px-5">
<div class="flex items-center justify-between gap-2">
<h2 id="diagnostics-queue-heading" class="text-sm font-semibold">{i18n.t("admin.diagnostics.queue")}</h2>
<span class="text-xs tabular-nums text-muted-foreground">
{i18n.t("admin.diagnostics.queueTotal", { total: formatCredits(queueTotal) })}
</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.queueStuck")}
<span
class={`ml-1 font-medium tabular-nums ${stuckCount > 0 ? "text-chart-amber" : "text-foreground"}`}
>
{formatCredits(stuckCount)}
</span>
</p>
</div>
<div class="p-4 sm:p-5">
{#if queueStatuses.length === 0}
<p class="text-sm text-muted-foreground">{i18n.t("admin.diagnostics.noJobsInQueue")}</p>
{:else}
<ul class="space-y-2">
{#each queueStatuses as [status, count]}
<li
class="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-sm"
>
<span class="capitalize text-muted-foreground">{status}</span>
<span class="tabular-nums font-semibold text-foreground"
>{formatCredits(Number(count))}</span
>
</li>
{/each}
</ul>
{/if}
<p class="mt-4 text-[11px] text-muted-foreground">
{i18n.t("admin.diagnostics.driver", {
driver: data.queue?.driver ?? "postgres_processing_jobs"
})}
</p>
<a
href="/admin/stuck-products"
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{i18n.t("admin.diagnostics.openStuck")}
<ArrowRight class="h-3.5 w-3.5" />
</a>
</div>
</section>
<!-- Config flags -->
<section
aria-labelledby="diagnostics-config-heading"
class="lg:col-span-3 rounded-xl border border-border bg-card text-card-foreground shadow-sm"
>
<div class="border-b border-border px-4 py-3 sm:px-5">
<h2 id="diagnostics-config-heading" class="text-sm font-semibold">
{i18n.t("admin.diagnostics.configPresence")}
</h2>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.configHint", {
env: data.config?.app_env ?? i18n.t("status.emDash"),
rpm: data.config?.processing_rpm ?? i18n.t("status.emDash"),
batch: data.config?.processing_batch_size ?? i18n.t("status.emDash"),
retries: data.config?.processing_max_retries ?? i18n.t("status.emDash")
})}
</p>
</div>
<ul class="grid gap-2 p-4 sm:grid-cols-2 sm:p-5">
{#each CONFIG_FLAG_LABELS as row}
<li
class="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-sm"
>
<span class="text-muted-foreground">{i18n.t(row.labelKey)}</span>
<span
class={`shrink-0 rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums ${
data.config?.[row.key] === true
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
: data.config?.[row.key] === false
? "bg-muted text-muted-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{flagLabel(data.config?.[row.key])}
</span>
</li>
{/each}
</ul>
</section>
</div>
<!-- Jobs -->
<section
aria-labelledby="diagnostics-jobs-heading"
class="rounded-xl border border-border bg-card text-card-foreground shadow-sm"
>
<div class="border-b border-border px-4 py-3 sm:px-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 id="diagnostics-jobs-heading" class="text-sm font-semibold">
{i18n.t("admin.diagnostics.recentJobs")}
<span class="ml-1 font-normal text-muted-foreground">({statusFilter})</span>
</h2>
<p class="text-xs text-muted-foreground">{i18n.t("admin.diagnostics.sanitizedErrors")}</p>
</div>
</div>
<div class="p-2 sm:p-4">
{#if (data.recent_failures ?? []).length === 0}
<EmptyState message={i18n.t("empty.admin.noJobsFilter")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.diagnostics.colId")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colStatus")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colProgress")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colUpdated")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colError")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each data.recent_failures as job}
<TableRow>
<TableCell class="font-mono text-xs text-muted-foreground"
>{job.id}</TableCell
>
<TableCell class="font-mono text-xs"
>{job.company_id || i18n.t("status.emDash")}</TableCell
>
<TableCell>
<Badge variant={checkStatusVariant(job.status)}>{job.status}</Badge>
</TableCell>
<TableCell class="tabular-nums">
{formatCredits(Number(job.processed_products ?? 0))}/{formatCredits(
Number(job.total_products ?? 0)
)}
</TableCell>
<TableCell>{formatDate(job.updated_at || job.created_at)}</TableCell>
<TableCell class="max-w-xs truncate text-sm text-destructive">
{job.error || i18n.t("status.emDash")}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</div>
</section>
{#if (data.recent_ai_failures ?? []).length > 0}
<section
aria-labelledby="diagnostics-ai-heading"
class="rounded-xl border border-border bg-card text-card-foreground shadow-sm"
>
<div class="border-b border-border px-4 py-3 sm:px-5">
<h2 id="diagnostics-ai-heading" class="text-sm font-semibold">
{i18n.t("admin.diagnostics.recentAiFailures")}
</h2>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.aiFailuresHint")}
</p>
</div>
<div class="p-2 sm:p-4">
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.diagnostics.colTicket")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colWhen")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each data.recent_ai_failures ?? [] as row}
<TableRow>
<TableCell class="font-mono text-xs">{row.ticket_id}</TableCell>
<TableCell class="font-mono text-xs">{row.company_id}</TableCell>
<TableCell>{formatDate(row.created_at)}</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
</div>
</section>
{/if}
{#if (data.notes ?? []).length > 0}
<ul class="list-disc space-y-1 pl-5 text-xs text-muted-foreground">
{#each data.notes ?? [] as note}
<li>{note}</li>
{/each}
</ul>
{/if}
{/if}
<section aria-labelledby="diagnostics-tools-heading" class="space-y-3">
<h2 id="diagnostics-tools-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.diagnostics.relatedTools")}
</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{#each tools as tool}
<a
href={tool.href}
class="group flex flex-col rounded-xl border border-border bg-card p-4 text-card-foreground transition hover:border-primary/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span class="text-sm font-semibold group-hover:text-primary"
>{i18n.t(tool.titleKey)}</span
>
<span class="mt-1 flex-1 text-xs text-muted-foreground"
>{i18n.t(tool.descriptionKey)}</span
>
<span
class="mt-3 inline-flex items-center gap-1 text-xs font-medium text-muted-foreground group-hover:text-primary"
>
{i18n.t("common.open")}
<ArrowRight class="h-3 w-3" />
</span>
</a>
{/each}
</div>
</section>
</div>
{/if}
</PageShell>
@@ -0,0 +1,12 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { i18n } from "$lib/i18n";
// Prefer Diagnostics (health + queue + failures). Keep this URL as a redirect.
onMount(() => {
void goto("/admin/diagnostics", { replaceState: true });
});
</script>
<p class="p-6 text-sm text-muted-foreground">{i18n.t("admin.redirect.diagnostics")}</p>
@@ -0,0 +1,71 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { requirePlatformAdmin } from "$lib/admin-gate";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "$lib/components/ui";
import { i18n } from "$lib/i18n";
let loading = $state(true);
let accessDenied = $state(false);
let error = $state("");
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
}
loading = false;
});
</script>
<PageShell
title={i18n.t("admin.migrate.title")}
description={i18n.t("admin.migrate.description")}
>
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<div class="space-y-4">
<Alert message={error} />
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.migrate.notAvailable")}</CardTitle>
<CardDescription>
{i18n.t("admin.migrate.body")}
</CardDescription>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
<p>
{i18n.t("admin.migrate.hint")}
</p>
</CardContent>
<CardFooter class="flex flex-wrap gap-2">
<a href="/admin"><Button variant="outline" size="sm">{i18n.t("admin.migrate.adminHome")}</Button></a>
<a href="/admin/users"><Button variant="outline" size="sm">{i18n.t("admin.migrate.users")}</Button></a>
</CardFooter>
</Card>
</div>
{/if}
</PageShell>
@@ -0,0 +1,273 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import {
ORPHAN_CLEANUP_API,
ORPHAN_REPORT_API,
canConfirmOrphanDelete,
normalizeOrphanReport,
orphanCleanupBody,
orphanReasonLabelKey,
parseOrphanCleanupResponse,
type OrphanProcessedReport
} from "$lib/admin-orphan-processed";
import { formatCredits } from "$lib/utils";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import ConfirmationDialog from "$lib/components/categories/formula/ConfirmationDialog.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell
} from "$lib/components/ui";
import { RefreshCw } from "@lucide/svelte";
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let busy = $state(false);
let error = $state("");
let success = $state("");
let report = $state<OrphanProcessedReport | null>(null);
let confirmOpen = $state(false);
const canDelete = $derived(canConfirmOrphanDelete({ report, busy: busy || refreshing }));
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
await reloadReport();
loading = false;
});
async function reloadReport() {
refreshing = true;
error = "";
try {
const data = await api<unknown>(ORPHAN_REPORT_API);
report = normalizeOrphanReport(data);
} catch (err) {
error = failureMessage(err, i18n.t("admin.orphan.loadFailed"));
report = null;
} finally {
refreshing = false;
}
}
async function runDryRunCleanup() {
busy = true;
error = "";
success = "";
try {
const data = await api<unknown>(ORPHAN_CLEANUP_API, {
method: "POST",
body: orphanCleanupBody(false)
});
const out = parseOrphanCleanupResponse(data);
report = out.report;
success = i18n.t("flash.admin.orphanDryRun", { total: out.report.total });
} catch (err) {
error = failureMessage(err, i18n.t("admin.orphan.dryRunFailed"));
} finally {
busy = false;
}
}
function openConfirm() {
if (!canDelete) return;
confirmOpen = true;
}
async function confirmDelete() {
confirmOpen = false;
if (!canConfirmOrphanDelete({ report, busy: false })) return;
busy = true;
error = "";
success = "";
try {
const data = await api<unknown>(ORPHAN_CLEANUP_API, {
method: "POST",
body: orphanCleanupBody(true)
});
const out = parseOrphanCleanupResponse(data);
report = out.report;
success = i18n.t("flash.admin.orphanDeleted", {
deleted: out.deleted,
remaining: out.report.total
});
} catch (err) {
error = failureMessage(err, i18n.t("admin.orphan.deleteFailed"));
} finally {
busy = false;
}
}
</script>
<PageShell
title={i18n.t("admin.orphan.title")}
description={i18n.t("admin.orphan.description")}
>
{#snippet actions()}
<Button variant="outline" size="sm" loading={refreshing} disabled={busy} onclick={reloadReport}>
<RefreshCw class="mr-2 h-4 w-4" />
{i18n.t("admin.orphan.refreshReport")}
</Button>
<Button
variant="secondary"
size="sm"
loading={busy}
disabled={refreshing}
onclick={runDryRunCleanup}
>
{i18n.t("admin.orphan.dryRunBtn")}
</Button>
<Button
variant="destructive"
size="sm"
disabled={!canDelete}
loading={busy}
onclick={openConfirm}
>
{i18n.t("admin.orphan.deleteBtn")}
</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Alert message={error} />
<Alert tone="success" message={success} />
<p class="mb-4 text-sm text-muted-foreground">{i18n.t("admin.orphan.cutoverHint")}</p>
<div class="mb-4 grid gap-3 sm:grid-cols-3">
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("admin.orphan.metric.total")}</CardDescription>
<CardTitle class="tabular-nums text-2xl">
{formatCredits(Number(report?.total ?? 0))}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("admin.orphan.metric.missingRaw")}</CardDescription>
<CardTitle class="tabular-nums text-2xl">
{formatCredits(Number(report?.missing_raw ?? 0))}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("admin.orphan.metric.unprocessedRaw")}</CardDescription>
<CardTitle class="tabular-nums text-2xl">
{formatCredits(Number(report?.unprocessed_raw ?? 0))}
</CardTitle>
</CardHeader>
</Card>
</div>
<Card>
<CardHeader>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>{i18n.t("admin.orphan.samplesTitle")}</CardTitle>
<CardDescription>{i18n.t("admin.orphan.samplesDesc")}</CardDescription>
</div>
{#if report && report.total === 0}
<Badge variant="success">{i18n.t("admin.orphan.cleanBadge")}</Badge>
{:else if report && report.total > 0}
<Badge variant="warning">{i18n.t("admin.orphan.needsCleanupBadge")}</Badge>
{/if}
</div>
</CardHeader>
<CardContent>
{#if !report || report.samples.length === 0}
<EmptyState
message={report && report.total === 0
? i18n.t("admin.orphan.noneFound")
: i18n.t("admin.orphan.noSamples")}
/>
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.orphan.colProcessed")}</TableHead>
<TableHead>{i18n.t("admin.orphan.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.orphan.colRaw")}</TableHead>
<TableHead>{i18n.t("admin.orphan.colReason")}</TableHead>
<TableHead>{i18n.t("admin.orphan.colRawStatus")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each report.samples as sample}
<TableRow>
<TableCell class="font-mono text-xs text-muted-foreground">
{sample.processed_id || "—"}
</TableCell>
<TableCell class="font-mono text-xs">{sample.company_id || "—"}</TableCell>
<TableCell class="font-mono text-xs">
{sample.raw_product_id || "—"}
</TableCell>
<TableCell>
<Badge variant="outline">{i18n.t(orphanReasonLabelKey(sample.reason))}</Badge>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{sample.raw_processing_status ||
(sample.raw_is_processed == null
? "—"
: sample.raw_is_processed
? i18n.t("admin.orphan.rawProcessedYes")
: i18n.t("admin.orphan.rawProcessedNo"))}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
{/if}
</PageShell>
<ConfirmationDialog
bind:open={confirmOpen}
title={i18n.t("admin.orphan.confirmTitle")}
description={i18n.t("admin.orphan.confirmDesc", { total: report?.total ?? 0 })}
confirmLabel={i18n.t("admin.orphan.confirmDelete")}
confirmAriaLabel={i18n.t("admin.orphan.confirmDelete")}
onClose={() => {
confirmOpen = false;
}}
onConfirm={() => void confirmDelete()}
/>
@@ -0,0 +1,187 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatDateTime } from "$lib/utils";
import { listAdminSalesLeads, type SalesLead } from "$lib/sales-contact";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
type BadgeVariant
} from "$lib/components/ui";
import { RefreshCw, Search } from "@lucide/svelte";
const STATUS_FILTERS = $derived([
{ value: "all", label: i18n.t("admin.sales.statusAll") },
{ value: "new", label: i18n.t("admin.sales.status.new") },
{ value: "contacted", label: i18n.t("admin.sales.status.contacted") },
{ value: "quoted", label: i18n.t("admin.sales.status.quoted") },
{ value: "won", label: i18n.t("admin.sales.status.won") },
{ value: "closed", label: i18n.t("admin.sales.status.closed") }
] as const);
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let error = $state("");
let leads = $state<SalesLead[]>([]);
let total = $state(0);
let search = $state("");
let statusFilter = $state("new");
function statusVariant(status: string): BadgeVariant {
switch (status) {
case "new":
return "default";
case "quoted":
return "secondary";
case "won":
return "success";
case "closed":
return "outline";
default:
return "secondary";
}
}
async function load(opts?: { quiet?: boolean }) {
if (!opts?.quiet) loading = true;
else refreshing = true;
error = "";
try {
const res = await listAdminSalesLeads({
status: statusFilter,
q: search.trim() || undefined,
limit: 50
});
leads = res.leads ?? [];
total = res.total ?? 0;
} catch (err) {
error = failureMessage(err, i18n.t("admin.sales.loadError"));
} finally {
loading = false;
refreshing = false;
}
}
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
accessDenied = true;
loading = false;
return;
}
await load();
});
</script>
{#if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<PageShell title={i18n.t("admin.sales.title")} description={i18n.t("admin.sales.description")}>
{#if error}
<Alert message={error} />
{/if}
<Card>
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<CardTitle>{i18n.t("admin.sales.listTitle")}</CardTitle>
<CardDescription>{i18n.t("admin.sales.listDesc", { total })}</CardDescription>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
class="pl-8 w-56"
placeholder={i18n.t("admin.sales.searchPlaceholder")}
bind:value={search}
onkeydown={(e) => {
if (e.key === "Enter") void load({ quiet: true });
}}
/>
</div>
<select
class="h-10 rounded-md border border-border bg-background px-3 text-sm"
bind:value={statusFilter}
onchange={() => void load({ quiet: true })}
>
{#each STATUS_FILTERS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<Button
variant="outline"
size="sm"
disabled={refreshing}
onclick={() => void load({ quiet: true })}
>
<RefreshCw class={`mr-1 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
{i18n.t("common.refresh")}
</Button>
</div>
</CardHeader>
<CardContent>
{#if loading}
<div class="flex justify-center py-12"><Spinner /></div>
{:else if leads.length === 0}
<EmptyState title={i18n.t("admin.sales.empty")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.sales.col.name")}</TableHead>
<TableHead>{i18n.t("admin.sales.col.email")}</TableHead>
<TableHead>{i18n.t("admin.sales.col.company")}</TableHead>
<TableHead>{i18n.t("admin.sales.col.status")}</TableHead>
<TableHead>{i18n.t("admin.sales.col.created")}</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each leads as lead}
<TableRow>
<TableCell class="font-medium">{lead.name}</TableCell>
<TableCell>{lead.email}</TableCell>
<TableCell>{lead.company_name || "—"}</TableCell>
<TableCell>
<Badge variant={statusVariant(lead.status)}>{lead.status}</Badge>
</TableCell>
<TableCell class="text-muted-foreground">{formatDateTime(lead.created_at)}</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onclick={() => goto(`/admin/sales/${lead.id}`)}
>
{i18n.t("common.open")}
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
</PageShell>
{/if}
@@ -0,0 +1,351 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { page } from "$app/state";
import { failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatDateTime } from "$lib/utils";
import { fetchAdminCompaniesList, type AdminBillingCompany } from "$lib/admin-billing-plans";
import {
createAdminSalesQuote,
getAdminSalesLead,
markAdminSalesQuoteSent,
prepareAdminSalesQuoteCheckout,
updateAdminSalesLead,
type SalesLead,
type SalesQuote
} from "$lib/sales-contact";
import { notifySuccess, notifyApiError } from "$lib/notify";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select,
Textarea
} from "$lib/components/ui";
let loading = $state(true);
let accessDenied = $state(false);
let error = $state("");
let lead = $state<SalesLead | null>(null);
let quotes = $state<SalesQuote[]>([]);
let companies = $state<AdminBillingCompany[]>([]);
let saving = $state(false);
let quoteBusy = $state(false);
let status = $state("new");
let companyId = $state("");
let adminNotes = $state("");
let planName = $state("");
let monthlyCredits = $state("5000");
let maxProducts = $state("");
let totalUsd = $state("1200");
let installmentCount = $state("1");
let installmentInterval = $state("month");
let termMonths = $state("12");
let prepareCheckout = $state(true);
const leadId = $derived(page.params.id ?? "");
async function load() {
loading = true;
error = "";
try {
const [detail, companyList] = await Promise.all([
getAdminSalesLead(leadId),
fetchAdminCompaniesList()
]);
lead = detail.lead;
quotes = detail.quotes ?? [];
companies = companyList;
status = detail.lead.status;
companyId = detail.lead.company_id ?? "";
adminNotes = detail.lead.admin_notes ?? "";
if (!planName && detail.lead.company_name) {
planName = `${detail.lead.company_name} Deal`;
}
} catch (err) {
error = failureMessage(err, i18n.t("admin.sales.loadError"));
} finally {
loading = false;
}
}
async function saveLead() {
if (!lead) return;
saving = true;
try {
lead = await updateAdminSalesLead(lead.id, {
status,
company_id: companyId || undefined,
clear_company: !companyId,
admin_notes: adminNotes
});
notifySuccess(i18n.t("admin.sales.saved"));
} catch (err) {
notifyApiError(err, i18n.t("admin.sales.saveError"));
} finally {
saving = false;
}
}
async function createQuote() {
if (!lead || !companyId) {
error = i18n.t("admin.sales.companyRequired");
return;
}
quoteBusy = true;
error = "";
try {
const totalCents = Math.round(Number.parseFloat(totalUsd) * 100);
const count = Number.parseInt(installmentCount, 10) || 1;
const credits = Number.parseInt(monthlyCredits, 10) || 0;
const maxP = maxProducts.trim() ? Number.parseInt(maxProducts, 10) : null;
const term = termMonths.trim() ? Number.parseInt(termMonths, 10) : null;
const quote = await createAdminSalesQuote(lead.id, {
company_id: companyId,
plan_name: planName.trim() || "Custom Deal",
monthly_credits: credits,
max_products: maxP != null && Number.isFinite(maxP) ? maxP : null,
currency: "usd",
total_amount_cents: totalCents,
installment_count: count,
installment_interval: installmentInterval,
term_months: term != null && Number.isFinite(term) ? term : null,
prepare_checkout: prepareCheckout
});
quotes = [quote, ...quotes];
notifySuccess(i18n.t("admin.sales.quoteCreated"));
await load();
} catch (err) {
error = failureMessage(err, i18n.t("admin.sales.quoteError"));
notifyApiError(err, i18n.t("admin.sales.quoteError"));
} finally {
quoteBusy = false;
}
}
async function prepareCheckoutFor(quote: SalesQuote) {
quoteBusy = true;
try {
const updated = await prepareAdminSalesQuoteCheckout(quote.id);
quotes = quotes.map((q) => (q.id === updated.id ? updated : q));
notifySuccess(i18n.t("admin.sales.checkoutReady"));
} catch (err) {
notifyApiError(err, i18n.t("admin.sales.checkoutError"));
} finally {
quoteBusy = false;
}
}
async function markSent(quote: SalesQuote) {
try {
const updated = await markAdminSalesQuoteSent(quote.id);
quotes = quotes.map((q) => (q.id === updated.id ? updated : q));
notifySuccess(i18n.t("admin.sales.markedSent"));
} catch (err) {
notifyApiError(err, i18n.t("admin.sales.saveError"));
}
}
function money(cents: number, currency: string): string {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: (currency || "usd").toUpperCase()
}).format(cents / 100);
}
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
accessDenied = true;
loading = false;
return;
}
await load();
});
</script>
{#if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else if loading}
<div class="flex justify-center py-24"><Spinner /></div>
{:else if !lead}
<PageShell title={i18n.t("admin.sales.title")}>
<Alert message={error || i18n.t("admin.sales.notFound")} />
</PageShell>
{:else}
<PageShell title={lead.name} description={lead.email}>
{#if error}
<Alert message={error} />
{/if}
<div class="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.sales.leadTitle")}</CardTitle>
<CardDescription>{formatDateTime(lead.created_at)} · {lead.source}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<p class="whitespace-pre-wrap text-sm text-text">{lead.message}</p>
{#if lead.estimated_skus != null}
<p class="text-sm text-muted-foreground">
{i18n.t("admin.sales.estimatedSkus")}: {lead.estimated_skus.toLocaleString()}
</p>
{/if}
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<Label for="lead-status">{i18n.t("admin.sales.col.status")}</Label>
<Select id="lead-status" bind:value={status}>
<option value="new">{i18n.t("admin.sales.status.new")}</option>
<option value="contacted">{i18n.t("admin.sales.status.contacted")}</option>
<option value="quoted">{i18n.t("admin.sales.status.quoted")}</option>
<option value="won">{i18n.t("admin.sales.status.won")}</option>
<option value="closed">{i18n.t("admin.sales.status.closed")}</option>
</Select>
</div>
<div class="space-y-2">
<Label for="lead-company">{i18n.t("admin.sales.linkCompany")}</Label>
<Select id="lead-company" bind:value={companyId}>
<option value="">{i18n.t("admin.sales.noCompany")}</option>
{#each companies as c}
<option value={c.id}>{c.name}</option>
{/each}
</Select>
</div>
</div>
<div class="space-y-2">
<Label for="lead-notes">{i18n.t("admin.sales.adminNotes")}</Label>
<Textarea id="lead-notes" bind:value={adminNotes} rows={4} />
</div>
<Button disabled={saving} onclick={() => void saveLead()}>
{saving ? i18n.t("common.saving") : i18n.t("common.save")}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.sales.prepareQuote")}</CardTitle>
<CardDescription>{i18n.t("admin.sales.prepareQuoteDesc")}</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<div class="space-y-2">
<Label for="q-plan">{i18n.t("admin.sales.planName")}</Label>
<Input id="q-plan" bind:value={planName} maxlength={120} />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<Label for="q-credits">{i18n.t("admin.sales.monthlyCredits")}</Label>
<Input id="q-credits" bind:value={monthlyCredits} inputmode="numeric" />
</div>
<div class="space-y-2">
<Label for="q-skus">{i18n.t("admin.sales.maxProducts")}</Label>
<Input id="q-skus" bind:value={maxProducts} inputmode="numeric" placeholder={i18n.t("admin.sales.unlimitedHint")} />
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<Label for="q-total">{i18n.t("admin.sales.totalUsd")}</Label>
<Input id="q-total" bind:value={totalUsd} inputmode="decimal" />
</div>
<div class="space-y-2">
<Label for="q-term">{i18n.t("admin.sales.termMonths")}</Label>
<Input id="q-term" bind:value={termMonths} inputmode="numeric" />
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<Label for="q-count">{i18n.t("admin.sales.installmentCount")}</Label>
<Input id="q-count" bind:value={installmentCount} inputmode="numeric" />
</div>
<div class="space-y-2">
<Label for="q-interval">{i18n.t("admin.sales.installmentInterval")}</Label>
<Select id="q-interval" bind:value={installmentInterval}>
<option value="month">{i18n.t("admin.sales.interval.month")}</option>
<option value="quarter">{i18n.t("admin.sales.interval.quarter")}</option>
<option value="year">{i18n.t("admin.sales.interval.year")}</option>
</Select>
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={prepareCheckout} />
{i18n.t("admin.sales.prepareCheckoutNow")}
</label>
<Button disabled={quoteBusy || !companyId} onclick={() => void createQuote()}>
{quoteBusy ? i18n.t("common.saving") : i18n.t("admin.sales.createQuote")}
</Button>
</CardContent>
</Card>
</div>
<Card class="mt-6">
<CardHeader>
<CardTitle>{i18n.t("admin.sales.quotesTitle")}</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
{#if quotes.length === 0}
<p class="text-sm text-muted-foreground">{i18n.t("admin.sales.noQuotes")}</p>
{:else}
{#each quotes as quote}
<div class="rounded-lg border border-border p-4 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{quote.plan_name}</span>
<Badge>{quote.status}</Badge>
<span class="text-sm text-muted-foreground">
{money(quote.total_amount_cents, quote.currency)}
· {quote.installment_count}× {money(quote.installment_amount_cents, quote.currency)}
/ {quote.installment_interval}
</span>
</div>
<p class="text-sm text-muted-foreground">
{i18n.t("admin.sales.monthlyCredits")}: {quote.monthly_credits.toLocaleString()}
{#if quote.max_products != null}
· SKUs {quote.max_products.toLocaleString()}
{/if}
</p>
{#if quote.checkout_url}
<a
href={quote.checkout_url}
target="_blank"
rel="noopener noreferrer"
class="text-sm font-medium text-link underline-offset-4 hover:underline break-all"
>
{quote.checkout_url}
</a>
{/if}
<div class="flex flex-wrap gap-2 pt-1">
{#if quote.status === "draft" || (!quote.checkout_url && quote.status !== "paid" && quote.status !== "canceled")}
<Button
size="sm"
variant="outline"
disabled={quoteBusy}
onclick={() => void prepareCheckoutFor(quote)}
>
{i18n.t("admin.sales.prepareCheckout")}
</Button>
{/if}
{#if quote.checkout_url && quote.status !== "paid"}
<Button size="sm" variant="outline" onclick={() => void markSent(quote)}>
{i18n.t("admin.sales.markSent")}
</Button>
{/if}
</div>
</div>
{/each}
{/if}
</CardContent>
</Card>
</PageShell>
{/if}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import {
STORE_RECONNECT_NEEDED_API,
normalizeStoreReconnectInventory,
storeReconnectChannelLabelKey,
storeReconnectReasonLabelKey,
type AdminStoreReconnectInventory
} from "$lib/admin-store-reconnect";
import { formatCredits } from "$lib/utils";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell
} from "$lib/components/ui";
import { RefreshCw } from "@lucide/svelte";
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let error = $state("");
let inventory = $state<AdminStoreReconnectInventory | null>(null);
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
await reload();
loading = false;
});
async function reload() {
refreshing = true;
error = "";
try {
const params = new URLSearchParams();
params.set("limit", "100");
params.set("offset", "0");
const data = await api<unknown>(`${STORE_RECONNECT_NEEDED_API}?${params}`);
inventory = normalizeStoreReconnectInventory(data);
} catch (err) {
error = failureMessage(err, i18n.t("admin.storeReconnect.loadFailed"));
inventory = null;
} finally {
refreshing = false;
}
}
</script>
<PageShell
title={i18n.t("admin.storeReconnect.title")}
description={i18n.t("admin.storeReconnect.description")}
>
{#snippet actions()}
<Button variant="outline" size="sm" loading={refreshing} onclick={reload}>
<RefreshCw class="mr-2 h-4 w-4" />
{i18n.t("admin.storeReconnect.refresh")}
</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Alert message={error} />
<p class="mb-4 text-sm text-muted-foreground">{i18n.t("admin.storeReconnect.cutoverHint")}</p>
<div class="mb-4 grid gap-3 sm:grid-cols-2">
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("admin.storeReconnect.metric.total")}</CardDescription>
<CardTitle class="tabular-nums text-2xl">
{formatCredits(Number(inventory?.total ?? 0))}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("admin.storeReconnect.metric.showing")}</CardDescription>
<CardTitle class="tabular-nums text-2xl">
{formatCredits(Number(inventory?.stores.length ?? 0))}
</CardTitle>
</CardHeader>
</Card>
</div>
<Card>
<CardHeader>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>{i18n.t("admin.storeReconnect.listTitle")}</CardTitle>
<CardDescription>{i18n.t("admin.storeReconnect.listDesc")}</CardDescription>
</div>
{#if inventory && inventory.total === 0}
<Badge variant="success">{i18n.t("admin.storeReconnect.cleanBadge")}</Badge>
{:else if inventory && inventory.total > 0}
<Badge variant="warning">{i18n.t("admin.storeReconnect.needsBadge")}</Badge>
{/if}
</div>
</CardHeader>
<CardContent>
{#if !inventory || inventory.stores.length === 0}
<EmptyState
message={inventory && inventory.total === 0
? i18n.t("admin.storeReconnect.noneFound")
: i18n.t("admin.storeReconnect.noRows")}
/>
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.storeReconnect.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.storeReconnect.colChannel")}</TableHead>
<TableHead>{i18n.t("admin.storeReconnect.colIdentity")}</TableHead>
<TableHead>{i18n.t("admin.storeReconnect.colReason")}</TableHead>
<TableHead>{i18n.t("admin.storeReconnect.colEnabled")}</TableHead>
<TableHead>{i18n.t("admin.storeReconnect.colLastTest")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each inventory.stores as row (row.company_id + row.channel)}
<TableRow data-testid="admin-store-reconnect-row">
<TableCell>
<div class="font-medium">{row.company_name || "—"}</div>
<div class="font-mono text-xs text-muted-foreground">
{row.company_id || "—"}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{i18n.t(storeReconnectChannelLabelKey(row.channel))}</Badge>
</TableCell>
<TableCell class="font-mono text-xs">{row.identity || "—"}</TableCell>
<TableCell>
<Badge variant="warning">{i18n.t(storeReconnectReasonLabelKey(row.reason))}</Badge>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{row.is_enabled
? i18n.t("admin.storeReconnect.enabledYes")
: i18n.t("admin.storeReconnect.enabledNo")}
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{row.last_test_status || "—"}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
{/if}
</PageShell>
@@ -0,0 +1,215 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatCredits, formatDate } from "$lib/utils";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
type BadgeVariant
} from "$lib/components/ui";
import { RefreshCw } from "@lucide/svelte";
type Job = {
id: string;
company_id?: string;
status?: string;
total_products?: number;
processed_products?: number;
error?: string | null;
created_at?: string;
updated_at?: string;
};
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let busy = $state(false);
let error = $state("");
let success = $state("");
let filter = $state("all");
let jobs = $state<Job[]>([]);
const stuckJobs = $derived.by(() => {
if (filter === "all") return jobs;
return jobs.filter((j) => String(j.status ?? "").toLowerCase() === filter);
});
const runningCount = $derived(
jobs.filter((j) => String(j.status ?? "").toLowerCase() === "running").length
);
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
await reload();
loading = false;
});
async function reload() {
refreshing = true;
error = "";
try {
const res = await api<{ jobs: Job[] }>("/api/admin/jobs?limit=100");
jobs = res.jobs ?? [];
} catch (err) {
error = failureMessage(err, i18n.t("admin.stuck.loadFailed"));
jobs = [];
} finally {
refreshing = false;
}
}
async function stuckCleanup() {
busy = true;
error = "";
success = "";
try {
const res = await api<{ jobs_marked_failed: number; products_reset: number }>(
"/api/admin/jobs/stuck-cleanup",
{ method: "POST", body: {} }
);
success = i18n.t("flash.admin.cleanup", { jobs: res.jobs_marked_failed, products: res.products_reset });
await reload();
} catch (err) {
error = failureMessage(err, i18n.t("admin.stuck.cleanupFailed"));
} finally {
busy = false;
}
}
function jobStatusVariant(status: string): BadgeVariant {
switch (status.toLowerCase()) {
case "completed":
return "success";
case "failed":
return "destructive";
case "running":
return "warning";
case "pending":
return "outline";
case "cancelled":
return "secondary";
default:
return "default";
}
}
</script>
<PageShell
title={i18n.t("admin.stuck.title")}
description={i18n.t("admin.stuck.description")}
>
{#snippet actions()}
<Button variant="outline" size="sm" loading={refreshing} onclick={reload}>
<RefreshCw class="mr-2 h-4 w-4" />
{i18n.t("common.refresh")}
</Button>
<Button variant="destructive" size="sm" loading={busy} onclick={stuckCleanup}>
{i18n.t("admin.stuck.cleanupBtn")}
</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Alert message={error} />
<Alert tone="success" message={success} />
<Card>
<CardHeader>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>{i18n.t("admin.stuck.jobsTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.stuck.jobsDesc", { running: runningCount })}
</CardDescription>
</div>
<select
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={i18n.t("admin.stuck.filterAria")}
bind:value={filter}
>
<option value="all">{i18n.t("admin.stuck.filterAll")}</option>
<option value="running">{i18n.t("admin.stuck.filterRunning")}</option>
<option value="failed">{i18n.t("admin.stuck.filterFailed")}</option>
<option value="pending">{i18n.t("admin.stuck.filterPending")}</option>
<option value="completed">{i18n.t("admin.stuck.filterCompleted")}</option>
</select>
</div>
</CardHeader>
<CardContent>
{#if stuckJobs.length === 0}
<EmptyState message={i18n.t("empty.admin.noJobs")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.stuck.colId")}</TableHead>
<TableHead>{i18n.t("admin.stuck.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.stuck.colStatus")}</TableHead>
<TableHead>{i18n.t("admin.stuck.colProgress")}</TableHead>
<TableHead>{i18n.t("admin.stuck.colUpdated")}</TableHead>
<TableHead>{i18n.t("admin.stuck.colError")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each stuckJobs as job}
<TableRow>
<TableCell class="font-mono text-xs text-muted-foreground">{job.id}</TableCell>
<TableCell class="font-mono text-xs">{job.company_id || "—"}</TableCell>
<TableCell>
<Badge variant={jobStatusVariant(String(job.status ?? ""))}>
{job.status ?? "—"}
</Badge>
</TableCell>
<TableCell class="tabular-nums">
{formatCredits(Number(job.processed_products ?? 0))}/{formatCredits(
Number(job.total_products ?? 0)
)}
</TableCell>
<TableCell>{formatDate(job.updated_at || job.created_at)}</TableCell>
<TableCell class="max-w-xs truncate text-sm text-destructive">
{job.error || "—"}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
{/if}
</PageShell>
@@ -0,0 +1,462 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { ApiError, failureMessage } from "$lib/api";
import { requireSupportDesk } from "$lib/admin-gate";
import { formatDateTime } from "$lib/utils";
import {
claimAdminSupportTicket,
isAdminSupportUnavailable,
listAdminSupportTickets,
type SupportListScope,
type SupportQueueFlag,
type SupportTicket
} from "$lib/support/admin-api";
import {
autoReplyStatusLabel,
normalizeAutoReplyStatus
} from "$lib/support/auto-assist";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
type BadgeVariant
} from "$lib/components/ui";
import { RefreshCw, Search, UserPlus } from "@lucide/svelte";
const STATUS_FILTERS = $derived([
{ value: "all", label: i18n.t("admin.support.statusAll") },
{ value: "open", label: i18n.t("support.status.open") },
{ value: "pending", label: i18n.t("support.status.pending") },
{ value: "resolved", label: i18n.t("support.status.resolved") },
{ value: "closed", label: i18n.t("support.status.closed") }
] as const);
const SCOPE_FILTERS_FULL = $derived([
{ value: "all" as const, label: i18n.t("admin.support.scopeAll") },
{ value: "inbox" as const, label: i18n.t("admin.support.scopeInbox") },
{ value: "mine" as const, label: i18n.t("admin.support.scopeMine") },
{ value: "unassigned" as const, label: i18n.t("admin.support.scopeUnassigned") }
]);
const SCOPE_FILTERS_STAFF = $derived([
{ value: "inbox" as const, label: i18n.t("admin.support.scopeInbox") },
{ value: "mine" as const, label: i18n.t("admin.support.scopeMine") },
{ value: "unassigned" as const, label: i18n.t("admin.support.scopeUnassigned") }
]);
const FLAG_FILTERS = $derived([
{ value: "" as const, label: i18n.t("admin.support.flagAny") },
{ value: "needs_human" as const, label: i18n.t("admin.support.flagNeedsHuman") },
{ value: "ai_draft" as const, label: i18n.t("admin.support.flagAiDraft") }
]);
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let error = $state("");
let unavailable = $state(false);
let tickets = $state<SupportTicket[]>([]);
let total = $state(0);
let search = $state("");
let statusFilter = $state("open");
let scopeFilter = $state<SupportListScope>("inbox");
let flagFilter = $state<SupportQueueFlag>("");
let fullAdmin = $state(false);
let meId = $state("");
let claimingId = $state<string | null>(null);
const scopeOptions = $derived(fullAdmin ? SCOPE_FILTERS_FULL : SCOPE_FILTERS_STAFF);
onMount(async () => {
const initialStatus = page.url.searchParams.get("status");
if (initialStatus && STATUS_FILTERS.some((s) => s.value === initialStatus)) {
statusFilter = initialStatus;
}
const initialScope = page.url.searchParams.get("scope");
if (
initialScope &&
["inbox", "mine", "unassigned", "all"].includes(initialScope)
) {
scopeFilter = initialScope as SupportListScope;
}
const initialFlag = page.url.searchParams.get("flag");
if (initialFlag === "needs_human" || initialFlag === "ai_draft") {
flagFilter = initialFlag;
}
const gate = await requireSupportDesk();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
fullAdmin = gate.staff.full_admin;
meId = gate.me.user.id;
if (!fullAdmin && scopeFilter === "all") {
scopeFilter = "inbox";
}
if (fullAdmin && !initialScope && !page.url.searchParams.get("scope")) {
scopeFilter = "all";
}
await reload();
loading = false;
});
async function syncUrl() {
const url = new URL(page.url);
if (statusFilter === "all") url.searchParams.delete("status");
else url.searchParams.set("status", statusFilter);
url.searchParams.set("scope", scopeFilter);
if (!flagFilter) url.searchParams.delete("flag");
else url.searchParams.set("flag", flagFilter);
await goto(`${url.pathname}${url.search}`, { replaceState: true, noScroll: true });
}
async function reload() {
refreshing = true;
error = "";
unavailable = false;
try {
const res = await listAdminSupportTickets({
status: statusFilter,
scope: scopeFilter,
q: search,
flag: flagFilter,
limit: 100
});
tickets = res.tickets;
total = res.total ?? res.tickets.length;
} catch (err) {
tickets = [];
total = 0;
if (isAdminSupportUnavailable(err)) {
unavailable = true;
error = i18n.t("flash.support.queueUnavailable");
} else if (err instanceof ApiError && err.status === 403) {
accessDenied = true;
} else {
error = failureMessage(err, i18n.t("admin.support.loadQueueFailed"));
}
} finally {
refreshing = false;
}
}
async function setStatusFilter(value: string) {
statusFilter = value;
await syncUrl();
await reload();
}
async function setScopeFilter(value: SupportListScope) {
scopeFilter = value;
await syncUrl();
await reload();
}
async function setFlagFilter(value: SupportQueueFlag) {
flagFilter = value;
await syncUrl();
await reload();
}
function statusVariant(status: string): BadgeVariant {
switch (status.toLowerCase()) {
case "open":
return "warning";
case "pending":
return "outline";
case "resolved":
return "success";
case "closed":
return "secondary";
default:
return "default";
}
}
function priorityLabel(priority: string): string {
const p = priority.trim().toLowerCase();
return p || "normal";
}
function canClaim(ticket: SupportTicket): boolean {
if (ticket.assignee_admin_user_id) return false;
const st = String(ticket.status ?? "").toLowerCase();
return st === "open" || st === "pending";
}
async function claimTicket(ticket: SupportTicket, e: MouseEvent) {
e.stopPropagation();
if (claimingId || !canClaim(ticket)) return;
claimingId = ticket.id;
error = "";
try {
const updated = await claimAdminSupportTicket(ticket.id);
tickets = tickets.map((t) => (t.id === ticket.id ? updated : t));
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.claimFailed"));
} finally {
claimingId = null;
}
}
function assigneeLabel(ticket: SupportTicket): string {
if (!ticket.assignee_admin_user_id) return i18n.t("admin.support.unassigned");
if (ticket.assignee_admin_user_id === meId) return i18n.t("admin.support.you");
return ticket.assignee_email || i18n.t("admin.support.assigned");
}
function autoBadgeVariant(status: string): BadgeVariant {
switch (normalizeAutoReplyStatus(status)) {
case "ai_draft":
return "warning";
case "handed_off":
case "failed":
return "destructive";
case "matched":
case "ai_sent":
return "success";
case "skipped":
return "outline";
default:
return "secondary";
}
}
</script>
<PageShell
title={i18n.t("admin.support.title")}
description={i18n.t("admin.support.description")}
>
{#snippet actions()}
<div class="flex flex-wrap items-center gap-2">
{#if fullAdmin}
<a href="/admin/support/knowledge">
<Button variant="outline" size="sm">{i18n.t("admin.support.knowledgeLink")}</Button>
</a>
{/if}
{#if !accessDenied}
<Button variant="outline" size="sm" loading={refreshing} onclick={reload}>
<RefreshCw class="mr-2 h-4 w-4" />
{i18n.t("common.refresh")}
</Button>
{/if}
</div>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="support" />
{:else}
<div class="space-y-4">
<Alert message={error} />
<Card>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>{i18n.t("admin.support.queueTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.support.queueMeta", {
total:
total === 1
? i18n.t("admin.support.ticketCountOne", { count: total })
: i18n.t("admin.support.ticketCountMany", { count: total }),
scope: scopeFilter
})}
{#if statusFilter !== "all"}
{" "}{i18n.t("admin.support.queueMetaStatus", { status: statusFilter })}
{/if}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap items-center gap-2" role="group" aria-label={i18n.t("admin.support.queueScopeAria")}>
{#each scopeOptions as opt}
<button
type="button"
class="rounded-md border px-3 py-1.5 text-sm font-medium transition-colors {scopeFilter ===
opt.value
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
onclick={() => setScopeFilter(opt.value)}
>
{opt.label}
</button>
{/each}
</div>
<div class="flex flex-wrap items-center gap-2" role="group" aria-label={i18n.t("admin.support.statusFilterAria")}>
{#each STATUS_FILTERS as opt}
<button
type="button"
class="rounded-md border px-3 py-1.5 text-sm font-medium transition-colors {statusFilter ===
opt.value
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
onclick={() => setStatusFilter(opt.value)}
>
{opt.label}
</button>
{/each}
</div>
<div class="flex flex-wrap items-center gap-2" role="group" aria-label={i18n.t("admin.support.autoAssistFilterAria")}>
{#each FLAG_FILTERS as opt}
<button
type="button"
class="rounded-md border px-3 py-1.5 text-sm font-medium transition-colors {flagFilter ===
opt.value
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
onclick={() => setFlagFilter(opt.value)}
>
{opt.label}
</button>
{/each}
</div>
<form
class="flex flex-wrap gap-2"
onsubmit={(e) => {
e.preventDefault();
void reload();
}}
>
<div class="relative min-w-0 flex-1">
<Search
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="pl-9"
placeholder={i18n.t("admin.support.searchPlaceholder")}
bind:value={search}
aria-label={i18n.t("admin.support.searchAria")}
/>
</div>
<Button type="submit" variant="secondary" size="sm" loading={refreshing}>{i18n.t("common.search")}</Button>
</form>
{#if unavailable}
<EmptyState message={i18n.t("empty.admin.queueUnavailable")} />
{:else if tickets.length === 0}
<EmptyState message={i18n.t("empty.admin.noTicketsQueue")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.support.colSubject")}</TableHead>
<TableHead>{i18n.t("admin.support.colStatus")}</TableHead>
<TableHead class="hidden md:table-cell">{i18n.t("admin.support.colAuto")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.support.colPriority")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.support.colAssignee")}</TableHead>
<TableHead class="hidden xl:table-cell">{i18n.t("admin.support.colCompany")}</TableHead>
<TableHead class="hidden xl:table-cell">{i18n.t("admin.support.colUpdated")}</TableHead>
<TableHead stickyRight>{i18n.t("admin.support.colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each tickets as ticket (ticket.id)}
<TableRow class="hover:bg-muted/40">
<TableCell>
<a
href={`/admin/support/${encodeURIComponent(ticket.id)}`}
class="block font-medium text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{ticket.subject}
</a>
<div class="text-xs text-muted-foreground capitalize">
{ticket.category || "other"}
{#if ticket.created_by_email}
· {ticket.created_by_email}
{/if}
</div>
<div class="mt-1 flex flex-wrap gap-1 md:hidden">
{#if ticket.auto_reply_disabled}
<Badge variant="outline">{i18n.t("admin.support.autoOff")}</Badge>
{:else if ticket.auto_reply_status && normalizeAutoReplyStatus(ticket.auto_reply_status) !== "none"}
<Badge variant={autoBadgeVariant(String(ticket.auto_reply_status))}>
{autoReplyStatusLabel(ticket.auto_reply_status)}
</Badge>
{/if}
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariant(String(ticket.status ?? ""))}>
{ticket.status}
</Badge>
</TableCell>
<TableCell class="hidden md:table-cell">
{#if ticket.auto_reply_disabled}
<Badge variant="outline">{i18n.t("admin.support.autoOff")}</Badge>
{:else if ticket.auto_reply_status && normalizeAutoReplyStatus(ticket.auto_reply_status) !== "none"}
<Badge variant={autoBadgeVariant(String(ticket.auto_reply_status))}>
{autoReplyStatusLabel(ticket.auto_reply_status)}
</Badge>
{:else}
<span class="text-xs text-muted-foreground"></span>
{/if}
</TableCell>
<TableCell class="hidden capitalize text-sm lg:table-cell">{priorityLabel(String(ticket.priority ?? ""))}</TableCell>
<TableCell class="hidden max-w-[10rem] truncate text-sm lg:table-cell">
{assigneeLabel(ticket)}
</TableCell>
<TableCell class="hidden max-w-[10rem] truncate text-sm xl:table-cell">
{ticket.company_name || "—"}
</TableCell>
<TableCell class="hidden whitespace-nowrap text-sm text-muted-foreground xl:table-cell">
{formatDateTime(ticket.last_message_at || ticket.updated_at)}
</TableCell>
<TableCell stickyRight>
{#if canClaim(ticket)}
<Button
variant="outline"
size="sm"
loading={claimingId === ticket.id}
onclick={(e) => claimTicket(ticket, e)}
aria-label={i18n.t("admin.support.claimAria", { subject: ticket.subject })}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.support.claim")}</span>
</Button>
{/if}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
</div>
{/if}
</PageShell>
@@ -0,0 +1,720 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { ApiError, failureMessage } from "$lib/api";
import { requireSupportDesk } from "$lib/admin-gate";
import { staffRoleLabel } from "$lib/admin-orgs";
import { formatDateTime } from "$lib/utils";
import {
approveAdminSupportAIDraft,
claimAdminSupportTicket,
discardAdminSupportAIDraft,
getAdminSupportTicket,
isAdminSupportUnavailable,
listSupportAgents,
releaseAdminSupportTicket,
replyAdminSupportTicket,
updateAdminSupportTicket,
type SupportAgent,
type SupportMessage,
type SupportTicket,
type SupportTicketStatus
} from "$lib/support/admin-api";
import {
autoReplyStatusLabel,
autoSourceLabel,
findAIDraftMessage,
formatConfidence,
normalizeAutoReplyStatus
} from "$lib/support/auto-assist";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Textarea,
type BadgeVariant
} from "$lib/components/ui";
import { ArrowLeft, Bot, CheckCircle2, RefreshCw, UserMinus, UserPlus } from "@lucide/svelte";
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let busy = $state(false);
let error = $state("");
let success = $state("");
let unavailable = $state(false);
let notFound = $state(false);
let ticket = $state<SupportTicket | null>(null);
let replyBody = $state("");
let internalNote = $state(false);
let replyStatus = $state<"" | SupportTicketStatus>("");
let fullAdmin = $state(false);
let meId = $state("");
let agents = $state<SupportAgent[]>([]);
let assignTo = $state("");
let draftBody = $state("");
let draftStatus = $state<"" | SupportTicketStatus>("pending");
const ticketId = $derived(page.params.id ?? "");
const messages = $derived.by(() => {
const list = ticket?.messages ?? [];
return Array.isArray(list) ? list : [];
});
const isMine = $derived(
Boolean(ticket?.assignee_admin_user_id && ticket.assignee_admin_user_id === meId)
);
const isUnassigned = $derived(!ticket?.assignee_admin_user_id);
const canClaim = $derived.by(() => {
if (!ticket || !isUnassigned) return false;
const st = String(ticket.status ?? "").toLowerCase();
return st === "open" || st === "pending";
});
const canRelease = $derived(Boolean(ticket && (isMine || (fullAdmin && !isUnassigned))));
const aiDraft = $derived.by(() => {
if (!ticket) return null;
if (normalizeAutoReplyStatus(ticket.auto_reply_status) !== "ai_draft") return null;
return findAIDraftMessage(ticket.messages, ticket.auto_reply_message_id);
});
const showAutoPanel = $derived(Boolean(ticket));
onMount(async () => {
const gate = await requireSupportDesk();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
fullAdmin = gate.staff.full_admin;
meId = gate.me.user.id;
if (fullAdmin) {
try {
const res = await listSupportAgents({ limit: 100 });
agents = res.agents;
} catch {
agents = [];
}
}
await reload();
loading = false;
});
async function reload() {
if (!ticketId) {
notFound = true;
return;
}
refreshing = true;
error = "";
unavailable = false;
notFound = false;
try {
ticket = await getAdminSupportTicket(ticketId);
assignTo = ticket.assignee_admin_user_id ?? "";
const draft = findAIDraftMessage(ticket.messages, ticket.auto_reply_message_id);
if (normalizeAutoReplyStatus(ticket.auto_reply_status) === "ai_draft" && draft) {
draftBody = draft.body;
}
} catch (err) {
ticket = null;
if (isAdminSupportUnavailable(err)) {
unavailable = true;
error = i18n.t("flash.support.deploymentUnavailable");
} else if (err instanceof ApiError && err.status === 404) {
notFound = true;
} else if (err instanceof ApiError && err.status === 403) {
accessDenied = true;
} else {
error = failureMessage(err, i18n.t("admin.support.loadTicketFailed"));
}
} finally {
refreshing = false;
}
}
function statusVariant(status: string): BadgeVariant {
switch (status.toLowerCase()) {
case "open":
return "warning";
case "pending":
return "outline";
case "resolved":
return "success";
case "closed":
return "secondary";
default:
return "default";
}
}
function authorLabel(msg: SupportMessage): string {
if (msg.is_internal_note && msg.is_auto_reply) return i18n.t("admin.support.authorAiDraft");
if (msg.is_internal_note) return i18n.t("admin.support.authorInternal");
const role = String(msg.author_role ?? "").toLowerCase();
if (msg.is_auto_reply || role === "system") {
return autoSourceLabel(msg.auto_source);
}
if (role === "agent") return i18n.t("admin.support.authorStaff");
return i18n.t("admin.support.authorCustomer");
}
async function sendReply() {
if (!ticket || busy) return;
const body = replyBody.trim();
if (!body) {
error = i18n.t("flash.support.replyRequired");
return;
}
busy = true;
error = "";
success = "";
const wasInternal = internalNote;
try {
const status = replyStatus || undefined;
ticket = await replyAdminSupportTicket(ticket.id, {
body,
is_internal_note: wasInternal,
status
});
replyBody = "";
internalNote = false;
replyStatus = "";
success = wasInternal
? i18n.t("admin.support.flash.noteSaved")
: i18n.t("admin.support.flash.replySent");
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.replyFailed"));
} finally {
busy = false;
}
}
async function setStatus(status: SupportTicketStatus) {
if (!ticket || busy) return;
busy = true;
error = "";
success = "";
try {
ticket = await updateAdminSupportTicket(ticket.id, { status });
success =
status === "resolved"
? i18n.t("admin.support.flash.resolved")
: status === "pending"
? i18n.t("admin.support.flash.pending")
: status === "open"
? i18n.t("admin.support.flash.reopened")
: i18n.t("admin.support.flash.statusSet", { status });
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.statusFailed"));
} finally {
busy = false;
}
}
async function resolveTicket() {
await setStatus("resolved");
}
async function claimTicket() {
if (!ticket || busy) return;
busy = true;
error = "";
success = "";
try {
ticket = await claimAdminSupportTicket(ticket.id);
assignTo = ticket.assignee_admin_user_id ?? "";
success = i18n.t("flash.support.claimed");
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.claimFailed"));
} finally {
busy = false;
}
}
async function releaseTicket() {
if (!ticket || busy) return;
busy = true;
error = "";
success = "";
try {
ticket = await releaseAdminSupportTicket(ticket.id);
assignTo = "";
success = i18n.t("flash.support.unassigned");
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.unassignFailed"));
} finally {
busy = false;
}
}
async function applyAssign() {
if (!ticket || busy || !fullAdmin) return;
busy = true;
error = "";
success = "";
try {
if (!assignTo) {
ticket = await updateAdminSupportTicket(ticket.id, { clear_assignee: true });
success = i18n.t("flash.support.assigneeCleared");
} else {
ticket = await updateAdminSupportTicket(ticket.id, {
assignee_admin_user_id: assignTo
});
success = i18n.t("flash.support.assigneeUpdated");
}
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.assignFailed"));
} finally {
busy = false;
}
}
async function setAutoDisabled(disabled: boolean) {
if (!ticket || busy) return;
busy = true;
error = "";
success = "";
try {
ticket = await updateAdminSupportTicket(ticket.id, { auto_reply_disabled: disabled });
success = disabled
? i18n.t("admin.support.flash.autoDisabled")
: i18n.t("admin.support.flash.autoEnabled");
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.autoFailed"));
} finally {
busy = false;
}
}
async function approveDraft() {
if (!ticket || busy) return;
const body = draftBody.trim();
if (!body) {
error = i18n.t("flash.support.draftRequired");
return;
}
busy = true;
error = "";
success = "";
try {
ticket = await approveAdminSupportAIDraft(ticket.id, {
body,
status: draftStatus || undefined
});
draftBody = "";
success = i18n.t("flash.support.draftApproved");
} catch (err) {
if (err instanceof ApiError && (err.status === 404 || err.status === 501)) {
// Fallback when approve route is not mounted yet: send as staff reply.
ticket = await replyAdminSupportTicket(ticket.id, {
body,
status: draftStatus || "pending"
});
try {
ticket = await updateAdminSupportTicket(ticket.id, { auto_reply_disabled: true });
} catch {
/* optional */
}
draftBody = "";
success = i18n.t("flash.support.draftSent");
} else {
error = failureMessage(err, i18n.t("admin.support.approveFailed"));
}
} finally {
busy = false;
}
}
async function discardDraft() {
if (!ticket || busy) return;
busy = true;
error = "";
success = "";
try {
ticket = await discardAdminSupportAIDraft(ticket.id);
draftBody = "";
success = i18n.t("flash.support.draftDiscarded");
} catch (err) {
if (err instanceof ApiError && (err.status === 404 || err.status === 501)) {
ticket = await updateAdminSupportTicket(ticket.id, { auto_reply_disabled: true });
draftBody = "";
success = i18n.t("flash.support.autoReplyDisabled");
} else {
error = failureMessage(err, i18n.t("admin.support.discardFailed"));
}
} finally {
busy = false;
}
}
</script>
<PageShell
title={ticket?.subject ?? i18n.t("admin.support.ticketFallbackTitle")}
description={ticket
? i18n.t("admin.support.ticketMeta", {
company: ticket.company_name || i18n.t("admin.support.companyFallback"),
requester: ticket.created_by_email || i18n.t("admin.support.requesterFallback")
})
: i18n.t("admin.support.ticketMetaFallback")}
>
{#snippet actions()}
<a href="/admin/support">
<Button variant="outline" size="sm">
<ArrowLeft class="mr-2 h-4 w-4" />
{i18n.t("admin.support.backQueue")}
</Button>
</a>
<Button variant="outline" size="sm" loading={refreshing} onclick={reload} disabled={accessDenied || !ticketId}>
<RefreshCw class="mr-2 h-4 w-4" />
{i18n.t("common.refresh")}
</Button>
{#if ticket && ticket.status !== "resolved" && ticket.status !== "closed"}
<Button variant="default" size="sm" loading={busy} onclick={resolveTicket}>
<CheckCircle2 class="mr-2 h-4 w-4" />
{i18n.t("admin.support.resolve")}
</Button>
{/if}
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="support" />
{:else if notFound}
<EmptyState message={i18n.t("empty.admin.ticketNotFound")} />
{:else if unavailable}
<div class="space-y-4">
<Alert message={error} />
<EmptyState message={i18n.t("empty.admin.supportUnavailable")} />
</div>
{:else if !ticket}
<div class="space-y-4">
<Alert message={error} />
<EmptyState message={i18n.t("empty.admin.ticketLoadFailed")} />
</div>
{:else}
<div class="space-y-4">
<Alert message={error} />
<Alert tone="success" message={success} />
<Card>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<CardTitle class="text-lg">{ticket.subject}</CardTitle>
<CardDescription class="space-x-2">
<span class="capitalize">{ticket.category || "other"}</span>
<span>·</span>
<span class="capitalize">{i18n.t("admin.support.priorityMeta", { priority: ticket.priority || "normal" })}</span>
<span>·</span>
<span class="font-mono text-xs">{ticket.id}</span>
</CardDescription>
</div>
<div class="flex flex-wrap items-center gap-2">
<Badge variant={statusVariant(String(ticket.status ?? ""))}>{ticket.status}</Badge>
{#if ticket.status !== "pending"}
<Button variant="outline" size="sm" loading={busy} onclick={() => setStatus("pending")}>
{i18n.t("admin.support.markPending")}
</Button>
{/if}
{#if ticket.status === "resolved" || ticket.status === "closed"}
<Button variant="outline" size="sm" loading={busy} onclick={() => setStatus("open")}>
{i18n.t("admin.support.reopen")}
</Button>
{/if}
</div>
</div>
</CardHeader>
<CardContent class="space-y-3 border-t border-border pt-4">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">{i18n.t("admin.support.assigneeLabel")}</span>
<span class="font-medium text-foreground">
{#if ticket.assignee_admin_user_id}
{#if isMine}
{i18n.t("admin.support.you")}
{:else}
{ticket.assignee_email || ticket.assignee_admin_user_id}
{/if}
{:else}
{i18n.t("admin.support.unassigned")}
{/if}
</span>
{#if canClaim}
<Button variant="secondary" size="sm" loading={busy} onclick={claimTicket}>
<UserPlus class="mr-1.5 h-3.5 w-3.5" />
{i18n.t("admin.support.claim")}
</Button>
{/if}
{#if canRelease}
<Button variant="outline" size="sm" loading={busy} onclick={releaseTicket}>
<UserMinus class="mr-1.5 h-3.5 w-3.5" />
{i18n.t("admin.support.unassign")}
</Button>
{/if}
</div>
{#if fullAdmin}
<form
class="flex flex-wrap items-end gap-2"
onsubmit={(e) => {
e.preventDefault();
void applyAssign();
}}
>
<label class="space-y-1 text-sm">
<span class="text-muted-foreground">{i18n.t("admin.support.assignToStaff")}</span>
<select
class="flex h-9 min-w-[220px] rounded-md border border-input bg-background px-2 text-sm"
bind:value={assignTo}
disabled={busy}
aria-label={i18n.t("admin.support.assignStaffAria")}
>
<option value="">{i18n.t("admin.support.unassigned")}</option>
{#each agents as agent (agent.id)}
<option value={agent.id}>
{agent.name ? `${agent.name} (${agent.email})` : agent.email}
{#if agent.staff_role}
· {staffRoleLabel(agent.staff_role)}
{/if}
</option>
{/each}
</select>
</label>
<Button type="submit" variant="secondary" size="sm" loading={busy}>{i18n.t("admin.support.apply")}</Button>
</form>
{/if}
</CardContent>
</Card>
{#if showAutoPanel}
<Card>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle class="flex items-center gap-2">
<Bot class="h-4 w-4" />
{i18n.t("admin.support.autoPanelTitle")}
</CardTitle>
<CardDescription>
{i18n.t("admin.support.autoPanelDesc")}
</CardDescription>
</div>
<div class="flex flex-wrap items-center gap-2">
{#if ticket.auto_reply_status}
<Badge variant="outline">{autoReplyStatusLabel(ticket.auto_reply_status)}</Badge>
{/if}
{#if ticket.auto_reply_disabled}
<Badge variant="secondary">{i18n.t("admin.support.autoDisabledBadge")}</Badge>
{/if}
</div>
</div>
</CardHeader>
<CardContent class="space-y-3">
<div class="grid gap-2 text-sm sm:grid-cols-2">
<div>
<span class="text-muted-foreground">{i18n.t("admin.support.autoStatusLabel")}</span>
<span class="ml-1 font-medium">{autoReplyStatusLabel(ticket.auto_reply_status ?? "none")}</span>
</div>
<div>
<span class="text-muted-foreground">{i18n.t("admin.support.lastAttempt")}</span>
<span class="ml-1">{ticket.auto_reply_attempted_at ? formatDateTime(ticket.auto_reply_attempted_at) : "—"}</span>
</div>
</div>
<div class="flex flex-wrap gap-2">
{#if ticket.auto_reply_disabled}
<Button variant="outline" size="sm" loading={busy} onclick={() => setAutoDisabled(false)}>
{i18n.t("admin.support.reenableAuto")}
</Button>
{:else}
<Button variant="secondary" size="sm" loading={busy} onclick={() => setAutoDisabled(true)}>
{i18n.t("admin.support.disableAuto")}
</Button>
{/if}
</div>
</CardContent>
</Card>
{/if}
{#if aiDraft}
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.support.aiDraftTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.support.aiDraftDesc")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
{#if typeof aiDraft.auto_confidence === "number"}
<p class="text-sm text-muted-foreground">
{i18n.t("admin.support.confidence", { value: formatConfidence(aiDraft.auto_confidence) })}
{#if aiDraft.auto_source}
· {autoSourceLabel(aiDraft.auto_source)}
{/if}
</p>
{/if}
<Textarea
bind:value={draftBody}
rows={6}
maxlength={10000}
aria-label={i18n.t("admin.support.aiDraftAria")}
disabled={busy}
/>
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<span>{i18n.t("admin.support.statusAfterSend")}</span>
<select
class="h-9 rounded-md border border-input bg-background px-2 text-sm"
bind:value={draftStatus}
disabled={busy}
>
<option value="pending">{i18n.t("support.status.pending")}</option>
<option value="open">{i18n.t("support.status.open")}</option>
<option value="resolved">{i18n.t("support.status.resolved")}</option>
</select>
</label>
</CardContent>
<CardFooter class="flex flex-wrap gap-2">
<Button loading={busy} onclick={approveDraft} disabled={!draftBody.trim()}>
{i18n.t("admin.support.approveSend")}
</Button>
<Button variant="outline" loading={busy} onclick={discardDraft}
>{i18n.t("admin.support.discardDraft")}</Button
>
</CardFooter>
</Card>
{/if}
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.support.threadTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.support.threadMeta", {
count:
messages.length === 1
? i18n.t("admin.support.messageCountOne", { count: messages.length })
: i18n.t("admin.support.messageCountMany", { count: messages.length }),
when: formatDateTime(ticket.last_message_at || ticket.updated_at)
})}
</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
{#if messages.length === 0}
<EmptyState message={i18n.t("empty.admin.noMessages")} />
{:else}
{#each messages as msg (msg.id)}
{@const staff = String(msg.author_role).toLowerCase() === "agent" || msg.is_internal_note}
{@const automated = Boolean(msg.is_auto_reply) || String(msg.author_role).toLowerCase() === "system"}
<div
class="rounded-md border px-3 py-3 text-sm {msg.is_internal_note
? 'border-dashed border-amber-500/40 bg-amber-500/5'
: automated
? 'border-sky-500/30 bg-sky-500/5'
: staff
? 'border-primary/20 bg-primary/5'
: 'border-border bg-card'}"
>
<div class="mb-1 flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-foreground">{authorLabel(msg)}</span>
{#if automated && !msg.is_internal_note}
<Badge variant="outline">{autoSourceLabel(msg.auto_source)}</Badge>
{/if}
{#if typeof msg.auto_confidence === "number"}
<span class="text-xs text-muted-foreground">{formatConfidence(msg.auto_confidence)}</span>
{/if}
</div>
<span class="text-xs text-muted-foreground">{formatDateTime(msg.created_at)}</span>
</div>
<p class="whitespace-pre-wrap break-words text-foreground">{msg.body}</p>
</div>
{/each}
{/if}
</CardContent>
</Card>
{#if ticket.status !== "closed"}
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.support.replyTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.support.replyDesc")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<Textarea
bind:value={replyBody}
rows={5}
maxlength={10000}
placeholder={i18n.t("admin.support.replyPlaceholder")}
aria-label={i18n.t("admin.support.replyAria")}
disabled={busy}
/>
<div class="flex flex-wrap items-center gap-4">
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<input type="checkbox" bind:checked={internalNote} disabled={busy} class="rounded border-input" />
{i18n.t("admin.support.internalNote")}
</label>
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<span>{i18n.t("admin.support.setStatus")}</span>
<select
class="h-9 rounded-md border border-input bg-background px-2 text-sm"
bind:value={replyStatus}
disabled={busy || internalNote}
>
<option value="">{i18n.t("admin.support.statusDefault")}</option>
<option value="open">{i18n.t("support.status.open")}</option>
<option value="pending">{i18n.t("support.status.pending")}</option>
<option value="resolved">{i18n.t("support.status.resolved")}</option>
</select>
</label>
</div>
</CardContent>
<CardFooter class="flex flex-wrap gap-2">
<Button loading={busy} onclick={sendReply} disabled={!replyBody.trim()}>
{internalNote ? i18n.t("admin.support.saveNote") : i18n.t("admin.support.sendReply")}
</Button>
{#if !internalNote}
<Button
variant="secondary"
loading={busy}
disabled={!replyBody.trim()}
onclick={async () => {
replyStatus = "resolved";
await sendReply();
}}
>
{i18n.t("admin.support.replyAndResolve")}
</Button>
{/if}
</CardFooter>
</Card>
{:else}
<Card>
<CardContent class="py-6 text-sm text-muted-foreground">
{i18n.t("admin.support.closedBanner")}
</CardContent>
</Card>
{/if}
</div>
{/if}
</PageShell>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { requirePlatformAdmin } from "$lib/admin-gate";
import Spinner from "$lib/components/Spinner.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import { i18n } from "$lib/i18n";
let loading = $state(true);
let accessDenied = $state(false);
// Prefer Stuck Products (job list + same cleanup action). Keep this URL as a redirect.
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
accessDenied = true;
loading = false;
return;
}
await goto("/admin/stuck-products", { replaceState: true });
});
</script>
{#if loading}
<div class="p-6"><Spinner /></div>
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<p class="p-6 text-sm text-muted-foreground">{i18n.t("admin.redirect.stuck")}</p>
{/if}
@@ -0,0 +1,363 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import {
isTranslationsUnavailable,
loadTranslationsCatalog,
saveTranslationUpdates,
type TranslationsCatalogResponse
} from "$lib/admin-translations";
import { i18n, DEFAULT_UI_LOCALE } from "$lib/i18n";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
Input,
Label,
Select,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
Textarea
} from "$lib/components/ui";
import { Languages, RefreshCw, Save, Search } from "@lucide/svelte";
type FilterMode = "all" | "missing" | "translated";
let loading = $state(true);
let refreshing = $state(false);
let saving = $state(false);
let accessDenied = $state(false);
let unavailable = $state(false);
let error = $state("");
let success = $state("");
let data = $state<TranslationsCatalogResponse | null>(null);
let locale = $state(DEFAULT_UI_LOCALE === "en" ? "es" : DEFAULT_UI_LOCALE);
let filter = $state<FilterMode>("missing");
let query = $state("");
let draft = $state<Record<string, string>>({});
let dirtyKeys = $state<Set<string>>(new Set());
const coverage = $derived(data?.coverage ?? []);
const baseKeys = $derived(data?.keys ?? []);
const baseDict = $derived(data?.catalog?.[DEFAULT_UI_LOCALE] ?? {});
const localeDict = $derived(data?.catalog?.[locale] ?? {});
const activeCoverage = $derived(coverage.find((c) => c.code === locale) ?? null);
const rows = $derived.by(() => {
const q = query.trim().toLowerCase();
const missingSet = new Set(activeCoverage?.missing_keys ?? []);
return baseKeys.filter((key) => {
const translated = !missingSet.has(key);
if (filter === "missing" && translated) return false;
if (filter === "translated" && !translated) return false;
if (!q) return true;
const en = (baseDict[key] ?? "").toLowerCase();
const loc = (draft[key] ?? localeDict[key] ?? "").toLowerCase();
return key.toLowerCase().includes(q) || en.includes(q) || loc.includes(q);
});
});
const dirtyCount = $derived(dirtyKeys.size);
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
await reload();
loading = false;
});
function resetDraft(next: TranslationsCatalogResponse) {
const pack = next.catalog[locale] ?? {};
const nextDraft: Record<string, string> = {};
for (const key of next.keys) {
nextDraft[key] = pack[key] ?? "";
}
draft = nextDraft;
dirtyKeys = new Set();
}
async function reload() {
refreshing = true;
error = "";
success = "";
unavailable = false;
try {
const next = await loadTranslationsCatalog();
data = next;
const preferred =
next.locales.find((l) => l.code === locale)?.code ??
next.locales.find((l) => l.code !== next.base_locale)?.code ??
next.base_locale;
locale = preferred;
resetDraft(next);
} catch (err) {
if (isTranslationsUnavailable(err)) {
unavailable = true;
} else {
error = failureMessage(err, "Could not load translation catalog.");
}
} finally {
refreshing = false;
}
}
function onDraftInput(key: string, value: string) {
draft = { ...draft, [key]: value };
const original = localeDict[key] ?? "";
const next = new Set(dirtyKeys);
if (value === original) next.delete(key);
else next.add(key);
dirtyKeys = next;
}
async function saveDirty() {
if (!dirtyCount) return;
saving = true;
error = "";
success = "";
const updates: Record<string, string> = {};
for (const key of dirtyKeys) {
updates[key] = draft[key] ?? "";
}
try {
const saved = await saveTranslationUpdates(locale, updates);
if (data) {
data = {
...data,
catalog: { ...data.catalog, [locale]: saved.messages }
};
}
await reload();
success = i18n.t("flash.admin.translationsSaved", { count: Object.keys(updates).length, locale });
} catch (err) {
error = failureMessage(err, "Could not save translations.");
} finally {
saving = false;
}
}
</script>
{#if loading}
<div class="flex min-h-[40vh] items-center justify-center">
<Spinner label="Checking access…" />
</div>
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<PageShell
title={i18n.t("admin.translations.title")}
description={i18n.t("admin.translations.description")}
eyebrow={i18n.t("admin.translations.eyebrow")}
>
{#snippet actions()}
<Button type="button" variant="outline" onclick={() => reload()} disabled={refreshing || saving}>
<RefreshCw class="size-4 {refreshing ? 'animate-spin' : ''}" />
Refresh
</Button>
<Button type="button" onclick={saveDirty} disabled={saving || dirtyCount === 0}>
<Save class="size-4" />
{saving ? "Saving…" : `Save${dirtyCount ? ` (${dirtyCount})` : ""}`}
</Button>
{/snippet}
<div class="space-y-6">
{#if error}
<Alert message={error} />
{/if}
{#if success}
<Alert tone="success" message={success} />
{/if}
{#if unavailable}
<EmptyState
title={i18n.t("empty.admin.translationsUnavailableTitle")}
message={i18n.t("empty.admin.translationsUnavailableMessage")}
/>
{:else if data}
<section
class="rounded-xl border border-border bg-card/40 px-4 py-3 text-sm text-muted-foreground"
aria-label={i18n.t("admin.translations.sourceOfTruth")}
>
<p class="flex items-start gap-2">
<Languages class="mt-0.5 size-4 shrink-0 text-foreground" />
<span>
<strong class="font-medium text-foreground">{i18n.t("admin.translations.sourceOfTruthLabel")}</strong>
{data.source_of_truth}
</span>
</p>
</section>
<section aria-labelledby="coverage-heading" class="space-y-3">
<h2 id="coverage-heading" class="text-sm font-semibold tracking-wide text-foreground">
Locale coverage
</h2>
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.translations.colLocale")}</TableHead>
<TableHead>{i18n.t("admin.translations.colTranslated")}</TableHead>
<TableHead>{i18n.t("admin.translations.colMissing")}</TableHead>
<TableHead>{i18n.t("admin.translations.colStatus")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each coverage as row}
<TableRow>
<TableCell>
<button
type="button"
class="font-medium text-foreground underline-offset-2 hover:underline"
onclick={() => {
locale = row.code;
if (data) resetDraft(data);
}}
>
{row.label}
<span class="text-muted-foreground">({row.code})</span>
</button>
</TableCell>
<TableCell>{row.translated}/{row.total}</TableCell>
<TableCell>{row.missing}</TableCell>
<TableCell>
{#if row.code === data.base_locale}
<Badge variant="secondary">{i18n.t("admin.translations.badgeBase")}</Badge>
{:else if row.missing === 0}
<Badge variant="success">{i18n.t("admin.translations.badgeComplete")}</Badge>
{:else if !row.registered}
<Badge variant="outline">{i18n.t("admin.translations.badgeUnregistered")}</Badge>
{:else}
<Badge variant="warning">{i18n.t("admin.translations.badgeGaps")}</Badge>
{/if}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
</section>
<section aria-labelledby="editor-heading" class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 id="editor-heading" class="text-sm font-semibold tracking-wide text-foreground">
Edit strings
</h2>
<p class="mt-1 text-sm text-muted-foreground">
Clear a non-English value to fall back to English. English values cannot be cleared.
</p>
</div>
<div class="flex flex-wrap gap-3">
<div class="min-w-[10rem] space-y-1.5">
<Label for="translation-locale">{i18n.t("admin.translations.language")}</Label>
<Select
id="translation-locale"
bind:value={locale}
onchange={() => {
if (data) resetDraft(data);
success = "";
error = "";
}}
>
{#each data.locales as item}
<option value={item.code}>{item.label}</option>
{/each}
</Select>
</div>
<div class="min-w-[10rem] space-y-1.5">
<Label for="translation-filter">{i18n.t("admin.translations.show")}</Label>
<Select id="translation-filter" bind:value={filter}>
<option value="missing">{i18n.t("admin.translations.filterMissing")}</option>
<option value="translated">{i18n.t("admin.translations.filterTranslated")}</option>
<option value="all">{i18n.t("admin.translations.filterAll")}</option>
</Select>
</div>
<div class="min-w-[14rem] flex-1 space-y-1.5">
<Label for="translation-search">{i18n.t("admin.translations.search")}</Label>
<div class="relative">
<Search
class="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="translation-search"
class="pl-8"
placeholder={i18n.t("admin.translations.searchPlaceholder")}
bind:value={query}
/>
</div>
</div>
</div>
</div>
{#if rows.length === 0}
<EmptyState
title={i18n.t("empty.admin.noKeysMatchTitle")}
message={i18n.t("empty.admin.noKeysMatchMessage")}
/>
{:else}
<div class="space-y-4">
{#each rows as key (key)}
{@const missing = !(localeDict[key] ?? "").trim()}
<div class="rounded-xl border border-border bg-card/30 p-4 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<code class="text-xs text-foreground">{key}</code>
{#if missing}
<Badge variant="warning">{i18n.t("admin.translations.badgeMissing")}</Badge>
{/if}
{#if dirtyKeys.has(key)}
<Badge variant="secondary">{i18n.t("admin.translations.badgeUnsaved")}</Badge>
{/if}
</div>
<div class="grid gap-3 lg:grid-cols-2">
<div class="space-y-1.5">
<Label for={`en-${key}`}>{i18n.t("admin.translations.english")}</Label>
<Textarea
id={`en-${key}`}
rows={2}
readonly
value={baseDict[key] ?? ""}
class="bg-muted/40"
/>
</div>
<div class="space-y-1.5">
<Label for={`loc-${key}`}>{activeCoverage?.label ?? locale}</Label>
<Textarea
id={`loc-${key}`}
rows={2}
value={draft[key] ?? ""}
oninput={(e) =>
onDraftInput(key, (e.currentTarget as HTMLTextAreaElement).value)}
/>
</div>
</div>
</div>
{/each}
</div>
{/if}
</section>
{/if}
</div>
</PageShell>
{/if}
@@ -0,0 +1,78 @@
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { DEFAULT_UI_LOCALE, UI_LOCALES, isUILocale, normalizeUILocale } from "$lib/i18n/locales";
import {
applyLocaleUpdates,
loadCatalogFromDisk,
MAX_PATCH_BODY_BYTES,
sanitizeUpdates,
sourceOfTruthNote
} from "$lib/server/i18n-messages";
import { readLimitedJsonBody } from "$lib/server/read-limited-json-body";
import {
assertSameOrigin,
requirePlatformAdminServer
} from "$lib/server/require-platform-admin";
export const GET: RequestHandler = async (event) => {
await requirePlatformAdminServer(event);
const disk = await loadCatalogFromDisk();
return json({
base_locale: DEFAULT_UI_LOCALE,
source_of_truth: sourceOfTruthNote(),
locales: UI_LOCALES,
keys: disk.keys,
catalog: disk.catalog,
coverage: disk.coverage
});
};
export const PATCH: RequestHandler = async (event) => {
assertSameOrigin(event);
await requirePlatformAdminServer(event);
const parsed = await readLimitedJsonBody(event.request, MAX_PATCH_BODY_BYTES);
if (!parsed.ok) {
if (parsed.kind === "payload_too_large") {
return json(
{ error: "payload_too_large", message: "Request body too large." },
{ status: 413 }
);
}
return json({ error: "invalid_json", message: "Invalid JSON body." }, { status: 400 });
}
const body = parsed.body;
const record = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const rawLocale = typeof record.locale === "string" ? record.locale.trim().toLowerCase() : "";
if (!isUILocale(rawLocale)) {
return json({ error: "invalid_locale", message: "Unsupported locale." }, { status: 400 });
}
const locale = normalizeUILocale(rawLocale);
let updates;
try {
updates = sanitizeUpdates(record.updates);
} catch (err) {
return json(
{
error: "invalid_updates",
message: err instanceof Error ? err.message : "Invalid updates."
},
{ status: 400 }
);
}
try {
const messages = await applyLocaleUpdates(locale, updates);
return json({
locale,
messages,
source_of_truth: sourceOfTruthNote()
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to save translations.";
const status = /English key|Unsupported locale|Invalid/.test(message) ? 400 : 500;
return json({ error: "save_failed", message }, { status });
}
};
@@ -0,0 +1,790 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api, ApiError, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatCredits, formatDate } from "$lib/utils";
import {
PAGE_SIZE,
STAFF_ROLE_OPTIONS,
assignAdminPlan,
companyPlanBadge,
isStaffRoleApiUnavailable,
listAdminCompanies,
listAdminPlansForAssign,
listAdminUsers,
planOptionLabel,
setAdminStaffRole,
staffRoleBadgeVariant,
staffRoleLabel,
type AdminBillingPlan,
type AdminOrgCompany,
type AdminOrgUser,
type PlatformStaffRole
} from "$lib/admin-orgs";
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 ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Dialog,
Input,
Label,
Select,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from "$lib/components/ui";
import { Building2, KeyRound, Search, Shield, UserPlus, Users } from "@lucide/svelte";
type TabKey = "users" | "companies";
let loading = $state(true);
let accessDenied = $state(false);
let busy = $state(false);
let busyUserId = $state<string | null>(null);
let error = $state("");
let success = $state("");
let tab = $state<TabKey>("users");
let search = $state("");
let staffOnly = $state(false);
let withoutPlan = $state(false);
let withoutApiKeys = $state(false);
let users = $state<AdminOrgUser[]>([]);
let usersTotal = $state(0);
let usersOffset = $state(0);
let companies = $state<AdminOrgCompany[]>([]);
let companiesTotal = $state(0);
let companiesOffset = $state(0);
let plans = $state<AdminBillingPlan[]>([]);
let devTools = $state(false);
let staffRoleApiOk = $state(true);
let roleOpen = $state(false);
let roleUser = $state<AdminOrgUser | null>(null);
let roleValue = $state<"" | PlatformStaffRole>("");
let assignOpen = $state(false);
let assignCompany = $state<AdminOrgCompany | null>(null);
let assignPlanId = $state("");
const usersPage = $derived(Math.floor(usersOffset / PAGE_SIZE) + 1);
const usersPages = $derived(Math.max(1, Math.ceil(usersTotal / PAGE_SIZE)));
const companiesPage = $derived(Math.floor(companiesOffset / PAGE_SIZE) + 1);
const companiesPages = $derived(Math.max(1, Math.ceil(companiesTotal / PAGE_SIZE)));
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
loading = false;
return;
}
error = gate.message;
loading = false;
return;
}
const initialTab = page.url.searchParams.get("tab");
if (initialTab === "companies") tab = "companies";
if (page.url.searchParams.get("without_api_keys") === "1") {
withoutApiKeys = true;
tab = "companies";
}
if (page.url.searchParams.get("without_active_plan") === "1") {
withoutPlan = true;
tab = "companies";
}
try {
await Promise.all([reloadUsers(), reloadCompanies(), loadPlans()]);
// Local-only password/impersonation helpers — never show in production builds.
devTools = !import.meta.env.PROD;
} catch (err) {
error = failureMessage(err, "Failed to load directory");
} finally {
loading = false;
}
});
async function loadPlans() {
try {
plans = await listAdminPlansForAssign();
} catch {
plans = [];
}
}
async function reloadUsers() {
const res = await listAdminUsers({
limit: PAGE_SIZE,
offset: usersOffset,
q: search,
staff_only: staffOnly
});
users = res.users;
usersTotal = res.total;
usersOffset = res.offset;
}
async function reloadCompanies() {
const res = await listAdminCompanies({
limit: PAGE_SIZE,
offset: companiesOffset,
q: search,
without_active_plan: withoutPlan,
without_api_keys: withoutApiKeys
});
companies = res.companies;
companiesTotal = res.total;
companiesOffset = res.offset;
}
async function applySearch() {
busy = true;
error = "";
usersOffset = 0;
companiesOffset = 0;
try {
if (tab === "users") await reloadUsers();
else await reloadCompanies();
} catch (err) {
error = failureMessage(err, "Search failed");
} finally {
busy = false;
}
}
async function changeUsersPage(delta: number) {
const next = Math.max(0, usersOffset + delta * PAGE_SIZE);
if (next >= usersTotal && usersTotal > 0) return;
usersOffset = next;
busy = true;
error = "";
try {
await reloadUsers();
} catch (err) {
error = failureMessage(err, "Failed to load users");
} finally {
busy = false;
}
}
async function changeCompaniesPage(delta: number) {
const next = Math.max(0, companiesOffset + delta * PAGE_SIZE);
if (next >= companiesTotal && companiesTotal > 0) return;
companiesOffset = next;
busy = true;
error = "";
try {
await reloadCompanies();
} catch (err) {
error = failureMessage(err, "Failed to load companies");
} finally {
busy = false;
}
}
async function onTabChange(value: string) {
tab = value === "companies" ? "companies" : "users";
error = "";
busy = true;
try {
if (tab === "users") await reloadUsers();
else await reloadCompanies();
} catch (err) {
error = failureMessage(err, "Failed to load");
} finally {
busy = false;
}
}
async function toggleStaffOnly() {
staffOnly = !staffOnly;
usersOffset = 0;
busy = true;
error = "";
try {
await reloadUsers();
} catch (err) {
error = failureMessage(err, "Failed to filter users");
} finally {
busy = false;
}
}
async function toggleWithoutPlan() {
const next = !withoutPlan;
withoutPlan = next;
companiesOffset = 0;
busy = true;
error = "";
try {
await reloadCompanies();
// Keep toggle in sync if a concurrent reload raced (should be rare).
withoutPlan = next;
} catch (err) {
withoutPlan = !next;
error = failureMessage(err, "Failed to filter companies");
} finally {
busy = false;
}
}
async function toggleWithoutApiKeys() {
const next = !withoutApiKeys;
withoutApiKeys = next;
companiesOffset = 0;
busy = true;
error = "";
try {
await reloadCompanies();
withoutApiKeys = next;
} catch (err) {
withoutApiKeys = !next;
error = failureMessage(err, "Failed to filter companies");
} finally {
busy = false;
}
}
function openRoleDialog(user: AdminOrgUser) {
roleUser = user;
const resolved = (user.staff_role || user.resolved_role || "") as "" | PlatformStaffRole;
roleValue =
resolved === "admin" || resolved === "developer" || resolved === "support_staff"
? resolved
: "";
roleOpen = true;
}
async function saveStaffRole(event: Event) {
event.preventDefault();
if (!roleUser) return;
busy = true;
error = "";
success = "";
try {
const updated = await setAdminStaffRole(roleUser.id, roleValue);
users = users.map((u) =>
u.id === updated.id
? {
...u,
...updated,
staff_role: updated.staff_role,
resolved_role: updated.resolved_role,
is_platform_admin: updated.is_platform_admin
}
: u
);
success = i18n.t("flash.admin.staffRoleUpdated", { email: updated.email });
roleOpen = false;
staffRoleApiOk = true;
} catch (err) {
if (isStaffRoleApiUnavailable(err)) {
staffRoleApiOk = false;
error = i18n.t("flash.admin.staffRoleUnavailable");
} else {
error = failureMessage(err, "Could not update staff role");
}
} finally {
busy = false;
}
}
function openAssignDialog(company: AdminOrgCompany) {
assignCompany = company;
assignPlanId = company.plan_id != null ? String(company.plan_id) : "";
assignOpen = true;
}
async function saveAssignPlan(event: Event) {
event.preventDefault();
if (!assignCompany || !assignPlanId) return;
busy = true;
error = "";
success = "";
try {
await assignAdminPlan({
company_id: assignCompany.id,
plan_id: Number(assignPlanId)
});
success = i18n.t("flash.admin.planAssigned", { name: assignCompany.name });
assignOpen = false;
await reloadCompanies();
} catch (err) {
error = failureMessage(err, "Assign plan failed");
} finally {
busy = false;
}
}
async function sendSetPasswordEmails(userId?: string) {
busy = true;
error = "";
success = "";
try {
const body = userId ? { user_id: userId } : {};
const res = await api<{
sent: number;
issued?: number;
skipped: number;
skipped_synthetic?: number;
skipped_rate_limited?: number;
smtp_enabled: boolean;
token?: string;
}>("/api/admin/emails/set-password", { method: "POST", body });
const parts = [
`sent ${res.sent}`,
`issued ${res.issued ?? 0}`,
`skipped ${res.skipped}`
];
if (res.skipped_synthetic) parts.push(`skipped test accounts ${res.skipped_synthetic}`);
if (res.skipped_rate_limited) parts.push(`rate-limited ${res.skipped_rate_limited}`);
success = i18n.t("flash.admin.invitesSummary", { parts: parts.join(", "), smtp: res.smtp_enabled ? i18n.t("flash.admin.smtpOn") : i18n.t("flash.admin.smtpOff") });
if (res.token) {
success += " Invite link available — copy and share it securely (email delivery is off).";
}
} catch (err) {
error = failureMessage(err, "Send failed");
} finally {
busy = false;
}
}
async function setDevPassword(userId: string) {
busyUserId = userId;
error = "";
success = "";
try {
const res = await api<{ email?: string }>(`/api/admin/users/${userId}/dev-password`, {
method: "POST",
body: {}
});
success = i18n.t("flash.admin.localPasswordSet", { email: res.email ?? "user" });
users = users.map((u) => (u.id === userId ? { ...u, must_set_password: false } : u));
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
devTools = false;
error = i18n.t("flash.admin.localPasswordUnavailable");
} else {
error = failureMessage(err, "Could not set local password");
}
} finally {
busyUserId = null;
}
}
async function switchToUser(userId: string) {
busyUserId = userId;
error = "";
success = "";
try {
await api(`/api/admin/users/${userId}/impersonate`, { method: "POST", body: {} });
success = i18n.t("flash.admin.switchedUser");
window.location.assign("/dashboard");
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
devTools = false;
error = i18n.t("flash.admin.switchUnavailable");
} else {
error = failureMessage(err, "Could not switch user");
}
busyUserId = null;
}
}
function displayRole(user: AdminOrgUser): string {
return staffRoleLabel(user.staff_role || user.resolved_role);
}
</script>
<PageShell
title={i18n.t("admin.users.title")}
description={i18n.t("admin.users.description")}
eyebrow={i18n.t("admin.users.eyebrow")}
>
{#snippet actions()}
<Button size="sm" variant="outline" loading={busy} onclick={() => sendSetPasswordEmails()}>
<UserPlus class="mr-2 h-4 w-4" />
Re-issue set-password invites
</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Alert message={error} />
<Alert tone="success" message={success} />
{#if !staffRoleApiOk}
<Alert
tone="info"
message={i18n.t("admin.users.staffRoleUnavailable")}
/>
{/if}
<div class="mb-4 flex flex-wrap items-end gap-3">
<div class="relative min-w-0 flex-1 basis-full sm:basis-auto">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={tab === "users" ? "Search users..." : "Search companies..."}
class="h-9 pl-8"
aria-label={tab === "users" ? "Search users" : "Search companies"}
bind:value={search}
onkeydown={(e) => {
if (e.key === "Enter") void applySearch();
}}
/>
</div>
<Button size="sm" variant="outline" loading={busy} onclick={() => applySearch()}>{i18n.t("admin.users.search")}</Button>
{#if tab === "users"}
<Button
size="sm"
variant={staffOnly ? "default" : "outline"}
aria-pressed={staffOnly}
onclick={() => toggleStaffOnly()}
>
Staff only
</Button>
{:else}
<Button
size="sm"
variant={withoutPlan ? "default" : "outline"}
aria-pressed={withoutPlan}
onclick={() => toggleWithoutPlan()}
>
{i18n.t("admin.users.filterWithoutPlan")}
</Button>
<Button
size="sm"
variant={withoutApiKeys ? "default" : "outline"}
aria-pressed={withoutApiKeys}
data-testid="admin-companies-without-api-keys"
onclick={() => toggleWithoutApiKeys()}
>
{i18n.t("admin.users.filterWithoutApiKeys")}
</Button>
{/if}
</div>
<Tabs
value={tab}
onValueChange={(v) => onTabChange(v)}
class="mb-8 w-full"
>
<TabsList class="mb-4 grid w-full max-w-md grid-cols-2">
<TabsTrigger value="users">{i18n.t("admin.users.tabUsers")}</TabsTrigger>
<TabsTrigger value="companies">{i18n.t("admin.users.tabCompanies")}</TabsTrigger>
</TabsList>
<TabsContent value="users" class="mt-0">
<Card>
<CardHeader class="pb-2">
<CardTitle>Users ({formatCredits(usersTotal)})</CardTitle>
<CardDescription>
Assign platform staff roles (Admin, Developer, or Support staff).
</CardDescription>
</CardHeader>
<CardContent>
{#if users.length === 0}
<EmptyState message={i18n.t("empty.admin.noUsersMatch")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.users.colName")}</TableHead>
<TableHead class="hidden md:table-cell">{i18n.t("admin.users.colStaffRole")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.users.colStatus")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.users.colCreated")}</TableHead>
<TableHead stickyRight>{i18n.t("admin.users.colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each users as user}
{@const role = user.staff_role || user.resolved_role}
<TableRow>
<TableCell>
<p class="font-medium text-foreground">{user.name || "—"}</p>
<p class="text-xs text-muted-foreground">{user.email}</p>
<div class="mt-1 flex flex-wrap gap-1 md:hidden">
<Badge variant={staffRoleBadgeVariant(role)}>{displayRole(user)}</Badge>
{#if user.must_set_password}
<Badge variant="warning">{i18n.t("admin.users.mustSetPassword")}</Badge>
{/if}
{#if user.is_active === false}
<Badge variant="outline">{i18n.t("admin.users.inactive")}</Badge>
{/if}
</div>
</TableCell>
<TableCell class="hidden md:table-cell">
<Badge variant={staffRoleBadgeVariant(role)}>{displayRole(user)}</Badge>
{#if user.must_set_password}
<Badge variant="warning" class="ml-1">{i18n.t("admin.users.mustSetPassword")}</Badge>
{/if}
</TableCell>
<TableCell class="hidden lg:table-cell">
{#if user.is_active !== false}
<span class="inline-flex items-center gap-2 text-sm">
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
Active
</span>
{:else}
<Badge variant="outline">{i18n.t("admin.users.inactive")}</Badge>
{/if}
</TableCell>
<TableCell class="hidden text-muted-foreground lg:table-cell">{formatDate(user.created_at)}</TableCell>
<TableCell stickyRight>
<div class="flex flex-wrap items-center justify-end gap-1">
<Button
size="sm"
variant="outline"
onclick={() => openRoleDialog(user)}
aria-label={`Staff role for ${user.email}`}
>
<Shield class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.colStaffRole")}</span>
</Button>
{#if user.must_set_password}
<Button
size="sm"
variant="outline"
loading={busy}
onclick={() => sendSetPasswordEmails(user.id)}
aria-label={`Re-issue invite for ${user.email}`}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.reissueInvite")}</span>
</Button>
{/if}
{#if devTools}
<Button
size="sm"
variant="outline"
loading={busyUserId === user.id}
onclick={() => setDevPassword(user.id)}
aria-label={`Set local password for ${user.email}`}
>
<KeyRound class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.setLocalPassword")}</span>
</Button>
<Button
size="sm"
loading={busyUserId === user.id}
onclick={() => switchToUser(user.id)}
aria-label={`Switch to ${user.email}`}
>
<Users class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.switchToUser")}</span>
</Button>
{/if}
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
<div class="mt-4 flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
Page {usersPage} of {usersPages} · {formatCredits(usersTotal)} total
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={usersOffset <= 0 || busy}
onclick={() => changeUsersPage(-1)}
>
Previous
</Button>
<Button
size="sm"
variant="outline"
disabled={usersOffset + PAGE_SIZE >= usersTotal || busy}
onclick={() => changeUsersPage(1)}
>
Next
</Button>
</div>
</div>
{/if}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="companies" class="mt-0">
<Card>
<CardHeader class="pb-2">
<div class="flex items-center gap-2">
<Building2 class="h-5 w-5 text-muted-foreground" />
<CardTitle>Companies ({formatCredits(companiesTotal)})</CardTitle>
</div>
<CardDescription>
View active plan (public / legacy / custom) and assign a plan. Credits from company balance.
</CardDescription>
</CardHeader>
<CardContent>
{#if companies.length === 0}
<EmptyState message={i18n.t("empty.admin.noCompaniesMatchFilter")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.users.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.users.colPlan")}</TableHead>
<TableHead class="hidden sm:table-cell">{i18n.t("admin.users.colCredits")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.users.colLanguage")}</TableHead>
<TableHead stickyRight>{i18n.t("admin.users.colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each companies as company}
{@const planBadge = companyPlanBadge(company)}
{@const total = Number(company.total_credits ?? 0)}
{@const used = Number(company.used_credits ?? 0)}
{@const remaining = Math.max(total - used, 0)}
<TableRow>
<TableCell>
<p class="font-medium text-foreground">{company.name}</p>
<p class="truncate font-mono text-xs text-muted-foreground" title={company.id}>
{company.id}
</p>
<p class="mt-1 text-xs tabular-nums text-muted-foreground sm:hidden">
{formatCredits(remaining)} / {formatCredits(total)} credits
</p>
</TableCell>
<TableCell>
<div class="flex flex-wrap items-center gap-1.5">
<Badge variant={planBadge.variant}>{planBadge.label}</Badge>
{#if company.has_api_key === false}
<Badge variant="warning">{i18n.t("admin.users.needsApiKeyReissue")}</Badge>
{/if}
</div>
</TableCell>
<TableCell class="hidden tabular-nums text-foreground sm:table-cell">
{formatCredits(remaining)}
<span class="text-xs text-muted-foreground">
/ {formatCredits(total)}
</span>
</TableCell>
<TableCell class="hidden text-muted-foreground lg:table-cell">{company.language || "—"}</TableCell>
<TableCell stickyRight>
<Button
size="sm"
onclick={() => openAssignDialog(company)}
aria-label={`Assign plan to ${company.name}`}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.assignPlan")}</span>
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
<div class="mt-4 flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
Page {companiesPage} of {companiesPages} · {formatCredits(companiesTotal)} total
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={companiesOffset <= 0 || busy}
onclick={() => changeCompaniesPage(-1)}
>
Previous
</Button>
<Button
size="sm"
variant="outline"
disabled={companiesOffset + PAGE_SIZE >= companiesTotal || busy}
onclick={() => changeCompaniesPage(1)}
>
Next
</Button>
</div>
</div>
{/if}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/if}
</PageShell>
<Dialog
bind:open={roleOpen}
title={i18n.t("admin.users.assignRoleTitle")}
description={i18n.t("admin.users.assignRoleDesc")}
>
<form class="space-y-4" onsubmit={saveStaffRole}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
{#if roleUser}
<p class="text-sm text-muted-foreground">
{roleUser.name || "—"} · {roleUser.email}
</p>
{/if}
<div class="space-y-2">
<Label for="staff-role">{i18n.t("admin.users.staffRole")}</Label>
<Select id="staff-role" bind:value={roleValue}>
{#each STAFF_ROLE_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</Select>
</div>
<p class="text-xs text-muted-foreground">
You cannot change your own staff role. Clearing a role removes platform admin access.
</p>
<Button type="submit" loading={busy}>{i18n.t("admin.users.saveRole")}</Button>
</form>
</Dialog>
<Dialog
bind:open={assignOpen}
title={i18n.t("admin.users.assignPlanTitle")}
description={i18n.t("admin.users.assignPlanDesc")}
>
<form class="space-y-4" onsubmit={saveAssignPlan}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
{#if assignCompany}
<p class="text-sm text-muted-foreground">{assignCompany.name}</p>
{/if}
<div class="space-y-2">
<Label for="assign-plan">{i18n.t("admin.users.colPlan")}</Label>
<Select id="assign-plan" bind:value={assignPlanId} required>
<option value="">{i18n.t("admin.users.selectPlan")}</option>
{#each plans as p}
<option value={String(p.id)}>{planOptionLabel(p)}</option>
{/each}
</Select>
</div>
<Button type="submit" loading={busy} disabled={!assignPlanId}>{i18n.t("admin.users.assign")}</Button>
</form>
</Dialog>
File diff suppressed because it is too large Load Diff
+794
View File
@@ -0,0 +1,794 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { CreditCard, FileText, ExternalLink, Wallet } from "@lucide/svelte";
import { api, ApiError, failureMessage } from "$lib/api";
import {
claimPurchaseTracking,
resolveCheckoutEcommerceFromParams,
trackEcommerceEvent
} from "$lib/analytics";
import { formatCredits, formatDate } from "$lib/utils";
import {
CREDIT_USAGE_ITEMS,
billingRecovery,
canUseAIFromCredits,
formatCreditsRemaining,
formatMonthlyCredits,
formatSkuUsage,
hasActivePlan,
isEnterprisePlan,
isFreePlan,
isLegacyPlan,
isPayAsYouGoPlan,
planDisplayName,
remainingCreditsOf,
upgradeCtaForRole,
withUpgradeHint,
nextSelfServeUpgradePlan,
planCheckoutDisplayName,
type PlanLike
} from "$lib/billing-display";
import { isCompanyAdmin } from "$lib/company-admin";
import {
fetchStripeStatus,
openBillingPortal,
startCheckout,
startCreditPackCheckout,
redirectToCheckout,
type StripeStatus
} from "$lib/stripe-billing";
import { CREDIT_PACKS } from "$lib/components/pricing/credit-packs";
import type { CreditBalance, MeResponse } from "$lib/types";
import { i18n } from "$lib/i18n";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import UpgradeBanner from "$lib/components/UpgradeBanner.svelte";
import AdminSeriesChart from "$lib/components/AdminSeriesChart.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Dialog,
Input,
Label,
Select,
buttonClasses
} from "$lib/components/ui";
type DateRangeOption = "7d" | "30d" | "cycle" | "all";
type UsageDayPoint = {
date: string;
products?: number;
tokens?: number;
};
type UsageSummary = {
company_id?: string;
range?: string;
credits_used?: number;
credits_total?: number;
credits_remaining?: number;
products_processed?: number;
products_total?: number;
tokens?: number;
feeds_input?: number;
feeds_export?: number;
jobs_total?: number;
cycle_start?: string | null;
cycle_end?: string | null;
series?: UsageDayPoint[];
notes?: string[];
};
let credits = $state<CreditBalance | null>(null);
let usage = $state<UsageSummary | null>(null);
let stripe = $state<StripeStatus | null>(null);
let companyId = $state("");
let isPlatformAdmin = $state(false);
let canManageBilling = $state(false);
let error = $state("");
let success = $state("");
let loading = $state(true);
let dateRangeOption = $state<DateRangeOption>("30d");
let addCreditsOpen = $state(false);
let creditAmount = $state("1000");
let addingCredits = $state(false);
let checkoutBusy = $state(false);
let packCheckoutBusy = $state<string | null>(null);
let portalBusy = $state(false);
let usageLoading = $state(false);
onMount(async () => {
try {
const me = await api<MeResponse>("/api/auth/me");
companyId = me.company?.id ?? me.active_company_id ?? "";
isPlatformAdmin = Boolean(me.user?.is_platform_admin);
canManageBilling = isCompanyAdmin(me);
const [creditsRes, usageRes, stripeRes] = await Promise.all([
me.credits
? Promise.resolve(me.credits)
: api<CreditBalance>("/api/billing/credits").catch(() => null),
api<UsageSummary>(`/api/billing/usage?range=${dateRangeOption}`).catch(() => null),
fetchStripeStatus().catch(() => null)
]);
credits = creditsRes;
usage = usageRes;
stripe = stripeRes;
const checkout = page.url.searchParams.get("checkout");
const portal = page.url.searchParams.get("portal");
const mockPlan = page.url.searchParams.get("plan");
const mockPack = page.url.searchParams.get("pack");
const mockCredits = page.url.searchParams.get("credits");
const checkoutEcommerce = resolveCheckoutEcommerceFromParams(page.url.searchParams);
if (checkout === "success") {
if (mockPack || mockCredits) {
success = i18n.t("billing.checkoutCompletePack", {
credits: mockCredits || "…"
});
} else {
success = mockPlan
? i18n.t("billing.checkoutCompletePlan", { plan: mockPlan })
: i18n.t("billing.checkoutComplete");
}
const purchaseId = checkoutEcommerce?.ecommerce.transaction_id;
const storage =
typeof sessionStorage !== "undefined" ? sessionStorage : null;
if (checkoutEcommerce && claimPurchaseTracking(purchaseId, storage)) {
trackEcommerceEvent(
"purchase",
checkoutEcommerce.ecommerce,
checkoutEcommerce.extra
);
}
credits = await api<CreditBalance>("/api/billing/credits").catch(() => credits);
stripe = await fetchStripeStatus().catch(() => stripe);
} else if (checkout === "cancel") {
error = i18n.t("flash.billing.checkoutCanceled");
if (checkoutEcommerce) {
const { ecommerce, extra } = checkoutEcommerce;
trackEcommerceEvent(
"checkout_canceled",
{
currency: ecommerce.currency,
value: ecommerce.value,
items: ecommerce.items
},
extra
);
}
} else if (portal === "mock") {
success = i18n.t("flash.billing.portalReturn");
}
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("billing.loadFailed"));
} finally {
loading = false;
}
});
const remaining = $derived.by(() => {
if (typeof usage?.credits_remaining === "number") {
return Math.max(0, usage.credits_remaining);
}
return remainingCreditsOf(credits);
});
const walletUsed = $derived(
typeof usage?.credits_used === "number" ? usage.credits_used : (credits?.used_credits ?? 0)
);
const plan = $derived.by((): PlanLike | null => {
if (!credits?.plan || typeof credits.plan !== "object") return null;
return credits.plan as PlanLike;
});
const planAssigned = $derived(hasActivePlan(credits, plan));
const planName = $derived(planDisplayName(plan, credits));
const enterprise = $derived(planAssigned && isEnterprisePlan(plan));
const freePlan = $derived(isFreePlan(plan, credits ?? undefined));
const payg = $derived(isPayAsYouGoPlan(plan, credits));
const canUseAI = $derived(canUseAIFromCredits(credits));
const upgradeCta = $derived(upgradeCtaForRole(canManageBilling));
const recovery = $derived(
billingRecovery({
credits,
plan,
subscriptionStatus: stripe?.subscription_status,
canManageBilling
})
);
const isContractExpiringSoon = $derived.by(() => {
const end = usage?.cycle_end ?? plan?.next_billing_date;
if (!end) return false;
const endDate = new Date(end);
if (Number.isNaN(endDate.getTime())) return false;
const days = Math.ceil((endDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return days <= 30 && days >= 0;
});
const rangeLabel = $derived.by(() => {
switch (dateRangeOption) {
case "7d":
return i18n.t("billing.range.7d");
case "30d":
return i18n.t("billing.range.30d");
case "cycle":
return i18n.t("billing.range.cycle");
default:
return i18n.t("billing.range.all");
}
});
const chartPoints = $derived(
(usage?.series ?? []).map((p) => ({
label: p.date.slice(5),
value: Number(p.products ?? 0),
secondary: Number(p.tokens ?? 0)
}))
);
const hasSeries = $derived(chartPoints.some((p) => p.value > 0 || (p.secondary ?? 0) > 0));
const catalogProductCount = $derived(
typeof credits?.product_count === "number"
? credits.product_count
: (usage?.products_total ?? 0)
);
const hasCatalogActivity = $derived(
catalogProductCount > 0 || (usage?.feeds_input ?? 0) > 0 || walletUsed > 0
);
const outOfCredits = $derived(!freePlan && !enterprise && remaining !== null && remaining <= 0);
const lowCredits = $derived(
Boolean(credits?.low_credits) && !outOfCredits && !freePlan && !enterprise && !payg
);
const atProductLimit = $derived(Boolean(credits?.at_product_limit) && !enterprise);
const nextUpgradePlan = $derived(nextSelfServeUpgradePlan(planName));
const showQuickUpgrade = $derived(
canManageBilling &&
!enterprise &&
!payg &&
!isLegacyPlan(plan) &&
!plan?.is_custom &&
nextUpgradePlan !== null &&
(freePlan || Boolean(plan))
);
const remainingLabel = $derived(formatCreditsRemaining(remaining, plan, credits));
const planBillingLabel = $derived.by(() => {
if (!planAssigned) return i18n.t("billing.noPlanAssigned");
if (enterprise) return i18n.t("billing.unlimited");
if (payg) return i18n.t("billing.payAsYouGo");
if (freePlan) return i18n.t("billing.noMonthlyAiCredits");
return formatMonthlyCredits(plan);
});
const headerPlanBlurb = $derived.by(() => {
if (!planAssigned || !plan) {
return i18n.t("billing.header.noPlan");
}
if (freePlan) return i18n.t("billing.header.free");
if (enterprise) return i18n.t("billing.header.enterprise");
if (payg) return i18n.t("billing.header.payg");
return i18n.t("billing.header.remaining", { remaining: remainingLabel });
});
const maxProducts = $derived(
typeof credits?.max_products === "number"
? credits.max_products
: typeof plan?.max_products === "number"
? plan.max_products
: plan?.max_products === null
? null
: undefined
);
const showPortal =
$derived(
Boolean(stripe && (stripe.has_customer || stripe.has_subscription || stripe.mock))
);
function formatContractDate(value: string | Date | null | undefined): string {
if (!value) return i18n.t("billing.na");
const d = value instanceof Date ? value : new Date(value);
if (Number.isNaN(d.getTime())) return i18n.t("billing.na");
return formatDate(d);
}
async function loadUsage(range: DateRangeOption) {
usageLoading = true;
dateRangeOption = range;
try {
usage = await api<UsageSummary>(`/api/billing/usage?range=${range}`);
} catch (err) {
error = failureMessage(err, i18n.t("billing.usageLoadFailed"));
} finally {
usageLoading = false;
}
}
async function submitAddCredits() {
const amount = Number(creditAmount);
if (!amount || amount <= 0) {
error = i18n.t("flash.billing.invalidAmount");
return;
}
if (!companyId) {
error = i18n.t("flash.billing.companyNotFound");
return;
}
addingCredits = true;
error = "";
success = "";
try {
await api("/api/admin/credits", {
method: "POST",
body: { company_id: companyId, amount }
});
success = i18n.t("flash.billing.creditsAdded", { amount: formatCredits(amount) });
addCreditsOpen = false;
credits = await api<CreditBalance>("/api/billing/credits");
await loadUsage(dateRangeOption);
} catch (err) {
error =
err instanceof ApiError
? err.message
: i18n.t("billing.addCreditsFailed");
} finally {
addingCredits = false;
}
}
function openAddCredits() {
error = "";
success = "";
creditAmount = "1000";
addCreditsOpen = true;
}
async function upgradeTo(planKey: string) {
checkoutBusy = true;
error = "";
try {
const result = await startCheckout(planKey, "monthly");
if (result.mock && result.applied) {
success = result.message ?? i18n.t("billing.planApplied");
credits = await api<CreditBalance>("/api/billing/credits");
stripe = await fetchStripeStatus().catch(() => stripe);
}
redirectToCheckout(result);
} catch (err) {
error = failureMessage(err, i18n.t("billing.checkoutFailed"));
} finally {
checkoutBusy = false;
}
}
async function buyCreditPack(packId: string) {
packCheckoutBusy = packId;
error = "";
try {
const result = await startCreditPackCheckout(packId);
if (result.mock && result.applied) {
success = result.message ?? i18n.t("billing.checkoutComplete");
credits = await api<CreditBalance>("/api/billing/credits");
stripe = await fetchStripeStatus().catch(() => stripe);
}
redirectToCheckout(result);
} catch (err) {
error = failureMessage(err, i18n.t("billing.packCheckoutFailed"));
} finally {
packCheckoutBusy = null;
}
}
async function manageSubscription() {
portalBusy = true;
error = "";
try {
const result = await openBillingPortal();
if (result.url) window.location.assign(result.url);
} catch (err) {
error = failureMessage(err, i18n.t("billing.portalFailed"));
} finally {
portalBusy = false;
}
}
</script>
{#if loading}
<div class="flex justify-center py-16">
<Spinner label={i18n.t("common.loading")} />
</div>
{:else}
<div class="mx-auto max-w-5xl space-y-6" data-tour="billing-page">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div class="min-w-0">
<h1 class="text-2xl font-bold tracking-tight sm:text-3xl">{i18n.t("billing.title")}</h1>
<p class="mt-1 text-sm text-muted-foreground">
{#if planAssigned && plan}
<span class="font-medium text-foreground">{planName}</span>
· {headerPlanBlurb}
{:else}
{headerPlanBlurb}
{/if}
</p>
</div>
<div class="flex flex-wrap gap-2">
{#if canManageBilling}
<a href="/plans" class={buttonClasses("outline", "default", "")}>{i18n.t("billing.comparePlans")}</a>
{#if showPortal}
<Button variant="outline" loading={portalBusy} onclick={() => void manageSubscription()}>
{i18n.t("billing.managePayment")}
</Button>
{/if}
{:else}
<a href={upgradeCta.primaryHref} class={buttonClasses("outline", "default", "")}>
{upgradeCta.primaryLabel}
</a>
{/if}
{#if isPlatformAdmin}
<Button onclick={openAddCredits}>
<Wallet class="mr-2 h-4 w-4" />
{i18n.t("billing.addCredits")}
</Button>
{/if}
</div>
</div>
<Alert message={error} />
<Alert tone="success" message={success} />
{#if recovery}
<UpgradeBanner
tone={recovery.tone}
title={recovery.title}
message={recovery.message}
primaryHref={recovery.primaryHref}
primaryLabel={recovery.primaryLabel}
showSales={recovery.showSales}
primaryOnClick={
recovery.openPortal && canManageBilling
? () => void manageSubscription()
: undefined
}
/>
{:else if freePlan}
<UpgradeBanner
tone="info"
title={i18n.t("billing.freeBannerTitle")}
message={withUpgradeHint(
i18n.t("billing.freeBannerMessage"),
upgradeCta
)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={canManageBilling ? i18n.t("billing.comparePlans") : upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{:else if outOfCredits || atProductLimit}
<UpgradeBanner
tone="danger"
title={outOfCredits ? i18n.t("billing.outOfCreditsTitle") : i18n.t("billing.productLimitTitle")}
message={withUpgradeHint(
outOfCredits
? i18n.t("billing.outOfCreditsMessage")
: i18n.t("billing.productLimitMessage"),
upgradeCta
)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{:else if lowCredits}
<UpgradeBanner
tone="warning"
title={i18n.t("billing.lowCreditsTitle")}
message={withUpgradeHint(
i18n.t("billing.lowCreditsMessage", { remaining: remainingLabel, plan: planName }),
upgradeCta
)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={canManageBilling ? i18n.t("billing.comparePlans") : upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{/if}
{#if credits}
<div class="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("billing.currentPlan")}</CardTitle>
<FileText class="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent class="space-y-1">
<div class="text-2xl font-bold">{planName}</div>
<p class="text-xs text-muted-foreground">{planBillingLabel}</p>
<p class="text-xs text-muted-foreground">
{formatSkuUsage(credits?.product_count, maxProducts ?? plan?.max_products, plan)}
</p>
{#if isContractExpiringSoon && !enterprise && !payg}
<p class="text-xs font-medium text-amber-600">
{i18n.t("billing.periodEnds", { date: formatContractDate(usage?.cycle_end ?? plan?.next_billing_date) })}
</p>
{:else if payg}
<p class="text-xs text-muted-foreground">{i18n.t("billing.openEndedContract")}</p>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">
{payg ? i18n.t("billing.creditWallet") : i18n.t("billing.creditsRemaining")}
</CardTitle>
<CreditCard class="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent class="space-y-1">
{#if enterprise}
<div class="text-2xl font-bold">{remainingLabel}</div>
<p class="text-xs text-muted-foreground">{i18n.t("billing.enterpriseCapacity")}</p>
{:else if payg}
<div class="text-2xl font-bold">{i18n.t("billing.payAsYouGo")}</div>
<p class="text-xs text-muted-foreground">
{i18n.t("billing.paygWallet", {
remaining: formatCredits(remaining ?? 0)
})}
</p>
{:else if freePlan}
<div class="text-2xl font-bold">{remainingLabel}</div>
<p class="text-xs text-muted-foreground">
{canUseAI ? i18n.t("billing.trialLeftover") : i18n.t("billing.aiLockedFree")}
</p>
{:else}
<div class="text-2xl font-bold">{remainingLabel}</div>
<p class="text-xs text-muted-foreground">
{i18n.t("billing.usedOf", {
used: formatCredits(walletUsed),
total: formatCredits(usage?.credits_total ?? credits.total_credits)
})}
</p>
{/if}
</CardContent>
</Card>
</div>
{:else}
<div class="rounded-lg border border-dashed border-border bg-card px-4 py-8 text-center">
<p class="text-sm font-medium text-foreground">{i18n.t("billing.noBalanceTitle")}</p>
<p class="mt-1 text-sm text-muted-foreground">
{i18n.t("billing.noBalanceBody")}
</p>
<div class="mt-4 flex flex-wrap justify-center gap-2">
{#if canManageBilling}
<a href="/plans" class={buttonClasses("default", "sm", "")}>{i18n.t("billing.comparePlans")}</a>
{:else}
<a href={upgradeCta.primaryHref} class={buttonClasses("outline", "sm", "")}>
{upgradeCta.primaryLabel}
</a>
{/if}
</div>
</div>
{/if}
{#if showQuickUpgrade}
<section class="rounded-lg border border-border bg-card px-4 py-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-sm font-medium text-foreground">{i18n.t("billing.needCapacity")}</p>
<p class="text-xs text-muted-foreground">
{i18n.t("billing.selfServeUpgrade")}
{#if stripe?.configured && !stripe?.mock}
{i18n.t("billing.viaStripe")}
{/if}.
</p>
</div>
<div class="flex flex-wrap gap-2">
{#if nextUpgradePlan}
<Button size="sm" loading={checkoutBusy} onclick={() => void upgradeTo(nextUpgradePlan)}>
{i18n.t("plans.cta.upgradeTo", { name: planCheckoutDisplayName(nextUpgradePlan) })}
</Button>
{/if}
<a href="/plans" class={buttonClasses("outline", "sm", "")}>{i18n.t("billing.comparePlans")}</a>
</div>
</div>
</section>
{:else if !canManageBilling && (freePlan || outOfCredits || atProductLimit || lowCredits)}
<section class="rounded-lg border border-border bg-muted/30 px-4 py-4">
<p class="text-sm font-medium text-foreground">{i18n.t("billing.needCapacity")}</p>
<p class="mt-1 text-sm text-muted-foreground">
{upgradeCta.memberHint ?? i18n.t("billing.askAdminPlan")}
</p>
<div class="mt-3">
<a href={upgradeCta.primaryHref} class={buttonClasses("outline", "sm", "")}>
{upgradeCta.primaryLabel}
</a>
</div>
</section>
{/if}
{#if canManageBilling && !enterprise && planAssigned}
<section class="space-y-3 rounded-lg border border-border bg-card px-4 py-4">
<div>
<h2 class="text-base font-semibold tracking-tight">{i18n.t("billing.buyCreditPacks")}</h2>
<p class="mt-1 text-xs text-muted-foreground">{i18n.t("billing.buyCreditPacksHint")}</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
{#each CREDIT_PACKS as pack (pack.id)}
<div class="flex flex-col gap-2 rounded-md border border-border px-3 py-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-sm font-medium text-foreground">{i18n.t(pack.nameKey)}</p>
<p class="text-xs text-muted-foreground">{i18n.t(pack.descriptionKey)}</p>
</div>
<p class="shrink-0 text-sm font-semibold tabular-nums">${pack.priceUSD}</p>
</div>
<p class="text-xs text-muted-foreground">
{i18n.t("billing.packCredits", { credits: pack.credits.toLocaleString() })}
· {i18n.t("billing.packApproxProducts", { count: pack.aiProducts.toLocaleString() })}
</p>
<Button
size="sm"
variant="outline"
class="w-full"
loading={packCheckoutBusy === pack.id}
disabled={packCheckoutBusy !== null}
onclick={() => void buyCreditPack(pack.id)}
>
{i18n.t("billing.buyPack")}
</Button>
</div>
{/each}
</div>
</section>
{/if}
<section class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-base font-semibold tracking-tight">{i18n.t("billing.usage")}</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("billing.productsProcessed", { count: (usage?.products_processed ?? 0).toLocaleString(), range: rangeLabel })}
{#if usageLoading}
· {i18n.t("billing.updating")}
{/if}
</p>
</div>
<Select
class="w-[180px]"
bind:value={dateRangeOption}
onchange={(e) => {
const next = (e.currentTarget as HTMLSelectElement).value as DateRangeOption;
void loadUsage(next);
}}
>
<option value="7d">{i18n.t("billing.rangeOpt.7d")}</option>
<option value="30d">{i18n.t("billing.rangeOpt.30d")}</option>
{#if usage?.cycle_start || plan?.next_billing_date}
<option value="cycle">{i18n.t("billing.rangeOpt.cycle")}</option>
{/if}
<option value="all">{i18n.t("billing.rangeOpt.all")}</option>
</Select>
</div>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("billing.productsByDay")}</CardTitle>
<CardDescription>
{#if isPlatformAdmin}
{i18n.t("billing.chartAmber", { range: rangeLabel })}
{:else}
{i18n.t("billing.chartRange", { range: rangeLabel })}
{/if}
</CardDescription>
</CardHeader>
<CardContent>
{#if !hasCatalogActivity}
<div class="py-8 text-center">
<p class="text-sm font-medium text-foreground">{i18n.t("billing.nothingToChartTitle")}</p>
<p class="mt-1 text-sm text-muted-foreground">
{i18n.t("billing.nothingToChartBody")}
</p>
</div>
{:else if dateRangeOption === "all"}
<div class="py-6 text-center text-sm text-muted-foreground">
{i18n.t("billing.dailyChartsHint")}
</div>
{:else if !hasSeries}
<div class="py-8 text-center">
<p class="text-sm font-medium text-foreground">{i18n.t("billing.noNewProducts", { range: rangeLabel })}</p>
<p class="mt-1 text-sm text-muted-foreground">
{i18n.t("billing.catalogOverall", { count: catalogProductCount.toLocaleString() })}
</p>
</div>
{:else}
<AdminSeriesChart
points={chartPoints}
primaryLabel={i18n.t("billing.chartProducts")}
secondaryLabel={isPlatformAdmin ? i18n.t("billing.chartTokens") : undefined}
emptyMessage={i18n.t("billing.chartEmpty")}
/>
{/if}
{#if isPlatformAdmin}
<p class="mt-3 text-xs text-muted-foreground">
{i18n.t("billing.adminStats", {
tokens: (usage?.tokens ?? 0).toLocaleString(),
jobs: (usage?.jobs_total ?? 0).toLocaleString(),
input: (usage?.feeds_input ?? 0).toLocaleString(),
export: (usage?.feeds_export ?? 0).toLocaleString()
})}
</p>
{/if}
</CardContent>
</Card>
</section>
<details class="rounded-lg border border-border bg-card px-4 py-3">
<summary class="cursor-pointer text-sm font-medium text-foreground">
{i18n.t("billing.whatUsesCredits")}
</summary>
<ul class="mt-3 grid gap-2 sm:grid-cols-2">
{#each CREDIT_USAGE_ITEMS as item (item.labelKey)}
<li class="rounded-md border border-border/60 px-3 py-2">
<div class="flex items-center gap-2">
{#if item.burns}
<Badge variant="secondary">{i18n.t("billing.burnsCredits")}</Badge>
{:else}
<Badge variant="outline">{i18n.t("billing.noCreditCost")}</Badge>
{/if}
<span class="text-sm font-medium">{i18n.t(item.labelKey)}</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">{i18n.t(item.detailKey)}</p>
</li>
{/each}
</ul>
</details>
<div class="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<a
href="/pricing"
class="inline-flex items-center font-medium text-foreground underline-offset-4 hover:underline"
>
{i18n.t("billing.publicPricing")}
<ExternalLink class="ml-1 h-3.5 w-3.5" />
</a>
{#if canManageBilling}
<a href="/plans" class="font-medium text-foreground underline-offset-4 hover:underline">
{i18n.t("billing.managePlanOnPlans")}
</a>
{/if}
</div>
</div>
{/if}
{#if isPlatformAdmin}
<Dialog
bind:open={addCreditsOpen}
title={i18n.t("billing.addCreditsTitle")}
description={i18n.t("billing.addCreditsDescription")}
class="sm:max-w-md sm:min-w-0"
>
<div class="flex flex-col gap-4 py-4">
<div class="flex flex-col gap-2">
<Label for="credit-amount">{i18n.t("billing.creditAmount")}</Label>
<Input id="credit-amount" type="number" min="100" bind:value={creditAmount} />
</div>
</div>
{#snippet footer()}
<Button variant="outline" disabled={addingCredits} onclick={() => (addCreditsOpen = false)}>
{i18n.t("common.cancel")}
</Button>
<Button loading={addingCredits} onclick={submitAddCredits}>
{addingCredits ? i18n.t("billing.addingCredits") : i18n.t("billing.addCredits")}
</Button>
{/snippet}
</Dialog>
{/if}
+378
View File
@@ -0,0 +1,378 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, ApiError, failureMessage } from "$lib/api";
import { notifySuccess, notifyError, notifyApiError } from "$lib/notify";
import { isCompanyAdmin } from "$lib/company-admin";
import { authSession } from "$lib/auth-session.svelte";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import type { BrandKitResponse, MeResponse } from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Textarea
} from "$lib/components/ui";
import { Palette, Save, Upload } from "@lucide/svelte";
let loading = $state(true);
let saving = $state(false);
let uploading = $state(false);
let canAdmin = $state(false);
let error = $state("");
let success = $state("");
let aiApplyAllowed = $state(false);
let tips = $state<string[]>([]);
let voiceTone = $state("");
let dosText = $state("");
let dontsText = $state("");
let preferredTermsText = $state("");
let primaryColor = $state("");
let secondaryColor = $state("");
let logoUrl = $state("");
const logoAccept = "image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp";
function linesToList(text: string): string[] {
return text
.split(/\r?\n|,/)
.map((s) => s.trim())
.filter(Boolean);
}
function listToLines(list: string[] | undefined): string {
return (list ?? []).join("\n");
}
function applyPayload(res: BrandKitResponse) {
const b = res.brand ?? {};
voiceTone = b.voice_tone ?? "";
dosText = listToLines(b.dos);
dontsText = listToLines(b.donts);
preferredTermsText = listToLines(b.preferred_terms);
primaryColor = b.primary_color ?? "";
secondaryColor = b.secondary_color ?? "";
logoUrl = b.logo_url ?? "";
aiApplyAllowed =
Boolean(res.ai_apply_allowed) &&
planCapabilities.can("marketing.brand_ai_apply") &&
planCapabilities.can("capability.brand_ai_apply");
tips = res.tips ?? [];
}
onMount(async () => {
try {
const me = await api<MeResponse>("/api/auth/me");
authSession.setMe(me);
canAdmin = isCompanyAdmin(me);
} catch {
canAdmin = authSession.isCompanyAdmin;
}
try {
const res = await api<BrandKitResponse>("/api/brand");
applyPayload(res);
} 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))) {
error = failureMessage(err, i18n.t("brand.flash.loadFailed"));
}
} finally {
loading = false;
}
});
async function onSave(event: Event) {
event.preventDefault();
if (!canAdmin) return;
saving = true;
error = "";
success = "";
try {
const res = await api<BrandKitResponse>("/api/brand", {
method: "PUT",
body: {
voice_tone: voiceTone,
dos: linesToList(dosText),
donts: linesToList(dontsText),
preferred_terms: linesToList(preferredTermsText),
primary_color: primaryColor,
secondary_color: secondaryColor,
logo_url: logoUrl
}
});
applyPayload(res);
success = i18n.t("flash.brand.saved");
notifySuccess(i18n.t("toast.brand.saved"));
} catch (err) {
error = notifyApiError(err, i18n.t("toast.brand.saveFailed"));
} finally {
saving = false;
}
}
async function onLogoFileChange(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!canAdmin || !file) return;
const lower = file.name.toLowerCase();
const okExt =
lower.endsWith(".png") ||
lower.endsWith(".jpg") ||
lower.endsWith(".jpeg") ||
lower.endsWith(".webp");
if (!okExt) {
error = i18n.t("flash.brand.logoType");
notifyError(error);
return;
}
if (file.size > 2 * 1024 * 1024) {
error = i18n.t("flash.brand.logoSize");
notifyError(error);
return;
}
uploading = true;
error = "";
success = "";
try {
const form = new FormData();
form.append("file", file);
const res = await api<BrandKitResponse>("/api/brand/logo", {
method: "POST",
body: form
});
applyPayload(res);
success = i18n.t("flash.brand.logoUploaded");
notifySuccess(i18n.t("toast.brand.logoUploaded"));
} catch (err) {
error = notifyApiError(err, i18n.t("toast.brand.uploadFailed"));
} finally {
uploading = false;
}
}
function clearLogo() {
if (!canAdmin) return;
logoUrl = "";
}
</script>
<PageShell
title={i18n.t("brand.title")}
description={i18n.t("brand.description")}
>
{#if loading}
<Spinner label={i18n.t("brand.loading")} />
{:else}
<Alert tone="error" message={error} />
<Alert tone="success" message={success} />
{#if !canAdmin}
<Alert
tone="info"
message={i18n.t("brand.adminOnly")}
/>
{/if}
{#if !aiApplyAllowed}
{#if canAdmin}
<Alert
tone="info"
message={i18n.t("brand.aiApplyFreeAdmin")}
/>
<p class="mb-4 text-sm text-muted-foreground">
<a class="underline" href="/plans">{i18n.t("brand.comparePlans")}</a>
</p>
{:else}
<Alert
tone="info"
message={i18n.t("brand.aiApplyGated")}
/>
{/if}
{:else}
<Alert tone="info" message={i18n.t("brand.aiEnhanceUses")} />
{/if}
<form class="space-y-6" onsubmit={onSave}>
<div class="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{i18n.t("brand.voice.title")}</CardTitle>
<CardDescription>{i18n.t("brand.voice.desc")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="voice_tone">{i18n.t("brand.field.voiceTone")}</Label>
<Textarea
id="voice_tone"
bind:value={voiceTone}
rows={3}
disabled={!canAdmin || saving || uploading}
placeholder={i18n.t("brand.placeholder.voiceTone")}
/>
</div>
<div class="space-y-2">
<Label for="dos">{i18n.t("brand.field.dos")}</Label>
<Textarea
id="dos"
bind:value={dosText}
rows={4}
disabled={!canAdmin || saving || uploading}
placeholder={i18n.t("brand.placeholder.dos")}
/>
</div>
<div class="space-y-2">
<Label for="donts">{i18n.t("brand.field.donts")}</Label>
<Textarea
id="donts"
bind:value={dontsText}
rows={4}
disabled={!canAdmin || saving || uploading}
placeholder={i18n.t("brand.placeholder.donts")}
/>
</div>
<div class="space-y-2">
<Label for="preferred_terms">{i18n.t("brand.field.preferredTerms")}</Label>
<Textarea
id="preferred_terms"
bind:value={preferredTermsText}
rows={3}
disabled={!canAdmin || saving || uploading}
placeholder={i18n.t("brand.placeholder.preferredTerms")}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Palette class="h-4 w-4" />
{i18n.t("brand.visual.title")}
</CardTitle>
<CardDescription>{i18n.t("brand.visual.desc")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="primary_color">{i18n.t("brand.field.primaryColor")}</Label>
<div class="flex gap-2">
<Input id="primary_color" bind:value={primaryColor} placeholder="#0F172A" disabled={!canAdmin || saving || uploading} />
{#if primaryColor}
<span
class="h-10 w-10 shrink-0 rounded-md border border-border"
style={`background:${primaryColor}`}
aria-hidden="true"
></span>
{/if}
</div>
</div>
<div class="space-y-2">
<Label for="secondary_color">{i18n.t("brand.field.secondaryColor")}</Label>
<div class="flex gap-2">
<Input id="secondary_color" bind:value={secondaryColor} placeholder="#64748B" disabled={!canAdmin || saving || uploading} />
{#if secondaryColor}
<span
class="h-10 w-10 shrink-0 rounded-md border border-border"
style={`background:${secondaryColor}`}
aria-hidden="true"
></span>
{/if}
</div>
</div>
</div>
{#if canAdmin}
<div class="space-y-2">
<Label for="logo_file">{i18n.t("brand.field.logoImage")}</Label>
<p class="text-xs text-muted-foreground">
{i18n.t("brand.logoHint")}
</p>
<div class="flex flex-wrap items-center gap-2">
<label
class="inline-flex h-10 cursor-pointer items-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
>
<Upload class="h-4 w-4" />
{uploading ? i18n.t("brand.uploading") : i18n.t("brand.uploadLogo")}
<input
id="logo_file"
type="file"
accept={logoAccept}
class="sr-only"
disabled={uploading || saving}
onchange={onLogoFileChange}
/>
</label>
{#if logoUrl}
<Button
type="button"
variant="outline"
size="sm"
onclick={clearLogo}
disabled={uploading || saving}
>
{i18n.t("brand.clear")}
</Button>
{/if}
</div>
</div>
{/if}
<div class="space-y-2">
<Label for="logo_url">{i18n.t("brand.field.logoUrl")}</Label>
<Input
id="logo_url"
bind:value={logoUrl}
placeholder={i18n.t("brand.placeholder.logoUrl")}
disabled={!canAdmin || saving || uploading}
/>
{#if logoUrl}
<img
src={logoUrl}
alt={i18n.t("brand.logoAlt")}
class="mt-2 h-16 max-w-full object-contain"
/>
{/if}
</div>
{#if tips.length > 0}
<div class="rounded-lg border border-border bg-muted/40 p-3">
<p class="mb-2 text-sm font-medium">{i18n.t("brand.tipsTitle")}</p>
<ul class="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{#each tips as tip}
<li>{tip}</li>
{/each}
</ul>
</div>
{/if}
</CardContent>
</Card>
</div>
{#if canAdmin}
<div class="flex justify-end">
<Button type="submit" disabled={saving || uploading}>
<Save class="mr-2 h-4 w-4" />
{saving ? i18n.t("brand.saving") : i18n.t("brand.save")}
</Button>
</div>
{/if}
</form>
{/if}
</PageShell>
+267
View File
@@ -0,0 +1,267 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { Megaphone, Plus, RefreshCw, Search } from "@lucide/svelte";
import { ApiError, failureMessage } from "$lib/api";
import { listCampaigns, deleteCampaign } from "$lib/campaigns/api";
import type { Campaign } from "$lib/campaigns/types";
import { formatDate, formatRelativeTime } from "$lib/utils";
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 StatusBadge from "$lib/components/StatusBadge.svelte";
import FeatureGate from "$lib/components/FeatureGate.svelte";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { i18n } from "$lib/i18n";
import {
Button,
Card,
CardContent,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "$lib/components/ui";
let campaigns = $state<Campaign[]>([]);
let unavailable = $state(false);
let loading = $state(true);
let error = $state("");
let searchQuery = $state("");
let deletingId = $state<string | null>(null);
let campaignsAbort: AbortController | null = null;
let campaignsFetchGen = 0;
const canCreate = $derived(planCapabilities.can("marketing.campaigns.create"));
const filtered = $derived.by(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return campaigns;
return campaigns.filter((c) => {
const name = String(c.name ?? "").toLowerCase();
const season = String(c.template_key ?? c.season ?? "").toLowerCase();
const status = String(c.status ?? "").toLowerCase();
return name.includes(q) || season.includes(q) || status.includes(q);
});
});
function isAbortError(err: unknown): boolean {
return (
(err instanceof DOMException && err.name === "AbortError") ||
(err instanceof Error && err.name === "AbortError")
);
}
async function load() {
campaignsAbort?.abort();
const ac = new AbortController();
campaignsAbort = ac;
const gen = ++campaignsFetchGen;
loading = true;
error = "";
try {
const result = await listCampaigns({ signal: ac.signal });
if (gen !== campaignsFetchGen) return;
campaigns = result.campaigns;
unavailable = result.unavailable;
} catch (err) {
if (isAbortError(err) || gen !== campaignsFetchGen) return;
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("campaigns.loadFailed"));
} finally {
if (gen === campaignsFetchGen) loading = false;
}
}
onMount(() => {
void load();
return () => {
campaignsAbort?.abort();
};
});
async function handleDelete(campaign: Campaign) {
if (!confirm(i18n.t("campaigns.deleteConfirm", { name: campaign.name }))) return;
deletingId = campaign.id;
try {
await deleteCampaign(campaign.id);
campaigns = campaigns.filter((c) => c.id !== campaign.id);
notifySuccess(i18n.t("campaigns.deleted"));
} catch (err) {
notifyApiError(err, i18n.t("campaigns.deleteFailed"));
} finally {
deletingId = null;
}
}
function seasonLabel(campaign: Campaign): string {
const key = String(campaign.template_key ?? campaign.season ?? "").replaceAll("_", " ");
return key || i18n.t("status.emDash");
}
</script>
<PageShell title={i18n.t("campaigns.title")} description={i18n.t("campaigns.description")}>
{#snippet actions()}
<div class="flex flex-wrap gap-2" data-surface="campaigns-page">
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
{i18n.t("common.refresh")}
</Button>
{#if canCreate}
<a href="/campaigns/new" data-surface="campaigns-create">
<Button size="sm">
<Plus class="mr-2 h-4 w-4" />
{i18n.t("campaigns.new")}
</Button>
</a>
{/if}
</div>
{/snippet}
<FeatureGate feature="marketing.campaigns" mode="upgrade">
{#if loading}
<div class="flex justify-center py-16">
<Spinner label={i18n.t("campaigns.loading")} />
</div>
{:else if error}
<Alert message={error} />
{:else}
<div class="space-y-4" data-testid="campaigns-list">
{#if unavailable && campaigns.length > 0}
<Alert tone="info" message={i18n.t("campaigns.partialUnavailable")} />
{/if}
{#if !unavailable || campaigns.length > 0}
<div class="relative max-w-sm">
<Search class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input class="pl-9" placeholder={i18n.t("campaigns.searchPlaceholder")} bind:value={searchQuery} />
</div>
{/if}
{#if filtered.length === 0}
{#if unavailable && campaigns.length === 0}
<EmptyState
title={i18n.t("campaigns.unavailableTitle")}
message={i18n.t("campaigns.unavailableMessage")}
>
{#if canCreate}
<a href="/campaigns/new">
<Button type="button" variant="outline">
<Megaphone class="mr-2 h-4 w-4" />
{i18n.t("campaigns.openWizard")}
</Button>
</a>
{/if}
<a href="/dashboard">
<Button type="button" variant="outline">{i18n.t("campaigns.backDashboard")}</Button>
</a>
<a href="/products">
<Button type="button" variant="outline">{i18n.t("campaigns.browseProducts")}</Button>
</a>
</EmptyState>
{:else}
<EmptyState
title={campaigns.length === 0 ? i18n.t("campaigns.emptyTitle") : i18n.t("campaigns.noMatches")}
message={campaigns.length === 0
? i18n.t("campaigns.emptyMessage")
: i18n.t("campaigns.noMatchesMessage")}
>
{#if campaigns.length === 0}
{#if canCreate}
<a href="/campaigns/new">
<Button>
<Megaphone class="mr-2 h-4 w-4" />
{i18n.t("campaigns.create")}
</Button>
</a>
{/if}
<a href="/products">
<Button variant="outline">{i18n.t("campaigns.browseProducts")}</Button>
</a>
{:else}
{#if canCreate}
<a href="/campaigns/new">
<Button>
<Megaphone class="mr-2 h-4 w-4" />
{i18n.t("campaigns.create")}
</Button>
</a>
{/if}
<Button variant="outline" onclick={() => (searchQuery = "")}>{i18n.t("common.clearSearch")}</Button>
{/if}
</EmptyState>
{/if}
{:else}
<Card class="min-w-0 overflow-hidden">
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("common.nameCol")}</TableHead>
<TableHead>{i18n.t("campaigns.col.season")}</TableHead>
<TableHead>{i18n.t("common.status")}</TableHead>
<TableHead>{i18n.t("campaigns.col.updated")}</TableHead>
<TableHead class="text-right">{i18n.t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each filtered as campaign (campaign.id)}
<TableRow>
<TableCell>
<a
href={`/campaigns/${campaign.id}`}
class="font-medium text-foreground hover:underline"
>
{campaign.name}
</a>
{#if campaign.scheduled_at}
<p class="mt-0.5 text-xs text-muted-foreground">
{i18n.t("campaigns.scheduled", { date: formatDate(campaign.scheduled_at) })}
</p>
{/if}
</TableCell>
<TableCell class="capitalize text-muted-foreground">
{seasonLabel(campaign)}
</TableCell>
<TableCell>
<StatusBadge status={campaign.status ?? "draft"} />
</TableCell>
<TableCell class="text-muted-foreground">
{formatRelativeTime(campaign.updated_at ?? campaign.created_at)}
</TableCell>
<TableCell class="text-right">
<div class="flex justify-end gap-2">
<a href={`/campaigns/${campaign.id}`}>
<Button size="sm" variant="outline">{i18n.t("common.open")}</Button>
</a>
{#if canCreate}
<Button
size="sm"
variant="ghost"
disabled={deletingId === campaign.id}
onclick={() => void handleDelete(campaign)}
>
{i18n.t("common.delete")}
</Button>
{/if}
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
{/if}
</div>
{/if}
</FeatureGate>
</PageShell>
@@ -0,0 +1,29 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { browser } from "$app/environment";
import PageShell from "$lib/components/PageShell.svelte";
import FeatureGate from "$lib/components/FeatureGate.svelte";
import CampaignWizard from "$lib/components/campaigns/CampaignWizard.svelte";
import { i18n } from "$lib/i18n";
/** Route param for /campaigns/[id] only — never reuse this page for other paths. */
const campaignId = $derived(String(page.params.id ?? "").trim());
$effect(() => {
if (!browser) return;
if (!campaignId) {
void goto("/campaigns", { replaceState: true });
}
});
</script>
{#if campaignId}
<PageShell title={i18n.t("campaigns.editTitle")} description={i18n.t("campaigns.editDesc")}>
<FeatureGate feature="marketing.campaigns" mode="upgrade">
{#key campaignId}
<CampaignWizard mode="edit" {campaignId} />
{/key}
</FeatureGate>
</PageShell>
{/if}
@@ -0,0 +1,12 @@
<script lang="ts">
import PageShell from "$lib/components/PageShell.svelte";
import FeatureGate from "$lib/components/FeatureGate.svelte";
import CampaignWizard from "$lib/components/campaigns/CampaignWizard.svelte";
import { i18n } from "$lib/i18n";
</script>
<PageShell title={i18n.t("campaigns.newTitle")} description={i18n.t("campaigns.newDesc")}>
<FeatureGate feature="marketing.campaigns.create" mode="upgrade">
<CampaignWizard mode="create" />
</FeatureGate>
</PageShell>
+314
View File
@@ -0,0 +1,314 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, ApiError, failureMessage } from "$lib/api";
import { unwrapList, TREE_LIST_LIMIT } from "$lib/list";
import { isCompanyAdmin } from "$lib/company-admin";
import { authSession } from "$lib/auth-session.svelte";
import type { ListResponse, MeResponse } from "$lib/types";
import type { Cat } from "$lib/categories/types";
import { buildTree, filterTreeDeep } from "$lib/categories/tree";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import {
Button,
Card,
CardContent,
Input
} from "$lib/components/ui";
import { FolderTree, Plus, Search, X } from "@lucide/svelte";
import CategoryTreeNode from "$lib/components/categories/CategoryTreeNode.svelte";
import AddCategoryDialog from "$lib/components/categories/AddCategoryDialog.svelte";
import EditCategoryDialog from "$lib/components/categories/EditCategoryDialog.svelte";
import DeleteCategoryDialog from "$lib/components/categories/DeleteCategoryDialog.svelte";
import { categoryFormulaPath } from "$lib/categories/resolve";
let categories = $state<Cat[]>([]);
let error = $state("");
let success = $state("");
let loading = $state(true);
let saving = $state(false);
let canAdmin = $state(false);
let categoriesAbort: AbortController | null = null;
let categoriesFetchGen = 0;
let searchQuery = $state("");
let expanded = $state<Set<string>>(new Set());
let menuOpenId = $state<string | null>(null);
let addOpen = $state(false);
let editOpen = $state(false);
let deleteOpen = $state(false);
let selected = $state<Cat | null>(null);
let toDelete = $state<Cat | null>(null);
const roots = $derived(filterTreeDeep(buildTree(categories, expanded), searchQuery));
const promptCount = $derived(categories.filter((c) => Boolean(c.has_prompt)).length);
function isAbortError(err: unknown): boolean {
return (
(err instanceof DOMException && err.name === "AbortError") ||
(err instanceof Error && err.name === "AbortError")
);
}
async function load() {
categoriesAbort?.abort();
const ac = new AbortController();
categoriesAbort = ac;
const gen = ++categoriesFetchGen;
loading = true;
error = "";
try {
const payload = await api<ListResponse<Cat>>(`/api/categories?tree=1&limit=${TREE_LIST_LIMIT}`, {
signal: ac.signal
});
if (gen !== categoriesFetchGen) return;
categories = unwrapList(payload);
} catch (err) {
if (isAbortError(err) || gen !== categoriesFetchGen) return;
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("categories.loadFailed"));
} finally {
if (gen === categoriesFetchGen) loading = false;
}
}
onMount(() => {
void (async () => {
try {
const me = await api<MeResponse>("/api/auth/me");
authSession.setMe(me);
canAdmin = isCompanyAdmin(me);
} catch {
canAdmin = authSession.isCompanyAdmin;
}
await load();
})();
const closeMenu = () => (menuOpenId = null);
window.addEventListener("click", closeMenu);
return () => {
window.removeEventListener("click", closeMenu);
categoriesAbort?.abort();
};
});
function toggleExpand(node: Cat & { unique_id?: string }) {
const uid = String(node.unique_id ?? node.id);
const next = new Set(expanded);
if (next.has(uid)) next.delete(uid);
else next.add(uid);
expanded = next;
}
async function createCategory(data: {
name: string;
unique_id: string;
parent_unique_id: string | null;
description?: string;
}) {
saving = true;
error = "";
success = "";
try {
await api("/api/categories", { method: "POST", body: data });
addOpen = false;
success = i18n.t("categories.created", { name: data.name });
await load();
} catch (err) {
error = failureMessage(err, i18n.t("catalog.createFailed"));
throw err;
} finally {
saving = false;
}
}
async function updateCategory(
id: string,
data: { name?: string; unique_id?: string; description?: string }
) {
saving = true;
error = "";
success = "";
try {
await api(`/api/categories/${id}`, { method: "PATCH", body: data });
editOpen = false;
selected = null;
success = i18n.t("flash.categories.updated");
await load();
} catch (err) {
error = failureMessage(err, i18n.t("catalog.updateFailed"));
throw err;
} finally {
saving = false;
}
}
async function confirmDelete() {
if (!toDelete) return;
saving = true;
error = "";
success = "";
try {
await api(`/api/categories/${toDelete.id}`, { method: "DELETE" });
deleteOpen = false;
toDelete = null;
success = i18n.t("flash.categories.deleted");
await load();
} catch (err) {
error = failureMessage(err, i18n.t("catalog.deleteFailed"));
} finally {
saving = false;
}
}
async function onImport(file: File) {
const form = new FormData();
form.append("file", file);
await api("/api/categories/import", { method: "POST", body: form });
addOpen = false;
success = i18n.t("flash.categories.csvImported");
await load();
}
</script>
<PageShell title={i18n.t("categories.title")} description={i18n.t("categories.description")}>
{#snippet actions()}
<Button onclick={() => (addOpen = true)}>
<Plus class="h-4 w-4" />
{i18n.t("categories.add")}
</Button>
{/snippet}
<Alert message={error} />
<Alert tone="success" message={success} />
{#if !loading && promptCount > 0}
<p class="text-sm text-muted-foreground">
{promptCount === 1
? i18n.t("categories.promptCountOne", { count: promptCount })
: i18n.t("categories.promptCountMany", { count: promptCount })}
</p>
{/if}
<Card>
<div class="border-b p-4">
<div class="relative max-w-xs flex-1">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={i18n.t("categories.searchPlaceholder")}
class="pl-9 pr-9"
bind:value={searchQuery}
/>
{#if searchQuery}
<Button
variant="ghost"
size="sm"
class="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 p-0 hover:bg-muted"
onclick={() => (searchQuery = "")}
aria-label={i18n.t("catalog.clearSearch")}
>
<X class="h-3 w-3" />
</Button>
{/if}
</div>
</div>
<CardContent class="p-0">
{#if loading}
<div class="space-y-3 p-4">
<div class="h-10 w-full animate-pulse rounded-md bg-muted"></div>
<div class="h-10 w-full animate-pulse rounded-md bg-muted"></div>
<div class="h-10 w-full animate-pulse rounded-md bg-muted"></div>
<div class="h-10 w-4/5 animate-pulse rounded-md bg-muted"></div>
</div>
{:else if roots.length === 0}
<div class="flex flex-col items-center gap-3 py-8 text-center">
<FolderTree class="h-8 w-8 text-muted-foreground/50" />
<p>{i18n.t("categories.noCategoriesYet")}</p>
<p class="text-sm text-muted-foreground">
{#if searchQuery.trim()}
{i18n.t("categories.noMatch", { query: searchQuery.trim() })}
{:else}
{i18n.t("categories.emptyHint")}
{/if}
</p>
<div class="flex flex-wrap justify-center gap-2 pt-1">
{#if searchQuery.trim()}
<Button variant="outline" onclick={() => (searchQuery = "")}>
{i18n.t("catalog.clearSearch")}
</Button>
{:else}
<Button onclick={() => (addOpen = true)}>
<Plus class="mr-2 h-4 w-4" />
{i18n.t("categories.add")}
</Button>
{/if}
</div>
</div>
{:else}
<div class="space-y-0 py-2">
{#each roots as node (node.id)}
<CategoryTreeNode
{node}
{menuOpenId}
canDelete={canAdmin}
onToggleExpand={toggleExpand}
onEdit={async (cat) => {
try {
selected = await api<Cat>(`/api/categories/${cat.id}`);
} catch {
selected = cat;
}
editOpen = true;
}}
onDelete={(cat) => {
toDelete = cat;
deleteOpen = true;
}}
onToggleMenu={(id) => (menuOpenId = id)}
onOpenPrompt={(cat) => {
void goto(categoryFormulaPath(cat, "prompt"));
}}
/>
{/each}
</div>
{/if}
</CardContent>
</Card>
</PageShell>
<AddCategoryDialog
bind:open={addOpen}
categories={categories}
{saving}
canImport={canAdmin}
onClose={() => (addOpen = false)}
onSubmit={createCategory}
{onImport}
/>
<EditCategoryDialog
bind:open={editOpen}
category={selected}
{saving}
onClose={() => {
editOpen = false;
selected = null;
}}
onSubmit={updateCategory}
/>
{#if canAdmin}
<DeleteCategoryDialog
bind:open={deleteOpen}
{saving}
onClose={() => {
deleteOpen = false;
toDelete = null;
}}
onConfirm={confirmDelete}
/>
{/if}
@@ -0,0 +1,490 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api, ApiError, failureMessage } from "$lib/api";
import type { BrandKitResponse } from "$lib/types";
import type { Cat, DescriptionSection, DescriptionSectionType } from "$lib/categories/types";
import { SECTION_TYPES } from "$lib/categories/types";
import {
getDefaultInstructions,
getDefaultMetaDescription,
getDefaultMetaTitle,
parseDescriptionTemplate
} from "$lib/categories/formula";
import { findCategoryIdByUniqueId, listAllCategories, resolveCategory } from "$lib/categories/resolve";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select
} from "$lib/components/ui";
import { ArrowLeft, GripVertical, Plus, Share2, WandSparkles, X } from "@lucide/svelte";
import TreeSelectDialog from "$lib/components/categories/TreeSelectDialog.svelte";
const categoryParam = $derived(String(page.params.categoryId ?? ""));
let loading = $state(true);
let saving = $state(false);
let assigning = $state(false);
let assignProgress = $state(0);
let error = $state("");
let success = $state("");
let category = $state<Cat | null>(null);
let allCategories = $state<Cat[]>([]);
let sections = $state<DescriptionSection[]>([]);
let metaTitle = $state(getDefaultMetaTitle());
let metaDescription = $state(getDefaultMetaDescription());
let assignOpen = $state(false);
let dragIndex = $state<number | null>(null);
let brandTips = $state<string[]>([]);
let aiApplyAllowed = $state(true);
const matchingUniqueIds = $derived(
allCategories
.filter((cat) => {
if (!cat.description_template || !sections.length) return false;
const current = {
sections,
metaTitle: metaTitle.trim() || undefined,
metaDescription: metaDescription.trim() || undefined
};
return JSON.stringify(cat.description_template) === JSON.stringify(current);
})
.map((c) => String(c.unique_id ?? ""))
.filter(Boolean)
);
async function load() {
loading = true;
error = "";
try {
const [cat, brandRes] = await Promise.all([
resolveCategory(categoryParam),
api<BrandKitResponse>("/api/brand").catch(() => null)
]);
category = cat;
if (brandRes) {
brandTips = brandRes.tips ?? [];
aiApplyAllowed = Boolean(brandRes.ai_apply_allowed);
}
const parsed = parseDescriptionTemplate(cat.description_template);
sections = parsed.sections;
metaTitle = parsed.metaTitle;
metaDescription = parsed.metaDescription;
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("categories.loadCategoryFailed"));
if (err instanceof ApiError && err.status === 404) await goto("/categories");
} finally {
loading = false;
}
}
onMount(() => {
void load();
});
function addSection() {
sections = [
...sections,
{
id: crypto.randomUUID(),
type: "p",
instructions: getDefaultInstructions("p")
}
];
}
function removeSection(id: string) {
sections = sections.filter((s) => s.id !== id);
}
function updateSection(id: string, updates: Partial<DescriptionSection>) {
sections = sections.map((section) => {
if (section.id !== id) return section;
const next = { ...section, ...updates };
if (updates.type && updates.type !== section.type) {
next.instructions = getDefaultInstructions(updates.type);
}
return next;
});
}
function reorder(from: number, to: number) {
const next = [...sections];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
sections = next;
}
function buildTemplate() {
if (sections.length === 0) return null;
return {
sections,
metaTitle: metaTitle.trim() || undefined,
metaDescription: metaDescription.trim() || undefined
};
}
async function saveFormula() {
if (!category) return;
saving = true;
error = "";
success = "";
try {
const template = buildTemplate();
await api(`/api/categories/${category.id}/description-formula`, {
method: "PATCH",
body: { description_template: template }
});
const exportIds = sections.filter((s) => s.exportId?.trim()).length;
success =
sections.length === 0
? i18n.t("categories.descriptionFormulaRemoved")
: exportIds > 0
? i18n.t("categories.descriptionFormulaSavedWithExports", { count: exportIds })
: i18n.t("categories.descriptionFormulaSaved");
allCategories = await listAllCategories();
} catch (err) {
error = failureMessage(err, i18n.t("categories.descriptionFormulaSaveFailed"));
} finally {
saving = false;
}
}
async function openAssignDialog() {
assignOpen = true;
if (allCategories.length > 0) return;
try {
allCategories = await listAllCategories();
} catch (err) {
error = failureMessage(err, i18n.t("categories.loadFailed"));
}
}
async function assignFormula(uniqueIds: string[]) {
if (!category) return;
assigning = true;
assignProgress = 0;
error = "";
success = "";
try {
const template = buildTemplate();
const ids = uniqueIds
.map((uid) => findCategoryIdByUniqueId(allCategories, uid))
.filter((id): id is string => Boolean(id));
for (let i = 0; i < ids.length; i++) {
await api(`/api/categories/${ids[i]}/description-formula`, {
method: "PATCH",
body: { description_template: template }
});
assignProgress = Math.round(((i + 1) / ids.length) * 100);
}
assignOpen = false;
success = i18n.t("flash.categories.descFormulaAssigned", { count: ids.length });
allCategories = await listAllCategories();
} catch (err) {
error = failureMessage(err, i18n.t("categories.descriptionFormulaAssignFailed"));
} finally {
assigning = false;
assignProgress = 0;
}
}
</script>
<PageShell
title={i18n.t("categories.descriptionFormula")}
description={i18n.t("categories.descriptionFormulaHelp")}
>
{#if loading}
<div class="flex justify-center p-12"><Spinner label={i18n.t("categories.loadingFormula")} /></div>
{:else if category}
<div class="mx-auto max-w-5xl space-y-6">
<Alert message={error} />
<Alert tone="success" message={success} />
<div class="flex items-center justify-between gap-4">
<div class="space-y-1">
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" onclick={() => void goto("/categories")}>
<ArrowLeft class="h-4 w-4" />
{i18n.t("common.back")}
</Button>
<h1 class="text-2xl font-bold">{i18n.t("categories.descriptionFormula")}</h1>
</div>
<p class="text-muted-foreground">
{i18n.t("categories.structureDescriptionsFor", { name: category.name ?? "" })}
</p>
</div>
<div class="flex shrink-0 gap-2">
<Button variant="outline" onclick={() => void openAssignDialog()}>
<Share2 class="h-4 w-4" />
{i18n.t("categories.assign")}
</Button>
<Button variant="outline" onclick={() => void goto("/categories")}>{i18n.t("common.cancel")}</Button>
<Button onclick={saveFormula} loading={saving}>
<WandSparkles class="h-4 w-4" />
{saving ? i18n.t("catalog.saving") : i18n.t("catalog.saveChanges")}
</Button>
</div>
</div>
{#if brandTips.length > 0 || !aiApplyAllowed}
<Card>
<CardHeader>
<CardTitle>{i18n.t("categories.brandTips.title")}</CardTitle>
<CardDescription>
{#if !aiApplyAllowed}
{i18n.t("categories.brandTips.freePlan")}
<a class="underline" href="/brand">{i18n.t("categories.brandTips.editBrandKit")}</a>
{:else}
{i18n.t("categories.brandTips.descGuidanceBefore")}<a class="underline" href="/brand">{i18n.t("categories.brandTips.brandKit")}</a>{i18n.t("categories.brandTips.descGuidanceAfter")}
{/if}
</CardDescription>
</CardHeader>
{#if brandTips.length > 0}
<CardContent>
<ul class="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{#each brandTips as tip}
<li>{tip}</li>
{/each}
</ul>
</CardContent>
{/if}
</Card>
{/if}
<Card>
<CardHeader>
<CardTitle>{i18n.t("categories.metaInformation")}</CardTitle>
<CardDescription>
{i18n.t("categories.metaInformationHelp")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="metaTitle">{i18n.t("categories.metaTitleFormula")}</Label>
<textarea
id="metaTitle"
class="flex min-h-[60px] w-full rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
bind:value={metaTitle}
rows="2"
placeholder={i18n.t("categories.seoTitlePlaceholder")}
></textarea>
<div class="flex items-center justify-between">
<p class="text-xs text-muted-foreground">
{i18n.t("categories.metaTitleHint")}
</p>
<Button
variant="ghost"
size="sm"
class="text-xs"
onclick={() => (metaTitle = getDefaultMetaTitle())}
>
{i18n.t("categories.resetToDefault")}
</Button>
</div>
</div>
<div class="space-y-2">
<Label for="metaDescription">{i18n.t("categories.metaDescriptionFormula")}</Label>
<textarea
id="metaDescription"
class="flex min-h-[80px] w-full rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
bind:value={metaDescription}
rows="3"
placeholder={i18n.t("categories.seoDescriptionPlaceholder")}
></textarea>
<div class="flex items-center justify-between">
<p class="text-xs text-muted-foreground">
{i18n.t("categories.metaDescriptionHint")}
</p>
<Button
variant="ghost"
size="sm"
class="text-xs"
onclick={() => (metaDescription = getDefaultMetaDescription())}
>
{i18n.t("categories.resetToDefault")}
</Button>
</div>
</div>
</CardContent>
</Card>
<div class="grid grid-cols-12 gap-6">
<div class="col-span-12 space-y-6 lg:col-span-6">
<Card>
<CardHeader>
<CardTitle>{i18n.t("categories.descriptionSections")}</CardTitle>
<CardDescription>{i18n.t("categories.descriptionSectionsHelp")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="min-h-[100px] space-y-4" role="list">
{#if sections.length === 0}
<div
class="flex h-[100px] items-center justify-center rounded-lg border-2 border-dashed"
>
<div class="text-center text-muted-foreground">
<p>{i18n.t("categories.noSectionsYet")}</p>
<p class="text-sm">{i18n.t("categories.addSectionsHint")}</p>
</div>
</div>
{:else}
{#each sections as section, index (section.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="space-y-4 rounded-md bg-muted/50 p-4"
role="listitem"
draggable="true"
ondragstart={() => (dragIndex = index)}
ondragover={(e) => e.preventDefault()}
ondrop={() => {
if (dragIndex === null || dragIndex === index) {
dragIndex = null;
return;
}
reorder(dragIndex, index);
dragIndex = null;
}}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="cursor-grab text-muted-foreground">
<GripVertical class="h-4 w-4" />
</span>
<Select
value={section.type}
onchange={(e) =>
updateSection(section.id, {
type: (e.currentTarget as HTMLSelectElement)
.value as DescriptionSectionType
})}
class="w-[180px]"
>
{#each SECTION_TYPES as type}
<option value={type.value}>{i18n.t(`categories.sectionType.${type.value}`)}</option>
{/each}
</Select>
</div>
<Button variant="ghost" size="sm" onclick={() => removeSection(section.id)}>
<X class="h-4 w-4" />
</Button>
</div>
<div class="space-y-2">
<Label>{i18n.t("categories.aiInstructions")}</Label>
<textarea
class="flex min-h-[80px] w-full rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
value={section.instructions}
oninput={(e) =>
updateSection(section.id, {
instructions: (e.currentTarget as HTMLTextAreaElement).value
})}
rows="3"
placeholder={i18n.t("categories.sectionPromptPlaceholder")}
></textarea>
</div>
<div class="space-y-2">
<Label for={`export-id-${section.id}`}>{i18n.t("categories.exportIdOptional")}</Label>
<Input
id={`export-id-${section.id}`}
value={section.exportId || ""}
oninput={(e) =>
updateSection(section.id, {
exportId: (e.currentTarget as HTMLInputElement).value.trim()
})}
placeholder={i18n.t("categories.sectionKeyPlaceholder")}
/>
{#if section.exportId && sections.filter((s) => s.exportId === section.exportId).length > 1}
<p class="text-sm text-red-500">{i18n.t("categories.exportIdUnique")}</p>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<Button variant="outline" onclick={addSection}>
<Plus class="h-4 w-4" />
{i18n.t("categories.addSection")}
</Button>
</CardContent>
</Card>
</div>
<div class="col-span-12 space-y-6 lg:col-span-6">
<Card>
<CardHeader>
<CardTitle>{i18n.t("categories.aboutDescriptionFormulas")}</CardTitle>
<CardDescription>
{i18n.t("categories.aboutDescriptionFormulasHelp")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 text-sm text-muted-foreground">
<div class="space-y-2">
<h4 class="font-medium text-foreground">{i18n.t("categories.sectionTypesHeading")}</h4>
<ul class="space-y-2">
<li>
<span class="font-medium text-foreground">{i18n.t("categories.sectionTypeHeadings")}</span> {i18n.t("categories.sectionTypeHeadingsHelp")}
</li>
<li>
<span class="font-medium text-foreground">{i18n.t("categories.sectionTypeParagraphs")}</span> {i18n.t("categories.sectionTypeParagraphsHelp")}
</li>
<li>
<span class="font-medium text-foreground">{i18n.t("categories.sectionTypeLists")}</span> {i18n.t("categories.sectionTypeListsHelp")}
</li>
</ul>
</div>
<div class="space-y-2">
<h4 class="font-medium text-foreground">{i18n.t("categories.aiInstructionsHeading")}</h4>
<p>
{i18n.t("categories.aiInstructionsBody")}
</p>
</div>
<div class="space-y-2">
<h4 class="font-medium text-foreground">{i18n.t("categories.exportIdsHeading")}</h4>
<p>
{i18n.t("categories.exportIdsBody")}
</p>
</div>
<p>{i18n.t("categories.assignDescCopyHelp")}</p>
</CardContent>
</Card>
</div>
</div>
</div>
{:else}
<div class="mx-auto max-w-lg space-y-4 py-12 text-center">
<Alert message={error || i18n.t("categories.notFound")} />
<p class="text-sm text-muted-foreground">
{i18n.t("categories.descriptionFormulaInvalid")}
</p>
<Button onclick={() => void goto("/categories")}>{i18n.t("categories.backToCategories")}</Button>
</div>
{/if}
</PageShell>
<TreeSelectDialog
bind:open={assignOpen}
categories={allCategories}
selectedUniqueIds={matchingUniqueIds}
title={i18n.t("categories.assignDescriptionFormula")}
description={i18n.t("categories.assignDescriptionHelp")}
saving={assigning}
progress={assignProgress}
onClose={() => (assignOpen = false)}
onSave={assignFormula}
/>
@@ -0,0 +1,336 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api, ApiError, failureMessage } from "$lib/api";
import type { Cat } from "$lib/categories/types";
import { findCategoryIdByUniqueId, listAllCategories, resolveCategory } from "$lib/categories/resolve";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import ContentLanguageSwitcher from "$lib/components/ContentLanguageSwitcher.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Label,
Textarea
} from "$lib/components/ui";
import { ArrowLeft, Share2, Sparkles, X } from "@lucide/svelte";
import TreeSelectDialog from "$lib/components/categories/TreeSelectDialog.svelte";
import {
CONTENT_LANGUAGES,
DEFAULT_CONTENT_LANGUAGE,
parseContentLanguage
} from "$lib/content-languages";
import type { MeResponse } from "$lib/types";
const categoryParam = $derived(String(page.params.categoryId ?? ""));
const PROMPT_VARS = [
{ name: "name", label: i18n.t("categories.productName") },
{ name: "description", label: i18n.t("products.enrichment.piece.description") },
{ name: "category", label: i18n.t("products.enrichment.piece.category") },
{ name: "attrs", label: i18n.t("products.enrichment.piece.attributes") },
{ name: "gtin", label: i18n.t("categories.gtin") },
{ name: "brand_voice", label: i18n.t("categories.brandVoice") },
{ name: "language", label: i18n.t("settings.contentLanguage") }
] as const;
let loading = $state(true);
let saving = $state(false);
let assigning = $state(false);
let assignProgress = $state(0);
let error = $state("");
let success = $state("");
let category = $state<Cat | null>(null);
let allCategories = $state<Cat[]>([]);
let promptsByLang = $state<Record<string, string>>({});
let selectedLang = $state(DEFAULT_CONTENT_LANGUAGE);
let extraLangs = $state<string[]>([]);
let contentLanguages = $state<string[]>([]);
let primaryLang = $state(DEFAULT_CONTENT_LANGUAGE);
let assignOpen = $state(false);
const prompt = $derived(promptsByLang[selectedLang] ?? "");
const langLabel = $derived(
CONTENT_LANGUAGES.find((l) => l.value === selectedLang)?.label ?? selectedLang
);
const matchingUniqueIds = $derived.by(() => {
if (!category) return [] as string[];
const uid = String(category.unique_id ?? "");
if (!uid) return [];
const saved = category.prompts ?? {};
const same =
Object.keys(promptsByLang).length === Object.keys(saved).length &&
Object.entries(promptsByLang).every(([k, v]) => String(saved[k] ?? "").trim() === v.trim());
if (same && Object.values(promptsByLang).some((v) => v.trim())) return [uid];
return [];
});
function applyCategory(cat: Cat) {
category = cat;
const map: Record<string, string> = {};
const raw = cat.prompts;
if (raw && typeof raw === "object") {
for (const [k, v] of Object.entries(raw)) {
const code = parseContentLanguage(k, "");
if (code && typeof v === "string") map[code] = v;
}
} else if (typeof cat.prompt === "string" && cat.prompt.trim()) {
map[primaryLang] = cat.prompt;
}
promptsByLang = map;
extraLangs = Object.keys(map).filter((c) => c !== primaryLang);
if (!selectedLang || (!(selectedLang in map) && !contentLanguages.includes(selectedLang))) {
selectedLang = primaryLang;
}
}
async function load() {
loading = true;
error = "";
try {
const [cat, me] = await Promise.all([
resolveCategory(categoryParam),
api<MeResponse>("/api/auth/me").catch(() => null)
]);
primaryLang = parseContentLanguage(me?.company?.language, DEFAULT_CONTENT_LANGUAGE);
const companyLangs = (me?.company as { content_languages?: string[] } | undefined)
?.content_languages;
contentLanguages =
Array.isArray(companyLangs) && companyLangs.length
? companyLangs.map((c) => parseContentLanguage(c)).filter(Boolean)
: [primaryLang];
selectedLang = primaryLang;
applyCategory(cat);
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("categories.loadCategoryFailed"));
if (err instanceof ApiError && err.status === 404) await goto("/categories");
} finally {
loading = false;
}
}
onMount(() => {
void load();
});
function setPrompt(next: string) {
promptsByLang = { ...promptsByLang, [selectedLang]: next };
}
function insertVariable(name: string) {
const token = `{{${name}}}`;
const el = document.getElementById("category-prompt") as HTMLTextAreaElement | null;
const current = promptsByLang[selectedLang] ?? "";
if (!el) {
setPrompt(`${current}${token}`);
return;
}
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? current.length;
setPrompt(`${current.slice(0, start)}${token}${current.slice(end)}`);
queueMicrotask(() => {
el.focus();
const pos = start + token.length;
el.setSelectionRange(pos, pos);
});
}
async function savePrompt() {
if (!category) return;
saving = true;
error = "";
success = "";
try {
const bodyPrompts: Record<string, string> = {};
for (const [k, v] of Object.entries(promptsByLang)) {
if (v.trim()) bodyPrompts[k] = v;
}
const updated = await api<Cat>(`/api/categories/${category.id}/prompt`, {
method: "PATCH",
body: { prompts: bodyPrompts }
});
applyCategory(updated);
const has = Boolean((promptsByLang[selectedLang] ?? "").trim());
success = has
? i18n.t("categories.aiPromptSavedLang", { lang: langLabel })
: i18n.t("categories.aiPromptClearedLang", { lang: langLabel });
allCategories = allCategories.map((c) =>
String(c.id) === String(updated.id) ? { ...c, ...updated } : c
);
} catch (err) {
error = failureMessage(err, i18n.t("categories.aiPromptSaveFailed"));
} finally {
saving = false;
}
}
async function openAssignDialog() {
assignOpen = true;
if (allCategories.length > 0) return;
try {
allCategories = await listAllCategories();
} catch (err) {
error = failureMessage(err, i18n.t("categories.loadFailed"));
}
}
async function assignPrompt(uniqueIds: string[]) {
if (!category || uniqueIds.length === 0) return;
assigning = true;
assignProgress = 0;
error = "";
success = "";
try {
const bodyPrompts: Record<string, string> = {};
for (const [k, v] of Object.entries(promptsByLang)) {
if (v.trim()) bodyPrompts[k] = v;
}
const ids = uniqueIds
.map((uid) => findCategoryIdByUniqueId(allCategories, uid))
.filter((id): id is string => Boolean(id));
for (let i = 0; i < ids.length; i++) {
await api(`/api/categories/${ids[i]}/prompt`, {
method: "PATCH",
body: { prompts: bodyPrompts }
});
assignProgress = Math.round(((i + 1) / ids.length) * 100);
}
success = i18n.t("flash.categories.promptAssigned", {
count: ids.length,
noun:
ids.length === 1
? i18n.t("flash.categories.promptNounOne")
: i18n.t("flash.categories.promptNounMany")
});
assignOpen = false;
allCategories = await listAllCategories();
const refreshed = await resolveCategory(String(category.id));
applyCategory(refreshed);
} catch (err) {
error = failureMessage(err, i18n.t("categories.aiPromptAssignFailed"));
} finally {
assigning = false;
assignProgress = 0;
}
}
</script>
<PageShell
title={i18n.t("categories.aiPromptTitle")}
description={i18n.t("categories.aiPromptDescription")}
>
{#if loading}
<div class="flex justify-center p-12"><Spinner label={i18n.t("categories.loadingPrompt")} /></div>
{:else if category}
<div class="mx-auto max-w-4xl space-y-6">
<Alert message={error} />
<Alert tone="success" message={success} />
<div class="flex items-center justify-between gap-4">
<div class="space-y-1">
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" onclick={() => void goto("/categories")}>
<ArrowLeft class="h-4 w-4" />
{i18n.t("common.back")}
</Button>
<h1 class="text-2xl font-bold">{i18n.t("categories.aiGenerationPrompt")}</h1>
</div>
<p class="text-muted-foreground">
{i18n.t("categories.aiPromptOverrides", { name: category.name ?? "" })}
</p>
</div>
<div class="flex shrink-0 gap-2">
<Button variant="outline" onclick={() => void openAssignDialog()}>
<Share2 class="h-4 w-4" />
{i18n.t("categories.assign")}
</Button>
<Button variant="outline" onclick={() => void goto("/categories")}>{i18n.t("common.cancel")}</Button>
<Button onclick={savePrompt} loading={saving}>
<Sparkles class="h-4 w-4" />
{saving ? i18n.t("catalog.saving") : i18n.t("catalog.saveChanges")}
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>{i18n.t("categories.promptTemplate")}</CardTitle>
<CardDescription>
{i18n.t("categories.promptTemplateHelp")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<ContentLanguageSwitcher
bind:value={selectedLang}
bind:languages={extraLangs}
configured={contentLanguages}
primary={primaryLang}
hasOverride={(code) => Boolean((promptsByLang[code] ?? "").trim())}
/>
<div class="flex flex-wrap gap-2">
{#each PROMPT_VARS as v}
<Button type="button" variant="outline" size="sm" onclick={() => insertVariable(v.name)}>
{v.label}
<span class="font-mono text-xs text-muted-foreground">{"{{"}{v.name}{"}}"}</span>
</Button>
{/each}
{#if prompt.trim()}
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => setPrompt("")}
>
<X class="h-4 w-4" />
{i18n.t("categories.clear")}
</Button>
{/if}
</div>
<div class="space-y-2">
<Label for="category-prompt">{i18n.t("categories.promptLabel")} ({langLabel})</Label>
<Textarea
id="category-prompt"
value={prompt}
oninput={(e) => setPrompt((e.currentTarget as HTMLTextAreaElement).value)}
rows={18}
class="min-h-[280px] font-mono text-sm"
placeholder={'Ustvari nov opis… Star_opis_izdelka: {{description}}; …'}
/>
<p class="text-xs text-muted-foreground">
{i18n.t("categories.charactersCount", { count: prompt.length.toLocaleString() })}
{#if prompt.trim()}
{i18n.t("categories.activeOverride")}
{:else}
{i18n.t("categories.usingCompanyDefaultForLang")}
{/if}
</p>
</div>
</CardContent>
</Card>
</div>
<TreeSelectDialog
bind:open={assignOpen}
categories={allCategories}
selectedUniqueIds={matchingUniqueIds}
title={i18n.t("categories.assignAiPrompt")}
description={i18n.t("categories.assignAiPromptHelp")}
saving={assigning}
progress={assignProgress}
onClose={() => (assignOpen = false)}
onSave={assignPrompt}
/>
{/if}
</PageShell>
@@ -0,0 +1,471 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api, ApiError, failureMessage } from "$lib/api";
import type { BrandKitResponse } from "$lib/types";
import type { Cat, FormulaElement, FormulaVariable, TitleFormula } from "$lib/categories/types";
import {
buildTemplateToSave,
elementId,
generatePreviewElements,
mapApiVariable,
parseTemplateToFormula
} from "$lib/categories/formula";
import { findCategoryIdByUniqueId, listAllCategories, resolveCategory } from "$lib/categories/resolve";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import {
Button,
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "$lib/components/ui";
import { AlertCircle, Plus } from "@lucide/svelte";
import FormulaHeader from "$lib/components/categories/formula/FormulaHeader.svelte";
import FormulaPreview from "$lib/components/categories/formula/FormulaPreview.svelte";
import FormulaBuilder from "$lib/components/categories/formula/FormulaBuilder.svelte";
import VariableSelector from "$lib/components/categories/formula/VariableSelector.svelte";
import TextElementDialog from "$lib/components/categories/formula/TextElementDialog.svelte";
import CustomVariableDialog from "$lib/components/categories/formula/CustomVariableDialog.svelte";
import ManageVariablesDialog from "$lib/components/categories/formula/ManageVariablesDialog.svelte";
import ConfirmationDialog from "$lib/components/categories/formula/ConfirmationDialog.svelte";
import TreeSelectDialog from "$lib/components/categories/TreeSelectDialog.svelte";
const categoryParam = $derived(String(page.params.categoryId ?? ""));
let loading = $state(true);
let saving = $state(false);
let assigning = $state(false);
let assignProgress = $state(0);
let error = $state("");
let success = $state("");
let category = $state<Cat | null>(null);
let allCategories = $state<Cat[]>([]);
let customVariables = $state<FormulaVariable[]>([]);
let formula = $state<TitleFormula>({ elements: [], separator: " " });
let searchQuery = $state("");
let textOpen = $state(false);
let textToEdit = $state<{ index: number; text: string } | null>(null);
let customVarOpen = $state(false);
let variableToEdit = $state<FormulaVariable | null>(null);
let manageVarsOpen = $state(false);
let assignOpen = $state(false);
let confirmOpen = $state(false);
let itemToDelete = $state<{ type: string; id: string } | null>(null);
let brandTips = $state<string[]>([]);
let aiApplyAllowed = $state(true);
const usedNames = $derived(
new Set(formula.elements.filter((e) => e.type === "variable").map((e) => e.value))
);
const filteredVariables = $derived(
customVariables.filter((v) => {
if (usedNames.has(v.name)) return false;
const q = searchQuery.toLowerCase();
if (!q) return true;
return (
v.label.toLowerCase().includes(q) ||
v.name.toLowerCase().includes(q) ||
(v.description?.toLowerCase() || "").includes(q)
);
})
);
const preview = $derived(generatePreviewElements(formula.elements, customVariables));
const matchingUniqueIds = $derived(
allCategories
.filter((cat) => {
if (!cat.title_template || !formula.elements.length) return false;
const saved = buildTemplateToSave(formula.elements, formula.separator, customVariables);
return JSON.stringify(cat.title_template) === JSON.stringify(saved);
})
.map((c) => String(c.unique_id ?? ""))
.filter(Boolean)
);
async function loadVariables() {
const [payload, fieldsRes] = await Promise.all([
api<{ variables: Record<string, unknown>[] }>("/api/variables?limit=2000"),
api<{ fields?: Record<string, unknown>[] }>("/api/standard-fields?limit=2000").catch(
() => ({ fields: [] as Record<string, unknown>[] })
)
]);
const custom = (payload.variables ?? []).map(mapApiVariable);
const fromFields: FormulaVariable[] = [];
const seen = new Set(custom.map((v) => v.name));
for (const row of fieldsRes.fields ?? []) {
const name = String(row.key ?? "").trim();
if (!name || seen.has(name)) continue;
seen.add(name);
fromFields.push({
id: `sf-${name}`,
name,
label: String(row.name ?? name),
description: row.description != null ? String(row.description) : undefined,
example: undefined,
value: String(row.name ?? name)
});
}
customVariables = [...custom, ...fromFields];
}
async function load() {
loading = true;
error = "";
try {
const [cat, brandRes] = await Promise.all([
resolveCategory(categoryParam),
api<BrandKitResponse>("/api/brand").catch(() => null)
]);
category = cat;
if (brandRes) {
brandTips = brandRes.tips ?? [];
aiApplyAllowed = Boolean(brandRes.ai_apply_allowed);
}
formula = parseTemplateToFormula(cat.title_template, customVariables);
await loadVariables();
formula = parseTemplateToFormula(cat.title_template, customVariables);
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("categories.loadCategoryFailed"));
if (err instanceof ApiError && err.status === 404) {
await goto("/categories");
}
} finally {
loading = false;
}
}
onMount(() => {
void load();
});
function addElement(partial: Omit<FormulaElement, "id">) {
formula = {
...formula,
elements: [
...formula.elements,
{
...partial,
id: elementId(partial.type, partial.value, formula.elements.length)
}
]
};
}
function removeElement(index: number) {
formula = {
...formula,
elements: formula.elements.filter((_, i) => i !== index)
};
}
function updateElement(index: number, next: Partial<FormulaElement>) {
formula = {
...formula,
elements: formula.elements.map((el, i) => (i === index ? { ...el, ...next } : el))
};
}
function reorder(from: number, to: number) {
const elements = [...formula.elements];
const [item] = elements.splice(from, 1);
elements.splice(to, 0, item);
formula = { ...formula, elements };
}
async function saveFormula() {
if (!category) return;
saving = true;
error = "";
success = "";
try {
const template = buildTemplateToSave(formula.elements, formula.separator, customVariables);
await api(`/api/categories/${category.id}/title-formula`, {
method: "PATCH",
body: { title_template: template }
});
success =
formula.elements.length === 0
? i18n.t("categories.titleFormulaRemoved")
: i18n.t("categories.titleFormulaSaved");
allCategories = await listAllCategories();
} catch (err) {
error = failureMessage(err, i18n.t("categories.titleFormulaSaveFailed"));
} finally {
saving = false;
}
}
async function openAssignDialog() {
assignOpen = true;
if (allCategories.length > 0) return;
try {
allCategories = await listAllCategories();
} catch (err) {
error = failureMessage(err, i18n.t("categories.loadFailed"));
}
}
async function assignFormula(uniqueIds: string[]) {
if (!category) return;
assigning = true;
assignProgress = 0;
error = "";
success = "";
try {
const template = buildTemplateToSave(formula.elements, formula.separator, customVariables);
const ids = uniqueIds
.map((uid) => findCategoryIdByUniqueId(allCategories, uid))
.filter((id): id is string => Boolean(id));
for (let i = 0; i < ids.length; i++) {
await api(`/api/categories/${ids[i]}/title-formula`, {
method: "PATCH",
body: { title_template: template }
});
assignProgress = Math.round(((i + 1) / ids.length) * 100);
}
assignOpen = false;
success = i18n.t("flash.categories.titleFormulaAssigned", { count: ids.length, noun: ids.length === 1 ? i18n.t("flash.categories.promptNounOne") : i18n.t("flash.categories.promptNounMany") });
allCategories = await listAllCategories();
} catch (err) {
error = failureMessage(err, i18n.t("categories.titleFormulaAssignFailed"));
} finally {
assigning = false;
assignProgress = 0;
}
}
async function saveVariable(variable: FormulaVariable) {
if (variableToEdit?.id) {
await api(`/api/variables/${variableToEdit.id}`, { method: "DELETE" }).catch(() => undefined);
}
await api("/api/variables", {
method: "POST",
body: {
name: variable.name,
value: variable.label,
label: variable.label,
description: variable.description ?? null
}
});
await loadVariables();
success = variableToEdit ? i18n.t("categories.variableUpdated") : i18n.t("categories.variableAdded");
}
async function confirmDelete() {
if (!itemToDelete) return;
if (itemToDelete.type === "variable") {
try {
await api(`/api/variables/${itemToDelete.id}`, { method: "DELETE" });
await loadVariables();
success = i18n.t("flash.categories.variableDeleted");
} catch (err) {
error = failureMessage(err, i18n.t("categories.variableDeleteFailed"));
}
} else if (itemToDelete.type === "element") {
const index = parseInt(itemToDelete.id, 10);
if (!Number.isNaN(index)) removeElement(index);
}
confirmOpen = false;
itemToDelete = null;
}
</script>
<PageShell
title={i18n.t("categories.titleFormula")}
description={i18n.t("categories.titleFormulaHelp")}
>
{#if loading}
<div class="flex justify-center p-12"><Spinner label={i18n.t("categories.loadingFormula")} /></div>
{:else if category}
<div class="mx-auto max-w-6xl space-y-6">
<Alert message={error} />
<Alert tone="success" message={success} />
<FormulaHeader
categoryName={category.name ?? ""}
saving={saving}
onBack={() => void goto("/categories")}
onAssign={() => void openAssignDialog()}
onSave={saveFormula}
/>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="col-span-2 space-y-6">
<FormulaPreview {preview} {formula} {brandTips} {aiApplyAllowed} />
<FormulaBuilder
{formula}
onSeparatorChange={(value) => (formula = { ...formula, separator: value })}
onReorder={reorder}
onRemoveElement={(index) => {
itemToDelete = { type: "element", id: String(index) };
confirmOpen = true;
}}
onEditElement={(index) => {
const el = formula.elements[index];
if (el?.type === "text") {
textToEdit = { index, text: el.value };
textOpen = true;
}
}}
/>
<div class="flex gap-2">
<Button
variant="outline"
class="flex items-center gap-1"
onclick={() => {
textToEdit = null;
textOpen = true;
}}
>
<Plus class="h-4 w-4" />
{i18n.t("categories.addText")}
</Button>
</div>
</div>
<div class="space-y-6">
<VariableSelector
variables={filteredVariables}
onAddVariable={(variable) =>
addElement({
type: "variable",
value: variable.name,
label: variable.label,
description: variable.description,
example: variable.example
})}
onAddCustomVariable={() => {
variableToEdit = null;
customVarOpen = true;
}}
onEditCustomVariable={(variable) => {
variableToEdit = variable;
customVarOpen = true;
}}
onManageVariables={() => (manageVarsOpen = true)}
onSearch={(q) => (searchQuery = q)}
/>
<Card>
<CardHeader>
<CardTitle>{i18n.t("categories.aboutTitleFormulas")}</CardTitle>
<CardDescription>
{i18n.t("categories.aboutTitleFormulasHelp")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-3 text-sm text-muted-foreground">
<div class="flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-foreground">
<AlertCircle class="mt-0.5 h-4 w-4 shrink-0" />
<p>
{i18n.t("categories.variablesReplacedHelp")}
</p>
</div>
<ul class="space-y-2" aria-label={i18n.t("categories.formulaElementTypes")}>
<li class="flex items-start gap-2">
<Badge variant="outline" class="mt-0.5">variable</Badge>
<span>{i18n.t("categories.variableTypeHelp")}</span>
</li>
<li class="flex items-start gap-2">
<Badge class="mt-0.5">text</Badge>
<span>{i18n.t("categories.textTypeHelp")}</span>
</li>
</ul>
<p>
{i18n.t("categories.assignTitleCopyHelp")}
</p>
</CardContent>
</Card>
</div>
</div>
</div>
{:else}
<div class="mx-auto max-w-lg space-y-4 py-12 text-center">
<Alert message={error || i18n.t("categories.notFound")} />
<p class="text-sm text-muted-foreground">
{i18n.t("categories.titleFormulaInvalid")}
</p>
<Button onclick={() => void goto("/categories")}>{i18n.t("categories.backToCategories")}</Button>
</div>
{/if}
</PageShell>
<TextElementDialog
bind:open={textOpen}
initialText={textToEdit?.text ?? ""}
index={textToEdit?.index ?? null}
onClose={() => {
textOpen = false;
textToEdit = null;
}}
onSave={(text) => {
if (textToEdit) updateElement(textToEdit.index, { type: "text", value: text });
else addElement({ type: "text", value: text });
}}
/>
<CustomVariableDialog
bind:open={customVarOpen}
initialVariable={variableToEdit}
title={variableToEdit ? i18n.t("categories.editCustomVariable") : i18n.t("categories.addCustomVariable")}
existingVariables={customVariables}
onClose={() => {
customVarOpen = false;
variableToEdit = null;
}}
onSave={saveVariable}
/>
<ManageVariablesDialog
bind:open={manageVarsOpen}
variables={customVariables}
onClose={() => (manageVarsOpen = false)}
onAddVariable={() => {
variableToEdit = null;
customVarOpen = true;
}}
onEditVariable={(variable) => {
variableToEdit = variable;
customVarOpen = true;
}}
onDeleteVariable={(id) => {
itemToDelete = { type: "variable", id };
confirmOpen = true;
}}
/>
<TreeSelectDialog
bind:open={assignOpen}
categories={allCategories}
selectedUniqueIds={matchingUniqueIds}
title={i18n.t("categories.assignTitleFormula")}
description={i18n.t("categories.assignTitleHelp")}
saving={assigning}
progress={assignProgress}
onClose={() => (assignOpen = false)}
onSave={assignFormula}
/>
<ConfirmationDialog
bind:open={confirmOpen}
title={i18n.t("catalog.confirmDeletion")}
description={itemToDelete?.type === "variable"
? i18n.t("categories.deleteVariableConfirm")
: i18n.t("categories.removeElementConfirm")}
onClose={() => {
confirmOpen = false;
itemToDelete = null;
}}
onConfirm={confirmDelete}
/>
@@ -0,0 +1,155 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { page } from "$app/state";
import { failureMessage } from "$lib/api";
import { DEMO_BOOKING_URL } from "$lib/site";
import { submitSalesContact } from "$lib/sales-contact";
import { notifySuccess, notifyApiError } from "$lib/notify";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Textarea
} from "$lib/components/ui";
let name = $state("");
let email = $state("");
let companyName = $state("");
let phone = $state("");
let message = $state("");
let estimatedSkus = $state("");
let submitting = $state(false);
let error = $state("");
let done = $state(false);
const source = $derived(page.url.searchParams.get("source")?.trim() || "pricing");
const canSubmit = $derived(
name.trim().length > 0 &&
email.trim().length > 3 &&
message.trim().length > 0 &&
!submitting
);
async function handleSubmit(event: Event) {
event.preventDefault();
if (!canSubmit) return;
submitting = true;
error = "";
try {
const skusRaw = estimatedSkus.trim();
const skus = skusRaw ? Number.parseInt(skusRaw, 10) : undefined;
await submitSalesContact({
name: name.trim(),
email: email.trim(),
company_name: companyName.trim() || undefined,
phone: phone.trim() || undefined,
message: message.trim(),
estimated_skus: skus != null && Number.isFinite(skus) ? skus : undefined,
source
});
done = true;
notifySuccess(i18n.t("sales.contact.toast.success"));
} catch (err) {
error = failureMessage(err, i18n.t("sales.contact.error"));
notifyApiError(err, i18n.t("sales.contact.error"));
} finally {
submitting = false;
}
}
</script>
<PageShell title={i18n.t("sales.contact.title")} description={i18n.t("sales.contact.lead")}>
{#if done}
<Card>
<CardHeader>
<CardTitle>{i18n.t("sales.contact.thanksTitle")}</CardTitle>
<CardDescription>{i18n.t("sales.contact.thanksBody")}</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-3">
<a
href={DEMO_BOOKING_URL}
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-10 items-center justify-center rounded-md border border-border px-4 text-sm font-medium text-text transition-colors hover:bg-surface-muted"
>
{i18n.t("sales.contact.bookDemo")}
</a>
<a
href="/pricing"
class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground"
>
{i18n.t("sales.contact.backPricing")}
</a>
</CardContent>
</Card>
{:else}
<Card class="max-w-2xl">
<CardHeader>
<CardTitle>{i18n.t("sales.contact.formTitle")}</CardTitle>
<CardDescription>{i18n.t("sales.contact.formLead")}</CardDescription>
</CardHeader>
<CardContent>
{#if error}
<Alert message={error} />
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="sales-name">{i18n.t("sales.contact.name")}</Label>
<Input id="sales-name" bind:value={name} required autocomplete="name" maxlength={200} />
</div>
<div class="space-y-2">
<Label for="sales-email">{i18n.t("sales.contact.email")}</Label>
<Input
id="sales-email"
type="email"
bind:value={email}
required
autocomplete="email"
maxlength={320}
/>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="sales-company">{i18n.t("sales.contact.company")}</Label>
<Input id="sales-company" bind:value={companyName} autocomplete="organization" maxlength={200} />
</div>
<div class="space-y-2">
<Label for="sales-phone">{i18n.t("sales.contact.phone")}</Label>
<Input id="sales-phone" bind:value={phone} autocomplete="tel" maxlength={40} />
</div>
</div>
<div class="space-y-2">
<Label for="sales-skus">{i18n.t("sales.contact.estimatedSkus")}</Label>
<Input id="sales-skus" inputmode="numeric" bind:value={estimatedSkus} />
</div>
<div class="space-y-2">
<Label for="sales-message">{i18n.t("sales.contact.message")}</Label>
<Textarea id="sales-message" bind:value={message} required rows={6} maxlength={10000} />
</div>
<div class="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={!canSubmit}>
{submitting ? i18n.t("sales.contact.submitting") : i18n.t("sales.contact.submit")}
</Button>
<a
href={DEMO_BOOKING_URL}
target="_blank"
rel="noopener noreferrer"
class="text-sm text-text-muted underline-offset-4 hover:text-link hover:underline"
>
{i18n.t("sales.contact.orBookDemo")}
</a>
</div>
</form>
</CardContent>
</Card>
{/if}
</PageShell>
+88
View File
@@ -0,0 +1,88 @@
<script lang="ts">
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
import Footer from "$lib/components/site/Footer.svelte";
import SeoHead from "$lib/components/site/SeoHead.svelte";
import { buttonClasses } from "$lib/components/ui";
import { cookieConsent } from "$lib/cookie-consent.svelte";
import { LEGAL_LAST_UPDATED } from "$lib/site";
import { i18n } from "$lib/i18n";
</script>
<SeoHead
title={i18n.t("seo.cookies.title")}
description={i18n.t("seo.cookies.description")}
path="/cookies"
/>
<div class="flex min-h-screen flex-col bg-background">
<SiteHeader />
<main class="mx-auto max-w-4xl flex-1 px-4 pt-28 pb-12 sm:px-6">
<div class="mb-12 text-center">
<h1 class="text-4xl font-bold tracking-tight">{i18n.t("legal.cookies.title")}</h1>
<p class="mt-2 text-text-muted">
{i18n.t("legal.lastUpdated", { date: LEGAL_LAST_UPDATED })}
</p>
</div>
<div class="space-y-10">
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.cookies.intro.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.cookies.intro.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.cookies.intro.p2")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.cookies.types.h")}</h2>
<h3 class="text-xl font-medium">{i18n.t("legal.cookies.necessary.h")}</h3>
<p class="text-text-muted">{i18n.t("legal.cookies.necessary.p")}</p>
<h3 class="text-xl font-medium">{i18n.t("legal.cookies.analytics.h")}</h3>
<p class="text-text-muted">{i18n.t("legal.cookies.analytics.p")}</p>
<h3 class="text-xl font-medium">{i18n.t("legal.cookies.marketing.h")}</h3>
<p class="text-text-muted">{i18n.t("legal.cookies.marketing.p")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.cookies.gtm.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.cookies.gtm.p")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.cookies.third.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.cookies.third.p")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.cookies.manage.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.cookies.manage.p")}</p>
<button
type="button"
class={buttonClasses("outline", "default", "mt-2")}
onclick={() => cookieConsent.openPreferences()}
>
{i18n.t("site.cookieSettings")}
</button>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.contact.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.contact.lead")}</p>
<p class="text-text-muted">
<strong class="text-text">{i18n.t("legal.emailLabel")}</strong>
<a href="mailto:privacy@descrybe.io" class="text-link hover:underline"
>privacy@descrybe.io</a
>
</p>
</section>
</div>
<div class="mt-10 border-t border-border pt-8">
<a href="/" class="text-link hover:underline">{i18n.t("legal.backHome")}</a>
</div>
</main>
<Footer />
</div>
+882
View File
@@ -0,0 +1,882 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, ApiError, failureMessage } from "$lib/api";
import { unwrapList, unwrapTotal, isAbortError, RECENT_JOBS_LIMIT } from "$lib/list";
import type {
ListResponse,
MeResponse,
ProcessingJob,
ShopifyConfig,
WooCommerceConfig
} from "$lib/types";
import Alert from "$lib/components/Alert.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import ListSkeleton from "$lib/components/ListSkeleton.svelte";
import StatCardsSkeleton from "$lib/components/StatCardsSkeleton.svelte";
import DashboardStats from "$lib/components/DashboardStats.svelte";
import NewsFeed from "$lib/components/NewsFeed.svelte";
import UpgradeBanner from "$lib/components/UpgradeBanner.svelte";
import ActivationChecklist from "$lib/components/ActivationChecklist.svelte";
import MigratedEtlGapsPanel from "$lib/components/MigratedEtlGapsPanel.svelte";
import StoreReconnectBanner from "$lib/components/stores/StoreReconnectBanner.svelte";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { isA1NamedPlan } from "$lib/etl-gaps";
import { isA1PaygPlanPure } from "$lib/plan-cohort";
import {
needsStoreReconnect,
STORE_RECONNECT_TARGETS,
type StoreReconnectTarget
} from "$lib/store-reconnect";
import {
Badge,
Button,
Card,
CardContent,
Skeleton,
type BadgeVariant
} from "$lib/components/ui";
import { formatJobStatusLabelPending, formatProcessingTypeLabel } from "$lib/job-status";
import {
billingRecovery,
formatCreditsStatusLabel,
formatCreditsStatusLine,
hasActivePlan,
isEnterprisePlan,
isFreePlan,
isPayAsYouGoPlan,
planDisplayName,
remainingCreditsOf,
upgradeCtaForRole,
withUpgradeHint,
type PlanLike
} from "$lib/billing-display";
import { canManageCompany, isCompanyAdmin } from "$lib/company-admin";
import { formatRelativeTime } from "$lib/utils";
import {
Play,
GraduationCap,
Package,
Plus,
Rss,
ArrowRight,
Upload,
Share2,
ChevronRight,
Map as MapIcon,
RefreshCw
} from "@lucide/svelte";
import { tutorial } from "$lib/tutorial";
import { i18n } from "$lib/i18n";
type FeedsOverview = ListResponse<unknown> & {
total?: number;
product_total?: number;
processed_total?: number;
unprocessed_total?: number;
};
let me = $state<MeResponse | null>(null);
let productTotal = $state(0);
let processedTotal = $state(0);
let unprocessedTotal = $state(0);
let categoryTotal = $state(0);
let attributeTotal = $state(0);
let feedTotal = $state(0);
let processingCount = $state(0);
let recentJobs = $state<ProcessingJob[]>([]);
let error = $state("");
let loading = $state(true);
let dashboardAbort: AbortController | null = null;
let storeReconnectItems = $state<StoreReconnectTarget[]>([]);
function isActiveJobStatus(status: string | null | undefined): boolean {
const s = (status || "").toLowerCase();
return s === "running" || s === "pending" || s === "processing" || s === "queued";
}
function jobLabel(job: ProcessingJob): string {
const type = job.processing_type ?? job.type;
if (type) return formatProcessingTypeLabel(type);
return i18n.t("dashboard.jobFallback", { id: job.id });
}
function statusVariant(status: string | null | undefined): BadgeVariant {
switch ((status ?? "").toLowerCase()) {
case "completed":
case "success":
case "done":
return "success";
case "failed":
case "error":
return "destructive";
case "cancelled":
case "canceled":
case "skipped":
return "secondary";
case "running":
case "processing":
return "secondary";
case "pending":
case "queued":
return "outline";
default:
return "default";
}
}
function statusLabel(status: string | null | undefined): string {
return formatJobStatusLabelPending(status);
}
function jobWhen(job: ProcessingJob): string {
return formatRelativeTime(job.completed_at ?? job.started_at ?? job.created_at);
}
function isPlatformDemoCompany(res: MeResponse | null): boolean {
const name = (res?.company?.name ?? "").trim();
return /^platform demo$/i.test(name) || /^demo$/i.test(name);
}
function numField(payload: unknown, key: string): number | null {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
const v = (payload as Record<string, unknown>)[key];
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
onMount(() => {
const ac = new AbortController();
dashboardAbort = ac;
void (async () => {
try {
const [meRes, feedsRes, catsRes, attrsRes, jobsRes, wooRes, shopifyRes] =
await Promise.all([
api<MeResponse>("/api/auth/me", { signal: ac.signal }),
// Feeds list includes company-scoped product_total / processed / unprocessed
// (same source as Products page tabs — not processed-only).
api<FeedsOverview>("/api/feeds?limit=1&offset=0", { signal: ac.signal }).catch(
(err) => {
if (isAbortError(err) || ac.signal.aborted) throw err;
return null;
}
),
api<ListResponse<unknown> & { total?: number }>(
"/api/categories?limit=1&offset=0",
{ signal: ac.signal }
).catch((err) => {
if (isAbortError(err) || ac.signal.aborted) throw err;
return null;
}),
api<ListResponse<unknown> & { total?: number }>(
"/api/attributes?limit=1&offset=0",
{ signal: ac.signal }
).catch((err) => {
if (isAbortError(err) || ac.signal.aborted) throw err;
return null;
}),
api<ListResponse<ProcessingJob>>(`/api/processing/jobs?limit=${RECENT_JOBS_LIMIT}`, {
signal: ac.signal
}).catch((err) => {
if (isAbortError(err) || ac.signal.aborted) throw err;
return null;
}),
api<WooCommerceConfig>("/api/woocommerce", { signal: ac.signal }).catch(
(err) => {
if (isAbortError(err) || ac.signal.aborted) throw err;
return null;
}
),
api<ShopifyConfig>("/api/shopify", { signal: ac.signal }).catch((err) => {
if (isAbortError(err) || ac.signal.aborted) throw err;
return null;
})
]);
if (ac.signal.aborted) return;
me = meRes;
if (feedsRes) {
feedTotal = unwrapTotal(feedsRes) ?? unwrapList(feedsRes).length;
productTotal =
numField(feedsRes, "product_total") ??
numField(meRes.credits, "product_count") ??
0;
processedTotal = numField(feedsRes, "processed_total") ?? 0;
unprocessedTotal = numField(feedsRes, "unprocessed_total") ?? 0;
} else {
productTotal = numField(meRes.credits, "product_count") ?? 0;
}
if (catsRes) {
categoryTotal = unwrapTotal(catsRes) ?? unwrapList(catsRes).length;
}
if (attrsRes) {
attributeTotal = unwrapTotal(attrsRes) ?? unwrapList(attrsRes).length;
}
if (jobsRes) {
const jobs = unwrapList(jobsRes);
recentJobs = jobs.slice(0, 5);
processingCount = jobs.filter((j) => isActiveJobStatus(j.status)).length;
}
const items: StoreReconnectTarget[] = [];
if (needsStoreReconnect(wooRes)) {
const t = STORE_RECONNECT_TARGETS.find((x) => x.id === "woocommerce");
if (t) items.push(t);
}
if (needsStoreReconnect(shopifyRes)) {
const t = STORE_RECONNECT_TARGETS.find((x) => x.id === "shopify");
if (t) items.push(t);
}
storeReconnectItems = items;
} catch (err) {
if (isAbortError(err) || ac.signal.aborted) return;
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("dashboard.loadFailed"));
} finally {
if (!ac.signal.aborted) loading = false;
}
})();
return () => {
ac.abort();
if (dashboardAbort === ac) dashboardAbort = null;
};
});
const usedCredits = $derived.by(() => me?.credits?.used_credits ?? 0);
const remainingCredits = $derived.by(() => remainingCreditsOf(me?.credits) ?? 0);
const planInfo = $derived.by((): PlanLike | null => {
const plan = me?.credits?.plan;
if (!plan || typeof plan !== "object") return null;
return plan as PlanLike;
});
const planAssigned = $derived(hasActivePlan(me?.credits, planInfo));
const planName = $derived(planDisplayName(planInfo, me?.credits));
const enterprise = $derived(planAssigned && isEnterprisePlan(planInfo));
const isTrial = $derived(Boolean(planInfo?.is_trial));
const isFreePlanFlag = $derived(isFreePlan(planInfo, me?.credits));
const isPayg = $derived(isPayAsYouGoPlan(planInfo, me?.credits));
const remainingLabel = $derived(
formatCreditsStatusLabel(remainingCredits, planInfo, me?.credits)
);
const statusLine = $derived(
formatCreditsStatusLine(remainingCredits, planInfo, me?.credits)
);
const canManageBilling = $derived(isCompanyAdmin(me));
const canManageWorkspace = $derived(canManageCompany(me));
const upgradeCta = $derived(upgradeCtaForRole(canManageBilling));
const recovery = $derived(
billingRecovery({
credits: me?.credits,
plan: planInfo,
canManageBilling
})
);
const outOfCredits = $derived(!isFreePlanFlag && !enterprise && remainingCredits <= 0);
const lowCredits = $derived(
Boolean(me?.credits?.low_credits) &&
!outOfCredits &&
!isFreePlanFlag &&
!enterprise &&
!isPayg
);
const atProductLimit = $derived(Boolean(me?.credits?.at_product_limit) && !enterprise);
const productCount = $derived(me?.credits?.product_count ?? productTotal);
const maxProducts = $derived(
typeof me?.credits?.max_products === "number"
? me.credits.max_products
: typeof planInfo?.max_products === "number"
? planInfo.max_products
: planInfo?.max_products === null
? null
: null
);
const showUpgradeBanner = $derived(
Boolean(recovery) ||
isFreePlanFlag ||
outOfCredits ||
atProductLimit ||
isTrial ||
lowCredits
);
/** Empty only when this company truly has no catalog/feeds. */
const isCatalogEmpty = $derived(
productTotal === 0 && categoryTotal === 0 && attributeTotal === 0 && feedTotal === 0
);
const isDemoSandbox = $derived(isPlatformDemoCompany(me));
const companyLabel = $derived(
(me?.company?.name ?? i18n.t("dashboard.workspaceFallback")).trim() ||
i18n.t("dashboard.workspaceFallback")
);
const trialEndLabel = $derived.by(() => {
const raw = planInfo?.next_billing_date;
if (!raw) return null;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
});
const subtitle = $derived.by(() => {
const bits: string[] = [];
if (statusLine) bits.push(statusLine);
if (processingCount > 0) {
bits.push(
i18n.t(
processingCount === 1 ? "dashboard.jobsRunning" : "dashboard.jobsRunningPlural",
{ count: processingCount.toLocaleString() }
)
);
}
return bits.join(" · ");
});
const canMonitorJobs = $derived(planCapabilities.can("processing.monitor"));
const canExportFeeds = $derived(planCapabilities.can("feeds.export_feeds"));
const planFlags = $derived({
name: typeof planInfo?.name === "string" ? planInfo.name : "",
is_legacy: typeof planInfo?.is_legacy === "boolean" ? planInfo.is_legacy : undefined,
is_custom: typeof planInfo?.is_custom === "boolean" ? planInfo.is_custom : undefined
});
/** A1 cohort never sees cutover reconnect / ETL honesty on the merchant shell. */
const isA1Cohort = $derived(isA1PaygPlanPure(planFlags) || isA1NamedPlan(planFlags));
const showStoreReconnect = $derived(
!isA1Cohort &&
planCapabilities.can("dashboard.store_reconnect") &&
storeReconnectItems.length > 0
);
const processHref = $derived(
unprocessedTotal > 0 ? "/products?type=raw&status=unprocessed" : "/products"
);
const workflowSteps = $derived([
{
id: "feeds",
href: feedTotal > 0 ? "/feeds" : "/feeds?add=1",
label: i18n.t("nav.feeds"),
detail:
feedTotal > 0
? i18n.t(
feedTotal === 1
? "dashboard.workflow.feeds.sources"
: "dashboard.workflow.feeds.sourcesPlural",
{ count: feedTotal.toLocaleString() }
)
: i18n.t("dashboard.workflow.feeds.connect"),
icon: Rss,
done: feedTotal > 0,
tour: "workflow-feeds"
},
{
id: "map",
href: "/feeds?focus=map",
label: i18n.t("dashboard.workflow.map"),
detail:
feedTotal > 0
? i18n.t("dashboard.workflow.map.ready")
: i18n.t("dashboard.workflow.map.detail"),
icon: MapIcon,
done: feedTotal > 0 && productTotal > 0,
tour: "workflow-map"
},
{
id: "sync",
href: "/feeds?focus=sync",
label: i18n.t("dashboard.workflow.sync"),
detail:
productTotal > 0
? i18n.t("dashboard.workflow.products.count", {
count: productTotal.toLocaleString()
})
: feedTotal > 0
? i18n.t("dashboard.workflow.sync.ready")
: i18n.t("dashboard.workflow.sync.detail"),
icon: RefreshCw,
done: productTotal > 0,
tour: "workflow-sync"
},
{
id: "process",
href: processHref,
label: i18n.t("dashboard.workflow.process"),
detail:
processingCount > 0
? i18n.t("dashboard.workflow.processActive", {
count: processingCount.toLocaleString()
})
: unprocessedTotal > 0
? i18n.t("dashboard.workflow.processWaiting", {
count: unprocessedTotal.toLocaleString()
})
: processedTotal > 0
? i18n.t("dashboard.workflow.processDone", {
count: processedTotal.toLocaleString()
})
: i18n.t("dashboard.workflow.processRun"),
icon: Play,
done: processedTotal > 0,
tour: "workflow-process"
}
]);
</script>
{#if loading}
<div class="mx-auto max-w-7xl space-y-8" aria-busy="true">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="flex max-w-xl flex-col gap-2">
<Skeleton class="h-8 w-56" />
<Skeleton class="h-4 w-full max-w-md" />
</div>
<div class="flex flex-wrap gap-2">
<Skeleton class="h-9 w-32" />
<Skeleton class="h-9 w-36" />
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{#each [1, 2, 3, 4] as i (i)}
<Skeleton class="h-24 w-full rounded-xl" />
{/each}
</div>
<StatCardsSkeleton count={5} />
<div class="rounded-xl border border-border bg-surface">
<ListSkeleton rows={3} />
</div>
</div>
{:else if error}
<Alert message={error} />
{:else}
<div class="mx-auto max-w-7xl space-y-8">
<header
class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"
data-tour="dashboard-welcome"
data-assistant-target="dashboard-welcome"
>
<div class="flex max-w-2xl flex-col gap-2">
{#if planAssigned}
<p class="text-xs font-medium uppercase tracking-wide text-text-muted">
{planName}{isTrial ? ` · ${i18n.t("dashboard.trialBadge")}` : ""}
</p>
{/if}
<h1 class="text-2xl font-bold tracking-tight text-text sm:text-3xl">
{#if isCatalogEmpty}
{isDemoSandbox
? i18n.t("dashboard.demoSandbox")
: i18n.t("dashboard.welcomeTo", { name: companyLabel })}
{:else}
{companyLabel}
{/if}
</h1>
<p class="text-sm text-text-muted">
{#if isCatalogEmpty && isDemoSandbox}
{i18n.t("dashboard.demoEmptyHint")}
{:else if isCatalogEmpty}
{#if isPayg}
{i18n.t("dashboard.emptyPaygHint")}
{:else}
{i18n.t("dashboard.emptyCreditsHint", { credits: remainingLabel })}
{/if}
{:else}
{subtitle}
{/if}
</p>
</div>
<div class="flex flex-wrap gap-2" role="group" aria-label={i18n.t("dashboard.actions")}>
{#if tutorial.canResume && !tutorial.active}
<Button
variant="outline"
size="sm"
data-tour="start-tutorial"
onclick={() => void tutorial.resume()}
>
<GraduationCap class="mr-2 h-4 w-4" />
{i18n.t("dashboard.resumeTutorial")}
</Button>
{:else if tutorial.canRestart && !tutorial.active}
<Button
variant="outline"
size="sm"
data-tour="start-tutorial"
onclick={() => void tutorial.restart()}
>
<GraduationCap class="mr-2 h-4 w-4" />
{i18n.t("dashboard.restartTutorial")}
</Button>
{:else if !tutorial.active}
<Button
variant="outline"
size="sm"
data-tour="start-tutorial"
onclick={() => void tutorial.start(true)}
>
<GraduationCap class="mr-2 h-4 w-4" />
{i18n.t("dashboard.startTutorial")}
</Button>
{/if}
{#if isCatalogEmpty && isDemoSandbox}
<Button size="sm" onclick={() => goto("/feeds?add=1")}>
<Rss class="mr-2 h-4 w-4" />
{i18n.t("dashboard.connectFeed")}
</Button>
<Button size="sm" variant="outline" onclick={() => goto("/files")}>
<Upload class="mr-2 h-4 w-4" />
{i18n.t("dashboard.uploadCsv")}
</Button>
{:else if !isCatalogEmpty && unprocessedTotal > 0}
<Button size="sm" onclick={() => goto(processHref)}>
<Play class="mr-2 h-4 w-4" />
{i18n.t("dashboard.processProducts")}
</Button>
{:else if !isCatalogEmpty}
<Button size="sm" onclick={() => goto("/products")}>
<Package class="mr-2 h-4 w-4" />
{i18n.t("dashboard.openProducts")}
</Button>
{/if}
</div>
</header>
{#if showStoreReconnect}
<StoreReconnectBanner canAdmin={canManageBilling} items={storeReconnectItems} />
{/if}
<MigratedEtlGapsPanel
canAdmin={canManageWorkspace}
planName={typeof planInfo?.name === "string" ? planInfo.name : null}
isLegacy={typeof planInfo?.is_legacy === "boolean" ? planInfo.is_legacy : null}
isCustom={typeof planInfo?.is_custom === "boolean" ? planInfo.is_custom : null}
/>
{#if showUpgradeBanner}
{#if recovery}
<UpgradeBanner
tone={recovery.tone}
title={recovery.title}
message={recovery.message}
primaryHref={recovery.primaryHref}
primaryLabel={recovery.kind === "past_due" && recovery.openPortal
? i18n.t("dashboard.goToBilling")
: recovery.primaryLabel}
showSales={recovery.showSales}
/>
{:else if isFreePlanFlag}
<UpgradeBanner
tone="info"
title={i18n.t("dashboard.freePlanTitle")}
message={withUpgradeHint(
maxProducts
? i18n.t("dashboard.freePlanMessageWithLimit", {
used: productCount.toLocaleString(),
max: maxProducts.toLocaleString()
})
: i18n.t("dashboard.freePlanMessage"),
upgradeCta
)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{:else if outOfCredits}
<UpgradeBanner
tone="danger"
title={i18n.t("dashboard.outOfCreditsTitle")}
message={withUpgradeHint(i18n.t("dashboard.outOfCreditsMessage"), upgradeCta)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{:else if atProductLimit}
<UpgradeBanner
tone="danger"
title={i18n.t("dashboard.productLimitTitle")}
message={withUpgradeHint(
maxProducts
? i18n.t("dashboard.productLimitMessage", {
plan: planName,
max: maxProducts.toLocaleString(),
count: productCount.toLocaleString()
})
: i18n.t("dashboard.productLimitMessageFull", { plan: planName }),
upgradeCta
)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={canManageBilling
? i18n.t("dashboard.comparePlans")
: upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{:else if isTrial}
<UpgradeBanner
tone="info"
title={i18n.t("dashboard.trialTitle", { plan: planName })}
message={withUpgradeHint(
trialEndLabel
? i18n.t("dashboard.trialMessageDated", {
credits: remainingLabel,
date: trialEndLabel
})
: i18n.t("dashboard.trialMessage", { credits: remainingLabel }),
upgradeCta
)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={canManageBilling
? i18n.t("dashboard.viewPlans")
: upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{:else if lowCredits}
<UpgradeBanner
tone="warning"
title={i18n.t("dashboard.lowCreditsTitle")}
message={withUpgradeHint(
i18n.t("dashboard.lowCreditsMessage", {
credits: remainingLabel,
plan: planName
}),
upgradeCta
)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={canManageBilling
? i18n.t("dashboard.comparePlans")
: upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{/if}
{/if}
{#if isCatalogEmpty && !isDemoSandbox}
<ActivationChecklist />
{:else if isCatalogEmpty && isDemoSandbox}
<EmptyState
title={i18n.t("dashboard.demoEmptyTitle")}
message={i18n.t("dashboard.demoEmptyMessage")}
>
<Button variant="outline" onclick={() => goto("/feeds?add=1")}>
<Plus class="mr-2 h-4 w-4" />
{i18n.t("dashboard.connectFeedAnyway")}
</Button>
</EmptyState>
{/if}
<section class="space-y-3" aria-labelledby="dashboard-workflow-heading">
<div>
<h2 id="dashboard-workflow-heading" class="text-base font-semibold tracking-tight text-text">
{i18n.t("dashboard.workflowTitle")}
</h2>
<p class="text-xs text-text-muted">
{i18n.t("dashboard.workflowHint", { name: companyLabel })}
</p>
</div>
<ol
class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"
data-tour="dashboard-workflow"
>
{#each workflowSteps as step, index (step.id)}
<li class="relative">
<a
href={step.href}
class="group flex h-full items-start gap-3 rounded-xl border border-border bg-surface p-4 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-tour={step.tour}
>
<span
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg {step.done
? 'bg-primary text-primary-foreground'
: 'bg-muted text-text'}"
aria-hidden="true"
>
<step.icon class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1">
<span class="flex items-center gap-1.5 text-sm font-semibold text-text">
<span class="text-text-muted">{index + 1}.</span>
{step.label}
</span>
<span class="mt-0.5 block text-xs text-text-muted">{step.detail}</span>
</span>
<ChevronRight
class="mt-1 h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5 group-hover:text-text"
aria-hidden="true"
/>
</a>
</li>
{/each}
</ol>
</section>
{#if !isCatalogEmpty}
<section class="space-y-3" aria-labelledby="dashboard-overview-heading">
<div class="flex items-end justify-between gap-3">
<div>
<h2 id="dashboard-overview-heading" class="text-base font-semibold tracking-tight text-text">
{i18n.t("dashboard.overviewTitle")}
</h2>
<p class="text-xs text-text-muted">
{i18n.t("dashboard.overviewHint", { name: companyLabel })}
</p>
</div>
</div>
<DashboardStats
stats={{
products: productTotal,
categories: categoryTotal,
attributes: attributeTotal,
feeds: feedTotal,
processed: processedTotal,
unprocessed: unprocessedTotal
}}
credits={{ usedCredits, remainingCredits }}
plan={planInfo}
/>
</section>
{/if}
<div class="grid gap-6 lg:grid-cols-5">
{#if !isCatalogEmpty}
<section class="space-y-3 lg:col-span-3" aria-labelledby="dashboard-jobs-heading">
<div class="flex items-end justify-between gap-4">
<div>
<h2 id="dashboard-jobs-heading" class="text-base font-semibold tracking-tight text-text">
{i18n.t("dashboard.recentActivity")}
</h2>
<p class="text-xs text-text-muted">
{#if processingCount > 0}
{i18n.t(
processingCount === 1
? "dashboard.activeJobs"
: "dashboard.activeJobsPlural",
{ count: processingCount.toLocaleString() }
)}
{:else}
{i18n.t("dashboard.latestJobs")}
{/if}
</p>
</div>
{#if canMonitorJobs}
<a
href="/processing"
class="text-sm font-medium text-link underline-offset-4 hover:underline"
>
{i18n.t("common.viewAll")}
</a>
{/if}
</div>
<Card class="border-border bg-surface shadow-sm">
<CardContent class="pt-4">
{#if recentJobs.length === 0}
<EmptyState
title={i18n.t("empty.dashboard.noJobsTitle")}
message={i18n.t("empty.dashboard.noJobsMessage")}
>
<a href="/products?type=raw&status=unprocessed">
<Button size="sm">
<Play class="mr-2 h-4 w-4" />
{i18n.t("dashboard.startJob")}
</Button>
</a>
</EmptyState>
{:else}
<ul class="divide-y divide-border">
{#each recentJobs as job (job.id)}
<li class="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-text">{jobLabel(job)}</p>
<p class="text-xs text-text-muted">{jobWhen(job)}</p>
</div>
<Badge variant={statusVariant(job.status)}>{statusLabel(job.status)}</Badge>
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
</section>
{/if}
<section
class="min-w-0 space-y-3 {isCatalogEmpty ? 'lg:col-span-5' : 'lg:col-span-2'}"
aria-labelledby="dashboard-shortcuts-heading"
>
<div>
<h2 id="dashboard-shortcuts-heading" class="text-base font-semibold tracking-tight text-text">
{i18n.t("dashboard.quickLinks")}
</h2>
<p class="text-xs text-text-muted">{i18n.t("dashboard.quickLinksHint")}</p>
</div>
<div class="space-y-2">
<a
href="/feeds"
class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-tour="manage-feeds"
>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted">
<Rss class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text">{i18n.t("nav.feeds")}</p>
<p class="text-xs text-text-muted">{i18n.t("dashboard.feedsImportMap")}</p>
</div>
<ArrowRight class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5" />
</a>
<a
href="/products"
class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-tour="start-processing"
data-assistant-target="start-processing"
>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted">
<Package class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text">{i18n.t("nav.products")}</p>
<p class="text-xs text-text-muted">{i18n.t("dashboard.productsBrowse")}</p>
</div>
<ArrowRight class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5" />
</a>
{#if canMonitorJobs}
<a
href="/processing"
class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted">
<Play class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text">{i18n.t("nav.jobs")}</p>
<p class="text-xs text-text-muted">{i18n.t("dashboard.jobsMonitor")}</p>
</div>
<ArrowRight
class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5"
/>
</a>
{/if}
{#if canExportFeeds}
<a
href="/export-feeds"
class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-tour="manage-exports"
>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted">
<Share2 class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text">{i18n.t("nav.exports")}</p>
<p class="text-xs text-text-muted">{i18n.t("dashboard.exportsTemplates")}</p>
</div>
<ArrowRight
class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5"
/>
</a>
{/if}
</div>
<div class="min-w-0 pb-16 pt-2">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-text-muted">
{i18n.t("dashboard.whatsNew")}
</h3>
<NewsFeed limit={1} />
</div>
</section>
</div>
</div>
{/if}
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import { page } from "$app/state";
import { OPENAPI_SPEC_URL } from "$lib/public-api-base";
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
</script>
<svelte:head>
<title>Docs error | Descrybe</title>
</svelte:head>
<div class="flex min-h-screen flex-col bg-background">
<SiteHeader />
<main id="main-content" class="mx-auto flex w-full max-w-xl flex-1 flex-col justify-center px-4 py-16 pt-24 text-center">
<p class="text-sm font-medium text-text-muted">HTTP {page.status}</p>
<h1 class="mt-2 text-xl font-semibold tracking-tight">Could not open API Docs</h1>
<p class="mt-2 text-sm text-text-muted">
{page.error?.message ?? "An unexpected error stopped the docs page from rendering."}
</p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<a
href="/docs"
class="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:opacity-90"
>Reload docs</a
>
<a
href={OPENAPI_SPEC_URL}
class="inline-flex h-9 items-center justify-center rounded-md border border-border px-4 text-sm font-medium hover:bg-muted"
>OpenAPI YAML</a
>
<a href="/" class="text-sm font-medium text-link hover:underline">Home</a>
</div>
</main>
</div>
+649
View File
@@ -0,0 +1,649 @@
<script lang="ts">
import { failureMessage } from "$lib/api";
import { onMount } from "svelte";
import { browser } from "$app/environment";
import {
loadOpenApiSpec,
prefetchOpenApiSpec,
applyRapiDocTryItServer,
lockRapiDocTryItServer,
OPENAPI_SPEC_URL
} from "$lib/public-api-base";
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
import DocsAskGuide from "$lib/components/docs/DocsAskGuide.svelte";
import { theme } from "$lib/theme.svelte";
import {
applyRapiDocApiKey,
clearRapiDocApiKeys,
clearStoredTryItKey,
loadDocsAuthStatus,
maskKeyPrefix,
readStoredTryItKey,
resolveDocsTryItKey,
writeStoredTryItKey,
type DocsAuthStatus,
type RapiDocAuthElement
} from "$lib/docs/rapi-doc-auth";
import { i18n } from "$lib/i18n";
import { CircleHelp, KeyRound } from "@lucide/svelte";
/**
* Static vendor copy of rapidoc (see scripts/copy-rapidoc-ui.mjs).
* Loaded as type=module — single file, no cloud phone-home.
*/
const RAPIDOC_JS = "/vendor/rapidoc/rapidoc-min.js";
const SCRIPT_TIMEOUT_MS = 20_000;
/** Bound RapiDoc YAML parse so a bad/huge spec cannot hang forever. */
const LOAD_SPEC_TIMEOUT_MS = 12_000;
// Start OpenAPI fetch during module init (before onMount). Prefer this over
// `<link rel="preload" as="fetch">`, which Chrome flagged as unused.
if (browser) {
void prefetchOpenApiSpec();
}
type RapiDocElement = RapiDocAuthElement & {
loadSpec: (specUrlOrObject: string | Record<string, unknown>) => Promise<void>;
selectedServer?: { url: string; computedUrl: string };
requestUpdate?: () => void;
};
let loadState: "loading" | "ready" | "error" = $state("loading");
let loadError = $state("");
/** Soft fault after ready — must not blank the shell. */
let runtimeWarning = $state("");
/** $state so theme $effect re-runs after mount + SiteHeader toggle. */
let rapiEl = $state<RapiDocElement | undefined>(undefined);
let askOpen = $state(false);
let mountGeneration = 0;
let hydrateCheckTimer: number | undefined;
/** Session probe for Try-it authorize (marketing layout skips /api/auth/me). */
let docsAuth: DocsAuthStatus | null = $state(null);
let docsAuthLoading = $state(true);
let authBusy = $state(false);
let authMessage = $state("");
let authError = $state("");
let appliedPrefix = $state("");
let pasteKey = $state("");
let showPaste = $state(false);
/** Match layout.css canvas / surface / ink (FOUC paint in theme.svelte). */
function applyRapiDocTheme(el: RapiDocElement, isDark: boolean) {
el.setAttribute("theme", isDark ? "dark" : "light");
el.setAttribute("nav-bg-color", isDark ? "#14121C" : "#F9F9FB");
el.setAttribute("bg-color", isDark ? "#1D1B28" : "#FFFFFF");
el.setAttribute("text-color", isDark ? "#F3F3F7" : "#2C2357");
}
onMount(() => {
if (!browser) return;
const authAc = new AbortController();
void (async () => {
docsAuthLoading = true;
try {
docsAuth = await loadDocsAuthStatus(authAc.signal);
} catch {
docsAuth = { kind: "anonymous" };
} finally {
if (!authAc.signal.aborted) docsAuthLoading = false;
}
})();
void bootViewer(false);
const onWindowError = (event: ErrorEvent) => {
if (loadState !== "ready") return;
const msg = event.message?.trim();
if (!msg) return;
// RapiDoc 9.3.x: responseTemplate/null.value + relative server URL() noise — docs still usable.
if (isBenignRapiDocNoise(msg, event.filename)) return;
runtimeWarning = msg.slice(0, 240);
};
const onRejection = (event: PromiseRejectionEvent) => {
if (loadState !== "ready") return;
const reason = event.reason;
const msg =
reason instanceof Error
? reason.message
: typeof reason === "string"
? reason
: "A docs panel failed unexpectedly.";
const trimmed = msg.trim();
if (!trimmed || isBenignRapiDocNoise(trimmed)) return;
runtimeWarning = trimmed.slice(0, 240);
};
window.addEventListener("error", onWindowError);
window.addEventListener("unhandledrejection", onRejection);
return () => {
authAc.abort();
mountGeneration += 1;
window.clearTimeout(hydrateCheckTimer);
window.removeEventListener("error", onWindowError);
window.removeEventListener("unhandledrejection", onRejection);
rapiEl?.remove();
rapiEl = undefined;
};
});
const activeCompanyId = $derived.by(() => {
if (!docsAuth || docsAuth.kind === "anonymous") return "";
const me = docsAuth.me;
return (me.active_company_id ?? me.company?.id ?? "").trim();
});
/** After RapiDoc mounts, re-apply a session-scoped try-it key if we have one. */
$effect(() => {
if (!browser || !rapiEl || loadState !== "ready") return;
const companyId = activeCompanyId;
if (!companyId) return;
const stored = readStoredTryItKey(companyId);
if (!stored) return;
if (applyRapiDocApiKey(rapiEl, stored.key)) {
appliedPrefix = stored.prefix || stored.key.slice(0, 10);
authMessage = `Try-it authorized (${maskKeyPrefix(appliedPrefix)}).`;
authError = "";
}
});
function applyKeyToViewer(rawKey: string, prefixHint?: string): boolean {
const key = rawKey.trim();
if (!key.startsWith("dk_")) {
authError = "API keys start with dk_.";
return false;
}
if (!rapiEl) {
authError = "Wait for the API reference to finish loading.";
return false;
}
if (!applyRapiDocApiKey(rapiEl, key)) {
authError = "Could not set the API key on RapiDoc.";
return false;
}
appliedPrefix = (prefixHint ?? key.slice(0, 10)).trim();
authMessage = `Try-it authorized (${maskKeyPrefix(appliedPrefix)}).`;
authError = "";
return true;
}
async function useMyApiKey() {
if (!docsAuth || docsAuth.kind !== "ready") return;
const companyId = activeCompanyId;
if (!companyId) {
authError = "Select a company in the dashboard first.";
return;
}
authBusy = true;
authError = "";
authMessage = "";
try {
const entry = await resolveDocsTryItKey(companyId, docsAuth.canCreateKey);
if (!applyKeyToViewer(entry.key, entry.prefix)) return;
authMessage = docsAuth.canCreateKey
? `Try-it authorized with ${maskKeyPrefix(entry.prefix)}. Key is kept in this browser tab only.`
: authMessage;
} catch (err) {
authError = failureMessage(err, "Could not authorize Try-it.");
} finally {
authBusy = false;
}
}
function applyPastedKey() {
const key = pasteKey.trim();
if (!applyKeyToViewer(key)) return;
const companyId = activeCompanyId;
if (companyId) {
writeStoredTryItKey({
key,
keyId: "",
prefix: key.slice(0, 10),
companyId
});
}
pasteKey = "";
showPaste = false;
}
function clearTryItAuth() {
clearRapiDocApiKeys(rapiEl);
if (activeCompanyId) clearStoredTryItKey(activeCompanyId);
appliedPrefix = "";
authMessage = "Try-it authorization cleared.";
authError = "";
}
/** Keep RapiDoc in lockstep with SiteHeader ThemeToggle (`theme` / descrybe-theme). */
$effect(() => {
if (!browser) return;
const isDark = theme.isDark;
const el = rapiEl;
if (!el) return;
applyRapiDocTheme(el, isDark);
});
function rapiDocDefined(): boolean {
return typeof customElements !== "undefined" && Boolean(customElements.get("rapi-doc"));
}
/** Known RapiDoc vendor faults that do not block reading the OpenAPI docs. */
function isBenignRapiDocNoise(message: string, filename?: string | null): boolean {
const fromVendor = /rapidoc/i.test(filename ?? "") || /rapidoc/i.test(message);
if (/YAMLParseError/i.test(message)) return false;
if (/Failed to construct 'URL': Invalid URL/i.test(message)) return true;
if (/Cannot read properties of null \(reading 'value'\)/i.test(message)) return true;
return fromVendor && /reading 'value'/i.test(message);
}
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const existing = document.querySelector(
`script[data-docs-rapidoc-js="1"]`
) as HTMLScriptElement | null;
const finish = () => {
if (rapiDocDefined()) {
resolve();
return;
}
customElements
.whenDefined("rapi-doc")
.then(() => resolve())
.catch(() => reject(new Error("rapi-doc custom element failed to register.")));
};
if (existing) {
if (rapiDocDefined()) {
resolve();
return;
}
const timer = window.setTimeout(() => {
reject(new Error(`Timed out waiting for ${src}`));
}, SCRIPT_TIMEOUT_MS);
existing.addEventListener(
"load",
() => {
window.clearTimeout(timer);
finish();
},
{ once: true }
);
existing.addEventListener(
"error",
() => {
window.clearTimeout(timer);
reject(new Error(`Failed to load ${src}`));
},
{ once: true }
);
return;
}
const script = document.createElement("script");
script.src = src;
script.type = "module";
script.async = true;
script.dataset.docsRapidocJs = "1";
const timer = window.setTimeout(() => {
reject(new Error(`Timed out loading ${src}`));
}, SCRIPT_TIMEOUT_MS);
script.onload = () => {
window.clearTimeout(timer);
finish();
};
script.onerror = () => {
window.clearTimeout(timer);
reject(new Error(`Failed to load ${src}`));
};
document.body.appendChild(script);
});
}
function scheduleHydrateGuard(el: HTMLElement, gen: number) {
window.clearTimeout(hydrateCheckTimer);
hydrateCheckTimer = window.setTimeout(() => {
if (gen !== mountGeneration || loadState !== "ready") return;
const rapi = el.querySelector("rapi-doc");
// RapiDoc renders into open shadow DOM — light-DOM queries always miss.
const root: ParentNode = rapi?.shadowRoot ?? el;
const hasStructure =
root.querySelector(
".nav-bar, .nav-scroll, .nav-bar-path, #link-overview, .operations, .section-gap, [class*='nav-']"
) != null;
if (!hasStructure) {
runtimeWarning =
"Docs sidebar did not render. Try Retry, or open the OpenAPI YAML link.";
}
}, 4_000);
}
async function bootViewer(forceRefresh: boolean) {
const gen = ++mountGeneration;
loadState = "loading";
loadError = "";
runtimeWarning = "";
window.clearTimeout(hydrateCheckTimer);
rapiEl?.remove();
rapiEl = undefined;
try {
const instance = await mountViewer(forceRefresh, theme.isDark, gen);
if (gen !== mountGeneration) {
instance?.remove();
return;
}
if (!instance) {
loadState = "error";
loadError = "API reference mount point missing.";
return;
}
rapiEl = instance;
} catch (err) {
if (gen !== mountGeneration) return;
loadState = "error";
loadError = failureMessage(err, "Failed to load the API reference viewer.");
}
}
async function mountViewer(
forceRefresh: boolean,
isDark: boolean,
gen: number
): Promise<RapiDocElement | undefined> {
const host = document.getElementById("api-reference");
if (!host) return undefined;
// Prefer shared timeout + session cache (do not bypass loadOpenApiSpec).
await Promise.all([
loadOpenApiSpec({ force: forceRefresh }),
loadScript(RAPIDOC_JS)
]);
if (gen !== mountGeneration) return undefined;
host.replaceChildren();
const doc = document.createElement("rapi-doc") as RapiDocElement;
applyRapiDocTheme(doc, isDark);
doc.setAttribute("render-style", "read");
doc.setAttribute("show-header", "false");
doc.setAttribute("load-fonts", "false");
doc.setAttribute("allow-spec-url-load", "false");
doc.setAttribute("allow-spec-file-load", "false");
doc.setAttribute("allow-spec-file-download", "true");
doc.setAttribute("show-method-in-nav-bar", "as-colored-text");
doc.setAttribute("use-path-in-nav-bar", "true");
// server-url + default-api-server must match or Try-it uses OpenAPI servers[0] (prod).
const tryItServer = applyRapiDocTryItServer(doc, window.location.origin);
doc.setAttribute("primary-color", "#2563eb");
doc.setAttribute("regular-font", "inherit");
doc.setAttribute("mono-font", "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace");
doc.style.width = "100%";
doc.style.height = "100%";
doc.style.minHeight = "70vh";
host.appendChild(doc);
let markedReady = false;
const markReady = () => {
if (gen !== mountGeneration || markedReady) return;
markedReady = true;
loadState = "ready";
loadError = "";
scheduleHydrateGuard(host, gen);
};
// Drop overlay as soon as the viewer shell is in the DOM; loadSpec fills it in.
markReady();
// Prefer absolute HTTP URL over blob: — @apitools/openapi-parser logs
// `Failed to construct 'URL': Invalid URL` when resolving against blob bases.
// loadOpenApiSpec already validated/cached the document (timeouts + session cache).
const absSpecUrl = new URL(OPENAPI_SPEC_URL, window.location.origin).href;
let loadSpecTimer: number | undefined;
try {
await Promise.race([
doc.loadSpec(absSpecUrl),
new Promise<never>((_, reject) => {
loadSpecTimer = window.setTimeout(
() => reject(new Error("Timed out parsing the OpenAPI document.")),
LOAD_SPEC_TIMEOUT_MS
);
})
]);
if (gen !== mountGeneration) return undefined;
lockRapiDocTryItServer(doc, tryItServer);
markReady();
} finally {
if (loadSpecTimer !== undefined) window.clearTimeout(loadSpecTimer);
}
return doc;
}
</script>
<svelte:head>
<title>API Docs | Descrybe</title>
<meta
name="description"
content="Descrybe public API reference: products, categories, feeds, and AI processing."
/>
<link rel="modulepreload" href={RAPIDOC_JS} />
</svelte:head>
<div class="flex min-h-screen flex-col bg-background">
<SiteHeader />
<main id="main-content" class="flex flex-1 flex-col pt-16">
<div class="flex items-center justify-between gap-4 border-b border-border px-4 py-3 sm:px-6">
<div>
<h1 class="text-lg font-semibold tracking-tight text-text">API Docs</h1>
<p class="mt-0.5 text-xs text-text-muted">
Public API-key surface · search in the sidebar
</p>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<button
type="button"
class="inline-flex items-center gap-1.5 text-sm font-medium text-link hover:underline"
onclick={() => (askOpen = true)}
>
<CircleHelp class="h-3.5 w-3.5" aria-hidden="true" />
Ask guide
</button>
<a
href={OPENAPI_SPEC_URL}
class="text-sm font-medium text-link hover:underline"
>OpenAPI YAML</a
>
</div>
</div>
<div
class="border-b border-border bg-surface px-4 py-2.5 sm:px-6"
role="region"
aria-label="API Try-it authorization"
>
{#if docsAuthLoading}
<p class="text-xs text-text-muted">Checking session for Try-it…</p>
{:else if !docsAuth || docsAuth.kind === "anonymous"}
<p class="text-sm text-text">
<a href="/login" class="font-medium text-link hover:underline">Log in</a>
to authorize Try-it with your company API key, or paste a
<code class="rounded bg-muted px-1 text-xs text-text">dk_</code> key.
<button
type="button"
class="ml-1 font-medium text-link hover:underline"
onclick={() => (showPaste = !showPaste)}
>
{showPaste ? "Hide paste" : "Paste key"}
</button>
</p>
{:else if docsAuth.kind === "no_company"}
<p class="text-sm text-text">
Signed in as {docsAuth.me.user.email}.
<a href="/dashboard" class="font-medium text-link hover:underline">Select a company</a>
to use or create an API key for Try-it.
</p>
{:else}
{@const companyName = docsAuth.me.company?.name ?? "your company"}
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<p class="text-sm text-text">
Signed in · {companyName}
{#if appliedPrefix}
<span class="text-text-muted">· Try-it {maskKeyPrefix(appliedPrefix)}</span>
{/if}
</p>
{#if docsAuth.canCreateKey}
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition hover:opacity-90 disabled:opacity-60"
disabled={authBusy || loadState !== "ready"}
onclick={() => void useMyApiKey()}
>
<KeyRound class="h-3.5 w-3.5" aria-hidden="true" />
{authBusy ? "Authorizing…" : "Use my API key"}
</button>
{/if}
<button
type="button"
class="text-xs font-medium text-link hover:underline"
onclick={() => (showPaste = !showPaste)}
>
{showPaste ? "Hide paste" : "Paste key"}
</button>
{#if appliedPrefix}
<button
type="button"
class="text-xs font-medium text-text-muted hover:underline"
onclick={clearTryItAuth}
>
Clear
</button>
{/if}
<a
href="/settings?tab=api-keys"
class="text-xs font-medium text-text-muted hover:underline"
>Settings → API keys</a
>
</div>
{#if docsAuth.keyPrefixes.length > 0}
<p class="mt-1 text-xs text-text-muted">
Existing key prefixes (full values cannot be revealed):
{docsAuth.keyPrefixes.slice(0, 5).map((p) => maskKeyPrefix(p)).join(", ")}
{#if !docsAuth.canCreateKey}
· ask an admin to create a key, or paste one you already have
{/if}
</p>
{:else}
<p class="mt-1 text-xs text-text-muted" data-testid="docs-tryit-no-keys-legacy">
{docsAuth.canCreateKey
? i18n.t("docs.tryIt.noKeysLegacyAdmin")
: i18n.t("docs.tryIt.noKeysLegacyMember")}
</p>
{/if}
{/if}
{#if showPaste}
<div class="mt-2 flex flex-wrap items-center gap-2">
<label class="sr-only" for="docs-tryit-paste">API key</label>
<input
id="docs-tryit-paste"
type="password"
autocomplete="off"
spellcheck="false"
placeholder="dk_…"
bind:value={pasteKey}
class="min-w-[16rem] flex-1 rounded-md border border-input bg-background px-2 py-1 font-mono text-xs text-text"
/>
<button
type="button"
class="rounded-md bg-secondary px-2.5 py-1 text-xs font-medium text-secondary-foreground transition hover:opacity-90 disabled:opacity-60"
disabled={!pasteKey.trim() || loadState !== "ready"}
onclick={applyPastedKey}
>
Apply to Try-it
</button>
</div>
{/if}
{#if authError}
<p class="mt-1.5 text-xs text-danger" role="alert">{authError}</p>
{:else if authMessage}
<p class="mt-1.5 text-xs text-success" role="status">{authMessage}</p>
{/if}
<p class="mt-1.5 text-[11px] text-text-muted">
Public
<code class="rounded bg-muted px-1 text-text">/api/v1</code>
uses Bearer or X-API-Key — not your dashboard session cookie. Prefer
<code class="rounded bg-muted px-1 text-text">Authorization: Bearer</code>.
</p>
</div>
{#if runtimeWarning && loadState === "ready"}
<div
class="flex items-start justify-between gap-3 border-b border-border bg-accent px-4 py-2 text-sm text-accent-foreground sm:px-6"
role="status"
>
<p class="min-w-0 flex-1">{runtimeWarning}</p>
<button
type="button"
class="shrink-0 font-medium text-link underline-offset-2 hover:underline"
onclick={() => (runtimeWarning = "")}
>
Dismiss
</button>
</div>
{/if}
<div class="relative min-h-[70vh] flex-1 bg-background">
{#if loadState === "loading"}
<div
class="absolute inset-0 z-10 flex items-center justify-center bg-background/80"
role="status"
aria-live="polite"
aria-busy="true"
>
<p class="text-sm text-text-muted">Loading interactive API reference…</p>
</div>
{/if}
{#if loadState === "error"}
<div class="mx-auto max-w-xl px-4 py-16 text-center">
<p class="font-medium text-danger">Could not load the interactive docs.</p>
<p class="mt-2 text-sm text-text-muted">{loadError}</p>
<p class="mt-4 text-sm text-text">
Open the raw OpenAPI document:
<a href={OPENAPI_SPEC_URL} class="font-medium text-link hover:underline"
>{OPENAPI_SPEC_URL}</a
>
·
<button
type="button"
class="font-medium text-link hover:underline"
onclick={() => (askOpen = true)}>Ask guide</button
>
</p>
<button
type="button"
class="mt-6 inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
onclick={() => void bootViewer(true)}
>
Retry
</button>
</div>
{/if}
<div id="api-reference" class="rapidoc-docs-wrap" class:hidden={loadState === "error"}></div>
</div>
</main>
<DocsAskGuide bind:open={askOpen} rapiEl={rapiEl} />
</div>
<style>
:global(.rapidoc-docs-wrap) {
min-height: 70vh;
height: calc(100vh - 8rem);
}
:global(.rapidoc-docs-wrap rapi-doc) {
display: block;
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,873 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, ApiError, failureMessage, apiUrl } from "$lib/api";
import { trackEvent } from "$lib/analytics";
import { unwrapList, OPTION_LIST_LIMIT } from "$lib/list";
import { formatRelativeTime } from "$lib/utils";
import { isCompanyAdmin } from "$lib/company-admin";
import { authSession } from "$lib/auth-session.svelte";
import { notifyApiError, notifySuccess } from "$lib/notify";
import type { ExportFeed, Feed, ListResponse, MeResponse } from "$lib/types";
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 DataCard from "$lib/components/DataCard.svelte";
import StatusBadge from "$lib/components/StatusBadge.svelte";
import {
Badge,
Button,
Dialog,
DropdownMenu,
DropdownMenuItem,
Input,
Label,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "$lib/components/ui";
import {
ChevronDown,
Copy,
Download,
Edit,
Eye,
MoreVertical,
Plus,
RefreshCw,
KeyRound,
Trash2
} from "@lucide/svelte";
import {
DEFAULT_CUSTOM_FIELDS,
EXPORT_PRESETS,
defaultNameForPreset,
presetById,
presetDescription,
presetHint,
presetLabel,
presetShortLabel,
type ExportField,
type ExportPresetId
} from "$lib/export-presets";
import {
activeFormValue,
buildExportFilters,
filterExportFeedsBySearch,
formatStatusList,
isActiveFromForm,
resolveExportFeedsListKind
} from "$lib/export-feeds-helpers";
type ExportRow = ExportFeed & {
format?: string | null;
public_token?: string | null;
source_feed_id?: string | null;
is_active?: boolean | null;
updated_at?: string | null;
last_generated_at?: string | null;
created_at?: string | null;
template?: {
root?: string;
item?: string;
fields?: ExportField[];
mappings?: Record<string, string>;
channel?: string;
} | null;
filters?: { statuses?: string[]; feed_id?: string } | null;
};
type InputFeed = Feed & { name?: string | null };
const SOURCE_OPTIONS = [
"product_id",
"name",
"title",
"category",
"description",
"processed_name",
"processed_description",
"status",
"feed_id",
"gtin",
"attr.url",
"attr.image",
"attr.availability",
"attr.price",
"attr.brand",
"attr.condition",
"attr.stock",
"attributes",
"processed_attributes",
"eprel_id",
"energy_class",
"eprel_energy_class",
"energy_scale",
"eprel_label",
"eprel_pdf",
"specifications",
"specifications.*"
];
let feeds = $state<ExportRow[]>([]);
let inputFeeds = $state<InputFeed[]>([]);
let error = $state("");
/** Set only by list `load()` — keeps create/save failures from looking like a failed list fetch. */
let listError = $state("");
let success = $state("");
let loading = $state(true);
let saving = $state(false);
let canAdmin = $state(false);
let search = $state("");
let createMenuOpen = $state(false);
let dialogOpen = $state(false);
let editingId = $state<string | null>(null);
let formPresetId = $state<ExportPresetId>("custom_xml");
let formName = $state("");
let formFormat = $state("xml");
let formRoot = $state("products");
let formItem = $state("product");
let formSourceFeedId = $state("");
let formActive = $state("active");
let formStatuses = $state("processed,completed");
let formFields = $state<ExportField[]>([...DEFAULT_CUSTOM_FIELDS]);
const activePreset = $derived(presetById(formPresetId));
let busyId = $state<string | null>(null);
let generateStatus = $state<{
id: string;
status: string;
products?: number;
last_generated_at?: string | null;
} | null>(null);
async function load() {
loading = true;
listError = "";
try {
const [exportPayload, feedsPayload] = await Promise.all([
api<ListResponse<ExportRow>>(`/api/export-feeds?limit=${OPTION_LIST_LIMIT}`),
api<ListResponse<InputFeed>>(`/api/feeds?limit=${OPTION_LIST_LIMIT}`).catch(() => ({ feeds: [] as InputFeed[] }))
]);
feeds = unwrapList(exportPayload);
inputFeeds = unwrapList(feedsPayload);
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
listError = failureMessage(err, i18n.t("exports.loadFailed"));
} finally {
loading = false;
}
}
onMount(() => {
void (async () => {
try {
const me = await api<MeResponse>("/api/auth/me");
authSession.setMe(me);
canAdmin = isCompanyAdmin(me);
} catch {
canAdmin = authSession.isCompanyAdmin;
}
await load();
})();
});
const filtered = $derived.by(() => filterExportFeedsBySearch(feeds, search));
const listKind = $derived(
resolveExportFeedsListKind({
loading,
totalCount: feeds.length,
filteredCount: filtered.length,
listError
})
);
function publicPath(feed: ExportRow): string {
const token = String(feed.public_token ?? "");
const ext = String(feed.format ?? "xml").toLowerCase() === "csv" ? "csv" : "xml";
return `/api/public/export-feeds/${token}.${ext}`;
}
function publicUrl(feed: ExportRow): string {
const path = publicPath(feed);
const built = apiUrl(path);
if (/^https?:\/\//i.test(built)) return built;
if (typeof window !== "undefined" && window.location?.origin) {
return `${window.location.origin}${built.startsWith("/") ? built : `/${built}`}`;
}
return built;
}
function fieldsFromTemplate(template: ExportRow["template"]): ExportField[] {
if (!template) return [...DEFAULT_CUSTOM_FIELDS];
if (Array.isArray(template.fields) && template.fields.length > 0) {
return template.fields.map((f) => {
const row = f as ExportField & { name?: string };
const key = String(row.key ?? row.name ?? "");
return {
key,
source: String(row.source ?? key)
};
});
}
if (template.mappings && typeof template.mappings === "object") {
return Object.entries(template.mappings).map(([key, source]) => ({
key,
source: String(source || key)
}));
}
return [...DEFAULT_CUSTOM_FIELDS];
}
function inferPresetId(format: string, template: ExportRow["template"]): ExportPresetId {
const channel = String(template?.channel ?? "").toLowerCase();
const fmt = format === "csv" ? "csv" : "xml";
if (channel === "google_shopping") {
return fmt === "csv" ? "google_shopping_csv" : "google_shopping_xml";
}
if (channel === "meta") return "meta_csv";
return fmt === "csv" ? "custom_csv" : "custom_xml";
}
function applyPreset(id: ExportPresetId, keepName = false) {
const preset = presetById(id);
formPresetId = preset.id;
formFormat = preset.format;
formRoot = preset.root ?? "products";
formItem = preset.item ?? "product";
formFields = preset.fields.map((f) => ({ ...f }));
if (!keepName) formName = defaultNameForPreset(preset);
}
function openCreate(presetId: ExportPresetId = "custom_xml") {
createMenuOpen = false;
editingId = null;
formSourceFeedId = "";
formActive = "active";
formStatuses = "processed,completed";
applyPreset(presetId);
generateStatus = null;
dialogOpen = true;
}
async function openEdit(feed: ExportRow) {
error = "";
success = "";
busyId = String(feed.id);
try {
const full = await api<ExportRow>(`/api/export-feeds/${feed.id}`);
editingId = String(full.id);
formName = String(full.name ?? i18n.t("exports.fallbackName"));
formFormat = String(full.format ?? "xml").toLowerCase() === "csv" ? "csv" : "xml";
formRoot = String(full.template?.root ?? "products");
formItem = String(full.template?.item ?? "product");
formSourceFeedId = full.source_feed_id ? String(full.source_feed_id) : "";
formActive = activeFormValue(full.is_active);
formStatuses = formatStatusList(
Array.isArray(full.filters?.statuses) ? full.filters.statuses : null
);
formFields = fieldsFromTemplate(full.template);
formPresetId = inferPresetId(formFormat, full.template);
dialogOpen = true;
} catch (err) {
error = failureMessage(err, i18n.t("exports.loadFeedFailed"));
} finally {
busyId = null;
}
}
function addField() {
formFields = [...formFields, { key: "", source: "name" }];
}
function removeField(index: number) {
formFields = formFields.filter((_, i) => i !== index);
}
function buildTemplate() {
const fields = formFields
.map((f) => ({
key: f.key.trim(),
source: f.source.trim() || f.key.trim()
}))
.filter((f) => f.key);
const preset = presetById(formPresetId);
return {
root: formRoot.trim() || "products",
item: formItem.trim() || "product",
channel: preset.channel,
fields
};
}
function buildFilters() {
return buildExportFilters({
statusesCsv: formStatuses,
sourceFeedId: formSourceFeedId
});
}
async function saveFeed(event: Event) {
event.preventDefault();
saving = true;
error = "";
success = "";
try {
const template = buildTemplate();
const filters = buildFilters();
const isCreate = !editingId;
if (editingId) {
await api(`/api/export-feeds/${editingId}`, {
method: "PATCH",
body: {
name: formName.trim(),
is_active: isActiveFromForm(formActive),
template,
filters
}
});
success = i18n.t("flash.export.updated");
} else {
await api("/api/export-feeds", {
method: "POST",
body: {
name: formName.trim(),
format: formFormat,
source_feed_id: formSourceFeedId || null,
template,
filters
}
});
success = i18n.t("flash.export.created");
}
trackEvent("export_feed_saved", {
preset_id: formPresetId,
is_create: isCreate
});
dialogOpen = false;
await load();
} catch (err) {
error = failureMessage(err, i18n.t("exports.saveFailed"));
} finally {
saving = false;
}
}
async function copyUrl(feed: ExportRow) {
try {
await navigator.clipboard.writeText(publicUrl(feed));
success = i18n.t("flash.export.urlCopied", { format: String(feed.format ?? "xml").toUpperCase() });
error = "";
} catch {
error = i18n.t("flash.export.copyFailed");
}
}
function previewFeed(feed: ExportRow) {
window.open(publicUrl(feed), "_blank", "noopener,noreferrer");
}
function downloadFeed(feed: ExportRow) {
window.open(publicUrl(feed), "_blank", "noopener,noreferrer");
}
async function refreshFeed(feed: ExportRow) {
busyId = String(feed.id);
error = "";
success = "";
generateStatus = {
id: String(feed.id),
status: "processing",
last_generated_at: feed.last_generated_at ?? null
};
try {
const result = await api<{
status?: string;
products_exported?: number;
last_generated_at?: string | null;
}>(`/api/export-feeds/${feed.id}/generate`, { method: "POST" });
generateStatus = {
id: String(feed.id),
status: result.status ?? "completed",
products: result.products_exported,
last_generated_at: result.last_generated_at
};
success = typeof result.products_exported === "number" ? i18n.t("flash.export.refreshedWithCount", { count: result.products_exported }) : i18n.t("flash.export.refreshed");
notifySuccess(success, { alert: "export_done" });
await load();
} catch (err) {
error = notifyApiError(err, i18n.t("toast.export.refreshFailed"), { alert: "export_fail" });
generateStatus = {
id: String(feed.id),
status: "failed",
last_generated_at: feed.last_generated_at ?? null
};
} finally {
busyId = null;
}
}
async function rotateToken(feed: ExportRow) {
if (!confirm(i18n.t("confirm.rotateExportToken"))) return;
busyId = String(feed.id);
error = "";
success = "";
try {
await api(`/api/export-feeds/${feed.id}/rotate-token`, { method: "POST" });
success = i18n.t("flash.export.tokenRotated");
await load();
} catch (err) {
error = failureMessage(err, i18n.t("exports.rotateFailed"));
} finally {
busyId = null;
}
}
async function deleteFeed(feed: ExportRow) {
if (!confirm(i18n.t("confirm.deleteExportFeed"))) return;
busyId = String(feed.id);
error = "";
success = "";
try {
await api(`/api/export-feeds/${feed.id}`, { method: "DELETE" });
success = i18n.t("flash.export.deleted");
if (generateStatus?.id === String(feed.id)) generateStatus = null;
await load();
} catch (err) {
error = failureMessage(err, i18n.t("exports.deleteFailed"));
} finally {
busyId = null;
}
}
</script>
<svelte:window
onclick={() => {
createMenuOpen = false;
}}
/>
<PageShell
title={i18n.t("exports.title")}
description={filtered.length !== feeds.length
? i18n.t("exports.descriptionFiltered", { filtered: filtered.length, total: feeds.length })
: i18n.t("exports.description")}
>
{#snippet actions()}
<Button
variant="outline"
loading={loading && feeds.length > 0}
disabled={loading}
onclick={() => void load()}
>
{loading ? i18n.t("exports.refreshing") : i18n.t("exports.refresh")}
</Button>
<div class="relative">
<Button
data-tour="export-create"
onclick={(e) => {
e.stopPropagation();
createMenuOpen = !createMenuOpen;
}}
>
{i18n.t("exports.create")}
<ChevronDown class="ml-2 h-4 w-4" />
</Button>
{#if createMenuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_interactive_supports_focus -->
<div
role="menu"
tabindex="-1"
class="absolute right-0 z-20 mt-1 w-72 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
onclick={(e) => e.stopPropagation()}
>
{#each EXPORT_PRESETS as preset}
<button
type="button"
class="flex w-full flex-col gap-0.5 rounded-sm px-2 py-2 text-left hover:bg-accent"
onclick={() => openCreate(preset.id)}
>
<span class="text-sm font-medium">{presetShortLabel(preset)}</span>
<span class="text-xs text-muted-foreground">{presetDescription(preset)}</span>
</button>
{/each}
</div>
{/if}
</div>
{/snippet}
<Alert message={error || listError} />
<Alert tone="success" message={success} />
<div class="rounded-lg border border-dashed border-border bg-card p-4 text-sm text-muted-foreground shadow-sm">
<p class="font-medium text-foreground">{i18n.t("exports.howToTitle")}</p>
<ul class="mt-2 list-disc space-y-1 pl-5">
<li>
<strong class="text-foreground">{i18n.t("exports.howTo.choose")}</strong> {i18n.t("exports.howTo.chooseBody")}
</li>
<li>
<strong class="text-foreground">{i18n.t("exports.howTo.subset")}</strong> {i18n.t("exports.howTo.subsetBody")}
<a class="underline-offset-4 hover:underline" href="/products">{i18n.t("exports.howTo.productsLink")}</a>
{i18n.t("exports.howTo.subsetTail")}
</li>
<li>
<strong class="text-foreground">{i18n.t("exports.howTo.google")}</strong> {i18n.t("exports.howTo.googleBody")}
</li>
<li>
<strong class="text-foreground">{i18n.t("exports.howTo.meta")}</strong> {i18n.t("exports.howTo.metaBody")}
</li>
<li>
<strong class="text-foreground">{i18n.t("exports.howTo.custom")}</strong> {i18n.t("exports.howTo.customBody")}
</li>
<li>
<strong class="text-foreground">{i18n.t("exports.howTo.rest")}</strong> {i18n.t("exports.howTo.restBody")}
<a class="underline-offset-4 hover:underline" href="/settings?tab=api-keys">{i18n.t("exports.howTo.settingsApiKeys")}</a>
{i18n.t("exports.howTo.restTail")}
<code class="rounded bg-muted px-1 py-0.5 text-foreground">/api/v1/products</code>
(<a class="underline-offset-4 hover:underline" href="/docs">{i18n.t("exports.howTo.openapi")}</a>).
</li>
</ul>
</div>
{#if generateStatus}
<div class="rounded-lg border border-border bg-card p-4 shadow-sm">
<div class="mb-2 flex items-center justify-between gap-3">
<h2 class="text-sm font-semibold text-foreground">{i18n.t("exports.feedStatus")}</h2>
<StatusBadge status={generateStatus.status} />
</div>
{#if generateStatus.status === "processing"}
<p class="text-sm text-muted-foreground">{i18n.t("exports.generating")}</p>
{:else if generateStatus.status === "failed"}
<p class="text-sm text-muted-foreground">{i18n.t("exports.generationFailed")}</p>
{:else if typeof generateStatus.products === "number"}
<p class="text-sm text-muted-foreground">
{i18n.t("exports.exportedProducts", { count: generateStatus.products.toLocaleString() })}
</p>
{/if}
{#if generateStatus.last_generated_at}
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("exports.lastGenerated", { time: formatRelativeTime(generateStatus.last_generated_at) })}
</p>
{:else if generateStatus.status !== "processing"}
<p class="mt-1 text-xs text-muted-foreground">{i18n.t("exports.neverGenerated")}</p>
{/if}
</div>
{/if}
{#if feeds.length > 0}
<div class="relative w-full sm:w-64">
<Input bind:value={search} placeholder={i18n.t("exports.searchPlaceholder")} aria-label={i18n.t("exports.searchAria")} />
{#if search}
<button
type="button"
class="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
aria-label={i18n.t("exports.clearSearch")}
onclick={() => (search = "")}
>
×
</button>
{/if}
</div>
{/if}
<DataCard>
{#if listKind === "loading"}
<div class="p-8"><Spinner label={i18n.t("exports.loading")} /></div>
{:else if listKind === "loadFailed"}
<EmptyState
title={i18n.t("empty.export.loadFailedTitle")}
message={i18n.t("empty.export.loadFailedMessage")}
>
<Button loading={loading} disabled={loading} onclick={() => void load()}>{i18n.t("exports.tryAgain")}</Button>
</EmptyState>
{:else if listKind === "none"}
<EmptyState
title={i18n.t("empty.export.noneTitle")}
message={i18n.t("empty.export.noneMessage")}
>
<Button onclick={() => openCreate("google_shopping_csv")}>{i18n.t("exports.emptyCta")}</Button>
<a href="/products">
<Button variant="outline">{i18n.t("empty.export.fromProductsCta")}</Button>
</a>
</EmptyState>
{:else if listKind === "noMatch"}
<EmptyState title={i18n.t("empty.export.noMatchTitle")} message={i18n.t("empty.export.noMatchMessage")}>
<Button variant="link" onclick={() => (search = "")}>{i18n.t("exports.clearSearch")}</Button>
</EmptyState>
{:else}
<Table>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("exports.col.name")}</TableHead>
<TableHead>{i18n.t("exports.col.format")}</TableHead>
<TableHead>{i18n.t("exports.col.feedUrl")}</TableHead>
<TableHead>{i18n.t("exports.col.lastGenerated")}</TableHead>
<TableHead>{i18n.t("exports.col.status")}</TableHead>
<TableHead stickyRight>{i18n.t("exports.col.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each filtered as feed}
{@const id = String(feed.id)}
{@const refreshing = busyId === id}
<TableRow class={refreshing ? "opacity-60" : ""}>
<TableCell class="font-medium">
<div class="flex items-center gap-2">
<span>{feed.name ?? id}</span>
{#if refreshing}
<span class="flex items-center gap-1 text-xs text-muted-foreground">
<RefreshCw class="h-3 w-3 animate-spin" />
{generateStatus?.id === id && generateStatus.status === "processing"
? i18n.t("exports.generatingShort")
: i18n.t("exports.working")}
</span>
{/if}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{String(feed.format ?? "xml").toUpperCase()}</Badge>
</TableCell>
<TableCell class="max-w-[min(18rem,55vw)]">
<div class="flex min-w-0 items-center gap-2">
<code class="min-w-0 truncate text-xs text-muted-foreground" title={publicUrl(feed)}>
{publicUrl(feed)}
</code>
<Button
variant="ghost"
size="sm"
class="h-8 w-8 shrink-0 p-0"
disabled={refreshing}
onclick={() => void copyUrl(feed)}
aria-label={i18n.t("exports.copyUrl")}
>
<Copy class="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
<TableCell class="text-muted-foreground">
{feed.last_generated_at
? formatRelativeTime(feed.last_generated_at)
: i18n.t("exports.neverGenerated")}
</TableCell>
<TableCell>
<StatusBadge status={feed.is_active === false ? "inactive" : "active"} />
</TableCell>
<TableCell stickyRight>
<DropdownMenu class="w-52">
{#snippet trigger({ open, toggle })}
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
aria-label={i18n.t("exports.openMenu")}
aria-haspopup="menu"
aria-expanded={open}
onclick={(e) => {
e.stopPropagation();
toggle();
}}
>
<MoreVertical class="h-4 w-4" />
</Button>
{/snippet}
<DropdownMenuItem onclick={() => previewFeed(feed)}>
<Eye class="mr-2 h-4 w-4" /> {i18n.t("exports.preview")}
</DropdownMenuItem>
<DropdownMenuItem onclick={() => downloadFeed(feed)}>
<Download class="mr-2 h-4 w-4" /> {i18n.t("exports.download")}
</DropdownMenuItem>
<DropdownMenuItem disabled={refreshing} onclick={() => void refreshFeed(feed)}>
<RefreshCw class="mr-2 h-4 w-4 {refreshing ? 'animate-spin' : ''}" />
{refreshing ? i18n.t("exports.refreshing") : i18n.t("exports.refreshFeed")}
</DropdownMenuItem>
<DropdownMenuItem onclick={() => void openEdit(feed)}>
<Edit class="mr-2 h-4 w-4" /> {i18n.t("exports.editAction")}
</DropdownMenuItem>
{#if canAdmin}
<DropdownMenuItem
disabled={refreshing}
onclick={() => void rotateToken(feed)}
>
<KeyRound class="mr-2 h-4 w-4" />
{i18n.t("exports.rotateToken")}
</DropdownMenuItem>
<DropdownMenuItem
class="text-destructive focus:text-destructive"
disabled={refreshing}
onclick={() => void deleteFeed(feed)}
>
<Trash2 class="mr-2 h-4 w-4" />
{refreshing ? i18n.t("exports.deleting") : i18n.t("exports.delete")}
</DropdownMenuItem>
{/if}
</DropdownMenu>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{/if}
</DataCard>
</PageShell>
<Dialog
bind:open={dialogOpen}
title={editingId ? i18n.t("exports.edit") : i18n.t("exports.create")}
description={editingId
? i18n.t("exports.editDescription")
: presetHint(activePreset)}
class="max-h-[90vh] max-w-2xl overflow-y-auto"
>
<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">
<Label for="export-channel">{i18n.t("exports.channelPreset")}</Label>
<Select
id="export-channel"
value={formPresetId}
onchange={(e) => {
const id = (e.currentTarget as HTMLSelectElement).value as ExportPresetId;
applyPreset(id);
}}
>
{#each EXPORT_PRESETS as preset}
<option value={preset.id}>{presetLabel(preset)}</option>
{/each}
</Select>
<p class="text-xs text-muted-foreground">{presetDescription(activePreset)}</p>
</div>
{/if}
<div class="space-y-1.5 sm:col-span-2">
<Label for="export-name">{i18n.t("exports.name")}</Label>
<Input id="export-name" bind:value={formName} required />
</div>
<div class="space-y-1.5">
<Label for="export-format">{i18n.t("exports.format")}</Label>
<Input id="export-format" value={formFormat.toUpperCase()} disabled />
</div>
<div class="space-y-1.5">
<Label for="export-source">{i18n.t("exports.sourceFeed")}</Label>
<Select id="export-source" bind:value={formSourceFeedId}>
<option value="">{i18n.t("exports.allProducts")}</option>
{#each inputFeeds as feed}
<option value={String(feed.id)}>{feed.name ?? feed.id}</option>
{/each}
</Select>
<p class="text-xs text-muted-foreground">{i18n.t("exports.sourceFeedHelp")}</p>
</div>
{#if formFormat === "xml"}
<div class="space-y-1.5">
<Label for="export-root">{i18n.t("exports.rootElement")}</Label>
<Input id="export-root" class="font-mono" bind:value={formRoot} />
</div>
<div class="space-y-1.5">
<Label for="export-item">{i18n.t("exports.itemElement")}</Label>
<Input id="export-item" class="font-mono" bind:value={formItem} />
</div>
{/if}
<div class="space-y-1.5 sm:col-span-2">
<Label for="export-statuses">{i18n.t("exports.productStatuses")}</Label>
<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">
<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>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("exports.outputKey")}</TableHead>
<TableHead>{i18n.t("exports.source")}</TableHead>
<TableHead class="w-16"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each formFields as field, index}
<TableRow>
<TableCell>
<Input class="font-mono" bind:value={field.key} placeholder="name" required />
</TableCell>
<TableCell>
<div class="space-y-1">
<Select
value={SOURCE_OPTIONS.includes(field.source) ? field.source : "__custom__"}
onchange={(e) => {
const v = (e.currentTarget as HTMLSelectElement).value;
if (v === "__custom__") {
field.source = field.source.startsWith("attr.") ? field.source : "attr.";
} else {
field.source = v;
}
formFields = [...formFields];
}}
>
{#each SOURCE_OPTIONS as opt}
<option value={opt}>{opt}</option>
{/each}
<option value="__custom__">{i18n.t("exports.customSource")}</option>
</Select>
{#if !SOURCE_OPTIONS.includes(field.source)}
<Input
class="font-mono"
bind:value={field.source}
placeholder="attr.color or spec.weight"
/>
{/if}
</div>
</TableCell>
<TableCell class="text-right">
<Button
type="button"
variant="ghost"
size="sm"
class="h-8 w-8 p-0 text-destructive"
onclick={() => removeField(index)}
aria-label={i18n.t("exports.removeField")}
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<p class="text-xs text-muted-foreground">
{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>
</Dialog>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
import ProductExplanationSection from "$lib/components/site/ProductExplanationSection.svelte";
import HowItWorksSection from "$lib/components/site/HowItWorksSection.svelte";
import BenefitsSection from "$lib/components/site/BenefitsSection.svelte";
import CtaBanner from "$lib/components/site/CtaBanner.svelte";
import Footer from "$lib/components/site/Footer.svelte";
import SeoHead from "$lib/components/site/SeoHead.svelte";
import { i18n } from "$lib/i18n";
</script>
<SeoHead
title={i18n.t("seo.features.title")}
description={i18n.t("seo.features.description")}
path="/features"
/>
<div class="flex min-h-screen flex-col">
<SiteHeader />
<main class="pt-16">
<ProductExplanationSection />
<HowItWorksSection />
<BenefitsSection />
<CtaBanner />
</main>
<Footer />
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+288
View File
@@ -0,0 +1,288 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { FileText, MoreVertical, RefreshCw, Trash2, Upload } from "@lucide/svelte";
import { api, ApiError, failureMessage } from "$lib/api";
import { trackEvent } from "$lib/analytics";
import { unwrapList, unwrapTotal } from "$lib/list";
import { formatBytes, formatCredits, formatRelativeTime } from "$lib/utils";
import { isCompanyAdmin } from "$lib/company-admin";
import { authSession } from "$lib/auth-session.svelte";
import type { ListResponse, MeResponse, UploadFile } from "$lib/types";
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 UploadEansDialog from "$lib/components/products/UploadEansDialog.svelte";
import { i18n } from "$lib/i18n";
import { countMetadataOnlyFiles, isMetadataOnlyFile } from "$lib/etl-gaps";
import {
Badge,
Button,
buttonClasses,
Card,
DropdownMenu,
DropdownMenuItem,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
type BadgeVariant
} from "$lib/components/ui";
let files = $state<UploadFile[]>([]);
let total = $state(0);
let loading = $state(true);
let canAdmin = $state(false);
let busyId = $state<string | null>(null);
let error = $state("");
let success = $state("");
let uploadOpen = $state(false);
let uploading = $state(false);
const metadataOnlyCount = $derived(countMetadataOnlyFiles(files));
async function loadFiles() {
loading = true;
error = "";
try {
const payload = await api<ListResponse<UploadFile>>("/api/files?limit=100");
files = unwrapList(payload);
total = unwrapTotal(payload) ?? files.length;
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("imports.loadFailed"));
} finally {
loading = false;
}
}
onMount(() => {
void (async () => {
try {
const me = await api<MeResponse>("/api/auth/me");
authSession.setMe(me);
canAdmin = isCompanyAdmin(me);
} catch {
canAdmin = authSession.isCompanyAdmin;
}
await loadFiles();
})();
});
function statusVariant(status: string | null | undefined): BadgeVariant {
switch ((status ?? "").toLowerCase()) {
case "completed":
return "success";
case "processing":
return "secondary";
case "failed":
return "destructive";
default:
return "outline";
}
}
function statusLabel(status: string | null | undefined): string {
const raw = (status ?? "uploaded").trim();
if (!raw || raw.toLowerCase() === "uploaded") return i18n.t("imports.status.uploaded");
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
function productCount(file: UploadFile): string {
const meta = file.metadata ?? {};
const totalRows = meta.total_rows ?? meta.totalRows;
if (typeof totalRows === "number") return totalRows.toLocaleString();
if (typeof totalRows === "string" && totalRows.trim()) return totalRows;
const created = typeof meta.created === "number" ? meta.created : 0;
const updated = typeof meta.updated === "number" ? meta.updated : 0;
const sum = created + updated;
return sum > 0 ? sum.toLocaleString() : i18n.t("status.emDash");
}
async function deleteFile(file: UploadFile) {
if (!confirm(i18n.t("imports.deleteConfirm", { name: file.name }))) {
return;
}
busyId = file.id;
error = "";
success = "";
try {
await api(`/api/files/${file.id}`, { method: "DELETE" });
files = files.filter((f) => f.id !== file.id);
total = Math.max(0, total - 1);
success = i18n.t("imports.deleted");
} catch (err) {
error = failureMessage(err, i18n.t("imports.deleteFailed"));
} finally {
busyId = null;
}
}
async function onUploadProducts(file: File) {
uploading = true;
error = "";
success = "";
try {
const form = new FormData();
form.append("file", file);
await api("/api/products/import", { method: "POST", body: form });
const ext = file.name.includes(".")
? file.name.slice(file.name.lastIndexOf(".") + 1).toLowerCase()
: "";
trackEvent("products_imported", {
import_source: "files_page",
...(ext ? { file_ext: ext } : {})
});
success = i18n.t("flash.products.csvImported");
await loadFiles();
} catch (err) {
error = failureMessage(err, i18n.t("catalog.importFailed"));
throw err;
} finally {
uploading = false;
}
}
</script>
<PageShell title={i18n.t("imports.title")} description={i18n.t("imports.description")}>
{#snippet actions()}
<div class="flex flex-wrap gap-2">
<Button variant="outline" disabled={loading} onclick={() => void loadFiles()}>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
{i18n.t("common.refresh")}
</Button>
{#if canAdmin}
<Button onclick={() => (uploadOpen = true)}>
<Upload class="mr-2 h-4 w-4" />
{i18n.t("imports.importProducts")}
</Button>
{:else}
<a href="/products" class={buttonClasses("outline", "default", "")}>
<Upload class="mr-2 h-4 w-4" />
{i18n.t("imports.goProducts")}
</a>
{/if}
</div>
{/snippet}
<Alert message={error} />
<Alert tone="success" message={success} />
{#if !loading && metadataOnlyCount > 0}
<Alert
tone="info"
id="files-metadata-only-notice"
message={i18n.t("imports.metadataOnlyNotice", { count: formatCredits(metadataOnlyCount) })}
/>
{/if}
{#if loading && files.length === 0}
<Spinner />
{:else if files.length === 0}
<EmptyState title={i18n.t("empty.files.noneTitle")} message={i18n.t("empty.files.noneMessage")}>
{#if canAdmin}
<Button onclick={() => (uploadOpen = true)}>
<Upload class="mr-2 h-4 w-4" />
{i18n.t("empty.files.uploadCta")}
</Button>
{:else}
<a href="/products" class={buttonClasses("default", "default", "")}>
{i18n.t("imports.goProducts")}
</a>
{/if}
<a href="/categories" class={buttonClasses("outline", "default", "")}>
<FileText class="mr-2 h-4 w-4" />
{i18n.t("imports.categories")}
</a>
</EmptyState>
{:else}
<Card class="overflow-hidden p-0">
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<h2 class="text-sm font-medium text-foreground">{i18n.t("imports.recent")}</h2>
<p class="text-xs text-muted-foreground">
{total === 1
? i18n.t("imports.fileCount", { count: total.toLocaleString() })
: i18n.t("imports.fileCountPlural", { count: total.toLocaleString() })}
</p>
</div>
<TableShell class="rounded-none border-0 shadow-none">
<TableHeader>
<TableRow>
<TableHead>{i18n.t("imports.col.fileName")}</TableHead>
<TableHead class="hidden md:table-cell">{i18n.t("imports.col.kind")}</TableHead>
<TableHead>{i18n.t("imports.col.uploaded")}</TableHead>
<TableHead>{i18n.t("common.status")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("imports.col.size")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("common.products")}</TableHead>
<TableHead stickyRight>{i18n.t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each files as file (file.id)}
<TableRow>
<TableCell class="max-w-[10rem] font-medium sm:max-w-none">
<div class="truncate" title={file.name}>{file.name}</div>
{#if isMetadataOnlyFile(file)}
<p class="mt-0.5 text-xs text-muted-foreground">
{i18n.t("imports.metadataOnlyBadge")}
</p>
{/if}
<div class="mt-0.5 text-xs capitalize text-muted-foreground md:hidden">
{file.kind || i18n.t("status.emDash")}
<span class="mx-1">·</span>
{formatBytes(file.size_bytes)}
</div>
</TableCell>
<TableCell class="hidden capitalize text-muted-foreground md:table-cell">
{file.kind || i18n.t("status.emDash")}
</TableCell>
<TableCell class="whitespace-nowrap text-sm">{formatRelativeTime(file.created_at)}</TableCell>
<TableCell>
<Badge variant={statusVariant(file.status)}>{statusLabel(file.status)}</Badge>
</TableCell>
<TableCell class="hidden lg:table-cell">{formatBytes(file.size_bytes)}</TableCell>
<TableCell class="hidden lg:table-cell">{productCount(file)}</TableCell>
<TableCell stickyRight>
{#if canAdmin}
<DropdownMenu class="min-w-[9rem]">
{#snippet trigger({ open, toggle })}
<Button
variant="ghost"
size="icon"
disabled={busyId === file.id}
aria-label={i18n.t("imports.actions")}
aria-haspopup="menu"
aria-expanded={open}
onclick={() => toggle()}
>
<MoreVertical class="h-4 w-4" />
</Button>
{/snippet}
<DropdownMenuItem
class="text-destructive focus:text-destructive"
onclick={() => void deleteFile(file)}
>
<Trash2 class="mr-2 h-4 w-4" />
{i18n.t("common.delete")}
</DropdownMenuItem>
</DropdownMenu>
{:else}
<span class="text-sm text-muted-foreground">{i18n.t("status.emDash")}</span>
{/if}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
</Card>
{/if}
</PageShell>
{#if canAdmin}
<UploadEansDialog bind:open={uploadOpen} loading={uploading} onUpload={onUploadProducts} />
{/if}
@@ -0,0 +1,105 @@
<script lang="ts">
import { api } from "$lib/api";
import { apiFormError, fieldDescribedBy, fieldInvalid } from "$lib/api-form-error";
import { i18n } from "$lib/i18n";
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label
} from "$lib/components/ui";
let email = $state("");
let error = $state("");
let fieldErrors = $state<Record<string, string>>({});
let loading = $state(false);
let sent = $state(false);
const errorId = "forgot-password-form-error";
async function onSubmit(event: Event) {
event.preventDefault();
error = "";
fieldErrors = {};
loading = true;
try {
await api("/api/auth/forgot-password", {
method: "POST",
body: { email }
});
sent = true;
} catch (err) {
const result = apiFormError(err, i18n.t("auth.forgot.failed"), {
email: ["email", "required", "rate limit"]
});
error = result.message;
fieldErrors = result.fields;
} finally {
loading = false;
}
}
</script>
<Card>
<CardHeader>
<CardTitle level={1}>{i18n.t("auth.forgot.title")}</CardTitle>
<CardDescription>{i18n.t("auth.forgot.description")}</CardDescription>
</CardHeader>
<CardContent>
{#if sent}
<Alert>
<AlertDescription>
<strong class="text-text">{i18n.t("auth.forgot.sentTitle")}</strong>
{" "}
{i18n.t("auth.forgot.sentBody")}
</AlertDescription>
</Alert>
<p class="mt-5 text-center text-sm text-text-muted">
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("auth.forgot.backToSignIn")}</a>
</p>
{:else}
<form
method="dialog"
class="space-y-4"
onsubmit={onSubmit}
aria-busy={loading}
aria-describedby={error ? errorId : undefined}
>
{#if error}
<Alert variant="destructive">
<AlertDescription id={errorId}>{error}</AlertDescription>
</Alert>
{/if}
<div class="space-y-2">
<Label for="email">{i18n.t("common.email")}</Label>
<Input
id="email"
type="email"
name="email"
autocomplete="email"
required
aria-invalid={fieldInvalid(fieldErrors, "email") ??
(error && !Object.keys(fieldErrors).length ? "true" : undefined)}
aria-describedby={fieldDescribedBy(fieldErrors, "email", errorId) ??
(error && !Object.keys(fieldErrors).length ? errorId : undefined)}
bind:value={email}
/>
</div>
<Button type="submit" class="w-full shadow-sm" loading={loading}>
{loading ? i18n.t("auth.forgot.submitting") : i18n.t("auth.forgot.submit")}
</Button>
</form>
<p class="mt-5 text-center text-sm text-text-muted">
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("auth.forgot.backToSignIn")}</a>
</p>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import Spinner from "$lib/components/Spinner.svelte";
onMount(() => {
void goto("/stores", { replaceState: true });
});
</script>
<Spinner label="Opening Stores." />
@@ -0,0 +1,644 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { Bot, Loader2, KeyRound, Sparkles } from "@lucide/svelte";
import { api, failureMessage, isForbidden, isUnauthorized, companyAdminDeniedMessage } from "$lib/api";
import { isCompanyAdmin } from "$lib/company-admin";
import { authSession } from "$lib/auth-session.svelte";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { upgradeMessageForFeature } from "$lib/plan-upgrade-message";
import PlanUpgradePanel from "$lib/components/PlanUpgradePanel.svelte";
import type {
AIProviderConfig,
AIPopularProvider,
AIPromptBundle,
AIPromptTemplate,
AIPromptVariable,
MeResponse
} from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select,
Textarea
} from "$lib/components/ui";
import ContentLanguageSwitcher from "$lib/components/ContentLanguageSwitcher.svelte";
let loading = $state(true);
let canAdmin = $state(false);
let accessDenied = $state(false);
let saving = $state(false);
let savingPrompts = $state(false);
let testing = $state(false);
let error = $state("");
const byokAllowed = $derived(
planCapabilities.can("integrations.ai.byok") && planCapabilities.can("capability.byok")
);
const byokGate = $derived(upgradeMessageForFeature("integrations.ai.byok", canAdmin));
let success = $state("");
let mode = $state<"internal" | "popular" | "custom">("internal");
let popularName = $state("openai");
let baseURL = $state("");
let model = $state("");
let apiKey = $state("");
let enabled = $state(false);
let hasApiKey = $state(false);
let apiKeyMasked = $state("");
let clearApiKey = $state(false);
let platformFallback = $state(false);
let activeModeLabel = $state("internal");
let lastTestStatus = $state("");
let popularProviders = $state<AIPopularProvider[]>([]);
let promptKey = $state("product_enhance");
let prompts = $state<AIPromptTemplate[]>([]);
let variables = $state<AIPromptVariable[]>([]);
let draftSystem = $state("");
let draftUser = $state("");
let draftEnabled = $state(true);
let insertTarget = $state<"system" | "user">("user");
let promptLanguage = $state("en");
let contentLanguages = $state<string[]>(["en"]);
let customLanguages = $state<Record<string, string[]>>({});
let promptExtraLangs = $state<string[]>([]);
const selectedPopular = $derived(
popularProviders.find((p) => p.name === popularName) ?? popularProviders[0]
);
const activePrompt = $derived(prompts.find((p) => p.key === promptKey) ?? prompts[0]);
const varsForKey = $derived(variables.filter((v) => v.keys.includes(promptKey)));
function apply(cfg: AIProviderConfig) {
mode = (cfg.mode as typeof mode) || "internal";
popularName = cfg.popular_name || "openai";
baseURL = cfg.base_url ?? "";
model = cfg.model ?? "";
enabled = Boolean(cfg.is_enabled) && mode !== "internal";
hasApiKey = Boolean(cfg.has_api_key);
apiKeyMasked = cfg.api_key_masked ?? (cfg.api_key_last4 ? `••••${cfg.api_key_last4}` : "");
platformFallback = Boolean(cfg.platform_fallback_available);
activeModeLabel = cfg.active_mode_label || "internal";
lastTestStatus = cfg.last_test_status ?? "";
popularProviders = cfg.popular_providers ?? [];
apiKey = "";
clearApiKey = false;
if (mode === "popular" && !model && selectedPopular) {
model = selectedPopular.default_model;
}
}
function applyPrompts(bundle: AIPromptBundle) {
prompts = bundle.prompts ?? [];
variables = bundle.variables ?? [];
promptLanguage = bundle.language || promptLanguage;
contentLanguages = bundle.content_languages?.length
? bundle.content_languages
: contentLanguages;
customLanguages = bundle.custom_languages ?? {};
const extras = new Set<string>();
for (const langs of Object.values(customLanguages)) {
for (const l of langs) {
if (l !== (contentLanguages[0] || "en")) extras.add(l);
}
}
promptExtraLangs = [...extras];
if (!prompts.some((p) => p.key === promptKey) && prompts[0]) {
promptKey = prompts[0].key;
}
syncDraftFromActive();
}
async function loadPromptBundle(lang: string) {
const bundle = await api<AIPromptBundle>(
`/api/integrations/ai/prompts?language=${encodeURIComponent(lang)}`
);
applyPrompts(bundle);
}
function syncDraftFromActive() {
const p = prompts.find((x) => x.key === promptKey) ?? prompts[0];
if (!p) {
draftSystem = "";
draftUser = "";
draftEnabled = true;
return;
}
draftSystem = p.system_template ?? "";
draftUser = p.user_template ?? "";
draftEnabled = p.is_enabled !== false;
}
onMount(async () => {
try {
const me = await api<MeResponse>("/api/auth/me");
authSession.setMe(me);
canAdmin = isCompanyAdmin(me);
} catch {
canAdmin = authSession.isCompanyAdmin;
}
try {
const [cfg, bundle] = await Promise.all([
api<AIProviderConfig>("/api/integrations/ai"),
api<AIPromptBundle>("/api/integrations/ai/prompts")
]);
apply(cfg);
applyPrompts(bundle);
if (!popularName && popularProviders[0]) {
popularName = popularProviders[0].name;
}
} catch (err) {
if (isUnauthorized(err)) {
await goto("/login");
return;
}
if (isForbidden(err)) {
accessDenied = true;
return;
}
error = failureMessage(err, i18n.t("ai.flash.loadFailed"));
} finally {
loading = false;
}
});
function onPopularChange() {
const pop = popularProviders.find((p) => p.name === popularName);
if (pop) {
model = pop.default_model;
baseURL = pop.base_url;
}
}
function onPromptKeyChange() {
syncDraftFromActive();
}
function insertVariable(name: string) {
const token = `{{${name}}}`;
const id = insertTarget === "system" ? "system_tpl" : "user_tpl";
const el = document.getElementById(id) as HTMLTextAreaElement | null;
if (insertTarget === "system") {
draftSystem = insertAtCursor(el, draftSystem, token);
} else {
draftUser = insertAtCursor(el, draftUser, token);
}
}
function insertAtCursor(el: HTMLTextAreaElement | null, value: string, token: string) {
if (!el) return value + token;
const start = el.selectionStart ?? value.length;
const end = el.selectionEnd ?? value.length;
const next = value.slice(0, start) + token + value.slice(end);
queueMicrotask(() => {
el.focus();
const pos = start + token.length;
el.setSelectionRange(pos, pos);
});
return next;
}
async function save(event: Event) {
event.preventDefault();
saving = true;
error = "";
success = "";
try {
const cfg = await api<AIProviderConfig>("/api/integrations/ai", {
method: "PUT",
body: {
mode,
popular_name: mode === "popular" ? popularName : undefined,
base_url: mode === "custom" ? baseURL : undefined,
model: mode === "internal" ? undefined : model,
api_key: apiKey || undefined,
clear_api_key: clearApiKey || undefined,
is_enabled: mode !== "internal" && enabled
}
});
apply(cfg);
success =
mode === "internal"
? i18n.t("ai.flash.usingPlatform")
: i18n.t("ai.flash.providerSaved");
} catch (err) {
error = isForbidden(err)
? companyAdminDeniedMessage(i18n.t("ai.action.changeSettings"))
: failureMessage(err, i18n.t("ai.flash.saveFailed"));
} finally {
saving = false;
}
}
async function savePrompts() {
savingPrompts = true;
error = "";
success = "";
try {
const bundle = await api<AIPromptBundle>("/api/integrations/ai/prompts", {
method: "PUT",
body: {
language: promptLanguage,
prompts: [
{
key: promptKey,
language: promptLanguage,
system_template: draftSystem,
user_template: draftUser,
is_enabled: draftEnabled
}
]
}
});
applyPrompts(bundle);
success = i18n.t("flash.ai.promptsSaved");
} catch (err) {
error = isForbidden(err)
? companyAdminDeniedMessage(i18n.t("ai.action.changePrompts"))
: failureMessage(err, i18n.t("ai.flash.savePromptsFailed"));
} finally {
savingPrompts = false;
}
}
async function resetPrompt() {
if (!canAdmin) return;
savingPrompts = true;
error = "";
success = "";
try {
const bundle = await api<AIPromptBundle>("/api/integrations/ai/prompts", {
method: "PUT",
body: { language: promptLanguage, prompts: [{ key: promptKey, language: promptLanguage, reset: true }] }
});
applyPrompts(bundle);
success = i18n.t("flash.ai.promptRestored");
} catch (err) {
error = failureMessage(err, i18n.t("ai.flash.resetFailed"));
} finally {
savingPrompts = false;
}
}
async function testConnection() {
testing = true;
error = "";
success = "";
try {
const res = await api<{ status?: string; message?: string; mode?: string; byok?: boolean }>(
"/api/integrations/ai/test",
{ method: "POST", body: {} }
);
lastTestStatus = res.status ?? "";
if (res.status === "ok") {
success = i18n.t("flash.ai.connectionOk", { detail: `${res.mode || activeModeLabel}${res.byok ? i18n.t("flash.ai.connectionByok") : ""}` });
} else {
error = res.message || i18n.t("ai.flash.connectionFailed");
}
const cfg = await api<AIProviderConfig>("/api/integrations/ai");
apply(cfg);
} catch (err) {
error = isForbidden(err)
? companyAdminDeniedMessage(i18n.t("ai.action.testProvider"))
: failureMessage(err, i18n.t("ai.flash.testFailed"));
} finally {
testing = false;
}
}
</script>
<PageShell
title={i18n.t("ai.title")}
description={i18n.t("ai.description")}
>
{#if loading}
<div class="flex justify-center py-16"><Spinner /></div>
{:else if accessDenied}
<ForbiddenEmptyState
message={i18n.t("empty.forbidden.ai")}
/>
{:else}
<div class="mb-4 flex flex-wrap gap-2 text-sm">
<a class="text-primary underline" href="/settings">{i18n.t("nav.settings")}</a>
<span class="text-muted-foreground">/</span>
<span>{i18n.t("ai.crumb")}</span>
</div>
<Alert tone="error" message={error} />
<Alert tone="success" message={success} />
{#if !canAdmin}
<Alert
tone="info"
message={i18n.t("ai.adminOnly")}
/>
<div class="mb-4">
<a href="/settings?tab=team">
<Button type="button" variant="outline" size="sm">{i18n.t("common.askAdmin")}</Button>
</a>
</div>
{/if}
<div class="mb-6 flex flex-wrap gap-2">
<Badge variant={hasApiKey && enabled ? "default" : "secondary"}>
{hasApiKey && enabled ? i18n.t("ai.badge.ownKey") : i18n.t("ai.badge.platform")}
</Badge>
<Badge variant="outline">{i18n.t("ai.badge.active", { label: activeModeLabel })}</Badge>
{#if platformFallback}
<Badge variant="outline">{i18n.t("ai.badge.platformKeyAvailable")}</Badge>
{/if}
{#if lastTestStatus}
<Badge variant="outline">{i18n.t("ai.badge.lastTest", { status: lastTestStatus })}</Badge>
{/if}
</div>
<form class="space-y-6" onsubmit={save}>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2"><Bot class="h-4 w-4" /> {i18n.t("ai.providerMode.title")}</CardTitle>
<CardDescription>
{i18n.t("ai.providerMode.desc")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="mode">{i18n.t("ai.whoPays")}</Label>
<Select
id="mode"
bind:value={mode}
onchange={() => {
if (!byokAllowed && mode !== "internal") {
mode = "internal";
return;
}
if (mode === "popular") onPopularChange();
if (mode === "internal") enabled = false;
else enabled = true;
}}
>
<option value="internal">{i18n.t("ai.mode.internal")}</option>
{#if byokAllowed}
<option value="popular">{i18n.t("ai.mode.popular")}</option>
<option value="custom">{i18n.t("ai.mode.custom")}</option>
{/if}
</Select>
</div>
{#if !byokAllowed}
<PlanUpgradePanel
title={byokGate.title}
message={byokGate.message}
cta={byokGate.cta}
tone="info"
stillWorks={byokGate.stillWorks}
featureKey={byokGate.featureKey}
/>
{/if}
{#if mode !== "internal"}
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={enabled} />
{i18n.t("ai.useCompanyKey")}
</label>
{/if}
</CardContent>
</Card>
{#if mode === "popular"}
<Card>
<CardHeader>
<CardTitle>{i18n.t("ai.popular.title")}</CardTitle>
<CardDescription>{i18n.t("ai.popular.desc")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="popular">{i18n.t("ai.field.provider")}</Label>
<Select id="popular" bind:value={popularName} onchange={onPopularChange}>
{#each popularProviders as p}
<option value={p.name}>{p.label}</option>
{/each}
</Select>
</div>
<div class="space-y-2">
<Label for="model">{i18n.t("ai.field.model")}</Label>
{#if selectedPopular?.models?.length}
<Select id="model" bind:value={model}>
{#each selectedPopular.models as m}
<option value={m}>{m}</option>
{/each}
</Select>
{:else}
<Input id="model" bind:value={model} placeholder={i18n.t("ai.placeholder.modelId")} />
{/if}
</div>
</div>
{#if selectedPopular}
<p class="text-xs text-muted-foreground">{i18n.t("ai.baseUrlLabel", { url: selectedPopular.base_url })}</p>
{/if}
</CardContent>
</Card>
{/if}
{#if mode === "custom"}
<Card>
<CardHeader>
<CardTitle>{i18n.t("ai.custom.title")}</CardTitle>
<CardDescription>{i18n.t("ai.custom.desc")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="base_url">{i18n.t("ai.field.baseUrl")}</Label>
<Input
id="base_url"
bind:value={baseURL}
placeholder="https://api.example.com/v1"
autocomplete="off"
/>
</div>
<div class="space-y-2">
<Label for="custom_model">{i18n.t("ai.field.model")}</Label>
<Input id="custom_model" bind:value={model} placeholder="gpt-4o-mini" autocomplete="off" />
</div>
</CardContent>
</Card>
{/if}
{#if mode !== "internal"}
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2"><KeyRound class="h-4 w-4" /> {i18n.t("ai.apiKey.title")}</CardTitle>
<CardDescription>
{i18n.t("ai.apiKey.desc")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
{#if hasApiKey && apiKeyMasked}
<p class="text-sm text-muted-foreground">{i18n.t("ai.storedKey", { masked: apiKeyMasked })}</p>
{/if}
<div class="space-y-2">
<Label for="api_key">{i18n.t("ai.field.apiKey")}</Label>
<Input
id="api_key"
type="password"
bind:value={apiKey}
placeholder={hasApiKey ? i18n.t("ai.placeholder.keepKey") : "sk-…"}
autocomplete="new-password"
/>
</div>
{#if hasApiKey}
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={clearApiKey} />
{i18n.t("ai.clearStoredKey")}
</label>
{/if}
</CardContent>
</Card>
{/if}
<div class="flex flex-wrap gap-2">
<Button type="submit" disabled={!canAdmin || saving}>
{#if saving}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
{i18n.t("ai.saveProvider")}
</Button>
<Button type="button" variant="outline" disabled={!canAdmin || testing || saving} onclick={testConnection}>
{#if testing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
{i18n.t("ai.testConnection")}
</Button>
</div>
</form>
<div class="mt-10 space-y-6">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2"><Sparkles class="h-4 w-4" /> {i18n.t("ai.prompts.title")}</CardTitle>
<CardDescription>
{i18n.t("ai.prompts.descBefore")} <code class="text-xs">{"{{name}}"}</code> {i18n.t("ai.prompts.descMid")} <code class="text-xs">{"{{brand_voice}}"}</code>{i18n.t("ai.prompts.descAfter")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<p class="text-xs text-muted-foreground">{i18n.t("ai.prompts.languageHelp")}</p>
<ContentLanguageSwitcher
bind:value={promptLanguage}
bind:languages={promptExtraLangs}
configured={contentLanguages}
primary={contentLanguages[0] || "en"}
hasOverride={(code) => (customLanguages[promptKey] ?? []).includes(code)}
onChange={(code) => {
void loadPromptBundle(code).catch((err) => {
error = failureMessage(err, i18n.t("ai.flash.loadFailed"));
});
}}
/>
<div class="space-y-2">
<Label for="prompt_key">{i18n.t("ai.field.feature")}</Label>
<Select id="prompt_key" bind:value={promptKey} onchange={onPromptKeyChange}>
{#each prompts as p}
<option value={p.key}>{p.label || p.key}</option>
{/each}
</Select>
{#if activePrompt?.description}
<p class="text-xs text-muted-foreground">{activePrompt.description}</p>
{/if}
<div class="flex flex-wrap gap-2 text-xs">
{#if activePrompt?.is_custom}
<Badge variant="default">{i18n.t("ai.badge.custom")}</Badge>
{:else}
<Badge variant="secondary">{i18n.t("ai.badge.builtin")}</Badge>
{/if}
{#if !draftEnabled}
<Badge variant="outline">{i18n.t("ai.badge.disabledDefault")}</Badge>
{/if}
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={draftEnabled} disabled={!canAdmin} />
{i18n.t("ai.useCustomPrompt")}
</label>
{#if varsForKey.length}
<div class="space-y-2">
<p class="text-sm font-medium">{i18n.t("ai.insertVariableInto")}</p>
<div class="flex flex-wrap gap-2">
<label class="flex items-center gap-1 text-xs">
<input type="radio" bind:group={insertTarget} value="system" />
{i18n.t("ai.insert.system")}
</label>
<label class="flex items-center gap-1 text-xs">
<input type="radio" bind:group={insertTarget} value="user" />
{i18n.t("ai.insert.user")}
</label>
</div>
<div class="flex flex-wrap gap-2">
{#each varsForKey as v}
<Button
type="button"
variant="outline"
size="sm"
disabled={!canAdmin}
onclick={() => insertVariable(v.name)}
title={v.description}
>
{"{{"}{v.name}{"}}"}
</Button>
{/each}
</div>
</div>
{/if}
<div class="space-y-2">
<Label for="system_tpl">{i18n.t("ai.field.systemPrompt")}</Label>
<Textarea
id="system_tpl"
rows={10}
bind:value={draftSystem}
disabled={!canAdmin}
class="font-mono text-xs"
/>
</div>
<div class="space-y-2">
<Label for="user_tpl">{i18n.t("ai.field.userPrompt")}</Label>
<Textarea
id="user_tpl"
rows={8}
bind:value={draftUser}
disabled={!canAdmin}
class="font-mono text-xs"
/>
</div>
<div class="flex flex-wrap gap-2">
<Button type="button" disabled={!canAdmin || savingPrompts} onclick={savePrompts}>
{#if savingPrompts}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
{i18n.t("ai.savePrompts")}
</Button>
<Button
type="button"
variant="outline"
disabled={!canAdmin || savingPrompts || !activePrompt?.is_custom}
onclick={resetPrompt}
>
{i18n.t("ai.resetDefault")}
</Button>
<a href="/brand" class="inline-flex">
<Button type="button" variant="ghost">{i18n.t("ai.editBrandVoice")}</Button>
</a>
</div>
</CardContent>
</Card>
</div>
{/if}
</PageShell>
@@ -0,0 +1,380 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { Mail, Loader2, CheckCircle, XCircle, Send } from "@lucide/svelte";
import { api, ApiError, failureMessage, isForbidden, isUnauthorized, companyAdminDeniedMessage } from "$lib/api";
import { isCompanyAdmin } from "$lib/company-admin";
import { authSession } from "$lib/auth-session.svelte";
import type { EmailProviderConfig, EmailSendResult, MeResponse } from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import BlastConfirmDialog from "$lib/components/email/BlastConfirmDialog.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select
} from "$lib/components/ui";
let loading = $state(true);
let canAdmin = $state(false);
let accessDenied = $state(false);
let saving = $state(false);
let verifying = $state(false);
let testing = $state(false);
let error = $state("");
let success = $state("");
let provider = $state("resend");
let fromEmail = $state("");
let fromName = $state("");
let replyTo = $state("");
let domain = $state("");
let apiKey = $state("");
let smtpHost = $state("");
let smtpPort = $state("587");
let smtpUser = $state("");
let smtpPassword = $state("");
let enabled = $state(false);
let hasApiKey = $state(false);
let hasSmtpPassword = $state(false);
let verified = $state(false);
let domainVerified = $state(false);
let fromVerified = $state(false);
let dryRunForced = $state(false);
let dryRunReason = $state("");
let canSendReal = $state(false);
let lastTestStatus = $state("");
let testTo = $state("");
let blastOpen = $state(false);
function apply(cfg: EmailProviderConfig) {
provider = cfg.provider || "smtp";
fromEmail = cfg.from_email ?? "";
fromName = cfg.from_name ?? "";
replyTo = cfg.reply_to ?? "";
domain = cfg.domain ?? "";
smtpHost = cfg.smtp_host ?? "";
smtpPort = cfg.smtp_port || "587";
smtpUser = cfg.smtp_user ?? "";
enabled = Boolean(cfg.is_enabled);
hasApiKey = Boolean(cfg.has_api_key);
hasSmtpPassword = Boolean(cfg.has_smtp_password);
verified = Boolean(cfg.verified);
domainVerified = Boolean(cfg.domain_verified);
fromVerified = Boolean(cfg.from_verified);
dryRunForced = Boolean(cfg.dry_run_forced);
dryRunReason = cfg.dry_run_reason ?? "";
canSendReal = Boolean(cfg.can_send_real);
lastTestStatus = cfg.last_test_status ?? "";
apiKey = "";
smtpPassword = "";
}
onMount(async () => {
try {
const me = await api<MeResponse>("/api/auth/me");
authSession.setMe(me);
canAdmin = isCompanyAdmin(me);
} catch {
canAdmin = authSession.isCompanyAdmin;
}
try {
const cfg = await api<EmailProviderConfig>("/api/integrations/email");
apply(cfg);
} catch (err) {
if (isUnauthorized(err)) {
await goto("/login");
return;
}
if (isForbidden(err)) {
accessDenied = true;
return;
}
if (err instanceof ApiError && (err.status === 404 || err.status === 503)) {
error = "";
} else {
error = failureMessage(err, i18n.t("email.flash.loadFailed"));
}
} finally {
loading = false;
}
});
async function save(event: Event) {
event.preventDefault();
saving = true;
error = "";
success = "";
try {
const cfg = await api<EmailProviderConfig>("/api/integrations/email", {
method: "PUT",
body: {
provider,
from_email: fromEmail,
from_name: fromName,
reply_to: replyTo,
domain: domain || undefined,
api_key: apiKey || undefined,
smtp_host: smtpHost,
smtp_port: smtpPort,
smtp_user: smtpUser,
smtp_password: smtpPassword || undefined,
is_enabled: enabled
}
});
apply(cfg);
success = i18n.t("flash.email.saved");
} catch (err) {
error = isForbidden(err)
? companyAdminDeniedMessage(i18n.t("email.action.changeSettings"))
: failureMessage(err, i18n.t("email.flash.saveFailed"));
} finally {
saving = false;
}
}
async function verify() {
verifying = true;
error = "";
success = "";
try {
const res = await api<{ config: EmailProviderConfig; message?: string }>(
"/api/integrations/email/verify",
{ method: "POST", body: {} }
);
apply(res.config);
success = res.message || i18n.t("email.flash.verifyUpdated");
} catch (err) {
error = isForbidden(err)
? companyAdminDeniedMessage(i18n.t("email.action.verifyDomain"))
: failureMessage(err, i18n.t("email.flash.verifyFailed"));
} finally {
verifying = false;
}
}
async function sendTest() {
testing = true;
error = "";
success = "";
try {
const res = await api<EmailSendResult>("/api/integrations/email/test", {
method: "POST",
body: { to: testTo }
});
if (res.dry_run) {
success = i18n.t("flash.email.testDryRun");
} else {
success = i18n.t("flash.email.testSent");
}
const cfg = await api<EmailProviderConfig>("/api/integrations/email");
apply(cfg);
} catch (err) {
error = isForbidden(err)
? companyAdminDeniedMessage(i18n.t("email.action.sendTest"))
: failureMessage(err, i18n.t("email.flash.testFailed"));
} finally {
testing = false;
}
}
async function onBlastConfirm(phrase: string) {
blastOpen = false;
error = "";
success = "";
const recipient = testTo.trim();
try {
const res = await api<EmailSendResult>("/api/email/send", {
method: "POST",
body: {
mode: "blast",
confirm_understood: phrase,
to: [recipient],
subject: i18n.t("email.blast.subject"),
text: i18n.t("email.blast.body", { recipient })
}
});
success = res.dry_run
? i18n.t("email.flash.blastDryRun")
: i18n.t("email.flash.blastDone", { sent: res.sent ?? 0, failed: res.failed ?? 0 });
} catch (err) {
error = isForbidden(err)
? companyAdminDeniedMessage(i18n.t("email.action.sendBlasts"))
: failureMessage(err, i18n.t("email.flash.blastFailed"));
}
}
</script>
<PageShell title={i18n.t("email.title")} description={i18n.t("email.description")}>
{#if loading}
<div class="flex justify-center py-16"><Spinner /></div>
{:else if accessDenied}
<ForbiddenEmptyState
message={i18n.t("empty.forbidden.email")}
/>
{:else}
{#if !canAdmin}
<Alert
tone="info"
message={i18n.t("email.adminOnly")}
/>
<div class="mb-4">
<a href="/settings?tab=team">
<Button type="button" variant="outline" size="sm">{i18n.t("common.askAdmin")}</Button>
</a>
</div>
{/if}
<div class="mb-4 flex flex-wrap gap-2 text-sm">
<a class="text-primary underline" href="/settings">{i18n.t("nav.settings")}</a>
<span class="text-muted-foreground">/</span>
<span>{i18n.t("email.crumb")}</span>
</div>
<Alert tone="error" message={error} />
<Alert tone="success" message={success} />
{#if dryRunForced}
<Alert
tone="info"
message={i18n.t("email.dryRunForced", { reason: dryRunReason || i18n.t("email.dryRunPolicy") })}
/>
{/if}
<div class="mb-6 flex flex-wrap gap-2">
<Badge variant={verified ? "default" : "secondary"}>
{verified ? i18n.t("email.badge.verified") : i18n.t("email.badge.notVerified")}
</Badge>
<Badge variant={canSendReal ? "default" : "secondary"}>
{canSendReal ? i18n.t("email.badge.canSendReal") : i18n.t("email.badge.realBlocked")}
</Badge>
{#if lastTestStatus}
<Badge variant="outline">{i18n.t("email.badge.lastTest", { status: lastTestStatus })}</Badge>
{/if}
</div>
<form class="space-y-6" onsubmit={save}>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2"><Mail class="h-4 w-4" /> {i18n.t("email.provider.title")}</CardTitle>
<CardDescription>{i18n.t("email.provider.desc")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="provider">{i18n.t("email.field.provider")}</Label>
<Select id="provider" bind:value={provider}>
<option value="resend">{i18n.t("email.provider.resend")}</option>
<option value="smtp">{i18n.t("email.provider.smtp")}</option>
</Select>
</div>
<div class="flex items-end gap-2">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={enabled} />
{i18n.t("common.enabled")}
</label>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="from_email">{i18n.t("email.field.fromEmail")}</Label>
<Input id="from_email" bind:value={fromEmail} placeholder="hello@yourdomain.com" required />
</div>
<div class="space-y-2">
<Label for="domain">{i18n.t("email.field.domain")}</Label>
<Input id="domain" bind:value={domain} placeholder="yourdomain.com" />
</div>
<div class="space-y-2">
<Label for="from_name">{i18n.t("email.field.fromName")}</Label>
<Input id="from_name" bind:value={fromName} placeholder="Acme Store" />
</div>
<div class="space-y-2">
<Label for="reply_to">{i18n.t("email.field.replyTo")}</Label>
<Input id="reply_to" bind:value={replyTo} placeholder="support@yourdomain.com" />
</div>
</div>
{#if provider === "resend"}
<div class="space-y-2">
<Label for="api_key">{hasApiKey ? i18n.t("email.resendKeySaved") : i18n.t("email.resendKey")}</Label>
<Input id="api_key" type="password" bind:value={apiKey} autocomplete="new-password" placeholder="re_…" />
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="smtp_host">{i18n.t("email.field.smtpHost")}</Label>
<Input id="smtp_host" bind:value={smtpHost} placeholder="smtp.example.com" />
</div>
<div class="space-y-2">
<Label for="smtp_port">{i18n.t("email.field.port")}</Label>
<Input id="smtp_port" bind:value={smtpPort} />
</div>
<div class="space-y-2">
<Label for="smtp_user">{i18n.t("email.field.username")}</Label>
<Input id="smtp_user" bind:value={smtpUser} />
</div>
<div class="space-y-2">
<Label for="smtp_password">{hasSmtpPassword ? i18n.t("email.field.passwordSaved") : i18n.t("email.field.password")}</Label>
<Input id="smtp_password" type="password" bind:value={smtpPassword} autocomplete="new-password" />
</div>
</div>
{/if}
<div class="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1">
{#if domainVerified}<CheckCircle class="h-3 w-3 text-green-600" />{:else}<XCircle class="h-3 w-3" />{/if}
{i18n.t("email.field.domain")}
</span>
<span class="inline-flex items-center gap-1">
{#if fromVerified}<CheckCircle class="h-3 w-3 text-green-600" />{:else}<XCircle class="h-3 w-3" />{/if}
{i18n.t("email.field.from")}
</span>
</div>
<div class="flex flex-wrap gap-2">
<Button type="submit" loading={saving} disabled={!canAdmin}>{i18n.t("common.saveShort")}</Button>
<Button type="button" variant="outline" loading={verifying} disabled={!canAdmin} onclick={() => void verify()}>{i18n.t("email.verifyDomain")}</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{i18n.t("email.testGate.title")}</CardTitle>
<CardDescription>
{i18n.t("email.testGate.desc")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="test_to">{i18n.t("email.field.recipient")}</Label>
<Input id="test_to" type="email" bind:value={testTo} placeholder="you@company.com" />
</div>
<div class="flex flex-wrap gap-2">
<Button type="button" variant="outline" loading={testing} disabled={!canAdmin || !testTo} onclick={() => void sendTest()}>
{#if testing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
{i18n.t("email.sendTest")}
</Button>
<Button type="button" disabled={!canAdmin || !testTo} onclick={() => (blastOpen = true)}>
<Send class="mr-2 h-4 w-4" /> {i18n.t("email.confirmBlast")}
</Button>
</div>
</CardContent>
</Card>
</form>
{/if}
</PageShell>
<BlastConfirmDialog bind:open={blastOpen} recipientCount={1} dryRun={dryRunForced || !canSendReal} onConfirm={onBlastConfirm} />
+513
View File
@@ -0,0 +1,513 @@
@import "tailwindcss";
/*
Class-based dark via `.dark` on html (FOUC script + $lib/theme.svelte).
Default is light when unset; OS prefers-color-scheme is not consulted.
Semantic tokens below power marketing, dashboard, admin, and auth shells.
*/
@custom-variant dark (&:where(.dark, .dark *));
/*
Descrybe design tokens brand: Russian Violet (ink/sidebar) + Majorelle Blue (accent).
HSL channels only; @theme / utilities wrap with hsl(). Refined from legacy Next.js
globals + tailwind.config; prefer semantic --color-* over raw hex in UI.
Light hex: canvas #F9F9FB · surface #FFFFFF · ink #2C2357 · muted-text #5A5366
· primary #634FFC · link #3722D3 · border #D9DCE3 · sidebar #2C2357
· success #297A5F · danger #D32222 · accent-wash #D9E3FC
Dark hex: canvas #14121C · surface #1D1B28 · elevated #262433 · text #F3F3F7
· muted-text #D9D6E0 · primary #9488F2 · on-primary #151221 · border #413D51
· sidebar #110F1A · success #40B58E · danger #E25A5A
Contrast (WCAG 2.2 AA): light muted-text ~#5A5366 (~7.5:1 on canvas, ~6.8:1 on muted
wash) so table headers / badge subtext stay AA without opacity dilutions; dark
muted-text ~#D9D6E0 (~13:1 on canvas, ~12:1 on card) for readable xs/secondary
labels without opacity dilutions; dark canvas is violet-tinted charcoal;
dark primary lightened so it clears AA as link+fill. Chart series tokens preserved
for /admin/analytics.
*/
:root {
color-scheme: light;
/* Canvas / text — seasalt + russian-violet ink */
--background: 240 20% 98%;
--foreground: 250 42% 24%;
--card: 0 0% 100%;
--card-foreground: 250 42% 24%;
--popover: 0 0% 100%;
--popover-foreground: 250 42% 24%;
/* Primary — majorelle-blue (fill + large text); link = deeper for AA body */
--primary: 247 97% 65%;
--primary-foreground: 240 20% 98%;
--link: 247 72% 48%;
/* Secondary — ultra-violet */
--secondary: 250 21% 37%;
--secondary-foreground: 240 20% 98%;
/* Muted — ghost-white / ink-muted (AA on canvas + muted wash; avoid /70 dilutions) */
--muted: 235 50% 95%;
--muted-foreground: 270 10% 34%;
/* Accent — lavender-web wash (hover/selected, not neon glow) */
--accent: 223 86% 92%;
--accent-foreground: 250 42% 24%;
/* Danger / success */
--destructive: 0 72% 48%;
--destructive-foreground: 240 20% 98%;
--danger: 0 72% 48%;
--danger-foreground: 240 20% 98%;
--success: 160 50% 32%;
--success-foreground: 240 20% 98%;
/* Border & input — platinum */
--border: 222 16% 87%;
--input: 222 16% 84%;
--ring: 247 97% 65%;
/* Sidebar — russian-violet chrome */
--sidebar-bg: 250 42% 24%;
--sidebar-text: 0 0% 100%;
--sidebar-foreground: 0 0% 100%;
--sidebar-border: 250 30% 32%;
--sidebar-accent: 250 35% 32%;
--sidebar-accent-foreground: 0 0% 100%;
--sidebar-primary: 247 97% 65%;
--sidebar-primary-foreground: 240 20% 98%;
--sidebar-ring: 247 97% 70%;
/* Info alerts: keep tinted but readable on both light and .dark */
--card-blue: 223 86% 92%;
--card-red: 355 68% 93%;
--card-green: 176 28% 89%;
/* Analytics chart palette — keep in sync with .dark / @theme chart-* below. */
--chart-primary: 247 97% 65%;
--chart-secondary: 38 92% 50%;
--chart-red: 355 68% 65%;
--chart-green: 176 28% 60%;
--chart-sky: 199 89% 48%;
--chart-emerald: 160 64% 42%;
--chart-amber: 38 92% 50%;
--chart-violet: 258 90% 66%;
--chart-slate: 215 16% 47%;
--chart-track: 235 40% 92%;
--chart-plot: 240 20% 99%;
/* Hover */
--hover-background: 235 50% 95%;
--hover-selected: 235 50% 90%;
/* shadcn radius (tailwind.config borderRadius.lg/md/sm) */
--radius: 0.5rem;
/* Flat aliases (resolved colors for inline styles / JS) */
--brand: hsl(var(--primary));
--brand-dark: hsl(247 90% 55%);
--ink: hsl(var(--foreground));
--surface: hsl(var(--card));
}
/*
Dark semantic tokens applied when html has `.dark` (app-wide theme).
Do not put `.dark` on body; sidebar keeps brand-ink via --sidebar-*.
*/
html.dark,
.dark {
color-scheme: dark;
/* Violet-tinted charcoal — brand cast without saturated purple walls */
--background: 252 22% 9%;
--foreground: 240 18% 96%;
--card: 250 20% 13%;
--card-foreground: 240 18% 96%;
--popover: 248 18% 17%;
--popover-foreground: 240 18% 96%;
/* Majorelle lifted for AA on charcoal (fill + link); on-primary near-ink for AA on lavender fill */
--primary: 247 80% 74%;
--primary-foreground: 252 40% 8%;
--link: 247 80% 78%;
--secondary: 250 18% 28%;
--secondary-foreground: 240 18% 96%;
--muted: 250 18% 17%;
--muted-foreground: 255 14% 86%;
--accent: 250 18% 17%;
--accent-foreground: 240 18% 96%;
--destructive: 0 70% 62%;
--destructive-foreground: 240 18% 96%;
--danger: 0 70% 62%;
--danger-foreground: 240 18% 96%;
--success: 160 48% 48%;
--success-foreground: 160 40% 10%;
--border: 250 14% 28%;
--input: 250 14% 32%;
--ring: 247 80% 74%;
--sidebar-bg: 252 28% 8%;
--sidebar-text: 240 18% 96%;
--sidebar-foreground: 240 18% 96%;
--sidebar-border: 250 14% 22%;
--sidebar-accent: 250 22% 14%;
--sidebar-accent-foreground: 240 18% 96%;
--sidebar-primary: 247 80% 74%;
--sidebar-primary-foreground: 252 40% 8%;
--sidebar-ring: 247 80% 78%;
--card-blue: 247 35% 18%;
--card-red: 0 35% 18%;
--card-green: 160 28% 16%;
/* Analytics chart palette — same names; values tuned for charcoal surfaces */
--chart-primary: 247 80% 74%;
--chart-secondary: 38 95% 58%;
--chart-red: 355 68% 70%;
--chart-green: 176 28% 65%;
--chart-sky: 199 90% 58%;
--chart-emerald: 160 55% 52%;
--chart-amber: 38 95% 58%;
--chart-violet: 258 90% 72%;
--chart-slate: 215 14% 62%;
--chart-track: 250 16% 22%;
--chart-plot: 250 20% 12%;
--hover-background: 250 18% 17%;
--hover-selected: 250 20% 22%;
--brand: hsl(var(--primary));
--brand-dark: hsl(247 75% 68%);
--ink: hsl(var(--foreground));
--surface: hsl(var(--card));
}
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-text: hsl(var(--foreground));
--color-text-muted: hsl(var(--muted-foreground));
--color-surface: hsl(var(--card));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-link: hsl(var(--link));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-danger: hsl(var(--danger));
--color-danger-foreground: hsl(var(--danger-foreground));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-sidebar: hsl(var(--sidebar-bg));
--color-sidebar-text: hsl(var(--sidebar-text));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
--color-sidebar-border: hsl(var(--sidebar-border));
--color-sidebar-accent: hsl(var(--sidebar-accent));
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
--color-sidebar-primary: hsl(var(--sidebar-primary));
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
--color-sidebar-ring: hsl(var(--sidebar-ring));
--color-card-blue: hsl(var(--card-blue));
--color-card-red: hsl(var(--card-red));
--color-card-green: hsl(var(--card-green));
--color-chart-primary: hsl(var(--chart-primary));
--color-chart-secondary: hsl(var(--chart-secondary));
--color-chart-red: hsl(var(--chart-red));
--color-chart-green: hsl(var(--chart-green));
--color-chart-sky: hsl(var(--chart-sky));
--color-chart-emerald: hsl(var(--chart-emerald));
--color-chart-amber: hsl(var(--chart-amber));
--color-chart-violet: hsl(var(--chart-violet));
--color-chart-slate: hsl(var(--chart-slate));
--color-chart-track: hsl(var(--chart-track));
--color-chart-plot: hsl(var(--chart-plot));
--color-hover: hsl(var(--hover-background));
--color-hover-selected: hsl(var(--hover-selected));
/* ring-offset-background → uses --color-background */
--color-ring-offset-background: hsl(var(--background));
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
/* Legacy body stack (globals.css) */
--font-sans: Arial, Helvetica, sans-serif;
/* Legacy gr-* font sizes (tailwind.config.ts) */
--text-gr-1: 40px;
--text-gr-1--line-height: 1.1;
--text-gr-1--letter-spacing: -0.02em;
--text-gr-2: 32px;
--text-gr-2--line-height: 1.2;
--text-gr-2--letter-spacing: -0.01em;
--text-gr-3: 22px;
--text-gr-3--line-height: 1.3;
--text-gr-4: 16px;
--text-gr-4--line-height: 1.4;
--text-gr-5: 10px;
--text-gr-5--line-height: 1.5;
/* Dashboard chrome tokens (sidebar.tsx / header.tsx / layout) */
--spacing-sidebar: 16rem;
--spacing-header: 4rem;
/* Marketing hero tiled backgrounds (legacy tailwind.config.ts) */
--background-image-tiled-pattern: url("/tiled_background.png");
--background-image-tiled-pattern-dark: url("/tiled_background_darkmode.png");
}
@layer base {
* {
border-color: hsl(var(--border));
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: inherit;
text-decoration: none;
}
}
@layer components {
/*
Solid primary fills must use on-primary ink. Some TW class merges drop
text-primary-foreground and inherit a mid gray (~3.5:1 on lavender) axe AA fail.
Opacity variants (bg-primary/5 etc.) are separate classes and are unaffected.
*/
.bg-primary {
color: hsl(var(--primary-foreground));
}
/* Legacy responsive type (globals.css @layer components) */
.h1-responsive {
@apply text-gr-1 text-4xl sm:text-5xl md:text-6xl lg:text-[72px];
}
.h2-responsive {
@apply text-gr-2 text-3xl sm:text-4xl md:text-[40px] lg:text-[44px];
}
.h3-responsive {
@apply text-gr-3 text-2xl sm:text-2xl md:text-[25px] lg:text-[27px];
}
.body-responsive {
@apply text-gr-4 text-base md:text-[17px];
}
}
@layer utilities {
.bg-tiled-pattern {
background-image: url("/tiled_background.png");
}
.bg-tiled-pattern-dark {
background-image: url("/tiled_background_darkmode.png");
}
.bg-card-blue {
background-color: hsl(var(--card-blue));
}
.bg-card-red {
background-color: hsl(var(--card-red));
}
.bg-card-green {
background-color: hsl(var(--card-green));
}
.bg-sidebar {
background-color: hsl(var(--sidebar-bg));
}
.text-sidebar-text {
color: hsl(var(--sidebar-text));
}
.text-sidebar-foreground {
color: hsl(var(--sidebar-foreground));
}
.border-sidebar-border {
border-color: hsl(var(--sidebar-border));
}
.bg-sidebar-accent {
background-color: hsl(var(--sidebar-accent));
}
.text-sidebar-accent-foreground {
color: hsl(var(--sidebar-accent-foreground));
}
.bg-sidebar-primary {
background-color: hsl(var(--sidebar-primary));
}
.text-sidebar-primary {
color: hsl(var(--sidebar-primary));
}
.bg-hover {
background-color: hsl(var(--hover-background));
}
.bg-selected {
background-color: hsl(var(--hover-selected));
}
/* Alias so ring-offset-background resolves like shadcn/TW3 */
.ring-offset-background {
--tw-ring-offset-color: hsl(var(--background));
}
}
/*
Escape-hatch utilities for raw markup class strings mirror
$lib/components/ui (legacy shadcn), not the earlier softer v2 kit.
*/
@layer components {
.ui-input {
@apply flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-base shadow-sm file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:bg-muted disabled:text-foreground disabled:opacity-100 md:text-sm;
}
.ui-select {
@apply flex h-10 w-full items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50;
}
.ui-textarea {
@apply flex min-h-[80px] w-full rounded-md border border-border bg-background px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm;
}
.ui-btn-primary {
@apply inline-flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-all duration-150 hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-95;
}
.ui-btn-secondary {
@apply inline-flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-md bg-secondary px-4 py-2 text-sm font-medium text-secondary-foreground ring-offset-background transition-all duration-150 hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-95;
}
.ui-btn-outline {
@apply inline-flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground ring-offset-background transition-all duration-150 hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:!border-border disabled:!bg-muted disabled:!text-muted-foreground disabled:!opacity-100 active:scale-95;
}
.ui-btn-ghost {
@apply inline-flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium ring-offset-background transition-all duration-150 hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-95;
}
.ui-table {
@apply w-full caption-bottom text-sm;
}
.ui-table thead tr {
@apply border-b border-border;
}
.ui-table th {
@apply h-12 px-4 text-left align-middle font-medium text-muted-foreground;
}
.ui-table td {
@apply p-4 align-middle;
}
.ui-table tbody tr {
@apply border-b border-border transition-colors hover:bg-hover data-[state=selected]:bg-hover-selected;
}
/*
Pin the trailing Actions column while the table scrolls horizontally.
Used via TableHead/TableCell stickyRight, or class="table-sticky-actions".
*/
.table-sticky-actions {
position: sticky;
right: 0;
z-index: 3;
background-color: hsl(var(--card));
box-shadow: -6px 0 8px -6px hsl(var(--foreground) / 0.1);
}
/* Mobile: sticky Actions overlays Ready/name — keep in document flow under md. */
@media (max-width: 767px) {
.table-sticky-actions {
position: static;
box-shadow: none;
}
}
thead .table-sticky-actions {
z-index: 4;
background-color: hsl(var(--card));
}
tr:hover > .table-sticky-actions {
background-color: hsl(var(--hover-background));
}
tr[data-state="selected"] > .table-sticky-actions {
background-color: hsl(var(--hover-selected));
}
.ui-label {
@apply text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70;
}
}
/*
Increased-contrast preference (WCAG 1.4.11 / 2.4.7): keep focus rings
clearly visible in both themes. Do not invert tokens boost ring only.
*/
@media (prefers-contrast: more) {
:root {
--ring: 247 100% 42%;
--sidebar-ring: 247 100% 70%;
}
html.dark,
.dark {
--ring: 247 95% 82%;
--sidebar-ring: 247 95% 88%;
}
:focus-visible {
outline: 3px solid hsl(var(--ring));
outline-offset: 2px;
}
}
+162
View File
@@ -0,0 +1,162 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api, ApiError } from "$lib/api";
import { trackEvent } from "$lib/analytics";
import { apiFormError, fieldDescribedBy, fieldInvalid, parseBodyErrorCodes } from "$lib/api-form-error";
import { i18n } from "$lib/i18n";
import { safeInternalNext } from "$lib/safe-next";
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label
} from "$lib/components/ui";
const LOGIN_FIELD_HINTS = {
// Prefer stable codes (locale-safe). English needles are last-resort only.
email: ["invalid_credentials", "email", "credentials"],
password: ["invalid_credentials", "password_not_set", "password", "credentials"]
} as const;
let email = $state("");
let password = $state("");
let error = $state("");
let fieldErrors = $state<Record<string, string>>({});
let loading = $state(false);
let needsPasswordInvite = $state(false);
const errorId = "login-form-error";
function apiErrorCode(err: unknown): string {
if (!(err instanceof ApiError)) return "";
return parseBodyErrorCodes(err.body)[0] ?? "";
}
async function onSubmit(event: Event) {
event.preventDefault();
error = "";
fieldErrors = {};
needsPasswordInvite = false;
loading = true;
try {
await api("/api/auth/login", {
method: "POST",
body: { email, password }
});
trackEvent("login", { method: "email" });
await goto(safeInternalNext(page.url.searchParams.get("next"), "/dashboard"));
} catch (err) {
if (apiErrorCode(err) === "password_not_set") {
needsPasswordInvite = true;
const result = apiFormError(err, i18n.t("auth.login.passwordNotSet"));
error = result.message;
fieldErrors = {};
return;
}
const result = apiFormError(err, i18n.t("auth.login.failed"), LOGIN_FIELD_HINTS);
error = result.message;
fieldErrors = result.fields;
} finally {
loading = false;
}
}
</script>
<Card>
<CardHeader>
<CardTitle level={1}>{i18n.t("auth.login.title")}</CardTitle>
<CardDescription>{i18n.t("auth.login.description")}</CardDescription>
</CardHeader>
<CardContent>
<form
method="post"
class="space-y-4"
onsubmit={onSubmit}
aria-busy={loading}
aria-describedby={error ? errorId : undefined}
>
{#if error}
<Alert variant="destructive">
<AlertDescription id={errorId}>{error}</AlertDescription>
</Alert>
{/if}
{#if needsPasswordInvite}
<div class="space-y-2 rounded-md border bg-muted/40 px-3 py-3 text-sm text-text-muted">
<p>
<strong class="text-text">{i18n.t("auth.login.setPasswordFirstTitle")}</strong>
{i18n.t("auth.login.setPasswordFirstBody", {
email: email || i18n.t("auth.login.yourEmail")
})}
</p>
<p>
{i18n.t("auth.login.platformAdminsReissue")}
<a href="/admin/users" class="font-medium text-link hover:underline"
>{i18n.t("auth.login.adminUsersLink")}</a
>.
</p>
<p>
<a href="/accept-invite" class="font-medium text-link hover:underline"
>{i18n.t("auth.login.haveToken")}</a
>
</p>
</div>
{/if}
<div class="space-y-2">
<Label for="email">{i18n.t("common.email")}</Label>
<Input
id="email"
type="email"
name="email"
autocomplete="email"
required
aria-invalid={fieldInvalid(fieldErrors, "email") ?? (error && !Object.keys(fieldErrors).length ? "true" : undefined)}
aria-describedby={fieldDescribedBy(fieldErrors, "email", errorId) ?? (error && !Object.keys(fieldErrors).length ? errorId : undefined)}
bind:value={email}
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label for="password">{i18n.t("common.password")}</Label>
<a href="/forgot-password" class="text-xs font-medium text-link hover:underline">
{i18n.t("auth.login.forgotPassword")}
</a>
</div>
<Input
id="password"
type="password"
name="password"
autocomplete="current-password"
required
aria-invalid={fieldInvalid(fieldErrors, "password") ?? (error && !Object.keys(fieldErrors).length ? "true" : undefined)}
aria-describedby={fieldDescribedBy(fieldErrors, "password", errorId) ?? (error && !Object.keys(fieldErrors).length ? errorId : undefined)}
bind:value={password}
/>
</div>
<Button type="submit" class="w-full shadow-sm" loading={loading}>
{loading ? i18n.t("auth.login.submitting") : i18n.t("auth.login.submit")}
</Button>
</form>
<p class="mt-5 text-center text-sm text-text-muted">
{i18n.t("auth.login.noAccount")}
<a href="/register" class="font-medium text-link hover:underline">{i18n.t("auth.login.createCompany")}</a>
</p>
<p class="mt-1.5 text-center text-sm text-text-muted">
{i18n.t("auth.login.haveInvite")}
<a href="/accept-invite" class="font-medium text-link hover:underline">{i18n.t("auth.login.acceptInvite")}</a>
</p>
<p class="mt-1.5 text-center text-sm text-text-muted">
<a href="/pricing" class="font-medium text-link hover:underline">{i18n.t("common.viewPricing")}</a>
</p>
</CardContent>
</Card>
@@ -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>
+480
View File
@@ -0,0 +1,480 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { Check, Zap } from "@lucide/svelte";
import { api, ApiError, failureMessage } from "$lib/api";
import { trackEvent } from "$lib/analytics";
import {
formatCreditsRemaining,
hasActivePlan,
isEnterprisePlan,
isPayAsYouGoPlan,
isSelfServeCheckoutPlan,
planDisplayName,
planNameOf,
remainingCreditsOf,
type PlanLike
} from "$lib/billing-display";
import { isCompanyAdmin } from "$lib/company-admin";
import {
fetchStripeStatus,
redirectToCheckout,
startCheckout,
type StripeStatus
} from "$lib/stripe-billing";
import type { CreditBalance, MeResponse } from "$lib/types";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import {
Badge,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "$lib/components/ui";
import { buttonClasses } from "$lib/components/ui/button-variants";
import {
PRICING_PLANS,
CONTACT_SALES_HREF,
applyApiMetersToPricingPlan,
applyResolvedFeaturesToPricingFeatures,
isPublicProductPlan,
type ApiPublicPlan
} from "$lib/components/pricing/pricing-data";
import PlanCalculator from "$lib/components/pricing/PlanCalculator.svelte";
import { i18n } from "$lib/i18n";
type ApiPlan = ApiPublicPlan & {
term?: string;
resolved_features?: Record<string, boolean>;
};
function featuresFromBackend(
marketing: (typeof PRICING_PLANS)[number],
apiPlan: ApiPlan | null
) {
const merged = applyApiMetersToPricingPlan(marketing, apiPlan ?? undefined);
return applyResolvedFeaturesToPricingFeatures(
merged.features,
marketing.features,
apiPlan?.resolved_features
);
}
let apiPlans = $state<ApiPlan[]>([]);
let currentPlan = $state<PlanLike | null>(null);
let hasPlan = $state(false);
let remainingCredits = $state<number | null>(null);
let stripe = $state<StripeStatus | null>(null);
let loading = $state(true);
let error = $state("");
let success = $state("");
let apiAvailable = $state(true);
let checkoutBusy = $state<string | null>(null);
let canManageBilling = $state(false);
function apiFor(name: string): ApiPlan | undefined {
const key = name.trim().toLowerCase();
return apiPlans.find((p) => p.name.trim().toLowerCase() === key);
}
onMount(() => {
void (async () => {
loading = true;
error = "";
try {
const [plansPayload, me, stripeRes] = await Promise.all([
api<{ plans?: ApiPlan[] }>("/api/billing/plans").catch((err: unknown) => {
if (err instanceof ApiError && err.status === 404) {
apiAvailable = false;
return { plans: [] as ApiPlan[] };
}
throw err;
}),
api<MeResponse>("/api/auth/me"),
fetchStripeStatus().catch(() => null)
]);
apiPlans = (plansPayload?.plans ?? []).filter((p) => isPublicProductPlan(p.name));
stripe = stripeRes;
canManageBilling = isCompanyAdmin(me);
const bal: CreditBalance | null =
me.credits ?? (await api<CreditBalance>("/api/billing/credits").catch(() => null));
if (bal?.plan && typeof bal.plan === "object") {
currentPlan = bal.plan as PlanLike;
} else {
currentPlan = null;
}
hasPlan = hasActivePlan(bal, currentPlan);
remainingCredits = remainingCreditsOf(bal);
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("plans.loadFailed"));
} finally {
loading = false;
}
})();
});
const currentName = $derived(hasPlan ? planNameOf(currentPlan).toLowerCase() : "");
const remainingLabel = $derived(formatCreditsRemaining(remainingCredits, hasPlan ? currentPlan : null));
const currentIsPayg = $derived(hasPlan && isPayAsYouGoPlan(currentPlan));
const displayPlans = $derived.by(() => {
return PRICING_PLANS.map((marketing) => {
const apiPlan = apiFor(marketing.name) ?? null;
const merged = applyApiMetersToPricingPlan(marketing, apiPlan ?? undefined);
const name = merged.name;
const key = name.toLowerCase();
const isCurrent = currentName === key;
const selfServe = isSelfServeCheckoutPlan(name);
const enterprise = key === "enterprise" || Boolean(merged.customPrice);
let ctaText = i18n.t("plans.cta.requestUpgrade");
let ctaMode: "checkout" | "link" | "current" = "link";
let ctaHref = `${CONTACT_SALES_HREF}?source=plans`;
if (isCurrent) {
ctaText = i18n.t("plans.cta.current");
ctaMode = "current";
ctaHref = "/billing";
} else if (enterprise) {
ctaText = i18n.t("plans.cta.contactSales");
ctaMode = "link";
ctaHref = `${CONTACT_SALES_HREF}?source=plans`;
} else if (key === "free") {
ctaText = isCurrent ? i18n.t("plans.cta.current") : i18n.t("plans.cta.switchInBilling");
ctaMode = "link";
ctaHref = "/billing";
} else if (selfServe) {
if (canManageBilling) {
ctaText = i18n.t("plans.cta.upgradeTo", { name });
ctaMode = "checkout";
ctaHref = "/billing";
} else {
ctaText = i18n.t("common.askAdmin");
ctaMode = "link";
ctaHref = "/settings?tab=team";
}
}
return {
api: apiPlan,
marketing: merged,
popular: merged.popular ?? key === "growth",
price: merged.customPrice ? null : merged.pricePerMonth,
customPrice: Boolean(merged.customPrice),
features: featuresFromBackend(marketing, apiPlan),
isCurrent,
ctaHref,
ctaText,
ctaMode
};
});
});
async function onCheckout(planName: string) {
const planId = planName.trim().toLowerCase();
trackEvent("select_item", {
item_id: planId,
item_name: planId,
item_category: "plan"
});
checkoutBusy = planName;
error = "";
success = "";
try {
const result = await startCheckout(planName, "monthly");
if (result.mock && result.applied) {
success =
result.message ?? i18n.t("plans.planApplied", { plan: planName });
}
redirectToCheckout(result);
} catch (err) {
error = failureMessage(err, i18n.t("billing.checkoutFailed"));
} finally {
checkoutBusy = null;
}
}
function onPlanLinkClick(planName: string, ctaHref: string) {
const planId = planName.trim().toLowerCase();
trackEvent("select_item", {
item_id: planId,
item_name: planId,
item_category: "plan"
});
if (ctaHref.includes(CONTACT_SALES_HREF) || ctaHref.includes("calendly.com")) {
trackEvent("contact_sales_clicked", {
cta_location: "plans_card",
destination: ctaHref.startsWith("http") ? "calendly" : "form"
});
}
}
function onContactSalesCta() {
trackEvent("contact_sales_clicked", {
cta_location: "plans_footer",
destination: "form"
});
}
</script>
{#if loading}
<div class="flex justify-center py-16">
<Spinner label={i18n.t("plans.loading")} />
</div>
{:else}
<div class="mx-auto max-w-6xl space-y-8">
<Alert message={error} />
<Alert tone="success" message={success} />
{#if !apiAvailable}
<Alert tone="info" message={i18n.t("plans.apiUnavailable")} />
{/if}
<div class="space-y-4 text-center">
<h1 class="text-4xl font-bold tracking-tight">{i18n.t("plans.title")}</h1>
<p class="mx-auto max-w-2xl text-xl text-muted-foreground">
{#if hasPlan && currentPlan}
{#if isEnterprisePlan(currentPlan)}
{i18n.t("plans.sub.onPrefix")}
<span class="font-medium text-foreground">{planDisplayName(currentPlan)}</span>
{i18n.t("plans.sub.enterpriseSuffix")}
{:else if currentIsPayg}
{i18n.t("plans.sub.onPrefix")}
<span class="font-medium text-foreground">{planDisplayName(currentPlan)}</span>
{i18n.t("plans.sub.paygSuffix")}
{:else}
{i18n.t("plans.sub.onPrefix")}
<span class="font-medium text-foreground">{planDisplayName(currentPlan)}</span>
{i18n.t("plans.sub.creditsMid", { remaining: remainingLabel })}
{i18n.t("plans.sub.creditsSuffix")}
{/if}
{:else}
{i18n.t("plans.sub.none")}
{/if}
</p>
{#if stripe?.configured && !stripe?.mock}
<p class="text-sm text-muted-foreground">{i18n.t("plans.stripeHint")}</p>
{/if}
</div>
<div class="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{#each displayPlans as row, index (row.api?.id ?? row.marketing?.id ?? index)}
{@const name = row.api?.name ?? row.marketing?.name ?? i18n.t("plans.fallbackName")}
{@const description =
row.api?.description ||
(row.marketing?.descriptionKey
? i18n.t(row.marketing.descriptionKey)
: row.marketing?.description) ||
i18n.t("plans.fallbackDescription")}
{@const maxProducts = row.api?.max_products ?? row.marketing?.maxProducts}
{@const credits = row.api?.monthly_credits ?? row.marketing?.monthlyCredits}
{@const coverPct = row.api?.ai_cover_percent}
<Card
class="relative flex flex-col {row.popular
? 'border-primary shadow-lg'
: ''} {row.isCurrent ? 'ring-2 ring-primary/30' : ''}"
>
{#if row.isCurrent}
<Badge
class="absolute -top-3 left-1/2 z-10 -translate-x-1/2 transform bg-secondary text-secondary-foreground"
>
{i18n.t("plans.badge.current")}
</Badge>
{:else if row.popular}
<Badge
class="absolute -top-3 left-1/2 -translate-x-1/2 transform bg-primary text-primary-foreground"
>
{i18n.t("plans.badge.popular")}
</Badge>
{/if}
<CardHeader>
<CardTitle class="text-2xl">{name}</CardTitle>
<CardDescription class="min-h-[50px]">{description}</CardDescription>
</CardHeader>
<CardContent class="flex-grow space-y-6">
<div>
<div class="flex items-baseline">
{#if row.customPrice}
<span class="text-4xl font-bold">{i18n.t("plans.price.custom")}</span>
{:else if row.price === 0}
<span class="text-4xl font-bold">$0</span>
<span class="ml-2 text-muted-foreground">{i18n.t("plans.price.forever")}</span>
{:else if row.price != null}
<span class="text-4xl font-bold">${row.price}</span>
<span class="ml-2 text-muted-foreground">{i18n.t("plans.price.perMonth")}</span>
{:else}
<span class="text-2xl font-bold text-muted-foreground"
>{i18n.t("plans.price.seePricing")}</span
>
{/if}
</div>
</div>
<div class="space-y-2 text-sm text-muted-foreground">
{#if row.customPrice || name.toLowerCase() === "enterprise"}
<div>{i18n.t("plans.capacity.unlimitedSkus")}</div>
<div>{i18n.t("plans.capacity.unlimitedAi")}</div>
{:else}
{#if typeof maxProducts === "number" && Number.isFinite(maxProducts) && maxProducts > 0}
<div>
{i18n.t("plans.capacity.upToProducts", {
count: maxProducts.toLocaleString()
})}
</div>
{/if}
{#if credits != null}
<div>
{i18n.t("plans.capacity.creditsPerMonth", {
count: credits.toLocaleString()
})}
{#if coverPct}
<span class="text-muted-foreground">
(~{coverPct}% credit base)
</span>
{/if}
</div>
{/if}
{/if}
</div>
<div class="space-y-3">
{#each row.features as feature, fi (fi)}
<div class="flex items-center">
<Check
class="mr-2 h-5 w-5 {feature.included
? 'text-primary'
: 'text-muted-foreground opacity-30'}"
/>
<span
class={feature.included
? ""
: "text-muted-foreground line-through opacity-70"}
>
{feature.nameKey ? i18n.t(feature.nameKey) : feature.name}
</span>
</div>
{/each}
</div>
</CardContent>
<CardFooter>
{#if row.ctaMode === "checkout"}
<button
type="button"
disabled={checkoutBusy !== null}
class={buttonClasses(
row.popular ? "default" : "outline",
"default",
"w-full"
)}
onclick={() => void onCheckout(name)}
>
{#if row.popular}
<Zap class="mr-2 h-4 w-4" />
{/if}
{checkoutBusy === name ? i18n.t("plans.cta.startingCheckout") : row.ctaText}
</button>
{:else}
<a
href={row.ctaHref}
target={row.ctaHref.startsWith("http") ? "_blank" : undefined}
rel={row.ctaHref.startsWith("http") ? "noopener noreferrer" : undefined}
class={buttonClasses(
row.isCurrent ? "secondary" : row.popular ? "default" : "outline",
"default",
"w-full"
)}
onclick={() => onPlanLinkClick(name, row.ctaHref)}
>
{#if row.popular && !row.isCurrent}
<Zap class="mr-2 h-4 w-4" />
{/if}
{row.ctaText}
</a>
{/if}
</CardFooter>
</Card>
{/each}
</div>
<div class="mt-12">
<PlanCalculator plans={displayPlans.map((r) => r.marketing)} />
</div>
<div class="mt-16 space-y-6">
<h2 class="text-center text-3xl font-bold">{i18n.t("plans.faq.title")}</h2>
<div class="mt-8 grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="text-xl">{i18n.t("plans.faq.limit.q")}</CardTitle>
</CardHeader>
<CardContent>
<p>{i18n.t("plans.faq.limit.a")}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-xl">{i18n.t("plans.faq.selfServe.q")}</CardTitle>
</CardHeader>
<CardContent>
<p>{i18n.t("plans.faq.selfServe.a")}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-xl">{i18n.t("plans.faq.enterprise.q")}</CardTitle>
</CardHeader>
<CardContent>
<p>{i18n.t("plans.faq.enterprise.a")}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-xl">{i18n.t("plans.faq.pricing.q")}</CardTitle>
</CardHeader>
<CardContent>
<p>
{i18n.t("plans.faq.pricing.aBefore")}
<a href="/pricing" class="font-medium text-primary underline-offset-4 hover:underline"
>{i18n.t("plans.faq.pricing.pricingPage")}</a
>
{i18n.t("plans.faq.pricing.aMid")}
<a href="/billing" class="font-medium text-primary underline-offset-4 hover:underline"
>{i18n.t("nav.billing")}</a
>{i18n.t("plans.faq.pricing.aAfter")}
</p>
</CardContent>
</Card>
</div>
</div>
<div class="mt-16 text-center">
<Card class="border-primary/20 bg-primary/5">
<CardContent class="pb-6 pt-6">
<h3 class="mb-2 text-2xl font-bold">{i18n.t("plans.custom.title")}</h3>
<p class="mb-6 text-muted-foreground">{i18n.t("plans.custom.body")}</p>
<a
href={`${CONTACT_SALES_HREF}?source=plans`}
class={buttonClasses("default", "default", "")}
onclick={onContactSalesCta}
>
{i18n.t("plans.cta.contactSales")}
</a>
<p class="mt-4 text-sm text-muted-foreground">
{i18n.t("plans.custom.looking")}
<a href="/pricing" class="font-medium text-primary underline-offset-4 hover:underline"
>{i18n.t("common.viewPricing")}</a
>
</p>
</CardContent>
</Card>
</div>
</div>
{/if}
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
import Footer from "$lib/components/site/Footer.svelte";
import MarketingAuthCtas from "$lib/components/site/MarketingAuthCtas.svelte";
import PricingSection from "$lib/components/pricing/PricingSection.svelte";
import SeoHead from "$lib/components/site/SeoHead.svelte";
import { i18n } from "$lib/i18n";
</script>
<SeoHead
title={i18n.t("seo.pricing.title")}
description={i18n.t("seo.pricing.description")}
path="/pricing"
/>
<div class="flex min-h-screen flex-col">
<SiteHeader />
<main id="main-content" class="flex-1 pt-16">
<section
class="border-b border-border bg-background px-4 py-12 text-center sm:px-6 sm:py-16"
>
<p class="text-sm font-medium text-text-muted">{i18n.t("pricing.page.eyebrow")}</p>
<h1 class="mt-2 text-4xl font-bold tracking-tight text-text sm:text-5xl">
{i18n.t("pricing.page.title")}
</h1>
<p class="mx-auto mt-4 max-w-2xl text-lg text-text-muted">
{i18n.t("pricing.page.lead")}
</p>
<MarketingAuthCtas class="mt-8" />
<p class="mt-4 text-sm text-text-muted">
{i18n.t("pricing.page.subscribedBefore")}
<a href="/billing" class="font-medium text-link hover:underline"
>{i18n.t("nav.billing")}</a
>
{i18n.t("pricing.page.subscribedMid")}
<a href="/plans" class="font-medium text-link hover:underline"
>{i18n.t("pricing.page.plansLink")}</a
>{i18n.t("pricing.page.subscribedAfter")}
</p>
</section>
<PricingSection hideIntro />
</main>
<Footer />
</div>
+158
View File
@@ -0,0 +1,158 @@
<script lang="ts">
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
import Footer from "$lib/components/site/Footer.svelte";
import SeoHead from "$lib/components/site/SeoHead.svelte";
import { LEGAL_LAST_UPDATED } from "$lib/site";
import { i18n } from "$lib/i18n";
</script>
<SeoHead
title={i18n.t("seo.privacy.title")}
description={i18n.t("seo.privacy.description")}
path="/privacy"
/>
<div class="flex min-h-screen flex-col bg-background">
<SiteHeader />
<main class="mx-auto max-w-4xl flex-1 px-4 pt-28 pb-12 sm:px-6">
<div class="mb-12 text-center">
<h1 class="text-4xl font-bold tracking-tight">{i18n.t("legal.privacy.title")}</h1>
<p class="mt-2 text-text-muted">
{i18n.t("legal.lastUpdated", { date: LEGAL_LAST_UPDATED })}
</p>
</div>
<div class="space-y-10">
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.intro.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.intro.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.privacy.intro.p2")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.collect.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.collect.lead")}</p>
<h3 class="text-xl font-medium">{i18n.t("legal.privacy.collect.personal.h")}</h3>
<p class="text-text-muted">{i18n.t("legal.privacy.collect.personal.p")}</p>
<h3 class="text-xl font-medium">{i18n.t("legal.privacy.collect.userData.h")}</h3>
<p class="text-text-muted">{i18n.t("legal.privacy.collect.userData.p")}</p>
<h3 class="text-xl font-medium">{i18n.t("legal.privacy.collect.usage.h")}</h3>
<p class="text-text-muted">{i18n.t("legal.privacy.collect.usage.p")}</p>
<h3 class="text-xl font-medium">{i18n.t("legal.privacy.collect.cookies.h")}</h3>
<p class="text-text-muted">{i18n.t("legal.privacy.collect.cookies.p")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.use.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.use.lead")}</p>
<ul class="list-disc space-y-2 pl-6 text-text-muted">
<li>{i18n.t("legal.privacy.use.li1")}</li>
<li>{i18n.t("legal.privacy.use.li2")}</li>
<li>{i18n.t("legal.privacy.use.li3")}</li>
<li>{i18n.t("legal.privacy.use.li4")}</li>
<li>{i18n.t("legal.privacy.use.li5")}</li>
<li>{i18n.t("legal.privacy.use.li6")}</li>
<li>{i18n.t("legal.privacy.use.li7")}</li>
<li>{i18n.t("legal.privacy.use.li8")}</li>
<li>{i18n.t("legal.privacy.use.li9")}</li>
<li>{i18n.t("legal.privacy.use.li10")}</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.ai.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.ai.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.privacy.ai.p2")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.share.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.share.lead")}</p>
<h3 class="text-xl font-medium">{i18n.t("legal.privacy.share.providers.h")}</h3>
<p class="text-text-muted">{i18n.t("legal.privacy.share.providers.p")}</p>
<h3 class="text-xl font-medium">{i18n.t("legal.privacy.share.transfers.h")}</h3>
<p class="text-text-muted">{i18n.t("legal.privacy.share.transfers.p")}</p>
<h3 class="text-xl font-medium">{i18n.t("legal.privacy.share.legal.h")}</h3>
<p class="text-text-muted">{i18n.t("legal.privacy.share.legal.p")}</p>
<h3 class="text-xl font-medium">{i18n.t("legal.privacy.share.consent.h")}</h3>
<p class="text-text-muted">{i18n.t("legal.privacy.share.consent.p")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.security.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.security.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.privacy.security.p2")}</p>
<p class="text-text-muted">{i18n.t("legal.privacy.security.p3")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.rights.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.rights.lead")}</p>
<ul class="list-disc space-y-2 pl-6 text-text-muted">
<li>{i18n.t("legal.privacy.rights.li1")}</li>
<li>{i18n.t("legal.privacy.rights.li2")}</li>
<li>{i18n.t("legal.privacy.rights.li3")}</li>
<li>{i18n.t("legal.privacy.rights.li4")}</li>
<li>{i18n.t("legal.privacy.rights.li5")}</li>
</ul>
<p class="text-text-muted">{i18n.t("legal.privacy.rights.footer")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.retention.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.retention.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.privacy.retention.p2")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.intl.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.intl.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.privacy.intl.p2")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.children.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.children.p")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.changes.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.changes.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.privacy.changes.p2")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.privacy.contact.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.privacy.contact.lead")}</p>
<p class="text-text-muted">
<strong class="text-text">{i18n.t("legal.emailLabel")}</strong>
<a href="mailto:privacy@descrybe.io" class="text-link hover:underline"
>privacy@descrybe.io</a
>
</p>
<p class="text-text-muted">
<strong class="text-text">{i18n.t("legal.postalLabel")}</strong><br />
Descrybe<br />
Dunajska cesta 106<br />
Ljubljana<br />
Slovenia, SI-1000
</p>
</section>
</div>
<div class="mt-10 border-t border-border pt-8">
<a href="/" class="text-link hover:underline">{i18n.t("legal.backHome")}</a>
</div>
</main>
<Footer />
</div>
+698
View File
@@ -0,0 +1,698 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { Layers, Play, RefreshCw, RotateCcw, X } from "@lucide/svelte";
import { api, ApiError, failureMessage } from "$lib/api";
import { unwrapList } from "$lib/list";
import { formatDateTime } from "$lib/utils";
import type { ListResponse, ProcessingJob, ProcessingStepProgress } from "$lib/types";
import Alert from "$lib/components/Alert.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import ListSkeleton from "$lib/components/ListSkeleton.svelte";
import PageShell from "$lib/components/PageShell.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Progress,
Skeleton,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
type BadgeVariant
} from "$lib/components/ui";
import {
formatJobErrorText,
formatJobStatusLabel,
formatJobStepLabel,
formatProcessingTypeLabel
} from "$lib/job-status";
type JobGroup = {
key: string;
jobs: ProcessingJob[];
jobCount: number;
totalProductsQueued: number;
isBatch: boolean;
};
let jobs = $state<ProcessingJob[]>([]);
let error = $state("");
let success = $state("");
let loading = $state(false);
let initialLoad = $state(true);
let busyID = $state<string | number | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
let lastFetch = 0;
let jobsAbort: AbortController | null = null;
let jobsFetchGen = 0;
const jobGroups = $derived(groupJobs(jobs));
function isAbortError(err: unknown): boolean {
return (
(err instanceof DOMException && err.name === "AbortError") ||
(err instanceof Error && err.name === "AbortError")
);
}
function actionError(err: unknown, fallback: string): string {
if (err instanceof ApiError) {
const detail = err.message?.trim();
if (detail) {
if (err.status === 400 || err.status === 409) return detail;
if (err.status === 429) return detail || i18n.t("processing.tooManyRequests");
if (err.status >= 500) return i18n.t("processing.serverError", { fallback, status: err.status });
return detail;
}
return i18n.t("processing.httpError", { fallback, status: err.status });
}
return failureMessage(err, fallback);
}
async function loadJobs(force = false) {
const now = Date.now();
if (!force && now - lastFetch < 1000) return;
lastFetch = now;
jobsAbort?.abort();
const ac = new AbortController();
jobsAbort = ac;
const gen = ++jobsFetchGen;
loading = true;
if (!force) error = "";
try {
const payload = await api<ListResponse<ProcessingJob>>("/api/processing/jobs?limit=200", {
signal: ac.signal
});
if (gen !== jobsFetchGen) return;
jobs = unwrapList(payload);
} catch (err) {
if (isAbortError(err) || gen !== jobsFetchGen) return;
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = actionError(err, i18n.t("processing.loadFailed"));
} finally {
if (gen === jobsFetchGen) {
loading = false;
initialLoad = false;
}
}
}
function onVisibilityChange() {
if (document.visibilityState === "visible") {
void loadJobs(true);
startPolling();
} else {
stopPolling();
}
}
function startPolling() {
if (timer) return;
timer = setInterval(() => {
void loadJobs();
}, 5000);
}
function stopPolling() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
onMount(() => {
void loadJobs(true);
if (document.visibilityState === "visible") startPolling();
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
stopPolling();
jobsAbort?.abort();
};
});
async function cancelJob(job: ProcessingJob) {
busyID = job.id;
error = "";
success = "";
try {
await api(`/api/processing/jobs/${job.id}/cancel`, { method: "POST" });
success = i18n.t("flash.processing.cancelled", { name: taskName(job) });
await loadJobs(true);
} catch (err) {
error = actionError(err, i18n.t("processing.cancelFailed"));
} finally {
busyID = null;
}
}
async function retryJob(job: ProcessingJob) {
busyID = job.id;
error = "";
success = "";
try {
await api(`/api/processing/jobs/${job.id}/retry`, { method: "POST" });
success = i18n.t("flash.processing.retried", { name: taskName(job) });
await loadJobs(true);
} catch (err) {
error = actionError(err, i18n.t("processing.retryFailed"));
} finally {
busyID = null;
}
}
function taskName(job: ProcessingJob): string {
const type = job.processing_type ?? job.type;
if (type) return formatProcessingTypeLabel(type);
return i18n.t("dashboard.jobFallback", { id: shortId(job.id) });
}
function shortId(id: string | number): string {
const s = String(id);
return s.length > 8 ? `${s.slice(0, 8)}…` : s;
}
function progressPct(job: ProcessingJob): number {
const total = typeof job.total_products === "number" ? job.total_products : 0;
const done = typeof job.processed_products === "number" ? job.processed_products : 0;
if (!total) return 0;
return Math.min(100, Math.round((done / total) * 100));
}
function groupProgressPct(group: JobGroup): number {
const total = group.jobs.reduce(
(sum, j) => sum + (typeof j.total_products === "number" ? j.total_products : 0),
0
);
const done = group.jobs.reduce(
(sum, j) => sum + (typeof j.processed_products === "number" ? j.processed_products : 0),
0
);
if (!total) return 0;
return Math.min(100, Math.round((done / total) * 100));
}
function canCancel(status: string | null | undefined): boolean {
const s = (status ?? "").toLowerCase();
return s === "pending" || s === "running";
}
function canRetry(status: string | null | undefined): boolean {
const s = (status ?? "").toLowerCase();
// Completed jobs already finished successfully — retry only after failure/cancel.
return s === "failed" || s === "cancelled" || s === "canceled";
}
function statusVariant(status: string | null | undefined): BadgeVariant {
switch ((status ?? "").toLowerCase()) {
case "completed":
case "success":
case "done":
return "success";
case "failed":
case "error":
return "destructive";
case "cancelled":
case "canceled":
case "skipped":
return "secondary";
case "running":
case "processing":
return "warning";
case "pending":
return "outline";
default:
return "default";
}
}
function statusLabel(status: string | null | undefined): string {
return formatJobStatusLabel(status);
}
function stepLabel(step: string): string {
return formatJobStepLabel(step);
}
function jobErrorText(job: ProcessingJob): string {
return formatJobErrorText(typeof job.error === "string" ? job.error : "");
}
function siblingIdsOf(job: ProcessingJob): string[] {
const raw = job.sibling_job_ids;
if (!Array.isArray(raw)) return [];
return raw.map((id) => String(id)).filter(Boolean);
}
function createdBucket(job: ProcessingJob): string {
const raw = job.created_at ? String(job.created_at) : "";
if (!raw) return "";
// Group auto-split siblings that share the same create second.
return raw.length >= 19 ? raw.slice(0, 19) : raw;
}
function groupJobs(list: ProcessingJob[]): JobGroup[] {
const assigned = new Set<string>();
const groups: JobGroup[] = [];
const claimMembers = (seed: ProcessingJob): ProcessingJob[] => {
const memberIds = new Set<string>([String(seed.id), ...siblingIdsOf(seed)]);
let grew = true;
while (grew) {
grew = false;
for (const other of list) {
const oid = String(other.id);
const otherSibs = siblingIdsOf(other);
const overlaps =
memberIds.has(oid) ||
otherSibs.some((s) => memberIds.has(s)) ||
(memberIds.has(String(seed.id)) && otherSibs.includes(String(seed.id)));
if (!overlaps) continue;
if (!memberIds.has(oid)) {
memberIds.add(oid);
grew = true;
}
for (const s of otherSibs) {
if (!memberIds.has(s)) {
memberIds.add(s);
grew = true;
}
}
}
}
return list.filter((j) => memberIds.has(String(j.id)));
};
for (const job of list) {
const id = String(job.id);
if (assigned.has(id)) continue;
const declaredCount = typeof job.job_count === "number" ? job.job_count : 0;
const hasSiblingMeta = siblingIdsOf(job).length > 0 || declaredCount > 1;
let members: ProcessingJob[] = [];
if (hasSiblingMeta) {
members = claimMembers(job);
} else {
const type = String(job.processing_type ?? job.type ?? "");
const bucket = createdBucket(job);
if (type && bucket) {
const cluster = list.filter(
(j) =>
!assigned.has(String(j.id)) &&
String(j.processing_type ?? j.type ?? "") === type &&
createdBucket(j) === bucket
);
if (cluster.length > 1) members = cluster;
}
}
if (members.length > 1) {
for (const m of members) assigned.add(String(m.id));
const queued =
typeof job.total_products_queued === "number"
? job.total_products_queued
: members.reduce(
(sum, j) => sum + (typeof j.total_products === "number" ? j.total_products : 0),
0
);
groups.push({
key: `batch-${id}`,
jobs: members,
jobCount: declaredCount > 1 ? declaredCount : members.length,
totalProductsQueued: queued,
isBatch: true
});
continue;
}
assigned.add(id);
groups.push({
key: id,
jobs: [job],
jobCount: 1,
totalProductsQueued: typeof job.total_products === "number" ? job.total_products : 0,
isBatch: false
});
}
return groups;
}
function stepsFor(job: ProcessingJob): ProcessingStepProgress[] {
let list: ProcessingStepProgress[] = [];
if (Array.isArray(job.step_progress) && job.step_progress.length > 0) {
list = job.step_progress;
} else if (job.current_step) {
list = [{ step: String(job.current_step), status: "running" }];
}
const jobStatus = (job.status ?? "").toLowerCase();
if (jobStatus !== "cancelled" && jobStatus !== "canceled") return list;
return list.map((st) => {
const s = (st.status ?? "").toLowerCase();
if (s === "pending" || s === "running" || s === "processing") {
return { ...st, status: "cancelled" };
}
return st;
});
}
function groupStatusSummary(group: JobGroup): string {
const counts = new Map<string, number>();
for (const j of group.jobs) {
const label = statusLabel(j.status);
counts.set(label, (counts.get(label) ?? 0) + 1);
}
return [...counts.entries()].map(([label, n]) => `${n} ${label.toLowerCase()}`).join(" · ");
}
</script>
<PageShell
title={i18n.t("processing.title")}
description={i18n.t("processing.description")}
tour="processing-page"
>
{#snippet actions()}
<Button size="sm" variant="outline" disabled={loading} onclick={() => loadJobs(true)}>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
{i18n.t("processing.refresh")}
</Button>
{/snippet}
<Alert message={error} />
<Alert tone="success" message={success} />
{#if initialLoad}
<Card aria-busy="true">
<CardHeader>
<Skeleton class="h-6 w-24" />
<Skeleton class="mt-2 h-4 w-72 max-w-full" />
</CardHeader>
<CardContent>
<ListSkeleton rows={6} class="p-0" />
</CardContent>
</Card>
{:else}
<Card>
<CardHeader>
<CardTitle>{i18n.t("processing.tasks")}</CardTitle>
<CardDescription>
{i18n.t("processing.pipelineHint")}
</CardDescription>
</CardHeader>
<CardContent>
{#if !loading && jobs.length === 0}
<EmptyState
title={i18n.t("empty.processing.noneTitle")}
message={i18n.t("empty.processing.noneMessage")}
>
<a href="/products?type=raw&status=unprocessed">
<Button>
<Play class="mr-2 h-4 w-4" />
{i18n.t("processing.startJob")}
</Button>
</a>
<a href="/feeds">
<Button variant="outline">{i18n.t("processing.connectFeed")}</Button>
</a>
</EmptyState>
{/if}
{#if jobs.length > 0}
<div class="space-y-4">
{#each jobGroups as group (group.key)}
{#if group.isBatch}
<div class="rounded-md border border-primary/20 bg-primary/5 px-3 py-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2 text-sm font-medium">
<Layers class="h-4 w-4 text-primary" />
{i18n.t("processing.splitBatch", { jobs: group.jobCount, products: group.totalProductsQueued })}
</div>
<div class="flex items-center gap-3 text-xs text-muted-foreground">
<span>{groupStatusSummary(group)}</span>
<span class="font-medium text-foreground">{groupProgressPct(group)}%</span>
</div>
</div>
<div class="mt-2 max-w-md">
<Progress value={groupProgressPct(group)} />
</div>
</div>
{/if}
<!-- Mobile: stacked job cards (avoids cramped 7-col table) -->
<div class="space-y-3 md:hidden">
{#each group.jobs as job (job.id)}
{@const busy = busyID === job.id}
{@const steps = stepsFor(job)}
{@const pct = progressPct(job)}
{@const errText = jobErrorText(job)}
{@const cancellable = canCancel(job.status)}
{@const retryable = canRetry(job.status)}
{@const completed =
(job.status ?? "").toLowerCase() === "completed" ||
(job.status ?? "").toLowerCase() === "success" ||
(job.status ?? "").toLowerCase() === "done"}
<div class="rounded-md border border-border bg-card p-3 shadow-sm">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-medium text-foreground">{taskName(job)}</div>
<div class="mt-0.5 font-mono text-[11px] text-muted-foreground">
{shortId(job.id)}
{#if group.isBatch}
<span class="ml-1">{i18n.t("processing.partOfBatch")}</span>
{/if}
</div>
</div>
<Badge variant={statusVariant(job.status)}>{statusLabel(job.status)}</Badge>
</div>
{#if job.current_step && cancellable}
<p class="mt-2 text-xs text-muted-foreground">
{i18n.t("processing.nowStep", { step: stepLabel(String(job.current_step)) })}
</p>
{/if}
<div class="mt-3 space-y-1">
<div class="flex items-center justify-between gap-2 text-xs">
<span class="font-medium tabular-nums text-foreground">{pct}%</span>
<span class="text-muted-foreground">
{job.processed_products || 0}/{job.total_products || 0}
</span>
</div>
<Progress value={pct} />
</div>
{#if steps.length > 0}
<div class="mt-3 flex flex-wrap gap-1">
{#each steps as st}
<Badge variant={statusVariant(st.status)} class="text-[10px]">
{stepLabel(st.step)}
{#if st.status === "skipped"}
{i18n.t("processing.skip")}
{/if}
</Badge>
{/each}
</div>
{/if}
{#if errText}
<p class="mt-2 text-xs text-destructive" title={errText}>{errText}</p>
{/if}
<div class="mt-3 grid grid-cols-2 gap-2 text-[11px] text-muted-foreground">
<div>
<span class="block font-medium text-foreground/70">{i18n.t("processing.col.started")}</span>
{job.started_at ? formatDateTime(String(job.started_at)) : i18n.t("status.emDash")}
</div>
<div>
<span class="block font-medium text-foreground/70">{i18n.t("processing.col.completed")}</span>
{job.completed_at ? formatDateTime(String(job.completed_at)) : i18n.t("status.emDash")}
</div>
</div>
{#if completed || cancellable || retryable}
<div class="mt-3 flex flex-wrap gap-2">
{#if completed}
<a href="/products?status=needs_review">
<Button size="sm" variant="outline" title={i18n.t("processing.reviewTitle")}>
{i18n.t("processing.reviewResults")}
</Button>
</a>
{/if}
{#if cancellable}
<Button
size="sm"
variant="destructive"
disabled={busy}
title={i18n.t("processing.cancelTitle")}
onclick={() => cancelJob(job)}
>
<X class="mr-1 h-4 w-4" />
{busy ? i18n.t("processing.cancelling") : i18n.t("processing.cancel")}
</Button>
{/if}
{#if retryable}
<Button
size="sm"
variant="outline"
disabled={busy}
onclick={() => retryJob(job)}
>
<RotateCcw class="mr-1 h-4 w-4" />
{busy ? i18n.t("processing.retrying") : i18n.t("processing.retry")}
</Button>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<!-- Desktop / tablet: full table -->
<div class="hidden rounded-md border md:block">
<TableShell class="rounded-md border-0 shadow-none">
<TableHeader>
<TableRow>
<TableHead>{i18n.t("processing.col.task")}</TableHead>
<TableHead>{i18n.t("processing.col.status")}</TableHead>
<TableHead>{i18n.t("processing.col.steps")}</TableHead>
<TableHead>{i18n.t("processing.col.progress")}</TableHead>
<TableHead>{i18n.t("processing.col.started")}</TableHead>
<TableHead>{i18n.t("processing.col.completed")}</TableHead>
<TableHead stickyRight>{i18n.t("processing.col.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each group.jobs as job (job.id)}
{@const busy = busyID === job.id}
{@const steps = stepsFor(job)}
{@const pct = progressPct(job)}
{@const errText = jobErrorText(job)}
{@const cancellable = canCancel(job.status)}
{@const retryable = canRetry(job.status)}
<TableRow>
<TableCell class="font-medium">
<div>{taskName(job)}</div>
<div class="mt-0.5 font-mono text-[11px] text-muted-foreground">
{shortId(job.id)}
{#if group.isBatch}
<span class="ml-1">{i18n.t("processing.partOfBatch")}</span>
{/if}
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariant(job.status)}>
{statusLabel(job.status)}
</Badge>
{#if job.current_step && canCancel(job.status)}
<div class="mt-1 text-xs text-muted-foreground">
{i18n.t("processing.nowStep", { step: stepLabel(String(job.current_step)) })}
</div>
{/if}
{#if errText}
<div
class="mt-1 max-w-[220px] text-xs text-destructive"
title={errText}
>
{errText}
</div>
{/if}
</TableCell>
<TableCell>
{#if steps.length === 0}
<span class="text-sm text-muted-foreground">{i18n.t("status.emDash")}</span>
{:else}
<div class="flex max-w-[280px] flex-wrap gap-1">
{#each steps as st}
<Badge variant={statusVariant(st.status)} class="text-[10px]">
{stepLabel(st.step)}
{#if st.status === "skipped"}
{i18n.t("processing.skip")}
{/if}
</Badge>
{/each}
</div>
{#if steps.some((s) => s.note)}
<div class="mt-1 text-[10px] text-muted-foreground">
{steps.find((s) => s.note)?.note}
</div>
{/if}
{/if}
</TableCell>
<TableCell>
<div class="w-full max-w-[200px] space-y-1">
<div class="flex items-center justify-between gap-2 text-xs">
<span class="font-medium tabular-nums text-foreground">{pct}%</span>
<span class="text-muted-foreground">
{job.processed_products || 0}/{job.total_products || 0}
</span>
</div>
<Progress value={pct} />
</div>
</TableCell>
<TableCell class="whitespace-nowrap">
{job.started_at ? formatDateTime(String(job.started_at)) : i18n.t("status.emDash")}
</TableCell>
<TableCell class="whitespace-nowrap">
{job.completed_at ? formatDateTime(String(job.completed_at)) : i18n.t("status.emDash")}
</TableCell>
<TableCell stickyRight>
<div class="flex flex-wrap items-center gap-2">
{#if (job.status ?? "").toLowerCase() === "completed" || (job.status ?? "").toLowerCase() === "success" || (job.status ?? "").toLowerCase() === "done"}
<a href="/products?status=needs_review">
<Button size="sm" variant="outline" title={i18n.t("processing.reviewTitle")}>
{i18n.t("processing.reviewResults")}
</Button>
</a>
{/if}
{#if cancellable}
<Button
size="sm"
variant="destructive"
disabled={busy}
title={i18n.t("processing.cancelTitle")}
onclick={() => cancelJob(job)}
>
<X class="mr-1 h-4 w-4" />
{busy ? i18n.t("processing.cancelling") : i18n.t("processing.cancel")}
</Button>
{/if}
{#if retryable}
<Button
size="sm"
variant="outline"
disabled={busy}
onclick={() => retryJob(job)}
>
<RotateCcw class="mr-1 h-4 w-4" />
{busy ? i18n.t("processing.retrying") : i18n.t("processing.retry")}
</Button>
{/if}
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
</div>
{/each}
</div>
{/if}
</CardContent>
</Card>
{/if}
</PageShell>
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { api } from "$lib/api";
import { trackEvent } from "$lib/analytics";
import { apiFormError, fieldDescribedBy, fieldInvalid } from "$lib/api-form-error";
import { i18n } from "$lib/i18n";
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label
} from "$lib/components/ui";
const REGISTER_FIELD_HINTS = {
// Prefer stable codes (locale-safe). English needles are last-resort only.
company_name: ["register_fields_required", "company name", "required"],
email: ["user_already_exists", "register_fields_required", "email", "user already exists", "required"],
password: ["password_too_short", "register_fields_required", "password", "required"]
} as const;
let company_name = $state("");
let name = $state("");
let email = $state("");
let password = $state("");
let error = $state("");
let fieldErrors = $state<Record<string, string>>({});
let loading = $state(false);
const errorId = "register-form-error";
async function onSubmit(event: Event) {
event.preventDefault();
error = "";
fieldErrors = {};
loading = true;
try {
await api("/api/auth/register", {
method: "POST",
body: { company_name, name, email, password }
});
trackEvent("sign_up", { method: "email" });
await goto("/dashboard");
} catch (err) {
const result = apiFormError(err, i18n.t("auth.register.failed"), REGISTER_FIELD_HINTS);
error = result.message;
fieldErrors = result.fields;
} finally {
loading = false;
}
}
</script>
<Card>
<CardHeader>
<CardTitle level={1}>{i18n.t("auth.register.title")}</CardTitle>
<CardDescription>{i18n.t("auth.register.description")}</CardDescription>
</CardHeader>
<CardContent>
<form
method="post"
class="space-y-4"
onsubmit={onSubmit}
aria-busy={loading}
aria-describedby={error ? errorId : undefined}
>
{#if error}
<Alert variant="destructive">
<AlertDescription id={errorId}>{error}</AlertDescription>
</Alert>
{/if}
<div class="space-y-2">
<Label for="company_name">{i18n.t("auth.register.companyName")}</Label>
<Input
id="company_name"
type="text"
name="company_name"
required
aria-invalid={fieldInvalid(fieldErrors, "company_name") ?? (error && !Object.keys(fieldErrors).length ? "true" : undefined)}
aria-describedby={fieldDescribedBy(fieldErrors, "company_name", errorId) ?? (error && !Object.keys(fieldErrors).length ? errorId : undefined)}
bind:value={company_name}
/>
</div>
<div class="space-y-2">
<Label for="name">{i18n.t("common.name")}</Label>
<Input
id="name"
type="text"
name="name"
autocomplete="name"
aria-invalid={fieldInvalid(fieldErrors, "name")}
aria-describedby={fieldDescribedBy(fieldErrors, "name", errorId)}
bind:value={name}
/>
</div>
<div class="space-y-2">
<Label for="email">{i18n.t("common.email")}</Label>
<Input
id="email"
type="email"
name="email"
autocomplete="email"
required
aria-invalid={fieldInvalid(fieldErrors, "email") ?? (error && !Object.keys(fieldErrors).length ? "true" : undefined)}
aria-describedby={fieldDescribedBy(fieldErrors, "email", errorId) ?? (error && !Object.keys(fieldErrors).length ? errorId : undefined)}
bind:value={email}
/>
</div>
<div class="space-y-2">
<Label for="password">{i18n.t("common.password")}</Label>
<Input
id="password"
type="password"
name="password"
autocomplete="new-password"
required
minlength={8}
aria-invalid={fieldInvalid(fieldErrors, "password") ?? (error && !Object.keys(fieldErrors).length ? "true" : undefined)}
aria-describedby={fieldDescribedBy(fieldErrors, "password", errorId) ?? (error && !Object.keys(fieldErrors).length ? errorId : undefined)}
bind:value={password}
/>
</div>
<Button type="submit" class="w-full shadow-sm" loading={loading}>
{loading ? i18n.t("auth.register.submitting") : i18n.t("auth.register.submit")}
</Button>
</form>
<p class="mt-5 text-center text-sm text-text-muted">
{i18n.t("auth.register.haveAccount")}
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("auth.register.signIn")}</a>
</p>
<p class="mt-1.5 text-center text-sm text-text-muted">
<a href="/pricing" class="font-medium text-link hover:underline">{i18n.t("common.viewPricing")}</a>
</p>
</CardContent>
</Card>
@@ -0,0 +1,193 @@
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/state";
import { api, ApiError } from "$lib/api";
import { apiFormError, fieldDescribedBy, fieldInvalid } from "$lib/api-form-error";
import { i18n } from "$lib/i18n";
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label
} from "$lib/components/ui";
function tokenFromHash(hash: string): string {
const raw = hash.startsWith("#") ? hash.slice(1) : hash;
const params = new URLSearchParams(raw);
return (params.get("token") ?? "").trim();
}
let token = $state("");
/** True when the token arrived via link; never show it in a visible input. */
let tokenFromLink = $state(false);
/** Fragment tokens are client-only; wait for mount before showing missing-token UI. */
let hydrated = $state(false);
let password = $state("");
let passwordConfirm = $state("");
let error = $state("");
let fieldErrors = $state<Record<string, string>>({});
let loading = $state(false);
let done = $state(false);
const errorId = "reset-password-form-error";
const missingErrorId = "reset-password-missing-error";
const passwordHintId = "reset-password-hint";
onMount(() => {
const fromHash = tokenFromHash(window.location.hash);
const fromQuery = (new URL(window.location.href).searchParams.get("token") ?? "").trim();
const fromSsrQuery = (page.url.searchParams.get("token") ?? "").trim();
const resolved = fromHash || fromQuery || fromSsrQuery;
if (resolved) {
token = resolved;
tokenFromLink = true;
}
hydrated = true;
if (!fromHash && !fromQuery && !fromSsrQuery) return;
const cleaned = new URL(window.location.href);
cleaned.searchParams.delete("token");
cleaned.hash = "";
history.replaceState(history.state, "", cleaned.pathname + cleaned.search);
});
async function onSubmit(event: Event) {
event.preventDefault();
error = "";
fieldErrors = {};
if (!token.trim()) {
error = i18n.t("auth.reset.tokenMissing");
return;
}
if (password !== passwordConfirm) {
error = i18n.t("auth.reset.passwordMismatch");
fieldErrors = { password: i18n.t("auth.reset.passwordMismatch") };
return;
}
loading = true;
try {
await api("/api/auth/reset-password", {
method: "POST",
body: { token: token.trim(), password }
});
done = true;
} catch (err) {
const result = apiFormError(err, i18n.t("auth.reset.failed"), {
password: ["password_too_short", "password", "required"],
token: ["invalid or expired", "token"]
});
error = result.message;
fieldErrors = result.fields;
if (err instanceof ApiError && /invalid or expired/i.test(err.message)) {
error = i18n.t("auth.reset.tokenInvalid");
}
} finally {
loading = false;
}
}
</script>
<Card>
{#if !hydrated}
<CardHeader>
<CardTitle level={1}>{i18n.t("auth.reset.title")}</CardTitle>
<CardDescription>{i18n.t("auth.reset.description")}</CardDescription>
</CardHeader>
<CardContent>
<p class="text-sm text-text-muted" aria-live="polite">{i18n.t("common.loading")}</p>
</CardContent>
{:else if !tokenFromLink}
<CardHeader>
<CardTitle level={1}>{i18n.t("auth.reset.missingTitle")}</CardTitle>
<CardDescription>{i18n.t("auth.reset.missingDescription")}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Alert variant="destructive">
<AlertDescription id={missingErrorId}>{i18n.t("auth.reset.tokenMissing")}</AlertDescription>
</Alert>
<p class="text-center text-sm text-text-muted">
<a href="/forgot-password" class="font-medium text-link hover:underline"
>{i18n.t("auth.reset.requestNewLink")}</a
>
</p>
<p class="text-center text-sm text-text-muted">
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("auth.reset.goToSignIn")}</a>
</p>
</CardContent>
{:else}
<CardHeader>
<CardTitle level={1}>
{done ? i18n.t("auth.reset.doneTitle") : i18n.t("auth.reset.title")}
</CardTitle>
<CardDescription>
{done ? i18n.t("auth.reset.doneDescription") : i18n.t("auth.reset.description")}
</CardDescription>
</CardHeader>
<CardContent>
{#if done}
<p class="text-center text-sm text-text-muted">
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("auth.reset.goToSignIn")}</a>
</p>
{:else}
<form
method="post"
class="space-y-4"
onsubmit={onSubmit}
aria-busy={loading}
aria-describedby={error ? errorId : undefined}
>
{#if error}
<Alert variant="destructive">
<AlertDescription id={errorId}>{error}</AlertDescription>
</Alert>
{/if}
<p class="rounded-md border bg-muted/40 px-3 py-2 text-xs text-text-muted" role="note">
{i18n.t("auth.reset.linkRecognized")}
</p>
<div class="space-y-2">
<Label for="password">{i18n.t("common.password")}</Label>
<Input
id="password"
type="password"
name="password"
autocomplete="new-password"
required
minlength={8}
aria-invalid={fieldInvalid(fieldErrors, "password")}
aria-describedby={fieldDescribedBy(fieldErrors, "password", errorId) ?? passwordHintId}
bind:value={password}
/>
<p id={passwordHintId} class="text-xs text-text-muted">{i18n.t("auth.reset.passwordHint")}</p>
</div>
<div class="space-y-2">
<Label for="password_confirm">{i18n.t("auth.reset.passwordConfirm")}</Label>
<Input
id="password_confirm"
type="password"
name="password_confirm"
autocomplete="new-password"
required
minlength={8}
bind:value={passwordConfirm}
/>
</div>
<Button type="submit" class="w-full shadow-sm" loading={loading}>
{loading ? i18n.t("auth.reset.submitting") : i18n.t("auth.reset.submit")}
</Button>
</form>
<p class="mt-5 text-center text-sm text-text-muted">
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("auth.reset.goToSignIn")}</a>
</p>
{/if}
</CardContent>
{/if}
</Card>
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import Spinner from "$lib/components/Spinner.svelte";
import { i18n } from "$lib/i18n";
/** Client fallback when universal load redirect is skipped (client nav edge cases). */
onMount(() => {
void goto("/woocommerce?tab=reviews", { replaceState: true });
});
</script>
<div class="flex justify-center py-16" data-tour="reviews-redirect">
<Spinner label={i18n.t("reviews.redirectLoading")} />
</div>
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
/** Marketing nav alias → WooCommerce Reviews tab. */
export const load: PageLoad = () => {
redirect(307, "/woocommerce?tab=reviews");
};
+384
View File
@@ -0,0 +1,384 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { RefreshCw, Sparkles, Wand2, ExternalLink } from "@lucide/svelte";
import { api, ApiError, failureMessage } from "$lib/api";
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 UpgradeBanner from "$lib/components/UpgradeBanner.svelte";
import { authSession } from "$lib/auth-session.svelte";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { upgradeCtaForRole, withUpgradeHint } from "$lib/billing-display";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
type BadgeVariant
} from "$lib/components/ui";
type ChecklistItem = {
type: string;
label: string;
severity: string;
count: number;
affected: number;
score: number;
fixable: boolean;
description: string;
};
type Recommendation = {
id: string;
type: string;
severity: string;
entity_type: string;
entity_id: string;
entity_label: string;
title: string;
message: string;
fixable: boolean;
fix_modes?: string[];
};
type SeoReport = {
company_id: string;
product_count: number;
category_count: number;
overall_score: number;
can_use_ai: boolean;
checklist: ChecklistItem[];
recommendations: Recommendation[];
types: string[];
};
let report = $state<SeoReport | null>(null);
let error = $state("");
let success = $state("");
let loading = $state(true);
let applyingID = $state<string | null>(null);
let filterType = $state<string>("all");
let showUpgrade = $state(false);
const upgradeCta = $derived(upgradeCtaForRole(authSession.isCompanyAdmin));
const canSeoAi = $derived(
planCapabilities.can("marketing.seo.ai_rewrite") &&
planCapabilities.can("capability.seo_ai_rewrite") &&
Boolean(report?.can_use_ai)
);
async function loadReport() {
loading = true;
error = "";
try {
report = await api<SeoReport>("/api/seo/recommendations");
} 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)) {
report = {
company_id: "",
product_count: 0,
category_count: 0,
overall_score: 100,
can_use_ai: false,
checklist: [],
recommendations: [],
types: []
};
error = "";
} else {
error = failureMessage(err, i18n.t("seo.flash.loadFailed"));
}
} finally {
loading = false;
}
}
onMount(() => {
void loadReport();
});
const filteredRecs = $derived.by(() => {
if (!report) return [];
if (filterType === "all") return report.recommendations;
return report.recommendations.filter((r) => r.type === filterType);
});
function severityVariant(severity: string): BadgeVariant {
switch (severity) {
case "critical":
return "destructive";
case "warn":
return "warning";
default:
return "secondary";
}
}
function scoreTone(score: number): string {
if (score >= 85) return "text-chart-emerald";
if (score >= 60) return "text-chart-amber";
return "text-destructive";
}
async function applyFix(rec: Recommendation, mode: "template" | "ai") {
if (rec.entity_type !== "product") return;
if (mode === "ai" && !canSeoAi) {
showUpgrade = true;
return;
}
applyingID = `${rec.id}:${mode}`;
error = "";
success = "";
showUpgrade = false;
try {
const result = await api<{
meta_title: string;
meta_description: string;
mode: string;
}>("/api/seo/apply", {
method: "POST",
body: { product_id: rec.entity_id, mode }
});
success = i18n.t("flash.seo.filledMeta", { label: rec.entity_label, mode: result.mode });
await loadReport();
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
showUpgrade = true;
error = err.message || i18n.t("seo.flash.aiPaid");
} else {
error = failureMessage(err, i18n.t("seo.flash.applyFailed"));
}
} finally {
applyingID = null;
}
}
function categoryHref(rec: Recommendation): string | null {
if (rec.entity_type !== "category") return null;
if (rec.type === "category_missing_meta_formula") {
return `/categories/${rec.entity_id}/description-formula`;
}
return `/categories`;
}
</script>
<PageShell
title={i18n.t("seo.title")}
description={i18n.t("seo.description")}
>
{#snippet actions()}
<Button variant="outline" size="sm" disabled={loading} onclick={() => loadReport()}>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
{i18n.t("common.refresh")}
</Button>
{/snippet}
{#if error}
<Alert message={error} />
{/if}
{#if success}
<Alert tone="success" message={success} />
{/if}
{#if showUpgrade}
<UpgradeBanner
tone="warning"
title={i18n.t("seo.upgradeTitle")}
message={withUpgradeHint(
i18n.t("seo.upgradeMessage"),
upgradeCta
)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={authSession.isCompanyAdmin ? i18n.t("seo.comparePlans") : upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{/if}
{#if loading && !report}
<div class="flex justify-center py-16">
<Spinner />
</div>
{:else if report}
{#if report.product_count === 0 && report.category_count === 0}
<EmptyState
title={i18n.t("empty.seo.noneTitle")}
message={i18n.t("empty.seo.noneMessage")}
>
<a href="/products"><Button variant="outline">{i18n.t("seo.openProducts")}</Button></a>
</EmptyState>
{/if}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("seo.overallScore")}</CardDescription>
<CardTitle class="text-3xl {scoreTone(Number(report.overall_score ?? 0))}">
{Number(report.overall_score ?? 0).toFixed(0)}
</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">{i18n.t("seo.overallScoreHint")}</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("seo.productsScanned")}</CardDescription>
<CardTitle class="text-3xl">{report.product_count}</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">{i18n.t("seo.productsScannedHint")}</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("seo.categoriesScanned")}</CardDescription>
<CardTitle class="text-3xl">{report.category_count}</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">{i18n.t("seo.categoriesScannedHint")}</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("seo.aiRewrite")}</CardDescription>
<CardTitle class="text-xl">
{#if canSeoAi}
<Badge variant="success">{i18n.t("seo.available")}</Badge>
{:else}
<Badge variant="secondary">{i18n.t("seo.gated")}</Badge>
{/if}
</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
{i18n.t("seo.templateFree")}
</CardContent>
</Card>
</div>
<section class="space-y-3" data-tour="seo-checklist">
<h2 class="text-lg font-semibold">{i18n.t("seo.checklist")}</h2>
<div class="grid gap-3 md:grid-cols-2">
{#each report.checklist as item}
<button
type="button"
class="rounded-lg border bg-card p-4 text-left transition hover:border-primary/40 {filterType ===
item.type
? 'border-primary ring-1 ring-primary/30'
: ''}"
onclick={() => (filterType = filterType === item.type ? "all" : item.type)}
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-medium">{item.label}</p>
<p class="mt-1 text-sm text-muted-foreground">{item.description}</p>
</div>
<span class="text-xl font-semibold {scoreTone(Number(item.score ?? 0))}">{Number(item.score ?? 0).toFixed(0)}</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
<span class="text-xs text-muted-foreground"
>{i18n.t("seo.affectedFindings", { affected: item.affected, count: item.count })}</span
>
{#if item.fixable}
<Badge variant="outline">{i18n.t("seo.fixable")}</Badge>
{/if}
</div>
</button>
{/each}
</div>
{#if filterType !== "all"}
<Button variant="ghost" size="sm" onclick={() => (filterType = "all")}>{i18n.t("seo.clearFilter")}</Button>
{/if}
</section>
<section class="space-y-3">
<h2 class="text-lg font-semibold">{i18n.t("seo.findings")}</h2>
{#if filteredRecs.length === 0}
<p class="text-sm text-muted-foreground">{i18n.t("seo.noFindings")}</p>
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("seo.col.issue")}</TableHead>
<TableHead>{i18n.t("seo.col.entity")}</TableHead>
<TableHead>{i18n.t("seo.col.severity")}</TableHead>
<TableHead class="text-right">{i18n.t("seo.col.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each filteredRecs as rec}
<TableRow>
<TableCell>
<div class="space-y-1">
<p class="font-medium">{rec.title}</p>
<p class="text-sm text-muted-foreground">{rec.message}</p>
<p class="text-xs text-muted-foreground font-mono">{rec.type}</p>
</div>
</TableCell>
<TableCell>
<div class="space-y-1">
<p class="text-sm font-medium">{rec.entity_label}</p>
<p class="text-xs text-muted-foreground capitalize">{rec.entity_type}</p>
</div>
</TableCell>
<TableCell>
<Badge variant={severityVariant(rec.severity)}>{rec.severity}</Badge>
</TableCell>
<TableCell class="text-right">
<div class="flex flex-wrap justify-end gap-2">
{#if rec.fixable && rec.fix_modes?.includes("template")}
<Button
size="sm"
variant="outline"
disabled={applyingID !== null}
onclick={() => applyFix(rec, "template")}
>
<Wand2 class="mr-2 h-3.5 w-3.5" />
{applyingID === `${rec.id}:template` ? i18n.t("seo.filling") : i18n.t("seo.templateFill")}
</Button>
{/if}
{#if rec.fixable && rec.fix_modes?.includes("ai")}
<Button
size="sm"
title={canSeoAi
? i18n.t("seo.aiTitleAllowed")
: i18n.t("seo.aiTitleGated")}
disabled={applyingID !== null || !canSeoAi}
onclick={() => applyFix(rec, "ai")}
>
<Sparkles class="mr-2 h-3.5 w-3.5" />
{applyingID === `${rec.id}:ai` ? i18n.t("seo.rewriting") : i18n.t("seo.aiRewriteBtn")}
</Button>
{/if}
{#if categoryHref(rec)}
<a href={categoryHref(rec)!}>
<Button size="sm" variant="ghost">
<ExternalLink class="mr-2 h-3.5 w-3.5" />
{i18n.t("common.open")}
</Button>
</a>
{/if}
{#if rec.entity_type === "product"}
<a href={`/products?q=${encodeURIComponent(rec.entity_label)}`}>
<Button size="sm" variant="ghost">{i18n.t("seo.viewProduct")}</Button>
</a>
{/if}
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</section>
{/if}
</PageShell>
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Spinner from "$lib/components/Spinner.svelte";
onMount(() => {
const q = page.url.search || "";
void goto(`/stores/shopify${q}`, { replaceState: true });
});
</script>
<Spinner label="Opening Shopify setup." />
File diff suppressed because it is too large Load Diff
+389
View File
@@ -0,0 +1,389 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import {
ArrowRight,
FileUp,
Key,
Link as LinkIcon,
Share2,
ShoppingBag,
Store
} from "@lucide/svelte";
import { api } from "$lib/api";
import { isCompanyAdmin } from "$lib/company-admin";
import { authSession } from "$lib/auth-session.svelte";
import type { MeResponse, ShopifyConfig, WooCommerceConfig } from "$lib/types";
import {
needsStoreReconnect,
STORE_RECONNECT_TARGETS,
storeConnectorAction,
type StoreReconnectTarget
} from "$lib/store-reconnect";
import PageShell from "$lib/components/PageShell.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import StoreReconnectBanner from "$lib/components/stores/StoreReconnectBanner.svelte";
import StoreSyncDeliveryBanner from "$lib/components/stores/StoreSyncDeliveryBanner.svelte";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { upgradeMessageForFeature } from "$lib/plan-upgrade-message";
import PlanUpgradePanel from "$lib/components/PlanUpgradePanel.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "$lib/components/ui";
type ConnectorCard = {
id: string;
title: string;
description: string;
href: string;
cta: string;
icon: typeof Store;
status: "ready" | "export" | "reconnect" | "disconnected";
statusLabel: string;
};
let canAdmin = $state(false);
let reconnectItems = $state<StoreReconnectTarget[]>([]);
let wooNeedsReconnect = $state(false);
let shopifyNeedsReconnect = $state(false);
let wooConnected = $state(false);
let shopifyConnected = $state(false);
const canHub = $derived(planCapabilities.can("stores.hub"));
const canWoo = $derived(planCapabilities.can("stores.woocommerce"));
const canShopify = $derived(planCapabilities.can("stores.shopify"));
const storesGate = $derived(
upgradeMessageForFeature("stores.hub", authSession.isCompanyAdmin, {
sectionEnabled: planCapabilities.sectionEnabled("stores")
})
);
const connectors = $derived.by((): ConnectorCard[] => {
const wooAction = storeConnectorAction({
canAdmin,
connected: wooConnected,
needsReconnect: wooNeedsReconnect
});
const shopifyAction = storeConnectorAction({
canAdmin,
connected: shopifyConnected,
needsReconnect: shopifyNeedsReconnect
});
return [
{
id: "woocommerce",
title: i18n.t("stores.woo.title"),
description: i18n.t("stores.woo.description"),
href: wooNeedsReconnect
? STORE_RECONNECT_TARGETS.find((t) => t.id === "woocommerce")!.href
: "/woocommerce",
cta:
wooAction === "reconnect"
? i18n.t("stores.woo.ctaReconnect")
: wooAction === "open"
? i18n.t("stores.woo.ctaOpen")
: wooAction === "view"
? i18n.t("stores.woo.ctaView")
: i18n.t("stores.woo.ctaConnect"),
icon: Store,
status: wooNeedsReconnect ? "reconnect" : wooConnected ? "ready" : "disconnected",
statusLabel: wooNeedsReconnect
? i18n.t("stores.status.needsReconnect")
: wooConnected
? i18n.t("stores.status.connected")
: i18n.t("stores.status.notConnected")
},
{
id: "shopify",
title: i18n.t("stores.shopify.title"),
description: i18n.t("stores.shopify.description"),
href: shopifyNeedsReconnect
? STORE_RECONNECT_TARGETS.find((t) => t.id === "shopify")!.href
: "/stores/shopify",
cta:
shopifyAction === "reconnect"
? i18n.t("stores.shopify.ctaReconnect")
: shopifyAction === "open"
? i18n.t("stores.shopify.ctaOpen")
: shopifyAction === "view"
? i18n.t("stores.shopify.ctaView")
: i18n.t("stores.shopify.ctaConnect"),
icon: ShoppingBag,
status: shopifyNeedsReconnect ? "reconnect" : shopifyConnected ? "ready" : "disconnected",
statusLabel: shopifyNeedsReconnect
? i18n.t("stores.status.needsReconnect")
: shopifyConnected
? i18n.t("stores.status.connected")
: i18n.t("stores.status.notConnected")
},
{
id: "feed-url",
title: i18n.t("stores.feedUrl.title"),
description: i18n.t("stores.feedUrl.description"),
href: "/feeds?add=1&source=url",
cta: i18n.t("stores.feedUrl.cta"),
icon: LinkIcon,
status: "ready",
statusLabel: i18n.t("stores.status.ready")
},
{
id: "csv-upload",
title: i18n.t("stores.csv.title"),
description: i18n.t("stores.csv.description"),
href: "/feeds?add=1&source=file",
cta: i18n.t("stores.csv.cta"),
icon: FileUp,
status: "ready",
statusLabel: i18n.t("stores.status.ready")
},
{
id: "export",
title: i18n.t("stores.export.title"),
description: i18n.t("stores.export.description"),
href: "/export-feeds",
cta: i18n.t("stores.export.cta"),
icon: Share2,
status: "export",
statusLabel: i18n.t("stores.status.outbound")
},
{
id: "api-keys",
title: i18n.t("stores.apiKeys.title"),
description: canAdmin
? i18n.t("stores.apiKeys.description")
: i18n.t("stores.apiKeys.descriptionMember"),
href: "/settings?tab=api-keys",
cta: canAdmin ? i18n.t("stores.apiKeys.cta") : i18n.t("stores.apiKeys.ctaView"),
icon: Key,
status: "export",
statusLabel: i18n.t("stores.status.reissue")
}
];
});
function statusVariant(
status: ConnectorCard["status"]
): "success" | "secondary" | "outline" | "warning" {
if (status === "reconnect") return "warning";
if (status === "ready") return "success";
if (status === "disconnected") return "secondary";
if (status === "export") return "outline";
return "secondary";
}
onMount(() => {
const ac = new AbortController();
void (async () => {
try {
const me = await api<MeResponse>("/api/auth/me", { signal: ac.signal });
if (ac.signal.aborted) return;
authSession.setMe(me);
canAdmin = isCompanyAdmin(me);
} catch {
if (ac.signal.aborted) return;
canAdmin = authSession.isCompanyAdmin;
}
const [woo, shopify] = await Promise.all([
api<WooCommerceConfig>("/api/woocommerce", { signal: ac.signal }).catch(() => null),
api<ShopifyConfig>("/api/shopify", { signal: ac.signal }).catch(() => null)
]);
if (ac.signal.aborted) return;
wooNeedsReconnect = needsStoreReconnect(woo);
shopifyNeedsReconnect = needsStoreReconnect(shopify);
wooConnected = Boolean(woo?.has_credentials) && !wooNeedsReconnect;
shopifyConnected = Boolean(shopify?.has_credentials) && !shopifyNeedsReconnect;
const items: StoreReconnectTarget[] = [];
if (wooNeedsReconnect) {
const t = STORE_RECONNECT_TARGETS.find((x) => x.id === "woocommerce");
if (t) items.push(t);
}
if (shopifyNeedsReconnect) {
const t = STORE_RECONNECT_TARGETS.find((x) => x.id === "shopify");
if (t) items.push(t);
}
reconnectItems = items;
})();
return () => ac.abort();
});
</script>
<PageShell
title={i18n.t("stores.title")}
description={i18n.t("stores.description")}
>
{#snippet actions()}
<Button variant="outline" onclick={() => void goto("/feeds")} data-tour="stores-all-feeds">
{i18n.t("stores.allInputFeeds")}
</Button>
<Button onclick={() => void goto("/export-feeds")}>{i18n.t("stores.exportFeeds")}</Button>
{/snippet}
{#if reconnectItems.length > 0}
<div class="mb-4">
<StoreReconnectBanner {canAdmin} items={reconnectItems} />
</div>
{:else}
<p class="text-sm text-muted-foreground" id="store-hub-migration-note">
{i18n.t("stores.migrationNote")}
</p>
{/if}
<div class="mb-2">
<StoreSyncDeliveryBanner compact={true} />
</div>
<p class="text-sm text-muted-foreground" id="store-hub-intro">
{i18n.t("stores.docsPrefix")}
<a class="font-medium text-foreground underline-offset-4 hover:underline" href="/docs"
>{i18n.t("stores.docsApiOverview")}</a
>
{i18n.t("stores.docsSuffix")}
</p>
{#if !canHub}
<div data-testid="stores-plan-upgrade">
<PlanUpgradePanel
title={storesGate.title}
message={storesGate.message}
cta={storesGate.cta}
stillWorks={storesGate.stillWorks}
featureKey={storesGate.featureKey}
compact={true}
/>
</div>
{/if}
{#if !wooConnected && !shopifyConnected && reconnectItems.length === 0}
<div class="mb-2">
<EmptyState
title={canAdmin ? i18n.t("empty.stores.noneTitle") : i18n.t("empty.stores.noneTitleMember")}
message={canAdmin ? i18n.t("empty.stores.noneMessage") : i18n.t("empty.stores.noneMessageMember")}
>
{#if canAdmin && canHub}
<Button onclick={() => void goto("/stores/wizard")} data-tour="stores-empty-guided">
{i18n.t("stores.wizard.guidedSetup")}
</Button>
{/if}
{#if canAdmin}
<Button variant="outline" onclick={() => void goto("/woocommerce")} data-tour="stores-empty-woo">
{i18n.t("stores.woo.ctaConnect")}
</Button>
{:else}
<a href="/settings?tab=team">
<Button type="button" variant="outline" data-tour="stores-empty-ask-admin">
{i18n.t("common.askAdmin")}
</Button>
</a>
{/if}
</EmptyState>
</div>
{/if}
<section
class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"
aria-labelledby="store-hub-heading"
data-tour="store-hub"
data-assistant-target="store-hub"
>
<h2 id="store-hub-heading" class="sr-only">{i18n.t("stores.connectorsHeading")}</h2>
{#each connectors as card}
{@const Icon = card.icon}
<article
class="flex flex-col"
aria-labelledby={`store-card-title-${card.id}`}
data-tour="store-card-{card.id}"
>
<Card class="flex h-full flex-col">
<CardHeader class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-foreground"
aria-hidden="true"
>
<Icon class="h-5 w-5" />
</div>
<Badge variant={statusVariant(card.status)} aria-label={i18n.t("stores.statusAria", { label: card.statusLabel })}>
{card.statusLabel}
</Badge>
</div>
<div class="space-y-1">
<CardTitle id={`store-card-title-${card.id}`} class="text-lg">{card.title}</CardTitle>
<CardDescription id={`store-card-desc-${card.id}`}>{card.description}</CardDescription>
</div>
</CardHeader>
<CardContent class="mt-auto pt-0">
{@const channelLocked =
(card.id === "woocommerce" && !canWoo) ||
(card.id === "shopify" && !canShopify)}
{#if channelLocked}
<Badge variant="outline" class="mb-2 w-fit">{i18n.t("stores.connectorLocked")}</Badge>
<Button
class="w-full justify-between"
variant="outline"
onclick={() => void goto(storesGate.cta.primaryHref)}
data-tour="store-connect-{card.id}-locked"
aria-label={i18n.t("stores.connectorUpgrade")}
>
{i18n.t("stores.connectorUpgrade")}
<ArrowRight class="h-4 w-4" aria-hidden="true" />
</Button>
{:else}
<Button
class="w-full justify-between"
variant="default"
onclick={() => void goto(card.href)}
data-tour="store-connect-{card.id}"
data-assistant-target="store-connect-{card.id}"
aria-describedby={`store-card-desc-${card.id}`}
aria-label={i18n.t("stores.ctaAria", { cta: card.cta, title: card.title })}
>
{card.cta}
<ArrowRight class="h-4 w-4" aria-hidden="true" />
</Button>
{/if}
</CardContent>
</Card>
</article>
{/each}
</section>
<aside class="mt-2" aria-labelledby="store-hub-start-title">
<Card class="border-dashed">
<CardHeader>
<CardTitle id="store-hub-start-title" class="text-base">{i18n.t("stores.startTitle")}</CardTitle>
<CardDescription>
{canAdmin ? i18n.t("stores.startBody") : i18n.t("stores.startBodyMember")}
</CardDescription>
</CardHeader>
{#if canAdmin && canHub}
<CardContent class="pt-0">
<Button
variant="outline"
class="w-full justify-between sm:w-auto"
onclick={() => void goto("/stores/wizard")}
data-tour="stores-guided-setup"
aria-label={i18n.t("stores.wizard.guidedSetupAria")}
>
{i18n.t("stores.wizard.guidedSetup")}
<ArrowRight class="h-4 w-4" aria-hidden="true" />
</Button>
</CardContent>
{:else if !canAdmin}
<CardContent class="pt-0">
<a href="/settings?tab=team">
<Button type="button" variant="outline" data-tour="stores-ask-admin">
{i18n.t("common.askAdmin")}
</Button>
</a>
</CardContent>
{/if}
</Card>
</aside>
</PageShell>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api } from "$lib/api";
import { canManageCompany } from "$lib/company-admin";
import { authSession } from "$lib/auth-session.svelte";
import { i18n } from "$lib/i18n";
import type { MeResponse } from "$lib/types";
import Alert from "$lib/components/Alert.svelte";
import PageShell from "$lib/components/PageShell.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import StoreSetupWizard from "$lib/components/stores/StoreSetupWizard.svelte";
import { Button } from "$lib/components/ui";
let canAdmin = $state(false);
let loading = $state(true);
onMount(() => {
const ac = new AbortController();
void (async () => {
try {
const me = await api<MeResponse>("/api/auth/me", { signal: ac.signal });
if (ac.signal.aborted) return;
authSession.setMe(me);
canAdmin = canManageCompany(me);
} catch {
if (ac.signal.aborted) return;
canAdmin = canManageCompany(authSession.me);
} finally {
if (!ac.signal.aborted) loading = false;
}
})();
return () => ac.abort();
});
</script>
<PageShell
title={i18n.t("stores.wizard.title")}
description={i18n.t("stores.wizard.description")}
>
{#snippet actions()}
<Button variant="outline" onclick={() => void goto("/stores")}>
{i18n.t("stores.wizard.backToHub")}
</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if !canAdmin}
<Alert tone="info" message={i18n.t("stores.wizard.adminOnly")} />
<div class="mt-4 flex flex-wrap gap-2">
<a href="/settings?tab=team">
<Button type="button" variant="outline">{i18n.t("common.askAdmin")}</Button>
</a>
<Button variant="outline" onclick={() => void goto("/stores")}>
{i18n.t("stores.wizard.backToHub")}
</Button>
</div>
{:else}
<StoreSetupWizard />
{/if}
</PageShell>
@@ -0,0 +1,277 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, ApiError, failureMessage } from "$lib/api";
import { unwrapList } from "$lib/list";
import type { ListResponse } from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import { notifyApiError, notifyError, notifySuccess } from "$lib/notify";
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Dialog,
Input,
Label,
Select,
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow
} from "$lib/components/ui";
type StructuredDescriptionField = {
id: string;
company_id?: string;
companyId?: string;
field_key?: string;
fieldKey?: string;
type: string;
created_at?: string | Date;
createdAt?: string | Date;
updated_at?: string | Date;
updatedAt?: string | Date;
};
let fields = $state<StructuredDescriptionField[]>([]);
let loading = $state(true);
let saving = $state(false);
let error = $state("");
let apiAvailable = $state(true);
let showAddDialog = $state(false);
let newFieldKey = $state("");
let newFieldType = $state("text");
function fieldKeyOf(field: StructuredDescriptionField): string {
return String(field.field_key ?? field.fieldKey ?? "");
}
function createdAtOf(field: StructuredDescriptionField): string | Date | undefined {
return field.created_at ?? field.createdAt;
}
function formatDate(value: string | Date | undefined): string {
if (!value) return "—";
const d = value instanceof Date ? value : new Date(value);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString();
}
async function loadFields() {
error = "";
try {
const payload = await api<
ListResponse<StructuredDescriptionField> | { fields?: StructuredDescriptionField[] }
>("/api/structured-descriptions?limit=2000");
let items = unwrapList(payload as ListResponse<StructuredDescriptionField>);
if (!items.length && payload && typeof payload === "object" && "fields" in payload) {
items = (payload as { fields?: StructuredDescriptionField[] }).fields ?? [];
}
fields = items;
apiAvailable = true;
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
if (err instanceof ApiError && err.status === 404) {
apiAvailable = false;
fields = [];
return;
}
error = failureMessage(err, i18n.t("structured.flash.loadFailed"));
}
}
onMount(() => {
void (async () => {
loading = true;
await loadFields();
loading = false;
})();
});
async function handleAddField(event: Event) {
event.preventDefault();
if (!newFieldKey.trim()) {
error = i18n.t("flash.structured.keyRequired");
notifyError(error);
return;
}
saving = true;
error = "";
try {
const created = await api<StructuredDescriptionField>("/api/structured-descriptions", {
method: "POST",
body: {
field_key: newFieldKey.trim(),
fieldKey: newFieldKey.trim(),
type: newFieldType
}
});
fields = [...fields, created];
newFieldKey = "";
newFieldType = "text";
showAddDialog = false;
notifySuccess(i18n.t("toast.structured.fieldAdded"), i18n.t("toast.structured.fieldAddedBody"));
} catch (err) {
error = notifyApiError(err, i18n.t("toast.structured.addFailed"));
} finally {
saving = false;
}
}
async function handleDelete(id: string) {
if (!confirm(i18n.t("confirm.deleteStructuredField"))) {
return;
}
saving = true;
error = "";
try {
await api(`/api/structured-descriptions/${id}`, { method: "DELETE" });
fields = fields.filter((field) => field.id !== id);
notifySuccess(i18n.t("toast.structured.fieldDeleted"), i18n.t("toast.structured.fieldDeletedBody"));
} catch (err) {
notifyApiError(err, i18n.t("toast.structured.deleteFailed"));
} finally {
saving = false;
}
}
</script>
<PageShell title={i18n.t("structured.title")}>
<Alert message={error} />
{#if loading}
<div class="rounded-lg border border-border bg-card p-8 shadow-sm">
<Spinner label={i18n.t("structured.loading")} />
</div>
{:else if !apiAvailable}
<EmptyState
title={i18n.t("empty.structured.unavailableTitle")}
message={i18n.t("empty.structured.unavailableMessage")}
>
<a href="/dashboard">
<Button type="button" variant="outline">{i18n.t("structured.backDashboard")}</Button>
</a>
<a href="/categories">
<Button type="button" variant="outline">{i18n.t("structured.categoryFormulas")}</Button>
</a>
<a href="/export-feeds">
<Button type="button" variant="outline">{i18n.t("structured.exportFeeds")}</Button>
</a>
</EmptyState>
{:else}
<p class="mb-4 text-sm text-muted-foreground">
{i18n.t("structured.intro")}
</p>
<Card>
<CardHeader class="flex flex-row items-center justify-between">
<CardTitle>{i18n.t("structured.title")}</CardTitle>
<Button onclick={() => (showAddDialog = true)}>{i18n.t("structured.addField")}</Button>
</CardHeader>
<CardContent>
{#if fields.length === 0}
<EmptyState
title={i18n.t("empty.structured.noneTitle")}
message={i18n.t("empty.structured.noneMessage")}
>
<Button type="button" onclick={() => (showAddDialog = true)}>{i18n.t("structured.addField")}</Button>
<a href="/categories">
<Button type="button" variant="outline">{i18n.t("structured.openCategories")}</Button>
</a>
<a href="/export-feeds">
<Button type="button" variant="outline">{i18n.t("structured.exportFeeds")}</Button>
</a>
</EmptyState>
{:else}
<div class="rounded-md border border-border">
<Table>
<TableCaption>
{i18n.t("structured.tableCaption")}
</TableCaption>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("structured.col.fieldKey")}</TableHead>
<TableHead>{i18n.t("structured.col.type")}</TableHead>
<TableHead>{i18n.t("structured.col.created")}</TableHead>
<TableHead stickyRight>{i18n.t("structured.col.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each fields as field (field.id)}
<TableRow>
<TableCell class="font-medium">{fieldKeyOf(field)}</TableCell>
<TableCell>{field.type}</TableCell>
<TableCell>{formatDate(createdAtOf(field))}</TableCell>
<TableCell stickyRight>
<Button
variant="destructive"
size="sm"
disabled={saving}
onclick={() => void handleDelete(field.id)}
>
{i18n.t("common.delete")}
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
{/if}
</CardContent>
</Card>
{/if}
</PageShell>
<Dialog
bind:open={showAddDialog}
title={i18n.t("structured.dialogTitle")}
class="max-w-[500px]"
>
<form class="space-y-4 py-4" onsubmit={handleAddField}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
<div class="space-y-2">
<Label for="sd-field-key">{i18n.t("structured.fieldKeyLabel")}</Label>
<Input
id="sd-field-key"
placeholder={i18n.t("structured.fieldKeyPlaceholder")}
bind:value={newFieldKey}
required
/>
</div>
<div class="space-y-2">
<Label for="sd-field-type">{i18n.t("structured.fieldTypeLabel")}</Label>
<Select id="sd-field-type" bind:value={newFieldType}>
<option value="text">{i18n.t("structured.type.text")}</option>
<option value="number">{i18n.t("structured.type.number")}</option>
<option value="boolean">{i18n.t("structured.type.boolean")}</option>
<option value="list">{i18n.t("structured.type.list")}</option>
</Select>
</div>
<div class="flex justify-end space-x-2">
<Button type="button" variant="outline" onclick={() => (showAddDialog = false)}>
{i18n.t("common.cancel")}
</Button>
<Button type="submit" loading={saving}>
{saving ? i18n.t("structured.adding") : i18n.t("structured.addField")}
</Button>
</div>
</form>
</Dialog>
+299
View File
@@ -0,0 +1,299 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { LifeBuoy, Plus, RefreshCw } from "@lucide/svelte";
import { ApiError, failureMessage, isUnauthorized } from "$lib/api";
import { listSupportTickets } from "$lib/support/api";
import { categoryLabel, priorityLabel } from "$lib/support/display";
import {
SUPPORT_STATUS_FILTERS,
type SupportTicket,
type SupportTicketStatus
} from "$lib/support/types";
import { formatRelativeTime } from "$lib/utils";
import { notifyApiError } from "$lib/notify";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
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 StatusBadge from "$lib/components/StatusBadge.svelte";
import FeatureGate from "$lib/components/FeatureGate.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell
} from "$lib/components/ui";
let tickets = $state<SupportTicket[]>([]);
let total = $state(0);
let unavailable = $state(false);
let loading = $state(true);
let error = $state("");
let statusFilter = $state<"" | SupportTicketStatus>("");
let ticketsAbort: AbortController | null = null;
let ticketsFetchGen = 0;
const canCreate = $derived(planCapabilities.can("support.ticket_create"));
function isAbortError(err: unknown): boolean {
return (
(err instanceof DOMException && err.name === "AbortError") ||
(err instanceof Error && err.name === "AbortError")
);
}
function statusFromUrl(): "" | SupportTicketStatus {
const raw = page.url.searchParams.get("status")?.trim().toLowerCase() ?? "";
if (
raw === "open" ||
raw === "pending" ||
raw === "resolved" ||
raw === "closed"
) {
return raw;
}
return "";
}
async function syncStatusToUrl(next: "" | SupportTicketStatus) {
const url = new URL(page.url);
if (next) url.searchParams.set("status", next);
else url.searchParams.delete("status");
const target = `${url.pathname}${url.search}`;
const current = `${page.url.pathname}${page.url.search}`;
if (target !== current) {
await goto(target, { replaceState: true, noScroll: true, keepFocus: true });
}
}
async function load() {
ticketsAbort?.abort();
const ac = new AbortController();
ticketsAbort = ac;
const gen = ++ticketsFetchGen;
loading = true;
error = "";
try {
const result = await listSupportTickets({
status: statusFilter || undefined,
signal: ac.signal
});
if (gen !== ticketsFetchGen) return;
tickets = result.tickets;
total = result.total;
unavailable = result.unavailable;
} catch (err) {
if (isAbortError(err) || gen !== ticketsFetchGen) return;
if (isUnauthorized(err) || (err instanceof ApiError && err.status === 401)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("support.loadFailed"));
notifyApiError(err, i18n.t("toast.support.loadFailed"));
} finally {
if (gen === ticketsFetchGen) loading = false;
}
}
function setStatusFilter(value: "" | SupportTicketStatus) {
statusFilter = value;
void syncStatusToUrl(value);
void load();
}
function openTicket(id: string) {
void goto(`/support/${id}`);
}
onMount(() => {
statusFilter = statusFromUrl();
void load();
return () => {
ticketsAbort?.abort();
};
});
</script>
<PageShell
title={i18n.t("support.title")}
description={i18n.t("support.description")}
>
{#snippet actions()}
<div class="flex flex-wrap gap-2" data-tour="support-page">
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
{i18n.t("common.refresh")}
</Button>
{#if canCreate}
<a href="/support/new" data-tour="support-new" data-assistant-target="support-new">
<Button size="sm">
<Plus class="mr-2 h-4 w-4" />
{i18n.t("support.newTicket")}
</Button>
</a>
{/if}
</div>
{/snippet}
{#if loading && tickets.length === 0}
<div class="flex justify-center py-16">
<Spinner label={i18n.t("support.loadingTickets")} />
</div>
{:else if error && tickets.length === 0}
<Alert message={error} />
{:else}
<div class="space-y-4" data-testid="support-ticket-list">
{#if error}
<Alert message={error} />
{/if}
{#if unavailable}
<Alert
tone="info"
message={i18n.t("support.unavailableBanner")}
/>
{/if}
<Card>
<CardHeader>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle class="text-base">{i18n.t("support.yourTickets")}</CardTitle>
<CardDescription>
{total === 1
? i18n.t("support.ticketCountOne", { count: total })
: i18n.t("support.ticketCount", { count: total })}
{#if statusFilter}
{i18n.t("support.statusFilterSuffix", {
status: i18n.t(`support.status.${statusFilter}`)
})}
{/if}
</CardDescription>
</div>
</div>
<div
class="flex max-w-full flex-nowrap items-center gap-2 overflow-x-auto overscroll-x-contain pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="group"
aria-label={i18n.t("support.filterByStatus")}
>
{#each SUPPORT_STATUS_FILTERS as opt}
<button
type="button"
class="shrink-0 touch-manipulation rounded-md border px-3 py-2 text-sm font-medium transition-colors {statusFilter ===
opt.value
? 'border-primary bg-primary/15 text-primary'
: 'border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
aria-pressed={statusFilter === opt.value}
onclick={() => setStatusFilter(opt.value)}
>
{i18n.t(opt.value ? `support.status.${opt.value}` : "support.status.all")}
</button>
{/each}
</div>
</div>
</CardHeader>
<CardContent class="p-0">
{#if tickets.length === 0}
<div class="px-6 pb-6">
<EmptyState
title={unavailable
? i18n.t("empty.support.unavailableTitle")
: i18n.t("empty.support.noneTitle")}
message={unavailable
? i18n.t("empty.support.unavailableMessage")
: statusFilter
? i18n.t("empty.support.filterMessage")
: i18n.t("empty.support.noneMessage")}
>
{#if canCreate}
<a href="/support/new">
<Button>
<LifeBuoy class="mr-2 h-4 w-4" />
{i18n.t("empty.support.createTicket")}
</Button>
</a>
{/if}
{#if statusFilter}
<Button variant="outline" onclick={() => setStatusFilter("")}>
{i18n.t("empty.support.clearFilter")}
</Button>
{/if}
</EmptyState>
</div>
{:else}
<TableShell tableClass="min-w-0 sm:min-w-[28rem] md:min-w-[44rem]">
<TableHeader>
<TableRow>
<TableHead>{i18n.t("support.table.subject")}</TableHead>
<TableHead class="hidden sm:table-cell">{i18n.t("support.table.category")}</TableHead>
<TableHead>{i18n.t("support.table.status")}</TableHead>
<TableHead class="hidden md:table-cell">{i18n.t("support.table.priority")}</TableHead>
<TableHead class="hidden sm:table-cell">{i18n.t("support.table.updated")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each tickets as ticket (ticket.id)}
<TableRow
class="cursor-pointer hover:bg-muted/40"
tabindex={0}
role="link"
aria-label={i18n.t("support.openTicketAria", { subject: ticket.subject })}
onclick={() => openTicket(ticket.id)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openTicket(ticket.id);
}
}}
>
<TableCell>
<div class="font-medium text-foreground">{ticket.subject}</div>
<div class="text-xs text-muted-foreground sm:hidden">
{categoryLabel(String(ticket.category))}
·
{formatRelativeTime(
ticket.last_message_at ?? ticket.updated_at ?? ticket.created_at
)}
</div>
</TableCell>
<TableCell class="hidden text-muted-foreground sm:table-cell">
{categoryLabel(String(ticket.category))}
</TableCell>
<TableCell>
<StatusBadge status={ticket.status} />
</TableCell>
<TableCell class="hidden capitalize text-muted-foreground md:table-cell">
{priorityLabel(String(ticket.priority))}
</TableCell>
<TableCell class="hidden whitespace-nowrap text-muted-foreground sm:table-cell">
{formatRelativeTime(
ticket.last_message_at ?? ticket.updated_at ?? ticket.created_at
)}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
{#if !canCreate}
<FeatureGate feature="support.ticket_create" mode="upgrade" />
{/if}
</div>
{/if}
</PageShell>
@@ -0,0 +1,354 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount, tick } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { ArrowLeft, RefreshCw, Send } from "@lucide/svelte";
import { ApiError, failureMessage, isUnauthorized } from "$lib/api";
import {
canRateTicket,
canReplyToTicket,
getSupportTicket,
isSupportUnavailable,
replySupportTicket
} from "$lib/support/api";
import {
categoryLabel,
messageBubbleClass,
messageKind,
messageKindBadge,
messageKindLabel,
priorityLabel,
ticketProgressState
} from "$lib/support/display";
import { markSupportNotificationsForTicket } from "$lib/support/notifications";
import { refreshSupportNotifications } from "$lib/support/notifications.svelte";
import type { SupportCsat, SupportTicket } from "$lib/support/types";
import { formatDateTime, formatRelativeTime } from "$lib/utils";
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 StatusBadge from "$lib/components/StatusBadge.svelte";
import SupportTicketRating from "$lib/components/SupportTicketRating.svelte";
import {
Badge,
Button,
Card,
CardDescription,
CardHeader,
CardTitle,
Label,
Textarea
} from "$lib/components/ui";
const POLL_MS = 20_000;
let ticket = $state<SupportTicket | null>(null);
let loading = $state(true);
let error = $state("");
let replyBody = $state("");
let sending = $state(false);
let replyError = $state("");
let loadAbort: AbortController | null = null;
let loadGen = 0;
let threadEl: HTMLElement | null = $state(null);
const ticketId = $derived(page.params.ticketId ?? "");
const messages = $derived(ticket?.messages ?? []);
const canReply = $derived(canReplyToTicket(ticket));
const showRating = $derived(Boolean(ticket && (canRateTicket(ticket) || ticket.csat)));
const progress = $derived(ticket ? ticketProgressState(ticket) : null);
function isAbortError(err: unknown): boolean {
return (
(err instanceof DOMException && err.name === "AbortError") ||
(err instanceof Error && err.name === "AbortError")
);
}
async function scrollThreadToEnd() {
await tick();
threadEl?.scrollTo({ top: threadEl.scrollHeight, behavior: "smooth" });
}
async function load(opts?: { quiet?: boolean }) {
if (!ticketId) {
error = i18n.t("flash.support.missingId");
loading = false;
return;
}
loadAbort?.abort();
const ac = new AbortController();
loadAbort = ac;
const gen = ++loadGen;
if (!opts?.quiet) {
loading = true;
error = "";
}
try {
const next = await getSupportTicket(ticketId, { signal: ac.signal });
if (gen !== loadGen) return;
const prevCount = ticket?.messages?.length ?? 0;
ticket = next;
if ((next.messages?.length ?? 0) > prevCount) {
void scrollThreadToEnd();
}
} catch (err) {
if (isAbortError(err) || gen !== loadGen) return;
if (isUnauthorized(err) || (err instanceof ApiError && err.status === 401)) {
await goto("/login");
return;
}
if (err instanceof ApiError && err.status === 404) {
ticket = null;
error = i18n.t("flash.support.notFound");
return;
}
if (isSupportUnavailable(err)) {
error = i18n.t("flash.support.unavailable");
return;
}
error = failureMessage(err, i18n.t("support.ticket.loadFailed"));
if (!opts?.quiet) notifyApiError(err, i18n.t("toast.support.loadTicketFailed"));
} finally {
if (gen === loadGen) loading = false;
}
}
async function handleReply(event: Event) {
event.preventDefault();
if (!ticket || !canReply || !replyBody.trim() || sending) return;
sending = true;
replyError = "";
try {
ticket = await replySupportTicket(ticket.id, { body: replyBody.trim() });
replyBody = "";
notifySuccess(i18n.t("toast.support.replySent"), {
description:
ticket.status === "open"
? i18n.t("support.ticket.reopened")
: i18n.t("support.ticket.replyAdded")
});
void refreshSupportNotifications();
void scrollThreadToEnd();
} catch (err) {
if (isUnauthorized(err) || (err instanceof ApiError && err.status === 401)) {
await goto("/login");
return;
}
replyError = failureMessage(err, i18n.t("support.ticket.replyFailed"));
notifyApiError(err, i18n.t("toast.support.replyFailed"));
} finally {
sending = false;
}
}
function onRated(csat: SupportCsat, nextTicket: SupportTicket | null) {
if (!ticket) return;
ticket = nextTicket?.id === ticket.id ? nextTicket : { ...ticket, csat };
}
onMount(() => {
void (async () => {
await load();
void scrollThreadToEnd();
if (ticketId) {
await markSupportNotificationsForTicket(ticketId);
void refreshSupportNotifications();
}
})();
const timer = window.setInterval(() => {
if (document.visibilityState === "hidden") return;
void load({ quiet: true });
}, POLL_MS);
const onVis = () => {
if (document.visibilityState === "visible") void load({ quiet: true });
};
document.addEventListener("visibilitychange", onVis);
return () => {
loadAbort?.abort();
window.clearInterval(timer);
document.removeEventListener("visibilitychange", onVis);
};
});
</script>
<PageShell
title={ticket?.subject ?? i18n.t("support.ticket.fallbackTitle")}
description={ticket
? i18n.t("support.ticket.meta", {
category: categoryLabel(String(ticket.category)),
priority: priorityLabel(String(ticket.priority)),
when: formatRelativeTime(ticket.last_message_at ?? ticket.updated_at)
})
: i18n.t("support.ticket.description")}
>
{#snippet actions()}
<div class="flex flex-wrap gap-2">
<a href="/support">
<Button variant="outline" size="sm">
<ArrowLeft class="mr-2 h-4 w-4" />
{i18n.t("support.ticket.allTickets")}
</Button>
</a>
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
{i18n.t("common.refresh")}
</Button>
</div>
{/snippet}
{#if loading && !ticket}
<div class="flex justify-center py-16">
<Spinner label={i18n.t("support.ticket.loading")} />
</div>
{:else if error && !ticket}
{#if error === i18n.t("flash.support.notFound")}
<EmptyState
title={i18n.t("empty.support.ticketNotFoundTitle")}
message={i18n.t("empty.support.ticketNotFoundMessage")}
>
<a href="/support">
<Button variant="outline">{i18n.t("support.ticket.backCenter")}</Button>
</a>
</EmptyState>
{:else}
<Alert message={error} />
{/if}
{:else if ticket}
<div class="mx-auto max-w-3xl space-y-4" data-testid="support-ticket-thread">
{#if error}
<Alert message={error} />
{/if}
<Card>
<CardHeader class="flex flex-row flex-wrap items-start justify-between gap-3 space-y-0">
<div class="space-y-1">
<CardTitle class="text-base">{ticket.subject}</CardTitle>
<CardDescription>
{i18n.t("support.ticket.openedMeta", {
when: formatDateTime(ticket.created_at),
category: categoryLabel(String(ticket.category)),
priority: priorityLabel(String(ticket.priority))
})}
</CardDescription>
{#if (ticket.tags && ticket.tags.length > 0) || ticket.related_sku}
<div class="flex flex-wrap gap-1.5 pt-1">
{#if ticket.related_sku}
<Badge variant="outline" class="font-normal"
>{i18n.t("support.ticket.sku", { sku: ticket.related_sku })}</Badge
>
{/if}
{#each ticket.tags ?? [] as tag (tag)}
<Badge variant="secondary" class="font-normal">{tag}</Badge>
{/each}
</div>
{/if}
</div>
<StatusBadge status={ticket.status} />
</CardHeader>
</Card>
{#if progress}
<div
class="rounded-lg border px-4 py-3 text-sm {progress.tone === 'success'
? 'border-chart-green/40 bg-card-green text-foreground'
: 'border-primary/30 bg-card-blue text-foreground'}"
role="status"
aria-live="polite"
data-testid="support-ticket-progress"
>
<p class="font-medium">{progress.title}</p>
<p class="mt-0.5 text-muted-foreground">{progress.message}</p>
</div>
{/if}
{#if showRating}
<SupportTicketRating {ticket} onRated={onRated} />
{/if}
<div
bind:this={threadEl}
class="max-h-[min(28rem,60vh)] space-y-3 overflow-y-auto rounded-lg border border-border p-3 sm:p-4"
role="log"
aria-label={i18n.t("support.ticket.messagesAria")}
aria-live="polite"
>
{#if messages.length === 0}
<EmptyState title={i18n.t("empty.support.noMessagesTitle")} message={i18n.t("empty.support.noMessagesMessage")} />
{:else}
{#each messages as msg (msg.id)}
{@const kind = messageKind(msg)}
{@const badge = messageKindBadge(kind)}
<div
class="rounded-lg border px-4 py-3 {messageBubbleClass(kind)}"
data-message-kind={kind}
>
<div class="mb-1 flex flex-wrap items-baseline justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold text-foreground"
>{messageKindLabel(kind)}</span
>
{#if badge}
<span
class="rounded-md border border-current/20 bg-background/60 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-foreground/80"
>
{badge}
</span>
{/if}
</div>
<span class="text-xs text-muted-foreground">{formatDateTime(msg.created_at)}</span>
</div>
<p class="whitespace-pre-wrap text-sm text-foreground">{msg.body}</p>
</div>
{/each}
{/if}
</div>
{#if canReply}
<form class="space-y-3" data-testid="support-reply-form" onsubmit={handleReply}>
{#if replyError}
<Alert message={replyError} />
{/if}
<div class="space-y-1.5">
<Label for="support-reply">{i18n.t("support.ticket.yourReply")}</Label>
<Textarea
id="support-reply"
rows={5}
maxlength={10000}
placeholder={i18n.t("support.ticket.replyPlaceholder")}
bind:value={replyBody}
disabled={sending}
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button type="submit" disabled={sending || !replyBody.trim()}>
<Send class="mr-2 h-4 w-4" />
{sending ? i18n.t("common.sending") : i18n.t("support.ticket.sendReply")}
</Button>
{#if ticket.status === "resolved"}
<p class="text-xs text-muted-foreground">
{i18n.t("support.ticket.reopenHint")}
</p>
{:else if String(ticket.auto_reply_status ?? "") === "matched" || String(ticket.auto_reply_status ?? "") === "ai_sent"}
<p class="text-xs text-muted-foreground">
{i18n.t("support.ticket.humanHint")}
</p>
{/if}
</div>
</form>
{:else}
<Alert
tone="info"
message={i18n.t("support.ticket.closedBanner")}
/>
<a href="/support/new">
<Button variant="outline" size="sm">{i18n.t("support.newTicket")}</Button>
</a>
{/if}
</div>
{/if}
</PageShell>
@@ -0,0 +1,291 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { ApiError, failureMessage, isUnauthorized } from "$lib/api";
import { trackEvent } from "$lib/analytics";
import { createSupportTicket, isSupportUnavailable } from "$lib/support/api";
import {
categoryHelp,
categoryLabel,
normalizeSupportTags,
priorityHelp,
priorityLabel
} from "$lib/support/display";
import {
SUPPORT_CATEGORIES,
SUPPORT_PRIORITIES,
type SupportTicketCategory,
type SupportTicketPriority
} from "$lib/support/types";
import {
parseSupportCategoryParam,
parseSupportPriorityParam
} from "$lib/hypercare-report";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { notifySuccess, notifyApiError } from "$lib/notify";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import FeatureGate from "$lib/components/FeatureGate.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select,
Textarea
} from "$lib/components/ui";
const categoryValues = SUPPORT_CATEGORIES.map((c) => c.value);
const priorityValues = SUPPORT_PRIORITIES.map((p) => p.value);
function initialFromSearch() {
const params = page.url.searchParams;
const category =
parseSupportCategoryParam(params.get("category"), categoryValues) ?? "other";
const priority =
parseSupportPriorityParam(params.get("priority"), priorityValues) ?? "normal";
const subject = (params.get("subject") ?? "").slice(0, 200);
const body = (params.get("body") ?? "").slice(0, 10000);
const tagsRaw = (params.get("tags") ?? "").slice(0, 400);
const relatedSku = (params.get("related_sku") ?? "").slice(0, 128);
return { category, priority, subject, body, tagsRaw, relatedSku };
}
const seeded = initialFromSearch();
let subject = $state(seeded.subject);
let category = $state<SupportTicketCategory>(seeded.category);
let priority = $state<SupportTicketPriority>(seeded.priority);
let body = $state(seeded.body);
let tagsRaw = $state(seeded.tagsRaw);
let relatedSku = $state(seeded.relatedSku);
let submitting = $state(false);
let error = $state("");
const allowed = $derived(planCapabilities.can("support.ticket_create"));
const subjectLen = $derived(subject.trim().length);
const bodyLen = $derived(body.trim().length);
const tags = $derived(normalizeSupportTags(tagsRaw));
const canSubmit = $derived(
allowed && subjectLen > 0 && bodyLen > 0 && !submitting
);
const categoryHint = $derived(categoryHelp(category));
const priorityHint = $derived(priorityHelp(priority));
async function handleSubmit(event: Event) {
event.preventDefault();
if (!canSubmit) return;
submitting = true;
error = "";
try {
const ticket = await createSupportTicket({
subject: subject.trim(),
category,
priority,
body: body.trim(),
tags: tags.length > 0 ? tags : undefined,
related_sku: relatedSku.trim() || undefined
});
trackEvent("support_ticket_created", { has_attachment: false });
notifySuccess(i18n.t("toast.support.ticketCreated"), {
description: i18n.t("support.new.createdBody"),
action: { label: i18n.t("support.new.viewTicket"), href: `/support/${ticket.id}` }
});
await goto(`/support/${ticket.id}`);
} catch (err) {
if (isUnauthorized(err) || (err instanceof ApiError && err.status === 401)) {
await goto("/login");
return;
}
if (isSupportUnavailable(err)) {
error = i18n.t("support.new.unavailable");
notifyApiError(err, i18n.t("toast.support.unavailable"));
return;
}
error = failureMessage(err, i18n.t("support.new.createFailed"));
notifyApiError(err, i18n.t("toast.support.createFailed"));
} finally {
submitting = false;
}
}
</script>
<PageShell
title={i18n.t("support.new.title")}
description={i18n.t("support.new.description")}
>
{#snippet actions()}
<a href="/support">
<Button variant="outline" size="sm">{i18n.t("support.new.back")}</Button>
</a>
{/snippet}
{#if !allowed}
<FeatureGate feature="support.ticket_create" mode="upgrade" />
{:else}
<form
class="mx-auto max-w-2xl space-y-4"
data-testid="support-create-form"
onsubmit={handleSubmit}
>
{#if error}
<Alert message={error} />
{/if}
<Card>
<CardHeader>
<CardTitle>{i18n.t("support.new.details")}</CardTitle>
<CardDescription>
{i18n.t("support.new.detailsHelp")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-1.5">
<div class="flex items-baseline justify-between gap-2">
<Label for="support-subject">{i18n.t("support.new.subject")}</Label>
<span class="text-xs text-muted-foreground">{subjectLen}/200</span>
</div>
<Input
id="support-subject"
name="subject"
required
maxlength={200}
placeholder={i18n.t("support.new.subjectPlaceholder")}
bind:value={subject}
disabled={submitting}
autocomplete="off"
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-1.5">
<Label for="support-category">{i18n.t("support.new.category")}</Label>
<Select
id="support-category"
name="category"
bind:value={category}
disabled={submitting}
aria-describedby="support-category-help"
>
{#each SUPPORT_CATEGORIES as opt}
<option value={opt.value}>{categoryLabel(opt.value)}</option>
{/each}
</Select>
<p id="support-category-help" class="text-xs text-muted-foreground">
{categoryHint}
</p>
</div>
<div class="space-y-1.5">
<Label for="support-priority">{i18n.t("support.new.priority")}</Label>
<Select
id="support-priority"
name="priority"
bind:value={priority}
disabled={submitting}
aria-describedby="support-priority-help"
>
{#each SUPPORT_PRIORITIES as opt}
<option value={opt.value}>{priorityLabel(opt.value)}</option>
{/each}
</Select>
<p id="support-priority-help" class="text-xs text-muted-foreground">
{priorityHint}
</p>
</div>
</div>
<details class="rounded-lg border border-border/80 bg-muted/20 px-3 py-2">
<summary
class="cursor-pointer list-none text-sm font-medium text-foreground marker:content-none [&::-webkit-details-marker]:hidden"
>
{i18n.t("support.new.optional")}
<span class="ml-2 font-normal text-muted-foreground"
>{i18n.t("support.new.optionalHint")}</span
>
</summary>
<div class="mt-3 space-y-3 border-t border-border/60 pt-3">
<div class="space-y-1.5">
<Label for="support-sku">{i18n.t("support.new.relatedSku")}</Label>
<Input
id="support-sku"
name="related_sku"
maxlength={128}
placeholder={i18n.t("support.new.relatedSkuPlaceholder")}
bind:value={relatedSku}
disabled={submitting}
autocomplete="off"
aria-describedby="support-sku-help"
/>
<p id="support-sku-help" class="text-xs text-muted-foreground">
{i18n.t("support.new.relatedSkuHelp")}
</p>
</div>
<div class="space-y-1.5">
<div class="flex items-baseline justify-between gap-2">
<Label for="support-tags">{i18n.t("support.new.tags")}</Label>
<span class="text-xs text-muted-foreground">{tags.length}/10</span>
</div>
<Input
id="support-tags"
name="tags"
placeholder={i18n.t("support.new.tagsPlaceholder")}
bind:value={tagsRaw}
disabled={submitting}
autocomplete="off"
aria-describedby="support-tags-help"
/>
<p id="support-tags-help" class="text-xs text-muted-foreground">
{i18n.t("support.new.tagsHelp")}
{#if tags.length > 0}
<span class="mt-1 block text-foreground/80">
{i18n.t("support.new.tagsWillSend", { tags: tags.join(", ") })}
</span>
{/if}
</p>
</div>
</div>
</details>
<div class="space-y-1.5">
<div class="flex items-baseline justify-between gap-2">
<Label for="support-body">{i18n.t("support.new.message")}</Label>
<span class="text-xs text-muted-foreground">{bodyLen}/10000</span>
</div>
<Textarea
id="support-body"
name="body"
required
rows={8}
maxlength={10000}
placeholder={i18n.t("support.new.messagePlaceholder")}
bind:value={body}
disabled={submitting}
/>
</div>
<div class="flex flex-wrap gap-2 pt-2">
<Button
type="submit"
disabled={!canSubmit}
data-assistant-target="support-create"
data-tour="support-create"
>
{submitting ? i18n.t("common.submitting") : i18n.t("support.createTicket")}
</Button>
<a href="/support">
<Button type="button" variant="outline" disabled={submitting}
>{i18n.t("common.cancel")}</Button
>
</a>
</div>
</CardContent>
</Card>
</form>
{/if}
</PageShell>
@@ -0,0 +1,7 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
/** Legacy `/tasks` → `/processing` (SSR redirect; avoids client-only flash). */
export const load: PageServerLoad = () => {
redirect(307, "/processing");
};
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
import Footer from "$lib/components/site/Footer.svelte";
import SeoHead from "$lib/components/site/SeoHead.svelte";
import { LEGAL_LAST_UPDATED } from "$lib/site";
import { i18n } from "$lib/i18n";
</script>
<SeoHead
title={i18n.t("seo.terms.title")}
description={i18n.t("seo.terms.description")}
path="/terms"
/>
<div class="flex min-h-screen flex-col bg-background">
<SiteHeader />
<main class="mx-auto max-w-4xl flex-1 px-4 pt-28 pb-12 sm:px-6">
<div class="mb-12 text-center">
<h1 class="text-4xl font-bold tracking-tight">{i18n.t("legal.terms.title")}</h1>
<p class="mt-2 text-text-muted">
{i18n.t("legal.lastUpdated", { date: LEGAL_LAST_UPDATED })}
</p>
</div>
<div class="space-y-10">
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.terms.s1.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.terms.s1.p")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.terms.s2.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.terms.s2.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.terms.s2.lead")}</p>
<ul class="list-disc space-y-2 pl-6 text-text-muted">
<li>{i18n.t("legal.terms.s2.li1")}</li>
<li>{i18n.t("legal.terms.s2.li2")}</li>
<li>{i18n.t("legal.terms.s2.li3")}</li>
<li>{i18n.t("legal.terms.s2.li4")}</li>
<li>{i18n.t("legal.terms.s2.li5")}</li>
</ul>
<p class="text-text-muted">{i18n.t("legal.terms.s2.p2")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.terms.s3.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.terms.s3.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.terms.s3.p2")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.terms.s4.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.terms.s4.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.terms.s4.lead")}</p>
<ul class="list-disc space-y-2 pl-6 text-text-muted">
<li>{i18n.t("legal.terms.s4.li1")}</li>
<li>{i18n.t("legal.terms.s4.li2")}</li>
<li>{i18n.t("legal.terms.s4.li3")}</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.terms.s5.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.terms.s5.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.terms.s5.p2")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.terms.s6.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.terms.s6.p")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.terms.s7.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.terms.s7.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.terms.s7.p2")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.terms.s8.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.terms.s8.lead")}</p>
<ul class="list-disc space-y-2 pl-6 text-text-muted">
<li>{i18n.t("legal.terms.s8.li1")}</li>
<li>{i18n.t("legal.terms.s8.li2")}</li>
<li>{i18n.t("legal.terms.s8.li3")}</li>
<li>{i18n.t("legal.terms.s8.li4")}</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.terms.s9.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.terms.s9.p1")}</p>
<p class="text-text-muted">{i18n.t("legal.terms.s9.p2")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.terms.s10.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.terms.s10.p")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.terms.s11.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.terms.s11.p")}</p>
</section>
<section class="space-y-3">
<h2 class="text-2xl font-semibold">{i18n.t("legal.terms.s12.h")}</h2>
<p class="text-text-muted">{i18n.t("legal.terms.s12.lead")}</p>
<p class="text-text-muted">
<strong class="text-text">{i18n.t("legal.emailLabel")}</strong>
<a href="mailto:legal@descrybe.io" class="text-link hover:underline"
>legal@descrybe.io</a
>
</p>
<p class="text-text-muted">
<strong class="text-text">{i18n.t("legal.postalLabel")}</strong><br />
Descrybe<br />
Dunajska cesta 106<br />
Ljubljana<br />
Slovenia, SI-1000
</p>
</section>
</div>
<div class="mt-10 border-t border-border pt-8">
<a href="/" class="text-link hover:underline">{i18n.t("legal.backHome")}</a>
</div>
</main>
<Footer />
</div>
@@ -0,0 +1,86 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { page } from "$app/state";
import { PUBLIC_API_URL } from "$env/static/public";
import { Button } from "$lib/components/ui";
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
import Footer from "$lib/components/site/Footer.svelte";
const API_BASE = (PUBLIC_API_URL ?? "").replace(/\/$/, "") || "";
let token = $state("");
let loading = $state(true);
let submitting = $state(false);
let message = $state("");
let error = $state("");
let already = $state(false);
onMount(async () => {
token = page.url.searchParams.get("token") ?? "";
if (!token) {
error = i18n.t("flash.unsubscribe.missingToken");
loading = false;
return;
}
try {
const res = await fetch(`${API_BASE}/api/public/unsubscribe?token=${encodeURIComponent(token)}`);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
error = (body as { error?: string }).error || i18n.t("unsubscribe.invalidLink");
} else {
already = Boolean((body as { already_unsubscribed?: boolean }).already_unsubscribed);
message = already ? i18n.t("unsubscribe.already") : i18n.t("unsubscribe.confirmPrompt");
}
} catch {
error = i18n.t("flash.unsubscribe.loadFailed");
} finally {
loading = false;
}
});
async function confirm() {
submitting = true;
error = "";
try {
const res = await fetch(`${API_BASE}/api/public/unsubscribe?token=${encodeURIComponent(token)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
error = (body as { error?: string }).error || i18n.t("unsubscribe.failedShort");
return;
}
already = true;
message = (body as { message?: string }).message || i18n.t("unsubscribe.done");
} catch {
error = i18n.t("flash.unsubscribe.failed");
} finally {
submitting = false;
}
}
</script>
<div class="flex min-h-screen flex-col bg-background">
<SiteHeader />
<main
id="main-content"
class="mx-auto flex w-full max-w-lg flex-1 flex-col justify-center gap-4 px-6 pt-24 pb-12"
>
<h1 class="text-2xl font-semibold text-text">{i18n.t("unsubscribe.title")}</h1>
{#if loading}
<p class="text-sm text-text-muted">{i18n.t("common.loading")}</p>
{:else if error}
<p class="text-sm text-danger">{error}</p>
{:else}
<p class="text-sm text-text-muted">{message}</p>
{#if !already}
<Button loading={submitting} onclick={() => void confirm()}>{i18n.t("unsubscribe.me")}</Button>
{/if}
{/if}
</main>
<Footer />
</div>
@@ -0,0 +1,178 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { api, ApiError } from "$lib/api";
import EmptyState from "$lib/components/EmptyState.svelte";
import PageShell from "$lib/components/PageShell.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import Alert from "$lib/components/Alert.svelte";
import { notifyApiError } from "$lib/notify";
import { Button, Card, Input } from "$lib/components/ui";
type CategoryMatch = {
category: string;
id: string;
score: number;
};
let isLoading = $state(false);
let probing = $state(true);
let query = $state("");
let matches = $state<CategoryMatch[]>([]);
let apiAvailable = $state(true);
let infoMessage = $state("");
function unavailableMessage(): string {
return i18n.t("vector.unavailable");
}
function markUnavailable(err: unknown): boolean {
if (err instanceof ApiError && err.status === 404) {
apiAvailable = false;
infoMessage = unavailableMessage();
return true;
}
return false;
}
async function probeAvailability() {
probing = true;
try {
await api<{ matches?: CategoryMatch[] }>("/api/vector-categories/search", {
method: "POST",
body: { query: "availability-probe" }
});
apiAvailable = true;
infoMessage = "";
} catch (err) {
if (markUnavailable(err)) return;
// Route exists but probe failed — keep the interactive test UI.
apiAvailable = true;
} finally {
probing = false;
}
}
onMount(() => {
void probeAvailability();
});
async function searchCategories() {
if (!apiAvailable || !query.trim()) return;
try {
isLoading = true;
const data = await api<{ matches?: CategoryMatch[] }>("/api/vector-categories/search", {
method: "POST",
body: { query }
});
apiAvailable = true;
matches = data.matches ?? [];
} catch (err) {
if (markUnavailable(err)) {
matches = [];
return;
}
notifyApiError(err, i18n.t("toast.vector.searchFailed"));
} finally {
isLoading = false;
}
}
</script>
<PageShell title={i18n.t("vector.title")} description={i18n.t("vector.description")}>
{#if probing}
<div class="flex justify-center py-16">
<Spinner label={i18n.t("vector.probing")} />
</div>
{:else if !apiAvailable}
<EmptyState
title={i18n.t("empty.vector.unavailableTitle")}
message={infoMessage || unavailableMessage()}
>
<a href="/dashboard">
<Button type="button" variant="outline">{i18n.t("empty.forbidden.backDashboard")}</Button>
</a>
<a href="/categories">
<Button type="button" variant="outline">{i18n.t("vector.browseCategories")}</Button>
</a>
</EmptyState>
{:else}
<div class="space-y-6">
<Card class="p-6">
<h2 class="mb-4 text-xl font-semibold">{i18n.t("vector.initHeading")}</h2>
<Alert tone="info" message={i18n.t("vector.initComingSoon")} />
<div class="mt-4 flex gap-4">
<Button
type="button"
variant="outline"
disabled
title={i18n.t("vector.initComingSoon")}
aria-disabled="true"
>
{i18n.t("vector.createIndex")}
</Button>
<Button
type="button"
disabled
title={i18n.t("vector.initComingSoon")}
aria-disabled="true"
>
{i18n.t("vector.initDb")}
</Button>
</div>
</Card>
<Card class="p-6">
<h2 class="mb-4 text-xl font-semibold">{i18n.t("vector.searchHeading")}</h2>
<div class="mb-6 flex gap-4">
<Input
class="flex-1"
placeholder={i18n.t("vector.searchPlaceholder")}
bind:value={query}
/>
<Button
disabled={isLoading || !query.trim()}
onclick={() => void searchCategories()}
>
{i18n.t("common.search")}
</Button>
</div>
{#if matches.length > 0}
<div class="space-y-4">
<h3 class="font-semibold">{i18n.t("vector.results")}</h3>
<div class="space-y-2">
{#each matches as match, idx (`${match.id}-${idx}`)}
<div class="rounded-lg border border-border p-3">
<p>
<span class="font-semibold">{i18n.t("vector.categoryLabel")}</span>
{match.category}
</p>
<p>
<span class="font-semibold">{i18n.t("vector.idLabel")}</span>
{match.id}
</p>
<p>
<span class="font-semibold">{i18n.t("vector.scoreLabel")}</span>
{match.score.toFixed(4)}
</p>
</div>
{/each}
</div>
</div>
{:else}
<EmptyState
title={i18n.t("empty.vector.noMatchTitle")}
message={i18n.t("empty.vector.noMatchMessage")}
>
<a href="/categories">
<Button type="button" variant="outline">{i18n.t("vector.browseCategories")}</Button>
</a>
</EmptyState>
{/if}
</Card>
</div>
{/if}
</PageShell>
File diff suppressed because it is too large Load Diff