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,19 @@
|
||||
export {
|
||||
ACTIVATION_STEPS,
|
||||
ACTIVATION_STEP_DEFS,
|
||||
visibleActivationSteps,
|
||||
activationStepIndexById,
|
||||
activationIndexFromTutorialStep,
|
||||
activationIndexFromWorkspace,
|
||||
type ActivationStep,
|
||||
type ActivationWorkspaceEvidence
|
||||
} from "./steps";
|
||||
export {
|
||||
ACTIVATION_STORAGE_KEY,
|
||||
ACTIVATION_PROGRESS_VERSION,
|
||||
readActivationProgress,
|
||||
writeActivationProgress,
|
||||
resolveActivationCursor,
|
||||
type ActivationProgress,
|
||||
type ActivationStatus
|
||||
} from "./storage";
|
||||
@@ -0,0 +1,134 @@
|
||||
import { i18n } from "$lib/i18n";
|
||||
import {
|
||||
activationIndexFromWorkspace as indexFromWorkspace,
|
||||
visibleActivationSteps as filterVisible,
|
||||
type ActivationWorkspaceEvidence
|
||||
} from "./workspace";
|
||||
|
||||
export type { ActivationWorkspaceEvidence } from "./workspace";
|
||||
|
||||
/** Checklist step shown on the dashboard (titles/bodies from i18n). */
|
||||
export type ActivationStep = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
href: string;
|
||||
optional?: boolean;
|
||||
feature?: string;
|
||||
tutorialDoneIds: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Destination / plan-gate definition without resolved copy.
|
||||
* Titles/bodies resolve via i18n (`activation.step.<id>.title|body`).
|
||||
*/
|
||||
export type ActivationStepDef = {
|
||||
id: string;
|
||||
href: string;
|
||||
optional?: boolean;
|
||||
feature?: string;
|
||||
tutorialDoneIds: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* First-value path on the dashboard checklist.
|
||||
* Value path (plain language): catalog in → enrich/process → export/push to stores.
|
||||
* Checklist sequence: enable-fields → Feeds → Map → Sync → Process → optional store-connect → export.
|
||||
* Aligned with tutorial/steps.ts core path (stores-hub sits with export after process).
|
||||
* tutorialDoneIds = tour steps that mean this checklist step is already past.
|
||||
* Deep links: add=1 opens the connect dialog; focus=map|sync highlights the next action on Feeds.
|
||||
* store-connect Continue opens the guided wizard when stores.hub is allowed.
|
||||
* ACTIVATION_STEP_DEFS (no i18n) is for destination / plan-gate unit tests.
|
||||
*/
|
||||
export const ACTIVATION_STEP_DEFS: ActivationStepDef[] = [
|
||||
{
|
||||
id: "enable-fields",
|
||||
href: "/standard-fields",
|
||||
tutorialDoneIds: ["connect-source", "map", "sync-sample", "process", "store-connect", "export", "tour-done", "done"]
|
||||
},
|
||||
{
|
||||
id: "connect-source",
|
||||
href: "/feeds?add=1",
|
||||
tutorialDoneIds: ["map", "sync-sample", "process", "store-connect", "export", "tour-done", "done"]
|
||||
},
|
||||
{
|
||||
id: "map",
|
||||
href: "/feeds?focus=map",
|
||||
tutorialDoneIds: ["sync-sample", "process", "store-connect", "export", "tour-done", "done"]
|
||||
},
|
||||
{
|
||||
id: "sync-sample",
|
||||
href: "/feeds?focus=sync",
|
||||
tutorialDoneIds: ["process", "store-connect", "export", "tour-done", "done"]
|
||||
},
|
||||
{
|
||||
id: "process",
|
||||
href: "/products?type=raw&status=unprocessed",
|
||||
tutorialDoneIds: ["store-connect", "stores-hub", "export", "tour-done", "done"]
|
||||
},
|
||||
{
|
||||
id: "store-connect",
|
||||
href: "/stores/wizard",
|
||||
optional: true,
|
||||
feature: "stores.hub",
|
||||
tutorialDoneIds: ["export", "tour-done", "done"]
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
href: "/export-feeds",
|
||||
tutorialDoneIds: ["tour-done", "done"]
|
||||
}
|
||||
];
|
||||
|
||||
function activationStep(def: ActivationStepDef): ActivationStep {
|
||||
return {
|
||||
...def,
|
||||
title: i18n.t(`activation.step.${def.id}.title`),
|
||||
body: i18n.t(`activation.step.${def.id}.body`)
|
||||
};
|
||||
}
|
||||
|
||||
export const ACTIVATION_STEPS: ActivationStep[] = ACTIVATION_STEP_DEFS.map(activationStep);
|
||||
|
||||
/** Steps visible for the current plan (omit feature-gated steps the plan denies). */
|
||||
export function visibleActivationSteps(
|
||||
can: (featureKey: string) => boolean = () => true
|
||||
): ActivationStep[] {
|
||||
return filterVisible(ACTIVATION_STEPS, can);
|
||||
}
|
||||
|
||||
export function activationStepIndexById(
|
||||
id: string | null | undefined,
|
||||
steps: ActivationStep[] = ACTIVATION_STEPS
|
||||
): number {
|
||||
if (!id) return 0;
|
||||
const idx = steps.findIndex((s) => s.id === id);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
/** Furthest activation index completed given a tutorial step id (exclusive of current tour focus). */
|
||||
export function activationIndexFromTutorialStep(
|
||||
tutorialStepId: string | null | undefined,
|
||||
steps: ActivationStep[] = ACTIVATION_STEPS
|
||||
): number {
|
||||
if (!tutorialStepId) return 0;
|
||||
// Current tour focus (aligned id) is not completed yet.
|
||||
if (steps.some((s) => s.id === tutorialStepId)) {
|
||||
return activationStepIndexById(tutorialStepId, steps);
|
||||
}
|
||||
let furthest = 0;
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (step.tutorialDoneIds.includes(tutorialStepId)) {
|
||||
furthest = i + 1;
|
||||
}
|
||||
}
|
||||
return Math.min(furthest, steps.length);
|
||||
}
|
||||
|
||||
export function activationIndexFromWorkspace(
|
||||
evidence: ActivationWorkspaceEvidence | null | undefined,
|
||||
steps: ActivationStep[] = ACTIVATION_STEPS
|
||||
): number {
|
||||
return indexFromWorkspace(evidence, steps);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { TutorialProgress, TutorialStatus } from "$lib/tutorial/types";
|
||||
import {
|
||||
ACTIVATION_STEPS,
|
||||
activationIndexFromWorkspace,
|
||||
type ActivationStep,
|
||||
type ActivationWorkspaceEvidence
|
||||
} from "./steps";
|
||||
|
||||
/** Same progress shape as tutorial/storage.ts (`TutorialProgress`). */
|
||||
export type ActivationProgress = TutorialProgress;
|
||||
export type ActivationStatus = TutorialStatus;
|
||||
|
||||
export const ACTIVATION_STORAGE_KEY = "descrybe.activation.v1";
|
||||
export const ACTIVATION_PROGRESS_VERSION = 1;
|
||||
|
||||
const idleProgress = (): ActivationProgress => ({
|
||||
version: ACTIVATION_PROGRESS_VERSION,
|
||||
status: "idle",
|
||||
stepId: null,
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
export function readActivationProgress(): ActivationProgress {
|
||||
if (typeof localStorage === "undefined") return idleProgress();
|
||||
try {
|
||||
const raw = localStorage.getItem(ACTIVATION_STORAGE_KEY);
|
||||
if (!raw) return idleProgress();
|
||||
const parsed = JSON.parse(raw) as Partial<ActivationProgress>;
|
||||
if (parsed.version !== ACTIVATION_PROGRESS_VERSION) return idleProgress();
|
||||
const status = parsed.status;
|
||||
if (
|
||||
status !== "idle" &&
|
||||
status !== "in_progress" &&
|
||||
status !== "completed" &&
|
||||
status !== "skipped"
|
||||
) {
|
||||
return idleProgress();
|
||||
}
|
||||
return {
|
||||
version: ACTIVATION_PROGRESS_VERSION,
|
||||
status,
|
||||
stepId: typeof parsed.stepId === "string" ? parsed.stepId : null,
|
||||
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString()
|
||||
};
|
||||
} catch {
|
||||
return idleProgress();
|
||||
}
|
||||
}
|
||||
|
||||
export function writeActivationProgress(
|
||||
status: ActivationStatus,
|
||||
stepId: string | null
|
||||
): ActivationProgress {
|
||||
const next: ActivationProgress = {
|
||||
version: ACTIVATION_PROGRESS_VERSION,
|
||||
status,
|
||||
stepId,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
localStorage.setItem(ACTIVATION_STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the checklist cursor, preferring activation storage and advancing from
|
||||
* live workspace evidence when further along. Pass `steps` from
|
||||
* `visibleActivationSteps` so plan-gated optional steps stay out of the cursor.
|
||||
*/
|
||||
export function resolveActivationCursor(
|
||||
progress: ActivationProgress = readActivationProgress(),
|
||||
workspace?: ActivationWorkspaceEvidence | null,
|
||||
steps: ActivationStep[] = ACTIVATION_STEPS
|
||||
): {
|
||||
progress: ActivationProgress;
|
||||
currentIndex: number;
|
||||
completedCount: number;
|
||||
} {
|
||||
if (progress.status === "completed") {
|
||||
return {
|
||||
progress,
|
||||
currentIndex: steps.length,
|
||||
completedCount: steps.length
|
||||
};
|
||||
}
|
||||
|
||||
if (progress.status === "skipped") {
|
||||
const storedIdx = progress.stepId ? steps.findIndex((s) => s.id === progress.stepId) : -1;
|
||||
const index = storedIdx >= 0 ? storedIdx : 0;
|
||||
const fromWorkspace = activationIndexFromWorkspace(workspace, steps);
|
||||
const completedCount = Math.max(index, fromWorkspace);
|
||||
return { progress, currentIndex: index, completedCount };
|
||||
}
|
||||
|
||||
const storedIdx =
|
||||
progress.status === "idle" || !progress.stepId
|
||||
? -1
|
||||
: steps.findIndex((s) => s.id === progress.stepId);
|
||||
let index = storedIdx >= 0 ? storedIdx : 0;
|
||||
|
||||
// Demo tour progress must not fake checklist completion — workspace evidence only.
|
||||
const fromWorkspace = activationIndexFromWorkspace(workspace, steps);
|
||||
if (fromWorkspace > index) index = fromWorkspace;
|
||||
// Gated-out step id (e.g. store-connect when stores.hub denied) → use workspace cursor.
|
||||
if (progress.stepId && storedIdx < 0 && fromWorkspace > 0) {
|
||||
index = fromWorkspace;
|
||||
}
|
||||
|
||||
if (index >= steps.length) {
|
||||
const completed = writeActivationProgress("completed", steps.at(-1)?.id ?? "export");
|
||||
return {
|
||||
progress: completed,
|
||||
currentIndex: steps.length,
|
||||
completedCount: steps.length
|
||||
};
|
||||
}
|
||||
|
||||
const stepId = steps[index]?.id ?? steps[0]?.id ?? null;
|
||||
if (
|
||||
stepId &&
|
||||
(progress.status === "idle" ||
|
||||
(progress.status === "in_progress" && progress.stepId !== stepId))
|
||||
) {
|
||||
const next = writeActivationProgress("in_progress", stepId);
|
||||
return { progress: next, currentIndex: index, completedCount: index };
|
||||
}
|
||||
|
||||
return { progress, currentIndex: index, completedCount: index };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/** Live workspace signals used to advance the checklist without relying only on localStorage. */
|
||||
export type ActivationWorkspaceEvidence = {
|
||||
hasEnabledFields?: boolean;
|
||||
hasSource?: boolean;
|
||||
hasMapping?: boolean;
|
||||
hasSyncedSample?: boolean;
|
||||
hasProcessed?: boolean;
|
||||
hasStoreConnect?: boolean;
|
||||
hasExport?: boolean;
|
||||
};
|
||||
|
||||
/** Minimal step shape for gating / workspace cursor (no i18n). */
|
||||
export type ActivationStepRef = {
|
||||
id: string;
|
||||
optional?: boolean;
|
||||
feature?: string;
|
||||
};
|
||||
|
||||
/** Steps visible for the current plan (omit feature-gated steps the plan denies). */
|
||||
export function visibleActivationSteps<T extends ActivationStepRef>(
|
||||
steps: T[],
|
||||
can: (featureKey: string) => boolean = () => true
|
||||
): T[] {
|
||||
return steps.filter((s) => !s.feature || can(s.feature));
|
||||
}
|
||||
|
||||
const evidenceForStep: Record<
|
||||
string,
|
||||
(evidence: ActivationWorkspaceEvidence) => boolean | undefined
|
||||
> = {
|
||||
"enable-fields": (e) => e.hasEnabledFields,
|
||||
"connect-source": (e) => e.hasSource,
|
||||
map: (e) => e.hasMapping,
|
||||
"sync-sample": (e) => e.hasSyncedSample,
|
||||
process: (e) => e.hasProcessed,
|
||||
"store-connect": (e) => e.hasStoreConnect,
|
||||
export: (e) => e.hasExport
|
||||
};
|
||||
|
||||
function stepEvidenceDone(step: ActivationStepRef, evidence: ActivationWorkspaceEvidence): boolean {
|
||||
const getter = evidenceForStep[step.id];
|
||||
return getter ? Boolean(getter(evidence)) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contiguous completed-step count from workspace state (stop at first incomplete).
|
||||
* Optional steps do not block later required evidence (e.g. export without a store).
|
||||
*/
|
||||
export function activationIndexFromWorkspace(
|
||||
evidence: ActivationWorkspaceEvidence | null | undefined,
|
||||
steps: ActivationStepRef[]
|
||||
): number {
|
||||
if (!evidence) return 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (stepEvidenceDone(step, evidence)) {
|
||||
count += 1;
|
||||
continue;
|
||||
}
|
||||
if (step.optional) {
|
||||
const laterRequiredDone = steps
|
||||
.slice(i + 1)
|
||||
.some((s) => !s.optional && stepEvidenceDone(s, evidence));
|
||||
if (laterRequiredDone) {
|
||||
count += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return Math.min(count, steps.length);
|
||||
}
|
||||
Reference in New Issue
Block a user