From 7442266a24944e4c6f80aeb8a3ebee2b3913335d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 10:35:10 +0000 Subject: [PATCH] feat: Add onboarding tour for first-time users on the climb list page Uses Ant Design Tour component with constrained width (max 340px) to prevent the tour from rendering too wide on mobile. The first step is a centered overlay (no target) instead of anchoring on the climb card header, which is double-clickable and could cause unintended interactions. Subsequent steps anchor on the card body, queue bar, and bluetooth button. Tour completion state is persisted in localStorage. https://claude.ai/code/session_01Y6Xx7PmoC1c3QJcf9KU2gu --- .../[size_id]/[set_ids]/[angle]/layout.tsx | 3 + packages/web/app/components/index.css | 5 + .../onboarding-tour/onboarding-tour.tsx | 106 ++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 packages/web/app/components/onboarding-tour/onboarding-tour.tsx 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 ( + + ); +}