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)
|
# codehelper (generated local — do not commit)
|
||||||
.zed/
|
.zed/
|
||||||
|
matej.md
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: "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: [
|
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
@@ -32,5 +32,5 @@ export const navUi = {
|
|||||||
}
|
}
|
||||||
mobileOpen = false;
|
mobileOpen = false;
|
||||||
commandPaletteOpen = true;
|
commandPaletteOpen = true;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user