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
This commit is contained in:
Pouzor
2026-07-19 00:40:08 +02:00
parent bf8551015c
commit 8c56921375
20 changed files with 1004 additions and 14 deletions
@@ -25,6 +25,8 @@ interface PendingDevicesModalProps {
onClose: () => void
highlightId?: string
initialStatus?: 'pending' | 'hidden'
/** Getting Started tour: render these canned devices and skip the backend fetch. */
demoDevices?: PendingDevice[]
}
const PORT_COLORS: Record<number, string> = {
@@ -113,7 +115,7 @@ function injectAutoEdges(edges: AutoEdge[] | undefined) {
}))
}
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices }: PendingDevicesModalProps) {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null)
@@ -136,6 +138,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
const load = useCallback(async () => {
// Tour/demo mode: show injected devices, never touch the backend.
if (demoDevices) { setDevices(statusFilter === 'pending' ? demoDevices : []); return }
setLoading(true)
try {
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
@@ -145,7 +149,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
} finally {
setLoading(false)
}
}, [statusFilter])
}, [statusFilter, demoDevices])
useEffect(() => { if (open) load() }, [open, load])
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])