Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { api } from "$lib/api";
|
||||
import { i18n } from "$lib/i18n";
|
||||
import { unwrapList, unwrapTotal, DEFAULT_PAGE_SIZE, RECENT_JOBS_LIMIT } from "$lib/list";
|
||||
import type { ListResponse, ProcessingJob, Product, ShopifyConfig, WooCommerceConfig } from "$lib/types";
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Progress } from "$lib/components/ui";
|
||||
import {
|
||||
activationIndexFromWorkspace,
|
||||
readActivationProgress,
|
||||
resolveActivationCursor,
|
||||
visibleActivationSteps,
|
||||
writeActivationProgress,
|
||||
type ActivationProgress,
|
||||
type ActivationWorkspaceEvidence
|
||||
} from "$lib/activation";
|
||||
import { trackEvent } from "$lib/analytics";
|
||||
import { isFeedActive, type FeedRow } from "$lib/components/feeds/types";
|
||||
import { planCapabilities } from "$lib/plan-capabilities.svelte";
|
||||
import { needsStoreReconnect } from "$lib/store-reconnect";
|
||||
import { Check, Circle, ListChecks, X } from "@lucide/svelte";
|
||||
|
||||
let showAllSteps = $state(false);
|
||||
|
||||
let progress = $state<ActivationProgress>(readActivationProgress());
|
||||
let currentIndex = $state(0);
|
||||
let completedCount = $state(0);
|
||||
let ready = $state(false);
|
||||
let workspace = $state<ActivationWorkspaceEvidence | null>(null);
|
||||
|
||||
const steps = $derived(visibleActivationSteps((key) => planCapabilities.can(key)));
|
||||
|
||||
function refresh(
|
||||
evidence: ActivationWorkspaceEvidence | null = workspace,
|
||||
stepList = steps
|
||||
) {
|
||||
const resolved = resolveActivationCursor(readActivationProgress(), evidence, stepList);
|
||||
progress = resolved.progress;
|
||||
currentIndex = resolved.currentIndex;
|
||||
completedCount = resolved.completedCount;
|
||||
}
|
||||
|
||||
async function loadWorkspaceEvidence(signal: AbortSignal): Promise<ActivationWorkspaceEvidence> {
|
||||
const evidence: ActivationWorkspaceEvidence = {};
|
||||
const canStores = planCapabilities.can("stores.hub");
|
||||
const [fieldsRes, feedsRes, productsRes, jobsRes, exportsRes, wooRes, shopifyRes] =
|
||||
await Promise.all([
|
||||
api<ListResponse<Record<string, unknown>>>("/api/standard-fields?enabled=true&limit=1", {
|
||||
signal
|
||||
}).catch(() => null),
|
||||
api<ListResponse<FeedRow> & { total?: number }>(`/api/feeds?limit=${DEFAULT_PAGE_SIZE}&offset=0`, {
|
||||
signal
|
||||
}).catch(() => null),
|
||||
api<ListResponse<Product> & { total?: number }>(
|
||||
"/api/products?limit=1&offset=0",
|
||||
{ signal }
|
||||
).catch(() => null),
|
||||
api<ListResponse<ProcessingJob>>(`/api/processing/jobs?limit=${RECENT_JOBS_LIMIT}`, {
|
||||
signal
|
||||
}).catch(() => null),
|
||||
api<ListResponse<Record<string, unknown>> & { total?: number }>(
|
||||
"/api/export-feeds?limit=1",
|
||||
{ signal }
|
||||
).catch(() => null),
|
||||
canStores
|
||||
? api<WooCommerceConfig>("/api/woocommerce", { signal }).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
canStores
|
||||
? api<ShopifyConfig>("/api/shopify", { signal }).catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
]);
|
||||
|
||||
if (signal.aborted) return evidence;
|
||||
|
||||
const enabledFields = fieldsRes ? unwrapList(fieldsRes).length : 0;
|
||||
const feeds = feedsRes ? unwrapList(feedsRes) : [];
|
||||
const feedTotal = feedsRes ? (unwrapTotal(feedsRes) ?? feeds.length) : 0;
|
||||
const productTotal = productsRes
|
||||
? (unwrapTotal(productsRes) ?? unwrapList(productsRes).length)
|
||||
: 0;
|
||||
const jobs = jobsRes ? unwrapList(jobsRes) : [];
|
||||
const exportTotal = exportsRes
|
||||
? (unwrapTotal(exportsRes) ?? unwrapList(exportsRes).length)
|
||||
: 0;
|
||||
|
||||
evidence.hasSource = feedTotal > 0;
|
||||
evidence.hasMapping = feeds.some((f) => {
|
||||
const status = String(f.status ?? "").toLowerCase();
|
||||
return status === "mapped" || isFeedActive(f);
|
||||
});
|
||||
evidence.hasSyncedSample = productTotal > 0;
|
||||
evidence.hasProcessed = jobs.length > 0;
|
||||
evidence.hasExport = exportTotal > 0;
|
||||
// Fields step: explicit enabled fields, or infer once a source already exists.
|
||||
evidence.hasEnabledFields = enabledFields > 0 || evidence.hasSource === true;
|
||||
|
||||
if (canStores) {
|
||||
const wooOk = Boolean(wooRes?.has_credentials) && !needsStoreReconnect(wooRes);
|
||||
const shopifyOk = Boolean(shopifyRes?.has_credentials) && !needsStoreReconnect(shopifyRes);
|
||||
evidence.hasStoreConnect = wooOk || shopifyOk;
|
||||
}
|
||||
|
||||
return evidence;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const ac = new AbortController();
|
||||
refresh(null, steps);
|
||||
ready = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const evidence = await loadWorkspaceEvidence(ac.signal);
|
||||
if (ac.signal.aborted) return;
|
||||
workspace = evidence;
|
||||
refresh(evidence, steps);
|
||||
} catch {
|
||||
/* keep localStorage/tutorial cursor */
|
||||
}
|
||||
})();
|
||||
return () => ac.abort();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const stepList = steps;
|
||||
if (!ready) return;
|
||||
refresh(workspace, stepList);
|
||||
});
|
||||
|
||||
const total = $derived(steps.length);
|
||||
const pct = $derived(total === 0 ? 0 : Math.round((completedCount / total) * 100));
|
||||
const visible = $derived(ready && (progress.status === "idle" || progress.status === "in_progress"));
|
||||
const dismissed = $derived(ready && progress.status === "skipped");
|
||||
const coreStepIds = new Set(["connect-source", "map", "sync-sample", "process"]);
|
||||
|
||||
function dismiss() {
|
||||
const stepId = steps[currentIndex]?.id ?? progress.stepId;
|
||||
progress = writeActivationProgress("skipped", stepId);
|
||||
trackEvent("activation_dismiss", stepId ? { step_id: stepId } : undefined);
|
||||
}
|
||||
|
||||
function resume() {
|
||||
const stepId =
|
||||
progress.stepId && steps.some((s) => s.id === progress.stepId)
|
||||
? progress.stepId
|
||||
: (steps[0]?.id ?? null);
|
||||
progress = writeActivationProgress("in_progress", stepId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
function openStep(index: number) {
|
||||
const step = steps[index];
|
||||
if (!step) return;
|
||||
progress = writeActivationProgress("in_progress", step.id);
|
||||
currentIndex = index;
|
||||
completedCount = Math.max(index, activationIndexFromWorkspace(workspace, steps));
|
||||
void goto(step.href);
|
||||
}
|
||||
|
||||
function markCurrentDone() {
|
||||
const completedId = steps[currentIndex]?.id;
|
||||
if (completedId) {
|
||||
trackEvent("activation_step_complete", { step_id: completedId });
|
||||
}
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= steps.length) {
|
||||
progress = writeActivationProgress("completed", steps.at(-1)?.id ?? "export");
|
||||
currentIndex = steps.length;
|
||||
completedCount = steps.length;
|
||||
return;
|
||||
}
|
||||
const next = steps[nextIndex];
|
||||
progress = writeActivationProgress("in_progress", next.id);
|
||||
currentIndex = nextIndex;
|
||||
completedCount = nextIndex;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if dismissed}
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
data-tour="activation-checklist-resume"
|
||||
role="status"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<ListChecks class="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-foreground">{i18n.t("activation.pausedTitle")}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{i18n.t("activation.pausedBody")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onclick={resume}>{i18n.t("activation.resume")}</Button>
|
||||
</div>
|
||||
{:else if visible}
|
||||
<Card data-tour="activation-checklist">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<ListChecks class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
{i18n.t("activation.title")}
|
||||
</CardTitle>
|
||||
<CardDescription data-testid="activation-value-prop">
|
||||
{i18n.t("activation.valueProp")}
|
||||
</CardDescription>
|
||||
<p
|
||||
class="pt-1 text-xs font-medium tracking-wide text-muted-foreground"
|
||||
data-testid="activation-core-path"
|
||||
>
|
||||
<span class="text-foreground">{i18n.t("activation.corePathLabel")}:</span>
|
||||
{" "}{i18n.t("activation.corePath")}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{i18n.t("activation.descriptionShort")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={i18n.t("activation.dismiss")}
|
||||
data-tour="activation-checklist-dismiss"
|
||||
onclick={dismiss}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-1.5 pt-2">
|
||||
<div class="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{i18n.t("activation.progress", { done: completedCount, total })}</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
<Progress value={pct} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="pt-0">
|
||||
<ol class="divide-y divide-border" aria-label={i18n.t("activation.stepsLabel")}>
|
||||
{#each steps as step, index (step.id)}
|
||||
{@const done = index < completedCount}
|
||||
{@const current = index === currentIndex && completedCount < total}
|
||||
{@const core = coreStepIds.has(step.id)}
|
||||
{#if showAllSteps || done || current}
|
||||
<li
|
||||
class="flex flex-col gap-3 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between
|
||||
{current ? 'rounded-md bg-muted/40 px-2 sm:px-3' : ''}"
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span
|
||||
class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium
|
||||
{done
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: current
|
||||
? 'border-foreground text-foreground'
|
||||
: 'border-border text-muted-foreground'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if done}
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
{:else if current}
|
||||
<span>{index + 1}</span>
|
||||
{:else}
|
||||
<Circle class="h-3 w-3 opacity-40" />
|
||||
{/if}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="text-sm font-medium {done
|
||||
? 'text-muted-foreground line-through'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{i18n.t(`activation.step.${step.id}.title`)}
|
||||
{#if step.optional && (current || showAllSteps)}
|
||||
<span class="ml-1 text-xs font-normal text-muted-foreground"
|
||||
>({i18n.t("common.optional")})</span
|
||||
>
|
||||
{:else if core && current}
|
||||
<span class="ml-1 text-xs font-normal text-muted-foreground"
|
||||
>({i18n.t("activation.corePathLabel")})</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{#if current || showAllSteps}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{i18n.t(`activation.step.${step.id}.body`)}
|
||||
</p>
|
||||
{#if step.id === "store-connect" && (current || showAllSteps)}
|
||||
<p class="mt-1 text-xs text-muted-foreground" data-tour="activation-store-optional-hint">
|
||||
{i18n.t("activation.storeWizardHint")}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if current}
|
||||
<div class="flex flex-wrap gap-2 sm:shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
class="bg-[#1e1b4b] hover:bg-[#1e1b4b]/90"
|
||||
data-tour="activation-checklist-continue"
|
||||
onclick={() => openStep(index)}
|
||||
>
|
||||
{i18n.t("activation.nextCta")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onclick={markCurrentDone}>
|
||||
{step.optional
|
||||
? i18n.t("activation.skipForNow")
|
||||
: i18n.t("activation.alreadyDone")}
|
||||
</Button>
|
||||
</div>
|
||||
{:else if !done && showAllSteps}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="sm:shrink-0"
|
||||
onclick={() => openStep(index)}
|
||||
>
|
||||
{i18n.t("common.open")}
|
||||
</Button>
|
||||
{/if}
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ol>
|
||||
{#if !showAllSteps && completedCount < total}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 text-sm font-medium text-link underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-expanded="false"
|
||||
onclick={() => (showAllSteps = true)}
|
||||
>
|
||||
{i18n.t("activation.showAllSteps", { total })}
|
||||
</button>
|
||||
{:else if showAllSteps && completedCount < total}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 text-sm font-medium text-link underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-expanded="true"
|
||||
onclick={() => (showAllSteps = false)}
|
||||
>
|
||||
{i18n.t("activation.showFewerSteps")}
|
||||
</button>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user