@u-krupaveho-kraba/form-schemas 0.2.0 → 0.4.0

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.
@@ -3,7 +3,10 @@ import { FormSchema } from "./form.js";
3
3
  export const FormReferenceSchema = z.object({
4
4
  ref: z.string(),
5
5
  });
6
- export const CampaignFormDefinitionSchema = z.union([FormSchema, FormReferenceSchema]);
6
+ export const CampaignFormDefinitionSchema = z.union([
7
+ FormSchema,
8
+ FormReferenceSchema,
9
+ ]);
7
10
  export const VariantSchema = z.object({
8
11
  id: z.string(),
9
12
  name: z.string(),
@@ -19,44 +22,85 @@ export const CampaignScheduleSchema = z.object({
19
22
  startsAt: z.optional(z.string()),
20
23
  endsAt: z.optional(z.string()),
21
24
  });
22
- // Used identically at Campaign level and CampaignItem level. `frequency`
23
- // used to be forbidden below the Campaign (the old SlotTargetingSchema,
24
- // ticket 117) because DisplayRuleState's display-count tracking was
25
- // per-campaign only. Every CampaignItem now owns its own display-budget
26
- // key (form-shared's displayKey), so that restriction no longer protects
27
- // anything -- one shape serves both levels.
25
+ // Used on CampaignItem (the only level left post-ADR-0025's Campaign
26
+ // removal).
28
27
  export const CampaignTargetingSchema = z.object({
29
28
  urlPatterns: z.optional(z.array(z.string())),
30
29
  schedule: z.optional(CampaignScheduleSchema),
31
30
  frequency: z.optional(FrequencyTargetingSchema),
32
31
  });
33
- export const ImmediateTriggerSchema = z.object({ type: z.literal("immediate") });
34
- export const DelayTriggerSchema = z.object({ type: z.literal("delay"), seconds: z.number() });
32
+ export const ImmediateTriggerSchema = z.object({
33
+ type: z.literal("immediate"),
34
+ });
35
+ export const DelayTriggerSchema = z.object({
36
+ type: z.literal("delay"),
37
+ seconds: z.number(),
38
+ });
35
39
  export const ScrollDepthTriggerSchema = z.object({
36
40
  type: z.literal("scroll_depth"),
37
41
  percent: z.number(),
38
42
  });
39
- export const ExitIntentTriggerSchema = z.object({ type: z.literal("exit_intent") });
43
+ export const ExitIntentTriggerSchema = z.object({
44
+ type: z.literal("exit_intent"),
45
+ });
40
46
  export const TriggerConditionSchema = z.discriminatedUnion("type", [
41
47
  ImmediateTriggerSchema,
42
48
  DelayTriggerSchema,
43
49
  ScrollDepthTriggerSchema,
44
50
  ExitIntentTriggerSchema,
45
51
  ]);
46
- // One independently-evaluated item in a Campaign -- replaces ticket 117's
47
- // CampaignSlotSchema union (VariantSchema | VariantGroupSchema). Default:
48
- // concurrent with every other item in the same Campaign. `exclusiveWith`,
52
+ // The unit of independent client-side evaluation -- since ADR 0025 (Campaign
53
+ // wrapper deleted), the only level left in the schema. Default: concurrent
54
+ // with every other item published for the same Website. `exclusiveWith`,
49
55
  // when shared by two or more items, makes them compete instead (first
50
- // eligible one, by array order, wins) -- see CampaignSchema's cross-item
51
- // check below for the one invariant this field needs enforced.
52
- export const CampaignItemSchema = z.object({
56
+ // eligible one, by array order, wins) -- see CampaignItemsFileSchema's
57
+ // cross-item checks below for the invariants this and `id` need enforced.
58
+ //
59
+ // `trigger` is optional (ADR 0028, reversing this schema's own original
60
+ // choice) -- a CampaignItem with no rule configured at all is now an
61
+ // ordinary item, not a distinct mode; the widget defaults a missing
62
+ // trigger to `{type: "immediate"}` at evaluation time (see
63
+ // apps/form-widget/src/lib/runWidget.ts) rather than this schema forcing
64
+ // every producer to write that default explicitly.
65
+ //
66
+ // The single source of truth for CampaignItem's shared envelope fields --
67
+ // `packages/form-shared`'s `CampaignItemBase` and `apps/form-editor`'s own
68
+ // copy both derive from this schema (`z.infer`/`Pick`) rather than
69
+ // hand-declaring the same field set a third and fourth time (architecture
70
+ // review, 2026-09-10).
71
+ export const CampaignItemBaseSchema = z.object({
72
+ // Stable, author-assigned -- also the display-budget key (see
73
+ // types/displayRuleState.ts), the DOM mount attribute, and the Submit
74
+ // Payload/Funnel Event `itemId` (ADR 0025). Never an array index:
75
+ // reordering the item list must not change what an item's stored display
76
+ // history refers to. Required unique across every item published for one
77
+ // Website -- there is no outer wrapper left to namespace two items
78
+ // sharing an id.
53
79
  id: z.string(),
80
+ // Human label for the authoring UI ("Footer bar", "Black Friday banner")
81
+ // -- never shown to an End Customer.
54
82
  name: z.optional(z.string()),
55
83
  targeting: z.optional(CampaignTargetingSchema),
56
- trigger: TriggerConditionSchema,
84
+ trigger: z.optional(TriggerConditionSchema),
57
85
  exclusiveWith: z.optional(z.string()),
58
- variants: z.array(VariantSchema),
86
+ // Freeform, integrator-set reporting/attribution tag (ADR 0025) -- lets
87
+ // several independent items share one identity in the integrator's own
88
+ // reporting (e.g. Ecomail populates it from its own form_campaigns.id).
89
+ // Inert at evaluation time: no role in uniqueness, exclusiveWith
90
+ // clustering, or eligibility. This is the direct replacement for what the
91
+ // deleted Campaign wrapper's `id`/`name` used to provide structurally.
92
+ campaignId: z.optional(z.string()),
59
93
  });
94
+ // ADR 0028: a CampaignItem carries its own content directly -- either a
95
+ // single `definition` (the common, non-AB case) or `variants` (opt-in AB
96
+ // test), never both, never neither. Kept as a plain `z.union` rather than a
97
+ // `z.discriminatedUnion` -- there is no literal tag field to discriminate
98
+ // on, same precedent as CampaignFormDefinitionSchema one level down (a
99
+ // Form-shaped object vs. a `{ref}`-shaped one, disambiguated structurally).
100
+ export const CampaignItemSchema = z.union([
101
+ z.object({ ...CampaignItemBaseSchema.shape, variants: z.array(VariantSchema) }),
102
+ z.object({ ...CampaignItemBaseSchema.shape, definition: CampaignFormDefinitionSchema }),
103
+ ]);
60
104
  // A lone item carrying an exclusiveWith value no sibling shares behaves
61
105
  // identically to one with no value at all (a cluster of one just always
62
106
  // wins), so it's never a runtime bug -- but it's always either dead
@@ -64,9 +108,11 @@ export const CampaignItemSchema = z.object({
64
108
  // typo in one of the two values. Reject it at publish time rather than
65
109
  // let it ship silently as a no-op field. `.check()` runs after the object
66
110
  // itself validates, so `items` here is already `CampaignItem[]`-shaped.
67
- function eachExclusiveWithValueIsShared(campaign) {
111
+ // Post-ADR-0025: clusters across the *entire* flat item list for a Website
112
+ // in one pass, not scoped to any wrapper.
113
+ function eachExclusiveWithValueIsShared(file) {
68
114
  const counts = new Map();
69
- for (const item of campaign.items) {
115
+ for (const item of file.items) {
70
116
  if (item.exclusiveWith === undefined)
71
117
  continue;
72
118
  counts.set(item.exclusiveWith, (counts.get(item.exclusiveWith) ?? 0) + 1);
@@ -74,31 +120,33 @@ function eachExclusiveWithValueIsShared(campaign) {
74
120
  return [...counts.values()].every((count) => count >= 2);
75
121
  }
76
122
  const EXCLUSIVE_WITH_SHARED_MESSAGE = "exclusiveWith value is used by only one item -- either remove it or add a second item that shares it";
77
- export const CampaignSchema = z
123
+ // ADR 0025: CampaignItem.id now does every job the deleted Campaign.id used
124
+ // to do (DOM mount attribute, displayKey namespacing, analytics identity),
125
+ // so it must be unique across the whole flat item list for a Website --
126
+ // there is no outer wrapper left to namespace two items sharing an id.
127
+ function everyItemIdIsUnique(file) {
128
+ const ids = file.items.map((item) => item.id);
129
+ return new Set(ids).size === ids.length;
130
+ }
131
+ const ITEM_ID_UNIQUE_MESSAGE = "item id is used by more than one item -- CampaignItem.id must be unique within a Website's item list";
132
+ export const CampaignItemsFileSchema = z
78
133
  .object({
79
- id: z.string(),
80
- name: z.string(),
81
- version: z.literal(1),
82
- targeting: z.optional(CampaignTargetingSchema),
134
+ formatVersion: z.literal(1),
135
+ account: z.string(),
136
+ website: z.string(),
83
137
  items: z.array(CampaignItemSchema),
84
138
  })
85
- .check(z.refine(eachExclusiveWithValueIsShared, EXCLUSIVE_WITH_SHARED_MESSAGE));
139
+ .check(z.refine(eachExclusiveWithValueIsShared, EXCLUSIVE_WITH_SHARED_MESSAGE), z.refine(everyItemIdIsUnique, ITEM_ID_UNIQUE_MESSAGE));
86
140
  // Ticket 113: field names match the real, already-deployed Ecomail publish
87
141
  // job's actual index-entry output, not FormReferenceSchema's {ref} shape.
88
- export const CampaignEntryRefSchema = z.object({
142
+ // Renamed from CampaignEntry(Ref)Schema by ADR 0025 -- one entry now
143
+ // references/inlines a single CampaignItem, not a whole (deleted) Campaign.
144
+ export const CampaignItemEntryRefSchema = z.object({
89
145
  id: z.string(),
90
146
  url: z.string(),
91
147
  rules: z.optional(z.unknown()),
92
148
  });
93
- export const CampaignEntrySchema = z.union([CampaignEntryRefSchema, CampaignSchema]);
94
- // The campaigns.json v1 envelope (ADR 0008) -- see the CampaignsFile doc
95
- // comment in form-shared/src/types/campaign.ts for how this relates to the
96
- // shipped index+per-file HTTP delivery split. Still v1: ticket 117's
97
- // CampaignSlot shape never shipped to a real customer (127/128/129 landed
98
- // 2026-08-12, zero adoption yet), so this revision replaces it in place.
99
- export const CampaignsFileSchema = z.object({
100
- formatVersion: z.literal(1),
101
- account: z.string(),
102
- website: z.string(),
103
- campaigns: z.array(CampaignSchema),
104
- });
149
+ export const CampaignItemEntrySchema = z.union([
150
+ CampaignItemEntryRefSchema,
151
+ CampaignItemSchema,
152
+ ]);
@@ -1,17 +1,20 @@
1
1
  import * as z from "zod/mini";
2
- import { BaseElementPropertiesSchema, ButtonElementPropertiesSchema, CheckboxElementPropertiesSchema, HtmlElementPropertiesSchema, ImageElementPropertiesSchema, RadioElementPropertiesSchema, SelectElementPropertiesSchema, SpacerElementPropertiesSchema, TextElementPropertiesSchema, TextInputElementPropertiesSchema, } from "./elementProperties.js";
2
+ import { BaseElementPropertiesSchema, ButtonElementPropertiesSchema, ButtonWidthSchema, CheckboxElementPropertiesSchema, CountdownElementPropertiesSchema, FloatingImageElementPropertiesSchema, GifElementPropertiesSchema, HtmlElementPropertiesSchema, ImageElementPropertiesSchema, RadioElementPropertiesSchema, SelectElementPropertiesSchema, SpacerElementPropertiesSchema, TextElementPropertiesSchema, TextInputElementPropertiesSchema, } from "./elementProperties.js";
3
3
  export const ElementTypeSchema = z.enum([
4
4
  "text_input",
5
5
  "text",
6
6
  "block",
7
7
  "button",
8
8
  "image",
9
+ "floating_image",
10
+ "gif",
9
11
  "column",
10
12
  "checkbox",
11
13
  "radio",
12
14
  "select",
13
15
  "html",
14
16
  "spacer",
17
+ "countdown",
15
18
  ]);
16
19
  // mirrors the Element<T, P> generic helper in form-shared/types/element.ts.
17
20
  // Takes a raw shape (not a ZodMiniObject) — matching zod's own `extend()`
@@ -32,11 +35,14 @@ export const ButtonElementSchema = elementSchema("button", ButtonElementProperti
32
35
  export const TextElementSchema = elementSchema("text", TextElementPropertiesSchema.shape);
33
36
  export const TextInputElementSchema = elementSchema("text_input", TextInputElementPropertiesSchema.shape);
34
37
  export const ImageElementSchema = elementSchema("image", ImageElementPropertiesSchema.shape);
38
+ export const FloatingImageElementSchema = elementSchema("floating_image", FloatingImageElementPropertiesSchema.shape);
39
+ export const GifElementSchema = elementSchema("gif", GifElementPropertiesSchema.shape);
35
40
  export const CheckboxElementSchema = elementSchema("checkbox", CheckboxElementPropertiesSchema.shape);
36
41
  export const RadioElementSchema = elementSchema("radio", RadioElementPropertiesSchema.shape);
37
42
  export const SelectElementSchema = elementSchema("select", SelectElementPropertiesSchema.shape);
38
43
  export const HtmlElementSchema = elementSchema("html", HtmlElementPropertiesSchema.shape);
39
44
  export const SpacerElementSchema = elementSchema("spacer", SpacerElementPropertiesSchema.shape);
45
+ export const CountdownElementSchema = elementSchema("countdown", CountdownElementPropertiesSchema.shape);
40
46
  export const BlockColumnElementSchema = z.object({
41
47
  id: z.string(),
42
48
  type: z.literal("column"),
@@ -50,6 +56,7 @@ export const BlockColumnElementSchema = z.object({
50
56
  });
51
57
  const blockLayoutBase = {
52
58
  ...BaseElementPropertiesSchema.shape,
59
+ width: z.optional(ButtonWidthSchema),
53
60
  };
54
61
  export const BlockElementPropertiesSchema = z.discriminatedUnion("block_type", [
55
62
  z.object({
@@ -65,7 +72,11 @@ export const BlockElementPropertiesSchema = z.discriminatedUnion("block_type", [
65
72
  z.object({
66
73
  ...blockLayoutBase,
67
74
  block_type: z.literal("triple_column"),
68
- columns: z.tuple([BlockColumnElementSchema, BlockColumnElementSchema, BlockColumnElementSchema]),
75
+ columns: z.tuple([
76
+ BlockColumnElementSchema,
77
+ BlockColumnElementSchema,
78
+ BlockColumnElementSchema,
79
+ ]),
69
80
  }),
70
81
  ]);
71
82
  export const BlockElementSchema = z.object({
@@ -78,6 +89,8 @@ export const FormElementSchema = z.discriminatedUnion("type", [
78
89
  TextElementSchema,
79
90
  TextInputElementSchema,
80
91
  ImageElementSchema,
92
+ FloatingImageElementSchema,
93
+ GifElementSchema,
81
94
  CheckboxElementSchema,
82
95
  RadioElementSchema,
83
96
  SelectElementSchema,
@@ -85,4 +98,5 @@ export const FormElementSchema = z.discriminatedUnion("type", [
85
98
  BlockColumnElementSchema,
86
99
  HtmlElementSchema,
87
100
  SpacerElementSchema,
101
+ CountdownElementSchema,
88
102
  ]);
@@ -16,9 +16,19 @@ export const LinkTargetSchema = z.object({
16
16
  url: z.string(),
17
17
  openInNewTab: z.optional(z.boolean()),
18
18
  });
19
+ // ADR 0026: where a successful Submit lands. Omitted/undefined means "use
20
+ // the effective default" -- the Thank You Page when the Form has one,
21
+ // otherwise no navigation (today's behavior).
22
+ export const PostSubmitDestinationSchema = z.enum([
23
+ "next_step",
24
+ "thank_you_page",
25
+ ]);
19
26
  export const ButtonActionSchema = z.discriminatedUnion("type", [
20
27
  z.object({ type: z.literal("next_step") }),
21
- z.object({ type: z.literal("submit") }),
28
+ z.object({
29
+ type: z.literal("submit"),
30
+ onSuccess: z.optional(PostSubmitDestinationSchema),
31
+ }),
22
32
  z.object({ type: z.literal("close") }),
23
33
  z.object({ type: z.literal("jump_to"), targetStepId: z.string() }),
24
34
  z.object({ type: z.literal("redirect"), ...LinkTargetSchema.shape }),
@@ -38,6 +48,13 @@ export const ButtonElementPropertiesSchema = z.object({
38
48
  fontSize: z.optional(z.number()),
39
49
  fontWeight: z.optional(z.enum(["normal", "bold"])),
40
50
  fontStyle: z.optional(z.enum(["normal", "italic"])),
51
+ // Alignment of the text inside the button's own box -- distinct from
52
+ // `align` below, which positions the button itself within its wrapper.
53
+ textAlign: z.optional(ButtonAlignSchema),
54
+ // A CSS gradient function string (e.g. "linear-gradient(90deg, ...)"),
55
+ // layered on top of the base `backgroundColor` (BaseContainerProperties)
56
+ // rather than replacing it -- same background-image/-color split LPE uses.
57
+ backgroundImage: z.optional(z.string()),
41
58
  hover: z.optional(z.object({
42
59
  backgroundColor: z.optional(z.string()),
43
60
  fontColor: z.optional(z.string()),
@@ -47,7 +64,14 @@ export const ButtonElementPropertiesSchema = z.object({
47
64
  height: z.optional(z.number()),
48
65
  align: z.optional(ButtonAlignSchema),
49
66
  });
50
- export const InputTypeSchema = z.enum(["text", "email", "telephone", "number", "date", "url"]);
67
+ export const InputTypeSchema = z.enum([
68
+ "text",
69
+ "email",
70
+ "telephone",
71
+ "number",
72
+ "date",
73
+ "url",
74
+ ]);
51
75
  export const InputFieldSchema = z.object({
52
76
  type: InputTypeSchema,
53
77
  name: z.string(),
@@ -62,7 +86,12 @@ export const PreferenceOptionSchema = z.object({
62
86
  value: z.string(),
63
87
  label: z.string(),
64
88
  });
65
- export const PreferenceTypeSchema = z.enum(["checkbox", "hidden", "radio", "select"]);
89
+ export const PreferenceTypeSchema = z.enum([
90
+ "checkbox",
91
+ "hidden",
92
+ "radio",
93
+ "select",
94
+ ]);
66
95
  export const PreferenceSchema = z.object({
67
96
  id: z.union([z.string(), z.number()]),
68
97
  name: z.string(),
@@ -80,7 +109,9 @@ export const PreferenceSchema = z.object({
80
109
  // -- which already wants a "true = valid" predicate -- can use it directly;
81
110
  // the one caller that wants the opposite (editorStore.ts's save-gate)
82
111
  // negates it at that single call site instead.
83
- export const satisfiesCustomKeyRequirement = (value) => !(value.preference === null && value.custom_options !== undefined && !value.customKey);
112
+ export const satisfiesCustomKeyRequirement = (value) => !(value.preference === null &&
113
+ value.custom_options !== undefined &&
114
+ !value.customKey);
84
115
  const CUSTOM_KEY_REQUIRED_MESSAGE = "customKey is required when custom_options is used without a linked preference";
85
116
  export const PreferenceElementPropertiesSchema = z
86
117
  .object({
@@ -91,14 +122,44 @@ export const PreferenceElementPropertiesSchema = z
91
122
  required: z.boolean(),
92
123
  })
93
124
  .check(z.refine(satisfiesCustomKeyRequirement, CUSTOM_KEY_REQUIRED_MESSAGE));
125
+ // Shared corner-radius preset for Checkbox's `shape` and Select's
126
+ // `controlRadius` -- same value set and same icon-button-group UI, applied
127
+ // to two different controls (ADR 0023: select's control styling stays
128
+ // independent, but the *shape vocabulary* is deliberately shared).
129
+ export const BoxShapeSchema = z.enum(["square", "rounded", "pill"]);
130
+ // Stacked (default, today's only behavior) vs. inline option layout --
131
+ // shared by Checkbox and Radio, meaningless for Select (a single dropdown).
132
+ export const PreferenceFieldLayoutSchema = z.enum(["vertical", "horizontal"]);
94
133
  export const SelectElementPropertiesSchema = z
95
134
  .object({
96
135
  ...PreferenceElementPropertiesSchema.shape,
97
136
  placeholder: z.string(),
137
+ labelColor: z.optional(z.string()),
138
+ controlTextColor: z.optional(z.string()),
139
+ controlBackgroundColor: z.optional(z.string()),
140
+ controlBorderColor: z.optional(z.string()),
141
+ controlRadius: z.optional(BoxShapeSchema),
142
+ })
143
+ .check(z.refine(satisfiesCustomKeyRequirement, CUSTOM_KEY_REQUIRED_MESSAGE));
144
+ export const CheckboxElementPropertiesSchema = z
145
+ .object({
146
+ ...PreferenceElementPropertiesSchema.shape,
147
+ accentColor: z.optional(z.string()),
148
+ labelColor: z.optional(z.string()),
149
+ shape: z.optional(BoxShapeSchema),
150
+ layout: z.optional(PreferenceFieldLayoutSchema),
151
+ })
152
+ .check(z.refine(satisfiesCustomKeyRequirement, CUSTOM_KEY_REQUIRED_MESSAGE));
153
+ export const RadioStyleSchema = z.enum(["outline", "filled"]);
154
+ export const RadioElementPropertiesSchema = z
155
+ .object({
156
+ ...PreferenceElementPropertiesSchema.shape,
157
+ accentColor: z.optional(z.string()),
158
+ labelColor: z.optional(z.string()),
159
+ style: z.optional(RadioStyleSchema),
160
+ layout: z.optional(PreferenceFieldLayoutSchema),
98
161
  })
99
162
  .check(z.refine(satisfiesCustomKeyRequirement, CUSTOM_KEY_REQUIRED_MESSAGE));
100
- export const CheckboxElementPropertiesSchema = PreferenceElementPropertiesSchema;
101
- export const RadioElementPropertiesSchema = PreferenceElementPropertiesSchema;
102
163
  export const TextElementPropertiesSchema = z.object({
103
164
  text: z.string(),
104
165
  color: z.string(),
@@ -114,22 +175,82 @@ export const TextElementPropertiesSchema = z.object({
114
175
  export const HtmlElementPropertiesSchema = z.object({
115
176
  html: z.string(),
116
177
  });
178
+ // Mirrors ButtonWidthSchema's full/custom split, minus "auto" -- see
179
+ // ImageWidth's doc comment in form-shared/types/elementProperties.ts.
180
+ export const ImageWidthSchema = z.union([
181
+ z.literal("full"),
182
+ z.object({ unit: z.enum(["px", "%"]), value: z.number() }),
183
+ ]);
117
184
  export const ImageElementPropertiesSchema = z.object({
118
185
  src: z.string(),
186
+ alt: z.optional(z.string()),
119
187
  link: z.optional(LinkTargetSchema),
120
- width: z.optional(z.object({ unit: z.enum(["px", "%"]), value: z.number() })),
188
+ width: z.optional(ImageWidthSchema),
121
189
  align: z.optional(z.enum(["left", "center", "right"])),
122
190
  objectFit: z.optional(z.enum(["cover", "contain", "fill"])),
123
191
  objectPosition: z.optional(z.string()),
124
192
  });
125
- // pre-authored for the upcoming floating_image element (currently commented
126
- // out in form-shared's ElementType/FormElementSchema) — not yet a consumer.
193
+ // Mirrors FloatingImageElementProperties in
194
+ // form-shared/types/elementProperties.ts -- spelled out explicitly (not
195
+ // derived from ImageElementPropertiesSchema) for the same reason the
196
+ // TypeScript type is: `width`/`align` don't carry over unchanged (`width`
197
+ // becomes a required number set via on-canvas resize, not the full/auto/
198
+ // custom ImageWidthSchema; `align` is dropped, meaningless once the element
199
+ // is taken out of flow), and `position` is a free {x,y} pixel offset set by
200
+ // dragging, not the popup Form's anchor+distance shape.
127
201
  export const FloatingImageElementPropertiesSchema = z.object({
128
202
  src: z.string(),
203
+ alt: z.optional(z.string()),
204
+ link: z.optional(LinkTargetSchema),
205
+ objectFit: z.optional(z.enum(["cover", "contain", "fill"])),
206
+ objectPosition: z.optional(z.string()),
207
+ position: z.object({
208
+ x: z.number(),
209
+ y: z.number(),
210
+ }),
211
+ width: z.number(),
212
+ height: z.number(),
129
213
  });
214
+ // Same shape as ImageElementPropertiesSchema -- mirrors
215
+ // GifElementProperties in form-shared/types/elementProperties.ts.
216
+ export const GifElementPropertiesSchema = ImageElementPropertiesSchema;
130
217
  export const SpacerElementPropertiesSchema = z.object({
131
218
  height: z.number(),
132
219
  showDivider: z.optional(z.boolean()),
133
220
  dividerColor: z.optional(z.string()),
134
221
  dividerThickness: z.optional(z.number()),
135
222
  });
223
+ // "fixed_time" is an evergreen duration -- it restarts fresh from `duration`
224
+ // every time a visitor loads the page (per-visitor, e.g. a "hurry, offer
225
+ // ends in 10 minutes" nudge). "fixed_date" is one real calendar deadline,
226
+ // the same for every visitor. Mirrors OptiMonk's countdown mode split.
227
+ export const CountdownModeSchema = z.enum(["fixed_date", "fixed_time"]);
228
+ export const CountdownDurationSchema = z.object({
229
+ days: z.number(),
230
+ hours: z.number(),
231
+ minutes: z.number(),
232
+ seconds: z.number(),
233
+ });
234
+ export const CountdownLabelsSchema = z.object({
235
+ days: z.string(),
236
+ hours: z.string(),
237
+ minutes: z.string(),
238
+ seconds: z.string(),
239
+ });
240
+ export const CountdownElementPropertiesSchema = z.object({
241
+ mode: CountdownModeSchema,
242
+ // Local wall-clock datetime string ("YYYY-MM-DDTHH:mm"), used when
243
+ // mode === "fixed_date" -- matches a native <input type="datetime-local">
244
+ // value directly, and JS's Date constructor already parses a timezone-less
245
+ // date-time string as local time, so no timezone field is needed.
246
+ targetDate: z.string(),
247
+ // Used when mode === "fixed_time"; restarted from this on every mount.
248
+ duration: CountdownDurationSchema,
249
+ showLabels: z.boolean(),
250
+ labels: CountdownLabelsSchema,
251
+ fontFamily: z.optional(z.string()),
252
+ fontSize: z.optional(z.number()),
253
+ color: z.optional(z.string()),
254
+ labelColor: z.optional(z.string()),
255
+ boxBackgroundColor: z.optional(z.string()),
256
+ });
package/schemas/form.js CHANGED
@@ -2,7 +2,12 @@ import * as z from "zod/mini";
2
2
  import { HexSchema } from "./helpers.js";
3
3
  import { StepSchema } from "./step.js";
4
4
  import { BasicFormDisplaySchema, MobilePopupFormDisplaySchema, PopupFormDisplaySchema, SliderFormDisplaySchema, } from "./formDisplay.js";
5
- export const FormTypeSchema = z.enum(["popup", "basic", "slider", "mobile_popup"]);
5
+ export const FormTypeSchema = z.enum([
6
+ "popup",
7
+ "basic",
8
+ "slider",
9
+ "mobile_popup",
10
+ ]);
6
11
  export const FormLanguageSchema = z.enum(["cs", "en"]);
7
12
  export const FormThemeSchema = z.object({
8
13
  corners: z.enum(["none", "sm", "md", "lg"]),
@@ -19,15 +24,35 @@ const formBase = {
19
24
  usedFonts: z.array(z.string()),
20
25
  };
21
26
  export const FormSchema = z.discriminatedUnion("type", [
22
- z.object({ ...formBase, type: z.literal("basic"), display: BasicFormDisplaySchema }),
23
- z.object({ ...formBase, type: z.literal("popup"), display: PopupFormDisplaySchema }),
24
- z.object({ ...formBase, type: z.literal("slider"), display: SliderFormDisplaySchema }),
25
- z.object({ ...formBase, type: z.literal("mobile_popup"), display: MobilePopupFormDisplaySchema }),
27
+ z.object({
28
+ ...formBase,
29
+ type: z.literal("basic"),
30
+ display: BasicFormDisplaySchema,
31
+ }),
32
+ z.object({
33
+ ...formBase,
34
+ type: z.literal("popup"),
35
+ display: PopupFormDisplaySchema,
36
+ }),
37
+ z.object({
38
+ ...formBase,
39
+ type: z.literal("slider"),
40
+ display: SliderFormDisplaySchema,
41
+ }),
42
+ z.object({
43
+ ...formBase,
44
+ type: z.literal("mobile_popup"),
45
+ display: MobilePopupFormDisplaySchema,
46
+ }),
26
47
  ]);
27
- // Renamed from FormCampaignSchema (ADR 0016) -- mirrors MountedForm in form-shared.
48
+ // Renamed from FormCampaignSchema (ADR 0016) -- mirrors MountedForm in
49
+ // form-shared. `campaignId` is optional (ADR 0025): sourced 1:1 from the
50
+ // winning CampaignItem's own optional reporting tag, present only when an
51
+ // integrator set one.
28
52
  export const MountedFormSchema = z.object({
29
53
  id: z.string(),
30
54
  name: z.string(),
31
55
  definition: FormSchema,
32
56
  variantId: z.optional(z.string()),
57
+ campaignId: z.optional(z.string()),
33
58
  });
@@ -7,6 +7,7 @@ export const FormBorderSchema = z.object({
7
7
  style: BorderStyleSchema,
8
8
  sides: z.optional(z.enum(["all", "top", "right", "bottom", "left"])),
9
9
  useTheme: z.optional(z.boolean()),
10
+ radius: z.optional(z.number()),
10
11
  });
11
12
  export const FormShadowSchema = z.object({
12
13
  offsetX: z.number(),
package/schemas/step.js CHANGED
@@ -6,10 +6,14 @@ export const StepDisplayPropertiesSchema = z.object({
6
6
  backgroundColor: z.optional(HexSchema),
7
7
  minHeight: z.optional(z.number()),
8
8
  });
9
+ // ADR 0026: marks the Step shown once a Submit succeeds. Omitted/undefined
10
+ // means an ordinary content step -- there is no explicit "content" literal.
11
+ export const StepKindSchema = z.literal("thankYou");
9
12
  export const StepSchema = z.object({
10
13
  type: z.literal("step"),
11
14
  name: z.string(),
12
15
  elements: z.array(FormElementSchema),
13
16
  id: z.string(),
14
17
  displayProperties: StepDisplayPropertiesSchema,
18
+ kind: z.optional(StepKindSchema),
15
19
  });
package/types/index.d.ts CHANGED
@@ -11,8 +11,11 @@ export * from "./schemas/campaign.js";
11
11
  */
12
12
  export declare function getFormJsonSchema(): Record<string, unknown>;
13
13
  /**
14
- * Serializes the Campaign contract as standard JSON Schema — the artifact
15
- * exported to ecomailapp so its PHP publish job can validate against the
16
- * real schema instead of drifting silently (ticket 115/118, ADR 0017).
14
+ * Serializes the CampaignItem contract as standard JSON Schema — the
15
+ * artifact exported to ecomailapp so its PHP publish job can validate each
16
+ * item it builds against the real schema instead of drifting silently
17
+ * (ticket 115/118, ADR 0017). Renamed from `getCampaignJsonSchema` by ADR
18
+ * 0025: there is no `Campaign` wrapper left to serialize, `CampaignItem` is
19
+ * now the atomic authored/published unit.
17
20
  */
18
- export declare function getCampaignJsonSchema(): Record<string, unknown>;
21
+ export declare function getCampaignItemJsonSchema(): Record<string, unknown>;