@delmaredigital/payload-experiments 0.3.0-rc.4 → 0.4.0-rc.1

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 (265) hide show
  1. package/README.md +113 -20
  2. package/dist/admin/MetricSourceSelect.js +3 -3
  3. package/dist/admin/PlacementStatus.d.ts +3 -0
  4. package/dist/admin/PlacementStatus.js +54 -0
  5. package/dist/admin/PreviewLinks.d.ts +2 -1
  6. package/dist/admin/PreviewLinks.js +2 -2
  7. package/dist/admin/actions/ConfirmDialog.d.ts +2 -1
  8. package/dist/admin/actions/ConfirmDialog.js +2 -2
  9. package/dist/admin/actions/DecideDialog.d.ts +2 -1
  10. package/dist/admin/actions/DecideDialog.js +2 -2
  11. package/dist/admin/api.d.ts +11 -2
  12. package/dist/admin/api.js +20 -2
  13. package/dist/admin/blocks/BlockPanel.d.ts +4 -0
  14. package/dist/admin/blocks/BlockPanel.js +153 -0
  15. package/dist/admin/blocks/TestBlockField.d.ts +5 -0
  16. package/dist/admin/blocks/TestBlockField.js +163 -0
  17. package/dist/admin/blocks/formApi.d.ts +43 -0
  18. package/dist/admin/blocks/formApi.js +153 -0
  19. package/dist/admin/blocks/inject.d.ts +7 -0
  20. package/dist/admin/blocks/inject.js +41 -0
  21. package/dist/admin/blocks/model.d.ts +17 -0
  22. package/dist/admin/blocks/model.js +45 -0
  23. package/dist/admin/blocks/statusCache.d.ts +7 -0
  24. package/dist/admin/blocks/statusCache.js +83 -0
  25. package/dist/admin/blocks/useBlockForm.d.ts +17 -0
  26. package/dist/admin/blocks/useBlockForm.js +46 -0
  27. package/dist/admin/copy/actions.d.ts +2 -0
  28. package/dist/admin/copy/actions.js +9 -0
  29. package/dist/admin/copy/blocks.d.ts +27 -0
  30. package/dist/admin/copy/blocks.js +38 -0
  31. package/dist/admin/copy/goals.d.ts +2 -0
  32. package/dist/admin/copy/goals.js +4 -1
  33. package/dist/admin/copy/newTest.d.ts +10 -0
  34. package/dist/admin/copy/newTest.js +10 -0
  35. package/dist/admin/copy/pages.d.ts +26 -0
  36. package/dist/admin/copy/pages.js +26 -0
  37. package/dist/admin/copy/readiness.d.ts +22 -3
  38. package/dist/admin/copy/readiness.js +23 -3
  39. package/dist/admin/copy/results.d.ts +1 -0
  40. package/dist/admin/copy/results.js +1 -0
  41. package/dist/admin/copy/testPage.d.ts +4 -0
  42. package/dist/admin/copy/testPage.js +4 -0
  43. package/dist/admin/goals/XpGoalField.d.ts +2 -0
  44. package/dist/admin/goals/XpGoalField.js +152 -0
  45. package/dist/admin/metricSource.model.d.ts +2 -0
  46. package/dist/admin/metricSource.model.js +4 -0
  47. package/dist/admin/pageCollections.d.ts +10 -0
  48. package/dist/admin/pageCollections.js +20 -0
  49. package/dist/admin/paths.d.ts +6 -0
  50. package/dist/admin/paths.js +6 -0
  51. package/dist/admin/placementStatus.model.d.ts +2 -0
  52. package/dist/admin/placementStatus.model.js +19 -0
  53. package/dist/admin/placements/placementLabels.d.ts +11 -0
  54. package/dist/admin/placements/placementLabels.js +21 -0
  55. package/dist/admin/placements/placementLabels.model.d.ts +13 -0
  56. package/dist/admin/placements/placementLabels.model.js +12 -0
  57. package/dist/admin/placements/usePlacementLabels.d.ts +12 -0
  58. package/dist/admin/placements/usePlacementLabels.js +102 -0
  59. package/dist/admin/results/DataQualityPanel.js +4 -1
  60. package/dist/admin/results/results.model.d.ts +3 -0
  61. package/dist/admin/results/results.model.js +8 -4
  62. package/dist/admin/surface/TestsHome.js +37 -6
  63. package/dist/admin/surface/TestsSurface.js +5 -1
  64. package/dist/admin/surface/contracts.d.ts +6 -5
  65. package/dist/admin/surface/goals/GoalForm.js +67 -34
  66. package/dist/admin/surface/goals/GoalsList.js +3 -3
  67. package/dist/admin/surface/goals/data.js +4 -1
  68. package/dist/admin/surface/goals/goals.model.d.ts +1 -2
  69. package/dist/admin/surface/goals/goals.model.js +1 -3
  70. package/dist/admin/surface/home.model.d.ts +1 -1
  71. package/dist/admin/surface/home.model.js +8 -4
  72. package/dist/admin/surface/newTest/NewTest.js +54 -13
  73. package/dist/admin/surface/newTest/PageWizard.d.ts +7 -0
  74. package/dist/admin/surface/newTest/PageWizard.js +209 -0
  75. package/dist/admin/surface/pagePicker/CopyRoute.d.ts +6 -0
  76. package/dist/admin/surface/pagePicker/CopyRoute.js +53 -0
  77. package/dist/admin/surface/pagePicker/PagePicker.d.ts +10 -0
  78. package/dist/admin/surface/pagePicker/PagePicker.js +172 -0
  79. package/dist/admin/surface/pagePicker/pagePicker.model.d.ts +25 -0
  80. package/dist/admin/surface/pagePicker/pagePicker.model.js +25 -0
  81. package/dist/admin/surface/readiness/ActionDialogs.js +99 -64
  82. package/dist/admin/surface/readiness/Banner.js +30 -6
  83. package/dist/admin/surface/readiness/readiness.model.d.ts +8 -1
  84. package/dist/admin/surface/readiness/readiness.model.js +91 -9
  85. package/dist/admin/surface/results/ReleasePages.d.ts +3 -1
  86. package/dist/admin/surface/results/ReleasePages.js +3 -7
  87. package/dist/admin/surface/results/ResultsBody.js +21 -2
  88. package/dist/admin/surface/results/ResultsHeader.js +2 -5
  89. package/dist/admin/surface/results/useResults.d.ts +10 -1
  90. package/dist/admin/surface/test/AdvancedSection.js +111 -104
  91. package/dist/admin/surface/test/GoalSection.js +90 -7
  92. package/dist/admin/surface/test/LengthSection.js +1 -1
  93. package/dist/admin/surface/test/PageHoldout.d.ts +3 -0
  94. package/dist/admin/surface/test/PageHoldout.js +67 -0
  95. package/dist/admin/surface/test/TestPage.js +2 -1
  96. package/dist/admin/surface/test/VersionsSection.js +12 -5
  97. package/dist/admin/surface/test/fields.d.ts +13 -4
  98. package/dist/admin/surface/test/fields.js +46 -14
  99. package/dist/admin/surface/test/jumpToSection.d.ts +1 -1
  100. package/dist/admin/surface/test/jumpToSection.js +4 -3
  101. package/dist/admin/surface/test/pageParts.d.ts +4 -1
  102. package/dist/admin/surface/test/pageParts.js +79 -2
  103. package/dist/admin/surface/test/testPage.css +8 -1
  104. package/dist/admin/surface/testDocument.model.d.ts +1 -0
  105. package/dist/admin/surface/testDocument.model.js +2 -1
  106. package/dist/admin/surface/useTestDocument.d.ts +11 -2
  107. package/dist/admin/surface/useTestDocument.js +53 -15
  108. package/dist/admin/useApi.d.ts +11 -2
  109. package/dist/admin/useApi.js +3 -5
  110. package/dist/admin/views/ExperimentsRootView.js +58 -16
  111. package/dist/blocks/bindings.d.ts +3 -0
  112. package/dist/blocks/bindings.js +34 -0
  113. package/dist/blocks/createExperimentBlock.d.ts +6 -0
  114. package/dist/blocks/createExperimentBlock.js +77 -0
  115. package/dist/blocks/formState.d.ts +31 -0
  116. package/dist/blocks/formState.js +146 -0
  117. package/dist/blocks/model.d.ts +41 -0
  118. package/dist/blocks/model.js +34 -0
  119. package/dist/client/ExperimentGroup.d.ts +6 -0
  120. package/dist/client/ExperimentGroup.js +9 -0
  121. package/dist/client/ExperimentsProvider.js +20 -4
  122. package/dist/client/clickGoals.d.ts +4 -0
  123. package/dist/client/clickGoals.js +43 -0
  124. package/dist/collections/experiments/config.js +12 -2
  125. package/dist/collections/experiments/deleteGuard.d.ts +2 -1
  126. package/dist/collections/experiments/deleteGuard.js +12 -1
  127. package/dist/collections/experiments/editability.js +4 -2
  128. package/dist/collections/experiments/pickSlug.d.ts +2 -0
  129. package/dist/collections/experiments/pickSlug.js +8 -0
  130. package/dist/collections/labels.d.ts +3 -0
  131. package/dist/collections/labels.js +20 -0
  132. package/dist/collections/metrics.js +12 -2
  133. package/dist/collections/phases.js +1 -0
  134. package/dist/collections/serialize.js +7 -0
  135. package/dist/db/clickGoals.d.ts +9 -0
  136. package/dist/db/clickGoals.js +22 -0
  137. package/dist/db/delete.d.ts +1 -0
  138. package/dist/db/delete.js +1 -0
  139. package/dist/db/locks.d.ts +8 -0
  140. package/dist/db/locks.js +27 -4
  141. package/dist/db/names.d.ts +4 -0
  142. package/dist/db/names.js +4 -0
  143. package/dist/db/pageBindings.d.ts +4 -0
  144. package/dist/db/pageBindings.js +17 -0
  145. package/dist/db/pageBusy.d.ts +2 -0
  146. package/dist/db/pageBusy.js +10 -0
  147. package/dist/db/pageContent.d.ts +10 -0
  148. package/dist/db/pageContent.js +79 -0
  149. package/dist/db/placements.d.ts +33 -0
  150. package/dist/db/placements.js +54 -0
  151. package/dist/db/slugs.d.ts +8 -0
  152. package/dist/db/slugs.js +40 -0
  153. package/dist/db/snapshots.d.ts +6 -0
  154. package/dist/db/snapshots.js +8 -5
  155. package/dist/endpoints/clickKeys.d.ts +5 -0
  156. package/dist/endpoints/clickKeys.js +17 -0
  157. package/dist/endpoints/events.js +4 -1
  158. package/dist/endpoints/index.js +28 -0
  159. package/dist/endpoints/pages.d.ts +5 -0
  160. package/dist/endpoints/pages.js +120 -0
  161. package/dist/endpoints/placementStatus.d.ts +6 -0
  162. package/dist/endpoints/placementStatus.js +238 -0
  163. package/dist/endpoints/readBody.d.ts +2 -0
  164. package/dist/endpoints/readBody.js +24 -0
  165. package/dist/endpoints/results.js +5 -1
  166. package/dist/endpoints/summary.js +11 -4
  167. package/dist/endpoints/wrapBlock.d.ts +6 -0
  168. package/dist/endpoints/wrapBlock.js +76 -0
  169. package/dist/errors.d.ts +3 -3
  170. package/dist/errors.js +4 -4
  171. package/dist/events/rows.d.ts +1 -1
  172. package/dist/events/rows.js +5 -3
  173. package/dist/exports/adminClient.d.ts +5 -0
  174. package/dist/exports/adminClient.js +5 -0
  175. package/dist/exports/blocks.d.ts +2 -0
  176. package/dist/exports/blocks.js +1 -0
  177. package/dist/exports/client.d.ts +1 -0
  178. package/dist/exports/client.js +1 -0
  179. package/dist/exports/index.d.ts +6 -1
  180. package/dist/exports/index.js +3 -0
  181. package/dist/exports/puck.d.ts +3 -1
  182. package/dist/exports/puck.js +2 -0
  183. package/dist/exports/puckEditor.d.ts +3 -0
  184. package/dist/exports/puckEditor.js +2 -0
  185. package/dist/exports/rsc.d.ts +3 -0
  186. package/dist/exports/rsc.js +2 -0
  187. package/dist/fields/xpGoal.d.ts +3 -0
  188. package/dist/fields/xpGoal.js +28 -0
  189. package/dist/globals/state.js +10 -0
  190. package/dist/goals/cache.d.ts +3 -0
  191. package/dist/goals/cache.js +35 -0
  192. package/dist/goals/fairness.d.ts +40 -0
  193. package/dist/goals/fairness.js +200 -0
  194. package/dist/goals/key.d.ts +4 -0
  195. package/dist/goals/key.js +3 -0
  196. package/dist/goals/picker/clickGoalPicker.model.d.ts +29 -0
  197. package/dist/goals/picker/clickGoalPicker.model.js +45 -0
  198. package/dist/goals/picker/copy.d.ts +16 -0
  199. package/dist/goals/picker/copy.js +15 -0
  200. package/dist/goals/picker/useClickGoalPicker.d.ts +30 -0
  201. package/dist/goals/picker/useClickGoalPicker.js +220 -0
  202. package/dist/hooks/pageGuard.d.ts +5 -1
  203. package/dist/hooks/pageGuard.js +157 -95
  204. package/dist/onInit.js +29 -0
  205. package/dist/options.d.ts +2 -0
  206. package/dist/options.js +41 -1
  207. package/dist/plugin.js +41 -28
  208. package/dist/puck/copy.d.ts +1 -0
  209. package/dist/puck/copy.js +1 -0
  210. package/dist/puck/editor/editor.chrome.css +1 -0
  211. package/dist/puck/editor/editor.css +1 -0
  212. package/dist/puck/editor/editor.model.d.ts +4 -2
  213. package/dist/puck/editor/editor.model.js +6 -3
  214. package/dist/puck/editor/integration.d.ts +10 -0
  215. package/dist/puck/editor/integration.js +30 -0
  216. package/dist/puck/editor/xpGoalPuckEditorField.d.ts +7 -0
  217. package/dist/puck/editor/xpGoalPuckEditorField.js +134 -0
  218. package/dist/puck/integration.d.ts +6 -0
  219. package/dist/puck/integration.js +21 -0
  220. package/dist/puck/xpGoalField.d.ts +3 -0
  221. package/dist/puck/xpGoalField.js +6 -0
  222. package/dist/results/health.d.ts +4 -0
  223. package/dist/results/health.js +27 -0
  224. package/dist/results/snapshot.js +15 -2
  225. package/dist/sanitizeReadinessDetail.js +41 -0
  226. package/dist/server/pageKindSeoContract.d.ts +27 -0
  227. package/dist/server/pageKindSeoContract.js +80 -0
  228. package/dist/server/recordContentChange.js +58 -8
  229. package/dist/server/resolveExperimentBlocks.d.ts +5 -0
  230. package/dist/server/resolveExperimentBlocks.js +54 -0
  231. package/dist/server/runResults.js +56 -15
  232. package/dist/server/toClientAssignments.js +2 -1
  233. package/dist/transitions/addMetric.d.ts +2 -1
  234. package/dist/transitions/addMetric.js +28 -1
  235. package/dist/transitions/checkStart.d.ts +13 -1
  236. package/dist/transitions/checkStart.js +89 -7
  237. package/dist/transitions/decide.js +24 -0
  238. package/dist/transitions/deleteExperiment.d.ts +3 -1
  239. package/dist/transitions/deleteExperiment.js +27 -1
  240. package/dist/transitions/kernel.js +4 -0
  241. package/dist/transitions/makeContent.d.ts +5 -0
  242. package/dist/transitions/makeContent.js +39 -11
  243. package/dist/transitions/pageCollection.d.ts +10 -0
  244. package/dist/transitions/pageCollection.js +20 -0
  245. package/dist/transitions/pageVersions.d.ts +10 -0
  246. package/dist/transitions/pageVersions.js +44 -0
  247. package/dist/transitions/phaseRecord.d.ts +1 -0
  248. package/dist/transitions/placementInteractions.d.ts +40 -0
  249. package/dist/transitions/placementInteractions.js +94 -0
  250. package/dist/transitions/placements.d.ts +8 -0
  251. package/dist/transitions/placements.js +148 -1
  252. package/dist/transitions/preflightData.d.ts +2 -2
  253. package/dist/transitions/preflightData.js +137 -5
  254. package/dist/transitions/resolveCaps.d.ts +1 -0
  255. package/dist/transitions/resolveCaps.js +6 -2
  256. package/dist/transitions/start.js +45 -1
  257. package/dist/types/admin.d.ts +1 -1
  258. package/dist/types/assignments.d.ts +1 -0
  259. package/dist/types/contracts.d.ts +15 -0
  260. package/dist/types/endpoints.d.ts +92 -5
  261. package/dist/types/index.d.ts +3 -0
  262. package/dist/types/puck.d.ts +7 -1
  263. package/dist/types/snapshot.d.ts +17 -4
  264. package/dist/types/surface.d.ts +13 -0
  265. package/package.json +8 -1
