Files
descrybe/apps/web/src/lib/components/ActivationChecklist.svelte
T

349 lines
12 KiB
Svelte
Raw Normal View History

<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}