dashboard page fixes v1 (visual only)
This commit is contained in:
@@ -64,3 +64,4 @@ apps/web/static/.audit/
|
||||
|
||||
# codehelper (generated local — do not commit)
|
||||
.zed/
|
||||
matej.md
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "I’ll route you to the right Descrybe public API docs (/api/v1). Pick one option — no free-text needed.",
|
||||
body: "I’ll 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
@@ -32,5 +32,5 @@ export const navUi = {
|
||||
}
|
||||
mobileOpen = false;
|
||||
commandPaletteOpen = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
{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 })}
|
||||
<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}
|
||||
</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}
|
||||
</DashboardHeader>
|
||||
<div class="min-w-0 flex-1 overflow-x-auto overflow-y-auto p-3 sm:p-6">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")}
|
||||
<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.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>
|
||||
{i18n.t("stores.docsSuffix")}
|
||||
</p>
|
||||
</PageShell>
|
||||
|
||||
Reference in New Issue
Block a user