diff --git a/packages/twenty-website/.gitignore b/packages/twenty-website/.gitignore
index 9dd27decc4..2c0524c1b0 100644
--- a/packages/twenty-website/.gitignore
+++ b/packages/twenty-website/.gitignore
@@ -30,7 +30,7 @@ yarn-error.log*
# local env files
.env
-.env*.local
+.env*
# vercel
.vercel
diff --git a/packages/twenty-website/src/app/[locale]/partners/components/PartnerThreeCards.tsx b/packages/twenty-website/src/app/[locale]/partners/components/PartnerThreeCards.tsx
deleted file mode 100644
index 2f65814b98..0000000000
--- a/packages/twenty-website/src/app/[locale]/partners/components/PartnerThreeCards.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Trans } from '@lingui/react/macro';
-
-import { PARTNER_ILLUSTRATION_CARDS } from '@/app/[locale]/partners/three-cards-illustration.data';
-import { Eyebrow, Heading, HeadingPart } from '@/design-system/components';
-import {
- IllustrationCards,
- type ThreeCardsScrollLayoutOptions,
-} from '@/sections/ThreeCards';
-import { ThreeCardsIntro, ThreeCardsSection } from '@/templates/ThreeCards';
-
-const SCROLL_LAYOUT_OPTIONS: ThreeCardsScrollLayoutOptions = {
- endEdgeRatio: 0.28,
- initialScale: 0.935,
- initialTranslateY: 132,
- opacityRamp: 0.28,
- stagger: 0.16,
-};
-
-export function PartnerThreeCards() {
- return (
-
-
-
-
- Which partner program is right for you?
-
-
-
-
-
- Find the program that fits your business
-
-
- and unlock new opportunities with Twenty
-
-
-
-
-
-
- );
-}
diff --git a/packages/twenty-website/src/app/[locale]/partners/page.tsx b/packages/twenty-website/src/app/[locale]/partners/page.tsx
index fa39b00d87..2f9eb8ef4a 100644
--- a/packages/twenty-website/src/app/[locale]/partners/page.tsx
+++ b/packages/twenty-website/src/app/[locale]/partners/page.tsx
@@ -9,7 +9,6 @@ import { CaseStudyCatalogPromo } from '@/sections/CaseStudyCatalog';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { PartnerSignoff } from '@/app/[locale]/partners/components/PartnerSignoff';
import { PartnerTestimonials } from '@/app/[locale]/partners/components/PartnerTestimonials';
-import { PartnerThreeCards } from '@/app/[locale]/partners/components/PartnerThreeCards';
import { theme } from '@/theme';
import { buildRouteMetadata } from '@/lib/seo';
import { styled } from '@linaria/react';
@@ -58,8 +57,6 @@ export default async function PartnerPage({ params }: PartnerPageProps) {
/>
-
-
diff --git a/packages/twenty-website/src/app/[locale]/partners/three-cards-illustration.data.ts b/packages/twenty-website/src/app/[locale]/partners/three-cards-illustration.data.ts
deleted file mode 100644
index 20e840ec07..0000000000
--- a/packages/twenty-website/src/app/[locale]/partners/three-cards-illustration.data.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { msg } from '@lingui/core/macro';
-import type { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards';
-
-export const PARTNER_ILLUSTRATION_CARDS: ThreeCardsIllustrationCardType[] = [
- {
- heading: msg`Technology Partners`,
- body: msg`Build integrations that connect Twenty with the tools your customers already use. Help us expand the Twenty ecosystem.`,
- benefits: [
- { text: msg`Co-marketing opportunities`, icon: 'users' },
- { text: msg`Listing on Twenty integrations page`, icon: 'search' },
- { text: msg`Soon: earn revenue`, icon: 'tag' },
- ],
- action: {
- kind: 'partnerApplication',
- label: msg`Become a Technology Partner`,
- programId: 'technology',
- },
- attribution: undefined,
- illustration: 'programming',
- },
- {
- heading: msg`Content & Community Partners`,
- body: msg`Share Twenty with your audience and help shape the future of the #1 Open Source CRM. We're looking for creators, educators, and community builders who want to showcase great software.`,
- benefits: [
- { text: msg`Revenue share for referred customers`, icon: 'tag' },
- {
- text: msg`Exclusive content collaboration opportunities`,
- icon: 'edit',
- },
- { text: msg`Marketing assets & brand resources`, icon: 'book' },
- ],
- action: {
- kind: 'partnerApplication',
- label: msg`Become a Content Partner`,
- programId: 'content',
- },
- attribution: undefined,
- illustration: 'connect',
- },
- {
- heading: msg`Solutions Partners`,
- body: msg`Help customers implement, customize, and succeed with Twenty. Combine sales and services to grow your business.`,
- benefits: [
- { text: msg`Resale discounts & revenue share`, icon: 'tag' },
- { text: msg`Marketplace listing`, icon: 'search' },
- { text: msg`Dedicated partner support`, icon: 'users' },
- ],
- action: {
- kind: 'partnerApplication',
- label: msg`Become a Solution Partner`,
- programId: 'solutions',
- },
- attribution: undefined,
- illustration: 'grow',
- },
-];
diff --git a/packages/twenty-website/src/app/api/partner-application/__tests__/partner-application-schema.test.ts b/packages/twenty-website/src/app/api/partner-application/__tests__/partner-application-schema.test.ts
new file mode 100644
index 0000000000..97693df457
--- /dev/null
+++ b/packages/twenty-website/src/app/api/partner-application/__tests__/partner-application-schema.test.ts
@@ -0,0 +1,118 @@
+import {
+ buildLogicFunctionPayload,
+ partnerApplicationRequestSchema,
+} from '@/app/api/partner-application/partner-application-schema';
+
+const minimalValid = {
+ name: 'Ada Lovelace',
+ email: 'ada@example.com',
+ company: 'Analytical Engines Ltd',
+};
+
+const fullValid = {
+ ...minimalValid,
+ website: 'https://analyticalengines.example',
+ linkedin: 'https://www.linkedin.com/in/ada',
+ city: 'London',
+ country: 'UNITED_KINGDOM',
+ languages: ['ENGLISH', 'FRENCH'],
+ typeOfTeam: 'SOLO',
+ partnerScope: ['ADVISORY', 'SOLUTIONING'],
+ skills: ['React', 'TypeScript'],
+ applicationNotes:
+ 'Workspace https://app.twenty.com/ws/ada · refs: Acme, Globex',
+ hourlyRate: 150,
+ projectBudgetMin: 5000,
+ calendarLink: 'https://cal.com/ada',
+};
+
+describe('partnerApplicationRequestSchema', () => {
+ it('accepts the minimal required payload', () => {
+ const parsed = partnerApplicationRequestSchema.safeParse(minimalValid);
+ expect(parsed.success).toBe(true);
+ });
+
+ it('accepts the full payload', () => {
+ const parsed = partnerApplicationRequestSchema.safeParse(fullValid);
+ expect(parsed.success).toBe(true);
+ });
+
+ it('rejects an unknown country enum value', () => {
+ const parsed = partnerApplicationRequestSchema.safeParse({
+ ...minimalValid,
+ country: 'ATLANTIS',
+ });
+ expect(parsed.success).toBe(false);
+ });
+
+ it('rejects an unknown scope enum value', () => {
+ const parsed = partnerApplicationRequestSchema.safeParse({
+ ...minimalValid,
+ partnerScope: ['NOPE'],
+ });
+ expect(parsed.success).toBe(false);
+ });
+
+ it('rejects a legacy scope enum value', () => {
+ const parsed = partnerApplicationRequestSchema.safeParse({
+ ...minimalValid,
+ partnerScope: ['APPS'],
+ });
+ expect(parsed.success).toBe(false);
+ });
+
+ it('forwards applicationNotes through to the payload', () => {
+ const payload = buildLogicFunctionPayload(fullValid as never);
+ expect(payload.applicationNotes).toContain('Acme');
+ });
+
+ it('rejects the removed deploymentExpertise key (strictObject)', () => {
+ const parsed = partnerApplicationRequestSchema.safeParse({
+ ...minimalValid,
+ deploymentExpertise: ['CLOUD'],
+ });
+ expect(parsed.success).toBe(false);
+ });
+
+ it('rejects unknown top-level keys (strictObject)', () => {
+ const parsed = partnerApplicationRequestSchema.safeParse({
+ ...minimalValid,
+ countryOther: 'Republic of Examples',
+ });
+ expect(parsed.success).toBe(false);
+ });
+
+ it('rejects an invalid email', () => {
+ const parsed = partnerApplicationRequestSchema.safeParse({
+ ...minimalValid,
+ email: 'not-an-email',
+ });
+ expect(parsed.success).toBe(false);
+ });
+});
+
+describe('buildLogicFunctionPayload', () => {
+ it('splits firstName/lastName from name and uses camelCase keys', () => {
+ const payload = buildLogicFunctionPayload(fullValid as never);
+ expect(payload.firstName).toBe('Ada');
+ expect(payload.lastName).toBe('Lovelace');
+ expect(payload.email).toBe('ada@example.com');
+ expect(payload.companyName).toBe('Analytical Engines Ltd');
+ expect(payload.hourlyRate).toBe(150);
+ expect((payload as Record).CurrencyCode).toBeUndefined();
+ });
+
+ it('omits keys for undefined optional fields', () => {
+ const payload = buildLogicFunctionPayload(minimalValid as never);
+ expect('linkedin' in payload).toBe(false);
+ expect('country' in payload).toBe(false);
+ expect('languages' in payload).toBe(false);
+ expect('partnerScope' in payload).toBe(false);
+ expect('domainName' in payload).toBe(false);
+ });
+
+ it('forwards website as domainName when provided', () => {
+ const payload = buildLogicFunctionPayload(fullValid as never);
+ expect(payload.domainName).toBe('https://analyticalengines.example');
+ });
+});
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 96594b1385..a17110ff33 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
@@ -1,13 +1,12 @@
const ORIGINAL_FETCH = global.fetch;
const ORIGINAL_WEBHOOK_URL = process.env.PARTNER_APPLICATION_WEBHOOK_URL;
+const ORIGINAL_API_KEY = process.env.PARTNER_APPLICATION_SECRET;
const VALID_PAYLOAD = {
email: 'a@b.co',
name: 'Ada Lovelace',
company: 'Analytical Engines',
website: 'https://analytical.example/',
- message: 'We would like to integrate Twenty with our analytical engine.',
- programId: 'technology' as const,
};
const VALID_BODY = JSON.stringify(VALID_PAYLOAD);
@@ -27,7 +26,6 @@ function buildRequest({
if (contentType !== null) headers.set('content-type', contentType);
headers.set('x-forwarded-for', ip);
if (contentLength !== undefined) headers.set('content-length', contentLength);
-
return new Request('https://example.com/api/partner-application', {
method: 'POST',
headers,
@@ -44,26 +42,26 @@ async function loadRoute() {
describe('POST /api/partner-application', () => {
beforeEach(() => {
process.env.PARTNER_APPLICATION_WEBHOOK_URL = 'https://hooks.example/test';
+ process.env.PARTNER_APPLICATION_SECRET = 'test-key-abc123';
});
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
process.env.PARTNER_APPLICATION_WEBHOOK_URL = ORIGINAL_WEBHOOK_URL;
+ process.env.PARTNER_APPLICATION_SECRET = ORIGINAL_API_KEY;
});
it('returns 503 when the webhook URL is not configured', async () => {
delete process.env.PARTNER_APPLICATION_WEBHOOK_URL;
const { POST } = await loadRoute();
-
const response = await POST(buildRequest());
expect(response.status).toBe(503);
});
- it('returns 503 when the webhook URL is not a valid URL', async () => {
- process.env.PARTNER_APPLICATION_WEBHOOK_URL = 'not-a-url';
+ it('returns 503 when the application secret is not configured', async () => {
+ delete process.env.PARTNER_APPLICATION_SECRET;
const { POST } = await loadRoute();
-
- const response = await POST(buildRequest());
+ const response = await POST(buildRequest({ ip: '203.0.113.2' }));
expect(response.status).toBe(503);
});
@@ -81,10 +79,7 @@ describe('POST /api/partner-application', () => {
it('returns 413 when content-length declares a too-large body', async () => {
const { POST } = await loadRoute();
const response = await POST(
- buildRequest({
- contentLength: '99999999',
- ip: '203.0.113.11',
- }),
+ buildRequest({ contentLength: '99999999', ip: '203.0.113.11' }),
);
expect(response.status).toBe(413);
});
@@ -119,53 +114,32 @@ describe('POST /api/partner-application', () => {
expect(response.status).toBe(400);
});
- it('returns 400 when extra fields are present (strict schema)', async () => {
+ it('returns 400 when an extra (legacy) field is present (strictObject)', async () => {
const { POST } = await loadRoute();
const response = await POST(
buildRequest({
- body: JSON.stringify({ ...VALID_PAYLOAD, extra: 'nope' }),
+ body: JSON.stringify({
+ ...VALID_PAYLOAD,
+ countryOther: 'Republic of Examples',
+ }),
ip: '203.0.113.15',
}),
);
expect(response.status).toBe(400);
});
- it('returns 400 when company is missing', async () => {
+ it('returns 400 when country enum is unknown', async () => {
const { POST } = await loadRoute();
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { company: _omitted, ...withoutCompany } = VALID_PAYLOAD;
const response = await POST(
buildRequest({
- body: JSON.stringify(withoutCompany),
+ body: JSON.stringify({ ...VALID_PAYLOAD, country: 'ATLANTIS' }),
ip: '203.0.113.16',
}),
);
expect(response.status).toBe(400);
});
- it('returns 400 when website is not a URL', async () => {
- const { POST } = await loadRoute();
- const response = await POST(
- buildRequest({
- body: JSON.stringify({ ...VALID_PAYLOAD, website: 'not-a-url' }),
- ip: '203.0.113.17',
- }),
- );
- expect(response.status).toBe(400);
- });
-
- it('returns 400 when programId is unknown', async () => {
- const { POST } = await loadRoute();
- const response = await POST(
- buildRequest({
- body: JSON.stringify({ ...VALID_PAYLOAD, programId: 'wat' }),
- ip: '203.0.113.18',
- }),
- );
- expect(response.status).toBe(400);
- });
-
- it('forwards a valid submission to the webhook with all fields and returns 200', async () => {
+ 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 }));
@@ -176,24 +150,24 @@ describe('POST /api/partner-application', () => {
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ success: true });
-
expect(fetchSpy).toHaveBeenCalledTimes(1);
+
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('https://hooks.example/test');
expect(init.method).toBe('POST');
+ expect(init.headers['X-Application-Secret']).toBe('test-key-abc123');
+ expect(init.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(init.body as string)).toEqual({
- Email: 'a@b.co',
- FirstName: 'Ada',
- LastName: 'Lovelace',
- Company: 'Analytical Engines',
- Website: 'https://analytical.example/',
- Message: 'We would like to integrate Twenty with our analytical engine.',
- ProgramId: 'technology',
+ email: 'a@b.co',
+ firstName: 'Ada',
+ lastName: 'Lovelace',
+ companyName: 'Analytical Engines',
+ domainName: 'https://analytical.example/',
});
expect(init.signal).toBeInstanceOf(AbortSignal);
});
- it('forwards optional Opportunities when provided', async () => {
+ it('forwards rich optional wizard fields with camelCase keys', async () => {
const fetchSpy = jest
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
@@ -204,7 +178,11 @@ describe('POST /api/partner-application', () => {
buildRequest({
body: JSON.stringify({
...VALID_PAYLOAD,
- opportunities: '50/month',
+ country: 'FRANCE',
+ languages: ['ENGLISH', 'FRENCH'],
+ typeOfTeam: 'SOLO',
+ partnerScope: ['ADVISORY'],
+ hourlyRate: 150,
}),
ip: '203.0.113.24',
}),
@@ -213,11 +191,15 @@ describe('POST /api/partner-application', () => {
expect(response.status).toBe(200);
const [, init] = fetchSpy.mock.calls[0];
expect(JSON.parse(init.body as string)).toMatchObject({
- Opportunities: '50/month',
+ country: 'FRANCE',
+ languages: ['ENGLISH', 'FRENCH'],
+ typeOfTeam: 'SOLO',
+ partnerScope: ['ADVISORY'],
+ hourlyRate: 150,
});
});
- it('omits optional Opportunities when not provided', async () => {
+ it('omits optional rich fields when not provided', async () => {
const fetchSpy = jest
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
@@ -228,14 +210,16 @@ describe('POST /api/partner-application', () => {
expect(response.status).toBe(200);
const [, init] = fetchSpy.mock.calls[0];
- expect(JSON.parse(init.body as string)).not.toHaveProperty('Opportunities');
+ const body = JSON.parse(init.body as string);
+ expect(body).not.toHaveProperty('country');
+ expect(body).not.toHaveProperty('languages');
+ expect(body).not.toHaveProperty('partnerScope');
});
it('returns 502 when the webhook responds with a non-2xx status', async () => {
global.fetch = jest
.fn()
.mockResolvedValue(new Response('boom', { status: 500 }));
-
const { POST } = await loadRoute();
const response = await POST(buildRequest({ ip: '203.0.113.21' }));
expect(response.status).toBe(502);
@@ -243,7 +227,6 @@ describe('POST /api/partner-application', () => {
it('returns 502 when the webhook fetch throws (network error)', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('connection refused'));
-
const { POST } = await loadRoute();
const response = await POST(buildRequest({ ip: '203.0.113.22' }));
expect(response.status).toBe(502);
@@ -255,7 +238,6 @@ describe('POST /api/partner-application', () => {
.mockRejectedValue(
Object.assign(new Error('aborted'), { name: 'AbortError' }),
);
-
const { POST } = await loadRoute();
const response = await POST(buildRequest({ ip: '203.0.113.23' }));
expect(response.status).toBe(504);
@@ -265,16 +247,13 @@ describe('POST /api/partner-application', () => {
global.fetch = jest
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
-
const { POST } = await loadRoute();
const ip = '203.0.113.99';
const statuses: number[] = [];
-
for (let i = 0; i < 6; i++) {
const r = await POST(buildRequest({ ip }));
statuses.push(r.status);
}
-
expect(statuses.slice(0, 5).every((s) => s === 200)).toBe(true);
expect(statuses[5]).toBe(429);
});
@@ -283,13 +262,9 @@ describe('POST /api/partner-application', () => {
global.fetch = jest
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
-
const { POST } = await loadRoute();
const ip = '203.0.113.100';
- for (let i = 0; i < 5; i++) {
- await POST(buildRequest({ ip }));
- }
-
+ for (let i = 0; i < 5; i++) await POST(buildRequest({ ip }));
const denied = await POST(buildRequest({ ip }));
expect(denied.status).toBe(429);
const retryAfter = denied.headers.get('Retry-After');
diff --git a/packages/twenty-website/src/app/api/partner-application/partner-application-schema.ts b/packages/twenty-website/src/app/api/partner-application/partner-application-schema.ts
new file mode 100644
index 0000000000..cf9143ff78
--- /dev/null
+++ b/packages/twenty-website/src/app/api/partner-application/partner-application-schema.ts
@@ -0,0 +1,69 @@
+import {
+ type PARTNER_COUNTRY_VALUES,
+ type PARTNER_LANGUAGE_VALUES,
+ type PARTNER_SCOPE_VALUES,
+ type PARTNER_TYPE_OF_TEAM_VALUES,
+} from '@/sections/PartnerApplication/wizard/partner-fields.data';
+import { type PartnerApplicationRequest } from '@/sections/PartnerApplication/partner-application-field-schemas';
+import { splitFullName } from '@/sections/PartnerApplication';
+
+// The request schema and type live in the section layer so the client wizard
+// can share them without `sections/**` importing from `@/app/**`. Re-export so
+// existing route-side import paths keep working.
+export {
+ partnerApplicationRequestSchema,
+ type PartnerApplicationRequest,
+} from '@/sections/PartnerApplication/partner-application-field-schemas';
+
+export type PartnerApplicationLogicFunctionPayload = {
+ firstName: string;
+ lastName: string;
+ email: string;
+ companyName: string;
+ domainName?: string;
+ linkedin?: string;
+ city?: string;
+ country?: (typeof PARTNER_COUNTRY_VALUES)[number];
+ languages?: ReadonlyArray<(typeof PARTNER_LANGUAGE_VALUES)[number]>;
+ typeOfTeam?: (typeof PARTNER_TYPE_OF_TEAM_VALUES)[number];
+ partnerScope?: ReadonlyArray<(typeof PARTNER_SCOPE_VALUES)[number]>;
+ skills?: ReadonlyArray;
+ applicationNotes?: string;
+ hourlyRate?: number;
+ projectBudgetMin?: number;
+ calendarLink?: string;
+};
+
+export function buildLogicFunctionPayload(
+ request: PartnerApplicationRequest,
+): PartnerApplicationLogicFunctionPayload {
+ const { firstName, lastName } = splitFullName(request.name);
+
+ const payload: PartnerApplicationLogicFunctionPayload = {
+ firstName,
+ lastName,
+ email: request.email,
+ companyName: request.company,
+ };
+
+ if (request.website !== undefined) payload.domainName = request.website;
+ if (request.linkedin !== undefined) payload.linkedin = request.linkedin;
+ if (request.city !== undefined) payload.city = request.city;
+ if (request.country !== undefined) payload.country = request.country;
+ if (request.languages !== undefined && request.languages.length > 0)
+ payload.languages = request.languages;
+ if (request.typeOfTeam !== undefined) payload.typeOfTeam = request.typeOfTeam;
+ if (request.partnerScope !== undefined && request.partnerScope.length > 0)
+ payload.partnerScope = request.partnerScope;
+ if (request.skills !== undefined && request.skills.length > 0)
+ payload.skills = request.skills;
+ if (request.applicationNotes !== undefined)
+ payload.applicationNotes = request.applicationNotes;
+ if (request.hourlyRate !== undefined) payload.hourlyRate = request.hourlyRate;
+ if (request.projectBudgetMin !== undefined)
+ payload.projectBudgetMin = request.projectBudgetMin;
+ if (request.calendarLink !== undefined)
+ payload.calendarLink = request.calendarLink;
+
+ return payload;
+}
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 64da364709..27762e766c 100644
--- a/packages/twenty-website/src/app/api/partner-application/route.ts
+++ b/packages/twenty-website/src/app/api/partner-application/route.ts
@@ -4,37 +4,23 @@ import {
getClientIpKey,
readJsonBody,
} from '@/lib/api';
-import { splitFullName } from '@/sections/PartnerApplication';
+import {
+ buildLogicFunctionPayload,
+ partnerApplicationRequestSchema,
+} from '@/app/api/partner-application/partner-application-schema';
import { NextResponse } from 'next/server';
import { z } from 'zod';
-const PARTNER_PROGRAM_IDS = ['technology', 'content', 'solutions'] as const;
-
-const partnerApplicationRequestSchema = z.strictObject({
- email: z
- .string()
- .trim()
- .min(1, { error: 'Email is required.' })
- .pipe(z.email({ error: 'Invalid email address.' })),
- name: z.string().trim().min(1, { error: 'Name is required.' }),
- company: z.string().trim().min(1, { error: 'Company is required.' }),
- website: z
- .string()
- .trim()
- .min(1, { error: 'Website is required.' })
- .pipe(z.httpUrl({ error: 'Invalid website URL.' })),
- message: z.string().trim().min(1, { error: 'Message is required.' }),
- programId: z.enum(PARTNER_PROGRAM_IDS).optional(),
- opportunities: z.string().trim().optional(),
-});
-
+// Use z.url() (not z.httpUrl) so localhost destinations are accepted in dev.
+// z.httpUrl enforces a TLD-shaped hostname and would reject http://localhost:2020/...
const webhookUrlSchema = z
.string()
.trim()
- .pipe(z.httpUrl({ error: 'Invalid webhook URL.' }));
+ .pipe(z.url({ error: 'Invalid webhook URL.' }));
+
+const applicationSecretSchema = z.string().trim().min(1);
const MAX_BODY_BYTES = 16 * 1024;
-
const WEBHOOK_TIMEOUT_MS = 8_000;
const checkRateLimit = createRateLimiter({
@@ -46,15 +32,45 @@ export async function POST(request: Request) {
const webhookUrlResult = webhookUrlSchema.safeParse(
process.env.PARTNER_APPLICATION_WEBHOOK_URL,
);
+ const secretResult = applicationSecretSchema.safeParse(
+ process.env.PARTNER_APPLICATION_SECRET,
+ );
- if (!webhookUrlResult.success) {
+ if (!webhookUrlResult.success || !secretResult.success) {
+ const rawWebhookUrl = process.env.PARTNER_APPLICATION_WEBHOOK_URL;
+ const rawSecret = process.env.PARTNER_APPLICATION_SECRET;
+ console.error(
+ '[partner-application] 503 — endpoint env vars failed validation',
+ JSON.stringify({
+ PARTNER_APPLICATION_WEBHOOK_URL: {
+ present: rawWebhookUrl !== undefined,
+ isEmptyAfterTrim: (rawWebhookUrl ?? '').trim() === '',
+ length: (rawWebhookUrl ?? '').length,
+ value: rawWebhookUrl,
+ parseError: webhookUrlResult.success
+ ? null
+ : (webhookUrlResult.error.issues[0]?.message ?? 'unknown'),
+ },
+ PARTNER_APPLICATION_SECRET: {
+ present: rawSecret !== undefined,
+ isEmptyAfterTrim: (rawSecret ?? '').trim() === '',
+ length: (rawSecret ?? '').length,
+ parseError: secretResult.success
+ ? null
+ : (secretResult.error.issues[0]?.message ?? 'unknown'),
+ },
+ envFileHint:
+ 'Next dev reads .env.local (not .env.prod). After editing env vars you must restart `yarn nx dev twenty-website` — Next does not hot-reload env vars.',
+ }),
+ );
return NextResponse.json(
- { error: 'Partner application webhook is not configured.' },
+ { error: 'Partner application endpoint is not configured.' },
{ status: 503 },
);
}
const webhookUrl = webhookUrlResult.data;
+ const applicationSecret = secretResult.data;
const rateLimit = checkRateLimit(getClientIpKey(request));
if (!rateLimit.allowed) {
@@ -102,25 +118,16 @@ export async function POST(request: Request) {
return NextResponse.json({ error: message }, { status: 400 });
}
- const { name, email, company, website, message, programId, opportunities } =
- parsed.data;
- const { firstName, lastName } = splitFullName(name);
+ const payload = buildLogicFunctionPayload(parsed.data);
const upstream = await fetchWithTimeout(
webhookUrl,
{
- body: JSON.stringify({
- Email: email,
- FirstName: firstName,
- LastName: lastName,
- Company: company,
- Website: website,
- Message: message,
- ...(programId !== undefined && { ProgramId: programId }),
- ...(opportunities !== undefined &&
- opportunities !== '' && { Opportunities: opportunities }),
- }),
- headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Application-Secret': applicationSecret,
+ },
method: 'POST',
},
{ timeoutMs: WEBHOOK_TIMEOUT_MS },
@@ -128,6 +135,14 @@ export async function POST(request: Request) {
if (!upstream.ok) {
const status = upstream.error === 'timeout' ? 504 : 502;
+ console.error(
+ '[partner-application] upstream fetch failed',
+ JSON.stringify({
+ url: webhookUrl,
+ error: upstream.error,
+ payloadKeys: Object.keys(payload),
+ }),
+ );
return NextResponse.json(
{ error: 'Partner application could not be submitted.' },
{ status },
@@ -135,6 +150,22 @@ export async function POST(request: Request) {
}
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({
+ url: webhookUrl,
+ status: upstream.response.status,
+ statusText: upstream.response.statusText,
+ body: upstreamBody.slice(0, 2000),
+ payloadKeys: Object.keys(payload),
+ }),
+ );
return NextResponse.json(
{ error: 'Partner application could not be submitted.' },
{ status: 502 },
diff --git a/packages/twenty-website/src/design-system/components/Form/Currency.tsx b/packages/twenty-website/src/design-system/components/Form/Currency.tsx
new file mode 100644
index 0000000000..3c8e4369e9
--- /dev/null
+++ b/packages/twenty-website/src/design-system/components/Form/Currency.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+import type { ChangeEvent } from 'react';
+
+const Wrapper = styled.div`
+ align-items: center;
+ background: transparent;
+ border: 1px solid ${theme.colors.secondary.border[20]};
+ border-radius: ${theme.radius(2)};
+ box-sizing: border-box;
+ display: flex;
+ height: clamp(40px, 5.5vh, 56px);
+ padding: 0 ${theme.spacing(3)};
+ width: 100%;
+
+ &[data-invalid='true'] {
+ border-color: #ff9a9a;
+ }
+
+ &:focus-within {
+ border-color: ${theme.colors.highlight[100]};
+ }
+`;
+
+const Prefix = styled.span`
+ color: ${theme.colors.secondary.text[60]};
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(4)};
+ margin-right: ${theme.spacing(2)};
+`;
+
+const Input = styled.input`
+ background: transparent;
+ border: none;
+ color: ${theme.colors.secondary.text[100]};
+ flex: 1 1 auto;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(4)};
+ height: 100%;
+ outline: none;
+ width: 100%;
+
+ &::placeholder {
+ color: ${theme.colors.secondary.text[40]};
+ }
+`;
+
+type FormCurrencyProps = {
+ value: string;
+ onValueChange: (value: string) => void;
+ placeholder?: string;
+ invalid?: boolean;
+ name?: string;
+ ariaLabel?: string;
+};
+
+// Allow either decimal separator: mobile `inputMode="decimal"` keyboards emit
+// ',' in many locales (EU, SA). The value is normalized to '.' on change.
+const ALLOWED_CHARS = /^[0-9]*[.,]?[0-9]*$/;
+
+export function FormCurrency({
+ value,
+ onValueChange,
+ placeholder,
+ invalid,
+ name,
+ ariaLabel,
+}: FormCurrencyProps) {
+ const handleChange = (event: ChangeEvent) => {
+ const next = event.target.value;
+ if (next === '' || ALLOWED_CHARS.test(next)) {
+ onValueChange(next.replace(',', '.'));
+ }
+ };
+
+ return (
+
+ $
+
+
+ );
+}
diff --git a/packages/twenty-website/src/design-system/components/Form/Form.tsx b/packages/twenty-website/src/design-system/components/Form/Form.tsx
index a4c2e841c7..019427ded8 100644
--- a/packages/twenty-website/src/design-system/components/Form/Form.tsx
+++ b/packages/twenty-website/src/design-system/components/Form/Form.tsx
@@ -5,6 +5,11 @@ import { Field } from '@base-ui/react/field';
import { styled } from '@linaria/react';
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
+import { FormCurrency } from './Currency';
+import { FormMultiSelect } from './MultiSelect';
+import { FormSelect } from './Select';
+import { FormTagInput } from './TagInput';
+
const FieldRootBase = styled(Field.Root)`
display: flex;
flex-direction: column;
@@ -152,4 +157,8 @@ export const Form = {
Label: FieldLabel,
Hint: FieldHint,
Error: FieldError,
+ Select: FormSelect,
+ MultiSelect: FormMultiSelect,
+ Currency: FormCurrency,
+ TagInput: FormTagInput,
};
diff --git a/packages/twenty-website/src/design-system/components/Form/MultiSelect.tsx b/packages/twenty-website/src/design-system/components/Form/MultiSelect.tsx
new file mode 100644
index 0000000000..751abaec5d
--- /dev/null
+++ b/packages/twenty-website/src/design-system/components/Form/MultiSelect.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+import type { ReactNode } from 'react';
+
+export type FormMultiSelectOption = {
+ value: TValue;
+ label: ReactNode;
+};
+
+const PillGroup = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${theme.spacing(2)};
+`;
+
+const PillButton = styled.button`
+ background: transparent;
+ border: 1px solid ${theme.colors.secondary.border[20]};
+ border-radius: ${theme.radius(8)};
+ color: ${theme.colors.secondary.text[100]};
+ cursor: pointer;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3.5)};
+ font-weight: ${theme.font.weight.regular};
+ padding: ${theme.spacing(1.5)} ${theme.spacing(3)};
+
+ &[data-selected='true'] {
+ background: ${theme.colors.primary.background[100]};
+ border-color: ${theme.colors.primary.background[100]};
+ color: ${theme.colors.primary.text[100]};
+ }
+
+ &[data-invalid='true'] {
+ border-color: #ff9a9a;
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${theme.colors.highlight[100]};
+ outline-offset: 2px;
+ }
+`;
+
+type FormMultiSelectProps = {
+ values: ReadonlyArray;
+ onToggle: (value: TValue) => void;
+ options: ReadonlyArray>;
+ invalid?: boolean;
+ ariaLabel?: string;
+};
+
+export function FormMultiSelect({
+ values,
+ onToggle,
+ options,
+ invalid,
+ ariaLabel,
+}: FormMultiSelectProps) {
+ return (
+
+ {options.map((option) => {
+ const selected = values.includes(option.value);
+ return (
+ onToggle(option.value)}
+ >
+ {option.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/twenty-website/src/design-system/components/Form/Select.tsx b/packages/twenty-website/src/design-system/components/Form/Select.tsx
new file mode 100644
index 0000000000..4b8e527bc0
--- /dev/null
+++ b/packages/twenty-website/src/design-system/components/Form/Select.tsx
@@ -0,0 +1,342 @@
+'use client';
+
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+import { IconChevronDown, IconSearch } from '@tabler/icons-react';
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type CSSProperties,
+ type ReactNode,
+} from 'react';
+import { createPortal } from 'react-dom';
+
+export type FormSelectOption = {
+ value: TValue;
+ label: ReactNode;
+};
+
+const Root = styled.div`
+ position: relative;
+ width: 100%;
+`;
+
+const Trigger = styled.button`
+ align-items: center;
+ background: transparent;
+ border: 1px solid ${theme.colors.secondary.border[20]};
+ border-radius: ${theme.radius(2)};
+ box-sizing: border-box;
+ color: ${theme.colors.secondary.text[100]};
+ cursor: pointer;
+ display: flex;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(4)};
+ font-weight: ${theme.font.weight.regular};
+ gap: ${theme.spacing(2)};
+ height: clamp(40px, 5.5vh, 56px);
+ justify-content: space-between;
+ padding-left: ${theme.spacing(3)};
+ padding-right: ${theme.spacing(2)};
+ width: 100%;
+
+ &[data-invalid='true'] {
+ border-color: #ff9a9a;
+ }
+
+ &:focus-visible {
+ border-color: ${theme.colors.highlight[100]};
+ outline: none;
+ }
+`;
+
+const Value = styled.span`
+ color: ${theme.colors.secondary.text[100]};
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &[data-empty='true'] {
+ color: ${theme.colors.secondary.text[40]};
+ }
+`;
+
+// Positioned via inline style (fixed, anchored to the trigger) and portaled to
+// so the modal's overflow/transform can't clip it.
+const Popup = styled.div`
+ background: ${theme.colors.secondary.background[100]};
+ border: 1px solid ${theme.colors.highlight[100]};
+ border-radius: ${theme.radius(2)};
+ box-shadow: 0 0 16px 0 rgba(15, 15, 15, 0.25);
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: fixed;
+ z-index: ${theme.zIndex.portalTop};
+`;
+
+const SearchRow = styled.div`
+ align-items: center;
+ border-bottom: 1px solid ${theme.colors.secondary.border[20]};
+ color: ${theme.colors.secondary.text[60]};
+ display: flex;
+ flex-shrink: 0;
+ gap: ${theme.spacing(2)};
+ padding: ${theme.spacing(2)} ${theme.spacing(3)};
+`;
+
+const SearchInput = styled.input`
+ background: transparent;
+ border: none;
+ color: ${theme.colors.secondary.text[100]};
+ flex: 1 1 auto;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(4)};
+ outline: none;
+
+ &::placeholder {
+ color: ${theme.colors.secondary.text[40]};
+ }
+`;
+
+const OptionList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ overflow-y: auto;
+ padding: ${theme.spacing(1)};
+`;
+
+const Option = styled.button`
+ align-items: center;
+ background: transparent;
+ border: none;
+ border-radius: ${theme.radius(1)};
+ color: ${theme.colors.secondary.text[100]};
+ cursor: pointer;
+ display: flex;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(4)};
+ padding: ${theme.spacing(2)} ${theme.spacing(3)};
+ text-align: left;
+ width: 100%;
+
+ &[data-selected='true'] {
+ background: rgba(74, 56, 245, 0.3);
+ }
+
+ &:hover {
+ background: rgba(74, 56, 245, 0.15);
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${theme.colors.highlight[100]};
+ outline-offset: -2px;
+ }
+`;
+
+const EmptyState = styled.span`
+ color: ${theme.colors.secondary.text[40]};
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3.5)};
+ padding: ${theme.spacing(3)};
+`;
+
+type FormSelectProps = {
+ value: TValue | '';
+ onValueChange: (value: TValue) => void;
+ placeholder: ReactNode;
+ options: ReadonlyArray>;
+ invalid?: boolean;
+ name?: string;
+ ariaLabel?: string;
+ searchable?: boolean;
+ searchPlaceholder?: string;
+ searchEmptyLabel?: string;
+};
+
+// Inline position for the portaled popup: pinned to the trigger, flipped up
+// when there is more room above, height capped so it always fits the viewport.
+type PopupPosition = {
+ left: number;
+ width: number;
+ maxHeight: number;
+ top?: number;
+ bottom?: number;
+};
+
+const POPUP_GAP_PX = 4;
+const VIEWPORT_MARGIN_PX = 8;
+const POPUP_MAX_HEIGHT_PX = 320;
+
+function labelMatches(label: ReactNode, query: string): boolean {
+ if (query === '') return true;
+ if (typeof label !== 'string') return true;
+ return label.toLowerCase().includes(query.toLowerCase());
+}
+
+export function FormSelect({
+ value,
+ onValueChange,
+ placeholder,
+ options,
+ invalid,
+ name,
+ ariaLabel,
+ searchable,
+ searchPlaceholder,
+ searchEmptyLabel,
+}: FormSelectProps) {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState('');
+ const [position, setPosition] = useState(null);
+ const rootRef = useRef(null);
+ const popupRef = useRef(null);
+
+ const updatePosition = useCallback(() => {
+ const anchor = rootRef.current;
+ if (!anchor) return;
+ const rect = anchor.getBoundingClientRect();
+ const spaceBelow = window.innerHeight - rect.bottom - VIEWPORT_MARGIN_PX;
+ const spaceAbove = rect.top - VIEWPORT_MARGIN_PX;
+ const openUp =
+ spaceBelow < Math.min(POPUP_MAX_HEIGHT_PX, 220) &&
+ spaceAbove > spaceBelow;
+ // Clamp to the room actually available so the dropdown can't overflow a
+ // short viewport; only grow toward POPUP_MAX_HEIGHT_PX when there's space.
+ const available = Math.max(0, openUp ? spaceAbove : spaceBelow);
+ const maxHeight = Math.min(POPUP_MAX_HEIGHT_PX, available);
+ setPosition({
+ left: rect.left,
+ width: rect.width,
+ maxHeight,
+ ...(openUp
+ ? { bottom: window.innerHeight - rect.top + POPUP_GAP_PX }
+ : { top: rect.bottom + POPUP_GAP_PX }),
+ });
+ }, []);
+
+ useEffect(() => {
+ if (!open) {
+ setQuery('');
+ setPosition(null);
+ return;
+ }
+ updatePosition();
+ const reposition = () => updatePosition();
+ // capture=true so the modal's own scroll container also triggers reposition.
+ window.addEventListener('scroll', reposition, true);
+ window.addEventListener('resize', reposition);
+ const handleClick = (event: MouseEvent) => {
+ const target = event.target as Node;
+ if (rootRef.current?.contains(target)) return;
+ if (popupRef.current?.contains(target)) return;
+ setOpen(false);
+ };
+ const handleKey = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') setOpen(false);
+ };
+ document.addEventListener('mousedown', handleClick);
+ document.addEventListener('keydown', handleKey);
+ return () => {
+ window.removeEventListener('scroll', reposition, true);
+ window.removeEventListener('resize', reposition);
+ document.removeEventListener('mousedown', handleClick);
+ document.removeEventListener('keydown', handleKey);
+ };
+ }, [open, updatePosition]);
+
+ const selectedOption = options.find((o) => o.value === value);
+ const visibleOptions = searchable
+ ? options.filter((o) => labelMatches(o.label, query))
+ : options;
+
+ const popupStyle: CSSProperties | undefined = position
+ ? {
+ left: position.left,
+ width: position.width,
+ maxHeight: position.maxHeight,
+ ...(position.top !== undefined
+ ? { top: position.top }
+ : { bottom: position.bottom }),
+ }
+ : undefined;
+
+ return (
+
+ setOpen((v) => !v)}
+ >
+
+ {value === '' || !selectedOption ? placeholder : selectedOption.label}
+
+
+
+ {name ? : null}
+ {open && popupStyle && typeof document !== 'undefined'
+ ? createPortal(
+ event.stopPropagation()}
+ onPointerDown={(event) => event.stopPropagation()}
+ >
+ {searchable && (
+
+
+ setQuery(event.target.value)}
+ />
+
+ )}
+
+ {visibleOptions.length === 0 ? (
+ {searchEmptyLabel ?? '—'}
+ ) : (
+ visibleOptions.map((option) => {
+ const selected = value === option.value;
+ return (
+
+ );
+ })
+ )}
+
+ ,
+ document.body,
+ )
+ : null}
+
+ );
+}
diff --git a/packages/twenty-website/src/design-system/components/Form/TagInput.tsx b/packages/twenty-website/src/design-system/components/Form/TagInput.tsx
new file mode 100644
index 0000000000..b3d08f2221
--- /dev/null
+++ b/packages/twenty-website/src/design-system/components/Form/TagInput.tsx
@@ -0,0 +1,273 @@
+'use client';
+
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+import { useId, useState, type KeyboardEvent } from 'react';
+
+import { filterSkillSuggestions } from './skill-suggestions';
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(2)};
+`;
+
+const ComboBox = styled.div`
+ position: relative;
+`;
+
+const Wrapper = styled.div`
+ align-items: center;
+ background: transparent;
+ border: 1px solid ${theme.colors.secondary.border[20]};
+ border-radius: ${theme.radius(2)};
+ box-sizing: border-box;
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${theme.spacing(1.5)};
+ min-height: clamp(40px, 5.5vh, 56px);
+ padding: ${theme.spacing(1.5)} ${theme.spacing(2)};
+ width: 100%;
+
+ &:focus-within {
+ border-color: ${theme.colors.highlight[100]};
+ }
+`;
+
+const Chip = styled.span`
+ align-items: center;
+ background: rgba(74, 56, 245, 0.18);
+ border-radius: ${theme.radius(8)};
+ color: ${theme.colors.secondary.text[100]};
+ display: inline-flex;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3.5)};
+ gap: ${theme.spacing(1)};
+ padding: ${theme.spacing(0.5)} ${theme.spacing(2)};
+`;
+
+const ChipRemove = styled.button`
+ background: transparent;
+ border: none;
+ color: ${theme.colors.secondary.text[60]};
+ cursor: pointer;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3.5)};
+ line-height: 1;
+ padding: 0;
+
+ &:focus-visible {
+ outline: 2px solid ${theme.colors.highlight[100]};
+ outline-offset: 2px;
+ }
+`;
+
+const InlineInput = styled.input`
+ background: transparent;
+ border: none;
+ color: ${theme.colors.secondary.text[100]};
+ flex: 1 1 120px;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(4)};
+ min-width: 120px;
+ outline: none;
+
+ &::placeholder {
+ color: ${theme.colors.secondary.text[40]};
+ }
+`;
+
+const Menu = styled.ul`
+ background: #1a1a1a;
+ border: 1px solid ${theme.colors.secondary.border[20]};
+ border-radius: ${theme.radius(2)};
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
+ left: 0;
+ list-style: none;
+ margin: ${theme.spacing(1)} 0 0;
+ max-height: 220px;
+ overflow-y: auto;
+ padding: ${theme.spacing(1)};
+ position: absolute;
+ right: 0;
+ top: 100%;
+ z-index: 2;
+`;
+
+const MenuItem = styled.li`
+ border-radius: ${theme.radius(1)};
+ color: ${theme.colors.secondary.text[80]};
+ cursor: pointer;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3.5)};
+ padding: ${theme.spacing(2)};
+
+ &[data-active='true'] {
+ background: rgba(74, 56, 245, 0.18);
+ color: ${theme.colors.secondary.text[100]};
+ }
+`;
+
+const SuggestRow = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${theme.spacing(1.5)};
+`;
+
+const Ghost = styled.button`
+ background: transparent;
+ border: 1px dashed ${theme.colors.secondary.border[20]};
+ border-radius: ${theme.radius(8)};
+ color: ${theme.colors.secondary.text[60]};
+ cursor: pointer;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3.25)};
+ padding: ${theme.spacing(1)} ${theme.spacing(2)};
+
+ &:hover {
+ border-color: ${theme.colors.secondary.border[40]};
+ color: ${theme.colors.secondary.text[100]};
+ }
+`;
+
+type FormTagInputProps = {
+ values: ReadonlyArray;
+ onValuesChange: (values: string[]) => void;
+ placeholder?: string;
+ ariaLabel?: string;
+ suggestions?: ReadonlyArray;
+};
+
+export function FormTagInput({
+ values,
+ onValuesChange,
+ placeholder,
+ ariaLabel,
+ suggestions,
+}: FormTagInputProps) {
+ const [draft, setDraft] = useState('');
+ const [activeIndex, setActiveIndex] = useState(-1);
+ const listId = useId();
+
+ const menuMatches =
+ suggestions !== undefined
+ ? filterSkillSuggestions(suggestions, values, draft)
+ : [];
+ const menuOpen = menuMatches.length > 0;
+
+ const addValue = (raw: string) => {
+ const trimmed = raw.trim();
+ if (trimmed === '') return;
+ if (!values.some((v) => v.toLowerCase() === trimmed.toLowerCase())) {
+ onValuesChange([...values, trimmed]);
+ }
+ setDraft('');
+ setActiveIndex(-1);
+ };
+
+ const removeAt = (index: number) => {
+ onValuesChange(values.filter((_, i) => i !== index));
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (menuOpen && event.key === 'ArrowDown') {
+ event.preventDefault();
+ setActiveIndex((i) => (i + 1) % menuMatches.length);
+ return;
+ }
+ if (menuOpen && event.key === 'ArrowUp') {
+ event.preventDefault();
+ // From no selection (-1) or the first option (0), wrap up to the last one.
+ setActiveIndex((i) => (i <= 0 ? menuMatches.length - 1 : i - 1));
+ return;
+ }
+ if (event.key === 'Enter' || event.key === ',') {
+ event.preventDefault();
+ if (menuOpen && activeIndex >= 0) addValue(menuMatches[activeIndex]);
+ else addValue(draft);
+ return;
+ }
+ if (event.key === 'Escape') {
+ setActiveIndex(-1);
+ return;
+ }
+ if (event.key === 'Backspace' && draft === '' && values.length > 0) {
+ onValuesChange(values.slice(0, -1));
+ }
+ };
+
+ const remainingSuggestions =
+ suggestions !== undefined
+ ? suggestions.filter(
+ (s) => !values.some((v) => v.toLowerCase() === s.toLowerCase()),
+ )
+ : [];
+
+ return (
+
+
+
+ {values.map((tag, index) => (
+
+ {tag}
+ removeAt(index)}
+ >
+ ×
+
+
+ ))}
+ {
+ setDraft(event.target.value);
+ setActiveIndex(-1);
+ }}
+ onKeyDown={handleKeyDown}
+ onBlur={() => addValue(draft)}
+ />
+
+ {menuOpen ? (
+
+ ) : null}
+
+ {remainingSuggestions.length > 0 ? (
+
+ {remainingSuggestions.map((suggestion) => (
+ addValue(suggestion)}
+ >
+ + {suggestion}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/twenty-website/src/design-system/components/Form/__tests__/skill-suggestions.test.ts b/packages/twenty-website/src/design-system/components/Form/__tests__/skill-suggestions.test.ts
new file mode 100644
index 0000000000..11b935bd4c
--- /dev/null
+++ b/packages/twenty-website/src/design-system/components/Form/__tests__/skill-suggestions.test.ts
@@ -0,0 +1,26 @@
+import { filterSkillSuggestions } from '@/design-system/components/Form/skill-suggestions';
+
+const POOL = ['React', 'PostgreSQL', 'Python', 'Shopify'];
+
+describe('filterSkillSuggestions', () => {
+ it('returns [] for an empty query', () => {
+ expect(filterSkillSuggestions(POOL, [], '')).toEqual([]);
+ expect(filterSkillSuggestions(POOL, [], ' ')).toEqual([]);
+ });
+
+ it('matches case-insensitively by substring', () => {
+ expect(filterSkillSuggestions(POOL, [], 'p')).toEqual([
+ 'PostgreSQL',
+ 'Python',
+ 'Shopify',
+ ]);
+ expect(filterSkillSuggestions(POOL, [], 'sql')).toEqual(['PostgreSQL']);
+ });
+
+ it('excludes already-selected values (case-insensitive)', () => {
+ expect(filterSkillSuggestions(POOL, ['postgresql'], 'p')).toEqual([
+ 'Python',
+ 'Shopify',
+ ]);
+ });
+});
diff --git a/packages/twenty-website/src/design-system/components/Form/skill-suggestions.ts b/packages/twenty-website/src/design-system/components/Form/skill-suggestions.ts
new file mode 100644
index 0000000000..de1dc13327
--- /dev/null
+++ b/packages/twenty-website/src/design-system/components/Form/skill-suggestions.ts
@@ -0,0 +1,15 @@
+// Pure filter for the TagInput autocomplete: suggestions in `pool` that match
+// `query` (case-insensitive substring) and are not already in `selected`.
+export function filterSkillSuggestions(
+ pool: ReadonlyArray,
+ selected: ReadonlyArray,
+ query: string,
+): string[] {
+ const trimmed = query.trim().toLowerCase();
+ if (trimmed === '') return [];
+ const taken = new Set(selected.map((value) => value.toLowerCase()));
+ return pool.filter(
+ (value) =>
+ !taken.has(value.toLowerCase()) && value.toLowerCase().includes(trimmed),
+ );
+}
diff --git a/packages/twenty-website/src/sections/PartnerApplication/PartnerApplicationModal.tsx b/packages/twenty-website/src/sections/PartnerApplication/PartnerApplicationModal.tsx
index 17ccabde0a..2269b260d0 100644
--- a/packages/twenty-website/src/sections/PartnerApplication/PartnerApplicationModal.tsx
+++ b/packages/twenty-website/src/sections/PartnerApplication/PartnerApplicationModal.tsx
@@ -1,569 +1,36 @@
'use client';
+import { Modal } from '@/design-system/components';
import {
- Body,
- Form,
- Heading,
- HeadingPart,
- Modal,
-} from '@/design-system/components';
-import {
- BUTTON_HEIGHTS_PX,
- buttonBaseStyles,
-} from '@/design-system/components/Button/BaseButton';
-import { ButtonShape } from '@/design-system/components/Button/ButtonShape';
-import { useLingui } from '@lingui/react';
-import {
- PARTNER_APPLICATION_MODAL_COPY,
- PARTNER_PROGRAM_IDS,
- PARTNER_PROGRAM_LABELS,
- type PartnerProgramId,
-} from '@/sections/PartnerApplication/partner-application-modal-data';
-import { theme } from '@/theme';
-import { css } from '@linaria/core';
-import { styled } from '@linaria/react';
-import { IconChevronDown } from '@tabler/icons-react';
-import { useCallback, useMemo, useRef, useState } from 'react';
-
-import { usePartnerFormReset } from './use-partner-form-reset';
-
-const partnerPanelClass = css`
- --modal-panel-width: min(360px, 100%);
-
- @media (min-width: ${theme.breakpoints.md}px) {
- --modal-panel-width: min(902px, 100%);
- }
-`;
-
-const TitleBlock = styled.div`
- display: flex;
- flex-direction: column;
- gap: clamp(8px, 2vh, 24px);
-`;
-
-const TitleHeadingWrapper = styled.div`
- color: ${theme.colors.secondary.text[100]};
-`;
-
-const SubtitleStack = styled.div`
- color: ${theme.colors.secondary.text[60]};
- display: flex;
- flex-direction: column;
- gap: 0;
-`;
-
-const Segments = styled.div`
- display: none;
- gap: ${theme.spacing(4)};
- width: 100%;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- display: flex;
- }
-`;
-
-const SegmentButton = styled.button`
- align-items: center;
- background: rgba(255, 255, 255, 0.1);
- border: 1px solid ${theme.colors.secondary.border[10]};
- border-radius: ${theme.radius(2)};
- box-sizing: border-box;
- color: ${theme.colors.secondary.text[100]};
- cursor: pointer;
- display: flex;
- flex: 1 1 0;
- font-family: ${theme.font.family.sans};
- font-size: ${theme.font.size(3.5)};
- font-weight: ${theme.font.weight.regular};
- height: clamp(40px, 5.5vh, 56px);
- justify-content: center;
- line-height: ${theme.lineHeight(3.5)};
- min-width: 0;
- padding-left: ${theme.spacing(2)};
- padding-right: ${theme.spacing(2)};
-
- &[data-active='true'] {
- background: ${theme.colors.primary.background[100]};
- color: ${theme.colors.primary.text[100]};
- }
-
- &:focus-visible {
- outline: 2px solid ${theme.colors.highlight[100]};
- outline-offset: 2px;
- }
-`;
-
-const MobileProgramField = styled.div`
- display: flex;
- flex-direction: column;
- gap: ${theme.spacing(2)};
- width: 100%;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- display: none;
- }
-`;
-
-const DropdownRoot = styled.div`
- position: relative;
- width: 100%;
-`;
-
-const DropdownTrigger = styled.button`
- align-items: center;
- background: ${theme.colors.secondary.background[100]};
- border: 1px solid ${theme.colors.highlight[100]};
- border-radius: ${theme.radius(2)};
- box-sizing: border-box;
- color: ${theme.colors.secondary.text[100]};
- cursor: pointer;
- display: flex;
- font-family: ${theme.font.family.sans};
- height: clamp(40px, 5.5vh, 56px);
- justify-content: space-between;
- padding-left: ${theme.spacing(3)};
- padding-right: ${theme.spacing(1)};
- width: 100%;
-
- &:focus-visible {
- outline: 2px solid ${theme.colors.highlight[100]};
- outline-offset: 2px;
- }
-`;
-
-const DropdownTriggerContent = styled.div`
- display: flex;
- flex-direction: column;
- gap: 2px;
-`;
-
-const DropdownLabel = styled.span`
- color: ${theme.colors.secondary.text[40]};
- font-size: ${theme.font.size(2.5)};
- font-weight: ${theme.font.weight.regular};
- line-height: ${theme.lineHeight(4)};
- text-align: left;
-`;
-
-const DropdownValue = styled.span`
- font-size: ${theme.font.size(4)};
- font-weight: ${theme.font.weight.regular};
- line-height: ${theme.lineHeight(5.5)};
- text-align: left;
-`;
-
-const DropdownIconContainer = styled.span`
- align-items: center;
- display: flex;
- flex-shrink: 0;
- height: 48px;
- justify-content: center;
- width: 48px;
-`;
-
-const DropdownPanel = styled.div`
- background: ${theme.colors.secondary.background[100]};
- border: 1px solid ${theme.colors.highlight[100]};
- border-radius: ${theme.radius(2)};
- box-shadow: 0 0 16px 0 rgba(15, 15, 15, 0.25);
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- gap: ${theme.spacing(1)};
- left: 0;
- margin-top: ${theme.spacing(2)};
- overflow: hidden;
- padding: ${theme.spacing(1)};
- position: absolute;
- right: 0;
- z-index: 1;
-`;
-
-const DropdownOption = styled.button`
- align-items: center;
- background: transparent;
- border: none;
- border-radius: ${theme.radius(1)};
- box-sizing: border-box;
- color: ${theme.colors.secondary.text[100]};
- cursor: pointer;
- display: flex;
- font-family: ${theme.font.family.sans};
- font-size: ${theme.font.size(4)};
- font-weight: ${theme.font.weight.regular};
- line-height: ${theme.lineHeight(5.5)};
- padding-bottom: ${theme.spacing(3)};
- padding-left: ${theme.spacing(2)};
- padding-right: ${theme.spacing(4)};
- padding-top: ${theme.spacing(3)};
- text-align: left;
- width: 100%;
-
- &[data-selected='true'] {
- background: rgba(74, 56, 245, 0.3);
- }
-
- &:hover {
- background: rgba(74, 56, 245, 0.15);
- }
-
- &[data-selected='true']:hover {
- background: rgba(74, 56, 245, 0.3);
- }
-
- &:focus-visible {
- outline: 2px solid ${theme.colors.highlight[100]};
- outline-offset: -2px;
- }
-`;
-
-const FieldRow = styled.div`
- display: flex;
- flex-direction: column;
- gap: clamp(8px, 1.5vh, 16px);
- width: 100%;
-
- @media (min-width: ${theme.breakpoints.md}px) {
- flex-direction: row;
- gap: ${theme.spacing(6)};
- }
-`;
-
-const SubmitError = styled.p`
- color: #ff9a9a;
- font-family: ${theme.font.family.sans};
- font-size: ${theme.font.size(3)};
- font-weight: ${theme.font.weight.regular};
- line-height: ${theme.lineHeight(3.5)};
- margin: 0;
-`;
-
-const SubmitButton = styled.button`
- ${buttonBaseStyles}
- position: relative;
- width: 100%;
-
- &:disabled {
- cursor: not-allowed;
- opacity: 0.65;
- }
-`;
-
-const SubmitLabel = styled.span`
- color: ${theme.colors.primary.text[100]};
- font-family: ${theme.font.family.mono};
- font-size: ${theme.font.size(3)};
- font-weight: ${theme.font.weight.medium};
- position: relative;
- text-transform: uppercase;
- z-index: 1;
-`;
-
-const FormFields = styled.div`
- display: flex;
- flex-direction: column;
- gap: clamp(8px, 1.5vh, 16px);
-`;
+ PartnerApplicationWizard,
+ partnerWizardPanelClass,
+} from '@/sections/PartnerApplication/wizard/PartnerApplicationWizard';
+import { useEffect, useState } from 'react';
type PartnerApplicationModalProps = {
open: boolean;
onClose: () => void;
- initialProgramId?: PartnerProgramId;
};
export function PartnerApplicationModal({
open,
onClose,
- initialProgramId = 'technology',
}: PartnerApplicationModalProps) {
- const { i18n } = useLingui();
- const formRef = useRef(null);
- const dropdownRef = useRef(null);
- const [programId, setProgramId] =
- useState(initialProgramId);
- const [dropdownOpen, setDropdownOpen] = useState(false);
- const [submitError, setSubmitError] = useState(null);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const copy = PARTNER_APPLICATION_MODAL_COPY;
+ const [resetSignal, setResetSignal] = useState(0);
- const formResetCallbacks = useMemo(
- () => ({
- formRef,
- setProgramId,
- setDropdownOpen,
- setSubmitError,
- setIsSubmitting,
- }),
- [],
- );
-
- usePartnerFormReset(open, initialProgramId, formResetCallbacks);
-
- const handleDropdownBlur = useCallback(
- (event: React.FocusEvent) => {
- if (!dropdownRef.current?.contains(event.relatedTarget as Node)) {
- setDropdownOpen(false);
- }
- },
- [],
- );
-
- const handleSubmit = useCallback(
- async (event: React.FormEvent) => {
- event.preventDefault();
-
- if (isSubmitting) {
- return;
- }
-
- const formData = new FormData(event.currentTarget);
-
- const nameValue = formData.get('name');
- const emailValue = formData.get('email');
- const companyValue = formData.get('company');
- const websiteValue = formData.get('website');
- const messageValue = formData.get('message');
- const opportunitiesValue = formData.get('opportunities');
-
- const name = typeof nameValue === 'string' ? nameValue.trim() : '';
- const email = typeof emailValue === 'string' ? emailValue.trim() : '';
- const company =
- typeof companyValue === 'string' ? companyValue.trim() : '';
- const website =
- typeof websiteValue === 'string' ? websiteValue.trim() : '';
- const message =
- typeof messageValue === 'string' ? messageValue.trim() : '';
- const opportunities =
- typeof opportunitiesValue === 'string' ? opportunitiesValue.trim() : '';
-
- const validationCopy = PARTNER_APPLICATION_MODAL_COPY.validation;
- const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
-
- setSubmitError(null);
-
- if (!name || !email || !company || !website || !message) {
- setSubmitError(i18n._(validationCopy.incompleteForm));
- return;
- }
-
- if (!emailLooksValid) {
- setSubmitError(i18n._(validationCopy.invalidEmail));
- return;
- }
-
- setIsSubmitting(true);
-
- try {
- const response = await fetch('/api/partner-application', {
- body: JSON.stringify({
- email,
- name,
- company,
- website,
- message,
- programId,
- ...(opportunities !== '' && { opportunities }),
- }),
- headers: { 'Content-Type': 'application/json' },
- method: 'POST',
- });
-
- if (!response.ok) {
- setSubmitError(i18n._(validationCopy.submitFailed));
- return;
- }
-
- onClose();
- } catch {
- setSubmitError(i18n._(validationCopy.submitFailed));
- } finally {
- setIsSubmitting(false);
- }
- },
- [isSubmitting, onClose, programId, i18n],
- );
+ useEffect(() => {
+ if (open) setResetSignal((n) => n + 1);
+ }, [open]);
return (
{
- if (!nextOpen) onClose();
+ onOpenChange={(next) => {
+ if (!next) onClose();
}}
- className={partnerPanelClass}
+ className={partnerWizardPanelClass}
>
-
-
-
-
- {i18n._(copy.titleSerif)}
-
-
-
- {i18n._(copy.titleSans)}
-
-
-
- }
- />
-
- {i18n._(copy.subtitleLine1)}
- {i18n._(copy.subtitleLine2)}
-
- }
- />
-
-
-
+
);
}
diff --git a/packages/twenty-website/src/sections/PartnerApplication/PartnerApplicationModalRoot.tsx b/packages/twenty-website/src/sections/PartnerApplication/PartnerApplicationModalRoot.tsx
index 46f4a683c4..a47a229f53 100644
--- a/packages/twenty-website/src/sections/PartnerApplication/PartnerApplicationModalRoot.tsx
+++ b/packages/twenty-website/src/sections/PartnerApplication/PartnerApplicationModalRoot.tsx
@@ -3,10 +3,9 @@
import { createContext, useContext, useState, type ReactNode } from 'react';
import { PartnerApplicationModal } from '@/sections/PartnerApplication/PartnerApplicationModal';
-import type { PartnerProgramId } from '@/sections/PartnerApplication/partner-application-modal-data';
type PartnerApplicationModalContextValue = {
- openPartnerApplicationModal: (programId?: PartnerProgramId) => void;
+ openPartnerApplicationModal: () => void;
};
const PartnerApplicationModalContext =
@@ -28,26 +27,12 @@ export function PartnerApplicationModalRoot({
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
- const [initialProgramId, setInitialProgramId] =
- useState('technology');
-
return (
{
- if (programId !== undefined) {
- setInitialProgramId(programId);
- }
- setOpen(true);
- },
- }}
+ value={{ openPartnerApplicationModal: () => setOpen(true) }}
>
{children}
- setOpen(false)}
- open={open}
- />
+ setOpen(false)} />
);
}
diff --git a/packages/twenty-website/src/sections/PartnerApplication/index.ts b/packages/twenty-website/src/sections/PartnerApplication/index.ts
index c586e267fe..426cb5fe7b 100644
--- a/packages/twenty-website/src/sections/PartnerApplication/index.ts
+++ b/packages/twenty-website/src/sections/PartnerApplication/index.ts
@@ -1,13 +1,6 @@
+export { PartnerApplicationModal } from './PartnerApplicationModal';
export {
PartnerApplicationModalRoot,
usePartnerApplicationModal,
} from './PartnerApplicationModalRoot';
-
-export {
- PARTNER_APPLICATION_MODAL_COPY,
- PARTNER_PROGRAM_IDS,
- PARTNER_PROGRAM_LABELS,
- type PartnerProgramId,
-} from './partner-application-modal-data';
-
export { splitFullName } from './split-full-name';
diff --git a/packages/twenty-website/src/sections/PartnerApplication/partner-application-field-schemas.ts b/packages/twenty-website/src/sections/PartnerApplication/partner-application-field-schemas.ts
new file mode 100644
index 0000000000..615709caec
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/partner-application-field-schemas.ts
@@ -0,0 +1,59 @@
+import {
+ PARTNER_COUNTRY_VALUES,
+ PARTNER_LANGUAGE_VALUES,
+ PARTNER_SCOPE_VALUES,
+ PARTNER_TYPE_OF_TEAM_VALUES,
+} from '@/sections/PartnerApplication/wizard/partner-fields.data';
+import { z } from 'zod';
+
+// Single source of truth for partner-application validation, shared by the
+// server route schema and the client wizard reducer so both reject the same
+// values. This lives in the section layer (not the route) because sections must
+// not import from `@/app/**`; the route imports from here instead.
+
+export const emailFieldSchema = z
+ .string()
+ .trim()
+ .min(1, { error: 'Email is required.' })
+ .pipe(z.email({ error: 'Invalid email address.' }));
+
+// z.httpUrl enforces an http(s) scheme and a TLD-shaped hostname. This means
+// the client now rejects bare hosts like http://localhost, matching the server.
+export const httpUrlFieldSchema = z
+ .string()
+ .trim()
+ .min(1)
+ .pipe(z.httpUrl({ error: 'Invalid URL.' }));
+
+const optionalNonEmptyString = z.string().trim().min(1).optional();
+const optionalUrl = httpUrlFieldSchema.optional();
+const optionalNonNegativeNumber = z.number().nonnegative().optional();
+
+export const partnerApplicationRequestSchema = z.strictObject({
+ // Identity
+ name: z.string().trim().min(1, { error: 'Name is required.' }),
+ email: emailFieldSchema,
+ company: z.string().trim().min(1, { error: 'Company is required.' }),
+ website: optionalUrl,
+
+ // Profile
+ linkedin: optionalUrl,
+ city: optionalNonEmptyString,
+ country: z.enum(PARTNER_COUNTRY_VALUES).optional(),
+ languages: z.array(z.enum(PARTNER_LANGUAGE_VALUES)).optional(),
+
+ // Expertise & experience
+ typeOfTeam: z.enum(PARTNER_TYPE_OF_TEAM_VALUES).optional(),
+ partnerScope: z.array(z.enum(PARTNER_SCOPE_VALUES)).optional(),
+ skills: z.array(z.string().trim().min(1)).optional(),
+ applicationNotes: optionalNonEmptyString,
+
+ // Commercials
+ hourlyRate: optionalNonNegativeNumber,
+ projectBudgetMin: optionalNonNegativeNumber,
+ calendarLink: optionalUrl,
+});
+
+export type PartnerApplicationRequest = z.infer<
+ typeof partnerApplicationRequestSchema
+>;
diff --git a/packages/twenty-website/src/sections/PartnerApplication/partner-application-modal-data.ts b/packages/twenty-website/src/sections/PartnerApplication/partner-application-modal-data.ts
index 3a9db4ec61..4269d44d3e 100644
--- a/packages/twenty-website/src/sections/PartnerApplication/partner-application-modal-data.ts
+++ b/packages/twenty-website/src/sections/PartnerApplication/partner-application-modal-data.ts
@@ -1,43 +1,69 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
-export const PARTNER_PROGRAM_IDS = [
- 'technology',
- 'content',
- 'solutions',
-] as const;
-
-export type PartnerProgramId = (typeof PARTNER_PROGRAM_IDS)[number];
-
-export const PARTNER_PROGRAM_LABELS: Record<
- PartnerProgramId,
- MessageDescriptor
-> = {
- technology: msg`Technology Partner`,
- content: msg`Content & Community Partner`,
- solutions: msg`Solutions Partner`,
-};
-
export const PARTNER_APPLICATION_MODAL_COPY = {
titleSerif: msg`Apply to build`,
titleSans: msg`the future of CRM`,
subtitleLine1: msg`Join our ecosystem and help businesses take control of their customer data with`,
subtitleLine2: msg`open-source primitives.`,
- selectLabel: msg`Select your team`,
- fields: {
- name: msg`Your name *`,
- email: msg`Work email *`,
- company: msg`Company or brand *`,
- website: msg`Website or github link *`,
- opportunities: msg`Estimated monthly opportunities (optional)`,
- messageLabel: msg`How do you want to partner with Twenty? *`,
- messageHint: msg`Tell us about the custom solutions or integrations you plan to build.`,
- },
+ stepProgressLabel: (current: number, total: number) =>
+ msg`Step ${current} of ${total}`,
+ back: msg`← Back`,
+ next: msg`Next →`,
submit: msg`Submit application`,
submitInFlight: msg`Submitting…`,
+ successTitleSerif: msg`Thanks,`,
+ successTitleSans: msg`we'll be in touch!`,
+ successClose: msg`Close`,
validation: {
- incompleteForm: msg`Please complete all required fields before submitting.`,
+ incompleteForm: msg`Please complete all required fields before continuing.`,
invalidEmail: msg`Enter a valid email address.`,
+ invalidUrl: msg`Enter a valid URL (starting with http:// or https://).`,
submitFailed: msg`We could not submit your application. Please try again in a moment.`,
},
} as const;
+
+export const PARTNER_APPLICATION_FIELD_COPY = {
+ // Identity
+ name: msg`Your name *`,
+ email: msg`Work email *`,
+ company: msg`Company or brand *`,
+ website: msg`Website or GitHub`,
+ // Profile
+ linkedin: msg`LinkedIn URL`,
+ city: msg`City`,
+ country: msg`Country *`,
+ countryPlaceholder: msg`Select your country`,
+ countrySearchPlaceholder: msg`Search a country…`,
+ countrySearchEmpty: msg`No matching country.`,
+ languages: msg`Languages spoken`,
+ // Expertise
+ typeOfTeam: msg`Type of team *`,
+ typeOfTeamPlaceholder: msg`Solo or agency?`,
+ partnerScope: msg`What you cover *`,
+ partnerScopeHint: msg`Pick every category that applies.`,
+ skills: msg`Technical skills`,
+ skillsHint: msg`Press Enter or comma to add a skill.`,
+ skillsPlaceholder: msg`e.g. React, Postgres, n8n…`,
+ applicationNotes: msg`Anything else we should know?`,
+ applicationNotesPlaceholder: msg`Workspace URL, customer references, relevant links…`,
+ // Commercials
+ hourlyRate: msg`Hourly rate`,
+ hourlyRatePlaceholder: msg`150`,
+ projectBudgetMin: msg`Minimum project budget`,
+ projectBudgetMinPlaceholder: msg`5,000`,
+ calendarLink: msg`Calendar / booking link`,
+} as const;
+
+export type PartnerApplicationFieldCopyKey =
+ keyof typeof PARTNER_APPLICATION_FIELD_COPY;
+
+export const PARTNER_APPLICATION_STEP_HEADER_LABELS: Record<
+ 'identity' | 'profile' | 'expertise' | 'commercials',
+ MessageDescriptor
+> = {
+ identity: msg`Identity`,
+ profile: msg`Profile`,
+ expertise: msg`Expertise & experience`,
+ commercials: msg`Commercials`,
+};
diff --git a/packages/twenty-website/src/sections/PartnerApplication/use-partner-form-reset.ts b/packages/twenty-website/src/sections/PartnerApplication/use-partner-form-reset.ts
deleted file mode 100644
index b93cf83bda..0000000000
--- a/packages/twenty-website/src/sections/PartnerApplication/use-partner-form-reset.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-'use client';
-
-import { useEffect } from 'react';
-
-import type { PartnerProgramId } from './partner-application-modal-data';
-
-export function usePartnerFormReset(
- open: boolean,
- initialProgramId: PartnerProgramId,
- callbacks: {
- formRef: React.RefObject;
- setProgramId: (id: PartnerProgramId) => void;
- setDropdownOpen: (open: boolean) => void;
- setSubmitError: (error: string | null) => void;
- setIsSubmitting: (submitting: boolean) => void;
- },
-) {
- useEffect(() => {
- if (open) {
- callbacks.setProgramId(initialProgramId);
- }
- }, [open, initialProgramId, callbacks]);
-
- useEffect(() => {
- if (open) {
- callbacks.setSubmitError(null);
- return;
- }
-
- callbacks.formRef.current?.reset();
- callbacks.setProgramId('technology');
- callbacks.setDropdownOpen(false);
- callbacks.setSubmitError(null);
- callbacks.setIsSubmitting(false);
- }, [open, callbacks]);
-}
diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/PartnerApplicationWizard.tsx b/packages/twenty-website/src/sections/PartnerApplication/wizard/PartnerApplicationWizard.tsx
new file mode 100644
index 0000000000..7231ddc5df
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/PartnerApplicationWizard.tsx
@@ -0,0 +1,381 @@
+'use client';
+
+import { Body, Heading, HeadingPart, Modal } from '@/design-system/components';
+import {
+ BUTTON_HEIGHTS_PX,
+ buttonBaseStyles,
+} from '@/design-system/components/Button/BaseButton';
+import { ButtonShape } from '@/design-system/components/Button/ButtonShape';
+import {
+ PARTNER_APPLICATION_MODAL_COPY,
+ PARTNER_APPLICATION_STEP_HEADER_LABELS,
+} from '@/sections/PartnerApplication/partner-application-modal-data';
+import { PARTNER_APPLICATION_STEP_IDS } from '@/sections/PartnerApplication/wizard/partner-fields.data';
+import { StepIndicator } from '@/sections/PartnerApplication/wizard/StepIndicator';
+import { IdentityStep } from '@/sections/PartnerApplication/wizard/steps/IdentityStep';
+import { ProfileStep } from '@/sections/PartnerApplication/wizard/steps/ProfileStep';
+import { ExpertiseStep } from '@/sections/PartnerApplication/wizard/steps/ExpertiseStep';
+import { CommercialsStep } from '@/sections/PartnerApplication/wizard/steps/CommercialsStep';
+import {
+ buildPartnerApplicationRequestBody,
+ getCurrentStepId,
+ usePartnerApplicationState,
+ type PartnerApplicationController,
+} from '@/sections/PartnerApplication/wizard/use-partner-application-state';
+import { theme } from '@/theme';
+import { useLingui } from '@lingui/react';
+import { css } from '@linaria/core';
+import { styled } from '@linaria/react';
+import { useCallback, useEffect } from 'react';
+
+const STEPS = PARTNER_APPLICATION_STEP_IDS;
+
+const TitleBlock = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: clamp(8px, 2vh, 24px);
+`;
+
+const TitleHeadingWrapper = styled.div`
+ color: ${theme.colors.secondary.text[100]};
+`;
+
+const SubtitleStack = styled.div`
+ color: ${theme.colors.secondary.text[60]};
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+`;
+
+const HeaderStrip = styled.div`
+ align-items: center;
+ display: flex;
+ gap: ${theme.spacing(3)};
+ justify-content: space-between;
+ margin-top: ${theme.spacing(2)};
+`;
+
+const HeaderLabel = styled.span`
+ color: ${theme.colors.secondary.text[60]};
+ font-family: ${theme.font.family.mono};
+ font-size: ${theme.font.size(3)};
+ text-transform: uppercase;
+`;
+
+const FieldsStack = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: clamp(8px, 1.5vh, 16px);
+`;
+
+const FooterControls = styled.div`
+ align-items: center;
+ display: flex;
+ gap: ${theme.spacing(3)};
+ justify-content: space-between;
+ width: 100%;
+`;
+
+const SecondaryButton = styled.button`
+ background: transparent;
+ border: 1px solid ${theme.colors.secondary.border[20]};
+ border-radius: ${theme.radius(2)};
+ color: ${theme.colors.secondary.text[100]};
+ cursor: pointer;
+ font-family: ${theme.font.family.mono};
+ font-size: ${theme.font.size(3)};
+ height: ${BUTTON_HEIGHTS_PX.regular}px;
+ padding: 0 ${theme.spacing(4)};
+ text-transform: uppercase;
+`;
+
+const PrimaryButton = styled.button`
+ ${buttonBaseStyles}
+ position: relative;
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.65;
+ }
+`;
+
+const PrimaryLabel = styled.span`
+ color: ${theme.colors.primary.text[100]};
+ font-family: ${theme.font.family.mono};
+ font-size: ${theme.font.size(3)};
+ font-weight: ${theme.font.weight.medium};
+ position: relative;
+ text-transform: uppercase;
+ z-index: 1;
+`;
+
+const SubmitError = styled.p`
+ color: #ff9a9a;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3)};
+ font-weight: ${theme.font.weight.regular};
+ margin: 0;
+`;
+
+const FieldErrorBanner = styled.p`
+ color: #ff9a9a;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3)};
+ margin: 0;
+`;
+
+const SuccessView = styled.div`
+ align-items: stretch;
+ display: flex;
+ flex-direction: column;
+ gap: clamp(16px, 4vh, 32px);
+ margin-top: clamp(16px, 4vh, 32px);
+`;
+
+function StepRenderer({
+ controller,
+}: {
+ controller: PartnerApplicationController;
+}) {
+ const stepId = getCurrentStepId(controller.state);
+ switch (stepId) {
+ case 'identity':
+ return ;
+ case 'profile':
+ return ;
+ case 'expertise':
+ return ;
+ case 'commercials':
+ return ;
+ }
+}
+
+type WizardProps = {
+ resetSignal: number;
+ onSuccess: () => void;
+};
+
+export function PartnerApplicationWizard({
+ resetSignal,
+ onSuccess,
+}: WizardProps) {
+ const { i18n } = useLingui();
+ const controller = usePartnerApplicationState();
+ const {
+ state,
+ goNext,
+ goBack,
+ setSubmitting,
+ setSubmitError,
+ setSubmitted,
+ reset,
+ } = controller;
+
+ useEffect(() => {
+ reset();
+ }, [resetSignal, reset]);
+
+ const stepId = getCurrentStepId(state);
+ const stepIndex = state.stepIndex;
+ const isLastStep = stepIndex === STEPS.length - 1;
+ const errorValues = Object.values(state.fieldErrors);
+ const hasFieldErrors = errorValues.length > 0;
+ const fieldErrorMessage = errorValues.includes('invalid_email')
+ ? PARTNER_APPLICATION_MODAL_COPY.validation.invalidEmail
+ : errorValues.includes('invalid_url')
+ ? PARTNER_APPLICATION_MODAL_COPY.validation.invalidUrl
+ : PARTNER_APPLICATION_MODAL_COPY.validation.incompleteForm;
+
+ const stepLabelNode = (
+ <>
+ {i18n._(
+ PARTNER_APPLICATION_MODAL_COPY.stepProgressLabel(
+ stepIndex + 1,
+ STEPS.length,
+ ),
+ )}{' '}
+ · {i18n._(PARTNER_APPLICATION_STEP_HEADER_LABELS[stepId])}
+ >
+ );
+
+ const handleSubmit = useCallback(
+ async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!isLastStep) {
+ goNext();
+ return;
+ }
+ if (state.isSubmitting) return;
+
+ const payload = buildPartnerApplicationRequestBody(state);
+
+ setSubmitError(null);
+ setSubmitting(true);
+ try {
+ const response = await fetch('/api/partner-application', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ setSubmitError(
+ i18n._(PARTNER_APPLICATION_MODAL_COPY.validation.submitFailed),
+ );
+ return;
+ }
+ setSubmitted();
+ } catch {
+ setSubmitError(
+ i18n._(PARTNER_APPLICATION_MODAL_COPY.validation.submitFailed),
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ },
+ [
+ isLastStep,
+ goNext,
+ state,
+ setSubmitError,
+ setSubmitting,
+ setSubmitted,
+ i18n,
+ ],
+ );
+
+ if (state.isSubmitted) {
+ return (
+ <>
+
+
+
+
+ {i18n._(PARTNER_APPLICATION_MODAL_COPY.successTitleSerif)}
+
+
+
+ {i18n._(PARTNER_APPLICATION_MODAL_COPY.successTitleSans)}
+
+
+
+ }
+ />
+
+
+
+
+
+
+ {i18n._(PARTNER_APPLICATION_MODAL_COPY.successClose)}
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ {stepIndex === 0 ? (
+ <>
+
+
+
+ {i18n._(PARTNER_APPLICATION_MODAL_COPY.titleSerif)}
+
+
+
+ {i18n._(PARTNER_APPLICATION_MODAL_COPY.titleSans)}
+
+
+
+ }
+ />
+
+
+ {i18n._(PARTNER_APPLICATION_MODAL_COPY.subtitleLine1)}
+
+
+ {i18n._(PARTNER_APPLICATION_MODAL_COPY.subtitleLine2)}
+
+
+ }
+ />
+ >
+ ) : null}
+
+ {stepIndex === 0 ? (
+ {stepLabelNode}
+ ) : (
+ {stepLabelNode}} />
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+export const partnerWizardPanelClass = css`
+ --modal-panel-width: min(360px, 100%);
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ --modal-panel-width: min(720px, 100%);
+ }
+`;
diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/StepIndicator.tsx b/packages/twenty-website/src/sections/PartnerApplication/wizard/StepIndicator.tsx
new file mode 100644
index 0000000000..dc2eedfefb
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/StepIndicator.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+
+const Track = styled.div`
+ align-items: center;
+ display: flex;
+ gap: ${theme.spacing(2)};
+`;
+
+const Dot = styled.span`
+ background: ${theme.colors.secondary.border[20]};
+ border-radius: 50%;
+ display: inline-block;
+ height: 8px;
+ transition: background 0.2s ease;
+ width: 8px;
+
+ &[data-state='active'],
+ &[data-state='completed'] {
+ background: ${theme.colors.highlight[100]};
+ }
+
+ &[data-state='active'] {
+ transform: scale(1.25);
+ }
+`;
+
+const Connector = styled.span`
+ background: ${theme.colors.secondary.border[20]};
+ display: inline-block;
+ flex: 1 1 auto;
+ height: 1px;
+ max-width: 32px;
+
+ &[data-completed='true'] {
+ background: ${theme.colors.highlight[100]};
+ }
+`;
+
+type StepIndicatorProps = {
+ stepCount: number;
+ activeStepIndex: number;
+};
+
+export function StepIndicator({
+ stepCount,
+ activeStepIndex,
+}: StepIndicatorProps) {
+ return (
+
+ );
+}
diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/__tests__/build-partner-application-request-body.test.ts b/packages/twenty-website/src/sections/PartnerApplication/wizard/__tests__/build-partner-application-request-body.test.ts
new file mode 100644
index 0000000000..cdb2b5a061
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/__tests__/build-partner-application-request-body.test.ts
@@ -0,0 +1,120 @@
+import {
+ buildPartnerApplicationRequestBody,
+ INITIAL_PARTNER_APPLICATION_STATE,
+ type PartnerApplicationState,
+} from '@/sections/PartnerApplication/wizard/use-partner-application-state';
+
+const minimalState: PartnerApplicationState = {
+ ...INITIAL_PARTNER_APPLICATION_STATE,
+ name: 'Ada Lovelace',
+ email: 'ada@example.com',
+ company: 'Analytical Engines Ltd',
+};
+
+describe('buildPartnerApplicationRequestBody', () => {
+ it('keeps only the required fields when optionals are empty', () => {
+ const body = buildPartnerApplicationRequestBody(minimalState);
+ expect(body).toEqual({
+ name: 'Ada Lovelace',
+ email: 'ada@example.com',
+ company: 'Analytical Engines Ltd',
+ });
+ });
+
+ it('trims the required string fields', () => {
+ const body = buildPartnerApplicationRequestBody({
+ ...minimalState,
+ name: ' Ada Lovelace ',
+ email: ' ada@example.com ',
+ company: ' Analytical Engines Ltd ',
+ });
+ expect(body.name).toBe('Ada Lovelace');
+ expect(body.email).toBe('ada@example.com');
+ expect(body.company).toBe('Analytical Engines Ltd');
+ });
+
+ it('omits optional string fields that are blank or whitespace-only', () => {
+ const body = buildPartnerApplicationRequestBody({
+ ...minimalState,
+ website: ' ',
+ linkedin: '',
+ city: ' ',
+ applicationNotes: '',
+ calendarLink: ' ',
+ });
+ expect('website' in body).toBe(false);
+ expect('linkedin' in body).toBe(false);
+ expect('city' in body).toBe(false);
+ expect('applicationNotes' in body).toBe(false);
+ expect('calendarLink' in body).toBe(false);
+ });
+
+ it('trims optional string fields when present', () => {
+ const body = buildPartnerApplicationRequestBody({
+ ...minimalState,
+ website: ' https://analyticalengines.example ',
+ linkedin: ' https://www.linkedin.com/in/ada ',
+ city: ' London ',
+ applicationNotes: ' refs: Acme ',
+ calendarLink: ' https://cal.com/ada ',
+ });
+ expect(body.website).toBe('https://analyticalengines.example');
+ expect(body.linkedin).toBe('https://www.linkedin.com/in/ada');
+ expect(body.city).toBe('London');
+ expect(body.applicationNotes).toBe('refs: Acme');
+ expect(body.calendarLink).toBe('https://cal.com/ada');
+ });
+
+ it('omits enum/array fields when unset and forwards them when set', () => {
+ const empty = buildPartnerApplicationRequestBody(minimalState);
+ expect('country' in empty).toBe(false);
+ expect('typeOfTeam' in empty).toBe(false);
+ expect('languages' in empty).toBe(false);
+ expect('partnerScope' in empty).toBe(false);
+ expect('skills' in empty).toBe(false);
+
+ const filled = buildPartnerApplicationRequestBody({
+ ...minimalState,
+ country: 'UNITED_KINGDOM',
+ typeOfTeam: 'SOLO',
+ languages: ['ENGLISH', 'FRENCH'],
+ partnerScope: ['ADVISORY', 'SOLUTIONING'],
+ skills: ['React', 'TypeScript'],
+ });
+ expect(filled.country).toBe('UNITED_KINGDOM');
+ expect(filled.typeOfTeam).toBe('SOLO');
+ expect(filled.languages).toEqual(['ENGLISH', 'FRENCH']);
+ expect(filled.partnerScope).toEqual(['ADVISORY', 'SOLUTIONING']);
+ expect(filled.skills).toEqual(['React', 'TypeScript']);
+ });
+
+ it('parses hourlyRate and projectBudgetMin into non-negative numbers', () => {
+ const body = buildPartnerApplicationRequestBody({
+ ...minimalState,
+ hourlyRate: '150',
+ projectBudgetMin: '5000',
+ });
+ expect(body.hourlyRate).toBe(150);
+ expect(body.projectBudgetMin).toBe(5000);
+ });
+
+ it('omits numeric fields that are blank or not parseable', () => {
+ const body = buildPartnerApplicationRequestBody({
+ ...minimalState,
+ hourlyRate: '',
+ projectBudgetMin: 'abc',
+ });
+ expect('hourlyRate' in body).toBe(false);
+ expect('projectBudgetMin' in body).toBe(false);
+ });
+
+ it('omits negative numeric values', () => {
+ const body = buildPartnerApplicationRequestBody({
+ ...minimalState,
+ hourlyRate: '-5',
+ projectBudgetMin: '-1',
+ });
+ expect('hourlyRate' in body).toBe(false);
+ expect('projectBudgetMin' in body).toBe(false);
+ });
+});
diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/__tests__/partner-fields.data.test.ts b/packages/twenty-website/src/sections/PartnerApplication/wizard/__tests__/partner-fields.data.test.ts
new file mode 100644
index 0000000000..6b36211e69
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/__tests__/partner-fields.data.test.ts
@@ -0,0 +1,44 @@
+import {
+ PARTNER_SCOPE_VALUES,
+ PARTNER_SCOPE_OPTIONS,
+ PARTNER_SKILL_SUGGESTIONS,
+ PARTNER_APPLICATION_STEP_REQUIRED_FIELDS,
+} from '@/sections/PartnerApplication/wizard/partner-fields.data';
+
+describe('partner-fields.data', () => {
+ it('exposes the five validated category values', () => {
+ expect([...PARTNER_SCOPE_VALUES]).toEqual([
+ 'ADVISORY',
+ 'SOLUTIONING',
+ 'DEVELOPMENT',
+ 'HOSTING',
+ 'SUPPORT',
+ ]);
+ });
+
+ it('has one option (value/label/description/examples) per category value', () => {
+ expect(PARTNER_SCOPE_OPTIONS.map((o) => o.value)).toEqual([
+ ...PARTNER_SCOPE_VALUES,
+ ]);
+ for (const option of PARTNER_SCOPE_OPTIONS) {
+ expect(option.label).toBeDefined();
+ expect(option.description).toBeDefined();
+ expect(option.examples).toBeDefined();
+ }
+ });
+
+ it('ships a non-empty starter skills suggestion pool', () => {
+ expect(PARTNER_SKILL_SUGGESTIONS.length).toBeGreaterThan(0);
+ expect(PARTNER_SKILL_SUGGESTIONS).toContain('React');
+ });
+
+ it('requires country + typeOfTeam on profile, partnerScope on expertise', () => {
+ expect([...PARTNER_APPLICATION_STEP_REQUIRED_FIELDS.profile]).toEqual([
+ 'country',
+ 'typeOfTeam',
+ ]);
+ expect([...PARTNER_APPLICATION_STEP_REQUIRED_FIELDS.expertise]).toEqual([
+ 'partnerScope',
+ ]);
+ });
+});
diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/__tests__/use-partner-application-state.test.ts b/packages/twenty-website/src/sections/PartnerApplication/wizard/__tests__/use-partner-application-state.test.ts
new file mode 100644
index 0000000000..cf0b159498
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/__tests__/use-partner-application-state.test.ts
@@ -0,0 +1,155 @@
+import {
+ INITIAL_PARTNER_APPLICATION_STATE,
+ partnerApplicationReducer,
+ type PartnerApplicationState,
+} from '@/sections/PartnerApplication/wizard/use-partner-application-state';
+
+const baseValidIdentity: Partial = {
+ name: 'Ada Lovelace',
+ email: 'ada@example.com',
+ company: 'Analytical Engines Ltd',
+};
+
+describe('partnerApplicationReducer', () => {
+ it('starts at stepIndex 0 with empty fields', () => {
+ expect(INITIAL_PARTNER_APPLICATION_STATE.stepIndex).toBe(0);
+ expect(INITIAL_PARTNER_APPLICATION_STATE.name).toBe('');
+ expect(INITIAL_PARTNER_APPLICATION_STATE.partnerScope).toEqual([]);
+ });
+
+ it('SET_FIELD updates the field and clears any prior error for that field', () => {
+ const seeded: PartnerApplicationState = {
+ ...INITIAL_PARTNER_APPLICATION_STATE,
+ fieldErrors: { email: 'invalid_email' },
+ };
+ const next = partnerApplicationReducer(seeded, {
+ type: 'SET_FIELD',
+ field: 'email',
+ value: 'ada@example.com',
+ });
+ expect(next.email).toBe('ada@example.com');
+ expect(next.fieldErrors.email).toBeUndefined();
+ });
+
+ it('TOGGLE_SCOPE adds and then removes a scope value', () => {
+ const added = partnerApplicationReducer(INITIAL_PARTNER_APPLICATION_STATE, {
+ type: 'TOGGLE_SCOPE',
+ value: 'ADVISORY',
+ });
+ expect(added.partnerScope).toEqual(['ADVISORY']);
+ const removed = partnerApplicationReducer(added, {
+ type: 'TOGGLE_SCOPE',
+ value: 'ADVISORY',
+ });
+ expect(removed.partnerScope).toEqual([]);
+ });
+
+ it('GO_NEXT on Identity step with missing required fields fills fieldErrors and stays put', () => {
+ const next = partnerApplicationReducer(INITIAL_PARTNER_APPLICATION_STATE, {
+ type: 'GO_NEXT',
+ });
+ expect(next.stepIndex).toBe(0);
+ expect(Object.keys(next.fieldErrors).sort()).toEqual([
+ 'company',
+ 'email',
+ 'name',
+ ]);
+ });
+
+ it('GO_NEXT on Identity step with required fields valid advances to Profile', () => {
+ const seeded: PartnerApplicationState = {
+ ...INITIAL_PARTNER_APPLICATION_STATE,
+ ...baseValidIdentity,
+ } as PartnerApplicationState;
+ const next = partnerApplicationReducer(seeded, { type: 'GO_NEXT' });
+ expect(next.stepIndex).toBe(1);
+ expect(next.fieldErrors).toEqual({});
+ });
+
+ it('GO_NEXT on Identity rejects malformed email', () => {
+ const seeded: PartnerApplicationState = {
+ ...INITIAL_PARTNER_APPLICATION_STATE,
+ ...baseValidIdentity,
+ email: 'not-an-email',
+ } as PartnerApplicationState;
+ const next = partnerApplicationReducer(seeded, { type: 'GO_NEXT' });
+ expect(next.stepIndex).toBe(0);
+ expect(next.fieldErrors.email).toBe('invalid_email');
+ });
+
+ it('GO_BACK clamps at 0 and clears errors', () => {
+ const onProfileWithErrors: PartnerApplicationState = {
+ ...INITIAL_PARTNER_APPLICATION_STATE,
+ stepIndex: 1,
+ fieldErrors: { country: 'required' },
+ };
+ const next = partnerApplicationReducer(onProfileWithErrors, {
+ type: 'GO_BACK',
+ });
+ expect(next.stepIndex).toBe(0);
+ expect(next.fieldErrors).toEqual({});
+ const clamped = partnerApplicationReducer(next, { type: 'GO_BACK' });
+ expect(clamped.stepIndex).toBe(0);
+ });
+
+ it('SET_SUBMITTED flips isSubmitted to true and clears submitError + isSubmitting', () => {
+ const seeded: PartnerApplicationState = {
+ ...INITIAL_PARTNER_APPLICATION_STATE,
+ isSubmitting: true,
+ submitError: 'transient network error',
+ };
+ const next = partnerApplicationReducer(seeded, { type: 'SET_SUBMITTED' });
+ expect(next.isSubmitted).toBe(true);
+ expect(next.isSubmitting).toBe(false);
+ expect(next.submitError).toBeNull();
+ });
+
+ it('RESET clears isSubmitted back to false', () => {
+ const seeded: PartnerApplicationState = {
+ ...INITIAL_PARTNER_APPLICATION_STATE,
+ isSubmitted: true,
+ };
+ expect(partnerApplicationReducer(seeded, { type: 'RESET' })).toEqual(
+ INITIAL_PARTNER_APPLICATION_STATE,
+ );
+ });
+
+ it('RESET returns to initial state from any state', () => {
+ const dirty: PartnerApplicationState = {
+ ...INITIAL_PARTNER_APPLICATION_STATE,
+ name: 'x',
+ stepIndex: 3,
+ isSubmitting: true,
+ };
+ expect(partnerApplicationReducer(dirty, { type: 'RESET' })).toEqual(
+ INITIAL_PARTNER_APPLICATION_STATE,
+ );
+ });
+
+ it('GO_NEXT on Profile requires country and typeOfTeam', () => {
+ const onProfile: PartnerApplicationState = {
+ ...INITIAL_PARTNER_APPLICATION_STATE,
+ stepIndex: 1,
+ country: 'FRANCE',
+ };
+ const blocked = partnerApplicationReducer(onProfile, { type: 'GO_NEXT' });
+ expect(blocked.stepIndex).toBe(1);
+ expect(blocked.fieldErrors.typeOfTeam).toBe('required');
+
+ const ok = partnerApplicationReducer(
+ { ...onProfile, typeOfTeam: 'SOLO' },
+ { type: 'GO_NEXT' },
+ );
+ expect(ok.stepIndex).toBe(2);
+ expect(ok.fieldErrors).toEqual({});
+ });
+
+ it('SET_FIELD sets applicationNotes', () => {
+ const next = partnerApplicationReducer(INITIAL_PARTNER_APPLICATION_STATE, {
+ type: 'SET_FIELD',
+ field: 'applicationNotes',
+ value: 'Workspace: https://x · refs: Acme',
+ });
+ expect(next.applicationNotes).toBe('Workspace: https://x · refs: Acme');
+ });
+});
diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/partner-fields.data.ts b/packages/twenty-website/src/sections/PartnerApplication/wizard/partner-fields.data.ts
new file mode 100644
index 0000000000..4c5dfdf97f
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/partner-fields.data.ts
@@ -0,0 +1,545 @@
+import type { MessageDescriptor } from '@lingui/core';
+import { msg } from '@lingui/core/macro';
+
+import type { PartnerApplicationState } from './use-partner-application-state';
+
+export const PARTNER_APPLICATION_STEP_IDS = [
+ 'identity',
+ 'profile',
+ 'expertise',
+ 'commercials',
+] as const;
+
+export type PartnerApplicationStepId =
+ (typeof PARTNER_APPLICATION_STEP_IDS)[number];
+
+export const PARTNER_TYPE_OF_TEAM_VALUES = ['SOLO', 'AGENCY'] as const;
+export type PartnerTypeOfTeam = (typeof PARTNER_TYPE_OF_TEAM_VALUES)[number];
+
+export const PARTNER_TYPE_OF_TEAM_OPTIONS: ReadonlyArray<{
+ value: PartnerTypeOfTeam;
+ label: MessageDescriptor;
+}> = [
+ { value: 'SOLO', label: msg`Solo` },
+ { value: 'AGENCY', label: msg`Agency` },
+];
+
+export const PARTNER_SCOPE_VALUES = [
+ 'ADVISORY',
+ 'SOLUTIONING',
+ 'DEVELOPMENT',
+ 'HOSTING',
+ 'SUPPORT',
+] as const;
+export type PartnerScopeValue = (typeof PARTNER_SCOPE_VALUES)[number];
+
+export const PARTNER_SCOPE_OPTIONS: ReadonlyArray<{
+ value: PartnerScopeValue;
+ label: MessageDescriptor;
+ description: MessageDescriptor;
+ examples: MessageDescriptor;
+}> = [
+ {
+ value: 'ADVISORY',
+ label: msg`Advisory & Discovery`,
+ description: msg`Upfront consulting, scoping, strategy.`,
+ examples: msg`CRM audit · Requirements · Process mapping · ROI · RevOps · Vendor selection`,
+ },
+ {
+ value: 'SOLUTIONING',
+ label: msg`Solutioning`,
+ description: msg`What an admin can do without writing code.`,
+ examples: msg`Data modeling · Migrations · No-code workflows · Dashboards · SSO/SCIM · Integrations`,
+ },
+ {
+ value: 'DEVELOPMENT',
+ label: msg`Custom Development`,
+ description: msg`Anything that needs a developer.`,
+ examples: msg`Custom Apps · Scripts · AI/agent integrations`,
+ },
+ {
+ value: 'HOSTING',
+ label: msg`Hosting & Infrastructure`,
+ description: msg`Anything that needs devops skills.`,
+ examples: msg`Self-hosted (Docker/K8s) · Cloud architecture · Scaling · Security · Monitoring`,
+ },
+ {
+ value: 'SUPPORT',
+ label: msg`Training, Adoption & Support`,
+ description: msg`User-side rollout & ongoing support.`,
+ examples: msg`Onboarding · Documentation · Change management · L1/L2 support · Managed services`,
+ },
+];
+
+export const PARTNER_SKILL_SUGGESTIONS: ReadonlyArray = [
+ 'React',
+ 'TypeScript',
+ 'Node.js',
+ 'Python',
+ 'PostgreSQL',
+ 'GraphQL',
+ 'n8n',
+ 'Zapier',
+ 'Make',
+ 'Salesforce',
+ 'HubSpot',
+ 'SAP',
+ 'Shopify',
+ 'Stripe',
+ 'Docker',
+ 'Kubernetes',
+ 'AWS',
+ 'GCP',
+];
+
+export const PARTNER_COUNTRY_VALUES = [
+ 'AFGHANISTAN',
+ 'ALBANIA',
+ 'ALGERIA',
+ 'ANDORRA',
+ 'ANGOLA',
+ 'ANTIGUA_AND_BARBUDA',
+ 'ARGENTINA',
+ 'ARMENIA',
+ 'AUSTRALIA',
+ 'AUSTRIA',
+ 'AZERBAIJAN',
+ 'BAHAMAS',
+ 'BAHRAIN',
+ 'BANGLADESH',
+ 'BARBADOS',
+ 'BELARUS',
+ 'BELGIUM',
+ 'BELIZE',
+ 'BENIN',
+ 'BHUTAN',
+ 'BOLIVIA',
+ 'BOSNIA_AND_HERZEGOVINA',
+ 'BOTSWANA',
+ 'BRAZIL',
+ 'BRUNEI',
+ 'BULGARIA',
+ 'BURKINA_FASO',
+ 'BURUNDI',
+ 'CAMBODIA',
+ 'CAMEROON',
+ 'CANADA',
+ 'CAPE_VERDE',
+ 'CENTRAL_AFRICAN_REPUBLIC',
+ 'CHAD',
+ 'CHILE',
+ 'CHINA',
+ 'COLOMBIA',
+ 'COMOROS',
+ 'CONGO',
+ 'DR_CONGO',
+ 'COSTA_RICA',
+ 'CROATIA',
+ 'CUBA',
+ 'CYPRUS',
+ 'CZECH_REPUBLIC',
+ 'DENMARK',
+ 'DJIBOUTI',
+ 'DOMINICA',
+ 'DOMINICAN_REPUBLIC',
+ 'ECUADOR',
+ 'EGYPT',
+ 'EL_SALVADOR',
+ 'EQUATORIAL_GUINEA',
+ 'ERITREA',
+ 'ESTONIA',
+ 'ESWATINI',
+ 'ETHIOPIA',
+ 'FIJI',
+ 'FINLAND',
+ 'FRANCE',
+ 'GABON',
+ 'GAMBIA',
+ 'GEORGIA',
+ 'GERMANY',
+ 'GHANA',
+ 'GREECE',
+ 'GRENADA',
+ 'GUATEMALA',
+ 'GUINEA',
+ 'GUINEA_BISSAU',
+ 'GUYANA',
+ 'HAITI',
+ 'HONDURAS',
+ 'HUNGARY',
+ 'ICELAND',
+ 'INDIA',
+ 'INDONESIA',
+ 'IRAN',
+ 'IRAQ',
+ 'IRELAND',
+ 'ISRAEL',
+ 'ITALY',
+ 'IVORY_COAST',
+ 'JAMAICA',
+ 'JAPAN',
+ 'JORDAN',
+ 'KAZAKHSTAN',
+ 'KENYA',
+ 'KIRIBATI',
+ 'KOSOVO',
+ 'KUWAIT',
+ 'KYRGYZSTAN',
+ 'LAOS',
+ 'LATVIA',
+ 'LEBANON',
+ 'LESOTHO',
+ 'LIBERIA',
+ 'LIBYA',
+ 'LIECHTENSTEIN',
+ 'LITHUANIA',
+ 'LUXEMBOURG',
+ 'MADAGASCAR',
+ 'MALAWI',
+ 'MALAYSIA',
+ 'MALDIVES',
+ 'MALI',
+ 'MALTA',
+ 'MARSHALL_ISLANDS',
+ 'MAURITANIA',
+ 'MAURITIUS',
+ 'MEXICO',
+ 'MICRONESIA',
+ 'MOLDOVA',
+ 'MONACO',
+ 'MONGOLIA',
+ 'MONTENEGRO',
+ 'MOROCCO',
+ 'MOZAMBIQUE',
+ 'MYANMAR',
+ 'NAMIBIA',
+ 'NAURU',
+ 'NEPAL',
+ 'NETHERLANDS',
+ 'NEW_ZEALAND',
+ 'NICARAGUA',
+ 'NIGER',
+ 'NIGERIA',
+ 'NORTH_KOREA',
+ 'NORTH_MACEDONIA',
+ 'NORWAY',
+ 'OMAN',
+ 'PAKISTAN',
+ 'PALAU',
+ 'PALESTINE',
+ 'PANAMA',
+ 'PAPUA_NEW_GUINEA',
+ 'PARAGUAY',
+ 'PERU',
+ 'PHILIPPINES',
+ 'POLAND',
+ 'PORTUGAL',
+ 'QATAR',
+ 'ROMANIA',
+ 'RUSSIA',
+ 'RWANDA',
+ 'SAINT_KITTS_AND_NEVIS',
+ 'SAINT_LUCIA',
+ 'SAINT_VINCENT',
+ 'SAMOA',
+ 'SAN_MARINO',
+ 'SAO_TOME_AND_PRINCIPE',
+ 'SAUDI_ARABIA',
+ 'SENEGAL',
+ 'SERBIA',
+ 'SEYCHELLES',
+ 'SIERRA_LEONE',
+ 'SINGAPORE',
+ 'SLOVAKIA',
+ 'SLOVENIA',
+ 'SOLOMON_ISLANDS',
+ 'SOMALIA',
+ 'SOUTH_AFRICA',
+ 'SOUTH_KOREA',
+ 'SOUTH_SUDAN',
+ 'SPAIN',
+ 'SRI_LANKA',
+ 'SUDAN',
+ 'SURINAME',
+ 'SWEDEN',
+ 'SWITZERLAND',
+ 'SYRIA',
+ 'TAIWAN',
+ 'TAJIKISTAN',
+ 'TANZANIA',
+ 'THAILAND',
+ 'TIMOR_LESTE',
+ 'TOGO',
+ 'TONGA',
+ 'TRINIDAD_AND_TOBAGO',
+ 'TUNISIA',
+ 'TURKEY',
+ 'TURKMENISTAN',
+ 'TUVALU',
+ 'UGANDA',
+ 'UKRAINE',
+ 'UNITED_ARAB_EMIRATES',
+ 'UNITED_KINGDOM',
+ 'UNITED_STATES',
+ 'URUGUAY',
+ 'UZBEKISTAN',
+ 'VANUATU',
+ 'VATICAN',
+ 'VENEZUELA',
+ 'VIETNAM',
+ 'YEMEN',
+ 'ZAMBIA',
+ 'ZIMBABWE',
+] as const;
+export type PartnerCountryValue = (typeof PARTNER_COUNTRY_VALUES)[number];
+
+export const PARTNER_COUNTRY_OPTIONS: ReadonlyArray<{
+ value: PartnerCountryValue;
+ label: MessageDescriptor;
+}> = [
+ { value: 'AFGHANISTAN', label: msg`Afghanistan 🇦🇫` },
+ { value: 'ALBANIA', label: msg`Albania 🇦🇱` },
+ { value: 'ALGERIA', label: msg`Algeria 🇩🇿` },
+ { value: 'ANDORRA', label: msg`Andorra 🇦🇩` },
+ { value: 'ANGOLA', label: msg`Angola 🇦🇴` },
+ { value: 'ANTIGUA_AND_BARBUDA', label: msg`Antigua & Barbuda 🇦🇬` },
+ { value: 'ARGENTINA', label: msg`Argentina 🇦🇷` },
+ { value: 'ARMENIA', label: msg`Armenia 🇦🇲` },
+ { value: 'AUSTRALIA', label: msg`Australia 🇦🇺` },
+ { value: 'AUSTRIA', label: msg`Austria 🇦🇹` },
+ { value: 'AZERBAIJAN', label: msg`Azerbaijan 🇦🇿` },
+ { value: 'BAHAMAS', label: msg`Bahamas 🇧🇸` },
+ { value: 'BAHRAIN', label: msg`Bahrain 🇧🇭` },
+ { value: 'BANGLADESH', label: msg`Bangladesh 🇧🇩` },
+ { value: 'BARBADOS', label: msg`Barbados 🇧🇧` },
+ { value: 'BELARUS', label: msg`Belarus 🇧🇾` },
+ { value: 'BELGIUM', label: msg`Belgium 🇧🇪` },
+ { value: 'BELIZE', label: msg`Belize 🇧🇿` },
+ { value: 'BENIN', label: msg`Benin 🇧🇯` },
+ { value: 'BHUTAN', label: msg`Bhutan 🇧🇹` },
+ { value: 'BOLIVIA', label: msg`Bolivia 🇧🇴` },
+ { value: 'BOSNIA_AND_HERZEGOVINA', label: msg`Bosnia & Herzegovina 🇧🇦` },
+ { value: 'BOTSWANA', label: msg`Botswana 🇧🇼` },
+ { value: 'BRAZIL', label: msg`Brazil 🇧🇷` },
+ { value: 'BRUNEI', label: msg`Brunei 🇧🇳` },
+ { value: 'BULGARIA', label: msg`Bulgaria 🇧🇬` },
+ { value: 'BURKINA_FASO', label: msg`Burkina Faso 🇧🇫` },
+ { value: 'BURUNDI', label: msg`Burundi 🇧🇮` },
+ { value: 'CAMBODIA', label: msg`Cambodia 🇰🇭` },
+ { value: 'CAMEROON', label: msg`Cameroon 🇨🇲` },
+ { value: 'CANADA', label: msg`Canada 🇨🇦` },
+ { value: 'CAPE_VERDE', label: msg`Cape Verde 🇨🇻` },
+ {
+ value: 'CENTRAL_AFRICAN_REPUBLIC',
+ label: msg`Central African Republic 🇨🇫`,
+ },
+ { value: 'CHAD', label: msg`Chad 🇹🇩` },
+ { value: 'CHILE', label: msg`Chile 🇨🇱` },
+ { value: 'CHINA', label: msg`China 🇨🇳` },
+ { value: 'COLOMBIA', label: msg`Colombia 🇨🇴` },
+ { value: 'COMOROS', label: msg`Comoros 🇰🇲` },
+ { value: 'CONGO', label: msg`Congo 🇨🇬` },
+ { value: 'DR_CONGO', label: msg`DR Congo 🇨🇩` },
+ { value: 'COSTA_RICA', label: msg`Costa Rica 🇨🇷` },
+ { value: 'CROATIA', label: msg`Croatia 🇭🇷` },
+ { value: 'CUBA', label: msg`Cuba 🇨🇺` },
+ { value: 'CYPRUS', label: msg`Cyprus 🇨🇾` },
+ { value: 'CZECH_REPUBLIC', label: msg`Czech Republic 🇨🇿` },
+ { value: 'DENMARK', label: msg`Denmark 🇩🇰` },
+ { value: 'DJIBOUTI', label: msg`Djibouti 🇩🇯` },
+ { value: 'DOMINICA', label: msg`Dominica 🇩🇲` },
+ { value: 'DOMINICAN_REPUBLIC', label: msg`Dominican Republic 🇩🇴` },
+ { value: 'ECUADOR', label: msg`Ecuador 🇪🇨` },
+ { value: 'EGYPT', label: msg`Egypt 🇪🇬` },
+ { value: 'EL_SALVADOR', label: msg`El Salvador 🇸🇻` },
+ { value: 'EQUATORIAL_GUINEA', label: msg`Equatorial Guinea 🇬🇶` },
+ { value: 'ERITREA', label: msg`Eritrea 🇪🇷` },
+ { value: 'ESTONIA', label: msg`Estonia 🇪🇪` },
+ { value: 'ESWATINI', label: msg`Eswatini 🇸🇿` },
+ { value: 'ETHIOPIA', label: msg`Ethiopia 🇪🇹` },
+ { value: 'FIJI', label: msg`Fiji 🇫🇯` },
+ { value: 'FINLAND', label: msg`Finland 🇫🇮` },
+ { value: 'FRANCE', label: msg`France 🇫🇷` },
+ { value: 'GABON', label: msg`Gabon 🇬🇦` },
+ { value: 'GAMBIA', label: msg`Gambia 🇬🇲` },
+ { value: 'GEORGIA', label: msg`Georgia 🇬🇪` },
+ { value: 'GERMANY', label: msg`Germany 🇩🇪` },
+ { value: 'GHANA', label: msg`Ghana 🇬🇭` },
+ { value: 'GREECE', label: msg`Greece 🇬🇷` },
+ { value: 'GRENADA', label: msg`Grenada 🇬🇩` },
+ { value: 'GUATEMALA', label: msg`Guatemala 🇬🇹` },
+ { value: 'GUINEA', label: msg`Guinea 🇬🇳` },
+ { value: 'GUINEA_BISSAU', label: msg`Guinea-Bissau 🇬🇼` },
+ { value: 'GUYANA', label: msg`Guyana 🇬🇾` },
+ { value: 'HAITI', label: msg`Haiti 🇭🇹` },
+ { value: 'HONDURAS', label: msg`Honduras 🇭🇳` },
+ { value: 'HUNGARY', label: msg`Hungary 🇭🇺` },
+ { value: 'ICELAND', label: msg`Iceland 🇮🇸` },
+ { value: 'INDIA', label: msg`India 🇮🇳` },
+ { value: 'INDONESIA', label: msg`Indonesia 🇮🇩` },
+ { value: 'IRAN', label: msg`Iran 🇮🇷` },
+ { value: 'IRAQ', label: msg`Iraq 🇮🇶` },
+ { value: 'IRELAND', label: msg`Ireland 🇮🇪` },
+ { value: 'ISRAEL', label: msg`Israel 🇮🇱` },
+ { value: 'ITALY', label: msg`Italy 🇮🇹` },
+ { value: 'IVORY_COAST', label: msg`Ivory Coast 🇨🇮` },
+ { value: 'JAMAICA', label: msg`Jamaica 🇯🇲` },
+ { value: 'JAPAN', label: msg`Japan 🇯🇵` },
+ { value: 'JORDAN', label: msg`Jordan 🇯🇴` },
+ { value: 'KAZAKHSTAN', label: msg`Kazakhstan 🇰🇿` },
+ { value: 'KENYA', label: msg`Kenya 🇰🇪` },
+ { value: 'KIRIBATI', label: msg`Kiribati 🇰🇮` },
+ { value: 'KOSOVO', label: msg`Kosovo 🇽🇰` },
+ { value: 'KUWAIT', label: msg`Kuwait 🇰🇼` },
+ { value: 'KYRGYZSTAN', label: msg`Kyrgyzstan 🇰🇬` },
+ { value: 'LAOS', label: msg`Laos 🇱🇦` },
+ { value: 'LATVIA', label: msg`Latvia 🇱🇻` },
+ { value: 'LEBANON', label: msg`Lebanon 🇱🇧` },
+ { value: 'LESOTHO', label: msg`Lesotho 🇱🇸` },
+ { value: 'LIBERIA', label: msg`Liberia 🇱🇷` },
+ { value: 'LIBYA', label: msg`Libya 🇱🇾` },
+ { value: 'LIECHTENSTEIN', label: msg`Liechtenstein 🇱🇮` },
+ { value: 'LITHUANIA', label: msg`Lithuania 🇱🇹` },
+ { value: 'LUXEMBOURG', label: msg`Luxembourg 🇱🇺` },
+ { value: 'MADAGASCAR', label: msg`Madagascar 🇲🇬` },
+ { value: 'MALAWI', label: msg`Malawi 🇲🇼` },
+ { value: 'MALAYSIA', label: msg`Malaysia 🇲🇾` },
+ { value: 'MALDIVES', label: msg`Maldives 🇲🇻` },
+ { value: 'MALI', label: msg`Mali 🇲🇱` },
+ { value: 'MALTA', label: msg`Malta 🇲🇹` },
+ { value: 'MARSHALL_ISLANDS', label: msg`Marshall Islands 🇲🇭` },
+ { value: 'MAURITANIA', label: msg`Mauritania 🇲🇷` },
+ { value: 'MAURITIUS', label: msg`Mauritius 🇲🇺` },
+ { value: 'MEXICO', label: msg`Mexico 🇲🇽` },
+ { value: 'MICRONESIA', label: msg`Micronesia 🇫🇲` },
+ { value: 'MOLDOVA', label: msg`Moldova 🇲🇩` },
+ { value: 'MONACO', label: msg`Monaco 🇲🇨` },
+ { value: 'MONGOLIA', label: msg`Mongolia 🇲🇳` },
+ { value: 'MONTENEGRO', label: msg`Montenegro 🇲🇪` },
+ { value: 'MOROCCO', label: msg`Morocco 🇲🇦` },
+ { value: 'MOZAMBIQUE', label: msg`Mozambique 🇲🇿` },
+ { value: 'MYANMAR', label: msg`Myanmar 🇲🇲` },
+ { value: 'NAMIBIA', label: msg`Namibia 🇳🇦` },
+ { value: 'NAURU', label: msg`Nauru 🇳🇷` },
+ { value: 'NEPAL', label: msg`Nepal 🇳🇵` },
+ { value: 'NETHERLANDS', label: msg`Netherlands 🇳🇱` },
+ { value: 'NEW_ZEALAND', label: msg`New Zealand 🇳🇿` },
+ { value: 'NICARAGUA', label: msg`Nicaragua 🇳🇮` },
+ { value: 'NIGER', label: msg`Niger 🇳🇪` },
+ { value: 'NIGERIA', label: msg`Nigeria 🇳🇬` },
+ { value: 'NORTH_KOREA', label: msg`North Korea 🇰🇵` },
+ { value: 'NORTH_MACEDONIA', label: msg`North Macedonia 🇲🇰` },
+ { value: 'NORWAY', label: msg`Norway 🇳🇴` },
+ { value: 'OMAN', label: msg`Oman 🇴🇲` },
+ { value: 'PAKISTAN', label: msg`Pakistan 🇵🇰` },
+ { value: 'PALAU', label: msg`Palau 🇵🇼` },
+ { value: 'PALESTINE', label: msg`Palestine 🇵🇸` },
+ { value: 'PANAMA', label: msg`Panama 🇵🇦` },
+ { value: 'PAPUA_NEW_GUINEA', label: msg`Papua New Guinea 🇵🇬` },
+ { value: 'PARAGUAY', label: msg`Paraguay 🇵🇾` },
+ { value: 'PERU', label: msg`Peru 🇵🇪` },
+ { value: 'PHILIPPINES', label: msg`Philippines 🇵🇭` },
+ { value: 'POLAND', label: msg`Poland 🇵🇱` },
+ { value: 'PORTUGAL', label: msg`Portugal 🇵🇹` },
+ { value: 'QATAR', label: msg`Qatar 🇶🇦` },
+ { value: 'ROMANIA', label: msg`Romania 🇷🇴` },
+ { value: 'RUSSIA', label: msg`Russia 🇷🇺` },
+ { value: 'RWANDA', label: msg`Rwanda 🇷🇼` },
+ { value: 'SAINT_KITTS_AND_NEVIS', label: msg`Saint Kitts & Nevis 🇰🇳` },
+ { value: 'SAINT_LUCIA', label: msg`Saint Lucia 🇱🇨` },
+ { value: 'SAINT_VINCENT', label: msg`Saint Vincent 🇻🇨` },
+ { value: 'SAMOA', label: msg`Samoa 🇼🇸` },
+ { value: 'SAN_MARINO', label: msg`San Marino 🇸🇲` },
+ { value: 'SAO_TOME_AND_PRINCIPE', label: msg`São Tomé & Príncipe 🇸🇹` },
+ { value: 'SAUDI_ARABIA', label: msg`Saudi Arabia 🇸🇦` },
+ { value: 'SENEGAL', label: msg`Senegal 🇸🇳` },
+ { value: 'SERBIA', label: msg`Serbia 🇷🇸` },
+ { value: 'SEYCHELLES', label: msg`Seychelles 🇸🇨` },
+ { value: 'SIERRA_LEONE', label: msg`Sierra Leone 🇸🇱` },
+ { value: 'SINGAPORE', label: msg`Singapore 🇸🇬` },
+ { value: 'SLOVAKIA', label: msg`Slovakia 🇸🇰` },
+ { value: 'SLOVENIA', label: msg`Slovenia 🇸🇮` },
+ { value: 'SOLOMON_ISLANDS', label: msg`Solomon Islands 🇸🇧` },
+ { value: 'SOMALIA', label: msg`Somalia 🇸🇴` },
+ { value: 'SOUTH_AFRICA', label: msg`South Africa 🇿🇦` },
+ { value: 'SOUTH_KOREA', label: msg`South Korea 🇰🇷` },
+ { value: 'SOUTH_SUDAN', label: msg`South Sudan 🇸🇸` },
+ { value: 'SPAIN', label: msg`Spain 🇪🇸` },
+ { value: 'SRI_LANKA', label: msg`Sri Lanka 🇱🇰` },
+ { value: 'SUDAN', label: msg`Sudan 🇸🇩` },
+ { value: 'SURINAME', label: msg`Suriname 🇸🇷` },
+ { value: 'SWEDEN', label: msg`Sweden 🇸🇪` },
+ { value: 'SWITZERLAND', label: msg`Switzerland 🇨🇭` },
+ { value: 'SYRIA', label: msg`Syria 🇸🇾` },
+ { value: 'TAIWAN', label: msg`Taiwan 🇹🇼` },
+ { value: 'TAJIKISTAN', label: msg`Tajikistan 🇹🇯` },
+ { value: 'TANZANIA', label: msg`Tanzania 🇹🇿` },
+ { value: 'THAILAND', label: msg`Thailand 🇹🇭` },
+ { value: 'TIMOR_LESTE', label: msg`Timor-Leste 🇹🇱` },
+ { value: 'TOGO', label: msg`Togo 🇹🇬` },
+ { value: 'TONGA', label: msg`Tonga 🇹🇴` },
+ { value: 'TRINIDAD_AND_TOBAGO', label: msg`Trinidad & Tobago 🇹🇹` },
+ { value: 'TUNISIA', label: msg`Tunisia 🇹🇳` },
+ { value: 'TURKEY', label: msg`Turkey 🇹🇷` },
+ { value: 'TURKMENISTAN', label: msg`Turkmenistan 🇹🇲` },
+ { value: 'TUVALU', label: msg`Tuvalu 🇹🇻` },
+ { value: 'UGANDA', label: msg`Uganda 🇺🇬` },
+ { value: 'UKRAINE', label: msg`Ukraine 🇺🇦` },
+ { value: 'UNITED_ARAB_EMIRATES', label: msg`UAE 🇦🇪` },
+ { value: 'UNITED_KINGDOM', label: msg`UK 🇬🇧` },
+ { value: 'UNITED_STATES', label: msg`USA 🇺🇸` },
+ { value: 'URUGUAY', label: msg`Uruguay 🇺🇾` },
+ { value: 'UZBEKISTAN', label: msg`Uzbekistan 🇺🇿` },
+ { value: 'VANUATU', label: msg`Vanuatu 🇻🇺` },
+ { value: 'VATICAN', label: msg`Vatican 🇻🇦` },
+ { value: 'VENEZUELA', label: msg`Venezuela 🇻🇪` },
+ { value: 'VIETNAM', label: msg`Vietnam 🇻🇳` },
+ { value: 'YEMEN', label: msg`Yemen 🇾🇪` },
+ { value: 'ZAMBIA', label: msg`Zambia 🇿🇲` },
+ { value: 'ZIMBABWE', label: msg`Zimbabwe 🇿🇼` },
+];
+
+export const PARTNER_LANGUAGE_VALUES = [
+ 'ENGLISH',
+ 'FRENCH',
+ 'GERMAN',
+ 'SPANISH',
+ 'PORTUGUESE',
+ 'ITALIAN',
+ 'DUTCH',
+ 'ARABIC',
+ 'CHINESE',
+ 'JAPANESE',
+ 'RUSSIAN',
+ 'HINDI',
+] as const;
+export type PartnerLanguageValue = (typeof PARTNER_LANGUAGE_VALUES)[number];
+
+export const PARTNER_LANGUAGE_OPTIONS: ReadonlyArray<{
+ value: PartnerLanguageValue;
+ label: MessageDescriptor;
+}> = [
+ { value: 'ENGLISH', label: msg`English` },
+ { value: 'FRENCH', label: msg`French` },
+ { value: 'GERMAN', label: msg`German` },
+ { value: 'SPANISH', label: msg`Spanish` },
+ { value: 'PORTUGUESE', label: msg`Portuguese` },
+ { value: 'ITALIAN', label: msg`Italian` },
+ { value: 'DUTCH', label: msg`Dutch` },
+ { value: 'ARABIC', label: msg`Arabic` },
+ { value: 'CHINESE', label: msg`Chinese` },
+ { value: 'JAPANESE', label: msg`Japanese` },
+ { value: 'RUSSIAN', label: msg`Russian` },
+ { value: 'HINDI', label: msg`Hindi` },
+];
+
+// Per-step required field names. The wizard reducer reads this to gate `goNext`.
+export const PARTNER_APPLICATION_STEP_REQUIRED_FIELDS: Record<
+ PartnerApplicationStepId,
+ ReadonlyArray
+> = {
+ identity: ['name', 'email', 'company'],
+ profile: ['country', 'typeOfTeam'],
+ expertise: ['partnerScope'],
+ commercials: [],
+};
diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/CategoryCardSelect.tsx b/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/CategoryCardSelect.tsx
new file mode 100644
index 0000000000..a405993ac4
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/CategoryCardSelect.tsx
@@ -0,0 +1,129 @@
+'use client';
+
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+
+export type CategoryOption = {
+ value: TValue;
+ label: string;
+ description: string;
+ examples: string;
+};
+
+const CardGroup = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(2)};
+`;
+
+const Card = styled.button`
+ align-items: flex-start;
+ background: transparent;
+ border: 1px solid ${theme.colors.secondary.border[20]};
+ border-radius: ${theme.radius(2)};
+ cursor: pointer;
+ display: flex;
+ gap: ${theme.spacing(2)};
+ padding: ${theme.spacing(2.5)} ${theme.spacing(3)};
+ text-align: left;
+ width: 100%;
+
+ &[data-selected='true'] {
+ background: rgba(74, 56, 245, 0.1);
+ border-color: ${theme.colors.highlight[100]};
+ }
+
+ &[data-invalid='true'] {
+ border-color: #ff9a9a;
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${theme.colors.highlight[100]};
+ outline-offset: 2px;
+ }
+`;
+
+const Square = styled.span`
+ align-items: center;
+ border: 1.5px solid ${theme.colors.secondary.border[40]};
+ border-radius: ${theme.radius(1)};
+ color: ${theme.colors.primary.text[100]};
+ display: flex;
+ flex: none;
+ font-size: ${theme.font.size(3)};
+ height: 18px;
+ justify-content: center;
+ margin-top: 2px;
+ width: 18px;
+
+ [data-selected='true'] & {
+ background: ${theme.colors.highlight[100]};
+ border-color: ${theme.colors.highlight[100]};
+ }
+`;
+
+const Texts = styled.span`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(0.5)};
+`;
+
+const Title = styled.span`
+ color: ${theme.colors.secondary.text[100]};
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3.75)};
+ font-weight: ${theme.font.weight.medium};
+`;
+
+const Description = styled.span`
+ color: ${theme.colors.secondary.text[60]};
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3.25)};
+`;
+
+const Examples = styled.span`
+ color: ${theme.colors.secondary.text[40]};
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3)};
+`;
+
+type CategoryCardSelectProps = {
+ options: ReadonlyArray>;
+ values: ReadonlyArray;
+ onToggle: (value: TValue) => void;
+ invalid?: boolean;
+ ariaLabel?: string;
+};
+
+export function CategoryCardSelect({
+ options,
+ values,
+ onToggle,
+ invalid,
+ ariaLabel,
+}: CategoryCardSelectProps) {
+ return (
+
+ {options.map((option) => {
+ const selected = values.includes(option.value);
+ return (
+ onToggle(option.value)}
+ >
+ {selected ? '✓' : ''}
+
+ {option.label}
+ {option.description}
+ ex. {option.examples}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/CommercialsStep.tsx b/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/CommercialsStep.tsx
new file mode 100644
index 0000000000..ec4507dbd7
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/CommercialsStep.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { Form } from '@/design-system/components';
+import { useLingui } from '@lingui/react';
+import { PARTNER_APPLICATION_FIELD_COPY } from '@/sections/PartnerApplication/partner-application-modal-data';
+import type { PartnerApplicationController } from '@/sections/PartnerApplication/wizard/use-partner-application-state';
+
+const COPY = PARTNER_APPLICATION_FIELD_COPY;
+
+type CommercialsStepProps = {
+ controller: PartnerApplicationController;
+};
+
+export function CommercialsStep({ controller }: CommercialsStepProps) {
+ const { state, setField } = controller;
+ const { i18n } = useLingui();
+
+ return (
+ <>
+
+ setField('hourlyRate', value)}
+ placeholder={i18n._(COPY.hourlyRatePlaceholder)}
+ name="hourlyRate"
+ ariaLabel={i18n._(COPY.hourlyRate)}
+ />
+
+
+ setField('projectBudgetMin', value)}
+ placeholder={i18n._(COPY.projectBudgetMinPlaceholder)}
+ name="projectBudgetMin"
+ ariaLabel={i18n._(COPY.projectBudgetMin)}
+ />
+
+
+ setField('calendarLink', event.target.value)}
+ aria-invalid={state.fieldErrors.calendarLink ? true : undefined}
+ />
+
+ >
+ );
+}
diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/ExpertiseStep.tsx b/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/ExpertiseStep.tsx
new file mode 100644
index 0000000000..173ab7db06
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/ExpertiseStep.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { Form } from '@/design-system/components';
+import { useLingui } from '@lingui/react';
+import { PARTNER_APPLICATION_FIELD_COPY } from '@/sections/PartnerApplication/partner-application-modal-data';
+import {
+ PARTNER_SCOPE_OPTIONS,
+ PARTNER_SKILL_SUGGESTIONS,
+ type PartnerScopeValue,
+} from '@/sections/PartnerApplication/wizard/partner-fields.data';
+import { CategoryCardSelect } from '@/sections/PartnerApplication/wizard/steps/CategoryCardSelect';
+import type { PartnerApplicationController } from '@/sections/PartnerApplication/wizard/use-partner-application-state';
+import type { MessageDescriptor } from '@lingui/core';
+
+const COPY = PARTNER_APPLICATION_FIELD_COPY;
+
+type ExpertiseStepProps = {
+ controller: PartnerApplicationController;
+};
+
+export function ExpertiseStep({ controller }: ExpertiseStepProps) {
+ const { i18n } = useLingui();
+ const { state, setField, toggleScope, setSkills } = controller;
+
+ const categoryOptions = PARTNER_SCOPE_OPTIONS.map((option) => ({
+ value: option.value,
+ label: i18n._(option.label as MessageDescriptor),
+ description: i18n._(option.description as MessageDescriptor),
+ examples: i18n._(option.examples as MessageDescriptor),
+ }));
+
+ return (
+ <>
+
+
+ options={categoryOptions}
+ values={state.partnerScope}
+ onToggle={toggleScope}
+ invalid={state.fieldErrors.partnerScope !== undefined}
+ ariaLabel={i18n._(COPY.partnerScope)}
+ />
+
+
+
+
+
+ setField('applicationNotes', event.target.value)}
+ />
+
+ >
+ );
+}
diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/IdentityStep.tsx b/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/IdentityStep.tsx
new file mode 100644
index 0000000000..27e78c6607
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/IdentityStep.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { Form } from '@/design-system/components';
+import { useLingui } from '@lingui/react';
+import { PARTNER_APPLICATION_FIELD_COPY } from '@/sections/PartnerApplication/partner-application-modal-data';
+import type { PartnerApplicationController } from '@/sections/PartnerApplication/wizard/use-partner-application-state';
+
+const COPY = PARTNER_APPLICATION_FIELD_COPY;
+
+type IdentityStepProps = {
+ controller: PartnerApplicationController;
+};
+
+export function IdentityStep({ controller }: IdentityStepProps) {
+ const { i18n } = useLingui();
+ const { state, setField } = controller;
+
+ return (
+ <>
+
+ setField('name', event.target.value)}
+ aria-invalid={state.fieldErrors.name ? true : undefined}
+ />
+
+
+ setField('email', event.target.value)}
+ aria-invalid={state.fieldErrors.email ? true : undefined}
+ />
+
+
+ setField('company', event.target.value)}
+ aria-invalid={state.fieldErrors.company ? true : undefined}
+ />
+
+
+ setField('website', event.target.value)}
+ aria-invalid={state.fieldErrors.website ? true : undefined}
+ />
+
+ >
+ );
+}
diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/ProfileStep.tsx b/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/ProfileStep.tsx
new file mode 100644
index 0000000000..2588057126
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/steps/ProfileStep.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import { Form } from '@/design-system/components';
+import { useLingui } from '@lingui/react';
+import { PARTNER_APPLICATION_FIELD_COPY } from '@/sections/PartnerApplication/partner-application-modal-data';
+import {
+ PARTNER_COUNTRY_OPTIONS,
+ PARTNER_LANGUAGE_OPTIONS,
+ PARTNER_TYPE_OF_TEAM_OPTIONS,
+ type PartnerCountryValue,
+ type PartnerLanguageValue,
+ type PartnerTypeOfTeam,
+} from '@/sections/PartnerApplication/wizard/partner-fields.data';
+import type { PartnerApplicationController } from '@/sections/PartnerApplication/wizard/use-partner-application-state';
+import type { MessageDescriptor } from '@lingui/core';
+
+const COPY = PARTNER_APPLICATION_FIELD_COPY;
+
+type ProfileStepProps = {
+ controller: PartnerApplicationController;
+};
+
+export function ProfileStep({ controller }: ProfileStepProps) {
+ const { i18n } = useLingui();
+ const { state, setField, toggleLanguage } = controller;
+
+ const countryOptions: ReadonlyArray<{
+ value: PartnerCountryValue;
+ label: string;
+ }> = PARTNER_COUNTRY_OPTIONS.map((option) => ({
+ value: option.value,
+ label: i18n._(option.label as MessageDescriptor),
+ }));
+
+ const languageOptions = PARTNER_LANGUAGE_OPTIONS.map((option) => ({
+ value: option.value,
+ label: i18n._(option.label as MessageDescriptor),
+ }));
+
+ const teamOptions = PARTNER_TYPE_OF_TEAM_OPTIONS.map((option) => ({
+ value: option.value,
+ label: i18n._(option.label as MessageDescriptor),
+ }));
+
+ return (
+ <>
+
+
+ value={state.typeOfTeam}
+ onValueChange={(value) => setField('typeOfTeam', value)}
+ placeholder={i18n._(COPY.typeOfTeamPlaceholder)}
+ options={teamOptions}
+ invalid={state.fieldErrors.typeOfTeam !== undefined}
+ name="typeOfTeam"
+ ariaLabel={i18n._(COPY.typeOfTeam)}
+ />
+
+
+ setField('linkedin', event.target.value)}
+ />
+
+
+ setField('city', event.target.value)}
+ />
+
+
+
+ value={state.country}
+ onValueChange={(value) => setField('country', value)}
+ placeholder={i18n._(COPY.countryPlaceholder)}
+ options={countryOptions}
+ invalid={state.fieldErrors.country !== undefined}
+ name="country"
+ ariaLabel={i18n._(COPY.country)}
+ searchable
+ searchPlaceholder={i18n._(COPY.countrySearchPlaceholder)}
+ searchEmptyLabel={i18n._(COPY.countrySearchEmpty)}
+ />
+
+
+
+ values={state.languages}
+ onToggle={toggleLanguage}
+ options={languageOptions}
+ ariaLabel={i18n._(COPY.languages)}
+ />
+
+ >
+ );
+}
diff --git a/packages/twenty-website/src/sections/PartnerApplication/wizard/use-partner-application-state.ts b/packages/twenty-website/src/sections/PartnerApplication/wizard/use-partner-application-state.ts
new file mode 100644
index 0000000000..017cc06501
--- /dev/null
+++ b/packages/twenty-website/src/sections/PartnerApplication/wizard/use-partner-application-state.ts
@@ -0,0 +1,332 @@
+import { useReducer, useCallback } from 'react';
+
+import {
+ emailFieldSchema,
+ httpUrlFieldSchema,
+ type PartnerApplicationRequest,
+} from '@/sections/PartnerApplication/partner-application-field-schemas';
+import {
+ PARTNER_APPLICATION_STEP_IDS,
+ PARTNER_APPLICATION_STEP_REQUIRED_FIELDS,
+ type PartnerApplicationStepId,
+ type PartnerCountryValue,
+ type PartnerLanguageValue,
+ type PartnerScopeValue,
+ type PartnerTypeOfTeam,
+} from './partner-fields.data';
+
+export type CountryFieldValue = PartnerCountryValue | '';
+
+export type PartnerApplicationState = {
+ stepIndex: number;
+
+ // Identity
+ name: string;
+ email: string;
+ company: string;
+ website: string;
+
+ // Profile
+ linkedin: string;
+ city: string;
+ country: CountryFieldValue;
+ languages: PartnerLanguageValue[];
+
+ // Expertise & experience
+ typeOfTeam: PartnerTypeOfTeam | '';
+ partnerScope: PartnerScopeValue[];
+ skills: string[];
+ applicationNotes: string;
+
+ // Commercials
+ hourlyRate: string;
+ projectBudgetMin: string;
+ calendarLink: string;
+
+ // Meta
+ fieldErrors: Partial>;
+ submitError: string | null;
+ isSubmitting: boolean;
+ isSubmitted: boolean;
+};
+
+export const INITIAL_PARTNER_APPLICATION_STATE: PartnerApplicationState = {
+ stepIndex: 0,
+ name: '',
+ email: '',
+ company: '',
+ website: '',
+ linkedin: '',
+ city: '',
+ country: '',
+ languages: [],
+ typeOfTeam: '',
+ partnerScope: [],
+ skills: [],
+ applicationNotes: '',
+ hourlyRate: '',
+ projectBudgetMin: '',
+ calendarLink: '',
+ fieldErrors: {},
+ submitError: null,
+ isSubmitting: false,
+ isSubmitted: false,
+};
+
+export type ScalarFieldName =
+ | 'name'
+ | 'email'
+ | 'company'
+ | 'website'
+ | 'linkedin'
+ | 'city'
+ | 'country'
+ | 'typeOfTeam'
+ | 'applicationNotes'
+ | 'hourlyRate'
+ | 'projectBudgetMin'
+ | 'calendarLink';
+
+export type PartnerApplicationAction =
+ | { type: 'SET_FIELD'; field: ScalarFieldName; value: string }
+ | { type: 'TOGGLE_SCOPE'; value: PartnerScopeValue }
+ | { type: 'TOGGLE_LANGUAGE'; value: PartnerLanguageValue }
+ | { type: 'SET_SKILLS'; value: string[] }
+ | { type: 'GO_NEXT' }
+ | { type: 'GO_BACK' }
+ | { type: 'SET_SUBMITTING'; value: boolean }
+ | { type: 'SET_SUBMIT_ERROR'; value: string | null }
+ | { type: 'SET_SUBMITTED' }
+ | { type: 'RESET' };
+
+function isEmpty(value: unknown): boolean {
+ if (value === '' || value === null || value === undefined) return true;
+ if (Array.isArray(value)) return value.length === 0;
+ return false;
+}
+
+// Per-step format checks reuse the shared field schemas so the client rejects
+// exactly what the server route schema rejects. The empty/required gate above
+// owns "is it filled in"; these only run on non-empty values.
+type FieldFormatCheck = {
+ field: 'email' | 'website' | 'linkedin' | 'calendarLink';
+ schema: typeof emailFieldSchema | typeof httpUrlFieldSchema;
+ errorCode: 'invalid_email' | 'invalid_url';
+};
+
+const STEP_FORMAT_CHECKS: Partial<
+ Record>
+> = {
+ identity: [
+ { field: 'email', schema: emailFieldSchema, errorCode: 'invalid_email' },
+ { field: 'website', schema: httpUrlFieldSchema, errorCode: 'invalid_url' },
+ ],
+ profile: [
+ { field: 'linkedin', schema: httpUrlFieldSchema, errorCode: 'invalid_url' },
+ ],
+ commercials: [
+ {
+ field: 'calendarLink',
+ schema: httpUrlFieldSchema,
+ errorCode: 'invalid_url',
+ },
+ ],
+};
+
+function validateStep(
+ state: PartnerApplicationState,
+): Partial> {
+ const stepId = PARTNER_APPLICATION_STEP_IDS[state.stepIndex];
+ const required = PARTNER_APPLICATION_STEP_REQUIRED_FIELDS[stepId];
+ const errors: Partial> = {};
+
+ for (const field of required) {
+ if (isEmpty(state[field])) {
+ errors[field] = 'required';
+ }
+ }
+
+ for (const check of STEP_FORMAT_CHECKS[stepId] ?? []) {
+ const value = state[check.field];
+ if (value && !check.schema.safeParse(value).success) {
+ errors[check.field] = check.errorCode;
+ }
+ }
+
+ return errors;
+}
+
+function dropError(
+ errors: Partial>,
+ field: string,
+): Partial> {
+ if (errors[field] === undefined) return errors;
+ const { [field]: _ignored, ...rest } = errors;
+ return rest;
+}
+
+export function partnerApplicationReducer(
+ state: PartnerApplicationState,
+ action: PartnerApplicationAction,
+): PartnerApplicationState {
+ switch (action.type) {
+ case 'SET_FIELD':
+ return {
+ ...state,
+ [action.field]: action.value,
+ fieldErrors: dropError(state.fieldErrors, action.field),
+ };
+ case 'TOGGLE_SCOPE': {
+ const next = state.partnerScope.includes(action.value)
+ ? state.partnerScope.filter((v) => v !== action.value)
+ : [...state.partnerScope, action.value];
+ return {
+ ...state,
+ partnerScope: next,
+ fieldErrors: dropError(state.fieldErrors, 'partnerScope'),
+ };
+ }
+ case 'TOGGLE_LANGUAGE': {
+ const next = state.languages.includes(action.value)
+ ? state.languages.filter((v) => v !== action.value)
+ : [...state.languages, action.value];
+ return { ...state, languages: next };
+ }
+ case 'SET_SKILLS':
+ return { ...state, skills: action.value };
+ case 'GO_NEXT': {
+ const errors = validateStep(state);
+ if (Object.keys(errors).length > 0) {
+ return { ...state, fieldErrors: errors };
+ }
+ const lastIndex = PARTNER_APPLICATION_STEP_IDS.length - 1;
+ return {
+ ...state,
+ stepIndex: Math.min(state.stepIndex + 1, lastIndex),
+ fieldErrors: {},
+ };
+ }
+ case 'GO_BACK':
+ return {
+ ...state,
+ stepIndex: Math.max(state.stepIndex - 1, 0),
+ fieldErrors: {},
+ };
+ case 'SET_SUBMITTING':
+ return { ...state, isSubmitting: action.value };
+ case 'SET_SUBMIT_ERROR':
+ return { ...state, submitError: action.value };
+ case 'SET_SUBMITTED':
+ return {
+ ...state,
+ isSubmitted: true,
+ isSubmitting: false,
+ submitError: null,
+ };
+ case 'RESET':
+ return INITIAL_PARTNER_APPLICATION_STATE;
+ default:
+ return state;
+ }
+}
+
+export function usePartnerApplicationState() {
+ const [state, dispatch] = useReducer(
+ partnerApplicationReducer,
+ INITIAL_PARTNER_APPLICATION_STATE,
+ );
+
+ const setField = useCallback(
+ (field: ScalarFieldName, value: string) =>
+ dispatch({ type: 'SET_FIELD', field, value }),
+ [],
+ );
+ const toggleScope = useCallback(
+ (value: PartnerScopeValue) => dispatch({ type: 'TOGGLE_SCOPE', value }),
+ [],
+ );
+ const toggleLanguage = useCallback(
+ (value: PartnerLanguageValue) =>
+ dispatch({ type: 'TOGGLE_LANGUAGE', value }),
+ [],
+ );
+ const setSkills = useCallback(
+ (value: string[]) => dispatch({ type: 'SET_SKILLS', value }),
+ [],
+ );
+ const goNext = useCallback(() => dispatch({ type: 'GO_NEXT' }), []);
+ const goBack = useCallback(() => dispatch({ type: 'GO_BACK' }), []);
+ const setSubmitting = useCallback(
+ (value: boolean) => dispatch({ type: 'SET_SUBMITTING', value }),
+ [],
+ );
+ const setSubmitError = useCallback(
+ (value: string | null) => dispatch({ type: 'SET_SUBMIT_ERROR', value }),
+ [],
+ );
+ const setSubmitted = useCallback(
+ () => dispatch({ type: 'SET_SUBMITTED' }),
+ [],
+ );
+ const reset = useCallback(() => dispatch({ type: 'RESET' }), []);
+
+ return {
+ state,
+ setField,
+ toggleScope,
+ toggleLanguage,
+ setSkills,
+ goNext,
+ goBack,
+ setSubmitting,
+ setSubmitError,
+ setSubmitted,
+ reset,
+ };
+}
+
+export type PartnerApplicationController = ReturnType<
+ typeof usePartnerApplicationState
+>;
+
+export function getCurrentStepId(
+ state: PartnerApplicationState,
+): PartnerApplicationStepId {
+ return PARTNER_APPLICATION_STEP_IDS[state.stepIndex];
+}
+
+// Maps the form state to the POST body. Trims strings, omits empty optional
+// fields, and parses the numeric commercials. Shape must match what the route
+// schema (`partnerApplicationRequestSchema`) accepts.
+export function buildPartnerApplicationRequestBody(
+ state: PartnerApplicationState,
+): PartnerApplicationRequest {
+ const body: PartnerApplicationRequest = {
+ name: state.name.trim(),
+ email: state.email.trim(),
+ company: state.company.trim(),
+ };
+
+ if (state.website.trim()) body.website = state.website.trim();
+ if (state.linkedin.trim()) body.linkedin = state.linkedin.trim();
+ if (state.city.trim()) body.city = state.city.trim();
+ if (state.country !== '') body.country = state.country;
+ if (state.languages.length > 0) body.languages = state.languages;
+ if (state.typeOfTeam !== '') body.typeOfTeam = state.typeOfTeam;
+ if (state.partnerScope.length > 0) body.partnerScope = state.partnerScope;
+ if (state.skills.length > 0) body.skills = state.skills;
+ if (state.applicationNotes.trim())
+ body.applicationNotes = state.applicationNotes.trim();
+
+ const hourlyRate = parseFloat(state.hourlyRate);
+ if (Number.isFinite(hourlyRate) && hourlyRate >= 0)
+ body.hourlyRate = hourlyRate;
+
+ const projectBudgetMin = parseFloat(state.projectBudgetMin);
+ if (Number.isFinite(projectBudgetMin) && projectBudgetMin >= 0)
+ body.projectBudgetMin = projectBudgetMin;
+
+ if (state.calendarLink.trim()) body.calendarLink = state.calendarLink.trim();
+
+ return body;
+}
diff --git a/packages/twenty-website/src/sections/ThreeCards/components/IllustrationCard/IllustrationCard.tsx b/packages/twenty-website/src/sections/ThreeCards/components/IllustrationCard/IllustrationCard.tsx
index 9fee8479dd..6cb38ccee3 100644
--- a/packages/twenty-website/src/sections/ThreeCards/components/IllustrationCard/IllustrationCard.tsx
+++ b/packages/twenty-website/src/sections/ThreeCards/components/IllustrationCard/IllustrationCard.tsx
@@ -5,12 +5,10 @@ import { Body, Heading, HeadingPart } from '@/design-system/components';
import { ArrowRightIcon } from '@/icons';
import { INFORMATIVE_ICONS } from '@/icons/informative';
import { LocalizedLink } from '@/lib/i18n';
-import { usePartnerApplicationModal } from '@/sections/PartnerApplication';
import { WebGlMount } from '@/lib/visual-runtime';
import type { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards/types';
import { THREE_CARDS_VISUALS } from '@/sections/ThreeCards/visuals';
import { theme } from '@/theme';
-import type { MessageDescriptor } from '@lingui/core';
import { useLingui } from '@lingui/react';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
@@ -122,97 +120,6 @@ const simpleCardBodyClassName = css`
}
`;
-const PartnerActionRow = styled.div`
- align-items: center;
- column-gap: ${theme.spacing(3)};
- display: flex;
- justify-content: flex-end;
- margin-top: auto;
-`;
-
-const PartnerActionButton = styled.button`
- align-items: center;
- appearance: none;
- background: transparent;
- border: none;
- color: ${theme.colors.primary.text[80]};
- cursor: pointer;
- display: inline-flex;
- flex: 1;
- font-family: ${theme.font.family.mono};
- font-size: ${theme.font.size(3)};
- font-weight: ${theme.font.weight.regular};
- justify-content: flex-end;
- letter-spacing: 0.04em;
- line-height: ${theme.lineHeight(4)};
- margin: 0;
- min-height: ${PARTNER_ACTION_ICON_BUTTON_SIZE}px;
- padding: 0;
- text-align: right;
- text-transform: uppercase;
- transition: color 0.2s ease;
-
- &:is(:hover, :focus-visible),
- ${PartnerActionRow}:hover &,
- ${PartnerActionRow}:focus-within & {
- color: ${theme.colors.primary.text[100]};
- }
-
- &:focus-visible {
- outline: 1px solid ${theme.colors.highlight[100]};
- outline-offset: 2px;
- }
-`;
-
-const PartnerActionIconButton = styled.button`
- align-items: center;
- appearance: none;
- background: transparent;
- border: none;
- color: ${theme.colors.primary.text[80]};
- cursor: pointer;
- display: inline-flex;
- flex-shrink: 0;
- height: ${PARTNER_ACTION_ICON_BUTTON_SIZE}px;
- justify-content: center;
- overflow: hidden;
- padding: 0;
- position: relative;
- transition:
- color 0.2s ease,
- transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
- width: ${PARTNER_ACTION_ICON_BUTTON_SIZE}px;
-
- &:is(:hover, :focus-visible),
- ${PartnerActionRow}:hover &,
- ${PartnerActionRow}:focus-within & {
- color: ${theme.colors.primary.text[100]};
- }
-
- &:is(:hover, :focus-visible) [data-slot='partner-action-icon-hover-fill'],
- ${PartnerActionRow}:hover & [data-slot='partner-action-icon-hover-fill'],
- ${PartnerActionRow}:focus-within
- &
- [data-slot='partner-action-icon-hover-fill'] {
- transform: translateX(0);
- }
-
- &:hover,
- ${PartnerActionRow}:hover &,
- ${PartnerActionRow}:focus-within & {
- transform: scale(1.05);
- }
-
- &:active {
- transform: scale(0.96);
- }
-
- &:focus-visible {
- outline: 1px solid ${theme.colors.highlight[100]};
- outline-offset: 1px;
- }
-`;
-
const PartnerActionIconLink = styled(LocalizedLink)`
align-items: center;
appearance: none;
@@ -233,23 +140,15 @@ const PartnerActionIconLink = styled(LocalizedLink)`
width: ${PARTNER_ACTION_ICON_BUTTON_SIZE}px;
text-decoration: none;
- &:is(:hover, :focus-visible),
- ${PartnerActionRow}:hover &,
- ${PartnerActionRow}:focus-within & {
+ &:is(:hover, :focus-visible) {
color: ${theme.colors.primary.text[100]};
}
- &:is(:hover, :focus-visible) [data-slot='partner-action-icon-hover-fill'],
- ${PartnerActionRow}:hover & [data-slot='partner-action-icon-hover-fill'],
- ${PartnerActionRow}:focus-within
- &
- [data-slot='partner-action-icon-hover-fill'] {
+ &:is(:hover, :focus-visible) [data-slot='partner-action-icon-hover-fill'] {
transform: translateX(0);
}
- &:hover,
- ${PartnerActionRow}:hover &,
- ${PartnerActionRow}:focus-within & {
+ &:hover {
transform: scale(1.05);
}
@@ -318,54 +217,6 @@ type IllustrationCardProps = {
variant?: 'shaped' | 'simple';
};
-function PartnerProgramAction({
- label,
- programId,
-}: {
- label: MessageDescriptor;
- programId: 'technology' | 'content' | 'solutions';
-}) {
- const { i18n } = useLingui();
- const { openPartnerApplicationModal } = usePartnerApplicationModal();
- const translatedLabel = i18n._(label);
-
- const openModal = () => {
- openPartnerApplicationModal(programId);
- };
-
- return (
-
-
- {translatedLabel}
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
export function IllustrationCard({
illustrationCard,
variant = 'shaped',
@@ -434,14 +285,6 @@ export function IllustrationCard({
) : null}
- {variant === 'simple' &&
- illustrationCard.action?.kind === 'partnerApplication' ? (
-
- ) : null}
-
{illustrationCard.attribution && (
diff --git a/packages/twenty-website/src/sections/ThreeCards/types/three-cards-illustration-card.ts b/packages/twenty-website/src/sections/ThreeCards/types/three-cards-illustration-card.ts
index f845b1a08a..b374b52337 100644
--- a/packages/twenty-website/src/sections/ThreeCards/types/three-cards-illustration-card.ts
+++ b/packages/twenty-website/src/sections/ThreeCards/types/three-cards-illustration-card.ts
@@ -2,12 +2,6 @@ import type { MessageDescriptor } from '@lingui/core';
import { type ThreeCardsIllustrationCardAttributionType } from './three-cards-illustration-card-attribution';
import type { ThreeCardsIllustrationId } from './three-cards-illustration-id';
-type ThreeCardsIllustrationCardActionType = {
- kind: 'partnerApplication';
- label: MessageDescriptor;
- programId: 'technology' | 'content' | 'solutions';
-};
-
type ThreeCardsIllustrationBenefitType = {
text: MessageDescriptor;
icon?:
@@ -25,7 +19,6 @@ export type ThreeCardsIllustrationCardType = {
heading: MessageDescriptor;
body: MessageDescriptor;
benefits?: ThreeCardsIllustrationBenefitType[];
- action?: ThreeCardsIllustrationCardActionType;
attribution?: ThreeCardsIllustrationCardAttributionType;
illustration: ThreeCardsIllustrationId;
caseStudySlug?: string;