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,37 @@
|
||||
/** Split a comma-separated selector list and try each in listed order. */
|
||||
export function querySelectorPrefer(selector: string | undefined | null): HTMLElement | null {
|
||||
if (!selector || typeof document === "undefined") return null;
|
||||
const parts = selector
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
for (const part of parts) {
|
||||
try {
|
||||
const el = document.querySelector(part);
|
||||
if (el instanceof HTMLElement) return el;
|
||||
} catch {
|
||||
/* invalid selector fragment — skip */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when `target` is inside an element matching any fragment of `selector`. */
|
||||
export function matchesSelectorPrefer(
|
||||
target: Element,
|
||||
selector: string | undefined | null
|
||||
): boolean {
|
||||
if (!selector) return false;
|
||||
const parts = selector
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
for (const part of parts) {
|
||||
try {
|
||||
if (target.closest(part)) return true;
|
||||
} catch {
|
||||
/* invalid selector fragment — skip */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
TUTORIAL_STEPS,
|
||||
TUTORIAL_SECTIONS,
|
||||
stepIndexById,
|
||||
stepsForSection,
|
||||
sectionIndexForStep,
|
||||
firstStepIndexForSection
|
||||
} from "./steps";
|
||||
export { tutorial } from "./state.svelte";
|
||||
export { readTutorialProgress, writeTutorialProgress, TUTORIAL_STORAGE_KEY } from "./storage";
|
||||
export { querySelectorPrefer, matchesSelectorPrefer } from "./dom";
|
||||
export type {
|
||||
TutorialStep,
|
||||
TutorialProgress,
|
||||
TutorialStatus,
|
||||
TutorialPlacement,
|
||||
TutorialAdvance,
|
||||
TutorialSection,
|
||||
TutorialSectionId
|
||||
} from "./types";
|
||||
@@ -0,0 +1,326 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { trackEvent } from "$lib/analytics";
|
||||
import { querySelectorPrefer } from "./dom";
|
||||
import {
|
||||
TUTORIAL_SECTIONS,
|
||||
TUTORIAL_STEPS,
|
||||
firstStepIndexForSection,
|
||||
stepIndexById
|
||||
} from "./steps";
|
||||
import { readTutorialProgress, writeTutorialProgress } from "./storage";
|
||||
import type { TutorialProgress, TutorialSectionId, TutorialStep } from "./types";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function createTutorialController() {
|
||||
let active = $state(false);
|
||||
let stepIndex = $state(0);
|
||||
let progress = $state<TutorialProgress>(readTutorialProgress());
|
||||
let targetRect = $state<DOMRect | null>(null);
|
||||
let missingTarget = $state(false);
|
||||
let skipEpoch = 0;
|
||||
/** When true, overlay shows section picker before / instead of a step. */
|
||||
let sectionPickerOpen = $state(false);
|
||||
|
||||
const step = $derived(TUTORIAL_STEPS[stepIndex] ?? TUTORIAL_STEPS[0]);
|
||||
const total = TUTORIAL_STEPS.length;
|
||||
const sections = TUTORIAL_SECTIONS;
|
||||
const canResume = $derived(
|
||||
progress.status === "in_progress" && typeof progress.stepId === "string"
|
||||
);
|
||||
const isDone = $derived(progress.status === "completed");
|
||||
const wasSkipped = $derived(progress.status === "skipped");
|
||||
const canRestart = $derived(isDone || wasSkipped);
|
||||
|
||||
function persist(status: TutorialProgress["status"], stepId: string | null) {
|
||||
progress = writeTutorialProgress(status, stepId);
|
||||
}
|
||||
|
||||
function currentStep(): TutorialStep {
|
||||
return TUTORIAL_STEPS[stepIndex] ?? TUTORIAL_STEPS[0];
|
||||
}
|
||||
|
||||
async function ensureRoute(s: TutorialStep) {
|
||||
if (!s.route || s.softRoute || typeof window === "undefined") return;
|
||||
const path = window.location.pathname;
|
||||
const matches = s.routeExact
|
||||
? path === s.route
|
||||
: path === s.route || path.startsWith(s.route);
|
||||
if (!matches) {
|
||||
await goto(s.route);
|
||||
}
|
||||
}
|
||||
|
||||
function measureTarget() {
|
||||
const s = currentStep();
|
||||
if (!s.selector || typeof document === "undefined") {
|
||||
targetRect = null;
|
||||
missingTarget = false;
|
||||
return;
|
||||
}
|
||||
const el = querySelectorPrefer(s.selector);
|
||||
if (!el) {
|
||||
targetRect = null;
|
||||
missingTarget = true;
|
||||
return;
|
||||
}
|
||||
missingTarget = false;
|
||||
el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
||||
targetRect = el.getBoundingClientRect();
|
||||
}
|
||||
|
||||
async function waitForTarget(maxMs: number): Promise<boolean> {
|
||||
const s = currentStep();
|
||||
if (!s.selector) {
|
||||
missingTarget = false;
|
||||
targetRect = null;
|
||||
return true;
|
||||
}
|
||||
const deadline = Date.now() + maxMs;
|
||||
while (Date.now() < deadline) {
|
||||
measureTarget();
|
||||
if (!missingTarget) return true;
|
||||
await sleep(50);
|
||||
}
|
||||
measureTarget();
|
||||
return !missingTarget;
|
||||
}
|
||||
|
||||
async function showStep(index: number) {
|
||||
const epoch = ++skipEpoch;
|
||||
sectionPickerOpen = false;
|
||||
const clamped = Math.max(0, Math.min(index, TUTORIAL_STEPS.length - 1));
|
||||
stepIndex = clamped;
|
||||
const s = currentStep();
|
||||
active = true;
|
||||
persist("in_progress", s.id);
|
||||
trackEvent("tutorial_step_view", {
|
||||
step_id: s.id,
|
||||
section_id: s.sectionId,
|
||||
step_index: clamped
|
||||
});
|
||||
await ensureRoute(s);
|
||||
if (epoch !== skipEpoch) return;
|
||||
|
||||
// Soft-route steps may need longer for client navigation + data load.
|
||||
const waitMs = s.softRoute ? 1200 : 700;
|
||||
await waitForTarget(waitMs);
|
||||
if (epoch !== skipEpoch) return;
|
||||
|
||||
// Missing target is OK in demo mode — Continue still works.
|
||||
requestAnimationFrame(() => {
|
||||
if (epoch !== skipEpoch) return;
|
||||
measureTarget();
|
||||
setTimeout(() => {
|
||||
if (epoch === skipEpoch) measureTarget();
|
||||
}, 160);
|
||||
});
|
||||
}
|
||||
|
||||
async function start(fromBeginning = true) {
|
||||
sectionPickerOpen = false;
|
||||
const startIdx = fromBeginning ? 0 : stepIndexById(progress.stepId);
|
||||
if (fromBeginning) {
|
||||
trackEvent("tutorial_start", { entry: "beginning" });
|
||||
}
|
||||
await showStep(startIdx);
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
if (!canResume) {
|
||||
await start(true);
|
||||
return;
|
||||
}
|
||||
await showStep(stepIndexById(progress.stepId));
|
||||
}
|
||||
|
||||
/** Restart from step 0 (clears completed / skipped). */
|
||||
async function restart() {
|
||||
persist("in_progress", TUTORIAL_STEPS[0]?.id ?? null);
|
||||
trackEvent("tutorial_start", { entry: "beginning" });
|
||||
await showStep(0);
|
||||
}
|
||||
|
||||
async function goToStep(stepId: string) {
|
||||
await showStep(stepIndexById(stepId));
|
||||
}
|
||||
|
||||
async function startSection(sectionId: TutorialSectionId) {
|
||||
trackEvent("tutorial_start", { entry: "section", section_id: sectionId });
|
||||
await showStep(firstStepIndexForSection(sectionId));
|
||||
}
|
||||
|
||||
/** Open tour with section jump list (header “Browse sections”). */
|
||||
async function openSectionPicker() {
|
||||
skipEpoch += 1;
|
||||
sectionPickerOpen = true;
|
||||
active = true;
|
||||
targetRect = null;
|
||||
missingTarget = false;
|
||||
if (progress.status === "idle" || progress.status === "completed" || progress.status === "skipped") {
|
||||
persist("in_progress", progress.stepId ?? TUTORIAL_STEPS[0]?.id ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
function closeSectionPicker() {
|
||||
sectionPickerOpen = false;
|
||||
}
|
||||
|
||||
async function next() {
|
||||
const s = currentStep();
|
||||
if (s.id === "tour-done" || stepIndex >= TUTORIAL_STEPS.length - 1) {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
await showStep(stepIndex + 1);
|
||||
}
|
||||
|
||||
async function back() {
|
||||
if (sectionPickerOpen) {
|
||||
sectionPickerOpen = false;
|
||||
if (progress.stepId) {
|
||||
await showStep(stepIndexById(progress.stepId));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stepIndex <= 0) return;
|
||||
await showStep(stepIndex - 1);
|
||||
}
|
||||
|
||||
function skip() {
|
||||
const s = currentStep();
|
||||
skipEpoch += 1;
|
||||
active = false;
|
||||
sectionPickerOpen = false;
|
||||
targetRect = null;
|
||||
persist("skipped", s.id);
|
||||
trackEvent("tutorial_dismiss", { step_id: s.id, section_id: s.sectionId });
|
||||
}
|
||||
|
||||
function complete() {
|
||||
skipEpoch += 1;
|
||||
active = false;
|
||||
sectionPickerOpen = false;
|
||||
targetRect = null;
|
||||
persist("completed", "tour-done");
|
||||
trackEvent("tutorial_complete", { steps_completed: TUTORIAL_STEPS.length });
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
const s = currentStep();
|
||||
skipEpoch += 1;
|
||||
active = false;
|
||||
sectionPickerOpen = false;
|
||||
targetRect = null;
|
||||
if (progress.status === "in_progress") {
|
||||
persist("in_progress", s.id);
|
||||
}
|
||||
trackEvent("tutorial_dismiss", { step_id: s.id, section_id: s.sectionId });
|
||||
}
|
||||
|
||||
function advanceAfterAction() {
|
||||
const s = currentStep();
|
||||
if (s.id === "tour-done" || stepIndex >= TUTORIAL_STEPS.length - 1) {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
void showStep(stepIndex + 1);
|
||||
}, 220);
|
||||
}
|
||||
|
||||
/** Call when a highlighted control is clicked during a click-advance step. */
|
||||
function notifyTargetClicked() {
|
||||
const s = currentStep();
|
||||
if (!active || sectionPickerOpen || s.advanceOn !== "click") return;
|
||||
advanceAfterAction();
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional: pages may still report successful actions.
|
||||
* Demo tour advances via Continue; this is a no-op unless advanceOn is action
|
||||
* and the user is on that step (optional shortcut).
|
||||
*/
|
||||
function reportAction(stepId: string) {
|
||||
const idx = TUTORIAL_STEPS.findIndex((s) => s.id === stepId);
|
||||
const target = idx >= 0 ? TUTORIAL_STEPS[idx] : null;
|
||||
if (!target || target.advanceOn !== "action") return;
|
||||
|
||||
if (active) {
|
||||
if (sectionPickerOpen || currentStep().id !== stepId) return;
|
||||
} else if (progress.status !== "in_progress" || progress.stepId !== stepId) {
|
||||
return;
|
||||
} else {
|
||||
stepIndex = idx;
|
||||
}
|
||||
|
||||
advanceAfterAction();
|
||||
}
|
||||
|
||||
function hydrateFromStorage() {
|
||||
progress = readTutorialProgress();
|
||||
}
|
||||
|
||||
return {
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get stepIndex() {
|
||||
return stepIndex;
|
||||
},
|
||||
get step() {
|
||||
return step;
|
||||
},
|
||||
get total() {
|
||||
return total;
|
||||
},
|
||||
get sections() {
|
||||
return sections;
|
||||
},
|
||||
get progress() {
|
||||
return progress;
|
||||
},
|
||||
get targetRect() {
|
||||
return targetRect;
|
||||
},
|
||||
get missingTarget() {
|
||||
return missingTarget;
|
||||
},
|
||||
get sectionPickerOpen() {
|
||||
return sectionPickerOpen;
|
||||
},
|
||||
get canResume() {
|
||||
return canResume;
|
||||
},
|
||||
get isDone() {
|
||||
return isDone;
|
||||
},
|
||||
get wasSkipped() {
|
||||
return wasSkipped;
|
||||
},
|
||||
get canRestart() {
|
||||
return canRestart;
|
||||
},
|
||||
start,
|
||||
resume,
|
||||
restart,
|
||||
goToStep,
|
||||
startSection,
|
||||
openSectionPicker,
|
||||
closeSectionPicker,
|
||||
next,
|
||||
back,
|
||||
skip,
|
||||
complete,
|
||||
dismiss,
|
||||
measureTarget,
|
||||
notifyTargetClicked,
|
||||
reportAction,
|
||||
hydrateFromStorage,
|
||||
ensureRoute
|
||||
};
|
||||
}
|
||||
|
||||
export const tutorial = createTutorialController();
|
||||
@@ -0,0 +1,184 @@
|
||||
import { i18n } from "$lib/i18n";
|
||||
import type { TutorialSection, TutorialSectionId, TutorialStep } from "./types";
|
||||
|
||||
/**
|
||||
* Safe guided demo tour — overlay on the live dashboard.
|
||||
* Continue / Back always work; never require Enable / Save / Sync / Create.
|
||||
* Core path (aligned with activation + dashboard workflow):
|
||||
* Feeds → Map → Sync → Process → Export / Stores.
|
||||
* Title/body/tip/warning/missingHint/nextLabel live in `$lib/i18n`
|
||||
* as `tutorial.step.<id>.*` and section labels as `tutorial.section.<id>`.
|
||||
*/
|
||||
export const TUTORIAL_STEPS: TutorialStep[] = [
|
||||
{
|
||||
id: "welcome",
|
||||
sectionId: "welcome",
|
||||
route: "/dashboard",
|
||||
routeExact: true,
|
||||
selector: '[data-tour="dashboard-welcome"]',
|
||||
placement: "bottom",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "dashboard-overview",
|
||||
sectionId: "welcome",
|
||||
route: "/dashboard",
|
||||
routeExact: true,
|
||||
// Prefer the catalog workflow strip (feeds → map → sync → process).
|
||||
selector:
|
||||
'[data-tour="dashboard-workflow"],[data-tour="activation-core-path"],[data-tour="activation-checklist"],[data-tour="stats-products"]',
|
||||
placement: "bottom",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "enable-fields",
|
||||
sectionId: "catalog",
|
||||
route: "/standard-fields",
|
||||
routeExact: true,
|
||||
selector: '[data-tour="enable-recommended"]',
|
||||
placement: "bottom",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "catalog-depth",
|
||||
sectionId: "catalog",
|
||||
route: "/standard-fields",
|
||||
routeExact: true,
|
||||
selector:
|
||||
'[data-tour="nav-categories"],[data-tour="nav-attributes"],[data-tour="nav-standard-fields"]',
|
||||
placement: "right",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "connect-source",
|
||||
sectionId: "sources",
|
||||
route: "/feeds",
|
||||
routeExact: true,
|
||||
selector: '[data-tour="feeds-add"],[data-tour="feeds-empty-add"],[data-tour="nav-feeds"]',
|
||||
placement: "bottom",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "map",
|
||||
sectionId: "mapping",
|
||||
route: "/feeds",
|
||||
routeExact: true,
|
||||
selector:
|
||||
'[data-tour="feed-open-mapping"],[data-tour="feeds-activation-focus"],[data-tour="feeds-add"],[data-tour="feeds-empty-add"]',
|
||||
placement: "left",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "sync-sample",
|
||||
sectionId: "mapping",
|
||||
route: "/feeds",
|
||||
routeExact: true,
|
||||
selector:
|
||||
'[data-tour="feed-sync-now"],[data-tour="feed-sync-process-sample"],[data-tour="feed-open-mapping"],[data-tour="feeds-activation-focus"]',
|
||||
placement: "top",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "process",
|
||||
sectionId: "products",
|
||||
route: "/products",
|
||||
selector: '[data-tour="products-table"],[data-tour="nav-products"],[data-tour="start-processing"]',
|
||||
placement: "top",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "processing-hub",
|
||||
sectionId: "products",
|
||||
route: "/processing",
|
||||
routeExact: true,
|
||||
selector:
|
||||
'[data-tour="processing-page"],[data-tour="nav-processing"],[data-tour="start-processing"]',
|
||||
placement: "bottom",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "stores-hub",
|
||||
sectionId: "export",
|
||||
route: "/stores",
|
||||
routeExact: true,
|
||||
selector: '[data-tour="store-hub"],[data-tour="nav-stores"]',
|
||||
placement: "bottom",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
sectionId: "export",
|
||||
route: "/export-feeds",
|
||||
routeExact: true,
|
||||
selector: '[data-tour="export-create"],[data-tour="nav-export-feeds"]',
|
||||
placement: "bottom",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "billing-hub",
|
||||
sectionId: "export",
|
||||
route: "/billing",
|
||||
routeExact: true,
|
||||
selector: '[data-tour="billing-page"],[data-tour="nav-billing"],[data-tour="stats-credits"]',
|
||||
placement: "bottom",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "grow-hub",
|
||||
sectionId: "grow",
|
||||
route: "/campaigns",
|
||||
routeExact: true,
|
||||
selector: '[data-tour="campaigns-page"],[data-tour="nav-campaigns"]',
|
||||
placement: "bottom",
|
||||
advanceOn: "next"
|
||||
},
|
||||
{
|
||||
id: "tour-done",
|
||||
sectionId: "done",
|
||||
route: "/dashboard",
|
||||
routeExact: true,
|
||||
selector: '[data-tour="start-tutorial"],[data-tour="dashboard-welcome"],[data-tour="dashboard-workflow"]',
|
||||
placement: "bottom",
|
||||
advanceOn: "next"
|
||||
}
|
||||
];
|
||||
|
||||
export const TUTORIAL_SECTIONS: TutorialSection[] = (() => {
|
||||
const seen = new Set<TutorialSectionId>();
|
||||
const out: TutorialSection[] = [];
|
||||
for (const step of TUTORIAL_STEPS) {
|
||||
if (seen.has(step.sectionId)) continue;
|
||||
seen.add(step.sectionId);
|
||||
const sectionId = step.sectionId;
|
||||
out.push({
|
||||
id: sectionId,
|
||||
get label() {
|
||||
return i18n.t(`tutorial.section.${sectionId}`);
|
||||
},
|
||||
startStepId: step.id
|
||||
});
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
export function stepIndexById(id: string | null | undefined): number {
|
||||
if (!id) return 0;
|
||||
const idx = TUTORIAL_STEPS.findIndex((s) => s.id === id);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
export function stepsForSection(sectionId: TutorialSectionId): TutorialStep[] {
|
||||
return TUTORIAL_STEPS.filter((s) => s.sectionId === sectionId);
|
||||
}
|
||||
|
||||
export function sectionIndexForStep(stepId: string | null | undefined): number {
|
||||
const step = TUTORIAL_STEPS[stepIndexById(stepId)];
|
||||
if (!step) return 0;
|
||||
const idx = TUTORIAL_SECTIONS.findIndex((s) => s.id === step.sectionId);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
export function firstStepIndexForSection(sectionId: TutorialSectionId): number {
|
||||
const idx = TUTORIAL_STEPS.findIndex((s) => s.sectionId === sectionId);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { TutorialProgress, TutorialStatus } from "./types";
|
||||
|
||||
export const TUTORIAL_STORAGE_KEY = "descrybe.tutorial.v2";
|
||||
/** Bumped when tour step ids change so stale in-progress cursors reset. */
|
||||
export const TUTORIAL_PROGRESS_VERSION = 4;
|
||||
|
||||
const idleProgress = (): TutorialProgress => ({
|
||||
version: TUTORIAL_PROGRESS_VERSION,
|
||||
status: "idle",
|
||||
stepId: null,
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
export function readTutorialProgress(): TutorialProgress {
|
||||
if (typeof localStorage === "undefined") return idleProgress();
|
||||
try {
|
||||
const raw = localStorage.getItem(TUTORIAL_STORAGE_KEY);
|
||||
if (!raw) return idleProgress();
|
||||
const parsed = JSON.parse(raw) as Partial<TutorialProgress>;
|
||||
if (parsed.version !== TUTORIAL_PROGRESS_VERSION) return idleProgress();
|
||||
const status = parsed.status;
|
||||
if (
|
||||
status !== "idle" &&
|
||||
status !== "in_progress" &&
|
||||
status !== "completed" &&
|
||||
status !== "skipped"
|
||||
) {
|
||||
return idleProgress();
|
||||
}
|
||||
return {
|
||||
version: TUTORIAL_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 writeTutorialProgress(
|
||||
status: TutorialStatus,
|
||||
stepId: string | null
|
||||
): TutorialProgress {
|
||||
const next: TutorialProgress = {
|
||||
version: TUTORIAL_PROGRESS_VERSION,
|
||||
status,
|
||||
stepId,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
localStorage.setItem(TUTORIAL_STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export type TutorialPlacement = "top" | "bottom" | "left" | "right" | "center";
|
||||
|
||||
/**
|
||||
* next — informational; Continue / Done always available (demo default).
|
||||
* click — optional: clicking the highlight advances, but Continue still works.
|
||||
* action — legacy: page may call reportAction(stepId); Continue still works in demo mode.
|
||||
*/
|
||||
export type TutorialAdvance = "next" | "click" | "action";
|
||||
|
||||
export type TutorialStatus = "idle" | "in_progress" | "completed" | "skipped";
|
||||
|
||||
export type TutorialSectionId =
|
||||
| "welcome"
|
||||
| "catalog"
|
||||
| "sources"
|
||||
| "mapping"
|
||||
| "products"
|
||||
| "export"
|
||||
| "grow"
|
||||
| "done";
|
||||
|
||||
export type TutorialSection = {
|
||||
id: TutorialSectionId;
|
||||
label: string;
|
||||
/** First step id in this section. */
|
||||
startStepId: string;
|
||||
};
|
||||
|
||||
export type TutorialStep = {
|
||||
id: string;
|
||||
/** Title/body/tip/warning/missingHint/nextLabel live in i18n as `tutorial.step.<id>.*`. */
|
||||
sectionId: TutorialSectionId;
|
||||
/** Route to ensure before highlighting (pathname prefix match unless exact). */
|
||||
route?: string;
|
||||
/** Exact pathname match when true. */
|
||||
routeExact?: boolean;
|
||||
/**
|
||||
* When true, do not navigate — wait until the user reaches a matching screen
|
||||
* (e.g. feed mapping URLs with unknown ids).
|
||||
*/
|
||||
softRoute?: boolean;
|
||||
/**
|
||||
* CSS selector(s). Prefer `[data-tour="…"]`.
|
||||
* Comma-separated lists are tried in listed order (first match wins),
|
||||
* not document order.
|
||||
*/
|
||||
selector?: string;
|
||||
placement?: TutorialPlacement;
|
||||
/** How the step advances. Default next. */
|
||||
advanceOn?: TutorialAdvance;
|
||||
};
|
||||
|
||||
export type TutorialProgress = {
|
||||
version: number;
|
||||
status: TutorialStatus;
|
||||
stepId: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
Reference in New Issue
Block a user