feat(website): add /partners/apply standalone page (#21219)

## 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
This commit is contained in:
Rashad Karanouh
2026-06-04 16:30:26 +04:00
committed by GitHub
parent 63435a7937
commit 5a55021e26
10 changed files with 173 additions and 17 deletions
@@ -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 (
<ApplyPageBackground>
<ApplyPageContainer>
<PartnerApplicationWizard
resetSignal={0}
onSuccess={() => router.push('/partners/list')}
slots={PAGE_SLOTS}
/>
</ApplyPageContainer>
</ApplyPageBackground>
);
}
@@ -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<LocaleRouteParams>;
};
export default async function PartnerApplyPage({ params }: ApplyPageProps) {
await getRouteI18n(params);
return <PartnerApplicationPageContent />;
}
@@ -7,7 +7,12 @@ describe('robots', () => {
expect(rule).toMatchObject({
allow: '/',
disallow: ['/api/', '/halftone', '/enterprise/activate'],
disallow: [
'/api/',
'/partners/apply',
'/halftone',
'/enterprise/activate',
],
userAgent: '*',
});
});
@@ -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;
}
@@ -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 }));
@@ -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<string, unknown>)['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 });
}
@@ -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',
]);
@@ -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',
@@ -9,6 +9,7 @@ export type WebsiteRouteId =
| 'pricing'
| 'partners'
| 'partnersList'
| 'partnersApply'
| 'releases'
| 'customers'
| 'articles'
@@ -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 (
<>
<TitleBlock>
<Modal.Title
<Title
render={
<TitleHeadingWrapper>
<Heading as="h2" size="lg" weight="light">
@@ -286,7 +299,7 @@ export function PartnerApplicationWizard({
<TitleBlock>
{stepIndex === 0 ? (
<>
<Modal.Title
<Title
render={
<TitleHeadingWrapper>
<Heading as="h2" size="lg" weight="light">
@@ -301,7 +314,7 @@ export function PartnerApplicationWizard({
</TitleHeadingWrapper>
}
/>
<Modal.Description
<Description
render={
<SubtitleStack>
<Body size="md">
@@ -319,7 +332,7 @@ export function PartnerApplicationWizard({
{stepIndex === 0 ? (
<HeaderLabel>{stepLabelNode}</HeaderLabel>
) : (
<Modal.Title render={<HeaderLabel>{stepLabelNode}</HeaderLabel>} />
<Title render={<HeaderLabel>{stepLabelNode}</HeaderLabel>} />
)}
<StepIndicator stepCount={STEPS.length} activeStepIndex={stepIndex} />
</HeaderStrip>