schemas.ts475 lines · main
1import { z } from 'zod'
2
3// ----- Shared primitives -----
4
5export const imageSchema = z.object({
6 src: z.string().min(1),
7 alt: z.string().min(1),
8 width: z.number().optional(),
9 height: z.number().optional(),
10})
11
12export const ctaSchema = z.object({
13 label: z.string().min(1),
14 href: z.string().min(1),
15 variant: z.enum(['primary', 'secondary']).optional().default('primary'),
16})
17
18// ----- Section schemas -----
19
20export const videoSchema = z.object({
21 src: z.string().min(1),
22 poster: z.string().optional(),
23})
24
25export const heroSectionSchema = z.object({
26 title: z.string().min(1),
27 subtitle: z.string().optional(),
28 description: z.string().optional(),
29 image: imageSchema.optional(),
30 video: videoSchema.optional(),
31 youtubeUrl: z.string().url().optional(),
32 ctas: z.array(ctaSchema).optional(),
33})
34
35export const contentBlockSchema = z.object({
36 heading: z.string().min(1),
37 body: z.string().min(1),
38 image: imageSchema.optional(),
39 icon: z.string().optional(),
40})
41
42export const contentBlocksSectionSchema = z.object({
43 heading: z.string().optional(),
44 blocks: z.array(contentBlockSchema).min(1),
45 columns: z.enum(['2', '3']).optional().default('3'),
46})
47
48export const socialProofSectionSchema = z.object({
49 heading: z.string().optional(),
50 logos: z.array(imageSchema).optional(),
51 testimonial: z
52 .object({
53 quote: z.string().min(1),
54 author: z.string().min(1),
55 role: z.string().optional(),
56 avatar: imageSchema.optional(),
57 })
58 .optional(),
59 stats: z
60 .array(
61 z.object({
62 value: z.string().min(1),
63 label: z.string().min(1),
64 })
65 )
66 .optional(),
67})
68
69export const textBodySectionSchema = z.object({
70 content: z.string().min(1),
71})
72
73const sectionBase = {
74 id: z.string().optional(),
75 className: z.string().optional(),
76}
77
78export const singleColumnSectionSchema = z.object({
79 ...sectionBase,
80 type: z.literal('single-column'),
81 title: z.string().min(1),
82 description: z.string().optional(),
83 children: z.any().optional(),
84})
85
86export const twoColumnSectionSchema = z.object({
87 ...sectionBase,
88 type: z.literal('two-column'),
89 title: z.string().optional(),
90 description: z.string().optional(),
91 children: z.any().optional(),
92})
93
94export const threeColumnSectionSchema = z.object({
95 ...sectionBase,
96 type: z.literal('three-column'),
97 title: z.string().optional(),
98 description: z.string().optional(),
99 children: z.any().optional(),
100})
101
102// ----- Form field schemas -----
103
104/**
105 * Conditional visibility rule: a field is visible only when the referenced
106 * field's current value satisfies all of the supplied criteria.
107 *
108 * - `equals` / `notEquals` — strict string compare against the live value.
109 * - `in` / `notIn` — membership check against a list of values.
110 * - `truthy` — when `true`, requires a non-empty value; when `false`, requires empty.
111 *
112 * Multiple criteria within the same `showWhen` are combined with AND. Hidden
113 * fields are excluded from the submitted payload.
114 */
115export const showWhenSchema = z
116 .object({
117 field: z.string().min(1),
118 equals: z.string().optional(),
119 notEquals: z.string().optional(),
120 in: z.array(z.string()).optional(),
121 notIn: z.array(z.string()).optional(),
122 truthy: z.boolean().optional(),
123 })
124 .refine(
125 (v) =>
126 v.equals !== undefined ||
127 v.notEquals !== undefined ||
128 v.in !== undefined ||
129 v.notIn !== undefined ||
130 v.truthy !== undefined,
131 { message: 'showWhen must include at least one of: equals, notEquals, in, notIn, truthy' }
132 )
133
134const formFieldBase = z.object({
135 name: z.string().min(1),
136 label: z.string().min(1),
137 /** Helper text rendered beneath the input. */
138 description: z.string().optional(),
139 placeholder: z.string().optional(),
140 required: z.boolean().optional().default(false),
141 half: z.boolean().optional().default(false),
142 showWhen: showWhenSchema.optional(),
143})
144
145export const textFieldSchema = formFieldBase.extend({
146 type: z.literal('text'),
147})
148
149export const urlFieldSchema = formFieldBase.extend({
150 type: z.literal('url'),
151})
152
153export const emailFieldSchema = formFieldBase.extend({
154 type: z.literal('email'),
155})
156
157export const textareaFieldSchema = formFieldBase.extend({
158 type: z.literal('textarea'),
159 rows: z.number().optional().default(4),
160})
161
162export const selectFieldSchema = formFieldBase.extend({
163 type: z.literal('select'),
164 options: z.array(z.object({ label: z.string(), value: z.string() })).min(1),
165})
166
167export const checkboxFieldSchema = formFieldBase.extend({
168 type: z.literal('checkbox'),
169})
170
171export const formFieldSchema = z.discriminatedUnion('type', [
172 textFieldSchema,
173 urlFieldSchema,
174 emailFieldSchema,
175 textareaFieldSchema,
176 selectFieldSchema,
177 checkboxFieldSchema,
178])
179
180// ----- Form CRM config schemas -----
181
182/**
183 * Matches a UUID or a bare 32-char hex ID (Notion's database IDs often come
184 * without dashes). Used for values interpolated into outbound API URLs — the
185 * `crm` config travels over the server-action wire and must be treated as
186 * untrusted input.
187 */
188const uuidLikeIdSchema = z.union([
189 z.string().uuid(),
190 z.string().regex(/^[0-9a-f]{32}$/i, 'Must be a 32-character hex ID'),
191])
192
193export const hubspotFormConfigSchema = z.object({
194 /**
195 * HubSpot form GUID. The portal ID is read from HUBSPOT_PORTAL_ID env var.
196 */
197 formGuid: uuidLikeIdSchema,
198 /**
199 * Map each form field `name` to a HubSpot field name.
200 * If omitted, the form field name is used as-is.
201 * Example: { workEmail: 'email', companyName: 'company' }
202 */
203 fieldMap: z.record(z.string(), z.string()).optional(),
204 /** Legal consent text for GDPR. */
205 consent: z.string().optional(),
206})
207
208export const customerioFormConfigSchema = z.object({
209 /**
210 * Event name sent to Customer.io on submit.
211 * Credentials are read from CUSTOMERIO_SITE_ID and CUSTOMERIO_API_KEY env vars.
212 */
213 event: z.string().min(1),
214 /**
215 * Map each form field `name` to a Customer.io profile attribute.
216 * Fields listed here are added to the contact profile via `identify`.
217 * Example: { workEmail: 'email', firstName: 'first_name' }
218 */
219 profileMap: z.record(z.string(), z.string()).optional(),
220 /**
221 * Static properties merged into the Customer.io track() event payload.
222 * Use this for fixed values that aren't form fields (e.g. event_name, source).
223 * Example: { event_name: 'Stripe Sessions 2026 Exec Dinner' }
224 */
225 staticProperties: z.record(z.string(), z.unknown()).optional(),
226})
227
228export const notionFormConfigSchema = z.object({
229 /**
230 * Notion database ID to create a page in. The integration token is read
231 * from the NOTION_FORMS_API_KEY env var and must have write access to the database.
232 */
233 database_id: uuidLikeIdSchema,
234 /**
235 * Map each form field `name` to a Notion database column name.
236 * Only fields listed here are sent. Property types (title, rich_text,
237 * email, etc.) are auto-detected from the database schema.
238 * Example: { email_address: 'email', first_name: 'first_name' }
239 */
240 columnMap: z.record(z.string(), z.string()).optional(),
241 /**
242 * Static properties merged into the Notion page. Keys must match column
243 * names in the target database.
244 * Example: { source: 'Website Go Page' }
245 */
246 staticProperties: z.record(z.string(), z.unknown()).optional(),
247})
248
249export const formCrmConfigSchema = z
250 .object({
251 hubspot: hubspotFormConfigSchema.optional(),
252 customerio: customerioFormConfigSchema.optional(),
253 notion: notionFormConfigSchema.optional(),
254 })
255 .refine((v) => v.hubspot || v.customerio || v.notion, {
256 message: 'At least one CRM provider (hubspot, customerio, or notion) must be configured',
257 })
258
259export const formSectionSchema = z.object({
260 ...sectionBase,
261 type: z.literal('form'),
262 title: z.string().optional(),
263 description: z.string().optional(),
264 fields: z.array(formFieldSchema).min(1),
265 submitLabel: z.string().min(1),
266 disclaimer: z.string().optional(),
267 /** Message shown after a successful submission. Defaults to a generic thank-you message. */
268 successMessage: z.string().optional(),
269 /** URL to redirect the user to after a successful submission. When set, overrides successMessage. */
270 successRedirect: z.string().optional(),
271 /** CRM integration config. When provided, form submissions are sent to the configured providers. */
272 crm: formCrmConfigSchema.optional(),
273})
274
275export const featureGridItemSchema = z.object({
276 icon: z.string().optional(),
277 title: z.string().min(1),
278 description: z.string().min(1),
279})
280
281export const featureGridSectionSchema = z.object({
282 ...sectionBase,
283 type: z.literal('feature-grid'),
284 title: z.string().optional(),
285 description: z.string().optional(),
286 columns: z
287 .union([z.literal(1), z.literal(2), z.literal(3)])
288 .optional()
289 .default(3),
290 items: z.array(featureGridItemSchema).min(1).max(6),
291})
292
293export const metricItemSchema = z.object({
294 label: z.string().min(1),
295 value: z.string().min(1),
296})
297
298export const metricsSectionSchema = z.object({
299 ...sectionBase,
300 type: z.literal('metrics'),
301 items: z.array(metricItemSchema).min(1).max(5),
302})
303
304export const tweetsSectionSchema = z.object({
305 ...sectionBase,
306 type: z.literal('tweets'),
307 title: z.string().optional(),
308 description: z.string().optional(),
309 ctas: z.array(ctaSchema).optional(),
310})
311
312export const faqItemSchema = z.object({
313 question: z.string().min(1),
314 answer: z.string().min(1),
315})
316
317export const faqSectionSchema = z.object({
318 ...sectionBase,
319 type: z.literal('faq'),
320 title: z.string().optional(),
321 description: z.string().optional(),
322 items: z.array(faqItemSchema).min(1),
323})
324
325export const codeFileSchema = z.object({
326 filename: z.string().min(1),
327 code: z.string().min(1),
328 language: z.string().optional().default('sql'),
329})
330
331export const codeBlockSectionSchema = z.object({
332 ...sectionBase,
333 type: z.literal('code-block'),
334 title: z.string().optional(),
335 description: z.string().optional(),
336 /** Single-file mode */
337 code: z.string().optional(),
338 language: z.string().optional().default('sql'),
339 filename: z.string().optional(),
340 /** Multi-file mode — takes precedence over code/filename/language when set */
341 files: z.array(codeFileSchema).optional(),
342})
343
344export const stepItemSchema = z.object({
345 title: z.string().min(1),
346 description: z.string().optional(),
347 content: z.any().optional(),
348 icon: z.string().optional(),
349})
350
351export const stepsSectionSchema = z.object({
352 ...sectionBase,
353 type: z.literal('steps'),
354 title: z.string().optional(),
355 description: z.string().optional(),
356 items: z.array(stepItemSchema).min(1),
357})
358
359export const quoteSectionSchema = z.object({
360 ...sectionBase,
361 type: z.literal('quote'),
362 quote: z.string().min(1),
363 author: z.string().min(1),
364 role: z.string().optional(),
365 avatar: imageSchema.optional(),
366})
367
368export const hubspotMeetingSectionSchema = z.object({
369 ...sectionBase,
370 type: z.literal('hubspot-meeting'),
371 title: z.string().optional(),
372 description: z.string().optional(),
373 /** The HubSpot meeting link path, e.g. "your-name/meeting-type" */
374 meetingSlug: z.string().min(1),
375})
376
377// ----- Dynamic sections -----
378
379export const goSectionSchema = z.discriminatedUnion('type', [
380 singleColumnSectionSchema,
381 twoColumnSectionSchema,
382 threeColumnSectionSchema,
383 formSectionSchema,
384 featureGridSectionSchema,
385 metricsSectionSchema,
386 tweetsSectionSchema,
387 faqSectionSchema,
388 codeBlockSectionSchema,
389 stepsSectionSchema,
390 quoteSectionSchema,
391 hubspotMeetingSectionSchema,
392])
393
394// ----- Page-level schemas -----
395
396export const metadataSchema = z.object({
397 title: z.string().min(1),
398 description: z.string().min(1),
399 ogImage: z.string().optional(),
400 noIndex: z.boolean().optional().default(true),
401})
402
403// ----- Page schemas -----
404
405const goPageBaseSchema = z.object({
406 slug: z.string().min(1),
407 metadata: metadataSchema,
408 hero: heroSectionSchema,
409 sections: z.array(goSectionSchema).optional(),
410 publishedAt: z.string().optional(),
411})
412
413export const leadGenPageSchema = goPageBaseSchema.extend({
414 template: z.literal('lead-gen'),
415})
416
417export const thankYouPageSchema = goPageBaseSchema.extend({
418 template: z.literal('thank-you'),
419})
420
421export const legalPageSchema = goPageBaseSchema.extend({
422 template: z.literal('legal'),
423 effectiveDate: z.string().optional(),
424 body: z.string().min(1),
425})
426
427export const goPageSchema = z.discriminatedUnion('template', [
428 leadGenPageSchema,
429 thankYouPageSchema,
430 legalPageSchema,
431])
432
433// ----- Inferred types -----
434
435export type GoImage = z.infer<typeof imageSchema>
436export type GoVideo = z.infer<typeof videoSchema>
437export type GoCta = z.infer<typeof ctaSchema>
438export type GoHeroSection = z.infer<typeof heroSectionSchema>
439export type GoContentBlock = z.infer<typeof contentBlockSchema>
440export type GoContentBlocksSection = z.infer<typeof contentBlocksSectionSchema>
441export type GoSocialProofSection = z.infer<typeof socialProofSectionSchema>
442export type GoTextBodySection = z.infer<typeof textBodySectionSchema>
443export type GoSingleColumnSection = z.infer<typeof singleColumnSectionSchema>
444export type GoTwoColumnSection = z.infer<typeof twoColumnSectionSchema>
445export type GoThreeColumnSection = z.infer<typeof threeColumnSectionSchema>
446export type GoFormField = z.infer<typeof formFieldSchema>
447export type GoFormFieldShowWhen = z.infer<typeof showWhenSchema>
448export type GoFormSection = z.infer<typeof formSectionSchema>
449export type GoHubSpotFormConfig = z.infer<typeof hubspotFormConfigSchema>
450export type GoCustomerIOFormConfig = z.infer<typeof customerioFormConfigSchema>
451export type GoNotionFormConfig = z.infer<typeof notionFormConfigSchema>
452export type GoFormCrmConfig = z.infer<typeof formCrmConfigSchema>
453export type GoFeatureGridItem = z.infer<typeof featureGridItemSchema>
454export type GoFeatureGridSection = z.infer<typeof featureGridSectionSchema>
455export type GoMetricItem = z.infer<typeof metricItemSchema>
456export type GoMetricsSection = z.infer<typeof metricsSectionSchema>
457export type GoTweetsSection = z.infer<typeof tweetsSectionSchema>
458export type GoFaqItem = z.infer<typeof faqItemSchema>
459export type GoFaqSection = z.infer<typeof faqSectionSchema>
460export type GoCodeFile = z.infer<typeof codeFileSchema>
461export type GoCodeBlockSection = z.infer<typeof codeBlockSectionSchema>
462export type GoStepItem = z.infer<typeof stepItemSchema>
463export type GoStepsSection = z.infer<typeof stepsSectionSchema>
464export type GoQuoteSection = z.infer<typeof quoteSectionSchema>
465export type GoHubSpotMeetingSection = z.infer<typeof hubspotMeetingSectionSchema>
466export type GoSection = z.infer<typeof goSectionSchema>
467export type GoMetadata = z.infer<typeof metadataSchema>
468
469export type GoPage = z.infer<typeof goPageSchema>
470export type LeadGenPage = z.infer<typeof leadGenPageSchema>
471export type ThankYouPage = z.infer<typeof thankYouPageSchema>
472export type LegalPage = z.infer<typeof legalPageSchema>
473
474/** Input type for registry files — fields with defaults are optional */
475export type GoPageInput = z.input<typeof goPageSchema>