Files
homelable/frontend/src/utils/walkthrough.ts
T
Pouzor 8c56921375 feat(walkthrough): add Getting Started tour with first-run invite
Bespoke spotlight walkthrough (no new deps) that guides new users through the
main features. A version-stamped invite bubble offers the tour to new users and
re-offers it once to existing users after an upgrade; restartable from Settings.

- Multi-hole SVG-mask overlay keeps a ringed control and any open modal lit
- Steps: scan, scan history, inventory, nodes, edit, text/zone, grouping,
  style, imports, and a closing recap with a GitHub link
- Scan/history/inventory/import steps inject demo data (no backend calls) and
  are filtered out in standalone, which ends on a full-mode recap
- Persisted invite state via localStorage (utils/walkthrough.ts), live tour
  state via zustand (stores/walkthroughStore.ts)

ha-relevant: maybe
2026-07-19 00:40:08 +02:00

74 lines
2.7 KiB
TypeScript

/**
* Getting Started walkthrough — persisted invite state (localStorage).
*
* Mirrors the autosaveSettings.ts pattern: a `homelable.<name>` key plus a
* `window` CustomEvent for same-tab sync, so App and SettingsModal stay aligned
* without a global store.
*
* `seenVersion` is stamped with WALKTHROUGH_VERSION when the user finishes or
* dismisses the tour. Bump WALKTHROUGH_VERSION whenever new steps ship so
* existing users are re-offered the tour exactly once (they may have missed the
* new features). A brand-new user starts at seenVersion=null and is offered too.
*/
export const WALKTHROUGH_VERSION = 1
export type WalkthroughStatus = 'pending' | 'skipped' | 'completed'
export interface WalkthroughState {
seenVersion: number | null
status: WalkthroughStatus
}
const KEY = 'homelable.walkthrough'
const EVENT = 'homelable:walkthrough-changed'
export const DEFAULT_WALKTHROUGH_STATE: WalkthroughState = { seenVersion: null, status: 'pending' }
function isStatus(v: unknown): v is WalkthroughStatus {
return v === 'pending' || v === 'skipped' || v === 'completed'
}
export function readWalkthrough(): WalkthroughState {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return { ...DEFAULT_WALKTHROUGH_STATE }
const parsed = JSON.parse(raw) as Partial<WalkthroughState>
return {
seenVersion: typeof parsed.seenVersion === 'number' ? parsed.seenVersion : null,
status: isStatus(parsed.status) ? parsed.status : 'pending',
}
} catch {
return { ...DEFAULT_WALKTHROUGH_STATE }
}
}
export function writeWalkthrough(state: WalkthroughState): void {
try {
localStorage.setItem(KEY, JSON.stringify(state))
window.dispatchEvent(new CustomEvent(EVENT, { detail: state }))
} catch {
// ignore quota / SSR
}
}
export function subscribeWalkthrough(listener: (s: WalkthroughState) => void): () => void {
const handler = (e: Event) => listener((e as CustomEvent<WalkthroughState>).detail)
window.addEventListener(EVENT, handler)
return () => window.removeEventListener(EVENT, handler)
}
/** True when the invite should be offered (new user, or upgraded past the last seen version). */
export function shouldOfferWalkthrough(state: WalkthroughState = readWalkthrough()): boolean {
return state.seenVersion !== WALKTHROUGH_VERSION
}
/** Stamp the current version so the invite stops auto-showing until the next bump. */
export function markWalkthroughSeen(status: Exclude<WalkthroughStatus, 'pending'>): void {
writeWalkthrough({ seenVersion: WALKTHROUGH_VERSION, status })
}
/** Clear the stamp so the tour is offered again (used by "Restart" in Settings). */
export function resetWalkthrough(): void {
writeWalkthrough({ ...DEFAULT_WALKTHROUGH_STATE })
}