Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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" }
|
||||
];
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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-");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, {});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" }
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 2–4 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 what’s 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}`;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 50–60 characters.";
|
||||
}
|
||||
|
||||
export function getDefaultMetaDescription(): string {
|
||||
return "Summarize the product and 1–2 standout features. Aim for 120–155 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 2–4 sentences explaining benefits and relevant specs for this section.";
|
||||
case "ul":
|
||||
return "List 3–6 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
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user