[Website] Partner application wizard + logic-function handover (#21039)

## Summary

Replaces the single-screen partner-application modal with a **4-step
wizard** on the public form, and points the route's upstream at the new
`submit-partner-application` HTTP logic function in the twenty-partners
SDK app.

After design review, the Expertise step landed on the validated
**Category + Skills** model: a small set of *stable* macro categories
the partner operates in, plus a *free, semi-structured* Skills field for
the concrete things that differentiate them (React, SAP, Shopify, …).

Companion PR (partners-app side): #21040

## ⚠️ Deployment notes

Before this can ship to prod, the website worker needs a new env var:

- **Add `PARTNER_APPLICATION_SECRET`** to the deploy config at
https://github.com/twentyhq/twenty-infra/tree/main/cloudflare/website.
Without it the route returns `503` ("Partner application endpoint is not
configured.").
- The value must **match** the `PARTNER_APPLICATION_SECRET` workspace
variable set in the partners workspace UI (Settings → Apps → Twenty
Partners → Variables) — that's how the handler authenticates the
incoming `X-Application-Secret` header.
- `PARTNER_APPLICATION_WEBHOOK_URL` also needs repointing from the TFT
webhook to the logic-function URL
(`https://partner.twenty.com/s/partner-applications` or equivalent) at
the same time.

## Wizard

- 4 steps inside `Modal.Root`: **Identity → Profile → Expertise →
Commercials**. Step-dot indicator, per-step required-field gating, reset
on close. The big serif hero shows **only on step 1**; later steps use
the compact `STEP n OF 4 · NAME` strip to reclaim vertical space.
- **Profile** captures Type of team (Solo/Agency), LinkedIn, City,
Country, Languages. Country uses the searchable Select (placeholder-only
label).
- **Expertise = Category + Skills + Notes:**
- **Category** — multi-select cards over 5 macro categories (`ADVISORY`,
`SOLUTIONING`, `DEVELOPMENT`, `HOSTING`, `SUPPORT`), each with a
one-line description + examples. (Replaces the old draft `partnerScope`
enum; the backend keeps the field name — see #21040.)
- **Skills** — free tag input with a clickable suggestion row + keyboard
autocomplete (↑/↓/Enter/Esc) and "add your own". Empty by default.
- **Notes** — one free textarea (merges the former `workspaceUrl` +
`customerReferences`), reviewed manually.
- `deploymentExpertise` removed from the form (covered by the Hosting
category).
- **In-modal success view** on submit ("Thanks, / we'll be in touch!")
with a Close button — replaces the old silent close.
- Removes the partners-page "Which partner program is right for you?"
three-cards section.

## Design-system primitives

- **`Form.Select`** — searchable popup whose dropdown is **portaled to
`<body>`** (fixed, anchored to the trigger, flips up, height-capped) so
the modal's `overflow`/`transform` can't clip it; pointer events are
stopped so clicking inside it doesn't dismiss the dialog.
- **`Form.TagInput`** — optional `suggestions` prop adds the suggestion
row + autocomplete menu (used by Skills); behaviour unchanged when no
suggestions are passed.
- **`CategoryCardSelect`** — compact multi-select cards.
- `Form.MultiSelect`, `Form.Currency`.

## Validation & payload

- **Single validation source:** client and server share Zod field
schemas (`partner-application-field-schemas.ts`). The reducer validates
via those instead of hand-rolled regexes, so client and server agree by
construction (e.g. both reject non-TLD URLs).
- **Typed request body:** `buildPartnerApplicationRequestBody(state)`
returns a typed `PartnerApplicationRequest` (unit-tested);
`handleSubmit` just serializes it.
- Payload is camelCase matching the logic-function input;
`applicationNotes` replaces `workspaceUrl`/`customerReferences`.
- Auth: the upstream call carries an `X-Application-Secret` header
backed by `PARTNER_APPLICATION_SECRET` (handler-enforced — the SDK's
`isAuthRequired` only accepts user-session JWTs, not workspace API
keys). The webhook-URL env uses `z.url()` (not `z.httpUrl()`) so
`http://localhost:2020/...` dev destinations parse.

## Demo

📹 _Screen recording of the wizard end-to-end (open → walk steps → submit
→ Partner record lands):_


https://github.com/user-attachments/assets/7458dd86-e3ff-47b5-9878-0eb134ff38e3

## Tests

- **62 passing** across reducer, Zod schema, route, the new
payload-builder suite, and Form helper suites. `npx tsc` clean, `nx
lint:diff-with-main` clean, Lingui catalogs regenerated (French slots
are a follow-up).

## Test plan

- [ ] `/partners` → "Become a partner" → wizard opens on Step 1 (full
hero)
- [ ] Identity: name / work email / company → Next
- [ ] Profile: pick **Type of team**; search country ("fra" → France);
pick languages → Next (compact header from here on)
- [ ] Expertise: select 1+ **Category** cards; add **Skills** (click a
suggestion, type one + Enter, drive the ↑/↓ autocomplete); optionally
fill **Notes**
- [ ] Country dropdown opens without being clipped by the modal, and
clicking inside it does **not** close the wizard
- [ ] Commercials → Submit → **in-modal "Thanks, we'll be in touch!"**;
Network shows POST `/api/partner-application` `200`
- [ ] Partner record lands with the chosen categories in `partnerScope`,
plus `skills`, `applicationNotes`, `slug` from company, `reviewed:
false`, `partnerTier: 'NEW'`
- [ ] Re-submit same email + different city → Partner updates;
`validationStage`/`reviewed`/`partnerTier` preserved
- [ ] Back/Next preserves entered values; Reset on close; mobile
single-column / chips wrap

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
Rashad Karanouh
2026-06-02 14:23:31 +04:00
committed by GitHub
parent 6a908b7876
commit d0e0e27035
35 changed files with 3342 additions and 1014 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ yarn-error.log*
# local env files
.env
.env*.local
.env*
# vercel
.vercel
@@ -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 (
<ThreeCardsSection scheme="muted">
<ThreeCardsIntro align="left">
<Eyebrow>
<HeadingPart fontFamily="sans">
<Trans>Which partner program is right for you?</Trans>
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">
Find the program that fits your business
</HeadingPart>
<HeadingPart fontFamily="sans">
and unlock new opportunities with Twenty
</HeadingPart>
</Trans>
</Heading>
</ThreeCardsIntro>
<IllustrationCards
illustrationCards={PARTNER_ILLUSTRATION_CARDS}
layoutOptions={SCROLL_LAYOUT_OPTIONS}
variant="simple"
/>
</ThreeCardsSection>
);
}
@@ -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) {
/>
</PromoSpacing>
<PartnerThreeCards />
<PartnerTestimonials />
<PartnerSignoff />
@@ -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',
},
];
@@ -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<string, unknown>).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');
});
});
@@ -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');
@@ -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<string>;
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;
}
@@ -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 },
@@ -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<HTMLInputElement>) => {
const next = event.target.value;
if (next === '' || ALLOWED_CHARS.test(next)) {
onValueChange(next.replace(',', '.'));
}
};
return (
<Wrapper data-invalid={invalid ? 'true' : undefined}>
<Prefix aria-hidden>$</Prefix>
<Input
type="text"
inputMode="decimal"
name={name}
value={value}
placeholder={placeholder}
aria-label={ariaLabel}
onChange={handleChange}
/>
</Wrapper>
);
}
@@ -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,
};
@@ -0,0 +1,79 @@
'use client';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
export type FormMultiSelectOption<TValue extends string> = {
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<TValue extends string> = {
values: ReadonlyArray<TValue>;
onToggle: (value: TValue) => void;
options: ReadonlyArray<FormMultiSelectOption<TValue>>;
invalid?: boolean;
ariaLabel?: string;
};
export function FormMultiSelect<TValue extends string>({
values,
onToggle,
options,
invalid,
ariaLabel,
}: FormMultiSelectProps<TValue>) {
return (
<PillGroup role="group" aria-label={ariaLabel}>
{options.map((option) => {
const selected = values.includes(option.value);
return (
<PillButton
key={option.value}
type="button"
aria-pressed={selected}
data-selected={selected ? 'true' : undefined}
data-invalid={invalid && !selected ? 'true' : undefined}
onClick={() => onToggle(option.value)}
>
{option.label}
</PillButton>
);
})}
</PillGroup>
);
}
@@ -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<TValue extends string> = {
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
// <body> 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<TValue extends string> = {
value: TValue | '';
onValueChange: (value: TValue) => void;
placeholder: ReactNode;
options: ReadonlyArray<FormSelectOption<TValue>>;
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<TValue extends string>({
value,
onValueChange,
placeholder,
options,
invalid,
name,
ariaLabel,
searchable,
searchPlaceholder,
searchEmptyLabel,
}: FormSelectProps<TValue>) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [position, setPosition] = useState<PopupPosition | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(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 (
<Root ref={rootRef}>
<Trigger
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
data-invalid={invalid ? 'true' : undefined}
onClick={() => setOpen((v) => !v)}
>
<Value
data-empty={value === '' || !selectedOption ? 'true' : undefined}
>
{value === '' || !selectedOption ? placeholder : selectedOption.label}
</Value>
<IconChevronDown size={20} stroke={1.5} aria-hidden />
</Trigger>
{name ? <input type="hidden" name={name} value={value} /> : null}
{open && popupStyle && typeof document !== 'undefined'
? createPortal(
<Popup
ref={popupRef}
role="listbox"
aria-label={ariaLabel}
style={popupStyle}
// Keep clicks inside the popup from reaching the modal's
// outside-press handler (which would close the dialog).
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{searchable && (
<SearchRow>
<IconSearch size={16} stroke={1.5} aria-hidden />
<SearchInput
autoFocus
type="text"
placeholder={searchPlaceholder}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</SearchRow>
)}
<OptionList>
{visibleOptions.length === 0 ? (
<EmptyState>{searchEmptyLabel ?? '—'}</EmptyState>
) : (
visibleOptions.map((option) => {
const selected = value === option.value;
return (
<Option
key={option.value}
type="button"
role="option"
aria-selected={selected}
data-selected={selected ? 'true' : undefined}
onClick={() => {
onValueChange(option.value);
setOpen(false);
}}
>
{option.label}
</Option>
);
})
)}
</OptionList>
</Popup>,
document.body,
)
: null}
</Root>
);
}
@@ -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<string>;
onValuesChange: (values: string[]) => void;
placeholder?: string;
ariaLabel?: string;
suggestions?: ReadonlyArray<string>;
};
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<HTMLInputElement>) => {
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 (
<Container>
<ComboBox>
<Wrapper>
{values.map((tag, index) => (
<Chip key={`${tag}-${index}`}>
{tag}
<ChipRemove
type="button"
aria-label={`Remove ${tag}`}
onClick={() => removeAt(index)}
>
×
</ChipRemove>
</Chip>
))}
<InlineInput
type="text"
value={draft}
placeholder={values.length === 0 ? placeholder : undefined}
aria-label={ariaLabel}
role={suggestions ? 'combobox' : undefined}
aria-expanded={suggestions ? menuOpen : undefined}
aria-controls={suggestions ? listId : undefined}
autoComplete="off"
onChange={(event) => {
setDraft(event.target.value);
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
onBlur={() => addValue(draft)}
/>
</Wrapper>
{menuOpen ? (
<Menu id={listId} role="listbox">
{menuMatches.map((match, index) => (
<MenuItem
key={match}
role="option"
aria-selected={index === activeIndex}
data-active={index === activeIndex ? 'true' : undefined}
onMouseDown={(event) => {
event.preventDefault();
addValue(match);
}}
>
{match}
</MenuItem>
))}
</Menu>
) : null}
</ComboBox>
{remainingSuggestions.length > 0 ? (
<SuggestRow>
{remainingSuggestions.map((suggestion) => (
<Ghost
key={suggestion}
type="button"
onClick={() => addValue(suggestion)}
>
+ {suggestion}
</Ghost>
))}
</SuggestRow>
) : null}
</Container>
);
}
@@ -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',
]);
});
});
@@ -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<string>,
selected: ReadonlyArray<string>,
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),
);
}
@@ -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<HTMLFormElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [programId, setProgramId] =
useState<PartnerProgramId>(initialProgramId);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(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<HTMLDivElement>) => {
if (!dropdownRef.current?.contains(event.relatedTarget as Node)) {
setDropdownOpen(false);
}
},
[],
);
const handleSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
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 (
<Modal.Root
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
onOpenChange={(next) => {
if (!next) onClose();
}}
className={partnerPanelClass}
className={partnerWizardPanelClass}
>
<TitleBlock>
<Modal.Title
render={
<TitleHeadingWrapper>
<Heading as="h2" size="lg" weight="light">
<HeadingPart fontFamily="serif" fontWeight="light">
{i18n._(copy.titleSerif)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans" fontWeight="light">
{i18n._(copy.titleSans)}
</HeadingPart>
</Heading>
</TitleHeadingWrapper>
}
/>
<Modal.Description
render={
<SubtitleStack>
<Body size="md">{i18n._(copy.subtitleLine1)}</Body>
<Body size="md">{i18n._(copy.subtitleLine2)}</Body>
</SubtitleStack>
}
/>
</TitleBlock>
<form ref={formRef} autoComplete="off" noValidate onSubmit={handleSubmit}>
<FormFields>
<Segments role="radiogroup" aria-label={i18n._(copy.selectLabel)}>
{PARTNER_PROGRAM_IDS.map((id) => (
<SegmentButton
key={id}
aria-checked={programId === id}
data-active={programId === id}
role="radio"
type="button"
onClick={() => {
setProgramId(id);
}}
>
{i18n._(PARTNER_PROGRAM_LABELS[id])}
</SegmentButton>
))}
</Segments>
<MobileProgramField>
<DropdownRoot ref={dropdownRef} onBlur={handleDropdownBlur}>
<DropdownTrigger
aria-expanded={dropdownOpen}
aria-haspopup="listbox"
aria-label={i18n._(copy.selectLabel)}
type="button"
onClick={() => {
setDropdownOpen((previous) => !previous);
}}
>
<DropdownTriggerContent>
<DropdownLabel>{i18n._(copy.selectLabel)}</DropdownLabel>
<DropdownValue>
{i18n._(PARTNER_PROGRAM_LABELS[programId])}
</DropdownValue>
</DropdownTriggerContent>
<DropdownIconContainer aria-hidden>
<IconChevronDown size={20} stroke={1.5} />
</DropdownIconContainer>
</DropdownTrigger>
{dropdownOpen && (
<DropdownPanel
role="listbox"
aria-label={i18n._(copy.selectLabel)}
>
{PARTNER_PROGRAM_IDS.map((id) => (
<DropdownOption
key={id}
aria-selected={programId === id}
data-selected={programId === id}
role="option"
type="button"
onClick={() => {
setProgramId(id);
setDropdownOpen(false);
}}
>
{i18n._(PARTNER_PROGRAM_LABELS[id])}
</DropdownOption>
))}
</DropdownPanel>
)}
</DropdownRoot>
</MobileProgramField>
<Form.Field>
<Form.Input
aria-required="true"
autoComplete="off"
name="name"
placeholder={i18n._(copy.fields.name)}
type="text"
/>
</Form.Field>
<FieldRow>
<Form.Field>
<Form.Input
aria-required="true"
autoComplete="off"
inputMode="email"
name="email"
placeholder={i18n._(copy.fields.email)}
type="text"
/>
</Form.Field>
<Form.Field>
<Form.Input
aria-required="true"
autoComplete="off"
name="company"
placeholder={i18n._(copy.fields.company)}
type="text"
/>
</Form.Field>
</FieldRow>
<Form.Field>
<Form.Input
aria-required="true"
autoComplete="off"
name="website"
placeholder={i18n._(copy.fields.website)}
type="text"
/>
</Form.Field>
<Form.Field>
<Form.Input
autoComplete="off"
name="opportunities"
placeholder={i18n._(copy.fields.opportunities)}
type="text"
/>
</Form.Field>
<Form.Field>
<Form.Textarea
aria-required="true"
autoComplete="off"
name="message"
placeholder={`${i18n._(copy.fields.messageLabel)}\n\n${i18n._(copy.fields.messageHint)}`}
/>
</Form.Field>
<Modal.Footer>
{submitError ? (
<SubmitError role="alert">{submitError}</SubmitError>
) : null}
<SubmitButton
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
>
<ButtonShape
fillColor={theme.colors.primary.background[100]}
height={BUTTON_HEIGHTS_PX.regular}
strokeColor="none"
/>
<SubmitLabel>
{i18n._(isSubmitting ? copy.submitInFlight : copy.submit)}
</SubmitLabel>
</SubmitButton>
</Modal.Footer>
</FormFields>
</form>
<PartnerApplicationWizard resetSignal={resetSignal} onSuccess={onClose} />
</Modal.Root>
);
}
@@ -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<PartnerProgramId>('technology');
return (
<PartnerApplicationModalContext.Provider
value={{
openPartnerApplicationModal: (programId?: PartnerProgramId) => {
if (programId !== undefined) {
setInitialProgramId(programId);
}
setOpen(true);
},
}}
value={{ openPartnerApplicationModal: () => setOpen(true) }}
>
{children}
<PartnerApplicationModal
initialProgramId={initialProgramId}
onClose={() => setOpen(false)}
open={open}
/>
<PartnerApplicationModal open={open} onClose={() => setOpen(false)} />
</PartnerApplicationModalContext.Provider>
);
}
@@ -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';
@@ -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
>;
@@ -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`,
};
@@ -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<HTMLFormElement | null>;
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]);
}
@@ -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 <IdentityStep controller={controller} />;
case 'profile':
return <ProfileStep controller={controller} />;
case 'expertise':
return <ExpertiseStep controller={controller} />;
case 'commercials':
return <CommercialsStep controller={controller} />;
}
}
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<HTMLFormElement>) => {
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 (
<>
<TitleBlock>
<Modal.Title
render={
<TitleHeadingWrapper>
<Heading as="h2" size="lg" weight="light">
<HeadingPart fontFamily="serif" fontWeight="light">
{i18n._(PARTNER_APPLICATION_MODAL_COPY.successTitleSerif)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans" fontWeight="light">
{i18n._(PARTNER_APPLICATION_MODAL_COPY.successTitleSans)}
</HeadingPart>
</Heading>
</TitleHeadingWrapper>
}
/>
</TitleBlock>
<SuccessView>
<Modal.Footer>
<PrimaryButton type="button" onClick={onSuccess}>
<ButtonShape
fillColor={theme.colors.primary.background[100]}
height={BUTTON_HEIGHTS_PX.regular}
strokeColor="none"
/>
<PrimaryLabel>
{i18n._(PARTNER_APPLICATION_MODAL_COPY.successClose)}
</PrimaryLabel>
</PrimaryButton>
</Modal.Footer>
</SuccessView>
</>
);
}
return (
<>
<TitleBlock>
{stepIndex === 0 ? (
<>
<Modal.Title
render={
<TitleHeadingWrapper>
<Heading as="h2" size="lg" weight="light">
<HeadingPart fontFamily="serif" fontWeight="light">
{i18n._(PARTNER_APPLICATION_MODAL_COPY.titleSerif)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans" fontWeight="light">
{i18n._(PARTNER_APPLICATION_MODAL_COPY.titleSans)}
</HeadingPart>
</Heading>
</TitleHeadingWrapper>
}
/>
<Modal.Description
render={
<SubtitleStack>
<Body size="md">
{i18n._(PARTNER_APPLICATION_MODAL_COPY.subtitleLine1)}
</Body>
<Body size="md">
{i18n._(PARTNER_APPLICATION_MODAL_COPY.subtitleLine2)}
</Body>
</SubtitleStack>
}
/>
</>
) : null}
<HeaderStrip>
{stepIndex === 0 ? (
<HeaderLabel>{stepLabelNode}</HeaderLabel>
) : (
<Modal.Title render={<HeaderLabel>{stepLabelNode}</HeaderLabel>} />
)}
<StepIndicator stepCount={STEPS.length} activeStepIndex={stepIndex} />
</HeaderStrip>
</TitleBlock>
<form noValidate autoComplete="off" onSubmit={handleSubmit}>
<FieldsStack>
<StepRenderer controller={controller} />
<Modal.Footer>
{state.submitError ? (
<SubmitError role="alert">{state.submitError}</SubmitError>
) : null}
{hasFieldErrors ? (
<FieldErrorBanner role="alert">
{i18n._(fieldErrorMessage)}
</FieldErrorBanner>
) : null}
<FooterControls>
{stepIndex > 0 ? (
<SecondaryButton type="button" onClick={goBack}>
{i18n._(PARTNER_APPLICATION_MODAL_COPY.back)}
</SecondaryButton>
) : (
<span />
)}
<PrimaryButton
type="submit"
disabled={state.isSubmitting}
aria-busy={state.isSubmitting}
>
<ButtonShape
fillColor={theme.colors.primary.background[100]}
height={BUTTON_HEIGHTS_PX.regular}
strokeColor="none"
/>
<PrimaryLabel>
{isLastStep
? state.isSubmitting
? i18n._(PARTNER_APPLICATION_MODAL_COPY.submitInFlight)
: i18n._(PARTNER_APPLICATION_MODAL_COPY.submit)
: i18n._(PARTNER_APPLICATION_MODAL_COPY.next)}
</PrimaryLabel>
</PrimaryButton>
</FooterControls>
</Modal.Footer>
</FieldsStack>
</form>
</>
);
}
export const partnerWizardPanelClass = css`
--modal-panel-width: min(360px, 100%);
@media (min-width: ${theme.breakpoints.md}px) {
--modal-panel-width: min(720px, 100%);
}
`;
@@ -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 (
<Track
role="progressbar"
aria-valuemin={1}
aria-valuemax={stepCount}
aria-valuenow={activeStepIndex + 1}
>
{Array.from({ length: stepCount }).map((_, index) => {
const state =
index < activeStepIndex
? 'completed'
: index === activeStepIndex
? 'active'
: 'upcoming';
const isLast = index === stepCount - 1;
return (
<span
key={index}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
>
<Dot data-state={state} />
{isLast ? null : (
<Connector
data-completed={index < activeStepIndex ? 'true' : undefined}
/>
)}
</span>
);
})}
</Track>
);
}
@@ -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);
});
});
@@ -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',
]);
});
});
@@ -0,0 +1,155 @@
import {
INITIAL_PARTNER_APPLICATION_STATE,
partnerApplicationReducer,
type PartnerApplicationState,
} from '@/sections/PartnerApplication/wizard/use-partner-application-state';
const baseValidIdentity: Partial<PartnerApplicationState> = {
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');
});
});
@@ -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<string> = [
'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<keyof PartnerApplicationState>
> = {
identity: ['name', 'email', 'company'],
profile: ['country', 'typeOfTeam'],
expertise: ['partnerScope'],
commercials: [],
};
@@ -0,0 +1,129 @@
'use client';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
export type CategoryOption<TValue extends string> = {
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<TValue extends string> = {
options: ReadonlyArray<CategoryOption<TValue>>;
values: ReadonlyArray<TValue>;
onToggle: (value: TValue) => void;
invalid?: boolean;
ariaLabel?: string;
};
export function CategoryCardSelect<TValue extends string>({
options,
values,
onToggle,
invalid,
ariaLabel,
}: CategoryCardSelectProps<TValue>) {
return (
<CardGroup role="group" aria-label={ariaLabel}>
{options.map((option) => {
const selected = values.includes(option.value);
return (
<Card
key={option.value}
type="button"
aria-pressed={selected}
data-selected={selected ? 'true' : undefined}
data-invalid={invalid && !selected ? 'true' : undefined}
onClick={() => onToggle(option.value)}
>
<Square>{selected ? '✓' : ''}</Square>
<Texts>
<Title>{option.label}</Title>
<Description>{option.description}</Description>
<Examples>ex. {option.examples}</Examples>
</Texts>
</Card>
);
})}
</CardGroup>
);
}
@@ -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 (
<>
<Form.Field label={i18n._(COPY.hourlyRate)}>
<Form.Currency
value={state.hourlyRate}
onValueChange={(value) => setField('hourlyRate', value)}
placeholder={i18n._(COPY.hourlyRatePlaceholder)}
name="hourlyRate"
ariaLabel={i18n._(COPY.hourlyRate)}
/>
</Form.Field>
<Form.Field label={i18n._(COPY.projectBudgetMin)}>
<Form.Currency
value={state.projectBudgetMin}
onValueChange={(value) => setField('projectBudgetMin', value)}
placeholder={i18n._(COPY.projectBudgetMinPlaceholder)}
name="projectBudgetMin"
ariaLabel={i18n._(COPY.projectBudgetMin)}
/>
</Form.Field>
<Form.Field>
<Form.Input
autoComplete="off"
name="calendarLink"
placeholder={i18n._(COPY.calendarLink)}
type="text"
value={state.calendarLink}
onChange={(event) => setField('calendarLink', event.target.value)}
aria-invalid={state.fieldErrors.calendarLink ? true : undefined}
/>
</Form.Field>
</>
);
}
@@ -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 (
<>
<Form.Field
label={i18n._(COPY.partnerScope)}
hint={i18n._(COPY.partnerScopeHint)}
>
<CategoryCardSelect<PartnerScopeValue>
options={categoryOptions}
values={state.partnerScope}
onToggle={toggleScope}
invalid={state.fieldErrors.partnerScope !== undefined}
ariaLabel={i18n._(COPY.partnerScope)}
/>
</Form.Field>
<Form.Field label={i18n._(COPY.skills)} hint={i18n._(COPY.skillsHint)}>
<Form.TagInput
values={state.skills}
onValuesChange={setSkills}
placeholder={i18n._(COPY.skillsPlaceholder)}
ariaLabel={i18n._(COPY.skills)}
suggestions={PARTNER_SKILL_SUGGESTIONS}
/>
</Form.Field>
<Form.Field label={i18n._(COPY.applicationNotes)}>
<Form.Textarea
autoComplete="off"
name="applicationNotes"
placeholder={i18n._(COPY.applicationNotesPlaceholder)}
value={state.applicationNotes}
onChange={(event) => setField('applicationNotes', event.target.value)}
/>
</Form.Field>
</>
);
}
@@ -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 (
<>
<Form.Field>
<Form.Input
autoComplete="off"
name="name"
placeholder={i18n._(COPY.name)}
type="text"
value={state.name}
onChange={(event) => setField('name', event.target.value)}
aria-invalid={state.fieldErrors.name ? true : undefined}
/>
</Form.Field>
<Form.Field>
<Form.Input
autoComplete="off"
inputMode="email"
name="email"
placeholder={i18n._(COPY.email)}
type="text"
value={state.email}
onChange={(event) => setField('email', event.target.value)}
aria-invalid={state.fieldErrors.email ? true : undefined}
/>
</Form.Field>
<Form.Field>
<Form.Input
autoComplete="off"
name="company"
placeholder={i18n._(COPY.company)}
type="text"
value={state.company}
onChange={(event) => setField('company', event.target.value)}
aria-invalid={state.fieldErrors.company ? true : undefined}
/>
</Form.Field>
<Form.Field>
<Form.Input
autoComplete="off"
name="website"
placeholder={i18n._(COPY.website)}
type="text"
value={state.website}
onChange={(event) => setField('website', event.target.value)}
aria-invalid={state.fieldErrors.website ? true : undefined}
/>
</Form.Field>
</>
);
}
@@ -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 (
<>
<Form.Field label={i18n._(COPY.typeOfTeam)}>
<Form.Select<PartnerTypeOfTeam>
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)}
/>
</Form.Field>
<Form.Field>
<Form.Input
autoComplete="off"
name="linkedin"
placeholder={i18n._(COPY.linkedin)}
type="text"
value={state.linkedin}
onChange={(event) => setField('linkedin', event.target.value)}
/>
</Form.Field>
<Form.Field>
<Form.Input
autoComplete="off"
name="city"
placeholder={i18n._(COPY.city)}
type="text"
value={state.city}
onChange={(event) => setField('city', event.target.value)}
/>
</Form.Field>
<Form.Field>
<Form.Select<PartnerCountryValue>
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)}
/>
</Form.Field>
<Form.Field label={i18n._(COPY.languages)}>
<Form.MultiSelect<PartnerLanguageValue>
values={state.languages}
onToggle={toggleLanguage}
options={languageOptions}
ariaLabel={i18n._(COPY.languages)}
/>
</Form.Field>
</>
);
}
@@ -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<Record<string, string>>;
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<PartnerApplicationStepId, ReadonlyArray<FieldFormatCheck>>
> = {
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<Record<string, string>> {
const stepId = PARTNER_APPLICATION_STEP_IDS[state.stepIndex];
const required = PARTNER_APPLICATION_STEP_REQUIRED_FIELDS[stepId];
const errors: Partial<Record<string, string>> = {};
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<Record<string, string>>,
field: string,
): Partial<Record<string, string>> {
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;
}
@@ -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 (
<PartnerActionRow>
<PartnerActionButton type="button" onClick={openModal}>
{translatedLabel}
</PartnerActionButton>
<PartnerActionIconButton
aria-label={translatedLabel}
type="button"
onClick={openModal}
>
<ButtonShape
fillColor="none"
height={PARTNER_ACTION_ICON_BUTTON_SIZE}
strokeColor={CARD_OUTLINE_COLOR}
/>
<PartnerActionIconHoverFill data-slot="partner-action-icon-hover-fill">
<ButtonShape
fillColor={theme.colors.primary.text[100]}
height={PARTNER_ACTION_ICON_BUTTON_SIZE}
strokeColor="none"
/>
</PartnerActionIconHoverFill>
<PartnerActionIconGlyph>
<ArrowRightIcon
size={PARTNER_ACTION_ICON_SIZE}
strokeColor="currentColor"
/>
</PartnerActionIconGlyph>
</PartnerActionIconButton>
</PartnerActionRow>
);
}
export function IllustrationCard({
illustrationCard,
variant = 'shaped',
@@ -434,14 +285,6 @@ export function IllustrationCard({
</BenefitList>
) : null}
{variant === 'simple' &&
illustrationCard.action?.kind === 'partnerApplication' ? (
<PartnerProgramAction
label={illustrationCard.action.label}
programId={illustrationCard.action.programId}
/>
) : null}
{illustrationCard.attribution && (
<CardFooter>
<Body size="xs" weight="medium">
@@ -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;