Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export function ProductTourTooltip({
return
}

scrollToElement(targetElement).then(() => {
scrollToElement(targetElement, () => {
if (previousStepRef.current !== currentStepIndex) {
return
}
Expand Down Expand Up @@ -181,7 +181,7 @@ export function ProductTourTooltip({
return
}

scrollToElement(targetElement).then(() => {
scrollToElement(targetElement, () => {
if (previousStepRef.current !== currentStepIndex) {
return
}
Expand Down
127 changes: 117 additions & 10 deletions packages/browser/src/extensions/product-tours/product-tours.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,88 @@
import { render } from 'preact'
import { PostHog } from '../../posthog-core'
import { ProductTour, ProductTourDismissReason, ProductTourRenderReason } from '../../posthog-product-tours-types'
import { checkTourConditions } from '../../utils/product-tour-utils'
import {
ProductTour,
ProductTourCallback,
ProductTourDismissReason,
ProductTourRenderReason,
} from '../../posthog-product-tours-types'
import { findElementBySelector, getElementMetadata, getProductTourStylesheet } from './product-tours-utils'
import { ProductTourTooltip } from './components/ProductTourTooltip'
import { createLogger } from '../../utils/logger'
import { document as _document } from '../../utils/globals'
import { document as _document, window as _window } from '../../utils/globals'
import { localStore } from '../../storage'
import { addEventListener } from '../../utils'
import { isNull } from '@posthog/core'

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

const document = _document as Document
const window = _window as Window & typeof globalThis

// Tour condition checking utilities (moved from utils/product-tour-utils.ts)
function doesTourUrlMatch(tour: ProductTour): boolean {
const conditions = tour.conditions
if (!conditions?.url) {
return true
}

const currentUrl = window.location.href
const targetUrl = conditions.url
const matchType = conditions.urlMatchType || 'contains'

switch (matchType) {
case 'exact':
return currentUrl === targetUrl
case 'contains':
return currentUrl.includes(targetUrl)
case 'regex':
try {
const regex = new RegExp(targetUrl)
return regex.test(currentUrl)
} catch {
return false
}
default:
return false
}
}

function doesTourSelectorMatch(tour: ProductTour): boolean {
const conditions = tour.conditions
if (!conditions?.selector) {
return true
}

try {
return !isNull(document.querySelector(conditions.selector))
} catch {
return false
}
}

function isTourInDateRange(tour: ProductTour): boolean {
const now = new Date()

if (tour.start_date) {
const startDate = new Date(tour.start_date)
if (now < startDate) {
return false
}
}

if (tour.end_date) {
const endDate = new Date(tour.end_date)
if (now > endDate) {
return false
}
}

return true
}

function checkTourConditions(tour: ProductTour): boolean {
return isTourInDateRange(tour) && doesTourUrlMatch(tour) && doesTourSelectorMatch(tour)
}

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

for (const tour of tours) {
// Tours with trigger_selector: always attach listener, skip eligibility
// Determine the trigger selector - explicit trigger_selector takes precedence,
// otherwise use conditions.selector for click-only tours (auto_launch=false)
const triggerSelector = tour.trigger_selector || (!tour.auto_launch ? tour.conditions?.selector : null)

// Tours with a trigger selector: always attach listener
// These are "on-demand" tours that show when clicked
if (tour.trigger_selector) {
if (triggerSelector) {
Comment on lines +193 to +199
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: when tour.auto_launch is undefined (not explicitly set), !tour.auto_launch evaluates to true, meaning tours without an explicit auto_launch field will use conditions.selector as a trigger selector. This changes the default behavior for existing tours that don't have the auto_launch field set. Consider checking explicitly for tour.auto_launch === false instead of !tour.auto_launch to preserve backwards compatibility.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browser/src/extensions/product-tours/product-tours.tsx
Line: 193:199

Comment:
**logic:** when `tour.auto_launch` is `undefined` (not explicitly set), `!tour.auto_launch` evaluates to `true`, meaning tours without an explicit `auto_launch` field will use `conditions.selector` as a trigger selector. This changes the default behavior for existing tours that don't have the `auto_launch` field set. Consider checking explicitly for `tour.auto_launch === false` instead of `!tour.auto_launch` to preserve backwards compatibility.

How can I resolve this? If you propose a fix, please make it concise.

activeTriggerTourIds.add(tour.id)
this._manageTriggerSelectorListener(tour)
continue
this._manageTriggerSelectorListener({ ...tour, trigger_selector: triggerSelector })
}

// Tours without trigger_selector: check eligibility for auto-show
if (!this._activeTour && this._isTourEligible(tour)) {
// Only auto-show if auto_launch is enabled
if (tour.auto_launch && !this._activeTour && this._isTourEligible(tour)) {
this.showTour(tour)
}
}
Expand Down Expand Up @@ -391,7 +465,7 @@ export class ProductTourManager {
this._renderTooltipWithPreact(element)
}

private _renderTooltipWithPreact(element: HTMLElement): void {
private _renderTooltipWithPreact(element: HTMLElement | null): void {
if (!this._activeTour) {
return
}
Expand Down Expand Up @@ -487,4 +561,37 @@ export class ProductTourManager {
private _captureEvent(eventName: string, properties: Record<string, any>): void {
this._instance.capture(eventName, properties)
}

// Public API methods delegated from PostHogProductTours
getActiveProductTours(callback: ProductTourCallback): void {
this._instance.productTours?.getProductTours((tours, context) => {
if (!context?.isLoaded) {
callback([], context)
return
}

const activeTours = tours.filter((tour) => this._isTourEligible(tour))
callback(activeTours, context)
})
}

resetTour(tourId: string): void {
localStore._remove(`ph_product_tour_completed_${tourId}`)
localStore._remove(`ph_product_tour_dismissed_${tourId}`)
}

resetAllTours(): void {
const storage = window?.localStorage
if (!storage) {
return
}
const keysToRemove: string[] = []
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i)
if (key?.startsWith('ph_product_tour_completed_') || key?.startsWith('ph_product_tour_dismissed_')) {
keysToRemove.push(key)
}
}
keysToRemove.forEach((key) => localStore._remove(key))
}
}
1 change: 1 addition & 0 deletions packages/browser/src/posthog-product-tours-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface ProductTour {
name: string
description?: string
type: 'product_tour'
auto_launch?: boolean
start_date: string | null
end_date: string | null
current_iteration?: number
Expand Down
59 changes: 12 additions & 47 deletions packages/browser/src/posthog-product-tours.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ import { PostHog } from './posthog-core'
import { ProductTour, ProductTourCallback } from './posthog-product-tours-types'
import { RemoteConfig } from './types'
import { createLogger } from './utils/logger'
import { checkTourConditions } from './utils/product-tour-utils'
import { isNullish, isUndefined, isArray } from '@posthog/core'
import { assignableWindow, window } from './utils/globals'
import { localStore } from './storage'
import { assignableWindow } from './utils/globals'

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

Expand All @@ -19,6 +17,9 @@ interface ProductTourManagerInterface {
dismissTour: (reason: string) => void
nextStep: () => void
previousStep: () => void
getActiveProductTours: (callback: ProductTourCallback) => void
resetTour: (tourId: string) => void
resetAllTours: () => void
}

export class PostHogProductTours {
Expand Down Expand Up @@ -164,36 +165,12 @@ export class PostHogProductTours {
}

getActiveProductTours(callback: ProductTourCallback): void {
this.getProductTours((tours, context) => {
if (!context?.isLoaded) {
callback([], context)
return
}

const activeTours = tours.filter((tour) => {
if (!checkTourConditions(tour)) {
return false
}

const completedKey = `ph_product_tour_completed_${tour.id}`
const dismissedKey = `ph_product_tour_dismissed_${tour.id}`

if (localStore._get(completedKey) || localStore._get(dismissedKey)) {
return false
}

if (tour.internal_targeting_flag_key) {
const flagValue = this._instance.featureFlags?.getFeatureFlag(tour.internal_targeting_flag_key)
if (!flagValue) {
return false
}
}

return true
})

callback(activeTours, context)
})
if (isNullish(this._productTourManager)) {
logger.warn('Product tours not loaded yet')
callback([], { isLoaded: false, error: 'Product tours not loaded' })
return
}
this._productTourManager.getActiveProductTours(callback)
}

showProductTour(tourId: string): void {
Expand Down Expand Up @@ -223,22 +200,10 @@ export class PostHogProductTours {
}

resetTour(tourId: string): void {
localStore._remove(`ph_product_tour_completed_${tourId}`)
localStore._remove(`ph_product_tour_dismissed_${tourId}`)
this._productTourManager?.resetTour(tourId)
}

resetAllTours(): void {
const storage = window?.localStorage
if (!storage) {
return
}
const keysToRemove: string[] = []
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i)
if (key?.startsWith('ph_product_tour_completed_') || key?.startsWith('ph_product_tour_dismissed_')) {
keysToRemove.push(key)
}
}
keysToRemove.forEach((key) => localStore._remove(key))
this._productTourManager?.resetAllTours()
}
}
70 changes: 0 additions & 70 deletions packages/browser/src/utils/product-tour-utils.ts

This file was deleted.

2 changes: 1 addition & 1 deletion playground/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"eslint": "^8.57.1",
"hls.js": "^1.5.15",
"next": "^15.5.7",
"posthog-js": "*",
"posthog-js": "file:/Users/adambowker/posthog-js/target/posthog-js.tgz",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syntax: hardcoded local file path should not be committed

Suggested change
"posthog-js": "file:/Users/adambowker/posthog-js/target/posthog-js.tgz",
"posthog-js": "*",
Prompt To Fix With AI
This is a comment left during a code review.
Path: playground/nextjs/package.json
Line: 23:23

Comment:
**syntax:** hardcoded local file path should not be committed

```suggestion
        "posthog-js": "*",
```

How can I resolve this? If you propose a fix, please make it concise.

"react": "19.2.1",
"react-dom": "19.2.1",
"socket.io": "^4.8.1",
Expand Down
1 change: 1 addition & 0 deletions playground/nextjs/src/posthog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// NOTE: This is how you can include the external dependencies so they are in your bundle and not loaded async at runtime
// import 'posthog-js/dist/recorder'
// import 'posthog-js/dist/surveys'
import 'posthog-js/dist/product-tours'
// import 'posthog-js/dist/exception-autocapture'
// import 'posthog-js/dist/tracing-headers'

Expand Down
Loading