dashboard page fixes v1 (visual only)

This commit is contained in:
MatejGhub
2026-09-12 23:17:23 +02:00
parent 32c6c828b1
commit 90abd6846c
29 changed files with 455 additions and 557 deletions
+1
View File
@@ -64,3 +64,4 @@ apps/web/static/.audit/
# codehelper (generated local — do not commit)
.zed/
matej.md
+2 -2
View File
@@ -41,13 +41,13 @@ export const AI_ROLE_META: Record<
docs_api: {
label: "Docs / API",
description:
"Future docs/API assistant slot /docs Ask stays rule-based and must never call this role.",
"Future docs/API assistant slot. /docs Ask stays rule-based and must never call this role.",
modelPlaceholder: "gpt-5.6-luna"
},
support: {
label: "Support",
description:
"Ticket auto-reply AI fallback — configure provider/key/model here; enable delivery in Support knowledge → Auto-reply.",
"Ticket auto-reply AI fallback. Configure the provider/key/model here; enable delivery in Support knowledge → Auto-reply.",
modelPlaceholder: "gpt-5.6-luna"
}
};
+4 -4
View File
@@ -75,14 +75,14 @@ export function buildIdentityReply(): AssistantMessage {
return makeMessage({
role: "assistant",
kind: "text",
text: "I am the System assistant — I help you navigate Descrybe, run supported actions after you confirm, and guide setup using built-in workflows."
text: "I am the System assistant. I can help you navigate Descrybe, run supported actions after you confirm, and guide you through setup."
});
}
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).",
"Use your API key in the X-API-Key header. The example below uses a placeholder. 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"`,
@@ -165,7 +165,7 @@ export function buildGuideMessages(intent: IntentDefinition): AssistantMessage[]
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.`
text: `Opening ${intent.route} and highlighting the control to use. Follow the steps above. Tell me if you get stuck.`
})
];
}
@@ -175,7 +175,7 @@ export function buildFailureSupportOffer(issueDetail: string): AssistantMessage
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).`,
text: `That action failed${safe ? ` (${safe})` : ""}. You can open a support ticket with this error summary. Do not include any secrets.`,
quickReplies: ["Open a support ticket", "Help", "Cancel"]
});
}
+2 -2
View File
@@ -134,7 +134,7 @@ export async function executeIntent(
const lines = [
`API key created${prefix ? ` (${prefix}…)` : ""}.`,
secret
? `Copy this secret now — it will not be shown again:\n${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):",
@@ -317,7 +317,7 @@ export async function executeIntent(
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."
"Open Billing to review usage or upgrade. Prices come directly from your live plan list."
]
.filter(Boolean)
.join("\n"),
+1 -3
View File
@@ -507,7 +507,7 @@ export const INTENT_REGISTRY: IntentDefinition[] = [
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" }
{ title: "Copy the secret once", detail: "It is shown only when created, so store it safely" }
]
},
{
@@ -704,11 +704,9 @@ export function intentById(id: IntentId): IntentDefinition | undefined {
}
export const QUICK_START_REPLIES = [
"Getting started",
"Add a feed URL",
"Upload a CSV",
"Map fields",
"Connect Shopify",
"API keys",
"Attributes",
"Start processing",
+3 -3
View File
@@ -57,7 +57,7 @@ function createAssistantController() {
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?",
text: "Hi! I can help you navigate Descrybe and set things up. What would you like to do?",
quickReplies: [...QUICK_START_REPLIES]
})
];
@@ -261,7 +261,7 @@ function createAssistantController() {
makeMessage({
role: "assistant",
kind: "steps",
text: "Next mapping:",
text: "Next: mapping",
steps: intentById("map_fields")?.guideSteps
})
);
@@ -491,7 +491,7 @@ function createAssistantController() {
}
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. A failure summary is already prepared, and you can edit or replace it. Do not include any secrets."
: "Describe the issue. No passwords, API keys, or personal data.";
push(
makeMessage({
@@ -327,7 +327,7 @@
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"
class="relative z-[61] flex w-full max-w-lg flex-col overflow-hidden rounded-lg border bg-background shadow-lg lg:fixed lg:left-[17.5rem] lg:top-[4.5rem] lg:w-80 lg:max-w-none"
onkeydown={onPanelKeydown}
onclick={(event) => event.stopPropagation()}
>
@@ -350,7 +350,7 @@
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"
class="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground focus-visible:!outline-none focus-visible:!outline-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"
@@ -1,32 +1,14 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { goto } from "$app/navigation";
import { api } from "$lib/api";
import { trackEvent } from "$lib/analytics";
import { navUi } from "$lib/nav-ui.svelte";
import { commandPaletteShortcutLabelFromEnv } from "$lib/command-palette-search";
import { i18n } from "$lib/i18n";
import { LogOut, Menu, Search } from "@lucide/svelte";
import { Menu, Search } from "@lucide/svelte";
/** App chrome header — ThemeToggle is slotted from +layout (shared $lib/theme). */
/** App chrome header with controls supplied by the app layout. */
let { children }: { children?: Snippet } = $props();
let loggingOut = $state(false);
const paletteShortcut = commandPaletteShortcutLabelFromEnv();
async function logout() {
if (loggingOut) return;
loggingOut = true;
try {
await api("/api/auth/logout", { method: "POST" });
} catch {
/* session may already be gone */
} finally {
trackEvent("logout");
await goto("/login");
loggingOut = false;
}
}
</script>
<header
@@ -63,18 +45,6 @@
{#if children}
{@render children()}
{/if}
<button
type="button"
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60 sm:h-8 sm:w-auto sm:gap-1.5 sm:px-2 sm:text-xs sm:font-medium"
onclick={logout}
disabled={loggingOut}
aria-label={i18n.t("header.signOut")}
>
<LogOut class="h-3.5 w-3.5" aria-hidden="true" />
<span class="hidden lg:inline"
>{loggingOut ? i18n.t("header.signingOut") : i18n.t("header.signOut")}</span
>
</button>
</div>
</div>
</header>
-15
View File
@@ -6,7 +6,6 @@
import { activateFocusTrap, type FocusTrapHandle } from "$lib/a11y/focus-trap";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { navItemAllowed } from "$lib/plan-capabilities";
import { commandPaletteShortcutLabelFromEnv } from "$lib/command-palette-search";
import { i18n } from "$lib/i18n";
import {
LayoutDashboard,
@@ -262,8 +261,6 @@
let trap: FocusTrapHandle | null = null;
let isDesktop = $state(false);
let navMounted = $state(false);
const paletteShortcut = commandPaletteShortcutLabelFromEnv();
$effect(() => {
if (typeof window === "undefined") return;
navMounted = true;
@@ -466,18 +463,6 @@
<span class="text-sm font-semibold text-sidebar-foreground">{i18n.t("app.name")}</span>
</a>
<div class="flex items-center gap-1">
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sidebar-foreground/90 transition hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
aria-label={i18n.t("nav.commandPalette")}
aria-keyshortcuts="Control+K Meta+K"
data-tour="nav-command-palette"
onclick={() => navUi.openCommandPalette()}
>
<Search class="h-4 w-4" aria-hidden="true" />
<span class="hidden text-xs font-medium sm:inline">{i18n.t("nav.commandPaletteLabel")}</span>
<kbd class="hidden rounded border border-sidebar-border px-1 py-px text-[10px] font-medium text-sidebar-foreground/70 lg:inline">{paletteShortcut}</kbd>
</button>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-md text-sidebar-foreground/90 transition hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring lg:hidden"
@@ -8,6 +8,7 @@
import { MessageCircle, RotateCcw, Send, X } from "@lucide/svelte";
let listEl = $state<HTMLDivElement | null>(null);
let inputEl = $state<HTMLTextAreaElement | null>(null);
let draft = $state("");
const needsFile = $derived(
@@ -26,17 +27,25 @@
void scrollBottom();
});
function submit() {
async function submit() {
const text = draft.trim();
if (!text) return;
draft = "";
await tick();
resizeInput();
void assistant.handleUserText(text);
}
function resizeInput() {
if (!inputEl) return;
inputEl.style.height = "auto";
inputEl.style.height = `${Math.min(inputEl.scrollHeight, 76)}px`;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
void submit();
}
}
@@ -64,7 +73,6 @@
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{i18n.t("assistant.title")}</p>
<p class="truncate text-xs text-muted-foreground">{i18n.t("assistant.subtitle")}</p>
</div>
<Button
variant="ghost"
@@ -116,16 +124,19 @@
class="flex items-end gap-2 border-t border-border p-3"
onsubmit={(e) => {
e.preventDefault();
submit();
void submit();
}}
>
<label class="sr-only" for="assistant-input">{i18n.t("assistant.messageLabel")}</label>
<textarea
bind:this={inputEl}
id="assistant-input"
rows="2"
class="min-h-[2.5rem] flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
rows="1"
maxlength="500"
class="min-h-[2.5rem] max-h-[4.75rem] flex-1 resize-none overflow-y-auto rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder={i18n.t("assistant.placeholder")}
bind:value={draft}
oninput={resizeInput}
onkeydown={onKeydown}
disabled={assistant.busy}
></textarea>
@@ -44,7 +44,7 @@
{#if assistant.open}
<div
class="fixed bottom-20 right-4 z-[55] w-[min(24rem,calc(100vw-2rem))] sm:bottom-24 sm:right-6"
class="fixed bottom-20 right-4 z-[65] w-[min(24rem,calc(100vw-2rem))] sm:bottom-24 sm:right-6"
>
<AssistantDock />
</div>
@@ -54,8 +54,8 @@
type="button"
class="{buttonClasses(
assistant.open ? 'secondary' : 'default',
'default',
'fixed bottom-4 right-4 z-[55] h-12 gap-2 rounded-full px-4 shadow-md sm:bottom-6 sm:right-6'
'icon',
'fixed bottom-4 right-4 z-[65] h-12 w-12 rounded-full shadow-md sm:bottom-6 sm:right-6'
)}"
data-assistant-target="assistant-fab"
data-tour="assistant-fab"
@@ -64,6 +64,5 @@
onclick={() => assistant.toggle()}
>
<MessageCircle class="h-5 w-5" />
<span class="hidden sm:inline">{i18n.t("assistant.label")}</span>
</button>
{/if}
@@ -53,7 +53,7 @@
function guideLineFor(node: DocsGuideNode): TranscriptEntry {
if (isQuestionNode(node)) {
return { role: "guide", text: node.body?.trim() ? `${node.title} ${node.body}` : node.title };
return { role: "guide", text: node.body?.trim() ? `${node.title}: ${node.body}` : node.title };
}
return { role: "guide", text: node.outcome || node.title };
}
@@ -235,7 +235,7 @@
<Dialog
bind:open
title="Ask find the right docs"
title="Ask: find the right docs"
description={tree.disclaimer}
class="max-w-lg sm:max-w-lg"
>
@@ -294,7 +294,7 @@
class="inline-flex max-w-full items-center gap-1.5 text-left text-sm font-medium text-blue-700 hover:underline dark:text-blue-300"
onclick={() => followEndpoint(ep)}
>
<span class="truncate">{ep.method} {ep.path}{ep.summary ? ` ${ep.summary}` : ""}</span>
<span class="truncate">{ep.method} {ep.path}{ep.summary ? `: ${ep.summary}` : ""}</span>
</button>
</li>
{/each}
@@ -187,7 +187,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
const missing = Object.values(checks as Record<string, { passed?: boolean; label?: string }>)
.filter((c) => !c?.passed)
.map((c) => c.label || "check");
if (missing.length) return `${grade} ${score}/100 missing: ${missing.join(", ")}`;
if (missing.length) return `${grade} ${score}/100; missing: ${missing.join(", ")}`;
}
return `${grade} ${score}/100`;
}
@@ -172,12 +172,12 @@
{#if canWoo}
<Button
variant="outline"
class="h-auto justify-start gap-3 px-4 py-3"
class="h-auto min-w-0 justify-start gap-3 whitespace-normal px-4 py-3"
data-tour="store-wizard-pick-woocommerce"
onclick={() => selectChannel("woocommerce")}
>
<Store class="h-5 w-5 shrink-0" aria-hidden="true" />
<span class="text-left">
<span class="min-w-0 flex-1 text-left">
<span class="block font-medium">{i18n.t("stores.wizard.channel.woocommerce")}</span>
<span class="block text-xs font-normal text-muted-foreground"
>{i18n.t("stores.woo.description")}</span
@@ -188,12 +188,12 @@
{#if canShopify}
<Button
variant="outline"
class="h-auto justify-start gap-3 px-4 py-3"
class="h-auto min-w-0 justify-start gap-3 whitespace-normal px-4 py-3"
data-tour="store-wizard-pick-shopify"
onclick={() => selectChannel("shopify")}
>
<ShoppingBag class="h-5 w-5 shrink-0" aria-hidden="true" />
<span class="text-left">
<span class="min-w-0 flex-1 text-left">
<span class="block font-medium">{i18n.t("stores.wizard.channel.shopify")}</span>
<span class="block text-xs font-normal text-muted-foreground"
>{i18n.t("stores.shopify.description")}</span
@@ -13,7 +13,6 @@
let popoverEl = $state<HTMLDivElement | null>(null);
let popoverStyle = $state("");
let trap: FocusTrapHandle | null = null;
let jumpOpen = $state(false);
type Hole = { top: number; left: number; width: number; height: number };
@@ -98,10 +97,6 @@
if (!tutorial.active) return;
if (event.key === "Escape") {
event.preventDefault();
if (jumpOpen) {
jumpOpen = false;
return;
}
tutorial.dismiss();
}
}
@@ -194,12 +189,6 @@
return () => clearInterval(id);
});
$effect(() => {
void tutorial.stepIndex;
void tutorial.sectionPickerOpen;
jumpOpen = false;
});
const stepNumber = $derived(tutorial.stepIndex + 1);
const progressPct = $derived(
tutorial.sectionPickerOpen
@@ -232,7 +221,6 @@
const sectionLabel = $derived(i18n.t(`tutorial.section.${tutorial.step.sectionId}`));
async function jumpToSection(sectionId: TutorialSectionId) {
jumpOpen = false;
await tutorial.startSection(sectionId);
}
@@ -398,40 +386,6 @@
</p>
{/if}
<div class="relative mt-3">
<button
type="button"
class="rounded-md px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded={jumpOpen}
aria-haspopup="listbox"
onclick={() => (jumpOpen = !jumpOpen)}
>
{i18n.t("tutorial.jumpTo")}
</button>
{#if jumpOpen}
<ul
class="absolute left-0 z-10 mt-1 max-h-48 w-52 overflow-y-auto rounded-md border border-border bg-card py-1 shadow-lg"
role="listbox"
aria-label={i18n.t("tutorial.jumpToSectionAria")}
>
{#each tutorial.sections as section (section.id)}
<li role="option" aria-selected={section.id === tutorial.step.sectionId}>
<button
type="button"
class="w-full px-3 py-1.5 text-left text-sm hover:bg-muted {section.id ===
tutorial.step.sectionId
? 'font-semibold text-foreground'
: 'text-muted-foreground'}"
onclick={() => void jumpToSection(section.id)}
>
{section.label}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<div class="mt-4 flex items-center justify-between gap-2">
<Button
variant="ghost"
@@ -81,7 +81,8 @@
if (!triggerEl) return;
const r = triggerEl.getBoundingClientRect();
const gap = 4;
let top = r.bottom + gap;
const headerBottom = triggerEl.closest("header")?.getBoundingClientRect().bottom ?? r.bottom;
let top = Math.max(r.bottom, headerBottom) + gap;
const menuHeight = menuEl?.offsetHeight ?? 0;
if (menuHeight > 0 && top + menuHeight > window.innerHeight - 8) {
top = Math.max(8, r.top - menuHeight - gap);
@@ -167,7 +168,7 @@
<div use:portal>
<button
type="button"
class="fixed inset-0 z-[60] cursor-default {!dismissArmed ? 'pointer-events-none' : ''}"
class="fixed inset-0 z-[120] cursor-default {!dismissArmed ? 'pointer-events-none' : ''}"
aria-label="Close menu"
tabindex="-1"
onclick={onBackdropClick}
@@ -184,7 +185,7 @@
if (index >= 0) activeIndex = index;
}}
class={cn(
"fixed z-[70] min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg",
"fixed z-[130] min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg",
className
)}
style={align === "end"
+14 -14
View File
@@ -20,7 +20,7 @@ const TRY_IT_TIP =
export const DOCS_GUIDE_TREE: DocsGuideTree = {
version: 2,
disclaimer:
"This is a guided router for the public API docs not a chat bot and not an AI. Pick options to land on the right endpoints.",
"This is a guided router for the public API docs, not a chat bot or AI. Pick options to find the right endpoints.",
rootId: "root",
escapeLinks: [
{ label: "Browse API reference", href: "/docs#overview", kind: "docs" },
@@ -33,7 +33,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
id: "root",
pathKey: "start",
title: "What are you trying to do?",
body: "Ill route you to the right Descrybe public API docs (/api/v1). Pick one option no free-text needed.",
body: "Ill route you to the right Descrybe public API docs (/api/v1). Pick one option; no free text is needed.",
choices: [
{
id: "first",
@@ -99,7 +99,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
{
id: "process",
label: "Process / enrich products",
description: "Two contracts choose carefully",
description: "Two contracts; choose carefully",
nextId: "q.process"
},
{
@@ -116,7 +116,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
id: "q.feeds",
pathKey: "feeds",
synonyms: ["supplier feed", "import csv", "sync feed"],
title: "Feeds what do you need?",
title: "Feeds: what do you need?",
choices: [
{
id: "create",
@@ -158,7 +158,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
},
{
id: "chooser",
label: "Not sure — compare both",
label: "Not sure? Compare both",
nextId: "answer.process.chooser"
}
]
@@ -169,7 +169,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
id: "q.export",
pathKey: "export",
synonyms: ["channel feed", "google shopping", "export csv"],
title: "Export feeds what do you need?",
title: "Export feeds: what do you need?",
choices: [
{
id: "setup",
@@ -323,10 +323,10 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
body: "1) Create or copy a company API key (dk_…). 2) Jump to Authorize and enter Authorization: Bearer dk_… (preferred) or X-API-Key. 3) Confirm the server URL matches your environment. 4) Open any operation below, fill params/body from the examples, and click TRY. Unauthenticated smoke: GET /health. Authenticated smoke: GET /products.",
authNote: AUTH_NOTE,
endpoints: [
endpoint("GET", "/health", "No key required confirm the viewer reaches the API"),
endpoint("GET", "/health", "No key required; confirm the viewer reaches the API"),
endpoint("GET", "/products", "Authenticated smoke list (page=1&limit=1)"),
endpoint("POST", "/products/process", "EAN batch after Authorize"),
endpoint("POST", "/process", "Jobs process after Authorize")
endpoint("POST", "/products/process", "EAN batch after Authorize"),
endpoint("POST", "/process", "Jobs process after Authorize")
],
links: [
{ label: "Authorize panel", href: docsAuthHref(), kind: "docs" },
@@ -421,7 +421,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
synonyms: ["ean", "items[].ean", "products/process"],
title: "Public process (EAN batch)",
outcome: "Start and poll enrichment using POST/GET /products/process.",
body: "POST body uses items[].ean (preferred). Responses use HTTP 200 with { data: { process_id, … } }. Poll GET until COMPLETED. items[].description is generated HTML product copy (headings, paragraphs, lists) whose length follows the category description formula not a one- or two-sentence blurb. Some categories request very long multi-section articles. Handler also accepts raw_product_ids as an alternate on this same path.",
body: "POST body uses items[].ean (preferred). Responses use HTTP 200 with { data: { process_id, … } }. Poll GET until COMPLETED. items[].description is generated HTML product copy (headings, paragraphs, lists) whose length follows the category description formula, not a one- or two-sentence blurb. Some categories request very long multi-section articles. Handler also accepts raw_product_ids as an alternate on this same path.",
authNote: AUTH_NOTE,
endpoints: [
endpoint("POST", "/products/process", "Start processing by EAN"),
@@ -437,7 +437,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
{ label: "Authorize & Try", href: docsAuthHref(), kind: "docs" },
{ label: "Processing UI", href: "/processing", kind: "app" }
],
warning: "Not an alias of POST/GET /process different body and envelope.",
warning: "Not an alias of POST/GET /process; it uses a different body and envelope.",
relatedIds: ["answer.process.jobs", "answer.process.chooser", "answer.products"]
},
@@ -474,7 +474,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
synonyms: ["which process", "dual contract"],
title: "Which process API should I use?",
outcome: "Pick EAN batch for integrations; pick /process for dashboard-style jobs.",
body: "1) Public process: POST/GET /products/process items[].ean, HTTP 200 { data: { process_id } }. 2) Jobs: POST/GET /process raw_product_ids, flat JSON, 202 on start. Mixing envelopes is the most common integration bug.",
body: "1) Public process: POST/GET /products/process uses items[].ean and returns HTTP 200 { data: { process_id } }. 2) Jobs: POST/GET /process uses raw_product_ids, flat JSON, and returns 202 on start. Mixing envelopes is the most common integration bug.",
authNote: AUTH_NOTE,
endpoints: [
endpoint("POST", "/products/process", "EAN batch"),
@@ -637,7 +637,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
{ label: "Authorize", href: docsAuthHref(), kind: "docs" },
{ label: "Products UI", href: "/products", kind: "app" }
],
warning: "Reset is destructive to processed state — confirm before calling in production.",
warning: "Reset is destructive to processed state. Confirm before calling it in production.",
relatedIds: ["answer.products", "answer.process.jobs"]
},
@@ -676,7 +676,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
pathKey: "billing",
synonyms: ["credits", "plan", "upgrade", "limits"],
title: "Billing and plan limits",
outcome: "Plan and credit limits are enforced server-side on processing — manage them in the app, not via /api/v1.",
outcome: "Plan and credit limits are enforced server-side during processing. Manage them in the app, not via /api/v1.",
body: "The public API-key surface does not expose checkout. Use Plans / Billing in the web app. Insufficient credits or free-tier gates typically surface as processing failures rather than OpenAPI operations.",
links: [
{ label: "Plans", href: "/plans", kind: "app" },
File diff suppressed because it is too large Load Diff
+30 -30
View File
@@ -3,34 +3,34 @@ let mobileOpen = $state(false);
let commandPaletteOpen = $state(false);
export const navUi = {
get mobileOpen() {
return mobileOpen;
},
openMobile() {
mobileOpen = true;
},
closeMobile() {
mobileOpen = false;
},
toggleMobile() {
mobileOpen = !mobileOpen;
},
get commandPaletteOpen() {
return commandPaletteOpen;
},
openCommandPalette() {
mobileOpen = false;
commandPaletteOpen = true;
},
closeCommandPalette() {
commandPaletteOpen = false;
},
toggleCommandPalette() {
if (commandPaletteOpen) {
commandPaletteOpen = false;
return;
}
mobileOpen = false;
commandPaletteOpen = true;
}
get mobileOpen() {
return mobileOpen;
},
openMobile() {
mobileOpen = true;
},
closeMobile() {
mobileOpen = false;
},
toggleMobile() {
mobileOpen = !mobileOpen;
},
get commandPaletteOpen() {
return commandPaletteOpen;
},
openCommandPalette() {
mobileOpen = false;
commandPaletteOpen = true;
},
closeCommandPalette() {
commandPaletteOpen = false;
},
toggleCommandPalette() {
if (commandPaletteOpen) {
commandPaletteOpen = false;
return;
}
mobileOpen = false;
commandPaletteOpen = true;
},
};
+5 -5
View File
@@ -17,7 +17,7 @@ export type PageSeo = {
};
export const HOME_SEO: PageSeo = {
title: "Descrybe Turn supplier feeds into ready product pages",
title: "Descrybe: Turn supplier feeds into ready product pages",
description:
"Connect CSV or XML supplier feeds, map them to your categories and attributes, generate titles and descriptions that match your rules, then export or sync to WooCommerce.",
path: "/",
@@ -26,9 +26,9 @@ export const HOME_SEO: PageSeo = {
};
export const PRICING_SEO: PageSeo = {
title: "Pricing Free, Starter, Growth, Business | Descrybe",
title: "Pricing: Free, Starter, Growth, Business | Descrybe",
description:
"Start free with 100 products and feed mapping. Paid plans add AI titles and descriptions, more SKUs, export feeds, and WooCommerce sync from $49/month. Enterprise for unlimited catalogs.",
"Start free with 100 products and feed mapping. Paid plans add AI titles and descriptions, more SKUs, export feeds, and WooCommerce sync from $49/month. Enterprise supports unlimited catalogs.",
path: "/pricing",
image: DEFAULT_OG_IMAGE,
imageAlt: DEFAULT_OG_IMAGE_ALT
@@ -46,14 +46,14 @@ export const PRIVACY_SEO: PageSeo = {
export const TERMS_SEO: PageSeo = {
title: "Terms of Service | Descrybe",
description:
"Terms for using Descrybe feed import, catalog enrichment, AI-assisted content, exports, and WooCommerce sync for your business.",
"Terms for using Descrybe, including feed import, catalog enrichment, AI-assisted content, exports, and WooCommerce sync for your business.",
path: "/terms",
image: DEFAULT_OG_IMAGE,
imageAlt: DEFAULT_OG_IMAGE_ALT
};
export const FEATURES_SEO: PageSeo = {
title: "Features Feeds, enrichment, and export | Descrybe",
title: "Features: Feeds, enrichment, and export | Descrybe",
description:
"See how Descrybe imports supplier feeds, maps fields to your taxonomy, enriches product data, and ships catalogs via export feeds, WooCommerce, or API.",
path: "/features",
+93 -57
View File
@@ -8,6 +8,12 @@
import SupportNotificationBell from "$lib/components/SupportNotificationBell.svelte";
import TaskStatusIndicator from "$lib/components/TaskStatusIndicator.svelte";
import Toaster from "$lib/components/ui/Toaster.svelte";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from "$lib/components/ui";
import TutorialOverlay from "$lib/components/tutorial/TutorialOverlay.svelte";
import AssistantHost from "$lib/components/assistant/AssistantHost.svelte";
import CompanySwitcher from "$lib/components/CompanySwitcher.svelte";
@@ -23,6 +29,7 @@
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { trackEvent } from "$lib/analytics";
import { api, isUnauthorized } from "$lib/api";
import { safeInternalNext } from "$lib/safe-next";
import { AUTH_PATHS, MARKETING_PATHS } from "$lib/site";
@@ -51,7 +58,7 @@
import { tutorial } from "$lib/tutorial";
import { billingRecovery, type PlanLike } from "$lib/billing-display";
import { isCompanyAdmin } from "$lib/company-admin";
import { GraduationCap, ListTree, Menu } from "@lucide/svelte";
import { GraduationCap, ListTree, LogOut, Menu, Moon, Settings, Sun } from "@lucide/svelte";
let { children } = $props();
@@ -61,6 +68,21 @@
let me = $state<MeResponse | null>(null);
let meRequested = $state(false);
let loggingOut = $state(false);
async function logout() {
if (loggingOut) return;
loggingOut = true;
try {
await api("/api/auth/logout", { method: "POST" });
} catch {
/* Session may already be gone. */
} finally {
trackEvent("logout");
await goto("/login");
loggingOut = false;
}
}
const SYSTEM_MODE_POLL_MS = 60_000;
@@ -438,31 +460,7 @@
{/if}
{/if}
{#if !tutorial.active}
{#if tutorial.canResume}
<button
type="button"
class="hidden h-8 shrink-0 items-center justify-center rounded-md border border-border px-2.5 text-xs font-medium text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:inline-flex"
data-tour="header-resume-tutorial"
aria-label={i18n.t("dashboard.resumeTutorial")}
title={i18n.t("dashboard.resumeTutorial")}
onclick={() => void tutorial.resume()}
>
<span aria-hidden="true" class="xl:hidden"><GraduationCap class="h-4 w-4" /></span>
<span aria-hidden="true" class="hidden xl:inline">{i18n.t("dashboard.resumeTutorial")}</span>
</button>
{:else if tutorial.canRestart}
<button
type="button"
class="hidden h-8 shrink-0 items-center justify-center rounded-md border border-border px-2.5 text-xs font-medium text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:inline-flex"
data-tour="header-restart-tutorial"
aria-label={i18n.t("dashboard.restartTutorial")}
title={i18n.t("dashboard.restartTutorial")}
onclick={() => void tutorial.restart()}
>
<span aria-hidden="true" class="xl:hidden"><GraduationCap class="h-4 w-4" /></span>
<span aria-hidden="true" class="hidden xl:inline">{i18n.t("dashboard.restartTutorial")}</span>
</button>
{:else}
{#if !tutorial.canResume && !tutorial.canRestart}
<button
type="button"
class="hidden h-8 shrink-0 items-center justify-center rounded-md px-2.5 text-xs font-medium text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:inline-flex"
@@ -475,43 +473,81 @@
<span aria-hidden="true" class="hidden xl:inline">{i18n.t("dashboard.startTutorial")}</span>
</button>
{/if}
<button
type="button"
class="hidden h-8 shrink-0 items-center justify-center rounded-md px-2.5 text-xs font-medium text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:inline-flex"
data-tour="header-browse-tutorial-sections"
aria-label={i18n.t("dashboard.browseTutorialSections")}
title={i18n.t("dashboard.tourTopics")}
onclick={() => void tutorial.openSectionPicker()}
>
<span aria-hidden="true" class="xl:hidden"><ListTree class="h-4 w-4" /></span>
<span aria-hidden="true" class="hidden xl:inline">{i18n.t("dashboard.tourTopics")}</span>
</button>
{/if}
<LocaleSwitcher
class="hidden h-8 w-8 shrink-0 items-center justify-center rounded-md text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:inline-flex"
/>
<ThemeToggle
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{#if me?.user}
{#if me.impersonating || me.dev_user_switch}
<a
href="/settings"
class="hidden max-w-[18rem] shrink-0 truncate text-xs text-text-muted transition hover:text-text lg:inline"
title={me.user.email}
data-tour="header-current-user-email"
<DropdownMenu align="end" class="w-64">
{#snippet trigger({ open, toggle })}
<button
type="button"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground shadow-sm transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:h-8 sm:w-8"
title={me?.user?.email ?? ""}
aria-label={i18n.t("header.accountSettingsAria", { email: me?.user?.email ?? "" })}
aria-expanded={open}
aria-haspopup="menu"
onclick={toggle}
>
{userInitial}
</button>
{/snippet}
<DropdownMenuLabel class="flex items-center justify-between gap-3 px-2 py-2">
<div class="min-w-0 space-y-0.5">
<p class="text-sm font-semibold text-foreground">{i18n.t("settings.profileHeading")}</p>
<p class="truncate text-xs font-normal text-muted-foreground">{me.user.email}</p>
</div>
<div
class="flex shrink-0 items-center rounded-md border border-border bg-muted p-0.5"
data-testid="theme-toggle"
data-tour="theme-toggle"
>
<button
type="button"
class="inline-flex h-7 w-7 items-center justify-center rounded {theme.isDark
? 'text-muted-foreground hover:text-foreground'
: 'bg-background text-foreground shadow-sm'}"
aria-label={i18n.t("theme.lightMode")}
aria-pressed={!theme.isDark}
title={i18n.t("theme.lightMode")}
onclick={() => theme.setTheme("light")}
>
<Sun class="h-3.5 w-3.5" />
</button>
<button
type="button"
class="inline-flex h-7 w-7 items-center justify-center rounded {theme.isDark
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-label={i18n.t("theme.darkMode")}
aria-pressed={theme.isDark}
title={i18n.t("theme.darkMode")}
onclick={() => theme.setTheme("dark")}
>
<Moon class="h-3.5 w-3.5" />
</button>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onclick={() => void goto("/settings")}>
<Settings class="h-4 w-4" />
{i18n.t("nav.settings")}
</DropdownMenuItem>
<DropdownMenuItem onclick={() => void tutorial.openSectionPicker()}>
<ListTree class="h-4 w-4" />
{i18n.t("dashboard.tourTopics")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
class="text-destructive focus:text-destructive"
disabled={loggingOut}
onclick={() => void logout()}
>
{me.user.email}
</a>
{/if}
<a
href="/settings"
class="hidden h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground shadow-sm transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 lg:flex"
title={me.user.email}
aria-label={i18n.t("header.accountSettingsAria", { email: me.user.email })}
>
{userInitial}
</a>
<LogOut class="h-4 w-4" />
{loggingOut ? i18n.t("header.signingOut") : i18n.t("header.signOut")}
</DropdownMenuItem>
</DropdownMenu>
{/if}
</DashboardHeader>
<div class="min-w-0 flex-1 overflow-x-auto overflow-y-auto p-3 sm:p-6">
+6 -6
View File
@@ -1114,14 +1114,14 @@
<div class="space-y-2 text-sm text-muted-foreground">
<p>{i18n.t("attributes.bulk.uploadHint")}</p>
<ul class="list-inside list-disc space-y-1">
<li><span class="font-mono text-xs">attribute_key</span> {i18n.t("attributes.bulk.col.key")}</li>
<li><span class="font-mono text-xs">name</span> {i18n.t("attributes.bulk.col.name")}</li>
<li><span class="font-mono text-xs">attribute_key</span>: {i18n.t("attributes.bulk.col.key")}</li>
<li><span class="font-mono text-xs">name</span>: {i18n.t("attributes.bulk.col.name")}</li>
<li>
<span class="font-mono text-xs">value_type</span> {i18n.t("attributes.bulk.col.valueType")}
<span class="font-mono text-xs">value_type</span>: {i18n.t("attributes.bulk.col.valueType")}
</li>
<li><span class="font-mono text-xs">category_id</span> {i18n.t("attributes.bulk.col.categoryId")}</li>
<li><span class="font-mono text-xs">unit</span> / <span class="font-mono text-xs">example</span> {i18n.t("attributes.bulk.col.unitExample")}</li>
<li><span class="font-mono text-xs">parent_key</span> {i18n.t("attributes.bulk.col.parentKey")}</li>
<li><span class="font-mono text-xs">category_id</span>: {i18n.t("attributes.bulk.col.categoryId")}</li>
<li><span class="font-mono text-xs">unit</span> / <span class="font-mono text-xs">example</span>: {i18n.t("attributes.bulk.col.unitExample")}</li>
<li><span class="font-mono text-xs">parent_key</span>: {i18n.t("attributes.bulk.col.parentKey")}</li>
</ul>
</div>
</div>
@@ -15,7 +15,6 @@
import ListSkeleton from "$lib/components/ListSkeleton.svelte";
import StatCardsSkeleton from "$lib/components/StatCardsSkeleton.svelte";
import DashboardStats from "$lib/components/DashboardStats.svelte";
import NewsFeed from "$lib/components/NewsFeed.svelte";
import UpgradeBanner from "$lib/components/UpgradeBanner.svelte";
import ActivationChecklist from "$lib/components/ActivationChecklist.svelte";
import MigratedEtlGapsPanel from "$lib/components/MigratedEtlGapsPanel.svelte";
@@ -882,12 +881,6 @@
{/if}
</div>
<div class="min-w-0 pb-16 pt-2">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-text-muted">
{i18n.t("dashboard.whatsNew")}
</h3>
<NewsFeed limit={1} />
</div>
</section>
</div>
</div>
+1 -1
View File
@@ -579,7 +579,7 @@
<p class="mt-1.5 text-[11px] text-text-muted">
Public
<code class="rounded bg-muted px-1 text-text">/api/v1</code>
uses Bearer or X-API-Key not your dashboard session cookie. Prefer
uses Bearer or X-API-Key, not your dashboard session cookie. Prefer
<code class="rounded bg-muted px-1 text-text">Authorization: Bearer</code>.
</p>
</div>
+2 -7
View File
@@ -690,7 +690,7 @@
success = i18n.t("flash.feed.syncFinishedNamed", { name: feed.name ?? id, status, products });
notifySuccess(i18n.t("toast.feed.syncFinished"), {
alert: "sync_done",
description: `"${feed.name ?? id}" ${products}. Process a sample when ready.`,
description: `"${feed.name ?? id}": ${products}. Process a sample when ready.`,
actions: [
{ label: i18n.t("feeds.viewProducts"), href: `/products?feed_id=${id}` },
{ label: i18n.t("feeds.processing"), href: "/processing" }
@@ -811,12 +811,7 @@
}
</script>
<PageShell
title={i18n.t("feeds.title")}
description={loading && feeds.length === 0
? i18n.t("feeds.loadingDesc")
: i18n.t("feeds.desc", { page: filtered.length, total })}
>
<PageShell title={i18n.t("feeds.title")}>
{#snippet actions()}
<Button variant="outline" class="w-full sm:w-auto" onclick={() => void goto("/stores")}>{i18n.t("feeds.storesHub")}</Button>
<Button class="w-full sm:w-auto" onclick={() => openAdd()} data-tour="feeds-add" data-assistant-target="feeds-add">
+3 -3
View File
@@ -588,8 +588,8 @@
const typeLabel = types.join(", ");
success =
!canUseAI && processingType === "full"
? `Processing started ${receipt} (normalize/specs/fill AI skipped on Free).`
: `Processing started ${receipt} (${typeLabel}).`;
? `Processing started: ${receipt} (normalize/specs/fill; AI skipped on Free).`
: `Processing started: ${receipt} (${typeLabel}).`;
notifySuccess(i18n.t("toast.products.jobStarted"), {
description: !canUseAI
? `${receipt} · free-tier steps (AI skipped). Track progress in Processing.`
@@ -1035,7 +1035,7 @@
});
success = result.message
? `${result.message} (${receipt})`
: `Reset complete ${receipt}.`;
: `Reset complete: ${receipt}.`;
notifySuccess(i18n.t("toast.products.resetComplete"), {
description: receipt,
actions: [PROCESSING_RECEIPT_ACTION],
@@ -64,6 +64,7 @@
} from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import NewsFeed from "$lib/components/NewsFeed.svelte";
import MemberPermissionsDialog from "$lib/components/settings/MemberPermissionsDialog.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
@@ -1125,6 +1126,11 @@
</div>
</CardContent>
</Card>
<section class="space-y-3">
<h2 class="text-2xl font-semibold tracking-tight">{i18n.t("dashboard.whatsNew")}</h2>
<NewsFeed limit={3} />
</section>
</TabsContent>
<!-- Company -->
@@ -572,7 +572,7 @@
data-assistant-target="enable-recommended"
onclick={() => void enableRecommended()}
>
Enable recommended
{i18n.t("standardFields.enableRecommended")}
</Button>
{#if selectedIds.length > 0}
<Button
@@ -602,9 +602,9 @@
</div>
{:else}
<div class="space-y-4">
<div class="flex items-center gap-2">
<Search class="h-4 w-4 text-muted-foreground" />
<Input class="max-w-sm" placeholder={i18n.t("standardFields.searchFields")} bind:value={fieldSearch} />
<div class="relative w-full max-w-sm">
<Search class="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input class="w-full pl-9" placeholder={i18n.t("standardFields.searchFields")} bind:value={fieldSearch} />
</div>
<div class="rounded-md border border-border bg-card">
@@ -689,18 +689,10 @@
</TableCell>
<TableCell>{groupNameOf(groupIdOf(field))}</TableCell>
<TableCell>
{#if isEnabled(field)}
<Badge variant="success">{i18n.t("common.on")}</Badge>
{:else}
<Badge variant="outline">{i18n.t("common.off")}</Badge>
{/if}
{#if isRequired(field)}
<Badge class="ml-1">{i18n.t("common.required")}</Badge>
<Badge>{i18n.t("common.required")}</Badge>
{:else}
<Badge variant="outline" class="ml-1">{i18n.t("common.optional")}</Badge>
{/if}
{#if isSystem(field)}
<Badge variant="secondary" class="ml-1">{i18n.t("common.system")}</Badge>
<Badge variant="outline">{i18n.t("common.optional")}</Badge>
{/if}
</TableCell>
<TableCell stickyRight>
@@ -759,9 +751,9 @@
</div>
<div class="space-y-4">
<div class="flex items-center gap-2">
<Search class="h-4 w-4 text-muted-foreground" />
<Input class="max-w-sm" placeholder={i18n.t("standardFields.searchGroups")} bind:value={groupSearch} />
<div class="relative w-full max-w-sm">
<Search class="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input class="w-full pl-9" placeholder={i18n.t("standardFields.searchGroups")} bind:value={groupSearch} />
</div>
<div class="rounded-md border border-border">
+8 -50
View File
@@ -24,7 +24,6 @@
import PageShell from "$lib/components/PageShell.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import StoreReconnectBanner from "$lib/components/stores/StoreReconnectBanner.svelte";
import StoreSyncDeliveryBanner from "$lib/components/stores/StoreSyncDeliveryBanner.svelte";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { upgradeMessageForFeature } from "$lib/plan-upgrade-message";
import PlanUpgradePanel from "$lib/components/PlanUpgradePanel.svelte";
@@ -229,24 +228,8 @@
<div class="mb-4">
<StoreReconnectBanner {canAdmin} items={reconnectItems} />
</div>
{:else}
<p class="text-sm text-muted-foreground" id="store-hub-migration-note">
{i18n.t("stores.migrationNote")}
</p>
{/if}
<div class="mb-2">
<StoreSyncDeliveryBanner compact={true} />
</div>
<p class="text-sm text-muted-foreground" id="store-hub-intro">
{i18n.t("stores.docsPrefix")}
<a class="font-medium text-foreground underline-offset-4 hover:underline" href="/docs"
>{i18n.t("stores.docsApiOverview")}</a
>
{i18n.t("stores.docsSuffix")}
</p>
{#if !canHub}
<div data-testid="stores-plan-upgrade">
<PlanUpgradePanel
@@ -261,7 +244,7 @@
{/if}
{#if !wooConnected && !shopifyConnected && reconnectItems.length === 0}
<div class="mb-2">
<div class="mb-4">
<EmptyState
title={canAdmin ? i18n.t("empty.stores.noneTitle") : i18n.t("empty.stores.noneTitleMember")}
message={canAdmin ? i18n.t("empty.stores.noneMessage") : i18n.t("empty.stores.noneMessageMember")}
@@ -354,36 +337,11 @@
{/each}
</section>
<aside class="mt-2" aria-labelledby="store-hub-start-title">
<Card class="border-dashed">
<CardHeader>
<CardTitle id="store-hub-start-title" class="text-base">{i18n.t("stores.startTitle")}</CardTitle>
<CardDescription>
{canAdmin ? i18n.t("stores.startBody") : i18n.t("stores.startBodyMember")}
</CardDescription>
</CardHeader>
{#if canAdmin && canHub}
<CardContent class="pt-0">
<Button
variant="outline"
class="w-full justify-between sm:w-auto"
onclick={() => void goto("/stores/wizard")}
data-tour="stores-guided-setup"
aria-label={i18n.t("stores.wizard.guidedSetupAria")}
>
{i18n.t("stores.wizard.guidedSetup")}
<ArrowRight class="h-4 w-4" aria-hidden="true" />
</Button>
</CardContent>
{:else if !canAdmin}
<CardContent class="pt-0">
<a href="/settings?tab=team">
<Button type="button" variant="outline" data-tour="stores-ask-admin">
{i18n.t("common.askAdmin")}
</Button>
</a>
</CardContent>
{/if}
</Card>
</aside>
<p class="mt-4 text-sm text-muted-foreground" id="store-hub-intro">
{i18n.t("stores.docsPrefix")}
<a class="font-medium text-foreground underline-offset-4 hover:underline" href="/docs"
>{i18n.t("stores.docsApiOverview")}</a
>
{i18n.t("stores.docsSuffix")}
</p>
</PageShell>