Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,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 />
|
||||
@@ -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>
|
||||
@@ -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
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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} />
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user