Add interactive guided tour with driver.js

6-step spotlight tour (Scan → Connect → Manage → Flash → Workspace → Inference)
with step completion validation, polling auto-detection, and i18n support (en/zh-TW).
Triggered from onboarding dialog or header help button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-03-10 11:29:05 +08:00
parent 7b4492c41e
commit 2d68387ed1
19 changed files with 617 additions and 12 deletions

View File

@ -13,6 +13,7 @@
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"driver.js": "^1.4.0",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"next-themes": "^0.4.6",

View File

@ -14,6 +14,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
driver.js:
specifier: ^1.4.0
version: 1.4.0
lucide-react:
specifier: ^0.575.0
version: 0.575.0(react@19.2.3)
@ -34,7 +37,7 @@ importers:
version: 19.2.3(react@19.2.3)
recharts:
specifier: ^3.7.0
version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1)
version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -2593,6 +2596,9 @@ packages:
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
engines: {node: '>=12'}
driver.js@1.4.0:
resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@ -7040,6 +7046,8 @@ snapshots:
dotenv@17.3.1: {}
driver.js@1.4.0: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@ -8595,7 +8603,7 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1):
recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
clsx: 2.1.1
@ -8605,7 +8613,7 @@ snapshots:
immer: 10.2.0
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-is: 16.13.1
react-is: 17.0.2
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3

View File

@ -61,7 +61,7 @@ export default function DeviceDetailClient() {
<FlashDialog deviceId={id} />
{selectedDevice.flashedModel && (
<Link href={`/workspace/${id}`}>
<Button variant="outline">{t('devices.detail.openWorkspace')}</Button>
<Button variant="outline" data-tour-id="open-workspace-btn">{t('devices.detail.openWorkspace')}</Button>
</Link>
)}
<Button variant="ghost" onClick={() => disconnectDevice(id)}>

View File

@ -25,7 +25,7 @@ export default function DevicesPage() {
<h1 className="text-2xl font-bold">{t('devices.title')}</h1>
<p className="text-muted-foreground">{t('devices.subtitle')}</p>
</div>
<Button onClick={scanDevices} disabled={scanning} variant="outline">
<Button onClick={scanDevices} disabled={scanning} variant="outline" data-tour-id="scan-devices-btn">
<RefreshCw className={`mr-2 h-4 w-4 ${scanning ? 'animate-spin' : ''}`} />
{scanning ? t('devices.scanning') : t('devices.scan')}
</Button>

View File

@ -123,4 +123,102 @@
body {
@apply bg-background text-foreground;
}
}
/* driver.js theme overrides — integrates with shadcn/ui CSS variables */
.driver-popover {
background-color: var(--popover) !important;
color: var(--popover-foreground) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1) !important;
}
.driver-popover .driver-popover-title {
font-size: 0.95rem !important;
font-weight: 600 !important;
color: var(--popover-foreground) !important;
}
.driver-popover .driver-popover-title .driver-step-counter {
font-weight: 400 !important;
font-size: 0.8rem !important;
color: var(--muted-foreground) !important;
margin-left: 4px;
}
.driver-popover .driver-popover-description {
font-size: 0.85rem !important;
color: var(--muted-foreground) !important;
}
.driver-popover .driver-popover-close-btn {
color: var(--muted-foreground) !important;
}
.driver-popover .driver-popover-close-btn:hover {
color: var(--popover-foreground) !important;
}
.driver-popover-navigation-btns {
gap: 6px;
}
.driver-popover .driver-popover-prev-btn {
background-color: var(--secondary) !important;
color: var(--secondary-foreground) !important;
border: 1px solid var(--border) !important;
border-radius: calc(var(--radius) - 2px) !important;
font-size: 0.8rem !important;
padding: 4px 12px !important;
text-shadow: none !important;
}
.driver-popover .driver-popover-next-btn {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
border: none !important;
border-radius: calc(var(--radius) - 2px) !important;
font-size: 0.8rem !important;
padding: 4px 12px !important;
text-shadow: none !important;
}
.driver-popover .driver-popover-prev-btn:hover {
opacity: 0.9;
}
.driver-popover .driver-popover-next-btn:hover {
opacity: 0.9;
}
.driver-overlay {
background-color: rgb(0 0 0 / 0.5) !important;
}
.driver-popover .driver-btn-disabled {
opacity: 0.4 !important;
cursor: not-allowed !important;
pointer-events: none !important;
}
.driver-popover .driver-pending-hint {
color: var(--chart-1) !important;
font-size: 0.8rem !important;
font-weight: 500;
}
.driver-popover-navigation-btns .driver-exit-tour {
font-size: 0.75rem;
color: var(--muted-foreground);
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
margin-right: auto;
transition: color 0.15s;
}
.driver-popover-navigation-btns .driver-exit-tour:hover {
color: var(--destructive);
}

