Skip to main content

Shepherd.js Tour Integration Guide — FlowPOS PWA

Complete guide for adding in-app guided onboarding tours to React forms using Shepherd.js within the FlowPOS frontend-pwa.


Table of Contents

  1. Architecture Overview
  2. System Components
  3. Tour Definition Schema
  4. Step-by-Step: Adding a New Tour
  5. Handling Tabs, Modals & Conditional UI
  6. DOM Anchoring with data-tour
  7. Shepherd Runner Internals
  8. i18n Integration
  9. Persistence & State Management
  10. Role-Based Filtering
  11. Styling & Theming
  12. Troubleshooting
  13. Testing Checklist
  14. Prompt Pack for AI-Assisted Development

1. Architecture Overview

The tour system follows a registry → hook → runner pattern:

tourRegistry (definitions)

useTour (hook — state, filtering, lifecycle)

shepherdRunner (execution — Shepherd.js instance, tab switching, DOM waiting)

TourLauncher (UI — trigger button, tour list, reset)

Key design decisions:

  • Shepherd.js is used for tours that span multiple tabs or require programmatic DOM preparation (e.g. the sales workflow tour). It provides beforeShowPromise which is critical for async tab switching.
  • Driver.js is used for simpler tours where all targets exist in the DOM simultaneously (e.g. the main navigation tour).
  • localStorage persists completion state per user+business pair — no backend storage needed.
  • Custom DOM events bridge the gap between the tour runner (vanilla JS) and React state (tab switching).

File Map

FilePurpose
src/tours/types.tsTypeScript types for tour definitions and state
src/tours/index.tsTour registry — maps TourIdTourDefinition
src/tours/shepherdRunner.tsShepherd.js tour executor with tab switching logic
src/tours/mainTour.tsMain onboarding tour definition
src/tours/salesTour.tsSales workflow tour definition
src/tours/tours.cssTheme overrides for both Driver.js and Shepherd.js
src/hooks/useTour.tsReact hook — state management, auto-launch, start/reset
src/components/TourLauncher.tsxUI component — help button + tour menu dropdown
src/lib/constants.tsStorage keys and custom event names

2. System Components

2.1 Tour Registry (src/tours/index.ts)

Central map of all tour definitions, keyed by TourId:

import { mainTour } from "./mainTour";
import { salesTour } from "./salesTour";
import type { TourDefinition, TourId } from "./types";

export const tourRegistry: Record<TourId, TourDefinition> = {
"main-onboarding": mainTour,
"sales-workflow": salesTour,
};

When you add a new tour, you register it here and extend the TourId union type.

2.2 useTour Hook (src/hooks/useTour.ts)

The hook is the central API for tour consumers:

const {
startTour, // (tourId: TourId) => void — launches a tour
checkAndAutoLaunch, // () => boolean — checks for pending auto-launch tours
resetTour, // (tourId: TourId) => void — clears completion state
isTourCompleted, // (tourId: TourId) => boolean
tourStates, // TourStateStore — all tour states
availableTours, // TourDefinition[] — filtered by current user role
} = useTour();

Routing logic inside startTour:

  1. Looks up the TourDefinition from the registry.
  2. Filters steps by the current user's role.
  3. For Shepherd tours: calls startShepherdSalesWorkflowTour() with callbacks.
  4. For Driver.js tours: optionally filters by DOM presence, then calls driver().drive().

2.3 TourLauncher (src/components/TourLauncher.tsx)

A help-icon button (CircleHelp) in the app header that opens a dropdown listing available tours. Each tour shows:

  • Name and description (translated)
  • Completion status (green "Completed" or gray "Not completed")
  • "Start Tour" button
  • "Reset" button (only when completed)

Before starting, the launcher checks if navigation is needed (requiredPath, requiredMainSelectedForm) and navigates with a 500ms delay to allow React to mount the target route.

2.4 Shepherd Runner (src/tours/shepherdRunner.ts)

