New Campaign Pages: /go (#42920)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES/NO

## What kind of change does this PR introduce?

Bug fix, feature, docs update, ...

## What is the current behavior?

Please link any relevant issues here.

## What is the new behavior?

Feel free to include screenshots if it includes visual changes.

## Additional context

Add any other context or screenshots.
This commit is contained in:
Alan Daniel authored and GitHub committed 2026-02-23 15:47:03 +00:00
1 parent d23302d2f5
commit 32330e26c0
44 files changed
+2427 -14

No files matched your search

+36
View File
@@ -130,6 +130,42 @@ og_image: /images/events/2025-01-meetup/custom-og.png # Optional override
**Note**: The `og_image` field is optional. If not provided, OG images are generated automatically via the Edge Function.
## Go pages (`/go/*`)
`/go/` is a system for building standalone campaign landing pages (lead generation, legal, thank-you flows). The name is intentionally generic — these pages are typically linked from ads, emails, or partner campaigns and are not part of the main site navigation.
Pages are defined as TypeScript objects (not MDX files) and validated against Zod schemas at build time.
### Parts
| Location | Purpose |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `apps/www/_go/` | Page definitions. Each file exports a page object. `index.tsx` registers all pages. |
| `apps/www/app/go/[slug]/page.tsx` | App Router route — renders the page for a given slug, handles 404s and metadata. |
| `apps/www/components/Go/GoPageRenderer.tsx` | www-specific wrapper — adds the Supabase logo header and footer, registers custom section renderers. |
| `packages/marketing/src/go/` | Framework-agnostic core: schemas, section components, templates, form server action. |
| `packages/marketing/src/crm/` | CRM client abstraction (HubSpot + Customer.io) used by the form server action. |
### Page structure
Each page specifies a `template` which determines its top-level layout:
- **`lead-gen`** — hero + arbitrary sections (form, metrics, feature grid, tweets, social proof, etc.)
- **`thank-you`** — hero + sections + confetti animation
- **`legal`** — hero + table-of-contents sidebar + markdown body
Pages are arrays of typed section objects. The `SectionRenderer` in `packages/marketing` dispatches each section to the right component based on its `type` field.
### Custom renderers
The `marketing` package doesn't know about `topTweets` data or the Pages Router `basePath`, so the `tweets` section type has no default renderer. `GoPageRenderer.tsx` in www registers `TweetsSection` as a custom renderer for that type. This is the extension point for any section that requires www-specific dependencies.
### Adding a new page
1. Create a new file in `apps/www/_go/<category>/my-page.tsx` exporting a page object.
2. Register it in `apps/www/_go/index.tsx`.
3. The page will be available at `/go/<slug>` automatically via static generation.
#### Customer Stories (Case Studies)
Customer stories are defined in MDX files (`apps/www/_customers/*.mdx`) and use a different approach:
+9
View File
@@ -0,0 +1,9 @@
import type { GoPageInput } from 'marketing'
import exampleLeadGen from './lead-gen/example-lead-gen'
import exampleLegal from './legal/example-legal'
import exampleThankYou from './thank-you/example-thank-you'
const pages: GoPageInput[] = [exampleLeadGen, exampleThankYou, exampleLegal]
export default pages
+148
View File
@@ -0,0 +1,148 @@
import { MediaBlock } from 'marketing'
import type { GoPageInput } from 'marketing'
const page: GoPageInput = {
template: 'lead-gen',
slug: 'example-ebook',
metadata: {
title: 'Free Ebook: Building Modern Applications with Supabase',
description:
'Download our comprehensive guide to building scalable applications with Supabase. Learn best practices for authentication, database design, and real-time features.',
ogImage: '/images/landing-pages/example-ebook/og.png',
},
hero: {
title: 'Building Modern Applications with Supabase',
subtitle: 'Sample ebook landing page',
description:
'This is a sample lead generation page. The content below demonstrates the template layout. Replace it with your real ebook title, description, and cover image.',
image: {
src: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=announcement&layout=vertical&copy=Modern+applications&icon=supabase.svg',
alt: 'Ebook cover: Building Modern Applications with Supabase',
width: 400,
height: 500,
},
ctas: [
{
label: 'Download Free Ebook',
href: '#form',
variant: 'primary',
},
],
},
sections: [
{
type: 'single-column',
title: 'See Supabase in action',
description: 'Watch a quick overview of what you can build with Supabase.',
children: <MediaBlock youtubeUrl="https://www.youtube.com/watch?v=YR30uzwWoDM" />,
},
{
type: 'feature-grid',
title: 'Developers can build faster with Supabase',
description: 'Features that help developers move quickly and focus.',
items: [
{
title: 'AI Assistant',
description:
'A single panel that persists across the Supabase Dashboard and maintains context across AI prompts.',
},
{
title: 'MCP Server',
description:
'Connect your favorite AI tools such as Cursor or Claude directly with Supabase.',
},
{
title: 'Auto-generated APIs',
description:
"Learn SQL when you're ready. In the meantime, Supabase generates automatic APIs to make coding a lot easier.",
},
{
title: 'Foreign Data Wrappers',
description:
'Connect Supabase to Redshift, BigQuery, MySQL, and external APIs for seamless integrations.',
},
{
title: 'Instant and secure deployment',
description: 'No need to set up servers, manage DevOps, or tweak security settings.',
},
{
title: 'Observability',
description:
'Built-in logs, query performance tools, and security insights for easy debugging.',
},
],
},
{
type: 'metrics',
items: [
{ label: 'Databases created', value: '16,000,000+' },
{ label: 'Databases launched daily', value: '90,000+' },
{ label: 'GitHub stars', value: '80,000+' },
],
},
{
type: 'tweets',
title: 'Loved by developers',
description: 'Discover what our community has to say about their Supabase experience.',
ctas: [
{
label: 'Start your project',
href: 'https://supabase.com/dashboard',
variant: 'primary',
},
],
},
{
type: 'form',
title: 'Get in touch',
description: 'Fill out the form below and our team will get back to you shortly.',
fields: [
{
type: 'text',
name: 'firstName',
label: 'First Name',
placeholder: 'First Name',
required: true,
half: true,
},
{
type: 'text',
name: 'lastName',
label: 'Last Name',
placeholder: 'Last Name',
required: true,
half: true,
},
{
type: 'email',
name: 'email',
label: 'Company Email',
placeholder: 'Company Email',
required: true,
},
{
type: 'textarea',
name: 'interest',
label: 'What are you interested in?',
placeholder: 'Share more about what you want to accomplish',
},
],
submitLabel: 'Request a demo',
disclaimer:
'By submitting this form, I confirm that I have read and understood the [Privacy Policy](https://supabase.com/privacy).',
crm: {
hubspot: {
formGuid: 'b9abbf77-86ae-4fe7-9147-d15922bf58ca',
fieldMap: {
firstName: 'firstname',
lastName: 'lastname',
},
consent:
'By submitting this form, I confirm that I have read and understood the Privacy Policy.',
},
},
},
],
}
export default page
+149
View File
@@ -0,0 +1,149 @@
import type { GoPageInput } from 'marketing'
const page: GoPageInput = {
template: 'legal',
slug: 'example-legal',
metadata: {
title: 'Supabase Official Rules For Giveaways & Sweepstakes',
description:
'NO PURCHASE OR PAYMENT NECESSARY TO ENTER OR WIN. A PURCHASE OR PAYMENT WILL NOT INCREASE YOUR CHANCES OF WINNING. VOID WHERE PROHIBITED.',
},
hero: {
title: 'Supabase Official Rules For Giveaways & Sweepstakes',
},
effectiveDate: '2026-02-16',
body: `
## 1. Sponsor
Supabase, Inc. ("Sponsor").
## 2. Promotion Period
The Promotion begins at the time and date announced at the applicable Supabase event or digital campaign and ends at the time specified in the Promotion materials (the "Promotion Period"). Sponsor's system is the official time-keeping device.
## 3. Eligibility
The Promotion is open to legal residents of countries where such promotions are permitted by law, who are at least the age of majority in their jurisdiction of residence at the time of entry.
The following are not eligible to enter or win:
- Employees, officers, and directors of Sponsor and its affiliates
- Contractors, marketing agencies, and vendors involved in administering the Promotion
- Immediate family members and household members of the above
The Promotion is void in jurisdictions where sweepstakes are prohibited or materially restricted without filings, bonding, or government approvals, including but not limited to:
- Brazil
- Quebec (Canada)
- Italy
- Spain
- Russia
- Iran
- North Korea
- Syria
- Cuba
- Belarus
- Mainland China
- Crimea, Donetsk, and Luhansk regions of Ukraine
- Any jurisdiction subject to U.S., EU, or UN trade sanctions
- Any jurisdiction requiring registration, bonding, or regulatory approvals not obtained by Sponsor
Sponsor reserves the right to disqualify any entrant whose participation is unlawful or non-compliant under local law.
## 4. How to Enter
To enter, follow the instructions provided at the applicable Supabase event or promotional campaign, which may include:
- Scanning a badge at a Supabase booth
- Submitting an entry form
- Creating a Supabase account
- Completing a product action (e.g., launching a project, loading data)
Limit: One (1) entry per person unless otherwise specified.
Entries must be received during the Promotion Period.
Automated, robotic, or scripted entries are prohibited.
## 5. Prizes
Prizes will be described in the Promotion materials and may include consumer electronics, merchandise, credits, or other items of value. Approximate Retail Value ("ARV") will be disclosed for each Promotion. Sponsor reserves the right to substitute a prize of equal or greater value if the advertised prize becomes unavailable or cannot be legally shipped or awarded in a winner's jurisdiction.
## 6. Winner Selection & Notification
Winners will be selected in a random drawing or other announced method at the time specified in the Promotion materials. Winners will be notified via the contact information provided at entry. Failure to respond within seven (7) days of notification may result in forfeiture, and an alternate winner may be selected.
## 7. International Winners -- Taxes, Customs, and Duties
Winners are solely responsible for all taxes, customs duties, VAT, import fees, brokerage fees, and any other charges imposed by local authorities. Sponsor may require winners to complete tax forms or declarations as required by law. Sponsor will not be responsible for prizes delayed, seized, or rejected by customs or local authorities. If a prize cannot be legally delivered to a winner's country, Sponsor may substitute a prize of equal or greater value or award a cash or credit equivalent, at Sponsor's discretion.
## 8. Prize Substitution & Local Compliance
Sponsor reserves the right to:
- Substitute prizes based on local availability or legal restrictions
- Modify the prize format (e.g., cash, store credit, digital gift card) for international winners
- Disqualify entries from jurisdictions where the Promotion cannot be lawfully administered
## 9. Odds
Odds of winning depend on the number of eligible entries received.
## 10. Publicity
Except where prohibited by law, participation constitutes winner's consent to Sponsor's use of winner's name, likeness, city, country, and prize information for promotional purposes without additional compensation.
## 11. Privacy
Personal information collected in connection with the Promotion will be used in accordance with Sponsor's [Privacy Policy](https://supabase.com/privacy).
## 12. Limitation of Liability
Sponsor is not responsible for:
- Lost, late, incomplete, or misdirected entries
- Technical failures of any kind
- Unauthorized human intervention
- Errors in the administration of the Promotion
By entering, entrants release and hold harmless Sponsor from any claims arising out of participation or prize acceptance.
## 13. Disputes & Governing Law
All disputes shall be governed by the laws of the State of California, USA, without regard to conflict-of-law principles. Any dispute, claim, or controversy arising out of or relating to this Promotion shall be resolved by binding arbitration administered by the American Arbitration Association under its Commercial Arbitration Rules. All claims shall be resolved individually, without class actions. The arbitration shall be conducted in San Francisco, California. The arbitrator's decision shall be final and binding.
## 14. Sanctions & Trade Compliance
Entrants represent and warrant that they are not located in, under the control of, or a resident of any country or territory subject to U.S., EU, or UN sanctions and are not listed on any government sanctions or restricted party lists.
Sponsor reserves the right to disqualify any entrant or withhold any prize if awarding such prize would violate applicable trade or sanctions laws.
## 15. Data Transfers -- GDPR / UK GDPR
By entering, entrants consent to the transfer, processing, and storage of their personal data in the United States and other jurisdictions for purposes of administering the Promotion, selecting winners, awarding prizes, and related promotional activities.
## 16. Force Majeure / Regulatory Impossibility
Sponsor shall not be responsible for any failure or delay in performance due to events beyond its reasonable control, including but not limited to acts of God, war, terrorism, labor disputes, governmental orders, regulatory changes, public health emergencies, shipping disruptions, or technical failures.
Sponsor reserves the right to modify, suspend, or cancel the Promotion if regulatory changes or governmental restrictions make lawful administration impracticable.
## 17. General Conditions
Sponsor reserves the right to cancel, modify, or suspend the Promotion if fraud, technical failure, or any other factor impairs the integrity of the Promotion. Sponsor's decisions are final.
## 18. Winners List
For a copy of the winners list, send a request to:
[legal@supabase.com](mailto:legal@supabase.com)
within sixty (60) days of the end of the Promotion.
© 2026 Supabase Inc.
[Privacy Policy](https://supabase.com/privacy) · [Terms of Service](https://supabase.com/terms)
`,
}
export default page
@@ -0,0 +1,36 @@
import type { GoPageInput } from 'marketing'
import Link from 'next/link'
import { Button } from 'ui'
const page: GoPageInput = {
template: 'thank-you',
slug: 'example-thank-you',
metadata: {
title: 'Thank You',
description: 'Thanks for signing up.',
},
hero: {
title: "You're all set!",
description: 'Check your inbox for a confirmation email with your download link.',
},
sections: [
{
type: 'single-column',
title: 'While you wait',
description:
'Explore our documentation and tutorials to get started with Supabase right away.',
children: (
<div className="flex items-center justify-center gap-4">
<Button asChild type="default" size="small">
<Link href="https://supabase.com/docs">Read the docs</Link>
</Button>
<Button asChild type="text" size="small">
<Link href="https://supabase.com/docs/guides">Watch tutorials</Link>
</Button>
</div>
),
},
],
}
export default page
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import GoPageRenderer from '@/components/Go/GoPageRenderer'
import { SITE_ORIGIN } from '@/lib/constants'
import { getAllGoSlugs, getGoPageBySlug } from '@/lib/go'
export const revalidate = 30
type Params = { slug: string }
export async function generateStaticParams() {
return getAllGoSlugs().map((slug) => ({ slug }))
}
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { slug } = await params
const page = getGoPageBySlug(slug)
if (!page) {
return { title: 'Page Not Found' }
}
const { metadata } = page
return {
title: `${metadata.title} | Supabase`,
description: metadata.description,
openGraph: {
title: metadata.title,
description: metadata.description,
url: `${SITE_ORIGIN}/go/${page.slug}`,
images: metadata.ogImage ? [{ url: metadata.ogImage }] : undefined,
},
twitter: {
card: 'summary_large_image',
title: metadata.title,
description: metadata.description,
images: metadata.ogImage ? [metadata.ogImage] : undefined,
},
robots: metadata.noIndex ? { index: false, follow: false } : undefined,
}
}
export default async function GoPage({ params }: { params: Promise<Params> }) {
const { slug } = await params
const page = getGoPageBySlug(slug)
if (!page) {
notFound()
}
return <GoPageRenderer page={page} />
}
+45
View File
@@ -0,0 +1,45 @@
import supabaseLogoIcon from 'common/assets/images/supabase-logo-icon.png'
import { GoPageRenderer as MarketingPageRenderer } from 'marketing'
import type { CustomSectionRenderers } from 'marketing'
import Image from 'next/image'
import Link from 'next/link'
import TweetsSection from './TweetsSection'
import type { GoPage } from '@/types/go'
const customRenderers: CustomSectionRenderers = {
tweets: TweetsSection,
}
// eslint-disable-next-line
export default function GoPageRenderer({ page }: { page: GoPage }) {
return (
<>
<nav className="absolute top-0 left-0 right-0 z-10">
<div className="max-w-[80rem] mx-auto flex items-center h-14 px-8">
<Link href="/">
<Image src={supabaseLogoIcon} width={24} height={24} alt="Supabase" priority />
<span className="sr-only">Supabase</span>
</Link>
</div>
</nav>
<main className="relative min-h-screen pb-16 sm:pb-24">
<MarketingPageRenderer page={page} customRenderers={customRenderers} />
</main>
<footer className="border-t border-muted">
<div className="max-w-[80rem] mx-auto flex flex-col sm:flex-row items-center justify-between gap-2 py-4 sm:h-14 px-8 text-sm text-foreground-lighter">
<span>&copy; {new Date().getFullYear()} Supabase Inc.</span>
<div className="flex gap-6">
<Link href="/privacy" className="hover:text-foreground transition-colors">
Privacy Policy
</Link>
<Link href="/terms" className="hover:text-foreground transition-colors">
Terms of Service
</Link>
</div>
</div>
</footer>
</>
)
}
+111
View File
@@ -0,0 +1,111 @@
'use client'
import { useBreakpoint } from 'common'
import { range } from 'lib/helpers'
import type { GoTweetsSection } from 'marketing'
import Link from 'next/link'
import { topTweets } from 'shared-data/tweets'
import { Button, cn } from 'ui'
import { TweetCard } from 'ui-patterns/TweetCard'
function MobileCarousel() {
return (
<div
className="tweets-carousel flex gap-3 overflow-x-auto snap-x snap-mandatory px-6 pb-4"
style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}
>
<style>{`.tweets-carousel::-webkit-scrollbar { display: none; }`}</style>
{topTweets.slice(0, 9).map((tweet: any) => (
<Link
key={tweet.text}
href={tweet.url}
target="_blank"
rel="noopener noreferrer"
className="snap-start shrink-0 w-[85vw] max-w-[320px] sm:w-[55vw] group/tweet-card"
>
<TweetCard handle={`@${tweet.handle}`} quote={tweet.text} img_url={tweet.img_url} />
</Link>
))}
</div>
)
}
function DesktopMarquee() {
const isMd = useBreakpoint(1024)
const visibleTweets = topTweets.slice(0, isMd ? 12 : 18)
return (
<div className="relative w-full max-w-[1400px] mx-auto overflow-hidden">
<div className="group relative transition-all max-h-[500px] w-auto flex flex-nowrap">
{range(0, 3).map((_, idx) => (
<div
key={`tweets-range-${idx}`}
className={cn(
'columns-2 lg:columns-3 xl:columns-5',
'gap-4 h-fit pr-4',
'min-w-[900px] max-w-[900px]',
'xl:min-w-[1600px] xl:max-w-[1600px]',
'animate-[marquee_40000ms_linear_both_infinite] group-hover:pause',
'motion-reduce:animate-none motion-reduce:will-change-none',
'will-change-transform transition-transform'
)}
>
{visibleTweets.map((tweet: any, i: number) => (
<Link
key={tweet.text}
href={tweet.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'min-w-[200px]',
'mb-4 z-0 break-inside-avoid-column block group/tweet-card',
i > 12 && 'hidden lg:block'
)}
>
<TweetCard handle={`@${tweet.handle}`} quote={tweet.text} img_url={tweet.img_url} />
</Link>
))}
</div>
))}
</div>
<div className="absolute pointer-events-none w-full h-full inset-0 top-auto lg:bg-[radial-gradient(50%_100%_at_50%_0,transparent_0%,transparent_50%,hsl(var(--background-default))_100%)]" />
</div>
)
}
export default function TweetsSection({ section }: { section: GoTweetsSection }) {
return (
<div>
{(section.title || section.description || section.ctas) && (
<div className="text-center flex flex-col items-center gap-4 mb-8 px-8">
{section.title && (
<h2 className="text-2xl sm:text-3xl text-foreground">{section.title}</h2>
)}
{section.description && (
<p className="text-foreground-lighter max-w-lg text-pretty">{section.description}</p>
)}
{section.ctas && section.ctas.length > 0 && (
<div className="flex gap-2 mt-2">
{section.ctas.map((cta, i) => (
<Button
key={i}
asChild
type={cta.variant === 'secondary' ? 'default' : 'primary'}
size="small"
>
<Link href={cta.href}>{cta.label}</Link>
</Button>
))}
</div>
)}
</div>
)}
<div className="lg:hidden">
<MobileCarousel />
</div>
<div className="hidden lg:block">
<DesktopMarquee />
</div>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import rawPages from '@/_go'
import { goPageSchema, type GoPage } from '@/types/go'
export function getAllGoPages(): GoPage[] {
const pages: GoPage[] = []
const seenSlugs = new Set<string>()
for (const raw of rawPages) {
const result = goPageSchema.safeParse(raw)
if (!result.success) {
throw new Error(
`Invalid go page definition (slug: "${(raw as any).slug ?? 'unknown'}"):\n${result.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n')}`
)
}
if (seenSlugs.has(result.data.slug)) {
throw new Error(`Duplicate slug "${result.data.slug}" in _go registry`)
}
seenSlugs.add(result.data.slug)
pages.push(result.data)
}
return pages
}
export function getAllGoSlugs(): string[] {
return getAllGoPages().map((p) => p.slug)
}
export function getGoPageBySlug(slug: string): GoPage | undefined {
return getAllGoPages().find((p) => p.slug === slug)
}
+1
View File
@@ -49,6 +49,7 @@ const nextConfig = {
'shared-data',
'icons',
'api-types',
'marketing',
// needed to make the octokit packages work in /changelog
'@octokit/plugin-paginate-graphql',
],
+1
View File
@@ -54,6 +54,7 @@
"gsap": "^3.13.0",
"icons": "workspace:*",
"lucide-react": "*",
"marketing": "workspace:*",
"markdown-toc": "^1.2.0",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-gfm": "^2.0.2",
+9 -14
View File
@@ -1,22 +1,17 @@
import { useRouter } from 'next/router'
import { forwardRef, useEffect, useRef, useState } from 'react'
import { NextSeo } from 'next-seo'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { Button, cn } from 'ui'
import { useFlag } from 'common'
import DefaultLayout from '~/components/Layouts/Default'
import { StateOfStartupsHeader } from '~/components/SurveyResults/StateOfStartupsHeader'
import { SurveyChapter } from '~/components/SurveyResults/SurveyChapter'
import { SurveyChapterSection } from '~/components/SurveyResults/SurveyChapterSection'
import { SurveySectionBreak } from '~/components/SurveyResults/SurveySectionBreak'
import { StateOfStartupsHeader } from '~/components/SurveyResults/StateOfStartupsHeader'
import { useSendTelemetryEvent } from '~/lib/telemetry'
import pageData from '~/data/surveys/state-of-startups-2025'
import { useSendTelemetryEvent } from '~/lib/telemetry'
import { useFlag } from 'common'
import { motion } from 'framer-motion'
import { NextSeo } from 'next-seo'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { forwardRef, useEffect, useRef, useState } from 'react'
import { Button, cn } from 'ui'
function StateOfStartupsPage() {
const router = useRouter()
+1
View File
@@ -11,6 +11,7 @@ module.exports = config({
'./app/**/*.{tsx,ts,js}',
'./../../packages/ui/src/**/*.{tsx,ts,js}',
'./../../packages/ui-patterns/src/**/*.{tsx,ts,js}',
'./../../packages/marketing/src/**/*.{tsx,ts,js}',
],
theme: {
extend: {
+3
View File
@@ -0,0 +1,3 @@
export type { GoPage, GoPageInput, LeadGenPage, ThankYouPage, LegalPage } from 'marketing'
export { goPageSchema } from 'marketing'
+1
View File
@@ -0,0 +1 @@
export * from './src/go'
+28
View File
@@ -0,0 +1,28 @@
{
"name": "marketing",
"version": "0.0.0",
"description": "Reusable marketing sections and schemas for campaign landing pages",
"main": "./index.ts",
"types": "./index.ts",
"exports": {
".": "./index.ts",
"./crm": "./src/crm/index.ts"
},
"scripts": {
"preinstall": "npx only-allow pnpm",
"clean": "rimraf node_modules"
},
"devDependencies": {
"tsconfig": "workspace:",
"typescript": "catalog:"
},
"dependencies": {
"ui": "workspace:*",
"zod": "catalog:",
"react": "catalog:",
"react-markdown": "^8.0.3",
"server-only": "^0.0.1",
"@types/react": "catalog:"
},
"license": "MIT"
}
+57
View File
@@ -0,0 +1,57 @@
import 'server-only'
export interface CustomerIOConfig {
siteId: string
apiKey: string
}
export class CustomerIOClient {
private baseUrl = 'https://track.customer.io/api/v1'
private auth: string
constructor(config: CustomerIOConfig) {
if (!config.siteId) throw new Error('CustomerIOClient: siteId is required')
if (!config.apiKey) throw new Error('CustomerIOClient: apiKey is required')
this.auth = btoa(`${config.siteId}:${config.apiKey}`)
}
private async makeRequest(
endpoint: string,
method: 'GET' | 'POST' | 'PUT',
body?: unknown
): Promise<void> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method,
headers: {
Authorization: `Basic ${this.auth}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Customer.io API request failed: ${response.status} - ${errorText}`)
}
}
async identify(email: string, attributes: Record<string, unknown> = {}): Promise<void> {
await this.makeRequest(`/customers/${encodeURIComponent(email)}`, 'PUT', {
email,
...attributes,
})
}
async track(
email: string,
event: string,
properties: Record<string, unknown> = {}
): Promise<void> {
await this.makeRequest(`/customers/${encodeURIComponent(email)}/events`, 'POST', {
name: event,
data: properties,
timestamp: Math.floor(Date.now() / 1000),
})
}
}
+88
View File
@@ -0,0 +1,88 @@
import 'server-only'
interface HubSpotField {
objectTypeId: string
name: string
value: string
}
interface HubSpotSubmission {
fields: HubSpotField[]
context?: {
pageUri?: string
pageName?: string
}
legalConsentOptions?: {
consent: {
consentToProcess: boolean
text: string
}
}
}
export interface HubSpotConfig {
portalId: string
formGuid: string
}
export class HubSpotClient {
private portalId: string
private formGuid: string
constructor(config: HubSpotConfig) {
if (!config.portalId) throw new Error('HubSpotClient: portalId is required')
if (!config.formGuid) throw new Error('HubSpotClient: formGuid is required')
this.portalId = config.portalId
this.formGuid = config.formGuid
}
async submitForm(
fields: Record<string, string>,
options?: {
pageUri?: string
pageName?: string
consent?: string
}
): Promise<void> {
const hubspotFields: HubSpotField[] = Object.entries(fields).map(([name, value]) => ({
objectTypeId: '0-1',
name,
value,
}))
const body: HubSpotSubmission = {
fields: hubspotFields,
}
if (options?.pageUri || options?.pageName) {
body.context = {
pageUri: options.pageUri,
pageName: options.pageName,
}
}
if (options?.consent) {
body.legalConsentOptions = {
consent: {
consentToProcess: true,
text: options.consent,
},
}
}
const response = await fetch(
`https://api.hsforms.com/submissions/v3/integration/submit/${this.portalId}/${this.formGuid}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`HubSpot form submission failed: ${response.status} - ${errorText}`)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import 'server-only'
import { CustomerIOClient, type CustomerIOConfig } from './customerio'
import { HubSpotClient, type HubSpotConfig } from './hubspot'
export type { CustomerIOConfig } from './customerio'
export type { HubSpotConfig } from './hubspot'
export { CustomerIOClient } from './customerio'
export { HubSpotClient } from './hubspot'
// --- Provider config types ---
interface HubSpotProviderConfig {
hubspot: HubSpotConfig
customerio?: never
}
interface CustomerIOProviderConfig {
hubspot?: never
customerio: CustomerIOConfig
}
interface BothProvidersConfig {
hubspot: HubSpotConfig
customerio: CustomerIOConfig
}
export type CRMConfig = HubSpotProviderConfig | CustomerIOProviderConfig | BothProvidersConfig
// --- Event option types per provider ---
interface HubSpotEventFields {
/** Fields submitted to HubSpot form (key = HubSpot field name, value = field value) */
hubspotFields: Record<string, string>
/** HubSpot form context */
context?: { pageUri?: string; pageName?: string }
/** Legal consent text for HubSpot */
consent?: string
}
interface CustomerIOEventFields {
/** Event name for Customer.io tracking */
event: string
/** Properties attached to the Customer.io event */
properties?: Record<string, unknown>
/** Profile attributes to set on the Customer.io contact */
customerioProfile?: Record<string, unknown>
}
type BaseEventOptions = {
/** Email used as identifier */
email: string
}
type SubmitEventFor<T extends CRMConfig> = BaseEventOptions &
(T extends { hubspot: HubSpotConfig } ? HubSpotEventFields : Partial<HubSpotEventFields>) &
(T extends { customerio: CustomerIOConfig }
? CustomerIOEventFields
: Partial<CustomerIOEventFields>)
export class CRMClient<T extends CRMConfig = CRMConfig> {
private hubspot: HubSpotClient | null
private customerio: CustomerIOClient | null
constructor(config: T) {
this.hubspot = config.hubspot ? new HubSpotClient(config.hubspot) : null
this.customerio = config.customerio ? new CustomerIOClient(config.customerio) : null
}
/**
* Submit an event to configured providers in parallel.
* Required fields are determined by which providers were configured.
*/
async submitEvent(options: SubmitEventFor<T>): Promise<{ errors: Error[] }> {
const promises: Promise<void>[] = []
const errors: Error[] = []
if (this.hubspot && options.hubspotFields) {
const fields = { ...options.hubspotFields }
if (!fields.email) fields.email = options.email
promises.push(
this.hubspot
.submitForm(fields, {
pageUri: options.context?.pageUri,
pageName: options.context?.pageName,
consent: options.consent,
})
.catch((err) => {
errors.push(new Error(`HubSpot: ${err.message}`))
})
)
}
if (this.customerio && options.event) {
promises.push(
(async () => {
try {
if (options.customerioProfile) {
await this.customerio!.identify(options.email, options.customerioProfile)
}
await this.customerio!.track(options.email, options.event!, {
...options.properties,
email: options.email,
submitted_at: new Date().toISOString(),
})
} catch (err: any) {
errors.push(new Error(`Customer.io: ${err.message}`))
}
})()
)
}
await Promise.all(promises)
return { errors }
}
}
@@ -0,0 +1,152 @@
'use server'
import { CRMClient, type CRMConfig } from '../../crm'
import type { GoFormCrmConfig } from '../schemas'
export interface FormSubmitResult {
success: boolean
errors: string[]
}
// Enable debug logging in local dev and on Vercel preview/development deployments
const isDebug =
process.env.NODE_ENV === 'development' ||
process.env.VERCEL_ENV === 'preview' ||
process.env.VERCEL_ENV === 'development'
function debug(message: string, data?: unknown) {
if (!isDebug) return
if (data !== undefined) {
console.log(`[go/form] ${message}`, JSON.stringify(data, null, 2))
} else {
console.log(`[go/form] ${message}`)
}
}
function buildCrmConfig(crm: GoFormCrmConfig): CRMConfig {
const hubspotPortalId = process.env.HUBSPOT_PORTAL_ID
const customerioSiteId = process.env.CUSTOMERIO_SITE_ID
const customerioApiKey = process.env.CUSTOMERIO_API_KEY
if (crm.hubspot && crm.customerio) {
if (!hubspotPortalId) throw new Error('HUBSPOT_PORTAL_ID env var is not set')
if (!customerioSiteId) throw new Error('CUSTOMERIO_SITE_ID env var is not set')
if (!customerioApiKey) throw new Error('CUSTOMERIO_API_KEY env var is not set')
return {
hubspot: { portalId: hubspotPortalId, formGuid: crm.hubspot.formGuid },
customerio: { siteId: customerioSiteId, apiKey: customerioApiKey },
}
}
if (crm.hubspot) {
if (!hubspotPortalId) throw new Error('HUBSPOT_PORTAL_ID env var is not set')
return { hubspot: { portalId: hubspotPortalId, formGuid: crm.hubspot.formGuid } }
}
// customerio only (guaranteed by schema refinement that at least one exists)
if (!customerioSiteId) throw new Error('CUSTOMERIO_SITE_ID env var is not set')
if (!customerioApiKey) throw new Error('CUSTOMERIO_API_KEY env var is not set')
return { customerio: { siteId: customerioSiteId, apiKey: customerioApiKey } }
}
/**
* Submit form values to the configured CRM providers (HubSpot and/or Customer.io).
*
* Credentials are read from environment variables:
* - HubSpot: HUBSPOT_PORTAL_ID
* - Customer.io: CUSTOMERIO_SITE_ID, CUSTOMERIO_API_KEY
*
* Per-form config (formGuid, event name, field mappings) lives in the page definition.
*/
export async function submitFormAction(
crm: GoFormCrmConfig,
values: Record<string, string>,
context?: { pageUri?: string; pageName?: string }
): Promise<FormSubmitResult> {
debug('Form submission received', { crm, values, context })
try {
// Detect the email value from common field names
const email =
values['email'] ?? values['workEmail'] ?? values['work_email'] ?? values['emailAddress'] ?? ''
if (!email) {
debug('Submission rejected: no email field found in values')
return { success: false, errors: ['An email field is required for form submission.'] }
}
let client: CRMClient
try {
const crmConfig = buildCrmConfig(crm)
debug('CRM config built', {
providers: Object.keys(crmConfig),
hubspot: crm.hubspot ? { formGuid: crm.hubspot.formGuid } : undefined,
customerio: crm.customerio ? { event: crm.customerio.event } : undefined,
})
client = new CRMClient(crmConfig)
} catch (err: any) {
debug('CRM config error', { error: err.message })
return { success: false, errors: [err.message] }
}
// Build HubSpot fields: apply optional field name mapping
let hubspotFields: Record<string, string> | undefined
let consent: string | undefined
if (crm.hubspot) {
const fieldMap = crm.hubspot.fieldMap ?? {}
hubspotFields = {}
for (const [formField, value] of Object.entries(values)) {
const hsField = fieldMap[formField] ?? formField
hubspotFields[hsField] = value
}
consent = crm.hubspot.consent
debug('HubSpot payload', { hubspotFields, consent, context })
}
// Build Customer.io profile attributes from the profileMap
let customerioProfile: Record<string, unknown> | undefined
if (crm.customerio?.profileMap) {
customerioProfile = {}
for (const [formField, attrName] of Object.entries(crm.customerio.profileMap)) {
customerioProfile[attrName] = values[formField]
}
}
if (crm.customerio) {
debug('Customer.io payload', {
event: crm.customerio.event,
properties: values,
customerioProfile,
})
}
// submitEvent is typed via generics — cast to any to avoid fighting the conditional types
// (the runtime behavior is correct: CRMClient checks which clients are configured)
const { errors } = await (client as CRMClient).submitEvent({
email,
hubspotFields,
context,
consent,
event: crm.customerio?.event,
properties: crm.customerio ? (values as Record<string, unknown>) : undefined,
customerioProfile,
} as any)
if (errors.length > 0) {
debug(
'CRM submission errors',
errors.map((e) => e.message)
)
return { success: false, errors: errors.map((e) => e.message) }
}
debug('Submission successful')
return { success: true, errors: [] }
} catch (err: any) {
// Catch any unexpected error so the client always gets a FormSubmitResult
console.error('[go/form] Unexpected error during form submission:', err)
return {
success: false,
errors: [
isDebug ? `Unexpected error: ${err.message}` : 'Something went wrong. Please try again.',
],
}
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './schemas'
export * from './sections'
export * from './templates'
export * from './actions/submitForm'
+307
View File
@@ -0,0 +1,307 @@
import { z } from 'zod'
// ----- Shared primitives -----
export const imageSchema = z.object({
src: z.string().min(1),
alt: z.string().min(1),
width: z.number().optional(),
height: z.number().optional(),
})
export const ctaSchema = z.object({
label: z.string().min(1),
href: z.string().min(1),
variant: z.enum(['primary', 'secondary']).optional().default('primary'),
})
// ----- Section schemas -----
export const videoSchema = z.object({
src: z.string().min(1),
poster: z.string().optional(),
})
export const heroSectionSchema = z.object({
title: z.string().min(1),
subtitle: z.string().optional(),
description: z.string().optional(),
image: imageSchema.optional(),
video: videoSchema.optional(),
youtubeUrl: z.string().url().optional(),
ctas: z.array(ctaSchema).optional(),
})
export const contentBlockSchema = z.object({
heading: z.string().min(1),
body: z.string().min(1),
image: imageSchema.optional(),
icon: z.string().optional(),
})
export const contentBlocksSectionSchema = z.object({
heading: z.string().optional(),
blocks: z.array(contentBlockSchema).min(1),
columns: z.enum(['2', '3']).optional().default('3'),
})
export const socialProofSectionSchema = z.object({
heading: z.string().optional(),
logos: z.array(imageSchema).optional(),
testimonial: z
.object({
quote: z.string().min(1),
author: z.string().min(1),
role: z.string().optional(),
avatar: imageSchema.optional(),
})
.optional(),
stats: z
.array(
z.object({
value: z.string().min(1),
label: z.string().min(1),
})
)
.optional(),
})
export const textBodySectionSchema = z.object({
content: z.string().min(1),
})
const sectionBase = {
id: z.string().optional(),
className: z.string().optional(),
}
export const singleColumnSectionSchema = z.object({
...sectionBase,
type: z.literal('single-column'),
title: z.string().min(1),
description: z.string().optional(),
children: z.any().optional(),
})
export const twoColumnSectionSchema = z.object({
...sectionBase,
type: z.literal('two-column'),
title: z.string().optional(),
description: z.string().optional(),
children: z.any().optional(),
})
export const threeColumnSectionSchema = z.object({
...sectionBase,
type: z.literal('three-column'),
title: z.string().optional(),
description: z.string().optional(),
children: z.any().optional(),
})
// ----- Form field schemas -----
const formFieldBase = z.object({
name: z.string().min(1),
label: z.string().min(1),
placeholder: z.string().optional(),
required: z.boolean().optional().default(false),
half: z.boolean().optional().default(false),
})
export const textFieldSchema = formFieldBase.extend({
type: z.literal('text'),
})
export const emailFieldSchema = formFieldBase.extend({
type: z.literal('email'),
})
export const textareaFieldSchema = formFieldBase.extend({
type: z.literal('textarea'),
rows: z.number().optional().default(4),
})
export const selectFieldSchema = formFieldBase.extend({
type: z.literal('select'),
options: z.array(z.object({ label: z.string(), value: z.string() })).min(1),
})
export const formFieldSchema = z.discriminatedUnion('type', [
textFieldSchema,
emailFieldSchema,
textareaFieldSchema,
selectFieldSchema,
])
// ----- Form CRM config schemas -----
export const hubspotFormConfigSchema = z.object({
/**
* HubSpot form GUID. The portal ID is read from HUBSPOT_PORTAL_ID env var.
*/
formGuid: z.string().min(1),
/**
* Map each form field `name` to a HubSpot field name.
* If omitted, the form field name is used as-is.
* Example: { workEmail: 'email', companyName: 'company' }
*/
fieldMap: z.record(z.string(), z.string()).optional(),
/** Legal consent text for GDPR. */
consent: z.string().optional(),
})
export const customerioFormConfigSchema = z.object({
/**
* Event name sent to Customer.io on submit.
* Credentials are read from CUSTOMERIO_SITE_ID and CUSTOMERIO_API_KEY env vars.
*/
event: z.string().min(1),
/**
* Map each form field `name` to a Customer.io profile attribute.
* Fields listed here are added to the contact profile via `identify`.
* Example: { workEmail: 'email', firstName: 'first_name' }
*/
profileMap: z.record(z.string(), z.string()).optional(),
})
export const formCrmConfigSchema = z
.object({
hubspot: hubspotFormConfigSchema.optional(),
customerio: customerioFormConfigSchema.optional(),
})
.refine((v) => v.hubspot || v.customerio, {
message: 'At least one CRM provider (hubspot or customerio) must be configured',
})
export const formSectionSchema = z.object({
...sectionBase,
type: z.literal('form'),
title: z.string().optional(),
description: z.string().optional(),
fields: z.array(formFieldSchema).min(1),
submitLabel: z.string().min(1),
disclaimer: z.string().optional(),
/** Message shown after a successful submission. Defaults to a generic thank-you message. */
successMessage: z.string().optional(),
/** CRM integration config. When provided, form submissions are sent to the configured providers. */
crm: formCrmConfigSchema.optional(),
})
export const featureGridItemSchema = z.object({
icon: z.string().optional(),
title: z.string().min(1),
description: z.string().min(1),
})
export const featureGridSectionSchema = z.object({
...sectionBase,
type: z.literal('feature-grid'),
title: z.string().optional(),
description: z.string().optional(),
items: z.array(featureGridItemSchema).min(1).max(6),
})
export const metricItemSchema = z.object({
label: z.string().min(1),
value: z.string().min(1),
})
export const metricsSectionSchema = z.object({
...sectionBase,
type: z.literal('metrics'),
items: z.array(metricItemSchema).min(1).max(5),
})
export const tweetsSectionSchema = z.object({
...sectionBase,
type: z.literal('tweets'),
title: z.string().optional(),
description: z.string().optional(),
ctas: z.array(ctaSchema).optional(),
})
// ----- Dynamic sections -----
export const goSectionSchema = z.discriminatedUnion('type', [
singleColumnSectionSchema,
twoColumnSectionSchema,
threeColumnSectionSchema,
formSectionSchema,
featureGridSectionSchema,
metricsSectionSchema,
tweetsSectionSchema,
])
// ----- Page-level schemas -----
export const metadataSchema = z.object({
title: z.string().min(1),
description: z.string().min(1),
ogImage: z.string().optional(),
noIndex: z.boolean().optional().default(true),
})
// ----- Page schemas -----
const goPageBaseSchema = z.object({
slug: z.string().min(1),
metadata: metadataSchema,
hero: heroSectionSchema,
sections: z.array(goSectionSchema).optional(),
publishedAt: z.string().optional(),
})
export const leadGenPageSchema = goPageBaseSchema.extend({
template: z.literal('lead-gen'),
})
export const thankYouPageSchema = goPageBaseSchema.extend({
template: z.literal('thank-you'),
})
export const legalPageSchema = goPageBaseSchema.extend({
template: z.literal('legal'),
effectiveDate: z.string().optional(),
body: z.string().min(1),
})
export const goPageSchema = z.discriminatedUnion('template', [
leadGenPageSchema,
thankYouPageSchema,
legalPageSchema,
])
// ----- Inferred types -----
export type GoImage = z.infer<typeof imageSchema>
export type GoVideo = z.infer<typeof videoSchema>
export type GoCta = z.infer<typeof ctaSchema>
export type GoHeroSection = z.infer<typeof heroSectionSchema>
export type GoContentBlock = z.infer<typeof contentBlockSchema>
export type GoContentBlocksSection = z.infer<typeof contentBlocksSectionSchema>
export type GoSocialProofSection = z.infer<typeof socialProofSectionSchema>
export type GoTextBodySection = z.infer<typeof textBodySectionSchema>
export type GoSingleColumnSection = z.infer<typeof singleColumnSectionSchema>
export type GoTwoColumnSection = z.infer<typeof twoColumnSectionSchema>
export type GoThreeColumnSection = z.infer<typeof threeColumnSectionSchema>
export type GoFormField = z.infer<typeof formFieldSchema>
export type GoFormSection = z.infer<typeof formSectionSchema>
export type GoHubSpotFormConfig = z.infer<typeof hubspotFormConfigSchema>
export type GoCustomerIOFormConfig = z.infer<typeof customerioFormConfigSchema>
export type GoFormCrmConfig = z.infer<typeof formCrmConfigSchema>
export type GoFeatureGridItem = z.infer<typeof featureGridItemSchema>
export type GoFeatureGridSection = z.infer<typeof featureGridSectionSchema>
export type GoMetricItem = z.infer<typeof metricItemSchema>
export type GoMetricsSection = z.infer<typeof metricsSectionSchema>
export type GoTweetsSection = z.infer<typeof tweetsSectionSchema>
export type GoSection = z.infer<typeof goSectionSchema>
export type GoMetadata = z.infer<typeof metadataSchema>
export type GoPage = z.infer<typeof goPageSchema>
export type LeadGenPage = z.infer<typeof leadGenPageSchema>
export type ThankYouPage = z.infer<typeof thankYouPageSchema>
export type LegalPage = z.infer<typeof legalPageSchema>
/** Input type for registry files — fields with defaults are optional */
export type GoPageInput = z.input<typeof goPageSchema>
@@ -0,0 +1,93 @@
'use client'
import { useEffect, useRef } from 'react'
interface Particle {
x: number
y: number
vx: number
vy: number
size: number
color: string
rotation: number
rotationSpeed: number
opacity: number
}
const COLORS = ['#15803d', '#16a34a', '#22c55e', '#4ade80', '#86efac']
export default function Confetti() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const rect = canvas.getBoundingClientRect()
canvas.width = rect.width
canvas.height = rect.height
const particles: Particle[] = []
const count = 80
for (let i = 0; i < count; i++) {
particles.push({
x: Math.random() * canvas.width,
y: -(Math.random() * canvas.height * 0.5),
vx: (Math.random() - 0.5) * 2,
vy: Math.random() * 1.5 + 0.5,
size: Math.random() * 6 + 3,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.1,
opacity: Math.random() * 0.4 + 0.6,
})
}
let frame: number
function animate() {
ctx!.clearRect(0, 0, canvas!.width, canvas!.height)
let alive = false
for (const p of particles) {
if (p.opacity <= 0) continue
alive = true
p.x += p.vx
p.vy += 0.02
p.y += p.vy
p.rotation += p.rotationSpeed
p.opacity -= 0.003
ctx!.save()
ctx!.translate(p.x, p.y)
ctx!.rotate(p.rotation)
ctx!.globalAlpha = Math.max(0, p.opacity)
ctx!.fillStyle = p.color
ctx!.fillRect(-p.size * 0.15, -p.size / 2, p.size * 0.3, p.size)
ctx!.restore()
}
if (alive) {
frame = requestAnimationFrame(animate)
}
}
frame = requestAnimationFrame(animate)
return () => cancelAnimationFrame(frame)
}, [])
return (
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full pointer-events-none z-10"
aria-hidden
/>
)
}
@@ -0,0 +1,22 @@
import type { GoContentBlocksSection } from '../schemas'
export default function ContentBlocksSection({ section }: { section: GoContentBlocksSection }) {
const gridCols = section.columns === '2' ? 'md:grid-cols-2' : 'md:grid-cols-3'
return (
<div>
{section.heading && (
<h2 className="text-foreground text-2xl text-center mb-8">{section.heading}</h2>
)}
<div className={`grid grid-cols-1 ${gridCols} gap-8`}>
{section.blocks.map((block) => (
<div key={block.heading}>
{block.icon && <span className="text-2xl mb-2 block">{block.icon}</span>}
<h3 className="text-foreground text-lg font-medium">{block.heading}</h3>
<p className="text-foreground-lighter mt-2 text-sm">{block.body}</p>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,52 @@
import { cn } from 'ui'
import type { GoFeatureGridSection } from '../schemas'
export default function FeatureGridSection({ section }: { section: GoFeatureGridSection }) {
const { items } = section
const hasSecondRow = items.length > 3
return (
<div className="max-w-[80rem] mx-auto px-8">
{(section.title || section.description) && (
<div className="mb-12">
{section.title && (
<h2 className="text-2xl sm:text-3xl font-medium text-foreground">{section.title}</h2>
)}
{section.description && (
<p className="text-foreground-lighter mt-3 text-lg">{section.description}</p>
)}
</div>
)}
<div className="border border-muted rounded-xl overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-3">
{items.map((item, i) => {
const col = i % 3
const row = Math.floor(i / 3)
const isLastCol = col === 2 || i === items.length - 1
const isLastRow = !hasSecondRow || row === 1
return (
<div
key={i}
className={cn(
'p-6 sm:p-8',
!isLastCol && 'md:border-r border-muted',
!isLastRow && 'border-b border-muted',
// On mobile, all items except the last get a bottom border
i < items.length - 1 && 'max-md:border-b max-md:border-muted'
)}
>
{item.icon && <span className="text-xl mb-3 block">{item.icon}</span>}
<h3 className="text-foreground font-medium text-base">{item.title}</h3>
<p className="text-foreground-lighter text-sm mt-2 leading-relaxed">
{item.description}
</p>
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1,230 @@
'use client'
import { useState } from 'react'
import ReactMarkdown from 'react-markdown'
import {
Button,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
TextArea_Shadcn_,
} from 'ui'
import { submitFormAction } from '../actions/submitForm'
import type { GoFormField, GoFormSection } from '../schemas'
function FormField({
field,
value,
onChange,
}: {
field: GoFormField
value: string
onChange: (value: string) => void
}) {
switch (field.type) {
case 'text':
case 'email':
return (
<Input_Shadcn_
type={field.type}
placeholder={field.placeholder}
required={field.required}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)
case 'textarea':
return (
<TextArea_Shadcn_
placeholder={field.placeholder}
required={field.required}
rows={field.rows}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)
case 'select':
return (
<Select_Shadcn_ value={value} onValueChange={onChange} required={field.required}>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder={field.placeholder} />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{field.options.map((opt) => (
<SelectItem_Shadcn_ key={opt.value} value={opt.value}>
{opt.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
)
default: {
const _exhaustive: never = field
return null
}
}
}
type SubmitState = 'idle' | 'loading' | 'success' | 'error'
export default function FormSection({ section }: { section: GoFormSection }) {
const [values, setValues] = useState<Record<string, string>>(() =>
Object.fromEntries(section.fields.map((f) => [f.name, '']))
)
const [submitState, setSubmitState] = useState<SubmitState>('idle')
const [errorMessages, setErrorMessages] = useState<string[]>([])
const handleChange = (name: string, value: string) => {
setValues((prev) => ({ ...prev, [name]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!section.crm) {
if (process.env.NODE_ENV === 'development') {
console.log('[go/form] No CRM configured — form values:', values)
}
return
}
setSubmitState('loading')
setErrorMessages([])
const pageUri = typeof window !== 'undefined' ? window.location.href : undefined
const pageName = typeof document !== 'undefined' ? document.title : undefined
try {
const result = await submitFormAction(section.crm, values, { pageUri, pageName })
if (result.success) {
setSubmitState('success')
} else {
setSubmitState('error')
setErrorMessages(result.errors)
}
} catch (err: any) {
// Unexpected client-side error (network failure, server action crash, etc.)
console.error('[go/form] Form submission failed:', err)
setSubmitState('error')
setErrorMessages(['Something went wrong. Please try again.'])
}
}
// Group fields into rows: half-width fields pair up, full-width fields get their own row
const rows: GoFormField[][] = []
let pendingHalf: GoFormField | null = null
for (const field of section.fields) {
if (field.half) {
if (pendingHalf) {
rows.push([pendingHalf, field])
pendingHalf = null
} else {
pendingHalf = field
}
} else {
if (pendingHalf) {
rows.push([pendingHalf])
pendingHalf = null
}
rows.push([field])
}
}
if (pendingHalf) {
rows.push([pendingHalf])
}
if (submitState === 'success') {
return (
<section>
<div className="max-w-2xl mx-auto px-8">
<div className="border border-muted rounded-2xl p-6 sm:p-8 flex flex-col items-center gap-4 text-center">
<p className="text-lg font-medium">Thank you!</p>
<p className="text-foreground-light">
{section.successMessage ??
"We've received your submission and will be in touch soon."}
</p>
</div>
</div>
</section>
)
}
return (
<section>
<div className="max-w-2xl mx-auto px-8">
{(section.title || section.description) && (
<div className="flex flex-col items-center gap-4 text-center text-balance mb-10">
{section.title && (
<h2 className="text-2xl md:text-3xl lg:text-4xl leading-tight tracking-tight">
{section.title}
</h2>
)}
{section.description && (
<p className="text-foreground-light text-lg max-w-xl">{section.description}</p>
)}
</div>
)}
<form
onSubmit={handleSubmit}
className="border border-muted rounded-2xl p-6 sm:p-8 flex flex-col gap-6"
>
{rows.map((row, rowIndex) => (
<div
key={rowIndex}
className={row.length > 1 ? 'grid grid-cols-1 sm:grid-cols-2 gap-4' : undefined}
>
{row.map((field) => (
<div key={field.name} className="flex flex-col gap-2">
<label className="text-sm text-foreground font-medium">{field.label}</label>
<FormField
field={field}
value={values[field.name] ?? ''}
onChange={(v) => handleChange(field.name, v)}
/>
</div>
))}
</div>
))}
{submitState === 'error' && errorMessages.length > 0 && (
<div className="flex flex-col gap-1">
{errorMessages.map((msg, i) => (
<p key={i} className="text-sm text-destructive">
{msg}
</p>
))}
</div>
)}
<hr className="border-muted" />
<Button
htmlType="submit"
type="primary"
size="large"
block
loading={submitState === 'loading'}
>
{section.submitLabel}
</Button>
{section.disclaimer && (
<div className="text-xs text-foreground-lighter leading-relaxed [&_a]:text-brand-link [&_a]:decoration-brand-link">
<ReactMarkdown
components={{ p: ({ children }) => <p>{children}</p>, a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer">{children}</a> }}
>
{section.disclaimer}
</ReactMarkdown>
</div>
)}
</form>
</div>
</section>
)
}
@@ -0,0 +1,78 @@
'use client'
import { Button, cn } from 'ui'
import type { GoHeroSection } from '../schemas'
import MediaBlock from './MediaBlock'
export default function HeroSection({
section,
compact,
}: {
section: GoHeroSection
compact?: boolean
}) {
const hasMedia = !!(section.image || section.video || section.youtubeUrl)
return (
<header
className={cn(
'flex flex-col border-b border-muted',
'bg-[radial-gradient(circle_at_50%_240%,hsl(var(--brand-300)),transparent_70%)]',
'md:bg-[radial-gradient(circle_at_50%_280%,hsl(var(--brand-300)),transparent_70%)]',
compact && 'items-center justify-center min-h-[35vh] lg:min-h-[50vh]'
)}
>
<div
className={cn(
'max-w-[80rem] mx-auto px-8',
hasMedia
? 'grid grid-cols-1 py-16 md:grid-cols-2 gap-10 md:gap-24 items-center md:py-32'
: 'flex flex-col items-center py-16 gap-4 sm:gap-8 text-center text-balance py-24'
)}
>
<div className={cn(hasMedia ? 'flex flex-col gap-4 sm:gap-6' : 'contents')}>
{section.subtitle && (
<p className="text-sm text-brand-link uppercase font-mono tracking-wider">
{section.subtitle}
</p>
)}
<h1 className="text-3xl md:text-4xl leading-tight tracking-tight text-balance">
{section.title}
</h1>
{section.description && (
<p
className={cn(
'text-lg leading-relaxed text-foreground-light text-pretty',
!hasMedia && 'md:text-xl md:text-foreground max-w-2xl'
)}
>
{section.description}
</p>
)}
{section.ctas && section.ctas.length > 0 && (
<div className="flex flex-wrap items-center gap-4 pt-2">
{section.ctas.map((cta) => (
<Button
key={cta.href}
asChild
type={cta.variant === 'secondary' ? 'default' : 'primary'}
size="medium"
>
<a href={cta.href}>{cta.label}</a>
</Button>
))}
</div>
)}
</div>
<MediaBlock
image={section.image}
video={section.video}
youtubeUrl={section.youtubeUrl}
className="p-1 bg-surface-300/30 rounded-2xl"
/>
</div>
</header>
)
}
@@ -0,0 +1,70 @@
import type { GoImage, GoVideo } from '../schemas'
interface MediaBlockProps {
image?: GoImage
video?: GoVideo
youtubeUrl?: string
className?: string
}
function getYouTubeEmbedUrl(url: string): string | null {
try {
const parsed = new URL(url)
let id: string | null = null
if (parsed.hostname === 'youtu.be') {
id = parsed.pathname.slice(1)
} else if (parsed.hostname === 'www.youtube.com' || parsed.hostname === 'youtube.com') {
if (parsed.pathname.startsWith('/embed/')) {
id = parsed.pathname.split('/embed/')[1]
} else {
id = parsed.searchParams.get('v')
}
}
return id ? `https://www.youtube.com/embed/${id}` : null
} catch {
return null
}
}
export default function MediaBlock({ image, video, youtubeUrl, className }: MediaBlockProps) {
const embedUrl = youtubeUrl ? getYouTubeEmbedUrl(youtubeUrl) : null
const media = image ? (
<img
src={image.src}
alt={image.alt}
width={image.width}
height={image.height}
className="w-full h-auto rounded-xl border"
/>
) : video ? (
<video
src={video.src}
poster={video.poster}
autoPlay
muted
loop
playsInline
className="w-full h-auto rounded-xl border"
/>
) : embedUrl ? (
<div
className="relative w-full rounded-xl border overflow-hidden"
style={{ paddingBottom: '56.25%' }}
>
<iframe
src={embedUrl}
title="YouTube video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 w-full h-full"
/>
</div>
) : null
if (!media) return null
return <div className={className}>{media}</div>
}
@@ -0,0 +1,18 @@
import type { GoMetricsSection } from '../schemas'
export default function MetricsSection({ section }: { section: GoMetricsSection }) {
return (
<div className="px-8">
<div className="max-w-[80rem] mx-auto flex flex-wrap justify-center gap-8 sm:gap-16">
{section.items.map((item, i) => (
<div key={i} className="text-center">
<p className="text-foreground-lighter text-xs tracking-widest uppercase mb-3">
{item.label}
</p>
<p className="text-foreground text-3xl sm:text-5xl font-medium">{item.value}</p>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,73 @@
import { cn } from 'ui'
import type { GoSection } from '../schemas'
import FeatureGridSection from './FeatureGridSection'
import FormSection from './FormSection'
import MetricsSection from './MetricsSection'
import SingleColumnSection from './SingleColumnSection'
import ThreeColumnSection from './ThreeColumnSection'
import TwoColumnSection from './TwoColumnSection'
export type CustomSectionRenderers = {
[K in GoSection['type']]?: React.ComponentType<{
section: Extract<GoSection, { type: K }>
}>
}
interface SectionRendererProps {
section: GoSection
customRenderers?: CustomSectionRenderers
}
export default function SectionRenderer({ section, customRenderers }: SectionRendererProps) {
// Check for a custom renderer first
const CustomRenderer = customRenderers?.[section.type] as
| React.ComponentType<{ section: typeof section }>
| undefined
let content: React.ReactNode = null
if (CustomRenderer) {
content = <CustomRenderer section={section} />
} else {
switch (section.type) {
case 'single-column':
content = <SingleColumnSection section={section} />
break
case 'two-column':
content = <TwoColumnSection section={section} />
break
case 'three-column':
content = <ThreeColumnSection section={section} />
break
case 'form':
content = <FormSection section={section} />
break
case 'feature-grid':
content = <FeatureGridSection section={section} />
break
case 'metrics':
content = <MetricsSection section={section} />
break
case 'tweets':
// Tweets requires app-specific dependencies — must be provided via customRenderers
break
default: {
const _exhaustive: never = section
break
}
}
}
if (!content) return null
if (section.id || section.className) {
return (
<div id={section.id} className={cn(section.className)}>
{content}
</div>
)
}
return content
}
@@ -0,0 +1,19 @@
import type { GoSingleColumnSection } from '../schemas'
export default function SingleColumnSection({ section }: { section: GoSingleColumnSection }) {
return (
<section>
<div className="max-w-[80rem] mx-auto flex flex-col items-center gap-4 text-center text-balance px-8">
<h2 className="text-2xl md:text-3xl lg:text-4xl leading-tight tracking-tight">
{section.title}
</h2>
{section.description && (
<p className="text-foreground-light text-lg max-w-2xl">{section.description}</p>
)}
</div>
{section.children && (
<div className="max-w-[80rem] mx-auto mt-10 px-8">{section.children}</div>
)}
</section>
)
}
@@ -0,0 +1,30 @@
import type { GoSocialProofSection } from '../schemas'
export default function SocialProofSection({ section }: { section: GoSocialProofSection }) {
return (
<div className="text-center">
{section.heading && <h2 className="text-foreground text-2xl mb-8">{section.heading}</h2>}
{section.stats && section.stats.length > 0 && (
<div className="flex items-center justify-center gap-12 mb-8">
{section.stats.map((stat) => (
<div key={stat.label}>
<p className="text-foreground text-3xl font-semibold">{stat.value}</p>
<p className="text-foreground-lighter text-sm mt-1">{stat.label}</p>
</div>
))}
</div>
)}
{section.testimonial && (
<blockquote className="max-w-xl mx-auto">
<p className="text-foreground-light text-lg italic">
&ldquo;{section.testimonial.quote}&rdquo;
</p>
<footer className="mt-4 text-foreground-lighter text-sm">
{section.testimonial.author}
{section.testimonial.role && <span> &middot; {section.testimonial.role}</span>}
</footer>
</blockquote>
)}
</div>
)
}
@@ -0,0 +1,98 @@
'use client'
import { useEffect, useState } from 'react'
import { cn } from 'ui'
function slugify(text: string) {
let slug = text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s/g, '-')
.replace(/-{2,}/g, '-')
.trim()
if (/^\d/.test(slug)) slug = `section-${slug}`
return slug
}
interface Heading {
text: string
slug: string
level: number
}
function extractHeadings(markdown: string): Heading[] {
const headings: Heading[] = []
// Trim each line first to handle indented markdown, then match ATX headings.
// Using a literal space (not \s+) after the hashes avoids polynomial backtracking.
const regex = /^(#{1,3}) (.+)$/gm
const normalized = markdown
.split('\n')
.map((line) => line.trimStart())
.join('\n')
let match
while ((match = regex.exec(normalized)) !== null) {
const text = match[2].trim()
headings.push({
text,
slug: slugify(text),
level: match[1].length,
})
}
return headings
}
export default function TableOfContents({ content }: { content: string }) {
const headings = extractHeadings(content)
const [activeSlug, setActiveSlug] = useState<string>('')
useEffect(() => {
const elements = headings
.map((h) => document.getElementById(h.slug))
.filter(Boolean) as HTMLElement[]
if (elements.length === 0) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveSlug(entry.target.id)
}
}
},
{ rootMargin: '-80px 0px -70% 0px' }
)
elements.forEach((el) => observer.observe(el))
return () => observer.disconnect()
}, [headings])
if (headings.length === 0) return null
return (
<nav className="sticky top-24">
<ul className="flex flex-col gap-2 border-l border-muted border-dashed">
{headings.map((heading) => (
<li key={heading.slug}>
<a
href={`#${heading.slug}`}
className={cn(
'block text-xs -ml-px border-l transition-colors',
heading.level === 1 && 'pl-4',
heading.level === 2 && 'pl-4',
heading.level === 3 && 'pl-8',
activeSlug === heading.slug
? 'text-foreground border-foreground'
: 'text-foreground-muted border-transparent hover:text-foreground-lighter'
)}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
)
}
export { slugify }
@@ -0,0 +1,44 @@
'use client'
import React from 'react'
import type { Components } from 'react-markdown'
import ReactMarkdown from 'react-markdown'
import type { GoTextBodySection } from '../schemas'
import { slugify } from './TableOfContents'
function getTextContent(node: React.ReactNode): string {
if (typeof node === 'string') return node
if (typeof node === 'number') return String(node)
if (Array.isArray(node)) return node.map(getTextContent).join('')
if (React.isValidElement(node) && node.props) {
return getTextContent((node.props as { children?: React.ReactNode }).children)
}
return ''
}
const headingWithId =
(Tag: 'h1' | 'h2' | 'h3'): Components[typeof Tag] =>
({ children }) => {
const text = getTextContent(children)
const id = slugify(text)
return (
<Tag id={id} className="scroll-mt-24">
{children}
</Tag>
)
}
const components: Components = {
h1: headingWithId('h1'),
h2: headingWithId('h2'),
h3: headingWithId('h3'),
}
export default function TextBodySection({ section }: { section: GoTextBodySection }) {
return (
<article className="prose prose-headings:text-foreground prose-p:text-foreground-light prose-strong:text-foreground prose-li:text-foreground-light prose-a:text-brand-link prose-a:decoration-brand-link max-w-none">
<ReactMarkdown components={components}>{section.content}</ReactMarkdown>
</article>
)
}
@@ -0,0 +1,25 @@
import type { GoThreeColumnSection } from '../schemas'
export default function ThreeColumnSection({ section }: { section: GoThreeColumnSection }) {
return (
<section>
{(section.title || section.description) && (
<div className="max-w-[80rem] mx-auto flex flex-col items-center gap-4 text-center text-balance px-8 mb-10">
{section.title && (
<h2 className="text-2xl md:text-3xl lg:text-4xl leading-tight tracking-tight">
{section.title}
</h2>
)}
{section.description && (
<p className="text-foreground-light text-lg max-w-2xl">{section.description}</p>
)}
</div>
)}
{section.children && (
<div className="max-w-[80rem] mx-auto grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 px-8">
{section.children}
</div>
)}
</section>
)
}
@@ -0,0 +1,25 @@
import type { GoTwoColumnSection } from '../schemas'
export default function TwoColumnSection({ section }: { section: GoTwoColumnSection }) {
return (
<section>
{(section.title || section.description) && (
<div className="max-w-[80rem] mx-auto flex flex-col items-center gap-4 text-center text-balance px-8 mb-10">
{section.title && (
<h2 className="text-2xl md:text-3xl lg:text-4xl leading-tight tracking-tight">
{section.title}
</h2>
)}
{section.description && (
<p className="text-foreground-light text-lg max-w-2xl">{section.description}</p>
)}
</div>
)}
{section.children && (
<div className="max-w-[80rem] mx-auto grid grid-cols-1 md:grid-cols-2 gap-8 px-8">
{section.children}
</div>
)}
</section>
)
}
@@ -0,0 +1,13 @@
export { default as HeroSection } from './HeroSection'
export { default as ContentBlocksSection } from './ContentBlocksSection'
export { default as SocialProofSection } from './SocialProofSection'
export { default as TextBodySection } from './TextBodySection'
export { default as SingleColumnSection } from './SingleColumnSection'
export { default as TwoColumnSection } from './TwoColumnSection'
export { default as ThreeColumnSection } from './ThreeColumnSection'
export { default as FormSection } from './FormSection'
export { default as FeatureGridSection } from './FeatureGridSection'
export { default as MetricsSection } from './MetricsSection'
export { default as SectionRenderer } from './SectionRenderer'
export type { CustomSectionRenderers } from './SectionRenderer'
export { default as MediaBlock } from './MediaBlock'
@@ -0,0 +1,29 @@
import type { GoPage } from '../schemas'
import type { CustomSectionRenderers } from '../sections/SectionRenderer'
import LeadGenTemplate from './LeadGenTemplate'
import LegalTemplate from './LegalTemplate'
import ThankYouTemplate from './ThankYouTemplate'
interface GoPageRendererProps {
page: GoPage
customRenderers?: CustomSectionRenderers
}
function TemplateSwitch({ page, customRenderers }: GoPageRendererProps) {
switch (page.template) {
case 'lead-gen':
return <LeadGenTemplate page={page} customRenderers={customRenderers} />
case 'thank-you':
return <ThankYouTemplate page={page} customRenderers={customRenderers} />
case 'legal':
return <LegalTemplate page={page} customRenderers={customRenderers} />
default: {
const _exhaustive: never = page
return null
}
}
}
export default function GoPageRenderer({ page, customRenderers }: GoPageRendererProps) {
return <TemplateSwitch page={page} customRenderers={customRenderers} />
}
@@ -0,0 +1,20 @@
import type { LeadGenPage } from '../schemas'
import HeroSection from '../sections/HeroSection'
import SectionRenderer, { type CustomSectionRenderers } from '../sections/SectionRenderer'
export default function LeadGenTemplate({
page,
customRenderers,
}: {
page: LeadGenPage
customRenderers?: CustomSectionRenderers
}) {
return (
<div className="flex flex-col gap-16 sm:gap-24">
<HeroSection section={page.hero} />
{page.sections?.map((section, i) => (
<SectionRenderer key={i} section={section} customRenderers={customRenderers} />
))}
</div>
)
}
@@ -0,0 +1,32 @@
import type { LegalPage } from '../schemas'
import HeroSection from '../sections/HeroSection'
import SectionRenderer, { type CustomSectionRenderers } from '../sections/SectionRenderer'
import TableOfContents from '../sections/TableOfContents'
import TextBodySection from '../sections/TextBodySection'
export default function LegalTemplate({
page,
customRenderers,
}: {
page: LegalPage
customRenderers?: CustomSectionRenderers
}) {
const hero = page.effectiveDate
? { ...page.hero, subtitle: `Effective date: ${page.effectiveDate}` }
: page.hero
return (
<div className="flex flex-col gap-16 sm:gap-24">
<HeroSection section={hero} compact />
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-24 max-w-5xl mx-auto px-8 w-full">
<div className="hidden lg:block">
<TableOfContents content={page.body} />
</div>
<TextBodySection section={{ content: page.body }} />
</div>
{page.sections?.map((section, i) => (
<SectionRenderer key={i} section={section} customRenderers={customRenderers} />
))}
</div>
)
}
@@ -0,0 +1,24 @@
import type { ThankYouPage } from '../schemas'
import Confetti from '../sections/Confetti'
import HeroSection from '../sections/HeroSection'
import SectionRenderer, { type CustomSectionRenderers } from '../sections/SectionRenderer'
export default function ThankYouTemplate({
page,
customRenderers,
}: {
page: ThankYouPage
customRenderers?: CustomSectionRenderers
}) {
return (
<div className="flex flex-col gap-16 sm:gap-24">
<div className="relative">
<HeroSection section={page.hero} compact />
<Confetti />
</div>
{page.sections?.map((section, i) => (
<SectionRenderer key={i} section={section} customRenderers={customRenderers} />
))}
</div>
)
}
@@ -0,0 +1,4 @@
export { default as GoPageRenderer } from './GoPageRenderer'
export { default as LeadGenTemplate } from './LeadGenTemplate'
export { default as ThankYouTemplate } from './ThankYouTemplate'
export { default as LegalTemplate } from './LegalTemplate'
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "tsconfig/react-library.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}
+31
View File
@@ -1660,6 +1660,9 @@ importers:
markdown-toc:
specifier: ^1.2.0
version: 1.2.0
marketing:
specifier: workspace:*
version: link:../../packages/marketing
mdast-util-from-markdown:
specifier: ^2.0.2
version: 2.0.2(supports-color@8.1.1)
@@ -2242,6 +2245,34 @@ importers:
specifier: 'catalog:'
version: 18.3.3
packages/marketing:
dependencies:
'@types/react':
specifier: 'catalog:'
version: 18.3.3
react:
specifier: 'catalog:'
version: 18.3.1
react-markdown:
specifier: ^8.0.3
version: 8.0.7(@types/react@18.3.3)(react@18.3.1)(supports-color@8.1.1)
server-only:
specifier: ^0.0.1
version: 0.0.1
ui:
specifier: workspace:*
version: link:../ui
zod:
specifier: 'catalog:'
version: 3.25.76
devDependencies:
tsconfig:
specifier: 'workspace:'
version: link:../tsconfig
typescript:
specifier: 'catalog:'
version: 5.9.2
packages/pg-meta:
dependencies:
zod: