mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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:
1 parent
d23302d2f5
commit
32330e26c0
44 files changed
+2427
-14
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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©=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
|
||||
@@ -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
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>© {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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { GoPage, GoPageInput, LeadGenPage, ThankYouPage, LegalPage } from 'marketing'
|
||||
|
||||
export { goPageSchema } from 'marketing'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './src/go'
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './schemas'
|
||||
export * from './sections'
|
||||
export * from './templates'
|
||||
export * from './actions/submitForm'
|
||||
@@ -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">
|
||||
“{section.testimonial.quote}”
|
||||
</p>
|
||||
<footer className="mt-4 text-foreground-lighter text-sm">
|
||||
{section.testimonial.author}
|
||||
{section.testimonial.role && <span> · {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'
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "tsconfig/react-library.json",
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
Generated
+31
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user