Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
/** Focusable controls for dialog / drawer traps (critical a11y). */
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled]):not([type='hidden'])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])"
].join(",");
export function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) =>
!el.hasAttribute("disabled") &&
el.getAttribute("aria-hidden") !== "true" &&
el.tabIndex !== -1 &&
!el.closest("[inert]")
);
}
export type FocusTrapOptions = {
initialFocus?: HTMLElement | null;
restoreFocus?: boolean;
/** Extra nodes in the Tab cycle (e.g. tutorial spotlight target). */
extraFocusables?: () => Array<HTMLElement | null | undefined>;
};
export type FocusTrapHandle = {
deactivate: () => void;
};
function collectCycle(container: HTMLElement, options?: FocusTrapOptions): HTMLElement[] {
const seen = new Set<HTMLElement>();
const out: HTMLElement[] = [];
for (const el of getFocusable(container)) {
if (seen.has(el)) continue;
seen.add(el);
out.push(el);
}
for (const el of options?.extraFocusables?.() ?? []) {
if (!el || seen.has(el) || !el.isConnected) continue;
seen.add(el);
out.push(el);
}
return out;
}
/**
* Trap Tab/Shift+Tab inside `container` (+ optional extras), restore focus on deactivate.
*/
export function activateFocusTrap(
container: HTMLElement,
options?: FocusTrapOptions
): FocusTrapHandle {
const previouslyFocused =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusInitial = () => {
const preferred = options?.initialFocus;
if (preferred && preferred.isConnected) {
preferred.focus();
return;
}
const items = collectCycle(container, options);
(items[0] ?? container).focus();
};
requestAnimationFrame(focusInitial);
function onKeydown(event: KeyboardEvent) {
if (event.key !== "Tab") return;
const items = collectCycle(container, options);
if (items.length === 0) {
event.preventDefault();
container.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
const inCycle = active instanceof HTMLElement && items.includes(active);
if (event.shiftKey) {
if (!inCycle || active === first) {
event.preventDefault();
last.focus();
}
} else if (!inCycle || active === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", onKeydown, true);
return {
deactivate() {
document.removeEventListener("keydown", onKeydown, true);
if (options?.restoreFocus !== false && previouslyFocused?.isConnected) {
previouslyFocused.focus();
}
}
};
}
+69
View File
@@ -0,0 +1,69 @@
/** WAI-ARIA menu item roles used by shared DropdownMenuItem. */
export const MENU_ITEM_SELECTOR =
'[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]';
export type MenuKeyAction =
| { type: "close" }
| { type: "focus"; index: number }
| { type: "none" };
/** Enabled menu items inside a menu root (skips aria/data-disabled). */
export function getMenuItems(container: ParentNode): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR)).filter(
(el) => el.getAttribute("aria-disabled") !== "true" && el.dataset.disabled === undefined
);
}
/** Index to focus when a menu opens (first enabled item, or -1). */
export function openMenuFocusIndex(itemCount: number): number {
return itemCount > 0 ? 0 : -1;
}
/**
* Pure keyboard → action map for an open menu (Arrow/Home/End/Escape).
* `currentIndex` may be -1 when nothing is focused yet.
*/
export function menuKeyAction(key: string, currentIndex: number, itemCount: number): MenuKeyAction {
if (key === "Escape") return { type: "close" };
if (itemCount <= 0) return { type: "none" };
const clamped = currentIndex < 0 || currentIndex >= itemCount ? -1 : currentIndex;
switch (key) {
case "ArrowDown":
return {
type: "focus",
index: clamped < 0 ? 0 : (clamped + 1) % itemCount
};
case "ArrowUp":
return {
type: "focus",
index: clamped < 0 ? itemCount - 1 : (clamped - 1 + itemCount) % itemCount
};
case "Home":
return { type: "focus", index: 0 };
case "End":
return { type: "focus", index: itemCount - 1 };
default:
return { type: "none" };
}
}
/** Keys that open a closed menu from the trigger (APG menu button). */
export function isMenuOpenKey(key: string): boolean {
return key === "ArrowDown" || key === "ArrowUp";
}
/**
* Case-insensitive substring filter over option labels (combobox / typeahead helpers).
* Empty query returns all items (same reference order).
*/
export function filterOptionsByQuery<T>(
items: readonly T[],
query: string,
getLabel: (item: T) => string
): T[] {
const q = query.trim().toLowerCase();
if (!q) return [...items];
return items.filter((item) => getLabel(item).toLowerCase().includes(q));
}
+11
View File
@@ -0,0 +1,11 @@
/** Move a node under `document.body` so it escapes overflow/transform ancestors. */
export function portal(node: HTMLElement, target: HTMLElement = document.body) {
target.appendChild(node);
return {
destroy() {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
}
};
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Activation checklist unit tests (node:test).
* Plan-gated optional store-connect + workspace cursor (no $lib / i18n).
*
* Run from apps/web:
* node --experimental-strip-types --test src/lib/activation.test.ts
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
activationIndexFromWorkspace,
visibleActivationSteps,
type ActivationStepRef
} from "./activation/workspace.ts";
const FULL_STEPS: ActivationStepRef[] = [
{ id: "enable-fields" },
{ id: "connect-source" },
{ id: "map" },
{ id: "sync-sample" },
{ id: "process" },
{ id: "store-connect", optional: true, feature: "stores.hub" },
{ id: "export" }
];
describe("visibleActivationSteps", () => {
it("includes store-connect when stores.hub is allowed", () => {
const steps = visibleActivationSteps(FULL_STEPS, () => true);
assert.ok(steps.some((s) => s.id === "store-connect"));
assert.equal(steps.find((s) => s.id === "store-connect")?.optional, true);
assert.equal(steps.find((s) => s.id === "store-connect")?.feature, "stores.hub");
});
it("hides store-connect when stores.hub is denied (A1-safe)", () => {
const steps = visibleActivationSteps(FULL_STEPS, (key) => key !== "stores.hub");
assert.equal(
steps.some((s) => s.id === "store-connect"),
false
);
assert.deepEqual(
steps.map((s) => s.id),
["enable-fields", "connect-source", "map", "sync-sample", "process", "export"]
);
});
it("places store-connect after process and before export", () => {
const ids = FULL_STEPS.map((s) => s.id);
assert.equal(ids.indexOf("store-connect"), ids.indexOf("process") + 1);
assert.equal(ids.indexOf("export"), ids.indexOf("store-connect") + 1);
});
});
describe("activationIndexFromWorkspace", () => {
const throughProcess = {
hasEnabledFields: true,
hasSource: true,
hasMapping: true,
hasSyncedSample: true,
hasProcessed: true
};
it("stops on optional store-connect when no store and no later required evidence", () => {
const steps = visibleActivationSteps(FULL_STEPS, () => true);
const count = activationIndexFromWorkspace(throughProcess, steps);
assert.equal(count, steps.findIndex((s) => s.id === "store-connect"));
});
it("does not block export when optional store is incomplete", () => {
const steps = visibleActivationSteps(FULL_STEPS, () => true);
const count = activationIndexFromWorkspace(
{ ...throughProcess, hasExport: true },
steps
);
assert.equal(count, steps.findIndex((s) => s.id === "export") + 1);
});
it("advances past store-connect when a store is connected", () => {
const steps = visibleActivationSteps(FULL_STEPS, () => true);
const count = activationIndexFromWorkspace(
{ ...throughProcess, hasStoreConnect: true },
steps
);
assert.equal(count, steps.findIndex((s) => s.id === "export"));
});
it("skips store evidence when step is gated out", () => {
const steps = visibleActivationSteps(FULL_STEPS, (key) => key !== "stores.hub");
const count = activationIndexFromWorkspace(
{ ...throughProcess, hasExport: true },
steps
);
assert.equal(count, steps.length);
});
});
describe("activation Continue destinations", () => {
/** Mirror of ACTIVATION_STEP_DEFS hrefs — keep in sync with activation/steps.ts. */
const CONTINUE_HREFS: Record<string, string> = {
"enable-fields": "/standard-fields",
"connect-source": "/feeds?add=1",
map: "/feeds?focus=map",
"sync-sample": "/feeds?focus=sync",
process: "/products?type=raw&status=unprocessed",
"store-connect": "/stores/wizard",
export: "/export-feeds"
};
it("keeps feed-first connect-source away from the stores wizard", () => {
assert.match(CONTINUE_HREFS["connect-source"], /^\/feeds/);
assert.doesNotMatch(CONTINUE_HREFS["connect-source"], /stores/);
assert.equal(CONTINUE_HREFS["store-connect"], "/stores/wizard");
});
it("matches the checklist sequence destinations", () => {
assert.deepEqual(
FULL_STEPS.map((s) => CONTINUE_HREFS[s.id]),
[
"/standard-fields",
"/feeds?add=1",
"/feeds?focus=map",
"/feeds?focus=sync",
"/products?type=raw&status=unprocessed",
"/stores/wizard",
"/export-feeds"
]
);
});
});
+19
View File
@@ -0,0 +1,19 @@
export {
ACTIVATION_STEPS,
ACTIVATION_STEP_DEFS,
visibleActivationSteps,
activationStepIndexById,
activationIndexFromTutorialStep,
activationIndexFromWorkspace,
type ActivationStep,
type ActivationWorkspaceEvidence
} from "./steps";
export {
ACTIVATION_STORAGE_KEY,
ACTIVATION_PROGRESS_VERSION,
readActivationProgress,
writeActivationProgress,
resolveActivationCursor,
type ActivationProgress,
type ActivationStatus
} from "./storage";
+134
View File
@@ -0,0 +1,134 @@
import { i18n } from "$lib/i18n";
import {
activationIndexFromWorkspace as indexFromWorkspace,
visibleActivationSteps as filterVisible,
type ActivationWorkspaceEvidence
} from "./workspace";
export type { ActivationWorkspaceEvidence } from "./workspace";
/** Checklist step shown on the dashboard (titles/bodies from i18n). */
export type ActivationStep = {
id: string;
title: string;
body: string;
href: string;
optional?: boolean;
feature?: string;
tutorialDoneIds: string[];
};
/**
* Destination / plan-gate definition without resolved copy.
* Titles/bodies resolve via i18n (`activation.step.<id>.title|body`).
*/
export type ActivationStepDef = {
id: string;
href: string;
optional?: boolean;
feature?: string;
tutorialDoneIds: string[];
};
/**
* First-value path on the dashboard checklist.
* Value path (plain language): catalog in → enrich/process → export/push to stores.
* Checklist sequence: enable-fields → Feeds → Map → Sync → Process → optional store-connect → export.
* Aligned with tutorial/steps.ts core path (stores-hub sits with export after process).
* tutorialDoneIds = tour steps that mean this checklist step is already past.
* Deep links: add=1 opens the connect dialog; focus=map|sync highlights the next action on Feeds.
* store-connect Continue opens the guided wizard when stores.hub is allowed.
* ACTIVATION_STEP_DEFS (no i18n) is for destination / plan-gate unit tests.
*/
export const ACTIVATION_STEP_DEFS: ActivationStepDef[] = [
{
id: "enable-fields",
href: "/standard-fields",
tutorialDoneIds: ["connect-source", "map", "sync-sample", "process", "store-connect", "export", "tour-done", "done"]
},
{
id: "connect-source",
href: "/feeds?add=1",
tutorialDoneIds: ["map", "sync-sample", "process", "store-connect", "export", "tour-done", "done"]
},
{
id: "map",
href: "/feeds?focus=map",
tutorialDoneIds: ["sync-sample", "process", "store-connect", "export", "tour-done", "done"]
},
{
id: "sync-sample",
href: "/feeds?focus=sync",
tutorialDoneIds: ["process", "store-connect", "export", "tour-done", "done"]
},
{
id: "process",
href: "/products?type=raw&status=unprocessed",
tutorialDoneIds: ["store-connect", "stores-hub", "export", "tour-done", "done"]
},
{
id: "store-connect",
href: "/stores/wizard",
optional: true,
feature: "stores.hub",
tutorialDoneIds: ["export", "tour-done", "done"]
},
{
id: "export",
href: "/export-feeds",
tutorialDoneIds: ["tour-done", "done"]
}
];
function activationStep(def: ActivationStepDef): ActivationStep {
return {
...def,
title: i18n.t(`activation.step.${def.id}.title`),
body: i18n.t(`activation.step.${def.id}.body`)
};
}
export const ACTIVATION_STEPS: ActivationStep[] = ACTIVATION_STEP_DEFS.map(activationStep);
/** Steps visible for the current plan (omit feature-gated steps the plan denies). */
export function visibleActivationSteps(
can: (featureKey: string) => boolean = () => true
): ActivationStep[] {
return filterVisible(ACTIVATION_STEPS, can);
}
export function activationStepIndexById(
id: string | null | undefined,
steps: ActivationStep[] = ACTIVATION_STEPS
): number {
if (!id) return 0;
const idx = steps.findIndex((s) => s.id === id);
return idx >= 0 ? idx : 0;
}
/** Furthest activation index completed given a tutorial step id (exclusive of current tour focus). */
export function activationIndexFromTutorialStep(
tutorialStepId: string | null | undefined,
steps: ActivationStep[] = ACTIVATION_STEPS
): number {
if (!tutorialStepId) return 0;
// Current tour focus (aligned id) is not completed yet.
if (steps.some((s) => s.id === tutorialStepId)) {
return activationStepIndexById(tutorialStepId, steps);
}
let furthest = 0;
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (step.tutorialDoneIds.includes(tutorialStepId)) {
furthest = i + 1;
}
}
return Math.min(furthest, steps.length);
}
export function activationIndexFromWorkspace(
evidence: ActivationWorkspaceEvidence | null | undefined,
steps: ActivationStep[] = ACTIVATION_STEPS
): number {
return indexFromWorkspace(evidence, steps);
}
+134
View File
@@ -0,0 +1,134 @@
import type { TutorialProgress, TutorialStatus } from "$lib/tutorial/types";
import {
ACTIVATION_STEPS,
activationIndexFromWorkspace,
type ActivationStep,
type ActivationWorkspaceEvidence
} from "./steps";
/** Same progress shape as tutorial/storage.ts (`TutorialProgress`). */
export type ActivationProgress = TutorialProgress;
export type ActivationStatus = TutorialStatus;
export const ACTIVATION_STORAGE_KEY = "descrybe.activation.v1";
export const ACTIVATION_PROGRESS_VERSION = 1;
const idleProgress = (): ActivationProgress => ({
version: ACTIVATION_PROGRESS_VERSION,
status: "idle",
stepId: null,
updatedAt: new Date().toISOString()
});
export function readActivationProgress(): ActivationProgress {
if (typeof localStorage === "undefined") return idleProgress();
try {
const raw = localStorage.getItem(ACTIVATION_STORAGE_KEY);
if (!raw) return idleProgress();
const parsed = JSON.parse(raw) as Partial<ActivationProgress>;
if (parsed.version !== ACTIVATION_PROGRESS_VERSION) return idleProgress();
const status = parsed.status;
if (
status !== "idle" &&
status !== "in_progress" &&
status !== "completed" &&
status !== "skipped"
) {
return idleProgress();
}
return {
version: ACTIVATION_PROGRESS_VERSION,
status,
stepId: typeof parsed.stepId === "string" ? parsed.stepId : null,
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString()
};
} catch {
return idleProgress();
}
}
export function writeActivationProgress(
status: ActivationStatus,
stepId: string | null
): ActivationProgress {
const next: ActivationProgress = {
version: ACTIVATION_PROGRESS_VERSION,
status,
stepId,
updatedAt: new Date().toISOString()
};
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem(ACTIVATION_STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota / private mode */
}
}
return next;
}
/**
* Resolve the checklist cursor, preferring activation storage and advancing from
* live workspace evidence when further along. Pass `steps` from
* `visibleActivationSteps` so plan-gated optional steps stay out of the cursor.
*/
export function resolveActivationCursor(
progress: ActivationProgress = readActivationProgress(),
workspace?: ActivationWorkspaceEvidence | null,
steps: ActivationStep[] = ACTIVATION_STEPS
): {
progress: ActivationProgress;
currentIndex: number;
completedCount: number;
} {
if (progress.status === "completed") {
return {
progress,
currentIndex: steps.length,
completedCount: steps.length
};
}
if (progress.status === "skipped") {
const storedIdx = progress.stepId ? steps.findIndex((s) => s.id === progress.stepId) : -1;
const index = storedIdx >= 0 ? storedIdx : 0;
const fromWorkspace = activationIndexFromWorkspace(workspace, steps);
const completedCount = Math.max(index, fromWorkspace);
return { progress, currentIndex: index, completedCount };
}
const storedIdx =
progress.status === "idle" || !progress.stepId
? -1
: steps.findIndex((s) => s.id === progress.stepId);
let index = storedIdx >= 0 ? storedIdx : 0;
// Demo tour progress must not fake checklist completion — workspace evidence only.
const fromWorkspace = activationIndexFromWorkspace(workspace, steps);
if (fromWorkspace > index) index = fromWorkspace;
// Gated-out step id (e.g. store-connect when stores.hub denied) → use workspace cursor.
if (progress.stepId && storedIdx < 0 && fromWorkspace > 0) {
index = fromWorkspace;
}
if (index >= steps.length) {
const completed = writeActivationProgress("completed", steps.at(-1)?.id ?? "export");
return {
progress: completed,
currentIndex: steps.length,
completedCount: steps.length
};
}
const stepId = steps[index]?.id ?? steps[0]?.id ?? null;
if (
stepId &&
(progress.status === "idle" ||
(progress.status === "in_progress" && progress.stepId !== stepId))
) {
const next = writeActivationProgress("in_progress", stepId);
return { progress: next, currentIndex: index, completedCount: index };
}
return { progress, currentIndex: index, completedCount: index };
}
+73
View File
@@ -0,0 +1,73 @@
/** Live workspace signals used to advance the checklist without relying only on localStorage. */
export type ActivationWorkspaceEvidence = {
hasEnabledFields?: boolean;
hasSource?: boolean;
hasMapping?: boolean;
hasSyncedSample?: boolean;
hasProcessed?: boolean;
hasStoreConnect?: boolean;
hasExport?: boolean;
};
/** Minimal step shape for gating / workspace cursor (no i18n). */
export type ActivationStepRef = {
id: string;
optional?: boolean;
feature?: string;
};
/** Steps visible for the current plan (omit feature-gated steps the plan denies). */
export function visibleActivationSteps<T extends ActivationStepRef>(
steps: T[],
can: (featureKey: string) => boolean = () => true
): T[] {
return steps.filter((s) => !s.feature || can(s.feature));
}
const evidenceForStep: Record<
string,
(evidence: ActivationWorkspaceEvidence) => boolean | undefined
> = {
"enable-fields": (e) => e.hasEnabledFields,
"connect-source": (e) => e.hasSource,
map: (e) => e.hasMapping,
"sync-sample": (e) => e.hasSyncedSample,
process: (e) => e.hasProcessed,
"store-connect": (e) => e.hasStoreConnect,
export: (e) => e.hasExport
};
function stepEvidenceDone(step: ActivationStepRef, evidence: ActivationWorkspaceEvidence): boolean {
const getter = evidenceForStep[step.id];
return getter ? Boolean(getter(evidence)) : false;
}
/**
* Contiguous completed-step count from workspace state (stop at first incomplete).
* Optional steps do not block later required evidence (e.g. export without a store).
*/
export function activationIndexFromWorkspace(
evidence: ActivationWorkspaceEvidence | null | undefined,
steps: ActivationStepRef[]
): number {
if (!evidence) return 0;
let count = 0;
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (stepEvidenceDone(step, evidence)) {
count += 1;
continue;
}
if (step.optional) {
const laterRequiredDone = steps
.slice(i + 1)
.some((s) => !s.optional && stepEvidenceDone(s, evidence));
if (laterRequiredDone) {
count += 1;
continue;
}
}
break;
}
return Math.min(count, steps.length);
}
+256
View File
@@ -0,0 +1,256 @@
/**
* Platform multi-AI role configs - agreed with backend AI schema agent.
*
* Roles: processing | vectorization | docs_api | support
* Fields: provider, base_url, api_key (secret), model, enabled, optional extras
*
* API (nested under platform settings):
* GET /api/admin/settings -> { ..., ai_roles: Record<role, Public> }
* PUT /api/admin/settings -> { ai_roles?: Partial<Record<role, Update>> }
* POST /api/admin/settings/ai-roles/{role}/test -> probe (ok|failed|skipped; 404 on older APIs)
*
* Secrets: never echo GET into password fields; blank keep; clear_api_key clears.
* Legacy openai maps to processing when ai_roles.processing is absent.
*/
import { api, ApiError } from "./api";
import {
PLATFORM_SETTINGS_PATH,
maskHint,
savePlatformAdminSettings,
type PlatformAdminSettings,
type PlatformOpenAIPublic
} from "./admin-platform-settings";
export const AI_ROLES = ["processing", "vectorization", "docs_api", "support"] as const;
export type AIRole = (typeof AI_ROLES)[number];
export const AI_ROLE_META: Record<
AIRole,
{ label: string; description: string; modelPlaceholder: string }
> = {
processing: {
label: "Processing",
description: "Product pipeline chat/completions (titles, descriptions, enhance).",
modelPlaceholder: "gpt-4o-mini"
},
vectorization: {
label: "Vectorization",
description: "Embeddings for search / Pinecone indexing (match index dimensions).",
modelPlaceholder: "text-embedding-3-small"
},
docs_api: {
label: "Docs / API",
description:
"Future docs/API assistant slot — /docs Ask stays rule-based and must never call this role.",
modelPlaceholder: "gpt-4o-mini"
},
support: {
label: "Support",
description:
"Ticket auto-reply AI fallback — configure provider/key/model here; enable delivery in Support knowledge → Auto-reply.",
modelPlaceholder: "gpt-4o-mini"
}
};
/** Common OpenAI-compatible provider labels for the admin select. */
export const AI_PROVIDER_OPTIONS = [
{ value: "openai", label: "OpenAI" },
{ value: "openrouter", label: "OpenRouter" },
{ value: "azure", label: "Azure OpenAI" },
{ value: "ollama", label: "Ollama" },
{ value: "custom", label: "Custom / other" }
] as const;
export type PlatformAIRolePublic = {
role?: AIRole | string;
provider?: string;
base_url?: string;
model?: string;
enabled?: boolean;
configured?: boolean;
has_api_key?: boolean;
api_key_last4?: string;
api_key_masked?: string;
source?: "db" | "env" | "none" | string;
/** Optional free-form string bag (dimensions, timeout, …). */
extras?: Record<string, string>;
};
export type PlatformAIRoleUpdate = {
provider?: string;
base_url?: string;
model?: string;
enabled?: boolean;
api_key?: string;
clear_api_key?: boolean;
extras?: Record<string, string | null>;
};
export type PlatformAIRolesMap = Partial<Record<AIRole, PlatformAIRolePublic>>;
export type PlatformAIRolesUpdate = Partial<Record<AIRole, PlatformAIRoleUpdate>>;
export type AIRoleFormState = {
provider: string;
baseURL: string;
model: string;
enabled: boolean;
apiKey: string;
hasKey: boolean;
keyMasked: string;
clearKey: boolean;
source: string;
/** Embeddings dimensions (vectorization extras.dimensions). */
dimensions: string;
};
export function emptyAIRoleForm(): AIRoleFormState {
return {
provider: "openai",
baseURL: "",
model: "",
enabled: false,
apiKey: "",
hasKey: false,
keyMasked: "",
clearKey: false,
source: "",
dimensions: ""
};
}
export function roleFromPublic(pub: PlatformAIRolePublic | undefined): AIRoleFormState {
const form = emptyAIRoleForm();
if (!pub) return form;
form.provider = pub.provider?.trim() || "openai";
form.baseURL = pub.base_url ?? "";
form.model = pub.model ?? "";
form.enabled = Boolean(pub.enabled ?? pub.configured ?? pub.has_api_key);
form.hasKey = Boolean(pub.has_api_key);
form.keyMasked = maskHint(form.hasKey, pub.api_key_masked, pub.api_key_last4);
form.source = pub.source ?? "";
form.apiKey = "";
form.clearKey = false;
form.dimensions = pub.extras?.dimensions ?? "";
return form;
}
/** Map legacy platform openai section into the processing role form. */
export function roleFromLegacyOpenAI(openai: PlatformOpenAIPublic | undefined): AIRoleFormState {
const form = emptyAIRoleForm();
if (!openai) return form;
form.provider = "openai";
form.baseURL = openai.base_url ?? "";
form.model = openai.model ?? "";
form.enabled = Boolean(openai.configured || openai.has_api_key);
form.hasKey = Boolean(openai.has_api_key);
form.keyMasked = maskHint(form.hasKey, openai.api_key_masked, openai.api_key_last4);
form.source = openai.source ?? "";
form.apiKey = "";
form.clearKey = false;
return form;
}
export function extractAIRoles(settings: PlatformAdminSettings): PlatformAIRolesMap {
const raw = settings.ai_roles;
if (!raw || typeof raw !== "object") return {};
const out: PlatformAIRolesMap = {};
for (const role of AI_ROLES) {
const entry = raw[role];
if (entry && typeof entry === "object") out[role] = entry;
}
return out;
}
export function buildRoleForms(settings: PlatformAdminSettings): Record<AIRole, AIRoleFormState> {
const roles = extractAIRoles(settings);
const forms = {} as Record<AIRole, AIRoleFormState>;
for (const role of AI_ROLES) {
if (roles[role]) {
forms[role] = roleFromPublic(roles[role]);
} else if (role === "processing") {
forms[role] = roleFromLegacyOpenAI(settings.openai);
} else {
forms[role] = emptyAIRoleForm();
}
}
return forms;
}
export function formToUpdate(form: AIRoleFormState, role: AIRole): PlatformAIRoleUpdate {
const update: PlatformAIRoleUpdate = {
provider: form.provider.trim() || "custom",
base_url: form.baseURL.trim(),
model: form.model.trim(),
enabled: form.enabled,
api_key: form.apiKey.trim() || undefined,
clear_api_key: form.clearKey
};
if (role === "vectorization") {
const dim = form.dimensions.trim();
update.extras = { dimensions: dim ? dim : null };
}
return update;
}
/**
* Save one or more AI roles via PUT /api/admin/settings { ai_roles }.
* When only processing is sent and the API ignores ai_roles, also mirror to openai
* so legacy backends keep working during cutover.
*/
export async function saveAIRoles(
roles: PlatformAIRolesUpdate,
opts?: { mirrorProcessingToOpenAI?: boolean }
): Promise<PlatformAdminSettings> {
const body: {
ai_roles: PlatformAIRolesUpdate;
openai?: {
base_url?: string;
model?: string;
api_key?: string;
clear_api_key?: boolean;
};
} = { ai_roles: roles };
if (opts?.mirrorProcessingToOpenAI !== false && roles.processing) {
const p = roles.processing;
body.openai = {
base_url: p.base_url,
model: p.model,
api_key: p.api_key,
clear_api_key: p.clear_api_key
};
}
return savePlatformAdminSettings(body);
}
export const AI_ROLE_TEST_PATH = (role: AIRole) =>
`${PLATFORM_SETTINGS_PATH}/ai-roles/${encodeURIComponent(role)}/test`;
export type PlatformAIRoleTestResult = {
status: "ok" | "failed" | "skipped" | string;
message: string;
role?: string;
};
export async function testAIRole(role: AIRole): Promise<PlatformAIRoleTestResult> {
try {
return await api<PlatformAIRoleTestResult>(AI_ROLE_TEST_PATH(role), {
method: "POST",
body: {}
});
} catch (err) {
if (err instanceof ApiError && (err.status === 404 || err.status === 501)) {
return {
status: "skipped",
message: "Connection test is not available on this API build.",
role
};
}
throw err;
}
}
export function roleConfigured(form: AIRoleFormState): boolean {
return form.hasKey || Boolean(form.baseURL.trim() && form.model.trim());
}
+314
View File
@@ -0,0 +1,314 @@
/**
* Admin billing plans helpers — list filters, visibility badges, upsert/assign API.
* Mirrors apps/api/internal/billing IsPublicProductPlan + migrated client deals (A1, …).
*/
import { api } from "$lib/api";
import { i18n } from "$lib/i18n";
import { isDefaultPublicPlanName } from "$lib/plan-feature-catalog";
import { formatCredits } from "$lib/utils";
export const ADMIN_PLANS_PATH = "/api/admin/plans";
export const ADMIN_ASSIGN_PLAN_PATH = "/api/admin/plans/assign";
/** Shared in-flight GET so keep-mounted billing tabs do not double-fetch the plans list. */
let adminPlansListInflight: Promise<AdminBillingPlan[]> | null = null;
/** Shared in-flight GET so billing cold load / remount races do not double-fetch companies. */
let adminCompaniesListInflight: Promise<AdminBillingCompany[]> | null = null;
/** Brief resolved caches — covers sequential remount after a fast GET completes (~9ms). */
let adminPlansListCache: { at: number; plans: AdminBillingPlan[] } | null = null;
let adminCompaniesListCache: { at: number; companies: AdminBillingCompany[] } | null = null;
const ADMIN_LIST_CACHE_MS = 1000;
export type AdminBillingPlan = {
id: number | string;
name: string;
description?: string | null;
monthly_credits?: number;
yearly_credits?: number | null;
max_products?: number | null;
is_custom?: boolean;
term?: string;
features?: Record<string, boolean>;
resolved_features?: Record<string, boolean>;
};
export type AdminBillingCompany = {
id: string;
name: string;
language?: string;
created_at?: string;
total_credits?: number;
used_credits?: number;
has_active_plan?: boolean;
};
/**
* GET /api/admin/plans with in-flight dedupe (no AbortSignal).
* Billing page + PlanPermissionsPanel may request the list concurrently on cold load.
*/
export async function fetchAdminPlansList(signal?: AbortSignal): Promise<AdminBillingPlan[]> {
if (!signal) {
if (adminPlansListInflight) return adminPlansListInflight;
if (adminPlansListCache && Date.now() - adminPlansListCache.at < ADMIN_LIST_CACHE_MS) {
return adminPlansListCache.plans;
}
}
const run = (async () => {
const body = await api<{ plans: AdminBillingPlan[] }>(ADMIN_PLANS_PATH, { signal });
const plans = Array.isArray(body?.plans) ? body.plans : [];
if (!signal) adminPlansListCache = { at: Date.now(), plans };
return plans;
})();
if (!signal) {
adminPlansListInflight = run;
void run.finally(() => {
if (adminPlansListInflight === run) adminPlansListInflight = null;
});
}
return run;
}
export const ADMIN_COMPANIES_PATH = "/api/admin/companies";
/**
* GET /api/admin/companies with in-flight dedupe (no AbortSignal).
* Billing summary cards + Companies tab share one cold-load fetch.
* Short resolved cache absorbs remount storms after the fast companies GET settles
* while the slower plans GET is still in flight.
*/
export async function fetchAdminCompaniesList(
signal?: AbortSignal
): Promise<AdminBillingCompany[]> {
if (!signal) {
if (adminCompaniesListInflight) return adminCompaniesListInflight;
if (
adminCompaniesListCache &&
Date.now() - adminCompaniesListCache.at < ADMIN_LIST_CACHE_MS
) {
return adminCompaniesListCache.companies;
}
}
const run = (async () => {
const body = await api<{ companies: AdminBillingCompany[] }>(ADMIN_COMPANIES_PATH, {
signal
});
const companies = Array.isArray(body?.companies) ? body.companies : [];
if (!signal) adminCompaniesListCache = { at: Date.now(), companies };
return companies;
})();
if (!signal) {
adminCompaniesListInflight = run;
void run.finally(() => {
if (adminCompaniesListInflight === run) adminCompaniesListInflight = null;
});
}
return run;
}
/** Drop list caches after billing mutations so reload() sees fresh rows. */
export function invalidateAdminBillingLists(): void {
adminPlansListCache = null;
adminCompaniesListCache = null;
}
/** Public ladder / legacy deal / custom client package / retained catalog / junk. */
export type AdminPlanVisibility = "public" | "legacy" | "custom" | "hidden";
export type AdminPlanFilter = "all" | "catalog" | AdminPlanVisibility;
export function isPublicAdminPlanName(name: string | null | undefined): boolean {
return isDefaultPublicPlanName(name);
}
/**
* Ephemeral integration-test plan rows (consume-contention-*, claim-test-plan-*, multi-plan-*).
* Keep in DB if assigned, but hide from the default admin catalog filter.
*/
export function isEphemeralTestPlanName(name: string | null | undefined): boolean {
const n = (name ?? "").trim().toLowerCase();
if (!n) return false;
return (
n.startsWith("consume-contention-") ||
n.startsWith("claim-test-plan-") ||
n.startsWith("multi-plan-")
);
}
/** Pre-v2 ladder leftovers that must never appear on Choose your plan. */
export function isObsoleteLadderPlanName(name: string | null | undefined): boolean {
const n = (name ?? "").trim().toLowerCase();
return (
n === "basic" ||
n === "professional" ||
n === "mini" ||
n === "merkur" ||
n === "meur" ||
n === "merkur trial"
);
}
/**
* Plans kept for product/ops: public ladder + A1 + Legacy + Platform Demo.
* Everything else (obsolete ladder, ephemeral tests) is "hidden" in the catalog filter.
*/
export function isRetainedCatalogPlanName(name: string | null | undefined): boolean {
if (isPublicAdminPlanName(name)) return true;
if (isLegacyPlanName(name)) return true;
const n = (name ?? "").trim().toLowerCase();
return n === "platform demo";
}
/**
* Migrated / pre-v2 package names treated as Legacy (limited nav matrix).
* Aligned with Go billing.IsLegacyPlanName: exact "legacy", A1*, or "a1 slovenija".
* Broader hidden ladder names (Basic, Merkur, …) stay public/custom via is_custom /
* public name checks — not forced into Legacy badges.
*/
export function isLegacyPlanName(name: string | null | undefined): boolean {
const n = (name ?? "").trim().toLowerCase();
if (!n) return false;
if (n === "legacy") return true;
if (n.includes("a1 slovenija")) return true;
if (n === "a1" || n.startsWith("a1 ") || n.startsWith("a1-") || n.startsWith("a1_")) return true;
return false;
}
/**
* Badge kind for admin plans table.
* Priority: public ladder → legacy migrated names → custom deals (incl. is_custom non-ladder).
*/
export function classifyAdminPlanVisibility(
plan: Pick<AdminBillingPlan, "name" | "is_custom"> | null | undefined
): AdminPlanVisibility {
if (!plan?.name?.trim()) return "custom";
if (isEphemeralTestPlanName(plan.name) || isObsoleteLadderPlanName(plan.name)) {
return "hidden";
}
if (isPublicAdminPlanName(plan.name)) return "public";
// A1 PAYG (is_custom) is a client deal matrix, not restricted Legacy.
if (isLegacyPlanName(plan.name)) return plan.is_custom ? "custom" : "legacy";
return "custom";
}
export function adminPlanVisibilityLabel(kind: AdminPlanVisibility): string {
switch (kind) {
case "public":
return i18n.t("admin.plans.visibility.public");
case "legacy":
return i18n.t("admin.plans.visibility.legacy");
case "hidden":
return i18n.t("admin.plans.visibility.hidden");
default:
return i18n.t("admin.plans.visibility.custom");
}
}
export function adminPlanVisibilityBadgeVariant(
kind: AdminPlanVisibility
): "outline" | "warning" | "secondary" {
switch (kind) {
case "public":
return "outline";
case "legacy":
return "warning";
default:
return "secondary";
}
}
export function filterAdminPlans(
plans: AdminBillingPlan[],
opts: { filter?: AdminPlanFilter; search?: string }
): AdminBillingPlan[] {
const filter = opts.filter ?? "catalog";
const q = (opts.search ?? "").trim().toLowerCase();
return plans.filter((p) => {
const kind = classifyAdminPlanVisibility(p);
if (filter === "catalog") {
if (kind === "hidden") return false;
} else if (filter !== "all" && kind !== filter) {
return false;
}
if (!q) return true;
const hay = `${p.name} ${p.description ?? ""} ${p.term ?? ""}`.toLowerCase();
return hay.includes(q);
});
}
export function countAdminPlansByVisibility(plans: AdminBillingPlan[]): Record<AdminPlanFilter, number> {
const counts: Record<AdminPlanFilter, number> = {
all: plans.length,
catalog: 0,
public: 0,
legacy: 0,
custom: 0,
hidden: 0
};
for (const p of plans) {
const kind = classifyAdminPlanVisibility(p);
counts[kind] += 1;
if (kind !== "hidden") counts.catalog += 1;
}
return counts;
}
export function maxProductsLabel(plan: Pick<AdminBillingPlan, "max_products">): string {
if (plan.max_products == null) return i18n.t("admin.plans.unlimited");
return formatCredits(Number(plan.max_products));
}
export function planOptionLabel(plan: AdminBillingPlan): string {
const credits = formatCredits(Number(plan.monthly_credits ?? 0));
const kind = adminPlanVisibilityLabel(classifyAdminPlanVisibility(plan));
return i18n.t("admin.plans.optionLabel", { name: plan.name, credits, kind });
}
export type UpsertAdminPlanInput = {
id?: number;
name: string;
description?: string | null;
monthly_credits: number;
yearly_credits?: number | null;
max_products?: number | null;
is_custom: boolean;
term?: string;
};
export async function upsertAdminPlan(input: UpsertAdminPlanInput): Promise<AdminBillingPlan> {
const body: Record<string, unknown> = {
name: input.name.trim(),
monthly_credits: Number(input.monthly_credits),
is_custom: Boolean(input.is_custom),
term: (input.term || "monthly").trim() || "monthly"
};
if (input.id != null && input.id > 0) body.id = input.id;
const desc = input.description == null ? "" : String(input.description).trim();
body.description = desc === "" ? null : desc;
// Always send nullable caps so edits can clear yearly / max_products back to unlimited.
body.yearly_credits =
input.yearly_credits != null && Number.isFinite(Number(input.yearly_credits))
? Number(input.yearly_credits)
: null;
body.max_products =
input.max_products != null && Number.isFinite(Number(input.max_products))
? Number(input.max_products)
: null;
return api<AdminBillingPlan>(ADMIN_PLANS_PATH, { method: "POST", body });
}
export async function assignAdminPlan(opts: {
company_id: string;
plan_id: number;
is_trial?: boolean;
trial_credits?: number;
}): Promise<void> {
await api(ADMIN_ASSIGN_PLAN_PATH, {
method: "POST",
body: {
company_id: opts.company_id,
plan_id: opts.plan_id,
is_trial: Boolean(opts.is_trial),
trial_credits: Number(opts.trial_credits ?? 0)
}
});
}
+229
View File
@@ -0,0 +1,229 @@
/**
* Admin diagnostics client — GET /api/admin/diagnostics
* Operational health only (no secrets). Distinct from /admin/analytics marketing charts.
*/
import { api, ApiError } from "$lib/api";
export const ADMIN_DIAGNOSTICS_PATH = "/api/admin/diagnostics";
export type DiagCheckStatus = "ok" | "warn" | "fail" | "skip" | string;
export type DiagCheck = {
name: string;
status: DiagCheckStatus;
detail?: string;
latency_ms?: number;
enabled?: boolean;
configured?: boolean;
dry_run?: boolean;
host_set?: boolean;
};
export type DiagQueue = {
driver?: string;
by_status?: Record<string, number>;
total?: number;
stuck_running?: number;
failed?: number;
running?: number;
pending?: number;
completed?: number;
cancelled?: number;
[key: string]: unknown;
};
export type DiagJobFailure = {
id: string;
company_id: string;
status: string;
total_products?: number;
processed_products?: number;
error?: string;
created_at?: string;
updated_at?: string;
};
export type DiagAIFailure = {
id: string;
ticket_id: string;
company_id: string;
kind: string;
created_at?: string;
};
export type DiagConfigSanity = {
app_env?: string;
maintenance_mode?: boolean;
read_only_mode?: boolean;
session_secure?: boolean;
smtp_enabled?: boolean;
email_dry_run?: boolean;
smtp_host_set?: boolean;
stripe_mock?: boolean;
eprel_enabled?: boolean;
processing_rpm?: number;
processing_batch_size?: number;
processing_max_retries?: number;
upload_dir_configured?: boolean;
trusted_proxies_configured?: boolean;
web_origin_set?: boolean;
public_api_url_set?: boolean;
token_signing_secret_set?: boolean;
openai_key_set?: boolean;
pinecone_key_set?: boolean;
stripe_secret_set?: boolean;
stripe_webhook_secret_set?: boolean;
stripe_mock_rejected_in_prod?: boolean;
credentials_encryption_key_set?: boolean;
[key: string]: unknown;
};
export type DiagCutoverGoose = {
status?: DiagCheckStatus;
detail?: string;
version_max?: number;
expected_min?: number;
required?: Record<string, boolean>;
};
export type DiagCutoverWorker = {
status?: DiagCheckStatus | "missing" | "stale" | "unavailable";
detail?: string;
last_seen_age_s?: number;
stale_after_s?: number;
};
export type DiagCutover = {
status?: DiagCheckStatus;
detail?: string;
goose?: DiagCutoverGoose;
worker?: DiagCutoverWorker;
companies_without_plan?: number;
/** Companies with zero non-revoked api_keys (reissue inventory; keys never ETL'd). */
companies_without_api_keys?: number;
};
/** Read-only ETL gap COUNTs (blobs metadata-only + jobs/history). Not an import path. */
export type DiagMigrationInventory = {
status?: DiagCheckStatus;
detail?: string;
files_total?: number;
files_metadata_only?: number;
processing_jobs_total?: number;
processing_jobs_migrated?: number;
tasks_total?: number;
jobs_domain_ran?: boolean;
notes?: string[];
};
export type AdminDiagnostics = {
status: "ok" | "degraded" | "fail" | string;
generated_at?: string;
checks: DiagCheck[];
queue: DiagQueue;
cutover?: DiagCutover;
migration_inventory?: DiagMigrationInventory;
config: DiagConfigSanity;
recent_failures: DiagJobFailure[];
recent_ai_failures?: DiagAIFailure[];
filters?: { status?: string; failures_limit?: number };
links?: Record<string, string>;
notes?: string[];
};
export type LoadDiagnosticsOpts = {
status?: string;
failuresLimit?: number;
};
/** True when the Go API returned a JSON error envelope (not HTML/proxy text). */
function isApiJsonErrorBody(body: unknown): boolean {
if (!body || typeof body !== "object") return false;
const rec = body as Record<string, unknown>;
return typeof rec.error === "string" || typeof rec.message === "string";
}
/**
* Endpoint missing on the API (chi JSON 404/501).
* Do NOT treat SPA/Vite HTML 404s or proxy text as "not on this deployment" —
* those are misconfig/reachability issues and must surface as load failures.
*/
export function isDiagnosticsUnavailable(err: unknown): boolean {
return (
err instanceof ApiError &&
(err.status === 404 || err.status === 501) &&
isApiJsonErrorBody(err.body)
);
}
export function isDiagnosticsRateLimited(err: unknown): boolean {
return err instanceof ApiError && err.status === 429;
}
/** Non-JSON 404/502/etc. — usually proxy down or PUBLIC_API_URL misaligned. */
export function isDiagnosticsUnreachable(err: unknown): boolean {
if (!(err instanceof ApiError)) return false;
if (err.status === 502 || err.status === 503 || err.status === 504) return true;
if ((err.status === 404 || err.status === 501) && !isApiJsonErrorBody(err.body)) return true;
return false;
}
export async function loadAdminDiagnostics(opts: LoadDiagnosticsOpts = {}): Promise<AdminDiagnostics> {
const params = new URLSearchParams();
const status = (opts.status ?? "").trim().toLowerCase();
if (status && status !== "all") params.set("status", status);
if (opts.failuresLimit && opts.failuresLimit > 0) {
params.set("failures_limit", String(Math.min(opts.failuresLimit, 50)));
}
const q = params.toString();
const path = q ? `${ADMIN_DIAGNOSTICS_PATH}?${q}` : ADMIN_DIAGNOSTICS_PATH;
return api<AdminDiagnostics>(path);
}
export function checkStatusVariant(
status: string
): "success" | "warning" | "destructive" | "secondary" | "outline" {
switch (String(status).toLowerCase()) {
case "ok":
case "ready":
return "success";
case "warn":
case "degraded":
case "warning":
case "missing":
case "stale":
case "unavailable":
return "warning";
case "fail":
case "failed":
case "error":
return "destructive";
case "skip":
return "secondary";
default:
return "outline";
}
}
/** Config keys that are boolean presence/flag indicators (safe to show as Yes/No). */
export const CONFIG_FLAG_LABELS: { key: keyof DiagConfigSanity; labelKey: string }[] = [
{ key: "maintenance_mode", labelKey: "admin.diagnostics.config.maintenance_mode" },
{ key: "read_only_mode", labelKey: "admin.diagnostics.config.read_only_mode" },
{ key: "session_secure", labelKey: "admin.diagnostics.config.session_secure" },
{ key: "smtp_enabled", labelKey: "admin.diagnostics.config.smtp_enabled" },
{ key: "email_dry_run", labelKey: "admin.diagnostics.config.email_dry_run" },
{ key: "smtp_host_set", labelKey: "admin.diagnostics.config.smtp_host_set" },
{ key: "stripe_mock", labelKey: "admin.diagnostics.config.stripe_mock" },
{ key: "eprel_enabled", labelKey: "admin.diagnostics.config.eprel_enabled" },
{ key: "upload_dir_configured", labelKey: "admin.diagnostics.config.upload_dir_configured" },
{ key: "trusted_proxies_configured", labelKey: "admin.diagnostics.config.trusted_proxies_configured" },
{ key: "web_origin_set", labelKey: "admin.diagnostics.config.web_origin_set" },
{ key: "public_api_url_set", labelKey: "admin.diagnostics.config.public_api_url_set" },
{ key: "token_signing_secret_set", labelKey: "admin.diagnostics.config.token_signing_secret_set" },
{ key: "openai_key_set", labelKey: "admin.diagnostics.config.openai_key_set" },
{ key: "pinecone_key_set", labelKey: "admin.diagnostics.config.pinecone_key_set" },
{ key: "stripe_secret_set", labelKey: "admin.diagnostics.config.stripe_secret_set" },
{ key: "stripe_webhook_secret_set", labelKey: "admin.diagnostics.config.stripe_webhook_secret_set" },
{ key: "stripe_mock_rejected_in_prod", labelKey: "admin.diagnostics.config.stripe_mock_rejected_in_prod" },
{ key: "credentials_encryption_key_set", labelKey: "admin.diagnostics.config.credentials_encryption_key_set" }
];
+71
View File
@@ -0,0 +1,71 @@
import { api, ApiError, failureMessage } from "$lib/api";
import type { MeResponse, StaffAccess } from "$lib/types";
export type AdminGateResult =
| { ok: true; me: MeResponse; staff: StaffAccess }
| { ok: false; reason: "auth" | "forbidden" | "error"; message: string };
function resolveStaff(me: MeResponse): StaffAccess {
if (me.staff_access) {
return {
staff_role: me.staff_access.staff_role,
full_admin: Boolean(me.staff_access.full_admin),
support_desk: Boolean(me.staff_access.support_desk),
is_support_only: Boolean(me.staff_access.is_support_only)
};
}
// Legacy fallback when staff_access is absent (pre-migration clients).
const full = Boolean(me.user?.is_platform_admin);
return {
full_admin: full,
support_desk: full,
is_support_only: false
};
}
export async function requirePlatformAdmin(): Promise<AdminGateResult> {
try {
const me = await api<MeResponse>("/api/auth/me");
const staff = resolveStaff(me);
if (!staff.full_admin) {
return { ok: false, reason: "forbidden", message: "Platform admin required." };
}
return { ok: true, me, staff };
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
return { ok: false, reason: "auth", message: "Authentication required." };
}
if (err instanceof ApiError && err.status === 403) {
return { ok: false, reason: "forbidden", message: "Platform admin required." };
}
return {
ok: false,
reason: "error",
message: failureMessage(err, "Failed to verify admin access")
};
}
}
/** Full admin or support_staff — for /admin/support desk pages. */
export async function requireSupportDesk(): Promise<AdminGateResult> {
try {
const me = await api<MeResponse>("/api/auth/me");
const staff = resolveStaff(me);
if (!staff.support_desk) {
return { ok: false, reason: "forbidden", message: "Support desk access required." };
}
return { ok: true, me, staff };
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
return { ok: false, reason: "auth", message: "Authentication required." };
}
if (err instanceof ApiError && err.status === 403) {
return { ok: false, reason: "forbidden", message: "Support desk access required." };
}
return {
ok: false,
reason: "error",
message: failureMessage(err, "Failed to verify support access")
};
}
}
+17
View File
@@ -0,0 +1,17 @@
/** Admin shell mobile drawer (separate from dashboard `navUi`). */
let mobileOpen = $state(false);
export const adminNavUi = {
get mobileOpen() {
return mobileOpen;
},
openMobile() {
mobileOpen = true;
},
closeMobile() {
mobileOpen = false;
},
toggleMobile() {
mobileOpen = !mobileOpen;
}
};
+91
View File
@@ -0,0 +1,91 @@
/** Admin nav IA — single source for shell chrome labels (mobile bar, docs). */
export type AdminNavGroupId =
| "overview"
| "directory"
| "support"
| "ops"
| "commerce"
| "system";
export type AdminNavRoute = {
/** i18n key under `admin.nav.*` */
titleKey: string;
href: string;
group: AdminNavGroupId;
fullAdminOnly?: boolean;
};
export const ADMIN_NAV_ROUTES: readonly AdminNavRoute[] = [
{ titleKey: "admin.nav.commandCenter", href: "/admin", group: "overview" },
{ titleKey: "admin.nav.analytics", href: "/admin/analytics", group: "overview", fullAdminOnly: true },
{ titleKey: "admin.nav.usersOrgs", href: "/admin/users", group: "directory", fullAdminOnly: true },
{ titleKey: "admin.nav.tickets", href: "/admin/support", group: "support" },
{
titleKey: "admin.nav.knowledge",
href: "/admin/support/knowledge",
group: "support",
fullAdminOnly: true
},
{
titleKey: "admin.nav.diagnostics",
href: "/admin/diagnostics",
group: "ops",
fullAdminOnly: true
},
{
titleKey: "admin.nav.stuckProducts",
href: "/admin/stuck-products",
group: "ops",
fullAdminOnly: true
},
{
titleKey: "admin.nav.orphanProcessed",
href: "/admin/orphan-processed",
group: "ops",
fullAdminOnly: true
},
{
titleKey: "admin.nav.storeReconnect",
href: "/admin/store-reconnect",
group: "ops",
fullAdminOnly: true
},
{ titleKey: "admin.nav.billing", href: "/admin/billing", group: "commerce", fullAdminOnly: true },
{ titleKey: "admin.nav.sales", href: "/admin/sales", group: "commerce", fullAdminOnly: true },
{
titleKey: "admin.nav.translations",
href: "/admin/translations",
group: "system",
fullAdminOnly: true
},
{ titleKey: "admin.nav.settings", href: "/admin/settings", group: "system", fullAdminOnly: true }
] as const;
/** Matches AdminNav aside width (`w-[15.5rem]`). */
export const ADMIN_SIDEBAR_WIDTH_CLASS = "lg:ml-[15.5rem]";
export const ADMIN_NAV_SECTIONS: { id: AdminNavGroupId; labelKey: string }[] = [
{ id: "overview", labelKey: "admin.nav.section.overview" },
{ id: "directory", labelKey: "admin.nav.section.directory" },
{ id: "support", labelKey: "admin.nav.section.support" },
{ id: "ops", labelKey: "admin.nav.section.ops" },
{ id: "commerce", labelKey: "admin.nav.section.commerce" },
{ id: "system", labelKey: "admin.nav.section.system" }
];
export function adminNavIsActive(href: string, pathname: string): boolean {
if (href === "/admin") return pathname === "/admin";
if (href === "/admin/support") {
return (
pathname === "/admin/support" ||
(pathname.startsWith("/admin/support/") && !pathname.startsWith("/admin/support/knowledge"))
);
}
return pathname === href || pathname.startsWith(`${href}/`);
}
/** Message key for the current admin page title (resolve with i18n.t). */
export function adminPageTitleKey(pathname: string): string {
const match = ADMIN_NAV_ROUTES.find((item) => adminNavIsActive(item.href, pathname));
return match?.titleKey ?? "admin.chrome.platformOps";
}
+209
View File
@@ -0,0 +1,209 @@
/**
* Admin orgs UI client — users + companies directory, staff roles, plan assign.
* Contract: docs/admin-roles-support/04-contract.md · Docs: 10-admin-orgs-ui.md
*/
import { api, ApiError } from "$lib/api";
import {
assignAdminPlan,
classifyAdminPlanVisibility,
adminPlanVisibilityBadgeVariant,
adminPlanVisibilityLabel,
planOptionLabel,
type AdminBillingPlan,
type AdminPlanVisibility,
ADMIN_PLANS_PATH
} from "$lib/admin-billing-plans";
export const ADMIN_USERS_PATH = "/api/admin/users";
export const ADMIN_COMPANIES_PATH = "/api/admin/companies";
export const ADMIN_STAFF_ROLE_PATH = (userId: string) =>
`/api/admin/users/${encodeURIComponent(userId)}/staff-role`;
export const PAGE_SIZE = 25;
export type PlatformStaffRole = "admin" | "developer" | "support_staff";
export const STAFF_ROLE_OPTIONS: { value: "" | PlatformStaffRole; label: string }[] = [
{ value: "", label: "No staff role" },
{ value: "admin", label: "Admin" },
{ value: "developer", label: "Developer" },
{ value: "support_staff", label: "Support staff" }
];
export type AdminOrgUser = {
id: string;
email: string;
name?: string | null;
must_set_password?: boolean;
is_platform_admin?: boolean;
staff_role?: string | null;
resolved_role?: string;
is_active?: boolean;
created_at?: string;
};
export type AdminOrgCompany = {
id: string;
name: string;
language?: string;
created_at?: string;
total_credits?: number;
used_credits?: number;
has_active_plan?: boolean;
plan_id?: number | null;
plan_name?: string | null;
plan_is_custom?: boolean;
plan_is_legacy?: boolean;
/** False when the company has no non-revoked api_keys (cutover reissue gap). */
has_api_key?: boolean;
};
export type PaginatedUsers = {
users: AdminOrgUser[];
total: number;
limit: number;
offset: number;
};
export type PaginatedCompanies = {
companies: AdminOrgCompany[];
total: number;
limit: number;
offset: number;
without_active_plan?: boolean;
without_api_keys?: boolean;
};
export function staffRoleLabel(role: string | null | undefined): string {
switch ((role ?? "").trim()) {
case "admin":
return "Admin";
case "developer":
return "Developer";
case "support_staff":
return "Support staff";
default:
return "User";
}
}
export function staffRoleBadgeVariant(
role: string | null | undefined
): "default" | "secondary" | "outline" | "warning" {
switch ((role ?? "").trim()) {
case "admin":
return "default";
case "developer":
return "secondary";
case "support_staff":
return "warning";
default:
return "outline";
}
}
export function companyPlanVisibility(
company: AdminOrgCompany
): AdminPlanVisibility | "none" {
if (!company.has_active_plan || !company.plan_name) return "none";
if (company.plan_is_legacy) return "legacy";
return classifyAdminPlanVisibility({
name: company.plan_name,
is_custom: Boolean(company.plan_is_custom)
});
}
export function companyPlanBadge(company: AdminOrgCompany): {
label: string;
variant: "outline" | "warning" | "secondary" | "default";
} {
if (!company.has_active_plan || !company.plan_name) {
return { label: "No plan", variant: "warning" };
}
if (company.plan_is_legacy) {
return { label: `Legacy · ${company.plan_name}`, variant: "warning" };
}
const kind = classifyAdminPlanVisibility({
name: company.plan_name,
is_custom: Boolean(company.plan_is_custom)
});
return {
label: `${adminPlanVisibilityLabel(kind)} · ${company.plan_name}`,
variant: adminPlanVisibilityBadgeVariant(kind)
};
}
export async function listAdminUsers(opts: {
limit?: number;
offset?: number;
q?: string;
staff_only?: boolean;
active_only?: boolean;
inactive_only?: boolean;
}): Promise<PaginatedUsers> {
const params = new URLSearchParams();
params.set("limit", String(opts.limit ?? PAGE_SIZE));
params.set("offset", String(opts.offset ?? 0));
if (opts.q?.trim()) params.set("q", opts.q.trim());
if (opts.staff_only) params.set("staff_only", "1");
if (opts.active_only) params.set("active_only", "1");
if (opts.inactive_only) params.set("inactive_only", "1");
const res = await api<PaginatedUsers>(`${ADMIN_USERS_PATH}?${params}`);
return {
users: res.users ?? [],
total: Number(res.total ?? res.users?.length ?? 0),
limit: Number(res.limit ?? opts.limit ?? PAGE_SIZE),
offset: Number(res.offset ?? opts.offset ?? 0)
};
}
export async function listAdminCompanies(opts: {
limit?: number;
offset?: number;
q?: string;
without_active_plan?: boolean;
without_api_keys?: boolean;
}): Promise<PaginatedCompanies> {
const params = new URLSearchParams();
params.set("limit", String(opts.limit ?? PAGE_SIZE));
params.set("offset", String(opts.offset ?? 0));
if (opts.q?.trim()) params.set("q", opts.q.trim());
if (opts.without_active_plan) params.set("without_active_plan", "1");
if (opts.without_api_keys) params.set("without_api_keys", "1");
const res = await api<PaginatedCompanies>(`${ADMIN_COMPANIES_PATH}?${params}`);
return {
companies: res.companies ?? [],
total: Number(res.total ?? res.companies?.length ?? 0),
limit: Number(res.limit ?? opts.limit ?? PAGE_SIZE),
offset: Number(res.offset ?? opts.offset ?? 0),
without_active_plan: Boolean(res.without_active_plan),
without_api_keys: Boolean(res.without_api_keys)
};
}
export async function listAdminPlansForAssign(): Promise<AdminBillingPlan[]> {
const res = await api<{ plans: AdminBillingPlan[] }>(ADMIN_PLANS_PATH);
return res.plans ?? [];
}
export async function setAdminStaffRole(
userId: string,
staffRole: "" | PlatformStaffRole
): Promise<AdminOrgUser> {
const body =
staffRole === ""
? { staff_role: null }
: { staff_role: staffRole };
const res = await api<{ user: AdminOrgUser }>(ADMIN_STAFF_ROLE_PATH(userId), {
method: "PATCH",
body
});
return res.user;
}
export function isStaffRoleApiUnavailable(err: unknown): boolean {
return err instanceof ApiError && (err.status === 404 || err.status === 501);
}
export { assignAdminPlan, planOptionLabel };
export type { AdminBillingPlan };
@@ -0,0 +1,135 @@
/**
* admin-orphan-processed unit tests (node:test).
*
* Run from apps/web:
* node --experimental-strip-types --test src/lib/admin-orphan-processed.test.ts
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
canConfirmOrphanDelete,
normalizeOrphanReport,
orphanCleanupBody,
orphanReasonLabelKey,
parseOrphanCleanupResponse
} from "./admin-orphan-processed.ts";
describe("normalizeOrphanReport", () => {
it("coerces counts and samples; confirmed only when strictly true", () => {
const report = normalizeOrphanReport({
missing_raw: "2",
unprocessed_raw: 3,
total: "5",
deleted: null,
confirmed: "true",
samples: [
{
processed_id: "p1",
company_id: "c1",
raw_product_id: null,
reason: "missing_raw"
}
]
});
assert.equal(report.missing_raw, 2);
assert.equal(report.unprocessed_raw, 3);
assert.equal(report.total, 5);
assert.equal(report.deleted, 0);
assert.equal(report.confirmed, false);
assert.equal(report.samples.length, 1);
assert.equal(report.samples[0]?.processed_id, "p1");
assert.equal(report.samples[0]?.reason, "missing_raw");
});
it("returns zeros for empty/invalid payloads", () => {
assert.deepEqual(normalizeOrphanReport(null), {
missing_raw: 0,
unprocessed_raw: 0,
total: 0,
deleted: 0,
confirmed: false,
samples: []
});
});
});
describe("parseOrphanCleanupResponse", () => {
it("treats nested report envelope as dry-run by default", () => {
const out = parseOrphanCleanupResponse({
ok: true,
deleted: 0,
message: "pass confirm=true",
report: { total: 4, missing_raw: 1, unprocessed_raw: 3, confirmed: false, samples: [] }
});
assert.equal(out.dryRun, true);
assert.equal(out.deleted, 0);
assert.equal(out.report.total, 4);
assert.match(String(out.message), /confirm=true/);
});
it("honors dry_run true on the envelope", () => {
const out = parseOrphanCleanupResponse({
dry_run: true,
deleted: 0,
report: { total: 1, confirmed: false, samples: [] }
});
assert.equal(out.dryRun, true);
});
it("treats confirmed report body as a live delete outcome", () => {
const out = parseOrphanCleanupResponse({
total: 0,
deleted: 7,
confirmed: true,
missing_raw: 0,
unprocessed_raw: 0,
samples: []
});
assert.equal(out.dryRun, false);
assert.equal(out.deleted, 7);
assert.equal(out.report.confirmed, true);
});
});
describe("orphanCleanupBody", () => {
it("never defaults confirm to true", () => {
assert.deepEqual(orphanCleanupBody(false), {});
assert.deepEqual(orphanCleanupBody(0 as unknown as boolean), {});
assert.deepEqual(orphanCleanupBody("true" as unknown as boolean), {});
assert.deepEqual(orphanCleanupBody(true), { confirm: true });
});
});
describe("canConfirmOrphanDelete", () => {
it("requires a report with orphans and idle state", () => {
assert.equal(canConfirmOrphanDelete({ report: null }), false);
assert.equal(
canConfirmOrphanDelete({
report: normalizeOrphanReport({ total: 0, samples: [] })
}),
false
);
assert.equal(
canConfirmOrphanDelete({
report: normalizeOrphanReport({ total: 2, samples: [] }),
busy: true
}),
false
);
assert.equal(
canConfirmOrphanDelete({
report: normalizeOrphanReport({ total: 2, samples: [] })
}),
true
);
});
});
describe("orphanReasonLabelKey", () => {
it("maps known reasons to i18n keys", () => {
assert.equal(orphanReasonLabelKey("missing_raw"), "admin.orphan.reason.missingRaw");
assert.equal(orphanReasonLabelKey("unprocessed_raw"), "admin.orphan.reason.unprocessedRaw");
assert.equal(orphanReasonLabelKey("weird"), "admin.orphan.reason.other");
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* Admin orphan-processed report/cleanup helpers (cutover ops #7).
* Pure normalize/parse/gates only — page calls api(); delete requires confirm=true.
*/
export const ORPHAN_REPORT_API = "/api/admin/jobs/orphan-processed";
export const ORPHAN_CLEANUP_API = "/api/admin/jobs/orphan-processed-cleanup";
export const ORPHAN_ADMIN_PAGE = "/admin/orphan-processed";
export type OrphanProcessedSample = {
processed_id: string;
company_id: string;
raw_product_id?: string | null;
reason: string;
raw_processing_status?: string | null;
raw_is_processed?: boolean | null;
};
export type OrphanProcessedReport = {
missing_raw: number;
unprocessed_raw: number;
total: number;
deleted: number;
confirmed: boolean;
samples: OrphanProcessedSample[];
};
export type OrphanCleanupOutcome = {
dryRun: boolean;
deleted: number;
report: OrphanProcessedReport;
message?: string;
};
function asInt(value: unknown): number {
const n = Number(value ?? 0);
return Number.isFinite(n) ? n : 0;
}
function normalizeSample(raw: unknown): OrphanProcessedSample {
const s = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
return {
processed_id: String(s.processed_id ?? ""),
company_id: String(s.company_id ?? ""),
raw_product_id: s.raw_product_id == null ? null : String(s.raw_product_id),
reason: String(s.reason ?? ""),
raw_processing_status:
s.raw_processing_status == null ? null : String(s.raw_processing_status),
raw_is_processed:
typeof s.raw_is_processed === "boolean" ? s.raw_is_processed : null
};
}
/** Normalize GET report or nested cleanup `report` payloads. */
export function normalizeOrphanReport(raw: unknown): OrphanProcessedReport {
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const samplesRaw = Array.isArray(r.samples) ? r.samples : [];
return {
missing_raw: asInt(r.missing_raw),
unprocessed_raw: asInt(r.unprocessed_raw),
total: asInt(r.total),
deleted: asInt(r.deleted),
confirmed: r.confirmed === true,
samples: samplesRaw.map(normalizeSample)
};
}
/**
* Parse POST cleanup responses.
* Dry-run envelope: `{ ok, dry_run?, deleted, message?, report }`.
* Confirmed delete: body is the report (`confirmed: true`).
*/
export function parseOrphanCleanupResponse(raw: unknown): OrphanCleanupOutcome {
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
if (r.report && typeof r.report === "object") {
const report = normalizeOrphanReport(r.report);
const dryRun = r.dry_run === true || report.confirmed !== true;
return {
dryRun,
deleted: asInt(r.deleted),
report,
message: typeof r.message === "string" ? r.message : undefined
};
}
const report = normalizeOrphanReport(raw);
return {
dryRun: report.confirmed !== true,
deleted: report.deleted,
report
};
}
/** JSON body for cleanup. Never defaults confirm to true. */
export function orphanCleanupBody(confirm: boolean): Record<string, never> | { confirm: true } {
return confirm === true ? { confirm: true } : {};
}
/** Delete CTA is enabled only after a report with orphans, while idle (fail-closed at 0). */
export function canConfirmOrphanDelete(input: {
report: OrphanProcessedReport | null;
busy?: boolean;
}): boolean {
if (input.busy) return false;
if (!input.report) return false;
// Fail-closed: never enable confirm when the latest report shows zero orphans.
if (!(input.report.total > 0)) return false;
return true;
}
export function orphanReasonLabelKey(reason: string): string {
switch (String(reason).toLowerCase()) {
case "missing_raw":
return "admin.orphan.reason.missingRaw";
case "unprocessed_raw":
return "admin.orphan.reason.unprocessedRaw";
default:
return "admin.orphan.reason.other";
}
}
+538
View File
@@ -0,0 +1,538 @@
/**
* Admin plan-permission API client + role/plan profiles.
*
* Contract: docs/plan-permissions/03-permission-contract.md
* Profiles / Legacy (A1): docs/admin-roles-support/08-permissions-ui.md
*
* Routes (platform admin session + CSRF):
* GET /api/admin/plans
* GET|PUT /api/admin/plans/{id}/features
* POST /api/admin/plans/{id}/features/enable-all|disable-all
* GET|PUT /api/admin/feature-gates
* PUT /api/admin/feature-gates/sections/{sec}
*/
import { api, ApiError } from "$lib/api";
import { fetchAdminPlansList } from "$lib/admin-billing-plans";
import { i18n } from "$lib/i18n";
import {
isA1PaygDeniedKey,
isA1PaygPlanPure,
isRestrictedLegacyPlan
} from "$lib/plan-cohort";
import {
PLAN_FEATURE_CATALOG,
PLAN_FEATURE_KEYS,
PLAN_FEATURE_SECTIONS,
isDefaultPublicPlanName,
type PlanFeatureSectionKey
} from "$lib/plan-feature-catalog";
export const ADMIN_PLANS_PATH = "/api/admin/plans";
export const ADMIN_FEATURE_GATES_PATH = "/api/admin/feature-gates";
/** Shared in-flight GET so keep-mounted billing tabs do not double-fetch gates. */
let featureGatesInflight: Promise<{ gates: FeatureGatesPayload; apiReady: boolean }> | null =
null;
export type FeatureMap = Record<string, boolean>;
export type AdminPlanWithFeatures = {
id: number | string;
name: string;
description?: string | null;
monthly_credits?: number;
yearly_credits?: number | null;
max_products?: number | null;
is_custom?: boolean;
/** Present when backend marks migrated / legacy packages. */
is_legacy?: boolean;
term?: string;
/** Sparse stored overrides only. */
features?: FeatureMap;
/** plan_allows only (ignores globals) — preferred for admin checklist. */
resolved_features?: FeatureMap;
};
export type FeatureGatesPayload = {
sections: FeatureMap;
features: FeatureMap;
};
export type PlanFeaturesView = {
plan_id: number;
plan_name: string;
is_custom: boolean;
features: FeatureMap;
resolved_features: FeatureMap;
};
/** Named matrices admins can apply in one click. */
export type PlanFeatureProfileId =
| "legacy"
| "free"
| "starter"
| "growth"
| "business"
| "enterprise";
export type PlanFeatureProfile = {
id: PlanFeatureProfileId;
label: string;
description: string;
};
function planProfile(id: PlanFeatureProfileId): PlanFeatureProfile {
return {
id,
get label() {
return i18n.t(`admin.profile.${id}.label`);
},
get description() {
return i18n.t(`admin.profile.${id}.description`);
}
};
}
export const PLAN_FEATURE_PROFILES: PlanFeatureProfile[] = [
planProfile("legacy"),
planProfile("free"),
planProfile("starter"),
planProfile("growth"),
planProfile("business"),
planProfile("enterprise")
];
/**
* Feature keys ON for Legacy (A1-like) profile.
* Aligned with docs/admin-roles-support/03-roles-matrix.md (legacy_user).
* Explicitly OFF: processing.monitor, stores.*, marketing.*, integrations.*, support.*.
*/
export const LEGACY_FEATURE_ALLOWLIST: ReadonlySet<string> = new Set([
"shell.navigation",
"shell.command_palette",
"shell.company_switcher",
"shell.tutorial",
"shell.account_menu",
"shell.billing_recovery_banner",
"dashboard.overview",
"dashboard.stats",
"dashboard.quick_links",
"dashboard.recent_jobs",
"dashboard.news_feed",
"dashboard.activation_checklist",
"dashboard.migrated_checklist",
"dashboard.etl_gaps",
"dashboard.upgrade_banners",
"catalog.products",
"catalog.products.tab_processed",
"catalog.products.tab_needs_review",
"catalog.products.tab_error",
"catalog.products.tab_processing",
"catalog.products.tab_unprocessed",
"catalog.products.process_categories",
"catalog.products.process_attributes",
"catalog.products.process_ai_titles",
"catalog.products.process_ai_descriptions",
"catalog.products.enrichment_review",
"catalog.products.export_selection",
"catalog.products.upgrade_prompt",
"catalog.categories",
"catalog.categories.title_formula",
"catalog.categories.description_formula",
"catalog.attributes",
"catalog.attributes.bulk_import",
"catalog.standard_fields",
"catalog.standard_fields.groups",
"feeds.list",
"feeds.add_url",
"feeds.add_csv",
"feeds.sync",
"feeds.mapping",
"feeds.mapping.select_item",
"feeds.mapping.map_fields",
"feeds.export_feeds",
"feeds.export_feeds.create",
"feeds.export_feeds.generate",
"feeds.uploads",
"billing.overview",
"billing.customer_portal",
"billing.quick_upgrade",
"billing.plans_compare",
"billing.checkout",
"settings.profile",
"settings.company",
"settings.alerts",
"settings.api_keys",
"settings.team",
"settings.team_invite",
"capability.sku_cap",
"capability.ai_credits",
"capability.ai_processing",
"capability.eprel",
"capability.normalize_specs_fill",
"capability.feed_source_limit",
"capability.export_feed_limit",
"capability.storage_limit",
"capability.api_access"
]);
const FREE_FEATURE_OFF: ReadonlySet<string> = new Set([
"catalog.products.process_ai_titles",
"catalog.products.process_ai_descriptions",
"marketing.campaigns.generate_ai",
"marketing.campaigns.send",
"marketing.brand_ai_apply",
"marketing.seo.ai_rewrite",
"integrations.ai.byok",
"settings.api_keys",
"capability.ai_processing",
"capability.campaign_ai",
"capability.email_live_send",
"capability.brand_ai_apply",
"capability.seo_ai_rewrite",
"capability.api_access",
"capability.byok"
]);
const STARTER_FEATURE_OFF: ReadonlySet<string> = new Set([
"integrations.ai.byok",
"capability.byok"
]);
export function isPlanPermissionsApiUnavailable(err: unknown): boolean {
return err instanceof ApiError && (err.status === 404 || err.status === 501);
}
export function adminPlanFeaturesPath(planId: number | string): string {
return `${ADMIN_PLANS_PATH}/${encodeURIComponent(String(planId))}/features`;
}
export function adminPlanFeaturesEnableAllPath(planId: number | string): string {
return `${adminPlanFeaturesPath(planId)}/enable-all`;
}
export function adminPlanFeaturesDisableAllPath(planId: number | string): string {
return `${adminPlanFeaturesPath(planId)}/disable-all`;
}
export function adminFeatureGateSectionPath(section: string): string {
return `${ADMIN_FEATURE_GATES_PATH}/sections/${encodeURIComponent(section)}`;
}
/**
* Restricted Legacy matrix packages (processing/stores/marketing off).
* A1* with is_custom is PAYG — not Legacy-like (see isA1PaygPlan).
*/
export function isLegacyLikePlan(
plan: Pick<AdminPlanWithFeatures, "name" | "is_legacy" | "is_custom"> | null | undefined
): boolean {
return isRestrictedLegacyPlan(plan);
}
/** A1* / A1 Slovenija with is_custom — enable-all minus stores/marketing/integrations. */
export function isA1PaygPlan(
plan: Pick<AdminPlanWithFeatures, "name" | "is_legacy" | "is_custom"> | null | undefined
): boolean {
return isA1PaygPlanPure(plan);
}
/** Keys OFF on A1 PAYG — mirrors billing.A1PaygFeatureDenied. */
export function a1PaygFeatureOffKeys(): ReadonlySet<string> {
return new Set(PLAN_FEATURE_KEYS.filter(isA1PaygDeniedKey));
}
export function packageKindOf(
plan: Pick<AdminPlanWithFeatures, "name" | "is_custom" | "is_legacy"> | null | undefined
): "legacy" | "default" | "ladder_custom" | "custom" | "deal" | null {
if (!plan) return null;
if (isLegacyLikePlan(plan)) return "legacy";
const ladder = isDefaultPublicPlanName(plan.name);
const custom = Boolean(plan.is_custom);
if (ladder && !custom) return "default";
if (ladder && custom) return "ladder_custom";
if (custom) return "custom";
return "deal";
}
export function hasStoredOverrides(plan: AdminPlanWithFeatures | null | undefined): boolean {
const f = plan?.features;
return Boolean(f && Object.keys(f).length > 0);
}
function allOnMap(): FeatureMap {
return Object.fromEntries(PLAN_FEATURE_KEYS.map((k) => [k, true])) as FeatureMap;
}
function allOffExcept(allow: ReadonlySet<string>): FeatureMap {
const out: FeatureMap = {};
for (const key of PLAN_FEATURE_KEYS) {
out[key] = allow.has(key);
}
return out;
}
function allOnExcept(deny: ReadonlySet<string>): FeatureMap {
const out: FeatureMap = {};
for (const key of PLAN_FEATURE_KEYS) {
out[key] = !deny.has(key);
}
return out;
}
/** Expanded default matrix for a plan name (mirrors billing.DefaultPlanFeatures). */
export function defaultResolvedFeaturesForPlan(opts: {
name: string;
isCustom?: boolean;
isLegacy?: boolean;
}): FeatureMap {
const plan = {
name: opts.name,
is_custom: opts.isCustom,
is_legacy: opts.isLegacy
};
if (opts.isLegacy || isLegacyLikePlan(plan)) {
return featuresForProfile("legacy");
}
// A1 PAYG before generic custom enable-all (Stores/Marketing/Integrations stay OFF).
if (isA1PaygPlan(plan)) {
return allOnExcept(a1PaygFeatureOffKeys());
}
const custom = Boolean(opts.isCustom);
const n = (opts.name ?? "").trim().toLowerCase();
if (custom || n === "enterprise") {
return allOnMap();
}
if (n === "free") return allOnExcept(FREE_FEATURE_OFF);
if (n === "starter" || n === "plus") return allOnExcept(STARTER_FEATURE_OFF);
// Growth / Business / Scale / named public ladder: all ON.
return allOnMap();
}
export function featuresForProfile(profile: PlanFeatureProfileId): FeatureMap {
switch (profile) {
case "legacy":
return allOffExcept(LEGACY_FEATURE_ALLOWLIST);
case "free":
return allOnExcept(FREE_FEATURE_OFF);
case "starter":
return allOnExcept(STARTER_FEATURE_OFF);
case "growth":
case "business":
case "enterprise":
return allOnMap();
default:
return allOnMap();
}
}
export function featureMapsEqual(a: FeatureMap, b: FeatureMap): boolean {
for (const key of PLAN_FEATURE_KEYS) {
const av = a[key] !== false;
const bv = b[key] !== false;
if (av !== bv) return false;
}
return true;
}
/** Which named profile the current resolved map matches (if any). */
export function matchingProfileId(resolved: FeatureMap): PlanFeatureProfileId | null {
for (const p of PLAN_FEATURE_PROFILES) {
if (featureMapsEqual(resolved, featuresForProfile(p.id))) return p.id;
}
return null;
}
export function mergeResolvedFeatures(plan: AdminPlanWithFeatures): FeatureMap {
const base = defaultResolvedFeaturesForPlan({
name: plan.name,
isCustom: plan.is_custom,
isLegacy: plan.is_legacy
});
if (plan.resolved_features && Object.keys(plan.resolved_features).length > 0) {
return { ...base, ...plan.resolved_features };
}
if (plan.features && Object.keys(plan.features).length > 0) {
return { ...base, ...plan.features };
}
return base;
}
export function defaultFeatureGates(): FeatureGatesPayload {
const sections: FeatureMap = {};
for (const s of PLAN_FEATURE_SECTIONS) {
sections[s.key] = true;
}
return { sections, features: {} };
}
export function mergeFeatureGates(
raw: Partial<FeatureGatesPayload> | null | undefined
): FeatureGatesPayload {
const base = defaultFeatureGates();
return {
sections: { ...base.sections, ...(raw?.sections ?? {}) },
features: { ...(raw?.features ?? {}) }
};
}
function planFromFeaturesView(
plan: AdminPlanWithFeatures,
view: PlanFeaturesView
): AdminPlanWithFeatures {
return {
...plan,
id: view.plan_id ?? plan.id,
name: view.plan_name || plan.name,
is_custom: view.is_custom ?? plan.is_custom,
features: view.features ?? {},
resolved_features: view.resolved_features ?? mergeResolvedFeatures({
...plan,
features: view.features ?? {}
})
};
}
export async function listAdminPlansWithFeatures(signal?: AbortSignal): Promise<{
plans: AdminPlanWithFeatures[];
apiReady: boolean;
}> {
const plans = (await fetchAdminPlansList(signal)) as AdminPlanWithFeatures[];
return { plans, apiReady: true };
}
export async function loadPlanFeatures(
planId: number | string,
signal?: AbortSignal
): Promise<PlanFeaturesView> {
return api<PlanFeaturesView>(adminPlanFeaturesPath(planId), { signal });
}
/** Persist overrides via PUT /api/admin/plans/{id}/features (real API). */
export async function savePlanFeatures(
plan: AdminPlanWithFeatures,
features: FeatureMap
): Promise<AdminPlanWithFeatures> {
const view = await api<PlanFeaturesView>(adminPlanFeaturesPath(plan.id), {
method: "PUT",
body: { features }
});
return planFromFeaturesView(plan, view);
}
/** Apply a named profile as a full override matrix. */
export async function applyPlanProfile(
plan: AdminPlanWithFeatures,
profile: PlanFeatureProfileId
): Promise<AdminPlanWithFeatures> {
return savePlanFeatures(plan, featuresForProfile(profile));
}
/** Clear stored overrides — resolve falls back to plan-name defaults. */
export async function resetPlanFeaturesToDefaults(
plan: AdminPlanWithFeatures
): Promise<AdminPlanWithFeatures> {
return savePlanFeatures(plan, {});
}
/** Enable/disable every catalog key in a section for one plan. */
export async function setPlanSectionFeatures(
plan: AdminPlanWithFeatures,
section: PlanFeatureSectionKey | string,
enabled: boolean
): Promise<AdminPlanWithFeatures> {
const next: FeatureMap = { ...(plan.features ?? {}) };
const resolved = mergeResolvedFeatures(plan);
for (const key of PLAN_FEATURE_KEYS) {
if (!(key in next)) next[key] = resolved[key] !== false;
}
for (const f of PLAN_FEATURE_CATALOG) {
if (f.section === section) next[f.key] = enabled;
}
return savePlanFeatures(plan, next);
}
export async function enableAllPlanFeatures(
plan: AdminPlanWithFeatures
): Promise<AdminPlanWithFeatures> {
const view = await api<PlanFeaturesView>(adminPlanFeaturesEnableAllPath(plan.id), {
method: "POST",
body: {}
});
return planFromFeaturesView(plan, view);
}
export async function disableAllPlanFeatures(
plan: AdminPlanWithFeatures
): Promise<AdminPlanWithFeatures> {
const view = await api<PlanFeaturesView>(adminPlanFeaturesDisableAllPath(plan.id), {
method: "POST",
body: {}
});
return planFromFeaturesView(plan, view);
}
export async function loadFeatureGates(signal?: AbortSignal): Promise<{
gates: FeatureGatesPayload;
apiReady: boolean;
}> {
if (!signal && featureGatesInflight) return featureGatesInflight;
const run = (async () => {
const body = await api<FeatureGatesPayload>(ADMIN_FEATURE_GATES_PATH, { signal });
return { gates: mergeFeatureGates(body), apiReady: true };
})();
if (!signal) {
featureGatesInflight = run;
void run.finally(() => {
if (featureGatesInflight === run) featureGatesInflight = null;
});
}
return run;
}
export async function saveFeatureGates(gates: FeatureGatesPayload): Promise<FeatureGatesPayload> {
const body = await api<FeatureGatesPayload>(ADMIN_FEATURE_GATES_PATH, {
method: "PUT",
body: {
sections: gates.sections,
features: gates.features
}
});
return mergeFeatureGates(body);
}
/**
* Toggle a global section master for ALL plans.
* When applyFeatures is true, also upserts global feature-gate rows for keys in that section.
*/
export async function setGlobalSection(
section: PlanFeatureSectionKey | string,
enabled: boolean,
opts: { applyFeatures?: boolean } = {}
): Promise<FeatureGatesPayload> {
const path = adminFeatureGateSectionPath(section);
const body = await api<FeatureGatesPayload>(path, {
method: "PUT",
body: { enabled }
});
let gates = mergeFeatureGates(body);
if (opts.applyFeatures) {
const featurePatch: FeatureMap = {};
for (const f of PLAN_FEATURE_CATALOG) {
if (f.section === section) featurePatch[f.key] = enabled;
}
gates = await saveFeatureGates({
sections: gates.sections,
features: { ...gates.features, ...featurePatch }
});
}
return gates;
}
export function sectionFeatureStats(
section: string,
planFeatures: FeatureMap
): { on: number; total: number } {
const items = PLAN_FEATURE_CATALOG.filter((f) => f.section === section);
const on = items.filter((f) => planFeatures[f.key] !== false).length;
return { on, total: items.length };
}
+202
View File
@@ -0,0 +1,202 @@
/**
* Platform admin settings client — matches apps/api/internal/platformsettings.
*
* GET /api/admin/settings
* PUT /api/admin/settings — partial; empty secrets keep existing
*
* First-class sections: openai, smtp, oauth.google, ai_roles (multi-AI).
* Extensible bag: values (eprel.*, stripe.*, pinecone.*, feeds.private_url_allowlist).
* Secrets are never shown in full for openai/smtp/oauth/ai_roles; for values.* secrets,
* the UI must not echo GET payloads into password fields (treat non-empty as configured).
*
* Multi-AI roles (agreed with backend): processing, vectorization, docs_api, support.
* See $lib/admin-ai-roles for DTOs and client helpers.
*/
import { api, ApiError } from "$lib/api";
import type { PlatformAIRolesMap, PlatformAIRolesUpdate } from "./admin-ai-roles";
export const PLATFORM_SETTINGS_PATH = "/api/admin/settings";
/** Catalog keys mirrored from platformsettings.Catalog (Agent 2). */
export const VALUE_KEYS = {
stripeSecretKey: "stripe.secret_key",
stripeWebhookSecret: "stripe.webhook_secret",
stripeMock: "stripe.mock",
stripePriceStarterMo: "stripe.price.starter.monthly",
stripePriceStarterYr: "stripe.price.starter.yearly",
stripePricePlusMo: "stripe.price.plus.monthly",
stripePricePlusYr: "stripe.price.plus.yearly",
stripePriceGrowthMo: "stripe.price.growth.monthly",
stripePriceGrowthYr: "stripe.price.growth.yearly",
stripePriceBizMo: "stripe.price.business.monthly",
stripePriceBizYr: "stripe.price.business.yearly",
stripePriceScaleMo: "stripe.price.scale.monthly",
stripePriceScaleYr: "stripe.price.scale.yearly",
stripePricePackSmall: "stripe.price.pack.small",
stripePricePackMedium: "stripe.price.pack.medium",
stripePricePackLarge: "stripe.price.pack.large",
stripePricePackXL: "stripe.price.pack.xl",
eprelEnabled: "eprel.enabled",
eprelBaseURL: "eprel.base_url",
eprelTimeout: "eprel.timeout",
eprelFicheLanguage: "eprel.fiche_language",
eprelAPIKey: "eprel.api_key",
pineconeAPIKey: "pinecone.api_key",
pineconeHost: "pinecone.host",
pineconeNamespace: "pinecone.namespace",
feedPrivateAllowlist: "feeds.private_url_allowlist"
} as const;
export const VALUE_SECRET_KEYS = new Set<string>([
VALUE_KEYS.stripeSecretKey,
VALUE_KEYS.stripeWebhookSecret,
VALUE_KEYS.eprelAPIKey,
VALUE_KEYS.pineconeAPIKey
]);
export type PlatformOpenAIPublic = {
configured?: boolean;
has_api_key?: boolean;
api_key_last4?: string;
api_key_masked?: string;
base_url?: string;
model?: string;
source?: "db" | "env" | "none" | string;
};
export type PlatformSMTPPublic = {
configured?: boolean;
enabled?: boolean;
host?: string;
port?: string;
user?: string;
from?: string;
has_password?: boolean;
password_last4?: string;
password_masked?: string;
source?: "db" | "env" | "none" | string;
};
export type PlatformGoogleOAuthPublic = {
configured?: boolean;
enabled?: boolean;
client_id?: string;
has_client_secret?: boolean;
client_secret_last4?: string;
client_secret_masked?: string;
source?: "db" | "env" | "none" | string;
};
export type PlatformAdminSettings = {
openai?: PlatformOpenAIPublic;
/** Per-role platform AI configs (processing, vectorization, docs_api, support). */
ai_roles?: PlatformAIRolesMap;
smtp?: PlatformSMTPPublic;
oauth?: { google?: PlatformGoogleOAuthPublic };
values?: Record<string, string>;
updated_at?: string | null;
};
export type PlatformOpenAIUpdate = {
base_url?: string;
model?: string;
api_key?: string;
clear_api_key?: boolean;
};
export type PlatformSMTPUpdate = {
enabled?: boolean;
host?: string;
port?: string;
user?: string;
from?: string;
password?: string;
clear_password?: boolean;
};
export type PlatformGoogleOAuthUpdate = {
enabled?: boolean;
client_id?: string;
client_secret?: string;
clear_client_secret?: boolean;
};
export type PlatformAdminSettingsUpdate = {
openai?: PlatformOpenAIUpdate;
ai_roles?: PlatformAIRolesUpdate;
smtp?: PlatformSMTPUpdate;
oauth?: { google?: PlatformGoogleOAuthUpdate };
/** null deletes the key; omit keeps; non-empty string sets */
values?: Record<string, string | null>;
};
export type PlatformSettingsLoadResult =
| { ok: true; settings: PlatformAdminSettings }
| { ok: false; unavailable: true; status: number; message: string };
export function isPlatformSettingsUnavailable(err: unknown): boolean {
if (!(err instanceof ApiError)) return false;
return err.status === 404 || err.status === 501 || err.status === 503;
}
export async function loadPlatformAdminSettings(): Promise<PlatformSettingsLoadResult> {
try {
const settings = await api<PlatformAdminSettings>(PLATFORM_SETTINGS_PATH);
return { ok: true, settings: settings ?? {} };
} catch (err) {
if (isPlatformSettingsUnavailable(err)) {
const status = err instanceof ApiError ? err.status : 503;
return {
ok: false,
unavailable: true,
status,
message:
"Platform settings are unavailable. Confirm the API is running and try again."
};
}
throw err;
}
}
export async function savePlatformAdminSettings(
body: PlatformAdminSettingsUpdate
): Promise<PlatformAdminSettings> {
return api<PlatformAdminSettings>(PLATFORM_SETTINGS_PATH, { method: "PUT", body });
}
/** POST /api/admin/settings/mail/test — probe SMTP with saved platform settings (no secrets in response). */
export const PLATFORM_MAIL_TEST_PATH = "/api/admin/settings/mail/test";
export type PlatformMailTestRequest = {
/** Optional; when omitted the API uses the session admin email. */
to?: string;
};
export type PlatformMailTestResult = {
status: "ok" | "failed" | "skipped" | string;
smtp_enabled: boolean;
message: string;
};
export async function testPlatformAdminMail(
body: PlatformMailTestRequest = {}
): Promise<PlatformMailTestResult> {
return api<PlatformMailTestResult>(PLATFORM_MAIL_TEST_PATH, { method: "POST", body });
}
export function maskHint(hasSecret: boolean, masked?: string, last4?: string): string {
if (masked) return masked;
if (last4) return `••••${last4}`;
if (hasSecret) return "Configured (hidden)";
return "";
}
/** Never put secret values from GET into form fields — only report configured. */
export function valueConfigured(values: Record<string, string> | undefined, key: string): boolean {
return Boolean(values?.[key]?.trim());
}
export function valuePlain(values: Record<string, string> | undefined, key: string): string {
if (VALUE_SECRET_KEYS.has(key)) return "";
return values?.[key] ?? "";
}
@@ -0,0 +1,64 @@
/**
* admin-store-reconnect unit tests (node:test).
*
* Run from apps/web:
* node --experimental-strip-types --test src/lib/admin-store-reconnect.test.ts
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
normalizeStoreReconnectInventory,
storeReconnectChannelLabelKey,
storeReconnectReasonLabelKey
} from "./admin-store-reconnect.ts";
describe("normalizeStoreReconnectInventory", () => {
it("returns empty inventory for nullish payloads", () => {
assert.deepEqual(normalizeStoreReconnectInventory(null), {
stores: [],
total: 0,
limit: 0,
offset: 0
});
});
it("normalizes gap rows", () => {
const inv = normalizeStoreReconnectInventory({
total: 1,
limit: 50,
offset: 0,
stores: [
{
company_id: "abc",
company_name: "Acme",
channel: "shopify",
identity: "acme.myshopify.com",
is_enabled: true,
reason: "missing_credentials",
last_test_status: ""
}
]
});
assert.equal(inv.total, 1);
assert.equal(inv.stores.length, 1);
assert.equal(inv.stores[0]?.channel, "shopify");
assert.equal(inv.stores[0]?.reason, "missing_credentials");
assert.equal(inv.stores[0]?.last_test_status, undefined);
});
});
describe("storeReconnect label keys", () => {
it("maps known reasons and channels", () => {
assert.equal(
storeReconnectReasonLabelKey("missing_credentials"),
"admin.storeReconnect.reason.missingCredentials"
);
assert.equal(storeReconnectReasonLabelKey("weird"), "admin.storeReconnect.reason.other");
assert.equal(
storeReconnectChannelLabelKey("woocommerce"),
"admin.storeReconnect.channel.woocommerce"
);
assert.equal(storeReconnectChannelLabelKey("x"), "admin.storeReconnect.channel.other");
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* Admin store reconnect inventory — companies with connected-but-invalid connectors.
* Pure normalize/label helpers; page calls api() for GET /api/admin/stores/reconnect-needed.
*/
export const STORE_RECONNECT_NEEDED_API = "/api/admin/stores/reconnect-needed";
export const STORE_RECONNECT_ADMIN_PAGE = "/admin/store-reconnect";
export type AdminStoreReconnectGap = {
company_id: string;
company_name: string;
channel: "shopify" | "woocommerce" | string;
identity: string;
is_enabled: boolean;
reason: string;
last_test_status?: string;
};
export type AdminStoreReconnectInventory = {
stores: AdminStoreReconnectGap[];
total: number;
limit: number;
offset: number;
};
function asInt(value: unknown): number {
const n = Number(value ?? 0);
return Number.isFinite(n) ? n : 0;
}
function normalizeGap(raw: unknown): AdminStoreReconnectGap {
const s = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
return {
company_id: String(s.company_id ?? ""),
company_name: String(s.company_name ?? ""),
channel: String(s.channel ?? ""),
identity: String(s.identity ?? ""),
is_enabled: s.is_enabled === true,
reason: String(s.reason ?? ""),
last_test_status:
s.last_test_status == null || s.last_test_status === ""
? undefined
: String(s.last_test_status)
};
}
/** Normalize GET /api/admin/stores/reconnect-needed payloads. */
export function normalizeStoreReconnectInventory(raw: unknown): AdminStoreReconnectInventory {
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const storesRaw = Array.isArray(r.stores) ? r.stores : [];
return {
stores: storesRaw.map(normalizeGap),
total: asInt(r.total),
limit: asInt(r.limit),
offset: asInt(r.offset)
};
}
export function storeReconnectReasonLabelKey(reason: string): string {
switch (String(reason).toLowerCase()) {
case "missing_credentials":
return "admin.storeReconnect.reason.missingCredentials";
default:
return "admin.storeReconnect.reason.other";
}
}
export function storeReconnectChannelLabelKey(channel: string): string {
switch (String(channel).toLowerCase()) {
case "shopify":
return "admin.storeReconnect.channel.shopify";
case "woocommerce":
return "admin.storeReconnect.channel.woocommerce";
default:
return "admin.storeReconnect.channel.other";
}
}
+77
View File
@@ -0,0 +1,77 @@
import { ApiError } from "$lib/api";
import type { LocaleCoverage } from "$lib/i18n/coverage";
import type { MessageDict } from "$lib/i18n/messages/types";
export type TranslationsCatalogResponse = {
base_locale: string;
source_of_truth: string;
locales: { code: string; label: string; htmlLang: string }[];
keys: string[];
catalog: Record<string, MessageDict>;
coverage: LocaleCoverage[];
};
export type SaveTranslationsResponse = {
locale: string;
messages: MessageDict;
source_of_truth: string;
};
/** Same-origin SvelteKit route (not the Go API — do not use `api()`). */
const CATALOG_PATH = "/admin/translations/catalog";
async function webJson<T>(path: string, init?: RequestInit): Promise<T> {
const method = (init?.method ?? "GET").toUpperCase();
const headers: Record<string, string> = {
Accept: "application/json",
...(init?.headers as Record<string, string> | undefined)
};
if (method !== "GET" && method !== "HEAD" && init?.body !== undefined) {
headers["Content-Type"] = "application/json";
}
const res = await fetch(path, {
...init,
method,
credentials: "include",
headers
});
const text = await res.text();
let parsed: unknown = undefined;
if (text) {
try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
}
if (!res.ok) {
const message =
parsed && typeof parsed === "object" && typeof (parsed as { message?: unknown }).message === "string"
? (parsed as { message: string }).message
: res.statusText || "Request failed";
throw new ApiError(message, res.status, parsed);
}
return parsed as T;
}
export async function loadTranslationsCatalog(): Promise<TranslationsCatalogResponse> {
return webJson<TranslationsCatalogResponse>(CATALOG_PATH);
}
export async function saveTranslationUpdates(
locale: string,
updates: MessageDict
): Promise<SaveTranslationsResponse> {
return webJson<SaveTranslationsResponse>(CATALOG_PATH, {
method: "PATCH",
body: JSON.stringify({ locale, updates })
});
}
export function isTranslationsUnavailable(err: unknown): boolean {
return err instanceof ApiError && (err.status === 404 || err.status === 501);
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Operator alert preferences (P0-9) — browser-local until a server prefs API exists.
* Defaults: failure alerts on; completion/success noise off.
*/
export type AlertKind =
| "sync_fail"
| "sync_done"
| "ai_fail"
| "ai_done"
| "export_fail"
| "export_done"
| "support_reply"
| "support_status";
export type AlertPrefs = Record<AlertKind, boolean>;
export const ALERT_PREFS_STORAGE_KEY = "descrybe.alert-prefs.v1";
export const ALERT_PREFS_VERSION = 1;
/** Failures on by default; completions muted to reduce toast noise. */
export const DEFAULT_ALERT_PREFS: AlertPrefs = {
sync_fail: true,
sync_done: false,
ai_fail: true,
ai_done: false,
export_fail: true,
export_done: false,
support_reply: true,
support_status: true
};
export const ALERT_KIND_ORDER: AlertKind[] = [
"sync_fail",
"sync_done",
"ai_fail",
"ai_done",
"export_fail",
"export_done",
"support_reply",
"support_status"
];
export const ALERT_KIND_LABELS: Record<AlertKind, { title: string; description: string }> = {
sync_fail: {
title: "Sync failures",
description: "When a feed sync times out or the API returns an error."
},
sync_done: {
title: "Sync completed",
description: "When a feed sync finishes successfully."
},
ai_fail: {
title: "AI / processing failures",
description: "When starting or running a processing job fails."
},
ai_done: {
title: "AI / processing completed",
description:
"When a background processing job finishes successfully. Job-start toasts stay always-on so Undo remains available."
},
export_fail: {
title: "Export failures",
description: "When a product or export-feed export fails."
},
export_done: {
title: "Export completed",
description: "When an export finishes successfully."
},
support_reply: {
title: "Support staff replies",
description: "When a platform agent replies to your support ticket."
},
support_status: {
title: "Support status changes",
description: "When a support ticket moves to pending or resolved."
}
};
type StoredAlertPrefs = {
version: number;
prefs: Partial<AlertPrefs>;
updatedAt: string;
};
function cloneDefaults(): AlertPrefs {
return { ...DEFAULT_ALERT_PREFS };
}
function normalizePrefs(partial: Partial<AlertPrefs> | null | undefined): AlertPrefs {
const next = cloneDefaults();
if (!partial || typeof partial !== "object") return next;
for (const kind of ALERT_KIND_ORDER) {
const value = partial[kind];
if (typeof value === "boolean") next[kind] = value;
}
return next;
}
export function readAlertPrefs(): AlertPrefs {
if (typeof localStorage === "undefined") return cloneDefaults();
try {
const raw = localStorage.getItem(ALERT_PREFS_STORAGE_KEY);
if (!raw) return cloneDefaults();
const parsed = JSON.parse(raw) as Partial<StoredAlertPrefs>;
if (parsed.version !== ALERT_PREFS_VERSION) return cloneDefaults();
return normalizePrefs(parsed.prefs);
} catch {
return cloneDefaults();
}
}
export function writeAlertPrefs(patch: Partial<AlertPrefs>): AlertPrefs {
const next = normalizePrefs({ ...readAlertPrefs(), ...patch });
if (typeof localStorage !== "undefined") {
try {
const payload: StoredAlertPrefs = {
version: ALERT_PREFS_VERSION,
prefs: next,
updatedAt: new Date().toISOString()
};
localStorage.setItem(ALERT_PREFS_STORAGE_KEY, JSON.stringify(payload));
} catch {
/* ignore quota / private mode */
}
}
return next;
}
export function isAlertEnabled(kind: AlertKind): boolean {
return readAlertPrefs()[kind];
}
export function setAlertEnabled(kind: AlertKind, enabled: boolean): AlertPrefs {
return writeAlertPrefs({ [kind]: enabled });
}
+201
View File
@@ -0,0 +1,201 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
DENIED_CONSENT_DEFAULTS,
estimatedSkusBucket,
parseStoredConsent,
preferencesToConsentSignals,
serializeConsent
} from "./analytics/consent-mode.ts";
import { resolveGtmId } from "./analytics/gtm-id.ts";
import {
buildCreditPackEcommerce,
buildSubscriptionEcommerce,
claimPurchaseTracking,
resolveCheckoutEcommerceFromParams,
safeCheckoutSessionId,
subscriptionListValue,
toEcommerceObject
} from "./analytics/ecommerce.ts";
describe("resolveGtmId", () => {
it("returns null for empty or invalid ids", () => {
assert.equal(resolveGtmId(undefined), null);
assert.equal(resolveGtmId(""), null);
assert.equal(resolveGtmId("G-XXXX"), null);
assert.equal(resolveGtmId("gtm-bad!"), null);
});
it("normalizes valid GTM container ids", () => {
assert.equal(resolveGtmId("GTM-ABC123"), "GTM-ABC123");
assert.equal(resolveGtmId(" gtm-xyz99 "), "GTM-XYZ99");
});
});
describe("consent mode mapping", () => {
it("defaults deny analytics and ads signals", () => {
assert.equal(DENIED_CONSENT_DEFAULTS.analytics_storage, "denied");
assert.equal(DENIED_CONSENT_DEFAULTS.ad_storage, "denied");
assert.equal(DENIED_CONSENT_DEFAULTS.ad_user_data, "denied");
assert.equal(DENIED_CONSENT_DEFAULTS.ad_personalization, "denied");
assert.equal(DENIED_CONSENT_DEFAULTS.security_storage, "granted");
});
it("maps analytics and marketing preferences to Consent Mode v2", () => {
assert.deepEqual(preferencesToConsentSignals({ analytics: true, marketing: false }), {
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
analytics_storage: "granted",
functionality_storage: "granted",
personalization_storage: "granted",
security_storage: "granted"
});
assert.equal(
preferencesToConsentSignals({ analytics: false, marketing: true }).ad_storage,
"granted"
);
});
it("round-trips stored consent JSON", () => {
const raw = serializeConsent({ analytics: true, marketing: false }, "2026-01-01T00:00:00.000Z");
const parsed = parseStoredConsent(raw);
assert.deepEqual(parsed, {
v: 1,
analytics: true,
marketing: false,
updatedAt: "2026-01-01T00:00:00.000Z"
});
assert.equal(parseStoredConsent("{not-json"), null);
assert.equal(parseStoredConsent('{"v":2,"analytics":true,"marketing":false}'), null);
});
});
describe("estimatedSkusBucket", () => {
it("buckets SKU estimates without exposing raw PII-adjacent precision", () => {
assert.equal(estimatedSkusBucket(undefined), "unknown");
assert.equal(estimatedSkusBucket(50), "0_999");
assert.equal(estimatedSkusBucket(2500), "1000_4999");
assert.equal(estimatedSkusBucket(150_000), "100000_plus");
});
});
describe("safeCheckoutSessionId", () => {
it("accepts Stripe Checkout Session ids only", () => {
assert.equal(safeCheckoutSessionId("cs_test_abc123"), "cs_test_abc123");
assert.equal(safeCheckoutSessionId(" cs_live_XYZ "), "cs_live_XYZ");
});
it("rejects customer ids, emails, and garbage", () => {
assert.equal(safeCheckoutSessionId("cus_abc"), undefined);
assert.equal(safeCheckoutSessionId("user@example.com"), undefined);
assert.equal(safeCheckoutSessionId("not-a-session"), undefined);
assert.equal(safeCheckoutSessionId(""), undefined);
});
});
describe("buildSubscriptionEcommerce", () => {
it("builds GA4 items with marketing list price and billing_term extra", () => {
const { ecommerce, extra } = buildSubscriptionEcommerce("starter", "monthly");
assert.equal(extra.plan, "starter");
assert.equal(extra.billing_term, "monthly");
assert.equal(ecommerce.currency, "USD");
assert.equal(ecommerce.value, 49);
assert.deepEqual(ecommerce.items[0], {
item_id: "starter",
item_name: "Starter",
item_category: "subscription",
quantity: 1,
item_variant: "monthly",
price: 49
});
});
it("applies annual discount for yearly term", () => {
const yearly = subscriptionListValue(49, "yearly");
assert.equal(yearly, Math.round(49 * 12 * 0.8 * 100) / 100);
const { ecommerce } = buildSubscriptionEcommerce("starter", "yearly");
assert.equal(ecommerce.value, yearly);
assert.equal(ecommerce.items[0]?.price, yearly);
});
});
describe("buildCreditPackEcommerce", () => {
it("includes value/currency/items from CREDIT_PACKS list prices", () => {
const { ecommerce, extra } = buildCreditPackEcommerce("tiny");
assert.equal(extra.pack_id, "tiny");
assert.equal(ecommerce.currency, "USD");
assert.equal(ecommerce.value, 29);
assert.deepEqual(ecommerce.items[0], {
item_id: "tiny",
item_name: "Nano pack",
item_category: "credit_pack",
quantity: 1,
price: 29
});
});
});
describe("toEcommerceObject + purchase resolve", () => {
it("nests GA4 ecommerce fields for dataLayer", () => {
const { ecommerce } = buildSubscriptionEcommerce("starter", "monthly", {
transactionId: "cs_test_abc"
});
assert.deepEqual(toEcommerceObject(ecommerce), {
items: ecommerce.items,
currency: "USD",
value: 49,
transaction_id: "cs_test_abc"
});
});
it("resolves purchase shape from Stripe return query params", () => {
const params = new URLSearchParams({
checkout: "success",
plan: "plus",
term: "monthly",
session_id: "cs_test_xyz"
});
const resolved = resolveCheckoutEcommerceFromParams(params);
assert.ok(resolved);
assert.equal(resolved.category, "subscription");
assert.equal(resolved.ecommerce.transaction_id, "cs_test_xyz");
assert.equal(resolved.ecommerce.value, 199);
assert.equal(resolved.ecommerce.items[0]?.item_id, "plus");
});
it("resolves credit pack purchase from pack + session_id", () => {
const resolved = resolveCheckoutEcommerceFromParams({
pack: "small",
session_id: "cs_test_pack1"
});
assert.ok(resolved);
assert.equal(resolved.category, "credit_pack");
assert.equal(resolved.ecommerce.transaction_id, "cs_test_pack1");
assert.equal(resolved.ecommerce.value, 59);
});
it("ignores cus_ session_id values", () => {
const resolved = resolveCheckoutEcommerceFromParams({
plan: "starter",
session_id: "cus_should_never_track"
});
assert.ok(resolved);
assert.equal(resolved.ecommerce.transaction_id, undefined);
});
});
describe("claimPurchaseTracking", () => {
it("de-dupes by transaction_id in session storage", () => {
const mem = new Map<string, string>();
const storage = {
getItem: (k: string) => mem.get(k) ?? null,
setItem: (k: string, v: string) => {
mem.set(k, v);
}
};
assert.equal(claimPurchaseTracking("cs_test_1", storage), true);
assert.equal(claimPurchaseTracking("cs_test_1", storage), false);
assert.equal(claimPurchaseTracking(undefined, storage), true);
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* Client analytics: GTM bootstrap, Consent Mode v2, dataLayer helpers.
*
* Setup (ops) — also documented on PUBLIC_GTM_ID in root `.env.example`:
* 1. Create a GA4 property in Google Analytics.
* 2. Create a GTM web container; set PUBLIC_GTM_ID=GTM-XXXX in root `.env`.
* 3. In GTM: GA4 Configuration tag with Consent Settings requiring
* analytics_storage (and ad_* for ads tags); publish the container.
* 4. SPA page views: Custom Event trigger `page_view` (from afterNavigate).
* Do NOT also enable GTM History Change / enhanced measurement page_view —
* that would double-count.
*
* Primary loading is GTM only — do not hard-code a GA measurement ID here.
*/
import { browser } from "$app/environment";
import { env } from "$env/dynamic/public";
import {
CONSENT_STORAGE_KEY,
CONSENT_WAIT_FOR_UPDATE_MS,
DENIED_CONSENT_DEFAULTS,
parseStoredConsent,
preferencesToConsentSignals,
type ConsentModeSignals,
type ConsentPreferences
} from "./analytics/consent-mode";
import { resolveGtmId } from "./analytics/gtm-id";
import {
toEcommerceObject,
type Ga4EcommerceFields
} from "./analytics/ecommerce";
export type { ConsentModeSignals, ConsentPreferences };
export type { Ga4EcommerceFields, Ga4EcommerceItem, Ga4ItemCategory } from "./analytics/ecommerce";
export {
CONSENT_STORAGE_KEY,
CONSENT_VERSION,
acceptAllPreferences,
estimatedSkusBucket,
parseStoredConsent,
preferencesToConsentSignals,
rejectNonEssentialPreferences,
serializeConsent
} from "./analytics/consent-mode";
export { resolveGtmId } from "./analytics/gtm-id";
export {
buildCreditPackEcommerce,
buildSubscriptionEcommerce,
claimPurchaseTracking,
resolveCheckoutEcommerceFromParams,
safeCheckoutSessionId
} from "./analytics/ecommerce";
type DataLayer = Array<Record<string, unknown> | IArguments | unknown[]>;
declare global {
interface Window {
dataLayer?: DataLayer;
gtag?: (...args: unknown[]) => void;
__descrybeGtmLoaded?: string;
__descrybeConsentDefaulted?: boolean;
}
}
function ensureDataLayer(): DataLayer {
if (!browser) return [];
window.dataLayer = window.dataLayer ?? [];
return window.dataLayer;
}
function ensureGtag(): void {
if (!browser) return;
ensureDataLayer();
if (typeof window.gtag === "function") return;
window.gtag = function gtag(...args: unknown[]) {
ensureDataLayer().push(args);
};
}
/** True when the user granted analytics_storage via the CMP. */
export function isAnalyticsGranted(): boolean {
if (!browser) return false;
try {
const stored = parseStoredConsent(localStorage.getItem(CONSENT_STORAGE_KEY));
return stored?.analytics === true;
} catch {
return false;
}
}
/** Call before GTM injects — EU/EEA default denied until CMP update. */
export function ensureConsentDefaults(): void {
if (!browser || window.__descrybeConsentDefaulted) return;
ensureGtag();
window.gtag?.("consent", "default", {
...DENIED_CONSENT_DEFAULTS,
wait_for_update: CONSENT_WAIT_FOR_UPDATE_MS
});
window.__descrybeConsentDefaulted = true;
}
export function updateConsentMode(prefs: ConsentPreferences): void {
if (!browser) return;
ensureGtag();
ensureConsentDefaults();
const signals = preferencesToConsentSignals(prefs);
window.gtag?.("consent", "update", signals);
pushDataLayer({
event: "consent_update",
analytics_storage: signals.analytics_storage,
ad_storage: signals.ad_storage,
ad_user_data: signals.ad_user_data,
ad_personalization: signals.ad_personalization
});
}
export function pushDataLayer(payload: Record<string, unknown>): void {
if (!browser) return;
ensureDataLayer().push(payload);
}
/**
* Custom event helper. No-ops until analytics_storage is granted via CMP.
* Never pass email, password, tokens, names, phone, SKU/GTIN/title, or API keys.
*/
export function trackEvent(event: string, params?: Record<string, unknown>): void {
if (!browser) return;
if (!isAnalyticsGranted()) return;
const name = event.trim();
if (!name) return;
pushDataLayer({
event: name,
...(params ?? {})
});
}
/**
* GA4 ecommerce custom event (GTM). Clears prior ecommerce, then pushes
* `{ event, ecommerce: { currency?, value?, transaction_id?, items } }`.
* Consent-gated like trackEvent. Never pass PII (email, cus_*, names).
*/
export function trackEcommerceEvent(
event: string,
ecommerce: Ga4EcommerceFields,
extra?: Record<string, unknown>
): void {
if (!browser) return;
if (!isAnalyticsGranted()) return;
const name = event.trim();
if (!name) return;
pushDataLayer({ ecommerce: null });
pushDataLayer({
event: name,
ecommerce: toEcommerceObject(ecommerce),
...(extra ?? {})
});
}
/**
* SPA page_view for GTM (Custom Event trigger: page_view).
* Gated on analytics consent. Prefer this over GTM History Change.
*/
export function trackPageview(path: string, opts?: { title?: string; location?: string }): void {
if (!browser) return;
if (!isAnalyticsGranted()) return;
pushDataLayer({
event: "page_view",
page_path: path,
page_title: opts?.title ?? document.title,
page_location: opts?.location ?? window.location.href
});
}
export function resolveConfiguredGtmId(): string | null {
return resolveGtmId(env.PUBLIC_GTM_ID);
}
/** Load GTM container once when PUBLIC_GTM_ID is a valid GTM-XXXX id. */
export function loadGoogleTagManager(gtmId = resolveConfiguredGtmId()): string | null {
if (!browser) return null;
const id = resolveGtmId(gtmId);
if (!id) return null;
if (window.__descrybeGtmLoaded === id) return id;
ensureConsentDefaults();
ensureDataLayer().push({ "gtm.start": Date.now(), event: "gtm.js" });
const script = document.createElement("script");
script.async = true;
script.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(id)}`;
document.head.appendChild(script);
window.__descrybeGtmLoaded = id;
return id;
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Pure Consent Mode v2 helpers (no $app / $env) — safe for node:test.
*
* Categories:
* - Necessary: always on (session, CSRF, theme, locale, consent preference)
* - Analytics → analytics_storage
* - Marketing → ad_storage, ad_user_data, ad_personalization
*/
export const CONSENT_STORAGE_KEY = "descrybe-cookie-consent";
export const CONSENT_VERSION = 1;
export const CONSENT_WAIT_FOR_UPDATE_MS = 500;
export type ConsentPreferences = {
analytics: boolean;
marketing: boolean;
};
export type StoredConsent = ConsentPreferences & {
v: number;
updatedAt: string;
};
/** Google Consent Mode v2 signal map (string literals for gtag). */
export type ConsentModeSignals = {
ad_storage: "granted" | "denied";
ad_user_data: "granted" | "denied";
ad_personalization: "granted" | "denied";
analytics_storage: "granted" | "denied";
functionality_storage: "granted" | "denied";
personalization_storage: "granted" | "denied";
security_storage: "granted" | "denied";
};
export const DENIED_CONSENT_DEFAULTS: ConsentModeSignals = {
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
analytics_storage: "denied",
functionality_storage: "granted",
personalization_storage: "denied",
security_storage: "granted"
};
export function preferencesToConsentSignals(
prefs: ConsentPreferences
): ConsentModeSignals {
const analytics = prefs.analytics ? "granted" : "denied";
const marketing = prefs.marketing ? "granted" : "denied";
return {
ad_storage: marketing,
ad_user_data: marketing,
ad_personalization: marketing,
analytics_storage: analytics,
functionality_storage: "granted",
personalization_storage: analytics,
security_storage: "granted"
};
}
export function parseStoredConsent(raw: string | null | undefined): StoredConsent | null {
if (!raw?.trim()) return null;
try {
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
if (obj.v !== CONSENT_VERSION) return null;
if (typeof obj.analytics !== "boolean" || typeof obj.marketing !== "boolean") {
return null;
}
const updatedAt =
typeof obj.updatedAt === "string" && obj.updatedAt.trim()
? obj.updatedAt
: new Date(0).toISOString();
return {
v: CONSENT_VERSION,
analytics: obj.analytics,
marketing: obj.marketing,
updatedAt
};
} catch {
return null;
}
}
export function serializeConsent(prefs: ConsentPreferences, updatedAt = new Date().toISOString()): string {
const stored: StoredConsent = {
v: CONSENT_VERSION,
analytics: prefs.analytics,
marketing: prefs.marketing,
updatedAt
};
return JSON.stringify(stored);
}
/** Accept all non-necessary categories. */
export function acceptAllPreferences(): ConsentPreferences {
return { analytics: true, marketing: true };
}
/** Reject analytics and marketing (necessary remains on). */
export function rejectNonEssentialPreferences(): ConsentPreferences {
return { analytics: false, marketing: false };
}
/** Coarse SKU-volume bucket for lead events — never send the raw count as PII-adjacent precision. */
export function estimatedSkusBucket(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n) || n < 0) return "unknown";
if (n < 1_000) return "0_999";
if (n < 5_000) return "1000_4999";
if (n < 20_000) return "5000_19999";
if (n < 100_000) return "20000_99999";
return "100000_plus";
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Pure GA4 ecommerce helpers (no $app / $env) — safe for node:test.
*
* List prices come from marketing sources (pricing-data / credit-packs).
* Live Stripe amounts may differ; prefer session_id as transaction_id.
* Never include email, cus_*, customer_id, or other PII.
*/
import { ANNUAL_DISCOUNT, PRICING_PLANS } from "../components/pricing/pricing-data.ts";
import { CREDIT_PACKS } from "../components/pricing/credit-packs.ts";
export type Ga4ItemCategory = "subscription" | "credit_pack";
export type Ga4EcommerceItem = {
item_id: string;
item_name: string;
item_category: Ga4ItemCategory;
quantity: number;
price?: number;
item_variant?: string;
};
export type Ga4EcommerceFields = {
currency?: string;
value?: number;
transaction_id?: string;
items: Ga4EcommerceItem[];
};
export type BillingTerm = "monthly" | "yearly";
const PURCHASE_DEDUP_PREFIX = "descrybe-ga4-purchase:";
/** Stripe Checkout Session ids are opaque `cs_…` — never treat `cus_…` as transaction_id. */
export function safeCheckoutSessionId(raw: string | null | undefined): string | undefined {
const id = (raw ?? "").trim();
if (!id) return undefined;
if (id.startsWith("cus_")) return undefined;
if (id.includes("@")) return undefined;
if (!/^cs_[A-Za-z0-9_]+$/.test(id)) return undefined;
return id;
}
export function normalizeBillingTerm(raw: string | null | undefined): BillingTerm {
const t = (raw ?? "").trim().toLowerCase();
return t === "yearly" || t === "annual" || t === "year" ? "yearly" : "monthly";
}
/** Marketing list price for a subscription term (USD). */
export function subscriptionListValue(
monthlyPrice: number,
term: BillingTerm
): number | undefined {
if (!(monthlyPrice > 0)) return undefined;
if (term === "yearly") {
return Math.round(monthlyPrice * 12 * (1 - ANNUAL_DISCOUNT) * 100) / 100;
}
return monthlyPrice;
}
export function buildSubscriptionEcommerce(
planSlug: string,
term: BillingTerm = "monthly",
opts?: { transactionId?: string }
): { ecommerce: Ga4EcommerceFields; extra: Record<string, unknown> } {
const key = planSlug.trim().toLowerCase();
const marketing = PRICING_PLANS.find((p) => p.name.toLowerCase() === key);
const monthly = marketing?.pricePerMonth;
const price =
typeof monthly === "number" && monthly > 0
? subscriptionListValue(monthly, term)
: undefined;
const itemName = marketing?.name?.trim() || key;
const item: Ga4EcommerceItem = {
item_id: key,
item_name: itemName,
item_category: "subscription",
quantity: 1,
item_variant: term,
...(price !== undefined ? { price } : {})
};
const ecommerce: Ga4EcommerceFields = {
items: [item],
...(price !== undefined ? { currency: "USD", value: price } : {}),
...(opts?.transactionId ? { transaction_id: opts.transactionId } : {})
};
return {
ecommerce,
extra: { billing_term: term, plan: key }
};
}
export function buildCreditPackEcommerce(
packId: string,
opts?: { transactionId?: string; priceUsd?: number; itemName?: string }
): { ecommerce: Ga4EcommerceFields; extra: Record<string, unknown> } {
const key = packId.trim().toLowerCase();
const marketing = CREDIT_PACKS.find((p) => p.id === key);
const price =
typeof opts?.priceUsd === "number" && opts.priceUsd > 0
? opts.priceUsd
: typeof marketing?.priceUSD === "number" && marketing.priceUSD > 0
? marketing.priceUSD
: undefined;
const itemName = (opts?.itemName ?? marketing?.name ?? key).trim() || key;
const item: Ga4EcommerceItem = {
item_id: key,
item_name: itemName,
item_category: "credit_pack",
quantity: 1,
...(price !== undefined ? { price } : {})
};
const ecommerce: Ga4EcommerceFields = {
items: [item],
...(price !== undefined ? { currency: "USD", value: price } : {}),
...(opts?.transactionId ? { transaction_id: opts.transactionId } : {})
};
return {
ecommerce,
extra: { pack_id: key }
};
}
/**
* Resolve ecommerce fields from Stripe return query params (plan/pack/term/session_id).
* Returns null when there is nothing useful to report.
*/
export function resolveCheckoutEcommerceFromParams(
params: URLSearchParams | Record<string, string | null | undefined>
): { ecommerce: Ga4EcommerceFields; extra: Record<string, unknown>; category: Ga4ItemCategory } | null {
const get = (k: string): string | null => {
if (params instanceof URLSearchParams) return params.get(k);
const v = params[k];
return v == null || v === "" ? null : String(v);
};
const pack = (get("pack") ?? "").trim().toLowerCase();
const plan = (get("plan") ?? "").trim().toLowerCase();
const term = normalizeBillingTerm(get("term"));
const transactionId = safeCheckoutSessionId(get("session_id"));
if (pack) {
const built = buildCreditPackEcommerce(pack, { transactionId });
return { ...built, category: "credit_pack" };
}
if (plan) {
const built = buildSubscriptionEcommerce(plan, term, { transactionId });
return { ...built, category: "subscription" };
}
if (transactionId) {
return {
category: "subscription",
ecommerce: { transaction_id: transactionId, items: [] },
extra: {}
};
}
return null;
}
/**
* Session-scoped purchase de-dupe. When transactionId is present, skip repeats.
* Without an id, always allows (caller still fires at most once per mount).
*/
export function claimPurchaseTracking(
transactionId: string | undefined,
storage: Pick<Storage, "getItem" | "setItem"> | null = null
): boolean {
if (!transactionId) return true;
if (!storage) return true;
const key = PURCHASE_DEDUP_PREFIX + transactionId;
try {
if (storage.getItem(key)) return false;
storage.setItem(key, "1");
return true;
} catch {
return true;
}
}
/** Nested ecommerce object for dataLayer (omit undefined fields). */
export function toEcommerceObject(fields: Ga4EcommerceFields): Record<string, unknown> {
const out: Record<string, unknown> = {
items: fields.items
};
if (fields.currency) out.currency = fields.currency;
if (typeof fields.value === "number") out.value = fields.value;
if (fields.transaction_id) out.transaction_id = fields.transaction_id;
return out;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Validate / normalize PUBLIC_GTM_ID (no $env import — pass the raw value in).
* Unset / invalid → null (do not load tags).
*/
const GTM_ID_RE = /^GTM-[A-Z0-9]+$/i;
export function resolveGtmId(raw: string | undefined | null): string | null {
const trimmed = (raw ?? "").trim();
if (!trimmed) return null;
if (!GTM_ID_RE.test(trimmed)) return null;
return trimmed.toUpperCase().replace(/^GTM-/i, "GTM-");
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Pure API error types/helpers (no $env / $app) — safe for node:test.
*/
export class ApiError extends Error {
status: number;
body: unknown;
constructor(message: string, status: number, body: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
/** HTTP status phrases that should not be shown as form copy. */
const OPAQUE_HTTP_STATUS = new Set([
"",
"bad request",
"unauthorized",
"forbidden",
"not found",
"conflict",
"too many requests",
"internal server error",
"service unavailable",
"method not allowed",
"request failed"
]);
function isOpaqueHttpStatus(msg: string): boolean {
return OPAQUE_HTTP_STATUS.has(msg.trim().toLowerCase());
}
/** User-visible message from an `api()` / `apiDownload()` catch value (or any thrown Error). */
export function failureMessage(err: unknown, fallback: string): string {
if (err instanceof Error) {
const msg = err.message.trim();
if (msg && !isOpaqueHttpStatus(msg)) return msg;
}
return fallback;
}
+123
View File
@@ -0,0 +1,123 @@
/**
* apiFormError unit tests (node:test).
* Pure modules only — imports api-error / api-form-error (no $env / $app).
*
* Run from apps/web:
* node --experimental-strip-types --test src/lib/api-form-error.test.ts
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { ApiError } from "./api-error.ts";
import {
apiFormError,
parseBodyErrorCodes,
parseBodyFields
} from "./api-form-error.ts";
const LOGIN_FIELD_HINTS = {
email: ["invalid_credentials", "email", "credentials"],
password: ["invalid_credentials", "password_not_set", "password", "credentials"]
} as const;
describe("parseBodyFields", () => {
it("maps FieldError body.fields object", () => {
assert.deepEqual(
parseBodyFields({
error: "niet geautoriseerd",
code: "invalid_credentials",
fields: {
email: "niet geautoriseerd",
password: "niet geautoriseerd"
}
}),
{
email: "niet geautoriseerd",
password: "niet geautoriseerd"
}
);
});
it("maps body.errors array rows", () => {
assert.deepEqual(
parseBodyFields({
errors: [{ field: "email", message: "required" }]
}),
{ email: "required" }
);
});
});
describe("parseBodyErrorCodes", () => {
it("reads top-level code and nested error.code", () => {
assert.deepEqual(parseBodyErrorCodes({ code: "invalid_credentials" }), [
"invalid_credentials"
]);
assert.deepEqual(
parseBodyErrorCodes({ error: { code: "password_not_set", message: "x" } }),
["password_not_set"]
);
assert.deepEqual(parseBodyErrorCodes({ error: "user_already_exists" }), [
"user_already_exists"
]);
});
});
describe("apiFormError", () => {
it("prefers structured FieldError fields including localized NL messages", () => {
const err = new ApiError("niet geautoriseerd", 401, {
error: "niet geautoriseerd",
code: "invalid_credentials",
fields: {
email: "niet geautoriseerd",
password: "niet geautoriseerd"
}
});
const result = apiFormError(err, "Login failed", LOGIN_FIELD_HINTS);
assert.equal(result.message, "niet geautoriseerd");
assert.deepEqual(result.fields, {
email: "niet geautoriseerd",
password: "niet geautoriseerd"
});
});
it("maps stable codes to fields when body.fields is absent", () => {
const err = new ApiError("Invalid credentials", 401, {
error: "Invalid credentials",
code: "invalid_credentials"
});
const result = apiFormError(err, "Login failed", LOGIN_FIELD_HINTS);
assert.equal(result.message, "Invalid credentials");
assert.equal(result.fields.email, "Invalid credentials");
assert.equal(result.fields.password, "Invalid credentials");
});
it("does not use English needles when localized NL message has no fields/codes", () => {
const err = new ApiError("niet geautoriseerd", 401, {
error: "niet geautoriseerd"
});
const result = apiFormError(err, "Login failed", LOGIN_FIELD_HINTS);
assert.equal(result.message, "niet geautoriseerd");
assert.deepEqual(result.fields, {});
});
it("uses English needles only as last resort for untranslated bodies", () => {
const err = new ApiError("Invalid email or password credentials", 401, {
error: "Invalid email or password credentials"
});
const result = apiFormError(err, "Login failed", LOGIN_FIELD_HINTS);
assert.equal(result.message, "Invalid email or password credentials");
assert.equal(result.fields.email, "Invalid email or password credentials");
assert.equal(result.fields.password, "Invalid email or password credentials");
});
it("skips code-shaped needles in the English last-resort pass", () => {
const err = new ApiError("Something went wrong", 400, {
error: "Something went wrong"
});
const result = apiFormError(err, "Failed", {
email: ["invalid_credentials"]
});
assert.deepEqual(result.fields, {});
});
});
+195
View File
@@ -0,0 +1,195 @@
import { ApiError, failureMessage } from "./api-error.ts";
export type FormErrorResult = {
message: string;
fields: Record<string, string>;
};
/**
* Form field → hint tokens for apiFormError.
*
* Prefer stable API error codes (snake_case, e.g. `user_already_exists`) so
* highlighting works under any Accept-Language / UI locale.
*
* English message substrings are a LAST RESORT for untranslated bodies only —
* they will not match localized es/fr/de validation text. Prefer `body.fields`
* / `body.errors` from the API when available.
*/
export type FieldHintMap = Record<string, readonly string[]>;
function trimMsg(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** True for machine-stable tokens like `password_not_set` (not prose). */
function looksLikeErrorCode(token: string): boolean {
return /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/.test(token);
}
function collectFieldMap(raw: unknown, into: Record<string, string>): void {
if (!raw || typeof raw !== "object") return;
if (Array.isArray(raw)) {
for (const entry of raw) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
const row = entry as Record<string, unknown>;
const key = trimMsg(row.field || row.path || row.name || row.key);
const text = trimMsg(row.message || row.error || row.msg || row.detail);
if (key && text) into[key] = text;
}
return;
}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (typeof value === "string") {
const text = trimMsg(value);
if (text) into[key] = text;
continue;
}
if (Array.isArray(value)) {
const parts = value.map(trimMsg).filter(Boolean);
if (parts.length) into[key] = parts.join(" ");
continue;
}
if (value && typeof value === "object") {
const row = value as Record<string, unknown>;
const text = trimMsg(row.message || row.error || row.msg);
if (text) into[key] = text;
}
}
}
/**
* Structured field map from known API error shapes (additive `fields` / `errors`).
* Also reads nested maps under `error` when that value is an object.
*/
export function parseBodyFields(body: unknown): Record<string, string> {
const fields: Record<string, string> = {};
if (!body || typeof body !== "object") return fields;
const record = body as Record<string, unknown>;
collectFieldMap(record.fields ?? record.errors, fields);
if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) {
const nested = record.error as Record<string, unknown>;
collectFieldMap(nested.fields ?? nested.errors, fields);
}
return fields;
}
/**
* Locale-stable error codes from API bodies:
* - top-level `code`
* - snake_case string `error` (e.g. password_not_set)
* - nested `{ error: { code } }` (CodedError envelope)
*/
export function parseBodyErrorCodes(body: unknown): string[] {
const codes: string[] = [];
const seen = new Set<string>();
const add = (raw: string) => {
const code = raw.trim().toLowerCase();
if (!code || seen.has(code)) return;
seen.add(code);
codes.push(code);
};
if (!body || typeof body !== "object") return codes;
const record = body as Record<string, unknown>;
if (typeof record.code === "string") add(record.code);
if (typeof record.error === "string") {
const err = record.error.trim();
if (looksLikeErrorCode(err.toLowerCase())) add(err);
} else if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) {
const nested = record.error as Record<string, unknown>;
if (typeof nested.code === "string") add(nested.code);
}
return codes;
}
/** Prefer sanitized API body text; never surface opaque HTTP status phrases. */
export function apiErrorMessage(err: unknown, fallback: string): string {
return failureMessage(err, fallback);
}
function applyCodeHints(
fields: Record<string, string>,
message: string,
codes: readonly string[],
fieldHints: FieldHintMap
): void {
if (!codes.length) return;
const codeSet = new Set(codes);
for (const [field, tokens] of Object.entries(fieldHints)) {
if (fields[field]) continue;
const hit = tokens.some((token) => codeSet.has(token.trim().toLowerCase()));
if (hit) fields[field] = message;
}
}
/**
* LAST RESORT: English (or untranslated) message substring matching.
* Skips tokens that look like error codes — those belong in the code pass.
*/
function applyEnglishNeedleHints(
fields: Record<string, string>,
message: string,
fieldHints: FieldHintMap
): void {
const lower = message.toLowerCase();
for (const [field, tokens] of Object.entries(fieldHints)) {
if (fields[field]) continue;
const hit = tokens.some((token) => {
const needle = token.trim().toLowerCase();
if (!needle || looksLikeErrorCode(needle)) return false;
return lower.includes(needle);
});
if (hit) fields[field] = message;
}
}
/**
* Form-facing error: form-level message plus optional field map.
*
* Mapping priority (locale-safe first):
* 1. Structured `body.fields` / `body.errors` (and nested under `error`)
* 2. Stable error codes (`body.code`, snake_case `error`, `error.code`) vs fieldHints
* 3. LAST RESORT: English message substrings in fieldHints (untranslated bodies only)
*/
export function apiFormError(
err: unknown,
fallback: string,
fieldHints?: FieldHintMap
): FormErrorResult {
const message = failureMessage(err, fallback);
const fields: Record<string, string> = {};
if (err instanceof ApiError) {
Object.assign(fields, parseBodyFields(err.body));
if (Object.keys(fields).length === 0 && fieldHints) {
applyCodeHints(fields, message, parseBodyErrorCodes(err.body), fieldHints);
}
}
if (Object.keys(fields).length === 0 && fieldHints) {
applyEnglishNeedleHints(fields, message, fieldHints);
}
return { message, fields };
}
export function fieldInvalid(
fields: Record<string, string>,
name: string
): "true" | undefined {
return fields[name] ? "true" : undefined;
}
export function fieldDescribedBy(
fields: Record<string, string>,
name: string,
formErrorId: string,
fieldErrorId?: string
): string | undefined {
if (!fields[name]) return undefined;
return fieldErrorId ? `${formErrorId} ${fieldErrorId}` : formErrorId;
}
+287
View File
@@ -0,0 +1,287 @@
import { PUBLIC_API_URL } from "$env/static/public";
import { resolveCsrfCookieName } from "$lib/csrf-cookie-name";
import { i18n, preferredAcceptLanguage } from "$lib/i18n";
import { alignLoopbackApiBase } from "$lib/loopback-api";
import { systemMode } from "$lib/system-mode.svelte";
export { alignLoopbackApiBase } from "$lib/loopback-api";
/** Empty PUBLIC_API_URL = same-origin (Vite proxies /api to the Go API on :28471). */
function apiBase(): string {
const raw = (PUBLIC_API_URL ?? "").replace(/\/$/, "");
if (typeof location === "undefined") return raw;
return alignLoopbackApiBase(raw, location.hostname);
}
export { ApiError, failureMessage } from "./api-error.ts";
import { ApiError } from "./api-error.ts";
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
function isMutatingMethod(method: string): boolean {
return MUTATING_METHODS.has(method.toUpperCase());
}
/** True when the API reported maintenance mode on this error. */
export function isMaintenanceError(err: unknown): boolean {
if (!(err instanceof ApiError) || !err.body || typeof err.body !== "object") return false;
const body = err.body as Record<string, unknown>;
return body.maintenance === true || body.error === "maintenance";
}
/** True when the API reported read-only mode on this error. */
export function isReadOnlyError(err: unknown): boolean {
if (!(err instanceof ApiError) || !err.body || typeof err.body !== "object") return false;
const body = err.body as Record<string, unknown>;
return body.read_only === true || body.error === "read_only";
}
function throwIfMutationsBlocked(method: string): void {
if (!isMutatingMethod(method) || !systemMode.mutationsBlocked) return;
const maintenance = systemMode.maintenance;
const body = {
error: maintenance ? "maintenance" : "read_only",
maintenance,
read_only: systemMode.readOnly || !maintenance
};
throw new ApiError(
errorMessage(body, maintenance ? "maintenance" : "read_only"),
503,
body
);
}
/** Session missing / expired — send the user to login. */
export function isUnauthorized(err: unknown): boolean {
return err instanceof ApiError && err.status === 401;
}
/** Authenticated but not allowed — show a permission empty state, not login. */
export function isForbidden(err: unknown): boolean {
return err instanceof ApiError && err.status === 403;
}
/** Member-facing copy when a company-admin-only mutation returns 403. */
export function companyAdminDeniedMessage(action?: string): string {
return i18n.t("errors.companyAdminDenied", {
action: action ?? i18n.t("errors.companyAdminDenied.actionDefault")
});
}
export type ApiOptions = Omit<RequestInit, "body"> & {
body?: unknown;
};
function errorMessage(body: unknown, fallback: string): string {
if (body && typeof body === "object") {
const record = body as Record<string, unknown>;
if (typeof record.message === "string" && record.message.trim()) {
return record.message.trim();
}
if (typeof record.error === "string" && record.error.trim()) {
const code = record.error.trim().toLowerCase();
if (code === "maintenance" || record.maintenance === true) {
return i18n.t("errors.maintenance");
}
if (code === "read_only" || record.read_only === true) {
return i18n.t("errors.readOnly");
}
return record.error.trim();
}
if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) {
const nested = record.error as Record<string, unknown>;
if (typeof nested.message === "string" && nested.message.trim()) {
return nested.message.trim();
}
}
if (record.maintenance === true) {
return i18n.t("errors.maintenance");
}
if (record.read_only === true) {
return i18n.t("errors.readOnly");
}
}
return fallback;
}
function readCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const parts = document.cookie.split(";").map((p) => p.trim());
for (const part of parts) {
if (part.startsWith(`${name}=`)) {
return decodeURIComponent(part.slice(name.length + 1));
}
}
return null;
}
function mintCsrfToken(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
let out = "";
for (const b of bytes) {
out += b.toString(16).padStart(2, "0");
}
return out;
}
/** Matches API CSRF_COOKIE_NAME via PUBLIC_CSRF_COOKIE_NAME (default descrybe_csrf). */
const CSRF_COOKIE_NAME = resolveCsrfCookieName(import.meta.env.PUBLIC_CSRF_COOKIE_NAME);
/** Matches API CSRF Max-Age (7 days). */
const CSRF_MAX_AGE_SEC = 7 * 24 * 60 * 60;
/** Secure flag: only on HTTPS pages. Never set Secure on http (incl. localhost preview of PROD builds). */
function csrfCookieSecure(): boolean {
return typeof location !== "undefined" && location.protocol === "https:";
}
/** Dedup concurrent seed GETs (login submit + parallel mutations). */
let csrfSeedInflight: Promise<string | null> | null = null;
/**
* Double-submit CSRF: cookie value must equal X-CSRF-Token on mutating calls.
* Proven pattern (browser + curl): GET /api/auth/me seeds descrybe_csrf (401 ok when
* logged out), then POST with X-CSRF-Token matching that cookie. Prefer API-issued
* cookie over local mint so the jar matches what credentialed fetch sends.
*/
async function ensureCsrfCookie(apiBase: string): Promise<string | null> {
let token = readCookie(CSRF_COOKIE_NAME);
if (token) return token;
if (typeof document === "undefined") return null;
if (!csrfSeedInflight) {
csrfSeedInflight = (async () => {
try {
const seedPath = "/api/auth/me";
const seedUrl = apiBase ? `${apiBase}${seedPath}` : seedPath;
await fetch(seedUrl, {
method: "GET",
credentials: "include",
headers: { Accept: "application/json" }
});
} catch {
/* network — fall through to mint */
}
const seeded = readCookie(CSRF_COOKIE_NAME);
if (seeded) return seeded;
// Same-host mint fallback (loopback twin already aligned via apiBase()).
const minted = mintCsrfToken();
const secure = csrfCookieSecure() ? "; Secure" : "";
document.cookie = `${CSRF_COOKIE_NAME}=${encodeURIComponent(minted)}; Path=/; SameSite=Lax; Max-Age=${CSRF_MAX_AGE_SEC}${secure}`;
return minted;
})().finally(() => {
csrfSeedInflight = null;
});
}
return csrfSeedInflight;
}
export async function api<T = unknown>(path: string, options: ApiOptions = {}): Promise<T> {
const { body, headers, method, ...rest } = options;
const base = apiBase();
const url = path.startsWith("http") ? path : `${base}${path.startsWith("/") ? path : `/${path}`}`;
const isForm = typeof FormData !== "undefined" && body instanceof FormData;
const verb = (method || (body !== undefined ? "POST" : "GET")).toUpperCase();
throwIfMutationsBlocked(verb);
const reqHeaders: Record<string, string> = {
Accept: "application/json",
"Accept-Language": preferredAcceptLanguage(),
...(body !== undefined && !isForm ? { "Content-Type": "application/json" } : {}),
...(headers as Record<string, string> | undefined)
};
if (verb !== "GET" && verb !== "HEAD" && verb !== "OPTIONS") {
const csrf = await ensureCsrfCookie(base);
if (csrf) reqHeaders["X-CSRF-Token"] = csrf;
}
const res = await fetch(url, {
...rest,
method: verb,
credentials: "include",
headers: reqHeaders,
body: body === undefined ? undefined : isForm ? (body as FormData) : JSON.stringify(body)
});
if (res.status === 204) {
return undefined as T;
}
const text = await res.text();
let parsed: unknown = undefined;
if (text) {
try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
}
systemMode.applyFromBody(parsed);
if (!res.ok) {
throw new ApiError(errorMessage(parsed, i18n.t("errors.requestFailed")), res.status, parsed);
}
return parsed as T;
}
export function apiUrl(path = ""): string {
const base = apiBase();
if (!path) return base;
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
}
export async function apiDownload(
path: string,
options: ApiOptions = {}
): Promise<{ blob: Blob; filename: string | null; productsExported: number | null }> {
const { body, headers, method, ...rest } = options;
const base = apiBase();
const url = path.startsWith("http") ? path : `${base}${path.startsWith("/") ? path : `/${path}`}`;
const isForm = typeof FormData !== "undefined" && body instanceof FormData;
const verb = (method || (body !== undefined ? "POST" : "GET")).toUpperCase();
throwIfMutationsBlocked(verb);
const reqHeaders: Record<string, string> = {
Accept: "*/*",
"Accept-Language": preferredAcceptLanguage(),
...(body !== undefined && !isForm ? { "Content-Type": "application/json" } : {}),
...(headers as Record<string, string> | undefined)
};
if (verb !== "GET" && verb !== "HEAD" && verb !== "OPTIONS") {
const csrf = await ensureCsrfCookie(base);
if (csrf) reqHeaders["X-CSRF-Token"] = csrf;
}
const res = await fetch(url, {
...rest,
method: verb,
credentials: "include",
headers: reqHeaders,
body: body === undefined ? undefined : isForm ? (body as FormData) : JSON.stringify(body)
});
if (!res.ok) {
const text = await res.text();
let parsed: unknown = text;
try {
parsed = text ? JSON.parse(text) : undefined;
} catch {
/* keep text */
}
systemMode.applyFromBody(parsed);
throw new ApiError(errorMessage(parsed, i18n.t("errors.requestFailed")), res.status, parsed);
}
const disposition = res.headers.get("Content-Disposition") ?? "";
const match = /filename\*?=(?:UTF-8''|")?([^\";]+)"?/i.exec(disposition);
const filename = match ? decodeURIComponent(match[1].replace(/"/g, "").trim()) : null;
const exportedRaw = res.headers.get("X-Products-Exported");
const productsExported = exportedRaw && /^\d+$/.test(exportedRaw) ? Number(exportedRaw) : null;
return { blob: await res.blob(), filename, productsExported };
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,223 @@
/**
* Deterministic System assistant unit tests.
* Run: node scripts/test-assistant.mjs
* Imports pure modules only (no $lib / $app — those need the SvelteKit runtime).
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { INTENT_REGISTRY, intentById } from "./intents.ts";
import { matchIntent, matchIdentityQuestion, normalizeUtterance } from "./match.ts";
import {
buildIdentityReply,
buildApiExamplesMessage,
buildHelpOverview,
sanitizeAssistantText,
redactSecrets,
isHttpUrl,
normalizeAttributeType,
slugAttributeKey
} from "./engine.ts";
describe("System assistant matching", () => {
it("matches getting started / help", () => {
const m = matchIntent("help");
assert.equal(m?.intent.id, "help_overview");
});
it("matches add feed url and captures URL", () => {
const m = matchIntent("add feed https://example.com/products.xml");
assert.equal(m?.intent.id, "add_feed_url");
assert.equal(m?.capturedUrl, "https://example.com/products.xml");
});
it("matches map fields", () => {
assert.equal(matchIntent("auto-map my columns")?.intent.id, "map_fields");
});
it("matches create api key over open keys when create is present", () => {
assert.equal(matchIntent("create api key")?.intent.id, "create_api_key");
assert.equal(matchIntent("generate api key for CI")?.intent.id, "create_api_key");
});
it("matches open api keys", () => {
assert.equal(matchIntent("open api keys")?.intent.id, "open_api_keys");
assert.equal(matchIntent("settings api keys")?.intent.id, "open_api_keys");
});
it("matches api examples / developer help", () => {
assert.equal(matchIntent("curl examples")?.intent.id, "api_examples");
assert.equal(matchIntent("how to use api key")?.intent.id, "api_examples");
assert.equal(matchIntent("sample curl for attributes")?.intent.id, "api_examples");
});
it("matches attributes intents", () => {
assert.equal(matchIntent("list attributes")?.intent.id, "list_attributes");
assert.equal(matchIntent("create attribute")?.intent.id, "create_attribute");
assert.equal(matchIntent("open attributes")?.intent.id, "open_attributes");
assert.equal(matchIntent("Attributes")?.intent.id, "open_attributes");
});
it("matches processing and support", () => {
assert.equal(matchIntent("start processing")?.intent.id, "start_processing");
assert.equal(matchIntent("process all products")?.intent.id, "start_processing");
assert.equal(matchIntent("open a support ticket")?.intent.id, "create_support_ticket");
assert.equal(matchIntent("go to support")?.intent.id, "open_support");
assert.equal(matchIntent("Support")?.intent.id, "open_support");
});
it("matches pricing suggestion", () => {
assert.equal(matchIntent("which plan do I need")?.intent.id, "suggest_pricing");
assert.equal(matchIntent("suggest plan")?.intent.id, "suggest_pricing");
});
it("keeps existing store connectors", () => {
assert.equal(matchIntent("connect shopify")?.intent.id, "connect_shopify");
assert.equal(matchIntent("connect woocommerce")?.intent.id, "connect_woocommerce");
});
it("matches navigate intents for primary nav tabs", () => {
const cases = [
["go to dashboard", "open_dashboard"],
["open products", "open_products"],
["show categories", "open_categories"],
["export feeds", "open_export_feeds"],
["open processing", "open_processing"],
["go to campaigns", "open_campaigns"],
["content calendar", "open_content_calendar"],
["open seo", "open_seo"],
["brand kit", "open_brand"],
["product reviews", "open_reviews"],
["ai integrations", "open_ai_integrations"],
["email sending", "open_email_integrations"],
["usage and billing", "open_billing"],
["company settings", "open_settings"],
["platform admin", "open_admin"]
] as const;
for (const [utterance, id] of cases) {
assert.equal(matchIntent(utterance)?.intent.id, id, utterance);
}
});
});
describe("bot / AI identity answers", () => {
it("detects bot/AI/LLM/ChatGPT questions", () => {
assert.equal(matchIdentityQuestion("are you a bot?"), true);
assert.equal(matchIdentityQuestion("Are you an AI?"), true);
assert.equal(matchIdentityQuestion("are you ChatGPT"), true);
assert.equal(matchIdentityQuestion("is this an LLM"), true);
assert.equal(matchIdentityQuestion("what are you"), true);
assert.equal(matchIdentityQuestion("add a feed"), false);
});
it("matchIntent returns identity_system for those questions", () => {
assert.equal(matchIntent("are you a bot")?.intent.id, "identity_system");
assert.equal(matchIntent("are you an LLM chatbot")?.intent.id, "identity_system");
});
it("identity reply says System assistant and denies LLM chatbot", () => {
const msg = buildIdentityReply();
assert.match(msg.text, /System assistant/i);
assert.match(msg.text, /not an LLM chatbot/i);
assert.doesNotMatch(msg.text, /\bno LLM\b/i);
assert.doesNotMatch(msg.text, /\bno AI\b/i);
});
});
describe("API examples intent", () => {
it("documents real v1 routes with placeholder key only", () => {
const msg = buildApiExamplesMessage();
assert.match(msg.text, /dk_YOUR_API_KEY/);
assert.match(msg.text, /\/api\/v1\/attributes/);
assert.match(msg.text, /\/api\/v1\/process/);
assert.match(msg.text, /X-API-Key/);
assert.doesNotMatch(msg.text, /\bdk_[A-Za-z0-9]{16,}\b/);
});
});
describe("help overview branding", () => {
it("does not advertise no AI / no LLM", () => {
const help = intentById("help_overview");
assert.ok(help);
const msgs = buildHelpOverview(help);
const blob = msgs.map((m) => m.text).join("\n");
assert.match(blob, /System assistant/);
assert.doesNotMatch(blob, /no AI/i);
assert.doesNotMatch(blob, /no LLM/i);
});
});
describe("sanitization & helpers", () => {
it("strips control characters", () => {
assert.equal(sanitizeAssistantText("hi\u0000there"), "hithere");
});
it("redacts api secrets", () => {
assert.match(redactSecrets("key=dk_abcdefghijklmnopqrstuv"), /dk_\u2022\u2022\u2022/);
});
it("validates http urls", () => {
assert.equal(isHttpUrl("https://example.com/feed.xml"), true);
assert.equal(isHttpUrl("ftp://example.com/x"), false);
assert.equal(isHttpUrl("https://user:pass@example.com/x"), false);
});
it("normalizes attribute keys and types", () => {
assert.equal(slugAttributeKey("Color Name!"), "color_name");
assert.equal(normalizeAttributeType("text"), "string");
assert.equal(normalizeAttributeType("dropdown"), "list");
});
it("normalizes utterances", () => {
assert.equal(normalizeUtterance(" Hello, WORLD! "), "hello world");
});
});
describe("intent registry contracts", () => {
it("registers expected new intents", () => {
const ids = new Set(INTENT_REGISTRY.map((i) => i.id));
const expected = [
"identity_system",
"open_dashboard",
"open_products",
"open_categories",
"open_export_feeds",
"open_processing",
"open_campaigns",
"open_content_calendar",
"open_seo",
"open_brand",
"open_reviews",
"open_ai_integrations",
"open_email_integrations",
"open_billing",
"open_settings",
"open_admin",
"open_api_keys",
"create_api_key",
"api_examples",
"open_attributes",
"list_attributes",
"create_attribute",
"open_support",
"create_support_ticket",
"suggest_pricing"
] as const;
for (const id of expected) {
assert.ok(ids.has(id), "missing " + id);
}
});
it("writes require confirm; list/create api can execute", () => {
assert.equal(intentById("create_api_key")?.canExecute, true);
assert.equal(intentById("create_api_key")?.requiresConfirm, true);
assert.equal(intentById("list_attributes")?.canExecute, true);
assert.equal(intentById("start_processing")?.canExecute, true);
assert.equal(intentById("map_fields")?.canExecute, false);
assert.equal(intentById("api_examples")?.canExecute, false);
});
it("api keys route targets settings tab", () => {
assert.equal(intentById("open_api_keys")?.route, "/settings?tab=api-keys");
assert.match(intentById("create_api_key")?.selector ?? "", /api-keys-create/);
});
});
+323
View File
@@ -0,0 +1,323 @@
import type { AssistantMessage, ConfirmAction, FlowState, IntentDefinition, IntentId } from "./types.ts";
let msgSeq = 0;
export function newMessageId(): string {
msgSeq += 1;
return `am-${Date.now()}-${msgSeq}`;
}
/** Strip control chars from chat text (UI already escapes HTML; this hardens paste payloads). */
export function sanitizeAssistantText(text: string): string {
return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
}
/** Redact API secrets and long tokens from assistant-visible text. */
export function redactSecrets(text: string): string {
return text
.replace(/\bdk_[A-Za-z0-9_-]{8,}\b/g, "dk_•••")
.replace(/\b(Bearer\s+)[A-Za-z0-9._-]{12,}/gi, "$1•••")
.replace(/\b(sk-|pk_|whsec_)[A-Za-z0-9_-]{8,}\b/g, "$1•••");
}
export function makeMessage(
partial: Omit<AssistantMessage, "id" | "createdAt"> & { id?: string; createdAt?: string }
): AssistantMessage {
return {
id: partial.id ?? newMessageId(),
createdAt: partial.createdAt ?? new Date().toISOString(),
role: partial.role,
kind: partial.kind,
text: sanitizeAssistantText(partial.text),
quickReplies: partial.quickReplies,
confirm: partial.confirm,
steps: partial.steps,
error: partial.error
? { ...partial.error, detail: redactSecrets(sanitizeAssistantText(partial.error.detail)) }
: undefined,
progress: partial.progress,
inputKind: partial.inputKind
};
}
export function idleFlow(): FlowState {
return { flowId: "idle", intentId: null, stepId: "idle", slots: {} };
}
export function confirmActionsFor(intent: IntentDefinition): ConfirmAction[] {
const actions: ConfirmAction[] = ["guide"];
if (intent.canExecute) actions.push("execute");
actions.push("cancel");
return actions;
}
export function buildConfirmCard(
intent: IntentDefinition,
payload?: Record<string, string>
): AssistantMessage {
const execHint = intent.canExecute
? "Choose Guide me for step-by-step highlights, or Do it for me to run the safe API action."
: "This path is guide-only. Choose Guide me to highlight what to click.";
return makeMessage({
role: "assistant",
kind: "confirm",
text: `${intent.label}: ${intent.description}\n\n${execHint}`,
confirm: {
intentId: intent.id,
actions: confirmActionsFor(intent),
payload
},
steps: intent.guideSteps
});
}
export function buildIdentityReply(): AssistantMessage {
return makeMessage({
role: "assistant",
kind: "text",
text: "I am not an LLM chatbot. I am the System assistant — I help you navigate Descrybe, run supported actions after you confirm, and guide setup using built-in workflows."
});
}
export function buildApiExamplesMessage(): AssistantMessage {
const key = "dk_YOUR_API_KEY";
const text = [
"Use your API key in the X-API-Key header (placeholder below — never paste a real secret into chat).",
"",
"List attributes:",
`curl -s -H "X-API-Key: ${key}" "https://descrybe.io/api/v1/attributes?page=1&limit=25"`,
"",
"Create attribute:",
`curl -s -X POST -H "X-API-Key: ${key}" -H "Content-Type: application/json" \\`,
` -d '{"attribute_key":"color","name":"Color","value_type":"string"}' \\`,
` "https://descrybe.io/api/v1/attributes"`,
"",
"Start processing (by raw product IDs):",
`curl -s -X POST -H "X-API-Key: ${key}" -H "Content-Type: application/json" \\`,
` -d '{"raw_product_ids":["PRODUCT_UUID"],"processing_type":"full"}' \\`,
` "https://descrybe.io/api/v1/process"`,
"",
"Session (logged-in) equivalents: GET/POST /api/attributes, POST /api/processing/jobs.",
"Full OpenAPI: /docs"
].join("\n");
return makeMessage({
role: "assistant",
kind: "text",
text
});
}
export function buildHelpOverview(intent: IntentDefinition): AssistantMessage[] {
return [
makeMessage({
role: "assistant",
kind: "text",
text: "I am the System assistant. I match what you type to known tasks, then guide you or run safe dashboard actions after you confirm."
}),
makeMessage({
role: "assistant",
kind: "steps",
text: "Typical first-time path:",
steps: intent.guideSteps
}),
makeMessage({
role: "assistant",
kind: "quick_replies",
text: "What do you want to do?",
quickReplies: [
"Add a feed URL",
"Upload a CSV",
"Map fields",
"API keys",
"Attributes",
"Start processing",
"API examples"
]
})
];
}
export function buildUnknownReply(): AssistantMessage {
return makeMessage({
role: "assistant",
kind: "quick_replies",
text: "I did not match that to a known task. Try one of these, or say “help”.",
quickReplies: [
"Help",
"Add a feed URL",
"Upload a CSV",
"Map fields",
"API keys",
"Open Feeds",
"Support"
]
});
}
export function buildGuideMessages(intent: IntentDefinition): AssistantMessage[] {
return [
makeMessage({
role: "assistant",
kind: "steps",
text: `Guiding you: ${intent.label}`,
steps: intent.guideSteps
}),
makeMessage({
role: "assistant",
kind: "text",
text: `Opening ${intent.route} and highlighting the control to use. Follow the steps above — tell me when you are stuck.`
})
];
}
export function buildFailureSupportOffer(issueDetail: string): AssistantMessage {
const safe = redactSecrets(sanitizeAssistantText(issueDetail)).slice(0, 240);
return makeMessage({
role: "assistant",
kind: "quick_replies",
text: `That action failed${safe ? ` (${safe})` : ""}. You can open a support ticket with this error summary (no secrets).`,
quickReplies: ["Open a support ticket", "Help", "Cancel"]
});
}
/** Start a multi-step flow that collects inputs before execute. */
export function startCollectFlow(intentId: IntentId): { flow: FlowState; messages: AssistantMessage[] } {
if (intentId === "add_feed_url") {
return {
flow: { flowId: "add_feed_url", intentId, stepId: "ask_url", slots: {} },
messages: [
makeMessage({
role: "assistant",
kind: "input_prompt",
text: "Paste the feed URL (http or https). I will ask for confirmation before creating the feed.",
inputKind: "url"
})
]
};
}
if (intentId === "upload_feed") {
return {
flow: { flowId: "upload_feed", intentId, stepId: "ask_file", slots: {} },
messages: [
makeMessage({
role: "assistant",
kind: "input_prompt",
text: "Choose a CSV file to upload. I will confirm before creating the feed.",
inputKind: "file"
})
]
};
}
if (intentId === "sync_feed") {
return {
flow: { flowId: "sync_feed", intentId, stepId: "ask_feed_id", slots: {} },
messages: [
makeMessage({
role: "assistant",
kind: "input_prompt",
text: "Paste the feed ID to sync, or open Feeds and use Guide me to click Sync now on a row.",
inputKind: "text"
})
]
};
}
if (intentId === "create_api_key") {
return {
flow: { flowId: "create_api_key", intentId, stepId: "ask_name", slots: {} },
messages: [
makeMessage({
role: "assistant",
kind: "input_prompt",
text: "Name for the API key (e.g. CI, staging). I will confirm before creating it.",
inputKind: "text"
})
]
};
}
if (intentId === "create_attribute") {
return {
flow: { flowId: "create_attribute", intentId, stepId: "ask_key", slots: {} },
messages: [
makeMessage({
role: "assistant",
kind: "input_prompt",
text: "Attribute key (snake_case, e.g. color or wattage). Next I will ask for display name and type.",
inputKind: "text"
})
]
};
}
if (intentId === "start_processing") {
return {
flow: { flowId: "start_processing", intentId, stepId: "ask_scope", slots: {} },
messages: [
makeMessage({
role: "assistant",
kind: "input_prompt",
text: 'Scope: type "all" for unprocessed products, or a category id/path (e.g. electronics). Max 25 products per run.',
inputKind: "text"
})
]
};
}
if (intentId === "create_support_ticket") {
return {
flow: { flowId: "create_support_ticket", intentId, stepId: "ask_subject", slots: {} },
messages: [
makeMessage({
role: "assistant",
kind: "input_prompt",
text: "Ticket subject (short). Do not include passwords or API keys.",
inputKind: "text"
})
]
};
}
return {
flow: idleFlow(),
messages: [makeMessage({ role: "assistant", kind: "text", text: "Nothing to collect for this task." })]
};
}
export function isHttpUrl(value: string): boolean {
try {
const trimmed = value.trim();
if (!trimmed || trimmed.length > 2048) return false;
const u = new URL(trimmed);
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
if (!u.hostname) return false;
// Reject embedded credentials in assistant-collected URLs.
if (u.username || u.password) return false;
return true;
} catch {
return false;
}
}
export function inferFeedType(url: string): "xml" | "csv" {
const lower = url.toLowerCase();
if (lower.includes(".csv") || lower.includes("format=csv") || lower.includes("type=csv")) {
return "csv";
}
return "xml";
}
const ATTR_TYPES = new Set(["string", "number", "boolean", "date", "list", "multiselect"]);
export function normalizeAttributeType(raw: string): string {
const t = raw.trim().toLowerCase();
if (ATTR_TYPES.has(t)) return t;
if (t === "text") return "string";
if (t === "bool" || t === "yes/no") return "boolean";
if (t === "dropdown") return "list";
return "string";
}
export function slugAttributeKey(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 64);
}
+355
View File
@@ -0,0 +1,355 @@
import { api, ApiError } from "$lib/api";
import { createSupportTicket } from "$lib/support/api";
import {
buildApiExamplesMessage,
inferFeedType,
isHttpUrl,
normalizeAttributeType,
redactSecrets,
slugAttributeKey
} from "./engine.ts";
import type { ExecutorResult, IntentId } from "./types.ts";
type FeedRow = { id?: string | number; name?: string };
type AttrRow = { id?: string; attribute_key?: string; name?: string; value_type?: string };
type PlanRow = {
id?: number;
name?: string;
max_products?: number | null;
monthly_credits?: number;
description?: string;
};
type ProductRow = { id?: string | number; raw_product_id?: string | number; category?: string };
const PROCESS_BATCH_LIMIT = 25;
function issueFromUnknown(err: unknown, fallback: string): ExecutorResult {
if (err instanceof ApiError) {
return {
ok: false,
issue: {
status: err.status,
code: typeof err.body === "object" && err.body && "error" in err.body
? String((err.body as { error?: unknown }).error ?? "")
: undefined,
detail: redactSecrets(err.message || fallback)
}
};
}
const detail = err instanceof Error ? err.message : fallback;
return { ok: false, issue: { detail: redactSecrets(detail) } };
}
function asId(value: unknown): string {
if (typeof value === "string" && value.trim()) return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return "";
}
export async function executeIntent(
intentId: IntentId,
slots: Record<string, string>,
file?: File | null
): Promise<ExecutorResult> {
switch (intentId) {
case "add_feed_url": {
const url = (slots.url ?? "").trim();
if (!isHttpUrl(url)) {
return { ok: false, issue: { detail: "A valid http(s) feed URL is required." } };
}
const name = (slots.name ?? "").trim() || deriveNameFromUrl(url);
const feedType = (slots.feed_type as "xml" | "csv" | undefined) ?? inferFeedType(url);
try {
const created = await api<FeedRow>("/api/feeds", {
method: "POST",
body: {
name,
url,
feed_type: feedType,
sync_interval_minutes: Number(slots.sync_interval_minutes) || 60
}
});
const feedId = created?.id != null ? String(created.id) : "";
return {
ok: true,
message: feedId
? `Feed created. Next: map fields before syncing.`
: "Feed created.",
feedId: feedId || undefined,
href: feedId ? `/feeds/${feedId}/mapping` : "/feeds"
};
} catch (err) {
return issueFromUnknown(err, "Could not create feed");
}
}
case "upload_feed": {
if (!file) {
return { ok: false, issue: { detail: "Choose a CSV file before uploading." } };
}
const name = (slots.name ?? "").trim() || file.name.replace(/\.[^.]+$/, "") || "Uploaded feed";
const body = new FormData();
body.append("name", name);
body.append("feed_type", "csv");
body.append("sync_interval_minutes", String(Number(slots.sync_interval_minutes) || 60));
body.append("file", file);
try {
const created = await api<FeedRow>("/api/feeds", { method: "POST", body });
const feedId = created?.id != null ? String(created.id) : "";
return {
ok: true,
message: "Feed uploaded. Next: map fields before syncing.",
feedId: feedId || undefined,
href: feedId ? `/feeds/${feedId}/mapping` : "/feeds"
};
} catch (err) {
return issueFromUnknown(err, "Could not upload feed");
}
}
case "sync_feed": {
const feedId = (slots.feed_id ?? "").trim();
if (!feedId) {
return { ok: false, issue: { detail: "Feed ID is required to sync." } };
}
try {
await api(`/api/feeds/${encodeURIComponent(feedId)}/sync`, { method: "POST" });
return {
ok: true,
message: "Sync started. Check the feed row for job status.",
feedId,
href: "/feeds"
};
} catch (err) {
return issueFromUnknown(err, "Could not start sync");
}
}
case "create_api_key": {
const name = (slots.name ?? "").trim() || "System assistant key";
try {
const created = await api<{ id?: string; key?: string; key_prefix?: string }>("/api/api-keys", {
method: "POST",
body: { name }
});
const secret = typeof created.key === "string" ? created.key : "";
const prefix = created.key_prefix ?? (secret ? secret.slice(0, 10) : "");
const lines = [
`API key created${prefix ? ` (${prefix}…)` : ""}.`,
secret
? `Copy this secret now — it will not be shown again:\n${secret}`
: "Secret was not returned (you may lack permission). Create one in Settings → API Keys.",
"",
"Example (placeholder if you already copied the secret elsewhere):",
`curl -s -H "X-API-Key: dk_YOUR_API_KEY" "https://descrybe.io/api/v1/attributes?page=1&limit=5"`
];
return {
ok: true,
message: lines.join("\n"),
href: "/settings?tab=api-keys"
};
} catch (err) {
return issueFromUnknown(err, "Could not create API key");
}
}
case "list_attributes": {
try {
const payload = await api<{ attributes?: AttrRow[]; total?: number }>(
"/api/attributes?limit=20&offset=0&roots=1"
);
const items = Array.isArray(payload.attributes) ? payload.attributes : [];
const total = typeof payload.total === "number" ? payload.total : items.length;
const sample = items
.slice(0, 5)
.map((a) => a.name || a.attribute_key || a.id || "?")
.filter(Boolean);
const sampleLine = sample.length ? ` Sample: ${sample.join(", ")}.` : "";
return {
ok: true,
message: `You have ${total} attribute(s) (showing up to 20 roots).${sampleLine}`,
href: "/attributes"
};
} catch (err) {
return issueFromUnknown(err, "Could not list attributes");
}
}
case "create_attribute": {
const key = slugAttributeKey(slots.attribute_key ?? slots.key ?? "");
const name = (slots.name ?? "").trim() || key;
const valueType = normalizeAttributeType(slots.value_type ?? slots.type ?? "string");
if (!key || key.length < 2) {
return { ok: false, issue: { detail: "attribute_key is required (e.g. color)." } };
}
try {
const created = await api<AttrRow>("/api/attributes", {
method: "POST",
body: {
attribute_key: key,
name,
value_type: valueType,
unit: null,
example: null,
parent_key: null
}
});
return {
ok: true,
message: `Attribute created: ${created.name ?? name} (${created.attribute_key ?? key}, ${valueType}).`,
href: "/attributes"
};
} catch (err) {
return issueFromUnknown(err, "Could not create attribute");
}
}
case "start_processing": {
const scope = (slots.scope ?? slots.category ?? "all").trim().toLowerCase();
const category = scope === "all" || scope === "*" ? "" : (slots.scope ?? slots.category ?? "").trim();
const params = new URLSearchParams({
kind: "raw",
status: "unprocessed",
limit: String(PROCESS_BATCH_LIMIT),
offset: "0"
});
if (category) params.set("category", category);
try {
const payload = await api<{ products?: ProductRow[]; total?: number }>(
`/api/products?${params.toString()}`
);
const products = Array.isArray(payload.products) ? payload.products : [];
const ids = products
.map((p) => asId(p.raw_product_id ?? p.id))
.filter(Boolean)
.slice(0, PROCESS_BATCH_LIMIT);
if (ids.length === 0) {
return {
ok: false,
issue: {
detail: category
? `No unprocessed products found for category “${category}”. Use Guide me on Products instead.`
: "No unprocessed products found. Sync a feed first, or use Guide me on Products."
}
};
}
const job = await api<{
id?: string | number;
jobs?: Array<{ id?: string | number }>;
total_products?: number;
}>("/api/processing/jobs", {
method: "POST",
body: {
raw_product_ids: ids,
processing_type: "full",
processing_types: ["category", "attributes", "title", "description"]
}
});
const jobId =
asId(job.id) ||
(Array.isArray(job.jobs) && job.jobs[0] ? asId(job.jobs[0].id) : "");
const queued = job.total_products ?? ids.length;
return {
ok: true,
message: jobId
? `Processing started for ${queued} product(s). Job id: ${jobId}.`
: `Processing started for ${queued} product(s).`,
jobId: jobId || undefined,
href: "/products?type=raw&status=unprocessed"
};
} catch (err) {
return issueFromUnknown(err, "Could not start processing");
}
}
case "create_support_ticket": {
const subject = redactSecrets((slots.subject ?? "").trim()).slice(0, 200);
const body = redactSecrets((slots.body ?? "").trim()).slice(0, 4000);
if (!subject || !body) {
return { ok: false, issue: { detail: "Subject and body are required." } };
}
try {
const ticket = await createSupportTicket({
subject,
body,
category: (slots.category as "other") || "other",
priority: "normal",
tags: ["system-assistant"]
});
return {
ok: true,
message: `Support ticket created (${ticket.id}). We will reply in Support.`,
href: `/support/${ticket.id}`
};
} catch (err) {
return issueFromUnknown(err, "Could not create support ticket");
}
}
case "suggest_pricing": {
try {
const [productsPayload, plansPayload] = await Promise.all([
api<{ total?: number; products?: unknown[] }>("/api/products?limit=1&offset=0&kind=raw"),
api<{ plans?: PlanRow[] }>("/api/billing/plans")
]);
const productCount =
typeof productsPayload.total === "number"
? productsPayload.total
: Array.isArray(productsPayload.products)
? productsPayload.products.length
: 0;
const plans = (plansPayload.plans ?? [])
.filter((p) => p.name)
.slice()
.sort((a, b) => {
const am = a.max_products == null ? Number.POSITIVE_INFINITY : Number(a.max_products);
const bm = b.max_products == null ? Number.POSITIVE_INFINITY : Number(b.max_products);
return am - bm;
});
if (plans.length === 0) {
return {
ok: true,
message: `You have about ${productCount} product(s). Open Billing or Pricing to compare plans.`,
href: "/billing"
};
}
const fit =
plans.find((p) => p.max_products == null || Number(p.max_products) >= productCount) ??
plans[plans.length - 1];
const cap =
fit.max_products == null ? "unlimited SKUs" : `up to ${fit.max_products} SKUs`;
const credits =
typeof fit.monthly_credits === "number" ? `, ${fit.monthly_credits} AI credits/mo` : "";
return {
ok: true,
message: [
`Based on ~${productCount} product(s) in your catalog, ${fit.name} fits (${cap}${credits}).`,
fit.description ? fit.description : "",
"Open Billing to review usage or upgrade. Prices follow your live plan list — not invented here."
]
.filter(Boolean)
.join("\n"),
href: "/billing"
};
} catch (err) {
return issueFromUnknown(err, "Could not load pricing suggestion");
}
}
case "api_examples": {
return {
ok: true,
message: buildApiExamplesMessage().text,
href: "/docs"
};
}
default:
return {
ok: false,
issue: {
detail: "This task is guide-only. Use Guide me to highlight the controls on the page."
}
};
}
}
function deriveNameFromUrl(url: string): string {
try {
const u = new URL(url);
const leaf = u.pathname.split("/").filter(Boolean).pop() ?? u.hostname;
return leaf.slice(0, 80) || "Feed from URL";
} catch {
return "Feed from URL";
}
}
+47
View File
@@ -0,0 +1,47 @@
export type {
IntentId,
AssistantMode,
AssistantMessage,
AssistantMessageKind,
ConfirmAction,
IntentDefinition,
IntentMatch,
FlowState,
ExecutorResult,
SpotlightTarget
} from "./types.ts";
export { INTENT_REGISTRY, intentById, QUICK_START_REPLIES } from "./intents.ts";
export { matchIntent, matchIdentityQuestion, normalizeUtterance, extractUrl } from "./match.ts";
export {
makeMessage,
idleFlow,
buildConfirmCard,
buildHelpOverview,
buildUnknownReply,
buildGuideMessages,
buildIdentityReply,
buildApiExamplesMessage,
buildFailureSupportOffer,
startCollectFlow,
isHttpUrl,
inferFeedType,
sanitizeAssistantText,
redactSecrets,
normalizeAttributeType,
slugAttributeKey
} from "./engine.ts";
export { executeIntent } from "./executor.ts";
export {
navigateForIntent,
navigateTo,
measureSelector,
waitForSelector,
sameClientRect,
nextTargetRect,
resolveMapFieldsGuide,
routePathname,
MAP_FIELDS_SELECTOR,
ADD_FEED_SELECTOR
} from "./navigator.ts";
export { assistant } from "./state.svelte.ts";
+716
View File
@@ -0,0 +1,716 @@
import type { IntentDefinition, IntentId } from "./types.ts";
import { ADD_FEED_SELECTOR, MAP_FIELDS_SELECTOR } from "./spotlight.ts";
/**
* Phrase → intent registry. Matching is deterministic keyword/phrase scoring.
* Keep phrases lowercase; matcher normalizes input the same way.
*/
export const INTENT_REGISTRY: IntentDefinition[] = [
{
id: "help_overview",
label: "Getting started",
phrases: [
"help",
"what can you do",
"how does this work",
"getting started",
"get started",
"overview",
"show me around",
"i am new",
"i'm new"
],
keywords: ["help", "start", "overview", "guide", "tour"],
description: "Overview of setup: fields, feeds, mapping, stores, processing, API keys.",
requiresConfirm: false,
canExecute: false,
route: "/dashboard",
selector: '[data-assistant-target="dashboard-welcome"],[data-tour="dashboard-welcome"]',
guideSteps: [
{ title: "Enable standard fields", detail: "Catalog → Standard Fields" },
{ title: "Add a feed", detail: "Feeds → Add Feed (URL or CSV upload)" },
{ title: "Map columns", detail: "Open Map on the feed, then Auto-map" },
{ title: "Connect a store (optional)", detail: "Stores → Shopify or WooCommerce" },
{ title: "Process products", detail: "Products or Sync + Process sample on the feed" },
{ title: "API keys (developers)", detail: "Account → Settings → API Keys" }
]
},
{
id: "identity_system",
label: "About this assistant",
phrases: [
"are you a bot",
"are you an ai",
"are you ai",
"are you chatgpt",
"are you an llm",
"are you a llm",
"are you a chatbot",
"are you human",
"is this chatgpt",
"is this an llm",
"what are you",
"who are you"
],
keywords: ["bot", "chatgpt", "llm", "chatbot"],
description: "Explain that this is the System assistant (built-in workflows).",
requiresConfirm: false,
canExecute: false,
route: "/dashboard",
guideSteps: [
{ title: "Navigate the app", detail: "I open the right page and highlight controls" },
{ title: "Run supported actions", detail: "After you confirm, I can call safe APIs" },
{ title: "Guide setup", detail: "Feeds, mapping, stores, attributes, processing, and more" }
]
},
{
id: "add_feed_url",
label: "Add feed from URL",
phrases: [
"add feed url",
"add a feed",
"import feed from url",
"create feed url",
"http feed",
"xml url",
"csv url",
"add product feed"
],
keywords: ["feed", "url", "http", "https", "xml", "csv", "import"],
description: "Create an input feed from an HTTP(S) XML or CSV URL.",
requiresConfirm: true,
canExecute: true,
route: "/feeds",
selector: ADD_FEED_SELECTOR,
guideSteps: [
{ title: "Open Feeds", detail: "Sidebar → Feeds" },
{ title: "Click Add Feed", detail: "Choose URL source" },
{ title: "Paste the feed URL", detail: "Pick XML or CSV type" },
{ title: "Create", detail: "Then map fields before syncing" }
]
},
{
id: "upload_feed",
label: "Upload feed file",
phrases: [
"upload feed",
"upload csv",
"upload xml",
"import csv file",
"add feed file",
"file upload feed"
],
keywords: ["upload", "file", "csv", "xml"],
description: "Create a feed by uploading a CSV (or XML) file.",
requiresConfirm: true,
canExecute: true,
route: "/feeds",
selector: ADD_FEED_SELECTOR,
guideSteps: [
{ title: "Open Feeds", detail: "Sidebar → Feeds" },
{ title: "Click Add Feed", detail: "Choose file upload" },
{ title: "Select your CSV", detail: "Name the feed and create" },
{ title: "Map fields", detail: "Auto-map, review, Save Mappings" }
]
},
{
id: "map_fields",
label: "Map feed fields",
phrases: [
"map fields",
"mapping",
"suggest mappings",
"auto map",
"auto-map",
"match columns",
"map my feed"
],
keywords: ["map", "mapping", "auto-map", "suggest", "columns"],
description: "Open feed mapping and use Auto-map / suggest mappings.",
requiresConfirm: true,
canExecute: false,
route: "/feeds",
// Map only — do not fall back to feeds-add (that spotlights Add Feed as if it were Map).
selector: MAP_FIELDS_SELECTOR,
guideSteps: [
{ title: "Open Feeds", detail: "Find the feed row" },
{ title: "Click Map", detail: "Opens the mapping screen" },
{ title: "Extract schema if needed", detail: "Then click Auto-map" },
{ title: "Review fuzzy matches", detail: "Confirm, then Save Mappings" }
]
},
{
id: "sync_feed",
label: "Sync a feed",
phrases: [
"sync feed",
"sync now",
"run sync",
"import products from feed",
"pull feed"
],
keywords: ["sync", "import", "pull"],
description: "Trigger a feed sync after mappings are saved.",
requiresConfirm: true,
canExecute: true,
route: "/feeds",
selector: '[data-assistant-target="feed-sync-now"],[data-tour="feed-sync-now"]',
guideSteps: [
{ title: "Confirm mappings are saved", detail: "Map → Save Mappings first" },
{ title: "On Feeds, click Sync now", detail: "Or use Sync + Process sample on Map" },
{ title: "Watch job status", detail: "Errors appear on the feed row" }
]
},
{
id: "open_standard_fields",
label: "Standard fields",
phrases: [
"standard fields",
"enable fields",
"product fields",
"enable recommended",
"catalog fields"
],
keywords: ["standard", "fields", "enable", "recommended"],
description: "Open Standard Fields and enable recommended columns.",
requiresConfirm: false,
canExecute: false,
route: "/standard-fields",
selector:
'[data-assistant-target="enable-recommended"],[data-tour="enable-recommended"]',
guideSteps: [
{ title: "Open Standard Fields", detail: "Catalog → Standard Fields" },
{ title: "Click Enable recommended", detail: "Or toggle individual fields" },
{ title: "Save if prompted", detail: "Mappings use enabled fields only" }
]
},
{
id: "connect_shopify",
label: "Connect Shopify",
phrases: [
"connect shopify",
"shopify",
"link shopify",
"shopify store",
"setup shopify"
],
keywords: ["shopify"],
description: "Open Shopify connector and enter shop domain + Admin API token.",
requiresConfirm: false,
canExecute: false,
route: "/stores/shopify",
selector:
'[data-assistant-target="store-connect-shopify"],[data-tour="store-connect-shopify"],[data-tour="store-card-shopify"]',
guideSteps: [
{ title: "Open Stores", detail: "Or go straight to Shopify" },
{ title: "Enter *.myshopify.com domain", detail: "Custom domains are not used for Admin API" },
{
title: "Paste Dev Dashboard Client ID + secret",
detail: "Or a legacy shpat_ token; then Save and Test Connection"
}
]
},
{
id: "connect_woocommerce",
label: "Connect WooCommerce",
phrases: [
"connect woocommerce",
"woocommerce",
"woo commerce",
"connect woo",
"link woo"
],
keywords: ["woocommerce", "woo"],
description: "Open WooCommerce connector entry point.",
requiresConfirm: false,
canExecute: false,
route: "/woocommerce",
selector:
'[data-assistant-target="store-connect-woocommerce"],[data-tour="store-connect-woocommerce"],[data-tour="store-card-woocommerce"]',
guideSteps: [
{ title: "Open Stores or WooCommerce", detail: "Pick WooCommerce card" },
{ title: "Enter store URL + API keys", detail: "Consumer key and secret" },
{ title: "Save and Test Connection", detail: "Fix reconnect banners if shown" }
]
},
{
id: "start_processing",
label: "Start processing",
phrases: [
"start processing",
"process products",
"run processing",
"generate descriptions",
"process catalog",
"process all products",
"process category"
],
keywords: ["process", "processing", "generate"],
description:
"Start a processing job for unprocessed products (all or a category), or open Products to select items.",
requiresConfirm: true,
canExecute: true,
route: "/products?type=raw&status=unprocessed",
selector:
'[data-assistant-target="start-processing"],[data-tour="start-processing"]',
guideSteps: [
{ title: "Ensure a feed is mapped and synced", detail: "Products need source data" },
{ title: "Open Products (unprocessed)", detail: "Select items on the page" },
{ title: "Choose processing types", detail: "Category, attributes, title, description" },
{ title: "Confirm and start", detail: "Watch credits and job status" }
]
},
{
id: "open_feeds",
label: "Open Feeds",
phrases: ["open feeds", "go to feeds", "show feeds", "feeds page"],
keywords: ["feeds"],
description: "Navigate to the Feeds list.",
requiresConfirm: false,
canExecute: false,
route: "/feeds",
selector: '[data-assistant-target="nav-feeds"],[data-tour="nav-feeds"],[data-tour="feeds-add"]',
guideSteps: [{ title: "You're on Feeds", detail: "Add a feed or open Map on an existing one" }]
},
{
id: "open_dashboard",
label: "Open Dashboard",
phrases: ["open dashboard", "go to dashboard", "show dashboard", "home page", "dashboard"],
keywords: ["dashboard", "home"],
description: "Navigate to the Dashboard overview.",
requiresConfirm: false,
canExecute: false,
route: "/dashboard",
selector: '[data-assistant-target="nav-dashboard"],[data-tour="nav-dashboard"]',
guideSteps: [{ title: "You're on Dashboard", detail: "Overview of catalog health and quick links" }]
},
{
id: "open_products",
label: "Open Products",
phrases: ["open products", "go to products", "show products", "products page", "product list"],
keywords: ["products", "catalog"],
description: "Navigate to the Products list.",
requiresConfirm: false,
canExecute: false,
route: "/products?status=completed&type=processed&page=1&sortBy=updatedAt&sortOrder=desc",
selector: '[data-assistant-target="nav-products"],[data-tour="nav-products"]',
guideSteps: [{ title: "You're on Products", detail: "Filter processed vs unprocessed as needed" }]
},
{
id: "open_categories",
label: "Open Categories",
phrases: ["open categories", "go to categories", "show categories", "categories page"],
keywords: ["categories"],
description: "Navigate to Categories.",
requiresConfirm: false,
canExecute: false,
route: "/categories",
selector: '[data-assistant-target="nav-categories"],[data-tour="nav-categories"]',
guideSteps: [{ title: "You're on Categories", detail: "Edit formulas and attribute assignments" }]
},
{
id: "open_export_feeds",
label: "Open Export Feeds",
phrases: ["open export feeds", "go to export feeds", "show export feeds", "export feeds"],
keywords: ["export"],
description: "Navigate to Export Feeds.",
requiresConfirm: false,
canExecute: false,
route: "/export-feeds",
selector: '[data-assistant-target="nav-export-feeds"],[data-tour="nav-export-feeds"]',
guideSteps: [{ title: "You're on Export Feeds", detail: "Configure outbound catalog feeds" }]
},
{
id: "open_stores",
label: "Open Stores",
phrases: ["open stores", "go to stores", "store hub", "stores page"],
keywords: ["stores"],
description: "Navigate to the Stores hub.",
requiresConfirm: false,
canExecute: false,
route: "/stores",
selector:
'[data-assistant-target="nav-stores"],[data-tour="nav-stores"],[data-assistant-target="store-hub"],[data-tour="store-hub"]',
guideSteps: [
{ title: "Pick Shopify, WooCommerce, or file upload", detail: "Connect before syncing channel data" }
]
},
{
id: "open_processing",
label: "Open Processing",
phrases: ["open processing", "go to processing", "show processing", "background tasks", "processing page"],
keywords: ["processing", "jobs", "tasks"],
description: "Navigate to Processing / job monitor.",
requiresConfirm: false,
canExecute: false,
route: "/processing",
selector: '[data-assistant-target="nav-processing"],[data-tour="nav-processing"]',
guideSteps: [{ title: "You're on Processing", detail: "Watch job status and retries" }]
},
{
id: "open_campaigns",
label: "Open Campaigns",
phrases: ["open campaigns", "go to campaigns", "show campaigns", "campaigns page"],
keywords: ["campaigns"],
description: "Navigate to Campaigns.",
requiresConfirm: false,
canExecute: false,
route: "/campaigns",
selector: '[data-assistant-target="nav-campaigns"],[data-tour="nav-campaigns"]',
guideSteps: [{ title: "You're on Campaigns", detail: "Create and manage marketing campaigns" }]
},
{
id: "open_content_calendar",
label: "Open Content calendar",
phrases: [
"open content calendar",
"go to content calendar",
"show content calendar",
"content calendar",
"marketing calendar"
],
keywords: ["calendar", "content"],
description: "Navigate to the Content calendar.",
requiresConfirm: false,
canExecute: false,
route: "/marketing/calendar",
selector: '[data-assistant-target="nav-content-calendar"],[data-tour="nav-content-calendar"]',
guideSteps: [{ title: "You're on Content calendar", detail: "Plan marketing content" }]
},
{
id: "open_seo",
label: "Open SEO",
phrases: ["open seo", "go to seo", "show seo", "seo page"],
keywords: ["seo"],
description: "Navigate to SEO tools.",
requiresConfirm: false,
canExecute: false,
route: "/seo",
selector: '[data-assistant-target="nav-seo"],[data-tour="nav-seo"]',
guideSteps: [{ title: "You're on SEO", detail: "Review SEO settings and suggestions" }]
},
{
id: "open_brand",
label: "Open Brand",
phrases: ["open brand", "go to brand", "show brand", "brand kit", "brand page"],
keywords: ["brand"],
description: "Navigate to Brand kit.",
requiresConfirm: false,
canExecute: false,
route: "/brand",
selector: '[data-assistant-target="nav-brand"],[data-tour="nav-brand"]',
guideSteps: [{ title: "You're on Brand", detail: "Logo, voice, and guidelines" }]
},
{
id: "open_reviews",
label: "Open Reviews",
phrases: ["open reviews", "go to reviews", "show reviews", "product reviews"],
keywords: ["reviews"],
description: "Navigate to Reviews (WooCommerce tab).",
requiresConfirm: false,
canExecute: false,
route: "/woocommerce?tab=reviews",
selector: '[data-assistant-target="nav-reviews"],[data-tour="nav-reviews"]',
guideSteps: [{ title: "You're on Reviews", detail: "Manage product reviews" }]
},
{
id: "open_ai_integrations",
label: "Open AI integrations",
phrases: [
"open ai integrations",
"go to ai integrations",
"ai integrations",
"ai settings",
"ai providers"
],
keywords: ["integrations", "byok", "providers"],
description: "Navigate to AI integrations.",
requiresConfirm: false,
canExecute: false,
route: "/integrations/ai",
selector: '[data-assistant-target="nav-ai"],[data-tour="nav-ai"]',
guideSteps: [{ title: "You're on AI integrations", detail: "Configure providers and keys" }]
},
{
id: "open_email_integrations",
label: "Open Email sending",
phrases: ["open email sending", "go to email", "email integrations", "email sending"],
keywords: ["email", "smtp"],
description: "Navigate to Email sending integrations.",
requiresConfirm: false,
canExecute: false,
route: "/integrations/email",
selector: '[data-assistant-target="nav-email"],[data-tour="nav-email"]',
guideSteps: [{ title: "You're on Email sending", detail: "Configure outbound email" }]
},
{
id: "open_billing",
label: "Open Billing",
phrases: ["open billing", "go to billing", "usage and billing", "show billing", "credits"],
keywords: ["billing", "usage", "credits"],
description: "Navigate to Usage & Billing.",
requiresConfirm: false,
canExecute: false,
route: "/billing",
selector: '[data-assistant-target="nav-billing"],[data-tour="nav-billing"]',
guideSteps: [{ title: "You're on Usage & Billing", detail: "Credits, plan, and invoices" }]
},
{
id: "open_settings",
label: "Open Settings",
phrases: ["open settings", "go to settings", "show settings", "company settings"],
keywords: ["settings"],
description: "Navigate to Settings.",
requiresConfirm: false,
canExecute: false,
route: "/settings",
selector: '[data-assistant-target="nav-settings"],[data-tour="nav-settings"]',
guideSteps: [{ title: "You're on Settings", detail: "Profile, company, team, and API keys" }]
},
{
id: "open_admin",
label: "Open Platform admin",
phrases: [
"open platform admin",
"go to admin",
"show admin",
"platform admin",
"admin panel"
],
keywords: ["admin", "platform"],
description: "Navigate to Platform admin (staff only).",
requiresConfirm: false,
canExecute: false,
route: "/admin",
selector: '[data-assistant-target="nav-admin"],[data-tour="nav-admin"]',
guideSteps: [{ title: "You're on Platform admin", detail: "Users, billing, support, and gates" }]
},
{
id: "open_api_keys",
label: "API keys",
phrases: [
"api keys",
"api key",
"open api keys",
"show api keys",
"developer keys",
"settings api keys"
],
keywords: ["api", "keys", "developer"],
description: "Open Settings → API Keys and highlight create controls.",
requiresConfirm: false,
canExecute: false,
route: "/settings?tab=api-keys",
selector:
'[data-assistant-target="api-keys-create"],[data-tour="api-keys-create"]',
guideSteps: [
{ title: "Open Settings → API Keys", detail: "Company admin required to create keys" },
{ title: "Click Create API Key", detail: "Name the key, then create" },
{ title: "Copy the secret once", detail: "It is shown only at creation — store it safely" }
]
},
{
id: "create_api_key",
label: "Create API key",
phrases: [
"create api key",
"generate api key",
"new api key",
"make an api key",
"create a key"
],
keywords: ["create", "generate", "api", "key"],
description: "Create an API key (confirm required). Secret is shown once, then use curl examples.",
requiresConfirm: true,
canExecute: true,
route: "/settings?tab=api-keys",
selector:
'[data-assistant-target="api-keys-create"],[data-tour="api-keys-create"]',
guideSteps: [
{ title: "Open Settings → API Keys", detail: "Company admin required" },
{ title: "Create API Key", detail: "Enter a name and create" },
{ title: "Copy the secret now", detail: "It cannot be retrieved later" }
]
},
{
id: "api_examples",
label: "API examples",
phrases: [
"api examples",
"curl examples",
"how to use api key",
"http examples",
"example api request",
"developer help",
"how do i call the api",
"sample curl"
],
keywords: ["curl", "example", "http", "openapi", "developer"],
description: "Show example HTTP/curl calls for attributes and processing (placeholder key).",
requiresConfirm: false,
canExecute: false,
route: "/docs",
selector: '[data-assistant-target="nav-settings"],[data-tour="nav-settings"]',
guideSteps: [
{ title: "Create an API key", detail: "Settings → API Keys" },
{ title: "Send X-API-Key", detail: "Header on /api/v1/* requests" },
{ title: "Open API docs", detail: "Docs page for the full OpenAPI surface" }
]
},
{
id: "open_attributes",
label: "Open Attributes",
phrases: [
"open attributes",
"go to attributes",
"attributes page",
"show attributes",
"attributes"
],
keywords: ["attributes"],
description: "Navigate to the Attributes page.",
requiresConfirm: false,
canExecute: false,
route: "/attributes",
selector:
'[data-assistant-target="attributes-add"],[data-tour="attributes-add"],[data-assistant-target="nav-attributes"],[data-tour="nav-attributes"]',
guideSteps: [
{ title: "Open Attributes", detail: "Sidebar → Attributes" },
{ title: "Add or search", detail: "Create fields and assign to categories" }
]
},
{
id: "list_attributes",
label: "List attributes",
phrases: [
"list attributes",
"show my attributes",
"how many attributes",
"get attributes",
"attribute count"
],
keywords: ["list", "attributes", "count"],
description: "Fetch attributes via API and summarize count + a short sample.",
requiresConfirm: true,
canExecute: true,
route: "/attributes",
selector:
'[data-assistant-target="attributes-add"],[data-tour="attributes-add"]',
guideSteps: [
{ title: "Open Attributes", detail: "Browse or search the table" },
{ title: "Or ask me to list", detail: "I can summarize count and sample names" }
]
},
{
id: "create_attribute",
label: "Create attribute",
phrases: [
"create attribute",
"add attribute",
"new attribute",
"define attribute"
],
keywords: ["create", "add", "attribute"],
description: "Create an attribute (key, name, type) after confirmation.",
requiresConfirm: true,
canExecute: true,
route: "/attributes",
selector:
'[data-assistant-target="attributes-add"],[data-tour="attributes-add"]',
guideSteps: [
{ title: "Open Attributes", detail: "Click Add Attribute" },
{ title: "Enter key, name, type", detail: "Optional unit and example" },
{ title: "Create", detail: "Then assign to categories if needed" }
]
},
{
id: "open_support",
label: "Open Support",
phrases: [
"open support",
"support center",
"help desk",
"go to support",
"support tickets",
"support"
],
keywords: ["support", "ticket", "helpdesk"],
description: "Navigate to the Support center.",
requiresConfirm: false,
canExecute: false,
route: "/support",
selector:
'[data-assistant-target="nav-support"],[data-tour="nav-support"],[data-assistant-target="support-new"],[data-tour="support-new"]',
guideSteps: [
{ title: "Open Support", detail: "Sidebar → Support" },
{ title: "New ticket", detail: "Describe the issue without secrets or passwords" }
]
},
{
id: "create_support_ticket",
label: "Create support ticket",
phrases: [
"create support ticket",
"open a support ticket",
"new support ticket",
"file a ticket",
"contact support",
"submit a ticket"
],
keywords: ["ticket", "support", "contact"],
description: "Create a support ticket (confirm + subject/body). No secrets in the message.",
requiresConfirm: true,
canExecute: true,
route: "/support/new",
selector:
'[data-assistant-target="support-create"],[data-tour="support-create"]',
guideSteps: [
{ title: "Open New ticket", detail: "Support → New ticket" },
{ title: "Subject and details", detail: "Omit passwords, API secrets, and personal data" },
{ title: "Submit", detail: "Track replies in Support" }
]
},
{
id: "suggest_pricing",
label: "Pricing suggestion",
phrases: [
"which plan",
"suggest plan",
"pricing suggestion",
"what plan do i need",
"upgrade plan",
"recommend a plan",
"pricing help",
"too many products"
],
keywords: ["plan", "pricing", "upgrade", "billing"],
description: "Suggest a plan tier from product count using existing public plans data.",
requiresConfirm: true,
canExecute: true,
route: "/billing",
selector:
'[data-assistant-target="nav-billing"],[data-tour="nav-billing"]',
guideSteps: [
{ title: "Check product count", detail: "Usage & Billing or Products" },
{ title: "Compare plans", detail: "Billing → Plans / Pricing" },
{ title: "Upgrade when ready", detail: "Checkout or contact sales for Enterprise" }
]
}
];
export function intentById(id: IntentId): IntentDefinition | undefined {
return INTENT_REGISTRY.find((i) => i.id === id);
}
export const QUICK_START_REPLIES = [
"Getting started",
"Add a feed URL",
"Upload a CSV",
"Map fields",
"Connect Shopify",
"API keys",
"Attributes",
"Start processing",
"Pricing suggestion",
"Support"
] as const;
+101
View File
@@ -0,0 +1,101 @@
import { INTENT_REGISTRY } from "./intents.ts";
import type { IntentMatch } from "./types.ts";
const URL_RE = /https?:\/\/[^\s<>"']+/i;
/** Normalize for phrase/keyword matching. */
export function normalizeUtterance(raw: string): string {
return raw
.toLowerCase()
.replace(/[^\p{L}\p{N}\s./:_-]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
export function extractUrl(raw: string): string | undefined {
const m = raw.match(URL_RE);
if (!m) return undefined;
return m[0].replace(/[),.;]+$/, "");
}
/**
* Detect bot / AI / LLM / ChatGPT identity questions.
* Handled before normal intent scoring so phrasing stays flexible.
*/
export function matchIdentityQuestion(raw: string): boolean {
const text = normalizeUtterance(raw);
if (!text) return false;
if (
/\b(are you|r you|is this|am i talking to)\b/.test(text) &&
/\b(bot|ai|a\.i|chatgpt|gpt|llm|language model|chatbot|artificial|human)\b/.test(text)
) {
return true;
}
if (/\b(what are you|who are you|are you real)\b/.test(text)) return true;
if (text === "chatgpt" || text === "llm" || text === "are you chatgpt") return true;
return false;
}
/**
* Score intents by phrase containment and keyword hits.
* Returns null when nothing clears the confidence floor.
*/
export function matchIntent(raw: string): IntentMatch | null {
const text = normalizeUtterance(raw);
if (!text) return null;
if (matchIdentityQuestion(raw)) {
const identity = INTENT_REGISTRY.find((i) => i.id === "identity_system");
if (identity) return { intent: identity, score: 100 };
}
const capturedUrl = extractUrl(raw);
let best: IntentMatch | null = null;
for (const intent of INTENT_REGISTRY) {
if (intent.id === "identity_system") continue;
let score = 0;
for (const phrase of intent.phrases) {
const p = normalizeUtterance(phrase);
if (!p) continue;
if (text === p) score += 10;
else if (text.includes(p)) score += 6;
else {
const words = p.split(" ").filter((w) => w.length > 2);
if (words.length >= 2 && words.every((w) => text.includes(w))) score += 4;
}
}
for (const kw of intent.keywords ?? []) {
const k = normalizeUtterance(kw);
if (k && text.includes(k)) score += 1.5;
}
// URL strongly suggests add_feed_url when feed-ish words present or alone with create/add.
if (capturedUrl && intent.id === "add_feed_url") {
if (/\b(feed|url|xml|csv|import|add|create)\b/.test(text) || text === normalizeUtterance(capturedUrl)) {
score += 5;
}
}
// Prefer create_api_key over open_api_keys when create/generate present.
if (intent.id === "create_api_key" && /\b(create|generate|new|make)\b/.test(text) && /\b(api|key)\b/.test(text)) {
score += 4;
}
if (intent.id === "create_attribute" && /\b(create|add|new|define)\b/.test(text) && /\battributes?\b/.test(text)) {
score += 3;
}
if (intent.id === "create_support_ticket" && /\b(create|open|new|file|submit|contact)\b/.test(text) && /\b(ticket|support)\b/.test(text)) {
score += 3;
}
if (score <= 0) continue;
if (!best || score > best.score) {
best = { intent, score, capturedUrl };
}
}
if (!best || best.score < 3) return null;
return best;
}
+104
View File
@@ -0,0 +1,104 @@
import { goto } from "$app/navigation";
import { querySelectorPrefer } from "$lib/tutorial/dom";
import { intentById } from "./intents.ts";
import { MAP_FIELDS_SELECTOR } from "./spotlight.ts";
import type { IntentId, SpotlightTarget } from "./types.ts";
export type NavigateResult = {
route: string;
spotlight: SpotlightTarget | null;
};
export {
ADD_FEED_SELECTOR,
MAP_FIELDS_SELECTOR,
nextTargetRect,
resolveMapFieldsGuide,
sameClientRect,
type MapFieldsGuideOutcome,
type RectLike
} from "./spotlight.ts";
/** Pathname without query/hash for route matching. */
export function routePathname(route: string): string {
const bare = route.split("#")[0] ?? route;
const path = bare.split("?")[0] ?? bare;
return path.startsWith("/") ? path : `/${path}`;
}
function pathMatchesRoute(currentPath: string, route: string): boolean {
const base = routePathname(route);
return (
currentPath === base ||
currentPath.startsWith(base + "/") ||
(base !== "/" && currentPath.startsWith(base))
);
}
/**
* Navigate to the intent route and resolve a spotlight selector.
* Reuses tutorial DOM helpers; selectors prefer data-assistant-target then data-tour.
*/
export async function navigateForIntent(intentId: IntentId): Promise<NavigateResult> {
const intent = intentById(intentId);
if (!intent) {
return { route: "/dashboard", spotlight: null };
}
const route = intent.route;
if (typeof window !== "undefined") {
const path = window.location.pathname;
const search = window.location.search || "";
const targetSearch = route.includes("?") ? `?${route.split("?")[1] ?? ""}` : "";
const samePath = pathMatchesRoute(path, route);
const sameQuery = !targetSearch || search === targetSearch || search.startsWith(targetSearch + "&");
if (!samePath || !sameQuery) {
await goto(route);
}
}
const selector = intentId === "map_fields" ? MAP_FIELDS_SELECTOR : intent.selector;
return {
route,
spotlight: selector ? { selector, label: intent.label } : null
};
}
export async function navigateTo(path: string, selector?: string): Promise<NavigateResult> {
if (typeof window !== "undefined") {
const targetPath = routePathname(path);
const targetSearch = path.includes("?") ? `?${path.split("?")[1] ?? ""}` : "";
const samePath = window.location.pathname === targetPath;
const sameQuery =
!targetSearch ||
window.location.search === targetSearch ||
window.location.search.startsWith(targetSearch + "&");
if (!samePath || !sameQuery) {
await goto(path);
}
}
return {
route: path,
spotlight: selector ? { selector } : null
};
}
export function measureSelector(
selector: string | undefined | null,
opts?: { scroll?: boolean }
): DOMRect | null {
const el = querySelectorPrefer(selector);
if (!el) return null;
if (opts?.scroll) {
el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
}
return el.getBoundingClientRect();
}
export async function waitForSelector(selector: string, maxMs = 2500): Promise<HTMLElement | null> {
const deadline = Date.now() + maxMs;
while (Date.now() < deadline) {
const el = querySelectorPrefer(selector);
if (el) return el;
await new Promise((r) => setTimeout(r, 50));
}
return querySelectorPrefer(selector);
}
+51
View File
@@ -0,0 +1,51 @@
import type { SpotlightTarget } from "./types.ts";
export type RectLike = { top: number; left: number; width: number; height: number };
/** Map control only — never fall back to Add Feed (that misguides map_fields). */
export const MAP_FIELDS_SELECTOR =
'[data-assistant-target="feed-open-mapping"],[data-tour="feed-open-mapping"]';
export const ADD_FEED_SELECTOR =
'[data-assistant-target="feeds-add"],[data-tour="feeds-add"],[data-tour="feeds-empty-add"]';
export function sameClientRect(a: RectLike | null | undefined, b: RectLike | null | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return (
Math.abs(a.top - b.top) < 0.5 &&
Math.abs(a.left - b.left) < 0.5 &&
Math.abs(a.width - b.width) < 0.5 &&
Math.abs(a.height - b.height) < 0.5
);
}
/**
* Return next rect only when it changed — used by remeasure to avoid reactive thrash.
* `undefined` means "keep current" (no write).
*/
export function nextTargetRect(
current: RectLike | null,
measured: RectLike | null
): RectLike | null | undefined {
if (sameClientRect(current, measured)) return undefined;
return measured;
}
/** Outcome for map_fields guide after waiting for feeds to render. */
export type MapFieldsGuideOutcome =
| { kind: "map"; spotlight: SpotlightTarget }
| { kind: "need_feed"; spotlight: SpotlightTarget };
export function resolveMapFieldsGuide(foundMap: boolean): MapFieldsGuideOutcome {
if (foundMap) {
return {
kind: "map",
spotlight: { selector: MAP_FIELDS_SELECTOR, label: "Map feed fields" }
};
}
return {
kind: "need_feed",
spotlight: { selector: ADD_FEED_SELECTOR, label: "Add Feed" }
};
}
+679
View File
@@ -0,0 +1,679 @@
import { trackEvent } from "$lib/analytics";
import { intentById, QUICK_START_REPLIES } from "./intents.ts";
import {
buildApiExamplesMessage,
buildConfirmCard,
buildFailureSupportOffer,
buildGuideMessages,
buildHelpOverview,
buildIdentityReply,
buildUnknownReply,
idleFlow,
isHttpUrl,
makeMessage,
normalizeAttributeType,
redactSecrets,
slugAttributeKey,
startCollectFlow
} from "./engine.ts";
import { executeIntent } from "./executor.ts";
import { matchIdentityQuestion, matchIntent } from "./match.ts";
import {
MAP_FIELDS_SELECTOR,
measureSelector,
navigateForIntent,
navigateTo,
nextTargetRect,
resolveMapFieldsGuide,
waitForSelector
} from "./navigator.ts";
import type {
AssistantMessage,
ConfirmAction,
FlowState,
IntentId,
SpotlightTarget
} from "./types.ts";
const ACTION_COOLDOWN_MS = 700;
const MAP_FIELDS_WAIT_MS = 8000;
function createAssistantController() {
let open = $state(false);
let messages = $state<AssistantMessage[]>([]);
let busy = $state(false);
let flow = $state<FlowState>(idleFlow());
let spotlight = $state<SpotlightTarget | null>(null);
let targetRect = $state<DOMRect | null>(null);
let pendingFile = $state<File | null>(null);
let draft = $state("");
let lastActionAt = 0;
let spotlightEpoch = 0;
let pendingTicketContext = $state<string | null>(null);
function resetSession() {
messages = [
makeMessage({
role: "assistant",
kind: "quick_replies",
text: "Hi — I am the System assistant. I help you navigate Descrybe and run supported setup actions. What do you want to do?",
quickReplies: [...QUICK_START_REPLIES]
})
];
flow = idleFlow();
spotlight = null;
targetRect = null;
pendingFile = null;
pendingTicketContext = null;
draft = "";
spotlightEpoch += 1;
}
function ensureWelcome() {
if (messages.length === 0) resetSession();
}
function push(...msgs: AssistantMessage[]) {
messages = [...messages, ...msgs];
}
function setSpotlight(target: SpotlightTarget | null) {
const epoch = ++spotlightEpoch;
spotlight = target;
if (!target?.selector) {
if (targetRect !== null) targetRect = null;
return;
}
void (async () => {
await waitForSelector(target.selector, target.selector.includes("feed-open-mapping") ? MAP_FIELDS_WAIT_MS : 2500);
if (epoch !== spotlightEpoch) return;
const measured = measureSelector(target.selector, { scroll: true });
const next = nextTargetRect(targetRect, measured);
if (next !== undefined) targetRect = next as DOMRect | null;
})();
}
function clearSpotlight() {
spotlightEpoch += 1;
spotlight = null;
if (targetRect !== null) targetRect = null;
}
function remeasure() {
if (!spotlight?.selector) {
if (targetRect !== null) targetRect = null;
return;
}
const measured = measureSelector(spotlight.selector, { scroll: false });
const next = nextTargetRect(targetRect, measured);
if (next !== undefined) targetRect = next as DOMRect | null;
}
function toggle() {
open = !open;
if (open) {
trackEvent("assistant_opened");
ensureWelcome();
}
if (!open) clearSpotlight();
}
function openPanel() {
open = true;
trackEvent("assistant_opened");
ensureWelcome();
}
function closePanel() {
open = false;
clearSpotlight();
}
function actionAllowed(action: ConfirmAction): boolean {
if (action === "cancel" || action === "guide") return !busy;
const now = Date.now();
if (busy) return false;
if (now - lastActionAt < ACTION_COOLDOWN_MS) return false;
lastActionAt = now;
return true;
}
function rememberFailure(intentId: IntentId, detail: string) {
const route =
typeof window !== "undefined" ? `${window.location.pathname}${window.location.search}` : "";
pendingTicketContext = redactSecrets(
[`Intent: ${intentId}`, route ? `Route: ${route}` : "", `Error: ${detail}`]
.filter(Boolean)
.join("\n")
).slice(0, 1500);
}
async function runMapFieldsGuide() {
const intent = intentById("map_fields");
if (!intent) return;
await navigateForIntent("map_fields");
const el = await waitForSelector(MAP_FIELDS_SELECTOR, MAP_FIELDS_WAIT_MS);
const outcome = resolveMapFieldsGuide(Boolean(el));
if (outcome.kind === "map") {
setSpotlight(outcome.spotlight);
push(...buildGuideMessages(intent));
push(
makeMessage({
role: "assistant",
kind: "text",
text: "Tip: on the Map screen, use Auto-map (suggest mappings), review fuzzy matches, then Save Mappings."
})
);
return;
}
push(
makeMessage({
role: "assistant",
kind: "quick_replies",
text: "No feed rows to map yet. Add a feed first, then open Map on that row.",
quickReplies: ["Add a feed URL", "Upload a CSV", "Open Feeds"]
})
);
// Honest empty-state: spotlight Add Feed only after explaining — not as if it were Map.
setSpotlight(outcome.spotlight);
}
async function runGuide(intentId: IntentId) {
const intent = intentById(intentId);
if (!intent) return;
busy = true;
try {
if (intentId === "map_fields") {
await runMapFieldsGuide();
return;
}
if (intentId === "identity_system") {
push(buildIdentityReply());
return;
}
if (intentId === "api_examples") {
push(buildApiExamplesMessage());
const nav = await navigateForIntent("open_api_keys");
setSpotlight(nav.spotlight);
return;
}
const nav = await navigateForIntent(intentId);
setSpotlight(nav.spotlight);
push(...buildGuideMessages(intent));
if (intentId === "connect_shopify" || intentId === "connect_woocommerce") {
if (intentId === "connect_shopify") {
await navigateTo("/stores/shopify");
} else {
await navigateTo("/woocommerce");
}
setSpotlight(nav.spotlight);
}
} finally {
busy = false;
flow = idleFlow();
}
}
async function runExecute(intentId: IntentId, slots: Record<string, string>) {
busy = true;
push(
makeMessage({
role: "assistant",
kind: "progress",
text: "Working…",
progress: { label: "Calling API" }
})
);
try {
const result = await executeIntent(intentId, slots, pendingFile);
pendingFile = null;
if (!result.ok) {
rememberFailure(intentId, result.issue.detail);
push(
makeMessage({
role: "assistant",
kind: "error",
text: "That action failed.",
error: result.issue
})
);
push(buildFailureSupportOffer(result.issue.detail));
return;
}
pendingTicketContext = null;
push(
makeMessage({
role: "assistant",
kind: "success",
text: result.message
})
);
if (result.href) {
const mapSel =
'[data-assistant-target="feed-automap"],[data-tour="feed-automap"],[data-tour="feed-save-mappings"]';
const nav = await navigateTo(
result.href,
result.href.includes("/mapping") ? mapSel : undefined
);
setSpotlight(nav.spotlight);
if (result.href.includes("/mapping")) {
push(
makeMessage({
role: "assistant",
kind: "steps",
text: "Next — mapping:",
steps: intentById("map_fields")?.guideSteps
})
);
}
}
if (intentId === "create_api_key") {
push(buildApiExamplesMessage());
}
} finally {
busy = false;
flow = idleFlow();
}
}
async function handleConfirm(action: ConfirmAction, intentId: IntentId, payload?: Record<string, string>) {
if (!actionAllowed(action)) return;
push(
makeMessage({
role: "user",
kind: "text",
text:
action === "guide"
? "Guide me"
: action === "execute"
? "Do it for me"
: "Cancel"
})
);
if (action === "cancel") {
push(makeMessage({ role: "assistant", kind: "text", text: "Cancelled. Ask another question anytime." }));
flow = idleFlow();
clearSpotlight();
return;
}
if (action === "guide") {
await runGuide(intentId);
return;
}
// execute
const intent = intentById(intentId);
if (!intent?.canExecute) {
push(
makeMessage({
role: "assistant",
kind: "text",
text: "This task is guide-only. Switching to Guide me."
})
);
await runGuide(intentId);
return;
}
const slots = { ...(payload ?? {}) };
if (intentId === "add_feed_url" && !slots.url) {
const started = startCollectFlow("add_feed_url");
flow = started.flow;
push(...started.messages);
return;
}
if (intentId === "upload_feed" && !pendingFile) {
const started = startCollectFlow("upload_feed");
flow = started.flow;
push(...started.messages);
return;
}
if (intentId === "sync_feed" && !slots.feed_id) {
const started = startCollectFlow("sync_feed");
flow = started.flow;
push(...started.messages);
return;
}
if (intentId === "create_api_key" && !slots.name) {
const started = startCollectFlow("create_api_key");
flow = started.flow;
push(...started.messages);
return;
}
if (intentId === "create_attribute" && (!slots.attribute_key || !slots.name)) {
const started = startCollectFlow("create_attribute");
flow = { ...started.flow, slots: { ...started.flow.slots, ...slots } };
push(...started.messages);
return;
}
if (intentId === "start_processing" && !slots.scope) {
const started = startCollectFlow("start_processing");
flow = started.flow;
push(...started.messages);
return;
}
if (intentId === "create_support_ticket" && (!slots.subject || !slots.body)) {
const started = startCollectFlow("create_support_ticket");
const withContext = pendingTicketContext
? { ...started.flow, slots: { ...started.flow.slots, body_prefill: pendingTicketContext } }
: started.flow;
flow = withContext;
push(...started.messages);
return;
}
if (intentId === "list_attributes" || intentId === "suggest_pricing") {
await runExecute(intentId, slots);
return;
}
await runExecute(intentId, slots);
}
async function handleCollectStep(text: string): Promise<boolean> {
if (flow.flowId === "add_feed_url" && flow.stepId === "ask_url") {
if (!isHttpUrl(text)) {
push(
makeMessage({
role: "assistant",
kind: "error",
text: "That does not look like an http(s) URL.",
error: { detail: "Example: https://example.com/products.xml" },
inputKind: "url"
})
);
return true;
}
flow = {
...flow,
stepId: "confirm_create",
slots: { ...flow.slots, url: text.trim() }
};
const intent = intentById("add_feed_url");
if (intent) push(buildConfirmCard(intent, { url: text.trim() }));
return true;
}
if (flow.flowId === "sync_feed" && flow.stepId === "ask_feed_id") {
flow = {
...flow,
stepId: "confirm_sync",
slots: { ...flow.slots, feed_id: text.trim() }
};
const intent = intentById("sync_feed");
if (intent) push(buildConfirmCard(intent, { feed_id: text.trim() }));
return true;
}
if (flow.flowId === "create_api_key" && flow.stepId === "ask_name") {
const name = text.trim().slice(0, 80) || "System assistant key";
flow = {
...flow,
stepId: "confirm_create",
slots: { ...flow.slots, name }
};
const intent = intentById("create_api_key");
if (intent) push(buildConfirmCard(intent, { name }));
return true;
}
if (flow.flowId === "create_attribute") {
if (flow.stepId === "ask_key") {
const key = slugAttributeKey(text);
if (key.length < 2) {
push(
makeMessage({
role: "assistant",
kind: "error",
text: "Use a short snake_case key (letters, numbers, underscores).",
error: { detail: "Example: color" },
inputKind: "text"
})
);
return true;
}
flow = { ...flow, stepId: "ask_name", slots: { ...flow.slots, attribute_key: key } };
push(
makeMessage({
role: "assistant",
kind: "input_prompt",
text: `Display name for “${key}” (e.g. Color).`,
inputKind: "text"
})
);
return true;
}
if (flow.stepId === "ask_name") {
const name = text.trim().slice(0, 120) || flow.slots.attribute_key;
flow = { ...flow, stepId: "ask_type", slots: { ...flow.slots, name } };
push(
makeMessage({
role: "assistant",
kind: "input_prompt",
text: "Value type: string, number, boolean, date, list, or multiselect (default string).",
inputKind: "text"
})
);
return true;
}
if (flow.stepId === "ask_type") {
const valueType = normalizeAttributeType(text);
const slots = { ...flow.slots, value_type: valueType };
flow = { ...flow, stepId: "confirm_create", slots };
const intent = intentById("create_attribute");
if (intent) push(buildConfirmCard(intent, slots));
return true;
}
}
if (flow.flowId === "start_processing" && flow.stepId === "ask_scope") {
const scope = text.trim() || "all";
flow = {
...flow,
stepId: "confirm_process",
slots: { ...flow.slots, scope }
};
const intent = intentById("start_processing");
if (intent) push(buildConfirmCard(intent, { scope }));
return true;
}
if (flow.flowId === "create_support_ticket") {
if (flow.stepId === "ask_subject") {
const subject = redactSecrets(text.trim()).slice(0, 200);
if (!subject) {
push(
makeMessage({
role: "assistant",
kind: "error",
text: "Subject cannot be empty.",
error: { detail: "One short line is enough." },
inputKind: "text"
})
);
return true;
}
flow = { ...flow, stepId: "ask_body", slots: { ...flow.slots, subject } };
const hint = flow.slots.body_prefill
? "Describe the issue (a failure summary is already prepared — you can edit or replace it). No secrets."
: "Describe the issue. No passwords, API keys, or personal data.";
push(
makeMessage({
role: "assistant",
kind: "input_prompt",
text: hint,
inputKind: "text"
})
);
if (flow.slots.body_prefill) {
push(
makeMessage({
role: "assistant",
kind: "text",
text: `Prepared context:\n${flow.slots.body_prefill}`
})
);
}
return true;
}
if (flow.stepId === "ask_body") {
let body = redactSecrets(text.trim());
if (!body && flow.slots.body_prefill) body = flow.slots.body_prefill;
if (!body) {
push(
makeMessage({
role: "assistant",
kind: "error",
text: "Body cannot be empty.",
error: { detail: "Add a short description of what failed." },
inputKind: "text"
})
);
return true;
}
const subject = flow.slots.subject ?? "";
const slots: Record<string, string> = {
...flow.slots,
subject,
body: body.slice(0, 4000)
};
flow = { ...flow, stepId: "confirm_create", slots };
const intent = intentById("create_support_ticket");
if (intent) push(buildConfirmCard(intent, { subject: slots.subject, body: slots.body }));
return true;
}
}
return false;
}
async function handleUserText(raw: string) {
const text = raw.trim();
if (!text || busy) return;
draft = "";
push(makeMessage({ role: "user", kind: "text", text }));
if (await handleCollectStep(text)) return;
if (matchIdentityQuestion(text)) {
trackEvent("assistant_intent", { intent_id: "identity_system" });
push(buildIdentityReply());
flow = idleFlow();
return;
}
const matched = matchIntent(text);
if (!matched) {
push(buildUnknownReply());
return;
}
const { intent, capturedUrl } = matched;
trackEvent("assistant_intent", { intent_id: intent.id });
if (intent.id === "help_overview") {
const help = intentById("help_overview");
if (help) {
push(...buildHelpOverview(help));
const nav = await navigateForIntent("help_overview");
setSpotlight(nav.spotlight);
}
return;
}
if (intent.id === "identity_system") {
push(buildIdentityReply());
return;
}
if (intent.id === "api_examples") {
push(buildApiExamplesMessage());
return;
}
if (!intent.requiresConfirm && !intent.canExecute) {
await runGuide(intent.id);
return;
}
const payload: Record<string, string> = {};
if (capturedUrl && intent.id === "add_feed_url") {
payload.url = capturedUrl;
}
if (intent.id === "create_support_ticket" && pendingTicketContext) {
payload.body_prefill = pendingTicketContext;
}
push(buildConfirmCard(intent, Object.keys(payload).length ? payload : undefined));
}
function setPendingFile(file: File | null) {
pendingFile = file;
if (!file) return;
push(
makeMessage({
role: "user",
kind: "text",
text: `Selected file: ${file.name}`
})
);
const intent = intentById("upload_feed");
if (intent) {
trackEvent("assistant_intent", { intent_id: "upload_feed" });
flow = {
flowId: "upload_feed",
intentId: "upload_feed",
stepId: "confirm_upload",
slots: { name: file.name.replace(/\.[^.]+$/, "") }
};
push(buildConfirmCard(intent, { name: flow.slots.name }));
}
}
async function handleQuickReply(label: string) {
if (label === "Cancel") {
push(makeMessage({ role: "user", kind: "text", text: "Cancel" }));
push(makeMessage({ role: "assistant", kind: "text", text: "Cancelled. Ask another question anytime." }));
flow = idleFlow();
clearSpotlight();
return;
}
await handleUserText(label);
}
return {
get open() {
return open;
},
get messages() {
return messages;
},
get busy() {
return busy;
},
get flow() {
return flow;
},
get spotlight() {
return spotlight;
},
get targetRect() {
return targetRect;
},
get draft() {
return draft;
},
set draft(v: string) {
draft = v;
},
get pendingFile() {
return pendingFile;
},
toggle,
openPanel,
closePanel,
resetSession,
handleUserText,
handleConfirm,
handleQuickReply,
setPendingFile,
clearSpotlight,
remeasure
};
}
export const assistant = createAssistantController();
+138
View File
@@ -0,0 +1,138 @@
/** Deterministic System assistant — shared contracts (built-in workflows only). */
export type IntentId =
| "help_overview"
| "identity_system"
| "add_feed_url"
| "upload_feed"
| "map_fields"
| "sync_feed"
| "open_standard_fields"
| "connect_shopify"
| "connect_woocommerce"
| "start_processing"
| "open_dashboard"
| "open_products"
| "open_categories"
| "open_feeds"
| "open_export_feeds"
| "open_stores"
| "open_processing"
| "open_campaigns"
| "open_content_calendar"
| "open_seo"
| "open_brand"
| "open_reviews"
| "open_ai_integrations"
| "open_email_integrations"
| "open_billing"
| "open_settings"
| "open_api_keys"
| "create_api_key"
| "api_examples"
| "open_attributes"
| "list_attributes"
| "create_attribute"
| "open_support"
| "create_support_ticket"
| "open_admin"
| "suggest_pricing";
export type AssistantMode = "guide" | "execute";
export type AssistantRole = "user" | "assistant" | "system";
export type AssistantMessageKind =
| "text"
| "confirm"
| "progress"
| "error"
| "success"
| "quick_replies"
| "steps"
| "input_prompt";
export type ConfirmAction = "guide" | "execute" | "cancel";
export type AssistantStep = {
title: string;
detail?: string;
};
export type AssistantIssue = {
status?: number;
code?: string;
detail: string;
};
export type AssistantMessage = {
id: string;
role: AssistantRole;
kind: AssistantMessageKind;
text: string;
createdAt: string;
quickReplies?: string[];
confirm?: {
intentId: IntentId;
actions: ConfirmAction[];
/** When execute needs a URL collected earlier. */
payload?: Record<string, string>;
};
steps?: AssistantStep[];
error?: AssistantIssue;
progress?: { label: string; percent?: number };
/** Expected free-text / URL / file for the active flow. */
inputKind?: "url" | "text" | "file" | "none";
};
export type IntentDefinition = {
id: IntentId;
label: string;
phrases: string[];
/** Keywords boost score when present (normalized). */
keywords?: string[];
description: string;
/** Destructive or write actions require confirm before execute. */
requiresConfirm: boolean;
/** Whether "Do it for me" can call APIs. */
canExecute: boolean;
route: string;
/** Prefer data-assistant-target; falls back to data-tour. */
selector?: string;
guideSteps: AssistantStep[];
};
export type IntentMatch = {
intent: IntentDefinition;
score: number;
/** Captured URL from the user utterance when present. */
capturedUrl?: string;
};
export type FlowId =
| "idle"
| "add_feed_url"
| "upload_feed"
| "sync_feed"
| "map_fields"
| "create_api_key"
| "create_attribute"
| "start_processing"
| "create_support_ticket"
| "generic_confirm";
export type FlowState = {
flowId: FlowId;
intentId: IntentId | null;
stepId: string;
slots: Record<string, string>;
};
export type SpotlightTarget = {
selector: string;
label?: string;
};
export type ExecutorResult =
| { ok: true; message: string; href?: string; feedId?: string; jobId?: string }
| { ok: false; issue: AssistantIssue };
+34
View File
@@ -0,0 +1,34 @@
import type { MeResponse } from "$lib/types";
import { canManageCompany, isCompanyAdmin as roleIsAdmin } from "$lib/company-admin";
import { isFullPlatformAdmin } from "$lib/staff-access";
/** Shared client auth snapshot from layout `/api/auth/me` (role for admin-only UI). */
let meState = $state<MeResponse | null>(null);
export const authSession = {
get me(): MeResponse | null {
return meState;
},
setMe(next: MeResponse | null) {
meState = next;
},
get isCompanyAdmin(): boolean {
return roleIsAdmin(meState);
},
/** Membership admin, platform admin, or non-prod privileged impersonation. */
get canManageCompany(): boolean {
return canManageCompany(meState);
},
get isPlatformAdmin(): boolean {
return isFullPlatformAdmin(meState);
},
get isSupportDesk(): boolean {
if (meState?.staff_access) {
return Boolean(meState.staff_access.support_desk);
}
return Boolean(meState?.user?.is_platform_admin);
},
get isSupportOnly(): boolean {
return Boolean(meState?.staff_access?.is_support_only);
}
};
+440
View File
@@ -0,0 +1,440 @@
import { i18n } from "$lib/i18n";
import { formatCredits } from "$lib/utils";
/** Matches apps/api/internal/billing.EnterpriseUnlimitedCredits. */
export const ENTERPRISE_UNLIMITED_CREDITS = 1_000_000;
export type PlanLike = {
name?: string | null;
is_custom?: boolean | null;
is_legacy?: boolean | null;
is_trial?: boolean | null;
monthly_credits?: number | null;
max_products?: number | null;
next_billing_date?: string | null;
subscription_status?: string | null;
};
/** Subset of CreditsOverview / auth/me credits — prefer API entitlement fields. */
export type CreditsLike = {
total_credits?: number;
used_credits?: number;
remaining?: number;
remaining_credits?: number;
can_use_ai?: boolean;
can_use_eprel?: boolean;
is_free_plan?: boolean;
is_paid_plan?: boolean;
has_active_plan?: boolean;
low_credits?: boolean;
at_product_limit?: boolean;
plan?: PlanLike | Record<string, unknown> | null;
/** Effective feature map from ResolveFeatures (additive; may be absent pre-cutover). */
features?: Record<string, boolean>;
/** Global section master switches (platform_feature_gates). */
sections?: Record<string, boolean>;
disabled_features?: string[];
feature_etag?: string;
};
export type UpgradeCta = {
primaryHref: string;
primaryLabel: string;
showSales: boolean;
/** Extra copy for members who cannot open Checkout (API requires company admin). */
memberHint: string | null;
};
export type BillingRecoveryKind = "missing_plan" | "past_due";
export type BillingRecovery = {
kind: BillingRecoveryKind;
tone: "warning" | "danger";
title: string;
message: string;
primaryHref: string;
primaryLabel: string;
/** When true, billing page should open Customer Portal instead of navigating. */
openPortal: boolean;
showSales: boolean;
};
type CreditUsageItem = {
burns: boolean;
labelKey: string;
detailKey: string;
};
/** What burns (or does not burn) AI credits — aligned with Free-plan gates + DebitAmount. */
export const CREDIT_USAGE_ITEMS: readonly CreditUsageItem[] = [
{
burns: false,
labelKey: "billing.creditUsage.normalize.label",
detailKey: "billing.creditUsage.normalize.detail"
},
{
burns: true,
labelKey: "billing.creditUsage.ai.label",
detailKey: "billing.creditUsage.ai.detail"
},
{
burns: false,
labelKey: "billing.creditUsage.eprel.label",
detailKey: "billing.creditUsage.eprel.detail"
},
{
burns: true,
labelKey: "billing.creditUsage.campaign.label",
detailKey: "billing.creditUsage.campaign.detail"
}
];
export function planNameOf(plan: PlanLike | null | undefined, fallback?: string): string {
const name = (plan?.name ?? "").trim();
return name || (fallback ?? i18n.t("billing.plan.free"));
}
/** Localize known catalog plan names for display (API still stores English names). */
export function localizePlanName(name: string | null | undefined): string {
const raw = (name ?? "").trim();
if (!raw) return "";
switch (raw.toLowerCase()) {
case "free":
return i18n.t("billing.plan.free");
case "enterprise":
return i18n.t("billing.plan.enterprise");
default:
return raw;
}
}
function payAsYouGoLabel(): string {
return i18n.t("billing.payAsYouGo");
}
function unlimitedLabel(): string {
return i18n.t("billing.unlimited");
}
/** True only when API reports an active company_plans row (or plan payload is present). */
export function hasActivePlan(credits?: CreditsLike | null, plan?: PlanLike | null): boolean {
if (typeof credits?.has_active_plan === "boolean") return credits.has_active_plan;
return Boolean(plan?.name?.trim());
}
/** Display label — never invent Free/Unlimited when the company has no assigned plan. */
export function planDisplayName(
plan: PlanLike | null | undefined,
credits?: CreditsLike | null
): string {
if (!hasActivePlan(credits, plan ?? undefined)) return i18n.t("billing.noPlanAssigned");
const raw = (plan?.name ?? "").trim();
if (!raw) return i18n.t("billing.plan.free");
return localizePlanName(raw);
}
export function isEnterprisePlan(plan: PlanLike | null | undefined): boolean {
if (!plan?.name?.trim() && !plan?.is_custom) return false;
const name = planNameOf(plan, "").toLowerCase();
if (name === "enterprise") return true;
// Custom deals with null SKU cap + large pack read as unlimited in the UI.
if (plan?.is_custom && plan.max_products == null) {
const monthly = plan.monthly_credits ?? 0;
if (monthly >= ENTERPRISE_UNLIMITED_CREDITS) return true;
}
return false;
}
/** Migrated A1 / Legacy limited-nav package (not public ladder; not enable-all custom). */
export function isLegacyPlan(plan: PlanLike | null | undefined): boolean {
if (!plan) return false;
// Prefer explicit API flag (A1 PAYG is seeded is_legacy=false).
if (plan.is_legacy === true) return true;
if (plan.is_legacy === false) return false;
const name = planNameOf(plan, "").toLowerCase();
if (!name) return false;
if (name === "legacy" || name.includes("legacy")) return true;
if (name === "a1" || name.startsWith("a1 ") || name.startsWith("a1-") || name.startsWith("a1_")) {
return true;
}
return name.includes("a1 slovenija");
}
/**
* Pay-as-you-go / custom wallet plans: monthly allotment is 0 (not Free, not Enterprise).
* Credits come from the wallet — never present remaining as a prepaid monthly pack.
*/
export function isPayAsYouGoPlan(
plan: PlanLike | null | undefined,
credits?: CreditsLike | null
): boolean {
if (!hasActivePlan(credits, plan ?? undefined)) return false;
if (isFreePlan(plan, credits)) return false;
if (isEnterprisePlan(plan)) return false;
const monthly = plan?.monthly_credits;
return monthly === 0;
}
export function isFreePlan(
plan: PlanLike | null | undefined,
credits?: CreditsLike | null
): boolean {
if (!hasActivePlan(credits, plan ?? undefined)) return false;
if (credits?.is_free_plan) return true;
return planNameOf(plan, "").toLowerCase() === "free";
}
export function subscriptionStatusOf(
plan?: PlanLike | null,
stripeStatus?: string | null
): string {
const fromPlan = (plan?.subscription_status ?? "").trim().toLowerCase();
if (fromPlan) return fromPlan;
return (stripeStatus ?? "").trim().toLowerCase();
}
export function isPastDueStatus(status: string | null | undefined): boolean {
return (status ?? "").trim().toLowerCase() === "past_due";
}
/** Recovery CTAs for missing/skipped company_plans or Stripe past_due (grace, not hard-lock). */
export function billingRecovery(options: {
credits?: CreditsLike | null;
plan?: PlanLike | null;
subscriptionStatus?: string | null;
canManageBilling: boolean;
}): BillingRecovery | null {
const { credits, plan, subscriptionStatus, canManageBilling } = options;
const status = subscriptionStatusOf(plan, subscriptionStatus);
if (isPastDueStatus(status)) {
if (canManageBilling) {
return {
kind: "past_due",
tone: "warning",
title: i18n.t("billing.recovery.pastDueTitle"),
message: i18n.t("billing.recovery.pastDueAdmin"),
primaryHref: "/billing",
primaryLabel: i18n.t("billing.recovery.openPortal"),
openPortal: true,
showSales: false
};
}
return {
kind: "past_due",
tone: "warning",
title: i18n.t("billing.recovery.pastDueTitle"),
message: i18n.t("billing.recovery.pastDueMember"),
primaryHref: "/settings?tab=team",
primaryLabel: i18n.t("billing.recovery.contactAdmin"),
openPortal: false,
showSales: false
};
}
if (!hasActivePlan(credits, plan ?? undefined)) {
const cta = upgradeCtaForRole(canManageBilling);
return {
kind: "missing_plan",
tone: "warning",
title: i18n.t("billing.recovery.missingPlanTitle"),
message: withUpgradeHint(i18n.t("billing.recovery.missingPlanMessage"), cta),
primaryHref: canManageBilling ? "/plans" : cta.primaryHref,
primaryLabel: canManageBilling
? i18n.t("billing.recovery.choosePlan")
: cta.primaryLabel,
openPortal: false,
showSales: cta.showSales
};
}
return null;
}
/**
* Remaining AI credits from CreditsOverview /auth/me.
* Prefers remaining_credits (API-clamped), then remaining, then total-used clamped at 0.
*/
export function remainingCreditsOf(credits: CreditsLike | null | undefined): number | null {
if (!credits) return null;
if (typeof credits.remaining_credits === "number") {
return Math.max(0, credits.remaining_credits);
}
if (typeof credits.remaining === "number") {
return Math.max(0, credits.remaining);
}
if (typeof credits.total_credits === "number" && typeof credits.used_credits === "number") {
return Math.max(0, credits.total_credits - credits.used_credits);
}
return null;
}
/**
* Matches ComputeEntitlements / CreditsOverview.can_use_ai:
* remaining > 0 OR paid plan (not Free).
*/
export function canUseAIFromCredits(credits: CreditsLike | null | undefined): boolean {
if (!credits) return false;
if (typeof credits.can_use_ai === "boolean") return credits.can_use_ai;
const rem = remainingCreditsOf(credits) ?? 0;
if (credits.is_paid_plan) return true;
if (credits.is_free_plan) return rem > 0;
return rem > 0;
}
/** Company admins may open Checkout / billing portal (POST /api/billing/checkout). */
export function upgradeCtaForRole(canManageBilling: boolean): UpgradeCta {
if (canManageBilling) {
return {
primaryHref: "/plans",
primaryLabel: i18n.t("billing.upgrade"),
showSales: true,
memberHint: null
};
}
return {
primaryHref: "/settings?tab=team",
primaryLabel: i18n.t("billing.askCompanyAdmin"),
showSales: false,
memberHint: i18n.t("billing.memberHint")
};
}
export function withUpgradeHint(message: string, cta: UpgradeCta): string {
if (!cta.memberHint) return message;
return `${message} ${cta.memberHint}`;
}
/**
* AI credit remaining label for cards and summaries.
* Enterprise plans say Unlimited for the plan entitlement; when the wallet still
* exposes a finite remaining balance (below the unlimited sentinel), surface both
* so operators are not confused by "Unlimited" alone.
* PAYG never heroes a wallet number — returns pay-as-you-go (same as status labels).
*/
export function formatCreditsRemaining(
remaining: number | null | undefined,
plan?: PlanLike | null,
credits?: CreditsLike | null
): string {
if (isPayAsYouGoPlan(plan, credits)) return payAsYouGoLabel();
if (!plan?.name?.trim() && !isEnterprisePlan(plan)) {
return formatCredits(remaining);
}
if (isEnterprisePlan(plan)) {
if (
typeof remaining === "number" &&
Number.isFinite(remaining) &&
remaining < ENTERPRISE_UNLIMITED_CREDITS
) {
return i18n.t("billing.unlimitedPlanWallet", {
wallet: formatCredits(remaining)
});
}
return unlimitedLabel();
}
return formatCredits(remaining);
}
/**
* Plan/billing status for dashboard headers and welcome copy.
* PAYG → pay-as-you-go (never "N credits ready" / fake monthly allotment language).
*/
export function formatCreditsStatusLabel(
remaining: number | null | undefined,
plan?: PlanLike | null,
credits?: CreditsLike | null
): string {
if (isPayAsYouGoPlan(plan, credits)) return payAsYouGoLabel();
return formatCreditsRemaining(remaining, plan);
}
/** Header line fragment — appends "credits" only for prepaid monthly wallets. */
export function formatCreditsStatusLine(
remaining: number | null | undefined,
plan?: PlanLike | null,
credits?: CreditsLike | null
): string {
const label = formatCreditsStatusLabel(remaining, plan, credits);
if (isPayAsYouGoPlan(plan, credits)) return label;
if (isEnterprisePlan(plan)) return label;
if (!hasActivePlan(credits, plan ?? undefined)) return label;
return i18n.t("billing.creditsSuffix", { label });
}
export function formatMonthlyCredits(plan: PlanLike | null | undefined): string {
if (!plan?.name?.trim() && !plan?.is_custom) return i18n.t("status.emDash");
if (isEnterprisePlan(plan)) return unlimitedLabel();
const monthly = plan?.monthly_credits;
if (monthly == null) return i18n.t("status.emDash");
if (monthly === 0) {
if (planNameOf(plan, "").toLowerCase() === "free") return i18n.t("billing.zeroPerMonth");
return payAsYouGoLabel();
}
return i18n.t("billing.perMonth", { amount: formatCredits(monthly) });
}
/** SKU cap — null on an assigned plan means unlimited (Enterprise / custom). Missing plan → em dash. */
export function formatSkuCap(
maxProducts: number | null | undefined,
plan?: PlanLike | null
): string {
if (!plan?.name?.trim() && !plan?.is_custom) {
return maxProducts == null
? i18n.t("status.emDash")
: i18n.t("billing.upTo", { count: formatCredits(maxProducts) });
}
if (isEnterprisePlan(plan)) return unlimitedLabel();
if (maxProducts == null) {
return plan ? unlimitedLabel() : i18n.t("status.emDash");
}
return i18n.t("billing.upTo", { count: formatCredits(maxProducts) });
}
export function formatSkuUsage(
productCount: number | null | undefined,
maxProducts: number | null | undefined,
plan?: PlanLike | null
): string {
const used = formatCredits(productCount ?? 0);
const assigned = Boolean(plan?.name?.trim() || plan?.is_custom);
if (isEnterprisePlan(plan) || (assigned && maxProducts == null)) {
return i18n.t("billing.skusUnlimited", { used });
}
if (maxProducts == null) {
return i18n.t("billing.skusOnly", { used });
}
return i18n.t("billing.skusOf", {
used,
max: formatCredits(maxProducts)
});
}
export function planKindLabel(plan: PlanLike | null | undefined): string {
if (!plan?.name) return i18n.t("billing.planKind.none");
if (isEnterprisePlan(plan) || plan.is_custom) return i18n.t("billing.planKind.enterprise");
if (plan.is_trial) return i18n.t("billing.planKind.trial");
return i18n.t("billing.planKind.standard");
}
/** Self-serve Stripe checkout ladder (not Free / Enterprise). */
export const SELF_SERVE_CHECKOUT_PLANS = ["starter", "plus", "growth", "business", "scale"] as const;
/** Self-serve Stripe plans only (not Free / Enterprise). */
export function isSelfServeCheckoutPlan(name: string | null | undefined): boolean {
const key = (name ?? "").trim().toLowerCase();
return (SELF_SERVE_CHECKOUT_PLANS as readonly string[]).includes(key);
}
/** Next paid ladder step for Billing quick-upgrade (Free → Starter … → Scale). */
export function nextSelfServeUpgradePlan(currentPlanName: string | null | undefined): string | null {
const key = (currentPlanName ?? "").trim().toLowerCase();
if (!key || key === "free") return "starter";
const idx = (SELF_SERVE_CHECKOUT_PLANS as readonly string[]).indexOf(key);
if (idx < 0 || idx >= SELF_SERVE_CHECKOUT_PLANS.length - 1) return null;
return SELF_SERVE_CHECKOUT_PLANS[idx + 1] ?? null;
}
/** Title-case checkout plan key for CTA labels (starter → Starter). */
export function planCheckoutDisplayName(planKey: string | null | undefined): string {
const key = (planKey ?? "").trim().toLowerCase();
if (!key) return "";
return key.charAt(0).toUpperCase() + key.slice(1);
}
+187
View File
@@ -0,0 +1,187 @@
import { api, ApiError } from "$lib/api";
import { trackEvent } from "$lib/analytics";
import { unwrapList } from "$lib/list";
import { DEFAULT_SEASON_TEMPLATES } from "./templates";
import type {
Campaign,
CreateCampaignInput,
GenerateCampaignInput,
ScheduleCampaignInput,
SeasonTemplate,
SendTestInput
} from "./types";
export function isCampaignsUnavailable(err: unknown): boolean {
return (
err instanceof ApiError &&
(err.status === 404 || err.status === 501 || err.status === 502 || err.status === 503)
);
}
export function isUpgradeRequired(err: unknown): boolean {
if (!(err instanceof ApiError)) return false;
if (err.status === 402) return true;
if (err.status !== 403) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("upgrade") ||
msg.includes("free") ||
msg.includes("plan") ||
msg.includes("ai") ||
msg.includes("credit")
);
}
function asCampaign(raw: unknown): Campaign | null {
if (!raw || typeof raw !== "object") return null;
const record = raw as Record<string, unknown>;
const nested = record.campaign;
if (nested && typeof nested === "object") return nested as Campaign;
if (typeof record.id === "string" || typeof record.id === "number") {
return { ...(record as Campaign), id: String(record.id) };
}
return null;
}
export async function listCampaigns(opts?: {
signal?: AbortSignal;
}): Promise<{ campaigns: Campaign[]; unavailable: boolean }> {
try {
const payload = await api<Record<string, unknown>>("/api/campaigns?limit=200", {
signal: opts?.signal
});
const list = unwrapList<Campaign>(payload).map((c) => ({
...c,
id: String(c.id)
}));
return { campaigns: list, unavailable: false };
} catch (err) {
if (opts?.signal?.aborted) throw err;
if (isCampaignsUnavailable(err) || isUpgradeRequired(err)) {
return { campaigns: [], unavailable: true };
}
throw err;
}
}
export async function getCampaign(id: string): Promise<Campaign> {
const payload = await api<unknown>(`/api/campaigns/${encodeURIComponent(id)}`);
const campaign = asCampaign(payload);
if (!campaign) throw new Error("Campaign not found");
return { ...campaign, id: String(campaign.id) };
}
export async function createCampaign(input: CreateCampaignInput): Promise<Campaign> {
const payload = await api<unknown>("/api/campaigns", { method: "POST", body: input });
const campaign = asCampaign(payload);
if (!campaign) throw new Error("Invalid create response");
trackEvent("campaign_created");
return { ...campaign, id: String(campaign.id) };
}
export async function updateCampaign(
id: string,
input: Partial<CreateCampaignInput> & { name?: string; status?: string }
): Promise<Campaign> {
const payload = await api<unknown>(`/api/campaigns/${encodeURIComponent(id)}`, {
method: "PATCH",
body: input
});
const campaign = asCampaign(payload);
if (!campaign) throw new Error("Invalid update response");
return { ...campaign, id: String(campaign.id) };
}
export async function deleteCampaign(id: string): Promise<void> {
await api(`/api/campaigns/${encodeURIComponent(id)}`, { method: "DELETE" });
}
export async function listTemplates(): Promise<SeasonTemplate[]> {
try {
const payload = await api<Record<string, unknown>>("/api/campaigns/templates");
const list = unwrapList<SeasonTemplate>(payload);
if (list.length) return list;
const named = payload.templates;
if (Array.isArray(named) && named.length) return named as SeasonTemplate[];
} catch (err) {
if (!isCampaignsUnavailable(err)) throw err;
}
return DEFAULT_SEASON_TEMPLATES;
}
export async function generateCampaign(
id: string,
input: GenerateCampaignInput = { use_ai: true }
): Promise<Campaign> {
const payload = await api<unknown>(`/api/campaigns/${encodeURIComponent(id)}/generate`, {
method: "POST",
body: input
});
const campaign = asCampaign(payload);
if (!campaign) throw new Error("Invalid generate response");
trackEvent("campaign_generated", { use_ai: input.use_ai !== false });
return { ...campaign, id: String(campaign.id) };
}
export async function sendTestCampaign(id: string, input: SendTestInput): Promise<void> {
await api(`/api/campaigns/${encodeURIComponent(id)}/send-test`, {
method: "POST",
body: input
});
}
export async function scheduleCampaign(id: string, input: ScheduleCampaignInput): Promise<Campaign> {
const payload = await api<unknown>(`/api/campaigns/${encodeURIComponent(id)}/schedule`, {
method: "POST",
body: input
});
const campaign = asCampaign(payload);
if (!campaign) throw new Error("Invalid schedule response");
return { ...campaign, id: String(campaign.id) };
}
export function previewSubject(campaign: Campaign): string {
return (
campaign.latest_version?.subject ||
campaign.subject ||
campaign.versions?.[0]?.subject ||
"(No subject yet)"
);
}
export function previewHtml(campaign: Campaign): string {
return (
campaign.latest_version?.html_body ||
campaign.html_body ||
campaign.versions?.[0]?.html_body ||
""
);
}
export function previewPlain(campaign: Campaign): string {
return (
campaign.latest_version?.plain_body ||
campaign.plain_body ||
campaign.versions?.[0]?.plain_body ||
""
);
}
/** Best-effort: orders exist for “purchased” audience option. */
export async function hasOrderAudience(): Promise<boolean> {
const paths = ["/api/woocommerce/orders?limit=1"];
for (const path of paths) {
try {
const payload = await api<Record<string, unknown>>(path);
const list = unwrapList(payload);
const total = typeof payload.total === "number" ? payload.total : null;
if (list.length > 0 || (total !== null && total > 0)) return true;
// Endpoint exists but empty — still allow the option.
return true;
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) throw err;
continue;
}
}
return false;
}
+63
View File
@@ -0,0 +1,63 @@
import type { SeasonTemplate } from "./types";
/** Client-side defaults when GET /api/campaigns/templates is unavailable. */
export const DEFAULT_SEASON_TEMPLATES: SeasonTemplate[] = [
{
key: "black_friday",
name: "Black Friday",
emoji: "🛍️",
description: "Urgent deals, limited-time offers, and product highlights.",
default_subject: "Black Friday picks from {{brand}}",
default_prompt:
"Write a Black Friday email highlighting the selected products. Emphasize limited-time savings, keep the tone energetic but trustworthy, include a clear CTA to shop, and mention 24 hero products with short benefit-led blurbs."
},
{
key: "christmas",
name: "Christmas",
emoji: "🎄",
description: "Gift guides and warm seasonal offers.",
default_subject: "Gift ideas for the holidays",
default_prompt:
"Write a Christmas / holiday gift-guide email for the selected products. Warm and festive tone, suggest who each product is for, keep copy scannable, and end with a clear shop CTA."
},
{
key: "spring",
name: "Spring",
emoji: "🌸",
description: "Fresh arrivals and seasonal refresh.",
default_subject: "New for spring: {{brand}} favorites",
default_prompt:
"Write a spring refresh email featuring the selected products. Light, optimistic tone; focus on whats new or renewed; short product blurbs and one primary CTA."
},
{
key: "summer",
name: "Summer",
emoji: "☀️",
description: "Warm-weather picks and outdoor-ready products.",
default_subject: "Summer essentials from {{brand}}",
default_prompt:
"Write a summer email featuring the selected products. Bright and inviting tone; highlight seasonal use-cases; keep paragraphs short with a clear shop CTA."
},
{
key: "custom",
name: "Custom",
emoji: "✉️",
description: "Start from a blank prompt and shape your own campaign.",
default_subject: "News from {{brand}}",
default_prompt:
"Write a promotional email for the selected products. Clear subject line energy, benefit-focused product blurbs, on-brand voice, and a single primary call to action."
}
];
export function templateByKey(
key: string,
templates: SeasonTemplate[] = DEFAULT_SEASON_TEMPLATES
): SeasonTemplate | undefined {
return templates.find((t) => t.key === key);
}
export function defaultCampaignName(template: SeasonTemplate): string {
const year = new Date().getFullYear();
if (template.key === "custom") return `Campaign ${year}`;
return `${template.name} ${year}`;
}
+72
View File
@@ -0,0 +1,72 @@
export type CampaignStatus = "draft" | "ready" | "scheduled" | "sending" | "sent" | "failed";
export type AudienceType = "all" | "by_category" | "purchased" | "not_purchased";
export type AudienceFilter = {
type: AudienceType;
category_ids?: string[];
product_ids?: string[];
};
export type SeasonTemplate = {
key: string;
name: string;
description?: string;
default_prompt: string;
default_subject?: string;
emoji?: string;
};
export type CampaignVersion = {
id?: string;
subject?: string | null;
html_body?: string | null;
plain_body?: string | null;
generated_at?: string | null;
};
export type Campaign = {
id: string;
name: string;
season?: string | null;
template_key?: string | null;
status?: CampaignStatus | string | null;
category_ids?: string[] | null;
product_ids?: string[] | null;
prompt?: string | null;
use_default_prompt?: boolean | null;
audience_filter?: AudienceFilter | null;
scheduled_at?: string | null;
subject?: string | null;
html_body?: string | null;
plain_body?: string | null;
latest_version?: CampaignVersion | null;
versions?: CampaignVersion[] | null;
created_at?: string | null;
updated_at?: string | null;
[key: string]: unknown;
};
export type CreateCampaignInput = {
name: string;
template_key: string;
season?: string;
category_ids?: string[];
product_ids?: string[];
prompt?: string;
use_default_prompt?: boolean;
audience_filter?: AudienceFilter;
};
export type GenerateCampaignInput = {
use_ai?: boolean;
prompt?: string;
};
export type ScheduleCampaignInput = {
scheduled_at: string;
};
export type SendTestInput = {
email: string;
};
+161
View File
@@ -0,0 +1,161 @@
import type { DescriptionSection, DescriptionSectionType, FormulaElement, FormulaVariable, TitleFormula } from "./types";
export function findVariableMetadata(name: string, variables: FormulaVariable[]) {
const variable = variables.find((v) => v.name === name);
if (!variable) return {};
return {
label: variable.label,
description: variable.description || null,
example: variable.example || null
};
}
export function buildTemplateToSave(
elements: FormulaElement[],
separator: string,
customVariables: FormulaVariable[]
): TitleFormula | null {
if (elements.length === 0) return null;
return {
elements: elements.map(({ type, value, label, description, example }) => {
const element: FormulaElement = { id: "", type, value };
if (type === "variable") {
const varInfo = customVariables.find((v) => v.name === value);
if (varInfo) {
return {
...element,
label: varInfo.label,
description: varInfo.description || null,
example: varInfo.example || null
};
}
return { ...element, label, description, example };
}
return element;
}),
separator
};
}
export function parseTemplateToFormula(
template: unknown,
customVariables: FormulaVariable[] = []
): TitleFormula {
const defaultState: TitleFormula = { elements: [], separator: " " };
if (!template) return defaultState;
try {
if (typeof template === "object" && template !== null && !Array.isArray(template)) {
const templateObj = template as { elements?: FormulaElement[]; separator?: string };
return {
elements: (templateObj.elements || []).map((el, index) => ({
...el,
id: el.id || `${index}-${el.type}-${el.value}`,
...(el.type === "variable" && !el.label
? findVariableMetadata(el.value, customVariables)
: {})
})),
separator: templateObj.separator || " "
};
}
if (Array.isArray(template)) {
return {
elements: (template as FormulaElement[]).map((el, index) => ({
...el,
id: el.id || `${index}-${el.type}-${el.value}`
})),
separator: " "
};
}
} catch {
/* fall through */
}
return defaultState;
}
export function generatePreviewElements(
elements: FormulaElement[],
customVariables: FormulaVariable[]
): Array<{ value: string; isPlaceholder: boolean }> {
return elements.map((element) => {
if (element.type === "text") {
return { value: element.value, isPlaceholder: false };
}
const customVar = customVariables.find((v) => v.name === element.value);
const exampleValue = customVar?.example ?? element.example;
if (exampleValue) {
return { value: exampleValue, isPlaceholder: false };
}
return { value: element.value, isPlaceholder: true };
});
}
export function elementId(type: string, value: string, index: number): string {
return `${index}-${type}-${value}-${Math.random().toString(36).slice(2, 8)}`;
}
export function getDefaultMetaTitle(): string {
return "Include the product name and one key benefit. Aim for 5060 characters.";
}
export function getDefaultMetaDescription(): string {
return "Summarize the product and 12 standout features. Aim for 120155 characters.";
}
export function getDefaultInstructions(type: DescriptionSectionType): string {
switch (type) {
case "h1":
return "Write one main heading with the product name and primary benefit.";
case "h2":
return "Write a section heading for a key topic (for example materials, fit, or use cases).";
case "h3":
return "Write a short subheading for a specific feature or detail.";
case "h4":
return "Write a minor subheading for supporting details.";
case "p":
return "Write 24 sentences explaining benefits and relevant specs for this section.";
case "ul":
return "List 36 concise bullets for features or specifications.";
default:
return "";
}
}
export function parseDescriptionTemplate(template: unknown): {
sections: DescriptionSection[];
metaTitle: string;
metaDescription: string;
} {
const empty = {
sections: [] as DescriptionSection[],
metaTitle: getDefaultMetaTitle(),
metaDescription: getDefaultMetaDescription()
};
if (!template || typeof template !== "object") return empty;
const t = template as {
sections?: DescriptionSection[];
metaTitle?: string;
metaDescription?: string;
};
return {
sections: (t.sections || []).map((s) => ({
...s,
id: s.id || crypto.randomUUID()
})),
metaTitle: t.metaTitle || getDefaultMetaTitle(),
metaDescription: t.metaDescription || getDefaultMetaDescription()
};
}
export function mapApiVariable(v: Record<string, unknown>): FormulaVariable {
const name = String(v.name ?? "");
const label = String(v.label ?? v.value ?? v.name ?? "Untitled Variable");
return {
id: String(v.id ?? name),
name,
label,
description: v.description != null ? String(v.description) : undefined,
example: v.example != null ? String(v.example) : undefined,
value: v.value != null ? String(v.value) : label
};
}
+46
View File
@@ -0,0 +1,46 @@
import { api, ApiError } from "$lib/api";
import { TREE_LIST_LIMIT, unwrapList } from "$lib/list";
import type { ListResponse } from "$lib/types";
import type { Cat } from "./types";
import { UUID_RE } from "./types";
export async function resolveCategory(categoryId: string): Promise<Cat> {
const raw = categoryId.trim();
if (!raw) {
throw new ApiError("Category not found", 404, { error: "not found" });
}
if (UUID_RE.test(raw)) {
return api<Cat>(`/api/categories/${raw}`);
}
// Prefer a narrow search over a full tree pull for unique_id / legacy slug routes.
const payload = await api<ListResponse<Cat>>(
`/api/categories?q=${encodeURIComponent(raw)}&limit=50`
);
const items = unwrapList(payload);
const found = items.find((c) => String(c.unique_id) === raw || String(c.id) === raw);
if (!found) {
throw new ApiError("Category not found", 404, { error: "not found" });
}
return api<Cat>(`/api/categories/${found.id}`);
}
export async function listAllCategories(): Promise<Cat[]> {
const payload = await api<ListResponse<Cat>>(`/api/categories?tree=1&limit=${TREE_LIST_LIMIT}`);
return unwrapList(payload);
}
export function findCategoryIdByUniqueId(categories: Cat[], uniqueId: string): string | null {
const found = categories.find((c) => String(c.unique_id) === uniqueId);
return found ? String(found.id) : null;
}
export function categoryFormulaPath(
category: Pick<Cat, "id" | "unique_id">,
kind: "title" | "description" | "prompt"
): string {
const slug = encodeURIComponent(String(category.unique_id || category.id));
if (kind === "prompt") {
return `/categories/${slug}/prompt`;
}
return `/categories/${slug}/${kind}-formula`;
}
+89
View File
@@ -0,0 +1,89 @@
import type { Cat, TreeNode } from "./types";
export function buildTree(items: Cat[], expanded = new Set<string>()): TreeNode[] {
const byUID = new Map<string, TreeNode>();
for (const c of items) {
const uid = String(c.unique_id ?? c.id);
byUID.set(uid, {
...c,
children: [],
hasChildren: false,
isExpanded: expanded.has(uid) || expanded.has(String(c.id))
});
}
const roots: TreeNode[] = [];
for (const node of byUID.values()) {
const parentUid = node.parent_unique_id ? String(node.parent_unique_id) : null;
const parent = parentUid ? byUID.get(parentUid) : undefined;
if (parent) {
parent.children.push(node);
parent.hasChildren = true;
} else {
roots.push(node);
}
}
const sortNodes = (nodes: TreeNode[]) => {
nodes.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? "")));
for (const n of nodes) {
if (n.children.length) sortNodes(n.children);
}
};
sortNodes(roots);
return roots;
}
export function filterTree(nodes: TreeNode[], q: string): TreeNode[] {
const needle = q.trim().toLowerCase();
if (!needle) return nodes;
const out: TreeNode[] = [];
for (const node of nodes) {
const children = filterTree(node.children, needle);
const hit =
String(node.name ?? "")
.toLowerCase()
.includes(needle) ||
String(node.unique_id ?? "")
.toLowerCase()
.includes(needle) ||
String(node.id)
.toLowerCase()
.includes(needle);
if (hit || children.length) {
out.push({
...node,
isExpanded: hit || children.length > 0 ? true : node.isExpanded,
children: hit && !q.trim() ? node.children : children.length ? children : node.children
});
}
}
return out;
}
/** When searching, flatten-match: keep matching nodes with filtered children, expand parents. */
export function filterTreeDeep(nodes: TreeNode[], q: string): TreeNode[] {
const needle = q.trim().toLowerCase();
if (!needle) return nodes;
function walk(list: TreeNode[]): TreeNode[] {
const result: TreeNode[] = [];
for (const node of list) {
const childMatches = walk(node.children);
const selfHit =
String(node.name ?? "")
.toLowerCase()
.includes(needle) ||
String(node.unique_id ?? "")
.toLowerCase()
.includes(needle);
if (selfHit || childMatches.length) {
result.push({
...node,
isExpanded: childMatches.length > 0 || node.isExpanded,
children: selfHit ? node.children.map((c) => ({ ...c })) : childMatches
});
}
}
return result;
}
return walk(nodes);
}
+76
View File
@@ -0,0 +1,76 @@
export type Cat = {
id: string;
name?: string | null;
unique_id?: string;
parent_unique_id?: string | null;
level?: number;
path?: string | null;
is_active?: boolean;
description?: string | null;
title_template?: unknown;
description_template?: unknown;
has_title_formula?: boolean | null;
has_description_formula?: boolean | null;
has_prompt?: boolean | null;
/** Convenience: primary-language prompt text. */
prompt?: string | null;
/** Per-language category AI prompts. */
prompts?: Record<string, string> | null;
[key: string]: unknown;
};
export type TreeNode = Cat & {
children: TreeNode[];
hasChildren: boolean;
isExpanded: boolean;
};
export type FormulaElement = {
id: string;
type: "text" | "variable";
value: string;
label?: string;
description?: string | null;
example?: string | null;
};
export type TitleFormula = {
elements: FormulaElement[];
separator: string;
};
export type FormulaVariable = {
id: string;
label: string;
name: string;
description?: string;
example?: string;
value?: string;
};
export type DescriptionSectionType = "h1" | "h2" | "h3" | "h4" | "p" | "ul";
export type DescriptionSection = {
id: string;
type: DescriptionSectionType;
instructions: string;
exportId?: string;
};
export type DescriptionTemplate = {
sections: DescriptionSection[];
metaTitle?: string;
metaDescription?: string;
};
export const SECTION_TYPES: { value: DescriptionSectionType; label: string }[] = [
{ value: "h1", label: "Heading 1" },
{ value: "h2", label: "Heading 2" },
{ value: "h3", label: "Heading 3" },
{ value: "h4", label: "Heading 4" },
{ value: "p", label: "Paragraph" },
{ value: "ul", label: "Bullet List" }
];
export const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -0,0 +1,125 @@
/**
* Command palette search ranking/filter helpers (node:test).
*
* Run from apps/web:
* node --experimental-strip-types --test src/lib/command-palette-search.test.ts
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
commandPaletteShortcutLabel,
filterAndRankPaletteItems,
normalizePaletteQuery,
scorePaletteItem,
scorePaletteToken,
type PaletteSearchItem
} from "./command-palette-search.ts";
const ITEMS: PaletteSearchItem[] = [
{ id: "products", label: "Products", keywords: "catalog items sku" },
{ id: "processing", label: "Jobs", keywords: "processing queue tasks" },
{ id: "feeds", label: "Feeds", keywords: "import sources" },
{ id: "seo", label: "SEO", keywords: "search optimization meta" },
{ id: "settings", label: "Settings", keywords: "account preferences" },
{ id: "stores", label: "Stores", keywords: "shopify woocommerce connections" }
];
describe("normalizePaletteQuery", () => {
it("trims and lowercases", () => {
assert.equal(normalizePaletteQuery(" Products "), "products");
assert.equal(normalizePaletteQuery(""), "");
assert.equal(normalizePaletteQuery(" "), "");
});
});
describe("scorePaletteToken", () => {
it("ranks exact label highest, then prefix, word, includes, keywords", () => {
assert.equal(scorePaletteToken("products", "Products", "catalog"), 100);
assert.equal(scorePaletteToken("prod", "Products", "catalog"), 80);
assert.equal(scorePaletteToken("duct", "Products", "catalog"), 50);
assert.equal(scorePaletteToken("catalog", "Products", "catalog items"), 40);
assert.equal(scorePaletteToken("items", "Products", "catalog items"), 40);
assert.equal(scorePaletteToken("talog", "Products", "catalog items"), 20);
assert.equal(scorePaletteToken("zzz", "Products", "catalog"), 0);
});
it("scores word-prefix inside multi-word labels", () => {
assert.equal(scorePaletteToken("fields", "Standard fields", "mapping"), 70);
});
});
describe("scorePaletteItem", () => {
it("returns 1 for empty query so idle lists keep order", () => {
assert.equal(scorePaletteItem("", ITEMS[0]!), 1);
assert.equal(scorePaletteItem(" ", ITEMS[0]!), 1);
});
it("requires every token to match (all tokens)", () => {
const item = ITEMS.find((i) => i.id === "stores")!;
assert.ok(scorePaletteItem("shopify", item) > 0);
assert.equal(scorePaletteItem("shopify billing", item), 0);
assert.ok(scorePaletteItem("shopify stores", item) > scorePaletteItem("shopify", item));
});
});
describe("filterAndRankPaletteItems", () => {
it("returns original order when query is empty", () => {
assert.deepEqual(
filterAndRankPaletteItems("", ITEMS).map((i) => i.id),
ITEMS.map((i) => i.id)
);
});
it("filters non-matches", () => {
const out = filterAndRankPaletteItems("billing", ITEMS);
assert.deepEqual(out, []);
});
it("ranks label prefix above keyword substring", () => {
const out = filterAndRankPaletteItems("search", ITEMS);
assert.deepEqual(
out.map((i) => i.id),
["seo"]
);
assert.equal(
out.some((i) => i.id === "products"),
false
);
});
it("prefers exact/label hits over weaker keyword hits", () => {
const mixed: PaletteSearchItem[] = [
{ id: "kw", label: "Marketing", keywords: "product launch" },
{ id: "label", label: "Products", keywords: "catalog" }
];
const out = filterAndRankPaletteItems("product", mixed);
assert.equal(out[0]?.id, "label");
assert.equal(out[1]?.id, "kw");
});
it("matches Jobs via processing keyword", () => {
const out = filterAndRankPaletteItems("processing", ITEMS);
assert.equal(out.length, 1);
assert.equal(out[0]?.id, "processing");
});
it("matches multi-token queries across label and keywords", () => {
const out = filterAndRankPaletteItems("shopify store", ITEMS);
assert.equal(out.length, 1);
assert.equal(out[0]?.id, "stores");
});
});
describe("commandPaletteShortcutLabel", () => {
it("shows CmdK on Apple platforms", () => {
assert.equal(commandPaletteShortcutLabel("MacIntel"), "⌘K");
assert.equal(commandPaletteShortcutLabel("", "Mozilla/5.0 (iPhone)"), "⌘K");
});
it("shows Ctrl+K elsewhere (Windows/Linux)", () => {
assert.equal(commandPaletteShortcutLabel("Win32"), "Ctrl+K");
assert.equal(commandPaletteShortcutLabel("Linux x86_64"), "Ctrl+K");
assert.equal(commandPaletteShortcutLabel(null, null), "Ctrl+K");
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* Pure command-palette search helpers (filter + rank).
* No Svelte / i18n / $app — safe for node:test.
*/
export type PaletteSearchItem = {
id: string;
label: string;
keywords: string;
};
/** Trim + lowercase; empty when the user has not typed a query yet. */
export function normalizePaletteQuery(query: string): string {
return query.trim().toLowerCase();
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** True when `token` starts a whole word in `hay` (space-separated). */
function wordStartsWith(hay: string, token: string): boolean {
if (!token) return false;
return new RegExp(`(?:^|\\s)${escapeRegExp(token)}`).test(hay);
}
/**
* Score one query token against label + keywords.
* Higher = better match. 0 = no match.
*/
export function scorePaletteToken(token: string, label: string, keywords: string): number {
const t = token.trim().toLowerCase();
if (!t) return 0;
const lab = label.toLowerCase();
const keys = keywords.toLowerCase();
if (lab === t) return 100;
if (lab.startsWith(t)) return 80;
if (wordStartsWith(lab, t)) return 70;
if (lab.includes(t)) return 50;
if (wordStartsWith(keys, t)) return 40;
if (keys.includes(t)) return 20;
return 0;
}
/**
* Score an item for a full query. Multi-token queries use AND:
* every token must score > 0; total is the sum.
* Empty query scores 1 (preserve input order when idle).
*/
export function scorePaletteItem(query: string, item: PaletteSearchItem): number {
const q = normalizePaletteQuery(query);
if (!q) return 1;
const tokens = q.split(/\s+/).filter(Boolean);
let total = 0;
for (const token of tokens) {
const part = scorePaletteToken(token, item.label, item.keywords);
if (part <= 0) return 0;
total += part;
}
return total;
}
/**
* Filter out non-matches and rank by score (desc), then label (asc).
* Empty / whitespace query returns items in original order.
*/
export function filterAndRankPaletteItems<T extends PaletteSearchItem>(
query: string,
items: readonly T[]
): T[] {
const q = normalizePaletteQuery(query);
if (!q) return [...items];
return items
.map((item) => ({ item, score: scorePaletteItem(q, item) }))
.filter((row) => row.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.item.label.localeCompare(b.item.label);
})
.map((row) => row.item);
}
/**
* Platform-aware shortcut hint for discoverability (⌘K vs Ctrl+K).
* Pass `platform` in tests; defaults to `navigator.platform` / `userAgent` in browser.
*/
export function commandPaletteShortcutLabel(
platform?: string | null,
userAgent?: string | null
): string {
const p = (platform ?? "").toLowerCase();
const ua = (userAgent ?? "").toLowerCase();
const hay = `${p} ${ua}`;
if (
hay.includes("mac") ||
hay.includes("iphone") ||
hay.includes("ipad") ||
hay.includes("ipod")
) {
return "⌘K";
}
return "Ctrl+K";
}
/** Resolve shortcut from the current environment (SSR-safe). */
export function commandPaletteShortcutLabelFromEnv(): string {
if (typeof navigator === "undefined") return "Ctrl+K";
return commandPaletteShortcutLabel(navigator.platform, navigator.userAgent);
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Company admin role helpers (node:test).
*
* Run from apps/web:
* node --experimental-strip-types --test src/lib/company-admin.test.ts
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { canManageCompany, isCompanyAdmin } from "./company-admin.ts";
import type { MeResponse } from "./types.ts";
type MeFixture = {
user?: Partial<MeResponse["user"]>;
membership?: { role: string; status?: string } | null;
staff_access?: MeResponse["staff_access"];
impersonating?: boolean;
};
function me(partial: MeFixture = {}): MeResponse {
const user = {
id: "u1",
email: "a@example.com",
...(partial.user ?? {})
};
const membership =
partial.membership === null
? null
: {
role: "member",
status: "active",
...(partial.membership ?? {})
};
return {
user,
membership,
staff_access: partial.staff_access,
impersonating: partial.impersonating
};
}
describe("isCompanyAdmin", () => {
it("accepts membership admin and string roles", () => {
assert.equal(isCompanyAdmin(me({ membership: { role: "admin" } })), true);
assert.equal(isCompanyAdmin("admin"), true);
assert.equal(isCompanyAdmin(" Admin "), true);
assert.equal(isCompanyAdmin({ role: "admin" }), true);
});
it("rejects members and empty values", () => {
assert.equal(isCompanyAdmin(me({ membership: { role: "member" } })), false);
assert.equal(isCompanyAdmin("member"), false);
assert.equal(isCompanyAdmin(""), false);
assert.equal(isCompanyAdmin(null), false);
assert.equal(isCompanyAdmin(undefined), false);
assert.equal(isCompanyAdmin(me({ membership: null })), false);
});
});
describe("canManageCompany", () => {
it("allows membership admins", () => {
assert.equal(canManageCompany(me({ membership: { role: "admin" } })), true);
});
it("allows full platform admin without company admin role", () => {
assert.equal(
canManageCompany(
me({
membership: { role: "member" },
staff_access: {
full_admin: true,
support_desk: true,
is_support_only: false
}
})
),
true
);
});
it("allows impersonating sessions", () => {
assert.equal(
canManageCompany(me({ membership: { role: "member" }, impersonating: true })),
true
);
});
it("rejects ordinary members", () => {
assert.equal(canManageCompany(me({ membership: { role: "member" } })), false);
assert.equal(canManageCompany(null), false);
assert.equal(canManageCompany(undefined), false);
});
});
+35
View File
@@ -0,0 +1,35 @@
import type { MeResponse } from "./types.ts";
import { isFullPlatformAdmin } from "./staff-access.ts";
/** True when the active company membership role is admin (matches API CompanyAdminAllowed session path). */
export function isCompanyAdmin(
meOrRole: MeResponse | { role?: string | null } | string | null | undefined
): boolean {
if (meOrRole == null) return false;
if (typeof meOrRole === "string") {
return meOrRole.trim().toLowerCase() === "admin";
}
if ("membership" in meOrRole) {
return String(meOrRole.membership?.role ?? "")
.trim()
.toLowerCase() === "admin";
}
const role =
"role" in meOrRole && meOrRole.role != null ? String(meOrRole.role) : "";
return role.trim().toLowerCase() === "admin";
}
/**
* True when the session may perform company-admin mutations (API keys, team, company settings).
* Includes membership admin, platform/full admin, and non-prod privileged impersonation
* (demo/platform actor switched into a member tenant — matches API allowCompanyAdminOrPlatform).
*/
export function canManageCompany(me: MeResponse | null | undefined): boolean {
if (me == null) return false;
if (isCompanyAdmin(me)) return true;
if (isFullPlatformAdmin(me)) {
return true;
}
// Only privileged actors can start non-prod user-switch; retain admin powers while switched.
return Boolean(me.impersonating);
}
@@ -0,0 +1,348 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api } from "$lib/api";
import { i18n } from "$lib/i18n";
import { unwrapList, unwrapTotal, DEFAULT_PAGE_SIZE, RECENT_JOBS_LIMIT } from "$lib/list";
import type { ListResponse, ProcessingJob, Product, ShopifyConfig, WooCommerceConfig } from "$lib/types";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Progress } from "$lib/components/ui";
import {
activationIndexFromWorkspace,
readActivationProgress,
resolveActivationCursor,
visibleActivationSteps,
writeActivationProgress,
type ActivationProgress,
type ActivationWorkspaceEvidence
} from "$lib/activation";
import { trackEvent } from "$lib/analytics";
import { isFeedActive, type FeedRow } from "$lib/components/feeds/types";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { needsStoreReconnect } from "$lib/store-reconnect";
import { Check, Circle, ListChecks, X } from "@lucide/svelte";
let showAllSteps = $state(false);
let progress = $state<ActivationProgress>(readActivationProgress());
let currentIndex = $state(0);
let completedCount = $state(0);
let ready = $state(false);
let workspace = $state<ActivationWorkspaceEvidence | null>(null);
const steps = $derived(visibleActivationSteps((key) => planCapabilities.can(key)));
function refresh(
evidence: ActivationWorkspaceEvidence | null = workspace,
stepList = steps
) {
const resolved = resolveActivationCursor(readActivationProgress(), evidence, stepList);
progress = resolved.progress;
currentIndex = resolved.currentIndex;
completedCount = resolved.completedCount;
}
async function loadWorkspaceEvidence(signal: AbortSignal): Promise<ActivationWorkspaceEvidence> {
const evidence: ActivationWorkspaceEvidence = {};
const canStores = planCapabilities.can("stores.hub");
const [fieldsRes, feedsRes, productsRes, jobsRes, exportsRes, wooRes, shopifyRes] =
await Promise.all([
api<ListResponse<Record<string, unknown>>>("/api/standard-fields?enabled=true&limit=1", {
signal
}).catch(() => null),
api<ListResponse<FeedRow> & { total?: number }>(`/api/feeds?limit=${DEFAULT_PAGE_SIZE}&offset=0`, {
signal
}).catch(() => null),
api<ListResponse<Product> & { total?: number }>(
"/api/products?limit=1&offset=0",
{ signal }
).catch(() => null),
api<ListResponse<ProcessingJob>>(`/api/processing/jobs?limit=${RECENT_JOBS_LIMIT}`, {
signal
}).catch(() => null),
api<ListResponse<Record<string, unknown>> & { total?: number }>(
"/api/export-feeds?limit=1",
{ signal }
).catch(() => null),
canStores
? api<WooCommerceConfig>("/api/woocommerce", { signal }).catch(() => null)
: Promise.resolve(null),
canStores
? api<ShopifyConfig>("/api/shopify", { signal }).catch(() => null)
: Promise.resolve(null)
]);
if (signal.aborted) return evidence;
const enabledFields = fieldsRes ? unwrapList(fieldsRes).length : 0;
const feeds = feedsRes ? unwrapList(feedsRes) : [];
const feedTotal = feedsRes ? (unwrapTotal(feedsRes) ?? feeds.length) : 0;
const productTotal = productsRes
? (unwrapTotal(productsRes) ?? unwrapList(productsRes).length)
: 0;
const jobs = jobsRes ? unwrapList(jobsRes) : [];
const exportTotal = exportsRes
? (unwrapTotal(exportsRes) ?? unwrapList(exportsRes).length)
: 0;
evidence.hasSource = feedTotal > 0;
evidence.hasMapping = feeds.some((f) => {
const status = String(f.status ?? "").toLowerCase();
return status === "mapped" || isFeedActive(f);
});
evidence.hasSyncedSample = productTotal > 0;
evidence.hasProcessed = jobs.length > 0;
evidence.hasExport = exportTotal > 0;
// Fields step: explicit enabled fields, or infer once a source already exists.
evidence.hasEnabledFields = enabledFields > 0 || evidence.hasSource === true;
if (canStores) {
const wooOk = Boolean(wooRes?.has_credentials) && !needsStoreReconnect(wooRes);
const shopifyOk = Boolean(shopifyRes?.has_credentials) && !needsStoreReconnect(shopifyRes);
evidence.hasStoreConnect = wooOk || shopifyOk;
}
return evidence;
}
onMount(() => {
const ac = new AbortController();
refresh(null, steps);
ready = true;
void (async () => {
try {
const evidence = await loadWorkspaceEvidence(ac.signal);
if (ac.signal.aborted) return;
workspace = evidence;
refresh(evidence, steps);
} catch {
/* keep localStorage/tutorial cursor */
}
})();
return () => ac.abort();
});
$effect(() => {
const stepList = steps;
if (!ready) return;
refresh(workspace, stepList);
});
const total = $derived(steps.length);
const pct = $derived(total === 0 ? 0 : Math.round((completedCount / total) * 100));
const visible = $derived(ready && (progress.status === "idle" || progress.status === "in_progress"));
const dismissed = $derived(ready && progress.status === "skipped");
const coreStepIds = new Set(["connect-source", "map", "sync-sample", "process"]);
function dismiss() {
const stepId = steps[currentIndex]?.id ?? progress.stepId;
progress = writeActivationProgress("skipped", stepId);
trackEvent("activation_dismiss", stepId ? { step_id: stepId } : undefined);
}
function resume() {
const stepId =
progress.stepId && steps.some((s) => s.id === progress.stepId)
? progress.stepId
: (steps[0]?.id ?? null);
progress = writeActivationProgress("in_progress", stepId);
refresh();
}
function openStep(index: number) {
const step = steps[index];
if (!step) return;
progress = writeActivationProgress("in_progress", step.id);
currentIndex = index;
completedCount = Math.max(index, activationIndexFromWorkspace(workspace, steps));
void goto(step.href);
}
function markCurrentDone() {
const completedId = steps[currentIndex]?.id;
if (completedId) {
trackEvent("activation_step_complete", { step_id: completedId });
}
const nextIndex = currentIndex + 1;
if (nextIndex >= steps.length) {
progress = writeActivationProgress("completed", steps.at(-1)?.id ?? "export");
currentIndex = steps.length;
completedCount = steps.length;
return;
}
const next = steps[nextIndex];
progress = writeActivationProgress("in_progress", next.id);
currentIndex = nextIndex;
completedCount = nextIndex;
}
</script>
{#if dismissed}
<div
class="flex flex-col gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:justify-between"
data-tour="activation-checklist-resume"
role="status"
>
<div class="flex items-start gap-3">
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted">
<ListChecks class="h-4 w-4 text-muted-foreground" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-foreground">{i18n.t("activation.pausedTitle")}</p>
<p class="text-sm text-muted-foreground">
{i18n.t("activation.pausedBody")}
</p>
</div>
</div>
<Button size="sm" variant="outline" onclick={resume}>{i18n.t("activation.resume")}</Button>
</div>
{:else if visible}
<Card data-tour="activation-checklist">
<CardHeader class="pb-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 space-y-1">
<CardTitle class="flex items-center gap-2 text-base">
<ListChecks class="h-4 w-4 shrink-0 text-muted-foreground" />
{i18n.t("activation.title")}
</CardTitle>
<CardDescription data-testid="activation-value-prop">
{i18n.t("activation.valueProp")}
</CardDescription>
<p
class="pt-1 text-xs font-medium tracking-wide text-muted-foreground"
data-testid="activation-core-path"
>
<span class="text-foreground">{i18n.t("activation.corePathLabel")}:</span>
{" "}{i18n.t("activation.corePath")}
</p>
<p class="text-sm text-muted-foreground">
{i18n.t("activation.descriptionShort")}
</p>
</div>
<button
type="button"
class="rounded-md p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={i18n.t("activation.dismiss")}
data-tour="activation-checklist-dismiss"
onclick={dismiss}
>
<X class="h-4 w-4" />
</button>
</div>
<div class="space-y-1.5 pt-2">
<div class="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{i18n.t("activation.progress", { done: completedCount, total })}</span>
<span>{pct}%</span>
</div>
<Progress value={pct} />
</div>
</CardHeader>
<CardContent class="pt-0">
<ol class="divide-y divide-border" aria-label={i18n.t("activation.stepsLabel")}>
{#each steps as step, index (step.id)}
{@const done = index < completedCount}
{@const current = index === currentIndex && completedCount < total}
{@const core = coreStepIds.has(step.id)}
{#if showAllSteps || done || current}
<li
class="flex flex-col gap-3 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between
{current ? 'rounded-md bg-muted/40 px-2 sm:px-3' : ''}"
>
<div class="flex min-w-0 items-start gap-3">
<span
class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium
{done
? 'border-foreground bg-foreground text-background'
: current
? 'border-foreground text-foreground'
: 'border-border text-muted-foreground'}"
aria-hidden="true"
>
{#if done}
<Check class="h-3.5 w-3.5" />
{:else if current}
<span>{index + 1}</span>
{:else}
<Circle class="h-3 w-3 opacity-40" />
{/if}
</span>
<div class="min-w-0">
<p
class="text-sm font-medium {done
? 'text-muted-foreground line-through'
: 'text-foreground'}"
>
{i18n.t(`activation.step.${step.id}.title`)}
{#if step.optional && (current || showAllSteps)}
<span class="ml-1 text-xs font-normal text-muted-foreground"
>({i18n.t("common.optional")})</span
>
{:else if core && current}
<span class="ml-1 text-xs font-normal text-muted-foreground"
>({i18n.t("activation.corePathLabel")})</span
>
{/if}
</p>
{#if current || showAllSteps}
<p class="text-sm text-muted-foreground">
{i18n.t(`activation.step.${step.id}.body`)}
</p>
{#if step.id === "store-connect" && (current || showAllSteps)}
<p class="mt-1 text-xs text-muted-foreground" data-tour="activation-store-optional-hint">
{i18n.t("activation.storeWizardHint")}
</p>
{/if}
{/if}
</div>
</div>
{#if current}
<div class="flex flex-wrap gap-2 sm:shrink-0">
<Button
size="sm"
class="bg-[#1e1b4b] hover:bg-[#1e1b4b]/90"
data-tour="activation-checklist-continue"
onclick={() => openStep(index)}
>
{i18n.t("activation.nextCta")}
</Button>
<Button size="sm" variant="ghost" onclick={markCurrentDone}>
{step.optional
? i18n.t("activation.skipForNow")
: i18n.t("activation.alreadyDone")}
</Button>
</div>
{:else if !done && showAllSteps}
<Button
size="sm"
variant="ghost"
class="sm:shrink-0"
onclick={() => openStep(index)}
>
{i18n.t("common.open")}
</Button>
{/if}
</li>
{/if}
{/each}
</ol>
{#if !showAllSteps && completedCount < total}
<button
type="button"
class="mt-3 text-sm font-medium text-link underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded="false"
onclick={() => (showAllSteps = true)}
>
{i18n.t("activation.showAllSteps", { total })}
</button>
{:else if showAllSteps && completedCount < total}
<button
type="button"
class="mt-3 text-sm font-medium text-link underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded="true"
onclick={() => (showAllSteps = false)}
>
{i18n.t("activation.showFewerSteps")}
</button>
{/if}
</CardContent>
</Card>
{/if}
+306
View File
@@ -0,0 +1,306 @@
<script lang="ts">
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { tick } from "svelte";
import {
ADMIN_NAV_ROUTES,
ADMIN_NAV_SECTIONS,
adminNavIsActive,
type AdminNavGroupId
} from "$lib/admin-nav";
import { adminNavUi } from "$lib/admin-nav-ui.svelte";
import { authSession } from "$lib/auth-session.svelte";
import { theme } from "$lib/theme.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import LocaleSwitcher from "$lib/components/LocaleSwitcher.svelte";
import { i18n } from "$lib/i18n";
import { activateFocusTrap, type FocusTrapHandle } from "$lib/a11y/focus-trap";
import { api } from "$lib/api";
import {
LayoutDashboard,
Users,
BarChart3,
ClipboardList,
Settings,
CreditCard,
Handshake,
LifeBuoy,
BookOpen,
Activity,
ArrowLeft,
FileWarning,
LogOut,
X
} from "@lucide/svelte";
const icons = {
"/admin": LayoutDashboard,
"/admin/analytics": BarChart3,
"/admin/users": Users,
"/admin/support": LifeBuoy,
"/admin/support/knowledge": BookOpen,
"/admin/diagnostics": Activity,
"/admin/stuck-products": ClipboardList,
"/admin/orphan-processed": FileWarning,
"/admin/billing": CreditCard,
"/admin/sales": Handshake,
"/admin/settings": Settings
} as const;
const visibleItems = $derived.by(() => {
const full = authSession.isPlatformAdmin;
return ADMIN_NAV_ROUTES.filter((item) => !item.fullAdminOnly || full);
});
const visibleSections = $derived.by(() =>
ADMIN_NAV_SECTIONS.map((section) => ({
id: section.id as AdminNavGroupId,
label: i18n.t(section.labelKey),
items: visibleItems.filter((item) => item.group === section.id)
})).filter((section) => section.items.length > 0)
);
const staffLabel = $derived.by(() => {
if (authSession.isSupportOnly) return i18n.t("admin.chrome.staff.supportDesk");
if (authSession.isPlatformAdmin) return i18n.t("admin.chrome.staff.platformAdmin");
if (authSession.isSupportDesk) return i18n.t("admin.chrome.staff.staff");
return i18n.t("admin.chrome.staff.admin");
});
const staffEmail = $derived(authSession.me?.user?.email?.trim() || "");
let asideEl = $state<HTMLElement | null>(null);
let trap: FocusTrapHandle | null = null;
let isDesktop = $state(false);
let navMounted = $state(false);
let clickedHref = $state<string | null>(null);
let loggingOut = $state(false);
$effect(() => {
if (typeof window === "undefined") return;
navMounted = true;
const mq = window.matchMedia("(min-width: 1024px)");
const sync = () => {
isDesktop = mq.matches;
if (mq.matches) adminNavUi.closeMobile();
};
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
});
/** Close drawer after client navigations (back/forward, deep links). */
$effect(() => {
void page.url.pathname;
adminNavUi.closeMobile();
});
/** Mobile drawer focus trap only (desktop sidebar stays in normal Tab order). */
$effect(() => {
const open = adminNavUi.mobileOpen;
if (isDesktop || !open) {
trap?.deactivate();
trap = null;
return;
}
let cancelled = false;
void tick().then(() => {
if (cancelled || !asideEl || isDesktop || !adminNavUi.mobileOpen) return;
trap?.deactivate();
trap = activateFocusTrap(asideEl, { restoreFocus: true });
});
return () => {
cancelled = true;
trap?.deactivate();
trap = null;
};
});
const mobileDrawerHidden = $derived(navMounted && !isDesktop && !adminNavUi.mobileOpen);
function linkClass(active: boolean, clicked: boolean): string {
const base =
"relative flex h-8 w-full items-center justify-start gap-2.5 rounded-md px-2.5 text-[13px] font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring";
if (active) {
return `${base} bg-sidebar-accent text-sidebar-accent-foreground ${clicked ? "scale-[0.98]" : ""}`;
}
return `${base} text-sidebar-foreground/70 hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground ${clicked ? "scale-[0.98] bg-sidebar-accent/50" : ""}`;
}
function footerActionClass(extra = ""): string {
return `flex h-8 w-full items-center gap-2.5 rounded-md px-2.5 text-[13px] font-medium text-sidebar-foreground/70 transition hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${extra}`;
}
function onAsideKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && adminNavUi.mobileOpen) {
event.preventDefault();
adminNavUi.closeMobile();
}
}
function handleNav(href: string, event: MouseEvent) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
adminNavUi.closeMobile();
return;
}
// Side effects only — do not preventDefault + goto. SvelteKit already hijacks
// same-origin <a> clicks; a manual goto() can abort while another navigation
// is settling, leaving the click as a no-op.
const key = href;
clickedHref = key;
adminNavUi.closeMobile();
setTimeout(() => {
if (clickedHref === key) clickedHref = null;
}, 300);
}
async function logout() {
if (loggingOut) return;
loggingOut = true;
adminNavUi.closeMobile();
try {
await api("/api/auth/logout", { method: "POST" });
} catch {
/* session may already be gone */
} finally {
await goto("/login");
loggingOut = false;
}
}
</script>
{#if adminNavUi.mobileOpen}
<button
type="button"
class="fixed inset-0 z-40 bg-foreground/40 lg:hidden"
aria-label={i18n.t("nav.close")}
tabindex="-1"
onclick={() => adminNavUi.closeMobile()}
></button>
{/if}
<aside
bind:this={asideEl}
id="admin-sidebar"
class="fixed inset-y-0 left-0 z-50 flex w-[15.5rem] flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-transform duration-200 ease-out lg:visible lg:translate-x-0 lg:pointer-events-auto {adminNavUi.mobileOpen
? 'translate-x-0'
: '-translate-x-full pointer-events-none invisible'}"
aria-label={i18n.t("admin.chrome.sidebar")}
aria-hidden={mobileDrawerHidden ? "true" : undefined}
inert={mobileDrawerHidden ? true : undefined}
tabindex="-1"
onkeydown={onAsideKeydown}
>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto px-3 pb-3 pt-4">
<div class="mb-5 flex items-center justify-between gap-2 px-1">
<a
href="/admin"
class="flex min-w-0 items-center gap-2 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
aria-label={i18n.t("admin.nav.commandCenter")}
onclick={(e) => handleNav("/admin", e)}
>
<img src="/descrybe_logo.png" alt="" width="28" height="28" class="h-7 w-7 shrink-0" />
<span class="min-w-0 truncate text-sm font-semibold tracking-tight text-sidebar-foreground">{i18n.t("app.name")}</span>
<span
class="shrink-0 rounded border border-sidebar-border bg-sidebar-accent px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.1em] text-sidebar-foreground/75"
>
{i18n.t("admin.chrome.opsBadge")}
</span>
</a>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-md text-sidebar-foreground/70 transition hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring lg:hidden"
aria-label={i18n.t("nav.closeMenu")}
onclick={() => adminNavUi.closeMobile()}
>
<X class="h-4 w-4" aria-hidden="true" />
</button>
</div>
<nav class="space-y-4" aria-label={i18n.t("admin.chrome.nav")}>
{#each visibleSections as section}
<div class="space-y-0.5" role="group" aria-labelledby="admin-nav-{section.id}">
<p
id="admin-nav-{section.id}"
class="px-2.5 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.1em] text-sidebar-foreground/55"
>
{section.label}
</p>
{#each section.items as item}
{@const Icon = icons[item.href as keyof typeof icons] ?? LayoutDashboard}
{@const active = adminNavIsActive(item.href, page.url.pathname)}
{@const clicked = clickedHref === item.href}
{@const title = i18n.t(item.titleKey)}
<a
href={item.href}
onclick={(e) => handleNav(item.href, e)}
aria-label={title}
aria-current={active ? "page" : undefined}
class={linkClass(active, clicked)}
>
{#if active}
<span
class="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-sidebar-primary"
aria-hidden="true"
></span>
{/if}
<span class="shrink-0" aria-hidden="true">
<Icon class="h-3.5 w-3.5" />
</span>
<span class="truncate">{title}</span>
</a>
{/each}
</div>
{/each}
</nav>
<div class="mt-auto space-y-2 border-t border-sidebar-border pt-3">
{#if staffEmail}
<div class="rounded-md bg-sidebar-accent px-2.5 py-2" data-admin-chrome="staff">
<p
class="truncate text-[11px] font-medium text-sidebar-accent-foreground"
title={staffEmail}
>
{staffEmail}
</p>
<p class="mt-0.5 text-[10px] uppercase tracking-wider text-sidebar-foreground/60">
{staffLabel}
</p>
</div>
{/if}
<div class={footerActionClass("justify-between pr-1.5")}>
<span class="min-w-0 truncate">{i18n.t("admin.chrome.uiLanguage")}</span>
<LocaleSwitcher
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-sidebar-foreground/80 transition hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
/>
</div>
<div class="{footerActionClass("justify-between pr-1.5")} max-lg:hidden">
<span class="min-w-0 truncate">{theme.isDark ? i18n.t("theme.darkMode") : i18n.t("theme.lightMode")}</span>
<ThemeToggle
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-sidebar-foreground/80 transition hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
/>
</div>
<a
href="/dashboard"
class={footerActionClass()}
onclick={() => adminNavUi.closeMobile()}
>
<ArrowLeft class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{i18n.t("admin.chrome.backToApp")}
</a>
<button
type="button"
class={footerActionClass("disabled:opacity-60")}
onclick={() => void logout()}
disabled={loggingOut}
aria-label={i18n.t("header.signOut")}
>
<LogOut class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{loggingOut ? i18n.t("header.signingOut") : i18n.t("header.signOut")}
</button>
</div>
</div>
</aside>
@@ -0,0 +1,100 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
type SeriesPoint = { label: string; value: number; secondary?: number };
let {
points,
primaryLabel,
secondaryLabel = "",
emptyMessage,
height = 180
}: {
points: SeriesPoint[];
primaryLabel?: string;
secondaryLabel?: string;
emptyMessage?: string;
height?: number;
} = $props();
const resolvedPrimary = $derived(primaryLabel ?? i18n.t("admin.charts.primaryDefault"));
const resolvedEmpty = $derived(emptyMessage ?? i18n.t("admin.charts.seriesEmpty"));
const maxValue = $derived(
Math.max(1, ...points.map((p) => Math.max(p.value, p.secondary ?? 0)))
);
const hasData = $derived(points.some((p) => p.value > 0 || (p.secondary ?? 0) > 0));
const showSecondary = $derived(Boolean(secondaryLabel) && points.some((p) => (p.secondary ?? 0) > 0));
function barHeight(v: number): number {
return Math.max(v > 0 ? 2 : 0, Math.round((v / maxValue) * (height - 28)));
}
</script>
<div class="w-full">
{#if !hasData}
<div
class="flex items-center justify-center rounded-lg border border-dashed border-border bg-muted/30 text-sm text-muted-foreground"
style="height: {height}px"
>
{resolvedEmpty}
</div>
{:else}
<div class="mb-2.5 flex flex-wrap items-center 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-primary" aria-hidden="true"></span>
{resolvedPrimary}
</span>
{#if showSecondary}
<span class="inline-flex items-center gap-1.5">
<span class="h-2.5 w-2.5 rounded-sm bg-chart-secondary" aria-hidden="true"></span>
{secondaryLabel}
</span>
{/if}
</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: {height}px"
role="img"
aria-label={i18n.t("admin.charts.seriesAria", { label: resolvedPrimary })}
>
{#each points as point}
{@const h1 = barHeight(point.value)}
{@const h2 = barHeight(point.secondary ?? 0)}
<div class="group relative flex min-w-[6px] flex-1 flex-col items-center justify-end gap-0.5">
<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"
>
{point.label}: {point.value.toLocaleString()}
{#if showSecondary}
· {(point.secondary ?? 0).toLocaleString()}
{/if}
</div>
{#if showSecondary}
<div class="flex w-full items-end justify-center gap-px" style="height: {height - 28}px">
<div
class="w-[45%] rounded-t-sm bg-chart-primary/90 transition-opacity group-hover:opacity-100"
style="height: {h1}px"
title={String(point.value)}
></div>
<div
class="w-[45%] rounded-t-sm bg-chart-secondary/90 transition-opacity group-hover:opacity-100"
style="height: {h2}px"
title={String(point.secondary ?? 0)}
></div>
</div>
{:else}
<div
class="w-full max-w-[14px] rounded-t-sm bg-chart-primary/90"
style="height: {h1}px"
title={String(point.value)}
></div>
{/if}
</div>
{/each}
</div>
<div class="mt-1.5 flex justify-between text-[10px] tabular-nums text-muted-foreground">
<span>{points[0]?.label ?? ""}</span>
<span>{points[points.length - 1]?.label ?? ""}</span>
</div>
{/if}
</div>
@@ -0,0 +1,63 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
type StatusEntry = { label: string; value: number; color?: string };
let {
entries,
emptyMessage,
height = 160
}: {
entries: StatusEntry[];
emptyMessage?: string;
height?: number;
} = $props();
const resolvedEmpty = $derived(emptyMessage ?? i18n.t("admin.charts.statusEmpty"));
const palette = [
"bg-chart-sky",
"bg-chart-emerald",
"bg-chart-amber",
"bg-chart-red",
"bg-chart-violet",
"bg-chart-slate"
];
const sorted = $derived(
[...entries]
.filter((e) => Number(e.value) > 0)
.sort((a, b) => b.value - a.value)
);
const total = $derived(sorted.reduce((sum, e) => sum + e.value, 0));
const max = $derived(Math.max(1, ...sorted.map((e) => e.value)));
</script>
{#if sorted.length === 0 || total === 0}
<div
class="flex items-center justify-center rounded-lg border border-dashed border-border bg-muted/30 text-sm text-muted-foreground"
style="height: {height}px"
>
{resolvedEmpty}
</div>
{:else}
<div class="space-y-2.5" role="img" aria-label={i18n.t("admin.charts.statusAria")}>
{#each sorted as entry, i}
{@const pct = Math.round((entry.value / total) * 100)}
{@const bar = Math.max(entry.value > 0 ? 4 : 0, Math.round((entry.value / max) * 100))}
<div class="grid grid-cols-[6.5rem_1fr_auto] items-center gap-2.5 text-sm sm:grid-cols-[7.5rem_1fr_auto]">
<span class="truncate font-medium capitalize text-foreground">{entry.label}</span>
<div class="h-2.5 overflow-hidden rounded-full bg-chart-track">
<div
class="h-full rounded-full {entry.color ?? palette[i % palette.length]}"
style="width: {bar}%"
title="{entry.value.toLocaleString()} ({pct}%)"
></div>
</div>
<span class="min-w-[4.75rem] text-right text-xs tabular-nums text-muted-foreground">
{entry.value.toLocaleString()} · {pct}%
</span>
</div>
{/each}
</div>
{/if}
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
let {
tone = "error",
message,
id
}: {
tone?: "error" | "success" | "info";
message: string;
id?: string;
} = $props();
const classes = $derived(
tone === "success"
? "border-chart-green/40 bg-card-green text-foreground"
: tone === "info"
? "border-primary/30 bg-card-blue text-foreground"
: "border-destructive/50 bg-destructive/5 text-destructive"
);
const live = $derived(tone === "error" ? "assertive" : "polite");
const role = $derived(tone === "error" ? "alert" : "status");
</script>
{#if message}
<div
{id}
class="relative mb-4 w-full rounded-lg border px-4 py-3 text-sm {classes}"
{role}
aria-live={live}
aria-atomic="true"
>
{message}
</div>
{/if}
@@ -0,0 +1,33 @@
<script lang="ts">
import { browser } from "$app/environment";
import { afterNavigate } from "$app/navigation";
import {
ensureConsentDefaults,
loadGoogleTagManager,
trackPageview
} from "$lib/analytics";
import { cookieConsent } from "$lib/cookie-consent.svelte";
if (browser) {
ensureConsentDefaults();
loadGoogleTagManager();
}
/** Session latch: skip the initial granted state (afterNavigate covers first paint). */
let sawAnalyticsGranted = browser ? cookieConsent.analyticsGranted : false;
$effect(() => {
if (!browser) return;
const granted = cookieConsent.analyticsGranted;
if (granted && !sawAnalyticsGranted) {
trackPageview(`${window.location.pathname}${window.location.search}`);
}
sawAnalyticsGranted = granted;
});
afterNavigate(({ to }) => {
if (!browser || !to || !cookieConsent.analyticsGranted) return;
const path = `${to.url.pathname}${to.url.search}`;
trackPageview(path);
});
</script>
@@ -0,0 +1,43 @@
<script lang="ts">
import type { BillingRecovery } from "$lib/billing-display";
import { i18n } from "$lib/i18n";
import { buttonClasses } from "$lib/components/ui";
let {
recovery
}: {
recovery: BillingRecovery;
} = $props();
const tone = $derived(
recovery.tone === "danger"
? "border-destructive/40 bg-destructive/5 text-foreground"
: "border-chart-amber/50 bg-chart-amber/15 text-foreground"
);
/** Sticky chrome links to billing; Portal open happens on the billing page. */
const href = $derived(recovery.kind === "past_due" ? "/billing" : recovery.primaryHref);
const label = $derived(
recovery.kind === "past_due" && recovery.openPortal
? i18n.t("billing.recovery.goToBilling")
: recovery.primaryLabel
);
</script>
<div
class="w-full border-b px-4 py-3 {tone}"
role="status"
aria-live="polite"
data-testid="billing-recovery-banner"
data-kind={recovery.kind}
>
<div
class="mx-auto flex max-w-6xl flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
>
<div class="space-y-0.5">
<p class="text-sm font-semibold">{recovery.title}</p>
<p class="text-sm text-muted-foreground">{recovery.message}</p>
</div>
<a href={href} class={buttonClasses("default", "sm", "shrink-0")}>{label}</a>
</div>
</div>
@@ -0,0 +1,29 @@
<script lang="ts">
let { size = 32 }: { size?: number } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
class="shrink-0"
>
<rect width="32" height="32" rx="8" fill="hsl(247 97% 65%)" />
<path
d="M9 16.5L14.2 21.5L23 11"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8 11h16M8 21h7"
stroke="white"
stroke-width="1.5"
stroke-linecap="round"
opacity="0.45"
/>
</svg>
@@ -0,0 +1,398 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { tick } from "svelte";
import {
Package,
FileText,
Clock,
Share2,
Settings,
DollarSign,
Search,
LifeBuoy,
Store,
Megaphone,
CalendarDays,
Palette,
Star,
Mail,
Bot
} from "@lucide/svelte";
import { navUi } from "$lib/nav-ui.svelte";
import { activateFocusTrap, type FocusTrapHandle } from "$lib/a11y/focus-trap";
import { featureKeyForHref } from "$lib/plan-capabilities";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { filterAndRankPaletteItems } from "$lib/command-palette-search";
import { i18n } from "$lib/i18n";
type PaletteItem = {
id: string;
label: string;
href: string;
keywords: string;
icon: typeof Package;
feature: string;
};
/** Destinations — hrefs/labels align with Nav; features from NAV_FEATURE_BY_HREF. */
const destinationsAll = $derived.by((): PaletteItem[] => {
const items: Omit<PaletteItem, "feature">[] = [
{
id: "dashboard",
label: i18n.t("nav.dashboard"),
href: "/dashboard",
keywords: "dashboard home overview",
icon: Package
},
{
id: "products",
label: i18n.t("nav.products"),
href: "/products?status=completed&type=processed&page=1&sortBy=updatedAt&sortOrder=desc",
keywords: "products catalog items",
icon: Package
},
{
id: "categories",
label: i18n.t("nav.categories"),
href: "/categories",
keywords: "categories taxonomy",
icon: Package
},
{
id: "attributes",
label: i18n.t("nav.attributes"),
href: "/attributes",
keywords: "attributes specs fields",
icon: Package
},
{
id: "standard-fields",
label: i18n.t("nav.fields"),
href: "/standard-fields",
keywords: "standard fields mapping columns",
icon: Settings
},
{
id: "feeds",
label: i18n.t("nav.feeds"),
href: "/feeds",
keywords: "feeds import sources",
icon: FileText
},
{
id: "export",
label: i18n.t("nav.exports"),
href: "/export-feeds",
keywords: "export feeds share output",
icon: Share2
},
{
id: "stores",
label: i18n.t("nav.stores"),
href: "/stores",
keywords: "stores shopify woocommerce connections hub",
icon: Store
},
{
id: "processing",
label: i18n.t("nav.jobs"),
href: "/processing",
keywords: "processing jobs tasks queue background",
icon: Clock
},
{
id: "campaigns",
label: i18n.t("nav.campaigns"),
href: "/campaigns",
keywords: "campaigns marketing email blast",
icon: Megaphone
},
{
id: "calendar",
label: i18n.t("nav.calendar"),
href: "/marketing/calendar",
keywords: "calendar content marketing schedule seasonal",
icon: CalendarDays
},
{
id: "seo",
label: i18n.t("nav.seo"),
href: "/seo",
keywords: "seo search optimization meta",
icon: Search
},
{
id: "brand",
label: i18n.t("nav.brand"),
href: "/brand",
keywords: "brand kit logo voice identity",
icon: Palette
},
{
id: "reviews",
label: i18n.t("nav.reviews"),
href: "/woocommerce?tab=reviews",
keywords: "reviews ratings woocommerce feedback",
icon: Star
},
{
id: "ai",
label: i18n.t("nav.ai"),
href: "/integrations/ai",
keywords: "ai integrations prompts openai",
icon: Bot
},
{
id: "email",
label: i18n.t("nav.email"),
href: "/integrations/email",
keywords: "email integrations smtp mail provider",
icon: Mail
},
{
id: "billing",
label: i18n.t("nav.billing"),
href: "/billing",
keywords: "billing plan credits subscription usage",
icon: DollarSign
},
{
id: "settings",
label: i18n.t("nav.settings"),
href: "/settings",
keywords: "settings account preferences",
icon: Settings
},
{
id: "support",
label: i18n.t("nav.support"),
href: "/support",
keywords: "support help ticket contact staff reply",
icon: LifeBuoy
}
];
return items.flatMap((item) => {
const feature = featureKeyForHref(item.href);
if (!feature) return [];
return [{ ...item, feature }];
});
});
const destinations = $derived(
destinationsAll.filter((item) => {
const gateSection = item.feature.split(".")[0] ?? "";
if (
gateSection &&
gateSection !== "shell" &&
gateSection !== "capability" &&
!planCapabilities.sectionEnabled(gateSection)
) {
return false;
}
return planCapabilities.can(item.feature);
})
);
let query = $state("");
let activeIndex = $state(0);
let panelEl = $state<HTMLDivElement | null>(null);
let inputEl = $state<HTMLInputElement | null>(null);
let listEl = $state<HTMLDivElement | null>(null);
let trap: FocusTrapHandle | null = null;
let announce = $state("");
const filtered = $derived(filterAndRankPaletteItems(query, destinations));
$effect(() => {
filtered;
activeIndex = 0;
});
$effect(() => {
if (!navUi.commandPaletteOpen) {
announce = "";
trap?.deactivate();
trap = null;
query = "";
activeIndex = 0;
return;
}
let cancelled = false;
void tick().then(() => {
if (cancelled || !panelEl) return;
trap?.deactivate();
trap = activateFocusTrap(panelEl, {
initialFocus: inputEl,
restoreFocus: true
});
});
return () => {
cancelled = true;
trap?.deactivate();
trap = null;
};
});
$effect(() => {
if (!navUi.commandPaletteOpen) return;
const n = filtered.length;
const q = query.trim();
if (!q) {
announce =
n === 1
? i18n.t("commandPalette.announceIdleOne", { count: n })
: i18n.t("commandPalette.announceIdleMany", { count: n });
return;
}
announce =
n === 0
? i18n.t("commandPalette.announceNone")
: n === 1
? i18n.t("commandPalette.announceMatchOne", { count: n })
: i18n.t("commandPalette.announceMatchMany", { count: n });
});
$effect(() => {
if (!navUi.commandPaletteOpen || !listEl) return;
const active = listEl.querySelector<HTMLElement>(`[data-palette-index="${activeIndex}"]`);
active?.scrollIntoView({ block: "nearest" });
});
function close() {
navUi.closeCommandPalette();
}
function selectItem(item: PaletteItem) {
close();
void goto(item.href);
}
function onPanelKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
if (filtered.length === 0) return;
activeIndex = (activeIndex + 1) % filtered.length;
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
if (filtered.length === 0) return;
activeIndex = (activeIndex - 1 + filtered.length) % filtered.length;
return;
}
if (event.key === "Enter") {
event.preventDefault();
const item = filtered[activeIndex];
if (item) selectItem(item);
}
}
function onGlobalKeydown(event: KeyboardEvent) {
if (navUi.commandPaletteOpen && event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close();
return;
}
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "k") return;
if (event.altKey || event.shiftKey) return;
event.preventDefault();
navUi.toggleCommandPalette();
}
</script>
<svelte:window onkeydown={onGlobalKeydown} />
<div class="sr-only" aria-live="polite" aria-atomic="true">{announce}</div>
{#if navUi.commandPaletteOpen}
<div class="fixed inset-0 z-[60] bg-black/80" aria-hidden="true"></div>
<div class="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[min(20vh,8rem)] sm:p-6">
<button
type="button"
class="absolute inset-0 cursor-default"
aria-label={i18n.t("commandPalette.closeAria")}
tabindex="-1"
onclick={close}
></button>
<div
bind:this={panelEl}
role="dialog"
aria-modal="true"
aria-label={i18n.t("commandPalette.ariaLabel")}
tabindex="-1"
class="relative z-[61] flex w-full max-w-lg flex-col overflow-hidden rounded-lg border bg-background shadow-lg"
onkeydown={onPanelKeydown}
onclick={(event) => event.stopPropagation()}
>
<div class="flex items-center gap-2 border-b px-3">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<input
bind:this={inputEl}
bind:value={query}
type="text"
role="combobox"
placeholder={i18n.t("commandPalette.placeholder")}
aria-label={i18n.t("commandPalette.searchAria")}
aria-controls="command-palette-list"
aria-expanded="true"
aria-haspopup="listbox"
aria-autocomplete="list"
aria-activedescendant={filtered[activeIndex]
? `command-palette-option-${filtered[activeIndex].id}`
: undefined}
autocomplete="off"
autocorrect="off"
spellcheck="false"
class="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0"
/>
<kbd
class="hidden shrink-0 rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline"
>
esc
</kbd>
</div>
<div
bind:this={listEl}
id="command-palette-list"
role="listbox"
aria-label={i18n.t("commandPalette.destinationsAria")}
class="max-h-[min(50vh,20rem)] overflow-y-auto p-1"
>
{#if filtered.length === 0}
<p class="px-3 py-6 text-center text-sm text-muted-foreground">
{i18n.t("commandPalette.noMatches")}
</p>
{:else}
{#each filtered as item, index (item.id)}
{@const Icon = item.icon}
{@const active = index === activeIndex}
<button
type="button"
role="option"
id="command-palette-option-{item.id}"
data-palette-index={index}
tabindex="-1"
aria-selected={active}
class="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {active
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent/60'}"
onmouseenter={() => (activeIndex = index)}
onclick={() => selectItem(item)}
>
<Icon class="h-4 w-4 shrink-0 opacity-70" aria-hidden="true" />
<span class="font-medium">{item.label}</span>
</button>
{/each}
{/if}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,103 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { Building2, Check, ChevronDown } from "@lucide/svelte";
import { api } from "$lib/api";
import { notifyApiError } from "$lib/notify";
import type { Company } from "$lib/types";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from "$lib/components/ui";
let {
companies = [],
activeCompanyId = "",
activeCompanyName = ""
}: {
companies?: Company[];
activeCompanyId?: string;
activeCompanyName?: string;
} = $props();
let switching = $state(false);
let menuOpen = $state(false);
const list = $derived(
[...companies].sort((a, b) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }))
);
const label = $derived.by(() => {
if (activeCompanyName.trim()) return activeCompanyName.trim();
const match = list.find((c) => c.id === activeCompanyId);
return match?.name?.trim() || i18n.t("companySwitcher.select");
});
const canSwitch = $derived(list.length > 1);
async function selectCompany(companyId: string) {
if (!companyId || companyId === activeCompanyId || switching) return;
switching = true;
try {
await api("/api/auth/select-company", {
method: "POST",
body: { company_id: companyId }
});
// Full reload so every page re-fetches tenant-scoped data.
window.location.assign(window.location.pathname + window.location.search);
} catch (err) {
switching = false;
notifyApiError(err, i18n.t("toast.requestFailed"), { title: i18n.t("toast.switcher.companyFailed") });
}
}
</script>
{#if list.length > 0}
{#if canSwitch}
<DropdownMenu bind:open={menuOpen} align="end">
{#snippet trigger({ open, toggle })}
<button
type="button"
class="inline-flex h-10 w-full max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 text-xs font-medium text-foreground transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60 sm:h-8 sm:px-2.5"
onclick={toggle}
disabled={switching}
aria-label={i18n.t("companySwitcher.switchAria", { label })}
aria-expanded={open}
aria-haspopup="menu"
data-tour="company-switcher"
title={label}
>
<Building2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 truncate">{switching ? i18n.t("switcher.switching") : label}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
{/snippet}
<DropdownMenuLabel>{i18n.t("companySwitcher.companies")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{#each list as company (company.id)}
<DropdownMenuItem
onclick={() => void selectCompany(company.id)}
class={company.id === activeCompanyId ? "bg-accent/60" : ""}
>
{#if company.id === activeCompanyId}
<Check class="h-3.5 w-3.5 text-primary" />
{:else}
<span class="inline-block h-3.5 w-3.5"></span>
{/if}
<span class="truncate">{company.name}</span>
</DropdownMenuItem>
{/each}
</DropdownMenu>
{:else}
<div
class="inline-flex h-8 max-w-[12rem] items-center gap-1.5 rounded-md border border-transparent px-2 text-xs font-medium text-muted-foreground sm:max-w-[18rem] sm:px-2.5"
data-tour="company-switcher"
title={label}
>
<Building2 class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{label}</span>
</div>
{/if}
{/if}
@@ -0,0 +1,147 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { trackEvent } from "$lib/analytics";
import { Badge, Button, Label, Select } from "$lib/components/ui";
import {
CONTENT_LANGUAGES,
parseContentLanguage,
type ContentLanguage
} from "$lib/content-languages";
import { Plus, X } from "@lucide/svelte";
let {
value = $bindable(""),
languages = $bindable([] as string[]),
available = CONTENT_LANGUAGES,
configured = [] as string[],
hasOverride = (_code: string) => false,
primary = "",
allowAdd = true,
class: className = "",
onChange
}: {
value?: string;
/** Active language tabs (editing set). */
languages?: string[];
available?: ContentLanguage[];
/** Company content languages (preferred order). */
configured?: string[];
hasOverride?: (code: string) => boolean;
primary?: string;
allowAdd?: boolean;
class?: string;
onChange?: (code: string) => void;
} = $props();
let addLang = $state("");
const primaryCode = $derived(parseContentLanguage(primary || configured[0]));
const labelFor = (code: string) =>
available.find((l) => l.value === code)?.label ?? code.toUpperCase();
const tabs = $derived.by(() => {
const set = new Set<string>();
const out: string[] = [];
const push = (code: string) => {
const c = parseContentLanguage(code, "");
if (!c || set.has(c)) return;
set.add(c);
out.push(c);
};
push(primaryCode);
for (const c of configured) push(c);
for (const c of languages) push(c);
if (value) push(value);
return out;
});
const addable = $derived(
available.filter((l) => !tabs.includes(l.value)).map((l) => l.value)
);
$effect(() => {
if (!value && tabs.length) {
value = tabs[0];
} else if (value && !tabs.includes(value) && tabs.length) {
value = tabs[0];
}
});
function select(code: string) {
value = code;
trackEvent("content_language_changed", { action: "select", language: code });
onChange?.(code);
}
function add() {
const code = parseContentLanguage(addLang, "");
if (!code || tabs.includes(code)) return;
languages = [...languages, code];
value = code;
addLang = "";
trackEvent("content_language_changed", { action: "add", language: code });
onChange?.(code);
}
function remove(code: string) {
if (code === primaryCode) return;
languages = languages.filter((c) => c !== code);
trackEvent("content_language_changed", { action: "remove", language: code });
if (value === code) {
value = primaryCode || tabs.find((c) => c !== code) || "";
onChange?.(value);
}
}
</script>
<div class="space-y-2 {className}">
<Label>{i18n.t("contentLang.switcherLabel")}</Label>
<div class="flex flex-wrap items-center gap-2">
{#each tabs as code}
<Button
type="button"
size="sm"
variant={value === code ? "default" : "outline"}
onclick={() => select(code)}
>
{labelFor(code)}
{#if hasOverride(code)}
<Badge variant="secondary" class="ml-1">{i18n.t("contentLang.hasOverride")}</Badge>
{/if}
{#if code === primaryCode}
<span class="ml-1 text-xs opacity-70">{i18n.t("contentLang.primary")}</span>
{/if}
</Button>
{#if allowAdd && code !== primaryCode && languages.includes(code)}
<Button
type="button"
size="sm"
variant="ghost"
class="h-8 w-8 p-0"
onclick={() => remove(code)}
aria-label={i18n.t("contentLang.removeLanguage", { lang: labelFor(code) })}
>
<X class="h-3.5 w-3.5" />
</Button>
{/if}
{/each}
{#if allowAdd && addable.length}
<div class="flex items-center gap-1">
<Select
id="add-content-lang"
bind:value={addLang}
class="h-8 w-auto min-w-[9rem]"
>
<option value="">{i18n.t("contentLang.addLanguage")}</option>
{#each addable as code}
<option value={code}>{labelFor(code)}</option>
{/each}
</Select>
<Button type="button" size="sm" variant="outline" disabled={!addLang} onclick={add}>
<Plus class="h-3.5 w-3.5" />
{i18n.t("contentLang.add")}
</Button>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import { browser } from "$app/environment";
import { buttonClasses } from "$lib/components/ui";
import Checkbox from "$lib/components/ui/Checkbox.svelte";
import { cookieConsent } from "$lib/cookie-consent.svelte";
import { i18n } from "$lib/i18n";
let analyticsDraft = $state(false);
let marketingDraft = $state(false);
/** Keep banner clear of fixed sidebars so chrome controls (theme) stay clickable. */
let sidebarInset = $state(false);
$effect(() => {
if (!cookieConsent.bannerOpen) return;
analyticsDraft = cookieConsent.prefs?.analytics ?? false;
marketingDraft = cookieConsent.prefs?.marketing ?? false;
});
$effect(() => {
if (!browser || !cookieConsent.bannerOpen) {
sidebarInset = false;
return;
}
sidebarInset = Boolean(
document.getElementById("app-shell") || document.getElementById("admin-shell")
);
});
function saveCustom() {
cookieConsent.saveCustom({
analytics: analyticsDraft,
marketing: marketingDraft
});
}
</script>
{#if cookieConsent.bannerOpen}
<div
class="fixed inset-x-0 bottom-0 z-[100] border-t border-border bg-background/95 p-3 shadow-lg backdrop-blur-md sm:p-4 {sidebarInset
? 'lg:left-64'
: ''}"
role="dialog"
aria-modal="false"
aria-labelledby="cookie-consent-title"
aria-describedby="cookie-consent-desc"
data-testid="cookie-consent-banner"
>
<div class="mx-auto flex max-w-[1216px] flex-col gap-3 sm:gap-4">
<div class="min-w-0 space-y-1 sm:space-y-2">
<h2 id="cookie-consent-title" class="text-sm font-semibold text-text sm:text-base">
{i18n.t("consent.title")}
</h2>
<p id="cookie-consent-desc" class="text-xs leading-snug text-text-muted sm:text-sm sm:leading-normal">
{i18n.t("consent.description")}
<a href="/cookies" class="text-link underline-offset-2 hover:underline">
{i18n.t("consent.policyLink")}
</a>
</p>
</div>
{#if cookieConsent.customizeOpen}
<div class="grid gap-3 rounded-md border border-border bg-surface/40 p-3 sm:grid-cols-3">
<div class="space-y-1">
<p class="text-sm font-medium text-text">{i18n.t("consent.necessary")}</p>
<p class="text-xs text-text-muted">{i18n.t("consent.necessaryDesc")}</p>
<p class="text-xs font-medium text-text-muted">{i18n.t("consent.alwaysOn")}</p>
</div>
<label class="flex cursor-pointer items-start gap-2">
<Checkbox bind:checked={analyticsDraft} class="mt-0.5" />
<span class="min-w-0">
<span class="block text-sm font-medium text-text">{i18n.t("consent.analytics")}</span>
<span class="block text-xs text-text-muted">{i18n.t("consent.analyticsDesc")}</span>
</span>
</label>
<label class="flex cursor-pointer items-start gap-2">
<Checkbox bind:checked={marketingDraft} class="mt-0.5" />
<span class="min-w-0">
<span class="block text-sm font-medium text-text">{i18n.t("consent.marketing")}</span>
<span class="block text-xs text-text-muted">{i18n.t("consent.marketingDesc")}</span>
</span>
</label>
</div>
{/if}
<div class="grid grid-cols-2 gap-2 sm:flex sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
{#if cookieConsent.customizeOpen}
<button
type="button"
class="{buttonClasses("default", "sm")} col-span-2 sm:col-span-1"
onclick={saveCustom}
>
{i18n.t("consent.save")}
</button>
{:else}
<button
type="button"
class="{buttonClasses("ghost", "sm")} justify-self-start"
onclick={() => cookieConsent.setCustomizeOpen(true)}
>
{i18n.t("consent.customize")}
</button>
{/if}
<button
type="button"
class="{buttonClasses("outline", "sm")} {cookieConsent.customizeOpen ? 'col-span-1' : ''}"
onclick={() => cookieConsent.rejectNonEssential()}
>
{i18n.t("consent.rejectNonEssential")}
</button>
<button
type="button"
class="{buttonClasses("default", "sm")} {cookieConsent.customizeOpen ? 'col-span-1' : 'col-span-2 sm:col-span-1'}"
onclick={() => cookieConsent.acceptAll()}
>
{i18n.t("consent.acceptAll")}
</button>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,80 @@
<script lang="ts">
import { cutoverReadiness } from "$lib/cutover-readiness.svelte";
import { i18n } from "$lib/i18n";
import { formatCredits } from "$lib/utils";
const show = $derived(cutoverReadiness.hasIssues);
const c = $derived(cutoverReadiness.counts);
</script>
{#if show && c}
<div
class="w-full border-b border-chart-amber/50 bg-chart-amber/15 px-4 py-3 text-foreground"
role="status"
aria-live="polite"
data-testid="cutover-readiness-banner"
>
<div class="mx-auto flex max-w-6xl flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div class="space-y-1">
<p class="text-sm font-semibold">{i18n.t("admin.readiness.title")}</p>
<p class="text-sm text-muted-foreground">{i18n.t("admin.readiness.detail")}</p>
<ul class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm text-foreground">
{#if c.must_set_password > 0}
<li>
{i18n.t("admin.readiness.mustSetPassword", {
count: formatCredits(c.must_set_password)
})}
</li>
{/if}
{#if c.companies_without_admin > 0}
<li>
{i18n.t("admin.readiness.withoutAdmin", {
count: formatCredits(c.companies_without_admin)
})}
</li>
{/if}
{#if c.companies_without_plan > 0}
<li>
{i18n.t("admin.readiness.withoutPlan", {
count: formatCredits(c.companies_without_plan)
})}
</li>
{/if}
{#if c.companies_without_api_keys > 0}
<li>
{i18n.t("admin.readiness.withoutApiKeys", {
count: formatCredits(c.companies_without_api_keys)
})}
</li>
{/if}
</ul>
</div>
<nav
class="flex flex-wrap items-center gap-2 text-sm"
aria-label={i18n.t("admin.readiness.linksAria")}
>
<a class="underline-offset-2 hover:underline" href="/admin/users"
>{i18n.t("admin.readiness.linkUsers")}</a
>
<a class="underline-offset-2 hover:underline" href="/admin/billing"
>{i18n.t("admin.readiness.linkBilling")}</a
>
<a class="underline-offset-2 hover:underline" href="/admin/settings"
>{i18n.t("admin.readiness.linkSettings")}</a
>
<a
class="underline-offset-2 hover:underline"
href="/admin/users?tab=companies&without_api_keys=1"
data-testid="cutover-readiness-api-keys"
>{i18n.t("admin.readiness.linkApiKeys")}</a
>
<a
class="underline-offset-2 hover:underline"
href="/admin/support?status=open&scope=all&q=Hypercare"
data-testid="cutover-readiness-hypercare-queue"
>{i18n.t("hypercare.report.adminTriage")}</a
>
</nav>
</div>
</div>
{/if}
@@ -0,0 +1,80 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { goto } from "$app/navigation";
import { api } from "$lib/api";
import { trackEvent } from "$lib/analytics";
import { navUi } from "$lib/nav-ui.svelte";
import { commandPaletteShortcutLabelFromEnv } from "$lib/command-palette-search";
import { i18n } from "$lib/i18n";
import { LogOut, Menu, Search } from "@lucide/svelte";
/** App chrome header — ThemeToggle is slotted from +layout (shared $lib/theme). */
let { children }: { children?: Snippet } = $props();
let loggingOut = $state(false);
const paletteShortcut = commandPaletteShortcutLabelFromEnv();
async function logout() {
if (loggingOut) return;
loggingOut = true;
try {
await api("/api/auth/logout", { method: "POST" });
} catch {
/* session may already be gone */
} finally {
trackEvent("logout");
await goto("/login");
loggingOut = false;
}
}
</script>
<header
class="sticky top-0 z-[110] border-b border-border bg-background/95 text-text shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-background/80"
>
<div class="flex h-14 min-w-0 items-center gap-2 px-2.5 sm:h-16 sm:gap-3 sm:px-6">
<button
type="button"
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-border text-text transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:hidden"
aria-label={navUi.mobileOpen ? i18n.t("nav.close") : i18n.t("nav.open")}
aria-expanded={navUi.mobileOpen}
aria-controls="app-sidebar"
data-tour="nav-mobile-toggle"
data-testid="nav-mobile-toggle"
onclick={() => navUi.toggleMobile()}
>
<Menu class="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
class="inline-flex h-10 min-w-0 items-center gap-1.5 rounded-md border border-border px-2.5 text-sm text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:max-w-xs sm:flex-1"
aria-label={i18n.t("nav.commandPalette")}
aria-keyshortcuts="Control+K Meta+K"
data-tour="header-command-palette"
onclick={() => navUi.openCommandPalette()}
>
<Search class="h-4 w-4 shrink-0" aria-hidden="true" />
<span class="min-w-0 flex-1 truncate text-left">{i18n.t("nav.commandPaletteLabel")}</span>
<kbd class="hidden shrink-0 rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-text-muted sm:inline"
>{paletteShortcut}</kbd
>
</button>
<div class="flex min-w-0 flex-1 items-center justify-end gap-1.5 sm:gap-2">
{#if children}
{@render children()}
{/if}
<button
type="button"
class="inline-flex h-10 w-10 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 disabled:opacity-60 sm:h-8 sm:w-auto sm:gap-1.5 sm:px-2 sm:text-xs sm:font-medium"
onclick={logout}
disabled={loggingOut}
aria-label={i18n.t("header.signOut")}
>
<LogOut class="h-3.5 w-3.5" aria-hidden="true" />
<span class="hidden lg:inline"
>{loggingOut ? i18n.t("header.signingOut") : i18n.t("header.signOut")}</span
>
</button>
</div>
</div>
</header>
@@ -0,0 +1,158 @@
<script lang="ts">
import { formatCredits } from "$lib/utils";
import {
formatCreditsRemaining,
isEnterprisePlan,
isPayAsYouGoPlan,
type PlanLike
} from "$lib/billing-display";
import { i18n } from "$lib/i18n";
import { FolderTree, ListTree, Package, Rss, Sparkles } from "@lucide/svelte";
let {
stats,
credits,
plan = null
}: {
stats: {
products: number;
categories: number;
attributes: number;
feeds: number;
processed?: number;
unprocessed?: number;
};
credits: { usedCredits: string | number; remainingCredits: string | number };
plan?: PlanLike | null;
} = $props();
const remainingNum = $derived(
typeof credits.remainingCredits === "number"
? credits.remainingCredits
: Number(credits.remainingCredits)
);
const remainingLabel = $derived(formatCreditsRemaining(remainingNum, plan));
const isPayg = $derived(isPayAsYouGoPlan(plan));
const isEnterprise = $derived(isEnterprisePlan(plan));
const productsHint = $derived.by(() => {
const processed = stats.processed;
const unprocessed = stats.unprocessed;
if (
typeof processed === "number" &&
typeof unprocessed === "number" &&
(processed > 0 || unprocessed > 0)
) {
return i18n.t("stats.productsHint", {
processed: processed.toLocaleString(),
unprocessed: unprocessed.toLocaleString()
});
}
return i18n.t("stats.openCatalog");
});
const cards = $derived([
{
href: "/products",
label: i18n.t("stats.products"),
value: stats.products,
hint: productsHint,
iconWrap: "bg-card-blue text-text",
icon: Package,
tour: "stats-products"
},
{
href: "/categories",
label: i18n.t("stats.categories"),
value: stats.categories,
hint: i18n.t("stats.catalogTree"),
iconWrap: "bg-muted text-text",
icon: FolderTree,
tour: "stats-categories"
},
{
href: "/attributes",
label: i18n.t("stats.attributes"),
value: stats.attributes,
hint: i18n.t("stats.attributeLibrary"),
iconWrap: "bg-muted text-text",
icon: ListTree,
tour: "stats-attributes"
},
{
href: "/feeds",
label: i18n.t("stats.feeds"),
value: stats.feeds,
hint: i18n.t("stats.importSources"),
iconWrap: "bg-muted text-text",
icon: Rss,
tour: "stats-feeds"
}
]);
</script>
<div
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5"
data-tour="dashboard-stats"
>
{#each cards as card (card.tour)}
<a
href={card.href}
class="group rounded-xl border border-border bg-surface 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={card.tour}
>
<div class="flex items-start justify-between gap-2 p-4 pb-1">
<div class="min-w-0">
<p class="text-xs font-medium text-text-muted">{card.label}</p>
<p class="mt-1 text-2xl font-semibold tracking-tight text-text">
{card.value.toLocaleString()}
</p>
</div>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg {card.iconWrap}"
aria-hidden="true"
>
<card.icon class="h-4 w-4" />
</div>
</div>
<div class="px-4 pb-3 text-xs text-text-muted group-hover:text-text/80">
{card.hint}
</div>
</a>
{/each}
<a
href="/billing"
class="group rounded-xl border border-border bg-surface 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="stats-credits"
>
<div class="flex items-start justify-between gap-2 p-4 pb-1">
<div class="min-w-0">
<p class="text-xs font-medium text-text-muted">{i18n.t("stats.credits")}</p>
<p class="mt-1 text-2xl font-semibold tracking-tight text-text">
{#if isPayg}
{i18n.t("stats.payAsYouGo")}
{:else}
{remainingLabel}
{/if}
</p>
</div>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-success/15 text-success"
aria-hidden="true"
>
<Sparkles class="h-4 w-4" />
</div>
</div>
<div class="px-4 pb-3 text-xs text-text-muted group-hover:text-text/80">
{#if isPayg}
{i18n.t("stats.walletBilling")}
{:else if isEnterprise}
{i18n.t("stats.enterpriseBilling")}
{:else}
{i18n.t("stats.usedBilling", { used: formatCredits(credits.usedCredits) })}
{/if}
</div>
</a>
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
toolbar,
footer,
children,
class: className = ""
}: {
toolbar?: Snippet;
footer?: Snippet;
children: Snippet;
class?: string;
} = $props();
</script>
<div class="overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm {className}">
{#if toolbar}
<div class="border-b border-border bg-card p-4">
{@render toolbar()}
</div>
{/if}
{@render children()}
{#if footer}
{@render footer()}
{/if}
</div>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { i18n } from "$lib/i18n";
let {
title = "",
message,
variant = "default",
children
}: {
title?: string;
message?: string;
/** `forbidden` = permission denied (distinct from no-data). */
variant?: "default" | "forbidden";
children?: Snippet;
} = $props();
const resolvedMessage = $derived(message ?? i18n.t("empty.default.noItems"));
</script>
<div
class="rounded-lg border px-4 py-12 text-center {variant === 'forbidden'
? 'border-solid border-chart-amber/50 bg-chart-amber/15'
: 'border-dashed border-border bg-muted/30 dark:bg-card/60'}"
role="status"
>
{#if title}
<p class="text-sm font-semibold tracking-tight text-foreground">{title}</p>
{/if}
<p class="mx-auto mt-1 max-w-md text-sm leading-relaxed text-muted-foreground">{resolvedMessage}</p>
{#if children}
<div class="mt-5 flex flex-wrap justify-center gap-2">
{@render children()}
</div>
{/if}
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from "svelte";
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";
let {
feature,
mode = "hide",
children
}: {
/** Feature key from the plan-permissions catalog. */
feature: string;
/** hide = omit children; upgrade = show PlanUpgradePanel. */
mode?: "hide" | "upgrade";
children?: Snippet;
} = $props();
const allowed = $derived(planCapabilities.can(feature));
const gate = $derived(
upgradeMessageForFeature(feature, authSession.isCompanyAdmin, {
sectionEnabled: planCapabilities.sectionEnabled(feature.split(".")[0] ?? "")
})
);
</script>
{#if allowed}
{@render children?.()}
{:else if mode === "upgrade"}
<PlanUpgradePanel
title={gate.title}
message={gate.message}
cta={gate.cta}
stillWorks={gate.stillWorks}
featureKey={gate.featureKey}
/>
{/if}
@@ -0,0 +1,124 @@
<script lang="ts">
import StatusBadge from "$lib/components/StatusBadge.svelte";
import {
Button,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "$lib/components/ui";
import { i18n } from "$lib/i18n";
import type { UploadedFile } from "$lib/types";
let {
files = [],
loading = false,
onDelete,
onRefresh
}: {
files?: UploadedFile[];
loading?: boolean;
onDelete?: (file: UploadedFile) => Promise<void> | void;
onRefresh?: () => Promise<void> | void;
} = $props();
let deletingId = $state<string | null>(null);
function rowsLabel(file: UploadedFile): string {
const meta = file.metadata;
if (!meta || typeof meta !== "object") return "—";
const total = meta.total_rows;
if (typeof total === "number") return total.toLocaleString();
const created = typeof meta.created === "number" ? meta.created : 0;
const updated = typeof meta.updated === "number" ? meta.updated : 0;
const skipped = typeof meta.skipped === "number" ? meta.skipped : 0;
const sum = created + updated + skipped;
return sum > 0 ? sum.toLocaleString() : "—";
}
function uploadedAt(file: UploadedFile): string {
if (!file.created_at) return "—";
const d = new Date(file.created_at);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString();
}
async function remove(file: UploadedFile) {
if (!onDelete) return;
deletingId = file.id;
try {
await onDelete(file);
} finally {
deletingId = null;
}
}
</script>
<section class="space-y-4">
<div class="flex items-center justify-between gap-4">
<h2 class="text-lg font-semibold tracking-tight">{i18n.t("files.recentUploads")}</h2>
{#if onRefresh}
<Button variant="outline" size="sm" disabled={loading} onclick={() => void onRefresh?.()}>
{i18n.t("common.refresh")}
</Button>
{/if}
</div>
<div class="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("files.colFileName")}</TableHead>
<TableHead>{i18n.t("files.colKind")}</TableHead>
<TableHead>{i18n.t("files.colUploaded")}</TableHead>
<TableHead>{i18n.t("common.status")}</TableHead>
<TableHead>{i18n.t("files.colRows")}</TableHead>
{#if onDelete}
<TableHead class="w-[100px]">{i18n.t("common.actions")}</TableHead>
{/if}
</TableRow>
</TableHeader>
<TableBody>
{#if loading && files.length === 0}
<TableRow>
<TableCell colspan={onDelete ? 6 : 5} class="h-24 text-center text-muted-foreground">
{i18n.t("files.loading")}
</TableCell>
</TableRow>
{:else if files.length === 0}
<TableRow>
<TableCell colspan={onDelete ? 6 : 5} class="h-24 text-center text-muted-foreground">
{i18n.t("files.empty")}
</TableCell>
</TableRow>
{:else}
{#each files as file (file.id)}
<TableRow>
<TableCell class="font-medium">{file.name}</TableCell>
<TableCell class="capitalize">{file.kind ?? "—"}</TableCell>
<TableCell>{uploadedAt(file)}</TableCell>
<TableCell>
<StatusBadge status={file.status ?? "uploaded"} />
</TableCell>
<TableCell>{rowsLabel(file)}</TableCell>
{#if onDelete}
<TableCell>
<Button
variant="ghost"
size="sm"
disabled={deletingId === file.id}
onclick={() => void remove(file)}
>
{deletingId === file.id ? "…" : i18n.t("common.delete")}
</Button>
</TableCell>
{/if}
</TableRow>
{/each}
{/if}
</TableBody>
</Table>
</div>
</section>
@@ -0,0 +1,48 @@
<script lang="ts">
import EmptyState from "$lib/components/EmptyState.svelte";
import { Button } from "$lib/components/ui";
import { i18n } from "$lib/i18n";
const ASK_COMPANY_ADMIN_HREF = "/settings?tab=team";
let {
kind = "company",
title = "",
message = ""
}: {
/** `company` = member lacking company-admin; `platform` = lacking platform admin; `support` = support desk. */
kind?: "company" | "platform" | "support";
title?: string;
message?: string;
} = $props();
const resolvedTitle = $derived(
title ||
(kind === "platform" || kind === "support"
? i18n.t("forbidden.title.accessRestricted")
: i18n.t("forbidden.title.permissionDenied"))
);
const resolvedMessage = $derived(
message ||
(kind === "support"
? i18n.t("forbidden.message.support")
: kind === "platform"
? i18n.t("forbidden.message.platform")
: i18n.t("forbidden.message.company"))
);
</script>
<EmptyState variant="forbidden" title={resolvedTitle} message={resolvedMessage}>
{#if kind === "company"}
<a href={ASK_COMPANY_ADMIN_HREF} class="inline-flex focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<Button type="button">{i18n.t("common.askAdmin")}</Button>
</a>
<a href="/dashboard" class="inline-flex focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<Button type="button" variant="outline">{i18n.t("forbidden.backToDashboard")}</Button>
</a>
{:else}
<a href="/dashboard" class="inline-flex focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<Button type="button" variant="outline">{i18n.t("forbidden.backToDashboard")}</Button>
</a>
{/if}
</EmptyState>
@@ -0,0 +1,76 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { buttonClasses } from "$lib/components/ui";
import { systemMode } from "$lib/system-mode.svelte";
import {
dismissHypercareReport,
hypercareReportHref,
HYPERCARE_ADMIN_QUEUE_HREF,
isHypercareReportDismissed
} from "$lib/hypercare-report";
let {
showAdminTriage = false
}: {
/** Platform / support-desk staff: link into admin queue filtered by Hypercare subject. */
showAdminTriage?: boolean;
} = $props();
let dismissed = $state(false);
$effect(() => {
dismissed = isHypercareReportDismissed();
});
const show = $derived(systemMode.hypercare && systemMode.known && !dismissed);
const reportHref = hypercareReportHref();
function onDismiss() {
dismissHypercareReport();
dismissed = true;
}
</script>
{#if show}
<div
class="w-full border-b border-sky-500/35 bg-sky-50 text-foreground dark:bg-sky-950/30"
role="status"
aria-live="polite"
data-testid="hypercare-report-banner"
>
<div
class="mx-auto flex max-w-6xl flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
>
<div class="min-w-0 space-y-0.5">
<p class="text-sm font-semibold">{i18n.t("hypercare.report.title")}</p>
<p class="text-sm text-muted-foreground">{i18n.t("hypercare.report.message")}</p>
</div>
<div class="flex flex-shrink-0 flex-wrap items-center gap-2">
<a
href={reportHref}
class={buttonClasses("default", "sm", "shrink-0")}
data-testid="hypercare-report-cta"
>
{i18n.t("hypercare.report.cta")}
</a>
{#if showAdminTriage}
<a
href={HYPERCARE_ADMIN_QUEUE_HREF}
class={buttonClasses("outline", "sm", "shrink-0")}
data-testid="hypercare-admin-triage"
>
{i18n.t("hypercare.report.adminTriage")}
</a>
{/if}
<button
type="button"
class={buttonClasses("ghost", "sm", "shrink-0")}
data-testid="hypercare-report-dismiss"
onclick={onDismiss}
>
{i18n.t("hypercare.report.dismiss")}
</button>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,20 @@
<script lang="ts">
import { Skeleton } from "$lib/components/ui";
import { i18n } from "$lib/i18n";
import { cn } from "$lib/utils";
let {
rows = 5,
class: className = ""
}: {
rows?: number;
class?: string;
} = $props();
</script>
<div class={cn("space-y-3 p-4", className)} role="status" aria-label={i18n.t("common.loading")} aria-busy="true">
{#each Array.from({ length: rows }, (_, i) => i) as index (index)}
<Skeleton class={cn("h-10 w-full", index === rows - 1 && rows > 1 && "w-4/5")} />
{/each}
<span class="sr-only">{i18n.t("common.loading")}</span>
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { Check, Languages } from "@lucide/svelte";
import { trackEvent } from "$lib/analytics";
import { UI_LOCALES, i18n } from "$lib/i18n";
import { DropdownMenu, DropdownMenuItem, DropdownMenuLabel } from "$lib/components/ui";
let {
class: className = "inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
showCode = false
}: { class?: string; showCode?: boolean } = $props();
let open = $state(false);
const currentLabel = $derived(
UI_LOCALES.find((l) => l.code === i18n.locale)?.label ?? UI_LOCALES[0]?.label ?? i18n.locale
);
const triggerLabel = $derived(i18n.t("locale.switcher"));
const menuLabel = $derived(i18n.t("locale.menu"));
const currentCode = $derived(i18n.locale.toUpperCase());
function selectLocale(code: string) {
if (code === i18n.locale) return;
const localeFrom = i18n.locale;
i18n.setLocale(code);
trackEvent("locale_changed", { locale_from: localeFrom, locale_to: code });
}
</script>
<DropdownMenu bind:open class="w-52" align="end">
{#snippet trigger({ open: isOpen, toggle })}
<button
type="button"
class={className}
aria-label="{triggerLabel}: {currentLabel}"
title="{triggerLabel}: {currentLabel}"
aria-haspopup="menu"
aria-expanded={isOpen}
data-tour="locale-switcher"
onclick={(event) => {
event.stopPropagation();
toggle();
}}
>
{#if showCode}
<span class="text-xs font-semibold tracking-wide" aria-hidden="true">{currentCode}</span>
{:else}
<Languages class="h-[18px] w-[18px]" aria-hidden="true" />
{/if}
</button>
{/snippet}
<DropdownMenuLabel id="locale-switcher-label">{menuLabel}</DropdownMenuLabel>
{#each UI_LOCALES as lang (lang.code)}
<DropdownMenuItem
role="menuitemradio"
aria-checked={lang.code === i18n.locale}
data-locale={lang.code}
onclick={() => selectLocale(lang.code)}
>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="w-7 shrink-0 text-xs font-semibold uppercase text-muted-foreground"
>{lang.code}</span
>
<span class="truncate">{lang.label}</span>
</span>
{#if lang.code === i18n.locale}
<Check class="ml-auto h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
{/if}
</DropdownMenuItem>
{/each}
</DropdownMenu>
@@ -0,0 +1,125 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { buttonClasses } from "$lib/components/ui";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { systemMode } from "$lib/system-mode.svelte";
import { formatCredits } from "$lib/utils";
import {
dismissEtlGaps,
ETL_GAP_ITEMS,
isA1NamedPlan,
isEtlGapsDismissed,
isMigratedCohortPlan
} from "$lib/etl-gaps";
import { isA1PaygPlanPure } from "$lib/plan-cohort";
let {
canAdmin = false,
planName = null as string | null,
isLegacy = null as boolean | null,
isCustom = null as boolean | null,
/** Optional platform inventory counts (admin diagnostics); omit on tenant dashboard. */
filesMetadataOnly = null as number | null,
jobsMigrated = null as number | null,
jobsDomainRan = null as boolean | null
}: {
/** Company admin (or manage-company) — recreate/reissue deep links. */
canAdmin?: boolean;
planName?: string | null;
isLegacy?: boolean | null;
isCustom?: boolean | null;
filesMetadataOnly?: number | null;
jobsMigrated?: number | null;
jobsDomainRan?: boolean | null;
} = $props();
let dismissed = $state(false);
$effect(() => {
dismissed = isEtlGapsDismissed();
});
const planFlags = $derived({
name: planName ?? planCapabilities.planName ?? "",
is_legacy: isLegacy ?? undefined,
is_custom: isCustom ?? undefined
});
const isA1Cohort = $derived(isA1PaygPlanPure(planFlags) || isA1NamedPlan(planFlags));
const cohort = $derived(isMigratedCohortPlan(planFlags));
/** Never A1 — even under global hypercare (A1 is not a cutover-honesty merchant). */
const show = $derived(
!isA1Cohort &&
canAdmin &&
planCapabilities.can("dashboard.etl_gaps") &&
!dismissed &&
(cohort || (systemMode.hypercare && systemMode.known))
);
function onDismiss() {
dismissEtlGaps();
dismissed = true;
}
function gapCountHint(id: string): string | null {
if (id === "blobs" && filesMetadataOnly != null && filesMetadataOnly > 0) {
return i18n.t("etl.gaps.blobs.countHint", { count: formatCredits(filesMetadataOnly) });
}
if (id === "jobs") {
if (jobsDomainRan === true && jobsMigrated != null) {
return i18n.t("etl.gaps.jobs.migratedHint", { count: formatCredits(jobsMigrated) });
}
if (jobsDomainRan === false) {
return i18n.t("etl.gaps.jobs.emptyHint");
}
}
return null;
}
</script>
{#if show}
<div
class="rounded-lg border border-chart-amber/50 bg-chart-amber/15 px-4 py-3 text-foreground"
role="status"
aria-live="polite"
data-testid="migrated-etl-gaps-panel"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 space-y-2">
<p class="text-sm font-semibold">{i18n.t("etl.gaps.title")}</p>
<p class="text-sm text-muted-foreground">{i18n.t("etl.gaps.message")}</p>
<ul class="space-y-2 text-sm">
{#each ETL_GAP_ITEMS as gap (gap.id)}
{@const hint = gapCountHint(gap.id)}
<li class="rounded-md border border-border/60 bg-background/60 px-3 py-2">
<p class="font-medium">{i18n.t(gap.titleKey)}</p>
<p class="text-muted-foreground">{i18n.t(gap.bodyKey)}</p>
{#if hint}
<p class="mt-1 text-xs text-muted-foreground" data-testid="etl-gap-count-{gap.id}">
{hint}
</p>
{/if}
<a
href={gap.href}
class="mt-1 inline-block font-medium underline-offset-4 hover:underline"
data-testid="etl-gap-link-{gap.id}"
>
{i18n.t(gap.ctaKey)}
</a>
</li>
{/each}
</ul>
</div>
<button
type="button"
class={buttonClasses("ghost", "sm", "shrink-0")}
data-testid="etl-gaps-dismiss"
onclick={onDismiss}
>
{i18n.t("etl.gaps.dismiss")}
</button>
</div>
</div>
{/if}
+584
View File
@@ -0,0 +1,584 @@
<script lang="ts">
import { page } from "$app/state";
import { tick } from "svelte";
import { tutorial } from "$lib/tutorial";
import { navUi } from "$lib/nav-ui.svelte";
import { activateFocusTrap, type FocusTrapHandle } from "$lib/a11y/focus-trap";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { navItemAllowed } from "$lib/plan-capabilities";
import { commandPaletteShortcutLabelFromEnv } from "$lib/command-palette-search";
import { i18n } from "$lib/i18n";
import {
LayoutDashboard,
Package,
FolderTree,
ListTree,
FileText,
Share2,
Clock,
Store,
DollarSign,
Settings,
Shield,
Database,
Megaphone,
CalendarDays,
Search,
Palette,
Star,
Mail,
Bot,
LifeBuoy,
X
} from "@lucide/svelte";
import LocaleSwitcher from "$lib/components/LocaleSwitcher.svelte";
type NavSectionId =
| "overview"
| "catalog"
| "feeds"
| "stores"
| "processing"
| "marketing"
| "integrations"
| "account"
| "platform";
type NavItem = {
href: string;
label: string;
icon: typeof LayoutDashboard;
adminOnly?: boolean;
/** Plan feature key; defaults from href via featureKeyForHref. */
feature?: string;
section: NavSectionId;
};
let {
showAdmin = false,
unlockAllFeatures = false
}: {
showAdmin?: boolean;
/** Platform / full admin: show every product tab regardless of company plan. */
unlockAllFeatures?: boolean;
} = $props();
const sectionMeta = $derived<{ id: NavSectionId; label: string }[]>([
{ id: "overview", label: i18n.t("nav.section.overview") },
{ id: "catalog", label: i18n.t("nav.section.catalog") },
{ id: "feeds", label: i18n.t("nav.section.feeds") },
{ id: "stores", label: i18n.t("nav.section.stores") },
{ id: "processing", label: i18n.t("nav.section.processing") },
{ id: "marketing", label: i18n.t("nav.section.marketing") },
{ id: "integrations", label: i18n.t("nav.section.integrations") },
{ id: "account", label: i18n.t("nav.section.account") },
{ id: "platform", label: i18n.t("nav.section.platform") }
]);
/**
* Full nav catalog. Legacy (A1) plans only enable overview + catalog + feeds + billing/settings,
* so empty sections hide automatically via feature gates.
*/
const allItems = $derived<NavItem[]>([
{
href: "/dashboard",
label: i18n.t("nav.dashboard"),
icon: LayoutDashboard,
feature: "dashboard.overview",
section: "overview"
},
{
href: "/products?status=completed&type=processed&page=1&sortBy=updatedAt&sortOrder=desc",
label: i18n.t("nav.products"),
icon: Package,
feature: "catalog.products",
section: "catalog"
},
{
href: "/categories",
label: i18n.t("nav.categories"),
icon: FolderTree,
feature: "catalog.categories",
section: "catalog"
},
{
href: "/attributes",
label: i18n.t("nav.attributes"),
icon: ListTree,
feature: "catalog.attributes",
section: "catalog"
},
{
href: "/standard-fields",
label: i18n.t("nav.fields"),
icon: Database,
feature: "catalog.standard_fields",
section: "catalog"
},
{ href: "/feeds", label: i18n.t("nav.feeds"), icon: FileText, feature: "feeds.list", section: "feeds" },
{
href: "/export-feeds",
label: i18n.t("nav.exports"),
icon: Share2,
feature: "feeds.export_feeds",
section: "feeds"
},
{ href: "/stores", label: i18n.t("nav.stores"), icon: Store, feature: "stores.hub", section: "stores" },
{
href: "/processing",
label: i18n.t("nav.jobs"),
icon: Clock,
feature: "processing.monitor",
section: "processing"
},
{
href: "/campaigns",
label: i18n.t("nav.campaigns"),
icon: Megaphone,
feature: "marketing.campaigns",
section: "marketing"
},
{
href: "/marketing/calendar",
label: i18n.t("nav.calendar"),
icon: CalendarDays,
feature: "marketing.content_calendar",
section: "marketing"
},
{ href: "/seo", label: i18n.t("nav.seo"), icon: Search, feature: "marketing.seo", section: "marketing" },
{
href: "/brand",
label: i18n.t("nav.brand"),
icon: Palette,
feature: "marketing.brand_kit",
section: "marketing"
},
{
href: "/woocommerce?tab=reviews",
label: i18n.t("nav.reviews"),
icon: Star,
feature: "marketing.reviews",
section: "marketing"
},
{
href: "/integrations/ai",
label: i18n.t("nav.ai"),
icon: Bot,
feature: "integrations.ai",
section: "integrations"
},
{
href: "/integrations/email",
label: i18n.t("nav.email"),
icon: Mail,
feature: "integrations.email",
section: "integrations"
},
{
href: "/billing",
label: i18n.t("nav.billing"),
icon: DollarSign,
feature: "billing.overview",
section: "account"
},
{
href: "/settings",
label: i18n.t("nav.settings"),
icon: Settings,
feature: "settings.profile",
section: "account"
},
{
href: "/support",
label: i18n.t("nav.support"),
icon: LifeBuoy,
feature: "support.center",
section: "account"
},
{ href: "/admin", label: i18n.t("nav.admin"), icon: Shield, adminOnly: true, section: "platform" }
]);
function itemAllowed(item: NavItem): boolean {
return navItemAllowed(item, planCapabilities, {
showAdmin,
unlockAllFeatures
});
}
const visibleItems = $derived(allItems.filter(itemAllowed));
/** Mobile drawer pin — primary destinations stay above collapsible sections. */
const MOBILE_ESSENTIAL_BASES = ["/products", "/feeds", "/stores", "/settings"] as const;
const mobileEssentials = $derived(
MOBILE_ESSENTIAL_BASES.map((base) =>
visibleItems.find((item) => (item.href.split("?")[0] ?? item.href) === base)
).filter((item): item is NavItem => item != null)
);
const visibleSections = $derived.by(() =>
sectionMeta
.map((section) => ({
...section,
items: visibleItems.filter((item) => item.section === section.id)
}))
.filter((section) => section.items.length > 0)
);
const COLLAPSE_KEY = "descrybe.nav.collapsed";
let collapsed = $state<Record<string, boolean>>({});
$effect(() => {
if (typeof window === "undefined") return;
try {
const raw = window.localStorage.getItem(COLLAPSE_KEY);
if (raw) collapsed = JSON.parse(raw) as Record<string, boolean>;
} catch {
collapsed = {};
}
});
function toggleSection(id: NavSectionId) {
const next = { ...collapsed, [id]: !collapsed[id] };
collapsed = next;
try {
window.localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next));
} catch {
/* ignore */
}
}
function sectionOpen(id: NavSectionId, items: NavItem[]): boolean {
// Mobile: keep every section expanded so destinations stay findable without hunting "+".
if (!isDesktop) return true;
const path = page.url.pathname;
// Keep the section with the active route expanded even if previously collapsed.
if (items.some((item) => isActive(item.href, path))) return true;
return collapsed[id] !== true;
}
let clickedHref = $state<string | null>(null);
let asideEl = $state<HTMLElement | null>(null);
let trap: FocusTrapHandle | null = null;
let isDesktop = $state(false);
let navMounted = $state(false);
const paletteShortcut = commandPaletteShortcutLabelFromEnv();
$effect(() => {
if (typeof window === "undefined") return;
navMounted = true;
const mq = window.matchMedia("(min-width: 1024px)");
const sync = () => {
isDesktop = mq.matches;
};
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
});
function basePath(href: string): string {
return href.split("?")[0] ?? href;
}
function tourAttr(href: string): string | undefined {
const base = basePath(href);
if (href.includes("tab=reviews")) return "nav-reviews";
const map: Record<string, string> = {
"/dashboard": "nav-dashboard",
"/products": "nav-products",
"/feeds": "nav-feeds",
"/export-feeds": "nav-export-feeds",
"/stores": "nav-stores",
"/standard-fields": "nav-standard-fields",
"/categories": "nav-categories",
"/attributes": "nav-attributes",
"/woocommerce": "nav-woocommerce",
"/shopify": "nav-shopify",
"/integrations": "nav-stores",
"/processing": "nav-processing",
"/billing": "nav-billing",
"/settings": "nav-settings",
"/integrations/ai": "nav-ai",
"/integrations/email": "nav-email",
"/campaigns": "nav-campaigns",
"/marketing/calendar": "nav-content-calendar",
"/seo": "nav-seo",
"/brand": "nav-brand",
"/support": "nav-support",
"/admin": "nav-admin"
};
return map[base];
}
function isActive(href: string, pathname: string): boolean {
const base = basePath(href);
const search = typeof window !== "undefined" ? window.location.search : page.url.search;
if (href.includes("tab=reviews")) {
return pathname.startsWith("/woocommerce") && search.includes("tab=reviews");
}
if (base === "/stores") {
return (
pathname === "/stores" ||
pathname.startsWith("/stores/") ||
pathname.startsWith("/shopify") ||
((pathname === "/woocommerce" || pathname.startsWith("/woocommerce/")) &&
!search.includes("tab=reviews"))
);
}
if (base === "/woocommerce") {
return (
(pathname === "/woocommerce" || pathname.startsWith("/woocommerce/")) &&
!search.includes("tab=reviews")
);
}
if (base === "/dashboard") return pathname === "/dashboard";
if (base === "/processing" && (pathname === "/tasks" || pathname.startsWith("/tasks/"))) {
return true;
}
if (base === "/products") {
return pathname === "/products" || pathname.startsWith("/products/");
}
return pathname === base || pathname.startsWith(`${base}/`);
}
function expandNavForTourSelector(sel: string) {
const tourIds = [...sel.matchAll(/data-tour="(nav-[^"]+)"/g)].map((m) => m[1]);
if (tourIds.length === 0) return;
let next = collapsed;
let changed = false;
for (const tourId of tourIds) {
const item = allItems.find((candidate) => tourAttr(candidate.href) === tourId);
if (!item || next[item.section] !== true) continue;
if (!changed) {
next = { ...collapsed };
changed = true;
}
next[item.section] = false;
}
if (!changed) return;
collapsed = next;
try {
window.localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next));
} catch {
/* ignore */
}
}
$effect(() => {
if (!tutorial.active) return;
const sel = tutorial.step?.selector ?? "";
if (!sel.includes('data-tour="nav-')) return;
expandNavForTourSelector(sel);
navUi.openMobile();
});
/** Close drawer after SPA navigations (incl. post-login settle) so the next open is intentional. */
let lastPathname: string | null = null;
$effect(() => {
const path = page.url.pathname;
if (lastPathname !== null && lastPathname !== path && !tutorial.active) {
navUi.closeMobile();
}
lastPathname = path;
});
$effect(() => {
const open = navUi.mobileOpen;
if (isDesktop || !open) {
trap?.deactivate();
trap = null;
return;
}
let cancelled = false;
void tick().then(() => {
if (cancelled || !asideEl || isDesktop || !navUi.mobileOpen) return;
trap?.deactivate();
trap = activateFocusTrap(asideEl, { restoreFocus: true });
});
return () => {
cancelled = true;
trap?.deactivate();
trap = null;
};
});
function onAsideKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && navUi.mobileOpen) {
event.preventDefault();
navUi.closeMobile();
}
}
function handleNav(href: string, event: MouseEvent) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
return;
}
// Side effects only — do not preventDefault + goto. SvelteKit already hijacks
// same-origin <a> clicks; a manual goto() can abort while another navigation
// (e.g. post-login) is settling, leaving the click as a no-op.
const key = href;
clickedHref = key;
navUi.closeMobile();
setTimeout(() => {
if (clickedHref === key) clickedHref = null;
}, 300);
}
function linkClass(active: boolean, clicked: boolean): string {
return `flex h-8 w-full items-center justify-start gap-2 rounded-md px-2.5 text-sidebar-foreground transition-all duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${
active ? "bg-sidebar-accent text-sidebar-accent-foreground" : ""
} ${clicked ? "scale-95 bg-sidebar-accent" : ""}`;
}
const mobileDrawerHidden = $derived(navMounted && !isDesktop && !navUi.mobileOpen);
</script>
{#if navUi.mobileOpen}
<button
type="button"
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
aria-label={i18n.t("nav.close")}
tabindex="-1"
onclick={() => navUi.closeMobile()}
></button>
{/if}
<aside
bind:this={asideEl}
id="app-sidebar"
class="fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-transform duration-200 ease-out lg:visible lg:translate-x-0 lg:pointer-events-auto {navUi.mobileOpen
? 'translate-x-0'
: '-translate-x-full pointer-events-none invisible'}"
aria-label={i18n.t("nav.sidebar")}
aria-hidden={mobileDrawerHidden ? "true" : undefined}
inert={mobileDrawerHidden ? true : undefined}
tabindex="-1"
onkeydown={onAsideKeydown}
>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-4">
<div class="mb-4 flex items-center justify-between gap-2 px-1">
<a
href="/dashboard"
class="flex items-center gap-2 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
aria-label={i18n.t("app.home")}
>
<img src="/descrybe_logo.png" alt="" width="28" height="28" class="h-7 w-7" />
<span class="text-sm font-semibold text-sidebar-foreground">{i18n.t("app.name")}</span>
</a>
<div class="flex items-center gap-1">
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sidebar-foreground/90 transition hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
aria-label={i18n.t("nav.commandPalette")}
aria-keyshortcuts="Control+K Meta+K"
data-tour="nav-command-palette"
onclick={() => navUi.openCommandPalette()}
>
<Search class="h-4 w-4" aria-hidden="true" />
<span class="hidden text-xs font-medium sm:inline">{i18n.t("nav.commandPaletteLabel")}</span>
<kbd class="hidden rounded border border-sidebar-border px-1 py-px text-[10px] font-medium text-sidebar-foreground/70 lg:inline">{paletteShortcut}</kbd>
</button>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-md text-sidebar-foreground/90 transition hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring lg:hidden"
aria-label={i18n.t("nav.closeMenu")}
onclick={() => navUi.closeMobile()}
>
<X class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
<nav class="space-y-2" data-tour="nav-sidebar" aria-label={i18n.t("nav.main")}>
{#if !isDesktop && mobileEssentials.length > 0}
<div
class="mb-1 space-y-0.5 rounded-md border border-sidebar-border/70 bg-sidebar-accent/20 p-1.5"
role="group"
aria-label={i18n.t("dashboard.quickLinks")}
data-tour="nav-mobile-essentials"
>
<p class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/90">
{i18n.t("dashboard.quickLinks")}
</p>
{#each mobileEssentials as item}
{@const Icon = item.icon}
{@const active = isActive(item.href, page.url.pathname)}
{@const clicked = clickedHref === item.href}
{@const target = tourAttr(item.href)}
<a
href={item.href}
onclick={(e) => handleNav(item.href, e)}
data-tour={target ? `${target}-essential` : undefined}
data-assistant-target={target}
aria-label={item.label}
aria-current={active ? "page" : undefined}
class={linkClass(active, clicked)}
>
<span class="transition-transform duration-150 {clicked ? 'scale-90' : ''}" aria-hidden="true">
<Icon class="h-3.5 w-3.5 shrink-0" />
</span>
<span class="truncate text-[13px] font-medium">{item.label}</span>
</a>
{/each}
</div>
{/if}
{#each visibleSections as section}
{@const open = sectionOpen(section.id, section.items)}
<div class="space-y-0.5" role="group" aria-labelledby="nav-section-{section.id}">
{#if isDesktop}
<button
type="button"
id="nav-section-{section.id}"
class="flex w-full items-center justify-between rounded-md px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/90 transition hover:bg-sidebar-accent/60 hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
aria-expanded={open}
onclick={() => toggleSection(section.id)}
>
<span>{section.label}</span>
<span class="text-sidebar-foreground/60" aria-hidden="true">{open ? "" : "+"}</span>
</button>
{:else}
<p
id="nav-section-{section.id}"
class="px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/90"
>
{section.label}
</p>
{/if}
{#if open}
{#each section.items as item}
{@const Icon = item.icon}
{@const active = isActive(item.href, page.url.pathname)}
{@const clicked = clickedHref === item.href}
{@const target = tourAttr(item.href)}
<a
href={item.href}
onclick={(e) => handleNav(item.href, e)}
data-tour={target}
data-assistant-target={target}
aria-label={item.label}
aria-current={active ? "page" : undefined}
class={linkClass(active, clicked)}
>
<span class="transition-transform duration-150 {clicked ? 'scale-90' : ''}" aria-hidden="true">
<Icon class="h-3.5 w-3.5 shrink-0" />
</span>
<span class="truncate text-[13px] font-medium">{item.label}</span>
</a>
{/each}
{/if}
</div>
{/each}
</nav>
</div>
<!-- Locale stays in the drawer through tablet; theme lives only in DashboardHeader (avoids duplicate/obscured toggles under the cookie banner). -->
<div class="flex shrink-0 items-center justify-between gap-2 border-t border-sidebar-border px-3 py-3 lg:hidden">
<p class="text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/80">
{i18n.t("nav.settings")}
</p>
<div class="flex items-center gap-1">
<LocaleSwitcher
class="inline-flex h-10 w-10 items-center justify-center rounded-md text-sidebar-foreground/90 transition hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
/>
</div>
</div>
</aside>
+293
View File
@@ -0,0 +1,293 @@
<script lang="ts">
import type { Component } from "svelte";
import {
Zap,
Bot,
BarChart3,
Cog,
List,
Settings,
Filter,
Upload,
Tag,
FileText,
Download
} from "@lucide/svelte";
import {
Badge,
Card,
CardContent,
CardHeader,
CardTitle
} from "$lib/components/ui";
import { i18n } from "$lib/i18n";
type NewsUpdate = {
id: string;
titleKey: string;
date: string;
icon: Component;
categoryKey: string;
descriptionKey: string;
featureKeys: string[];
isNew?: boolean;
};
let { limit = undefined }: { limit?: number } = $props();
const newsUpdates: NewsUpdate[] = [
{
id: "user-experience-improvements",
titleKey: "news.uxImprovements.title",
date: "Nov 10, 2025",
icon: Settings,
categoryKey: "news.category.enhancement",
isNew: true,
descriptionKey: "news.uxImprovements.description",
featureKeys: [
"news.uxImprovements.f1",
"news.uxImprovements.f2",
"news.uxImprovements.f3",
"news.uxImprovements.f4"
]
},
{
id: "multiselect-attributes",
titleKey: "news.multiselect.title",
date: "Oct 29, 2025",
icon: List,
categoryKey: "news.category.enhancement",
descriptionKey: "news.multiselect.description",
featureKeys: [
"news.multiselect.f1",
"news.multiselect.f2",
"news.multiselect.f3",
"news.multiselect.f4",
"news.multiselect.f5"
]
},
{
id: "csv-ean-upload",
titleKey: "news.csvEan.title",
date: "Oct 15, 2025",
icon: Upload,
categoryKey: "news.category.feature",
descriptionKey: "news.csvEan.description",
featureKeys: [
"news.csvEan.f1",
"news.csvEan.f2",
"news.csvEan.f3",
"news.csvEan.f4",
"news.csvEan.f5"
]
},
{
id: "brick-capabilities",
titleKey: "news.brick.title",
date: "Sep 6, 2025",
icon: BarChart3,
categoryKey: "news.category.feature",
descriptionKey: "news.brick.description",
featureKeys: ["news.brick.f1", "news.brick.f2", "news.brick.f3", "news.brick.f4"]
},
{
id: "api-v1",
titleKey: "news.apiV1.title",
date: "Aug 29, 2025",
icon: Cog,
categoryKey: "news.category.integration",
descriptionKey: "news.apiV1.description",
featureKeys: [
"news.apiV1.f1",
"news.apiV1.f2",
"news.apiV1.f3",
"news.apiV1.f4",
"news.apiV1.f5",
"news.apiV1.f6"
]
},
{
id: "products-feed-filtering",
titleKey: "news.feedFiltering.title",
date: "Aug 26, 2025",
icon: Filter,
categoryKey: "news.category.enhancement",
descriptionKey: "news.feedFiltering.description",
featureKeys: [
"news.feedFiltering.f1",
"news.feedFiltering.f2",
"news.feedFiltering.f3",
"news.feedFiltering.f4",
"news.feedFiltering.f5"
]
},
{
id: "export-feeds-system",
titleKey: "news.exportFeeds.title",
date: "Jul 15, 2025",
icon: Download,
categoryKey: "news.category.feature",
descriptionKey: "news.exportFeeds.description",
featureKeys: [
"news.exportFeeds.f1",
"news.exportFeeds.f2",
"news.exportFeeds.f3",
"news.exportFeeds.f4",
"news.exportFeeds.f5",
"news.exportFeeds.f6"
]
},
{
id: "woocommerce-integration",
titleKey: "news.woocommerce.title",
date: "Jul 1, 2025",
icon: Tag,
categoryKey: "news.category.integration",
descriptionKey: "news.woocommerce.description",
featureKeys: [
"news.woocommerce.f1",
"news.woocommerce.f2",
"news.woocommerce.f3",
"news.woocommerce.f4"
]
},
{
id: "eprel-integration",
titleKey: "news.eprel.title",
date: "Jun 18, 2025",
icon: Zap,
categoryKey: "news.category.newFeature",
descriptionKey: "news.eprel.description",
featureKeys: [
"news.eprel.f1",
"news.eprel.f2",
"news.eprel.f3",
"news.eprel.f4",
"news.eprel.f5"
]
},
{
id: "ai-categorization",
titleKey: "news.aiCategorization.title",
date: "Jun 5, 2025",
icon: Bot,
categoryKey: "news.category.enhancement",
descriptionKey: "news.aiCategorization.description",
featureKeys: [
"news.aiCategorization.f1",
"news.aiCategorization.f2",
"news.aiCategorization.f3",
"news.aiCategorization.f4"
]
},
{
id: "standard-fields-system",
titleKey: "news.standardFields.title",
date: "May 30, 2025",
icon: FileText,
categoryKey: "news.category.feature",
descriptionKey: "news.standardFields.description",
featureKeys: [
"news.standardFields.f1",
"news.standardFields.f2",
"news.standardFields.f3",
"news.standardFields.f4",
"news.standardFields.f5"
]
},
{
id: "billing-system",
titleKey: "news.billingSystem.title",
date: "Mar 8, 2025",
icon: Cog,
categoryKey: "news.category.billing",
descriptionKey: "news.billingSystem.description",
featureKeys: [
"news.billingSystem.f1",
"news.billingSystem.f2",
"news.billingSystem.f3",
"news.billingSystem.f4",
"news.billingSystem.f5"
]
}
];
const visibleUpdates = $derived(
typeof limit === "number" && limit >= 0 ? newsUpdates.slice(0, limit) : newsUpdates
);
</script>
<div class="space-y-6">
{#if typeof limit !== "number"}
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold tracking-tight">{i18n.t("news.heading")}</h2>
<p class="mt-1 text-sm text-muted-foreground">{i18n.t("news.subtitle")}</p>
</div>
</div>
{/if}
<div class="grid gap-4">
{#each visibleUpdates as update (update.id)}
{@const Icon = update.icon}
{@const compact = typeof limit === "number"}
<Card class="min-w-0 transition-shadow hover:shadow-md">
<CardHeader class={compact ? "space-y-1 p-4 pb-2" : "pb-3"}>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="flex min-w-0 items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
<Icon class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<div class="mb-1 flex min-w-0 items-start gap-2">
<CardTitle
class={compact
? "min-w-0 flex-1 break-words text-base leading-snug"
: "min-w-0 flex-1 break-words text-lg leading-snug"}
>{i18n.t(update.titleKey)}</CardTitle
>
{#if update.isNew}
<Badge variant="secondary" class="shrink-0 px-2 py-0.5 text-xs"
>{i18n.t("news.badge.new")}</Badge
>
{/if}
</div>
<div class="flex flex-wrap items-center gap-2 text-sm text-muted-foreground sm:gap-3">
<Badge variant="outline" class="text-xs">{i18n.t(update.categoryKey)}</Badge>
<span>{update.date}</span>
</div>
</div>
</div>
</div>
</CardHeader>
<CardContent class={compact ? "px-4 pb-4 pt-0" : "pt-0"}>
<p class="mb-0 break-words text-sm text-muted-foreground">{i18n.t(update.descriptionKey)}</p>
{#if !compact}
<div class="mt-3 space-y-2">
<h4 class="text-sm font-medium text-foreground">{i18n.t("news.keyFeatures")}</h4>
<ul class="space-y-1.5">
{#each update.featureKeys as featureKey}
<li class="flex items-start gap-2 text-sm text-muted-foreground">
<div
class="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/40"
></div>
<span class="min-w-0 flex-1 break-words">{i18n.t(featureKey)}</span>
</li>
{/each}
</ul>
</div>
{/if}
</CardContent>
</Card>
{/each}
</div>
{#if typeof limit !== "number"}
<div class="mt-8">
<div class="border-t border-border"></div>
<div class="flex items-center justify-center py-4 text-sm text-muted-foreground">
{i18n.t("news.footer")}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
title,
description = "",
eyebrow = "",
actions
}: {
title: string;
description?: string;
eyebrow?: string;
actions?: Snippet;
} = $props();
</script>
<header class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div class="min-w-0">
{#if eyebrow}
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{eyebrow}
</p>
{/if}
<h1 class="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">{title}</h1>
{#if description}
<p class="mt-1 max-w-2xl text-sm leading-relaxed text-muted-foreground">{description}</p>
{/if}
</div>
{#if actions}
<div
class="flex w-full min-w-0 flex-col gap-2 sm:w-auto sm:shrink-0 sm:flex-row sm:flex-wrap sm:items-center [&>*]:w-full sm:[&>*]:w-auto"
>
{@render actions()}
</div>
{/if}
</header>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from "svelte";
import PageHeader from "./PageHeader.svelte";
let {
title,
description = "",
eyebrow = "",
tour = undefined,
actions,
children
}: {
title: string;
description?: string;
/** Optional small label above the title (e.g. section context). */
eyebrow?: string;
/** Optional product-tour / tutorial anchor. */
tour?: string;
actions?: Snippet;
children: Snippet;
} = $props();
</script>
<div class="mx-auto w-full max-w-7xl min-w-0 space-y-6 sm:space-y-8" data-tour={tour}>
<PageHeader {title} {description} {eyebrow} {actions} />
{@render children()}
</div>
@@ -0,0 +1,59 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { page } from "$app/state";
import { authSession } from "$lib/auth-session.svelte";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import {
featureKeyForPathname,
isGatedDashboardPath
} from "$lib/plan-capabilities";
import { upgradeMessageForFeature } from "$lib/plan-upgrade-message";
import PlanUpgradePanel from "$lib/components/PlanUpgradePanel.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import { i18n } from "$lib/i18n";
let { children }: { children: Snippet } = $props();
const featureKey = $derived(
featureKeyForPathname(page.url.pathname, page.url.search)
);
const gated = $derived(isGatedDashboardPath(page.url.pathname));
/** Avoid fail-open flash of premium pages before /me features hydrate.
* Do not wait forever when layout /me failed (unauthenticated) — layout redirects. */
const awaitingMatrix = $derived(
gated &&
Boolean(featureKey) &&
Boolean(authSession.me) &&
planCapabilities.features == null &&
(planCapabilities.status === "idle" || planCapabilities.status === "loading")
);
const allowed = $derived(
!gated || !featureKey || (!awaitingMatrix && planCapabilities.can(featureKey))
);
const gate = $derived(
featureKey
? upgradeMessageForFeature(featureKey, authSession.isCompanyAdmin, {
sectionEnabled: planCapabilities.sectionEnabled(featureKey.split(".")[0] ?? "")
})
: null
);
</script>
{#if awaitingMatrix}
<div
class="flex min-h-[12rem] items-center justify-center py-10"
data-testid="plan-route-pending"
>
<Spinner label={i18n.t("plan.checkingAccess")} />
</div>
{:else if allowed}
{@render children()}
{:else if gate}
<PlanUpgradePanel
title={gate.title}
message={gate.message}
cta={gate.cta}
stillWorks={gate.stillWorks}
featureKey={gate.featureKey}
/>
{/if}
@@ -0,0 +1,78 @@
<script lang="ts">
import UpgradeBanner from "$lib/components/UpgradeBanner.svelte";
import type { UpgradeCta } from "$lib/billing-display";
import type { FeatureGateStillWorksItem } from "$lib/plan-upgrade-message";
import { i18n } from "$lib/i18n";
import { buttonClasses } from "$lib/components/ui";
let {
title,
message,
cta,
tone = "info",
stillWorks = undefined,
featureKey = undefined,
compact = false
}: {
title?: string;
message?: string;
cta: UpgradeCta;
tone?: "info" | "warning" | "danger";
stillWorks?: {
title: string;
items: FeatureGateStillWorksItem[];
};
featureKey?: string;
/** Tighter spacing when embedded under an existing page heading. */
compact?: boolean;
} = $props();
const resolvedTitle = $derived(title ?? i18n.t("plan.upgrade.title"));
const resolvedMessage = $derived(message ?? i18n.t("plan.upgrade.message"));
const hasStillWorks = $derived(Boolean(stillWorks?.items?.length));
</script>
<div
class={compact ? "space-y-4" : "mx-auto max-w-2xl space-y-4 py-6"}
data-testid="plan-upgrade-panel"
>
<UpgradeBanner
{tone}
title={resolvedTitle}
message={resolvedMessage}
primaryHref={cta.primaryHref}
primaryLabel={cta.primaryLabel}
showSales={cta.showSales}
analyticsReason="feature_gate"
analyticsFeature={featureKey}
/>
{#if hasStillWorks && stillWorks}
<aside
class="space-y-3 rounded-lg border border-dashed px-4 py-4"
aria-labelledby="plan-upgrade-still-works-title"
data-testid="plan-upgrade-still-works"
>
<h2 id="plan-upgrade-still-works-title" class="text-sm font-semibold text-foreground">
{stillWorks.title}
</h2>
<ul class="space-y-3">
{#each stillWorks.items as item (item.href)}
<li class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div class="min-w-0 space-y-0.5">
<p class="text-sm font-medium text-foreground">{item.label}</p>
<p class="text-sm text-muted-foreground">{item.description}</p>
</div>
<a
href={item.href}
class="{buttonClasses('outline', 'sm')} shrink-0"
data-testid="plan-upgrade-still-works-link"
>
{item.label}
</a>
</li>
{/each}
</ul>
</aside>
{/if}
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { i18n } from "$lib/i18n";
let {
href = "#main-content",
class: className = ""
}: {
href?: string;
class?: string;
} = $props();
</script>
<a
{href}
class={cn(
"sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[60] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-md focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-ring",
className
)}
>
{i18n.t("a11y.skipToContent")}
</a>
@@ -0,0 +1,13 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
let { label }: { label?: string } = $props();
const displayLabel = $derived(label ?? i18n.t("common.loading"));
</script>
<div class="flex items-center gap-2 text-sm text-muted-foreground" role="status">
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-border border-t-primary"
></span>
{displayLabel}
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import { Card, CardContent, CardHeader, Skeleton } from "$lib/components/ui";
import { i18n } from "$lib/i18n";
import { cn } from "$lib/utils";
let {
count = 3,
class: className = ""
}: {
count?: number;
class?: string;
} = $props();
</script>
<div
class={cn(
"grid gap-4",
count <= 2 ? "sm:grid-cols-2" : "sm:grid-cols-2 lg:grid-cols-3",
className
)}
role="status"
aria-label={i18n.t("common.loading")}
aria-busy="true"
>
{#each Array.from({ length: count }, (_, i) => i) as index (index)}
<Card>
<CardHeader class="space-y-2 pb-2">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-8 w-20" />
</CardHeader>
<CardContent>
<Skeleton class="h-3 w-32" />
</CardContent>
</Card>
{/each}
<span class="sr-only">{i18n.t("common.loading")}</span>
</div>
@@ -0,0 +1,14 @@
<script lang="ts">
import { statusBadgeLabel, statusBadgeTone } from "$lib/feeds-list-controls";
let { status = "unknown" }: { status?: string | number | null | unknown } = $props();
const label = $derived(statusBadgeLabel(status));
const tone = $derived(statusBadgeTone(label));
</script>
<span
class="inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium capitalize {tone}"
>
{label}
</span>
@@ -0,0 +1,31 @@
<script lang="ts">
import { Bell } from "@lucide/svelte";
import { i18n } from "$lib/i18n";
import { supportNotifications } from "$lib/support/notifications.svelte";
const unread = $derived(supportNotifications.unreadCount);
const show = $derived(supportNotifications.available && unread > 0);
const label = $derived(
show
? i18n.t("support.notifications.ariaUnread", { count: unread })
: i18n.t("support.notifications.aria")
);
</script>
<a
href="/support"
class="relative inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-8 sm:w-8 {!show ? 'max-sm:hidden' : ''}"
data-tour="support-notification-bell"
aria-label={label}
title={label}
>
<Bell class="h-4 w-4" aria-hidden="true" />
{#if show}
<span
class="pointer-events-none absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground ring-2 ring-background"
aria-hidden="true"
>
{unread > 99 ? "99+" : unread}
</span>
{/if}
</a>
@@ -0,0 +1,191 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { Star } from "@lucide/svelte";
import { ApiError, failureMessage } from "$lib/api";
import { isSupportUnavailable, submitSupportCsat } from "$lib/support/api";
import type { SupportCsat, SupportTicket } from "$lib/support/types";
import { notifySuccess, notifyApiError } from "$lib/notify";
import Alert from "$lib/components/Alert.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Label,
Textarea
} from "$lib/components/ui";
let {
ticket,
onRated
}: {
ticket: SupportTicket;
onRated?: (csat: SupportCsat, nextTicket: SupportTicket | null) => void;
} = $props();
let score = $state(0);
let hoverScore = $state(0);
let comment = $state("");
let submitting = $state(false);
let error = $state("");
let unavailable = $state(false);
const existing = $derived(ticket.csat ?? null);
const activeScore = $derived(hoverScore || score);
const canSubmit = $derived(score >= 1 && score <= 5 && !submitting && !unavailable);
function scoreLabel(value: number): string {
return i18n.t(`support.csat.score.${value}`);
}
async function handleSubmit(event: Event) {
event.preventDefault();
if (!canSubmit) return;
submitting = true;
error = "";
try {
const result = await submitSupportCsat(ticket.id, {
score,
comment: comment.trim() || undefined
});
notifySuccess(i18n.t("toast.support.thanksFeedback"));
onRated?.(result.csat, result.ticket);
} catch (err) {
if (isSupportUnavailable(err)) {
unavailable = true;
error = i18n.t("flash.support.ratingUnavailable");
notifyApiError(err, i18n.t("toast.support.ratingUnavailable"));
return;
}
if (err instanceof ApiError && err.status === 409) {
error = i18n.t("flash.support.alreadyRated");
notifyApiError(err, i18n.t("toast.support.alreadyRated"));
return;
}
error = failureMessage(err, i18n.t("support.csat.submitFailed"));
notifyApiError(err, i18n.t("toast.support.ratingFailed"));
} finally {
submitting = false;
}
}
</script>
{#if existing}
<Card data-testid="support-csat-done" class="border-chart-green/30 bg-card-green/40">
<CardHeader class="pb-2">
<CardTitle class="text-base">{i18n.t("support.csat.yourRating")}</CardTitle>
<CardDescription>
{i18n.t("support.csat.ratedOutOf", { score: existing.score })}
{#if scoreLabel(existing.score)}
· {scoreLabel(existing.score)}
{/if}
</CardDescription>
</CardHeader>
{#if existing.comment?.trim()}
<CardContent>
<p class="whitespace-pre-wrap text-sm text-foreground">{existing.comment}</p>
</CardContent>
{/if}
</Card>
{:else}
<form
class="space-y-3"
data-testid="support-csat-form"
onsubmit={handleSubmit}
aria-labelledby="support-csat-title"
>
{#if error}
<Alert message={error} />
{/if}
<Card>
<CardHeader>
<CardTitle id="support-csat-title" class="text-base"
>{i18n.t("support.csat.howDidWeDo")}</CardTitle
>
<CardDescription>
{i18n.t("support.csat.formHelp", { status: String(ticket.status).toLowerCase() })}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<span class="text-sm font-medium text-foreground" id="support-csat-score-label"
>{i18n.t("support.csat.rating")}</span
>
<div
class="flex flex-wrap items-center gap-1"
role="radiogroup"
aria-labelledby="support-csat-score-label"
>
{#each [1, 2, 3, 4, 5] as value (value)}
<button
type="button"
role="radio"
aria-checked={score === value}
aria-label={i18n.t("support.csat.scoreAria", {
value,
label: scoreLabel(value)
})}
disabled={submitting || unavailable}
class="rounded-md p-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 {activeScore >=
value
? 'text-amber-600'
: 'text-muted-foreground hover:text-amber-500'}"
onmouseenter={() => (hoverScore = value)}
onmouseleave={() => (hoverScore = 0)}
onfocus={() => (hoverScore = value)}
onblur={() => (hoverScore = 0)}
onclick={() => (score = value)}
>
<Star
class="h-7 w-7 {activeScore >= value ? 'fill-current' : ''}"
aria-hidden="true"
/>
</button>
{/each}
{#if activeScore}
<span class="ml-2 text-sm text-muted-foreground">
{scoreLabel(activeScore)}
</span>
{/if}
</div>
</div>
{#if score > 0 && score <= 2}
<div class="space-y-1.5">
<Label for="support-csat-comment">{i18n.t("support.csat.whatWentWrong")}</Label>
<Textarea
id="support-csat-comment"
rows={3}
maxlength={2000}
placeholder={i18n.t("support.csat.whatWentWrongPlaceholder")}
bind:value={comment}
disabled={submitting || unavailable}
/>
</div>
{:else if score >= 3}
<div class="space-y-1.5">
<Label for="support-csat-comment">{i18n.t("support.csat.anythingElse")}</Label>
<Textarea
id="support-csat-comment"
rows={2}
maxlength={2000}
placeholder={i18n.t("support.csat.optionalFeedback")}
bind:value={comment}
disabled={submitting || unavailable}
/>
</div>
{/if}
<div class="flex flex-wrap gap-2">
<Button type="submit" disabled={!canSubmit}>
{submitting ? i18n.t("common.submitting") : i18n.t("support.csat.submit")}
</Button>
</div>
</CardContent>
</Card>
</form>
{/if}
@@ -0,0 +1,28 @@
<script lang="ts">
import { systemMode } from "$lib/system-mode.svelte";
const show = $derived(systemMode.mutationsBlocked);
const title = $derived(systemMode.bannerTitle);
const message = $derived(systemMode.bannerMessage);
const tone = $derived(
systemMode.maintenance
? "border-destructive/40 bg-destructive/5 text-foreground"
: "border-chart-amber/50 bg-chart-amber/15 text-foreground"
);
</script>
{#if show}
<div
class="w-full border-b px-4 py-3 {tone}"
role="alert"
aria-live="assertive"
data-testid="system-mode-banner"
data-maintenance={systemMode.maintenance || undefined}
data-read-only={systemMode.readOnly || undefined}
>
<div class="mx-auto flex max-w-6xl flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-3">
<p class="text-sm font-semibold">{title}</p>
<p class="text-sm text-muted-foreground">{message}</p>
</div>
</div>
{/if}
@@ -0,0 +1,272 @@
<script lang="ts">
import { browser } from "$app/environment";
import { page } from "$app/state";
import { onMount } from "svelte";
import { api } from "$lib/api";
import { unwrapList } from "$lib/list";
import { i18n } from "$lib/i18n";
import {
formatJobErrorText,
formatJobStatusLabel,
formatProcessingTypeLabel,
isActiveProcessingJob
} from "$lib/job-status";
import { notifyError, notifySuccess } from "$lib/notify";
import type { ListResponse, ProcessingJob } from "$lib/types";
import { Badge, Progress, buttonClasses } from "$lib/components/ui";
import { ChevronDown, ChevronUp, Layers } from "@lucide/svelte";
const POLL_MS = 2_000;
const RETRY_MS = 10_000;
/** Keep the float visible briefly after the last active job finishes (UX + short E2E windows). */
const LINGER_MS = 6_000;
let jobs = $state<ProcessingJob[]>([]);
let displayJobs = $state<ProcessingJob[]>([]);
let collapsed = $state(false);
let timer: number | undefined;
let lingerTimer: number | undefined;
let abort: AbortController | null = null;
let gen = 0;
/** Jobs observed as active this session — terminal toasts only for transitions we watched. */
const watchedActiveIds = new Set<string>();
const toastedTerminalIds = new Set<string>();
const activeJobs = $derived(jobs.filter((j) => isActiveProcessingJob(j.status)));
const visible = $derived(
displayJobs.length > 0 && !page.url.pathname.startsWith("/processing")
);
const headline = $derived(
displayJobs.length === 1
? i18n.t("taskIndicator.activeOne")
: i18n.t("taskIndicator.activeMany", {
count: Math.max(displayJobs.length, activeJobs.length)
})
);
const previewJobs = $derived(displayJobs.slice(0, 3));
function isTerminalSuccess(status: string | null | undefined): boolean {
const s = (status ?? "").toLowerCase().trim();
return s === "completed" || s === "success" || s === "done";
}
function isTerminalFailure(status: string | null | undefined): boolean {
const s = (status ?? "").toLowerCase().trim();
return s === "failed" || s === "error";
}
function isTerminalCancelled(status: string | null | undefined): boolean {
const s = (status ?? "").toLowerCase().trim();
return s === "cancelled" || s === "canceled";
}
/** Fire ai_done / ai_fail toasts when a watched active job reaches a terminal status. */
function notifyTerminalTransitions(nextJobs: ProcessingJob[]) {
for (const job of nextJobs) {
const id = String(job.id ?? "").trim();
if (!id) continue;
if (isActiveProcessingJob(job.status)) {
watchedActiveIds.add(id);
continue;
}
if (!watchedActiveIds.has(id) || toastedTerminalIds.has(id)) {
if (isTerminalCancelled(job.status) || isTerminalSuccess(job.status) || isTerminalFailure(job.status)) {
watchedActiveIds.delete(id);
}
continue;
}
watchedActiveIds.delete(id);
if (isTerminalCancelled(job.status)) continue;
toastedTerminalIds.add(id);
const typeLabel = formatProcessingTypeLabel(job.processing_type ?? job.type);
const viewAction = { label: i18n.t("taskIndicator.view"), href: "/processing" };
if (isTerminalSuccess(job.status)) {
notifySuccess(i18n.t("toast.processing.jobCompleted"), {
alert: "ai_done",
description: typeLabel,
actions: [viewAction],
duration: 10000
});
continue;
}
if (isTerminalFailure(job.status)) {
const detail = formatJobErrorText(job.error);
notifyError(i18n.t("toast.processing.jobFailed"), {
alert: "ai_fail",
description: detail || typeLabel,
actions: [viewAction],
duration: 12000
});
}
}
if (toastedTerminalIds.size > 200) {
toastedTerminalIds.clear();
}
}
function syncDisplay(nextJobs: ProcessingJob[]) {
notifyTerminalTransitions(nextJobs);
const active = nextJobs.filter((j) => isActiveProcessingJob(j.status));
if (active.length > 0) {
displayJobs = active.slice(0, 3);
if (lingerTimer !== undefined) {
window.clearTimeout(lingerTimer);
lingerTimer = undefined;
}
return;
}
if (displayJobs.length === 0 || lingerTimer !== undefined) return;
lingerTimer = window.setTimeout(() => {
displayJobs = [];
lingerTimer = undefined;
}, LINGER_MS);
}
function jobProgress(job: ProcessingJob): { pct: number; done: number; total: 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 { pct: 0, done, total: 0 };
return { pct: Math.min(100, Math.round((done / total) * 100)), done, total };
}
function jobTitle(job: ProcessingJob): string {
return formatProcessingTypeLabel(job.processing_type ?? job.type);
}
async function refresh(signal?: AbortSignal) {
const myGen = ++gen;
try {
const payload = await api<ListResponse<ProcessingJob>>("/api/processing/jobs?limit=50", {
signal
});
if (myGen !== gen) return true;
jobs = unwrapList(payload);
syncDisplay(jobs);
return true;
} catch (err) {
if (myGen !== gen) return true;
// Aborted fetches are normal on unmount / overlapping ticks — do not enter the
// slow retry backoff or the indicator stays blank across short-lived jobs.
const aborted =
signal?.aborted ||
(err instanceof DOMException && err.name === "AbortError") ||
(err instanceof Error && err.name === "AbortError");
return aborted;
}
}
function schedule(ms: number) {
if (timer !== undefined) window.clearInterval(timer);
timer = window.setInterval(() => {
void tick();
}, ms);
}
async function tick() {
if (document.visibilityState === "hidden") return;
abort?.abort();
const ac = new AbortController();
abort = ac;
const ok = await refresh(ac.signal);
if (ac.signal.aborted) return;
schedule(ok ? POLL_MS : RETRY_MS);
}
onMount(() => {
if (!browser) return;
const ac = new AbortController();
void (async () => {
const ok = await refresh(ac.signal);
if (ac.signal.aborted) return;
schedule(ok ? POLL_MS : RETRY_MS);
})();
const onVis = () => {
if (document.visibilityState === "visible") void tick();
};
document.addEventListener("visibilitychange", onVis);
const boot = window.setTimeout(() => {
if (!ac.signal.aborted) void tick();
}, 750);
return () => {
window.clearTimeout(boot);
if (lingerTimer !== undefined) window.clearTimeout(lingerTimer);
ac.abort();
abort?.abort();
if (timer !== undefined) window.clearInterval(timer);
document.removeEventListener("visibilitychange", onVis);
};
});
</script>
{#if visible}
<div
class="pointer-events-none fixed bottom-4 right-4 z-40 flex w-[min(100vw-2rem,22rem)] flex-col items-stretch gap-2"
role="status"
aria-live="polite"
data-testid="task-status-indicator"
>
<div
class="pointer-events-auto overflow-hidden rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-background/90"
>
<div class="flex items-start gap-2 border-b border-border px-3 py-2.5">
<Layers class="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">{i18n.t("taskIndicator.title")}</p>
<p class="text-xs text-muted-foreground">{headline}</p>
</div>
<button
type="button"
class="rounded-md p-1 text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded={!collapsed}
aria-label={collapsed ? i18n.t("taskIndicator.expand") : i18n.t("taskIndicator.collapse")}
onclick={() => (collapsed = !collapsed)}
>
{#if collapsed}
<ChevronUp class="h-3.5 w-3.5" aria-hidden="true" />
{:else}
<ChevronDown class="h-3.5 w-3.5" aria-hidden="true" />
{/if}
</button>
</div>
{#if !collapsed}
<ul class="divide-y divide-border">
{#each previewJobs as job (job.id)}
{@const { pct, done, total } = jobProgress(job)}
<li class="space-y-1.5 px-3 py-2.5">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-xs font-medium">{jobTitle(job)}</p>
<Badge variant="outline" class="shrink-0 capitalize">
{formatJobStatusLabel(job.status)}
</Badge>
</div>
{#if total > 0}
<Progress value={pct} class="" />
<p class="text-[11px] tabular-nums text-muted-foreground">
{i18n.t("taskIndicator.progressCounts", { done, total })}
</p>
<span class="sr-only">{i18n.t("taskIndicator.progressAria", { pct })}</span>
{/if}
</li>
{/each}
</ul>
<div class="border-t border-border px-3 py-2">
<a href="/processing" class={buttonClasses("outline", "sm", "w-full justify-center")}>
{i18n.t("taskIndicator.view")}
</a>
</div>
{:else}
<div class="px-3 py-2">
<a
href="/processing"
class="text-xs font-medium text-link underline-offset-4 hover:underline"
>
{i18n.t("taskIndicator.view")}
</a>
</div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
import { Moon, Sun } from "@lucide/svelte";
import { theme } from "$lib/theme.svelte";
import { i18n } from "$lib/i18n";
let {
class: className = "inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
}: { class?: string } = $props();
const label = $derived(
theme.isDark ? i18n.t("theme.switchToLight") : i18n.t("theme.switchToDark")
);
</script>
<button
type="button"
class={className}
aria-label={label}
title={label}
data-testid="theme-toggle"
data-tour="theme-toggle"
onclick={() => theme.toggle()}
>
{#if theme.isDark}
<Sun class="h-[18px] w-[18px]" aria-hidden="true" />
{:else}
<Moon class="h-[18px] w-[18px]" aria-hidden="true" />
{/if}
</button>
@@ -0,0 +1,102 @@
<script lang="ts">
import { onMount } from "svelte";
import { Button, buttonClasses } from "$lib/components/ui";
import { CONTACT_SALES_HREF } from "$lib/components/pricing/pricing-data";
import { trackEvent } from "$lib/analytics";
import { i18n } from "$lib/i18n";
let {
tone = "info",
title,
message,
primaryHref = "/pricing",
primaryLabel,
showSales = false,
primaryOnClick = undefined,
analyticsReason = "banner",
analyticsFeature = undefined as string | undefined
}: {
tone?: "info" | "warning" | "danger";
title: string;
message: string;
primaryHref?: string;
primaryLabel?: string;
showSales?: boolean;
primaryOnClick?: (() => void) | undefined;
analyticsReason?: "402" | "feature_gate" | "banner" | string;
analyticsFeature?: string | undefined;
} = $props();
const resolvedPrimaryLabel = $derived(primaryLabel ?? i18n.t("upgradeBanner.comparePlans"));
const shell = $derived(
tone === "danger"
? "border-destructive/40 bg-destructive/5"
: tone === "warning"
? "border-chart-amber/50 bg-chart-amber/15"
: "border-primary/30 bg-card-blue"
);
onMount(() => {
trackEvent("upgrade_prompt_shown", {
reason: analyticsReason,
route: typeof window !== "undefined" ? window.location.pathname : "",
...(analyticsFeature ? { feature: analyticsFeature } : {})
});
});
function trackPrimaryCta() {
trackEvent("upgrade_cta_clicked", {
cta_location: "upgrade_banner",
route: typeof window !== "undefined" ? window.location.pathname : ""
});
}
function onPrimaryClick() {
trackPrimaryCta();
primaryOnClick?.();
}
function onSalesClick() {
trackEvent("contact_sales_clicked", {
cta_location: "upgrade_banner",
destination: "form"
});
}
</script>
<div
class="rounded-lg border px-4 py-3 {shell}"
role="status"
aria-label="{title}. {message}"
data-testid="upgrade-banner"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<p class="text-sm font-semibold" data-testid="upgrade-banner-title">{title}</p>
<p class="text-sm text-muted-foreground" data-testid="upgrade-banner-message">{message}</p>
</div>
<div class="flex flex-shrink-0 flex-wrap gap-2">
{#if primaryOnClick}
<Button size="sm" onclick={onPrimaryClick}>{resolvedPrimaryLabel}</Button>
{:else}
<a
href={primaryHref}
class={buttonClasses("default", "sm")}
onclick={trackPrimaryCta}
>
{resolvedPrimaryLabel}
</a>
{/if}
{#if showSales}
<a
href={`${CONTACT_SALES_HREF}?source=upgrade`}
class={buttonClasses("outline", "sm")}
onclick={onSalesClick}
>
{i18n.t("upgradeBanner.talkToSales")}
</a>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,235 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { Building2, Check, ChevronDown, Star, Undo2, UserRound } from "@lucide/svelte";
import { api, ApiError } from "$lib/api";
import { notifyApiError } from "$lib/notify";
import type { MeResponse, SwitchableUser } from "$lib/types";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from "$lib/components/ui";
let {
me
}: {
me: MeResponse;
} = $props();
let switching = $state(false);
let menuOpen = $state(false);
let loadingUsers = $state(false);
let users = $state<SwitchableUser[]>([]);
let loadError = $state("");
let unavailable = $state(false);
const currentUserId = $derived(me.user?.id ?? "");
const currentEmail = $derived((me.user?.email ?? "").trim());
const companyName = $derived((me.company?.name ?? "").trim() || i18n.t("switcher.noCompany"));
const companyHint = $derived.by(() => {
const n = companyName;
if (/^platform demo$/i.test(n) || /^demo$/i.test(n)) return "Platform Demo";
if (n === "Local Demo Co" || /^a1(\s|$)/i.test(n)) return "A1 Slovenija";
return n;
});
const impersonating = $derived(Boolean(me.impersonating));
const impersonatorEmail = $derived((me.impersonator?.email ?? "").trim() || "admin");
const currentSwitchable = $derived(users.find((u) => u.id === currentUserId) ?? null);
/** Prefer API label, then DB name, then email — never hang on raw @legacy.local alone when name/label exist. */
function displayLabel(user: SwitchableUser | null | undefined): string {
if (!user) return "";
const label = (user.label ?? "").trim();
if (label) return label;
const name = (user.name ?? "").trim();
if (name) return name;
return (user.email ?? "").trim();
}
const triggerLabel = $derived.by(() => {
const primary = displayLabel(currentSwitchable);
if (primary) {
return `${primary} · ${currentSwitchable?.company_label || companyHint}`;
}
const meName = (me.user?.name ?? "").trim();
if (meName) {
return `${meName} · ${companyHint}`;
}
if (currentEmail) {
return `${currentEmail} · ${companyHint}`;
}
return companyHint;
});
/** Compact label for narrow headers — full string stays in title / aria-label. */
const triggerLabelShort = $derived.by(() => {
const company = (currentSwitchable?.company_label || companyHint).trim();
if (company) return company;
return triggerLabel;
});
const grouped = $derived.by(() => {
const map = new Map<string, SwitchableUser[]>();
for (const u of users) {
const key = (u.company_label || u.company_name || i18n.t("switcher.unknownCompany")).trim();
const list = map.get(key) ?? [];
list.push(u);
map.set(key, list);
}
return [...map.entries()];
});
$effect(() => {
if (menuOpen) void ensureUsersLoaded();
});
async function ensureUsersLoaded() {
if (unavailable || loadingUsers) return;
// Reload when labels are missing (stale/old API payload) so hard-refresh isn't required after API restart.
const hasLabels = users.some((u) => Boolean((u.label ?? "").trim() || (u.name ?? "").trim()));
if (users.length > 0 && hasLabels) return;
loadingUsers = true;
loadError = "";
try {
const res = await api<{ users?: SwitchableUser[] }>("/api/admin/dev/switchable-users");
users = res.users ?? [];
} catch (err) {
if (err instanceof ApiError && (err.status === 404 || err.status === 403)) {
unavailable = true;
loadError = i18n.t("switcher.unavailable");
} else {
loadError = i18n.t("switcher.loadFailed");
notifyApiError(err, i18n.t("toast.requestFailed"), { title: i18n.t("toast.switcher.loadUsersFailed") });
}
} finally {
loadingUsers = false;
}
}
async function switchToUser(userId: string) {
if (!userId || userId === currentUserId || switching) return;
switching = true;
try {
await api(`/api/admin/users/${userId}/impersonate`, { method: "POST", body: {} });
window.location.assign(window.location.pathname + window.location.search);
} catch (err) {
switching = false;
if (err instanceof ApiError && err.status === 404) {
unavailable = true;
}
notifyApiError(err, i18n.t("toast.requestFailed"), { title: i18n.t("toast.switcher.switchFailed") });
}
}
async function returnToActor() {
if (!impersonating || switching) return;
switching = true;
try {
await api("/api/admin/dev/stop-impersonate", { method: "POST", body: {} });
window.location.assign("/dashboard");
} catch (err) {
switching = false;
notifyApiError(err, i18n.t("toast.requestFailed"), { title: i18n.t("toast.switcher.returnFailed") });
}
}
function optionTitle(user: SwitchableUser): string {
const parts = [displayLabel(user), user.subtitle, user.email, user.company_label || user.company_name];
return [...new Set(parts.filter(Boolean))].join(" · ");
}
</script>
{#if !unavailable}
<div class="w-full min-w-0 max-w-full">
<DropdownMenu
bind:open={menuOpen}
align="end"
class="max-h-[min(24rem,70vh)] min-w-[min(20rem,calc(100vw-1.5rem))] max-w-[min(30rem,calc(100vw-1.5rem))] overflow-y-auto"
>
{#snippet trigger({ open, toggle })}
<button
type="button"
class="inline-flex h-10 w-full max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 text-xs font-medium text-foreground transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60 sm:h-8 sm:px-2.5"
onclick={toggle}
disabled={switching}
aria-label={i18n.t("switcher.switchUserAria", { label: triggerLabel })}
aria-expanded={open}
aria-haspopup="menu"
data-tour="user-switcher"
title={triggerLabel}
>
{#if impersonating}
<UserRound class="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
{:else}
<Building2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{/if}
<span class="min-w-0 truncate">{switching ? i18n.t("switcher.switching") : triggerLabelShort}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
{/snippet}
{#if impersonating}
<DropdownMenuItem onclick={() => void returnToActor()}>
<Undo2 class="h-3.5 w-3.5 text-primary" />
<span class="truncate">{i18n.t("switcher.returnTo", { email: impersonatorEmail })}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/if}
<DropdownMenuLabel>{i18n.t("switcher.switchUser")}</DropdownMenuLabel>
{#if loadingUsers}
<div class="px-2 py-1.5 text-xs text-muted-foreground">{i18n.t("switcher.loadingUsers")}</div>
{:else if loadError}
<div class="px-2 py-1.5 text-xs text-destructive">{loadError}</div>
{:else if users.length === 0}
<div class="px-2 py-1.5 text-xs text-muted-foreground">{i18n.t("switcher.empty")}</div>
{:else}
{#each grouped as [groupName, groupUsers] (groupName)}
<DropdownMenuSeparator />
<DropdownMenuLabel>
<span class="text-[10px] uppercase tracking-wide text-muted-foreground">{groupName}</span>
</DropdownMenuLabel>
{#each groupUsers as user (user.id)}
<DropdownMenuItem
onclick={() => void switchToUser(user.id)}
disabled={user.id === currentUserId}
class={`items-start ${user.id === currentUserId ? "bg-accent/60" : ""} ${user.is_primary_a1 ? "border-l-2 border-amber-500/70" : ""}`}
>
{#if user.id === currentUserId}
<Check class="mt-0.5 h-3.5 w-3.5 text-primary" />
{:else if user.is_primary_a1}
<Star class="mt-0.5 h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
{:else}
<span class="mt-0.5 inline-block h-3.5 w-3.5"></span>
{/if}
<span class="min-w-0 flex-1" title={optionTitle(user)}>
<span class="block truncate font-medium leading-tight">{displayLabel(user)}</span>
{#if user.subtitle}
<span class="block truncate text-[11px] text-muted-foreground leading-tight">
{user.subtitle}
</span>
{:else if user.email && displayLabel(user) !== user.email}
<span class="block truncate text-[11px] text-muted-foreground leading-tight">
{user.email}
</span>
{/if}
</span>
</DropdownMenuItem>
{/each}
{/each}
{/if}
</DropdownMenu>
</div>
{:else}
<div
class="inline-flex h-8 max-w-[12rem] items-center gap-1.5 rounded-md border border-transparent px-2 text-xs font-medium text-muted-foreground sm:max-w-[18rem] sm:px-2.5"
data-tour="company-switcher"
title={companyHint}
>
<Building2 class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{companyHint}</span>
</div>
{/if}
@@ -0,0 +1,96 @@
<script lang="ts" generics="T">
import type { Snippet } from "svelte";
/**
* Fixed-row virtual window for long scroll lists (Svelte 5).
* Prefer with server pagination — only mounts visible rows + overscan.
* Use table-layout/fixed column widths in the row markup to avoid scroll jitter.
*/
let {
items,
estimateSize = 80,
overscan = 8,
class: className = "",
getKey,
children
}: {
items: T[];
estimateSize?: number;
overscan?: number;
class?: string;
getKey?: (item: T, index: number) => string | number;
children: Snippet<[T, number]>;
} = $props();
let scrollEl = $state<HTMLDivElement | null>(null);
let scrollTop = $state(0);
let viewportHeight = $state(0);
const rowHeight = $derived(Math.max(1, Math.floor(estimateSize) || 80));
const totalHeight = $derived(items.length * rowHeight);
const startIndex = $derived(
Math.max(0, Math.floor(scrollTop / rowHeight) - Math.max(0, overscan))
);
const endIndex = $derived(
Math.min(
items.length,
Math.ceil((scrollTop + Math.max(viewportHeight, rowHeight)) / rowHeight) +
Math.max(0, overscan)
)
);
const visible = $derived(items.slice(startIndex, endIndex));
const offsetY = $derived(startIndex * rowHeight);
function onScroll() {
if (scrollEl) scrollTop = scrollEl.scrollTop;
}
$effect(() => {
const el = scrollEl;
if (!el) return;
const update = () => {
viewportHeight = el.clientHeight;
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
});
$effect(() => {
// New page / filter result — restart at the top of the window.
const first = items[0];
const headKey =
first !== undefined && getKey ? String(getKey(first, 0)) : String(items.length);
void headKey;
if (scrollEl) {
scrollEl.scrollTop = 0;
scrollTop = 0;
}
});
$effect(() => {
// Reset scroll window when the item set shrinks below the prior offset.
void items.length;
if (scrollEl && scrollEl.scrollTop > totalHeight) {
scrollEl.scrollTop = Math.max(0, totalHeight - viewportHeight);
scrollTop = scrollEl.scrollTop;
}
});
</script>
<div
bind:this={scrollEl}
class={className}
style="overflow-y: auto;"
onscroll={onScroll}
data-virtual-list
>
<div style="height: {totalHeight}px; position: relative;" data-virtual-spacer>
<div style="transform: translateY({offsetY}px);" data-virtual-window>
{#each visible as item, i (getKey ? getKey(item, startIndex + i) : startIndex + i)}
{@render children(item, startIndex + i)}
{/each}
</div>
</div>
</div>
@@ -0,0 +1,208 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
adminPlanVisibilityBadgeVariant,
adminPlanVisibilityLabel,
classifyAdminPlanVisibility,
countAdminPlansByVisibility,
filterAdminPlans,
maxProductsLabel,
type AdminBillingPlan,
type AdminPlanFilter
} from "$lib/admin-billing-plans";
import { formatCredits } from "$lib/utils";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell
} from "$lib/components/ui";
import { Pencil, Shield, UserPlus } from "@lucide/svelte";
type Props = {
plans?: AdminBillingPlan[];
busy?: boolean;
onEdit?: (plan: AdminBillingPlan) => void;
onAssign?: (plan: AdminBillingPlan) => void;
onPermissions?: (planId: number | string) => void;
};
let {
plans = [],
busy = false,
onEdit,
onAssign,
onPermissions
}: Props = $props();
let search = $state("");
let filter = $state<AdminPlanFilter>("catalog");
const counts = $derived(countAdminPlansByVisibility(plans));
const filtered = $derived(filterAdminPlans(plans, { filter, search }));
const filterTabs = $derived(
(
[
{ id: "catalog", labelKey: "admin.plans.filter.catalog" },
{ id: "public", labelKey: "admin.plans.filter.public" },
{ id: "legacy", labelKey: "admin.plans.filter.legacy" },
{ id: "custom", labelKey: "admin.plans.filter.custom" },
{ id: "hidden", labelKey: "admin.plans.filter.hidden" },
{ id: "all", labelKey: "admin.plans.filter.all" }
] as const
).map((tab) => ({ id: tab.id as AdminPlanFilter, label: i18n.t(tab.labelKey) }))
);
</script>
<Card>
<CardHeader class="gap-4 space-y-0 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1.5">
<CardTitle>{i18n.t("admin.plans.title")}</CardTitle>
<CardDescription>
{i18n.t("admin.plans.description")}
</CardDescription>
</div>
<div class="relative w-full sm:max-w-xs">
<label class="sr-only" for="admin-plans-search">{i18n.t("admin.plans.searchAria")}</label>
<Input
id="admin-plans-search"
type="search"
placeholder={i18n.t("admin.plans.searchPlaceholder")}
bind:value={search}
autocomplete="off"
/>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div
class="flex flex-wrap gap-2"
role="group"
aria-label={i18n.t("admin.plans.filterAria")}
>
{#each filterTabs as tab}
{@const active = filter === tab.id}
{@const count = counts[tab.id]}
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {active
? 'border-foreground bg-foreground text-background'
: 'border-border bg-card text-foreground hover:bg-muted'}"
aria-pressed={active}
onclick={() => (filter = tab.id)}
>
{tab.label}
<span
class="rounded-full px-1.5 py-0.5 text-xs tabular-nums {active
? 'bg-background/20 text-background'
: 'bg-muted text-foreground'}"
>
{count}
</span>
</button>
{/each}
</div>
{#if plans.length === 0}
<EmptyState message={i18n.t("empty.plans.none")} />
{:else if filtered.length === 0}
<EmptyState message={i18n.t("empty.plans.noMatch")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.plans.col.plan")}</TableHead>
<TableHead>{i18n.t("admin.plans.col.visibility")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.plans.col.monthlyCredits")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.plans.col.maxProducts")}</TableHead>
<TableHead class="hidden md:table-cell">{i18n.t("admin.plans.col.term")}</TableHead>
<TableHead stickyRight>{i18n.t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each filtered as plan (String(plan.id))}
{@const kind = classifyAdminPlanVisibility(plan)}
<TableRow>
<TableCell>
<p class="font-medium text-foreground">{plan.name}</p>
{#if plan.description}
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{plan.description}
</p>
{/if}
{#if plan.is_custom && kind === "public"}
<p class="mt-1">
<Badge variant="secondary">{i18n.t("admin.plans.customPackageFlag")}</Badge>
</p>
{/if}
</TableCell>
<TableCell>
<Badge variant={adminPlanVisibilityBadgeVariant(kind)}>
{adminPlanVisibilityLabel(kind)}
</Badge>
</TableCell>
<TableCell class="hidden tabular-nums text-foreground lg:table-cell">
{formatCredits(Number(plan.monthly_credits ?? 0))}
</TableCell>
<TableCell class="hidden tabular-nums text-foreground lg:table-cell">
{maxProductsLabel(plan)}
</TableCell>
<TableCell class="hidden capitalize text-muted-foreground md:table-cell">
{plan.term || i18n.t("status.emDash")}
</TableCell>
<TableCell stickyRight>
<div class="flex flex-wrap items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
disabled={busy}
onclick={() => onEdit?.(plan)}
aria-label={i18n.t("admin.plans.editAria", { name: plan.name })}
>
<Pencil class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.plans.edit")}</span>
</Button>
<Button
variant="ghost"
size="sm"
disabled={busy}
onclick={() => onAssign?.(plan)}
aria-label={i18n.t("admin.plans.assignAria", { name: plan.name })}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.plans.assign")}</span>
</Button>
<Button
variant="ghost"
size="sm"
disabled={busy}
onclick={() => onPermissions?.(plan.id)}
aria-label={i18n.t("admin.plans.permissionsAria", { name: plan.name })}
>
<Shield class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.plans.permissions")}</span>
</Button>
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.plans.showing", { filtered: filtered.length, total: plans.length })}
</p>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,356 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { failureMessage } from "$lib/api";
import {
loadFeatureGates,
saveFeatureGates,
setGlobalSection,
type FeatureGatesPayload,
type FeatureMap
} from "$lib/admin-plan-permissions";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import {
PLAN_FEATURE_CATALOG,
PLAN_FEATURE_SECTIONS,
featuresForSection,
sectionLabel
} from "$lib/plan-feature-catalog";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Checkbox,
Input,
Label,
Select
} from "$lib/components/ui";
let loading = $state(true);
let busy = $state(false);
let togglingKey = $state("");
let error = $state("");
let success = $state("");
let gates = $state<FeatureGatesPayload>({ sections: {}, features: {} });
let filterSection = $state("all");
let search = $state("");
let collapsedSections = $state<Record<string, boolean>>({});
const visibleSections = $derived(
PLAN_FEATURE_SECTIONS.filter((s) => filterSection === "all" || filterSection === s.key)
);
const sectionsOff = $derived(
PLAN_FEATURE_SECTIONS.filter((s) => gates.sections[s.key] === false).length
);
const featuresOff = $derived(
PLAN_FEATURE_CATALOG.filter((f) => gates.features[f.key] === false).length
);
onMount(() => {
void bootstrap();
});
async function bootstrap() {
loading = true;
error = "";
try {
const res = await loadFeatureGates();
gates = res.gates;
} catch (err) {
error = failureMessage(err, i18n.t("admin.gates.loadFailed"));
} finally {
loading = false;
}
}
function sectionEnabled(section: string): boolean {
return gates.sections[section] !== false;
}
function featureEnabled(key: string): boolean {
return gates.features[key] !== false;
}
function featureVisible(key: string, label: string): boolean {
const q = search.trim().toLowerCase();
if (!q) return true;
return key.toLowerCase().includes(q) || label.toLowerCase().includes(q);
}
function toggleCollapsed(section: string) {
collapsedSections = {
...collapsedSections,
[section]: !collapsedSections[section]
};
}
/** Plain snapshot — structuredClone fails on Svelte $state proxies (DataCloneError). */
function snapshotGates(src: FeatureGatesPayload): FeatureGatesPayload {
return {
sections: { ...src.sections },
features: { ...src.features }
};
}
async function onToggleSection(section: string, enabled: boolean, applyFeatures: boolean) {
if (busy) return;
busy = true;
error = "";
success = "";
const prev = snapshotGates(gates);
gates = {
sections: { ...gates.sections, [section]: enabled },
features: { ...gates.features }
};
try {
gates = await setGlobalSection(section, enabled, { applyFeatures });
void planCapabilities.refresh(undefined, true);
const label = sectionLabel(section);
const state = enabled
? i18n.t("admin.gates.state.enabled")
: i18n.t("admin.gates.state.disabled");
success = applyFeatures
? i18n.t("admin.gates.sectionSuccessWithFeatures", { label, state })
: i18n.t("admin.gates.sectionSuccess", { label, state });
} catch (err) {
gates = prev;
error = failureMessage(err, i18n.t("admin.gates.sectionFailed"));
} finally {
busy = false;
}
}
async function toggleFeature(key: string) {
if (busy) return;
const prev = snapshotGates(gates);
const nextVal = !featureEnabled(key);
const nextFeatures: FeatureMap = { ...gates.features, [key]: nextVal };
gates = { sections: { ...gates.sections }, features: nextFeatures };
busy = true;
togglingKey = key;
error = "";
success = "";
try {
gates = await saveFeatureGates({
sections: gates.sections,
features: nextFeatures
});
void planCapabilities.refresh(undefined, true);
const state = nextVal
? i18n.t("admin.gates.state.enabled")
: i18n.t("admin.gates.state.disabled");
success = i18n.t("admin.gates.featureSuccess", { key, state });
} catch (err) {
gates = prev;
error = failureMessage(err, i18n.t("admin.gates.saveFailed"));
} finally {
busy = false;
togglingKey = "";
}
}
</script>
{#if loading}
<Spinner />
{:else}
<div class="space-y-4">
<Alert message={error} />
<Alert tone="success" message={success} />
<div
class="rounded-md border border-border bg-muted/20 px-3 py-2 text-sm text-muted-foreground"
role="note"
>
<strong class="font-medium text-foreground">{i18n.t("admin.gates.noteStrong")}</strong>
{i18n.t("admin.gates.noteBody", {
combo: i18n.t("admin.gates.combo"),
permissions: i18n.t("admin.gates.permissionsTab")
})}
</div>
<div class="flex flex-wrap gap-2 text-xs text-muted-foreground">
<Badge variant={sectionsOff > 0 ? "secondary" : "outline"}>
{sectionsOff === 1
? i18n.t("admin.gates.sectionsOff", { count: sectionsOff })
: i18n.t("admin.gates.sectionsOffPlural", { count: sectionsOff })}
</Badge>
<Badge variant={featuresOff > 0 ? "secondary" : "outline"}>
{featuresOff === 1
? i18n.t("admin.gates.featuresOff", { count: featuresOff })
: i18n.t("admin.gates.featuresOffPlural", { count: featuresOff })}
</Badge>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<Card class="border-border bg-card text-card-foreground">
<CardHeader class="space-y-4">
<div class="space-y-1">
<CardTitle class="text-foreground">{i18n.t("admin.gates.title")}</CardTitle>
<CardDescription class="text-muted-foreground">
{i18n.t("admin.gates.description")}
</CardDescription>
</div>
<div class="flex flex-wrap items-end gap-3">
<div class="min-w-[10rem] space-y-1">
<Label for="global-section-filter">{i18n.t("admin.gates.section")}</Label>
<Select id="global-section-filter" bind:value={filterSection} disabled={busy}>
<option value="all">{i18n.t("admin.gates.allSections")}</option>
{#each PLAN_FEATURE_SECTIONS as s}
<option value={s.key}>{s.label}</option>
{/each}
</Select>
</div>
<div class="min-w-[10rem] flex-1 space-y-1">
<Label for="global-feature-search">{i18n.t("admin.gates.search")}</Label>
<Input
id="global-feature-search"
placeholder={i18n.t("admin.gates.searchPlaceholder")}
bind:value={search}
disabled={busy}
/>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
{#each visibleSections as section}
{@const items = featuresForSection(section.key).filter((f) =>
featureVisible(f.key, f.label)
)}
{#if items.length > 0 || filterSection === section.key}
<section
class="rounded-md border border-border"
aria-labelledby={`global-sec-${section.key}`}
>
<div
class="flex flex-wrap items-center justify-between gap-2 border-b border-border bg-muted/20 px-3 py-2"
>
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-2 text-left"
onclick={() => toggleCollapsed(section.key)}
aria-expanded={!collapsedSections[section.key]}
>
<span
id={`global-sec-${section.key}`}
class="text-sm font-semibold text-foreground"
>
{section.label}
</span>
{#if !sectionEnabled(section.key)}
<Badge variant="secondary">{i18n.t("admin.gates.sectionOff")}</Badge>
{/if}
</button>
</div>
{#if !collapsedSections[section.key]}
{#if items.length === 0}
<p class="px-3 py-2 text-xs text-muted-foreground">
{i18n.t("admin.gates.noMatch")}
</p>
{:else}
<ul class="divide-y divide-border">
{#each items as feature}
{@const checked = featureEnabled(feature.key)}
<li class="flex items-start gap-3 px-3 py-2 hover:bg-accent/40">
<Checkbox
id={`global-feat-${feature.key}`}
role="switch"
checked={checked}
disabled={busy}
aria-label={i18n.t("admin.gates.featureAria", {
label: feature.label
})}
onchange={() => toggleFeature(feature.key)}
class="mt-0.5"
/>
<label
for={`global-feat-${feature.key}`}
class="min-w-0 flex-1 cursor-pointer"
>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-foreground">
{feature.label}
</span>
{#if togglingKey === feature.key}
<span class="text-xs text-muted-foreground"
>{i18n.t("common.saving")}</span
>
{/if}
</div>
<div class="font-mono text-xs text-muted-foreground">
{feature.key}
</div>
</label>
</li>
{/each}
</ul>
{/if}
{/if}
</section>
{/if}
{/each}
</CardContent>
</Card>
<Card class="h-fit border-border bg-card text-card-foreground">
<CardHeader>
<CardTitle class="text-foreground">{i18n.t("admin.gates.sectionsTitle")}</CardTitle>
<CardDescription class="text-muted-foreground">
{i18n.t("admin.gates.sectionsDesc")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-2">
{#each PLAN_FEATURE_SECTIONS as section}
{@const on = sectionEnabled(section.key)}
<div class="rounded-md border border-border p-3">
<div class="flex items-start gap-3">
<Checkbox
id={`global-section-${section.key}`}
role="switch"
checked={on}
disabled={busy}
aria-label={i18n.t("admin.gates.sectionAria", { label: section.label })}
onchange={() => onToggleSection(section.key, !on, false)}
class="mt-0.5"
/>
<div class="min-w-0 flex-1">
<label
for={`global-section-${section.key}`}
class="text-sm font-medium text-foreground"
>
{section.label}
</label>
<p class="text-xs text-muted-foreground">{section.key}</p>
<div class="mt-2 flex flex-wrap gap-1">
<Button
variant="ghost"
size="sm"
disabled={busy}
onclick={() => onToggleSection(section.key, true, true)}
>
{i18n.t("admin.gates.enableSectionFeatures")}
</Button>
<Button
variant="ghost"
size="sm"
disabled={busy}
onclick={() => onToggleSection(section.key, false, true)}
>
{i18n.t("admin.gates.disableSectionFeatures")}
</Button>
</div>
</div>
</div>
</div>
{/each}
</CardContent>
</Card>
</div>
</div>
{/if}

Some files were not shown because too many files have changed in this diff Show More