The runner is responsible for:

  • Creating a Shepherd.Tour instance with modal overlay
  • Adding steps with beforeShowPromise hooks for async DOM preparation
  • Mapping step targets to required tabs via STEP_TAB_MAP
  • Dispatching CustomEvent to switch React tabs
  • Waiting for the DOM element to appear (with retry logic)
  • Wiring up Prev/Next/Done buttons with i18n labels
  • Calling onDone/onCancel callbacks for state persistence

2.5 AuthenticatedLayout (Auto-Launch)

The layout component calls checkAndAutoLaunch() on mount with a two-phase retry:

Mount → wait 500ms → checkAndAutoLaunch()
├─ launched? → done
└─ not launched? → wait 1000ms more → checkAndAutoLaunch() (retry)

This handles cases where DOM elements haven't rendered yet on first check.


3. Tour Definition Schema

TourDefinition

interface TourDefinition {
id: TourId; // Unique identifier (union type)
nameKey: string; // i18n key for tour name
descriptionKey: string; // i18n key for tour description
roles: UniqueRoleName[]; // Which roles can see this tour
autoLaunch: boolean; // Auto-start on first visit?
steps: TourStepDefinition[]; // Ordered list of steps
requiredPath?: string; // Route the user must be on (e.g. "/main")
requiredMainSelectedForm?: string; // location.state.selectedForm value needed
skipDomPresenceFilter?: boolean; // Skip "all targets must exist" check
}

TourStepDefinition

interface TourStepDefinition {
target: string; // CSS selector, typically [data-tour="name"]
titleKey: string; // i18n key for step title
descriptionKey: string; // i18n key for step description
roles?: UniqueRoleName[];// Optional per-step role filter
side?: "top" | "bottom" | "left" | "right"; // Popover placement
}

TourStepState (persisted)

interface TourStepState {
completed: boolean;
completedAt?: string; // ISO timestamp
autoLaunchSeen: boolean; // Prevents re-triggering auto-launch
}

4. Step-by-Step: Adding a New Tour

Step 1: Extend the TourId union

In src/tours/types.ts:

export type TourId = "main-onboarding" | "sales-workflow" | "inventory-workflow";
// ^^^^^^^^^^^^^^^^^^^

Step 2: Create the tour definition file

Create src/tours/inventoryTour.ts:

import { UniqueRoleName } from "@flowpos-workspace/global/enums/role.enums";
import type { TourDefinition } from "./types";

export const inventoryTour: TourDefinition = {
id: "inventory-workflow",
nameKey: "tour.inventory.name",
descriptionKey: "tour.inventory.description",
roles: [
UniqueRoleName.Owner,
UniqueRoleName.Admin,
UniqueRoleName.StoreManager,
],
autoLaunch: false,
requiredPath: "/main",
// If your tour spans tabs:
// skipDomPresenceFilter: true,
// requiredMainSelectedForm: "/forms/inventory",
steps: [
{
target: '[data-tour="inventory-search"]',
titleKey: "tour.inventory.search.title",
descriptionKey: "tour.inventory.search.description",
side: "bottom",
},
{
target: '[data-tour="inventory-filters"]',
titleKey: "tour.inventory.filters.title",
descriptionKey: "tour.inventory.filters.description",
side: "bottom",
},
{
target: '[data-tour="inventory-add-product"]',
titleKey: "tour.inventory.addProduct.title",
descriptionKey: "tour.inventory.addProduct.description",
side: "left",
},
],
};

Step 3: Register the tour

In src/tours/index.ts:

import { inventoryTour } from "./inventoryTour";

export const tourRegistry: Record<TourId, TourDefinition> = {
"main-onboarding": mainTour,
"sales-workflow": salesTour,
"inventory-workflow": inventoryTour,
};

Step 4: Add data-tour attributes to target components

In your React components, add the data-tour attribute to the actual DOM element that should be highlighted:

// Direct HTML element — works immediately
<input data-tour="inventory-search" placeholder="Search products..." />

// Shadcn component — verify prop forwarding (see Section 6)
<Button data-tour="inventory-add-product">Add Product</Button>

