schemas.ts475 lines · main
| 1 | import { z } from 'zod' |
| 2 | |
| 3 | // ----- Shared primitives ----- |
| 4 | |
| 5 | export 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 | |
| 12 | export 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 | |
| 20 | export const videoSchema = z.object({ |
| 21 | src: z.string().min(1), |
| 22 | poster: z.string().optional(), |
| 23 | }) |
| 24 | |
| 25 | export 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 | |
| 35 | export 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 | |
| 42 | export 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 | |
| 48 | export 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 | |
| 69 | export const textBodySectionSchema = z.object({ |
| 70 | content: z.string().min(1), |
| 71 | }) |
| 72 | |
| 73 | const sectionBase = { |
| 74 | id: z.string().optional(), |
| 75 | className: z.string().optional(), |
| 76 | } |
| 77 | |
| 78 | export 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 | |
| 86 | export 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 | |
| 94 | export 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 | */ |
| 115 | export 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 | |
| 134 | const 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 | |
| 145 | export const textFieldSchema = formFieldBase.extend({ |
| 146 | type: z.literal('text'), |
| 147 | }) |
| 148 | |
| 149 | export const urlFieldSchema = formFieldBase.extend({ |
| 150 | type: z.literal('url'), |
| 151 | }) |
| 152 | |
| 153 | export const emailFieldSchema = formFieldBase.extend({ |
| 154 | type: z.literal('email'), |
| 155 | }) |
| 156 | |
| 157 | export const textareaFieldSchema = formFieldBase.extend({ |
| 158 | type: z.literal('textarea'), |
| 159 | rows: z.number().optional().default(4), |
| 160 | }) |
| 161 | |
| 162 | export 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 | |
| 167 | export const checkboxFieldSchema = formFieldBase.extend({ |
| 168 | type: z.literal('checkbox'), |
| 169 | }) |
| 170 | |
| 171 | export 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 | */ |
| 188 | const 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 | |
| 193 | export 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 | |
| 208 | export 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 | |
| 228 | export 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 | |
| 249 | export 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 | |
| 259 | export 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 | |
| 275 | export const featureGridItemSchema = z.object({ |
| 276 | icon: z.string().optional(), |
| 277 | title: z.string().min(1), |
| 278 | description: z.string().min(1), |
| 279 | }) |
| 280 | |
| 281 | export 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 | |
| 293 | export const metricItemSchema = z.object({ |
| 294 | label: z.string().min(1), |
| 295 | value: z.string().min(1), |
| 296 | }) |
| 297 | |
| 298 | export const metricsSectionSchema = z.object({ |
| 299 | ...sectionBase, |
| 300 | type: z.literal('metrics'), |
| 301 | items: z.array(metricItemSchema).min(1).max(5), |
| 302 | }) |
| 303 | |
| 304 | export 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 | |
| 312 | export const faqItemSchema = z.object({ |
| 313 | question: z.string().min(1), |
| 314 | answer: z.string().min(1), |
| 315 | }) |
| 316 | |
| 317 | export 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 | |
| 325 | export 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 | |
| 331 | export 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 | |
| 344 | export 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 | |
| 351 | export 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 | |
| 359 | export 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 | |
| 368 | export 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 | |
| 379 | export 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 | |
| 396 | export 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 | |
| 405 | const 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 | |
| 413 | export const leadGenPageSchema = goPageBaseSchema.extend({ |
| 414 | template: z.literal('lead-gen'), |
| 415 | }) |
| 416 | |
| 417 | export const thankYouPageSchema = goPageBaseSchema.extend({ |
| 418 | template: z.literal('thank-you'), |
| 419 | }) |
| 420 | |
| 421 | export const legalPageSchema = goPageBaseSchema.extend({ |
| 422 | template: z.literal('legal'), |
| 423 | effectiveDate: z.string().optional(), |
| 424 | body: z.string().min(1), |
| 425 | }) |
| 426 | |
| 427 | export const goPageSchema = z.discriminatedUnion('template', [ |
| 428 | leadGenPageSchema, |
| 429 | thankYouPageSchema, |
| 430 | legalPageSchema, |
| 431 | ]) |
| 432 | |
| 433 | // ----- Inferred types ----- |
| 434 | |
| 435 | export type GoImage = z.infer<typeof imageSchema> |
| 436 | export type GoVideo = z.infer<typeof videoSchema> |
| 437 | export type GoCta = z.infer<typeof ctaSchema> |
| 438 | export type GoHeroSection = z.infer<typeof heroSectionSchema> |
| 439 | export type GoContentBlock = z.infer<typeof contentBlockSchema> |
| 440 | export type GoContentBlocksSection = z.infer<typeof contentBlocksSectionSchema> |
| 441 | export type GoSocialProofSection = z.infer<typeof socialProofSectionSchema> |
| 442 | export type GoTextBodySection = z.infer<typeof textBodySectionSchema> |
| 443 | export type GoSingleColumnSection = z.infer<typeof singleColumnSectionSchema> |
| 444 | export type GoTwoColumnSection = z.infer<typeof twoColumnSectionSchema> |
| 445 | export type GoThreeColumnSection = z.infer<typeof threeColumnSectionSchema> |
| 446 | export type GoFormField = z.infer<typeof formFieldSchema> |
| 447 | export type GoFormFieldShowWhen = z.infer<typeof showWhenSchema> |
| 448 | export type GoFormSection = z.infer<typeof formSectionSchema> |
| 449 | export type GoHubSpotFormConfig = z.infer<typeof hubspotFormConfigSchema> |
| 450 | export type GoCustomerIOFormConfig = z.infer<typeof customerioFormConfigSchema> |
| 451 | export type GoNotionFormConfig = z.infer<typeof notionFormConfigSchema> |
| 452 | export type GoFormCrmConfig = z.infer<typeof formCrmConfigSchema> |
| 453 | export type GoFeatureGridItem = z.infer<typeof featureGridItemSchema> |
| 454 | export type GoFeatureGridSection = z.infer<typeof featureGridSectionSchema> |
| 455 | export type GoMetricItem = z.infer<typeof metricItemSchema> |
| 456 | export type GoMetricsSection = z.infer<typeof metricsSectionSchema> |
| 457 | export type GoTweetsSection = z.infer<typeof tweetsSectionSchema> |
| 458 | export type GoFaqItem = z.infer<typeof faqItemSchema> |
| 459 | export type GoFaqSection = z.infer<typeof faqSectionSchema> |
| 460 | export type GoCodeFile = z.infer<typeof codeFileSchema> |
| 461 | export type GoCodeBlockSection = z.infer<typeof codeBlockSectionSchema> |
| 462 | export type GoStepItem = z.infer<typeof stepItemSchema> |
| 463 | export type GoStepsSection = z.infer<typeof stepsSectionSchema> |
| 464 | export type GoQuoteSection = z.infer<typeof quoteSectionSchema> |
| 465 | export type GoHubSpotMeetingSection = z.infer<typeof hubspotMeetingSectionSchema> |
| 466 | export type GoSection = z.infer<typeof goSectionSchema> |
| 467 | export type GoMetadata = z.infer<typeof metadataSchema> |
| 468 | |
| 469 | export type GoPage = z.infer<typeof goPageSchema> |
| 470 | export type LeadGenPage = z.infer<typeof leadGenPageSchema> |
| 471 | export type ThankYouPage = z.infer<typeof thankYouPageSchema> |
| 472 | export type LegalPage = z.infer<typeof legalPageSchema> |
| 473 | |
| 474 | /** Input type for registry files — fields with defaults are optional */ |
| 475 | export type GoPageInput = z.input<typeof goPageSchema> |