Skip to content

Commit 37a44b9

Browse files
committed
feat: product tours
1 parent 5ffc6a7 commit 37a44b9

File tree

15 files changed

+2254
-2
lines changed

15 files changed

+2254
-2
lines changed

packages/browser/src/entrypoints/main.cjs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ import { init_as_module } from '../posthog-core'
33
export { PostHog } from '../posthog-core'
44
export * from '../types'
55
export * from '../posthog-surveys-types'
6+
export * from '../posthog-product-tours-types'
67
export const posthog = init_as_module()
78
export default posthog

packages/browser/src/entrypoints/module.no-external.es.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ import { init_as_module } from '../posthog-core'
22
export { PostHog } from '../posthog-core'
33
export * from '../types'
44
export * from '../posthog-surveys-types'
5+
export * from '../posthog-product-tours-types'
56
export const posthog = init_as_module()
67
export default posthog
Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
/**
2+
* Product Tour Tooltip Component
3+
*
4+
* Preact component that renders the tooltip and overlay for product tours.
5+
* Handles smooth transitions between steps with proper scroll detection.
6+
*/
7+
8+
import { h } from 'preact'
9+
import { useEffect, useState, useCallback, useRef } from 'preact/hooks'
10+
import { ProductTour, ProductTourStep, ProductTourDismissReason } from '../../../posthog-product-tours-types'
11+
import {
12+
calculateTooltipPosition,
13+
getSpotlightStyle,
14+
mergeAppearance,
15+
renderTipTapContent,
16+
TooltipPosition,
17+
} from '../product-tours-utils'
18+
import { addEventListener } from '../../../utils'
19+
import { window as _window } from '../../../utils/globals'
20+
import { IconPosthogLogo, cancelSVG } from '../../surveys/icons'
21+
22+
const window = _window as Window & typeof globalThis
23+
24+
type TransitionState = 'initializing' | 'entering' | 'visible' | 'exiting'
25+
26+
export interface ProductTourTooltipProps {
27+
tour: ProductTour
28+
step: ProductTourStep
29+
stepIndex: number
30+
totalSteps: number
31+
targetElement: HTMLElement
32+
onNext: () => void
33+
onPrevious: () => void
34+
onDismiss: (reason: ProductTourDismissReason) => void
35+
}
36+
37+
function getOppositePosition(position: TooltipPosition): TooltipPosition {
38+
const opposites: Record<TooltipPosition, TooltipPosition> = {
39+
top: 'bottom',
40+
bottom: 'top',
41+
left: 'right',
42+
right: 'left',
43+
}
44+
return opposites[position]
45+
}
46+
47+
/**
48+
* Scroll element into view and return a promise that resolves when scrolling is complete
49+
*/
50+
function scrollToElement(element: HTMLElement): Promise<void> {
51+
return new Promise((resolve) => {
52+
// Get initial position
53+
const initialRect = element.getBoundingClientRect()
54+
const viewportHeight = window.innerHeight
55+
const viewportWidth = window.innerWidth
56+
57+
// Define "safe zone" - center 2/3 of viewport
58+
// This ensures there's room for the tooltip around the element
59+
const safeMarginY = viewportHeight / 6 // ~16.7% margin top and bottom
60+
const safeMarginX = viewportWidth / 6
61+
62+
const isInSafeZone =
63+
initialRect.top >= safeMarginY &&
64+
initialRect.bottom <= viewportHeight - safeMarginY &&
65+
initialRect.left >= safeMarginX &&
66+
initialRect.right <= viewportWidth - safeMarginX
67+
68+
// If already in safe zone, resolve immediately
69+
if (isInSafeZone) {
70+
resolve()
71+
return
72+
}
73+
74+
// Start scrolling
75+
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
76+
77+
// Poll for position stability - works regardless of scrollend support
78+
let lastTop = initialRect.top
79+
let stableCount = 0
80+
let resolved = false
81+
82+
const checkStability = () => {
83+
if (resolved) return
84+
85+
const currentRect = element.getBoundingClientRect()
86+
if (Math.abs(currentRect.top - lastTop) < 1) {
87+
stableCount++
88+
if (stableCount >= 3) {
89+
resolved = true
90+
resolve()
91+
return
92+
}
93+
} else {
94+
stableCount = 0
95+
}
96+
lastTop = currentRect.top
97+
setTimeout(checkStability, 50)
98+
}
99+
100+
// Start checking after a brief delay to let scroll begin
101+
setTimeout(checkStability, 30)
102+
103+
// Fallback timeout (shorter since polling should catch most cases)
104+
setTimeout(() => {
105+
if (!resolved) {
106+
resolved = true
107+
resolve()
108+
}
109+
}, 500)
110+
})
111+
}
112+
113+
export function ProductTourTooltip({
114+
tour,
115+
step,
116+
stepIndex,
117+
totalSteps,
118+
targetElement,
119+
onNext,
120+
onPrevious,
121+
onDismiss,
122+
}: ProductTourTooltipProps): h.JSX.Element {
123+
const appearance = mergeAppearance(tour.appearance)
124+
const [transitionState, setTransitionState] = useState<TransitionState>('initializing')
125+
const [position, setPosition] = useState<ReturnType<typeof calculateTooltipPosition> | null>(null)
126+
const [spotlightStyle, setSpotlightStyle] = useState<ReturnType<typeof getSpotlightStyle> | null>(null)
127+
128+
// Store displayed content separately so it doesn't change during transitions
129+
const [displayedStep, setDisplayedStep] = useState(step)
130+
const [displayedStepIndex, setDisplayedStepIndex] = useState(stepIndex)
131+
132+
const previousStepRef = useRef(stepIndex)
133+
const isTransitioningRef = useRef(false)
134+
const isFirstRender = useRef(true)
135+
136+
const updatePosition = useCallback(() => {
137+
const rect = targetElement.getBoundingClientRect()
138+
setPosition(calculateTooltipPosition(rect))
139+
setSpotlightStyle(getSpotlightStyle(rect))
140+
}, [targetElement])
141+
142+
// Handle initial mount and step changes
143+
useEffect(() => {
144+
const isStepChange = previousStepRef.current !== stepIndex
145+
146+
// Capture the current stepIndex to check if it's still valid when async operations complete
147+
const currentStepIndex = stepIndex
148+
149+
// On first render, scroll first then show
150+
if (isFirstRender.current) {
151+
isFirstRender.current = false
152+
previousStepRef.current = stepIndex
153+
isTransitioningRef.current = true
154+
155+
scrollToElement(targetElement).then(() => {
156+
// Check if step changed during scroll (user clicked rapidly)
157+
if (previousStepRef.current !== currentStepIndex) {
158+
return // Abort - a newer transition will handle this
159+
}
160+
161+
// Now that scroll is complete, calculate position and show
162+
const rect = targetElement.getBoundingClientRect()
163+
setPosition(calculateTooltipPosition(rect))
164+
setSpotlightStyle(getSpotlightStyle(rect))
165+
setTransitionState('visible')
166+
isTransitioningRef.current = false
167+
})
168+
return
169+
}
170+
171+
// On step change, do exit -> scroll -> enter
172+
if (isStepChange) {
173+
previousStepRef.current = stepIndex
174+
isTransitioningRef.current = true
175+
176+
// Exit animation first
177+
setTransitionState('exiting')
178+
179+
// Wait for exit animation, then update content and scroll
180+
setTimeout(() => {
181+
// Check if step changed during exit animation (user clicked rapidly)
182+
if (previousStepRef.current !== currentStepIndex) {
183+
return // Abort - a newer transition will handle this
184+
}
185+
186+
// Now update the displayed content
187+
setDisplayedStep(step)
188+
setDisplayedStepIndex(stepIndex)
189+
setTransitionState('entering')
190+
191+
scrollToElement(targetElement).then(() => {
192+
// Check again after scroll completes
193+
if (previousStepRef.current !== currentStepIndex) {
194+
return // Abort - a newer transition will handle this
195+
}
196+
197+
updatePosition()
198+
setTimeout(() => {
199+
// Final check before showing
200+
if (previousStepRef.current !== currentStepIndex) {
201+
return
202+
}
203+
setTransitionState('visible')
204+
isTransitioningRef.current = false
205+
}, 50)
206+
})
207+
}, 150) // Match the exit animation duration
208+
}
209+
}, [targetElement, stepIndex, step, updatePosition])
210+
211+
// Handle scroll and resize (only when visible, not during transitions)
212+
useEffect(() => {
213+
if (transitionState !== 'visible') {
214+
return
215+
}
216+
217+
const handleUpdate = () => {
218+
if (!isTransitioningRef.current) {
219+
updatePosition()
220+
}
221+
}
222+
223+
addEventListener(window, 'scroll', handleUpdate as EventListener, { capture: true })
224+
addEventListener(window, 'resize', handleUpdate as EventListener)
225+
226+
return () => {
227+
window?.removeEventListener('scroll', handleUpdate, true)
228+
window?.removeEventListener('resize', handleUpdate)
229+
}
230+
}, [updatePosition, transitionState])
231+
232+
// Handle escape key
233+
useEffect(() => {
234+
const handleKeyDown = (e: KeyboardEvent) => {
235+
if (e.key === 'Escape') {
236+
onDismiss('escape_key')
237+
}
238+
}
239+
addEventListener(window, 'keydown', handleKeyDown as EventListener)
240+
return () => {
241+
window?.removeEventListener('keydown', handleKeyDown)
242+
}
243+
}, [onDismiss])
244+
245+
const handleOverlayClick = (e: MouseEvent) => {
246+
e.stopPropagation()
247+
onDismiss('user_clicked_outside')
248+
}
249+
250+
const handleTooltipClick = (e: MouseEvent) => {
251+
e.stopPropagation()
252+
}
253+
254+
const isLastStep = displayedStepIndex >= totalSteps - 1
255+
const isFirstStep = displayedStepIndex === 0
256+
257+
const containerStyle = {
258+
'--ph-tour-background-color': appearance.backgroundColor,
259+
'--ph-tour-text-color': appearance.textColor,
260+
'--ph-tour-button-color': appearance.buttonColor,
261+
'--ph-tour-button-text-color': appearance.buttonTextColor,
262+
'--ph-tour-border-radius': `${appearance.borderRadius}px`,
263+
'--ph-tour-border-color': appearance.borderColor,
264+
} as h.JSX.CSSProperties
265+
266+
// During 'initializing', render nothing (wait for scroll to complete)
267+
// During 'visible', show everything
268+
// During 'entering'/'exiting', show dimming but hide spotlight and tooltip
269+
const isReady = transitionState !== 'initializing' && position && spotlightStyle
270+
const isVisible = transitionState === 'visible'
271+
272+
// During initialization, just show the dimming overlay
273+
if (!isReady) {
274+
return (
275+
<div class="ph-tour-container" style={containerStyle}>
276+
<div class="ph-tour-click-overlay" onClick={handleOverlayClick} />
277+
<div class="ph-tour-spotlight" style={{ top: '50%', left: '50%', width: '0px', height: '0px' }} />
278+
</div>
279+
)
280+
}
281+
282+
return (
283+
<div class="ph-tour-container" style={containerStyle}>
284+
{/* Click overlay - always present for dismissing */}
285+
<div class="ph-tour-click-overlay" onClick={handleOverlayClick} />
286+
287+
{/* Spotlight with box-shadow dimming - shrinks to zero during transitions */}
288+
<div
289+
class="ph-tour-spotlight"
290+
style={isVisible ? spotlightStyle : {
291+
top: '50%',
292+
left: '50%',
293+
width: '0px',
294+
height: '0px',
295+
}}
296+
/>
297+
298+
{/* Tooltip */}
299+
<div
300+
class={`ph-tour-tooltip ${isVisible ? 'ph-tour-tooltip--visible' : 'ph-tour-tooltip--hidden'}`}
301+
style={{
302+
top: `${position.top}px`,
303+
left: `${position.left}px`,
304+
opacity: isVisible ? 1 : 0,
305+
transform: isVisible ? 'translateY(0)' : 'translateY(10px)',
306+
transition: 'opacity 0.15s ease-out, transform 0.15s ease-out',
307+
}}
308+
onClick={handleTooltipClick}
309+
>
310+
{/* Arrow */}
311+
<div class={`ph-tour-arrow ph-tour-arrow--${getOppositePosition(position.position)}`} />
312+
313+
{/* Dismiss button - positioned in header area */}
314+
<button
315+
class="ph-tour-dismiss"
316+
onClick={() => onDismiss('user_clicked_skip')}
317+
aria-label="Close tour"
318+
>
319+
{cancelSVG}
320+
</button>
321+
322+
{/* Content - uses displayedStep to prevent flash */}
323+
<div
324+
class="ph-tour-content"
325+
dangerouslySetInnerHTML={{ __html: renderTipTapContent(displayedStep.content) }}
326+
/>
327+
328+
{/* Footer */}
329+
<div class="ph-tour-footer">
330+
<span class="ph-tour-progress">
331+
{displayedStepIndex + 1} of {totalSteps}
332+
</span>
333+
334+
<div class="ph-tour-buttons">
335+
{!isFirstStep && (
336+
<button class="ph-tour-button ph-tour-button--secondary" onClick={onPrevious}>
337+
Back
338+
</button>
339+
)}
340+
<button class="ph-tour-button ph-tour-button--primary" onClick={onNext}>
341+
{isLastStep ? 'Done' : 'Next'}
342+
</button>
343+
</div>
344+
</div>
345+
346+
{/* Branding - matches surveys style */}
347+
{!appearance.whiteLabel && (
348+
<a
349+
href="https://posthog.com/product-tours"
350+
target="_blank"
351+
rel="noopener noreferrer"
352+
class="ph-tour-branding"
353+
>
354+
Tour by {IconPosthogLogo}
355+
</a>
356+
)}
357+
</div>
358+
</div>
359+
)
360+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Product Tours Extension
3+
*
4+
* Exports the ProductTourManager for use by the PostHog instance.
5+
*/
6+
7+
export { ProductTourManager } from './product-tours'
8+
export {
9+
checkTourConditions,
10+
findElementBySelector,
11+
getElementMetadata,
12+
getProductTourStylesheet,
13+
PRODUCT_TOUR_IN_PROGRESS_KEY,
14+
} from './product-tours-utils'

0 commit comments

Comments
 (0)