diff --git a/.env b/.env index e66f28c5..65e0a44f 100644 --- a/.env +++ b/.env @@ -9,6 +9,10 @@ PUBLIC_TURNSTILE_DEV_BYPASS_VALUE=INVALID PUBLIC_DEVELOPMENT_BANNER=false +PUBLIC_DISABLE_ONBOARDING=false + +PUBLIC_DISABLE_SHOCKER_MAP=true + PUBLIC_SITE_URL=https://openshock.app PUBLIC_SITE_SHORT_URL=https://openshock.app PUBLIC_BACKEND_API_URL=https://api.openshock.app diff --git a/package.json b/package.json index 68f49f6d..4cb58b71 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "vitest": "^4.1.6" }, "dependencies": { + "driver.js": "^1.4.0", "temporal-polyfill": "^0.3.2", "vite": "^8.0.13" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc4736c9..dfc0c8aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: .: dependencies: + driver.js: + specifier: ^1.4.0 + version: 1.4.0 temporal-polyfill: specifier: ^0.3.2 version: 0.3.2 @@ -1576,6 +1579,9 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + driver.js@1.4.0: + resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==} + effect@3.21.2: resolution: {integrity: sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg==} @@ -4031,6 +4037,8 @@ snapshots: dotenv@17.4.2: {} + driver.js@1.4.0: {} + effect@3.21.2: dependencies: '@standard-schema/spec': 1.1.0 diff --git a/src/app.css b/src/app.css index 9df0f995..92185e03 100644 --- a/src/app.css +++ b/src/app.css @@ -250,3 +250,54 @@ display: none; } } + +/* driver.js theme override — match the dark UI */ +.driver-popover { + background: #13131a; + color: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0.75rem; +} +.driver-popover-title { + color: #fff; + font-weight: 600; +} +.driver-popover-description { + color: rgba(255, 255, 255, 0.7); +} +.driver-popover-arrow-side-left.driver-popover-arrow, +.driver-popover-arrow-side-right.driver-popover-arrow, +.driver-popover-arrow-side-top.driver-popover-arrow, +.driver-popover-arrow-side-bottom.driver-popover-arrow { + border-color: #13131a; +} +.driver-popover-progress-text { + color: rgba(255, 255, 255, 0.5); + font-size: 0.75rem; +} +.driver-popover-footer button { + background: rgba(255, 255, 255, 0.08) !important; + color: #fff !important; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0.5rem; + padding: 0.375rem 0.875rem; + font-size: 0.875rem; + text-shadow: none !important; +} +.driver-popover-footer button:hover { + background: rgba(255, 255, 255, 0.14) !important; +} +.driver-popover-footer .driver-popover-next-btn { + background: #fff !important; + color: #000 !important; + border-color: transparent; +} +.driver-popover-footer .driver-popover-next-btn:hover { + background: rgba(255, 255, 255, 0.9) !important; +} +.driver-popover-close-btn { + color: rgba(255, 255, 255, 0.5); +} +.driver-popover-close-btn:hover { + color: #fff; +} diff --git a/src/hooks.client.ts b/src/hooks.client.ts index 22ab2672..dac6aa83 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,9 +1,11 @@ +import { base } from '$app/paths'; import { versionGetBackendInfo } from '$lib/api'; import { handleApiError } from '$lib/errorhandling/apiErrorHandling'; import { authState, startAuthLifecycle } from '$lib/state/auth-state.svelte'; import { backendMetadata } from '$lib/state/backend-metadata-state.svelte'; import { initializeColorScheme } from '$lib/state/color-scheme-state.svelte'; import { userState } from '$lib/state/user-state.svelte'; +import { redirectLegacyHashRoute } from '$lib/utils/legacy-hash-redirect'; async function ensureTemporal(): Promise { if (typeof (globalThis as { Temporal?: unknown }).Temporal === 'undefined') { @@ -26,6 +28,7 @@ async function clientInit(): Promise { } export async function init() { + redirectLegacyHashRoute(base); await ensureTemporal(); await clientInit().catch(handleApiError); initializeColorScheme(); diff --git a/src/lib/components/DotGrid.svelte b/src/lib/components/DotGrid.svelte new file mode 100644 index 00000000..da347d8f --- /dev/null +++ b/src/lib/components/DotGrid.svelte @@ -0,0 +1,50 @@ + + +
+ + +
+ + diff --git a/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte index 7c22d2a8..53e0c4d7 100644 --- a/src/lib/components/ui/sidebar/sidebar-menu-button.svelte +++ b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -2,10 +2,10 @@ import { tv, type VariantProps } from "tailwind-variants"; export const sidebarMenuButtonVariants = tv({ - base: "ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button group/menu-button flex w-full items-center overflow-hidden outline-hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", + base: "ring-sidebar-ring hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent/50 data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button group/menu-button flex w-full items-center overflow-hidden outline-hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", variants: { variant: { - default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + default: "hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground", outline: "bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", }, size: { diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte index 020c6745..e94b26b3 100644 --- a/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -19,7 +19,7 @@ const mergedProps = $derived({ class: cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0", + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0", className ), "data-slot": "sidebar-menu-sub-button", diff --git a/src/lib/state/auth-state.svelte.ts b/src/lib/state/auth-state.svelte.ts index 0f636c8d..061f6944 100644 --- a/src/lib/state/auth-state.svelte.ts +++ b/src/lib/state/auth-state.svelte.ts @@ -1,5 +1,13 @@ +import { dialog } from '$lib/components/dialog-manager/dialog-store.svelte'; import { registerOnUnauthorized } from '$lib/errorhandling/apiErrorHandling'; import { destroySignalR, initializeSignalR } from '$lib/signalr/user.svelte'; +import { + hasCompletedTour, + hasSeenWelcome, + isOnboardingDisabled, + markTourCompleted, + startWelcomeTour, +} from '$lib/tour/welcome-tour'; import { userState } from './user-state.svelte'; export const AuthStatus = { @@ -25,6 +33,18 @@ export const authState = { }, }; +async function maybeTourPrompt() { + if (isOnboardingDisabled() || hasCompletedTour() || !hasSeenWelcome()) return; + const result = await dialog.confirm({ + title: 'Take the quick tour?', + desc: "You skipped it earlier. It only takes a minute and shows you what's new.", + confirmButtonText: 'Sure, show me', + cancelButtonText: 'No thanks', + }); + if (result.confirmed) await startWelcomeTour(); + else markTourCompleted(); +} + /** * Wires up the reactive side-effects of auth state: * - SignalR connects/disconnects as the user logs in/out. @@ -37,12 +57,16 @@ export function startAuthLifecycle() { registerOnUnauthorized(() => userState.reset()); _stopLifecycle = $effect.root(() => { + let prevSelf: typeof userState.self = null; $effect(() => { - if (userState.self) { + const self = userState.self; + if (self && !prevSelf) { + void maybeTourPrompt(); void initializeSignalR(); - } else { + } else if (!self) { void destroySignalR(); } + prevSelf = self; }); }); } diff --git a/src/lib/tour/onboarding-state.ts b/src/lib/tour/onboarding-state.ts new file mode 100644 index 00000000..211fcd13 --- /dev/null +++ b/src/lib/tour/onboarding-state.ts @@ -0,0 +1,60 @@ +import { PUBLIC_DISABLE_ONBOARDING } from '$env/static/public'; +import { isTruthy } from '$lib/utils/parse'; + +const CURRENT_WELCOME_VERSION = 1; +const WELCOME_VERSION_KEY = 'os.welcomeVersion'; + +const CURRENT_TOUR_VERSION = 1; +const TOUR_VERSION_KEY = 'os.tourCompletedVersion'; + +export function isOnboardingDisabled(): boolean { + return isTruthy(PUBLIC_DISABLE_ONBOARDING); +} + +export function hasSeenWelcome(): boolean { + try { + const raw = localStorage.getItem(WELCOME_VERSION_KEY); + const seen = raw ? parseInt(raw, 10) : 0; + return Number.isFinite(seen) && seen >= CURRENT_WELCOME_VERSION; + } catch { + return false; + } +} + +export function markWelcomed(): void { + try { + localStorage.setItem(WELCOME_VERSION_KEY, String(CURRENT_WELCOME_VERSION)); + } catch { + // ignore (private mode, quota, etc.) + } +} + +export function shouldShowWelcome(): boolean { + if (isOnboardingDisabled()) return false; + try { + const raw = localStorage.getItem(WELCOME_VERSION_KEY); + const seen = raw ? parseInt(raw, 10) : 0; + return !Number.isFinite(seen) || seen < CURRENT_WELCOME_VERSION; + } catch { + return false; + } +} + +export function hasCompletedTour(): boolean { + if (isOnboardingDisabled()) return true; + try { + const raw = localStorage.getItem(TOUR_VERSION_KEY); + const n = raw ? parseInt(raw, 10) : 0; + return Number.isFinite(n) && n >= CURRENT_TOUR_VERSION; + } catch { + return false; + } +} + +export function markTourCompleted(): void { + try { + localStorage.setItem(TOUR_VERSION_KEY, String(CURRENT_TOUR_VERSION)); + } catch { + // ignore + } +} diff --git a/src/lib/tour/welcome-tour.ts b/src/lib/tour/welcome-tour.ts new file mode 100644 index 00000000..40db3d2c --- /dev/null +++ b/src/lib/tour/welcome-tour.ts @@ -0,0 +1,472 @@ +import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; +import { resolve as resolvePath } from '$app/paths'; +import { driver, type Driver, type DriveStep } from 'driver.js'; +import 'driver.js/dist/driver.css'; +import { isOnboardingDisabled, markTourCompleted } from './onboarding-state'; + +export { + hasCompletedTour, + hasSeenWelcome, + isOnboardingDisabled, + markTourCompleted, + markWelcomed, + shouldShowWelcome, +} from './onboarding-state'; + +const SIDEBAR_TOGGLE_SEL = 'button[title="Toggle Sidebar"]'; +const SIDEBAR_ROOT_SEL = '[data-slot="sidebar"]'; +const SIDEBAR_LINK = (href: string) => `[data-slot="sidebar"] a[href$="${href}"]`; +const USER_MENU_SEL = 'header button:has(> img.rounded-full)'; +const TOUR = (tag: string) => `[data-tour="${tag}"]`; + +type SidebarState = 'desktop-expanded' | 'desktop-collapsed' | 'mobile-open' | 'mobile-closed'; + +function isMobileViewport(): boolean { + return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; +} + +function readSidebarState(): SidebarState { + if (isMobileViewport()) { + return document.querySelector('[data-slot="sidebar"][data-mobile="true"]') + ? 'mobile-open' + : 'mobile-closed'; + } + return document.querySelector('[data-slot="sidebar"][data-state="expanded"]') + ? 'desktop-expanded' + : 'desktop-collapsed'; +} + +function waitForElement(selector: string, timeoutMs = 5000): Promise { + const existing = document.querySelector(selector); + if (existing) return Promise.resolve(existing); + return new Promise((resolve) => { + const obs = new MutationObserver(() => { + const el = document.querySelector(selector); + if (el) { + obs.disconnect(); + clearTimeout(timer); + resolve(el); + } + }); + obs.observe(document.body, { childList: true, subtree: true }); + const timer = setTimeout(() => { + obs.disconnect(); + resolve(document.querySelector(selector)); + }, timeoutMs); + }); +} + +function waitForMobileSidebar(): Promise { + return new Promise((resolve) => setTimeout(resolve, 250)); +} + +interface TourStep { + // `action` advances on a real click of `element` (no Next button shown). + // `info` advances on the Next button. + kind: 'info' | 'action'; + // `sidebar` = element lives inside the sidebar drawer (must be open on mobile). + // `page` = element lives in the page content (sidebar should be closed on mobile). + // `any` = no sidebar management needed (default). + mobileContext?: 'sidebar' | 'page' | 'any'; + element?: string; + title: string; + description: string; + side?: DriveStep['popover'] extends infer P ? (P extends { side?: infer S } ? S : never) : never; + // If set, the step is skipped when the predicate returns true at tour-start. + skipIf?: () => boolean; +} + +function sidebarOverviewSteps(): TourStep[] { + return [ + { + kind: 'info', + mobileContext: 'sidebar', + element: SIDEBAR_LINK('/home'), + title: 'Home', + description: 'Your dashboard, with quick access to the shockers you control most often.', + side: 'right', + }, + { + kind: 'info', + mobileContext: 'sidebar', + element: SIDEBAR_LINK('/hubs'), + title: 'Hubs', + description: 'Your hubs live here. Pair new ones and check their status from this page.', + side: 'right', + }, + { + kind: 'info', + mobileContext: 'sidebar', + element: SIDEBAR_LINK('/shares/public'), + title: 'Public shares', + description: 'Generate links anyone can use, with limits and expiry you set.', + side: 'right', + }, + ]; +} + +function shockersDeepDive(): TourStep[] { + return [ + { + kind: 'action', + mobileContext: 'sidebar', + element: SIDEBAR_LINK('/shockers/own'), + title: 'Your shockers', + description: 'This is where your own shockers live. Click to open the page.', + side: 'right', + }, + { + kind: 'info', + mobileContext: 'page', + element: TOUR('shockers-add'), + title: 'Add a shocker', + description: + 'Pair a new shocker to one of your hubs. You can set its RF protocol, name, and limits when you add it.', + side: 'bottom', + }, + { + kind: 'info', + mobileContext: 'page', + element: TOUR('shockers-viewmode'), + title: 'Pick a control layout', + description: + 'Classic, Rich, or Simple. Switch between layouts depending on how you like to trigger shockers.', + side: 'bottom', + }, + { + kind: 'info', + mobileContext: 'page', + element: TOUR('shockers-layout'), + title: 'Group & layout options', + description: 'Group cards by hub and tweak how the grid is organised.', + side: 'bottom', + }, + ]; +} + +function userSharesDeepDive(): TourStep[] { + return [ + { + kind: 'action', + mobileContext: 'sidebar', + element: SIDEBAR_LINK('/shares/user'), + title: 'User shares', + description: 'Permanent shares between you and another OpenShock user. Click to take a look.', + side: 'right', + }, + { + kind: 'info', + mobileContext: 'page', + element: TOUR('user-shares-tabs'), + title: 'Three tabs', + description: + 'The tabs let you navigate between shares you give out, shares you receive, and pending invites.', + side: 'bottom', + }, + { + kind: 'info', + mobileContext: 'page', + element: TOUR('user-shares-tab-outgoing'), + title: 'Shares — outgoing', + description: + 'Everything you have shared with others. Each entry shows which shockers you granted and the permissions you set.', + side: 'bottom', + }, + { + kind: 'info', + mobileContext: 'page', + element: TOUR('user-shares-tab-incoming'), + title: 'Shared with Me', + description: + 'Shockers other users have granted you access to. They appear here once the owner accepts your request or sends you a share link.', + side: 'bottom', + }, + { + kind: 'info', + mobileContext: 'page', + element: TOUR('user-shares-tab-invites'), + title: 'Invites', + description: + 'Pending share requests waiting for the other party to accept. Once accepted they move to the Shares or Shared with Me tab.', + side: 'bottom', + }, + { + kind: 'info', + mobileContext: 'page', + element: TOUR('user-shares-new'), + title: 'Create a share', + description: + 'Pick a user, choose which shockers, and configure exactly what they can do: shock, vibrate, sound, intensity caps, durations.', + side: 'bottom', + }, + { + kind: 'info', + mobileContext: 'page', + element: TOUR('user-shares-redeem'), + title: 'Redeem a code', + description: + "Got a share code from someone else? Paste it here to accept the shockers they've shared with you.", + side: 'bottom', + }, + ]; +} + +function connectionsDeepDive(): TourStep[] { + return [ + { + kind: 'action', + mobileContext: 'sidebar', + element: SIDEBAR_LINK('/settings/connections'), + title: 'Connections', + description: + 'Link third-party accounts so you can sign in faster next time. Click to open it.', + side: 'right', + }, + { + kind: 'info', + mobileContext: 'page', + element: TOUR('connections-link'), + title: 'OAuth connections', + description: + "Link a Discord, GitHub, or other supported account and you'll be able to sign in with one click. No password needed.", + side: 'bottom', + }, + ]; +} + +function desktopSteps(initial: SidebarState): TourStep[] { + const startsExpanded = initial === 'desktop-expanded'; + + const steps: TourStep[] = []; + + if (startsExpanded) { + steps.push({ + kind: 'info', + element: SIDEBAR_ROOT_SEL, + title: 'Your navigation', + description: 'Everything you can reach in the app lives in this sidebar.', + side: 'right', + }); + steps.push(...sidebarOverviewSteps()); + steps.push({ + kind: 'action', + element: SIDEBAR_TOGGLE_SEL, + title: 'Collapse it', + description: 'Click to collapse the sidebar. Even collapsed, every icon is still navigable.', + side: 'bottom', + }); + } else { + steps.push({ + kind: 'info', + element: SIDEBAR_ROOT_SEL, + title: 'The icon rail', + description: 'When collapsed, the sidebar is a compact icon rail.', + side: 'right', + }); + steps.push({ + kind: 'action', + element: SIDEBAR_TOGGLE_SEL, + title: 'Expand the sidebar', + description: 'Click to open it up so we can walk through what each section does.', + side: 'bottom', + }); + steps.push(...sidebarOverviewSteps()); + } + + steps.push(...shockersDeepDive()); + steps.push(...userSharesDeepDive()); + steps.push(...connectionsDeepDive()); + + steps.push({ + kind: 'info', + element: USER_MENU_SEL, + title: 'Your account menu', + description: 'Profile, account settings, and logout are tucked in here.', + side: 'bottom', + skipIf: () => !document.querySelector(USER_MENU_SEL), + }); + + steps.push({ + kind: 'info', + title: "You're all set", + description: + 'Explore at your own pace. If we add a major new feature later, we may show you a fresh tour the next time you visit.', + }); + + return steps; +} + +function mobileSteps(): TourStep[] { + const steps: TourStep[] = []; + + steps.push({ + kind: 'info', + mobileContext: 'sidebar', + element: SIDEBAR_ROOT_SEL, + title: 'Your navigation', + description: 'Everything you can reach in the app lives here.', + side: 'right', + }); + + steps.push(...shockersDeepDive()); + steps.push(...userSharesDeepDive()); + steps.push(...connectionsDeepDive()); + + steps.push({ + kind: 'info', + title: "You're all set", + description: 'Tap any icon in the menu to navigate.', + }); + + return steps; +} + +function toDriverStep(step: TourStep): DriveStep { + return { + element: step.element, + // Highlighted element is only interactive on action steps (where the user + // is explicitly asked to click it). On info steps we don't want a stray + // click on a sidebar link to navigate away from the tour. + disableActiveInteraction: step.kind !== 'action', + popover: { + title: step.title, + description: step.description, + side: step.side ?? 'bottom', + // Close button is hidden because it sits behind the title element and is not clickable. + showButtons: step.kind === 'action' ? ['previous'] : ['next', 'previous'], + }, + }; +} + +/** + * Starts an interactive welcome tour. Steps adapt to the current sidebar + * state and require the user to click into pages themselves; we wait for + * each next highlight target to render before advancing. + */ +export async function startWelcomeTour(): Promise { + if (!browser || isOnboardingDisabled()) return; + const initial = readSidebarState(); + const allSteps = isMobileViewport() ? mobileSteps() : desktopSteps(initial); + const tourSteps = allSteps.filter((s) => !s.skipIf?.()); + const driverSteps = tourSteps.map(toDriverStep); + + // On mobile, ensure the sidebar is open before the first step. + if (isMobileViewport() && tourSteps[0]?.mobileContext === 'sidebar') { + if (readSidebarState() === 'mobile-closed') { + (document.querySelector(SIDEBAR_TOGGLE_SEL) as HTMLElement)?.click(); + await waitForMobileSidebar(); + } + } + + return new Promise((resolve) => { + const ctx: { d: Driver | null; cleanup: (() => void) | null; completed: boolean } = { + d: null, + cleanup: null, + completed: false, + }; + + const wireUpActionAdvance = async () => { + ctx.cleanup?.(); + ctx.cleanup = null; + + const idx = ctx.d!.getActiveIndex(); + if (idx === undefined) return; + const step = tourSteps[idx]; + + if (step?.kind !== 'action' || !step.element) return; + + const target = document.querySelector(step.element); + if (!target) return; + + const handler = async () => { + ctx.cleanup?.(); + ctx.cleanup = null; + const next = tourSteps[idx + 1]; + // On mobile, close sidebar before showing page content steps. + if (isMobileViewport() && next?.mobileContext === 'page') { + if (readSidebarState() === 'mobile-open') { + (document.querySelector(SIDEBAR_TOGGLE_SEL) as HTMLElement)?.click(); + await waitForMobileSidebar(); + } + } + if (next?.element) { + await waitForElement(next.element); + } else { + await new Promise((r) => setTimeout(r, 80)); + } + ctx.d!.moveNext(); + }; + target.addEventListener('click', handler, { once: true }); + ctx.cleanup = () => target.removeEventListener('click', handler); + }; + + const handleNextClick = async () => { + const idx = ctx.d!.getActiveIndex(); + if (idx === undefined) return ctx.d!.moveNext(); + const next = tourSteps[idx + 1]; + if (!next) ctx.completed = true; // last step — Done button + // On mobile, open sidebar before showing sidebar steps reached via Next. + if (isMobileViewport() && next?.mobileContext === 'sidebar') { + if (readSidebarState() === 'mobile-closed') { + (document.querySelector(SIDEBAR_TOGGLE_SEL) as HTMLElement)?.click(); + await waitForMobileSidebar(); + if (next.element) await waitForElement(next.element); + } + } + ctx.d!.moveNext(); + }; + + const handlePrevClick = async () => { + const idx = ctx.d!.getActiveIndex(); + if (idx === undefined) return ctx.d!.movePrevious(); + const prev = tourSteps[idx - 1]; + // On mobile, open sidebar when going back to a sidebar step. + if (isMobileViewport() && prev?.mobileContext === 'sidebar') { + if (readSidebarState() === 'mobile-closed') { + (document.querySelector(SIDEBAR_TOGGLE_SEL) as HTMLElement)?.click(); + await waitForMobileSidebar(); + if (prev.element) await waitForElement(prev.element); + } + } + // On mobile, close sidebar when going back to a page step. + if (isMobileViewport() && prev?.mobileContext === 'page') { + if (readSidebarState() === 'mobile-open') { + (document.querySelector(SIDEBAR_TOGGLE_SEL) as HTMLElement)?.click(); + await waitForMobileSidebar(); + } + } + ctx.d!.movePrevious(); + }; + + ctx.d = driver({ + showProgress: true, + allowClose: false, // close button is hidden (see showButtons above) + overlayClickBehavior: () => {}, + progressText: '{{current}} of {{total}}', + nextBtnText: 'Next', + prevBtnText: 'Back', + doneBtnText: 'Done', + steps: driverSteps, + onHighlighted: wireUpActionAdvance, + onNextClick: handleNextClick, + onPrevClick: handlePrevClick, + onCloseClick: (_el, _step, { driver: d }) => { + ctx.cleanup?.(); + markTourCompleted(); + d.destroy(); + resolve(); + }, + onDeselected: () => { + ctx.cleanup?.(); + ctx.cleanup = null; + }, + onDestroyed: () => { + ctx.cleanup?.(); + if (ctx.completed) goto(resolvePath('/home')); + resolve(); + }, + }); + ctx.d.drive(); + }); +} diff --git a/src/lib/utils/legacy-hash-redirect.test.ts b/src/lib/utils/legacy-hash-redirect.test.ts new file mode 100644 index 00000000..e8e0e3db --- /dev/null +++ b/src/lib/utils/legacy-hash-redirect.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mapLegacyHashRoute, redirectLegacyHashRoute } from './legacy-hash-redirect'; + +describe('mapLegacyHashRoute', () => { + it('returns null when path does not start with /', () => { + expect(mapLegacyHashRoute('dashboard')).toBeNull(); + expect(mapLegacyHashRoute('')).toBeNull(); + expect(mapLegacyHashRoute('http://example.com')).toBeNull(); + }); + + describe('exact routes', () => { + const cases: [string, string][] = [ + ['/', '/home'], + ['/dashboard', '/home'], + ['/dashboard/home', '/home'], + ['/dashboard/shockers', '/shockers/own'], + ['/dashboard/shockers/own', '/shockers/own'], + ['/dashboard/shockers/shared', '/shockers/shared'], + ['/dashboard/shares', '/shares/public'], + ['/dashboard/shares/links', '/shares/public'], + ['/dashboard/admin', '/admin/online-hubs'], + ['/dashboard/admin/users', '/admin/users'], + ['/dashboard/admin/online-devices', '/admin/online-hubs'], + ['/dashboard/profile', '/settings/account'], + ['/dashboard/profile/account', '/settings/account'], + ['/dashboard/profile/settings', '/settings/account'], + ['/dashboard/profile/license', '/settings/account'], + ['/dashboard/profile/connections', '/settings/connections'], + ['/dashboard/profile/connections/patreon', '/settings/connections'], + ['/dashboard/devices', '/hubs'], + ['/dashboard/tokens', '/settings/api-tokens'], + ['/account', '/login'], + ['/account/login', '/login'], + ['/account/signup', '/signup'], + ['/account/password', '/forgot-password'], + ['/account/password/reset', '/forgot-password'], + ['/public', '/'], + ['/public/home', '/'], + ['/public/proxy/token', '/t'], + ]; + + for (const [input, expected] of cases) { + it(`maps "${input}" → "${expected}"`, () => { + expect(mapLegacyHashRoute(input)).toBe(expected); + }); + } + }); + + describe('exact routes preserve query string', () => { + it('appends query string to exact match result', () => { + expect(mapLegacyHashRoute('/dashboard?foo=bar')).toBe('/home?foo=bar'); + expect(mapLegacyHashRoute('/account/login?redirect=/home')).toBe('/login?redirect=/home'); + }); + }); + + describe('pattern routes', () => { + const guid = '12345678-1234-1234-1234-123456789abc'; + + it('maps /dashboard/shockers/{guid}/shares → /shockers/{guid}/edit', () => { + expect(mapLegacyHashRoute(`/dashboard/shockers/${guid}/shares`)).toBe( + `/shockers/${guid}/edit` + ); + }); + + it('maps /dashboard/shockers/{guid}/logs → /shockers/logs/{guid}', () => { + expect(mapLegacyHashRoute(`/dashboard/shockers/${guid}/logs`)).toBe(`/shockers/logs/${guid}`); + }); + + it('maps /dashboard/shares/links/{guid} → /shares/public/{guid}/edit', () => { + expect(mapLegacyHashRoute(`/dashboard/shares/links/${guid}`)).toBe( + `/shares/public/${guid}/edit` + ); + }); + + it('maps /dashboard/devices/{guid}/setup → /hubs', () => { + expect(mapLegacyHashRoute(`/dashboard/devices/${guid}/setup`)).toBe('/hubs'); + }); + + it('maps /dashboard/devices/{guid}/ota → /hubs/{guid}/update', () => { + expect(mapLegacyHashRoute(`/dashboard/devices/${guid}/ota`)).toBe(`/hubs/${guid}/update`); + }); + + it('maps /account/password/recover/{token}/{token} → /forgot-password', () => { + expect(mapLegacyHashRoute('/account/password/recover/abc123/xyz456')).toBe( + '/forgot-password' + ); + }); + + it('maps /account/activate/{token}/{token} → /activate', () => { + expect(mapLegacyHashRoute('/account/activate/abc123/xyz456')).toBe('/activate'); + }); + + it('maps /public/shares/links/{guid} → /shares/public/{guid}', () => { + expect(mapLegacyHashRoute(`/public/shares/links/${guid}`)).toBe(`/shares/public/${guid}`); + }); + + it('maps /public/proxy/shares/links/{guid} → /shares/public/{guid}', () => { + expect(mapLegacyHashRoute(`/public/proxy/shares/links/${guid}`)).toBe( + `/shares/public/${guid}` + ); + }); + + it('maps /public/proxy/shares/code/{guid} → /usc/{guid}', () => { + expect(mapLegacyHashRoute(`/public/proxy/shares/code/${guid}`)).toBe(`/usc/${guid}`); + }); + }); + + describe('pattern routes preserve query string', () => { + const guid = '12345678-1234-1234-1234-123456789abc'; + + it('appends query string to pattern match result', () => { + expect(mapLegacyHashRoute(`/dashboard/shockers/${guid}/logs?page=2`)).toBe( + `/shockers/logs/${guid}?page=2` + ); + }); + }); + + describe('unknown legacy routes', () => { + it('returns /home for unknown paths starting with /', () => { + expect(mapLegacyHashRoute('/unknown/route')).toBe('/home'); + expect(mapLegacyHashRoute('/dashboard/nonexistent')).toBe('/home'); + }); + + it('appends query string even for unknown routes', () => { + expect(mapLegacyHashRoute('/unknown?foo=1')).toBe('/home?foo=1'); + }); + }); +}); + +describe('redirectLegacyHashRoute', () => { + let replaceMock: ReturnType; + + function setHash(hash: string) { + vi.stubGlobal('location', { hash, replace: replaceMock }); + } + + beforeEach(() => { + replaceMock = vi.fn(); + vi.stubGlobal('location', { hash: '', replace: replaceMock }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('does nothing when hash is empty', () => { + setHash(''); + redirectLegacyHashRoute(); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it('does nothing when hash does not start with #/', () => { + setHash('#about'); + redirectLegacyHashRoute(); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it('redirects a known hash route', () => { + setHash('#/dashboard'); + redirectLegacyHashRoute(); + expect(replaceMock).toHaveBeenCalledWith('/home'); + }); + + it('redirects an unknown hash route to /home', () => { + setHash('#/some/unknown/path'); + redirectLegacyHashRoute(); + expect(replaceMock).toHaveBeenCalledWith('/home'); + }); + + it('does not follow a protocol-relative path — redirects to /home instead', () => { + setHash('#/dashboard'); + // Pass an unsafe mapper to exercise the defence-in-depth guard directly, + // since mapLegacyHashRoute itself always returns safe paths. + redirectLegacyHashRoute('', () => '//evil.com/steal'); + expect(replaceMock).toHaveBeenCalledWith('/home'); + }); + + it('does not follow a scheme-prefixed path — redirects to /home instead', () => { + setHash('#/dashboard'); + redirectLegacyHashRoute('', () => 'javascript:alert(1)'); + expect(replaceMock).toHaveBeenCalledWith('/home'); + }); + + it('preserves query string when redirecting', () => { + setHash('#/dashboard?tab=overview'); + redirectLegacyHashRoute(); + expect(replaceMock).toHaveBeenCalledWith('/home?tab=overview'); + }); + + it('prepends pathBase to the redirect target', () => { + setHash('#/dashboard/shockers'); + redirectLegacyHashRoute('/app'); + expect(replaceMock).toHaveBeenCalledWith('/app/shockers/own'); + }); +}); diff --git a/src/lib/utils/legacy-hash-redirect.ts b/src/lib/utils/legacy-hash-redirect.ts new file mode 100644 index 00000000..13802eee --- /dev/null +++ b/src/lib/utils/legacy-hash-redirect.ts @@ -0,0 +1,88 @@ +const GUID = '([0-9a-fA-F-]{36})'; +const TOKEN = '([^/]+)'; + +const EXACT: Record = { + '/': '/home', + '/dashboard': '/home', + '/dashboard/home': '/home', + '/dashboard/shockers': '/shockers/own', + '/dashboard/shockers/own': '/shockers/own', + '/dashboard/shockers/shared': '/shockers/shared', + '/dashboard/shares': '/shares/public', + '/dashboard/shares/links': '/shares/public', + '/dashboard/admin': '/admin/online-hubs', + '/dashboard/admin/users': '/admin/users', + '/dashboard/admin/online-devices': '/admin/online-hubs', + '/dashboard/profile': '/settings/account', + '/dashboard/profile/account': '/settings/account', + '/dashboard/profile/settings': '/settings/account', + '/dashboard/profile/license': '/settings/account', + '/dashboard/profile/connections': '/settings/connections', + '/dashboard/profile/connections/patreon': '/settings/connections', + '/dashboard/devices': '/hubs', + '/dashboard/tokens': '/settings/api-tokens', + '/account': '/login', + '/account/login': '/login', + '/account/signup': '/signup', + '/account/password': '/forgot-password', + '/account/password/reset': '/forgot-password', + '/public': '/', + '/public/home': '/', + '/public/proxy/token': '/t', +}; + +const PATTERNS: { re: RegExp; to: string }[] = [ + { re: new RegExp(`^/dashboard/shockers/${GUID}/shares$`), to: '/shockers/$1/edit' }, + { re: new RegExp(`^/dashboard/shockers/${GUID}/logs$`), to: '/shockers/logs/$1' }, + { re: new RegExp(`^/dashboard/shares/links/${GUID}$`), to: '/shares/public/$1/edit' }, + { re: new RegExp(`^/dashboard/devices/${GUID}/setup$`), to: '/hubs' }, + { re: new RegExp(`^/dashboard/devices/${GUID}/ota$`), to: '/hubs/$1/update' }, + { re: new RegExp(`^/account/password/recover/${TOKEN}/${TOKEN}$`), to: '/forgot-password' }, + { re: new RegExp(`^/account/activate/${TOKEN}/${TOKEN}$`), to: '/activate' }, + { re: new RegExp(`^/public/shares/links/${GUID}$`), to: '/shares/public/$1' }, + { re: new RegExp(`^/public/proxy/shares/links/${GUID}$`), to: '/shares/public/$1' }, + { re: new RegExp(`^/public/proxy/shares/code/${GUID}$`), to: '/usc/$1' }, +]; + +/** + * Maps a legacy WebUI hash route (without the leading `#`) to its new path, + * or returns `null` if the path doesn't look like a legacy route. + */ +export function mapLegacyHashRoute(legacyPath: string): string | null { + if (!legacyPath.startsWith('/')) return null; + + const qIdx = legacyPath.indexOf('?'); + const path = qIdx === -1 ? legacyPath : legacyPath.slice(0, qIdx); + const search = qIdx === -1 ? '' : legacyPath.slice(qIdx); + + if (Object.prototype.hasOwnProperty.call(EXACT, path)) return EXACT[path] + search; + + for (const { re, to } of PATTERNS) { + if (re.test(path)) return path.replace(re, to) + search; + } + + // Unknown legacy route → home so the user isn't dumped on a 404. + return '/home' + search; +} + +/** + * If the current URL has a legacy `#/...` hash route, replace it with the + * mapped new path. Call before the SvelteKit router boots. + * + * @param pathBase - The SvelteKit `base` path (from `$app/paths`). Prepended + * to every redirect so deployments under a sub-path work correctly. + * @param mapper - Override for `mapLegacyHashRoute`; exposed for testing. + */ +export function redirectLegacyHashRoute(pathBase = '', mapper = mapLegacyHashRoute): void { + const hash = location.hash; + if (!hash || hash.charAt(1) !== '/') return; + + const target = mapper(hash.slice(1)); + if (!target) return; + + // Defense-in-depth: only allow same-origin, root-relative redirects. + // Reject protocol-relative (`//...`) and scheme-prefixed (`http:...`, `javascript:...`) values. + const isSafeInternalPath = + target.startsWith('/') && !target.startsWith('//') && !/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(target); + location.replace(pathBase + (isSafeInternalPath ? target : '/home')); +} diff --git a/src/routes/(app)/settings/connections/+page.svelte b/src/routes/(app)/settings/connections/+page.svelte index 12bb32fd..b182939d 100644 --- a/src/routes/(app)/settings/connections/+page.svelte +++ b/src/routes/(app)/settings/connections/+page.svelte @@ -117,7 +117,12 @@ {#snippet child({ props })} - diff --git a/src/routes/(app)/shares/user/+layout.svelte b/src/routes/(app)/shares/user/+layout.svelte index ba4613b8..bce0f4d7 100644 --- a/src/routes/(app)/shares/user/+layout.svelte +++ b/src/routes/(app)/shares/user/+layout.svelte @@ -81,24 +81,38 @@ - -
- + - navigateTo('outgoing')}>Shares - navigateTo('incoming')} - >Shared with Me navigateTo('outgoing')} + data-tour="user-shares-tab-outgoing">Shares + navigateTo('incoming')} + data-tour="user-shares-tab-incoming">Shared with Me + navigateTo('invites')} + data-tour="user-shares-tab-invites">Invites - navigateTo('invites')}>Invites
diff --git a/src/routes/(app)/shockers/own/+page.svelte b/src/routes/(app)/shockers/own/+page.svelte index f853cbf9..006b7eca 100644 --- a/src/routes/(app)/shockers/own/+page.svelte +++ b/src/routes/(app)/shockers/own/+page.svelte @@ -1,4 +1,6 @@ -
- +
grid?.handlePointerMove(e)} +> + + + logo logo -

