Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -173,6 +174,8 @@ export default async function BoardLayout(props: PropsWithChildren<BoardLayoutPr
<Affix offsetBottom={0}>
<QueueControlBar board={board_name} boardDetails={boardDetails} angle={angle} />
</Affix>

<OnboardingTour />
</PartyProvider>
</GraphQLQueueProvider>
</ConnectionSettingsProvider>
Expand Down
5 changes: 5 additions & 0 deletions packages/web/app/components/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
106 changes: 106 additions & 0 deletions packages/web/app/components/onboarding-tour/onboarding-tour.tsx
Original file line number Diff line number Diff line change
@@ -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<TourProps['steps']>([]);
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<TourProps['steps']> = [
{
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 (
<Tour
open={open}
onClose={handleClose}
onFinish={handleClose}
steps={steps}
rootClassName="onboarding-tour"
scrollIntoViewOptions={{ block: 'center' }}
/>
);
}
Loading