349 lines
12 KiB
Svelte
349 lines
12 KiB
Svelte
<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}
|