@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
@@ -1,4 +1,9 @@
1
+ import { goalContent, OUTSIDE_GOAL } from '../goals/fairness.js';
2
+ import { placementInteractions, placementPaths, uniquePlacementDocs } from './placementInteractions.js';
1
3
  import { Forbidden, NotFound } from 'payload';
4
+ import { normalisePath } from '../runtime/normalisePath.js';
5
+ import { resolvePageCollection } from './pageCollection.js';
6
+ import { identicalPageVersions } from './pageVersions.js';
2
7
  import { safeError } from '../log.js';
3
8
  import { powerMetricWeeks } from '@delmaredigital/experiments-stats';
4
9
  import { checkStart, relationshipId } from './checkStart.js';
@@ -35,13 +40,18 @@ const failureDetail = (cause)=>{
35
40
  return routedPageTests(await req.transactionID != null ? txExecutor(req) : pooledExecutor(req.payload));
36
41
  }
37
42
  /** Page targets no registered collection reports as published. A lookup that throws counts as
38
- * unpublished (logged). Start calls it again under its page-path locks on a detached request. */ export async function unpublishedPageTargets(req, options, experiment) {
43
+ * unpublished (logged) unless propagateErrors is requested by decide. Start calls it again under its page-path locks on a detached request. */ export async function unpublishedPageTargets(req, options, experiment, propagateErrors = false) {
44
+ const resolved = await resolvePageCollection(req, options, experiment, propagateErrors);
45
+ const collections = resolved ? [
46
+ resolved
47
+ ] : [];
39
48
  const targets = await Promise.all(experiment.arms.filter((arm)=>arm.kind !== 'holdout' && arm.pageTarget?.trim()).map(async (arm)=>{
40
- for (const collection of options.pages?.collections ?? []){
49
+ for (const collection of collections){
41
50
  try {
42
51
  const page = await collection.findByPath(arm.pageTarget, req);
43
52
  if (page?.published === true) return null;
44
53
  } catch (cause) {
54
+ if (propagateErrors) throw cause;
45
55
  req.payload.logger.error({
46
56
  err: safeError(cause),
47
57
  experimentId: experiment.id,
@@ -114,13 +124,19 @@ const metricIds = (experiment)=>[
114
124
  pageTestsInUse ??= loadPageTestsInUse(req);
115
125
  input.pageTestsInUse = await pageTestsInUse;
116
126
  input.pageTargetsUnchecked = options.pages === undefined;
117
- if (options.pages) input.unpublishedPageTargets = await unpublishedPageTargets(req, options, experiment);
127
+ input.unknownPageCollection = Boolean(experiment.pageCollection && !options.pages?.collections.some((entry)=>entry.slug === experiment.pageCollection));
128
+ if (options.pages) {
129
+ input.unpublishedPageTargets = await unpublishedPageTargets(req, options, experiment);
130
+ if (options.pages.collections.some((entry)=>entry.contentOf)) input.identicalVersions = await identicalPageVersions(req, await resolvePageCollection(req, options, experiment), experiment.arms);
131
+ }
118
132
  }
119
133
  if (experiment.kind === 'block') {
120
134
  try {
121
135
  blocks ??= scanBlockPlacements(req, {
122
136
  slugs: blockSlugs,
123
- puckCollections: options.puck?.collections ?? []
137
+ compareVersions: true,
138
+ puckCollections: options.puck?.collections ?? [],
139
+ blocksCollections: options.blocks?.collections ?? []
124
140
  });
125
141
  input.placements = (await blocks).get(experiment.slug) ?? [];
126
142
  } catch (cause) {
@@ -250,6 +266,13 @@ const metricIds = (experiment)=>[
250
266
  })());
251
267
  input.layer = await layers.get(key);
252
268
  }
269
+ if ((experiment.kind === 'page' || experiment.kind === 'block') && options.pages?.collections.length) {
270
+ const paths = experiment.kind === 'page' ? experiment.arms.flatMap((a)=>typeof a.pageTarget === 'string' ? [
271
+ a.pageTarget
272
+ ] : []) : await placementPaths(req, options, uniquePlacementDocs(input.placements ?? []));
273
+ input.interactions = await placementInteractions(req, await req.transactionID != null ? txExecutor(req) : pooledExecutor(req.payload), options, experiment, paths.map(normalisePath));
274
+ }
275
+ if (input.metrics.some((metric)=>metric.source.startsWith('click:'))) input.goalContent = await goalContent(req, options, experiment, input.placements ? uniquePlacementDocs(input.placements.filter((placement)=>placement.published)) : undefined);
253
276
  inputs.set(String(experiment.id), input);
254
277
  } catch (cause) {
255
278
  if (!tolerateErrors) throw cause;
@@ -279,7 +302,9 @@ async function placementUrls(options, placement, pendingPreviews, onFailure, pre
279
302
  const url = options.admin?.editorUrl?.({
280
303
  collection,
281
304
  id,
282
- draft: !published
305
+ draft: !published,
306
+ editor: placement.editor ?? 'puck',
307
+ doc: placement.doc
283
308
  });
284
309
  if (typeof url === 'string') result.editorUrl = url;
285
310
  } catch {}
@@ -476,9 +501,116 @@ export async function preflightData(req, { experimentId, options }, { now = new
476
501
  detail: placement.detail
477
502
  } : {}
478
503
  });
504
+ const pagePlacements = [];
505
+ if (e.kind === 'page') {
506
+ const collection = await resolvePageCollection(req, options, e);
507
+ for (const arm of e.arms.filter((arm)=>arm.kind !== 'holdout')){
508
+ const path = arm.pageTarget ?? '';
509
+ let page = null;
510
+ try {
511
+ page = path && collection ? await collection.findByPath(path, req) : null;
512
+ } catch (cause) {
513
+ onPreviewFailure(cause, {
514
+ collection: collection.slug,
515
+ id: path,
516
+ title: path,
517
+ published: false,
518
+ slots: {
519
+ control: 0,
520
+ variantA: 0,
521
+ variantB: 0,
522
+ variantC: 0
523
+ }
524
+ });
525
+ }
526
+ if (!page || !collection) {
527
+ pagePlacements.push({
528
+ arm: arm.key,
529
+ path,
530
+ published: false,
531
+ id: null,
532
+ ...collection ? {
533
+ collection: collection.slug
534
+ } : {}
535
+ });
536
+ continue;
537
+ }
538
+ let doc;
539
+ try {
540
+ doc = await req.payload.findByID({
541
+ collection: collection.slug,
542
+ id: page.id,
543
+ req,
544
+ overrideAccess: false,
545
+ depth: 0,
546
+ draft: !page.published
547
+ });
548
+ } catch (cause) {
549
+ onPreviewFailure(cause, {
550
+ collection: collection.slug,
551
+ id: page.id,
552
+ title: path,
553
+ published: false,
554
+ slots: {
555
+ control: 0,
556
+ variantA: 0,
557
+ variantB: 0,
558
+ variantC: 0
559
+ }
560
+ });
561
+ pagePlacements.push({
562
+ arm: arm.key,
563
+ path,
564
+ collection: collection.slug,
565
+ published: false,
566
+ id: null
567
+ });
568
+ continue;
569
+ }
570
+ const titleField = req.payload.config.collections?.find((entry)=>entry.slug === collection.slug)?.admin.useAsTitle ?? 'title';
571
+ const placement = {
572
+ collection: collection.slug,
573
+ id: page.id,
574
+ title: String(doc[titleField] ?? doc.id),
575
+ published: page.published,
576
+ ...page.published && typeof doc.slug === 'string' ? {
577
+ slug: doc.slug
578
+ } : {},
579
+ slots: {
580
+ control: 0,
581
+ variantA: 0,
582
+ variantB: 0,
583
+ variantC: 0
584
+ },
585
+ editor: 'page',
586
+ doc
587
+ };
588
+ pagePlacements.push({
589
+ arm: arm.key,
590
+ path,
591
+ collection: collection.slug,
592
+ id: page.id,
593
+ title: placement.title,
594
+ published: placement.published,
595
+ ...placement.slug ? {
596
+ slug: placement.slug
597
+ } : {},
598
+ ...await placementUrls(options, placement, pendingPreviews, onPreviewFailure, previews)
599
+ });
600
+ }
601
+ }
479
602
  return {
480
603
  checks: checkStart(input),
604
+ ...input.goalContent ? {
605
+ goalNotes: input.metrics.filter((m)=>m.source.startsWith('click:') && input.goalContent && !input.goalContent.skipped && !input.goalContent.unchecked.length && !input.goalContent.keys.some((k)=>`click:${k.key}` === m.source)).map((m)=>({
606
+ metricId: m.id,
607
+ message: OUTSIDE_GOAL
608
+ }))
609
+ } : {},
481
610
  placements,
611
+ ...e.kind === 'page' ? {
612
+ pagePlacements
613
+ } : {},
482
614
  baseline,
483
615
  trafficPerDay,
484
616
  estimate: power
@@ -2,6 +2,7 @@ import type { DocId, MetricSource } from '../types/contracts.js';
2
2
  export interface MetricDefinition extends Record<string, unknown> {
3
3
  id: DocId;
4
4
  source: string;
5
+ direction?: 'increase' | 'decrease';
5
6
  attributionWindowHours?: number | null;
6
7
  minSampleSize?: number | null;
7
8
  guardrailThreshold?: number | null;
@@ -8,7 +8,7 @@ import { perUnitHistory } from '../results/history.js';
8
8
  const from = new Date(start.getTime() - 90 * 86400000);
9
9
  for (const metric of metrics){
10
10
  const source = sources.find((source)=>source.id === metric.source);
11
- const kind = metric.source.startsWith('events:') ? 'binomial' : source?.kind;
11
+ const kind = metric.source.startsWith('events:') || metric.source.startsWith('click:') ? 'binomial' : source?.kind;
12
12
  const definition = {
13
13
  ...structuredClone(metric),
14
14
  ...source ? {
@@ -19,11 +19,15 @@ import { perUnitHistory } from '../results/history.js';
19
19
  historyUnits: undefined,
20
20
  capNotice: undefined
21
21
  };
22
+ if (metric.source.startsWith('click:')) {
23
+ definition.direction = 'increase';
24
+ definition.capping = null;
25
+ }
22
26
  const cap = metric.capping?.type ? metric.capping : kind === 'revenue' ? {
23
27
  type: 'percentile',
24
28
  value: 0.99
25
29
  } : null;
26
- if (kind === 'revenue' || !source && cap && !metric.source.startsWith('events:')) {
30
+ if (kind === 'revenue' || !source && cap && !(metric.source.startsWith('events:') || metric.source.startsWith('click:'))) {
27
31
  definition.capping = cap;
28
32
  if (cap?.type === 'absolute' && typeof cap.value === 'number' && Number.isFinite(cap.value) && cap.value >= 0) {
29
33
  definition.resolvedCap = cap.value;
@@ -1,8 +1,12 @@
1
+ import { goalContent } from '../goals/fairness.js';
2
+ import { detached } from './placementInteractions.js';
3
+ import { lockAndRescanPlacements, placementInteractions } from './placementInteractions.js';
1
4
  import { lockPagePaths } from '../db/locks.js';
2
5
  import { lockMetricsForFreeze } from '../db/metricUsage.js';
3
6
  import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
4
7
  import { claimSlices } from '../db/slices.js';
5
- import { pageInUseCopy, unpublishedPageCopy } from '../admin/copy/readiness.js';
8
+ import { asymmetricGoalsCopy, sameAddressCopy, interactingTestsCopy, pageInUseCopy, unpublishedPageCopy } from '../admin/copy/readiness.js';
9
+ import { GROUP_LABELS } from '../admin/copy/arms.js';
6
10
  import { XpRefusal } from '../errors.js';
7
11
  import { isOpValid } from '../targeting/ops.js';
8
12
  import { newSeed } from '../runtime/seed.js';
@@ -31,7 +35,19 @@ export async function readExperiment(req, id) {
31
35
  }
32
36
  export function refuse(code, detail) {
33
37
  const messages = {
38
+ 'goal-in-some-versions': asymmetricGoalsCopy(detail),
39
+ 'placements-changed': "This test's pages changed while it was starting. Try again.",
40
+ 'page-busy': "One of this test's pages is being saved right now. Try again in a moment.",
41
+ 'interacting-tests': (()=>{
42
+ const d = detail?.[0];
43
+ return d ? interactingTestsCopy(d.path, d.status, d.name, d.version) : 'Finish one of the tests first.';
44
+ })(),
45
+ 'unknown-collection': "That page type isn't set up for tests.",
34
46
  'page-in-use': pageInUseCopy(detail?.paths?.[0] ?? 'this page'),
47
+ 'version-same-address': (()=>{
48
+ const d = detail;
49
+ return sameAddressCopy(GROUP_LABELS[d?.arm ?? 'variantA'], GROUP_LABELS[d?.otherArm ?? 'control'], d?.path ?? 'this address');
50
+ })(),
35
51
  'page-target-unpublished': unpublishedPageCopy(typeof detail === 'string' ? detail : 'This page'),
36
52
  'invalid-status': 'This action is not available in the current status.',
37
53
  'stale-draft': 'The experiment changed. Review it again before starting.',
@@ -46,6 +62,9 @@ export function refuse(code, detail) {
46
62
  'entry-not-dismissed': 'That page is not dismissed.'
47
63
  };
48
64
  const conflict = [
65
+ 'page-busy',
66
+ 'placements-changed',
67
+ 'interacting-tests',
49
68
  'invalid-status',
50
69
  'stale-draft',
51
70
  'page-in-use',
@@ -110,6 +129,17 @@ export async function start(req, { experimentId, options }) {
110
129
  const current = await readExperiment(req, experimentId);
111
130
  if (current.updatedAt !== updatedAt) refuse('stale-draft');
112
131
  if (current.status !== 'draft' && current.status !== 'stopped') refuse('invalid-status');
132
+ let placementDocs;
133
+ if (current.kind === 'block') {
134
+ const rescan = await lockAndRescanPlacements(req, exec, options, current, input.placements ?? []);
135
+ placementDocs = rescan.docs;
136
+ const failure = checkStart({
137
+ ...input,
138
+ experiment: current,
139
+ placements: rescan.placements
140
+ }).find((check)=>!check.ok);
141
+ if (failure && failure.code !== 'page-targets-unchecked' && failure.code !== 'make-content-unsupported') refuse(failure.code, failure.detail);
142
+ }
113
143
  if (current.kind === 'page') {
114
144
  // Independent experiment/layer locks do not serialize overlapping pages.
115
145
  // Re-read occupancy after acquiring sorted path locks; hold through commit.
@@ -121,6 +151,8 @@ export async function start(req, { experimentId, options }) {
121
151
  pageTestsInUse: await loadPageTestsInUse(req)
122
152
  }).find((check)=>check.code === 'page-in-use' && !check.ok);
123
153
  if (recheck) refuse('page-in-use', recheck.detail);
154
+ const conflicts = await placementInteractions(req, exec, options, current, pageTargetPaths(current.arms));
155
+ if (conflicts.length) refuse('interacting-tests', conflicts);
124
156
  if (options.pages) {
125
157
  // The page guard takes the same path locks, so a delete, unpublish or rename that committed
126
158
  // while Start waited is visible now. Read it on a detached request (committed state, outside
@@ -181,6 +213,15 @@ export async function start(req, { experimentId, options }) {
181
213
  }).find((check)=>!check.ok);
182
214
  if (recheck && recheck.code !== 'page-targets-unchecked' && recheck.code !== 'make-content-unsupported') refuse(recheck.code, recheck.detail);
183
215
  }
216
+ if (input.metrics.some((metric)=>metric.source.startsWith('click:'))) {
217
+ const content = await detached(req, ()=>goalContent(req, options, current, placementDocs));
218
+ const failure = checkStart({
219
+ ...input,
220
+ experiment: current,
221
+ goalContent: content
222
+ }).find((check)=>check.code === 'goal-in-some-versions' && !check.ok);
223
+ if (failure) refuse('goal-in-some-versions', failure.detail);
224
+ }
184
225
  const lockedMetrics = await lockMetricsForFreeze(exec, caps.definitions.map((metric)=>metric.id));
185
226
  if (!lockedMetrics) {
186
227
  throw new XpRefusal('stale-draft', 'A goal in this test was deleted. Review the goals before starting.', undefined, 409);
@@ -233,6 +274,9 @@ export async function start(req, { experimentId, options }) {
233
274
  allocationChanges: [],
234
275
  targeting,
235
276
  metricDefinitions: definitions,
277
+ ...placementDocs ? {
278
+ placementDocs
279
+ } : {},
236
280
  analysis: {
237
281
  ...current.analysis,
238
282
  method: current.analysis?.method ?? 'sequential',
@@ -18,4 +18,4 @@ export type SerializedAttributeKeys = Record<string, {
18
18
  type: 'string' | 'number' | 'boolean';
19
19
  values?: string[];
20
20
  }>;
21
- export type EditableField = 'name' | 'hypothesis' | 'arms' | 'targeting' | 'allocation' | 'flagPoint' | 'metrics' | 'analysis' | 'exposurePolicy';
21
+ export type EditableField = 'name' | 'hypothesis' | 'arms' | 'targeting' | 'allocation' | 'flagPoint' | 'metrics' | 'analysis' | 'exposurePolicy' | 'pageCollection';
@@ -32,6 +32,7 @@ export interface FlagPointResult {
32
32
  exposable: boolean;
33
33
  }
34
34
  export interface ClientAssignments {
35
+ clickGoalsArmed?: boolean;
35
36
  v: 1;
36
37
  revision: number;
37
38
  apiRoute: string;
@@ -103,6 +103,10 @@ export type ExperimentsAccess = (args: {
103
103
  }) => boolean | Promise<boolean>;
104
104
  export interface ExperimentsPageCollection {
105
105
  slug: string;
106
+ contentOf?: (doc: Record<string, unknown>) => unknown;
107
+ copyHint?: string;
108
+ copyUrl?: string;
109
+ copyLabel?: string;
106
110
  pathOf: (doc: Record<string, unknown>) => string | null;
107
111
  /** Lookup with the supplied req and overrideAccess: false. Under an anonymous req (results checks) only published pages are visible, so a hit is { published: true } or null. Under a req that can read drafts (Start), a draft-only page may be reported as { published: false }. */
108
112
  findByPath: (path: string, req: PayloadRequest) => Promise<{
@@ -130,9 +134,12 @@ export interface ExperimentsPluginOptions<TUser extends {
130
134
  collection: string;
131
135
  id: DocId;
132
136
  draft?: boolean;
137
+ editor: 'puck' | 'blocks' | 'page';
138
+ doc?: Record<string, unknown>;
133
139
  }) => string;
134
140
  previewUrl?: (placement: PreviewPlacement) => string | null | undefined | Promise<string | null | undefined>;
135
141
  descriptions?: {
142
+ page?: string;
136
143
  block?: string;
137
144
  document?: string;
138
145
  setting?: string;
@@ -151,6 +158,14 @@ export interface ExperimentsPluginOptions<TUser extends {
151
158
  pages?: {
152
159
  collections: ExperimentsPageCollection[];
153
160
  };
161
+ blocks?: {
162
+ collections: Array<{
163
+ slug: string;
164
+ field: string;
165
+ blockSlug?: string;
166
+ appliesTo?: (doc: Record<string, unknown>) => boolean;
167
+ }>;
168
+ };
154
169
  puck?: {
155
170
  enabled: boolean;
156
171
  collections: Array<string | {
@@ -7,7 +7,7 @@ import type { Snapshot, VerdictCode } from './snapshot.js';
7
7
  export type ArmIssueCode = 'weights-total' | 'control-count' | 'control-first' | 'holdout-last' | 'holdout-count' | 'variant-count' | 'duplicate-key' | 'duplicate-slot' | 'slot-required' | 'invalid-key';
8
8
  /** Content-report dismiss/restore refusals (CORE-44). */
9
9
  export type ContentEntryRefusal = 'not-decided' | 'entry-not-found' | 'entry-ok' | 'entry-dismissed' | 'entry-not-dismissed';
10
- export type RefusalCode = ArmIssueCode | 'not-found' | ContentEntryRefusal | 'no-planned-duration' | 'no-primary-metric' | 'no-placements' | 'empty-slot' | 'page-in-use' | 'page-no-targets' | 'page-target-unpublished' | 'page-user-unit' | 'version-page-unavailable' | 'version-page-unchecked' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'layer-full' | 'layer-unit-mismatch' | 'unknown-metric-source' | 'unit-source-mismatch' | 'no-reach-per-day' | 'invalid-status' | 'stale-draft' | 'stale-edit' | 'commit-unverified' | 'allocation-lower' | 'confirmation-required' | 'invalid-winner' | 'slices-taken' | 'locked-placement' | 'not-implemented' | 'forbidden' | 'unauthenticated' | 'body-too-large' | 'invalid-body' | 'too-many-ids' | 'invalid-phase' | 'invalid-ids' | 'recompute-failed' | 'analysis-failed' | 'undeclared-targeting-key' | 'invalid-targeting-rule' | 'block-path-rule' | 'duplicate-metric';
10
+ export type RefusalCode = ArmIssueCode | 'goal-in-some-versions' | 'placement-unchecked' | 'placement-unpublished' | 'duplicate-version-row' | 'interacting-tests' | 'placements-changed' | 'page-busy' | 'wrap-target-missing' | 'version-identical' | 'version-same-address' | 'unknown-collection' | 'not-found' | ContentEntryRefusal | 'no-planned-duration' | 'no-primary-metric' | 'no-placements' | 'empty-slot' | 'page-in-use' | 'page-no-targets' | 'page-target-unpublished' | 'page-user-unit' | 'version-page-unavailable' | 'version-page-unchecked' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'layer-full' | 'layer-unit-mismatch' | 'unknown-metric-source' | 'unit-source-mismatch' | 'no-reach-per-day' | 'invalid-status' | 'stale-draft' | 'stale-edit' | 'commit-unverified' | 'allocation-lower' | 'confirmation-required' | 'invalid-winner' | 'slices-taken' | 'locked-placement' | 'not-implemented' | 'forbidden' | 'unauthenticated' | 'body-too-large' | 'invalid-body' | 'too-many-ids' | 'invalid-phase' | 'invalid-ids' | 'recompute-failed' | 'analysis-failed' | 'undeclared-targeting-key' | 'invalid-targeting-rule' | 'block-path-rule' | 'duplicate-metric';
11
11
  export type ErrorEnvelope = {
12
12
  errors: Array<{
13
13
  code: RefusalCode;
@@ -22,6 +22,13 @@ export type TransitionSuccess = {
22
22
  export type AllocateBody = {
23
23
  allocation: number;
24
24
  };
25
+ export type AddMetricWarning = {
26
+ code: 'goal-in-some-versions' | 'goal-added-mid-test' | 'placement-unchecked';
27
+ detail?: unknown;
28
+ };
29
+ export type AddMetricResult = TransitionSuccess & {
30
+ warnings: AddMetricWarning[];
31
+ };
25
32
  export type AddMetricBody = {
26
33
  metric: DocId;
27
34
  role: 'secondary' | 'guardrail';
@@ -57,12 +64,27 @@ export type MakeContentResult = TransitionSuccess & {
57
64
  }>;
58
65
  };
59
66
  export type PreflightResult = {
67
+ goalNotes?: Array<{
68
+ metricId: DocId;
69
+ message: string;
70
+ }>;
60
71
  checks: Array<{
61
72
  code: ReadinessCode;
62
73
  ok: boolean;
63
74
  detail?: unknown;
64
75
  warning?: boolean;
65
76
  }>;
77
+ pagePlacements?: Array<{
78
+ arm: string;
79
+ path: string;
80
+ collection?: string;
81
+ id?: DocId | null;
82
+ title?: string;
83
+ published: boolean;
84
+ slug?: string;
85
+ url?: string;
86
+ editorUrl?: string;
87
+ }>;
66
88
  placements: Array<{
67
89
  collection: string;
68
90
  id: DocId;
@@ -104,6 +126,7 @@ export interface PhaseSummary {
104
126
  name: string;
105
127
  kind: MetricSource['kind'];
106
128
  role: 'primary' | 'secondary' | 'guardrail';
129
+ clickGoal?: true;
107
130
  resolvedCap?: number | null;
108
131
  historyUnits?: number;
109
132
  capNotice?: 'no-history';
@@ -137,10 +160,7 @@ export type SummaryResponse = Record<DocId, {
137
160
  code: ReadinessCode;
138
161
  detail?: unknown;
139
162
  };
140
- health?: Array<{
141
- code: 'version-page-unavailable' | 'version-page-unchecked';
142
- path: string;
143
- }>;
163
+ health?: import('./snapshot.js').SnapshotHealth[];
144
164
  exposures: number;
145
165
  days: number;
146
166
  verdict: {
@@ -174,3 +194,70 @@ export type EndpointContext<TUser extends {
174
194
  visitorId: string;
175
195
  };
176
196
  };
197
+ export type ClickKeysResult = {
198
+ keys: Array<{
199
+ key: string;
200
+ arms: string[];
201
+ label: string;
202
+ }>;
203
+ };
204
+ export type PagesResult = {
205
+ docs: Array<{
206
+ id: DocId;
207
+ title: string;
208
+ path: string | null;
209
+ pathKey: string | null;
210
+ published: boolean;
211
+ }>;
212
+ hasNextPage: boolean;
213
+ };
214
+ export type PlacementStatusBody = {
215
+ docs?: Array<{
216
+ collection: string;
217
+ id: DocId;
218
+ }>;
219
+ slugs?: string[];
220
+ };
221
+ export type PageTestStatus = {
222
+ slug: string;
223
+ name: string;
224
+ status: Status;
225
+ role: 'original' | 'version';
226
+ winner: boolean;
227
+ armLabel: string;
228
+ winnerLabel?: string;
229
+ winnerTitle?: string;
230
+ winnerPath?: string;
231
+ originalPath?: string;
232
+ rollout: boolean;
233
+ pagesReleased: boolean;
234
+ };
235
+ export type PlacementStatusResult = {
236
+ docs: Record<string, {
237
+ path: string | null;
238
+ tests: PageTestStatus[];
239
+ }>;
240
+ slugs: Record<string, {
241
+ missing: true;
242
+ } | {
243
+ deleted: true;
244
+ } | {
245
+ name: string;
246
+ status: Status;
247
+ kind: ExperimentKind;
248
+ arms: Array<{
249
+ key: string;
250
+ label: string;
251
+ slot?: string;
252
+ }>;
253
+ winnerArm?: string;
254
+ rollout: boolean;
255
+ }>;
256
+ };
257
+ export type WrapBlockBody = {
258
+ collection: string;
259
+ id: DocId;
260
+ field: string;
261
+ blockId: string;
262
+ experiment: string;
263
+ };
@@ -7,3 +7,6 @@ export * from './admin.js';
7
7
  export * from './endpoints.js';
8
8
  export * from './snapshot.js';
9
9
  export * from './reasons.js';
10
+ export type { HostBlock, ExperimentBlock, ExperimentBlockGroup, ResolvedHostBlock, ResolvedExperimentBlocks } from '../blocks/model.js';
11
+ export type { WrapBlockBody } from './endpoints.js';
12
+ export type { SnapshotHealth } from './snapshot.js';
@@ -1,5 +1,5 @@
1
1
  import type { DocId } from './contracts.js';
2
- import type { Slot } from '@puckeditor/core';
2
+ import type { ComponentConfig, Config, Plugin, Slot } from '@puckeditor/core';
3
3
  export type { Slot } from '@puckeditor/core';
4
4
  export type ArmSlot = 'control' | 'variantA' | 'variantB' | 'variantC';
5
5
  export type ExperimentRef = {
@@ -21,3 +21,9 @@ export type ExperimentSlotProps = {
21
21
  };
22
22
  };
23
23
  export declare const EXPERIMENT_SLOT_TYPE: "ExperimentSlot";
24
+ export interface ExperimentsPuckIntegration {
25
+ name: string;
26
+ components: Record<string, ComponentConfig<ExperimentSlotProps>>;
27
+ categories: Config['categories'];
28
+ plugins?: readonly Plugin[];
29
+ }
@@ -1,3 +1,18 @@
1
+ export type SnapshotHealth = {
2
+ code: 'version-page-unavailable' | 'version-page-unchecked';
3
+ path: string;
4
+ } | {
5
+ code: 'goal-in-some-versions';
6
+ metricId: string | number;
7
+ name: string;
8
+ } | {
9
+ code: 'placement-unchecked';
10
+ detail: {
11
+ collection?: string;
12
+ id?: string | number;
13
+ path?: string;
14
+ };
15
+ };
1
16
  import type { GaussianPrior } from '@delmaredigital/experiments-stats';
2
17
  import type { DocId, MetricSource } from './contracts.js';
3
18
  import type { MetricUnavailableReason } from './reasons.js';
@@ -108,10 +123,8 @@ export interface Snapshot {
108
123
  guardrailLine?: string;
109
124
  worstArm?: string;
110
125
  };
111
- health?: Array<{
112
- code: 'version-page-unavailable' | 'version-page-unchecked';
113
- path: string;
114
- }>;
126
+ goalFairnessNotice?: string;
127
+ health?: SnapshotHealth[];
115
128
  contentChanges: Array<{
116
129
  at: string;
117
130
  doc: {
@@ -7,6 +7,9 @@ export interface TestsSurfaceProps {
7
7
  returnToTest?: string;
8
8
  adminRoute: string;
9
9
  apiRoute: string;
10
+ collectionTitles?: Record<string, string>;
11
+ userId?: string | number;
12
+ userCollection?: string;
10
13
  isAdmin: boolean;
11
14
  pages?: Array<{
12
15
  collection: string;
@@ -14,6 +17,15 @@ export interface TestsSurfaceProps {
14
17
  id: DocId;
15
18
  title: string;
16
19
  editorUrl: string;
20
+ editor: 'puck' | 'blocks';
21
+ }>;
22
+ pageCollections?: Array<{
23
+ slug: string;
24
+ label: string;
25
+ contentChecks: boolean;
26
+ copyHint?: string;
27
+ copyUrl?: string;
28
+ copyLabel?: string;
17
29
  }>;
18
30
  canManage: boolean;
19
31
  canReadResults: boolean;
@@ -22,6 +34,7 @@ export interface TestsSurfaceProps {
22
34
  metricSources: SerializedMetricSource[];
23
35
  admin?: {
24
36
  descriptions?: {
37
+ page?: string;
25
38
  block?: string;
26
39
  document?: string;
27
40
  setting?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-experiments",
3
- "version": "0.3.0-rc.4",
3
+ "version": "0.4.0-rc.1",
4
4
  "description": "No-code A/B testing for Payload CMS and Next.js: block, page and flag experiments with holdouts, layers, exposure logging and honest statistics inside the admin — no vendor",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,12 @@
37
37
  "import": "./dist/exports/rsc.js",
38
38
  "default": "./dist/exports/rsc.js"
39
39
  },
40
+ "./blocks": {
41
+ "@delmaredigital/source": "./src/exports/blocks.ts",
42
+ "types": "./dist/exports/blocks.d.ts",
43
+ "import": "./dist/exports/blocks.js",
44
+ "default": "./dist/exports/blocks.js"
45
+ },
40
46
  "./client": {
41
47
  "@delmaredigital/source": "./src/exports/client.ts",
42
48
  "types": "./dist/exports/client.d.ts",
@@ -120,6 +126,7 @@
120
126
  },
121
127
  "homepage": "https://delmaredigital.com/developers",
122
128
  "devDependencies": {
129
+ "@delmaredigital/payload-puck": "0.10.3",
123
130
  "payload": "^3.90.2",
124
131
  "@payloadcms/db-postgres": "^3.90.2",
125
132
  "@payloadcms/ui": "^3.90.2",