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,32 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host --strictPort",
|
||||
"build": "vite build",
|
||||
"start": "node build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo '' && node scripts/copy-rapidoc-ui.mjs",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "node --experimental-strip-types --disable-warning=ExperimentalWarning --test src/lib/*.test.ts src/lib/server/*.test.ts src/lib/i18n/*.test.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "5.5.7",
|
||||
"@sveltejs/kit": "2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "7.3.0",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"svelte": "5.56.8",
|
||||
"svelte-check": "4.7.5",
|
||||
"tailwindcss": "4.3.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "1.30.0",
|
||||
"playwright-core": "1.62.1",
|
||||
"rapidoc": "9.3.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Apply English-value → locale phrase map across all packs (force update).
|
||||
* Phrase map: scripts/phrase-map.json { "English": { es, fr, de, it, pt, nl, pl, ja } }
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { SAME_AS_EN } from "./locale-extra.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const messagesDir = path.join(__dirname, "../src/lib/i18n/messages");
|
||||
const locales = ["es", "fr", "de", "it", "pt", "nl", "pl", "ja"];
|
||||
const mapPath = path.join(__dirname, "phrase-map.json");
|
||||
|
||||
function parseDict(source) {
|
||||
const dict = {};
|
||||
const re = /"([^"\\]+)"\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:`(?:\\.|[^`\\])*`))/gs;
|
||||
let m;
|
||||
while ((m = re.exec(source))) {
|
||||
const key = m[1];
|
||||
const raw = m[2];
|
||||
dict[key] = raw.startsWith("`")
|
||||
? raw.slice(1, -1).replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\n/g, "\n")
|
||||
: JSON.parse(raw);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
function emitPack(exportName, comment, dict, keyOrder) {
|
||||
const lines = [
|
||||
`import type { MessageDict } from "./types";`,
|
||||
``,
|
||||
`/** ${comment} */`,
|
||||
`export const ${exportName}: MessageDict = {`
|
||||
];
|
||||
for (const key of keyOrder) {
|
||||
lines.push(`\t${JSON.stringify(key)}: ${JSON.stringify(dict[key])},`);
|
||||
}
|
||||
lines.push(`};`, ``);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const comments = {
|
||||
es: "Spanish (es) UI pack — keys must stay in sync with en.ts.",
|
||||
fr: "French (fr) UI pack — keys must stay in sync with en.ts.",
|
||||
de: "German (de) UI pack — keys must stay in sync with en.ts.",
|
||||
it: "Italian (it) UI pack — keys must stay in sync with en.ts.",
|
||||
pt: "Portuguese (pt) UI pack — keys must stay in sync with en.ts.",
|
||||
nl: "Dutch (nl) UI pack — keys must stay in sync with en.ts.",
|
||||
pl: "Polish (pl) UI pack — keys must stay in sync with en.ts.",
|
||||
ja: "Japanese (ja) UI pack — keys must stay in sync with en.ts."
|
||||
};
|
||||
|
||||
if (!fs.existsSync(mapPath)) {
|
||||
console.error("missing phrase-map.json — run fill scripts first");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const phraseMap = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
||||
const en = parseDict(fs.readFileSync(path.join(messagesDir, "en.ts"), "utf8"));
|
||||
const keyOrder = Object.keys(en);
|
||||
|
||||
let hit = 0;
|
||||
let miss = 0;
|
||||
for (const code of locales) {
|
||||
const existing = parseDict(fs.readFileSync(path.join(messagesDir, `${code}.ts`), "utf8"));
|
||||
const dict = {};
|
||||
for (const key of keyOrder) {
|
||||
const enVal = en[key];
|
||||
const mapped = phraseMap[enVal]?.[code];
|
||||
if (typeof mapped === "string" && mapped.trim()) {
|
||||
dict[key] = mapped;
|
||||
if (existing[key] === enVal || !existing[key]) hit++;
|
||||
} else if (typeof existing[key] === "string" && existing[key].trim()) {
|
||||
dict[key] = existing[key];
|
||||
} else {
|
||||
dict[key] = enVal;
|
||||
if (!SAME_AS_EN.has(key)) miss++;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(messagesDir, `${code}.ts`),
|
||||
emitPack(code, comments[code], dict, keyOrder),
|
||||
"utf8"
|
||||
);
|
||||
const same = keyOrder.filter((k) => dict[k] === en[k] && !SAME_AS_EN.has(k)).length;
|
||||
console.log(code, "keys", keyOrder.length, "sameAsEn", same);
|
||||
}
|
||||
console.log("phrase hits(approx)", hit, "pads", miss);
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Build locale-extra-rest.mjs from English→locale phrase map for keys still English in non-es packs.
|
||||
* Also covers new settings/dashboard strings for all locales including es.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { EXTRA as EXTRA_ES } from "./locale-extra-es.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function parseMessageDict(source) {
|
||||
const dict = {};
|
||||
const re = /"([^"\\]+)"\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:`(?:\\.|[^`\\])*`))/gs;
|
||||
let m;
|
||||
while ((m = re.exec(source))) {
|
||||
const key = m[1];
|
||||
const raw = m[2];
|
||||
dict[key] = raw.startsWith("`")
|
||||
? raw.slice(1, -1).replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\n/g, "\n")
|
||||
: JSON.parse(raw);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
const en = parseMessageDict(
|
||||
fs.readFileSync(path.resolve(__dirname, "../src/lib/i18n/messages/en.ts"), "utf8")
|
||||
);
|
||||
|
||||
/** English source string → { fr, de, it, pt, nl, pl, ja, es? } */
|
||||
const BY_EN = JSON.parse(fs.readFileSync(path.join(__dirname, "phrase-map.json"), "utf8"));
|
||||
|
||||
const codes = ["es", "fr", "de", "it", "pt", "nl", "pl", "ja"];
|
||||
const EXTRA = Object.fromEntries(codes.map((c) => [c, {}]));
|
||||
|
||||
// 1) Seed non-es from Spanish extras via English lookup of the same key
|
||||
for (const [key, esText] of Object.entries(EXTRA_ES.es ?? {})) {
|
||||
const enText = en[key];
|
||||
if (!enText) continue;
|
||||
EXTRA.es[key] = esText;
|
||||
const mapped = BY_EN[enText];
|
||||
if (!mapped) continue;
|
||||
for (const code of ["fr", "de", "it", "pt", "nl", "pl", "ja"]) {
|
||||
if (mapped[code]) EXTRA[code][key] = mapped[code];
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Apply phrase map to every en key (fills new settings/dashboard too)
|
||||
for (const [key, enText] of Object.entries(en)) {
|
||||
const mapped = BY_EN[enText];
|
||||
if (!mapped) continue;
|
||||
for (const code of codes) {
|
||||
if (mapped[code]) EXTRA[code][key] = mapped[code];
|
||||
}
|
||||
}
|
||||
|
||||
const out = `/** Auto-built by build-phrase-extra.mjs — do not hand-edit; update phrase-map.json. */\nexport const EXTRA = ${JSON.stringify(EXTRA, null, 2)};\n`;
|
||||
fs.writeFileSync(path.join(__dirname, "locale-extra-rest.mjs"), out, "utf8");
|
||||
|
||||
for (const code of codes) {
|
||||
console.log(code, Object.keys(EXTRA[code]).length);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DOCS_GUIDE_TREE, validateDocsGuideTree } from "../src/lib/docs-guide/index.ts";
|
||||
|
||||
const errors = validateDocsGuideTree(DOCS_GUIDE_TREE);
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`docs-guide ok nodes=${Object.keys(DOCS_GUIDE_TREE.nodes).length} version=${DOCS_GUIDE_TREE.version}`
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createGzip } from "node:zlib";
|
||||
import { createReadStream, createWriteStream, cpSync, mkdirSync, existsSync, rmSync } from "node:fs";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = join(here, "..");
|
||||
const dest = join(webRoot, "static", "vendor", "rapidoc");
|
||||
|
||||
const pkgRoot = dirname(require.resolve("rapidoc/package.json"));
|
||||
const source = join(pkgRoot, "dist", "rapidoc-min.js");
|
||||
if (!existsSync(source)) {
|
||||
throw new Error(`rapidoc missing dist/rapidoc-min.js at ${source}`);
|
||||
}
|
||||
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
mkdirSync(dest, { recursive: true });
|
||||
|
||||
const destJs = join(dest, "rapidoc-min.js");
|
||||
cpSync(source, destJs);
|
||||
|
||||
// Precompress for hooks.server.ts (Accept-Encoding: gzip).
|
||||
await pipeline(createReadStream(destJs), createGzip({ level: 9 }), createWriteStream(`${destJs}.gz`));
|
||||
|
||||
console.log(`Copied rapidoc dist/rapidoc-min.js → ${dest}`);
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Count keys identical to English across locale packs.
|
||||
* Brands/loanwords/symbols are excluded from the "unfinished" total.
|
||||
*
|
||||
* Run: node apps/web/scripts/count-identical-to-en.mjs
|
||||
* Optional: --json writes apps/web/scripts/_identical-to-en-report.json
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const messagesDir = path.resolve(__dirname, "../src/lib/i18n/messages");
|
||||
const LOCALES = ["es", "fr", "de", "it", "pt", "nl", "pl", "ja"];
|
||||
|
||||
const BRAND_VALUES = new Set([
|
||||
" · upgrade",
|
||||
" (v{version})",
|
||||
"—",
|
||||
".",
|
||||
"…",
|
||||
"· upgrade",
|
||||
"(v{version})",
|
||||
"{count} file",
|
||||
"{count} options",
|
||||
"{credits} credits",
|
||||
"{used} / {max} SKUs",
|
||||
"→",
|
||||
"Account",
|
||||
"Action",
|
||||
"Actions",
|
||||
"Admin",
|
||||
"AI",
|
||||
"Alias",
|
||||
"Allowlist",
|
||||
"Analytics",
|
||||
"API",
|
||||
"Assistant",
|
||||
"Audience",
|
||||
"Azure OpenAI",
|
||||
"Base",
|
||||
"Black Friday 2026",
|
||||
"Business",
|
||||
"Client ID",
|
||||
"cm",
|
||||
"colleague@example.com",
|
||||
"Commerce",
|
||||
"Compliance",
|
||||
"CSV",
|
||||
"Date",
|
||||
"Description",
|
||||
"Descrybe",
|
||||
"Dimension",
|
||||
"Directory",
|
||||
"Docs:",
|
||||
"EAN/GTIN",
|
||||
"Enterprise",
|
||||
"EPREL",
|
||||
"EPREL ID",
|
||||
"EUR",
|
||||
"Exact",
|
||||
"Exports",
|
||||
"FAQ auto-match",
|
||||
"Feeds",
|
||||
"Format",
|
||||
"Format:",
|
||||
"format: csv",
|
||||
"Fuzzy",
|
||||
"Google OAuth",
|
||||
"GPS",
|
||||
"Growth",
|
||||
"Host",
|
||||
"https://…",
|
||||
"IA",
|
||||
"ID",
|
||||
"Insight",
|
||||
"Integration",
|
||||
"item: {path}",
|
||||
"Job {id}",
|
||||
"kg",
|
||||
"Knowledge",
|
||||
"Knowledge base",
|
||||
"Last Updated",
|
||||
"Legacy",
|
||||
"Live",
|
||||
"Mail",
|
||||
"Mapping",
|
||||
"Marketing",
|
||||
"Material",
|
||||
"Media",
|
||||
"Model",
|
||||
"Name (A-Z)",
|
||||
"Name (Z-A)",
|
||||
"Namespace",
|
||||
"No",
|
||||
"Normalize",
|
||||
"Notes",
|
||||
"OAuth, EPREL, Pinecone, Stripe, feeds",
|
||||
"OK",
|
||||
"Ollama",
|
||||
"OpenAI",
|
||||
"OpenAPI",
|
||||
"OpenRouter",
|
||||
"Ops",
|
||||
"Optional",
|
||||
"Parent",
|
||||
"pcs",
|
||||
"Pinecone",
|
||||
"Plan:",
|
||||
"Popular",
|
||||
"Product",
|
||||
"Prompt",
|
||||
"Re: {subject}",
|
||||
"REST",
|
||||
"Reviewer",
|
||||
"reviews",
|
||||
"Reviews: {status}",
|
||||
"SEO",
|
||||
"Service",
|
||||
"Shopify",
|
||||
"Single",
|
||||
"SKU",
|
||||
"Source",
|
||||
"source {source}",
|
||||
"Staff",
|
||||
"Starter",
|
||||
"Stripe",
|
||||
"Sync: {status}",
|
||||
"Tenant vs platform",
|
||||
"Tenants · volume",
|
||||
"Test: {status}",
|
||||
"Ticket",
|
||||
"Tickets",
|
||||
"Timeout",
|
||||
"TSV",
|
||||
"Type",
|
||||
"Uploads",
|
||||
"URL",
|
||||
"via Stripe",
|
||||
"Volume",
|
||||
"W",
|
||||
"WooCommerce",
|
||||
"you@company.com",
|
||||
]);
|
||||
|
||||
function parseMessageDict(source) {
|
||||
const dict = {};
|
||||
const re = /"([^"\\]+)"\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:`(?:\\.|[^`\\])*`))/gs;
|
||||
let m;
|
||||
while ((m = re.exec(source))) {
|
||||
const key = m[1];
|
||||
const raw = m[2];
|
||||
dict[key] = raw.startsWith("`")
|
||||
? raw.slice(1, -1).replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\n/g, "\n")
|
||||
: JSON.parse(raw);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
function isBrandOrLoanword(value) {
|
||||
const raw = String(value ?? "");
|
||||
const t = raw.trim();
|
||||
if (!t) return true;
|
||||
if (BRAND_VALUES.has(raw) || BRAND_VALUES.has(t)) return true;
|
||||
if (/^\{[^}]+\}$/.test(t)) return true;
|
||||
// Short all-caps / code-like tokens kept identical by policy
|
||||
if (/^[A-Z0-9][A-Z0-9._/-]{0,14}$/.test(t) && t === t.toUpperCase()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function load(code) {
|
||||
return parseMessageDict(fs.readFileSync(path.join(messagesDir, `${code}.ts`), "utf8"));
|
||||
}
|
||||
|
||||
const en = load("en");
|
||||
const enKeys = Object.keys(en);
|
||||
const byLocale = {};
|
||||
let totalIdentical = 0;
|
||||
let totalIdenticalExBrand = 0;
|
||||
let totalTranslated = 0;
|
||||
let totalBrandIdentical = 0;
|
||||
|
||||
for (const code of LOCALES) {
|
||||
const pack = load(code);
|
||||
let identical = 0;
|
||||
let identicalExBrand = 0;
|
||||
let brandIdentical = 0;
|
||||
let translated = 0;
|
||||
let missing = 0;
|
||||
const unfinishedSamples = [];
|
||||
for (const key of enKeys) {
|
||||
if (!(key in pack)) {
|
||||
missing += 1;
|
||||
identicalExBrand += 1;
|
||||
if (unfinishedSamples.length < 8) unfinishedSamples.push(key);
|
||||
continue;
|
||||
}
|
||||
if (pack[key] === en[key]) {
|
||||
identical += 1;
|
||||
if (isBrandOrLoanword(en[key])) {
|
||||
brandIdentical += 1;
|
||||
} else {
|
||||
identicalExBrand += 1;
|
||||
if (unfinishedSamples.length < 8) unfinishedSamples.push(`${key}=${JSON.stringify(en[key])}`);
|
||||
}
|
||||
} else {
|
||||
translated += 1;
|
||||
}
|
||||
}
|
||||
byLocale[code] = {
|
||||
keys: Object.keys(pack).length,
|
||||
identical,
|
||||
identicalExBrand,
|
||||
brandIdentical,
|
||||
translated,
|
||||
missing,
|
||||
unfinishedSamples,
|
||||
};
|
||||
totalIdentical += identical;
|
||||
totalIdenticalExBrand += identicalExBrand;
|
||||
totalBrandIdentical += brandIdentical;
|
||||
totalTranslated += translated;
|
||||
console.log(
|
||||
`${code}: identical=${identical} identicalExBrand=${identicalExBrand} brandIdentical=${brandIdentical} translated=${translated} missing=${missing}`,
|
||||
);
|
||||
}
|
||||
|
||||
const report = {
|
||||
enKeys: enKeys.length,
|
||||
locales: LOCALES,
|
||||
byLocale,
|
||||
totals: {
|
||||
identical: totalIdentical,
|
||||
identicalExBrand: totalIdenticalExBrand,
|
||||
brandIdentical: totalBrandIdentical,
|
||||
translated: totalTranslated,
|
||||
avgIdenticalExBrandPerLocale: Math.round(totalIdenticalExBrand / LOCALES.length),
|
||||
},
|
||||
measuredAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log("---");
|
||||
console.log(`enKeys=${report.enKeys}`);
|
||||
console.log(`TOTAL identical=${totalIdentical}`);
|
||||
console.log(`TOTAL identicalExBrand (excl brands)=${totalIdenticalExBrand}`);
|
||||
console.log(`AVG identicalExBrand/locale=${report.totals.avgIdenticalExBrandPerLocale}`);
|
||||
console.log(`TOTAL brandIdentical=${totalBrandIdentical}`);
|
||||
console.log(`TOTAL translated=${totalTranslated}`);
|
||||
|
||||
if (process.argv.includes("--json")) {
|
||||
const out = path.join(__dirname, "_identical-to-en-report.json");
|
||||
fs.writeFileSync(out, JSON.stringify(report, null, 2), "utf8");
|
||||
console.log(`wrote ${out}`);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../src/lib/i18n/messages");
|
||||
|
||||
function countKeys(file) {
|
||||
const s = fs.readFileSync(path.join(dir, file), "utf8");
|
||||
const d = {};
|
||||
const re = /"([^"\\]+)"\s*:/g;
|
||||
let m;
|
||||
while ((m = re.exec(s))) d[m[1]] = 1;
|
||||
return Object.keys(d).length;
|
||||
}
|
||||
|
||||
for (const c of ["en", "es", "fr", "de", "it", "pt", "nl", "pl", "ja"]) {
|
||||
console.log(c, countKeys(`${c}.ts`));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { EXTRA as EXTRA_COMMON, SAME_AS_EN } from "./locale-extra.mjs";
|
||||
import { EXTRA as EXTRA_ES } from "./locale-extra-es.mjs";
|
||||
import { EXTRA as EXTRA_REST } from "./locale-extra-rest.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const enPath = path.resolve(__dirname, "../src/lib/i18n/messages/en.ts");
|
||||
|
||||
function parseMessageDict(source) {
|
||||
const dict = {};
|
||||
const re = /"([^"\\]+)"\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:`(?:\\.|[^`\\])*`))/gs;
|
||||
let m;
|
||||
while ((m = re.exec(source))) {
|
||||
const key = m[1];
|
||||
const raw = m[2];
|
||||
dict[key] = raw.startsWith("`")
|
||||
? raw.slice(1, -1).replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\n/g, "\n")
|
||||
: JSON.parse(raw);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
// Import PACKS by re-running merge logic inline is hard — load from written es/fr instead.
|
||||
const en = parseMessageDict(fs.readFileSync(enPath, "utf8"));
|
||||
const esFile = parseMessageDict(
|
||||
fs.readFileSync(path.resolve(__dirname, "../src/lib/i18n/messages/es.ts"), "utf8")
|
||||
);
|
||||
const frFile = parseMessageDict(
|
||||
fs.readFileSync(path.resolve(__dirname, "../src/lib/i18n/messages/fr.ts"), "utf8")
|
||||
);
|
||||
|
||||
const esMissing = {};
|
||||
const frMissing = {};
|
||||
for (const [k, v] of Object.entries(en)) {
|
||||
if (SAME_AS_EN.has(k)) continue;
|
||||
if (esFile[k] === v) esMissing[k] = v;
|
||||
if (frFile[k] === v) frMissing[k] = v;
|
||||
}
|
||||
fs.writeFileSync(path.join(__dirname, "_es-still-en.json"), JSON.stringify(esMissing, null, 2));
|
||||
fs.writeFileSync(path.join(__dirname, "_fr-still-en.json"), JSON.stringify(frMissing, null, 2));
|
||||
console.log("es still en", Object.keys(esMissing).length);
|
||||
console.log("fr still en", Object.keys(frMissing).length);
|
||||
console.log("en total", Object.keys(en).length);
|
||||
@@ -0,0 +1,25 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const enPath = path.resolve(__dirname, "../src/lib/i18n/messages/en.ts");
|
||||
const outPath = path.resolve(__dirname, "_en-dump.json");
|
||||
|
||||
function parseMessageDict(source) {
|
||||
const dict = {};
|
||||
const re = /"([^"\\]+)"\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:`(?:\\.|[^`\\])*`))/gs;
|
||||
let m;
|
||||
while ((m = re.exec(source))) {
|
||||
const key = m[1];
|
||||
const raw = m[2];
|
||||
dict[key] = raw.startsWith("`")
|
||||
? raw.slice(1, -1).replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\n/g, "\n")
|
||||
: JSON.parse(raw);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
const en = parseMessageDict(fs.readFileSync(enPath, "utf8"));
|
||||
fs.writeFileSync(outPath, JSON.stringify(en, null, 2), "utf8");
|
||||
console.log(`keys=${Object.keys(en).length} -> ${outPath}`);
|
||||
@@ -0,0 +1,199 @@
|
||||
import fs from "node:fs";
|
||||
import { EXTRA as EXTRA_ES } from "./locale-extra-es.mjs";
|
||||
|
||||
function parse(s) {
|
||||
const d = {};
|
||||
const re = /"([^"\\]+)"\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:`(?:\\.|[^`\\])*`))/gs;
|
||||
let m;
|
||||
while ((m = re.exec(s))) {
|
||||
const raw = m[2];
|
||||
d[m[1]] = raw.startsWith("`")
|
||||
? raw.slice(1, -1).replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\n/g, "\n")
|
||||
: JSON.parse(raw);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
const en = parse(fs.readFileSync("../src/lib/i18n/messages/en.ts", "utf8"));
|
||||
const seed = JSON.parse(fs.readFileSync("_phrase-seed.json", "utf8"));
|
||||
|
||||
/** @type {Record<string, {fr:string,de:string,it:string,pt:string,nl:string,pl:string,ja:string}>} */
|
||||
const MORE = {
|
||||
Descrybe: { fr: "Descrybe", de: "Descrybe", it: "Descrybe", pt: "Descrybe", nl: "Descrybe", pl: "Descrybe", ja: "Descrybe" },
|
||||
"—": { fr: "—", de: "—", it: "—", pt: "—", nl: "—", pl: "—", ja: "—" },
|
||||
Marketing: { fr: "Marketing", de: "Marketing", it: "Marketing", pt: "Marketing", nl: "Marketing", pl: "Marketing", ja: "マーケティング" },
|
||||
Exports: { fr: "Exports", de: "Exporte", it: "Esportazioni", pt: "Exportações", nl: "Exports", pl: "Eksporty", ja: "エクスポート" },
|
||||
SEO: { fr: "SEO", de: "SEO", it: "SEO", pt: "SEO", nl: "SEO", pl: "SEO", ja: "SEO" },
|
||||
Admin: { fr: "Admin", de: "Admin", it: "Admin", pt: "Admin", nl: "Admin", pl: "Admin", ja: "管理" },
|
||||
EPREL: { fr: "EPREL", de: "EPREL", it: "EPREL", pt: "EPREL", nl: "EPREL", pl: "EPREL", ja: "EPREL" },
|
||||
"Re: {subject}": { fr: "Re: {subject}", de: "Re: {subject}", it: "Re: {subject}", pt: "Re: {subject}", nl: "Re: {subject}", pl: "Re: {subject}", ja: "Re: {subject}" },
|
||||
"Sign in": { fr: "Se connecter", de: "Anmelden", it: "Accedi", pt: "Iniciar sessão", nl: "Inloggen", pl: "Zaloguj się", ja: "ログイン" },
|
||||
"Use your Descrybe email and password.": { fr: "Utilisez votre e-mail et mot de passe Descrybe.", de: "Verwenden Sie Ihre Descrybe-E-Mail und Ihr Passwort.", it: "Usa la tua email e password Descrybe.", pt: "Utilize o seu e-mail e palavra-passe Descrybe.", nl: "Gebruik uw Descrybe e-mailadres en wachtwoord.", pl: "Użyj adresu e-mail i hasła Descrybe.", ja: "Descrybeのメールアドレスとパスワードを使用してください。" },
|
||||
"Signing in…": { fr: "Connexion…", de: "Anmeldung…", it: "Accesso in corso…", pt: "A iniciar sessão…", nl: "Bezig met inloggen…", pl: "Logowanie…", ja: "ログイン中…" },
|
||||
"Login failed": { fr: "Échec de la connexion", de: "Anmeldung fehlgeschlagen", it: "Accesso non riuscito", pt: "Falha no início de sessão", nl: "Inloggen mislukt", pl: "Logowanie nie powiodło się", ja: "ログインに失敗しました" },
|
||||
"Create company": { fr: "Créer une entreprise", de: "Unternehmen erstellen", it: "Crea azienda", pt: "Criar empresa", nl: "Bedrijf aanmaken", pl: "Utwórz firmę", ja: "会社を作成" },
|
||||
"Accept invite": { fr: "Accepter l'invitation", de: "Einladung annehmen", it: "Accetta invito", pt: "Aceitar convite", nl: "Uitnodiging accepteren", pl: "Zaakceptuj zaproszenie", ja: "招待を承認" },
|
||||
"Set password": { fr: "Définir le mot de passe", de: "Passwort festlegen", it: "Imposta password", pt: "Definir palavra-passe", nl: "Wachtwoord instellen", pl: "Ustaw hasło", ja: "パスワードを設定" },
|
||||
"Company name": { fr: "Nom de l'entreprise", de: "Unternehmensname", it: "Nome azienda", pt: "Nome da empresa", nl: "Bedrijfsnaam", pl: "Nazwa firmy", ja: "会社名" },
|
||||
"Create account": { fr: "Créer un compte", de: "Konto erstellen", it: "Crea account", pt: "Criar conta", nl: "Account aanmaken", pl: "Utwórz konto", ja: "アカウントを作成" },
|
||||
"Creating…": { fr: "Création…", de: "Wird erstellt…", it: "Creazione…", pt: "A criar…", nl: "Bezig met aanmaken…", pl: "Tworzenie…", ja: "作成中…" },
|
||||
"Registration failed": { fr: "Échec de l'inscription", de: "Registrierung fehlgeschlagen", it: "Registrazione non riuscita", pt: "Falha no registo", nl: "Registratie mislukt", pl: "Rejestracja nie powiodła się", ja: "登録に失敗しました" },
|
||||
"Already have an account?": { fr: "Vous avez déjà un compte ?", de: "Haben Sie bereits ein Konto?", it: "Hai già un account?", pt: "Já tem uma conta?", nl: "Heeft u al een account?", pl: "Masz już konto?", ja: "すでにアカウントをお持ちですか?" },
|
||||
"No account?": { fr: "Pas de compte ?", de: "Kein Konto?", it: "Nessun account?", pt: "Sem conta?", nl: "Geen account?", pl: "Brak konta?", ja: "アカウントがありませんか?" },
|
||||
"Admin → Users": { fr: "Admin → Utilisateurs", de: "Admin → Benutzer", it: "Admin → Utenti", pt: "Admin → Utilizadores", nl: "Admin → Gebruikers", pl: "Admin → Użytkownicy", ja: "管理 → ユーザー" },
|
||||
"your email": { fr: "votre e-mail", de: "Ihre E-Mail", it: "la tua email", pt: "o seu e-mail", nl: "uw e-mail", pl: "twój e-mail", ja: "あなたのメール" },
|
||||
Profile: { fr: "Profil", de: "Profil", it: "Profilo", pt: "Perfil", nl: "Profiel", pl: "Profil", ja: "プロフィール" },
|
||||
Email: { fr: "E-mail", de: "E-Mail", it: "Email", pt: "E-mail", nl: "E-mail", pl: "E-mail", ja: "メール" },
|
||||
Member: { fr: "Membre", de: "Mitglied", it: "Membro", pt: "Membro", nl: "Lid", pl: "Członek", ja: "メンバー" },
|
||||
Remove: { fr: "Retirer", de: "Entfernen", it: "Rimuovi", pt: "Remover", nl: "Verwijderen", pl: "Usuń", ja: "削除" },
|
||||
Promote: { fr: "Promouvoir", de: "Befördern", it: "Promuovi", pt: "Promover", nl: "Promoveren", pl: "Awansuj", ja: "昇格" },
|
||||
Demote: { fr: "Rétrograder", de: "Herabstufen", it: "Degrada", pt: "Despromover", nl: "Degraderen", pl: "Degraduj", ja: "降格" },
|
||||
Role: { fr: "Rôle", de: "Rolle", it: "Ruolo", pt: "Papel", nl: "Rol", pl: "Rola", ja: "ロール" },
|
||||
Status: { fr: "Statut", de: "Status", it: "Stato", pt: "Estado", nl: "Status", pl: "Status", ja: "ステータス" },
|
||||
Actions: { fr: "Actions", de: "Aktionen", it: "Azioni", pt: "Ações", nl: "Acties", pl: "Akcje", ja: "操作" },
|
||||
Name: { fr: "Nom", de: "Name", it: "Nome", pt: "Nome", nl: "Naam", pl: "Nazwa", ja: "名前" },
|
||||
Key: { fr: "Clé", de: "Schlüssel", it: "Chiave", pt: "Chave", nl: "Sleutel", pl: "Klucz", ja: "キー" },
|
||||
Plan: { fr: "Offre", de: "Plan", it: "Piano", pt: "Plano", nl: "Plan", pl: "Plan", ja: "プラン" },
|
||||
Used: { fr: "Utilisé", de: "Verbraucht", it: "Usato", pt: "Usado", nl: "Gebruikt", pl: "Użyte", ja: "使用済み" },
|
||||
Trial: { fr: "Essai", de: "Testphase", it: "Prova", pt: "Teste", nl: "Proef", pl: "Okres próbny", ja: "トライアル" },
|
||||
Export: { fr: "Exporter", de: "Exportieren", it: "Esporta", pt: "Exportar", nl: "Exporteren", pl: "Eksportuj", ja: "エクスポート" },
|
||||
"API Keys": { fr: "Clés API", de: "API-Schlüssel", it: "Chiavi API", pt: "Chaves API", nl: "API-sleutels", pl: "Klucze API", ja: "APIキー" },
|
||||
"Copy link": { fr: "Copier le lien", de: "Link kopieren", it: "Copia link", pt: "Copiar link", nl: "Link kopiëren", pl: "Kopiuj link", ja: "リンクをコピー" },
|
||||
"Send invite": { fr: "Envoyer l'invitation", de: "Einladung senden", it: "Invia invito", pt: "Enviar convite", nl: "Uitnodiging verzenden", pl: "Wyślij zaproszenie", ja: "招待を送信" },
|
||||
"Invite user": { fr: "Inviter un utilisateur", de: "Benutzer einladen", it: "Invita utente", pt: "Convidar utilizador", nl: "Gebruiker uitnodigen", pl: "Zaproś użytkownika", ja: "ユーザーを招待" },
|
||||
"First Name": { fr: "Prénom", de: "Vorname", it: "Nome", pt: "Nome próprio", nl: "Voornaam", pl: "Imię", ja: "名" },
|
||||
"Last Name": { fr: "Nom", de: "Nachname", it: "Cognome", pt: "Apelido", nl: "Achternaam", pl: "Nazwisko", ja: "姓" },
|
||||
"Team Members": { fr: "Membres de l'équipe", de: "Teammitglieder", it: "Membri del team", pt: "Membros da equipa", nl: "Teamleden", pl: "Członkowie zespołu", ja: "チームメンバー" },
|
||||
"Personal Information": { fr: "Informations personnelles", de: "Persönliche Daten", it: "Informazioni personali", pt: "Informação pessoal", nl: "Persoonlijke gegevens", pl: "Dane osobowe", ja: "個人情報" },
|
||||
"Update your personal details": { fr: "Mettez à jour vos informations personnelles", de: "Aktualisieren Sie Ihre persönlichen Daten", it: "Aggiorna i tuoi dati personali", pt: "Atualize os seus dados pessoais", nl: "Werk uw persoonlijke gegevens bij", pl: "Zaktualizuj swoje dane osobowe", ja: "個人情報を更新" },
|
||||
"Your first name": { fr: "Votre prénom", de: "Ihr Vorname", it: "Il tuo nome", pt: "O seu nome próprio", nl: "Uw voornaam", pl: "Twoje imię", ja: "名" },
|
||||
"Your last name": { fr: "Votre nom", de: "Ihr Nachname", it: "Il tuo cognome", pt: "O seu apelido", nl: "Uw achternaam", pl: "Twoje nazwisko", ja: "姓" },
|
||||
"Profile updated.": { fr: "Profil mis à jour.", de: "Profil aktualisiert.", it: "Profilo aggiornato.", pt: "Perfil atualizado.", nl: "Profiel bijgewerkt.", pl: "Profil zaktualizowany.", ja: "プロフィールを更新しました。" },
|
||||
"Could not update profile": { fr: "Impossible de mettre à jour le profil", de: "Profil konnte nicht aktualisiert werden", it: "Impossibile aggiornare il profilo", pt: "Não foi possível atualizar o perfil", nl: "Profiel kon niet worden bijgewerkt", pl: "Nie można zaktualizować profilu", ja: "プロフィールを更新できませんでした" },
|
||||
"Saving…": { fr: "Enregistrement…", de: "Speichern…", it: "Salvataggio…", pt: "A guardar…", nl: "Bezig met opslaan…", pl: "Zapisywanie…", ja: "保存中…" },
|
||||
"Accepting…": { fr: "Acceptation…", de: "Wird angenommen…", it: "Accettazione…", pt: "A aceitar…", nl: "Bezig met accepteren…", pl: "Akceptowanie…", ja: "承認中…" },
|
||||
"Open dashboard": { fr: "Ouvrir le tableau de bord", de: "Dashboard öffnen", it: "Apri dashboard", pt: "Abrir painel", nl: "Dashboard openen", pl: "Otwórz panel", ja: "ダッシュボードを開く" },
|
||||
"Company settings": { fr: "Paramètres de l'entreprise", de: "Unternehmenseinstellungen", it: "Impostazioni azienda", pt: "Definições da empresa", nl: "Bedrijfsinstellingen", pl: "Ustawienia firmy", ja: "会社の設定" },
|
||||
"Go to sign in": { fr: "Aller à la connexion", de: "Zur Anmeldung", it: "Vai all'accesso", pt: "Ir para início de sessão", nl: "Naar inloggen", pl: "Przejdź do logowania", ja: "ログインへ" },
|
||||
"Password saved": { fr: "Mot de passe enregistré", de: "Passwort gespeichert", it: "Password salvata", pt: "Palavra-passe guardada", nl: "Wachtwoord opgeslagen", pl: "Hasło zapisane", ja: "パスワードを保存しました" },
|
||||
"You're on the team": { fr: "Vous faites partie de l'équipe", de: "Sie sind im Team", it: "Fai parte del team", pt: "Já faz parte da equipa", nl: "U bent in het team", pl: "Jesteś w zespole", ja: "チームに参加しました" },
|
||||
"Enable fields": { fr: "Activer les champs", de: "Felder aktivieren", it: "Abilita campi", pt: "Ativar campos", nl: "Velden inschakelen", pl: "Włącz pola", ja: "フィールドを有効化" },
|
||||
"Process products": { fr: "Traiter les produits", de: "Produkte verarbeiten", it: "Elabora prodotti", pt: "Processar produtos", nl: "Producten verwerken", pl: "Przetwarzaj produkty", ja: "商品を処理" },
|
||||
"Connect feed": { fr: "Connecter un flux", de: "Feed verbinden", it: "Collega feed", pt: "Ligar feed", nl: "Feed koppelen", pl: "Podłącz feed", ja: "フィードを接続" },
|
||||
"Upload CSV": { fr: "Téléverser un CSV", de: "CSV hochladen", it: "Carica CSV", pt: "Carregar CSV", nl: "CSV uploaden", pl: "Prześlij CSV", ja: "CSVをアップロード" },
|
||||
"View plans": { fr: "Voir les offres", de: "Pläne ansehen", it: "Vedi i piani", pt: "Ver planos", nl: "Plannen bekijken", pl: "Zobacz plany", ja: "プランを見る" },
|
||||
"Compare plans": { fr: "Comparer les offres", de: "Pläne vergleichen", it: "Confronta i piani", pt: "Comparar planos", nl: "Plannen vergelijken", pl: "Porównaj plany", ja: "プランを比較" },
|
||||
"Start a job": { fr: "Démarrer une tâche", de: "Job starten", it: "Avvia un processo", pt: "Iniciar uma tarefa", nl: "Een job starten", pl: "Uruchom zadanie", ja: "ジョブを開始" },
|
||||
"Resume tutorial": { fr: "Reprendre le tutoriel", de: "Tutorial fortsetzen", it: "Riprendi tutorial", pt: "Retomar tutorial", nl: "Tutorial hervatten", pl: "Wznów samouczek", ja: "チュートリアルを再開" },
|
||||
"Restart tutorial": { fr: "Relancer le tutoriel", de: "Tutorial neu starten", it: "Riavvia tutorial", pt: "Reiniciar tutorial", nl: "Tutorial opnieuw starten", pl: "Uruchom ponownie samouczek", ja: "チュートリアルを再開する" },
|
||||
"Open products": { fr: "Ouvrir les produits", de: "Produkte öffnen", it: "Apri prodotti", pt: "Abrir produtos", nl: "Producten openen", pl: "Otwórz produkty", ja: "商品を開く" },
|
||||
"Welcome to {name}": { fr: "Bienvenue sur {name}", de: "Willkommen bei {name}", it: "Benvenuto in {name}", pt: "Bem-vindo a {name}", nl: "Welkom bij {name}", pl: "Witamy w {name}", ja: "{name} へようこそ" },
|
||||
"Company Settings": { fr: "Paramètres de l'entreprise", de: "Unternehmenseinstellungen", it: "Impostazioni azienda", pt: "Definições da empresa", nl: "Bedrijfsinstellingen", pl: "Ustawienia firmy", ja: "会社の設定" },
|
||||
"Active company": { fr: "Entreprise active", de: "Aktives Unternehmen", it: "Azienda attiva", pt: "Empresa ativa", nl: "Actief bedrijf", pl: "Aktywna firma", ja: "アクティブな会社" },
|
||||
"Credits overview": { fr: "Aperçu des crédits", de: "Credit-Übersicht", it: "Panoramica crediti", pt: "Resumo de créditos", nl: "Credits-overzicht", pl: "Przegląd kredytów", ja: "クレジット概要" },
|
||||
"Company Information": { fr: "Informations sur l'entreprise", de: "Unternehmensinformationen", it: "Informazioni azienda", pt: "Informação da empresa", nl: "Bedrijfsgegevens", pl: "Informacje o firmie", ja: "会社情報" },
|
||||
"Update your company details": { fr: "Mettez à jour les détails de votre entreprise", de: "Aktualisieren Sie Ihre Unternehmensdaten", it: "Aggiorna i dettagli dell'azienda", pt: "Atualize os detalhes da empresa", nl: "Werk uw bedrijfsgegevens bij", pl: "Zaktualizuj dane firmy", ja: "会社の詳細を更新" },
|
||||
"Company Name": { fr: "Nom de l'entreprise", de: "Unternehmensname", it: "Nome azienda", pt: "Nome da empresa", nl: "Bedrijfsnaam", pl: "Nazwa firmy", ja: "会社名" },
|
||||
"Your company name": { fr: "Le nom de votre entreprise", de: "Ihr Unternehmensname", it: "Il nome della tua azienda", pt: "O nome da sua empresa", nl: "Uw bedrijfsnaam", pl: "Nazwa Twojej firmy", ja: "会社名" },
|
||||
"Content Settings": { fr: "Paramètres de contenu", de: "Inhaltseinstellungen", it: "Impostazioni contenuti", pt: "Definições de conteúdo", nl: "Contentinstellingen", pl: "Ustawienia treści", ja: "コンテンツ設定" },
|
||||
"Merge products with the same GTIN": { fr: "Fusionner les produits avec le même GTIN", de: "Produkte mit derselben GTIN zusammenführen", it: "Unisci prodotti con lo stesso GTIN", pt: "Unir produtos com o mesmo GTIN", nl: "Producten met dezelfde GTIN samenvoegen", pl: "Scal produkty z tym samym GTIN", ja: "同じGTINの商品をマージ" },
|
||||
"Email integration": { fr: "Intégration e-mail", de: "E-Mail-Integration", it: "Integrazione email", pt: "Integração de e-mail", nl: "E-mailintegratie", pl: "Integracja e-mail", ja: "メール連携" },
|
||||
"AI integrations": { fr: "Intégrations IA", de: "KI-Integrationen", it: "Integrazioni IA", pt: "Integrações de IA", nl: "AI-integraties", pl: "Integracje AI", ja: "AI連携" },
|
||||
"Operator alerts": { fr: "Alertes opérateur", de: "Operator-Benachrichtigungen", it: "Avvisi operatore", pt: "Alertas do operador", nl: "Operator-meldingen", pl: "Alerty operatora", ja: "オペレーターアラート" },
|
||||
"In-app toasts": { fr: "Toasts dans l'application", de: "In-App-Toasts", it: "Toast in-app", pt: "Toasts na aplicação", nl: "In-app toasts", pl: "Powiadomienia w aplikacji", ja: "アプリ内トースト" },
|
||||
"Email alerts": { fr: "Alertes e-mail", de: "E-Mail-Benachrichtigungen", it: "Avvisi email", pt: "Alertas por e-mail", nl: "E-mailmeldingen", pl: "Alerty e-mail", ja: "メールアラート" },
|
||||
"Create API Key": { fr: "Créer une clé API", de: "API-Schlüssel erstellen", it: "Crea chiave API", pt: "Criar chave API", nl: "API-sleutel maken", pl: "Utwórz klucz API", ja: "APIキーを作成" },
|
||||
"Create API key": { fr: "Créer une clé API", de: "API-Schlüssel erstellen", it: "Crea chiave API", pt: "Criar chave API", nl: "API-sleutel maken", pl: "Utwórz klucz API", ja: "APIキーを作成" },
|
||||
"API key": { fr: "Clé API", de: "API-Schlüssel", it: "Chiave API", pt: "Chave API", nl: "API-sleutel", pl: "Klucz API", ja: "APIキー" },
|
||||
"Key name": { fr: "Nom de la clé", de: "Schlüsselname", it: "Nome chiave", pt: "Nome da chave", nl: "Sleutelnaam", pl: "Nazwa klucza", ja: "キー名" },
|
||||
"Store it somewhere safe.": { fr: "Conservez-la en lieu sûr.", de: "Bewahren Sie ihn sicher auf.", it: "Conservala in un posto sicuro.", pt: "Guarde-a num local seguro.", nl: "Bewaar hem op een veilige plek.", pl: "Przechowuj go w bezpiecznym miejscu.", ja: "安全な場所に保管してください。" },
|
||||
"Last Used": { fr: "Dernière utilisation", de: "Zuletzt verwendet", it: "Ultimo utilizzo", pt: "Última utilização", nl: "Laatst gebruikt", pl: "Ostatnio użyty", ja: "最終使用" },
|
||||
"Expires {date}": { fr: "Expire le {date}", de: "Läuft ab am {date}", it: "Scade il {date}", pt: "Expira a {date}", nl: "Verloopt op {date}", pl: "Wygasa {date}", ja: "{date} に期限切れ" },
|
||||
"Invite for {email}.": { fr: "Invitation pour {email}.", de: "Einladung für {email}.", it: "Invito per {email}.", pt: "Convite para {email}.", nl: "Uitnodiging voor {email}.", pl: "Zaproszenie dla {email}.", ja: "{email} 宛の招待です。" },
|
||||
"Joined / expires": { fr: "Inscription / expiration", de: "Beigetreten / läuft ab", it: "Iscrizione / scadenza", pt: "Adesão / expira", nl: "Toegetreden / verloopt", pl: "Dołączył / wygasa", ja: "参加 / 期限" },
|
||||
"Make admin": { fr: "Rendre admin", de: "Zum Admin machen", it: "Rendi admin", pt: "Tornar admin", nl: "Admin maken", pl: "Uczyń adminem", ja: "管理者にする" },
|
||||
"Make member": { fr: "Rendre membre", de: "Zum Mitglied machen", it: "Rendi membro", pt: "Tornar membro", nl: "Lid maken", pl: "Uczyń członkiem", ja: "メンバーにする" },
|
||||
"Revoke invite": { fr: "Révoquer l'invitation", de: "Einladung widerrufen", it: "Revoca invito", pt: "Revogar convite", nl: "Uitnodiging intrekken", pl: "Unieważnij zaproszenie", ja: "招待を取り消す" },
|
||||
"Member actions": { fr: "Actions du membre", de: "Mitgliederaktionen", it: "Azioni del membro", pt: "Ações do membro", nl: "Acties voor lid", pl: "Akcje członka", ja: "メンバーの操作" },
|
||||
"No teammates yet": { fr: "Pas encore de coéquipiers", de: "Noch keine Teammitglieder", it: "Ancora nessun compagno di team", pt: "Ainda sem colegas", nl: "Nog geen teamleden", pl: "Brak jeszcze członków zespołu", ja: "まだチームメンバーがいません" },
|
||||
"Share accept link": { fr: "Partager le lien d'acceptation", de: "Annahmelink teilen", it: "Condividi link di accettazione", pt: "Partilhar link de aceitação", nl: "Acceptatielink delen", pl: "Udostępnij link akceptacji", ja: "承認リンクを共有" },
|
||||
"Accept link copied.": { fr: "Lien d'acceptation copié.", de: "Annahmelink kopiert.", it: "Link di accettazione copiato.", pt: "Link de aceitação copiado.", nl: "Acceptatielink gekopieerd.", pl: "Skopiowano link akceptacji.", ja: "承認リンクをコピーしました。" },
|
||||
"Invite teammate": { fr: "Inviter un coéquipier", de: "Teammitglied einladen", it: "Invita un collega", pt: "Convidar colega", nl: "Teamlid uitnodigen", pl: "Zaproś członka zespołu", ja: "チームメイトを招待" },
|
||||
"colleague@example.com": { fr: "collegue@exemple.com", de: "kollege@beispiel.com", it: "collega@esempio.com", pt: "colega@exemplo.com", nl: "collega@voorbeeld.com", pl: "kolega@przyklad.com", ja: "colleague@example.com" },
|
||||
"Go to Billing": { fr: "Aller à la facturation", de: "Zur Abrechnung", it: "Vai alla fatturazione", pt: "Ir para Faturação", nl: "Naar facturering", pl: "Przejdź do rozliczeń", ja: "請求へ" },
|
||||
"Dashboard actions": { fr: "Actions du tableau de bord", de: "Dashboard-Aktionen", it: "Azioni dashboard", pt: "Ações do painel", nl: "Dashboardacties", pl: "Akcje panelu", ja: "ダッシュボードの操作" },
|
||||
"No catalog data yet": { fr: "Pas encore de données catalogue", de: "Noch keine Katalogdaten", it: "Ancora nessun dato di catalogo", pt: "Ainda sem dados de catálogo", nl: "Nog geen catalogusgegevens", pl: "Brak jeszcze danych katalogu", ja: "まだカタログデータがありません" },
|
||||
"Connect feed anyway": { fr: "Connecter un flux quand même", de: "Feed trotzdem verbinden", it: "Collega comunque un feed", pt: "Ligar feed mesmo assim", nl: "Feed toch koppelen", pl: "Podłącz feed mimo to", ja: "それでもフィードを接続" },
|
||||
"Import and map": { fr: "Importer et mapper", de: "Importieren und zuordnen", it: "Importa e mappa", pt: "Importar e mapear", nl: "Importeren en mappen", pl: "Importuj i mapuj", ja: "インポートとマップ" },
|
||||
"Browse and process": { fr: "Parcourir et traiter", de: "Durchsuchen und verarbeiten", it: "Sfoglia ed elabora", pt: "Explorar e processar", nl: "Bladeren en verwerken", pl: "Przeglądaj i przetwarzaj", ja: "閲覧と処理" },
|
||||
"Monitor tasks": { fr: "Surveiller les tâches", de: "Aufgaben überwachen", it: "Monitora le attività", pt: "Monitorizar tarefas", nl: "Taken monitoren", pl: "Monitoruj zadania", ja: "タスクを監視" },
|
||||
"Templates & download": { fr: "Modèles et téléchargement", de: "Vorlagen & Download", it: "Modelli e download", pt: "Modelos e transferência", nl: "Sjablonen & download", pl: "Szablony i pobieranie", ja: "テンプレートとダウンロード" },
|
||||
"Sync a sample": { fr: "Synchroniser un échantillon", de: "Stichprobe synchronisieren", it: "Sincronizza un campione", pt: "Sincronizar uma amostra", nl: "Een steekproef synchroniseren", pl: "Synchronizuj próbkę", ja: "サンプルを同期" },
|
||||
"Map source fields": { fr: "Mapper les champs source", de: "Quellfelder zuordnen", it: "Mappa campi origine", pt: "Mapear campos de origem", nl: "Bronvelden mappen", pl: "Mapuj pola źródła", ja: "ソースフィールドをマップ" },
|
||||
"Add or connect a source": { fr: "Ajouter ou connecter une source", de: "Quelle hinzufügen oder verbinden", it: "Aggiungi o collega un'origine", pt: "Adicionar ou ligar uma origem", nl: "Bron toevoegen of koppelen", pl: "Dodaj lub podłącz źródło", ja: "ソースを追加または接続" },
|
||||
"Latest processing jobs": { fr: "Dernières tâches de traitement", de: "Neueste Verarbeitungsjobs", it: "Ultimi processi di elaborazione", pt: "Últimas tarefas de processamento", nl: "Laatste verwerkingsjobs", pl: "Najnowsze zadania przetwarzania", ja: "最新の処理ジョブ" },
|
||||
"Credits running low": { fr: "Crédits bientôt épuisés", de: "Credits werden knapp", it: "Crediti in esaurimento", pt: "Créditos a esgotar-se", nl: "Credits raken op", pl: "Kończą się kredyty", ja: "クレジットが少なくなっています" },
|
||||
"Product limit reached": { fr: "Limite de produits atteinte", de: "Produktlimit erreicht", it: "Limite prodotti raggiunto", pt: "Limite de produtos atingido", nl: "Productlimiet bereikt", pl: "Osiągnięto limit produktów", ja: "商品上限に達しました" },
|
||||
"You're out of AI credits": { fr: "Vous n'avez plus de crédits IA", de: "Ihre KI-Credits sind aufgebraucht", it: "Hai esaurito i crediti IA", pt: "Ficou sem créditos de IA", nl: "Uw AI-credits zijn op", pl: "Skończyły Ci się kredyty AI", ja: "AIクレジットがなくなりました" },
|
||||
"You're on the Free plan": { fr: "Vous êtes sur l'offre Free", de: "Sie nutzen den Free-Plan", it: "Sei sul piano Free", pt: "Está no plano Free", nl: "U zit op het Free-plan", pl: "Korzystasz z planu Free", ja: "Freeプランをご利用中です" },
|
||||
"Trial · {plan}": { fr: "Essai · {plan}", de: "Testphase · {plan}", it: "Prova · {plan}", pt: "Teste · {plan}", nl: "Proef · {plan}", pl: "Okres próbny · {plan}", ja: "トライアル · {plan}" },
|
||||
"Revoke this invitation?": { fr: "Révoquer cette invitation ?", de: "Diese Einladung widerrufen?", it: "Revocare questo invito?", pt: "Revogar este convite?", nl: "Deze uitnodiging intrekken?", pl: "Unieważnić to zaproszenie?", ja: "この招待を取り消しますか?" },
|
||||
"Invitation revoked.": { fr: "Invitation révoquée.", de: "Einladung widerrufen.", it: "Invito revocato.", pt: "Convite revogado.", nl: "Uitnodiging ingetrokken.", pl: "Zaproszenie unieważnione.", ja: "招待を取り消しました。" },
|
||||
"Could not send invite": { fr: "Impossible d'envoyer l'invitation", de: "Einladung konnte nicht gesendet werden", it: "Impossibile inviare l'invito", pt: "Não foi possível enviar o convite", nl: "Uitnodiging kon niet worden verzonden", pl: "Nie można wysłać zaproszenia", ja: "招待を送信できませんでした" },
|
||||
"Could not revoke invite": { fr: "Impossible de révoquer l'invitation", de: "Einladung konnte nicht widerrufen werden", it: "Impossibile revocare l'invito", pt: "Não foi possível revogar o convite", nl: "Uitnodiging kon niet worden ingetrokken", pl: "Nie można unieważnić zaproszenia", ja: "招待を取り消せませんでした" },
|
||||
"Could not remove user": { fr: "Impossible de retirer l'utilisateur", de: "Benutzer konnte nicht entfernt werden", it: "Impossibile rimuovere l'utente", pt: "Não foi possível remover o utilizador", nl: "Gebruiker kon niet worden verwijderd", pl: "Nie można usunąć użytkownika", ja: "ユーザーを削除できませんでした" },
|
||||
"Could not update role": { fr: "Impossible de mettre à jour le rôle", de: "Rolle konnte nicht aktualisiert werden", it: "Impossibile aggiornare il ruolo", pt: "Não foi possível atualizar o papel", nl: "Rol kon niet worden bijgewerkt", pl: "Nie można zaktualizować roli", ja: "ロールを更新できませんでした" },
|
||||
"Could not verify invite": { fr: "Impossible de vérifier l'invitation", de: "Einladung konnte nicht verifiziert werden", it: "Impossibile verificare l'invito", pt: "Não foi possível verificar o convite", nl: "Uitnodiging kon niet worden geverifieerd", pl: "Nie można zweryfikować zaproszenia", ja: "招待を確認できませんでした" },
|
||||
"Could not accept invite": { fr: "Impossible d'accepter l'invitation", de: "Einladung konnte nicht angenommen werden", it: "Impossibile accettare l'invito", pt: "Não foi possível aceitar o convite", nl: "Uitnodiging kon niet worden geaccepteerd", pl: "Nie można zaakceptować zaproszenia", ja: "招待を承認できませんでした" },
|
||||
"Could not set password": { fr: "Impossible de définir le mot de passe", de: "Passwort konnte nicht festgelegt werden", it: "Impossibile impostare la password", pt: "Não foi possível definir a palavra-passe", nl: "Wachtwoord kon niet worden ingesteld", pl: "Nie można ustawić hasła", ja: "パスワードを設定できませんでした" },
|
||||
"Enter a valid email address.": { fr: "Saisissez une adresse e-mail valide.", de: "Geben Sie eine gültige E-Mail-Adresse ein.", it: "Inserisci un indirizzo email valido.", pt: "Introduza um endereço de e-mail válido.", nl: "Voer een geldig e-mailadres in.", pl: "Wprowadź prawidłowy adres e-mail.", ja: "有効なメールアドレスを入力してください。" },
|
||||
"Companies need at least one admin": { fr: "Les entreprises ont besoin d'au moins un administrateur", de: "Unternehmen benötigen mindestens einen Admin", it: "Le aziende necessitano di almeno un amministratore", pt: "As empresas precisam de pelo menos um administrador", nl: "Bedrijven hebben minstens één beheerder nodig", pl: "Firmy potrzebują co najmniej jednego administratora", ja: "会社には少なくとも1人の管理者が必要です" },
|
||||
"Set password first:": { fr: "Définissez d'abord le mot de passe :", de: "Zuerst Passwort festlegen:", it: "Imposta prima la password:", pt: "Defina primeiro a palavra-passe:", nl: "Stel eerst een wachtwoord in:", pl: "Najpierw ustaw hasło:", ja: "先にパスワードを設定:" },
|
||||
"Platform admins can re-issue from": { fr: "Les administrateurs de la plateforme peuvent réémettre depuis", de: "Plattform-Admins können erneut ausstellen unter", it: "Gli amministratori della piattaforma possono riemettere da", pt: "Os administradores da plataforma podem reemitir a partir de", nl: "Platformbeheerders kunnen opnieuw uitgeven via", pl: "Administratorzy platformy mogą ponownie wystawić z", ja: "プラットフォーム管理者は次から再発行できます:" },
|
||||
"Have a token? Open accept invite": { fr: "Vous avez un jeton ? Ouvrir accepter l'invitation", de: "Haben Sie ein Token? Einladung annehmen öffnen", it: "Hai un token? Apri accetta invito", pt: "Tem um token? Abrir aceitar convite", nl: "Heeft u een token? Open uitnodiging accepteren", pl: "Masz token? Otwórz akceptację zaproszenia", ja: "トークンがありますか?招待の承認を開く" },
|
||||
"Have an invite or set-password link?": { fr: "Vous avez une invitation ou un lien de définition de mot de passe ?", de: "Haben Sie eine Einladung oder einen Passwort-Link?", it: "Hai un invito o un link per impostare la password?", pt: "Tem um convite ou um link para definir a palavra-passe?", nl: "Heeft u een uitnodiging of set-wachtwoordlink?", pl: "Masz zaproszenie lub link do ustawienia hasła?", ja: "招待またはパスワード設定リンクがありますか?" },
|
||||
"Registers a company and its admin user.": { fr: "Enregistre une entreprise et son utilisateur administrateur.", de: "Registriert ein Unternehmen und dessen Admin-Benutzer.", it: "Registra un'azienda e il relativo utente amministratore.", pt: "Regista uma empresa e o respetivo utilizador administrador.", nl: "Registreert een bedrijf en de bijbehorende beheerdersgebruiker.", pl: "Rejestruje firmę i jej użytkownika administratora.", ja: "会社とその管理者ユーザーを登録します。" },
|
||||
"Checking invite…": { fr: "Vérification de l'invitation…", de: "Einladung wird geprüft…", it: "Verifica invito…", pt: "A verificar convite…", nl: "Uitnodiging controleren…", pl: "Sprawdzanie zaproszenia…", ja: "招待を確認中…" },
|
||||
"Invite token": { fr: "Jeton d'invitation", de: "Einladungs-Token", it: "Token di invito", pt: "Token de convite", nl: "Uitnodigingstoken", pl: "Token zaproszenia", ja: "招待トークン" },
|
||||
"Reset token": { fr: "Jeton de réinitialisation", de: "Reset-Token", it: "Token di reimpostazione", pt: "Token de redefinição", nl: "Resettoken", pl: "Token resetowania", ja: "リセットトークン" },
|
||||
"At least 8 characters. No other complexity rules.": { fr: "Au moins 8 caractères. Aucune autre règle de complexité.", de: "Mindestens 8 Zeichen. Keine weiteren Komplexitätsregeln.", it: "Almeno 8 caratteri. Nessun'altra regola di complessità.", pt: "Pelo menos 8 caracteres. Sem outras regras de complexidade.", nl: "Minimaal 8 tekens. Geen andere complexiteitsregels.", pl: "Co najmniej 8 znaków. Brak innych reguł złożoności.", ja: "8文字以上。その他の複雑さの規則はありません。" },
|
||||
"Switch account:": { fr: "Changer de compte :", de: "Konto wechseln:", it: "Cambia account:", pt: "Mudar de conta:", nl: "Account wisselen:", pl: "Zmień konto:", ja: "アカウント切替:" },
|
||||
"Re-issue path:": { fr: "Chemin de réémission :", de: "Neuausstellungs-Pfad:", it: "Percorso di riemissione:", pt: "Caminho de reemissão:", nl: "Pad voor opnieuw uitgeven:", pl: "Ścieżka ponownego wystawienia:", ja: "再発行の手順:" },
|
||||
"Wrong account for this invite": { fr: "Mauvais compte pour cette invitation", de: "Falsches Konto für diese Einladung", it: "Account errato per questo invito", pt: "Conta errada para este convite", nl: "Verkeerd account voor deze uitnodiging", pl: "Złe konto dla tego zaproszenia", ja: "この招待には別のアカウントが必要です" },
|
||||
"{email} removed.": { fr: "{email} retiré.", de: "{email} entfernt.", it: "{email} rimosso.", pt: "{email} removido.", nl: "{email} verwijderd.", pl: "Usunięto {email}.", ja: "{email} を削除しました。" },
|
||||
"{email} is now {role}.": { fr: "{email} est maintenant {role}.", de: "{email} ist jetzt {role}.", it: "{email} ora è {role}.", pt: "{email} é agora {role}.", nl: "{email} is nu {role}.", pl: "{email} jest teraz {role}.", ja: "{email} は現在 {role} です。" },
|
||||
"{action} {email} to {role}?": { fr: "{action} {email} en {role} ?", de: "{email} zu {role} {action}?", it: "{action} {email} a {role}?", pt: "{action} {email} para {role}?", nl: "{email} naar {role} {action}?", pl: "{action} {email} do {role}?", ja: "{email} を {role} に{action}しますか?" },
|
||||
"Remove {email} from this company?": { fr: "Retirer {email} de cette entreprise ?", de: "{email} aus diesem Unternehmen entfernen?", it: "Rimuovere {email} da questa azienda?", pt: "Remover {email} desta empresa?", nl: "{email} uit dit bedrijf verwijderen?", pl: "Usunąć {email} z tej firmy?", ja: "{email} をこの会社から削除しますか?" },
|
||||
"Live totals for {name}": { fr: "Totaux en direct pour {name}", de: "Live-Summen für {name}", it: "Totali in tempo reale per {name}", pt: "Totais em direto para {name}", nl: "Live totalen voor {name}", pl: "Bieżące sumy dla {name}", ja: "{name} のリアルタイム合計" },
|
||||
"Trial ends {date}. {credits} credits remaining.": { fr: "L'essai se termine le {date}. {credits} crédits restants.", de: "Testphase endet am {date}. {credits} Credits übrig.", it: "La prova termina il {date}. {credits} crediti rimanenti.", pt: "O teste termina a {date}. {credits} créditos restantes.", nl: "Proef eindigt op {date}. {credits} credits resterend.", pl: "Okres próbny kończy się {date}. Pozostało {credits} kredytów.", ja: "トライアルは {date} に終了します。残りクレジット {credits}。" },
|
||||
"{credits} credits remaining on your trial.": { fr: "{credits} crédits restants sur votre essai.", de: "{credits} Credits verbleiben in Ihrer Testphase.", it: "{credits} crediti rimanenti nella prova.", pt: "{credits} créditos restantes no seu teste.", nl: "{credits} credits resterend op uw proef.", pl: "Pozostało {credits} kredytów w okresie próbnym.", ja: "トライアルの残りクレジットは {credits} です。" },
|
||||
"Feeds, products, jobs, and export.": { fr: "Flux, produits, tâches et export.", de: "Feeds, Produkte, Jobs und Export.", it: "Feed, prodotti, processi ed esportazione.", pt: "Feeds, produtos, tarefas e exportação.", nl: "Feeds, producten, jobs en export.", pl: "Feedy, produkty, zadania i eksport.", ja: "フィード、商品、ジョブ、エクスポート。" }
|
||||
};
|
||||
|
||||
// Merge seed.es + MORE into full phrase-map
|
||||
const map = { ...seed };
|
||||
for (const [enText, langs] of Object.entries(MORE)) {
|
||||
map[enText] = { ...(map[enText] || {}), ...langs };
|
||||
}
|
||||
|
||||
// Also attach es from EXTRA_ES for any en phrases covered by keys
|
||||
for (const [k, es] of Object.entries(EXTRA_ES.es)) {
|
||||
const e = en[k];
|
||||
if (!e) continue;
|
||||
map[e] = { ...(map[e] || {}), es };
|
||||
}
|
||||
|
||||
fs.writeFileSync("phrase-map.json", JSON.stringify(map, null, 2));
|
||||
console.log("phrase-map entries", Object.keys(map).length);
|
||||
|
||||
// Count coverage vs needed phrases
|
||||
const needed = JSON.parse(fs.readFileSync("_phrases-needed.json", "utf8"));
|
||||
let covered = 0;
|
||||
const gaps = [];
|
||||
for (const p of needed) {
|
||||
const m = map[p];
|
||||
if (m && m.fr && m.de && m.it && m.pt && m.nl && m.pl && m.ja) covered++;
|
||||
else gaps.push(p);
|
||||
}
|
||||
console.log("needed", needed.length, "fully covered", covered, "gaps", gaps.length);
|
||||
fs.writeFileSync("_phrase-gaps.json", JSON.stringify(gaps, null, 2));
|
||||
@@ -0,0 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import { EXTRA } from "./locale-extra-es.mjs";
|
||||
fs.writeFileSync("_es-extra-keys.txt", Object.keys(EXTRA.es).join("\n"));
|
||||
console.log(Object.keys(EXTRA.es).length);
|
||||
@@ -0,0 +1,643 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
const map = JSON.parse(fs.readFileSync("phrase-map.json", "utf8"));
|
||||
|
||||
const GAPS = {
|
||||
"This account still needs a password. Open your invite link, or ask an admin to re-issue one.": {
|
||||
es: "Esta cuenta aún necesita una contraseña. Abre el enlace de invitación o pide a un administrador que emita uno nuevo.",
|
||||
fr: "Ce compte a encore besoin d'un mot de passe. Ouvrez votre lien d'invitation, ou demandez à un administrateur d'en émettre un nouveau.",
|
||||
de: "Dieses Konto benötigt noch ein Passwort. Öffnen Sie Ihren Einladungslink oder bitten Sie einen Admin, einen neuen auszustellen.",
|
||||
it: "Questo account richiede ancora una password. Apri il link di invito o chiedi a un amministratore di generarne uno nuovo.",
|
||||
pt: "Esta conta ainda precisa de uma palavra-passe. Abra o link do convite ou peça a um administrador para emitir um novo.",
|
||||
nl: "Dit account heeft nog een wachtwoord nodig. Open uw uitnodigingslink of vraag een beheerder om een nieuwe.",
|
||||
pl: "To konto nadal wymaga hasła. Otwórz link zaproszenia lub poproś administratora o wystawienie nowego.",
|
||||
ja: "このアカウントにはまだパスワードが必要です。招待リンクを開くか、管理者に再発行を依頼してください。"
|
||||
},
|
||||
"use the invite link from your email. If the link went to an old address (email drift), ask a company admin to re-issue a set-password invite to {email}.": {
|
||||
es: "usa el enlace de invitación de tu correo. Si el enlace fue a una dirección antigua (cambio de correo), pide a un administrador de la empresa que emita una nueva invitación para establecer contraseña a {email}.",
|
||||
fr: "utilisez le lien d'invitation de votre e-mail. Si le lien a été envoyé à une ancienne adresse (dérive d'e-mail), demandez à un administrateur de l'entreprise de renvoyer une invitation de définition de mot de passe à {email}.",
|
||||
de: "nutzen Sie den Einladungslink aus Ihrer E-Mail. Wenn der Link an eine alte Adresse ging (E-Mail-Drift), bitten Sie einen Unternehmens-Admin, eine neue Passwort-Einladung an {email} auszustellen.",
|
||||
it: "usa il link di invito dalla tua email. Se il link è andato a un indirizzo vecchio (deriva email), chiedi a un amministratore dell'azienda di riemettere un invito per impostare la password a {email}.",
|
||||
pt: "utilize o link do convite do seu e-mail. Se o link foi para um endereço antigo (desvio de e-mail), peça a um administrador da empresa para emitir um novo convite de definição de palavra-passe para {email}.",
|
||||
nl: "gebruik de uitnodigingslink uit uw e-mail. Als de link naar een oud adres ging (e-maildrift), vraag dan een bedrijfsbeheerder om een nieuwe set-wachtwoorduitnodiging naar {email} te sturen.",
|
||||
pl: "użyj linku zaproszenia z e-maila. Jeśli link poszedł na stary adres (dryf e-mail), poproś administratora firmy o ponowne wystawienie zaproszenia do ustawienia hasła na {email}.",
|
||||
ja: "メールの招待リンクを使用してください。リンクが古いアドレスに送られた場合(メール変更)、会社の管理者に {email} 向けのパスワード設定招待の再発行を依頼してください。"
|
||||
},
|
||||
"Set your password to join the company. Your admin assigned either Member (day-to-day work) or Admin (team and billing).": {
|
||||
es: "Establece tu contraseña para unirte a la empresa. Tu administrador asignó Miembro (trabajo diario) o Admin (equipo y facturación).",
|
||||
fr: "Définissez votre mot de passe pour rejoindre l'entreprise. Votre administrateur a attribué Membre (travail quotidien) ou Admin (équipe et facturation).",
|
||||
de: "Legen Sie Ihr Passwort fest, um dem Unternehmen beizutreten. Ihr Admin hat entweder Mitglied (Tagesgeschäft) oder Admin (Team und Abrechnung) zugewiesen.",
|
||||
it: "Imposta la password per unirti all'azienda. Il tuo amministratore ha assegnato Membro (lavoro quotidiano) o Admin (team e fatturazione).",
|
||||
pt: "Defina a sua palavra-passe para aderir à empresa. O seu administrador atribuiu Membro (trabalho diário) ou Admin (equipa e faturação).",
|
||||
nl: "Stel uw wachtwoord in om toe te treden tot het bedrijf. Uw beheerder heeft Lid (dagelijks werk) of Admin (team en facturering) toegewezen.",
|
||||
pl: "Ustaw hasło, aby dołączyć do firmy. Administrator przypisał rolę Członek (codzienna praca) lub Admin (zespół i rozliczenia).",
|
||||
ja: "会社に参加するにはパスワードを設定してください。管理者はメンバー(日常業務)または管理者(チームと請求)のいずれかを割り当てています。"
|
||||
},
|
||||
"Choose a password for your migrated Descrybe account (at least 8 characters).": {
|
||||
es: "Elige una contraseña para tu cuenta Descrybe migrada (al menos 8 caracteres).",
|
||||
fr: "Choisissez un mot de passe pour votre compte Descrybe migré (au moins 8 caractères).",
|
||||
de: "Wählen Sie ein Passwort für Ihr migriertes Descrybe-Konto (mindestens 8 Zeichen).",
|
||||
it: "Scegli una password per il tuo account Descrybe migrato (almeno 8 caratteri).",
|
||||
pt: "Escolha uma palavra-passe para a sua conta Descrybe migrada (pelo menos 8 caracteres).",
|
||||
nl: "Kies een wachtwoord voor uw gemigreerde Descrybe-account (minimaal 8 tekens).",
|
||||
pl: "Wybierz hasło do zmigrowanego konta Descrybe (co najmniej 8 znaków).",
|
||||
ja: "移行されたDescrybeアカウント用のパスワードを選んでください(8文字以上)。"
|
||||
},
|
||||
"Invite link recognized. Enter a password below to continue — the secret is not shown on this page.": {
|
||||
es: "Enlace de invitación reconocido. Introduce una contraseña abajo para continuar — el secreto no se muestra en esta página.",
|
||||
fr: "Lien d'invitation reconnu. Saisissez un mot de passe ci-dessous pour continuer — le secret n'est pas affiché sur cette page.",
|
||||
de: "Einladungslink erkannt. Geben Sie unten ein Passwort ein, um fortzufahren — das Geheimnis wird auf dieser Seite nicht angezeigt.",
|
||||
it: "Link di invito riconosciuto. Inserisci una password qui sotto per continuare — il segreto non è mostrato in questa pagina.",
|
||||
pt: "Link de convite reconhecido. Introduza uma palavra-passe abaixo para continuar — o segredo não é mostrado nesta página.",
|
||||
nl: "Uitnodigingslink herkend. Voer hieronder een wachtwoord in om door te gaan — het geheim wordt op deze pagina niet getoond.",
|
||||
pl: "Rozpoznano link zaproszenia. Wprowadź hasło poniżej, aby kontynuować — sekret nie jest wyświetlany na tej stronie.",
|
||||
ja: "招待リンクを認識しました。続行するには下にパスワードを入力してください — このページにシークレットは表示されません。"
|
||||
},
|
||||
"Reset link recognized. Enter a password below to continue — the secret is not shown on this page.": {
|
||||
es: "Enlace de restablecimiento reconocido. Introduce una contraseña abajo para continuar — el secreto no se muestra en esta página.",
|
||||
fr: "Lien de réinitialisation reconnu. Saisissez un mot de passe ci-dessous pour continuer — le secret n'est pas affiché sur cette page.",
|
||||
de: "Reset-Link erkannt. Geben Sie unten ein Passwort ein, um fortzufahren — das Geheimnis wird auf dieser Seite nicht angezeigt.",
|
||||
it: "Link di reimpostazione riconosciuto. Inserisci una password qui sotto per continuare — il segreto non è mostrato in questa pagina.",
|
||||
pt: "Link de redefinição reconhecido. Introduza uma palavra-passe abaixo para continuar — o segredo não é mostrado nesta página.",
|
||||
nl: "Resetlink herkend. Voer hieronder een wachtwoord in om door te gaan — het geheim wordt op deze pagina niet getoond.",
|
||||
pl: "Rozpoznano link resetowania. Wprowadź hasło poniżej, aby kontynuować — sekret nie jest wyświetlany na tej stronie.",
|
||||
ja: "リセットリンクを認識しました。続行するには下にパスワードを入力してください — このページにシークレットは表示されません。"
|
||||
},
|
||||
"Paste the token from your invite email. It is masked in this field.": {
|
||||
es: "Pega el token del correo de invitación. Se muestra enmascarado en este campo.",
|
||||
fr: "Collez le jeton de votre e-mail d'invitation. Il est masqué dans ce champ.",
|
||||
de: "Fügen Sie das Token aus Ihrer Einladungs-E-Mail ein. Es wird in diesem Feld maskiert angezeigt.",
|
||||
it: "Incolla il token dall'email di invito. È mascherato in questo campo.",
|
||||
pt: "Cole o token do e-mail de convite. É mascarado neste campo.",
|
||||
nl: "Plak het token uit uw uitnodigingsmail. Het wordt in dit veld gemaskeerd.",
|
||||
pl: "Wklej token z e-maila z zaproszeniem. Jest maskowany w tym polu.",
|
||||
ja: "招待メールのトークンを貼り付けてください。このフィールドではマスク表示されます。"
|
||||
},
|
||||
"Could not verify set-password link": {
|
||||
es: "No se pudo verificar el enlace para establecer contraseña",
|
||||
fr: "Impossible de vérifier le lien de définition du mot de passe",
|
||||
de: "Passwort-Link konnte nicht verifiziert werden",
|
||||
it: "Impossibile verificare il link per impostare la password",
|
||||
pt: "Não foi possível verificar o link de definição de palavra-passe",
|
||||
nl: "Set-wachtwoordlink kon niet worden geverifieerd",
|
||||
pl: "Nie można zweryfikować linku ustawienia hasła",
|
||||
ja: "パスワード設定リンクを確認できませんでした"
|
||||
},
|
||||
"This invite is invalid or expired. Ask your company admin to send a new invite, then open the new link (or paste the new token below).": {
|
||||
es: "Esta invitación no es válida o ha caducado. Pide a tu administrador de la empresa que envíe una nueva invitación y abre el nuevo enlace (o pega el nuevo token abajo).",
|
||||
fr: "Cette invitation est invalide ou expirée. Demandez à votre administrateur d'envoyer une nouvelle invitation, puis ouvrez le nouveau lien (ou collez le nouveau jeton ci-dessous).",
|
||||
de: "Diese Einladung ist ungültig oder abgelaufen. Bitten Sie Ihren Unternehmens-Admin um eine neue Einladung und öffnen Sie den neuen Link (oder fügen Sie das neue Token unten ein).",
|
||||
it: "Questo invito non è valido o è scaduto. Chiedi all'amministratore dell'azienda di inviare un nuovo invito, poi apri il nuovo link (o incolla il nuovo token qui sotto).",
|
||||
pt: "Este convite é inválido ou expirou. Peça ao administrador da empresa para enviar um novo convite e abra o novo link (ou cole o novo token abaixo).",
|
||||
nl: "Deze uitnodiging is ongeldig of verlopen. Vraag uw bedrijfsbeheerder om een nieuwe uitnodiging te sturen en open de nieuwe link (of plak het nieuwe token hieronder).",
|
||||
pl: "To zaproszenie jest nieprawidłowe lub wygasło. Poproś administratora firmy o nowe zaproszenie, a następnie otwórz nowy link (lub wklej nowy token poniżej).",
|
||||
ja: "この招待は無効または期限切れです。会社の管理者に新しい招待の送信を依頼し、新しいリンクを開くか(下に新しいトークンを貼り付けてください)。"
|
||||
},
|
||||
"This set-password link is invalid or expired. Ask a company or platform admin to re-issue it, then open the new link (or paste the new token below).": {
|
||||
es: "Este enlace para establecer contraseña no es válido o ha caducado. Pide a un administrador de la empresa o de la plataforma que lo reemita y abre el nuevo enlace (o pega el nuevo token abajo).",
|
||||
fr: "Ce lien de définition de mot de passe est invalide ou expiré. Demandez à un administrateur de l'entreprise ou de la plateforme de le réémettre, puis ouvrez le nouveau lien (ou collez le nouveau jeton ci-dessous).",
|
||||
de: "Dieser Passwort-Link ist ungültig oder abgelaufen. Bitten Sie einen Unternehmens- oder Plattform-Admin um Neuausstellung und öffnen Sie den neuen Link (oder fügen Sie das neue Token unten ein).",
|
||||
it: "Questo link per impostare la password non è valido o è scaduto. Chiedi a un amministratore dell'azienda o della piattaforma di riemetterlo, poi apri il nuovo link (o incolla il nuovo token qui sotto).",
|
||||
pt: "Este link de definição de palavra-passe é inválido ou expirou. Peça a um administrador da empresa ou da plataforma para o reemitir e abra o novo link (ou cole o novo token abaixo).",
|
||||
nl: "Deze set-wachtwoordlink is ongeldig of verlopen. Vraag een bedrijfs- of platformbeheerder om hem opnieuw uit te geven en open de nieuwe link (of plak het nieuwe token hieronder).",
|
||||
pl: "Ten link do ustawienia hasła jest nieprawidłowy lub wygasł. Poproś administratora firmy lub platformy o ponowne wystawienie, a następnie otwórz nowy link (lub wklej nowy token poniżej).",
|
||||
ja: "このパスワード設定リンクは無効または期限切れです。会社またはプラットフォームの管理者に再発行を依頼し、新しいリンクを開くか(下に新しいトークンを貼り付けてください)。"
|
||||
},
|
||||
"You're signed in as a different email than this invite.": {
|
||||
es: "Has iniciado sesión con un correo distinto al de esta invitación.",
|
||||
fr: "Vous êtes connecté avec un e-mail différent de celui de cette invitation.",
|
||||
de: "Sie sind mit einer anderen E-Mail angemeldet als diese Einladung.",
|
||||
it: "Hai effettuato l'accesso con un'email diversa da questo invito.",
|
||||
pt: "Tem sessão iniciada com um e-mail diferente deste convite.",
|
||||
nl: "U bent ingelogd met een ander e-mailadres dan deze uitnodiging.",
|
||||
pl: "Jesteś zalogowany na inny e-mail niż w tym zaproszeniu.",
|
||||
ja: "この招待とは別のメールアドレスでログインしています。"
|
||||
},
|
||||
"Expired link? Ask an admin to re-issue — there is no self-serve resend API. Platform admins:": {
|
||||
es: "¿Enlace caducado? Pide a un administrador que lo reemita — no hay API de reenvío autoservicio. Administradores de plataforma:",
|
||||
fr: "Lien expiré ? Demandez à un administrateur de le réémettre — il n'y a pas d'API de renvoi en libre-service. Administrateurs de plateforme :",
|
||||
de: "Abgelaufener Link? Bitten Sie einen Admin um Neuausstellung — es gibt keine Self-Service-API zum erneuten Senden. Plattform-Admins:",
|
||||
it: "Link scaduto? Chiedi a un amministratore di riemetterlo — non c'è un'API di reinvio self-service. Amministratori della piattaforma:",
|
||||
pt: "Link expirado? Peça a um administrador para o reemitir — não há API de reenvio self-service. Administradores da plataforma:",
|
||||
nl: "Verlopen link? Vraag een beheerder om opnieuw uit te geven — er is geen self-service-API voor opnieuw verzenden. Platformbeheerders:",
|
||||
pl: "Wygasły link? Poproś administratora o ponowne wystawienie — nie ma API samodzielnego ponownego wysyłania. Administratorzy platformy:",
|
||||
ja: "期限切れのリンクですか?管理者に再発行を依頼してください — セルフサービスの再送信APIはありません。プラットフォーム管理者:"
|
||||
},
|
||||
"Your account is ready. Next, open the dashboard to work with feeds and products, or review company settings.": {
|
||||
es: "Tu cuenta está lista. A continuación, abre el panel para trabajar con feeds y productos, o revisa la configuración de la empresa.",
|
||||
fr: "Votre compte est prêt. Ensuite, ouvrez le tableau de bord pour travailler avec les flux et les produits, ou consultez les paramètres de l'entreprise.",
|
||||
de: "Ihr Konto ist bereit. Öffnen Sie als Nächstes das Dashboard, um mit Feeds und Produkten zu arbeiten, oder prüfen Sie die Unternehmenseinstellungen.",
|
||||
it: "Il tuo account è pronto. Apri la dashboard per lavorare con feed e prodotti, oppure rivedi le impostazioni dell'azienda.",
|
||||
pt: "A sua conta está pronta. Em seguida, abra o painel para trabalhar com feeds e produtos, ou reveja as definições da empresa.",
|
||||
nl: "Uw account is klaar. Open vervolgens het dashboard om met feeds en producten te werken, of bekijk de bedrijfsinstellingen.",
|
||||
pl: "Twoje konto jest gotowe. Następnie otwórz panel, aby pracować z feedami i produktami, lub przejrzyj ustawienia firmy.",
|
||||
ja: "アカウントの準備ができました。次にダッシュボードを開いてフィードや商品を扱うか、会社の設定を確認してください。"
|
||||
},
|
||||
"Sign in with your email and new password to open your workspace.": {
|
||||
es: "Inicia sesión con tu correo y la nueva contraseña para abrir tu espacio de trabajo.",
|
||||
fr: "Connectez-vous avec votre e-mail et le nouveau mot de passe pour ouvrir votre espace de travail.",
|
||||
de: "Melden Sie sich mit Ihrer E-Mail und dem neuen Passwort an, um Ihren Arbeitsbereich zu öffnen.",
|
||||
it: "Accedi con la tua email e la nuova password per aprire il tuo spazio di lavoro.",
|
||||
pt: "Inicie sessão com o seu e-mail e a nova palavra-passe para abrir o seu espaço de trabalho.",
|
||||
nl: "Log in met uw e-mailadres en nieuwe wachtwoord om uw werkruimte te openen.",
|
||||
pl: "Zaloguj się e-mailem i nowym hasłem, aby otworzyć przestrzeń roboczą.",
|
||||
ja: "メールと新しいパスワードでログインしてワークスペースを開いてください。"
|
||||
},
|
||||
"After sign-in you land in your workspace — the greenfield setup tour is skipped.": {
|
||||
es: "Tras iniciar sesión llegas a tu espacio de trabajo — se omite el recorrido de configuración inicial.",
|
||||
fr: "Après la connexion, vous arrivez dans votre espace de travail — le parcours de configuration initiale est ignoré.",
|
||||
de: "Nach der Anmeldung landen Sie in Ihrem Arbeitsbereich — die Greenfield-Einrichtungstour wird übersprungen.",
|
||||
it: "Dopo l'accesso arrivi nel tuo spazio di lavoro — il tour di configurazione iniziale viene saltato.",
|
||||
pt: "Após o início de sessão chega ao seu espaço de trabalho — o tour de configuração inicial é ignorado.",
|
||||
nl: "Na het inloggen komt u in uw werkruimte — de greenfield-instellingstour wordt overgeslagen.",
|
||||
pl: "Po zalogowaniu trafiasz do przestrzeni roboczej — pomijana jest wycieczka po konfiguracji początkowej.",
|
||||
ja: "ログイン後はワークスペースに入ります — 初期セットアップツアーはスキップされます。"
|
||||
},
|
||||
"This link is for a different email than the one you're signed in with. Sign out to continue as the invited user, or stay signed in and ask an admin to re-issue the invite.": {
|
||||
es: "Este enlace es para un correo distinto al de la sesión actual. Cierra sesión para continuar como el usuario invitado, o permanece conectado y pide a un administrador que reemita la invitación.",
|
||||
fr: "Ce lien est destiné à un e-mail différent de celui avec lequel vous êtes connecté. Déconnectez-vous pour continuer en tant qu'utilisateur invité, ou restez connecté et demandez à un administrateur de réémettre l'invitation.",
|
||||
de: "Dieser Link gilt für eine andere E-Mail als die, mit der Sie angemeldet sind. Melden Sie sich ab, um als eingeladener Benutzer fortzufahren, oder bleiben Sie angemeldet und bitten Sie einen Admin um Neuausstellung der Einladung.",
|
||||
it: "Questo link è per un'email diversa da quella con cui hai effettuato l'accesso. Esci per continuare come utente invitato, oppure resta connesso e chiedi a un amministratore di riemettere l'invito.",
|
||||
pt: "Este link é para um e-mail diferente daquele com que tem sessão iniciada. Termine a sessão para continuar como o utilizador convidado, ou mantenha a sessão e peça a um administrador para reemitir o convite.",
|
||||
nl: "Deze link is voor een ander e-mailadres dan waarmee u bent ingelogd. Log uit om door te gaan als de uitgenodigde gebruiker, of blijf ingelogd en vraag een beheerder om de uitnodiging opnieuw uit te geven.",
|
||||
pl: "Ten link jest dla innego e-maila niż ten, na który jesteś zalogowany. Wyloguj się, aby kontynuować jako zaproszony użytkownik, albo pozostań zalogowany i poproś administratora o ponowne wystawienie zaproszenia.",
|
||||
ja: "このリンクは、現在ログイン中のメールとは別のアドレス宛です。招待されたユーザーとして続行するにはログアウトするか、ログインしたまま管理者に招待の再発行を依頼してください。"
|
||||
},
|
||||
"Signed in as {session}, but this invite is for {invite}.": {
|
||||
es: "Sesión iniciada como {session}, pero esta invitación es para {invite}.",
|
||||
fr: "Connecté en tant que {session}, mais cette invitation est pour {invite}.",
|
||||
de: "Angemeldet als {session}, aber diese Einladung ist für {invite}.",
|
||||
it: "Accesso come {session}, ma questo invito è per {invite}.",
|
||||
pt: "Sessão iniciada como {session}, mas este convite é para {invite}.",
|
||||
nl: "Ingelogd als {session}, maar deze uitnodiging is voor {invite}.",
|
||||
pl: "Zalogowano jako {session}, ale to zaproszenie jest dla {invite}.",
|
||||
ja: "{session} でログイン中ですが、この招待は {invite} 宛です。"
|
||||
},
|
||||
"Signed-in email does not match this invite.": {
|
||||
es: "El correo de la sesión no coincide con esta invitación.",
|
||||
fr: "L'e-mail de la session ne correspond pas à cette invitation.",
|
||||
de: "Die angemeldete E-Mail stimmt nicht mit dieser Einladung überein.",
|
||||
it: "L'email della sessione non corrisponde a questo invito.",
|
||||
pt: "O e-mail da sessão não corresponde a este convite.",
|
||||
nl: "Het ingelogde e-mailadres komt niet overeen met deze uitnodiging.",
|
||||
pl: "E-mail sesji nie pasuje do tego zaproszenia.",
|
||||
ja: "ログイン中のメールがこの招待と一致しません。"
|
||||
},
|
||||
"sign out, then finish this form with the invited email{emailSuffix}.": {
|
||||
es: "cierra sesión y completa este formulario con el correo invitado{emailSuffix}.",
|
||||
fr: "déconnectez-vous, puis terminez ce formulaire avec l'e-mail invité{emailSuffix}.",
|
||||
de: "melden Sie sich ab und schließen Sie dieses Formular mit der eingeladenen E-Mail{emailSuffix} ab.",
|
||||
it: "esci, poi completa questo modulo con l'email invitata{emailSuffix}.",
|
||||
pt: "termine a sessão e conclua este formulário com o e-mail convidado{emailSuffix}.",
|
||||
nl: "log uit en voltooi dit formulier met het uitgenodigde e-mailadres{emailSuffix}.",
|
||||
pl: "wyloguj się, a następnie dokończ ten formularz zaproszonym e-mailem{emailSuffix}.",
|
||||
ja: "ログアウトしてから、招待されたメール{emailSuffix}でこのフォームを完了してください。"
|
||||
},
|
||||
"if your real login email changed (email drift), ask a company admin to revoke this invite and send a new one to the email you use to sign in. Platform admins can also re-issue set-password links from Admin → Users.": {
|
||||
es: "si cambió tu correo real de acceso (desfase de correo), pide a un administrador de la empresa que revoque esta invitación y envíe una nueva al correo con el que inicias sesión. Los administradores de plataforma también pueden reemitir enlaces para establecer contraseña desde Admin → Usuarios.",
|
||||
fr: "si votre vrai e-mail de connexion a changé (dérive d'e-mail), demandez à un administrateur de l'entreprise de révoquer cette invitation et d'en envoyer une nouvelle à l'e-mail que vous utilisez pour vous connecter. Les administrateurs de la plateforme peuvent aussi réémettre des liens de définition de mot de passe depuis Admin → Utilisateurs.",
|
||||
de: "wenn sich Ihre echte Anmelde-E-Mail geändert hat (E-Mail-Drift), bitten Sie einen Unternehmens-Admin, diese Einladung zu widerrufen und eine neue an die E-Mail zu senden, mit der Sie sich anmelden. Plattform-Admins können Passwort-Links auch unter Admin → Benutzer erneut ausstellen.",
|
||||
it: "se la tua email di accesso reale è cambiata (deriva email), chiedi a un amministratore dell'azienda di revocare questo invito e inviarne uno nuovo all'email con cui accedi. Gli amministratori della piattaforma possono anche riemettere link per impostare la password da Admin → Utenti.",
|
||||
pt: "se o seu e-mail real de início de sessão mudou (desvio de e-mail), peça a um administrador da empresa para revogar este convite e enviar um novo para o e-mail que utiliza para iniciar sessão. Os administradores da plataforma também podem reemitir links de definição de palavra-passe em Admin → Utilizadores.",
|
||||
nl: "als uw echte login-e-mail is gewijzigd (e-maildrift), vraag dan een bedrijfsbeheerder om deze uitnodiging in te trekken en een nieuwe te sturen naar het e-mailadres waarmee u inlogt. Platformbeheerders kunnen ook set-wachtwoordlinks opnieuw uitgeven via Admin → Gebruikers.",
|
||||
pl: "jeśli zmienił się Twój prawdziwy e-mail logowania (dryf e-mail), poproś administratora firmy o unieważnienie tego zaproszenia i wysłanie nowego na e-mail używany do logowania. Administratorzy platformy mogą też ponownie wystawiać linki ustawienia hasła w Admin → Użytkownicy.",
|
||||
ja: "実際のログイン用メールが変わった場合(メール変更)、会社の管理者にこの招待の取り消しと、ログインに使うメールへの新しい招待送信を依頼してください。プラットフォーム管理者は「管理 → ユーザー」からパスワード設定リンクも再発行できます。"
|
||||
},
|
||||
"You don't have permission to open company settings. Ask a company admin for help.": {
|
||||
es: "No tienes permiso para abrir la configuración de la empresa. Pide ayuda a un administrador de la empresa.",
|
||||
fr: "Vous n'avez pas l'autorisation d'ouvrir les paramètres de l'entreprise. Demandez de l'aide à un administrateur.",
|
||||
de: "Sie haben keine Berechtigung, die Unternehmenseinstellungen zu öffnen. Bitten Sie einen Unternehmens-Admin um Hilfe.",
|
||||
it: "Non hai l'autorizzazione per aprire le impostazioni dell'azienda. Chiedi aiuto a un amministratore.",
|
||||
pt: "Não tem permissão para abrir as definições da empresa. Peça ajuda a um administrador da empresa.",
|
||||
nl: "U hebt geen toestemming om bedrijfsinstellingen te openen. Vraag een bedrijfsbeheerder om hulp.",
|
||||
pl: "Nie masz uprawnień do otwarcia ustawień firmy. Poproś administratora firmy o pomoc.",
|
||||
ja: "会社の設定を開く権限がありません。会社の管理者に問い合わせてください。"
|
||||
},
|
||||
"Only company admins can invite, promote, demote, or remove teammates.": {
|
||||
es: "Solo los administradores de la empresa pueden invitar, ascender, degradar o eliminar compañeros.",
|
||||
fr: "Seuls les administrateurs de l'entreprise peuvent inviter, promouvoir, rétrograder ou retirer des coéquipiers.",
|
||||
de: "Nur Unternehmens-Admins können Teammitglieder einladen, befördern, herabstufen oder entfernen.",
|
||||
it: "Solo gli amministratori dell'azienda possono invitare, promuovere, degradare o rimuovere compagni di team.",
|
||||
pt: "Apenas administradores da empresa podem convidar, promover, despromover ou remover colegas.",
|
||||
nl: "Alleen bedrijfsbeheerders kunnen teamleden uitnodigen, promoveren, degraderen of verwijderen.",
|
||||
pl: "Tylko administratorzy firmy mogą zapraszać, awansować, degradować lub usuwać członków zespołu.",
|
||||
ja: "同僚の招待、昇格、降格、削除ができるのは会社の管理者のみです。"
|
||||
},
|
||||
"Outbound email is not configured. Copy this one-time link and send it to the invitee. They'll set a password (at least 8 characters) and join with the role you chose.": {
|
||||
es: "El correo saliente no está configurado. Copia este enlace de un solo uso y envíaselo al invitado. Establecerá una contraseña (al menos 8 caracteres) y se unirá con el rol que elegiste.",
|
||||
fr: "L'e-mail sortant n'est pas configuré. Copiez ce lien à usage unique et envoyez-le à l'invité. Il définira un mot de passe (au moins 8 caractères) et rejoindra avec le rôle que vous avez choisi.",
|
||||
de: "Ausgehende E-Mail ist nicht konfiguriert. Kopieren Sie diesen Einmal-Link und senden Sie ihn an den Eingeladenen. Er legt ein Passwort fest (mindestens 8 Zeichen) und tritt mit der von Ihnen gewählten Rolle bei.",
|
||||
it: "L'email in uscita non è configurata. Copia questo link monouso e invialo all'invitato. Imposterà una password (almeno 8 caratteri) e si unirà con il ruolo che hai scelto.",
|
||||
pt: "O e-mail de saída não está configurado. Copie este link de utilização única e envie-o ao convidado. Definirá uma palavra-passe (pelo menos 8 caracteres) e aderirá com o papel que escolheu.",
|
||||
nl: "Uitgaande e-mail is niet geconfigureerd. Kopieer deze eenmalige link en stuur hem naar de genodigde. Die stelt een wachtwoord in (minimaal 8 tekens) en treedt toe met de rol die u koos.",
|
||||
pl: "Wychodzący e-mail nie jest skonfigurowany. Skopiuj ten jednorazowy link i wyślij go zaproszonemu. Ustawi hasło (co najmniej 8 znaków) i dołączy z wybraną przez Ciebie rolą.",
|
||||
ja: "送信メールが設定されていません。この一回限りのリンクをコピーして招待者に送ってください。パスワード(8文字以上)を設定し、選択したロールで参加します。"
|
||||
},
|
||||
"One-time accept invite link": {
|
||||
es: "Enlace de aceptación de invitación de un solo uso",
|
||||
fr: "Lien d'acceptation d'invitation à usage unique",
|
||||
de: "Einmaliger Einladungs-Annahmelink",
|
||||
it: "Link monouso di accettazione invito",
|
||||
pt: "Link de aceitação de convite de utilização única",
|
||||
nl: "Eenmalige acceptatie-uitnodigingslink",
|
||||
pl: "Jednorazowy link akceptacji zaproszenia",
|
||||
ja: "一回限りの招待承認リンク"
|
||||
},
|
||||
"You don't have permission to view the team list. Ask a company admin for help.": {
|
||||
es: "No tienes permiso para ver la lista del equipo. Pide ayuda a un administrador de la empresa.",
|
||||
fr: "Vous n'avez pas l'autorisation de voir la liste de l'équipe. Demandez de l'aide à un administrateur.",
|
||||
de: "Sie haben keine Berechtigung, die Teamliste anzuzeigen. Bitten Sie einen Unternehmens-Admin um Hilfe.",
|
||||
it: "Non hai l'autorizzazione per visualizzare l'elenco del team. Chiedi aiuto a un amministratore.",
|
||||
pt: "Não tem permissão para ver a lista da equipa. Peça ajuda a um administrador da empresa.",
|
||||
nl: "U hebt geen toestemming om de teamlijst te bekijken. Vraag een bedrijfsbeheerder om hulp.",
|
||||
pl: "Nie masz uprawnień do przeglądania listy zespołu. Poproś administratora firmy o pomoc.",
|
||||
ja: "チーム一覧を表示する権限がありません。会社の管理者に問い合わせてください。"
|
||||
},
|
||||
"Invite colleagues as Member (products and feeds) or Admin (team and company settings). Pending invites show here until accepted.": {
|
||||
es: "Invita a colegas como Miembro (productos y feeds) o Admin (equipo y configuración de la empresa). Las invitaciones pendientes aparecen aquí hasta que se acepten.",
|
||||
fr: "Invitez des collègues en tant que Membre (produits et flux) ou Admin (équipe et paramètres de l'entreprise). Les invitations en attente s'affichent ici jusqu'à acceptation.",
|
||||
de: "Laden Sie Kollegen als Mitglied (Produkte und Feeds) oder Admin (Team und Unternehmenseinstellungen) ein. Ausstehende Einladungen erscheinen hier bis zur Annahme.",
|
||||
it: "Invita colleghi come Membro (prodotti e feed) o Admin (team e impostazioni azienda). Gli inviti in sospeso compaiono qui fino all'accettazione.",
|
||||
pt: "Convide colegas como Membro (produtos e feeds) ou Admin (equipa e definições da empresa). Os convites pendentes aparecem aqui até serem aceites.",
|
||||
nl: "Nodig collega's uit als Lid (producten en feeds) of Admin (team en bedrijfsinstellingen). Openstaande uitnodigingen verschijnen hier tot ze zijn geaccepteerd.",
|
||||
pl: "Zapraszaj współpracowników jako Członek (produkty i feedy) lub Admin (zespół i ustawienia firmy). Oczekujące zaproszenia są tu widoczne do akceptacji.",
|
||||
ja: "同僚をメンバー(商品とフィード)または管理者(チームと会社設定)として招待します。未承認の招待はここに表示されます。"
|
||||
},
|
||||
"No teammates listed yet. Ask a company admin to send invites.": {
|
||||
es: "Aún no hay compañeros en la lista. Pide a un administrador de la empresa que envíe invitaciones.",
|
||||
fr: "Aucun coéquipier listé pour le moment. Demandez à un administrateur d'envoyer des invitations.",
|
||||
de: "Noch keine Teammitglieder aufgelistet. Bitten Sie einen Unternehmens-Admin, Einladungen zu senden.",
|
||||
it: "Ancora nessun compagno di team elencato. Chiedi a un amministratore di inviare inviti.",
|
||||
pt: "Ainda não há colegas listados. Peça a um administrador da empresa para enviar convites.",
|
||||
nl: "Nog geen teamleden weergegeven. Vraag een bedrijfsbeheerder om uitnodigingen te sturen.",
|
||||
pl: "Brak jeszcze członków zespołu na liście. Poproś administratora firmy o wysłanie zaproszeń.",
|
||||
ja: "まだチームメンバーが一覧にありません。会社の管理者に招待の送信を依頼してください。"
|
||||
},
|
||||
"Invite created for {email} as {role}. Copy the accept link below and share it — outbound email is not configured.": {
|
||||
es: "Invitación creada para {email} como {role}. Copia el enlace de aceptación abajo y compártelo — el correo saliente no está configurado.",
|
||||
fr: "Invitation créée pour {email} en tant que {role}. Copiez le lien d'acceptation ci-dessous et partagez-le — l'e-mail sortant n'est pas configuré.",
|
||||
de: "Einladung für {email} als {role} erstellt. Kopieren Sie den Annahmelink unten und teilen Sie ihn — ausgehende E-Mail ist nicht konfiguriert.",
|
||||
it: "Invito creato per {email} come {role}. Copia il link di accettazione qui sotto e condividilo — l'email in uscita non è configurata.",
|
||||
pt: "Convite criado para {email} como {role}. Copie o link de aceitação abaixo e partilhe-o — o e-mail de saída não está configurado.",
|
||||
nl: "Uitnodiging aangemaakt voor {email} als {role}. Kopieer de acceptatielink hieronder en deel hem — uitgaande e-mail is niet geconfigureerd.",
|
||||
pl: "Utworzono zaproszenie dla {email} jako {role}. Skopiuj link akceptacji poniżej i udostępnij go — wychodzący e-mail nie jest skonfigurowany.",
|
||||
ja: "{email} を {role} として招待を作成しました。下の承認リンクをコピーして共有してください — 送信メールは設定されていません。"
|
||||
},
|
||||
"Invite sent to {email} as {role}. They should open the email and accept before it expires.": {
|
||||
es: "Invitación enviada a {email} como {role}. Debe abrir el correo y aceptar antes de que caduque.",
|
||||
fr: "Invitation envoyée à {email} en tant que {role}. La personne doit ouvrir l'e-mail et accepter avant expiration.",
|
||||
de: "Einladung an {email} als {role} gesendet. Die Person sollte die E-Mail öffnen und vor Ablauf annehmen.",
|
||||
it: "Invito inviato a {email} come {role}. Deve aprire l'email e accettare prima della scadenza.",
|
||||
pt: "Convite enviado para {email} como {role}. Deve abrir o e-mail e aceitar antes de expirar.",
|
||||
nl: "Uitnodiging verzonden naar {email} als {role}. Die moet de e-mail openen en accepteren vóór de vervaldatum.",
|
||||
pl: "Wysłano zaproszenie do {email} jako {role}. Osoba powinna otworzyć e-mail i zaakceptować przed wygaśnięciem.",
|
||||
ja: "{email} に {role} として招待を送信しました。期限前にメールを開いて承認してください。"
|
||||
},
|
||||
"They'll get a link to set a password (at least 8 characters) and join this company.": {
|
||||
es: "Recibirá un enlace para establecer una contraseña (al menos 8 caracteres) y unirse a esta empresa.",
|
||||
fr: "Ils recevront un lien pour définir un mot de passe (au moins 8 caractères) et rejoindre cette entreprise.",
|
||||
de: "Sie erhalten einen Link, um ein Passwort festzulegen (mindestens 8 Zeichen) und diesem Unternehmen beizutreten.",
|
||||
it: "Riceveranno un link per impostare una password (almeno 8 caratteri) e unirsi a questa azienda.",
|
||||
pt: "Receberão um link para definir uma palavra-passe (pelo menos 8 caracteres) e aderir a esta empresa.",
|
||||
nl: "Ze krijgen een link om een wachtwoord in te stellen (minimaal 8 tekens) en toe te treden tot dit bedrijf.",
|
||||
pl: "Otrzymają link do ustawienia hasła (co najmniej 8 znaków) i dołączenia do tej firmy.",
|
||||
ja: "パスワード(8文字以上)を設定してこの会社に参加するためのリンクが届きます。"
|
||||
},
|
||||
"Members manage products and feeds. Admins can also invite teammates and change company settings.": {
|
||||
es: "Los miembros gestionan productos y feeds. Los administradores también pueden invitar compañeros y cambiar la configuración de la empresa.",
|
||||
fr: "Les membres gèrent les produits et les flux. Les administrateurs peuvent aussi inviter des coéquipiers et modifier les paramètres de l'entreprise.",
|
||||
de: "Mitglieder verwalten Produkte und Feeds. Admins können auch Teammitglieder einladen und Unternehmenseinstellungen ändern.",
|
||||
it: "I membri gestiscono prodotti e feed. Gli amministratori possono anche invitare colleghi e modificare le impostazioni dell'azienda.",
|
||||
pt: "Os membros gerem produtos e feeds. Os administradores também podem convidar colegas e alterar as definições da empresa.",
|
||||
nl: "Leden beheren producten en feeds. Beheerders kunnen ook teamleden uitnodigen en bedrijfsinstellingen wijzigen.",
|
||||
pl: "Członkowie zarządzają produktami i feedami. Administratorzy mogą też zapraszać współpracowników i zmieniać ustawienia firmy.",
|
||||
ja: "メンバーは商品とフィードを管理します。管理者は同僚の招待と会社設定の変更もできます。"
|
||||
},
|
||||
"Demo sandbox is empty — switch to A1 or connect a feed to see real catalog stats.": {
|
||||
es: "La zona de pruebas demo está vacía — cambia a A1 o conecta un feed para ver estadísticas reales del catálogo.",
|
||||
fr: "Le bac à sable démo est vide — basculez vers A1 ou connectez un flux pour voir de vraies stats catalogue.",
|
||||
de: "Demo-Sandbox ist leer — wechseln Sie zu A1 oder verbinden Sie einen Feed, um echte Katalogstatistiken zu sehen.",
|
||||
it: "La sandbox demo è vuota — passa ad A1 o collega un feed per vedere statistiche reali del catalogo.",
|
||||
pt: "A sandbox de demonstração está vazia — mude para A1 ou ligue um feed para ver estatísticas reais do catálogo.",
|
||||
nl: "Demo-sandbox is leeg — schakel over naar A1 of koppel een feed om echte catalogusstatistieken te zien.",
|
||||
pl: "Piaskownica demo jest pusta — przełącz na A1 lub podłącz feed, aby zobaczyć realne statystyki katalogu.",
|
||||
ja: "デモサンドボックスは空です — A1に切り替えるかフィードを接続して実際のカタログ統計を表示します。"
|
||||
},
|
||||
"Add a feed or upload a CSV to populate this workspace.": {
|
||||
es: "Añade un feed o sube un CSV para poblar este espacio de trabajo.",
|
||||
fr: "Ajoutez un flux ou téléversez un CSV pour remplir cet espace de travail.",
|
||||
de: "Fügen Sie einen Feed hinzu oder laden Sie eine CSV hoch, um diesen Arbeitsbereich zu füllen.",
|
||||
it: "Aggiungi un feed o carica un CSV per popolare questo spazio di lavoro.",
|
||||
pt: "Adicione um feed ou carregue um CSV para preencher este espaço de trabalho.",
|
||||
nl: "Voeg een feed toe of upload een CSV om deze werkruimte te vullen.",
|
||||
pl: "Dodaj feed lub prześlij CSV, aby wypełnić tę przestrzeń roboczą.",
|
||||
ja: "フィードを追加するかCSVをアップロードして、このワークスペースにデータを入れます。"
|
||||
},
|
||||
"Add a feed or upload a CSV to start using your credits.": {
|
||||
es: "Añade un feed o sube un CSV para empezar a usar tus créditos.",
|
||||
fr: "Ajoutez un flux ou téléversez un CSV pour commencer à utiliser vos crédits.",
|
||||
de: "Fügen Sie einen Feed hinzu oder laden Sie eine CSV hoch, um Ihre Credits zu nutzen.",
|
||||
it: "Aggiungi un feed o carica un CSV per iniziare a usare i tuoi crediti.",
|
||||
pt: "Adicione um feed ou carregue um CSV para começar a usar os seus créditos.",
|
||||
nl: "Voeg een feed toe of upload een CSV om uw credits te gebruiken.",
|
||||
pl: "Dodaj feed lub prześlij CSV, aby zacząć używać kredytów.",
|
||||
ja: "フィードを追加するかCSVをアップロードして、クレジットの利用を開始します。"
|
||||
},
|
||||
"Import → enrich → publish. Jump to the next step for {name}.": {
|
||||
es: "Importar → enriquecer → publicar. Salta al siguiente paso para {name}.",
|
||||
fr: "Importer → enrichir → publier. Passez à l'étape suivante pour {name}.",
|
||||
de: "Importieren → anreichern → veröffentlichen. Zum nächsten Schritt für {name}.",
|
||||
it: "Importa → arricchisci → pubblica. Vai al passo successivo per {name}.",
|
||||
pt: "Importar → enriquecer → publicar. Salte para o passo seguinte para {name}.",
|
||||
nl: "Importeren → verrijken → publiceren. Ga naar de volgende stap voor {name}.",
|
||||
pl: "Importuj → wzbogacaj → publikuj. Przejdź do następnego kroku dla {name}.",
|
||||
ja: "インポート → 強化 → 公開。{name} の次のステップへ。"
|
||||
},
|
||||
"Switch to A1 (or another seeded company) in the header, or connect a feed here to populate this sandbox.": {
|
||||
es: "Cambia a A1 (u otra empresa con datos) en el encabezado, o conecta un feed aquí para poblar esta zona de pruebas.",
|
||||
fr: "Basculez vers A1 (ou une autre entreprise seedée) dans l'en-tête, ou connectez un flux ici pour remplir ce bac à sable.",
|
||||
de: "Wechseln Sie in der Kopfzeile zu A1 (oder einem anderen Seed-Unternehmen) oder verbinden Sie hier einen Feed, um diese Sandbox zu füllen.",
|
||||
it: "Passa ad A1 (o un'altra azienda con dati) nell'intestazione, oppure collega un feed qui per popolare questa sandbox.",
|
||||
pt: "Mude para A1 (ou outra empresa com dados) no cabeçalho, ou ligue um feed aqui para preencher esta sandbox.",
|
||||
nl: "Schakel in de header over naar A1 (of een ander geseeded bedrijf), of koppel hier een feed om deze sandbox te vullen.",
|
||||
pl: "Przełącz na A1 (lub inną firmę z danymi) w nagłówku albo podłącz tu feed, aby wypełnić tę piaskownicę.",
|
||||
ja: "ヘッダーでA1(または別のシード済み会社)に切り替えるか、ここでフィードを接続してサンドボックスにデータを入れます。"
|
||||
},
|
||||
"Connect a feed or upload a CSV to start building your catalog.": {
|
||||
es: "Conecta un feed o sube un CSV para empezar a crear tu catálogo.",
|
||||
fr: "Connectez un flux ou téléversez un CSV pour commencer à construire votre catalogue.",
|
||||
de: "Verbinden Sie einen Feed oder laden Sie eine CSV hoch, um Ihren Katalog aufzubauen.",
|
||||
it: "Collega un feed o carica un CSV per iniziare a costruire il catalogo.",
|
||||
pt: "Ligue um feed ou carregue um CSV para começar a criar o seu catálogo.",
|
||||
nl: "Koppel een feed of upload een CSV om uw catalogus op te bouwen.",
|
||||
pl: "Podłącz feed lub prześlij CSV, aby zacząć budować katalog.",
|
||||
ja: "フィードを接続するかCSVをアップロードして、カタログの構築を開始します。"
|
||||
},
|
||||
"{used} of {max} products used. Feed mapping, basic cleanup, and EU energy labels (EPREL) are included; upgrade for AI titles and descriptions, and more capacity.": {
|
||||
es: "{used} de {max} productos usados. El mapeo de feeds, la limpieza básica y las etiquetas energéticas de la UE (EPREL) están incluidos; actualiza para títulos y descripciones con IA, y más capacidad.",
|
||||
fr: "{used} sur {max} produits utilisés. Le mapping des flux, le nettoyage de base et les labels énergétiques UE (EPREL) sont inclus ; passez à une offre supérieure pour les titres et descriptions IA, et plus de capacité.",
|
||||
de: "{used} von {max} Produkten genutzt. Feed-Zuordnung, Basisbereinigung und EU-Energieetiketten (EPREL) sind enthalten; upgraden Sie für KI-Titel und -Beschreibungen sowie mehr Kapazität.",
|
||||
it: "{used} di {max} prodotti usati. Mappatura feed, pulizia di base ed etichette energetiche UE (EPREL) sono inclusi; passa a un piano superiore per titoli e descrizioni IA e più capacità.",
|
||||
pt: "{used} de {max} produtos usados. O mapeamento de feeds, a limpeza básica e as etiquetas energéticas da UE (EPREL) estão incluídos; atualize para títulos e descrições com IA e mais capacidade.",
|
||||
nl: "{used} van {max} producten gebruikt. Feed-mapping, basisopschoning en EU-energielabels (EPREL) zijn inbegrepen; upgrade voor AI-titels en -beschrijvingen en meer capaciteit.",
|
||||
pl: "Użyto {used} z {max} produktów. Mapowanie feedów, podstawowe czyszczenie i etykiety energetyczne UE (EPREL) są wliczone; ulepsz plan o tytuły i opisy AI oraz większą pojemność.",
|
||||
ja: "{max} 件中 {used} 件の商品を使用中。フィードマッピング、基本クリーンアップ、EUエネルギーラベル(EPREL)は含まれます。AIタイトル・説明と容量増加はアップグレードが必要です。"
|
||||
},
|
||||
"Feed mapping, basic cleanup, and EU energy labels (EPREL) are included; upgrade for AI titles and descriptions, and more capacity.": {
|
||||
es: "El mapeo de feeds, la limpieza básica y las etiquetas energéticas de la UE (EPREL) están incluidos; actualiza para títulos y descripciones con IA, y más capacidad.",
|
||||
fr: "Le mapping des flux, le nettoyage de base et les labels énergétiques UE (EPREL) sont inclus ; passez à une offre supérieure pour les titres et descriptions IA, et plus de capacité.",
|
||||
de: "Feed-Zuordnung, Basisbereinigung und EU-Energieetiketten (EPREL) sind enthalten; upgraden Sie für KI-Titel und -Beschreibungen sowie mehr Kapazität.",
|
||||
it: "Mappatura feed, pulizia di base ed etichette energetiche UE (EPREL) sono inclusi; passa a un piano superiore per titoli e descrizioni IA e più capacità.",
|
||||
pt: "O mapeamento de feeds, a limpeza básica e as etiquetas energéticas da UE (EPREL) estão incluídos; atualize para títulos e descrições com IA e mais capacidade.",
|
||||
nl: "Feed-mapping, basisopschoning en EU-energielabels (EPREL) zijn inbegrepen; upgrade voor AI-titels en -beschrijvingen en meer capaciteit.",
|
||||
pl: "Mapowanie feedów, podstawowe czyszczenie i etykiety energetyczne UE (EPREL) są wliczone; ulepsz plan o tytuły i opisy AI oraz większą pojemność.",
|
||||
ja: "フィードマッピング、基本クリーンアップ、EUエネルギーラベル(EPREL)は含まれます。AIタイトル・説明と容量増加はアップグレードが必要です。"
|
||||
},
|
||||
"Buy more credits or upgrade your plan to keep processing.": {
|
||||
es: "Compra más créditos o actualiza tu plan para seguir procesando.",
|
||||
fr: "Achetez plus de crédits ou passez à une offre supérieure pour continuer le traitement.",
|
||||
de: "Kaufen Sie mehr Credits oder upgraden Sie Ihren Plan, um die Verarbeitung fortzusetzen.",
|
||||
it: "Acquista altri crediti o passa a un piano superiore per continuare l'elaborazione.",
|
||||
pt: "Compre mais créditos ou atualize o plano para continuar a processar.",
|
||||
nl: "Koop meer credits of upgrade uw plan om te blijven verwerken.",
|
||||
pl: "Kup więcej kredytów lub ulepsz plan, aby kontynuować przetwarzanie.",
|
||||
ja: "処理を続けるにはクレジットを追加購入するかプランをアップグレードしてください。"
|
||||
},
|
||||
"Your {plan} plan allows {max} products ({count} in catalog). Upgrade to process more.": {
|
||||
es: "Tu plan {plan} permite {max} productos ({count} en el catálogo). Actualiza para procesar más.",
|
||||
fr: "Votre offre {plan} autorise {max} produits ({count} dans le catalogue). Passez à une offre supérieure pour en traiter plus.",
|
||||
de: "Ihr {plan}-Plan erlaubt {max} Produkte ({count} im Katalog). Upgraden Sie, um mehr zu verarbeiten.",
|
||||
it: "Il piano {plan} consente {max} prodotti ({count} nel catalogo). Passa a un piano superiore per elaborarne di più.",
|
||||
pt: "O seu plano {plan} permite {max} produtos ({count} no catálogo). Atualize para processar mais.",
|
||||
nl: "Uw {plan}-plan staat {max} producten toe ({count} in catalogus). Upgrade om meer te verwerken.",
|
||||
pl: "Twój plan {plan} pozwala na {max} produktów ({count} w katalogu). Ulepsz, aby przetwarzać więcej.",
|
||||
ja: "{plan} プランでは商品 {max} 件までです(カタログ内 {count} 件)。さらに処理するにはアップグレードしてください。"
|
||||
},
|
||||
"Your {plan} plan product limit is reached. Upgrade to process more.": {
|
||||
es: "Se alcanzó el límite de productos de tu plan {plan}. Actualiza para procesar más.",
|
||||
fr: "La limite de produits de votre offre {plan} est atteinte. Passez à une offre supérieure pour en traiter plus.",
|
||||
de: "Das Produktlimit Ihres {plan}-Plans ist erreicht. Upgraden Sie, um mehr zu verarbeiten.",
|
||||
it: "È stato raggiunto il limite prodotti del piano {plan}. Passa a un piano superiore per elaborarne di più.",
|
||||
pt: "O limite de produtos do plano {plan} foi atingido. Atualize para processar mais.",
|
||||
nl: "De productlimiet van uw {plan}-plan is bereikt. Upgrade om meer te verwerken.",
|
||||
pl: "Osiągnięto limit produktów planu {plan}. Ulepsz, aby przetwarzać więcej.",
|
||||
ja: "{plan} プランの商品上限に達しました。さらに処理するにはアップグレードしてください。"
|
||||
},
|
||||
"{remaining} of {total} credits left. Top up or upgrade before jobs stall.": {
|
||||
es: "Quedan {remaining} de {total} créditos. Recarga o actualiza antes de que se detengan los trabajos.",
|
||||
fr: "Il reste {remaining} crédits sur {total}. Rechargez ou passez à une offre supérieure avant que les tâches ne s'arrêtent.",
|
||||
de: "{remaining} von {total} Credits übrig. Laden Sie auf oder upgraden Sie, bevor Jobs stoppen.",
|
||||
it: "Restano {remaining} di {total} crediti. Ricarica o passa a un piano superiore prima che i processi si fermino.",
|
||||
pt: "Restam {remaining} de {total} créditos. Recarregue ou atualize antes de as tarefas pararem.",
|
||||
nl: "{remaining} van {total} credits over. Vul aan of upgrade voordat jobs stilvallen.",
|
||||
pl: "Pozostało {remaining} z {total} kredytów. Doładuj lub ulepsz, zanim zadania się zatrzymają.",
|
||||
ja: "クレジット残り {total} 中 {remaining}。ジョブが止まる前に補充またはアップグレードしてください。"
|
||||
},
|
||||
"No jobs yet — import products first.": {
|
||||
es: "Aún no hay trabajos — importa productos primero.",
|
||||
fr: "Pas encore de tâches — importez d'abord des produits.",
|
||||
de: "Noch keine Jobs — importieren Sie zuerst Produkte.",
|
||||
it: "Ancora nessun processo — importa prima i prodotti.",
|
||||
pt: "Ainda sem tarefas — importe produtos primeiro.",
|
||||
nl: "Nog geen jobs — importeer eerst producten.",
|
||||
pl: "Brak jeszcze zadań — najpierw zaimportuj produkty.",
|
||||
ja: "まだジョブがありません — 先に商品をインポートしてください。"
|
||||
},
|
||||
"No recent jobs. Start one from Products when you are ready.": {
|
||||
es: "No hay trabajos recientes. Inicia uno desde Productos cuando estés listo.",
|
||||
fr: "Aucune tâche récente. Démarrez-en une depuis Produits quand vous êtes prêt.",
|
||||
de: "Keine aktuellen Jobs. Starten Sie einen unter Produkte, wenn Sie bereit sind.",
|
||||
it: "Nessun processo recente. Avviane uno da Prodotti quando sei pronto.",
|
||||
pt: "Sem tarefas recentes. Inicie uma em Produtos quando estiver pronto.",
|
||||
nl: "Geen recente jobs. Start er een vanuit Producten wanneer u klaar bent.",
|
||||
pl: "Brak ostatnich zadań. Uruchom jedno w Produktach, gdy będziesz gotowy.",
|
||||
ja: "最近のジョブはありません。準備ができたら商品から開始してください。"
|
||||
},
|
||||
"Turn on the standard product columns Descrybe maps and processes.": {
|
||||
es: "Activa las columnas de producto estándar que Descrybe mapea y procesa.",
|
||||
fr: "Activez les colonnes produit standard que Descrybe mappe et traite.",
|
||||
de: "Aktivieren Sie die Standard-Produktspalten, die Descrybe zuordnet und verarbeitet.",
|
||||
it: "Attiva le colonne prodotto standard che Descrybe mappa ed elabora.",
|
||||
pt: "Ative as colunas de produto padrão que o Descrybe mapeia e processa.",
|
||||
nl: "Schakel de standaard productkolommen in die Descrybe mapt en verwerkt.",
|
||||
pl: "Włącz standardowe kolumny produktów, które Descrybe mapuje i przetwarza.",
|
||||
ja: "Descrybeがマップおよび処理する標準の商品列を有効にします。"
|
||||
},
|
||||
"Add a CSV/XML feed or connect a store so products can flow in.": {
|
||||
es: "Añade un feed CSV/XML o conecta una tienda para que entren productos.",
|
||||
fr: "Ajoutez un flux CSV/XML ou connectez une boutique pour faire entrer les produits.",
|
||||
de: "Fügen Sie einen CSV/XML-Feed hinzu oder verbinden Sie einen Shop, damit Produkte einfließen können.",
|
||||
it: "Aggiungi un feed CSV/XML o collega un negozio così che i prodotti possano entrare.",
|
||||
pt: "Adicione um feed CSV/XML ou ligue uma loja para os produtos poderem entrar.",
|
||||
nl: "Voeg een CSV/XML-feed toe of koppel een winkel zodat producten kunnen binnenkomen.",
|
||||
pl: "Dodaj feed CSV/XML lub podłącz sklep, aby produkty mogły napływać.",
|
||||
ja: "CSV/XMLフィードを追加するかストアを接続して、商品を取り込めるようにします。"
|
||||
},
|
||||
"Match supplier columns to Descrybe fields, then save the mapping.": {
|
||||
es: "Asocia las columnas del proveedor a los campos de Descrybe y guarda el mapeo.",
|
||||
fr: "Faites correspondre les colonnes fournisseur aux champs Descrybe, puis enregistrez le mapping.",
|
||||
de: "Ordnen Sie Lieferantenspalten den Descrybe-Feldern zu und speichern Sie die Zuordnung.",
|
||||
it: "Abbina le colonne del fornitore ai campi Descrybe, poi salva la mappatura.",
|
||||
pt: "Faça corresponder as colunas do fornecedor aos campos Descrybe e guarde o mapeamento.",
|
||||
nl: "Koppel leverancierskolommen aan Descrybe-velden en sla de mapping op.",
|
||||
pl: "Dopasuj kolumny dostawcy do pól Descrybe, a następnie zapisz mapowanie.",
|
||||
ja: "仕入先の列をDescrybeのフィールドに対応付け、マッピングを保存します。"
|
||||
},
|
||||
"Pull a small sample so you can verify mapping before a full run.": {
|
||||
es: "Extrae una muestra pequeña para verificar el mapeo antes de una ejecución completa.",
|
||||
fr: "Récupérez un petit échantillon pour vérifier le mapping avant une exécution complète.",
|
||||
de: "Ziehen Sie eine kleine Stichprobe, um die Zuordnung vor einem vollständigen Lauf zu prüfen.",
|
||||
it: "Recupera un piccolo campione per verificare la mappatura prima di un'esecuzione completa.",
|
||||
pt: "Obtenha uma pequena amostra para verificar o mapeamento antes de uma execução completa.",
|
||||
nl: "Haal een kleine steekproef op om de mapping te controleren vóór een volledige run.",
|
||||
pl: "Pobierz małą próbkę, aby zweryfikować mapowanie przed pełnym uruchomieniem.",
|
||||
ja: "本番実行の前にマッピングを確認できるよう、小さなサンプルを取得します。"
|
||||
},
|
||||
"Run processing on synced products to generate cleaned catalog content.": {
|
||||
es: "Ejecuta el procesamiento sobre productos sincronizados para generar contenido de catálogo limpio.",
|
||||
fr: "Lancez le traitement sur les produits synchronisés pour générer un contenu catalogue nettoyé.",
|
||||
de: "Führen Sie die Verarbeitung für synchronisierte Produkte aus, um bereinigte Kataloginhalte zu erzeugen.",
|
||||
it: "Esegui l'elaborazione sui prodotti sincronizzati per generare contenuti di catalogo puliti.",
|
||||
pt: "Execute o processamento nos produtos sincronizados para gerar conteúdo de catálogo limpo.",
|
||||
nl: "Voer verwerking uit op gesynchroniseerde producten om schone catalogusinhoud te genereren.",
|
||||
pl: "Uruchom przetwarzanie zsynchronizowanych produktów, aby wygenerować oczyszczoną treść katalogu.",
|
||||
ja: "同期済み商品を処理して、整備されたカタログコンテンツを生成します。"
|
||||
},
|
||||
"Create an export feed to publish cleaned products as XML or CSV.": {
|
||||
es: "Crea un feed de exportación para publicar productos limpios como XML o CSV.",
|
||||
fr: "Créez un flux d'export pour publier les produits nettoyés en XML ou CSV.",
|
||||
de: "Erstellen Sie einen Export-Feed, um bereinigte Produkte als XML oder CSV zu veröffentlichen.",
|
||||
it: "Crea un feed di esportazione per pubblicare prodotti puliti come XML o CSV.",
|
||||
pt: "Crie um feed de exportação para publicar produtos limpos como XML ou CSV.",
|
||||
nl: "Maak een exportfeed om schone producten als XML of CSV te publiceren.",
|
||||
pl: "Utwórz feed eksportu, aby publikować oczyszczone produkty jako XML lub CSV.",
|
||||
ja: "整備された商品をXMLまたはCSVとして公開するエクスポートフィードを作成します。"
|
||||
},
|
||||
"Company Settings": {
|
||||
es: "Configuración de la empresa",
|
||||
fr: "Paramètres de l'entreprise",
|
||||
de: "Unternehmenseinstellungen",
|
||||
it: "Impostazioni azienda",
|
||||
pt: "Definições da empresa",
|
||||
nl: "Bedrijfsinstellingen",
|
||||
pl: "Ustawienia firmy",
|
||||
ja: "会社の設定"
|
||||
},
|
||||
"Active company": { es: "Empresa activa" },
|
||||
"Credits overview": { es: "Resumen de créditos" },
|
||||
Plan: { es: "Plan" },
|
||||
Used: { es: "Usados" },
|
||||
"Company Information": { es: "Información de la empresa" },
|
||||
"Update your company details": { es: "Actualiza los datos de tu empresa" },
|
||||
"Company Name": { es: "Nombre de la empresa" },
|
||||
"Your company name": { es: "Nombre de tu empresa" },
|
||||
"Content Settings": { es: "Configuración de contenido" },
|
||||
"Merge products with the same GTIN": { es: "Fusionar productos con el mismo GTIN" },
|
||||
"Email integration": { es: "Integración de correo" },
|
||||
"AI integrations": { es: "Integraciones de IA" },
|
||||
"Operator alerts": { es: "Alertas del operador" },
|
||||
"In-app toasts": { es: "Toasts en la app" },
|
||||
"Email alerts": { es: "Alertas por correo" },
|
||||
"API Keys": { es: "Claves API" },
|
||||
"Create API Key": { es: "Crear clave API" },
|
||||
"Create API key": { es: "Crear clave API" },
|
||||
"API key": { es: "Clave API" },
|
||||
"Key name": { es: "Nombre de la clave" },
|
||||
"Store it somewhere safe.": { es: "Guárdala en un lugar seguro." },
|
||||
Name: { es: "Nombre" },
|
||||
Key: { es: "Clave" },
|
||||
"Last Used": { es: "Último uso" },
|
||||
"Resume tutorial": { es: "Reanudar tutorial" },
|
||||
"Restart tutorial": { es: "Reiniciar tutorial" },
|
||||
"Process products": { es: "Procesar productos" },
|
||||
"Open products": { es: "Abrir productos" },
|
||||
"Welcome to {name}": { es: "Bienvenido a {name}" },
|
||||
Trial: { es: "Prueba" },
|
||||
"Dashboard actions": { es: "Acciones del panel" },
|
||||
Processing: {
|
||||
es: "Procesando",
|
||||
fr: "Traitement",
|
||||
de: "Verarbeitung",
|
||||
it: "Elaborazione",
|
||||
pt: "A processar",
|
||||
nl: "Verwerken",
|
||||
pl: "Przetwarzanie",
|
||||
ja: "処理中"
|
||||
},
|
||||
Completed: {
|
||||
es: "Completado",
|
||||
fr: "Terminé",
|
||||
de: "Abgeschlossen",
|
||||
it: "Completato",
|
||||
pt: "Concluído",
|
||||
nl: "Voltooid",
|
||||
pl: "Ukończono",
|
||||
ja: "完了"
|
||||
},
|
||||
Failed: {
|
||||
es: "Fallido",
|
||||
fr: "Échoué",
|
||||
de: "Fehlgeschlagen",
|
||||
it: "Non riuscito",
|
||||
pt: "Falhou",
|
||||
nl: "Mislukt",
|
||||
pl: "Niepowodzenie",
|
||||
ja: "失敗"
|
||||
},
|
||||
Cancelled: {
|
||||
es: "Cancelado",
|
||||
fr: "Annulé",
|
||||
de: "Abgebrochen",
|
||||
it: "Annullato",
|
||||
pt: "Cancelado",
|
||||
nl: "Geannuleerd",
|
||||
pl: "Anulowano",
|
||||
ja: "キャンセル済み"
|
||||
},
|
||||
Exports: {
|
||||
es: "Exportaciones",
|
||||
fr: "Exports",
|
||||
de: "Exporte",
|
||||
it: "Esportazioni",
|
||||
pt: "Exportações",
|
||||
nl: "Exports",
|
||||
pl: "Eksporty",
|
||||
ja: "エクスポート"
|
||||
},
|
||||
Actions: {
|
||||
es: "Acciones",
|
||||
fr: "Actions",
|
||||
de: "Aktionen",
|
||||
it: "Azioni",
|
||||
pt: "Ações",
|
||||
nl: "Acties",
|
||||
pl: "Akcje",
|
||||
ja: "操作"
|
||||
},
|
||||
"All {count} products failed.": {
|
||||
es: "Fallaron los {count} productos.",
|
||||
fr: "Les {count} produits ont échoué.",
|
||||
de: "Alle {count} Produkte sind fehlgeschlagen.",
|
||||
it: "Tutti i {count} prodotti non sono riusciti.",
|
||||
pt: "Todos os {count} produtos falharam.",
|
||||
nl: "Alle {count} producten zijn mislukt.",
|
||||
pl: "Wszystkie {count} produktów nie powiodło się.",
|
||||
ja: "{count} 件すべての商品が失敗しました。"
|
||||
},
|
||||
"{count} products failed.": {
|
||||
es: "Fallaron {count} productos.",
|
||||
fr: "{count} produits ont échoué.",
|
||||
de: "{count} Produkte sind fehlgeschlagen.",
|
||||
it: "{count} prodotti non sono riusciti.",
|
||||
pt: "{count} produtos falharam.",
|
||||
nl: "{count} producten zijn mislukt.",
|
||||
pl: "{count} produktów nie powiodło się.",
|
||||
ja: "{count} 件の商品が失敗しました。"
|
||||
}
|
||||
};
|
||||
|
||||
for (const [en, langs] of Object.entries(GAPS)) {
|
||||
map[en] = { ...(map[en] || {}), ...langs };
|
||||
}
|
||||
fs.writeFileSync("phrase-map.json", JSON.stringify(map, null, 2));
|
||||
console.log("phrase-map entries", Object.keys(map).length);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Harvest EN→locale phrase map from already-translated message keys,
|
||||
* then merge into phrase-map.json (does not overwrite non-empty existing locales).
|
||||
*
|
||||
* Run: node apps/web/scripts/harvest-phrase-map.mjs
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const messagesDir = path.resolve(__dirname, "../src/lib/i18n/messages");
|
||||
const mapPath = path.join(__dirname, "phrase-map.json");
|
||||
const LOCALES = ["es", "fr", "de", "it", "pt", "nl", "pl", "ja"];
|
||||
|
||||
function parseMessageDict(source) {
|
||||
const dict = {};
|
||||
const re = /"([^"\\]+)"\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:`(?:\\.|[^`\\])*`))/gs;
|
||||
let m;
|
||||
while ((m = re.exec(source))) {
|
||||
const key = m[1];
|
||||
const raw = m[2];
|
||||
dict[key] = raw.startsWith("`")
|
||||
? raw.slice(1, -1).replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\n/g, "\n")
|
||||
: JSON.parse(raw);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
function load(code) {
|
||||
return parseMessageDict(fs.readFileSync(path.join(messagesDir, `${code}.ts`), "utf8"));
|
||||
}
|
||||
|
||||
const en = load("en");
|
||||
const packs = Object.fromEntries(LOCALES.map((c) => [c, load(c)]));
|
||||
const map = fs.existsSync(mapPath) ? JSON.parse(fs.readFileSync(mapPath, "utf8")) : {};
|
||||
|
||||
let harvestedPhrases = 0;
|
||||
let filledSlots = 0;
|
||||
let newPhrases = 0;
|
||||
|
||||
for (const [key, enVal] of Object.entries(en)) {
|
||||
if (!enVal || typeof enVal !== "string") continue;
|
||||
const byLoc = {};
|
||||
let good = 0;
|
||||
for (const code of LOCALES) {
|
||||
const v = packs[code][key];
|
||||
if (typeof v === "string" && v.trim() && v !== enVal) {
|
||||
byLoc[code] = v;
|
||||
good += 1;
|
||||
}
|
||||
}
|
||||
if (good < 4) continue;
|
||||
|
||||
const existing = map[enVal] ?? {};
|
||||
let changed = false;
|
||||
const next = { ...existing };
|
||||
for (const code of LOCALES) {
|
||||
if ((!next[code] || !String(next[code]).trim()) && byLoc[code]) {
|
||||
next[code] = byLoc[code];
|
||||
filledSlots += 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
if (!map[enVal]) newPhrases += 1;
|
||||
map[enVal] = next;
|
||||
harvestedPhrases += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(mapPath, JSON.stringify(map, null, "\t") + "\n", "utf8");
|
||||
console.log({
|
||||
phraseCount: Object.keys(map).length,
|
||||
harvestedPhrases,
|
||||
newPhrases,
|
||||
filledSlots,
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve("apps/api/internal/httpapi");
|
||||
const msgs = new Set();
|
||||
function walk(dir) {
|
||||
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, ent.name);
|
||||
if (ent.isDirectory()) walk(p);
|
||||
else if (ent.name.endsWith(".go")) {
|
||||
const s = fs.readFileSync(p, "utf8");
|
||||
for (const m of s.matchAll(/Error\(w,\s*http\.Status\w+,\s*"([^"]+)"/g)) {
|
||||
msgs.add(m[1]);
|
||||
}
|
||||
for (const m of s.matchAll(/CodedError\(w,\s*http\.Status\w+,\s*"[^"]+",\s*"([^"]+)"/g)) {
|
||||
msgs.add(m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
console.log([...msgs].sort().join("\n"));
|
||||
console.log("COUNT", msgs.size);
|
||||
@@ -0,0 +1,32 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
// Re-parse PACKS by evaluating gen script is hard; instead diff en vs es.
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const messagesDir = path.resolve(__dirname, "../src/lib/i18n/messages");
|
||||
|
||||
function parseMessageDict(source) {
|
||||
const dict = {};
|
||||
const re = /"([^"\\]+)"\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:`(?:\\.|[^`\\])*`))/gs;
|
||||
let m;
|
||||
while ((m = re.exec(source))) {
|
||||
const key = m[1];
|
||||
const raw = m[2];
|
||||
dict[key] = raw.startsWith("`")
|
||||
? raw.slice(1, -1).replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\n/g, "\n")
|
||||
: JSON.parse(raw);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
const en = parseMessageDict(fs.readFileSync(path.join(messagesDir, "en.ts"), "utf8"));
|
||||
const es = parseMessageDict(fs.readFileSync(path.join(messagesDir, "es.ts"), "utf8"));
|
||||
const missing = {};
|
||||
for (const [k, v] of Object.entries(en)) {
|
||||
if (es[k] === v) missing[k] = v; // likely untranslated (same as en) OR intentionally same
|
||||
}
|
||||
// Prefer keys not in our known translated set — dump all where es === en
|
||||
fs.writeFileSync(path.join(__dirname, "_missing.json"), JSON.stringify(missing, null, 2), "utf8");
|
||||
console.log(`candidates same-as-en: ${Object.keys(missing).length}`);
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function parseMessageDict(source) {
|
||||
const dict = {};
|
||||
const re = /"([^"\\]+)"\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:`(?:\\.|[^`\\])*`))/gs;
|
||||
let m;
|
||||
while ((m = re.exec(source))) {
|
||||
const key = m[1];
|
||||
const raw = m[2];
|
||||
dict[key] = raw.startsWith("`")
|
||||
? raw.slice(1, -1).replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\n/g, "\n")
|
||||
: JSON.parse(raw);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
const en = parseMessageDict(
|
||||
fs.readFileSync(path.resolve(__dirname, "../src/lib/i18n/messages/en.ts"), "utf8")
|
||||
);
|
||||
const fr = parseMessageDict(
|
||||
fs.readFileSync(path.resolve(__dirname, "../src/lib/i18n/messages/fr.ts"), "utf8")
|
||||
);
|
||||
const phrases = {};
|
||||
for (const [k, v] of Object.entries(en)) {
|
||||
if (fr[k] === v) phrases[v] = true;
|
||||
}
|
||||
const list = Object.keys(phrases);
|
||||
fs.writeFileSync(path.join(__dirname, "_phrases-needed.json"), JSON.stringify(list, null, 2));
|
||||
console.log("unique phrases needed", list.length);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Chrome + tutorial overlays for gen-locale-packs / rebuild.
|
||||
* Brand/loanwords stay in SAME_AS_EN (locale-extra.mjs).
|
||||
* One-shot scratch `_chrome-tutorial-data.mjs` was removed; packs already
|
||||
* contain those strings under src/lib/i18n/messages.
|
||||
*/
|
||||
/** @type {Record<string, Record<string, string>>} */
|
||||
export const EXTRA = {};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
/** Supplemental UI strings merged into gen-locale-packs.mjs (auth/settings/dashboard extras). */
|
||||
export const EXTRA = {
|
||||
es: {
|
||||
"common.askAdmin": "Pregunta a un administrador de la empresa",
|
||||
"common.you": "Tú",
|
||||
"common.active": "Activo",
|
||||
"common.pending": "Pendiente",
|
||||
"common.email": "Correo electrónico",
|
||||
"common.password": "Contraseña",
|
||||
"common.name": "Tu nombre",
|
||||
"common.viewPricing": "Ver precios",
|
||||
"common.backToSignIn": "Volver a iniciar sesión",
|
||||
"common.signingOut": "Cerrando sesión…",
|
||||
"common.signOutAndContinue": "Cerrar sesión y continuar",
|
||||
"common.staySignedIn": "Seguir conectado",
|
||||
"nav.section.feeds": "Feeds",
|
||||
"nav.section.marketing": "Marketing",
|
||||
"nav.feeds": "Feeds",
|
||||
"nav.seo": "SEO",
|
||||
"nav.admin": "Admin",
|
||||
"auth.login.title": "Iniciar sesión",
|
||||
"auth.login.description": "Usa tu correo y contraseña de Descrybe.",
|
||||
"auth.login.submit": "Iniciar sesión",
|
||||
"auth.login.submitting": "Iniciando sesión…",
|
||||
"auth.login.failed": "Error al iniciar sesión",
|
||||
"auth.login.passwordNotSet":
|
||||
"Esta cuenta aún necesita una contraseña. Abre el enlace de invitación o pide a un administrador que emita uno nuevo.",
|
||||
"auth.login.setPasswordFirstTitle": "Establece la contraseña primero:",
|
||||
"auth.login.setPasswordFirstBody":
|
||||
"usa el enlace de invitación de tu correo. Si el enlace fue a una dirección antigua (cambio de correo), pide a un administrador de la empresa que emita una nueva invitación para establecer contraseña a {email}.",
|
||||
"auth.login.yourEmail": "tu correo",
|
||||
"auth.login.platformAdminsReissue": "Los administradores de plataforma pueden reemitir desde",
|
||||
"auth.login.adminUsersLink": "Admin → Usuarios",
|
||||
"auth.login.haveToken": "¿Tienes un token? Abrir aceptar invitación",
|
||||
"auth.login.noAccount": "¿Sin cuenta?",
|
||||
"auth.login.createCompany": "Crear empresa",
|
||||
"auth.login.haveInvite": "¿Tienes una invitación o un enlace para establecer contraseña?",
|
||||
"auth.login.acceptInvite": "Aceptar invitación",
|
||||
"auth.register.title": "Crear empresa",
|
||||
"auth.register.description": "Registra una empresa y su usuario administrador.",
|
||||
"auth.register.companyName": "Nombre de la empresa",
|
||||
"auth.register.submit": "Crear cuenta",
|
||||
"auth.register.submitting": "Creando…",
|
||||
"auth.register.failed": "Error en el registro",
|
||||
"auth.register.haveAccount": "¿Ya tienes una cuenta?",
|
||||
"auth.register.signIn": "Iniciar sesión",
|
||||
"auth.invite.title": "Aceptar invitación",
|
||||
"auth.invite.setPasswordTitle": "Establecer contraseña",
|
||||
"auth.invite.description":
|
||||
"Establece tu contraseña para unirte a la empresa. Tu administrador asignó Miembro (trabajo diario) o Admin (equipo y facturación).",
|
||||
"auth.invite.setPasswordDescription":
|
||||
"Elige una contraseña para tu cuenta Descrybe migrada (al menos 8 caracteres).",
|
||||
"auth.invite.checking": "Comprobando invitación…",
|
||||
"auth.invite.forEmail": "Invitación para {email}.",
|
||||
"auth.invite.linkRecognized":
|
||||
"Enlace de invitación reconocido. Introduce una contraseña abajo para continuar — el secreto no se muestra en esta página.",
|
||||
"auth.invite.resetLinkRecognized":
|
||||
"Enlace de restablecimiento reconocido. Introduce una contraseña abajo para continuar — el secreto no se muestra en esta página.",
|
||||
"auth.invite.tokenLabel": "Token de invitación",
|
||||
"auth.invite.resetTokenLabel": "Token de restablecimiento",
|
||||
"auth.invite.tokenHelp":
|
||||
"Pega el token del correo de invitación. Se muestra enmascarado en este campo.",
|
||||
"auth.invite.passwordHint": "Al menos 8 caracteres. Sin otras reglas de complejidad.",
|
||||
"auth.invite.submit": "Aceptar invitación",
|
||||
"auth.invite.setPasswordSubmit": "Establecer contraseña",
|
||||
"auth.invite.accepting": "Aceptando…",
|
||||
"auth.invite.saving": "Guardando…",
|
||||
"auth.invite.verifyFailed": "No se pudo verificar la invitación",
|
||||
"auth.invite.verifySetPasswordFailed":
|
||||
"No se pudo verificar el enlace para establecer contraseña",
|
||||
"auth.invite.acceptFailed": "No se pudo aceptar la invitación",
|
||||
"auth.invite.setPasswordFailed": "No se pudo establecer la contraseña",
|
||||
"auth.invite.expired":
|
||||
"Esta invitación no es válida o ha caducado. Pide a tu administrador de la empresa que envíe una nueva invitación y abre el nuevo enlace (o pega el nuevo token abajo).",
|
||||
"auth.invite.setPasswordExpired":
|
||||
"Este enlace para establecer contraseña no es válido o ha caducado. Pide a un administrador de la empresa o de la plataforma que lo reemita y abre el nuevo enlace (o pega el nuevo token abajo).",
|
||||
"auth.invite.emailMismatchDefault":
|
||||
"Has iniciado sesión con un correo distinto al de esta invitación.",
|
||||
"auth.invite.expiredFooter":
|
||||
"¿Enlace caducado? Pide a un administrador que lo reemita — no hay API de reenvío autoservicio. Administradores de plataforma:",
|
||||
"auth.invite.adminUsersLink": "Admin → Usuarios",
|
||||
"auth.invite.doneTitle": "Ya formas parte del equipo",
|
||||
"auth.invite.doneSetPasswordTitle": "Contraseña guardada",
|
||||
"auth.invite.doneDescription":
|
||||
"Tu cuenta está lista. A continuación, abre el panel para trabajar con feeds y productos, o revisa la configuración de la empresa.",
|
||||
"auth.invite.doneSetPasswordDescription":
|
||||
"Inicia sesión con tu correo y la nueva contraseña para abrir tu espacio de trabajo.",
|
||||
"auth.invite.openDashboard": "Abrir panel",
|
||||
"auth.invite.companySettings": "Configuración de la empresa",
|
||||
"auth.invite.goToSignIn": "Ir a iniciar sesión",
|
||||
"auth.invite.afterSignInNote":
|
||||
"Tras iniciar sesión llegas a tu espacio de trabajo — se omite el recorrido de configuración inicial.",
|
||||
"auth.invite.mismatchTitle": "Cuenta incorrecta para esta invitación",
|
||||
"auth.invite.mismatchDescription":
|
||||
"Este enlace es para un correo distinto al de la sesión actual. Cierra sesión para continuar como el usuario invitado, o permanece conectado y pide a un administrador que reemita la invitación.",
|
||||
"auth.invite.mismatchDetail":
|
||||
"Sesión iniciada como {session}, pero esta invitación es para {invite}.",
|
||||
"auth.invite.mismatchFallback": "El correo de la sesión no coincide con esta invitación.",
|
||||
"auth.invite.switchAccountTitle": "Cambiar de cuenta:",
|
||||
"auth.invite.switchAccountBody":
|
||||
"cierra sesión y completa este formulario con el correo invitado{emailSuffix}.",
|
||||
"auth.invite.reissueTitle": "Vía de reemisión:",
|
||||
"auth.invite.reissueBody":
|
||||
"si cambió tu correo real de acceso (desfase de correo), pide a un administrador de la empresa que revoque esta invitación y envíe una nueva al correo con el que inicias sesión. Los administradores de plataforma también pueden reemitir enlaces para establecer contraseña desde Admin → Usuarios.",
|
||||
"settings.accessDenied":
|
||||
"No tienes permiso para abrir la configuración de la empresa. Pide ayuda a un administrador de la empresa.",
|
||||
"settings.profileHeading": "Perfil",
|
||||
"settings.personalInfo": "Información personal",
|
||||
"settings.personalInfoHelp": "Actualiza tus datos personales",
|
||||
"settings.firstName": "Nombre",
|
||||
"settings.firstNamePlaceholder": "Tu nombre",
|
||||
"settings.lastName": "Apellidos",
|
||||
"settings.lastNamePlaceholder": "Tus apellidos",
|
||||
"settings.email": "Correo electrónico",
|
||||
"settings.profileUpdated": "Perfil actualizado.",
|
||||
"settings.profileUpdateFailed": "No se pudo actualizar el perfil",
|
||||
"settings.role.member": "Miembro",
|
||||
"settings.role.admin": "Admin",
|
||||
"settings.teamHeading": "Miembros del equipo",
|
||||
"settings.inviteUser": "Invitar usuario",
|
||||
"settings.teamAdminOnly":
|
||||
"Solo los administradores de la empresa pueden invitar, ascender, degradar o eliminar compañeros.",
|
||||
"settings.shareAcceptLink": "Compartir enlace de aceptación",
|
||||
"settings.shareAcceptLinkHelp":
|
||||
"El correo saliente no está configurado. Copia este enlace de un solo uso y envíaselo al invitado. Establecerá una contraseña (al menos 8 caracteres) y se unirá con el rol que elegiste.",
|
||||
"settings.acceptLinkLabel": "Enlace de aceptación de invitación de un solo uso",
|
||||
"settings.copyLink": "Copiar enlace",
|
||||
"settings.linkCopied": "Enlace de aceptación copiado.",
|
||||
"settings.table.email": "Correo electrónico",
|
||||
"settings.table.role": "Rol",
|
||||
"settings.table.status": "Estado",
|
||||
"settings.table.joined": "Alta / caduca",
|
||||
"settings.table.actions": "Acciones",
|
||||
"settings.teamForbidden":
|
||||
"No tienes permiso para ver la lista del equipo. Pide ayuda a un administrador de la empresa.",
|
||||
"settings.noTeammates": "Aún no hay compañeros",
|
||||
"settings.noTeammatesHelp":
|
||||
"Invita a colegas como Miembro (productos y feeds) o Admin (equipo y configuración de la empresa). Las invitaciones pendientes aparecen aquí hasta que se acepten.",
|
||||
"settings.noTeammatesMemberHelp":
|
||||
"Aún no hay compañeros en la lista. Pide a un administrador de la empresa que envíe invitaciones.",
|
||||
"settings.memberActions": "Acciones del miembro",
|
||||
"settings.makeAdmin": "Hacer admin",
|
||||
"settings.makeMember": "Hacer miembro",
|
||||
"settings.removeMember": "Eliminar",
|
||||
"settings.revokeInvite": "Revocar invitación",
|
||||
"settings.needOneAdmin": "Las empresas necesitan al menos un administrador",
|
||||
"settings.expires": "Caduca el {date}",
|
||||
"settings.invalidEmail": "Introduce una dirección de correo válida.",
|
||||
"settings.inviteCreatedNoMail":
|
||||
"Invitación creada para {email} como {role}. Copia el enlace de aceptación abajo y compártelo — el correo saliente no está configurado.",
|
||||
"settings.inviteSent":
|
||||
"Invitación enviada a {email} como {role}. Debe abrir el correo y aceptar antes de que caduque.",
|
||||
"settings.inviteFailed": "No se pudo enviar la invitación",
|
||||
"settings.revokeConfirm": "¿Revocar esta invitación?",
|
||||
"settings.revoked": "Invitación revocada.",
|
||||
"settings.revokeFailed": "No se pudo revocar la invitación",
|
||||
"settings.removeConfirm": "¿Eliminar a {email} de esta empresa?",
|
||||
"settings.memberRemoved": "{email} eliminado.",
|
||||
"settings.removeFailed": "No se pudo eliminar al usuario",
|
||||
"settings.roleChangeConfirm": "¿{action} a {email} a {role}?",
|
||||
"settings.roleChanged": "{email} ahora es {role}.",
|
||||
"settings.roleChangeFailed": "No se pudo actualizar el rol",
|
||||
"settings.promote": "Ascender",
|
||||
"settings.demote": "Degradar",
|
||||
"settings.inviteTitle": "Invitar compañero",
|
||||
"settings.inviteDescription":
|
||||
"Recibirá un enlace para establecer una contraseña (al menos 8 caracteres) y unirse a esta empresa.",
|
||||
"settings.inviteEmail": "Correo electrónico",
|
||||
"settings.inviteEmailPlaceholder": "colega@ejemplo.com",
|
||||
"settings.inviteRole": "Rol",
|
||||
"settings.inviteRoleHint":
|
||||
"Los miembros gestionan productos y feeds. Los administradores también pueden invitar compañeros y cambiar la configuración de la empresa.",
|
||||
"settings.sendInvite": "Enviar invitación",
|
||||
"dashboard.demoEmptyHint":
|
||||
"La zona de pruebas demo está vacía — cambia a A1 o conecta un feed para ver estadísticas reales del catálogo.",
|
||||
"dashboard.workflowHint": "Importar → enriquecer → publicar. Salta al siguiente paso para {name}.",
|
||||
"dashboard.overviewHint": "Totales en vivo para {name}",
|
||||
"dashboard.demoEmptyMessage":
|
||||
"Cambia a A1 (u otra empresa con datos) en el encabezado, o conecta un feed aquí para poblar esta zona de pruebas.",
|
||||
"dashboard.emptyTitle": "Aún no hay datos de catálogo",
|
||||
"dashboard.emptyMessage": "Conecta un feed o sube un CSV para empezar a crear tu catálogo.",
|
||||
"dashboard.connectFeedAnyway": "Conectar feed de todos modos",
|
||||
"dashboard.connectFeedShort": "Conectar feed",
|
||||
"dashboard.uploadCsv": "Subir CSV",
|
||||
"dashboard.goToBilling": "Ir a Facturación",
|
||||
"dashboard.freePlanTitle": "Estás en el plan Free",
|
||||
"dashboard.freePlanMessageWithLimit":
|
||||
"{used} de {max} productos usados. El mapeo de feeds, la limpieza básica y las etiquetas energéticas de la UE (EPREL) están incluidos; actualiza para títulos y descripciones con IA, y más capacidad.",
|
||||
"dashboard.freePlanMessage":
|
||||
"El mapeo de feeds, la limpieza básica y las etiquetas energéticas de la UE (EPREL) están incluidos; actualiza para títulos y descripciones con IA, y más capacidad.",
|
||||
"dashboard.outOfCreditsTitle": "Te has quedado sin créditos de IA",
|
||||
"dashboard.outOfCreditsMessage":
|
||||
"Compra más créditos o actualiza tu plan para seguir procesando.",
|
||||
"dashboard.productLimitTitle": "Límite de productos alcanzado",
|
||||
"dashboard.productLimitMessage":
|
||||
"Tu plan {plan} permite {max} productos ({count} en el catálogo). Actualiza para procesar más.",
|
||||
"dashboard.productLimitMessageFull":
|
||||
"Se alcanzó el límite de productos de tu plan {plan}. Actualiza para procesar más.",
|
||||
"dashboard.comparePlans": "Comparar planes",
|
||||
"dashboard.viewPlans": "Ver planes",
|
||||
"dashboard.trialTitle": "Prueba · {plan}",
|
||||
"dashboard.trialMessageDated": "La prueba termina el {date}. {credits} créditos restantes.",
|
||||
"dashboard.trialMessage": "{credits} créditos restantes en tu prueba.",
|
||||
"dashboard.lowCreditsTitle": "Créditos bajos",
|
||||
"dashboard.lowCreditsMessage":
|
||||
"Quedan {remaining} de {total} créditos. Recarga o actualiza antes de que se detengan los trabajos.",
|
||||
"dashboard.latestJobs": "Últimos trabajos de procesamiento",
|
||||
"dashboard.noJobsEmpty": "Aún no hay trabajos — importa productos primero.",
|
||||
"dashboard.noJobsReady": "No hay trabajos recientes. Inicia uno desde Productos cuando estés listo.",
|
||||
"dashboard.startJob": "Iniciar un trabajo",
|
||||
"dashboard.quickLinksHint": "Feeds, productos, trabajos y exportación.",
|
||||
"dashboard.feedsImportMap": "Importar y mapear",
|
||||
"dashboard.productsBrowse": "Explorar y procesar",
|
||||
"dashboard.jobsMonitor": "Supervisar tareas",
|
||||
"dashboard.exportsTemplates": "Plantillas y descarga",
|
||||
"activation.step.enable-fields.title": "Activar campos",
|
||||
"activation.step.enable-fields.body":
|
||||
"Activa las columnas de producto estándar que Descrybe mapea y procesa.",
|
||||
"activation.step.connect-source.title": "Añadir o conectar un origen",
|
||||
"activation.step.connect-source.body":
|
||||
"Añade un feed CSV/XML o conecta una tienda para que entren productos.",
|
||||
"activation.step.map.title": "Mapear campos de origen",
|
||||
"activation.step.map.body":
|
||||
"Asocia las columnas del proveedor a los campos de Descrybe y guarda el mapeo.",
|
||||
"activation.step.sync-sample.title": "Sincronizar una muestra",
|
||||
"activation.step.sync-sample.body":
|
||||
"Extrae una muestra pequeña para verificar el mapeo antes de una ejecución completa.",
|
||||
"activation.step.process.title": "Procesar productos",
|
||||
"activation.step.process.body":
|
||||
"Ejecuta el procesamiento sobre productos sincronizados para generar contenido de catálogo limpio.",
|
||||
"activation.step.export.title": "Exportar",
|
||||
"activation.step.export.body":
|
||||
"Crea un feed de exportación para publicar productos limpios como XML o CSV.",
|
||||
"stats.feeds": "Feeds",
|
||||
"processing.step.eprel": "EPREL",
|
||||
"toast.support.replyRe": "Re: {subject}"
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Extra locale strings (auth / settings / dashboard) merged by gen-locale-packs.mjs.
|
||||
* Brand/loanwords that stay identical across locales are listed in SAME_AS_EN.
|
||||
*/
|
||||
export const SAME_AS_EN = new Set([
|
||||
"admin.settings.integrationsCardDesc",
|
||||
"admin.settings.providerOption.ollama",
|
||||
"admin.settings.providerOption.azure",
|
||||
"admin.settings.providerOption.openrouter",
|
||||
"admin.settings.providerOption.openai",
|
||||
"admin.analytics.tokensCol",
|
||||
"admin.billing.col.total",
|
||||
"admin.billing.field.term",
|
||||
"admin.billing.plan",
|
||||
"admin.chrome.opsBadge",
|
||||
"admin.chrome.staff.admin",
|
||||
"admin.diagnostics.colId",
|
||||
"admin.diagnostics.colStatus",
|
||||
"admin.knowledge.col.status",
|
||||
"admin.overview.badge.live",
|
||||
"admin.overview.tokens",
|
||||
"admin.plans.col.plan",
|
||||
"admin.plans.col.term",
|
||||
"admin.plans.filter.legacy",
|
||||
"admin.plans.visibility.legacy",
|
||||
"admin.profile.business.label",
|
||||
"admin.profile.growth.label",
|
||||
"admin.profile.legacy.label",
|
||||
"admin.profile.starter.label",
|
||||
"admin.settings.allowlist",
|
||||
"admin.settings.colEmail",
|
||||
"admin.settings.eprel",
|
||||
"admin.settings.googleOauth",
|
||||
"admin.settings.host",
|
||||
"admin.settings.linkAi",
|
||||
"admin.settings.linkEmail",
|
||||
"admin.settings.model",
|
||||
"admin.settings.namespace",
|
||||
"admin.settings.pinecone",
|
||||
"admin.settings.port",
|
||||
"admin.settings.stripe",
|
||||
"admin.settings.tab.mail",
|
||||
"admin.translations.badgeBase",
|
||||
"admin.translations.colStatus",
|
||||
"admin.users.colPlan",
|
||||
"admin.users.colStatus",
|
||||
"app.name",
|
||||
"attributes.color",
|
||||
"attributes.field.keyPlaceholder",
|
||||
"billing.chartTokens",
|
||||
"billing.na",
|
||||
"billing.plan.enterprise",
|
||||
"billing.skusOf",
|
||||
"blast.placeholder",
|
||||
"exports.howTo.google",
|
||||
"exports.howTo.openapi",
|
||||
"exports.howTo.rest",
|
||||
"exports.preset.google_shopping_csv.label",
|
||||
"exports.preset.google_shopping_xml.defaultName",
|
||||
"exports.preset.google_shopping_xml.label",
|
||||
"exports.preset.google_shopping_xml.shortLabel",
|
||||
"exports.preset.meta_csv.shortLabel",
|
||||
"feeds.field.urlPlaceholder",
|
||||
"feeds.sync",
|
||||
"fields.type.color",
|
||||
"fields.type.dimension",
|
||||
"fields.type.image",
|
||||
"fields.type.url",
|
||||
"standardFields.type.dimension.label",
|
||||
"standardFields.type.image.label",
|
||||
"standardFields.fallback.service",
|
||||
"standardFields.fallback.eprel_id",
|
||||
"nav.admin",
|
||||
"nav.ai",
|
||||
"nav.feeds",
|
||||
"nav.section.feeds",
|
||||
"nav.section.marketing",
|
||||
"nav.seo",
|
||||
"processing.actions.export",
|
||||
"processing.col.status",
|
||||
"processing.httpError",
|
||||
"processing.skip",
|
||||
"processing.step.eprel",
|
||||
"processing.type.eprel",
|
||||
"processing.type.seo",
|
||||
"products.actions.export",
|
||||
"products.edit.specs",
|
||||
"products.edit.specsBadge",
|
||||
"products.edit.status",
|
||||
"products.edit.tab.feed",
|
||||
"products.issue.gtin",
|
||||
"products.table.colFeed",
|
||||
"products.table.colStatus",
|
||||
"settings.email",
|
||||
"settings.inviteEmail",
|
||||
"settings.inviteEmailPlaceholder",
|
||||
"settings.keyNamePlaceholder",
|
||||
"settings.plan",
|
||||
"settings.role.admin",
|
||||
"settings.table.email",
|
||||
"settings.table.status",
|
||||
"shopify.credentialsDesc.code",
|
||||
"shopify.setup.step3.after",
|
||||
"shopify.setup.step4.after",
|
||||
"shopify.setup.step4.domain",
|
||||
"standardFields.fallback.color",
|
||||
"standardFields.fallback.gtin",
|
||||
"standardFields.fallback.material",
|
||||
"standardFields.fallback.mpn",
|
||||
"standardFields.fallback.sku",
|
||||
"standardFields.fallback.stock",
|
||||
"standardFields.field.keyPlaceholder",
|
||||
"standardFields.field.unitPlaceholder",
|
||||
"standardFields.type.color.label",
|
||||
"standardFields.type.url.label",
|
||||
"stats.feeds",
|
||||
"status.emDash",
|
||||
"stores.docsPrefix",
|
||||
"stores.shopify.title",
|
||||
"stores.woo.title",
|
||||
"toast.support.replyRe",
|
||||
"woo.field.consumerKey",
|
||||
"woo.field.consumerSecret",
|
||||
"woo.match.ean",
|
||||
"woo.match.sku",
|
||||
"woo.setup.step3.after",
|
||||
"woo.setup.step3.arrow"
|
||||
]);
|
||||
|
||||
/** @type {Record<string, Record<string, string>>} */
|
||||
export const EXTRA = {};
|
||||
|
||||
function fill(locales, map) {
|
||||
for (const [key, byLocale] of Object.entries(map)) {
|
||||
for (const [code, text] of Object.entries(byLocale)) {
|
||||
EXTRA[code] ??= {};
|
||||
EXTRA[code][key] = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fill(
|
||||
null,
|
||||
{
|
||||
"common.askAdmin": {
|
||||
es: "Pregunta a un administrador de la empresa",
|
||||
fr: "Demandez à un administrateur de l'entreprise",
|
||||
de: "Fragen Sie einen Unternehmens-Admin",
|
||||
it: "Chiedi a un amministratore dell'azienda",
|
||||
pt: "Peça a um administrador da empresa",
|
||||
nl: "Vraag een bedrijfsbeheerder",
|
||||
pl: "Zapytaj administratora firmy",
|
||||
ja: "会社の管理者に問い合わせる"
|
||||
},
|
||||
"common.you": {
|
||||
es: "Tú",
|
||||
fr: "Vous",
|
||||
de: "Sie",
|
||||
it: "Tu",
|
||||
pt: "Você",
|
||||
nl: "U",
|
||||
pl: "Ty",
|
||||
ja: "あなた"
|
||||
},
|
||||
"common.active": {
|
||||
es: "Activo",
|
||||
fr: "Actif",
|
||||
de: "Aktiv",
|
||||
it: "Attivo",
|
||||
pt: "Ativo",
|
||||
nl: "Actief",
|
||||
pl: "Aktywny",
|
||||
ja: "有効"
|
||||
},
|
||||
"common.pending": {
|
||||
es: "Pendiente",
|
||||
fr: "En attente",
|
||||
de: "Ausstehend",
|
||||
it: "In sospeso",
|
||||
pt: "Pendente",
|
||||
nl: "In behandeling",
|
||||
pl: "Oczekujące",
|
||||
ja: "保留中"
|
||||
},
|
||||
"common.email": {
|
||||
es: "Correo electrónico",
|
||||
fr: "E-mail",
|
||||
de: "E-Mail",
|
||||
it: "Email",
|
||||
pt: "E-mail",
|
||||
nl: "E-mail",
|
||||
pl: "E-mail",
|
||||
ja: "メール"
|
||||
},
|
||||
"common.password": {
|
||||
es: "Contraseña",
|
||||
fr: "Mot de passe",
|
||||
de: "Passwort",
|
||||
it: "Password",
|
||||
pt: "Palavra-passe",
|
||||
nl: "Wachtwoord",
|
||||
pl: "Hasło",
|
||||
ja: "パスワード"
|
||||
},
|
||||
"common.name": {
|
||||
es: "Tu nombre",
|
||||
fr: "Votre nom",
|
||||
de: "Ihr Name",
|
||||
it: "Il tuo nome",
|
||||
pt: "O seu nome",
|
||||
nl: "Uw naam",
|
||||
pl: "Twoje imię i nazwisko",
|
||||
ja: "お名前"
|
||||
},
|
||||
"common.viewPricing": {
|
||||
es: "Ver precios",
|
||||
fr: "Voir les tarifs",
|
||||
de: "Preise ansehen",
|
||||
it: "Vedi i prezzi",
|
||||
pt: "Ver preços",
|
||||
nl: "Prijzen bekijken",
|
||||
pl: "Zobacz cennik",
|
||||
ja: "料金を見る"
|
||||
},
|
||||
"common.backToSignIn": {
|
||||
es: "Volver a iniciar sesión",
|
||||
fr: "Retour à la connexion",
|
||||
de: "Zurück zur Anmeldung",
|
||||
it: "Torna all'accesso",
|
||||
pt: "Voltar ao início de sessão",
|
||||
nl: "Terug naar inloggen",
|
||||
pl: "Powrót do logowania",
|
||||
ja: "ログインに戻る"
|
||||
},
|
||||
"common.signingOut": {
|
||||
es: "Cerrando sesión…",
|
||||
fr: "Déconnexion…",
|
||||
de: "Abmelden…",
|
||||
it: "Disconnessione…",
|
||||
pt: "A terminar sessão…",
|
||||
nl: "Bezig met uitloggen…",
|
||||
pl: "Wylogowywanie…",
|
||||
ja: "ログアウト中…"
|
||||
},
|
||||
"common.signOutAndContinue": {
|
||||
es: "Cerrar sesión y continuar",
|
||||
fr: "Se déconnecter et continuer",
|
||||
de: "Abmelden und fortfahren",
|
||||
it: "Esci e continua",
|
||||
pt: "Terminar sessão e continuar",
|
||||
nl: "Uitloggen en doorgaan",
|
||||
pl: "Wyloguj się i kontynuuj",
|
||||
ja: "ログアウトして続行"
|
||||
},
|
||||
"common.staySignedIn": {
|
||||
es: "Seguir conectado",
|
||||
fr: "Rester connecté",
|
||||
de: "Angemeldet bleiben",
|
||||
it: "Resta connesso",
|
||||
pt: "Manter sessão iniciada",
|
||||
nl: "Ingelogd blijven",
|
||||
pl: "Pozostań zalogowany",
|
||||
ja: "ログインしたままにする"
|
||||
},
|
||||
"common.startOver": {
|
||||
es: "Empezar de nuevo",
|
||||
fr: "Recommencer",
|
||||
de: "Von vorn beginnen",
|
||||
it: "Ricomincia",
|
||||
pt: "Começar de novo",
|
||||
nl: "Opnieuw beginnen",
|
||||
pl: "Zacznij od nowa",
|
||||
ja: "最初から"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Keep file size manageable: remaining extras live in locale-extra-rest.mjs
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* MERGE-preserve recovery: fill locale values that still equal English
|
||||
* from phrase-map.json. Never overwrite a value that already differs from en.
|
||||
*
|
||||
* Run: node apps/web/scripts/merge-preserve-packs.mjs
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const messagesDir = path.resolve(__dirname, "../src/lib/i18n/messages");
|
||||
const mapPath = path.join(__dirname, "phrase-map.json");
|
||||
const LOCALES = ["es", "fr", "de", "it", "pt", "nl", "pl", "ja"];
|
||||
|
||||
const comments = {
|
||||
es: "Spanish (es) UI pack — keys must stay in sync with en.ts.",
|
||||
fr: "French (fr) UI pack — keys must stay in sync with en.ts.",
|
||||
de: "German (de) UI pack — keys must stay in sync with en.ts.",
|
||||
it: "Italian (it) UI pack — keys must stay in sync with en.ts.",
|
||||
pt: "Portuguese (pt) UI pack — keys must stay in sync with en.ts.",
|
||||
nl: "Dutch (nl) UI pack — keys must stay in sync with en.ts.",
|
||||
pl: "Polish (pl) UI pack — keys must stay in sync with en.ts.",
|
||||
ja: "Japanese (ja) UI pack — keys must stay in sync with en.ts.",
|
||||
};
|
||||
|
||||
function parseMessageDict(source) {
|
||||
const dict = {};
|
||||
const re = /"([^"\\]+)"\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:`(?:\\.|[^`\\])*`))/gs;
|
||||
let m;
|
||||
while ((m = re.exec(source))) {
|
||||
const key = m[1];
|
||||
const raw = m[2];
|
||||
dict[key] = raw.startsWith("`")
|
||||
? raw.slice(1, -1).replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\n/g, "\n")
|
||||
: JSON.parse(raw);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
function emitPack(exportName, comment, dict, keyOrder) {
|
||||
const lines = [
|
||||
`import type { MessageDict } from "./types";`,
|
||||
``,
|
||||
`/** ${comment} */`,
|
||||
`export const ${exportName}: MessageDict = {`,
|
||||
];
|
||||
for (const key of keyOrder) {
|
||||
lines.push(`\t${JSON.stringify(key)}: ${JSON.stringify(dict[key])},`);
|
||||
}
|
||||
lines.push(`};`, ``);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function load(code) {
|
||||
return parseMessageDict(fs.readFileSync(path.join(messagesDir, `${code}.ts`), "utf8"));
|
||||
}
|
||||
|
||||
if (!fs.existsSync(mapPath)) {
|
||||
console.error(`missing phrase-map: ${mapPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const phraseMap = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
||||
const en = load("en");
|
||||
const keyOrder = Object.keys(en);
|
||||
|
||||
let totalApplied = 0;
|
||||
let totalSkippedGood = 0;
|
||||
let totalNoMap = 0;
|
||||
const byLocale = {};
|
||||
|
||||
for (const code of LOCALES) {
|
||||
const pack = load(code);
|
||||
const next = { ...pack };
|
||||
let applied = 0;
|
||||
let skippedGood = 0;
|
||||
let noMap = 0;
|
||||
let missingKeys = 0;
|
||||
|
||||
for (const key of keyOrder) {
|
||||
const enVal = en[key];
|
||||
const cur = next[key];
|
||||
|
||||
if (typeof cur === "string" && cur !== enVal) {
|
||||
skippedGood += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identical to EN (or missing) — try phrase-map by English string
|
||||
const mapped = phraseMap[enVal];
|
||||
const tr = mapped && typeof mapped[code] === "string" ? mapped[code].trim() : "";
|
||||
if (tr && tr !== enVal) {
|
||||
next[key] = mapped[code];
|
||||
applied += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(key in next)) {
|
||||
next[key] = enVal;
|
||||
missingKeys += 1;
|
||||
}
|
||||
noMap += 1;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(messagesDir, `${code}.ts`),
|
||||
emitPack(code, comments[code], next, keyOrder),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
byLocale[code] = { applied, skippedGood, noMap, missingKeys };
|
||||
totalApplied += applied;
|
||||
totalSkippedGood += skippedGood;
|
||||
totalNoMap += noMap;
|
||||
console.log(
|
||||
`${code}: applied=${applied} skippedGood=${skippedGood} noMap=${noMap} missingKeys=${missingKeys}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("---");
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
phraseMapEntries: Object.keys(phraseMap).length,
|
||||
enKeys: keyOrder.length,
|
||||
totalApplied,
|
||||
totalSkippedGood,
|
||||
totalNoMap,
|
||||
byLocale,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
import fs from "node:fs";
|
||||
import { EXTRA } from "./locale-extra-es.mjs";
|
||||
|
||||
function parse(s) {
|
||||
const d = {};
|
||||
const re = /"([^"\\]+)"\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:`(?:\\.|[^`\\])*`))/gs;
|
||||
let m;
|
||||
while ((m = re.exec(s))) {
|
||||
const raw = m[2];
|
||||
d[m[1]] = raw.startsWith("`")
|
||||
? raw.slice(1, -1).replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\n/g, "\n")
|
||||
: JSON.parse(raw);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
const en = parse(fs.readFileSync("../src/lib/i18n/messages/en.ts", "utf8"));
|
||||
const map = {};
|
||||
for (const [k, es] of Object.entries(EXTRA.es)) {
|
||||
const e = en[k];
|
||||
if (e) map[e] = { ...(map[e] || {}), es };
|
||||
}
|
||||
fs.writeFileSync("_phrase-seed.json", JSON.stringify(map, null, 2));
|
||||
console.log(Object.keys(map).length);
|
||||
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const messagesDir = path.join(__dirname, "..", "src", "lib", "i18n", "messages");
|
||||
|
||||
const locales = ["de", "es", "fr", "it", "ja", "nl", "pl", "pt"];
|
||||
const names = {
|
||||
de: "German (de)",
|
||||
es: "Spanish (es)",
|
||||
fr: "French (fr)",
|
||||
it: "Italian (it)",
|
||||
ja: "Japanese (ja)",
|
||||
nl: "Dutch (nl)",
|
||||
pl: "Polish (pl)",
|
||||
pt: "Portuguese (pt)"
|
||||
};
|
||||
|
||||
/** Parse flat `"key": "value",` MessageDict bodies (JSON-string keys/values). */
|
||||
function parseMessageDict(source) {
|
||||
const start = source.indexOf("{");
|
||||
const end = source.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) {
|
||||
throw new Error("MessageDict object braces not found");
|
||||
}
|
||||
const body = source.slice(start, end + 1);
|
||||
// Keys/values are JSON strings in this repo's packs.
|
||||
const out = {};
|
||||
const re = /("(?:\\.|[^"\\])*")\s*:\s*("(?:\\.|[^"\\])*")\s*,?/g;
|
||||
let m;
|
||||
while ((m = re.exec(body)) !== null) {
|
||||
out[JSON.parse(m[1])] = JSON.parse(m[2]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writePack(code, dict, enKeys) {
|
||||
const lines = [
|
||||
'import type { MessageDict } from "./types";',
|
||||
"",
|
||||
`/** ${names[code]} UI pack — keys must stay in sync with en.ts. */`,
|
||||
`export const ${code}: MessageDict = {`
|
||||
];
|
||||
for (const k of enKeys) {
|
||||
lines.push(`\t${JSON.stringify(k)}: ${JSON.stringify(dict[k])},`);
|
||||
}
|
||||
lines.push("};", "");
|
||||
fs.writeFileSync(path.join(messagesDir, `${code}.ts`), lines.join("\n"), "utf8");
|
||||
}
|
||||
|
||||
const enSource = fs.readFileSync(path.join(messagesDir, "en.ts"), "utf8");
|
||||
const en = parseMessageDict(enSource);
|
||||
const enKeys = Object.keys(en);
|
||||
|
||||
let totalFilled = 0;
|
||||
let totalRemoved = 0;
|
||||
|
||||
for (const code of locales) {
|
||||
const filePath = path.join(messagesDir, `${code}.ts`);
|
||||
const pack = parseMessageDict(fs.readFileSync(filePath, "utf8"));
|
||||
const extras = Object.keys(pack).filter((k) => !(k in en));
|
||||
const next = {};
|
||||
let filled = 0;
|
||||
for (const k of enKeys) {
|
||||
const cur = pack[k];
|
||||
if (cur == null || !String(cur).trim()) {
|
||||
next[k] = en[k];
|
||||
filled += 1;
|
||||
} else {
|
||||
next[k] = cur;
|
||||
}
|
||||
}
|
||||
totalFilled += filled;
|
||||
totalRemoved += extras.length;
|
||||
writePack(code, next, enKeys);
|
||||
console.log(`${code}: filled=${filled} removed_extra=${extras.length} keys=${enKeys.length}`);
|
||||
}
|
||||
|
||||
console.log(`DONE filled_total=${totalFilled} removed_total=${totalRemoved}`);
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
<!-- App-wide theme FOUC guard: keep in sync with $lib/theme.svelte.ts -->
|
||||
<script>
|
||||
(function () {
|
||||
var THEME_KEY = "descrybe-theme";
|
||||
var LEGACY_MARKETING = "descrybe-marketing-theme";
|
||||
var LEGACY_UI = "descrybe-ui-theme";
|
||||
var DARK_PAINT = "hsl(252 22% 9%)";
|
||||
var LIGHT_PAINT = "hsl(240 20% 98%)";
|
||||
var theme = "light";
|
||||
try {
|
||||
var stored =
|
||||
localStorage.getItem(THEME_KEY) ||
|
||||
localStorage.getItem(LEGACY_MARKETING) ||
|
||||
localStorage.getItem(LEGACY_UI);
|
||||
if (stored === "light" || stored === "dark") theme = stored;
|
||||
} catch (e) {}
|
||||
window.__THEME__ = theme;
|
||||
window.__MARKETING_THEME__ = theme;
|
||||
window.__UI_THEME__ = theme;
|
||||
var root = document.documentElement;
|
||||
root.classList.toggle("dark", theme === "dark");
|
||||
root.dataset.theme = theme;
|
||||
root.style.colorScheme = theme;
|
||||
root.style.backgroundColor = theme === "dark" ? DARK_PAINT : LIGHT_PAINT;
|
||||
function applyShells() {
|
||||
var ids = ["marketing-shell", "auth-shell", "admin-shell", "app-shell"];
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var el = document.getElementById(ids[i]);
|
||||
if (el) el.classList.toggle("dark", theme === "dark");
|
||||
}
|
||||
}
|
||||
applyShells();
|
||||
var obs = new MutationObserver(function () {
|
||||
applyShells();
|
||||
});
|
||||
obs.observe(document.documentElement, { childList: true, subtree: true });
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
applyShells();
|
||||
obs.disconnect();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,114 @@
|
||||
import { redirect, type Handle } from "@sveltejs/kit";
|
||||
import { dev } from "$app/environment";
|
||||
import { PUBLIC_API_URL } from "$env/static/public";
|
||||
import { env as publicEnv } from "$env/dynamic/public";
|
||||
import { contentSecurityPolicy, resolveApiOrigin } from "$lib/server/csp";
|
||||
|
||||
/**
|
||||
* Legacy Next.js (`/dashboard/...`) and alias paths → v2 SvelteKit routes.
|
||||
* Keeps bookmarks and cutover links from landing on SvelteKit "Not found".
|
||||
*/
|
||||
const EXACT_REDIRECTS: Record<string, string> = {
|
||||
"/signup": "/register",
|
||||
"/accept-invitation": "/accept-invite",
|
||||
"/onboarding": "/dashboard",
|
||||
"/integrations": "/stores",
|
||||
"/marketing": "/marketing/calendar",
|
||||
"/dashboard/tasks": "/processing",
|
||||
"/dashboard/products": "/products",
|
||||
"/dashboard/feeds": "/feeds",
|
||||
"/dashboard/woocommerce": "/woocommerce",
|
||||
"/dashboard/export-feeds": "/export-feeds",
|
||||
"/stores/woocommerce": "/woocommerce",
|
||||
"/stores/feeds": "/feeds",
|
||||
"/stores/export": "/export-feeds",
|
||||
"/dashboard/categories": "/categories",
|
||||
"/dashboard/attributes": "/attributes",
|
||||
"/dashboard/standard-fields": "/standard-fields",
|
||||
"/dashboard/billing": "/billing",
|
||||
"/dashboard/settings": "/settings",
|
||||
"/dashboard/plans": "/plans",
|
||||
"/dashboard/marketing": "/marketing/calendar",
|
||||
"/dashboard/structured-descriptions": "/structured-descriptions",
|
||||
"/dashboard/vector-categories": "/vector-categories",
|
||||
"/dashboard/process/new": "/products",
|
||||
"/dashboard/files": "/files"
|
||||
};
|
||||
|
||||
function legacyRedirectTarget(pathname: string): string | null {
|
||||
if (EXACT_REDIRECTS[pathname]) return EXACT_REDIRECTS[pathname];
|
||||
|
||||
let m = pathname.match(/^\/dashboard\/feeds\/([^/]+)\/mapping(?:-v2)?\/?$/);
|
||||
if (m) return `/feeds/${m[1]}/mapping`;
|
||||
|
||||
m = pathname.match(/^\/dashboard\/categories\/([^/]+)\/(title-formula|description-formula|prompt)\/?$/);
|
||||
if (m) return `/categories/${m[1]}/${m[2]}`;
|
||||
|
||||
m = pathname.match(/^\/dashboard\/export-feeds\/(?:new|[^/]+)\/?$/);
|
||||
if (m) return "/export-feeds";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const target = legacyRedirectTarget(event.url.pathname);
|
||||
if (target && target !== event.url.pathname) {
|
||||
redirect(307, `${target}${event.url.search}`);
|
||||
}
|
||||
|
||||
const pathname = event.url.pathname;
|
||||
// Never keep credentials in the query string (e.g. native GET before hydration).
|
||||
if (
|
||||
(pathname === "/login" ||
|
||||
pathname === "/register" ||
|
||||
pathname === "/accept-invite" ||
|
||||
pathname === "/forgot-password" ||
|
||||
pathname === "/reset-password") &&
|
||||
event.url.searchParams.has("password")
|
||||
) {
|
||||
const clean = new URL(event.url);
|
||||
clean.searchParams.delete("password");
|
||||
redirect(303, `${clean.pathname}${clean.search}`);
|
||||
}
|
||||
const isRapiDocVendor = pathname.startsWith("/vendor/rapidoc/");
|
||||
|
||||
// Serve precompressed RapiDoc when client accepts gzip (copy-rapidoc-ui.mjs).
|
||||
if (pathname === "/vendor/rapidoc/rapidoc-min.js") {
|
||||
const accept = event.request.headers.get("accept-encoding") ?? "";
|
||||
if (/\bgzip\b/i.test(accept)) {
|
||||
const gz = await event.fetch("/vendor/rapidoc/rapidoc-min.js.gz");
|
||||
if (gz.ok) {
|
||||
const headers = new Headers(gz.headers);
|
||||
headers.set("Content-Type", "application/javascript; charset=utf-8");
|
||||
headers.set("Content-Encoding", "gzip");
|
||||
headers.set("Vary", "Accept-Encoding");
|
||||
headers.set("Cache-Control", "public, max-age=604800, immutable");
|
||||
return new Response(gz.body, { status: 200, headers });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await resolve(event);
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
contentSecurityPolicy({
|
||||
dev,
|
||||
apiOrigin: resolveApiOrigin(PUBLIC_API_URL ?? "", event.url.origin),
|
||||
gtmId: publicEnv.PUBLIC_GTM_ID
|
||||
})
|
||||
);
|
||||
if (event.url.protocol === "https:") {
|
||||
response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
}
|
||||
if (isRapiDocVendor) {
|
||||
response.headers.set("Cache-Control", "public, max-age=604800, immutable");
|
||||
if (!response.headers.has("Vary")) {
|
||||
response.headers.set("Vary", "Accept-Encoding");
|
||||
}
|
||||
}
|
||||
return response;
|
||||
};
|
||||
@@ -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}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user