Step 5: Add i18n translation keys

In src/i18n/locales/en.json:

{
"tour": {
"inventory": {
"name": "Inventory Tour",
"description": "Learn how to manage your product inventory",
"search": {
"title": "Product Search",
"description": "Use this search bar to quickly find products by name, SKU, or barcode."
},
"filters": {
"title": "Filters",
"description": "Narrow down your inventory view by category, stock level, or location."
},
"addProduct": {
"title": "Add New Product",
"description": "Click here to create a new product in your catalog."
}
}
}
}

Repeat for es.json (and any other locales).

Step 6: Wire up the Shepherd runner (if multi-tab)

If your tour is single-page with all targets visible, the existing Driver.js path in useTour handles it automatically — no runner changes needed.

If your tour spans tabs or has conditional UI, you need to:

  1. Create a runner function (or extend shepherdRunner.ts)
  2. Add a STEP_TAB_MAP for your tour's step-to-tab mapping
  3. Define and export a custom event constant in src/lib/constants.ts
  4. Add an event listener in the target form component
  5. Route to the new runner in useTour.ts startTour()

See Section 5 for the full pattern.

Step 7: Test

Follow the Testing Checklist.


5. Handling Tabs, Modals & Conditional UI

This is the most complex part of the tour system. When a tour step targets an element that only exists when a specific tab is active (or a modal is open), you must:

5.1 The Problem

React tabs unmount/remount content. If Shepherd tries to attach to [data-tour="cart-area"] but the "items" tab isn't active, the element doesn't exist in the DOM.

5.2 The Solution: Custom Events + beforeShowPromise

Architecture:

shepherdRunner.ts                          React Form Component
│ │
│ 1. Dispatch CustomEvent │
│ { tab: "items" } │
├──────────────────────────────────────────→│
│ │ 2. setActiveTab("items")
│ │ 3. React re-renders
│ 4. waitForSelector('[data-tour="x"]') │
│ (polls DOM every 50ms) │
│ │
│ 5. Element found → step renders │
▼ ▼

Step-by-step implementation:

A. Define the tab map in your runner

// Maps data-tour value → tab name that must be active
const STEP_TAB_MAP: Record<string, string> = {
"customer-section": "customer",
"customer-next": "customer",
"cart-area": "items",
"payment-button": "checkout",
};

B. Define a custom event constant

In src/lib/constants.ts:

export const FLOWPOS_TOUR_SET_SALE_TAB = "flowpos-tour-set-sale-tab";
// For a new form:
export const FLOWPOS_TOUR_SET_INVENTORY_TAB = "flowpos-tour-set-inventory-tab";

C. Dispatch the event in beforeShowPromise

The runner's ensureTabAndWait function:

async function ensureTabAndWait(dataTour: string, selector: string): Promise<void> {
const requiredTab = STEP_TAB_MAP[dataTour];

if (requiredTab) {
// Tell React to switch tabs
window.dispatchEvent(
new CustomEvent(FLOWPOS_TOUR_SET_SALE_TAB, {
detail: { tab: requiredTab },
})
);
// Wait for React to commit the state change
await waitForDomAfterReactUpdate();
}

// Wait for the target element to appear and be visible
await waitForSelector(selector, 12000);
}

D. Listen in the React form component

// In your form component (e.g., SaleForm)
useEffect(() => {
const handler = (event: Event) => {
const { tab } = (event as CustomEvent<{ tab: string }>).detail;
if (["customer", "items", "checkout"].includes(tab)) {
setActiveTab(tab);
}
};
window.addEventListener(FLOWPOS_TOUR_SET_SALE_TAB, handler);
return () => window.removeEventListener(FLOWPOS_TOUR_SET_SALE_TAB, handler);
}, []);

E. Set skipDomPresenceFilter: true in the tour definition

This tells useTour not to filter out steps whose targets aren't currently in the DOM:

export const salesTour: TourDefinition = {
// ...
skipDomPresenceFilter: true,
// ...
};

5.3 Timing: waitForDomAfterReactUpdate()