@@ -0,0 +1,34 @@
1
+ import { documentEditor, puckCollections } from '../options.js';
2
+ import { EXPERIMENT_BLOCK_SLUG, hostBlocks, isObject } from './model.js';
3
+ /** Same document-kind rule as the scan; Payload blocks are top level, Puck is recursive. */ export function placementSlugs(options, collection, doc) {
4
+ if (!doc) return [];
5
+ const editor = documentEditor(options, collection, doc);
6
+ if (editor === 'blocks') {
7
+ const registration = options.blocks?.collections.find((r)=>r.slug === collection);
8
+ if (!registration) return [];
9
+ return [
10
+ ...new Set(hostBlocks(doc[registration.field]).flatMap((block)=>block.blockType === (registration.blockSlug ?? EXPERIMENT_BLOCK_SLUG) && typeof block.experiment === 'string' ? [
11
+ block.experiment
12
+ ] : []))
13
+ ];
14
+ }
15
+ if (editor !== 'puck') return [];
16
+ const registration = puckCollections(options).find((r)=>r.slug === collection);
17
+ const found = new Set();
18
+ function walk(v) {
19
+ if (Array.isArray(v)) {
20
+ v.forEach(walk);
21
+ return;
22
+ }
23
+ if (!isObject(v)) return;
24
+ if (v.type === 'ExperimentSlot' && isObject(v.props)) {
25
+ const ref = v.props.experiment, slug = typeof ref === 'string' ? ref : isObject(ref) ? ref.slug : undefined;
26
+ if (typeof slug === 'string') found.add(slug);
27
+ }
28
+ Object.values(v).forEach(walk);
29
+ }
30
+ if (registration) walk(doc[registration.field]);
31
+ return [
32
+ ...found
33
+ ];
34
+ }
@@ -0,0 +1,6 @@
1
+ import type { Block } from 'payload';
2
+ export declare function validateExperimentSlots(value: unknown): true | string;
3
+ export declare function createExperimentBlock({ blocks, slug }: {
4
+ blocks: Block[];
5
+ slug?: string;
6
+ }): Block;
@@ -0,0 +1,77 @@
1
+ import { XP_BLOCK_STATUS, XP_BLOCK_ROW_LABEL, XP_COPY_ORIGINAL } from '../admin/paths.js';
2
+ import { BLOCK_SLOTS, EXPERIMENT_BLOCK_SLUG } from './model.js';
3
+ export function validateExperimentSlots(value) {
4
+ if (!Array.isArray(value)) return true;
5
+ const slots = value.map((row)=>row?.slot);
6
+ return new Set(slots).size === slots.length ? true : 'Each version appears once';
7
+ }
8
+ export function createExperimentBlock({ blocks, slug = EXPERIMENT_BLOCK_SLUG }) {
9
+ if (blocks.some((block)=>block.slug === slug)) throw new Error('Experiment blocks cannot be nested.');
10
+ return {
11
+ slug,
12
+ labels: {
13
+ singular: 'Experiment',
14
+ plural: 'Experiments'
15
+ },
16
+ fields: [
17
+ {
18
+ name: 'xpBlockStatus',
19
+ type: 'ui',
20
+ admin: {
21
+ components: {
22
+ Field: XP_BLOCK_STATUS
23
+ }
24
+ }
25
+ },
26
+ {
27
+ name: 'experiment',
28
+ label: 'Test',
29
+ type: 'text',
30
+ required: true
31
+ },
32
+ {
33
+ name: 'slots',
34
+ label: 'Versions',
35
+ type: 'array',
36
+ validate: validateExperimentSlots,
37
+ admin: {
38
+ components: {
39
+ RowLabel: XP_BLOCK_ROW_LABEL
40
+ }
41
+ },
42
+ fields: [
43
+ {
44
+ name: 'xpCopyOriginal',
45
+ type: 'ui',
46
+ admin: {
47
+ components: {
48
+ Field: XP_COPY_ORIGINAL
49
+ }
50
+ }
51
+ },
52
+ {
53
+ name: 'slot',
54
+ label: 'Version',
55
+ type: 'select',
56
+ required: true,
57
+ options: BLOCK_SLOTS.map((value, index)=>({
58
+ value,
59
+ label: [
60
+ 'Original',
61
+ 'Variant A',
62
+ 'Variant B',
63
+ 'Variant C'
64
+ ][index]
65
+ }))
66
+ },
67
+ {
68
+ name: 'blocks',
69
+ label: 'Content',
70
+ type: 'blocks',
71
+ blocks
72
+ }
73
+ ]
74
+ }
75
+ ]
76
+ };
77
+ }
@@ -0,0 +1,31 @@
1
+ import type { Block, Field, SanitizedConfig } from 'payload';
2
+ import { type ExperimentBlock, type HostBlock } from './model.js';
3
+ type RowConfig = {
4
+ blocks?: Block[];
5
+ localization?: SanitizedConfig['localization'];
6
+ };
7
+ type RowsField = Extract<Field, {
8
+ type: 'array' | 'blocks';
9
+ }>;
10
+ /** Mirror Payload 3.90.2's collections/operations/utilities/copyDataWithFreshRowIDs.js:
11
+ * walk configured row/subfield containers only. Mint ids for array/blocks rows, leaving
12
+ * opaque values (including json, richText, relationships and uploads) unchanged.
13
+ * Global block definitions resolve blockReferences; inline slot blocks retain host configs.
14
+ */
15
+ export declare function freshBlockIds<T>(value: T, field: RowsField, config?: RowConfig, mint?: () => string): T;
16
+ export declare function wrapExperimentBlock(block: HostBlock, experiment: string, field: RowsField, config?: RowConfig, blockSlug?: string): ExperimentBlock;
17
+ export declare function syncExperimentSlots(block: ExperimentBlock, arms: readonly {
18
+ key: string;
19
+ kind?: string;
20
+ slot?: string | null;
21
+ }[], field: RowsField, config?: RowConfig): ExperimentBlock;
22
+ export declare function copyOriginalIntoVersion(block: ExperimentBlock, slot: string, field: RowsField, config?: RowConfig): ExperimentBlock;
23
+ export declare function unwrapExperimentBlock(block: HostBlock, field: RowsField, slot?: string, config?: RowConfig): HostBlock[];
24
+ /** Config coordinates include unnamed layout containers. No field component is injected here. */
25
+ export declare function findRegisteredBlocksField(fields: readonly Field[], name: string): {
26
+ field: Extract<Field, {
27
+ type: 'blocks';
28
+ }>;
29
+ path: Array<string | number>;
30
+ } | undefined;
31
+ export {};
@@ -0,0 +1,146 @@
1
+ import { BLOCK_SLOTS, EXPERIMENT_BLOCK_SLUG, isObject, slotBlocks } from './model.js';
2
+ /** Mirror Payload 3.90.2's collections/operations/utilities/copyDataWithFreshRowIDs.js:
3
+ * walk configured row/subfield containers only. Mint ids for array/blocks rows, leaving
4
+ * opaque values (including json, richText, relationships and uploads) unchanged.
5
+ * Global block definitions resolve blockReferences; inline slot blocks retain host configs.
6
+ */ export function freshBlockIds(value, field, config = {}, mint = ()=>globalThis.crypto.randomUUID()) {
7
+ function copyObject(data, fields, parentIsLocalized = false) {
8
+ const result = {
9
+ ...data
10
+ };
11
+ for (const child of fields){
12
+ if (child.type === 'row' || child.type === 'collapsible' || child.type === 'group' && !('name' in child && child.name)) {
13
+ Object.assign(result, copyObject(result, child.fields, parentIsLocalized));
14
+ } else if (child.type === 'tabs') {
15
+ for (const tab of child.tabs){
16
+ if ('name' in tab && tab.name) {
17
+ if (Object.hasOwn(result, tab.name)) result[tab.name] = subfields(result[tab.name], tab.fields, tab.localized, parentIsLocalized);
18
+ } else Object.assign(result, copyObject(result, tab.fields, parentIsLocalized));
19
+ }
20
+ } else if (child.type === 'group' && 'name' in child && child.name) {
21
+ if (Object.hasOwn(result, child.name)) result[child.name] = subfields(result[child.name], child.fields, child.localized, parentIsLocalized);
22
+ } else if (child.type === 'array' || child.type === 'blocks') {
23
+ if (Object.hasOwn(result, child.name)) result[child.name] = copyRows(result[child.name], child, parentIsLocalized || Boolean(child.localized));
24
+ }
25
+ }
26
+ return result;
27
+ }
28
+ function subfields(data, fields, localized, parentIsLocalized) {
29
+ if (!isObject(data)) return data;
30
+ const locales = config.localization ? config.localization.localeCodes : [];
31
+ if (localized && !parentIsLocalized && Object.keys(data).length > 0 && Object.keys(data).every((key)=>locales.includes(key))) return Object.fromEntries(Object.entries(data).map(([locale, value])=>[
32
+ locale,
33
+ isObject(value) ? copyObject(value, fields, true) : value
34
+ ]));
35
+ return copyObject(data, fields, parentIsLocalized || Boolean(localized));
36
+ }
37
+ function copyRows(data, rowsField, parentIsLocalized = false) {
38
+ if (Array.isArray(data)) return data.map((row)=>{
39
+ if (!isObject(row)) return row;
40
+ const fields = rowsField.type === 'array' ? rowsField.fields : (config.blocks?.find((block)=>block.slug === row.blockType) ?? [
41
+ ...rowsField.blocks,
42
+ ...(rowsField.blockReferences ?? []).filter((block)=>typeof block !== 'string')
43
+ ].find((block)=>block.slug === row.blockType))?.fields ?? [];
44
+ return {
45
+ ...copyObject(row, fields, parentIsLocalized),
46
+ id: mint()
47
+ };
48
+ });
49
+ if (isObject(data)) return Object.fromEntries(Object.entries(data).map(([locale, rows])=>[
50
+ locale,
51
+ Array.isArray(rows) ? copyRows(rows, rowsField, true) : rows
52
+ ]));
53
+ return data;
54
+ }
55
+ return copyRows(value, field);
56
+ }
57
+ export function wrapExperimentBlock(block, experiment, field, config = {}, blockSlug = EXPERIMENT_BLOCK_SLUG) {
58
+ if (block.blockType === blockSlug) throw new Error('Experiment blocks cannot be nested.');
59
+ return {
60
+ id: globalThis.crypto.randomUUID(),
61
+ blockType: blockSlug,
62
+ experiment,
63
+ slots: [
64
+ {
65
+ id: globalThis.crypto.randomUUID(),
66
+ slot: 'control',
67
+ blocks: [
68
+ block
69
+ ]
70
+ },
71
+ {
72
+ id: globalThis.crypto.randomUUID(),
73
+ slot: 'variantA',
74
+ blocks: freshBlockIds([
75
+ block
76
+ ], field, config)
77
+ }
78
+ ]
79
+ };
80
+ }
81
+ function copySlotBlocks(block, slot, field, config) {
82
+ // Traverse the wrapper too: slot content may use blocks defined only inside
83
+ // createExperimentBlock, rather than in the host's outer blocks field.
84
+ return slotBlocks(freshBlockIds([
85
+ block
86
+ ], field, config)[0], slot);
87
+ }
88
+ export function syncExperimentSlots(block, arms, field, config = {}) {
89
+ const wanted = arms.filter((a)=>a.kind !== 'holdout' && a.key !== 'holdout').map((a)=>a.slot ?? a.key).filter((slot)=>BLOCK_SLOTS.includes(slot));
90
+ const slots = [
91
+ ...new Set(wanted)
92
+ ].map((slot)=>block.slots.find((row)=>row.slot === slot) ?? {
93
+ id: globalThis.crypto.randomUUID(),
94
+ slot,
95
+ blocks: copySlotBlocks(block, 'control', field, config)
96
+ });
97
+ return {
98
+ ...block,
99
+ slots
100
+ };
101
+ }
102
+ export function copyOriginalIntoVersion(block, slot, field, config = {}) {
103
+ return {
104
+ ...block,
105
+ slots: block.slots.map((row)=>row.slot === slot && slot !== 'control' ? {
106
+ ...row,
107
+ blocks: copySlotBlocks(block, 'control', field, config)
108
+ } : row)
109
+ };
110
+ }
111
+ export function unwrapExperimentBlock(block, field, slot = 'control', config = {}) {
112
+ return copySlotBlocks(block, slot, field, config);
113
+ }
114
+ /** Config coordinates include unnamed layout containers. No field component is injected here. */ export function findRegisteredBlocksField(fields, name) {
115
+ function visit(fields, path) {
116
+ for(let i = 0; i < fields.length; i++){
117
+ const field = fields[i], here = [
118
+ ...path,
119
+ i
120
+ ];
121
+ if ('name' in field && field.name === name && field.type === 'blocks') return {
122
+ field,
123
+ path: here
124
+ };
125
+ if (field.type === 'tabs') for(let t = 0; t < field.tabs.length; t++){
126
+ const tab = field.tabs[t];
127
+ if ('name' in tab && tab.name) continue;
128
+ const found = visit(field.tabs[t].fields, [
129
+ ...here,
130
+ 'tabs',
131
+ t,
132
+ 'fields'
133
+ ]);
134
+ if (found) return found;
135
+ }
136
+ if (field.type === 'row' || field.type === 'collapsible') {
137
+ const found = visit(field.fields, [
138
+ ...here,
139
+ 'fields'
140
+ ]);
141
+ if (found) return found;
142
+ }
143
+ }
144
+ }
145
+ return visit(fields, []);
146
+ }
@@ -0,0 +1,41 @@
1
+ import type { RenderedExposure } from '../types/assignments.js';
2
+ export declare const EXPERIMENT_BLOCK_SLUG = "xpExperiment";
3
+ export declare const BLOCK_SLOTS: readonly ["control", "variantA", "variantB", "variantC"];
4
+ export interface HostBlock {
5
+ id?: string;
6
+ blockType: string;
7
+ blockName?: string;
8
+ [field: string]: unknown;
9
+ }
10
+ export interface ExperimentBlock extends HostBlock {
11
+ experiment: string;
12
+ slots: Array<{
13
+ id?: string;
14
+ slot: string;
15
+ blocks: HostBlock[];
16
+ }>;
17
+ }
18
+ export interface ExperimentBlockGroup {
19
+ slug: string;
20
+ expose?: RenderedExposure;
21
+ }
22
+ export type ResolvedHostBlock = HostBlock & {
23
+ xpGroup?: ExperimentBlockGroup;
24
+ };
25
+ export interface ResolvedExperimentBlocks {
26
+ blocks: ResolvedHostBlock[];
27
+ rendered: RenderedExposure[];
28
+ groups: Array<ExperimentBlockGroup & {
29
+ start: number;
30
+ end: number;
31
+ }>;
32
+ }
33
+ export declare const isObject: (v: unknown) => v is Record<string, unknown>;
34
+ export declare const hostBlocks: (v: unknown) => HostBlock[];
35
+ export declare function slotBlocks(block: HostBlock, slot: string): HostBlock[];
36
+ /** Experiments-off rendering preserves stored row ids; only top-level wrappers are read. */
37
+ export declare function controlOnlyBlocks(blocks: unknown, blockSlug?: string): HostBlock[];
38
+ export declare function segmentExperimentBlocks(blocks: readonly ResolvedHostBlock[]): Array<{
39
+ group?: ExperimentBlockGroup;
40
+ blocks: HostBlock[];
41
+ }>;
@@ -0,0 +1,34 @@
1
+ export const EXPERIMENT_BLOCK_SLUG = 'xpExperiment';
2
+ export const BLOCK_SLOTS = [
3
+ 'control',
4
+ 'variantA',
5
+ 'variantB',
6
+ 'variantC'
7
+ ];
8
+ export const isObject = (v)=>!!v && typeof v === 'object' && !Array.isArray(v);
9
+ export const hostBlocks = (v)=>Array.isArray(v) ? v.filter((b)=>isObject(b) && typeof b.blockType === 'string') : [];
10
+ export function slotBlocks(block, slot) {
11
+ const row = Array.isArray(block.slots) ? block.slots.find((row)=>isObject(row) && row.slot === slot) : undefined;
12
+ return isObject(row) ? hostBlocks(row.blocks) : [];
13
+ }
14
+ /** Experiments-off rendering preserves stored row ids; only top-level wrappers are read. */ export function controlOnlyBlocks(blocks, blockSlug = EXPERIMENT_BLOCK_SLUG) {
15
+ return hostBlocks(blocks).flatMap((block)=>block.blockType === blockSlug ? slotBlocks(block, 'control') : [
16
+ block
17
+ ]);
18
+ }
19
+ export function segmentExperimentBlocks(blocks) {
20
+ const runs = [];
21
+ for (const block of blocks){
22
+ const group = block.xpGroup, last = runs.at(-1);
23
+ if (last && last.group === group) last.blocks.push(block);
24
+ else runs.push({
25
+ ...group ? {
26
+ group
27
+ } : {},
28
+ blocks: [
29
+ block
30
+ ]
31
+ });
32
+ }
33
+ return runs;
34
+ }
@@ -0,0 +1,6 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { RenderedExposure } from '../types/assignments.js';
3
+ export declare function ExperimentGroup({ expose, children }: {
4
+ expose?: RenderedExposure;
5
+ children: ReactNode;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,9 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { ViewportExposure } from './ViewportExposure.js';
4
+ export function ExperimentGroup({ expose, children }) {
5
+ return /*#__PURE__*/ _jsx(ViewportExposure, {
6
+ expose: expose,
7
+ children: children
8
+ });
9
+ }
@@ -1,9 +1,12 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useLayoutEffect, useRef } from 'react';
3
+ import { createContext, useContext, useLayoutEffect, useRef } from 'react';
4
4
  import { registerApiRoute, unregisterApiRoute } from './beacon.js';
5
+ import { registerClickGoals, unregisterClickGoals } from './clickGoals.js';
5
6
  import { ExperimentsContext } from './context.js';
7
+ const DepthContext = /*#__PURE__*/ createContext(0);
6
8
  export function ExperimentsProvider(props) {
9
+ const depth = useContext(DepthContext) + 1;
7
10
  const token = useRef(Symbol()).current;
8
11
  // Register committed providers before descendants can send from passive effects.
9
12
  useLayoutEffect(()=>{
@@ -15,8 +18,21 @@ export function ExperimentsProvider(props) {
15
18
  useLayoutEffect(()=>()=>unregisterApiRoute(token), [
16
19
  token
17
20
  ]);
18
- return /*#__PURE__*/ _jsx(ExperimentsContext.Provider, {
19
- value: props.value,
20
- children: props.children
21
+ useLayoutEffect(()=>{
22
+ registerClickGoals(token, depth, props.value);
23
+ }, [
24
+ token,
25
+ depth,
26
+ props.value
27
+ ]);
28
+ useLayoutEffect(()=>()=>unregisterClickGoals(token), [
29
+ token
30
+ ]);
31
+ return /*#__PURE__*/ _jsx(DepthContext.Provider, {
32
+ value: depth,
33
+ children: /*#__PURE__*/ _jsx(ExperimentsContext.Provider, {
34
+ value: props.value,
35
+ children: props.children
36
+ })
21
37
  });
22
38
  }
@@ -0,0 +1,4 @@
1
+ import type { ClientAssignments } from '../types/assignments.js';
2
+ /** One delegated listener; depth, rather than React's child-first commit order, decides ownership. */
3
+ export declare function registerClickGoals(token: symbol, depth: number, value: ClientAssignments): void;
4
+ export declare function unregisterClickGoals(token: symbol): void;
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+ import { CLICK_KEY } from '../goals/key.js';
3
+ import { sendEvent } from './beacon.js';
4
+ import { idempotencyKey } from './idempotency.js';
5
+ const providers = new Map();
6
+ let listening;
7
+ function onClick(event) {
8
+ let owner;
9
+ for (const provider of providers.values()){
10
+ if (provider.value.clickGoalsArmed && (!owner || provider.depth >= owner.depth)) owner = provider;
11
+ }
12
+ if (!owner) return;
13
+ const target = event.target;
14
+ const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
15
+ const key = element?.closest('[data-xp-goal]')?.getAttribute('data-xp-goal');
16
+ if (!key || !CLICK_KEY.test(key)) return;
17
+ sendEvent(owner.value.apiRoute, {
18
+ type: 'conversion',
19
+ eventName: `click:${key}`,
20
+ idempotencyKey: `${owner.pageviewId}:${key}`
21
+ });
22
+ }
23
+ /** One delegated listener; depth, rather than React's child-first commit order, decides ownership. */ export function registerClickGoals(token, depth, value) {
24
+ const previous = providers.get(token);
25
+ const pageviewId = previous?.value === value ? previous.pageviewId : idempotencyKey();
26
+ providers.delete(token);
27
+ providers.set(token, {
28
+ depth,
29
+ value,
30
+ pageviewId
31
+ });
32
+ if (!listening && typeof document !== 'undefined') {
33
+ listening = document;
34
+ listening.addEventListener('click', onClick);
35
+ }
36
+ }
37
+ export function unregisterClickGoals(token) {
38
+ providers.delete(token);
39
+ if (!providers.size && listening) {
40
+ listening.removeEventListener('click', onClick);
41
+ listening = undefined;
42
+ }
43
+ }
@@ -5,8 +5,9 @@ import { TARGETING_OPS, isOpValid } from '../../targeting/ops.js';
5
5
  import { readListValue } from '../../targeting/values.js';
6
6
  import { createDefaults } from './createDefaults.js';
7
7
  import { flagControlParams } from './flagControlParams.js';
8
+ import { pickExperimentSlug } from './pickSlug.js';
8
9
  import { lockHook } from './lockHook.js';
9
- import { deleteGuard, deleteGuardAfter } from './deleteGuard.js';
10
+ import { deleteGuard, deleteGuardAfter, deleteGuardBeforeOperation } from './deleteGuard.js';
10
11
  import { checkArms } from './armInvariants.js';
11
12
  import { XpNotImplemented, XpRefusal } from '../../errors.js';
12
13
  const targetingValue = (rule)=>rule.op === 'in' || rule.op === 'nin' ? readListValue(rule.value) : rule.value;
@@ -81,12 +82,16 @@ export function experimentsCollection(options) {
81
82
  }
82
83
  },
83
84
  hooks: {
85
+ beforeOperation: [
86
+ deleteGuardBeforeOperation
87
+ ],
84
88
  beforeValidate: [
85
89
  createDefaults
86
90
  ],
87
91
  beforeChange: [
88
92
  flagControlParams,
89
- lockHook
93
+ lockHook,
94
+ pickExperimentSlug
90
95
  ],
91
96
  beforeDelete: [
92
97
  deleteGuard
@@ -166,6 +171,11 @@ export function experimentsCollection(options) {
166
171
  read: async (args)=>args.doc?.kind !== 'code' || adminOnly(args)
167
172
  }
168
173
  },
174
+ {
175
+ name: 'pageCollection',
176
+ type: 'text',
177
+ validate: (value, { previousValue })=>value === previousValue || value == null || value === '' || options.pages?.collections.some((entry)=>entry.slug === value) || 'Choose a registered page collection.'
178
+ },
169
179
  {
170
180
  name: 'flagPoint',
171
181
  type: 'text',
@@ -1,3 +1,4 @@
1
- import type { CollectionAfterDeleteHook, CollectionBeforeDeleteHook } from 'payload';
1
+ import type { CollectionBeforeOperationHook, CollectionAfterDeleteHook, CollectionBeforeDeleteHook } from 'payload';
2
2
  export declare const deleteGuard: CollectionBeforeDeleteHook;
3
3
  export declare const deleteGuardAfter: CollectionAfterDeleteHook;
4
+ export declare const deleteGuardBeforeOperation: CollectionBeforeOperationHook;
@@ -1,4 +1,5 @@
1
- import { deleteExperiment, deleteExperimentAfter } from '../../transitions/deleteExperiment.js';
1
+ import { deleteExperiment, deleteExperimentAfter, prepareBulkDelete } from '../../transitions/deleteExperiment.js';
2
+ import { xpOptionsFromReq } from '../../options/fromReq.js';
2
3
  export const deleteGuard = async ({ req, id })=>{
3
4
  await deleteExperiment(req, id);
4
5
  };
@@ -6,3 +7,13 @@ export const deleteGuardAfter = async ({ req, id, doc })=>{
6
7
  await deleteExperimentAfter(req, id);
7
8
  return doc;
8
9
  };
10
+ export const deleteGuardBeforeOperation = async ({ operation, args, req })=>{
11
+ if (operation === 'delete' && 'where' in args && args.where) {
12
+ // Payload runs beforeOperation before access; only a manager, or an explicit overrideAccess call, may make the plugin discover and lock the delete scope.
13
+ if (args.overrideAccess !== true && await xpOptionsFromReq(req).access.manage({
14
+ req
15
+ }) !== true) return args;
16
+ await prepareBulkDelete(req, args.where);
17
+ }
18
+ return args;
19
+ };
@@ -8,7 +8,8 @@ export const EDITABLE_BY_STATUS = {
8
8
  'flagPoint',
9
9
  'metrics',
10
10
  'analysis',
11
- 'exposurePolicy'
11
+ 'exposurePolicy',
12
+ 'pageCollection'
12
13
  ],
13
14
  stopped: [
14
15
  'name',
@@ -18,7 +19,8 @@ export const EDITABLE_BY_STATUS = {
18
19
  'allocation',
19
20
  'metrics',
20
21
  'analysis',
21
- 'exposurePolicy'
22
+ 'exposurePolicy',
23
+ 'pageCollection'
22
24
  ],
23
25
  running: [
24
26
  'name',
@@ -0,0 +1,2 @@
1
+ import type { CollectionBeforeChangeHook } from 'payload';
2
+ export declare const pickExperimentSlug: CollectionBeforeChangeHook;
@@ -0,0 +1,8 @@
1
+ import { pickSlug } from '../../db/slugs.js';
2
+ import { pooledExecutor, txExecutor } from '../../db/executor.js';
3
+ export const pickExperimentSlug = async ({ req, data, operation })=>{
4
+ if (operation !== 'create' || typeof data.slug !== 'string' || !data.slug) return data;
5
+ const transactional = await req.transactionID != null;
6
+ data.slug = await pickSlug(transactional ? txExecutor(req) : pooledExecutor(req.payload), data.slug, transactional);
7
+ return data;
8
+ };
@@ -0,0 +1,3 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ /** Payload labels may be translated strings or functions. Keep server entries dependency-safe. */
3
+ export declare function collectionLabel(label: unknown, slug: string, req: PayloadRequest): string;
@@ -0,0 +1,20 @@
1
+ /** Payload labels may be translated strings or functions. Keep server entries dependency-safe. */ export function collectionLabel(label, slug, req) {
2
+ try {
3
+ if (typeof label === 'function') {
4
+ const value = label({
5
+ i18n: req.i18n,
6
+ t: req.i18n.t
7
+ });
8
+ return typeof value === 'string' ? value : slug;
9
+ }
10
+ if (typeof label === 'string') return label;
11
+ const translations = label && typeof label === 'object' ? label : {};
12
+ return [
13
+ translations[req.i18n?.language ?? 'en'],
14
+ translations.en,
15
+ ...Object.values(translations)
16
+ ].find((value)=>typeof value === 'string') ?? slug;
17
+ } catch {
18
+ return slug;
19
+ }
20
+ }
@@ -1,3 +1,4 @@
1
+ import { CLICK_KEY, isClickSource, validClickSource } from '../goals/key.js';
1
2
  import { XP_METRIC_SOURCE_SELECT } from '../admin/paths.js';
2
3
  import { managedAccess } from './access.js';
3
4
  import { serializeMetricSources } from './serialize.js';
@@ -30,6 +31,14 @@ export function metricsCollection(options) {
30
31
  beforeValidate: [
31
32
  ({ data, originalDoc })=>{
32
33
  if (!data) return data;
34
+ if (isClickSource(data.source ?? originalDoc?.source)) {
35
+ data.source = `click:${data.slug ?? originalDoc?.slug ?? ''}`;
36
+ data.direction = 'increase';
37
+ data.capping = {
38
+ type: null,
39
+ value: null
40
+ };
41
+ }
33
42
  const source = sources.find((source)=>source.id === (data.source ?? originalDoc?.source));
34
43
  const capping = data.capping === undefined ? originalDoc?.capping : data.capping;
35
44
  // Payload may submit an empty optional group as { type: null, value: null }.
@@ -58,6 +67,7 @@ export function metricsCollection(options) {
58
67
  type: 'text',
59
68
  required: true,
60
69
  unique: true,
70
+ validate: (value, { data })=>!isClickSource(data?.source) || typeof value === 'string' && CLICK_KEY.test(value) || 'Use 1–64 lowercase letters, numbers, underscores or hyphens, starting with a letter or number.',
61
71
  admin: {
62
72
  description: 'Use a unique short name for code that refers to this goal.'
63
73
  }
@@ -67,7 +77,7 @@ export function metricsCollection(options) {
67
77
  label: 'Measured by',
68
78
  type: 'text',
69
79
  required: true,
70
- validate: (value)=>sources.some((source)=>source.id === value) || 'Choose what this goal is measured by.',
80
+ validate: (value)=>(isClickSource(value) ? validClickSource(value) : sources.some((source)=>source.id === value)) || 'Choose what this goal is measured by.',
71
81
  admin: {
72
82
  description: 'Choose the outcome this goal measures.',
73
83
  components: {
@@ -135,7 +145,7 @@ export function metricsCollection(options) {
135
145
  },
136
146
  validate: (value, { data, siblingData })=>{
137
147
  const source = sources.find((source)=>source.id === data?.source);
138
- return validateCappingType(value, siblingData?.value, source?.kind);
148
+ return validateCappingType(value, siblingData?.value, isClickSource(data?.source) ? 'binomial' : source?.kind);
139
149
  }
140
150
  },
141
151
  {