View File

@ -8,6 +8,7 @@ import { ThemeSync } from "@/components/theme-sync";
import { LangSync } from "@/components/lang-sync";
import { StoreHydration } from "@/components/store-hydration";
import { RelayTokenSync } from "@/components/relay-token-sync";
import { GuidedTour } from "@/components/guided-tour";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -47,6 +48,7 @@ export default function RootLayout({
<RelayTokenSync />
<ThemeSync />
<LangSync />
<GuidedTour />
<Toaster richColors position="bottom-right" />
</body>
</html>

View File

@ -74,7 +74,7 @@ export default function WorkspaceClient() {
{t('inference.stopInference')}
</Button>
) : (
<Button onClick={handleStartInference} disabled={!isStreaming}>
<Button onClick={handleStartInference} disabled={!isStreaming} data-tour-id="start-inference-btn">
{t('inference.startInference')}
</Button>
)}

View File

@ -11,9 +11,10 @@ import type { Device } from '@/types/device';
interface DeviceCardProps {
device: Device;
isFirstCard?: boolean;
}
export function DeviceCard({ device }: DeviceCardProps) {
export function DeviceCard({ device, isFirstCard }: DeviceCardProps) {
const { t } = useTranslation();
const { connectDevice, disconnectDevice } = useDeviceStore();
const prefs = useDevicePreferencesStore((s) => s.getPreferences(device.id));
@ -54,7 +55,7 @@ export function DeviceCard({ device }: DeviceCardProps) {
{isConnected ? (
<>
<Link href={`/devices/${device.id}`}>
<Button size="sm" variant="outline">
<Button size="sm" variant="outline" {...(isFirstCard ? { 'data-tour-id': 'manage-device-btn' } : {})}>
{t('common.manage')}
</Button>
</Link>
@ -70,6 +71,7 @@ export function DeviceCard({ device }: DeviceCardProps) {
<Button
size="sm"
onClick={() => connectDevice(device.id)}
{...(isFirstCard ? { 'data-tour-id': 'connect-device-btn' } : {})}
>
{t('common.connect')}
</Button>

View File

@ -39,8 +39,8 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{devices.map((device) => (
<DeviceCard key={device.id} device={device} />
{devices.map((device, index) => (
<DeviceCard key={device.id} device={device} isFirstCard={index === 0} />
))}
</div>
);

View File

@ -70,7 +70,7 @@ export function FlashDialog({ deviceId }: FlashDialogProps) {
setOpen(v);
}}>
<DialogTrigger asChild>
<Button>{t('devices.flash.flashModel')}</Button>
<Button data-tour-id="flash-model-btn">{t('devices.flash.flashModel')}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>

View File

@ -0,0 +1,249 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { driver, type DriveStep, type Config } from 'driver.js';
import 'driver.js/dist/driver.css';
import { tourSteps } from '@/lib/tour-steps';
import { useTourStore } from '@/stores/tour-store';
import { useDeviceStore } from '@/stores/device-store';
import { useTranslation } from '@/lib/i18n';
function waitForElement(selector: string, timeout = 3000): Promise<Element | null> {
return new Promise((resolve) => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const observer = new MutationObserver(() => {
const found = document.querySelector(selector);
if (found) {
observer.disconnect();
resolve(found);
}
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
});
}
export function GuidedTour() {
const { t } = useTranslation();
const pathname = usePathname();
const router = useRouter();
const { isActive, currentStepIndex, endTour, markTourCompleted, goToStep } = useTourStore();
const devices = useDeviceStore((s) => s.devices);
const driverRef = useRef<ReturnType<typeof driver> | null>(null);
const isNavigatingRef = useRef(false);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const getFirstConnectedDeviceId = useCallback(() => {
const connected = devices.find((d) => d.status === 'connected' || d.status === 'flashing' || d.status === 'inferencing');
if (connected) return connected.id;
return devices.length > 0 ? devices[0].id : null;
}, [devices]);
const resolvePagePath = useCallback((pageTpl: string) => {
const deviceId = getFirstConnectedDeviceId();
if (!deviceId) return pageTpl;
return pageTpl.replace('__DEVICE_ID__', deviceId);
}, [getFirstConnectedDeviceId]);
const cleanup = useCallback(() => {
if (driverRef.current) {
driverRef.current.destroy();
driverRef.current = null;
}
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}, []);
// Clean up on unmount
useEffect(() => cleanup, [cleanup]);
// Main tour effect — runs whenever step changes or becomes active
useEffect(() => {
if (!isActive) {
cleanup();
return;
}
const step = tourSteps[currentStepIndex];
if (!step) {
markTourCompleted();
endTour();
cleanup();
return;
}
const targetPage = resolvePagePath(step.page);
// If we need to navigate to a different page
if (!pathname.startsWith(targetPage)) {
isNavigatingRef.current = true;
cleanup();
router.push(targetPage);
return;
}
// We're on the right page — wait for the element and highlight it
const highlight = async () => {
if (isNavigatingRef.current) {
await new Promise((r) => setTimeout(r, 500));
isNavigatingRef.current = false;
}
const el = await waitForElement(step.elementSelector);
cleanup();
const totalSteps = tourSteps.length;
const isLast = currentStepIndex === totalSteps - 1;
const isFirst = currentStepIndex === 0;
const stepComplete = step.isComplete();
const description = stepComplete
? t(step.descriptionKey)
: `${t(step.descriptionKey)}<br/><br/><span class="driver-pending-hint">⏳ ${t(step.pendingDescKey)}</span>`;
const driverSteps: DriveStep[] = [{
element: el ? step.elementSelector : undefined,
popover: {
title: `${t(step.titleKey)} <span class="driver-step-counter">(${currentStepIndex + 1} ${t('tour.of')} ${totalSteps})</span>`,
description,
side: step.side,
align: 'center',
},
}];
const config: Config = {
showProgress: false,
showButtons: ['next', 'previous', 'close'],
nextBtnText: isLast ? t('tour.done') : t('tour.next'),
prevBtnText: t('tour.prev'),
doneBtnText: t('tour.done'),
steps: driverSteps,
allowClose: true,
overlayClickBehavior: 'nextStep',
stagePadding: 8,
stageRadius: 8,
onCloseClick: () => {
endTour();
cleanup();
},
onNextClick: () => {
// Block advancing if step not complete
if (!step.isComplete()) return;
if (isLast) {
markTourCompleted();
endTour();
cleanup();
} else {
cleanup();
goToStep(currentStepIndex + 1);
}
},
onPrevClick: () => {
if (!isFirst) {
cleanup();
goToStep(currentStepIndex - 1);
}
},
onDestroyStarted: () => {
if (driverRef.current) {
driverRef.current.destroy();
driverRef.current = null;
}
},
};
if (isFirst) {
config.showButtons = ['next', 'close'];
}
const d = driver(config);
driverRef.current = d;
d.drive();
// Style the next button as disabled if step not complete
updateNextButtonState(stepComplete);
// Add the Exit Tour button in nav row
addExitButton(t('tour.exit'));
// Poll for step completion — refresh the popover when condition changes
if (!stepComplete) {
pollingRef.current = setInterval(() => {
if (step.isComplete()) {
// Step completed! Refresh the highlight to update description & enable Next
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
// Re-trigger highlight by re-setting the same step
cleanup();
goToStep(currentStepIndex);
}
}, 800);
}
};
highlight();
}, [isActive, currentStepIndex, pathname, t, router, resolvePagePath, endTour, markTourCompleted, goToStep, cleanup]);
// If user navigates away via sidebar during tour, end the tour
useEffect(() => {
if (!isActive) return;
const step = tourSteps[currentStepIndex];
if (!step) return;
const targetPage = resolvePagePath(step.page);
if (!pathname.startsWith(targetPage) && !isNavigatingRef.current) {
endTour();
cleanup();
}
}, [pathname, isActive, currentStepIndex, resolvePagePath, endTour, cleanup]);
return null;
}
/** Visually disable/enable the Next button */
function updateNextButtonState(enabled: boolean) {
requestAnimationFrame(() => {
const nextBtn = document.querySelector('.driver-popover-next-btn') as HTMLButtonElement | null;
if (!nextBtn) return;
if (enabled) {
nextBtn.classList.remove('driver-btn-disabled');
} else {
nextBtn.classList.add('driver-btn-disabled');
}
});
}
/** Inject an "Exit Tour" button into the navigation button row */
function addExitButton(exitText: string) {
requestAnimationFrame(() => {
const nav = document.querySelector('.driver-popover-navigation-btns');
if (!nav || nav.querySelector('.driver-exit-tour')) return;
const exitBtn = document.createElement('button');
exitBtn.className = 'driver-exit-tour';
exitBtn.textContent = exitText;
exitBtn.addEventListener('click', (e) => {
e.stopPropagation();
useTourStore.getState().endTour();
document.querySelectorAll('.driver-overlay, .driver-popover').forEach((el) => el.remove());
document.querySelectorAll('[class*="driver-active"]').forEach((el) => {
el.classList.remove('driver-active-element');
});
});
// Insert at the beginning of the nav row (left side)
nav.insertBefore(exitBtn, nav.firstChild);
});
}

View File

@ -1,6 +1,7 @@
'use client';
import { ConnectionStatus } from './connection-status';
import { HelpButton } from './help-button';
import { useTranslation } from '@/lib/i18n';
export function Header() {
@ -10,6 +11,7 @@ export function Header() {
<header className="flex h-14 items-center justify-between border-b bg-card px-6">
<h1 className="text-lg font-semibold">{t('nav.platformTitle')}</h1>
<div className="flex items-center gap-4">
<HelpButton />
<ConnectionStatus />
</div>
</header>

View File

@ -0,0 +1,30 @@
'use client';
import { CircleHelp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTourStore } from '@/stores/tour-store';
import { useTranslation } from '@/lib/i18n';
import { useRouter } from 'next/navigation';
export function HelpButton() {
const { t } = useTranslation();
const { startTour } = useTourStore();
const router = useRouter();
const handleClick = () => {
router.push('/devices');
setTimeout(() => startTour(), 300);
};
return (
<Button
variant="ghost"
size="icon"
onClick={handleClick}
title={t('tour.helpTooltip')}
className="h-8 w-8"
>
<CircleHelp className="h-4 w-4" />
</Button>
);
}

View File

@ -13,6 +13,7 @@ import {
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/lib/i18n';
import { useFirstVisit } from '@/hooks/use-first-visit';
import { useTourStore } from '@/stores/tour-store';
import type { LucideIcon } from 'lucide-react';
interface Step {
@ -31,6 +32,7 @@ export function OnboardingDialog() {
const { t } = useTranslation();
const [isFirstVisit, markComplete] = useFirstVisit();
const [step, setStep] = useState(0);
const { startTour } = useTourStore();
if (!isFirstVisit) return null;
@ -83,7 +85,14 @@ export function OnboardingDialog() {
</Button>
)}
</div>
<Button size="sm" onClick={() => isLast ? markComplete() : setStep(step + 1)}>
<Button size="sm" onClick={() => {
if (isLast) {
markComplete();
setTimeout(() => startTour(), 300);
} else {
setStep(step + 1);
}
}}>
{isLast ? t('onboarding.getStarted') : t('onboarding.next')}
</Button>
</DialogFooter>

View File

@ -377,6 +377,32 @@ export const en: TranslationDict = {
openWorkspace: 'Open Workspace',
workspace: 'Cluster Workspace',
},
tour: {
step1Title: 'Scan for Devices',
step1Desc: 'Click this button to scan for connected Kneron USB devices.',
step1Pending: 'Please click "Scan Devices" first. No devices detected yet.',
step2Title: 'Connect a Device',
step2Desc: 'Click Connect to establish a connection with the detected device.',
step2Pending: 'Please click "Connect" on a device first.',
step3Title: 'Manage Device',
step3Desc: 'Click Manage to view device details, flash models, and open the workspace.',
step3Pending: 'Please connect a device first to see the Manage button.',
step4Title: 'Flash a Model',
step4Desc: 'Select an AI model from the library and flash it to your device.',
step4Pending: 'Please flash a model to the device first.',
step5Title: 'Open Workspace',
step5Desc: 'Open the workspace to configure input sources and run inference.',
step5Pending: 'Please flash a model first — the workspace button will appear after.',
step6Title: 'Start Inference',
step6Desc: 'Select a camera or upload an image/video, then start real-time AI inference!',
step6Pending: 'Please start inference to complete the tour.',
next: 'Next',
prev: 'Previous',
done: 'Done',
exit: 'Exit Tour',
of: 'of',
helpTooltip: 'Start guided tour',
},
errors: {
serverDisconnected: 'Server connection lost',
serverReconnected: 'Server reconnected',

View File

@ -375,6 +375,32 @@ export interface TranslationDict {
openWorkspace: string;
workspace: string;
};
tour: {
step1Title: string;
step1Desc: string;
step1Pending: string;
step2Title: string;
step2Desc: string;
step2Pending: string;
step3Title: string;
step3Desc: string;
step3Pending: string;
step4Title: string;
step4Desc: string;
step4Pending: string;
step5Title: string;
step5Desc: string;
step5Pending: string;
step6Title: string;
step6Desc: string;
step6Pending: string;
next: string;
prev: string;
done: string;
exit: string;
of: string;
helpTooltip: string;
};
errors: {
serverDisconnected: string;
serverReconnected: string;

View File

@ -377,6 +377,32 @@ export const zhTW: TranslationDict = {
openWorkspace: '開啟工作區',
workspace: '叢集工作區',
},
tour: {
step1Title: '掃描裝置',
step1Desc: '點擊此按鈕掃描已連接的 Kneron USB 裝置。',
step1Pending: '請先點擊「掃描裝置」,目前尚未偵測到任何裝置。',
step2Title: '連線裝置',
step2Desc: '點擊「連線」與偵測到的裝置建立連線。',
step2Pending: '請先點擊裝置上的「連線」按鈕。',
step3Title: '管理裝置',
step3Desc: '點擊「管理」檢視裝置詳情、燒錄模型及開啟工作區。',
step3Pending: '請先連線裝置,才會顯示「管理」按鈕。',
step4Title: '燒錄模型',
step4Desc: '從模型庫選擇 AI 模型並燒錄至你的裝置。',
step4Pending: '請先將模型燒錄至裝置。',
step5Title: '開啟工作區',
step5Desc: '開啟工作區設定輸入來源並執行推論。',
step5Pending: '請先燒錄模型,完成後會出現「開啟工作區」按鈕。',
step6Title: '開始推論',
step6Desc: '選擇攝影機或上傳圖片/影片,開始即時 AI 推論!',
step6Pending: '請開始推論以完成導覽。',
next: '下一步',
prev: '上一步',
done: '完成',
exit: '離開導覽',
of: '/',
helpTooltip: '開始導覽',
},
errors: {
serverDisconnected: '伺服器連線中斷',
serverReconnected: '伺服器已重新連線',

View File

@ -0,0 +1,92 @@
import type { TranslationKey } from '@/lib/i18n/types';
import { useDeviceStore } from '@/stores/device-store';
import { useInferenceStore } from '@/stores/inference-store';
export interface TourStep {
id: string;
page: string;
elementSelector: string;
titleKey: TranslationKey;
descriptionKey: TranslationKey;
pendingDescKey: TranslationKey;
side: 'top' | 'bottom' | 'left' | 'right';
/** Returns true when the step's goal has been achieved */
isComplete: () => boolean;
}
export const tourSteps: TourStep[] = [
{
id: 'scan-devices',
page: '/devices',
elementSelector: '[data-tour-id="scan-devices-btn"]',
titleKey: 'tour.step1Title',
descriptionKey: 'tour.step1Desc',
pendingDescKey: 'tour.step1Pending',
side: 'bottom',
isComplete: () => useDeviceStore.getState().devices.length > 0,
},
{
id: 'connect-device',
page: '/devices',
elementSelector: '[data-tour-id="connect-device-btn"]',
titleKey: 'tour.step2Title',
descriptionKey: 'tour.step2Desc',
pendingDescKey: 'tour.step2Pending',
side: 'bottom',
isComplete: () => {
const devices = useDeviceStore.getState().devices;
return devices.some((d) => d.status === 'connected' || d.status === 'flashing' || d.status === 'inferencing');
},
},
{
id: 'manage-device',
page: '/devices',
elementSelector: '[data-tour-id="manage-device-btn"]',
titleKey: 'tour.step3Title',
descriptionKey: 'tour.step3Desc',
pendingDescKey: 'tour.step3Pending',
side: 'bottom',
// "Manage" is always available once a device is connected (previous step guarantees this)
isComplete: () => {
const devices = useDeviceStore.getState().devices;
return devices.some((d) => d.status === 'connected' || d.status === 'flashing' || d.status === 'inferencing');
},
},
{
id: 'flash-model',
page: '/devices/__DEVICE_ID__',
elementSelector: '[data-tour-id="flash-model-btn"]',
titleKey: 'tour.step4Title',
descriptionKey: 'tour.step4Desc',
pendingDescKey: 'tour.step4Pending',
side: 'bottom',
isComplete: () => {
const device = useDeviceStore.getState().selectedDevice;
return !!device?.flashedModel;
},
},
{
id: 'open-workspace',
page: '/devices/__DEVICE_ID__',
elementSelector: '[data-tour-id="open-workspace-btn"]',
titleKey: 'tour.step5Title',
descriptionKey: 'tour.step5Desc',
pendingDescKey: 'tour.step5Pending',
side: 'bottom',
// Workspace button only shows when flashedModel exists, so same condition
isComplete: () => {
const device = useDeviceStore.getState().selectedDevice;
return !!device?.flashedModel;
},
},
{
id: 'start-inference',
page: '/workspace/__DEVICE_ID__',
elementSelector: '[data-tour-id="start-inference-btn"]',
titleKey: 'tour.step6Title',
descriptionKey: 'tour.step6Desc',
pendingDescKey: 'tour.step6Pending',
side: 'bottom',
isComplete: () => useInferenceStore.getState().isRunning,
},
];

View File

@ -0,0 +1,34 @@
import { create } from 'zustand';
interface TourState {
isActive: boolean;
currentStepIndex: number;
startTour: () => void;
endTour: () => void;
nextStep: () => void;
prevStep: () => void;
goToStep: (index: number) => void;
markTourCompleted: () => void;
hasTourCompleted: () => boolean;
}
const TOUR_COMPLETED_KEY = 'tour-completed';
export const useTourStore = create<TourState>()((set) => ({
isActive: false,
currentStepIndex: 0,
startTour: () => set({ isActive: true, currentStepIndex: 0 }),
endTour: () => set({ isActive: false, currentStepIndex: 0 }),
nextStep: () => set((s) => ({ currentStepIndex: s.currentStepIndex + 1 })),
prevStep: () => set((s) => ({ currentStepIndex: Math.max(0, s.currentStepIndex - 1) })),
goToStep: (index: number) => set({ currentStepIndex: index }),
markTourCompleted: () => {
try { localStorage.setItem(TOUR_COMPLETED_KEY, 'true'); } catch {}
},
hasTourCompleted: () => {
try { return localStorage.getItem(TOUR_COMPLETED_KEY) === 'true'; } catch { return false; }
},
}));