Skip to content

Commit 2b56252

Browse files
committed
feat(product tours): support auto-launch field, reduce bundle size
1 parent a0277ac commit 2b56252

File tree

8 files changed

+163
-138
lines changed

8 files changed

+163
-138
lines changed

packages/browser/src/extensions/product-tours/components/ProductTourTooltip.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,13 @@ export function ProductTourTooltip({
134134
isTransitioningRef.current = true
135135

136136
if (isModalStep) {
137-
// Modal steps don't need scrolling or position calculation
137+
// Modal steps are just centered on screen - no positioning needed
138138
setTransitionState('visible')
139139
isTransitioningRef.current = false
140140
return
141141
}
142142

143-
scrollToElement(targetElement).then(() => {
143+
scrollToElement(targetElement, () => {
144144
if (previousStepRef.current !== currentStepIndex) {
145145
return
146146
}
@@ -181,7 +181,7 @@ export function ProductTourTooltip({
181181
return
182182
}
183183

184-
scrollToElement(targetElement).then(() => {
184+
scrollToElement(targetElement, () => {
185185
if (previousStepRef.current !== currentStepIndex) {
186186
return
187187
}
@@ -264,17 +264,14 @@ export function ProductTourTooltip({
264264
)
265265
}
266266

267-
// Modal step: centered on screen, no overlay/spotlight, no arrow
267+
// Modal step: centered on screen with overlay dimming, no spotlight/arrow
268268
if (isModalStep) {
269269
return (
270270
<div class="ph-tour-container" style={containerStyle}>
271+
<div class="ph-tour-click-overlay" onClick={handleOverlayClick} />
272+
<div class="ph-tour-modal-overlay" />
271273
<div
272-
class={`ph-tour-tooltip ph-tour-tooltip--modal ${isVisible ? 'ph-tour-tooltip--visible' : 'ph-tour-tooltip--hidden'}`}
273-
style={{
274-
opacity: isVisible ? 1 : 0,
275-
transform: isVisible ? 'translate(-50%, -50%)' : 'translate(-50%, calc(-50% + 10px))',
276-
transition: 'opacity 0.15s ease-out, transform 0.15s ease-out',
277-
}}
274+
class="ph-tour-tooltip ph-tour-tooltip--modal"
278275
onClick={handleTooltipClick}
279276
>
280277
<button

packages/browser/src/extensions/product-tours/product-tour.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@
4949
pointer-events: none;
5050
}
5151

52+
.ph-tour-modal-overlay {
53+
position: fixed;
54+
top: 0;
55+
left: 0;
56+
right: 0;
57+
bottom: 0;
58+
z-index: var(--ph-tour-z-index);
59+
background: var(--ph-tour-overlay-color);
60+
pointer-events: none;
61+
}
62+
5263
.ph-tour-tooltip {
5364
position: fixed;
5465
z-index: calc(var(--ph-tour-z-index) + 1);
@@ -259,6 +270,19 @@
259270
}
260271
}
261272

273+
@keyframes ph-tour-modal-fade-in {
274+
from {
275+
opacity: 0;
276+
}
277+
to {
278+
opacity: 1;
279+
}
280+
}
281+
262282
.ph-tour-tooltip {
263283
animation: ph-tour-fade-in 0.2s ease-out;
264284
}
285+
286+
.ph-tour-tooltip--modal {
287+
animation: ph-tour-modal-fade-in 0.2s ease-out;
288+
}

packages/browser/src/extensions/product-tours/product-tours.tsx

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,88 @@
11
import { render } from 'preact'
22
import { PostHog } from '../../posthog-core'
3-
import { ProductTour, ProductTourDismissReason, ProductTourRenderReason } from '../../posthog-product-tours-types'
4-
import { checkTourConditions } from '../../utils/product-tour-utils'
3+
import {
4+
ProductTour,
5+
ProductTourCallback,
6+
ProductTourDismissReason,
7+
ProductTourRenderReason,
8+
} from '../../posthog-product-tours-types'
59
import { findElementBySelector, getElementMetadata, getProductTourStylesheet } from './product-tours-utils'
610
import { ProductTourTooltip } from './components/ProductTourTooltip'
711
import { createLogger } from '../../utils/logger'
8-
import { document as _document } from '../../utils/globals'
12+
import { document as _document, window as _window } from '../../utils/globals'
913
import { localStore } from '../../storage'
1014
import { addEventListener } from '../../utils'
15+
import { isNull } from '@posthog/core'
1116

1217
const logger = createLogger('[Product Tours]')
1318

1419
const document = _document as Document
20+
const window = _window as Window & typeof globalThis
21+
22+
// Tour condition checking utilities (moved from utils/product-tour-utils.ts)
23+
function doesTourUrlMatch(tour: ProductTour): boolean {
24+
const conditions = tour.conditions
25+
if (!conditions?.url) {
26+
return true
27+
}
28+
29+
const currentUrl = window.location.href
30+
const targetUrl = conditions.url
31+
const matchType = conditions.urlMatchType || 'contains'
32+
33+
switch (matchType) {
34+
case 'exact':
35+
return currentUrl === targetUrl
36+
case 'contains':
37+
return currentUrl.includes(targetUrl)
38+
case 'regex':
39+
try {
40+
const regex = new RegExp(targetUrl)
41+
return regex.test(currentUrl)
42+
} catch {
43+
return false
44+
}
45+
default:
46+
return false
47+
}
48+
}
49+
50+
function doesTourSelectorMatch(tour: ProductTour): boolean {
51+
const conditions = tour.conditions
52+
if (!conditions?.selector) {
53+
return true
54+
}
55+
56+
try {
57+
return !isNull(document.querySelector(conditions.selector))
58+
} catch {
59+
return false
60+
}
61+
}
62+
63+
function isTourInDateRange(tour: ProductTour): boolean {
64+
const now = new Date()
65+
66+
if (tour.start_date) {
67+
const startDate = new Date(tour.start_date)
68+
if (now < startDate) {
69+
return false
70+
}
71+
}
72+
73+
if (tour.end_date) {
74+
const endDate = new Date(tour.end_date)
75+
if (now > endDate) {
76+
return false
77+
}
78+
}
79+
80+
return true
81+
}
82+
83+
function checkTourConditions(tour: ProductTour): boolean {
84+
return isTourInDateRange(tour) && doesTourUrlMatch(tour) && doesTourSelectorMatch(tour)
85+
}
1586

1687
const CONTAINER_CLASS = 'ph-product-tour-container'
1788
const TRIGGER_LISTENER_ATTRIBUTE = 'data-ph-tour-trigger'
@@ -119,16 +190,19 @@ export class ProductTourManager {
119190
const activeTriggerTourIds = new Set<string>()
120191

121192
for (const tour of tours) {
122-
// Tours with trigger_selector: always attach listener, skip eligibility
193+
// Determine the trigger selector - explicit trigger_selector takes precedence,
194+
// otherwise use conditions.selector for click-only tours (auto_launch=false)
195+
const triggerSelector = tour.trigger_selector || (!tour.auto_launch ? tour.conditions?.selector : null)
196+
197+
// Tours with a trigger selector: always attach listener
123198
// These are "on-demand" tours that show when clicked
124-
if (tour.trigger_selector) {
199+
if (triggerSelector) {
125200
activeTriggerTourIds.add(tour.id)
126-
this._manageTriggerSelectorListener(tour)
127-
continue
201+
this._manageTriggerSelectorListener({ ...tour, trigger_selector: triggerSelector })
128202
}
129203

130-
// Tours without trigger_selector: check eligibility for auto-show
131-
if (!this._activeTour && this._isTourEligible(tour)) {
204+
// Only auto-show if auto_launch is enabled
205+
if (tour.auto_launch && !this._activeTour && this._isTourEligible(tour)) {
132206
this.showTour(tour)
133207
}
134208
}
@@ -391,7 +465,7 @@ export class ProductTourManager {
391465
this._renderTooltipWithPreact(element)
392466
}
393467

394-
private _renderTooltipWithPreact(element: HTMLElement): void {
468+
private _renderTooltipWithPreact(element: HTMLElement | null): void {
395469
if (!this._activeTour) {
396470
return
397471
}
@@ -487,4 +561,37 @@ export class ProductTourManager {
487561
private _captureEvent(eventName: string, properties: Record<string, any>): void {
488562
this._instance.capture(eventName, properties)
489563
}
564+
565+
// Public API methods delegated from PostHogProductTours
566+
getActiveProductTours(callback: ProductTourCallback): void {
567+
this._instance.productTours?.getProductTours((tours, context) => {
568+
if (!context?.isLoaded) {
569+
callback([], context)
570+
return
571+
}
572+
573+
const activeTours = tours.filter((tour) => this._isTourEligible(tour))
574+
callback(activeTours, context)
575+
})
576+
}
577+
578+
resetTour(tourId: string): void {
579+
localStore._remove(`ph_product_tour_completed_${tourId}`)
580+
localStore._remove(`ph_product_tour_dismissed_${tourId}`)
581+
}
582+
583+
resetAllTours(): void {
584+
const storage = window?.localStorage
585+
if (!storage) {
586+
return
587+
}
588+
const keysToRemove: string[] = []
589+
for (let i = 0; i < storage.length; i++) {
590+
const key = storage.key(i)
591+
if (key?.startsWith('ph_product_tour_completed_') || key?.startsWith('ph_product_tour_dismissed_')) {
592+
keysToRemove.push(key)
593+
}
594+
}
595+
keysToRemove.forEach((key) => localStore._remove(key))
596+
}
490597
}

packages/browser/src/posthog-product-tours-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export interface ProductTour {
3333
name: string
3434
description?: string
3535
type: 'product_tour'
36+
auto_launch?: boolean
3637
start_date: string | null
3738
end_date: string | null
3839
current_iteration?: number

packages/browser/src/posthog-product-tours.ts

Lines changed: 12 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@ import { PostHog } from './posthog-core'
22
import { ProductTour, ProductTourCallback } from './posthog-product-tours-types'
33
import { RemoteConfig } from './types'
44
import { createLogger } from './utils/logger'
5-
import { checkTourConditions } from './utils/product-tour-utils'
65
import { isNullish, isUndefined, isArray } from '@posthog/core'
7-
import { assignableWindow, window } from './utils/globals'
8-
import { localStore } from './storage'
6+
import { assignableWindow } from './utils/globals'
97

108
const logger = createLogger('[Product Tours]')
119

@@ -19,6 +17,9 @@ interface ProductTourManagerInterface {
1917
dismissTour: (reason: string) => void
2018
nextStep: () => void
2119
previousStep: () => void
20+
getActiveProductTours: (callback: ProductTourCallback) => void
21+
resetTour: (tourId: string) => void
22+
resetAllTours: () => void
2223
}
2324

2425
export class PostHogProductTours {
@@ -164,36 +165,12 @@ export class PostHogProductTours {
164165
}
165166

166167
getActiveProductTours(callback: ProductTourCallback): void {
167-
this.getProductTours((tours, context) => {
168-
if (!context?.isLoaded) {
169-
callback([], context)
170-
return
171-
}
172-
173-
const activeTours = tours.filter((tour) => {
174-
if (!checkTourConditions(tour)) {
175-
return false
176-
}
177-
178-
const completedKey = `ph_product_tour_completed_${tour.id}`
179-
const dismissedKey = `ph_product_tour_dismissed_${tour.id}`
180-
181-
if (localStore._get(completedKey) || localStore._get(dismissedKey)) {
182-
return false
183-
}
184-
185-
if (tour.internal_targeting_flag_key) {
186-
const flagValue = this._instance.featureFlags?.getFeatureFlag(tour.internal_targeting_flag_key)
187-
if (!flagValue) {
188-
return false
189-
}
190-
}
191-
192-
return true
193-
})
194-
195-
callback(activeTours, context)
196-
})
168+
if (isNullish(this._productTourManager)) {
169+
logger.warn('Product tours not loaded yet')
170+
callback([], { isLoaded: false, error: 'Product tours not loaded' })
171+
return
172+
}
173+
this._productTourManager.getActiveProductTours(callback)
197174
}
198175

199176
showProductTour(tourId: string): void {
@@ -223,22 +200,10 @@ export class PostHogProductTours {
223200
}
224201

225202
resetTour(tourId: string): void {
226-
localStore._remove(`ph_product_tour_completed_${tourId}`)
227-
localStore._remove(`ph_product_tour_dismissed_${tourId}`)
203+
this._productTourManager?.resetTour(tourId)
228204
}
229205

230206
resetAllTours(): void {
231-
const storage = window?.localStorage
232-
if (!storage) {
233-
return
234-
}
235-
const keysToRemove: string[] = []
236-
for (let i = 0; i < storage.length; i++) {
237-
const key = storage.key(i)
238-
if (key?.startsWith('ph_product_tour_completed_') || key?.startsWith('ph_product_tour_dismissed_')) {
239-
keysToRemove.push(key)
240-
}
241-
}
242-
keysToRemove.forEach((key) => localStore._remove(key))
207+
this._productTourManager?.resetAllTours()
243208
}
244209
}

0 commit comments

Comments
 (0)