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) # codehelper (generated local — do not commit)
.zed/ .zed/
matej.md
+2 -2
View File
@@ -41,13 +41,13 @@ export const AI_ROLE_META: Record<
docs_api: { docs_api: {
label: "Docs / API", label: "Docs / API",
description: 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" modelPlaceholder: "gpt-5.6-luna"
}, },
support: { support: {
label: "Support", label: "Support",
description: 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" modelPlaceholder: "gpt-5.6-luna"
} }
}; };
+4 -4
View File
@@ -75,14 +75,14 @@ export function buildIdentityReply(): AssistantMessage {
return makeMessage({ return makeMessage({
role: "assistant", role: "assistant",
kind: "text", 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 { export function buildApiExamplesMessage(): AssistantMessage {
const key = "dk_YOUR_API_KEY"; const key = "dk_YOUR_API_KEY";
const text = [ 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:", "List attributes:",
`curl -s -H "X-API-Key: ${key}" "https://descrybe.io/api/v1/attributes?page=1&limit=25"`, `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({ makeMessage({
role: "assistant", role: "assistant",
kind: "text", 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({ return makeMessage({
role: "assistant", role: "assistant",
kind: "quick_replies", 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"] quickReplies: ["Open a support ticket", "Help", "Cancel"]
}); });
} }
+2 -2
View File
@@ -134,7 +134,7 @@ export async function executeIntent(
const lines = [ const lines = [
`API key created${prefix ? ` (${prefix}…)` : ""}.`, `API key created${prefix ? ` (${prefix}…)` : ""}.`,
secret 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.", : "Secret was not returned (you may lack permission). Create one in Settings → API Keys.",
"", "",
"Example (placeholder if you already copied the secret elsewhere):", "Example (placeholder if you already copied the secret elsewhere):",
@@ -317,7 +317,7 @@ export async function executeIntent(
message: [ message: [
`Based on ~${productCount} product(s) in your catalog, ${fit.name} fits (${cap}${credits}).`, `Based on ~${productCount} product(s) in your catalog, ${fit.name} fits (${cap}${credits}).`,
fit.description ? fit.description : "", 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) .filter(Boolean)
.join("\n"), .join("\n"),
+1 -3
View File
@@ -507,7 +507,7 @@ export const INTENT_REGISTRY: IntentDefinition[] = [
guideSteps: [ guideSteps: [
{ title: "Open Settings → API Keys", detail: "Company admin required to create keys" }, { title: "Open Settings → API Keys", detail: "Company admin required to create keys" },
{ title: "Click Create API Key", detail: "Name the key, then create" }, { 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 = [ export const QUICK_START_REPLIES = [
"Getting started",
"Add a feed URL", "Add a feed URL",
"Upload a CSV", "Upload a CSV",
"Map fields", "Map fields",
"Connect Shopify",
"API keys", "API keys",
"Attributes", "Attributes",
"Start processing", "Start processing",
+3 -3
View File
@@ -57,7 +57,7 @@ function createAssistantController() {
makeMessage({ makeMessage({
role: "assistant", role: "assistant",
kind: "quick_replies", 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] quickReplies: [...QUICK_START_REPLIES]
}) })
]; ];
@@ -261,7 +261,7 @@ function createAssistantController() {
makeMessage({ makeMessage({
role: "assistant", role: "assistant",
kind: "steps", kind: "steps",
text: "Next mapping:", text: "Next: mapping",
steps: intentById("map_fields")?.guideSteps steps: intentById("map_fields")?.guideSteps
}) })
); );
@@ -491,7 +491,7 @@ function createAssistantController() {
} }
flow = { ...flow, stepId: "ask_body", slots: { ...flow.slots, subject } }; flow = { ...flow, stepId: "ask_body", slots: { ...flow.slots, subject } };
const hint = flow.slots.body_prefill 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."; : "Describe the issue. No passwords, API keys, or personal data.";
push( push(
makeMessage({ makeMessage({
@@ -327,7 +327,7 @@
aria-modal="true" aria-modal="true"
aria-label={i18n.t("commandPalette.ariaLabel")} aria-label={i18n.t("commandPalette.ariaLabel")}
tabindex="-1" 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} onkeydown={onPanelKeydown}
onclick={(event) => event.stopPropagation()} onclick={(event) => event.stopPropagation()}
> >
@@ -350,7 +350,7 @@
autocomplete="off" autocomplete="off"
autocorrect="off" autocorrect="off"
spellcheck="false" 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 <kbd
class="hidden shrink-0 rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline" 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"> <script lang="ts">
import type { Snippet } from "svelte"; 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 { navUi } from "$lib/nav-ui.svelte";
import { commandPaletteShortcutLabelFromEnv } from "$lib/command-palette-search"; import { commandPaletteShortcutLabelFromEnv } from "$lib/command-palette-search";
import { i18n } from "$lib/i18n"; 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 { children }: { children?: Snippet } = $props();
let loggingOut = $state(false);
const paletteShortcut = commandPaletteShortcutLabelFromEnv(); 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> </script>
<header <header
@@ -63,18 +45,6 @@
{#if children} {#if children}
{@render children()} {@render children()}
{/if} {/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>
</div> </div>
</header> </header>
-15
View File
@@ -6,7 +6,6 @@
import { activateFocusTrap, type FocusTrapHandle } from "$lib/a11y/focus-trap"; import { activateFocusTrap, type FocusTrapHandle } from "$lib/a11y/focus-trap";
import { planCapabilities } from "$lib/plan-capabilities.svelte"; import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { navItemAllowed } from "$lib/plan-capabilities"; import { navItemAllowed } from "$lib/plan-capabilities";
import { commandPaletteShortcutLabelFromEnv } from "$lib/command-palette-search";
import { i18n } from "$lib/i18n"; import { i18n } from "$lib/i18n";
import { import {
LayoutDashboard, LayoutDashboard,
@@ -262,8 +261,6 @@
let trap: FocusTrapHandle | null = null; let trap: FocusTrapHandle | null = null;
let isDesktop = $state(false); let isDesktop = $state(false);
let navMounted = $state(false); let navMounted = $state(false);
const paletteShortcut = commandPaletteShortcutLabelFromEnv();
$effect(() => { $effect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
navMounted = true; navMounted = true;
@@ -466,18 +463,6 @@
<span class="text-sm font-semibold text-sidebar-foreground">{i18n.t("app.name")}</span> <span class="text-sm font-semibold text-sidebar-foreground">{i18n.t("app.name")}</span>
</a> </a>
<div class="flex items-center gap-1"> <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 <button
type="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" 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"; import { MessageCircle, RotateCcw, Send, X } from "@lucide/svelte";
let listEl = $state<HTMLDivElement | null>(null); let listEl = $state<HTMLDivElement | null>(null);
let inputEl = $state<HTMLTextAreaElement | null>(null);
let draft = $state(""); let draft = $state("");
const needsFile = $derived( const needsFile = $derived(
@@ -26,17 +27,25 @@
void scrollBottom(); void scrollBottom();
}); });
function submit() { async function submit() {
const text = draft.trim(); const text = draft.trim();
if (!text) return; if (!text) return;
draft = ""; draft = "";
await tick();
resizeInput();
void assistant.handleUserText(text); 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) { function onKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
submit(); void submit();
} }
} }
@@ -64,7 +73,6 @@
</div> </div>
<div class="min-w-0 flex-1"> <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-sm font-semibold text-foreground">{i18n.t("assistant.title")}</p>
<p class="truncate text-xs text-muted-foreground">{i18n.t("assistant.subtitle")}</p>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -116,16 +124,19 @@
class="flex items-end gap-2 border-t border-border p-3" class="flex items-end gap-2 border-t border-border p-3"
onsubmit={(e) => { onsubmit={(e) => {
e.preventDefault(); e.preventDefault();
submit(); void submit();
}} }}
> >
<label class="sr-only" for="assistant-input">{i18n.t("assistant.messageLabel")}</label> <label class="sr-only" for="assistant-input">{i18n.t("assistant.messageLabel")}</label>
<textarea <textarea
bind:this={inputEl}
id="assistant-input" id="assistant-input"
rows="2" rows="1"
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" 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")} placeholder={i18n.t("assistant.placeholder")}
bind:value={draft} bind:value={draft}
oninput={resizeInput}
onkeydown={onKeydown} onkeydown={onKeydown}
disabled={assistant.busy} disabled={assistant.busy}
></textarea> ></textarea>
@@ -44,7 +44,7 @@
{#if assistant.open} {#if assistant.open}
<div <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 /> <AssistantDock />
</div> </div>
@@ -54,8 +54,8 @@
type="button" type="button"
class="{buttonClasses( class="{buttonClasses(
assistant.open ? 'secondary' : 'default', assistant.open ? 'secondary' : 'default',
'default', 'icon',
'fixed bottom-4 right-4 z-[55] h-12 gap-2 rounded-full px-4 shadow-md sm:bottom-6 sm:right-6' '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-assistant-target="assistant-fab"
data-tour="assistant-fab" data-tour="assistant-fab"
@@ -64,6 +64,5 @@
onclick={() => assistant.toggle()} onclick={() => assistant.toggle()}
> >
<MessageCircle class="h-5 w-5" /> <MessageCircle class="h-5 w-5" />
<span class="hidden sm:inline">{i18n.t("assistant.label")}</span>
</button> </button>
{/if} {/if}
@@ -53,7 +53,7 @@
function guideLineFor(node: DocsGuideNode): TranscriptEntry { function guideLineFor(node: DocsGuideNode): TranscriptEntry {
if (isQuestionNode(node)) { 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 }; return { role: "guide", text: node.outcome || node.title };
} }
@@ -235,7 +235,7 @@
<Dialog <Dialog
bind:open bind:open
title="Ask find the right docs" title="Ask: find the right docs"
description={tree.disclaimer} description={tree.disclaimer}
class="max-w-lg sm:max-w-lg" 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" 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)} 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> </button>
</li> </li>
{/each} {/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 }>) const missing = Object.values(checks as Record<string, { passed?: boolean; label?: string }>)
.filter((c) => !c?.passed) .filter((c) => !c?.passed)
.map((c) => c.label || "check"); .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`; return `${grade} ${score}/100`;
} }
@@ -172,12 +172,12 @@
{#if canWoo} {#if canWoo}
<Button <Button
variant="outline" 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" data-tour="store-wizard-pick-woocommerce"
onclick={() => selectChannel("woocommerce")} onclick={() => selectChannel("woocommerce")}
> >
<Store class="h-5 w-5 shrink-0" aria-hidden="true" /> <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 font-medium">{i18n.t("stores.wizard.channel.woocommerce")}</span>
<span class="block text-xs font-normal text-muted-foreground" <span class="block text-xs font-normal text-muted-foreground"
>{i18n.t("stores.woo.description")}</span >{i18n.t("stores.woo.description")}</span
@@ -188,12 +188,12 @@
{#if canShopify} {#if canShopify}
<Button <Button
variant="outline" 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" data-tour="store-wizard-pick-shopify"
onclick={() => selectChannel("shopify")} onclick={() => selectChannel("shopify")}
> >
<ShoppingBag class="h-5 w-5 shrink-0" aria-hidden="true" /> <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 font-medium">{i18n.t("stores.wizard.channel.shopify")}</span>
<span class="block text-xs font-normal text-muted-foreground" <span class="block text-xs font-normal text-muted-foreground"
>{i18n.t("stores.shopify.description")}</span >{i18n.t("stores.shopify.description")}</span
@@ -13,7 +13,6 @@
let popoverEl = $state<HTMLDivElement | null>(null); let popoverEl = $state<HTMLDivElement | null>(null);
let popoverStyle = $state(""); let popoverStyle = $state("");
let trap: FocusTrapHandle | null = null; let trap: FocusTrapHandle | null = null;
let jumpOpen = $state(false);
type Hole = { top: number; left: number; width: number; height: number }; type Hole = { top: number; left: number; width: number; height: number };
@@ -98,10 +97,6 @@
if (!tutorial.active) return; if (!tutorial.active) return;
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
if (jumpOpen) {
jumpOpen = false;
return;
}
tutorial.dismiss(); tutorial.dismiss();
} }
} }
@@ -194,12 +189,6 @@
return () => clearInterval(id); return () => clearInterval(id);
}); });
$effect(() => {
void tutorial.stepIndex;
void tutorial.sectionPickerOpen;
jumpOpen = false;
});
const stepNumber = $derived(tutorial.stepIndex + 1); const stepNumber = $derived(tutorial.stepIndex + 1);
const progressPct = $derived( const progressPct = $derived(
tutorial.sectionPickerOpen tutorial.sectionPickerOpen
@@ -232,7 +221,6 @@
const sectionLabel = $derived(i18n.t(`tutorial.section.${tutorial.step.sectionId}`)); const sectionLabel = $derived(i18n.t(`tutorial.section.${tutorial.step.sectionId}`));
async function jumpToSection(sectionId: TutorialSectionId) { async function jumpToSection(sectionId: TutorialSectionId) {
jumpOpen = false;
await tutorial.startSection(sectionId); await tutorial.startSection(sectionId);
} }
@@ -398,40 +386,6 @@
</p> </p>
{/if} {/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"> <div class="mt-4 flex items-center justify-between gap-2">
<Button <Button
variant="ghost" variant="ghost"
@@ -81,7 +81,8 @@
if (!triggerEl) return; if (!triggerEl) return;
const r = triggerEl.getBoundingClientRect(); const r = triggerEl.getBoundingClientRect();
const gap = 4; 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; const menuHeight = menuEl?.offsetHeight ?? 0;
if (menuHeight > 0 && top + menuHeight > window.innerHeight - 8) { if (menuHeight > 0 && top + menuHeight > window.innerHeight - 8) {
top = Math.max(8, r.top - menuHeight - gap); top = Math.max(8, r.top - menuHeight - gap);
@@ -167,7 +168,7 @@
<div use:portal> <div use:portal>
<button <button
type="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" aria-label="Close menu"
tabindex="-1" tabindex="-1"
onclick={onBackdropClick} onclick={onBackdropClick}
@@ -184,7 +185,7 @@
if (index >= 0) activeIndex = index; if (index >= 0) activeIndex = index;
}} }}
class={cn( 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 className
)} )}
style={align === "end" style={align === "end"
+14 -14
View File
@@ -20,7 +20,7 @@ const TRY_IT_TIP =
export const DOCS_GUIDE_TREE: DocsGuideTree = { export const DOCS_GUIDE_TREE: DocsGuideTree = {
version: 2, version: 2,
disclaimer: 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", rootId: "root",
escapeLinks: [ escapeLinks: [
{ label: "Browse API reference", href: "/docs#overview", kind: "docs" }, { label: "Browse API reference", href: "/docs#overview", kind: "docs" },
@@ -33,7 +33,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
id: "root", id: "root",
pathKey: "start", pathKey: "start",
title: "What are you trying to do?", 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: [ choices: [
{ {
id: "first", id: "first",
@@ -99,7 +99,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
{ {
id: "process", id: "process",
label: "Process / enrich products", label: "Process / enrich products",
description: "Two contracts choose carefully", description: "Two contracts; choose carefully",
nextId: "q.process" nextId: "q.process"
}, },
{ {
@@ -116,7 +116,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
id: "q.feeds", id: "q.feeds",
pathKey: "feeds", pathKey: "feeds",
synonyms: ["supplier feed", "import csv", "sync feed"], synonyms: ["supplier feed", "import csv", "sync feed"],
title: "Feeds what do you need?", title: "Feeds: what do you need?",
choices: [ choices: [
{ {
id: "create", id: "create",
@@ -158,7 +158,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
}, },
{ {
id: "chooser", id: "chooser",
label: "Not sure — compare both", label: "Not sure? Compare both",
nextId: "answer.process.chooser" nextId: "answer.process.chooser"
} }
] ]
@@ -169,7 +169,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
id: "q.export", id: "q.export",
pathKey: "export", pathKey: "export",
synonyms: ["channel feed", "google shopping", "export csv"], synonyms: ["channel feed", "google shopping", "export csv"],
title: "Export feeds what do you need?", title: "Export feeds: what do you need?",
choices: [ choices: [
{ {
id: "setup", 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.", 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, authNote: AUTH_NOTE,
endpoints: [ 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("GET", "/products", "Authenticated smoke list (page=1&limit=1)"),
endpoint("POST", "/products/process", "EAN batch after Authorize"), endpoint("POST", "/products/process", "EAN batch after Authorize"),
endpoint("POST", "/process", "Jobs process after Authorize") endpoint("POST", "/process", "Jobs process after Authorize")
], ],
links: [ links: [
{ label: "Authorize panel", href: docsAuthHref(), kind: "docs" }, { label: "Authorize panel", href: docsAuthHref(), kind: "docs" },
@@ -421,7 +421,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
synonyms: ["ean", "items[].ean", "products/process"], synonyms: ["ean", "items[].ean", "products/process"],
title: "Public process (EAN batch)", title: "Public process (EAN batch)",
outcome: "Start and poll enrichment using POST/GET /products/process.", 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, authNote: AUTH_NOTE,
endpoints: [ endpoints: [
endpoint("POST", "/products/process", "Start processing by EAN"), 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: "Authorize & Try", href: docsAuthHref(), kind: "docs" },
{ label: "Processing UI", href: "/processing", kind: "app" } { 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"] relatedIds: ["answer.process.jobs", "answer.process.chooser", "answer.products"]
}, },
@@ -474,7 +474,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
synonyms: ["which process", "dual contract"], synonyms: ["which process", "dual contract"],
title: "Which process API should I use?", title: "Which process API should I use?",
outcome: "Pick EAN batch for integrations; pick /process for dashboard-style jobs.", 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, authNote: AUTH_NOTE,
endpoints: [ endpoints: [
endpoint("POST", "/products/process", "EAN batch"), endpoint("POST", "/products/process", "EAN batch"),
@@ -637,7 +637,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
{ label: "Authorize", href: docsAuthHref(), kind: "docs" }, { label: "Authorize", href: docsAuthHref(), kind: "docs" },
{ label: "Products UI", href: "/products", kind: "app" } { 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"] relatedIds: ["answer.products", "answer.process.jobs"]
}, },
@@ -676,7 +676,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
pathKey: "billing", pathKey: "billing",
synonyms: ["credits", "plan", "upgrade", "limits"], synonyms: ["credits", "plan", "upgrade", "limits"],
title: "Billing and plan 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.", 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: [ links: [
{ label: "Plans", href: "/plans", kind: "app" }, { label: "Plans", href: "/plans", kind: "app" },
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,5 +32,5 @@ export const navUi = {
} }
mobileOpen = false; mobileOpen = false;
commandPaletteOpen = true; commandPaletteOpen = true;
} },
}; };
+5 -5
View File
@@ -17,7 +17,7 @@ export type PageSeo = {
}; };
export const HOME_SEO: 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: 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.", "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: "/", path: "/",
@@ -26,9 +26,9 @@ export const HOME_SEO: PageSeo = {
}; };
export const PRICING_SEO: PageSeo = { export const PRICING_SEO: PageSeo = {
title: "Pricing Free, Starter, Growth, Business | Descrybe", title: "Pricing: Free, Starter, Growth, Business | Descrybe",
description: 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", path: "/pricing",
image: DEFAULT_OG_IMAGE, image: DEFAULT_OG_IMAGE,
imageAlt: DEFAULT_OG_IMAGE_ALT imageAlt: DEFAULT_OG_IMAGE_ALT
@@ -46,14 +46,14 @@ export const PRIVACY_SEO: PageSeo = {
export const TERMS_SEO: PageSeo = { export const TERMS_SEO: PageSeo = {
title: "Terms of Service | Descrybe", title: "Terms of Service | Descrybe",
description: 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", path: "/terms",
image: DEFAULT_OG_IMAGE, image: DEFAULT_OG_IMAGE,
imageAlt: DEFAULT_OG_IMAGE_ALT imageAlt: DEFAULT_OG_IMAGE_ALT
}; };
export const FEATURES_SEO: PageSeo = { export const FEATURES_SEO: PageSeo = {
title: "Features Feeds, enrichment, and export | Descrybe", title: "Features: Feeds, enrichment, and export | Descrybe",
description: description:
"See how Descrybe imports supplier feeds, maps fields to your taxonomy, enriches product data, and ships catalogs via export feeds, WooCommerce, or API.", "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", path: "/features",
+92 -56
View File
@@ -8,6 +8,12 @@
import SupportNotificationBell from "$lib/components/SupportNotificationBell.svelte"; import SupportNotificationBell from "$lib/components/SupportNotificationBell.svelte";
import TaskStatusIndicator from "$lib/components/TaskStatusIndicator.svelte"; import TaskStatusIndicator from "$lib/components/TaskStatusIndicator.svelte";
import Toaster from "$lib/components/ui/Toaster.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 TutorialOverlay from "$lib/components/tutorial/TutorialOverlay.svelte";
import AssistantHost from "$lib/components/assistant/AssistantHost.svelte"; import AssistantHost from "$lib/components/assistant/AssistantHost.svelte";
import CompanySwitcher from "$lib/components/CompanySwitcher.svelte"; import CompanySwitcher from "$lib/components/CompanySwitcher.svelte";
@@ -23,6 +29,7 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import { trackEvent } from "$lib/analytics";
import { api, isUnauthorized } from "$lib/api"; import { api, isUnauthorized } from "$lib/api";
import { safeInternalNext } from "$lib/safe-next"; import { safeInternalNext } from "$lib/safe-next";
import { AUTH_PATHS, MARKETING_PATHS } from "$lib/site"; import { AUTH_PATHS, MARKETING_PATHS } from "$lib/site";
@@ -51,7 +58,7 @@
import { tutorial } from "$lib/tutorial"; import { tutorial } from "$lib/tutorial";
import { billingRecovery, type PlanLike } from "$lib/billing-display"; import { billingRecovery, type PlanLike } from "$lib/billing-display";
import { isCompanyAdmin } from "$lib/company-admin"; 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(); let { children } = $props();
@@ -61,6 +68,21 @@
let me = $state<MeResponse | null>(null); let me = $state<MeResponse | null>(null);
let meRequested = $state(false); 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; const SYSTEM_MODE_POLL_MS = 60_000;
@@ -438,31 +460,7 @@
{/if} {/if}
{/if} {/if}
{#if !tutorial.active} {#if !tutorial.active}
{#if tutorial.canResume} {#if !tutorial.canResume && !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-resume-tutorial"
aria-label={i18n.t("dashboard.resumeTutorial")}
title={i18n.t("dashboard.resumeTutorial")}
onclick={() => void tutorial.resume()}
>
<span aria-hidden="true" class="xl:hidden"><GraduationCap class="h-4 w-4" /></span>
<span aria-hidden="true" class="hidden xl:inline">{i18n.t("dashboard.resumeTutorial")}</span>
</button>
{:else if tutorial.canRestart}
<button
type="button"
class="hidden h-8 shrink-0 items-center justify-center rounded-md border border-border px-2.5 text-xs font-medium text-text-muted transition hover:bg-accent hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:inline-flex"
data-tour="header-restart-tutorial"
aria-label={i18n.t("dashboard.restartTutorial")}
title={i18n.t("dashboard.restartTutorial")}
onclick={() => void tutorial.restart()}
>
<span aria-hidden="true" class="xl:hidden"><GraduationCap class="h-4 w-4" /></span>
<span aria-hidden="true" class="hidden xl:inline">{i18n.t("dashboard.restartTutorial")}</span>
</button>
{:else}
<button <button
type="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" 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> <span aria-hidden="true" class="hidden xl:inline">{i18n.t("dashboard.startTutorial")}</span>
</button> </button>
{/if} {/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} {/if}
<LocaleSwitcher <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" 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?.user}
{#if me.impersonating || me.dev_user_switch} <DropdownMenu align="end" class="w-64">
<a {#snippet trigger({ open, toggle })}
href="/settings" <button
class="hidden max-w-[18rem] shrink-0 truncate text-xs text-text-muted transition hover:text-text lg:inline" type="button"
title={me.user.email} 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"
data-tour="header-current-user-email" title={me?.user?.email ?? ""}
> aria-label={i18n.t("header.accountSettingsAria", { email: me?.user?.email ?? "" })}
{me.user.email} aria-expanded={open}
</a> aria-haspopup="menu"
{/if} onclick={toggle}
<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} {userInitial}
</a> </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()}
>
<LogOut class="h-4 w-4" />
{loggingOut ? i18n.t("header.signingOut") : i18n.t("header.signOut")}
</DropdownMenuItem>
</DropdownMenu>
{/if} {/if}
</DashboardHeader> </DashboardHeader>
<div class="min-w-0 flex-1 overflow-x-auto overflow-y-auto p-3 sm:p-6"> <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"> <div class="space-y-2 text-sm text-muted-foreground">
<p>{i18n.t("attributes.bulk.uploadHint")}</p> <p>{i18n.t("attributes.bulk.uploadHint")}</p>
<ul class="list-inside list-disc space-y-1"> <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">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">name</span>: {i18n.t("attributes.bulk.col.name")}</li>
<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>
<li><span class="font-mono text-xs">category_id</span> {i18n.t("attributes.bulk.col.categoryId")}</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">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">parent_key</span>: {i18n.t("attributes.bulk.col.parentKey")}</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -15,7 +15,6 @@
import ListSkeleton from "$lib/components/ListSkeleton.svelte"; import ListSkeleton from "$lib/components/ListSkeleton.svelte";
import StatCardsSkeleton from "$lib/components/StatCardsSkeleton.svelte"; import StatCardsSkeleton from "$lib/components/StatCardsSkeleton.svelte";
import DashboardStats from "$lib/components/DashboardStats.svelte"; import DashboardStats from "$lib/components/DashboardStats.svelte";
import NewsFeed from "$lib/components/NewsFeed.svelte";
import UpgradeBanner from "$lib/components/UpgradeBanner.svelte"; import UpgradeBanner from "$lib/components/UpgradeBanner.svelte";
import ActivationChecklist from "$lib/components/ActivationChecklist.svelte"; import ActivationChecklist from "$lib/components/ActivationChecklist.svelte";
import MigratedEtlGapsPanel from "$lib/components/MigratedEtlGapsPanel.svelte"; import MigratedEtlGapsPanel from "$lib/components/MigratedEtlGapsPanel.svelte";
@@ -882,12 +881,6 @@
{/if} {/if}
</div> </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> </section>
</div> </div>
</div> </div>
+1 -1
View File
@@ -579,7 +579,7 @@
<p class="mt-1.5 text-[11px] text-text-muted"> <p class="mt-1.5 text-[11px] text-text-muted">
Public Public
<code class="rounded bg-muted px-1 text-text">/api/v1</code> <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>. <code class="rounded bg-muted px-1 text-text">Authorization: Bearer</code>.
</p> </p>
</div> </div>
+2 -7
View File
@@ -690,7 +690,7 @@
success = i18n.t("flash.feed.syncFinishedNamed", { name: feed.name ?? id, status, products }); success = i18n.t("flash.feed.syncFinishedNamed", { name: feed.name ?? id, status, products });
notifySuccess(i18n.t("toast.feed.syncFinished"), { notifySuccess(i18n.t("toast.feed.syncFinished"), {
alert: "sync_done", alert: "sync_done",
description: `"${feed.name ?? id}" ${products}. Process a sample when ready.`, description: `"${feed.name ?? id}": ${products}. Process a sample when ready.`,
actions: [ actions: [
{ label: i18n.t("feeds.viewProducts"), href: `/products?feed_id=${id}` }, { label: i18n.t("feeds.viewProducts"), href: `/products?feed_id=${id}` },
{ label: i18n.t("feeds.processing"), href: "/processing" } { label: i18n.t("feeds.processing"), href: "/processing" }
@@ -811,12 +811,7 @@
} }
</script> </script>
<PageShell <PageShell title={i18n.t("feeds.title")}>
title={i18n.t("feeds.title")}
description={loading && feeds.length === 0
? i18n.t("feeds.loadingDesc")
: i18n.t("feeds.desc", { page: filtered.length, total })}
>
{#snippet actions()} {#snippet actions()}
<Button variant="outline" class="w-full sm:w-auto" onclick={() => void goto("/stores")}>{i18n.t("feeds.storesHub")}</Button> <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"> <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(", "); const typeLabel = types.join(", ");
success = success =
!canUseAI && processingType === "full" !canUseAI && processingType === "full"
? `Processing started ${receipt} (normalize/specs/fill AI skipped on Free).` ? `Processing started: ${receipt} (normalize/specs/fill; AI skipped on Free).`
: `Processing started ${receipt} (${typeLabel}).`; : `Processing started: ${receipt} (${typeLabel}).`;
notifySuccess(i18n.t("toast.products.jobStarted"), { notifySuccess(i18n.t("toast.products.jobStarted"), {
description: !canUseAI description: !canUseAI
? `${receipt} · free-tier steps (AI skipped). Track progress in Processing.` ? `${receipt} · free-tier steps (AI skipped). Track progress in Processing.`
@@ -1035,7 +1035,7 @@
}); });
success = result.message success = result.message
? `${result.message} (${receipt})` ? `${result.message} (${receipt})`
: `Reset complete ${receipt}.`; : `Reset complete: ${receipt}.`;
notifySuccess(i18n.t("toast.products.resetComplete"), { notifySuccess(i18n.t("toast.products.resetComplete"), {
description: receipt, description: receipt,
actions: [PROCESSING_RECEIPT_ACTION], actions: [PROCESSING_RECEIPT_ACTION],
@@ -64,6 +64,7 @@
} from "$lib/types"; } from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte"; import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte"; import Alert from "$lib/components/Alert.svelte";
import NewsFeed from "$lib/components/NewsFeed.svelte";
import MemberPermissionsDialog from "$lib/components/settings/MemberPermissionsDialog.svelte"; import MemberPermissionsDialog from "$lib/components/settings/MemberPermissionsDialog.svelte";
import Spinner from "$lib/components/Spinner.svelte"; import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte"; import EmptyState from "$lib/components/EmptyState.svelte";
@@ -1125,6 +1126,11 @@
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<section class="space-y-3">
<h2 class="text-2xl font-semibold tracking-tight">{i18n.t("dashboard.whatsNew")}</h2>
<NewsFeed limit={3} />
</section>
</TabsContent> </TabsContent>
<!-- Company --> <!-- Company -->
@@ -572,7 +572,7 @@
data-assistant-target="enable-recommended" data-assistant-target="enable-recommended"
onclick={() => void enableRecommended()} onclick={() => void enableRecommended()}
> >
Enable recommended {i18n.t("standardFields.enableRecommended")}
</Button> </Button>
{#if selectedIds.length > 0} {#if selectedIds.length > 0}
<Button <Button
@@ -602,9 +602,9 @@
</div> </div>
{:else} {:else}
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center gap-2"> <div class="relative w-full max-w-sm">
<Search class="h-4 w-4 text-muted-foreground" /> <Search class="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input class="max-w-sm" placeholder={i18n.t("standardFields.searchFields")} bind:value={fieldSearch} /> <Input class="w-full pl-9" placeholder={i18n.t("standardFields.searchFields")} bind:value={fieldSearch} />
</div> </div>
<div class="rounded-md border border-border bg-card"> <div class="rounded-md border border-border bg-card">
@@ -689,18 +689,10 @@
</TableCell> </TableCell>
<TableCell>{groupNameOf(groupIdOf(field))}</TableCell> <TableCell>{groupNameOf(groupIdOf(field))}</TableCell>
<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)} {#if isRequired(field)}
<Badge class="ml-1">{i18n.t("common.required")}</Badge> <Badge>{i18n.t("common.required")}</Badge>
{:else} {:else}
<Badge variant="outline" class="ml-1">{i18n.t("common.optional")}</Badge> <Badge variant="outline">{i18n.t("common.optional")}</Badge>
{/if}
{#if isSystem(field)}
<Badge variant="secondary" class="ml-1">{i18n.t("common.system")}</Badge>
{/if} {/if}
</TableCell> </TableCell>
<TableCell stickyRight> <TableCell stickyRight>
@@ -759,9 +751,9 @@
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center gap-2"> <div class="relative w-full max-w-sm">
<Search class="h-4 w-4 text-muted-foreground" /> <Search class="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input class="max-w-sm" placeholder={i18n.t("standardFields.searchGroups")} bind:value={groupSearch} /> <Input class="w-full pl-9" placeholder={i18n.t("standardFields.searchGroups")} bind:value={groupSearch} />
</div> </div>
<div class="rounded-md border border-border"> <div class="rounded-md border border-border">
+7 -49
View File
@@ -24,7 +24,6 @@
import PageShell from "$lib/components/PageShell.svelte"; import PageShell from "$lib/components/PageShell.svelte";
import EmptyState from "$lib/components/EmptyState.svelte"; import EmptyState from "$lib/components/EmptyState.svelte";
import StoreReconnectBanner from "$lib/components/stores/StoreReconnectBanner.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 { planCapabilities } from "$lib/plan-capabilities.svelte";
import { upgradeMessageForFeature } from "$lib/plan-upgrade-message"; import { upgradeMessageForFeature } from "$lib/plan-upgrade-message";
import PlanUpgradePanel from "$lib/components/PlanUpgradePanel.svelte"; import PlanUpgradePanel from "$lib/components/PlanUpgradePanel.svelte";
@@ -229,24 +228,8 @@
<div class="mb-4"> <div class="mb-4">
<StoreReconnectBanner {canAdmin} items={reconnectItems} /> <StoreReconnectBanner {canAdmin} items={reconnectItems} />
</div> </div>
{:else}
<p class="text-sm text-muted-foreground" id="store-hub-migration-note">
{i18n.t("stores.migrationNote")}
</p>
{/if} {/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} {#if !canHub}
<div data-testid="stores-plan-upgrade"> <div data-testid="stores-plan-upgrade">
<PlanUpgradePanel <PlanUpgradePanel
@@ -261,7 +244,7 @@
{/if} {/if}
{#if !wooConnected && !shopifyConnected && reconnectItems.length === 0} {#if !wooConnected && !shopifyConnected && reconnectItems.length === 0}
<div class="mb-2"> <div class="mb-4">
<EmptyState <EmptyState
title={canAdmin ? i18n.t("empty.stores.noneTitle") : i18n.t("empty.stores.noneTitleMember")} title={canAdmin ? i18n.t("empty.stores.noneTitle") : i18n.t("empty.stores.noneTitleMember")}
message={canAdmin ? i18n.t("empty.stores.noneMessage") : i18n.t("empty.stores.noneMessageMember")} message={canAdmin ? i18n.t("empty.stores.noneMessage") : i18n.t("empty.stores.noneMessageMember")}
@@ -354,36 +337,11 @@
{/each} {/each}
</section> </section>
<aside class="mt-2" aria-labelledby="store-hub-start-title"> <p class="mt-4 text-sm text-muted-foreground" id="store-hub-intro">
<Card class="border-dashed"> {i18n.t("stores.docsPrefix")}
<CardHeader> <a class="font-medium text-foreground underline-offset-4 hover:underline" href="/docs"
<CardTitle id="store-hub-start-title" class="text-base">{i18n.t("stores.startTitle")}</CardTitle> >{i18n.t("stores.docsApiOverview")}</a
<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")} {i18n.t("stores.docsSuffix")}
<ArrowRight class="h-4 w-4" aria-hidden="true" /> </p>
</Button>
</CardContent>
{:else if !canAdmin}
<CardContent class="pt-0">
<a href="/settings?tab=team">
<Button type="button" variant="outline" data-tour="stores-ask-admin">
{i18n.t("common.askAdmin")}
</Button>
</a>
</CardContent>
{/if}
</Card>
</aside>
</PageShell> </PageShell>