Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/famous-loops-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': minor
---

product tours: support auto-show config, add modal steps
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface ProductTourTooltipProps {
step: ProductTourStep
stepIndex: number
totalSteps: number
targetElement: HTMLElement
targetElement: HTMLElement | null
onNext: () => void
onPrevious: () => void
onDismiss: (reason: ProductTourDismissReason) => void
Expand Down Expand Up @@ -112,11 +112,16 @@ export function ProductTourTooltip({
const isTransitioningRef = useRef(false)
const isFirstRender = useRef(true)

const isModalStep = !targetElement

const updatePosition = useCallback(() => {
if (isModalStep) {
return
}
const rect = targetElement.getBoundingClientRect()
setPosition(calculateTooltipPosition(rect))
setSpotlightStyle(getSpotlightStyle(rect))
}, [targetElement])
}, [targetElement, isModalStep])

useEffect(() => {
const isStepChange = previousStepRef.current !== stepIndex
Expand All @@ -128,6 +133,13 @@ export function ProductTourTooltip({
previousStepRef.current = stepIndex
isTransitioningRef.current = true

if (isModalStep) {
// Modal steps are just centered on screen - no positioning needed
setTransitionState('visible')
isTransitioningRef.current = false
return
}

scrollToElement(targetElement, () => {
if (previousStepRef.current !== currentStepIndex) {
return
Expand Down Expand Up @@ -157,6 +169,18 @@ export function ProductTourTooltip({
setDisplayedStepIndex(stepIndex)
setTransitionState('entering')

if (isModalStep) {
// Modal steps don't need scrolling or position calculation
setTimeout(() => {
if (previousStepRef.current !== currentStepIndex) {
return
}
setTransitionState('visible')
isTransitioningRef.current = false
}, 50)
return
}

scrollToElement(targetElement, () => {
if (previousStepRef.current !== currentStepIndex) {
return
Expand All @@ -173,10 +197,10 @@ export function ProductTourTooltip({
})
}, 150)
}
}, [targetElement, stepIndex, step, updatePosition])
}, [targetElement, stepIndex, step, updatePosition, isModalStep])

useEffect(() => {
if (transitionState !== 'visible') {
if (transitionState !== 'visible' || isModalStep) {
return
}

Expand All @@ -193,7 +217,7 @@ export function ProductTourTooltip({
window?.removeEventListener('scroll', handleUpdate, true)
window?.removeEventListener('resize', handleUpdate)
}
}, [updatePosition, transitionState])
}, [updatePosition, transitionState, isModalStep])

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
Expand Down Expand Up @@ -228,7 +252,7 @@ export function ProductTourTooltip({
'--ph-tour-border-color': appearance.borderColor,
} as h.JSX.CSSProperties

const isReady = transitionState !== 'initializing' && position && spotlightStyle
const isReady = isModalStep || (transitionState !== 'initializing' && position && spotlightStyle)
const isVisible = transitionState === 'visible'

if (!isReady) {
Expand All @@ -240,14 +264,66 @@ export function ProductTourTooltip({
)
}

// Modal step: centered on screen with overlay dimming, no spotlight/arrow
if (isModalStep) {
return (
<div class="ph-tour-container" style={containerStyle}>
<div class="ph-tour-click-overlay" onClick={handleOverlayClick} />
<div class="ph-tour-modal-overlay" />
<div class="ph-tour-tooltip ph-tour-tooltip--modal" onClick={handleTooltipClick}>
<button
class="ph-tour-dismiss"
onClick={() => onDismiss('user_clicked_skip')}
aria-label="Close tour"
>
{cancelSVG}
</button>

<div
class="ph-tour-content"
dangerouslySetInnerHTML={{ __html: renderTipTapContent(displayedStep.content) }}
/>

<div class="ph-tour-footer">
<span class="ph-tour-progress">
{displayedStepIndex + 1} of {totalSteps}
</span>

<div class="ph-tour-buttons">
{!isFirstStep && (
<button class="ph-tour-button ph-tour-button--secondary" onClick={onPrevious}>
Back
</button>
)}
<button class="ph-tour-button ph-tour-button--primary" onClick={onNext}>
{isLastStep ? 'Done' : 'Next'}
</button>
</div>
</div>

{!appearance.whiteLabel && (
<a
href="https://posthog.com/product-tours"
target="_blank"
rel="noopener noreferrer"
class="ph-tour-branding"
>
Tour by {IconPosthogLogo}
</a>
)}
</div>
</div>
)
}

return (
<div class="ph-tour-container" style={containerStyle}>
<div class="ph-tour-click-overlay" onClick={handleOverlayClick} />

<div
class="ph-tour-spotlight"
style={
isVisible
isVisible && spotlightStyle
? spotlightStyle
: {
top: '50%',
Expand All @@ -261,15 +337,15 @@ export function ProductTourTooltip({
<div
class={`ph-tour-tooltip ${isVisible ? 'ph-tour-tooltip--visible' : 'ph-tour-tooltip--hidden'}`}
style={{
top: `${position.top}px`,
left: `${position.left}px`,
top: `${position!.top}px`,
left: `${position!.left}px`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(10px)',
transition: 'opacity 0.15s ease-out, transform 0.15s ease-out',
}}
onClick={handleTooltipClick}
>
<div class={`ph-tour-arrow ph-tour-arrow--${getOppositePosition(position.position)}`} />
<div class={`ph-tour-arrow ph-tour-arrow--${getOppositePosition(position!.position)}`} />

<button class="ph-tour-dismiss" onClick={() => onDismiss('user_clicked_skip')} aria-label="Close tour">
{cancelSVG}
Expand Down
31 changes: 31 additions & 0 deletions packages/browser/src/extensions/product-tours/product-tour.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@
pointer-events: none;
}

.ph-tour-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: var(--ph-tour-z-index);
background: var(--ph-tour-overlay-color);
pointer-events: none;
}

.ph-tour-tooltip {
position: fixed;
z-index: calc(var(--ph-tour-z-index) + 1);
Expand All @@ -63,6 +74,13 @@
pointer-events: auto;
}

.ph-tour-tooltip--modal {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2), 0 2px 8px rgba(0, 0, 0, 0.1);
}

.ph-tour-arrow {
position: absolute;
width: 0;
Expand Down Expand Up @@ -252,6 +270,19 @@
}
}

@keyframes ph-tour-modal-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

.ph-tour-tooltip {
animation: ph-tour-fade-in 0.2s ease-out;
}

.ph-tour-tooltip--modal {
animation: ph-tour-modal-fade-in 0.2s ease-out;
}
Loading
Loading