@teleporthq/teleport-plugin-next-workflows 0.43.69 → 0.43.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/__tests__/chapter-reached-trigger.test.ts +141 -0
  2. package/__tests__/element-video-control.test.ts +102 -0
  3. package/__tests__/element-visible-settings.test.ts +128 -0
  4. package/__tests__/element-visible-trigger-scoping.test.ts +22 -0
  5. package/__tests__/scrolled-past-trigger.test.ts +189 -0
  6. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  7. package/dist/cjs/api-route-generator.js +1 -0
  8. package/dist/cjs/api-route-generator.js.map +1 -1
  9. package/dist/cjs/element-visible-trigger.d.ts +32 -0
  10. package/dist/cjs/element-visible-trigger.d.ts.map +1 -0
  11. package/dist/cjs/element-visible-trigger.js +42 -0
  12. package/dist/cjs/element-visible-trigger.js.map +1 -0
  13. package/dist/cjs/index.d.ts +1 -0
  14. package/dist/cjs/index.d.ts.map +1 -1
  15. package/dist/cjs/index.js +5 -1
  16. package/dist/cjs/index.js.map +1 -1
  17. package/dist/cjs/nodes/element/element-video-control.d.ts +3 -0
  18. package/dist/cjs/nodes/element/element-video-control.d.ts.map +1 -0
  19. package/dist/cjs/nodes/element/element-video-control.js +94 -0
  20. package/dist/cjs/nodes/element/element-video-control.js.map +1 -0
  21. package/dist/cjs/nodes/index.d.ts.map +1 -1
  22. package/dist/cjs/nodes/index.js +2 -1
  23. package/dist/cjs/nodes/index.js.map +1 -1
  24. package/dist/cjs/scroll-points.d.ts +14 -0
  25. package/dist/cjs/scroll-points.d.ts.map +1 -0
  26. package/dist/cjs/scroll-points.js +23 -0
  27. package/dist/cjs/scroll-points.js.map +1 -0
  28. package/dist/cjs/trigger-generator.d.ts.map +1 -1
  29. package/dist/cjs/trigger-generator.js +3 -15
  30. package/dist/cjs/trigger-generator.js.map +1 -1
  31. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  32. package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
  33. package/dist/cjs/workflow-component-plugin.js +121 -13
  34. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  35. package/dist/esm/api-route-generator.d.ts.map +1 -1
  36. package/dist/esm/api-route-generator.js +1 -0
  37. package/dist/esm/api-route-generator.js.map +1 -1
  38. package/dist/esm/element-visible-trigger.d.ts +32 -0
  39. package/dist/esm/element-visible-trigger.d.ts.map +1 -0
  40. package/dist/esm/element-visible-trigger.js +37 -0
  41. package/dist/esm/element-visible-trigger.js.map +1 -0
  42. package/dist/esm/index.d.ts +1 -0
  43. package/dist/esm/index.d.ts.map +1 -1
  44. package/dist/esm/index.js +1 -0
  45. package/dist/esm/index.js.map +1 -1
  46. package/dist/esm/nodes/element/element-video-control.d.ts +3 -0
  47. package/dist/esm/nodes/element/element-video-control.d.ts.map +1 -0
  48. package/dist/esm/nodes/element/element-video-control.js +91 -0
  49. package/dist/esm/nodes/element/element-video-control.js.map +1 -0
  50. package/dist/esm/nodes/index.d.ts.map +1 -1
  51. package/dist/esm/nodes/index.js +2 -1
  52. package/dist/esm/nodes/index.js.map +1 -1
  53. package/dist/esm/scroll-points.d.ts +14 -0
  54. package/dist/esm/scroll-points.d.ts.map +1 -0
  55. package/dist/esm/scroll-points.js +19 -0
  56. package/dist/esm/scroll-points.js.map +1 -0
  57. package/dist/esm/trigger-generator.d.ts.map +1 -1
  58. package/dist/esm/trigger-generator.js +3 -15
  59. package/dist/esm/trigger-generator.js.map +1 -1
  60. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  61. package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
  62. package/dist/esm/workflow-component-plugin.js +121 -13
  63. package/dist/esm/workflow-component-plugin.js.map +1 -1
  64. package/package.json +4 -4
  65. package/src/api-route-generator.ts +1 -0
  66. package/src/element-visible-trigger.ts +63 -0
  67. package/src/index.ts +1 -0
  68. package/src/nodes/element/element-video-control.ts +95 -0
  69. package/src/nodes/index.ts +2 -0
  70. package/src/scroll-points.ts +21 -0
  71. package/src/trigger-generator.ts +7 -16
  72. package/src/workflow-component-plugin.ts +135 -13
