[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:
@@ -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',
|
||||
},
|
||||
];
|
||||
+118
@@ -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>
|
||||
);
|
||||
}
|
||||
+26
@@ -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),
|
||||
);
|
||||
}
|
||||
+13
-546
@@ -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
-18
@@ -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';
|
||||
|
||||
+59
@@ -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
|
||||
>;
|
||||
+54
-28
@@ -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]);
|
||||
}
|
||||
+381
@@ -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>
|
||||
);
|
||||
}
|
||||
+120
@@ -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);
|
||||
});
|
||||
});
|
||||
+44
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+155
@@ -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');
|
||||
});
|
||||
});
|
||||
+545
@@ -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: [],
|
||||
};
|
||||
+129
@@ -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>
|
||||
);
|
||||
}
|
||||
+51
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+66
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+332
@@ -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;
|
||||
}
|
||||
+3
-160
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user