From ce5cce50302a3be65115140ad154bf15cdcac758 Mon Sep 17 00:00:00 2001 From: Illia Basalaiev <44750366+Ellba@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:54:40 +0100 Subject: [PATCH 1/6] replace github discussions with local guides in the docs search (#42335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? feature ## What is the current behavior? Currently, old GitHub discussions appear in the docs search instead of troubleshooting guides in docs/guides/troubleshooting ## What is the new behavior? Local troubleshooting guides appear in the search ## Additional context CleanShot 2026-01-31 at 23 37 33@2x **troubleshooting.ts** - New source loader that reads local MDX files from content/troubleshooting/ directly instead of fetching from GitHub Discussions API - Generates correct docs paths: /guides/troubleshooting/{slug} - Uses type = 'troubleshooting' for proper search result mapping - Sets slug: undefined to avoid trailing # in URLs - Checksum includes title/topics/keywords so metadata-only changes trigger re-indexing - Left comments for review **index.ts** - Replaced GitHub discussion sources with local troubleshooting sources - Removed GitHubDiscussionLoader, fetchDiscussions, buildGithubUrlToSlugMap imports - Added fetchTroubleshootingSources and TroubleshootingSource - Updated SearchSource type union **globalSearchModel.ts** - Changed type mapping from 'github-discussions' to 'troubleshooting' **generate-embeddings.ts** - Removed GitHub App env vars from required list (DOCS_GITHUB_APP_ID, DOCS_GITHUB_APP_INSTALLATION_ID, DOCS_GITHUB_APP_PRIVATE_KEY) since they're no longer needed ## Summary by CodeRabbit * **New Features** * Local troubleshooting articles are now indexed and appear directly in search results for easier access to step‑by‑step guidance. * Search UI now recognizes a Troubleshooting page type and shows appropriate icons/sections. * **Refactor** * Search sourcing switched from external discussion feeds to local troubleshooting sources to improve relevance and indexing consistency. --------- Co-authored-by: Illia Basalaiev Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com> Co-authored-by: Chris Chinchilla --- .../globalSearch/globalSearchModel.ts | 2 +- .../scripts/search/generate-embeddings.ts | 3 - apps/docs/scripts/search/sources/index.ts | 32 ++---- .../scripts/search/sources/troubleshooting.ts | 101 ++++++++++++++++++ packages/common/hooks/useDocsSearch.ts | 1 + .../prepackaged/DocsSearch/DocsSearchPage.tsx | 12 ++- 6 files changed, 122 insertions(+), 29 deletions(-) create mode 100644 apps/docs/scripts/search/sources/troubleshooting.ts diff --git a/apps/docs/resources/globalSearch/globalSearchModel.ts b/apps/docs/resources/globalSearch/globalSearchModel.ts index 731d0271e2c72..0d6b4881f61c4 100644 --- a/apps/docs/resources/globalSearch/globalSearchModel.ts +++ b/apps/docs/resources/globalSearch/globalSearchModel.ts @@ -128,7 +128,7 @@ function createModelFromMatch({ } else { return null } - case 'github-discussions': + case 'troubleshooting': return new TroubleshootingModel({ title: page_title, href, diff --git a/apps/docs/scripts/search/generate-embeddings.ts b/apps/docs/scripts/search/generate-embeddings.ts index 81263f113a5b7..d7519d2b15665 100644 --- a/apps/docs/scripts/search/generate-embeddings.ts +++ b/apps/docs/scripts/search/generate-embeddings.ts @@ -446,9 +446,6 @@ async function generateEmbeddings() { } requireEnvOrThrow([ - 'DOCS_GITHUB_APP_ID', - 'DOCS_GITHUB_APP_INSTALLATION_ID', - 'DOCS_GITHUB_APP_PRIVATE_KEY', 'NEXT_PUBLIC_MISC_ANON_KEY', 'NEXT_PUBLIC_MISC_URL', 'NEXT_PUBLIC_SUPABASE_URL', diff --git a/apps/docs/scripts/search/sources/index.ts b/apps/docs/scripts/search/sources/index.ts index 88a1de69c2f79..e62f320f3712d 100644 --- a/apps/docs/scripts/search/sources/index.ts +++ b/apps/docs/scripts/search/sources/index.ts @@ -1,10 +1,5 @@ import { type GuideModel } from '../../../resources/guide/guideModel.js' import { GuideModelLoader } from '../../../resources/guide/guideModelLoader.js' -import { - GitHubDiscussionLoader, - type GitHubDiscussionSource, - fetchDiscussions, -} from './github-discussion.js' import { LintWarningsGuideLoader, type LintWarningsGuideSource } from './lint-warnings-guide.js' import { MarkdownLoader, type MarkdownSource } from './markdown.js' import { IntegrationLoader, type IntegrationSource, fetchPartners } from './partner-integrations.js' @@ -16,13 +11,14 @@ import { OpenApiReferenceLoader, type OpenApiReferenceSource, } from './reference-doc.js' +import { fetchTroubleshootingSources, type TroubleshootingSource } from './troubleshooting.js' export type SearchSource = | MarkdownSource | OpenApiReferenceSource | ClientLibReferenceSource | CliReferenceSource - | GitHubDiscussionSource + | TroubleshootingSource | IntegrationSource | LintWarningsGuideSource @@ -150,21 +146,15 @@ export async function fetchAllSources(fullIndex: boolean) { .then((data) => data.flat()) : [] - const githubDiscussionSources = fetchDiscussions( - 'supabase', - 'supabase', - 'DIC_kwDODMpXOc4CUvEr' // 'Troubleshooting' category - ) - .then((discussions) => - Promise.all( - discussions.map((discussion) => - new GitHubDiscussionLoader('supabase/supabase', discussion).load() - ) - ) - ) + // Load troubleshooting articles from local MDX files + const troubleshootingSources = fetchTroubleshootingSources() + .then((loaders) => Promise.all(loaders.map((loader) => loader.load()))) .then((data) => data.flat()) - const sources: SearchSource[] = ( + // Type assertion required because ReferenceLoader.load() returns Promise + // which widens the inferred union type. All concrete sources in this array are valid + // SearchSource types (MarkdownSource, OpenApiReferenceSource, etc.). + const sources = ( await Promise.all([ guideSources, lintWarningsGuideSources, @@ -177,9 +167,9 @@ export async function fetchAllSources(fullIndex: boolean) { ktLibReferenceSource, cliReferenceSource, partnerIntegrationSources, - githubDiscussionSources, + troubleshootingSources, ]) - ).flat() + ).flat() as SearchSource[] return sources } diff --git a/apps/docs/scripts/search/sources/troubleshooting.ts b/apps/docs/scripts/search/sources/troubleshooting.ts new file mode 100644 index 0000000000000..424ba8aea59ae --- /dev/null +++ b/apps/docs/scripts/search/sources/troubleshooting.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto' +import { BaseLoader, BaseSource } from './base.js' +import { + getAllTroubleshootingEntriesInternal, + getArticleSlug, +} from '../../../features/docs/Troubleshooting.utils.common.mjs' +import type { ITroubleshootingEntry } from '../../../features/docs/Troubleshooting.utils.js' + +/** + * Loader for troubleshooting articles from local MDX files. + * + * The path format is `/guides/troubleshooting/{slug}` where slug is derived + * from the filename (e.g., `auth-error-handling.mdx` → `auth-error-handling`). + */ +export class TroubleshootingLoader extends BaseLoader { + type = 'troubleshooting' as const + + constructor( + source: string, + public entry: ITroubleshootingEntry + ) { + const slug = getArticleSlug(entry) + super(source, `/guides/troubleshooting/${slug}`) + } + + async load(): Promise { + return [new TroubleshootingSource(this.source, this.path, this.entry)] + } +} + +/** + * Search source for a single troubleshooting article. + * + * Each article becomes one indexed page with a single section containing + * the full content. This differs from guide pages which may have multiple + * sections based on headings. + */ +export class TroubleshootingSource extends BaseSource { + type = 'troubleshooting' as const + + constructor( + source: string, + path: string, + public entry: ITroubleshootingEntry + ) { + super(source, path) + } + + async process() { + const { title, topics, keywords } = this.entry.data + const content = this.entry.contentWithoutJsx + + // Include title and metadata in checksum so any changes trigger re-indexing. + // This ensures updates to title, topics, or keywords are picked up even if + // the main content hasn't changed. + const checksum = createHash('sha256') + .update(JSON.stringify({ title, topics, keywords, content })) + .digest('base64') + + const meta = { title, topics, keywords } + + // Troubleshooting articles are single-section pages (no sub-headings indexed). + // We explicitly set slug to undefined so the database's `get_full_content_url` + // function returns the page URL without a fragment (e.g., no trailing `#slug`). + // This is handled in SQL: `CASE WHEN slug IS NULL THEN '' ELSE concat('#', slug) END` + const sections = [ + { + heading: title, + slug: undefined as string | undefined, + content: `# ${title}\n${content}`, + }, + ] + + this.checksum = checksum + this.meta = meta + this.sections = sections + + return { checksum, meta, sections } + } + + /** + * Returns the full article content formatted for full-text search indexing. + * The title is included as a heading to boost its relevance in search results. + */ + extractIndexedContent(): string { + return `# ${this.entry.data.title}\n\n${this.entry.contentWithoutJsx}` + } +} + +/** + * Loads all troubleshooting articles from `content/troubleshooting/` directory. + * + * Each MDX file is parsed, validated, and converted to a TroubleshootingLoader. + * Hidden files (prefixed with `_`) are excluded. + * + * @returns Array of loaders, one per troubleshooting article + */ +export async function fetchTroubleshootingSources(): Promise { + const entries = (await getAllTroubleshootingEntriesInternal()) as ITroubleshootingEntry[] + return entries.map((entry) => new TroubleshootingLoader('troubleshooting', entry)) +} diff --git a/packages/common/hooks/useDocsSearch.ts b/packages/common/hooks/useDocsSearch.ts index 1b02bb44779f3..b64f14b6f908e 100644 --- a/packages/common/hooks/useDocsSearch.ts +++ b/packages/common/hooks/useDocsSearch.ts @@ -16,6 +16,7 @@ enum PageType { Reference = 'reference', Integration = 'partner-integration', GithubDiscussion = 'github-discussions', + Troubleshooting = 'troubleshooting', } interface PageSection { diff --git a/packages/ui-patterns/src/CommandMenu/prepackaged/DocsSearch/DocsSearchPage.tsx b/packages/ui-patterns/src/CommandMenu/prepackaged/DocsSearch/DocsSearchPage.tsx index db6b2a8457ef8..afba52261c160 100644 --- a/packages/ui-patterns/src/CommandMenu/prepackaged/DocsSearch/DocsSearchPage.tsx +++ b/packages/ui-patterns/src/CommandMenu/prepackaged/DocsSearch/DocsSearchPage.tsx @@ -1,14 +1,14 @@ 'use client' import { - DocsSearchResultType as PageType, - useDocsSearch, type DocsSearchResult as Page, type DocsSearchResultSection as PageSection, + DocsSearchResultType as PageType, + useDocsSearch, } from 'common' import { Book, ChevronRight, Github, Hash, Loader2, MessageSquare, Search } from 'lucide-react' import { useEffect, useRef } from 'react' -import { Button, cn, CommandGroup_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_ } from 'ui' +import { Button, CommandGroup_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, cn } from 'ui' import { StatusIcon } from 'ui/src/components/StatusIcon' import { @@ -83,6 +83,7 @@ const DocsSearchPage = () => { switch (pageType) { case PageType.Markdown: case PageType.Reference: + case PageType.Troubleshooting: if (BASE_PATH === '/docs') { router.push(link) setIsOpen(false) @@ -299,8 +300,9 @@ export function formatSectionUrl(page: Page, section: PageSection) { return `${page.path}#${section.slug ?? ''}` case PageType.Reference: return `${page.path}/${section.slug ?? ''}` + case PageType.Troubleshooting: + // [Charis] Markdown headings on integrations pages don't have slugs yet case PageType.Integration: - // [Charis] Markdown headings on integrations pages don't have slugs yet return page.path default: throw new Error(`Unknown page type '${page.type}'`) @@ -312,6 +314,7 @@ export function getPageIcon(page: Page) { case PageType.Markdown: case PageType.Reference: case PageType.Integration: + case PageType.Troubleshooting: return case PageType.GithubDiscussion: return @@ -325,6 +328,7 @@ export function getPageSectionIcon(page: Page) { case PageType.Markdown: case PageType.Reference: case PageType.Integration: + case PageType.Troubleshooting: return case PageType.GithubDiscussion: return From 9486e6e1a4e8ac9ea1b34af34749168d6309ff4a Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:56:10 +0100 Subject: [PATCH 2/6] feat: custom report snippets from inline sql editor (#43090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adds option to create snippets from inline sql editor - removes the date range from the header, its noisy ## to test - go to custom report - add block → snippets → add snippet - create a new snippet in the inline SQL Editor - back to add block → snippets → select your snippet - your snippet should show in the report - now try updating the snippet in the inline SQL Editor - the chart/block should auto update - amazing! --- .../interfaces/Reports/MetricOptions.tsx | 48 +++++++++++++++---- .../components/interfaces/Reports/Reports.tsx | 28 +++-------- 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/apps/studio/components/interfaces/Reports/MetricOptions.tsx b/apps/studio/components/interfaces/Reports/MetricOptions.tsx index 8ebe236af42bc..ffd5bd424339c 100644 --- a/apps/studio/components/interfaces/Reports/MetricOptions.tsx +++ b/apps/studio/components/interfaces/Reports/MetricOptions.tsx @@ -1,17 +1,18 @@ import { useDebounce } from '@uidotdev/usehooks' -import { Home } from 'lucide-react' -import { useState } from 'react' - import { useParams } from 'common' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useContentQuery } from 'data/content/content-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Metric, METRIC_CATEGORIES, METRICS } from 'lib/constants/metrics' +import { Home, Plus } from 'lucide-react' +import { useState } from 'react' +import { editorPanelState } from 'state/editor-panel-state' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import type { Dashboards } from 'types' import { Command_Shadcn_, - CommandEmpty_Shadcn_, CommandGroup_Shadcn_, CommandInput_Shadcn_, CommandItem_Shadcn_, @@ -24,6 +25,7 @@ import { SQL_ICON, } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import { DEPRECATED_REPORTS } from './Reports.constants' interface MetricOptionsProps { @@ -40,6 +42,7 @@ interface MetricOptionsProps { export const MetricOptions = ({ config, handleChartSelection }: MetricOptionsProps) => { const { ref: projectRef } = useParams() const { data: selectedOrganization } = useSelectedOrganizationQuery() + const { openSidebar } = useSidebarManagerSnapshot() const [search, setSearch] = useState('') const { projectAuthAll: authEnabled, projectStorageAll: storageEnabled } = useIsFeatureEnabled([ @@ -56,7 +59,11 @@ export const MetricOptions = ({ config, handleChartSelection }: MetricOptionsPro const { mutate: sendEvent } = useSendEventMutation() const debouncedSearch = useDebounce(search, 300) - const { data, isPending: isLoading } = useContentQuery({ + const { + data, + isPending: isLoading, + refetch, + } = useContentQuery({ projectRef, type: 'sql', name: debouncedSearch.length === 0 ? undefined : debouncedSearch, @@ -96,7 +103,11 @@ export const MetricOptions = ({ config, handleChartSelection }: MetricOptionsPro ) })} - + { + if (open) refetch() + }} + > - ) : ( - No snippets found - )} + ) : !snippets?.length ? ( +

+ No snippets found +

+ ) : null} {snippets?.map((snippet) => ( + +
+ + + { + editorPanelState.openAsNew() + openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) + }} + > +
+ +

Create snippet

+
+
+
diff --git a/apps/studio/components/interfaces/Reports/Reports.tsx b/apps/studio/components/interfaces/Reports/Reports.tsx index 5a7afb238e90a..ca74c461aefcd 100644 --- a/apps/studio/components/interfaces/Reports/Reports.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.tsx @@ -1,12 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' -import dayjs from 'dayjs' -import { groupBy, isEqual, isNull } from 'lodash' -import { ArrowRight, Plus, RefreshCw, Save } from 'lucide-react' -import { useRouter } from 'next/router' -import { DragEvent, useEffect, useState } from 'react' -import { toast } from 'sonner' - import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DatabaseSelector } from 'components/ui/DatabaseSelector' @@ -21,6 +14,7 @@ import { useContentUpsertMutation, } from 'data/content/content-upsert-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import dayjs from 'dayjs' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -28,10 +22,16 @@ import { BASE_PATH } from 'lib/constants' import { Metric, TIME_PERIODS_REPORTS } from 'lib/constants/metrics' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' +import { groupBy, isEqual, isNull } from 'lodash' +import { Plus, RefreshCw, Save } from 'lucide-react' +import { useRouter } from 'next/router' +import { DragEvent, useEffect, useState } from 'react' +import { toast } from 'sonner' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import type { Dashboards } from 'types' import { Button, cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, LogoLoader } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + import { createSqlSnippetSkeletonV2 } from '../SQLEditor/SQLEditor.utils' import { ChartConfig } from '../SQLEditor/UtilityPanel/ChartConfig' import { GridResize } from './GridResize' @@ -450,20 +450,6 @@ const Reports = () => {
} /> - - {startDate && endDate && ( -
- - {dayjs(startDate).format('MMM D, YYYY')} - - - - - - {dayjs(endDate).format('MMM D, YYYY')} - -
- )} From 37a240c581a584b530db7f74cf7b14f5229d1c6b Mon Sep 17 00:00:00 2001 From: "supabase-supabase-autofixer[bot]" <248690971+supabase-supabase-autofixer[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:41:47 +0100 Subject: [PATCH 3/6] [bot] Decrease ESLint ratchet baselines (#43074) Automated weekly decrease of ESLint ratchet baselines. Co-authored-by: github-actions[bot] --- apps/studio/.github/eslint-rule-baselines.json | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index c18c10191a810..5903ab1be2386 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -1,11 +1,11 @@ { "rules": { - "react-hooks/exhaustive-deps": 209, + "react-hooks/exhaustive-deps": 208, "import/no-anonymous-default-export": 57, "@tanstack/query/exhaustive-deps": 13, - "@typescript-eslint/no-explicit-any": 1215, + "@typescript-eslint/no-explicit-any": 1214, "no-restricted-imports": 59, - "no-restricted-exports": 300, + "no-restricted-exports": 297, "react/no-unstable-nested-components": 69 }, "ruleFiles": { @@ -56,7 +56,6 @@ "components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx": 1, "components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx": 1, "components/interfaces/Integrations/Wrappers/WrapperDynamicColumns.tsx": 1, - "components/interfaces/Integrations/templates/StripeSyncEngine/InstallationOverview.tsx": 1, "components/interfaces/Observability/DatabaseInfrastructureSection.tsx": 1, "components/interfaces/Observability/useSlowQueriesCount.ts": 1, "components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts": 1, @@ -302,7 +301,6 @@ "components/interfaces/Database/Backups/PITR/PITR.utils.ts": 1, "components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx": 1, "components/interfaces/Database/EnumeratedTypes/CreateEnumeratedTypeSidePanel.tsx": 2, - "components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx": 1, "components/interfaces/Database/EnumeratedTypes/EditEnumeratedTypeSidePanel.tsx": 2, "components/interfaces/Database/EnumeratedTypes/EnumeratedTypeValueRow.tsx": 1, "components/interfaces/Database/Functions/CreateFunction/FunctionEditor.tsx": 1, @@ -318,7 +316,6 @@ "components/interfaces/Database/Replication/UpdateVersionModal.tsx": 2, "components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx": 1, "components/interfaces/Database/Roles/RoleRow.tsx": 4, - "components/interfaces/Database/Roles/RolesList.tsx": 1, "components/interfaces/Database/Schemas/SchemaGraph.tsx": 1, "components/interfaces/Database/Tables/ColumnList.tsx": 2, "components/interfaces/Docs/Description.tsx": 2, @@ -446,7 +443,6 @@ "components/interfaces/Settings/Logs/PreviewFilterPanel.tsx": 1, "components/interfaces/Settings/Logs/PreviewFilterPanelWithUniversal.tsx": 2, "components/interfaces/Settings/Logs/SidebarV2/SidebarItem.tsx": 1, - "components/interfaces/Sidebar.tsx": 2, "components/interfaces/SignIn/SignInForm.tsx": 1, "components/interfaces/SignIn/SignInWithCustom.tsx": 1, "components/interfaces/SignIn/SignInWithGitHub.tsx": 1, @@ -485,7 +481,7 @@ "components/interfaces/Support/LinkSupportTicketForm.tsx": 6, "components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx": 1, "components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnDefaultValue.tsx": 2, - "components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx": 5, + "components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx": 6, "components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts": 2, "components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx": 1, "components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/InputWithSuggestions.tsx": 2, @@ -494,7 +490,7 @@ "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx": 1, "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownViewer.tsx": 3, "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/index.tsx": 3, - "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx": 9, + "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx": 10, "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts": 1, "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts": 9, "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx": 2, @@ -620,6 +616,7 @@ "data/documents/dpa-request-mutation.ts": 1, "data/edge-functions/edge-function-test-mutation.ts": 1, "data/edge-functions/edge-functions-deploy-mutation.ts": 2, + "data/entitlements/entitlements-query.ts": 1, "data/fdw/fdw-create-mutation.ts": 1, "data/fdw/fdw-update-mutation.ts": 1, "data/fetchers.ts": 12, @@ -805,7 +802,6 @@ "components/grid/components/header/sort/SortRow.tsx": 1, "components/interfaces/Account/TOTPFactors/DeleteFactorModal.tsx": 1, "components/interfaces/App/CommandMenu/CommandMenu.tsx": 1, - "components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx": 1, "components/interfaces/Auth/AuthProvidersForm/FormField.tsx": 1, "components/interfaces/Auth/Policies/PolicyEditor/PolicyAllowedOperation.tsx": 1, "components/interfaces/Auth/Policies/PolicyEditor/PolicyDefinition.tsx": 1, @@ -836,12 +832,10 @@ "components/interfaces/Database/Backups/PITR/PITRStatus.tsx": 1, "components/interfaces/Database/Backups/PITR/TimeInput.tsx": 1, "components/interfaces/Database/EnumeratedTypes/CreateEnumeratedTypeSidePanel.tsx": 1, - "components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx": 1, "components/interfaces/Database/EnumeratedTypes/EditEnumeratedTypeSidePanel.tsx": 1, "components/interfaces/Database/EnumeratedTypes/EnumeratedTypeValueRow.tsx": 1, "components/interfaces/Database/Extensions/ExtensionCardSkeleton.tsx": 1, "components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx": 1, - "components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx": 1, "components/interfaces/Database/Indexes/Indexes.tsx": 1, "components/interfaces/Database/Migrations/Migrations.tsx": 1, "components/interfaces/Database/Privileges/PrivilegesHead.tsx": 1, From ffb362cd10525701c018bb30acbd955403f015f4 Mon Sep 17 00:00:00 2001 From: Chris Chinchilla Date: Mon, 23 Feb 2026 16:10:42 +0100 Subject: [PATCH 4/6] fix: Update message for enabling point in time recovery (#43048) Doing a series of docs updates around backups and tidying some UI copy as I do. ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES --- apps/studio/pages/project/[ref]/database/backups/pitr.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx index 68b1b8bc0aeec..96b0fd6a934f3 100644 --- a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -112,12 +112,12 @@ const PITR = () => { ) : !isActiveHealthy ? ( From d23302d2f5b3392c87cb30d49a00e7532d4179c7 Mon Sep 17 00:00:00 2001 From: Chris Chinchilla Date: Mon, 23 Feb 2026 16:22:15 +0100 Subject: [PATCH 5/6] docs: Fix breaking rendering issues (#43096) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES --- .../with-expo-react-native-social-auth.mdx | 117 ++++++++++-------- .../react-user-management/.env.example | 2 + 2 files changed, 68 insertions(+), 51 deletions(-) create mode 100644 examples/user-management/react-user-management/.env.example diff --git a/apps/docs/content/guides/auth/quickstarts/with-expo-react-native-social-auth.mdx b/apps/docs/content/guides/auth/quickstarts/with-expo-react-native-social-auth.mdx index daa535e479a28..4b82f77273120 100644 --- a/apps/docs/content/guides/auth/quickstarts/with-expo-react-native-social-auth.mdx +++ b/apps/docs/content/guides/auth/quickstarts/with-expo-react-native-social-auth.mdx @@ -53,11 +53,12 @@ Now, create a helper file to initialize the Supabase client for both web and Rea queryGroup="auth-store" > + <$CodeSample path="/auth/expo-social-auth/lib/supabase.web.ts" lines={[[1, -1]]} meta="name=lib/supabase.web.ts" -/> + /> @@ -65,6 +66,7 @@ Now, create a helper file to initialize the Supabase client for both web and Rea If you want to encrypt the user's session information, use `aes-js` and store the encryption key in [Expo SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore). The [`aes-js` library](https://github.com/ricmoo/aes-js) is a reputable JavaScript-only implementation of the AES encryption algorithm in CTR mode. A new 256-bit encryption key is generated using the `react-native-get-random-values` library. This key is stored inside Expo's SecureStore, while the value is encrypted and placed inside AsyncStorage. Make sure that: + - You keep the `expo-secure-storage`, `aes-js` and `react-native-get-random-values` libraries up-to-date. - Choose the correct [`SecureStoreOptions`](https://docs.expo.dev/versions/latest/sdk/securestore/#securestoreoptions) for your app's needs. E.g. [`SecureStore.WHEN_UNLOCKED`](https://docs.expo.dev/versions/latest/sdk/securestore/#securestorewhen_unlocked) regulates when the data can be accessed. - Carefully consider optimizations or other modifications to the above example, as those can lead to introducing subtle security vulnerabilities. @@ -75,8 +77,7 @@ Now, create a helper file to initialize the Supabase client for both web and Rea path="/auth/expo-social-auth/lib/supabase.ts" lines={[[1, -1]]} meta="name=lib/supabase.ts" - -/> + /> @@ -89,7 +90,7 @@ These variables are safe to expose in your Expo app since Supabase has [Row Leve Create a `.env` file containing these variables: <$CodeSample -path="/auth/expo-social-auth/.env" +path="/auth/expo-social-auth/.env.template" lines={[[1, -1]]} meta="name=.env" /> @@ -162,8 +163,9 @@ Wrap the navigation with the `AuthProvider` and `SplashScreenController`. Using [Expo Router's protected routes](https://docs.expo.dev/router/advanced/authentication/#using-protected-routes), you can secure navigation: +{/* prettier-ignore */} <$CodeSample -path="/auth/expo-social-auth/app/\_layout.tsx" +path="/auth/expo-social-auth/app/_layout.tsx" lines={[[1, -1]]} meta="name=app/\_layout.tsx" /> @@ -197,22 +199,22 @@ Start by adding the button inside the login screen: <$CodeTabs> ```tsx name=app/login.tsx -... +… import AppleSignInButton from '@/components/social-auth-buttons/apple/apple-sign-in-button'; -... +… export default function LoginScreen() { return ( <> - ... + … - ... + … ); } -... +… ``` @@ -269,9 +271,11 @@ For more information, follow the [Supabase Login with Apple](/docs/guides/auth/s Then create the iOS specific button component `AppleSignInButton`: - <$CodeSample path="/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.ios.tsx" lines={[[1, -1]]} meta="name=components/social-auth-buttons/apple/apple-sign-in-button.ios.tsx" - -/> + <$CodeSample + path="/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.ios.tsx" + lines={[[1, -1]]} + meta="name=components/social-auth-buttons/apple/apple-sign-in-button.ios.tsx" + /> @@ -280,9 +284,9 @@ For more information, follow the [Supabase Login with Apple](/docs/guides/auth/s <$CodeTabs> ```tsx name=components/social-auth-buttons/apple/apple-sign-in-button.ios.tsx - ... + … const credentialState = await appleAuth.getCredentialStateForUser(appleAuthRequestResponse.user); - ... + … ``` @@ -297,13 +301,13 @@ For more information, follow the [Supabase Login with Apple](/docs/guides/auth/s ```json name=app.json { "expo": { - ... + … "ios": { - ... + … "usesAppleSignIn": true - ... + … }, - ... + … } } ``` @@ -357,9 +361,11 @@ For more information, follow the [Supabase Login with Apple](/docs/guides/auth/s Next, create the Android-specific `AppleSignInButton` component: - <$CodeSample path="/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.android.tsx" lines={[[1, -1]]} meta="name=components/social-auth-buttons/apple/apple-sign-in-button.android.tsx" - -/> + <$CodeSample + path="/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.android.tsx" + lines={[[1, -1]]} + meta="name=components/social-auth-buttons/apple/apple-sign-in-button.android.tsx" + /> You should now be able to test the authentication by running it on a physical device or simulator: @@ -392,9 +398,11 @@ For more information, follow the [Supabase Login with Apple](/docs/guides/auth/s Next, create the Web-specific `AppleSignInButton` component: - <$CodeSample path="/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.web.tsx" lines={[[1, -1]]} meta="name=components/social-auth-buttons/apple/apple-sign-in-button.web.tsx" - -/> + <$CodeSample + path="/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.web.tsx" + lines={[[1, -1]]} + meta="name=components/social-auth-buttons/apple/apple-sign-in-button.web.tsx" + /> Test the authentication in your browser using the tunneled HTTPS URL: @@ -425,14 +433,14 @@ For more information, follow the [Supabase Login with Apple](/docs/guides/auth/s ```json name=app.json { "expo": { - ... + … "ios": { - ... + … "usesAppleSignIn": true - ... + … }, "plugins": ["expo-apple-authentication"] - ... + … } } ``` @@ -441,9 +449,11 @@ For more information, follow the [Supabase Login with Apple](/docs/guides/auth/s Then create the iOS specific button component `AppleSignInButton`: - <$CodeSample path="/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.tsx" lines={[[1, -1]]} meta="name=components/social-auth-buttons/apple/apple-sign-in-button.tsx" - -/> + <$CodeSample + path="/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.tsx" + lines={[[1, -1]]} + meta="name=components/social-auth-buttons/apple/apple-sign-in-button.tsx" + /> @@ -461,22 +471,22 @@ Start by adding the button to the login screen: <$CodeTabs> ```tsx name=app/login.tsx -... +… import GoogleSignInButton from '@/components/social-auth-buttons/google/google-sign-in-button'; -... +… export default function LoginScreen() { return ( <> - ... + … - ... + … ); } -... +… ``` @@ -485,7 +495,7 @@ For Google authentication, you can choose between the following options: - [GN Google Sign In Premium](https://react-native-google-signin.github.io/docs/install#sponsor-only-version) - that supports iOS, Android, and Web by using the latest Google's One Tap sign-in (but [it requires a subscription](https://universal-sign-in.com/)) - [@react-oauth/google](https://github.com/MomenSherif/react-oauth#googlelogin) - that supports Web (so it's not a good option for mobile, but it works) -- Relying on the [``signInWithOAuth](/docs/reference/javascript/auth-signinwithoauth) function of the Supabase Auth - that also supports iOS, Android and Web (useful also to manage any other OAuth provider) +- Relying on the [`signInWithOAuth`](/docs/reference/javascript/auth-signinwithoauth) function of the Supabase Auth - that also supports iOS, Android and Web (useful also to manage any other OAuth provider) @@ -510,13 +520,16 @@ EXPO_PUBLIC_GOOGLE_AUTH_WEB_CLIENT_ID="YOUR_GOOGLE_AUTH_WEB_CLIENT_ID" defaultActiveId="web" queryGroup="google-authentication" > - - Create the mobile generic button component `GoogleSignInButton`: + - <$CodeSample path="/auth/expo-social-auth/components/social-auth-buttons/google/google-sign-in-button.tsx" lines={[[1, -1]]} meta="name=components/social-auth-buttons/google/google-sign-in-button.tsx" + Create the mobile generic button component `GoogleSignInButton`: -/> + <$CodeSample + path="/auth/expo-social-auth/components/social-auth-buttons/google/google-sign-in-button.tsx" + lines={[[1, -1]]} + meta="name=components/social-auth-buttons/google/google-sign-in-button.tsx" + /> Finally, update the iOS and Android projects by running the Expo prebuild command: @@ -550,18 +563,18 @@ EXPO_PUBLIC_GOOGLE_AUTH_WEB_CLIENT_ID="YOUR_GOOGLE_AUTH_WEB_CLIENT_ID" ```json name=app.json { "expo": { - ... - "plugins": { - ... + … + "plugins": [ + … [ "expo-web-browser", { "experimentalLauncherActivity": false } ] - ... - }, - ... + … + ], + … } } ``` @@ -570,9 +583,11 @@ EXPO_PUBLIC_GOOGLE_AUTH_WEB_CLIENT_ID="YOUR_GOOGLE_AUTH_WEB_CLIENT_ID" Then create the iOS specific button component `GoogleSignInButton`: - <$CodeSample path="/auth/expo-social-auth/components/social-auth-buttons/google/google-sign-in-button.web.tsx" lines={[[1, -1]]} meta="name=components/social-auth-buttons/google/google-sign-in-button.web.tsx" - -/> + <$CodeSample + path="/auth/expo-social-auth/components/social-auth-buttons/google/google-sign-in-button.web.tsx" + lines={[[1, -1]]} + meta="name=components/social-auth-buttons/google/google-sign-in-button.web.tsx" + /> Test the authentication in your browser using the tunnelled HTTPS URL: diff --git a/examples/user-management/react-user-management/.env.example b/examples/user-management/react-user-management/.env.example new file mode 100644 index 0000000000000..27bb48db051ff --- /dev/null +++ b/examples/user-management/react-user-management/.env.example @@ -0,0 +1,2 @@ +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY= \ No newline at end of file From 32330e26c091b13a0184fb10ea34c9aaab0277d7 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Mon, 23 Feb 2026 11:47:03 -0400 Subject: [PATCH 6/6] 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. --- apps/www/README.md | 36 ++ apps/www/_go/index.tsx | 9 + apps/www/_go/lead-gen/example-lead-gen.tsx | 148 +++++++++ apps/www/_go/legal/example-legal.tsx | 149 +++++++++ apps/www/_go/thank-you/example-thank-you.tsx | 36 ++ apps/www/app/go/[slug]/page.tsx | 54 +++ apps/www/components/Go/GoPageRenderer.tsx | 45 +++ apps/www/components/Go/TweetsSection.tsx | 111 +++++++ apps/www/lib/go.ts | 34 ++ apps/www/next.config.mjs | 1 + apps/www/package.json | 1 + apps/www/pages/state-of-startups.tsx | 23 +- apps/www/tailwind.config.js | 1 + apps/www/types/go.ts | 3 + packages/marketing/index.ts | 1 + packages/marketing/package.json | 28 ++ packages/marketing/src/crm/customerio.ts | 57 ++++ packages/marketing/src/crm/hubspot.ts | 88 +++++ packages/marketing/src/crm/index.ts | 118 +++++++ .../marketing/src/go/actions/submitForm.ts | 152 +++++++++ packages/marketing/src/go/index.ts | 4 + packages/marketing/src/go/schemas.ts | 307 ++++++++++++++++++ .../marketing/src/go/sections/Confetti.tsx | 93 ++++++ .../src/go/sections/ContentBlocksSection.tsx | 22 ++ .../src/go/sections/FeatureGridSection.tsx | 52 +++ .../marketing/src/go/sections/FormSection.tsx | 230 +++++++++++++ .../marketing/src/go/sections/HeroSection.tsx | 78 +++++ .../marketing/src/go/sections/MediaBlock.tsx | 70 ++++ .../src/go/sections/MetricsSection.tsx | 18 + .../src/go/sections/SectionRenderer.tsx | 73 +++++ .../src/go/sections/SingleColumnSection.tsx | 19 ++ .../src/go/sections/SocialProofSection.tsx | 30 ++ .../src/go/sections/TableOfContents.tsx | 98 ++++++ .../src/go/sections/TextBodySection.tsx | 44 +++ .../src/go/sections/ThreeColumnSection.tsx | 25 ++ .../src/go/sections/TwoColumnSection.tsx | 25 ++ packages/marketing/src/go/sections/index.ts | 13 + .../src/go/templates/GoPageRenderer.tsx | 29 ++ .../src/go/templates/LeadGenTemplate.tsx | 20 ++ .../src/go/templates/LegalTemplate.tsx | 32 ++ .../src/go/templates/ThankYouTemplate.tsx | 24 ++ packages/marketing/src/go/templates/index.ts | 4 + packages/marketing/tsconfig.json | 5 + pnpm-lock.yaml | 31 ++ 44 files changed, 2427 insertions(+), 14 deletions(-) create mode 100644 apps/www/_go/index.tsx create mode 100644 apps/www/_go/lead-gen/example-lead-gen.tsx create mode 100644 apps/www/_go/legal/example-legal.tsx create mode 100644 apps/www/_go/thank-you/example-thank-you.tsx create mode 100644 apps/www/app/go/[slug]/page.tsx create mode 100644 apps/www/components/Go/GoPageRenderer.tsx create mode 100644 apps/www/components/Go/TweetsSection.tsx create mode 100644 apps/www/lib/go.ts create mode 100644 apps/www/types/go.ts create mode 100644 packages/marketing/index.ts create mode 100644 packages/marketing/package.json create mode 100644 packages/marketing/src/crm/customerio.ts create mode 100644 packages/marketing/src/crm/hubspot.ts create mode 100644 packages/marketing/src/crm/index.ts create mode 100644 packages/marketing/src/go/actions/submitForm.ts create mode 100644 packages/marketing/src/go/index.ts create mode 100644 packages/marketing/src/go/schemas.ts create mode 100644 packages/marketing/src/go/sections/Confetti.tsx create mode 100644 packages/marketing/src/go/sections/ContentBlocksSection.tsx create mode 100644 packages/marketing/src/go/sections/FeatureGridSection.tsx create mode 100644 packages/marketing/src/go/sections/FormSection.tsx create mode 100644 packages/marketing/src/go/sections/HeroSection.tsx create mode 100644 packages/marketing/src/go/sections/MediaBlock.tsx create mode 100644 packages/marketing/src/go/sections/MetricsSection.tsx create mode 100644 packages/marketing/src/go/sections/SectionRenderer.tsx create mode 100644 packages/marketing/src/go/sections/SingleColumnSection.tsx create mode 100644 packages/marketing/src/go/sections/SocialProofSection.tsx create mode 100644 packages/marketing/src/go/sections/TableOfContents.tsx create mode 100644 packages/marketing/src/go/sections/TextBodySection.tsx create mode 100644 packages/marketing/src/go/sections/ThreeColumnSection.tsx create mode 100644 packages/marketing/src/go/sections/TwoColumnSection.tsx create mode 100644 packages/marketing/src/go/sections/index.ts create mode 100644 packages/marketing/src/go/templates/GoPageRenderer.tsx create mode 100644 packages/marketing/src/go/templates/LeadGenTemplate.tsx create mode 100644 packages/marketing/src/go/templates/LegalTemplate.tsx create mode 100644 packages/marketing/src/go/templates/ThankYouTemplate.tsx create mode 100644 packages/marketing/src/go/templates/index.ts create mode 100644 packages/marketing/tsconfig.json diff --git a/apps/www/README.md b/apps/www/README.md index de8fd24414337..f2f7df15d90c9 100644 --- a/apps/www/README.md +++ b/apps/www/README.md @@ -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//my-page.tsx` exporting a page object. +2. Register it in `apps/www/_go/index.tsx`. +3. The page will be available at `/go/` automatically via static generation. + #### Customer Stories (Case Studies) Customer stories are defined in MDX files (`apps/www/_customers/*.mdx`) and use a different approach: diff --git a/apps/www/_go/index.tsx b/apps/www/_go/index.tsx new file mode 100644 index 0000000000000..6faa297aba18e --- /dev/null +++ b/apps/www/_go/index.tsx @@ -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 diff --git a/apps/www/_go/lead-gen/example-lead-gen.tsx b/apps/www/_go/lead-gen/example-lead-gen.tsx new file mode 100644 index 0000000000000..ec6fa623ea996 --- /dev/null +++ b/apps/www/_go/lead-gen/example-lead-gen.tsx @@ -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: , + }, + { + 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 diff --git a/apps/www/_go/legal/example-legal.tsx b/apps/www/_go/legal/example-legal.tsx new file mode 100644 index 0000000000000..d25d4f1113ac8 --- /dev/null +++ b/apps/www/_go/legal/example-legal.tsx @@ -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 diff --git a/apps/www/_go/thank-you/example-thank-you.tsx b/apps/www/_go/thank-you/example-thank-you.tsx new file mode 100644 index 0000000000000..34e75401bd2da --- /dev/null +++ b/apps/www/_go/thank-you/example-thank-you.tsx @@ -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: ( +
+ + +
+ ), + }, + ], +} + +export default page diff --git a/apps/www/app/go/[slug]/page.tsx b/apps/www/app/go/[slug]/page.tsx new file mode 100644 index 0000000000000..c26dd252aea0a --- /dev/null +++ b/apps/www/app/go/[slug]/page.tsx @@ -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 }): Promise { + 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 }) { + const { slug } = await params + const page = getGoPageBySlug(slug) + + if (!page) { + notFound() + } + + return +} diff --git a/apps/www/components/Go/GoPageRenderer.tsx b/apps/www/components/Go/GoPageRenderer.tsx new file mode 100644 index 0000000000000..1fc19c6fb34bd --- /dev/null +++ b/apps/www/components/Go/GoPageRenderer.tsx @@ -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 ( + <> + + +
+ +
+
+
+ © {new Date().getFullYear()} Supabase Inc. +
+ + Privacy Policy + + + Terms of Service + +
+
+
+ + ) +} diff --git a/apps/www/components/Go/TweetsSection.tsx b/apps/www/components/Go/TweetsSection.tsx new file mode 100644 index 0000000000000..1e512fabe7036 --- /dev/null +++ b/apps/www/components/Go/TweetsSection.tsx @@ -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 ( +
+ + {topTweets.slice(0, 9).map((tweet: any) => ( + + + + ))} +
+ ) +} + +function DesktopMarquee() { + const isMd = useBreakpoint(1024) + const visibleTweets = topTweets.slice(0, isMd ? 12 : 18) + + return ( +
+
+ {range(0, 3).map((_, idx) => ( +
+ {visibleTweets.map((tweet: any, i: number) => ( + 12 && 'hidden lg:block' + )} + > + + + ))} +
+ ))} +
+
+
+ ) +} + +export default function TweetsSection({ section }: { section: GoTweetsSection }) { + return ( +
+ {(section.title || section.description || section.ctas) && ( +
+ {section.title && ( +

{section.title}

+ )} + {section.description && ( +

{section.description}

+ )} + {section.ctas && section.ctas.length > 0 && ( +
+ {section.ctas.map((cta, i) => ( + + ))} +
+ )} +
+ )} +
+ +
+
+ +
+
+ ) +} diff --git a/apps/www/lib/go.ts b/apps/www/lib/go.ts new file mode 100644 index 0000000000000..3876928737c05 --- /dev/null +++ b/apps/www/lib/go.ts @@ -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() + + 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) +} diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index c401aafdcc066..1ae45ea0622d6 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -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', ], diff --git a/apps/www/package.json b/apps/www/package.json index 588d7f4e71d25..7affd1ea62bea 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -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", diff --git a/apps/www/pages/state-of-startups.tsx b/apps/www/pages/state-of-startups.tsx index 2ce5bb779d9ab..e96aef08b3864 100644 --- a/apps/www/pages/state-of-startups.tsx +++ b/apps/www/pages/state-of-startups.tsx @@ -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() diff --git a/apps/www/tailwind.config.js b/apps/www/tailwind.config.js index e9d3e989b96d9..0fd6f9784b793 100644 --- a/apps/www/tailwind.config.js +++ b/apps/www/tailwind.config.js @@ -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: { diff --git a/apps/www/types/go.ts b/apps/www/types/go.ts new file mode 100644 index 0000000000000..acd1d68ea14b8 --- /dev/null +++ b/apps/www/types/go.ts @@ -0,0 +1,3 @@ +export type { GoPage, GoPageInput, LeadGenPage, ThankYouPage, LegalPage } from 'marketing' + +export { goPageSchema } from 'marketing' diff --git a/packages/marketing/index.ts b/packages/marketing/index.ts new file mode 100644 index 0000000000000..8f286a4718521 --- /dev/null +++ b/packages/marketing/index.ts @@ -0,0 +1 @@ +export * from './src/go' diff --git a/packages/marketing/package.json b/packages/marketing/package.json new file mode 100644 index 0000000000000..e4b2230f2500d --- /dev/null +++ b/packages/marketing/package.json @@ -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" +} diff --git a/packages/marketing/src/crm/customerio.ts b/packages/marketing/src/crm/customerio.ts new file mode 100644 index 0000000000000..68285f772e352 --- /dev/null +++ b/packages/marketing/src/crm/customerio.ts @@ -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 { + 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 = {}): Promise { + await this.makeRequest(`/customers/${encodeURIComponent(email)}`, 'PUT', { + email, + ...attributes, + }) + } + + async track( + email: string, + event: string, + properties: Record = {} + ): Promise { + await this.makeRequest(`/customers/${encodeURIComponent(email)}/events`, 'POST', { + name: event, + data: properties, + timestamp: Math.floor(Date.now() / 1000), + }) + } +} diff --git a/packages/marketing/src/crm/hubspot.ts b/packages/marketing/src/crm/hubspot.ts new file mode 100644 index 0000000000000..c34036dd78936 --- /dev/null +++ b/packages/marketing/src/crm/hubspot.ts @@ -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, + options?: { + pageUri?: string + pageName?: string + consent?: string + } + ): Promise { + 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}`) + } + } +} diff --git a/packages/marketing/src/crm/index.ts b/packages/marketing/src/crm/index.ts new file mode 100644 index 0000000000000..ce5eb2573ae39 --- /dev/null +++ b/packages/marketing/src/crm/index.ts @@ -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 + /** 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 + /** Profile attributes to set on the Customer.io contact */ + customerioProfile?: Record +} + +type BaseEventOptions = { + /** Email used as identifier */ + email: string +} + +type SubmitEventFor = BaseEventOptions & + (T extends { hubspot: HubSpotConfig } ? HubSpotEventFields : Partial) & + (T extends { customerio: CustomerIOConfig } + ? CustomerIOEventFields + : Partial) + +export class CRMClient { + 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): Promise<{ errors: Error[] }> { + const promises: Promise[] = [] + 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 } + } +} diff --git a/packages/marketing/src/go/actions/submitForm.ts b/packages/marketing/src/go/actions/submitForm.ts new file mode 100644 index 0000000000000..f0a9ffdb3bacf --- /dev/null +++ b/packages/marketing/src/go/actions/submitForm.ts @@ -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, + context?: { pageUri?: string; pageName?: string } +): Promise { + 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 | 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 | 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) : 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.', + ], + } + } +} diff --git a/packages/marketing/src/go/index.ts b/packages/marketing/src/go/index.ts new file mode 100644 index 0000000000000..24186b54d8382 --- /dev/null +++ b/packages/marketing/src/go/index.ts @@ -0,0 +1,4 @@ +export * from './schemas' +export * from './sections' +export * from './templates' +export * from './actions/submitForm' diff --git a/packages/marketing/src/go/schemas.ts b/packages/marketing/src/go/schemas.ts new file mode 100644 index 0000000000000..5c879877f7886 --- /dev/null +++ b/packages/marketing/src/go/schemas.ts @@ -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 +export type GoVideo = z.infer +export type GoCta = z.infer +export type GoHeroSection = z.infer +export type GoContentBlock = z.infer +export type GoContentBlocksSection = z.infer +export type GoSocialProofSection = z.infer +export type GoTextBodySection = z.infer +export type GoSingleColumnSection = z.infer +export type GoTwoColumnSection = z.infer +export type GoThreeColumnSection = z.infer +export type GoFormField = z.infer +export type GoFormSection = z.infer +export type GoHubSpotFormConfig = z.infer +export type GoCustomerIOFormConfig = z.infer +export type GoFormCrmConfig = z.infer +export type GoFeatureGridItem = z.infer +export type GoFeatureGridSection = z.infer +export type GoMetricItem = z.infer +export type GoMetricsSection = z.infer +export type GoTweetsSection = z.infer +export type GoSection = z.infer +export type GoMetadata = z.infer + +export type GoPage = z.infer +export type LeadGenPage = z.infer +export type ThankYouPage = z.infer +export type LegalPage = z.infer + +/** Input type for registry files — fields with defaults are optional */ +export type GoPageInput = z.input diff --git a/packages/marketing/src/go/sections/Confetti.tsx b/packages/marketing/src/go/sections/Confetti.tsx new file mode 100644 index 0000000000000..41d3306ff88f4 --- /dev/null +++ b/packages/marketing/src/go/sections/Confetti.tsx @@ -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(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 ( + + ) +} diff --git a/packages/marketing/src/go/sections/ContentBlocksSection.tsx b/packages/marketing/src/go/sections/ContentBlocksSection.tsx new file mode 100644 index 0000000000000..75b6c0c783dcd --- /dev/null +++ b/packages/marketing/src/go/sections/ContentBlocksSection.tsx @@ -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 ( +
+ {section.heading && ( +

{section.heading}

+ )} +
+ {section.blocks.map((block) => ( +
+ {block.icon && {block.icon}} +

{block.heading}

+

{block.body}

+
+ ))} +
+
+ ) +} diff --git a/packages/marketing/src/go/sections/FeatureGridSection.tsx b/packages/marketing/src/go/sections/FeatureGridSection.tsx new file mode 100644 index 0000000000000..16900432698cf --- /dev/null +++ b/packages/marketing/src/go/sections/FeatureGridSection.tsx @@ -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 ( +
+ {(section.title || section.description) && ( +
+ {section.title && ( +

{section.title}

+ )} + {section.description && ( +

{section.description}

+ )} +
+ )} +
+
+ {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 ( +
+ {item.icon && {item.icon}} +

{item.title}

+

+ {item.description} +

+
+ ) + })} +
+
+
+ ) +} diff --git a/packages/marketing/src/go/sections/FormSection.tsx b/packages/marketing/src/go/sections/FormSection.tsx new file mode 100644 index 0000000000000..ba566d67998e1 --- /dev/null +++ b/packages/marketing/src/go/sections/FormSection.tsx @@ -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 ( + onChange(e.target.value)} + /> + ) + case 'textarea': + return ( + onChange(e.target.value)} + /> + ) + case 'select': + return ( + + + + + + {field.options.map((opt) => ( + + {opt.label} + + ))} + + + ) + default: { + const _exhaustive: never = field + return null + } + } +} + +type SubmitState = 'idle' | 'loading' | 'success' | 'error' + +export default function FormSection({ section }: { section: GoFormSection }) { + const [values, setValues] = useState>(() => + Object.fromEntries(section.fields.map((f) => [f.name, ''])) + ) + const [submitState, setSubmitState] = useState('idle') + const [errorMessages, setErrorMessages] = useState([]) + + 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 ( +
+
+
+

Thank you!

+

+ {section.successMessage ?? + "We've received your submission and will be in touch soon."} +

+
+
+
+ ) + } + + return ( +
+
+ {(section.title || section.description) && ( +
+ {section.title && ( +

+ {section.title} +

+ )} + {section.description && ( +

{section.description}

+ )} +
+ )} +
+ {rows.map((row, rowIndex) => ( +
1 ? 'grid grid-cols-1 sm:grid-cols-2 gap-4' : undefined} + > + {row.map((field) => ( +
+ + handleChange(field.name, v)} + /> +
+ ))} +
+ ))} + + {submitState === 'error' && errorMessages.length > 0 && ( +
+ {errorMessages.map((msg, i) => ( +

+ {msg} +

+ ))} +
+ )} + +
+ + + + {section.disclaimer && ( +
+

{children}

, a: ({ href, children }) => {children} }} + > + {section.disclaimer} +
+
+ )} +
+
+
+ ) +} diff --git a/packages/marketing/src/go/sections/HeroSection.tsx b/packages/marketing/src/go/sections/HeroSection.tsx new file mode 100644 index 0000000000000..97abb1c658f87 --- /dev/null +++ b/packages/marketing/src/go/sections/HeroSection.tsx @@ -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 ( +
+
+
+ {section.subtitle && ( +

+ {section.subtitle} +

+ )} +

+ {section.title} +

+ {section.description && ( +

+ {section.description} +

+ )} + {section.ctas && section.ctas.length > 0 && ( +
+ {section.ctas.map((cta) => ( + + ))} +
+ )} +
+ + +
+
+ ) +} diff --git a/packages/marketing/src/go/sections/MediaBlock.tsx b/packages/marketing/src/go/sections/MediaBlock.tsx new file mode 100644 index 0000000000000..e5632391aae89 --- /dev/null +++ b/packages/marketing/src/go/sections/MediaBlock.tsx @@ -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 ? ( + {image.alt} + ) : video ? ( +