diff --git a/packages/twenty-website/src/app/[locale]/partners/apply/PartnerApplicationPageContent.tsx b/packages/twenty-website/src/app/[locale]/partners/apply/PartnerApplicationPageContent.tsx new file mode 100644 index 0000000000..668dc01178 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/apply/PartnerApplicationPageContent.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { PartnerApplicationWizard } from '@/sections/PartnerApplication/wizard/PartnerApplicationWizard'; +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { useRouter } from 'next/navigation'; +import { type ReactElement } from 'react'; + +function PassTitle({ render }: { render?: ReactElement }) { + return <>{render}; +} + +function PassDescription({ render }: { render?: ReactElement }) { + return <>{render}; +} + +const PAGE_SLOTS = { Title: PassTitle, Description: PassDescription }; + +const ApplyPageBackground = styled.div` + align-items: center; + background: #0c0c0c; + display: flex; + justify-content: center; + min-height: 100vh; +`; + +const ApplyPageContainer = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: ${theme.spacing(4)}; + max-width: min(720px, 100%); + padding: ${theme.spacing(5)} ${theme.spacing(4)}; + width: 100%; + + @media (min-width: ${theme.breakpoints.md}px) { + padding: ${theme.spacing(6)}; + } +`; + +export function PartnerApplicationPageContent() { + const router = useRouter(); + + return ( + + + router.push('/partners/list')} + slots={PAGE_SLOTS} + /> + + + ); +} diff --git a/packages/twenty-website/src/app/[locale]/partners/apply/page.tsx b/packages/twenty-website/src/app/[locale]/partners/apply/page.tsx new file mode 100644 index 0000000000..22bb92499d --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/apply/page.tsx @@ -0,0 +1,17 @@ +import { buildRouteMetadata } from '@/lib/seo'; +import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server'; +import { PartnerApplicationPageContent } from './PartnerApplicationPageContent'; + +export const generateMetadata = buildRouteMetadata('partnersApply', { + extend: { robots: { index: false, follow: false } }, +}); + +type ApplyPageProps = { + params: Promise; +}; + +export default async function PartnerApplyPage({ params }: ApplyPageProps) { + await getRouteI18n(params); + + return ; +} diff --git a/packages/twenty-website/src/app/__tests__/robots.test.ts b/packages/twenty-website/src/app/__tests__/robots.test.ts index a932345399..1c0b0891b2 100644 --- a/packages/twenty-website/src/app/__tests__/robots.test.ts +++ b/packages/twenty-website/src/app/__tests__/robots.test.ts @@ -7,7 +7,12 @@ describe('robots', () => { expect(rule).toMatchObject({ allow: '/', - disallow: ['/api/', '/halftone', '/enterprise/activate'], + disallow: [ + '/api/', + '/partners/apply', + '/halftone', + '/enterprise/activate', + ], userAgent: '*', }); }); diff --git a/packages/twenty-website/src/app/_components/FooterVisibilityGate.tsx b/packages/twenty-website/src/app/_components/FooterVisibilityGate.tsx index c53661f48c..0c495b0623 100644 --- a/packages/twenty-website/src/app/_components/FooterVisibilityGate.tsx +++ b/packages/twenty-website/src/app/_components/FooterVisibilityGate.tsx @@ -11,7 +11,7 @@ type FooterVisibilityGateProps = { export function FooterVisibilityGate({ children }: FooterVisibilityGateProps) { const route = useUnlocalizedPathname(); - if (route === '/halftone') { + if (route === '/halftone' || route === '/partners/apply') { return null; } diff --git a/packages/twenty-website/src/app/api/partner-application/__tests__/route.test.ts b/packages/twenty-website/src/app/api/partner-application/__tests__/route.test.ts index a17110ff33..efabe12ebe 100644 --- a/packages/twenty-website/src/app/api/partner-application/__tests__/route.test.ts +++ b/packages/twenty-website/src/app/api/partner-application/__tests__/route.test.ts @@ -142,7 +142,12 @@ describe('POST /api/partner-application', () => { it('forwards a valid submission to the webhook with camelCase payload + header auth and returns 200', async () => { const fetchSpy = jest .fn() - .mockResolvedValue(new Response(null, { status: 200 })); + .mockResolvedValue( + new Response( + JSON.stringify({ ok: true, created: true, partnerId: 'test-id' }), + { status: 200 }, + ), + ); global.fetch = fetchSpy; const { POST } = await loadRoute(); @@ -170,7 +175,12 @@ describe('POST /api/partner-application', () => { it('forwards rich optional wizard fields with camelCase keys', async () => { const fetchSpy = jest .fn() - .mockResolvedValue(new Response(null, { status: 200 })); + .mockResolvedValue( + new Response( + JSON.stringify({ ok: true, created: true, partnerId: 'test-id' }), + { status: 200 }, + ), + ); global.fetch = fetchSpy; const { POST } = await loadRoute(); @@ -202,7 +212,12 @@ describe('POST /api/partner-application', () => { it('omits optional rich fields when not provided', async () => { const fetchSpy = jest .fn() - .mockResolvedValue(new Response(null, { status: 200 })); + .mockResolvedValue( + new Response( + JSON.stringify({ ok: true, created: true, partnerId: 'test-id' }), + { status: 200 }, + ), + ); global.fetch = fetchSpy; const { POST } = await loadRoute(); @@ -246,7 +261,14 @@ describe('POST /api/partner-application', () => { it('rate-limits the same IP after the burst capacity is spent', async () => { global.fetch = jest .fn() - .mockResolvedValue(new Response(null, { status: 200 })); + .mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ ok: true, created: true, partnerId: 'test-id' }), + { status: 200 }, + ), + ), + ); const { POST } = await loadRoute(); const ip = '203.0.113.99'; const statuses: number[] = []; @@ -261,7 +283,12 @@ describe('POST /api/partner-application', () => { it('attaches a Retry-After header on 429 responses', async () => { global.fetch = jest .fn() - .mockResolvedValue(new Response(null, { status: 200 })); + .mockResolvedValue( + new Response( + JSON.stringify({ ok: true, created: true, partnerId: 'test-id' }), + { status: 200 }, + ), + ); const { POST } = await loadRoute(); const ip = '203.0.113.100'; for (let i = 0; i < 5; i++) await POST(buildRequest({ ip })); diff --git a/packages/twenty-website/src/app/api/partner-application/route.ts b/packages/twenty-website/src/app/api/partner-application/route.ts index 27762e766c..4094040a82 100644 --- a/packages/twenty-website/src/app/api/partner-application/route.ts +++ b/packages/twenty-website/src/app/api/partner-application/route.ts @@ -149,13 +149,14 @@ export async function POST(request: Request) { ); } + let upstreamBody: string; + try { + upstreamBody = await upstream.response.text(); + } catch { + upstreamBody = ''; + } + if (!upstream.response.ok) { - let upstreamBody = ''; - try { - upstreamBody = await upstream.response.text(); - } catch { - upstreamBody = '(could not read upstream body)'; - } console.error( '[partner-application] upstream returned non-2xx', JSON.stringify({ @@ -172,5 +173,31 @@ export async function POST(request: Request) { ); } + let logicResult: unknown; + try { + logicResult = JSON.parse(upstreamBody); + } catch { + logicResult = null; + } + + if ( + typeof logicResult !== 'object' || + logicResult === null || + (logicResult as Record)['ok'] !== true + ) { + console.error( + '[partner-application] logic function returned non-ok result', + JSON.stringify({ + url: webhookUrl, + body: upstreamBody.slice(0, 2000), + payloadKeys: Object.keys(payload), + }), + ); + return NextResponse.json( + { error: 'Partner application could not be submitted.' }, + { status: 502 }, + ); + } + return NextResponse.json({ success: true }); } diff --git a/packages/twenty-website/src/lib/website-routing/__tests__/website-routes.test.ts b/packages/twenty-website/src/lib/website-routing/__tests__/website-routes.test.ts index 87a62b8873..de2febc0fd 100644 --- a/packages/twenty-website/src/lib/website-routing/__tests__/website-routes.test.ts +++ b/packages/twenty-website/src/lib/website-routing/__tests__/website-routes.test.ts @@ -41,6 +41,7 @@ describe('website route registry', () => { it('derives robots disallow paths from routes marked private', () => { expect(getRobotsDisallowedRoutePaths()).toEqual([ + '/partners/apply', '/halftone', '/enterprise/activate', ]); diff --git a/packages/twenty-website/src/lib/website-routing/static-website-routes.ts b/packages/twenty-website/src/lib/website-routing/static-website-routes.ts index a241bf11ca..b1eb1b67ba 100644 --- a/packages/twenty-website/src/lib/website-routing/static-website-routes.ts +++ b/packages/twenty-website/src/lib/website-routing/static-website-routes.ts @@ -57,6 +57,16 @@ export const STATIC_WEBSITE_ROUTES = [ priority: 0.7, indexed: true, }, + { + id: 'partnersApply', + path: '/partners/apply', + title: msg`Become a Twenty Partner — Application`, + description: msg`Apply to join the Twenty certified partner ecosystem.`, + changeFrequency: 'yearly', + priority: 0, + indexed: false, + robotsDisallow: true, + }, { id: 'releases', path: '/releases', diff --git a/packages/twenty-website/src/lib/website-routing/types.ts b/packages/twenty-website/src/lib/website-routing/types.ts index 272b7228b8..cdbd1e08f9 100644 --- a/packages/twenty-website/src/lib/website-routing/types.ts +++ b/packages/twenty-website/src/lib/website-routing/types.ts @@ -9,6 +9,7 @@ export type WebsiteRouteId = | 'pricing' | 'partners' | 'partnersList' + | 'partnersApply' | 'releases' | 'customers' | 'articles' diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/PartnerApplicationWizard.tsx b/packages/twenty-website/src/sections/PartnerApplication/wizard/PartnerApplicationWizard.tsx index 7231ddc5df..7680ee35e9 100644 --- a/packages/twenty-website/src/sections/PartnerApplication/wizard/PartnerApplicationWizard.tsx +++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/PartnerApplicationWizard.tsx @@ -150,16 +150,29 @@ function StepRenderer({ } } +type DialogPrimitive = React.ComponentType<{ render?: React.ReactElement }>; + +type WizardSlots = { + Title?: DialogPrimitive; + Description?: DialogPrimitive; +}; + type WizardProps = { resetSignal: number; onSuccess: () => void; + slots?: WizardSlots; }; export function PartnerApplicationWizard({ resetSignal, onSuccess, + slots, }: WizardProps) { const { i18n } = useLingui(); + const { + Title = Modal.Title as DialogPrimitive, + Description = Modal.Description as DialogPrimitive, + } = slots ?? {}; const controller = usePartnerApplicationState(); const { state, @@ -247,7 +260,7 @@ export function PartnerApplicationWizard({ return ( <> - @@ -286,7 +299,7 @@ export function PartnerApplicationWizard({ {stepIndex === 0 ? ( <> - @@ -301,7 +314,7 @@ export function PartnerApplicationWizard({ } /> - @@ -319,7 +332,7 @@ export function PartnerApplicationWizard({ {stepIndex === 0 ? ( {stepLabelNode} ) : ( - {stepLabelNode}} /> + {stepLabelNode}</HeaderLabel>} /> )} <StepIndicator stepCount={STEPS.length} activeStepIndex={stepIndex} /> </HeaderStrip>