- Tour now switches to the All tab and clears search on start, ensuring the 11" Latex product is always visible and the exit overlay works - data-tour="first-card" now targets the 11" Latex item by name instead of whichever card happens to be first in the filtered list - Modal header title now truncates with ellipsis so the X close button is never pushed off screen by a long product name Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
|
|
interface TourStep {
|
|
target: string | null // CSS selector, or null = centered modal
|
|
title: string
|
|
body: string
|
|
position: 'top' | 'bottom' | 'right' | 'center'
|
|
onEnter?: () => void // fired when this step becomes active
|
|
noOverlay?: boolean // skip dark overlay (use when target is inside a modal)
|
|
}
|
|
|
|
const STEPS: TourStep[] = [
|
|
{
|
|
target: '[data-tour="tabs"]',
|
|
title: 'Browse by occasion or category',
|
|
body: 'Tap any tab to filter the collection — or "All" to see everything. Seasonal occasions like birthdays and weddings appear here automatically.',
|
|
position: 'bottom',
|
|
},
|
|
{
|
|
target: '[data-tour="first-card"]',
|
|
title: 'Tap any arrangement to customize',
|
|
body: 'Each card shows your arrangement options. Tap the card or "Customize & Order" to open the builder.',
|
|
position: 'bottom',
|
|
},
|
|
{
|
|
target: '[data-tour="color-section"]',
|
|
title: 'Pick your balloon colors',
|
|
body: 'Tap a color family to browse 40+ latex shades. Build your palette by selecting one or more colors.',
|
|
position: 'bottom',
|
|
noOverlay: true,
|
|
onEnter: () => {
|
|
const card = document.querySelector('[data-tour="first-card"] .product-card') as HTMLElement | null
|
|
card?.click()
|
|
},
|
|
},
|
|
{
|
|
target: '[data-tour="add-to-order"]',
|
|
title: 'Add to your order',
|
|
body: "When you're happy with your colors and options, tap \"Add to Order\" at the bottom of the screen to add the arrangement to your cart.",
|
|
position: 'center',
|
|
noOverlay: true,
|
|
},
|
|
{
|
|
target: null,
|
|
title: 'Delivery or pickup — your choice',
|
|
body: "At checkout, pick a delivery time and we'll bring everything to you, or choose a pickup time at our Milford, CT shop. Payment is fully secure.",
|
|
position: 'center',
|
|
onEnter: () => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
},
|
|
},
|
|
]
|
|
|
|
const PAD = 10 // px padding around spotlight
|
|
const TIP_WIDTH = 300 // tooltip width in px
|
|
|
|
interface Props {
|
|
onDone: () => void
|
|
onStart?: () => void
|
|
}
|
|
|
|
export default function GuidedTour({ onDone, onStart }: Props) {
|
|
const [step, setStep] = useState(0)
|
|
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
|
|
|
|
const current = STEPS[step]
|
|
|
|
const measureTarget = useCallback(() => {
|
|
if (!current.target) { setTargetRect(null); return }
|
|
const el = document.querySelector(current.target)
|
|
if (el) setTargetRect(el.getBoundingClientRect())
|
|
}, [current.target])
|
|
|
|
// Switch to All tab and pick the 11" Latex card as the tour example on mount
|
|
useEffect(() => {
|
|
onStart?.()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
// On step change: fire onEnter, poll until target appears, then scroll + measure.
|
|
useEffect(() => {
|
|
setTargetRect(null) // clear stale rect immediately
|
|
current.onEnter?.()
|
|
|
|
if (!current.target) return
|
|
|
|
let cancelled = false
|
|
let attempts = 0
|
|
const MAX = 15
|
|
|
|
const tryMeasure = () => {
|
|
if (cancelled) return
|
|
const el = document.querySelector(current.target!)
|
|
if (el) {
|
|
// Scroll within modal body (more reliable than scrollIntoView in fixed containers on mobile)
|
|
const scrollParent = el.closest('.modal-card-body') as HTMLElement | null
|
|
if (scrollParent) {
|
|
const parentRect = scrollParent.getBoundingClientRect()
|
|
const elRect = el.getBoundingClientRect()
|
|
scrollParent.scrollTop = elRect.top - parentRect.top + scrollParent.scrollTop - 20
|
|
} else {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
}
|
|
// Measure after layout settles (double-rAF ensures paint is done)
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
if (!cancelled) measureTarget()
|
|
})
|
|
})
|
|
} else if (attempts < MAX) {
|
|
attempts++
|
|
setTimeout(tryMeasure, 200)
|
|
}
|
|
}
|
|
tryMeasure()
|
|
|
|
return () => { cancelled = true }
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [step])
|
|
|
|
// Keep spotlight synced with resize / scroll
|
|
useEffect(() => {
|
|
window.addEventListener('resize', measureTarget)
|
|
window.addEventListener('scroll', measureTarget, true)
|
|
return () => {
|
|
window.removeEventListener('resize', measureTarget)
|
|
window.removeEventListener('scroll', measureTarget, true)
|
|
}
|
|
}, [measureTarget])
|
|
|
|
// Hide the Bulma modal-background for the entire tour so there's no dark flash when modals open
|
|
useEffect(() => {
|
|
const style = document.createElement('style')
|
|
style.textContent = '.modal-background { opacity: 0 !important; pointer-events: none !important; }'
|
|
document.head.appendChild(style)
|
|
return () => style.remove()
|
|
}, [])
|
|
|
|
const next = () => {
|
|
if (step < STEPS.length - 1) setStep((s) => s + 1)
|
|
else onDone()
|
|
}
|
|
|
|
const prev = () => setStep((s) => Math.max(0, s - 1))
|
|
|
|
// ── Spotlight geometry ───────────────────────────────────────────────────────
|
|
const spot = targetRect
|
|
? {
|
|
top: targetRect.top - PAD,
|
|
left: targetRect.left - PAD,
|
|
width: targetRect.width + PAD * 2,
|
|
height: targetRect.height + PAD * 2,
|
|
}
|
|
: null
|
|
|
|
// ── Tooltip geometry ─────────────────────────────────────────────────────────
|
|
const vw = typeof window !== 'undefined' ? window.innerWidth : 800
|
|
const vh = typeof window !== 'undefined' ? window.innerHeight : 600
|
|
|
|
let tooltipStyle: React.CSSProperties = {}
|
|
|
|
if (!spot || current.position === 'center') {
|
|
tooltipStyle = {
|
|
position: 'fixed',
|
|
top: '50%',
|
|
left: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
width: Math.min(TIP_WIDTH, vw - 32),
|
|
zIndex: 10002,
|
|
}
|
|
} else if (current.position === 'bottom') {
|
|
const left = Math.min(
|
|
Math.max(8, spot.left + spot.width / 2 - TIP_WIDTH / 2),
|
|
vw - TIP_WIDTH - 8
|
|
)
|
|
tooltipStyle = {
|
|
position: 'fixed',
|
|
top: spot.top + spot.height + 12,
|
|
left,
|
|
width: Math.min(TIP_WIDTH, vw - 16),
|
|
zIndex: 10002,
|
|
}
|
|
} else if (current.position === 'top') {
|
|
const left = Math.min(
|
|
Math.max(8, spot.left + spot.width / 2 - TIP_WIDTH / 2),
|
|
vw - TIP_WIDTH - 8
|
|
)
|
|
tooltipStyle = {
|
|
position: 'fixed',
|
|
bottom: vh - spot.top + 12,
|
|
left,
|
|
width: Math.min(TIP_WIDTH, vw - 16),
|
|
zIndex: 10002,
|
|
}
|
|
} else {
|
|
// right
|
|
tooltipStyle = {
|
|
position: 'fixed',
|
|
top: Math.max(8, spot.top + spot.height / 2 - 80),
|
|
left: Math.min(spot.left + spot.width + 12, vw - TIP_WIDTH - 8),
|
|
width: Math.min(TIP_WIDTH, vw - 16),
|
|
zIndex: 10002,
|
|
}
|
|
}
|
|
|
|
const isLast = step === STEPS.length - 1
|
|
const isFirst = step === 0
|
|
|
|
return (
|
|
<>
|
|
{/* Dark overlay — only when not targeting a modal element */}
|
|
{!current.noOverlay && (
|
|
<div
|
|
onClick={onDone}
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: spot ? 'rgba(0,0,0,0.55)' : 'rgba(0,0,0,0.65)',
|
|
zIndex: 9998,
|
|
cursor: 'pointer',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Spotlight — dark surround with cutout */}
|
|
{spot && !current.noOverlay && (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
top: spot.top,
|
|
left: spot.left,
|
|
width: spot.width,
|
|
height: spot.height,
|
|
borderRadius: '6px',
|
|
boxShadow: '0 0 0 9999px rgba(0,0,0,0.55)',
|
|
zIndex: 9999,
|
|
pointerEvents: 'none',
|
|
outline: '2px solid rgba(17,179,190,0.7)',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Highlight ring — bright border, no overlay, for modal steps */}
|
|
{spot && current.noOverlay && (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
top: spot.top,
|
|
left: spot.left,
|
|
width: spot.width,
|
|
height: spot.height,
|
|
borderRadius: '8px',
|
|
border: '2.5px solid #11b3be',
|
|
boxShadow: '0 0 0 4px rgba(17,179,190,0.25)',
|
|
zIndex: 10001,
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Tooltip card */}
|
|
<div
|
|
style={{
|
|
...tooltipStyle,
|
|
background: '#fff',
|
|
borderRadius: '12px',
|
|
padding: '1.25rem 1.25rem 1rem',
|
|
boxShadow: '0 8px 32px rgba(0,0,0,0.22)',
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Step dots */}
|
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem' }}>
|
|
{STEPS.map((_, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
width: i === step ? '18px' : '8px',
|
|
height: '8px',
|
|
borderRadius: '4px',
|
|
background: i === step ? '#11b3be' : '#d0d0d0',
|
|
transition: 'width 0.25s, background 0.25s',
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<p style={{ fontWeight: 700, fontSize: '0.95rem', marginBottom: '0.4rem', color: '#111' }}>
|
|
{current.title}
|
|
</p>
|
|
<p style={{ fontSize: '0.85rem', color: '#555', lineHeight: 1.5, marginBottom: '1rem' }}>
|
|
{current.body}
|
|
</p>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<button
|
|
onClick={onDone}
|
|
style={{ fontSize: '0.8rem', color: '#999', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
|
|
>
|
|
Skip tour
|
|
</button>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
{!isFirst && (
|
|
<button onClick={prev} className="button is-light is-small">
|
|
Back
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={next}
|
|
className="button is-small"
|
|
style={{ background: '#11b3be', color: '#fff', border: 'none' }}
|
|
>
|
|
{isLast ? "Let's go!" : 'Next →'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|