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:
+55
@@ -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'
|
||||
|
||||
+17
-4
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user