From 8c56921375f7baa0b7ee36b2f9866cdef0425867 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Jul 2026 00:40:08 +0200 Subject: [PATCH] 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 --- frontend/src/App.tsx | 66 +++++- .../components/modals/PendingDevicesModal.tsx | 8 +- .../components/modals/ScanHistoryModal.tsx | 9 +- .../src/components/modals/SettingsModal.tsx | 13 ++ .../src/components/panels/DetailPanel.tsx | 1 + frontend/src/components/panels/Sidebar.tsx | 19 +- frontend/src/components/panels/Toolbar.tsx | 2 +- .../stores/__tests__/walkthroughStore.test.ts | 58 +++++ frontend/src/stores/walkthroughStore.ts | 48 ++++ .../src/utils/__tests__/walkthrough.test.ts | 66 ++++++ frontend/src/utils/walkthrough.ts | 73 ++++++ .../src/walkthrough/WalkthroughInvite.tsx | 72 ++++++ .../src/walkthrough/WalkthroughOverlay.tsx | 219 ++++++++++++++++++ .../__tests__/WalkthroughInvite.test.tsx | 46 ++++ .../__tests__/WalkthroughOverlay.test.tsx | 46 ++++ .../src/walkthrough/__tests__/steps.test.ts | 27 +++ frontend/src/walkthrough/actions.ts | 30 +++ frontend/src/walkthrough/demoTourData.ts | 65 ++++++ frontend/src/walkthrough/steps.ts | 121 ++++++++++ frontend/src/walkthrough/walkthrough.css | 29 +++ 20 files changed, 1004 insertions(+), 14 deletions(-) create mode 100644 frontend/src/stores/__tests__/walkthroughStore.test.ts create mode 100644 frontend/src/stores/walkthroughStore.ts create mode 100644 frontend/src/utils/__tests__/walkthrough.test.ts create mode 100644 frontend/src/utils/walkthrough.ts create mode 100644 frontend/src/walkthrough/WalkthroughInvite.tsx create mode 100644 frontend/src/walkthrough/WalkthroughOverlay.tsx create mode 100644 frontend/src/walkthrough/__tests__/WalkthroughInvite.test.tsx create mode 100644 frontend/src/walkthrough/__tests__/WalkthroughOverlay.test.tsx create mode 100644 frontend/src/walkthrough/__tests__/steps.test.ts create mode 100644 frontend/src/walkthrough/actions.ts create mode 100644 frontend/src/walkthrough/demoTourData.ts create mode 100644 frontend/src/walkthrough/steps.ts create mode 100644 frontend/src/walkthrough/walkthrough.css diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5395ae8..c0fda1c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback, useRef, useState } from 'react' +import { useEffect, useCallback, useMemo, useRef, useState } from 'react' import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react' import { type Node } from '@xyflow/react' import { applyDagreLayout } from '@/utils/layout' @@ -46,6 +46,10 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client' import * as standaloneStorage from '@/utils/standaloneStorage' import { demoNodes, demoEdges } from '@/utils/demoData' import { decideCanvasLoad, isNewUserCanvas } from '@/utils/canvasLoadDecision' +import { WalkthroughActionsProvider, type WalkthroughActionApi } from '@/walkthrough/actions' +import { WalkthroughInvite } from '@/walkthrough/WalkthroughInvite' +import { WalkthroughOverlay } from '@/walkthrough/WalkthroughOverlay' +import { DEMO_SCAN_RUNS, DEMO_PENDING_DEVICES } from '@/walkthrough/demoTourData' import { useStatusPolling } from '@/hooks/useStatusPolling' import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types' import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' @@ -82,6 +86,11 @@ export default function App() { // entry signal for the upcoming Getting Started walkthrough. const [isNewUser, setIsNewUser] = useState(false) + // Getting Started tour: when true, the corresponding modal renders injected demo + // data instead of hitting the backend. + const [tourScanHistoryDemo, setTourScanHistoryDemo] = useState(false) + const [tourInventoryDemo, setTourInventoryDemo] = useState(false) + const [themeModalOpen, setThemeModalOpen] = useState(false) const [styleEditorType, setStyleEditorType] = useState(null) const [searchOpen, setSearchOpen] = useState(false) @@ -285,6 +294,55 @@ export default function App() { void loadDesignsAndCanvasRef.current() }, []) + // Bridge the Getting Started tour steps to the App's modal controls. The overlay + // calls closeAll() before each step, then the step's action to open the target. + const walkthroughActions = useMemo(() => ({ + closeAll: () => { + setScanConfigOpen(false) + setScanHistoryOpen(false) + setTourScanHistoryDemo(false) + setPendingModalOpen(false) + setTourInventoryDemo(false) + setEditNodeId(null) + setThemeModalOpen(false) + setZigbeeImportOpen(false) + // Clear any tour-driven multi-selection so the DetailPanel closes. + useCanvasStore.setState((s) => ({ + nodes: s.nodes.map((n) => (n.selected ? { ...n, selected: false } : n)), + selectedNodeIds: [], + selectedNodeId: null, + })) + }, + openScanConfig: () => setScanConfigOpen(true), + openScanHistoryDemo: () => { + setTourScanHistoryDemo(true) + setScanHistoryOpen(true) + }, + openInventoryDemo: () => { + setTourInventoryDemo(true) + openPendingModal(undefined, 'pending') + }, + editFirstNode: () => { + const first = useCanvasStore.getState().nodes.find((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') + if (first) setEditNodeId(first.id) + }, + selectTwoNodes: () => { + const ids = useCanvasStore.getState().nodes + .filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') + .slice(0, 2) + .map((n) => n.id) + if (ids.length < 2) return + const set = new Set(ids) + useCanvasStore.setState((s) => ({ + nodes: s.nodes.map((n) => ({ ...n, selected: set.has(n.id) })), + selectedNodeIds: ids, + selectedNodeId: null, + })) + }, + openStyle: () => setThemeModalOpen(true), + openZigbeeImport: () => setZigbeeImportOpen(true), + }), [openPendingModal]) + // Load designs + canvas on auth (or immediately in standalone mode, which has // no auth gate). useEffect(() => { @@ -867,6 +925,7 @@ export default function App() { if (!STANDALONE && !isAuthenticated) return return ( + {/* data-new-user marks a first-time (demo) canvas — the hook the upcoming @@ -1036,6 +1095,7 @@ export default function App() { setScanHistoryOpen(false)} + demoRuns={tourScanHistoryDemo ? DEMO_SCAN_RUNS : undefined} /> )} @@ -1160,6 +1220,7 @@ export default function App() { onClose={() => setPendingModalOpen(false)} highlightId={pendingHighlightId} initialStatus={pendingModalStatus} + demoDevices={tourInventoryDemo ? DEMO_PENDING_DEVICES : undefined} /> + + + ) } diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx index 1ea9fd2..8c2da0b 100644 --- a/frontend/src/components/modals/PendingDevicesModal.tsx +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -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 = { @@ -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([]) const [loading, setLoading] = useState(false) const [selected, setSelected] = useState(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]) diff --git a/frontend/src/components/modals/ScanHistoryModal.tsx b/frontend/src/components/modals/ScanHistoryModal.tsx index 8dabc09..584e12a 100644 --- a/frontend/src/components/modals/ScanHistoryModal.tsx +++ b/frontend/src/components/modals/ScanHistoryModal.tsx @@ -20,6 +20,8 @@ export interface ScanRun { interface ScanHistoryModalProps { open: boolean onClose: () => void + /** Getting Started tour: render these canned runs and skip the backend fetch. */ + demoRuns?: ScanRun[] } type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox' @@ -85,7 +87,7 @@ function runDuration(r: ScanRun, now: number): string { return formatDuration(end - start) } -export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) { +export function ScanHistoryModal({ open, onClose, demoRuns }: ScanHistoryModalProps) { const [runs, setRuns] = useState([]) const [loading, setLoading] = useState(false) const [stopping, setStopping] = useState(null) @@ -95,6 +97,8 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) { const prevRunsRef = useRef([]) const load = useCallback(async () => { + // Tour/demo mode: show injected runs, never touch the backend. + if (demoRuns) { setRuns(demoRuns); prevRunsRef.current = demoRuns; return } setLoading(true) try { const res = await scanApi.runs() @@ -127,7 +131,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) { } finally { setLoading(false) } - }, []) + }, [demoRuns]) // Load when opened; reset prior-state tracker so we don't replay old transitions useEffect(() => { @@ -155,6 +159,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) { }, [open, runs]) const handleStop = async (runId: string) => { + if (demoRuns) return // tour mode — no backend setStopping(runId) try { await scanApi.stop(runId) diff --git a/frontend/src/components/modals/SettingsModal.tsx b/frontend/src/components/modals/SettingsModal.tsx index 58f98a5..55ccbe9 100644 --- a/frontend/src/components/modals/SettingsModal.tsx +++ b/frontend/src/components/modals/SettingsModal.tsx @@ -11,6 +11,7 @@ import { type ZwaveConfigData, } from '@/api/client' import { useCanvasStore } from '@/stores/canvasStore' +import { useWalkthroughStore } from '@/stores/walkthroughStore' import { toast } from 'sonner' import { type AlignmentSettings, @@ -401,6 +402,18 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { Saves silently after this many seconds with no changes. Manual Ctrl+S still works.

+ +
+ Getting started tour + +
diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 2a65e19..68e88e8 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -516,6 +516,7 @@ function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGro ) : ( - +
+
+ +
+
+

New here? Take the tour

+

+ A 2-minute walkthrough of scanning, devices, and building your canvas. +

+
+
+
+ + +
+ + ) +} diff --git a/frontend/src/walkthrough/WalkthroughOverlay.tsx b/frontend/src/walkthrough/WalkthroughOverlay.tsx new file mode 100644 index 0000000..7d08002 --- /dev/null +++ b/frontend/src/walkthrough/WalkthroughOverlay.tsx @@ -0,0 +1,219 @@ +import { useEffect, useMemo, useState, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { ArrowLeft, ArrowRight, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useWalkthroughStore } from '@/stores/walkthroughStore' +import { useWalkthroughActions } from './actions' +import { getSteps, type StepPlacement } from './steps' +import './walkthrough.css' + +const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' + +const CARD_W = 320 +const CARD_H_EST = 210 +const GAP = 16 +const PAD = 6 // spotlight padding around the target + +/** Coach-card position from the target rect + requested placement. */ +function cardPosition(rect: DOMRect | null, placement: StepPlacement): { left: number; top: number } { + const vw = window.innerWidth + const vh = window.innerHeight + const clampL = (l: number) => Math.max(8, Math.min(l, vw - CARD_W - 8)) + const clampT = (t: number) => Math.max(8, Math.min(t, vh - CARD_H_EST - 8)) + if (!rect) return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) } + + let side = placement + if (side === 'auto') { + side = rect.right + GAP + CARD_W < vw ? 'right' : rect.left - GAP - CARD_W > 0 ? 'left' : 'bottom' + } + switch (side) { + case 'right': return { left: clampL(rect.right + GAP), top: clampT(rect.top) } + case 'left': return { left: clampL(rect.left - GAP - CARD_W), top: clampT(rect.top) } + case 'top': return { left: clampL(rect.left), top: clampT(rect.top - GAP - CARD_H_EST) } + case 'bottom': return { left: clampL(rect.left), top: clampT(rect.bottom + GAP) } + case 'center': + default: return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) } + } +} + +export function WalkthroughOverlay() { + const { isActive, stepIndex, total, setTotal, next, prev, skip } = useWalkthroughStore() + const actions = useWalkthroughActions() + const steps = useMemo(() => getSteps(STANDALONE), []) + const step = steps[stepIndex] + + // anchorRect: the element the step rings (sidebar/toolbar button). dialogRect: + // any open app modal, always kept lit so it stays readable during modal steps. + const [anchorRect, setAnchorRect] = useState(null) + const [dialogRect, setDialogRect] = useState(null) + + // Keep the store's total in sync with the mode-filtered step list. + useEffect(() => { + if (isActive) setTotal(steps.length) + }, [isActive, steps.length, setTotal]) + + // Run the step's action on enter (close leftovers, open the right modal / inject + // demo data). Close everything when the tour ends. + useEffect(() => { + if (!actions) return + if (!isActive) { actions.closeAll(); return } + actions.closeAll() + const key = step?.action as keyof typeof actions | undefined + if (key && typeof actions[key] === 'function') (actions[key] as () => void)() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isActive, stepIndex, actions]) + + // Track target rects. Polls each frame because modals mount asynchronously after + // the step action fires, and layout shifts as they animate in. + useEffect(() => { + if (!isActive) return + let raf = 0 + const update = () => { + const anchorEl = step?.anchor ? document.querySelector(step.anchor) : null + setAnchorRect(anchorEl ? anchorEl.getBoundingClientRect() : null) + // Any open app modal, excluding our own coach card (inside the overlay). + const dialogEl = Array.from(document.querySelectorAll('[role="dialog"]')) + .find((el) => !el.closest('[data-walkthrough-overlay]')) + setDialogRect(dialogEl ? dialogEl.getBoundingClientRect() : null) + raf = window.requestAnimationFrame(update) + } + update() + window.addEventListener('resize', update) + window.addEventListener('scroll', update, true) + return () => { + window.cancelAnimationFrame(raf) + window.removeEventListener('resize', update) + window.removeEventListener('scroll', update, true) + } + }, [isActive, stepIndex, step?.anchor]) + + const handleKey = useCallback((e: KeyboardEvent) => { + if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); next() } + else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() } + else if (e.key === 'Escape') { e.preventDefault(); skip() } + }, [next, prev, skip]) + + useEffect(() => { + if (!isActive) return + window.addEventListener('keydown', handleKey) + return () => window.removeEventListener('keydown', handleKey) + }, [isActive, handleKey]) + + if (!isActive || !step) return null + + const isLast = stepIndex >= total - 1 + // Ring / card follow the anchor if the step has one, else the open modal. + const ringRect = anchorRect ?? dialogRect + const pos = cardPosition(ringRect, step.placement ?? 'auto') + // Cut-outs keep both the ringed control and any open modal lit. + const holes = [anchorRect, dialogRect].filter((r): r is DOMRect => r !== null) + + return createPortal( +
+ {/* Backdrop: dim everything except the cut-out holes (multi-hole SVG mask so + a ringed button AND an open modal can both stay readable). */} + + + + + {holes.map((r, i) => ( + + ))} + + + + + + {/* Pulsing ring on the primary target */} + {ringRect && ( +
+ )} + + {/* Coach card */} +
+
+

{step.title}

+ +
+

{step.body}

+ + {step.link && ( + + {step.link.label} + + )} + +
+ {/* Progress dots */} +
+ {steps.map((s, i) => ( + + ))} +
+
+ + {stepIndex + 1}/{steps.length} + + + +
+
+
+
, + document.body, + ) +} diff --git a/frontend/src/walkthrough/__tests__/WalkthroughInvite.test.tsx b/frontend/src/walkthrough/__tests__/WalkthroughInvite.test.tsx new file mode 100644 index 0000000..752e4f5 --- /dev/null +++ b/frontend/src/walkthrough/__tests__/WalkthroughInvite.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { renderWithProviders, screen, fireEvent } from '@/test/render' +import { WalkthroughInvite } from '../WalkthroughInvite' +import { useWalkthroughStore } from '@/stores/walkthroughStore' +import { readWalkthrough } from '@/utils/walkthrough' + +beforeEach(() => { + localStorage.clear() + useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 }) +}) + +describe('WalkthroughInvite', () => { + it('offers the tour to a new user', () => { + renderWithProviders() + expect(screen.getByText('New here? Take the tour')).toBeInTheDocument() + }) + + it('is hidden once already seen', () => { + localStorage.setItem('homelable.walkthrough', JSON.stringify({ seenVersion: 1, status: 'completed' })) + renderWithProviders() + expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument() + }) + + it('"Getting started" launches the tour', () => { + renderWithProviders() + fireEvent.click(screen.getByText('Getting started')) + expect(useWalkthroughStore.getState().isActive).toBe(true) + // Invite hides while the tour is active. + expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument() + }) + + it('"Don\'t show again" persists and hides', () => { + renderWithProviders() + fireEvent.click(screen.getByText("Don't show again")) + expect(readWalkthrough().status).toBe('skipped') + expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument() + }) + + it('"Not now" hides without persisting', () => { + renderWithProviders() + fireEvent.click(screen.getByLabelText('Not now')) + expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument() + // Not persisted → would be offered again on next load. + expect(readWalkthrough().seenVersion).toBeNull() + }) +}) diff --git a/frontend/src/walkthrough/__tests__/WalkthroughOverlay.test.tsx b/frontend/src/walkthrough/__tests__/WalkthroughOverlay.test.tsx new file mode 100644 index 0000000..3c21c75 --- /dev/null +++ b/frontend/src/walkthrough/__tests__/WalkthroughOverlay.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { renderWithProviders, screen, fireEvent, cleanup, act } from '@/test/render' +import { WalkthroughOverlay } from '../WalkthroughOverlay' +import { getSteps } from '../steps' +import { useWalkthroughStore } from '@/stores/walkthroughStore' + +const TOTAL = getSteps(false).length + +const startTour = () => act(() => { useWalkthroughStore.getState().start() }) + +beforeEach(() => { + localStorage.clear() + useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 }) +}) +afterEach(() => cleanup()) + +describe('WalkthroughOverlay', () => { + it('renders nothing while inactive', () => { + renderWithProviders() + expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument() + }) + + it('shows the first step and progress when active', () => { + renderWithProviders() + startTour() + expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument() + expect(screen.getByText(`1/${TOTAL}`)).toBeInTheDocument() + }) + + it('advances and rewinds between steps', () => { + renderWithProviders() + startTour() + fireEvent.click(screen.getByText('Next')) + expect(screen.getByText('Scan your network')).toBeInTheDocument() + fireEvent.click(screen.getByLabelText('Previous step')) + expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument() + }) + + it('skip closes the overlay', () => { + renderWithProviders() + startTour() + fireEvent.click(screen.getByLabelText('Skip walkthrough')) + expect(useWalkthroughStore.getState().isActive).toBe(false) + expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/walkthrough/__tests__/steps.test.ts b/frontend/src/walkthrough/__tests__/steps.test.ts new file mode 100644 index 0000000..b654943 --- /dev/null +++ b/frontend/src/walkthrough/__tests__/steps.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { getSteps, STEPS } from '../steps' + +describe('getSteps', () => { + it('returns every step in full mode', () => { + expect(getSteps(false)).toHaveLength(STEPS.length) + }) + + it('drops backend-only (mode:full) steps in standalone', () => { + const standalone = getSteps(true) + expect(standalone.every((s) => s.mode !== 'full')).toBe(true) + expect(standalone.length).toBeLessThan(STEPS.length) + const ids = standalone.map((s) => s.id) + // Canvas-only steps survive; backend-only steps are filtered out. + expect(ids).toEqual(expect.arrayContaining(['welcome', 'nodes', 'grouping', 'style', 'end'])) + expect(ids).not.toContain('scan') + expect(ids).not.toContain('scan-history') + expect(ids).not.toContain('inventory') + expect(ids).not.toContain('imports') + }) + + it('ends on a step with a GitHub link (the full-mode recap in standalone)', () => { + const last = getSteps(true).at(-1) + expect(last?.id).toBe('end') + expect(last?.link?.href).toContain('github.com') + }) +}) diff --git a/frontend/src/walkthrough/actions.ts b/frontend/src/walkthrough/actions.ts new file mode 100644 index 0000000..d110302 --- /dev/null +++ b/frontend/src/walkthrough/actions.ts @@ -0,0 +1,30 @@ +import { createContext, useContext } from 'react' + +/** + * Bridge between tour steps and the App's modal/canvas controls. App builds the + * concrete implementation from its useState setters and provides it here; the + * overlay resolves each step's `action` string to a function on this object. + * + * `closeAll` is called before every step's action so leftover modals from the + * previous step are dismissed. + * + * Grows as steps land (openInventoryDemo, editDemoNode, openStyle, …). + */ +export interface WalkthroughActionApi { + closeAll: () => void + openScanConfig: () => void + openScanHistoryDemo: () => void + openInventoryDemo: () => void + editFirstNode: () => void + selectTwoNodes: () => void + openStyle: () => void + openZigbeeImport: () => void +} + +const WalkthroughActionsContext = createContext(null) + +export const WalkthroughActionsProvider = WalkthroughActionsContext.Provider + +export function useWalkthroughActions(): WalkthroughActionApi | null { + return useContext(WalkthroughActionsContext) +} diff --git a/frontend/src/walkthrough/demoTourData.ts b/frontend/src/walkthrough/demoTourData.ts new file mode 100644 index 0000000..671f0f6 --- /dev/null +++ b/frontend/src/walkthrough/demoTourData.ts @@ -0,0 +1,65 @@ +import type { ScanRun } from '@/components/modals/ScanHistoryModal' +import type { PendingDevice } from '@/components/modals/PendingDeviceModal' + +/** + * Canned data injected into the real modals during the tour, so the demo steps + * look alive on a fresh install without hitting the backend. Never persisted. + */ +export const DEMO_SCAN_RUNS: ScanRun[] = [ + { + id: 'tour-scan-running', + status: 'running', + kind: 'ip', + ranges: ['192.168.1.0/24'], + devices_found: 12, + // 8s ago so the live "Duration" reads a realistic elapsed time. + started_at: new Date(Date.now() - 8000).toISOString(), + finished_at: null, + error: null, + }, + { + id: 'tour-scan-done', + status: 'done', + kind: 'ip', + ranges: ['192.168.1.0/24'], + devices_found: 9, + started_at: new Date(Date.now() - 3600_000).toISOString(), + finished_at: new Date(Date.now() - 3560_000).toISOString(), + error: null, + }, +] + +export const DEMO_PENDING_DEVICES: PendingDevice[] = [ + { + id: 'tour-dev-1', + ip: '192.168.1.20', + mac: 'AA:BB:CC:DD:EE:20', + hostname: 'synology-nas', + os: 'DSM 7.2', + services: [{ port: 5000, protocol: 'tcp', service_name: 'Synology DSM', category: 'nas' }], + suggested_type: 'nas', + status: 'pending', + discovery_source: 'arp', + friendly_name: 'Synology NAS', + vendor: 'Synology', + model: 'DS920+', + discovered_at: new Date(Date.now() - 120_000).toISOString(), + canvas_count: 0, + }, + { + id: 'tour-dev-2', + ip: '192.168.1.30', + mac: 'AA:BB:CC:DD:EE:30', + hostname: 'pi-hole', + os: null, + services: [{ port: 80, protocol: 'tcp', service_name: 'Pi-hole', category: 'network' }], + suggested_type: 'generic', + status: 'pending', + discovery_source: 'arp', + friendly_name: 'Pi-hole', + vendor: 'Raspberry Pi', + model: null, + discovered_at: new Date(Date.now() - 90_000).toISOString(), + canvas_count: 0, + }, +] diff --git a/frontend/src/walkthrough/steps.ts b/frontend/src/walkthrough/steps.ts new file mode 100644 index 0000000..4b27d51 --- /dev/null +++ b/frontend/src/walkthrough/steps.ts @@ -0,0 +1,121 @@ +/** + * Declarative Getting Started steps. + * + * `anchor` is a CSS selector for the element to ring/spotlight (undefined → the + * overlay lights any open modal, or centers the card). `action` is a key resolved + * against the walkthrough actions context on step enter (opens a modal, injects + * demo data, selects nodes, …). `mode: 'full'` marks a backend-only step that is + * filtered out of the standalone/public build. `link` renders a link in the card + * (used for the closing "questions?" step). + */ +export type StepPlacement = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'auto' + +export interface TourStep { + id: string + title: string + body: string + anchor?: string + placement?: StepPlacement + action?: string + mode?: 'full' | 'all' + link?: { label: string; href: string } +} + +export const STEPS: TourStep[] = [ + { + id: 'welcome', + title: 'Welcome to Homelable', + body: 'Take a quick tour of the main features — scanning your network, managing devices, and building your canvas. You can skip anytime and restart later from Settings.', + placement: 'center', + mode: 'all', + }, + { + id: 'scan', + title: 'Scan your network', + body: 'Start here: run an IP scan to auto-discover devices on your network. Pick the ranges and ports, then launch — discovered devices land in your inventory for review.', + anchor: '[data-tour="scan-network"]', + placement: 'right', + action: 'openScanConfig', + mode: 'full', + }, + { + id: 'scan-history', + title: 'Follow scan progress', + body: 'Scan History shows every run live. A running scan updates in place with elapsed time and device count — you can stop it here too. (This is example data.)', + placement: 'left', + action: 'openScanHistoryDemo', + mode: 'full', + }, + { + id: 'inventory', + title: 'Review discovered devices', + body: 'Discovered devices wait in your inventory. For each one you can approve it onto the canvas, hide it, or delete it — with detected services and vendor shown. (Example devices.)', + placement: 'left', + action: 'openInventoryDemo', + mode: 'full', + }, + { + id: 'nodes', + title: 'Your devices on the canvas', + body: 'Approved devices become nodes on the canvas. Each shows live status, IP, hostname and running services. Drag to arrange them and draw links between them.', + anchor: '.react-flow__node', + placement: 'auto', + mode: 'all', + }, + { + id: 'node-edit', + title: 'Edit a device', + body: 'Double-click any node to edit it: name, type, IP, the status-check method, appearance, and its parent (e.g. a VM inside a Proxmox host). Changes are yours until you Save.', + placement: 'left', + action: 'editFirstNode', + mode: 'all', + }, + { + id: 'text-zone', + title: 'Annotate with text & zones', + body: 'Add free Text labels and Zones (colored rectangles) to document your layout — group a rack, mark a VLAN, or leave a note. Both live under the canvas actions.', + anchor: '[data-tour="add-zone"]', + placement: 'right', + mode: 'all', + }, + { + id: 'grouping', + title: 'Group related devices', + body: 'Select multiple nodes, then Create Group to box them together and drag them as one — perfect for a rack, a site, or a subnet.', + anchor: '[data-tour="create-group"]', + placement: 'left', + action: 'selectTwoNodes', + mode: 'all', + }, + { + id: 'style', + title: 'Make it yours', + body: 'Switch the canvas theme from Style, or fine-tune colors, borders and fonts per node type. Your homelab, your look.', + anchor: '[data-tour="style"]', + placement: 'bottom', + action: 'openStyle', + mode: 'all', + }, + { + id: 'imports', + title: 'Import from your stack', + body: 'Already running Zigbee2MQTT, Z-Wave JS, or Proxmox? Import devices directly from them instead of scanning — one click brings the whole topology in.', + anchor: '[data-tour="zigbee-import"]', + placement: 'right', + action: 'openZigbeeImport', + mode: 'full', + }, + { + id: 'end', + title: "You're all set", + body: "That's the tour! Build your map, save it, and share a read-only view. In full mode you also get network scanning, a device inventory, and Zigbee / Z-Wave / Proxmox import. Have a question or an idea?", + placement: 'center', + mode: 'all', + link: { label: 'Ask on GitHub', href: 'https://github.com/Pouzor/homelable/issues' }, + }, +] + +/** Steps for the current build — backend-only steps are dropped in standalone. */ +export function getSteps(standalone: boolean): TourStep[] { + return STEPS.filter((s) => s.mode !== 'full' || !standalone) +} diff --git a/frontend/src/walkthrough/walkthrough.css b/frontend/src/walkthrough/walkthrough.css new file mode 100644 index 0000000..448a18f --- /dev/null +++ b/frontend/src/walkthrough/walkthrough.css @@ -0,0 +1,29 @@ +/* Getting Started walkthrough animations. Kept in a shared stylesheet (imported + by both the invite bubble and the overlay) so keyframes exist regardless of + which component is mounted. */ + +@keyframes wt-invite-enter { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: none; } +} +.walkthrough-invite-enter { animation: wt-invite-enter 0.25s ease-out; } + +@keyframes wt-card-enter { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} +.walkthrough-card-enter { animation: wt-card-enter 0.2s ease-out; } + +/* The dim surround is drawn by the overlay's SVG mask; this is just the pulsing + cyan ring around the primary target (outline so it sits outside the border). */ +.walkthrough-spotlight--pulse { animation: wt-pulse 1.8s ease-in-out infinite; } +@keyframes wt-pulse { + 0%, 100% { outline: 0px solid rgba(0, 212, 255, 0.55); } + 50% { outline: 7px solid rgba(0, 212, 255, 0); } +} + +@media (prefers-reduced-motion: reduce) { + .walkthrough-invite-enter, + .walkthrough-card-enter, + .walkthrough-spotlight--pulse { animation: none; } +}