This utility ensures React has committed its state update before we query the DOM:

function waitForDomAfterReactUpdate(): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => { // 1. Yield to microtask queue
requestAnimationFrame(() => { // 2. Wait for paint
requestAnimationFrame(() => { // 3. Wait for next paint (React commit)
resolve();
});
});
}, 0);
});
}

This is more reliable than a fixed setTimeout(500) because it ties to the actual browser rendering cycle.

5.4 Handling Modals

For steps targeting elements inside modals:

  1. Open the modal in beforeShowPromise (dispatch a custom event or call a function)
  2. Wait for the modal's content to mount with waitForSelector
  3. Optionally close the modal when moving to the next step (use Shepherd's hide event)
// Example: open a modal before a step
tour.addStep({
id: "modal-content",
attachTo: { element: '[data-tour="modal-field"]', on: "bottom" },
beforeShowPromise: async () => {
window.dispatchEvent(new CustomEvent("tour-open-modal", { detail: { modal: "discount" } }));
await waitForSelector('[data-tour="modal-field"]', 8000);
},
// ...
});

6. DOM Anchoring with data-tour

6.1 The Pattern

All tour step targets use data-tour attributes as CSS selectors:

<div data-tour="customer-section">...</div>

Selector in step definition:

target: '[data-tour="customer-section"]'

6.2 Naming Convention

Use kebab-case descriptive names that indicate the feature area:

nav-sales           — Navigation: sales section
new-sale-button — Action: create new sale
customer-section — Area: customer form section
payment-button — Action: complete payment
inventory-search — Feature: inventory search bar

6.3 Ensuring Props Reach the DOM

HTML elements always accept data-* attributes — no issues.

Shadcn/ui components — most forward props to the underlying DOM element via React.forwardRef and spread ...props. Verify by inspecting the rendered DOM.

Components that may NOT forward data-*:

ComponentForwards?Workaround
<Button>YesDirect usage works
<Input>YesDirect usage works
<Select>PartialWrap in a <div data-tour="...">
<Dialog>No (portal)Place data-tour on trigger or wrap content
<Tabs>PartialPlace data-tour on TabsContent children
Custom wrappersVariesCheck if ...rest is spread to the root element

Verification method:

// In browser DevTools:
document.querySelector('[data-tour="your-target"]')
// Should return the element. If null, the prop isn't reaching the DOM.

6.4 Wrapper Pattern for Stubborn Components

When a component swallows data-tour, wrap it:

// Instead of:
<Select data-tour="category-filter" />

// Use:
<div data-tour="category-filter">
<Select />
</div>

This ensures the highlight covers the component while the selector always resolves.


7. Shepherd Runner Internals

7.1 Tour Instance Configuration

const tour = new Shepherd.Tour({
tourName: tourId,
useModalOverlay: true, // Dark backdrop behind highlighted element
exitOnEsc: true, // ESC key cancels tour
keyboardNavigation: false, // Disable arrow key navigation (conflicts with form inputs)
defaultStepOptions: {
canClickTarget: false, // Prevent clicking highlighted element during tour
modalOverlayOpeningPadding: 8, // Space around highlighted element
modalOverlayOpeningRadius: 8, // Rounded corners for highlight cutout
scrollTo: {
behavior: "smooth",
block: "nearest",
inline: "nearest",
},
},
});

7.2 Step Anatomy

Each step added to the tour:

tour.addStep({
id: "customer-section", // Unique step ID
title: t("tour.sales.customerSection.title"), // Translated title
text: stepDescriptionHtml(t, descriptionKey, idx, total), // HTML with progress
attachTo: { element: '[data-tour="customer-section"]', on: "bottom" },
beforeShowPromise: () => ensureTabAndWait("customer-section", selector),
buttons: [
{ text: "Previous", action() { tour.back(); }, secondary: true },
{ text: "Next", action() { tour.next(); } },
],
});

7.3 Progress Display

Steps show "Step X of Y" as an HTML paragraph above the description:

function stepDescriptionHtml(t, descriptionKey, stepIndex, totalSteps): string {
const progress = t("tour.common.progress", { current: stepIndex + 1, total: totalSteps });
return `<p class="shepherd-step-progress">${progress}</p><p>${description}</p>`;
}

7.4 Element Visibility Check

Before attaching to an element, the runner verifies it's actually visible (not hidden or zero-size):

function isTargetAttachable(selector: string): boolean {
const el = document.querySelector(selector);
if (!el) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}

7.5 Retry Logic

If a step's target isn't found after tab switching, the runner retries once:

try {
await switchTabAndWaitForTarget();
} catch {
// Retry once
try {
await switchTabAndWaitForTarget();
} catch {
console.warn(`Element not found — step may show centered`);
}
}

If the element still isn't found, Shepherd shows the popover centered (no highlight) rather than crashing.


8. i18n Integration

8.1 Key Structure

All tour translations live under the tour namespace:

tour.
├── common. # Shared button labels
│ ├── next
│ ├── prev
│ ├── done
│ ├── skip
│ └── progress # "Step {{current}} of {{total}}"
├── <tourName>. # Per-tour metadata
│ ├── name
│ ├── description
│ └── <stepId>. # Per-step content
│ ├── title
│ └── description
└── launcher. # TourLauncher UI
├── title
├── description
├── start
├── reset
├── completed
└── notCompleted

8.2 Adding Translations

Always add to both en.json and es.json (at minimum):

src/i18n/locales/en.json
src/i18n/locales/es.json

8.3 Interpolation

The progress string uses i18next interpolation:

"progress": "Step {{current}} of {{total}}"

Usage in code:

t("tour.common.progress", { current: 2, total: 5 })
// → "Step 2 of 5"

9. Persistence & State Management

9.1 Storage Key

flowpos:tours:{userId}:{businessId}

Example: flowpos:tours:abc123:biz456

9.2 Storage Shape

{
"main-onboarding": {
"completed": true,
"completedAt": "2026-03-25T10:30:00.000Z",
"autoLaunchSeen": true
},
"sales-workflow": {
"completed": false,
"autoLaunchSeen": false
}
}

9.3 State Transitions

EventState Change
Tour completes (Done button)completed: true, completedAt: <now>, autoLaunchSeen: true
Tour cancelled (ESC or X)Same as complete (intentional — don't nag the user)
Auto-launch firesautoLaunchSeen: true (before tour starts, prevents re-trigger)
User clicks "Reset"completed: false, completedAt: undefined

9.4 Why Cancel = Complete

Both onDone and onCancel mark the tour as completed. This is intentional — if a user dismisses a tour, they likely don't want it to keep re-appearing. They can always re-launch from the TourLauncher.


10. Role-Based Filtering

10.1 Tour-Level Roles

The roles array on TourDefinition controls which roles can see the tour in the launcher:

roles: [UniqueRoleName.Owner, UniqueRoleName.Admin, UniqueRoleName.StoreManager]

A Cashier won't see this tour in the TourLauncher dropdown.

10.2 Step-Level Roles

Individual steps can have their own roles array. Steps are filtered out for users who don't match:

{
target: '[data-tour="nav-reports"]',
titleKey: "tour.main.navReports.title",
descriptionKey: "tour.main.navReports.description",
roles: [UniqueRoleName.Owner, UniqueRoleName.Admin], // Cashiers skip this step
}

10.3 Filtering Order

  1. useTour.startTour() filters steps by currentRole
  2. If skipDomPresenceFilter is false, further filters by DOM element existence
  3. If zero steps remain after filtering, the tour doesn't start

11. Styling & Theming

11.1 CSS File

All tour styles live in src/tours/tours.css, imported by useTour.ts.

11.2 Design Tokens

The styles use Tailwind CSS custom properties (HSL format) to support light/dark mode:

.shepherd-content {
background-color: hsl(var(--popover));
color: hsl(var(--popover-foreground));
border: 1px solid hsl(var(--border));
}

11.3 Key Dimensions

PropertyValue
Max width340px (desktop), 100vw - 2rem (mobile)
Padding1.25rem (desktop), 1rem (mobile < 480px)
Border radius0.75rem
Overlay z-index10000
Popover z-index10001
Modal overlay opening padding8px
Modal overlay opening radius8px

11.4 Responsive Breakpoints

@media (max-width: 768px)  { /* Tablet adjustments */ }
@media (max-width: 480px) { /* Phone adjustments — smaller padding/font */ }

11.5 Matching Driver.js and Shepherd.js

Both libraries are styled to look identical. If you add new CSS for Shepherd, consider adding the equivalent for Driver.js (and vice versa) to maintain visual consistency.


12. Troubleshooting

Tour doesn't start

SymptomCauseFix
startTour() does nothingZero steps after role filteringCheck user's role vs. step roles
startTour() does nothingZero steps after DOM filteringCheck data-tour attributes exist in DOM, or set skipDomPresenceFilter: true
Tour starts but no highlightElement has display: none or zero dimensionsEnsure element is visible when step activates

Step shows centered (no highlight)

This means waitForSelector timed out — the element wasn't found within 12 seconds.

  • Check if the tab-switching event is being dispatched correctly
  • Verify the event listener in the form component is wired up
  • Check that STEP_TAB_MAP maps the step's data-tour value to the correct tab name
  • Inspect the DOM to confirm the element exists after tab switch

Auto-launch doesn't fire

  • Check autoLaunch: true in the tour definition
  • Check that autoLaunchSeen isn't already true in localStorage
  • Verify the user's role is in the tour's roles array
  • Confirm at least one step's target exists in the DOM at launch time

data-tour attribute not in DOM

  • The component may not forward data-* props — wrap it in a <div> (see Section 6.4)
  • The component may be conditionally rendered — ensure it's mounted

Tour overlay blocks interaction after cancel

  • Ensure onCancel callback is wired: tour.on("cancel", () => onCancel())
  • Check that driverRef.current?.destroy() and shepherdRef.current?.cancel() are called on cleanup

Multiple tours interfere

The hook destroys any existing tour instance before starting a new one:

driverRef.current?.destroy();
void shepherdRef.current?.cancel();

If you see overlapping tours, ensure you're going through useTour.startTour() and not creating Shepherd instances directly.


13. Testing Checklist

Manual Testing

  • Launcher visibility — TourLauncher button appears in header for authenticated users
  • Tour listing — All role-appropriate tours appear in the dropdown
  • Start from launcher — Tour starts and first step highlights correctly
  • Auto-launch — Tour auto-starts on first visit (if autoLaunch: true)
  • Auto-launch once — Refreshing the page does NOT re-trigger auto-launch
  • Step navigation — Next/Previous buttons work correctly
  • Step highlighting — Each step highlights the correct DOM element
  • Tab switching — For multi-tab tours, tabs switch automatically before each step
  • Popover placement — Popover appears on the correct side of the element
  • Scroll to element — Off-screen elements are scrolled into view smoothly
  • ESC to cancel — Pressing ESC dismisses the tour
  • Done button — Final step shows "Done" instead of "Next"
  • Completion persists — After completing, the tour shows as "Completed" in launcher
  • Reset — Clicking "Reset" in launcher allows re-starting the tour
  • Role filtering — Users with different roles see different steps
  • Dark mode — Tour styling adapts correctly to dark theme
  • Mobile responsive — Popover fits within viewport on small screens
  • Navigation required — If on wrong route, launcher navigates before starting
  • Unmount cleanup — Navigating away mid-tour doesn't leave overlay artifacts

localStorage Verification

// In browser DevTools console:
const key = Object.keys(localStorage).find(k => k.startsWith("flowpos:tours:"));
JSON.parse(localStorage.getItem(key));
// Should show tour states with correct completed/autoLaunchSeen values

// To reset all tour state for testing:
Object.keys(localStorage)
.filter(k => k.startsWith("flowpos:tours:"))
.forEach(k => localStorage.removeItem(k));

14. Prompt Pack for AI-Assisted Development

Use these prompts when asking an AI assistant (Claude, Cursor, etc.) to help integrate a new tour. Replace [FormName], [route], and element descriptions with your specifics.

Prompt 1: Full Tour Integration (single prompt for simple forms)

Add a Shepherd.js tour for [FormName] at route [route].
Use the existing pattern: TourDefinition in src/tours/, register in tourRegistry,
useTour hook, TourLauncher, and data-tour attributes.

Target these elements:
1. [element description] → data-tour="[name]"
2. [element description] → data-tour="[name]"
3. [element description] → data-tour="[name]"

The form has [no tabs / tabs: tab1, tab2, tab3 / a modal for X].
Roles: [Owner, Admin, StoreManager].
Auto-launch: [yes/no].

Ensure data-tour attrs reach the DOM (wrap Shadcn components if needed).
Add i18n keys to both en.json and es.json.

Prompt 2: DOM Anchor Audit (before defining steps)

In [file path], find where each of these UI elements is rendered and confirm
that a data-tour attribute on each one will reach the actual DOM element
(not be swallowed by a wrapper component):

1. [element description]
2. [element description]
3. [element description]

For each, tell me:
- The exact JSX line where I should add data-tour="[name]"
- Whether the component forwards data-* props to the DOM
- If it doesn't, suggest a wrapper approach
- Which tab/state must be active for the element to exist

Prompt 3: Multi-Tab Tour (complex forms)

Create a Shepherd.js tour for [FormName] that spans these tabs: [tab1, tab2, tab3].

Steps:
1. [step on tab1] → data-tour="[name]"
2. [step on tab2] → data-tour="[name]"
3. [step on tab3] → data-tour="[name]"

Follow the existing pattern in shepherdRunner.ts:
- Add a STEP_TAB_MAP for this tour
- Define a custom event constant (like FLOWPOS_TOUR_SET_SALE_TAB)
- Add the event listener in [FormComponent]
- Wire up the runner in useTour.ts startTour()
- Set skipDomPresenceFilter: true
- Use beforeShowPromise with ensureTabAndWait pattern

Prompt 4: Tour Teardown & Edge Cases

Review the tour integration for [tourId] and verify:
1. Cleanup on component unmount (no leaked overlays)
2. Cleanup on route change mid-tour
3. What happens if the user resizes the window during the tour
4. What happens if the target element is removed by async data loading
5. That the tour handles the case where the form has no data yet (empty state)
Add any missing cleanup or guard logic.

Prompt 5: i18n + Step Copywriting

Write the i18n entries for a [FormName] tour with these steps:
1. [step description and purpose]
2. [step description and purpose]
3. [step description and purpose]

Follow the existing key structure: tour.[tourName].[stepId].title/description
Write in both English and Spanish.
Keep descriptions concise (1-2 sentences) and action-oriented.
Include the tour name and description keys for the launcher.

Appendix: Quick Reference

Adding a Tour (Minimal Steps)

  1. Add ID to TourId union in types.ts
  2. Create src/tours/[name]Tour.ts with TourDefinition
  3. Register in src/tours/index.ts
  4. Add data-tour attributes to target components
  5. Add i18n keys to en.json and es.json
  6. (If multi-tab) Add tab map + custom event + listener + runner logic

Key Files to Touch

WhatFile
Tour ID typesrc/tours/types.ts
Tour definitionsrc/tours/[name]Tour.ts
Registrysrc/tours/index.ts
Runner (multi-tab only)src/tours/shepherdRunner.ts
Hook routing (Shepherd only)src/hooks/useTour.ts
Constants (multi-tab only)src/lib/constants.ts
Translationssrc/i18n/locales/en.json, es.json
CSS (rarely)src/tours/tours.css
Target componentsWherever the highlighted elements live

Shepherd.js Version

15.2.2 — see apps/frontend-pwa/package.json

Driver.js Version

1.4.0 — used for simpler tours (main-onboarding)