chris c6d5a0265f fix: tour init on All tab + 11" Latex card; fix modal title truncation
- 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>
2026-04-15 14:39:45 -04:00

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>
</>
)
}