@@ -0,0 +1,141 @@
1
+ // `event-chapter-reached` is a LIFECYCLE trigger bound to ONE element (a
2
+ // scroll-scene chapter): it listens for the `tq-chapter-reached` event the
3
+ // published TqScrollScene dispatches on the chapter, so it follows the same
4
+ // rules as element-visible — DOM id lookup, and pruned from every page whose
5
+ // JSX does not render the chapter.
6
+
7
+ import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
8
+
9
+ const HTML_ID = 'thq_chapter_pricing'
10
+ const NODE_ID = 'TQ__chapterPricing'
11
+
12
+ const chapterWorkflow = (config: Record<string, unknown>) => ({
13
+ id: 'wf-chapter-pricing',
14
+ name: 'Load pricing on chapter',
15
+ trigger: {
16
+ type: 'event-chapter-reached',
17
+ nodeId: 'trigger-chapter',
18
+ scope: 'element',
19
+ config,
20
+ },
21
+ nodes: [
22
+ {
23
+ id: 'update-1',
24
+ type: 'state-update-local-state',
25
+ config: { property: 'pricingLoaded', value: true },
26
+ stepNumber: 1,
27
+ label: 'Mark loaded',
28
+ },
29
+ ],
30
+ edges: [{ id: 'e1', source: 'trigger-chapter', target: 'update-1' }],
31
+ })
32
+
33
+ const jsxWithElementId = (elementId: string | null) => ({
34
+ type: 'chunk-type-ast',
35
+ name: 'jsx-component',
36
+ content: {
37
+ type: 'VariableDeclaration',
38
+ declarations: [
39
+ {
40
+ type: 'VariableDeclarator',
41
+ init: {
42
+ type: 'ArrowFunctionExpression',
43
+ body: {
44
+ type: 'BlockStatement',
45
+ body: [
46
+ {
47
+ type: 'ReturnStatement',
48
+ argument: {
49
+ type: 'JSXElement',
50
+ openingElement: {
51
+ type: 'JSXOpeningElement',
52
+ name: { type: 'JSXIdentifier', name: 'div' },
53
+ attributes: elementId
54
+ ? [
55
+ {
56
+ type: 'JSXAttribute',
57
+ name: { type: 'JSXIdentifier', name: 'id' },
58
+ value: { type: 'StringLiteral', value: elementId },
59
+ },
60
+ ]
61
+ : [],
62
+ },
63
+ children: [],
64
+ },
65
+ },
66
+ ],
67
+ },
68
+ },
69
+ },
70
+ ],
71
+ },
72
+ })
73
+
74
+ const getWorkflowModule = async (
75
+ workflow: any,
76
+ renderedElementId: string | null
77
+ ): Promise<string | null> => {
78
+ const plugin = createNextWorkflowPlugin({ isPage: true })
79
+ const structure: any = {
80
+ uidl: {
81
+ name: 'Story',
82
+ node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
83
+ stateDefinitions: { pricingLoaded: { type: 'boolean', defaultValue: false } },
84
+ },
85
+ chunks: [jsxWithElementId(renderedElementId)],
86
+ options: { workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} } },
87
+ dependencies: {},
88
+ }
89
+ await plugin(structure)
90
+ const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
91
+ return moduleChunk ? String(moduleChunk.content) : null
92
+ }
93
+
94
+ describe('event-chapter-reached — generated listener', () => {
95
+ const module = (config: Record<string, unknown>, rendered: string | null = HTML_ID) =>
96
+ getWorkflowModule(chapterWorkflow(config), rendered)
97
+
98
+ it('listens on the chapter DOM id for the scene announcement', async () => {
99
+ const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID })
100
+ expect(code).not.toBeNull()
101
+ expect(code).toContain(`document.getElementById('${HTML_ID}')`)
102
+ expect(code).toContain("addEventListener('tq-chapter-reached'")
103
+ expect(code).toContain(`elementId: '${HTML_ID}'`)
104
+ expect(code).toContain('chapterIndex: detail.chapterIndex')
105
+ expect(code).not.toContain(NODE_ID)
106
+ })
107
+
108
+ it('listens on the chapter root and ignores announcements bubbling from a nested scene', async () => {
109
+ const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID })
110
+ expect(code).toContain(".closest('[data-scene-stage] > *, [data-scene-track] > *')")
111
+ expect(code).toMatch(/if \(event\.target !== __chRoot_\w+\) \{ return; \}/)
112
+ })
113
+
114
+ it('fires once by default and detaches after the first announcement', async () => {
115
+ const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID })
116
+ expect(code).toMatch(
117
+ /__chRoot_\w+\.removeEventListener\('tq-chapter-reached', __chH_\w+\);\n\s+__chFire_/
118
+ )
119
+ expect(code).toMatch(/if \(!__chStamp_\w+\) \{/)
120
+ })
121
+
122
+ it('keeps listening when the author asked for every time', async () => {
123
+ const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID, once: false })
124
+ expect(code).not.toMatch(
125
+ /__chRoot_\w+\.removeEventListener\('tq-chapter-reached', __chH_\w+\);\n\s+__chFire_/
126
+ )
127
+ expect(code).not.toMatch(/if \(!__chStamp_\w+\) \{/)
128
+ expect(code).toContain("addEventListener('tq-chapter-reached'")
129
+ })
130
+
131
+ it('reads the stamped chapter and count when it attaches after the announcement', async () => {
132
+ const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID })
133
+ expect(code).toContain("getAttribute('data-chapter-active')")
134
+ expect(code).toContain("getAttribute('data-chapter-count')")
135
+ })
136
+
137
+ it('is pruned from a page that does not render the chapter', async () => {
138
+ const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID }, 'some_other_element')
139
+ expect(code === null || !code.includes('tq-chapter-reached')).toBe(true)
140
+ })
141
+ })
@@ -0,0 +1,102 @@
1
+ // "Control a Video" runs in the browser against the bound Video element. The
2
+ // generated handler is exercised here on a stubbed DOM: every action, the
3
+ // container-holding-a-video case, and the muted retry when the browser
4
+ // blocks a play with sound.
5
+
6
+ import { nodeRegistry } from '../src'
7
+ import { resolveHandlerEntryName } from '../src/nodes/types'
8
+
9
+ type Handler = (config: Record<string, unknown>, context: Record<string, unknown>) => Promise<any>
10
+
11
+ const loadHandler = (): Handler => {
12
+ const source = nodeRegistry['element-video-control'].generateHandler()
13
+ const entry = resolveHandlerEntryName(source, 'element-video-control')
14
+ // eslint-disable-next-line no-new-func
15
+ return new Function(`${source}\nreturn ${entry};`)() as Handler
16
+ }
17
+
18
+ const fakeVideo = (options: { blockSound?: boolean } = {}) => {
19
+ const video: any = {
20
+ tagName: 'VIDEO',
21
+ paused: true,
22
+ ended: false,
23
+ muted: false,
24
+ currentTime: 12,
25
+ playCalls: 0,
26
+ play() {
27
+ this.playCalls += 1
28
+ if (options.blockSound && !this.muted) {
29
+ return Promise.reject(new Error('NotAllowedError'))
30
+ }
31
+ this.paused = false
32
+ return Promise.resolve()
33
+ },
34
+ pause() {
35
+ this.paused = true
36
+ },
37
+ }
38
+ return video
39
+ }
40
+
41
+ const withDom = (byId: Record<string, any>) => {
42
+ ;(globalThis as any).document = {
43
+ getElementById: (id: string) => byId[id] || null,
44
+ }
45
+ }
46
+
47
+ describe('element-video-control — generated handler', () => {
48
+ afterEach(() => {
49
+ delete (globalThis as any).document
50
+ })
51
+
52
+ it('registers as a client-side element node', () => {
53
+ expect(nodeRegistry['element-video-control'].executionEnv).toBe('client')
54
+ expect(nodeRegistry['element-video-control'].generateHandler()).not.toContain('`')
55
+ })
56
+
57
+ it('plays by default and reports the state', async () => {
58
+ const video = fakeVideo()
59
+ withDom({ hero: video })
60
+ const result = await loadHandler()({ nodeId: 'hero' }, {})
61
+ expect(result).toMatchObject({ success: true, action: 'play', playing: true, currentTime: 12 })
62
+ expect(video.playCalls).toBe(1)
63
+ })
64
+
65
+ it('finds the video inside a bound container and resolves the html id fallback', async () => {
66
+ const video = fakeVideo()
67
+ const container = { tagName: 'DIV', querySelector: () => video }
68
+ withDom({ thq_hero: container })
69
+ const result = await loadHandler()(
70
+ { nodeId: 'TQ_hero', elementHtmlId: 'thq_hero', action: 'pause' },
71
+ {}
72
+ )
73
+ expect(result).toMatchObject({ success: true, action: 'pause', playing: false })
74
+ })
75
+
76
+ it('pauses, toggles, restarts, mutes and unmutes', async () => {
77
+ const video = fakeVideo()
78
+ video.paused = false
79
+ withDom({ v: video })
80
+ const handler = loadHandler()
81
+ expect((await handler({ nodeId: 'v', action: 'toggle' }, {})).playing).toBe(false)
82
+ expect((await handler({ nodeId: 'v', action: 'toggle' }, {})).playing).toBe(true)
83
+ const restarted = await handler({ nodeId: 'v', action: 'restart' }, {})
84
+ expect(restarted).toMatchObject({ playing: true, currentTime: 0 })
85
+ expect((await handler({ nodeId: 'v', action: 'mute' }, {})).muted).toBe(true)
86
+ expect((await handler({ nodeId: 'v', action: 'unmute' }, {})).muted).toBe(false)
87
+ })
88
+
89
+ it('retries muted when the browser blocks a play with sound, and says so', async () => {
90
+ const video = fakeVideo({ blockSound: true })
91
+ withDom({ v: video })
92
+ const result = await loadHandler()({ nodeId: 'v', action: 'play' }, {})
93
+ expect(result).toMatchObject({ success: true, playing: true, muted: true, mutedFallback: true })
94
+ expect(video.playCalls).toBe(2)
95
+ })
96
+
97
+ it('reports a missing video instead of throwing', async () => {
98
+ withDom({})
99
+ const result = await loadHandler()({ nodeId: 'nowhere', action: 'play' }, {})
100
+ expect(result).toEqual({ success: false, action: 'play', reason: 'video-not-found' })
101
+ })
102
+ })
@@ -0,0 +1,128 @@
1
+ // The "On Visible" trigger's settings, as the editor's inspector writes them:
2
+ // a PERCENT threshold and `once` (formerly `triggerOnce`). Before 2026-09-18 the
3
+ // percent reached IntersectionObserver unconverted — Chrome 153: "RangeError:
4
+ // Failed to construct 'IntersectionObserver': Threshold values must be numbers
5
+ // between 0 and 1" — and "run once" was never read.
6
+
7
+ import { elementVisibleObserverCode, elementVisibleSettings } from '../src/element-visible-trigger'
8
+ import { generateTriggerCode } from '../src/trigger-generator'
9
+
10
+ describe('elementVisibleSettings', () => {
11
+ it('turns the inspector percent into the share the browser counts, within 0–1', () => {
12
+ expect(elementVisibleSettings({ threshold: 50 }).threshold).toBe(0.5)
13
+ expect(elementVisibleSettings({ threshold: 100 }).threshold).toBe(1)
14
+ expect(elementVisibleSettings({ threshold: 250 }).threshold).toBe(1)
15
+ expect(elementVisibleSettings({ threshold: -5 }).threshold).toBe(0)
16
+ expect(elementVisibleSettings({}).threshold).toBe(0)
17
+ expect(elementVisibleSettings({ threshold: 'x' }).threshold).toBe(0)
18
+ })
19
+
20
+ it('runs once unless told otherwise, and still reads the name older projects saved', () => {
21
+ expect(elementVisibleSettings({}).once).toBe(true)
22
+ expect(elementVisibleSettings({ once: false }).once).toBe(false)
23
+ expect(elementVisibleSettings({ triggerOnce: false }).once).toBe(false)
24
+ expect(elementVisibleSettings({ triggerOnce: true }).once).toBe(true)
25
+ // the current name wins when both are present
26
+ expect(elementVisibleSettings({ once: true, triggerOnce: false }).once).toBe(true)
27
+ })
28
+ })
29
+
30
+ /**
31
+ * Runs the generated statements against a fake IntersectionObserver and lets a
32
+ * test feed it the entries a browser would, one visibility change at a time.
33
+ */
34
+ const observe = (config: Record<string, unknown>, viewportHeight = 1000) => {
35
+ let callback: (entries: unknown[]) => void = () => undefined
36
+ let options: { threshold: number | number[] } = { threshold: 0 }
37
+ let disconnected = false
38
+ class FakeObserver {
39
+ constructor(cb: (entries: unknown[]) => void, opts: { threshold: number | number[] }) {
40
+ callback = cb
41
+ options = opts
42
+ }
43
+ disconnect() {
44
+ disconnected = true
45
+ }
46
+ }
47
+ const runs: number[] = []
48
+ const code = elementVisibleObserverCode('obs', config, 'runs.push(entry.intersectionRatio);')
49
+ // tslint:disable-next-line:function-constructor
50
+ new Function('IntersectionObserver', 'window', 'runs', code)(
51
+ FakeObserver,
52
+ { innerHeight: viewportHeight },
53
+ runs
54
+ )
55
+ const see = (ratio: number, height = 400) => {
56
+ if (!disconnected) {
57
+ callback([
58
+ { isIntersecting: ratio > 0, intersectionRatio: ratio, boundingClientRect: { height } },
59
+ ])
60
+ }
61
+ }
62
+ return { runs, see, options: () => options, disconnected: () => disconnected }
63
+ }
64
+
65
+ describe('the generated observer', () => {
66
+ it('builds a valid observer for a threshold typed in the inspector', () => {
67
+ const { options } = observe({ threshold: 50 })
68
+ const thresholds = ([] as number[]).concat(options().threshold)
69
+ expect(thresholds.every((value) => value >= 0 && value <= 1)).toBe(true)
70
+ })
71
+
72
+ it('runs when enough of the element is on screen, once by default', () => {
73
+ const trigger = observe({ threshold: 50 })
74
+ trigger.see(0.3)
75
+ expect(trigger.runs).toEqual([])
76
+ trigger.see(0.55)
77
+ expect(trigger.runs).toEqual([0.55])
78
+ expect(trigger.disconnected()).toBe(true)
79
+ trigger.see(0.1)
80
+ trigger.see(0.9)
81
+ expect(trigger.runs).toEqual([0.55])
82
+ })
83
+
84
+ it('"every time" runs again after the element has left, never twice while it stays', () => {
85
+ const trigger = observe({ threshold: 50, triggerOnce: false })
86
+ trigger.see(0.6)
87
+ trigger.see(0.8)
88
+ trigger.see(0.65)
89
+ expect(trigger.runs).toEqual([0.6])
90
+ trigger.see(0.2)
91
+ trigger.see(0.7)
92
+ expect(trigger.runs).toEqual([0.6, 0.7])
93
+ expect(trigger.disconnected()).toBe(false)
94
+ })
95
+
96
+ it('an element taller than the screen runs at what it can reach', () => {
97
+ // 3000px tall in a 1000px screen: at most a third of it is ever visible
98
+ const trigger = observe({ threshold: 60 }, 1000)
99
+ trigger.see(0.32, 3000)
100
+ expect(trigger.runs).toEqual([0.32])
101
+ })
102
+
103
+ it('with no threshold it runs as soon as any part appears', () => {
104
+ const trigger = observe({})
105
+ expect(trigger.options().threshold).toBe(0)
106
+ trigger.see(0.01)
107
+ expect(trigger.runs).toEqual([0.01])
108
+ })
109
+ })
110
+
111
+ describe('both generator paths use it', () => {
112
+ it('the exported trigger generator no longer passes the percent to the browser', () => {
113
+ const code = generateTriggerCode(
114
+ {
115
+ id: 'wf-1',
116
+ trigger: {
117
+ type: 'event-element-visible',
118
+ config: { elementHtmlId: 'hero', threshold: 50, triggerOnce: false },
119
+ },
120
+ } as never,
121
+ 'wf'
122
+ )
123
+ expect(code).not.toContain('threshold: 50')
124
+ expect(code).toContain('Math.min(0.5, reach * 0.9)')
125
+ // "every time": the only disconnect left is the cleanup's
126
+ expect(code.match(/disconnect\(\)/g)).toHaveLength(1)
127
+ })
128
+ })
@@ -294,3 +294,25 @@ describe('event-element-visible — config/handler integrity when the module is
294
294
  expect(code).toContain('const __wfConfig_wf_cookie_visible =')
295
295
  })
