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