+

The go-to platform for safe, reliable, real low-latency remote shocking.
{#if data.ok} {data.deviceCount} people online right now. {/if}

-
+
Learn More diff --git a/src/routes/WelcomeScreen.svelte b/src/routes/WelcomeScreen.svelte new file mode 100644 index 00000000..8eb3a913 --- /dev/null +++ b/src/routes/WelcomeScreen.svelte @@ -0,0 +1,337 @@ + + + + +{#if open} + +{/if} + +{#snippet stepWelcome()} +
+

+ Welcome to the new +

+ + OpenShock + +

+ frontend +

+ +

+ Take the quick tour, or skip ahead and dive in. +

+
+{/snippet} + +{#snippet stepFeatures()} +

+ Over 2 years of work. Faster, fully mobile-friendly, + and a much better base for everything we want to build next. +

+
    + {#each [{ t: 'Reworked shocker dashboard', d: 'Groundwork for customizable layouts.' }, { t: 'Web Terminal', d: 'Configure your hub and flash firmware straight from the browser. No installs.' }, { t: 'OAuth sign-in', d: 'Log in with accounts you already have.' }, { t: 'Better sharing', d: 'Public links, user shares, invite tracking, and per-shocker shortcuts.' }] as item, i (item.t)} +
  • +
    {item.t}
    +
    {item.d}
    +
  • + {/each} +
+{/snippet} + +{#snippet stepRedirects()} +

+ Old bookmarks like + + openshock.app/#/dashboard/home + + forward automatically. Nothing for you to do. +

+

+ The legacy site stays online during the transition. We'll announce a sunset date once the new + site has full feature parity. +

+{/snippet} + +{#snippet stepFeedback()} +

Something broken or missing? The faster you report it, the faster we can fix it.

+ + + +

Lots more is landing soon. Thanks for sticking with us.

+{/snippet}