296
296
  })
297
+
298
+ // The inspector writes the threshold as a percent and, in projects saved before
299
+ // 2026-09-18, the run choice as `triggerOnce` (see element-visible-trigger.ts).
300
+ describe('event-element-visible — the inspector settings reach the page', () => {
301
+ it('a 50% threshold becomes the 0.5 the browser accepts, and "every time" keeps observing', async () => {
302
+ const code =
303
+ (await getWorkflowModule(
304
+ elementVisibleWorkflow({
305
+ nodeId: NODE_ID,
306
+ elementHtmlId: HTML_ID,
307
+ threshold: 50,
308
+ triggerOnce: false,
309
+ }),
310
+ HTML_ID
311
+ )) || ''
312
+
313
+ expect(code).toContain('Math.min(0.5, reach * 0.9)')
314
+ expect(code).not.toContain('threshold: 50 }')
315
+ // the only disconnect left is the cleanup's
316
+ expect(code.match(/__obs_wf_cookie_visible\.disconnect\(\)/g)).toHaveLength(1)
317
+ })
318
+ })
@@ -0,0 +1,189 @@
1
+ // The two "scrolled past a point" triggers. `event-scene-scrolled-past` is a
2
+ // LIFECYCLE trigger bound to ONE element (the scroll scene): it listens for
3
+ // `tq-scene-point-passed` on the scene's track and is pruned from pages that
4
+ // do not render the scene. `event-page-scrolled-past` is page-scoped and
5
+ // watches the window's own scroll progress.
6
+
7
+ import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
8
+
9
+ const HTML_ID = 'thq_story_scene'
10
+ const NODE_ID = 'TQ__storyScene'
11
+
12
+ const workflowWith = (trigger: Record<string, unknown>) => ({
13
+ id: 'wf-scrolled-past',
14
+ name: 'Reveal offer',
15
+ trigger: { nodeId: 'trigger-scroll', ...trigger },
16
+ nodes: [
17
+ {
18
+ id: 'update-1',
19
+ type: 'state-update-local-state',
20
+ config: { property: 'offerShown', value: true },
21
+ stepNumber: 1,
22
+ label: 'Show offer',
23
+ },
24
+ ],
25
+ edges: [{ id: 'e1', source: 'trigger-scroll', target: 'update-1' }],
26
+ })
27
+
28
+ const sceneWorkflow = (config: Record<string, unknown>) =>
29
+ workflowWith({ type: 'event-scene-scrolled-past', scope: 'element', config })
30
+
31
+ const pageWorkflow = (config: Record<string, unknown>) =>
32
+ workflowWith({ type: 'event-page-scrolled-past', scope: 'page', config })
33
+
34
+ const jsxWithElementId = (elementId: string | null) => ({
35
+ type: 'chunk-type-ast',
36
+ name: 'jsx-component',
37
+ content: {
38
+ type: 'VariableDeclaration',
39
+ declarations: [
40
+ {
41
+ type: 'VariableDeclarator',
42
+ init: {
43
+ type: 'ArrowFunctionExpression',
44
+ body: {
45
+ type: 'BlockStatement',
46
+ body: [
47
+ {
48
+ type: 'ReturnStatement',
49
+ argument: {
50
+ type: 'JSXElement',
51
+ openingElement: {
52
+ type: 'JSXOpeningElement',
53
+ name: { type: 'JSXIdentifier', name: 'div' },
54
+ attributes: elementId
55
+ ? [
56
+ {
57
+ type: 'JSXAttribute',
58
+ name: { type: 'JSXIdentifier', name: 'id' },
59
+ value: { type: 'StringLiteral', value: elementId },
60
+ },
61
+ ]
62
+ : [],
63
+ },
64
+ children: [],
65
+ },
66
+ },
67
+ ],
68
+ },
69
+ },
70
+ },
71
+ ],
72
+ },
73
+ })
74
+
75
+ const getWorkflowModule = async (
76
+ workflow: any,
77
+ renderedElementId: string | null
78
+ ): Promise<string | null> => {
79
+ const plugin = createNextWorkflowPlugin({ isPage: true })
80
+ const structure: any = {
81
+ uidl: {
82
+ name: 'Story',
83
+ outputOptions: { pageId: 'page_home', fileName: 'page_home' },
84
+ node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
85
+ stateDefinitions: { offerShown: { type: 'boolean', defaultValue: false } },
86
+ },
87
+ chunks: [jsxWithElementId(renderedElementId)],
88
+ options: { workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} } },
89
+ dependencies: {},
90
+ }
91
+ await plugin(structure)
92
+ const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
93
+ return moduleChunk ? String(moduleChunk.content) : null
94
+ }
95
+
96
+ describe('event-scene-scrolled-past — generated listener', () => {
97
+ const module = (config: Record<string, unknown>, rendered: string | null = HTML_ID) =>
98
+ getWorkflowModule(sceneWorkflow(config), rendered)
99
+
100
+ it('listens on the scene track for the point the author picked', async () => {
101
+ const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID, point: 'three-quarters' })
102
+ expect(code).not.toBeNull()
103
+ expect(code).toContain(`document.getElementById('${HTML_ID}')`)
104
+ expect(code).toContain(".closest('[data-scene-track]')")
105
+ expect(code).toContain("addEventListener('tq-scene-point-passed'")
106
+ expect(code).toContain("if (detail.point !== 'three-quarters') { return; }")
107
+ expect(code).toContain(`elementId: '${HTML_ID}', point: 'three-quarters'`)
108
+ expect(code).toMatch(/if \(event\.target !== __spRoot_\w+\) \{ return; \}/)
109
+ expect(code).not.toContain(NODE_ID)
110
+ })
111
+
112
+ it('falls back to halfway when no point was set', async () => {
113
+ const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID })
114
+ expect(code).toContain("if (detail.point !== 'half') { return; }")
115
+ expect(code).toMatch(/if \(__spStamp_\w+ >= 2\) \{/)
116
+ })
117
+
118
+ it('fires once by default: detaches after the first pass and skips the listener when the stamp already covers it', async () => {
119
+ const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID, point: 'end' })
120
+ expect(code).toMatch(
121
+ /__spRoot_\w+\.removeEventListener\('tq-scene-point-passed', __spH_\w+\);\n\s+__spFire_/
122
+ )
123
+ expect(code).toMatch(/if \(__spStamp_\w+ < 4\) \{/)
124
+ })
125
+
126
+ it('keeps listening when the author asked for every time', async () => {
127
+ const code = await module({
128
+ nodeId: NODE_ID,
129
+ elementHtmlId: HTML_ID,
130
+ point: 'quarter',
131
+ once: false,
132
+ })
133
+ expect(code).not.toMatch(
134
+ /__spRoot_\w+\.removeEventListener\('tq-scene-point-passed', __spH_\w+\);\n\s+__spFire_/
135
+ )
136
+ expect(code).not.toMatch(/if \(__spStamp_\w+ < 1\) \{/)
137
+ expect(code).toContain("addEventListener('tq-scene-point-passed'")
138
+ })
139
+
140
+ it('reads the stamped furthest point when it attaches after the pass', async () => {
141
+ const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID, point: 'half' })
142
+ expect(code).toContain("getAttribute('data-scene-point')")
143
+ expect(code).toMatch(/if \(__spStamp_\w+ >= 2\) \{\n\s+__spFire_\w+\(0\.5\);/)
144
+ })
145
+
146
+ it('is pruned from a page that does not render the scene', async () => {
147
+ const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID }, 'some_other_element')
148
+ expect(code === null || !code.includes('tq-scene-point-passed')).toBe(true)
149
+ })
150
+ })
151
+
152
+ describe('event-page-scrolled-past — generated listener', () => {
153
+ const module = (config: Record<string, unknown>) => getWorkflowModule(pageWorkflow(config), null)
154
+
155
+ it('watches the window scroll progress for the point the author picked', async () => {
156
+ const code = await module({ pageId: 'page_home', point: 'three-quarters' })
157
+ expect(code).not.toBeNull()
158
+ expect(code).toContain("window.addEventListener('scroll', __ppOnScroll_")
159
+ expect(code).toContain("window.addEventListener('resize', __ppOnScroll_")
160
+ expect(code).toContain('document.documentElement.scrollHeight - window.innerHeight')
161
+ expect(code).toContain('if (progress < 0.75 - 0.000001)')
162
+ expect(code).toContain("point: 'three-quarters', progress: progress, url: window.location.href")
163
+ })
164
+
165
+ it('checks once on attach so an anchor link that opens past the point counts', async () => {
166
+ const code = await module({ pageId: 'page_home', point: 'half' })
167
+ expect(code).toMatch(/cleanups\.push\(__ppStop_\w+\);\n\s+__ppCheck_\w+\(\);/)
168
+ })
169
+
170
+ it('stays off every page but the one it watches', async () => {
171
+ const code = await module({ pageId: 'page_pricing', point: 'half' })
172
+ expect(code === null || !code.includes('__ppOnScroll_')).toBe(true)
173
+ const everywhere = await module({ allPages: true, point: 'half' })
174
+ expect(everywhere).toContain('__ppOnScroll_')
175
+ })
176
+
177
+ it('treats a page that cannot scroll as fully in view', async () => {
178
+ const code = await module({ pageId: 'page_home', point: 'end' })
179
+ expect(code).toContain('return max <= 0 ? 1 :')
180
+ })
181
+
182
+ it('fires once by default and detaches; every time re-arms when the visitor scrolls back up', async () => {
183
+ const once = await module({ pageId: 'page_home', point: 'half' })
184
+ expect(once).toMatch(/__ppPassed_\w+ = true;\n\s+__ppStop_\w+\(\);/)
185
+ const every = await module({ pageId: 'page_home', point: 'half', once: false })
186
+ expect(every).not.toMatch(/__ppPassed_\w+ = true;\n\s+__ppStop_\w+\(\);/)
187
+ expect(every).toMatch(/\{ __ppPassed_\w+ = false; return; \}/)
188
+ })
189
+ })
@@ -1 +1 @@
1
- {"version":3,"file":"api-route-generator.d.ts","sourceRoot":"","sources":["../../src/api-route-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,aAAa,EAIb,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAuD9C,MAAM,WAAW,yBAAyB;IACxC,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;;OAIG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;CACrB;AA4BD,eAAO,MAAM,6BAA6B,YAC/B,eAAe,iBACT,MAAM,eACR,sBAAsB,YAC1B,yBAAyB,KACjC,MA+WF,CAAA;AAED,eAAO,MAAM,kBAAkB,YAAa,eAAe,KAAG,OAE7D,CAAA;AAED,eAAO,MAAM,sCAAsC,YACxC,eAAe,iBACT,MAAM,eACR,sBAAsB,YAC1B,yBAAyB,KACjC,MAsbF,CAAA;AA2JD,eAAO,MAAM,oBAAoB,aACrB,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,KACnD,MAsHF,CAAA;AAED,eAAO,MAAM,mBAAmB,eAClB,MAAM,aACP,MAAM,iBACF,MAAM,KACpB,MA4BF,CAAA;AAED,eAAO,MAAM,oBAAoB,aAAc,YAAY,KAAG,MAa7D,CAAA;AA8RD,eAAO,MAAM,+BAA+B,aAChC,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,KACnD,MA0OF,CAAA;AAED,eAAO,MAAM,uBAAuB,aAAc,YAAY,KAAG,MAiBhE,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,cAC1B,aAAa,GAAG,SAAS,cACxB,MAAM,KACjB,MAAM,GAAG,IAYX,CAAA;AAED,eAAO,MAAM,mBAAmB,aAAc,YAAY,KAAG,MAAM,EAqBlE,CAAA"}
1
+ {"version":3,"file":"api-route-generator.d.ts","sourceRoot":"","sources":["../../src/api-route-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,aAAa,EAIb,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAuD9C,MAAM,WAAW,yBAAyB;IACxC,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;;OAIG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;CACrB;AA4BD,eAAO,MAAM,6BAA6B,YAC/B,eAAe,iBACT,MAAM,eACR,sBAAsB,YAC1B,yBAAyB,KACjC,MA+WF,CAAA;AAED,eAAO,MAAM,kBAAkB,YAAa,eAAe,KAAG,OAE7D,CAAA;AAED,eAAO,MAAM,sCAAsC,YACxC,eAAe,iBACT,MAAM,eACR,sBAAsB,YAC1B,yBAAyB,KACjC,MAsbF,CAAA;AA4JD,eAAO,MAAM,oBAAoB,aACrB,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,KACnD,MAsHF,CAAA;AAED,eAAO,MAAM,mBAAmB,eAClB,MAAM,aACP,MAAM,iBACF,MAAM,KACpB,MA4BF,CAAA;AAED,eAAO,MAAM,oBAAoB,aAAc,YAAY,KAAG,MAa7D,CAAA;AA8RD,eAAO,MAAM,+BAA+B,aAChC,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,KACnD,MA0OF,CAAA;AAED,eAAO,MAAM,uBAAuB,aAAc,YAAY,KAAG,MAiBhE,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,cAC1B,aAAa,GAAG,SAAS,cACxB,MAAM,KACjB,MAAM,GAAG,IAYX,CAAA;AAED,eAAO,MAAM,mBAAmB,aAAc,YAAY,KAAG,MAAM,EAqBlE,CAAA"}
@@ -942,6 +942,7 @@ const CLIENT_ONLY_NODES = new Set([
942
942
  'element-show',
943
943
  'element-hide',
944
944
  'element-scroll-to',
945
+ 'element-video-control',
945
946
  'element-get-input-value',
946
947
  'element-set-text',
947
948
  'element-set-attribute',