Skip to content

Commit 09081fd

Browse files
committed
feat(product tours): add tour wait period config
1 parent d6fd9c9 commit 09081fd

File tree

12 files changed

+362
-13
lines changed

12 files changed

+362
-13
lines changed

.changeset/bumpy-words-stand.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-js': minor
3+
---
4+
5+
add product tour wait period support

packages/browser/playwright/mocked/product-tours/utils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import {
77
TOUR_COMPLETED_KEY_PREFIX,
88
TOUR_DISMISSED_KEY_PREFIX,
99
ACTIVE_TOUR_SESSION_KEY,
10+
LAST_SEEN_TOUR_DATE_KEY_PREFIX,
1011
} from '@/extensions/product-tours/constants'
1112

12-
export { ACTIVE_TOUR_SESSION_KEY }
13+
export { ACTIVE_TOUR_SESSION_KEY, LAST_SEEN_TOUR_DATE_KEY_PREFIX }
1314

1415
export const tourShownKey = (tourId: string) => `${TOUR_SHOWN_KEY_PREFIX}${tourId}`
1516
export const tourCompletedKey = (tourId: string) => `${TOUR_COMPLETED_KEY_PREFIX}${tourId}`
@@ -42,7 +43,7 @@ export function createTour(overrides: Partial<ProductTour> = {}): ProductTour {
4243
return {
4344
id,
4445
name: `Test Tour ${id}`,
45-
type: 'product_tour',
46+
tour_type: 'tour',
4647
auto_launch: true,
4748
start_date: '2021-01-01T00:00:00Z',
4849
end_date: null,
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { expect, test } from '../utils/posthog-playwright-test-base'
2+
import { start, gotoPage } from '../utils/setup'
3+
import {
4+
createTour,
5+
startOptionsWithProductTours,
6+
startWithTours,
7+
tourTooltip,
8+
tourContainer,
9+
LAST_SEEN_TOUR_DATE_KEY_PREFIX,
10+
} from './utils'
11+
12+
test.describe('product tours - wait period', () => {
13+
test('shows tour when no seenTourWaitPeriod is configured', async ({ page, context }) => {
14+
const tour = createTour({ id: 'no-wait-period', tour_type: 'tour' })
15+
await startWithTours(page, context, [tour])
16+
17+
await expect(tourTooltip(page, 'no-wait-period')).toBeVisible({ timeout: 5000 })
18+
})
19+
20+
test('blocks tour when within wait period for matching type', async ({ page, context }) => {
21+
// Navigate first to establish localStorage
22+
await gotoPage(page, './playground/cypress/index.html')
23+
24+
// Seed a recent seen date for 'tour' type (seen today)
25+
await page.evaluate(({ key, value }: { key: string; value: string }) => localStorage.setItem(key, value), {
26+
key: `${LAST_SEEN_TOUR_DATE_KEY_PREFIX}tour`,
27+
value: JSON.stringify(new Date().toISOString()),
28+
})
29+
30+
const tour = createTour({
31+
id: 'wait-blocked',
32+
tour_type: 'tour',
33+
conditions: {
34+
seenTourWaitPeriod: { days: 7, types: ['tour'] },
35+
},
36+
})
37+
38+
await startWithTours(page, context, [tour], {
39+
startOptions: { ...startOptionsWithProductTours, type: 'reload' },
40+
})
41+
42+
await page.waitForTimeout(2000)
43+
await expect(tourTooltip(page, 'wait-blocked')).not.toBeVisible()
44+
})
45+
46+
test('shows tour when wait period has passed', async ({ page, context }) => {
47+
// Navigate first to establish localStorage
48+
await gotoPage(page, './playground/cypress/index.html')
49+
50+
// Seed a seen date from 10 days ago (past the 7-day wait period)
51+
await page.evaluate(({ key, value }: { key: string; value: string }) => localStorage.setItem(key, value), {
52+
key: `${LAST_SEEN_TOUR_DATE_KEY_PREFIX}tour`,
53+
value: JSON.stringify(new Date(Date.now() - 10 * 24 * 3600 * 1000).toISOString()),
54+
})
55+
56+
const tour = createTour({
57+
id: 'wait-passed',
58+
tour_type: 'tour',
59+
conditions: {
60+
seenTourWaitPeriod: { days: 7, types: ['tour'] },
61+
},
62+
})
63+
64+
await startWithTours(page, context, [tour], {
65+
startOptions: { ...startOptionsWithProductTours, type: 'reload' },
66+
})
67+
68+
await expect(tourTooltip(page, 'wait-passed')).toBeVisible({ timeout: 5000 })
69+
})
70+
71+
test('shows tour when wait period type does not match stored type', async ({ page, context }) => {
72+
// Navigate first to establish localStorage
73+
await gotoPage(page, './playground/cypress/index.html')
74+
75+
// Seed a recent seen date for 'announcement' type
76+
await page.evaluate(({ key, value }: { key: string; value: string }) => localStorage.setItem(key, value), {
77+
key: `${LAST_SEEN_TOUR_DATE_KEY_PREFIX}announcement`,
78+
value: JSON.stringify(new Date().toISOString()),
79+
})
80+
81+
// Tour with wait period only checking 'tour' type — should NOT be blocked by 'announcement'
82+
const tour = createTour({
83+
id: 'wait-type-mismatch',
84+
tour_type: 'tour',
85+
conditions: {
86+
seenTourWaitPeriod: { days: 7, types: ['tour'] },
87+
},
88+
})
89+
90+
await startWithTours(page, context, [tour], {
91+
startOptions: { ...startOptionsWithProductTours, type: 'reload' },
92+
})
93+
94+
await expect(tourTooltip(page, 'wait-type-mismatch')).toBeVisible({ timeout: 5000 })
95+
})
96+
97+
test('blocks tour when any of the configured types was seen recently', async ({ page, context }) => {
98+
// Navigate first to establish localStorage
99+
await gotoPage(page, './playground/cypress/index.html')
100+
101+
// 'tour' was seen long ago, but 'announcement' was seen today
102+
await page.evaluate(
103+
({ prefix }: { prefix: string }) => {
104+
const oldDate = new Date(Date.now() - 10 * 24 * 3600 * 1000).toISOString()
105+
const recentDate = new Date().toISOString()
106+
localStorage.setItem(`${prefix}tour`, JSON.stringify(oldDate))
107+
localStorage.setItem(`${prefix}announcement`, JSON.stringify(recentDate))
108+
},
109+
{ prefix: LAST_SEEN_TOUR_DATE_KEY_PREFIX }
110+
)
111+
112+
const tour = createTour({
113+
id: 'wait-multi-type',
114+
tour_type: 'tour',
115+
conditions: {
116+
seenTourWaitPeriod: { days: 7, types: ['tour', 'announcement'] },
117+
},
118+
})
119+
120+
await startWithTours(page, context, [tour], {
121+
startOptions: { ...startOptionsWithProductTours, type: 'reload' },
122+
})
123+
124+
await page.waitForTimeout(2000)
125+
await expect(tourTooltip(page, 'wait-multi-type')).not.toBeVisible()
126+
})
127+
128+
test('showing a tour stores the last seen date for its tour_type', async ({ page, context }) => {
129+
const tour = createTour({ id: 'stores-date', tour_type: 'announcement' })
130+
await startWithTours(page, context, [tour])
131+
132+
await expect(tourTooltip(page, 'stores-date')).toBeVisible({ timeout: 5000 })
133+
134+
const storedValue = await page.evaluate(
135+
(key: string) => localStorage.getItem(key),
136+
`${LAST_SEEN_TOUR_DATE_KEY_PREFIX}announcement`
137+
)
138+
139+
expect(storedValue).toBeTruthy()
140+
const parsed = new Date(JSON.parse(storedValue!))
141+
expect(parsed.getTime()).toBeGreaterThan(Date.now() - 60_000) // within last minute
142+
})
143+
144+
test('second tour blocked by wait period after first tour shown', async ({ page, context }) => {
145+
// Show first tour (type 'tour'), which sets the last seen date
146+
const firstTour = createTour({ id: 'first-tour', tour_type: 'tour' })
147+
await startWithTours(page, context, [firstTour])
148+
149+
const firstTooltip = tourTooltip(page, 'first-tour')
150+
await expect(firstTooltip).toBeVisible({ timeout: 5000 })
151+
152+
// Dismiss the first tour
153+
await tourContainer(page, 'first-tour').locator('.ph-tour-dismiss').click()
154+
await expect(firstTooltip).not.toBeVisible()
155+
156+
// Now reload with a second tour that has a wait period checking 'tour' type
157+
const secondTour = createTour({
158+
id: 'second-tour',
159+
tour_type: 'tour',
160+
conditions: {
161+
seenTourWaitPeriod: { days: 7, types: ['tour'] },
162+
},
163+
})
164+
165+
await page.route('**/api/product_tours/**', async (route) => {
166+
await route.fulfill({ json: { product_tours: [secondTour] } })
167+
})
168+
169+
await page.reload()
170+
await start({ ...startOptionsWithProductTours, type: 'reload' }, page, context)
171+
172+
await page.waitForTimeout(2000)
173+
await expect(tourTooltip(page, 'second-tour')).not.toBeVisible()
174+
})
175+
})

packages/browser/src/__tests__/extensions/product-tours-utils.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import {
44
renderTipTapContent,
55
normalizeUrl,
66
resolveStepTranslation,
7+
hasTourWaitPeriodPassed,
78
} from '../../extensions/product-tours/product-tours-utils'
89
import { ProductTourStep } from '../../posthog-product-tours-types'
910
import { doesTourActivateByEvent, doesTourActivateByAction } from '../../utils/product-tour-utils'
11+
import { LAST_SEEN_TOUR_DATE_KEY_PREFIX } from '../../extensions/product-tours/constants'
1012

1113
describe('calculateTooltipPosition', () => {
1214
const mockWindow = {
@@ -323,3 +325,43 @@ describe('doesTourActivateByAction', () => {
323325
expect(doesTourActivateByAction(tour)).toBe(false)
324326
})
325327
})
328+
329+
describe('hasTourWaitPeriodPassed', () => {
330+
beforeEach(() => localStorage.clear())
331+
332+
const setLastSeen = (type: string, daysAgo: number) => {
333+
const date = new Date()
334+
date.setDate(date.getDate() - daysAgo)
335+
localStorage.setItem(`${LAST_SEEN_TOUR_DATE_KEY_PREFIX}${type}`, JSON.stringify(date.toISOString()))
336+
}
337+
338+
it.each([
339+
['no config', undefined, true],
340+
['days is 0', { days: 0, types: ['tour' as const] }, true],
341+
['empty types', { days: 7, types: [] }, true],
342+
['no stored date', { days: 7, types: ['tour' as const] }, true],
343+
])('returns true when %s', (_desc, config, expected) => {
344+
expect(hasTourWaitPeriodPassed(config)).toBe(expected)
345+
})
346+
347+
it('returns false when within the wait period', () => {
348+
setLastSeen('tour', 0)
349+
expect(hasTourWaitPeriodPassed({ days: 7, types: ['tour'] })).toBe(false)
350+
})
351+
352+
it('returns true when past the wait period', () => {
353+
setLastSeen('tour', 10)
354+
expect(hasTourWaitPeriodPassed({ days: 7, types: ['tour'] })).toBe(true)
355+
})
356+
357+
it('ignores types not in the config', () => {
358+
setLastSeen('announcement', 0)
359+
expect(hasTourWaitPeriodPassed({ days: 7, types: ['tour'] })).toBe(true)
360+
})
361+
362+
it('uses the most recent date across multiple types', () => {
363+
setLastSeen('tour', 10)
364+
setLastSeen('announcement', 0)
365+
expect(hasTourWaitPeriodPassed({ days: 7, types: ['tour', 'announcement'] })).toBe(false)
366+
})
367+
})

packages/browser/src/__tests__/posthog-core.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { defaultPostHog } from './helpers/posthog-instance'
22
import type { PostHogConfig } from '../types'
33
import { uuidv7 } from '../uuidv7'
44
import { SurveyEventName, SurveyEventProperties } from '../posthog-surveys-types'
5+
import { ProductTourEventName, ProductTourEventProperties } from '../posthog-product-tours-types'
56
import { SURVEY_SEEN_PREFIX } from '../utils/survey-utils'
67
import { beforeEach } from '@jest/globals'
78

@@ -376,6 +377,52 @@ describe('posthog core', () => {
376377
})
377378
})
378379

380+
describe('product tour capture()', () => {
381+
const setup = (config: Partial<PostHogConfig> = {}, token: string = uuidv7()) => {
382+
const beforeSendMock = jest.fn().mockImplementation((e) => e)
383+
const posthog = defaultPostHog().init(token, { ...config, before_send: beforeSendMock }, token)!
384+
return { posthog, beforeSendMock }
385+
}
386+
387+
it('sending product tour shown events with tour_type should set type-specific last seen date property', () => {
388+
// arrange
389+
const { posthog, beforeSendMock } = setup({ debug: false })
390+
391+
// act
392+
posthog.capture(ProductTourEventName.SHOWN, {
393+
[ProductTourEventProperties.TOUR_ID]: 'testTour1',
394+
[ProductTourEventProperties.TOUR_NAME]: 'Test Tour',
395+
[ProductTourEventProperties.TOUR_TYPE]: 'tour',
396+
})
397+
398+
// assert
399+
const capturedEvent = beforeSendMock.mock.calls[0][0]
400+
expect(capturedEvent.$set).toBeDefined()
401+
const typeSpecificKey = `${ProductTourEventProperties.TOUR_LAST_SEEN_DATE}/tour`
402+
expect(capturedEvent.$set[typeSpecificKey]).toBeDefined()
403+
// Verify it's a valid ISO date string
404+
expect(new Date(capturedEvent.$set[typeSpecificKey]).toISOString()).toBe(
405+
capturedEvent.$set[typeSpecificKey]
406+
)
407+
})
408+
409+
it('sending product tour shown events without tour_type should not set last seen date property', () => {
410+
// arrange
411+
const { posthog, beforeSendMock } = setup({ debug: false })
412+
413+
// act
414+
posthog.capture(ProductTourEventName.SHOWN, {
415+
[ProductTourEventProperties.TOUR_ID]: 'testTour1',
416+
[ProductTourEventProperties.TOUR_NAME]: 'Test Tour',
417+
})
418+
419+
// assert
420+
const capturedEvent = beforeSendMock.mock.calls[0][0]
421+
// No $set should be added when tour_type is missing
422+
expect(capturedEvent.$set).toBeUndefined()
423+
})
424+
})
425+
379426
describe('setInternalOrTestUser()', () => {
380427
const setup = (config: Partial<PostHogConfig> = {}, token: string = uuidv7()) => {
381428
const beforeSendMock = jest.fn().mockImplementation((e) => e)

packages/browser/src/extensions/product-tours/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export const TOUR_SHOWN_KEY_PREFIX = 'ph_product_tour_shown_'
22
export const TOUR_COMPLETED_KEY_PREFIX = 'ph_product_tour_completed_'
33
export const TOUR_DISMISSED_KEY_PREFIX = 'ph_product_tour_dismissed_'
44
export const ACTIVE_TOUR_SESSION_KEY = 'ph_active_product_tour'
5+
export const LAST_SEEN_TOUR_DATE_KEY_PREFIX = 'ph_last_seen_tour_date_'

packages/browser/src/extensions/product-tours/product-tours-utils.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@ import {
66
ProductTourSelectorError,
77
ProductTourStep,
88
DEFAULT_PRODUCT_TOUR_APPEARANCE,
9+
ProductTourWaitPeriod,
910
} from '../../posthog-product-tours-types'
1011
import { findElement } from './element-inference'
1112
import { prepareStylesheet } from '../utils/stylesheet-loader'
1213
import { document as _document, window as _window } from '../../utils/globals'
1314
import { getFontFamily, getContrastingTextColor, hexToRgba } from '../surveys/surveys-extension-utils'
1415
import { createLogger } from '../../utils/logger'
16+
import { localStore } from '../../storage'
17+
import { LAST_SEEN_TOUR_DATE_KEY_PREFIX } from './constants'
1518

1619
import productTourStyles from './product-tour.css'
1720
import { isUndefined } from '@posthog/core'
21+
import { hasPeriodPassed } from '../utils/matcher-utils'
1822

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

@@ -352,3 +356,37 @@ export function getStepHtml(step: ProductTourStep): string {
352356
// backwards compat, will be deprecated
353357
return renderTipTapContent(step.content)
354358
}
359+
360+
export function hasTourWaitPeriodPassed(seenTourWaitPeriod?: ProductTourWaitPeriod): boolean {
361+
if (!seenTourWaitPeriod) {
362+
return true
363+
}
364+
365+
const { days, types } = seenTourWaitPeriod
366+
if (!days || !types || types.length === 0) {
367+
return true
368+
}
369+
370+
let mostRecentDate: Date | null = null
371+
372+
for (const type of types) {
373+
const raw = localStore._get(`${LAST_SEEN_TOUR_DATE_KEY_PREFIX}${type}`)
374+
if (raw) {
375+
try {
376+
const stored = JSON.parse(raw)
377+
const date = new Date(stored)
378+
if (!isNaN(date.getTime()) && (!mostRecentDate || date > mostRecentDate)) {
379+
mostRecentDate = date
380+
}
381+
} catch {
382+
// ignore malformed entries
383+
}
384+
}
385+
}
386+
387+
if (!mostRecentDate) {
388+
return true
389+
}
390+
391+
return hasPeriodPassed(days, mostRecentDate)
392+
}

0 commit comments

Comments
 (0)