diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx index 9f4697c9b..db05a9363 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx @@ -16,6 +16,7 @@ import { PartyProvider } from '@/app/components/party-manager/party-context'; import { BoardSessionBridge } from '@/app/components/persistent-session'; import { Metadata } from 'next'; import BoardPageSkeleton from '@/app/components/board-page/board-page-skeleton'; +import OnboardingTour from '@/app/components/onboarding-tour/onboarding-tour'; // Helper to get board details for any board type function getBoardDetailsUniversal(parsedParams: ParsedBoardRouteParameters): BoardDetails { @@ -173,6 +174,8 @@ export default async function BoardLayout(props: PropsWithChildren + + diff --git a/packages/web/app/components/index.css b/packages/web/app/components/index.css index a6e16fd49..b2f207a73 100644 --- a/packages/web/app/components/index.css +++ b/packages/web/app/components/index.css @@ -270,6 +270,11 @@ html { box-shadow: 0 0 0 2px #fff; } +/* Onboarding tour - constrain width for mobile-friendly display */ +.onboarding-tour .ant-tour-inner { + max-width: min(340px, calc(100vw - 32px)); +} + /* Override Atlaskit drag-and-drop indicator color to match theme Note: !important is required here as third-party library sets inline styles */ [data-drop-indicator-edge] { diff --git a/packages/web/app/components/onboarding-tour/onboarding-tour.tsx b/packages/web/app/components/onboarding-tour/onboarding-tour.tsx new file mode 100644 index 000000000..7028479e0 --- /dev/null +++ b/packages/web/app/components/onboarding-tour/onboarding-tour.tsx @@ -0,0 +1,106 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { Tour, type TourProps } from 'antd'; +import { usePathname } from 'next/navigation'; + +const TOUR_STORAGE_KEY = 'boardsesh-onboarding-completed'; + +/** + * Onboarding tour that shows on the list page for first-time users. + * Uses Ant Design Tour with constrained step widths and avoids + * anchoring on interactive elements like the climb card header. + */ +export default function OnboardingTour() { + const [open, setOpen] = useState(false); + const [steps, setSteps] = useState([]); + const pathname = usePathname(); + + const isListPage = pathname.includes('/list'); + + useEffect(() => { + // Only show on the list page + if (!isListPage) return; + + // Check if user has already completed the tour + try { + if (localStorage.getItem(TOUR_STORAGE_KEY)) return; + } catch { + // localStorage not available + return; + } + + // Wait for DOM elements to render + const timer = setTimeout(() => { + const queueBar = document.querySelector('[data-testid="queue-control-bar"]') as HTMLElement; + const illuminateButton = document.getElementById('button-illuminate'); + const firstClimbCardBody = document.querySelector('[data-testid="climb-card"] .ant-card-body') as HTMLElement; + + const tourSteps: NonNullable = [ + { + title: 'Welcome to Boardsesh!', + description: + 'Browse climbs, build a queue, and control your board. Let\u2019s take a quick look around.', + // No target = centered overlay + }, + ]; + + if (firstClimbCardBody) { + tourSteps.push({ + title: 'Select a Climb', + description: + 'Double-tap the board image to select a climb. It will appear in the queue bar at the bottom.', + target: () => firstClimbCardBody, + placement: 'bottom', + }); + } + + if (queueBar) { + tourSteps.push({ + title: 'Queue & Navigation', + description: + 'Your current climb shows here. Swipe or use arrows to move through your queue.', + target: () => queueBar, + placement: 'top', + }); + } + + if (illuminateButton) { + tourSteps.push({ + title: 'Light Up the Board', + description: + 'Connect via Bluetooth to illuminate holds on your board for the selected climb.', + target: () => illuminateButton, + placement: 'bottom', + }); + } + + setSteps(tourSteps); + setOpen(true); + }, 800); + + return () => clearTimeout(timer); + }, [isListPage]); + + const handleClose = useCallback(() => { + setOpen(false); + try { + localStorage.setItem(TOUR_STORAGE_KEY, 'true'); + } catch { + // localStorage not available + } + }, []); + + if (!isListPage || steps.length === 0) return null; + + return ( + + ); +}