From 5a55021e268e63230b06329d7955a718f9be5603 Mon Sep 17 00:00:00 2001
From: Rashad Karanouh <11599358+rashad@users.noreply.github.com>
Date: Thu, 4 Jun 2026 16:30:26 +0400
Subject: [PATCH] feat(website): add /partners/apply standalone page (#21219)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- Adds a standalone `/partners/apply` page — a shareable URL that opens
the partner application wizard full-page (no modal, no nav, no footer),
on a plain black background
- Adds a `slots` prop to `PartnerApplicationWizard` so it can render
outside a `Dialog.Root` context (Base UI), keeping the existing modal on
`/partners` completely untouched
- Surfaces logic function errors to the user: the API route now checks
the webhook response body for `ok: true`, so a silent backend failure no
longer shows a false success state
## Test plan
- [ ] `yarn jest --no-coverage` — 365/365 passing
- [ ] `npx oxlint -c .oxlintrc.json .` — 0 errors (1 pre-existing
warning unrelated to this PR)
- [ ] `npx oxfmt --check .` — clean
- [ ] `npx tsc --noEmit` — clean
- [ ] Visit `/partners/apply` — wizard loads full-page on black
background, no menu, no footer
- [ ] Complete the wizard and submit — redirects to `/partners/list`
- [ ] Visit `/partners` — "Become a partner" modal still opens normally
---
.../apply/PartnerApplicationPageContent.tsx | 55 +++++++++++++++++++
.../src/app/[locale]/partners/apply/page.tsx | 17 ++++++
.../src/app/__tests__/robots.test.ts | 7 ++-
.../app/_components/FooterVisibilityGate.tsx | 2 +-
.../__tests__/route.test.ts | 37 +++++++++++--
.../src/app/api/partner-application/route.ts | 39 +++++++++++--
.../__tests__/website-routes.test.ts | 1 +
.../website-routing/static-website-routes.ts | 10 ++++
.../src/lib/website-routing/types.ts | 1 +
.../wizard/PartnerApplicationWizard.tsx | 21 +++++--
10 files changed, 173 insertions(+), 17 deletions(-)
create mode 100644 packages/twenty-website/src/app/[locale]/partners/apply/PartnerApplicationPageContent.tsx
create mode 100644 packages/twenty-website/src/app/[locale]/partners/apply/page.tsx
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}} />
)}