From ccbd3b6c460c2197f070613fd94fb9e438fd8455 Mon Sep 17 00:00:00 2001 From: Rashad Karanouh <11599358+rashad@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:13:16 +0200 Subject: [PATCH] =?UTF-8?q?Client=20brief=20wizard=20=E2=80=94=20/partners?= =?UTF-8?q?/brief=20(#22291)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Brief — website (Release ① of the brief + glowup rollout) Public client-brief wizard at `/partners/brief`, plus the marketplace entry points (match-me card, brief prompt/link, brief CTAs across partner surfaces). **Backend already shipped:** the `submit-client-brief` logic function merged in #22290 (v1.2.0) and is live in prod, so this PR is **website-only** and needs no app deploy. Rebased onto current `main` (was ~341 commits behind); lint + format pass locally, typecheck/tests via CI. ### Release sequence (do not break) 1. **① Brief website — THIS PR.** Independent; backend already live in prod. → merge → website deploy. 2. **② Glowup app → prod** (#22470). Rebase onto `main` (SDK 2.21), apply deterministic-id handling, bump 1.2.10 → 1.3.0, `deploy` + `install` on `partner-twenty-com`, set new app variables, refresh partners-doc. **This is the gate for ③.** 3. **③ Glowup website** (#22471) — only **after ② is LIVE on prod** (it reads the new partner links / services / case-study objects). → merge → website deploy. 4. Reconcile #22637 (partners-traffic-web) with ③ — both touch `partners-marketplace/*`. Draft — do not merge until vetted. --- packages/twenty-website/.env.example | 5 + .../partners/brief/ClientBriefPageContent.tsx | 36 +++ .../(focused)/partners/brief/page.tsx | 19 ++ .../src/app/api/client-brief/route.test.ts | 293 ++++++++++++++++++ .../src/app/api/client-brief/route.ts | 13 + .../src/app/api/partner-application/route.ts | 197 +----------- .../build-client-brief-payload.ts | 9 + .../build-client-brief-request-body.ts | 32 ++ .../src/client-brief/client-brief-copy.ts | 51 +++ .../client-brief/client-brief-reducer.test.ts | 194 ++++++++++++ .../src/client-brief/client-brief-reducer.ts | 74 +++++ .../client-brief-request-schema.test.ts | 71 +++++ .../client-brief-request-schema.ts | 24 ++ .../src/client-brief/client-brief-state.ts | 76 +++++ .../data/client-brief-step-ids.ts | 7 + .../client-brief/data/hosting-type-options.ts | 17 + .../client-brief/data/hosting-type-values.ts | 4 + .../src/client-brief/get-current-step-id.ts | 9 + .../twenty-website/src/client-brief/index.ts | 1 + .../client-brief/use-client-brief-state.ts | 58 ++++ .../validate-client-brief-step.ts | 43 +++ .../wizard/ClientBriefSuccess.tsx | 36 +++ .../client-brief/wizard/ClientBriefWizard.tsx | 282 +++++++++++++++++ .../client-brief/wizard/steps/BriefStep.tsx | 43 +++ .../client-brief/wizard/steps/ContextStep.tsx | 85 +++++ .../wizard/steps/IdentityStep.tsx | 64 ++++ .../src/partners-marketplace/EmptyState.tsx | 4 + .../MarketplaceBriefLink.tsx | 12 + .../MarketplaceBriefPrompt.tsx | 23 ++ .../MarketplaceCardFrame.tsx | 61 ++++ .../MarketplaceClient.tsx | 39 +-- .../partners-marketplace/MarketplaceGrid.tsx | 4 +- .../MarketplaceHeader.tsx | 15 +- .../MarketplaceMatchCard.tsx | 125 ++++++++ .../src/partners-marketplace/PartnerCard.tsx | 53 +--- .../PartnerProfileCtas.tsx | 5 + .../http/create-webhook-forwarding-route.ts | 201 ++++++++++++ .../twenty-website/src/platform/http/index.ts | 1 + .../platform/routing/static-website-routes.ts | 9 + .../src/platform/routing/website-route.ts | 1 + .../PricingEngagementBand.tsx | 138 +-------- .../twenty-website/src/ui/EngagementBand.tsx | 144 +++++++++ .../twenty-website/src/ui/TextareaField.tsx | 3 + packages/twenty-website/src/ui/index.ts | 1 + 44 files changed, 2195 insertions(+), 387 deletions(-) create mode 100644 packages/twenty-website/src/app/[locale]/(focused)/partners/brief/ClientBriefPageContent.tsx create mode 100644 packages/twenty-website/src/app/[locale]/(focused)/partners/brief/page.tsx create mode 100644 packages/twenty-website/src/app/api/client-brief/route.test.ts create mode 100644 packages/twenty-website/src/app/api/client-brief/route.ts create mode 100644 packages/twenty-website/src/client-brief/build-client-brief-payload.ts create mode 100644 packages/twenty-website/src/client-brief/build-client-brief-request-body.ts create mode 100644 packages/twenty-website/src/client-brief/client-brief-copy.ts create mode 100644 packages/twenty-website/src/client-brief/client-brief-reducer.test.ts create mode 100644 packages/twenty-website/src/client-brief/client-brief-reducer.ts create mode 100644 packages/twenty-website/src/client-brief/client-brief-request-schema.test.ts create mode 100644 packages/twenty-website/src/client-brief/client-brief-request-schema.ts create mode 100644 packages/twenty-website/src/client-brief/client-brief-state.ts create mode 100644 packages/twenty-website/src/client-brief/data/client-brief-step-ids.ts create mode 100644 packages/twenty-website/src/client-brief/data/hosting-type-options.ts create mode 100644 packages/twenty-website/src/client-brief/data/hosting-type-values.ts create mode 100644 packages/twenty-website/src/client-brief/get-current-step-id.ts create mode 100644 packages/twenty-website/src/client-brief/index.ts create mode 100644 packages/twenty-website/src/client-brief/use-client-brief-state.ts create mode 100644 packages/twenty-website/src/client-brief/validate-client-brief-step.ts create mode 100644 packages/twenty-website/src/client-brief/wizard/ClientBriefSuccess.tsx create mode 100644 packages/twenty-website/src/client-brief/wizard/ClientBriefWizard.tsx create mode 100644 packages/twenty-website/src/client-brief/wizard/steps/BriefStep.tsx create mode 100644 packages/twenty-website/src/client-brief/wizard/steps/ContextStep.tsx create mode 100644 packages/twenty-website/src/client-brief/wizard/steps/IdentityStep.tsx create mode 100644 packages/twenty-website/src/partners-marketplace/MarketplaceBriefLink.tsx create mode 100644 packages/twenty-website/src/partners-marketplace/MarketplaceBriefPrompt.tsx create mode 100644 packages/twenty-website/src/partners-marketplace/MarketplaceCardFrame.tsx create mode 100644 packages/twenty-website/src/partners-marketplace/MarketplaceMatchCard.tsx create mode 100644 packages/twenty-website/src/platform/http/create-webhook-forwarding-route.ts create mode 100644 packages/twenty-website/src/ui/EngagementBand.tsx diff --git a/packages/twenty-website/.env.example b/packages/twenty-website/.env.example index 0fc49c511c..229f74e6a9 100644 --- a/packages/twenty-website/.env.example +++ b/packages/twenty-website/.env.example @@ -8,6 +8,11 @@ NEXT_PUBLIC_WEBSITE_URL= PARTNER_APPLICATION_WEBHOOK_URL= PARTNER_APPLICATION_SECRET= +# Client brief — the form POSTs to /api/client-brief, which forwards the payload +# to this webhook with the secret as X-Application-Secret. +CLIENT_BRIEF_WEBHOOK_URL= +CLIENT_BRIEF_SECRET= + # Partners marketplace — the Twenty workspace the partner directory reads from # (server-side only) via the /s/partners REST endpoint. TWENTY_PARTNERS_API_URL= diff --git a/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/ClientBriefPageContent.tsx b/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/ClientBriefPageContent.tsx new file mode 100644 index 0000000000..738ea3e644 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/ClientBriefPageContent.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { styled } from '@linaria/react'; + +import { ClientBriefWizard } from '@/client-brief'; +import { buildSchemeContext, mediaUp, MODAL_SURFACE, spacing } from '@/tokens'; + +const BriefBackground = styled.div` + ${buildSchemeContext('dark')} + align-items: center; + background: ${MODAL_SURFACE.panel}; + display: flex; + justify-content: center; + min-height: 100dvh; +`; + +const BriefContainer = styled.div` + box-sizing: border-box; + max-width: min(720px, 100%); + padding: ${spacing(5)} ${spacing(4)}; + width: 100%; + + ${mediaUp('md')} { + padding: ${spacing(6)}; + } +`; + +export function ClientBriefPageContent() { + return ( + + + + + + ); +} diff --git a/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/page.tsx b/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/page.tsx new file mode 100644 index 0000000000..3b3c15f78d --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/page.tsx @@ -0,0 +1,19 @@ +import { + getRouteI18n, + type LocaleRouteParams, +} from '@/platform/i18n/get-route-i18n'; +import { buildRouteMetadata } from '@/platform/seo'; + +import { ClientBriefPageContent } from './ClientBriefPageContent'; + +export const generateMetadata = buildRouteMetadata('partnersBrief'); + +export default async function ClientBriefPage({ + params, +}: { + params: Promise; +}) { + await getRouteI18n(params); + + return ; +} diff --git a/packages/twenty-website/src/app/api/client-brief/route.test.ts b/packages/twenty-website/src/app/api/client-brief/route.test.ts new file mode 100644 index 0000000000..3a9c9912dd --- /dev/null +++ b/packages/twenty-website/src/app/api/client-brief/route.test.ts @@ -0,0 +1,293 @@ +// oxlint-disable-next-line unicorn/require-module-specifiers -- isolate from partner-application route.test globals +export {}; + +const ORIGINAL_FETCH = global.fetch; +const ORIGINAL_WEBHOOK_URL = process.env.CLIENT_BRIEF_WEBHOOK_URL; +const ORIGINAL_API_KEY = process.env.CLIENT_BRIEF_SECRET; + +function restoreEnvVar(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +const VALID_PAYLOAD = { + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@acme.com', + companyName: 'Acme Real Estate', + need: 'Migrate from HubSpot', +}; + +const VALID_BODY = JSON.stringify(VALID_PAYLOAD); + +function buildRequest({ + body = VALID_BODY, + contentType = 'application/json', + ip = '203.0.113.1', + contentLength, +}: { + body?: string; + contentType?: string | null; + ip?: string; + contentLength?: string; +} = {}) { + const headers = new Headers(); + 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/client-brief', { + method: 'POST', + headers, + body, + }); +} + +async function loadRoute() { + jest.resetModules(); + return import('@/app/api/client-brief/route'); +} + +async function runSequentially( + makeCall: () => Promise, + count: number, +): Promise { + if (count === 0) return []; + const response = await makeCall(); + const rest = await runSequentially(makeCall, count - 1); + return [response.status, ...rest]; +} + +describe('POST /api/client-brief', () => { + beforeEach(() => { + process.env.CLIENT_BRIEF_WEBHOOK_URL = 'https://hooks.example/client-brief'; + process.env.CLIENT_BRIEF_SECRET = 'test-key-abc123'; + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + global.fetch = ORIGINAL_FETCH; + restoreEnvVar('CLIENT_BRIEF_WEBHOOK_URL', ORIGINAL_WEBHOOK_URL); + restoreEnvVar('CLIENT_BRIEF_SECRET', ORIGINAL_API_KEY); + }); + + it('returns 503 when env vars are missing', async () => { + delete process.env.CLIENT_BRIEF_WEBHOOK_URL; + delete process.env.CLIENT_BRIEF_SECRET; + const { POST } = await loadRoute(); + const response = await POST(buildRequest({ ip: '203.0.113.2' })); + expect(response.status).toBe(503); + }); + + it('returns 415 when content-type is not JSON', async () => { + const { POST } = await loadRoute(); + const response = await POST( + buildRequest({ + contentType: 'application/x-www-form-urlencoded', + ip: '203.0.113.10', + }), + ); + expect(response.status).toBe(415); + }); + + 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' }), + ); + expect(response.status).toBe(413); + }); + + it('returns 400 on malformed JSON', async () => { + const { POST } = await loadRoute(); + const response = await POST( + buildRequest({ body: '{not-json', ip: '203.0.113.12' }), + ); + expect(response.status).toBe(400); + }); + + it('returns 400 when required fields are missing', async () => { + const { POST } = await loadRoute(); + const response = await POST( + buildRequest({ + body: JSON.stringify({ email: 'jane@acme.com' }), + ip: '203.0.113.13', + }), + ); + expect(response.status).toBe(400); + }); + + it('returns 400 when email is invalid', async () => { + const { POST } = await loadRoute(); + const response = await POST( + buildRequest({ + body: JSON.stringify({ ...VALID_PAYLOAD, email: 'not-an-email' }), + ip: '203.0.113.14', + }), + ); + expect(response.status).toBe(400); + }); + + it('returns 400 when an extra field is present (strictObject)', async () => { + const { POST } = await loadRoute(); + const response = await POST( + buildRequest({ + body: JSON.stringify({ + ...VALID_PAYLOAD, + briefSource: 'website', + }), + ip: '203.0.113.15', + }), + ); + expect(response.status).toBe(400); + }); + + it('forwards a valid submission to the webhook with header auth and returns 200', async () => { + const fetchSpy = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true, opportunityId: 'test-id' }), { + status: 200, + }), + ); + global.fetch = fetchSpy; + + const { POST } = await loadRoute(); + const response = await POST(buildRequest({ ip: '203.0.113.20' })); + + 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/client-brief'); + 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(VALID_PAYLOAD); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it('forwards optional context fields when provided', async () => { + const fetchSpy = jest + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + global.fetch = fetchSpy; + + const { POST } = await loadRoute(); + const response = await POST( + buildRequest({ + body: JSON.stringify({ + ...VALID_PAYLOAD, + requirements: 'French UI', + hostingType: 'CLOUD', + languages: ['French', 'English'], + seatCount: '~30', + }), + ip: '203.0.113.24', + }), + ); + + expect(response.status).toBe(200); + const [, init] = fetchSpy.mock.calls[0]; + expect(JSON.parse(init.body as string)).toMatchObject({ + requirements: 'French UI', + hostingType: 'CLOUD', + languages: ['French', 'English'], + seatCount: '~30', + }); + }); + + it('strips an empty languages array before forwarding', async () => { + const fetchSpy = jest + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + global.fetch = fetchSpy; + + const { POST } = await loadRoute(); + const response = await POST( + buildRequest({ + body: JSON.stringify({ ...VALID_PAYLOAD, languages: [] }), + ip: '203.0.113.25', + }), + ); + + expect(response.status).toBe(200); + const [, init] = fetchSpy.mock.calls[0]; + expect(JSON.parse(init.body as string)).toEqual(VALID_PAYLOAD); + }); + + 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); + }); + + 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); + }); + + it('returns 502 when the webhook returns ok:false in its body', async () => { + global.fetch = jest + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ok: false }), { status: 200 }), + ); + const { POST } = await loadRoute(); + const response = await POST(buildRequest({ ip: '203.0.113.26' })); + expect(response.status).toBe(502); + }); + + it('returns 504 when the webhook surfaces an AbortError (timeout path)', async () => { + global.fetch = jest + .fn() + .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); + }); + + it('rate-limits the same IP after the burst capacity is spent', async () => { + global.fetch = jest + .fn() + .mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ), + ); + const { POST } = await loadRoute(); + const ip = '203.0.113.99'; + const statuses = await runSequentially(() => POST(buildRequest({ ip })), 6); + expect(statuses.slice(0, 5).every((status) => status === 200)).toBe(true); + expect(statuses[5]).toBe(429); + }); + + it('attaches a Retry-After header on 429 responses', async () => { + global.fetch = jest + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + const { POST } = await loadRoute(); + const ip = '203.0.113.100'; + await runSequentially(() => POST(buildRequest({ ip })), 5); + const denied = await POST(buildRequest({ ip })); + expect(denied.status).toBe(429); + const retryAfter = denied.headers.get('Retry-After'); + expect(retryAfter).not.toBeNull(); + expect(Number.parseInt(retryAfter ?? '0', 10)).toBeGreaterThan(0); + }); +}); diff --git a/packages/twenty-website/src/app/api/client-brief/route.ts b/packages/twenty-website/src/app/api/client-brief/route.ts new file mode 100644 index 0000000000..2f231d4b83 --- /dev/null +++ b/packages/twenty-website/src/app/api/client-brief/route.ts @@ -0,0 +1,13 @@ +import { buildClientBriefPayload } from '@/client-brief/build-client-brief-payload'; +import { clientBriefRequestSchema } from '@/client-brief/client-brief-request-schema'; +import { createWebhookForwardingRoute } from '@/platform/http'; + +export const POST = createWebhookForwardingRoute({ + webhookUrlEnvVar: 'CLIENT_BRIEF_WEBHOOK_URL', + secretEnvVar: 'CLIENT_BRIEF_SECRET', + schema: clientBriefRequestSchema, + buildPayload: buildClientBriefPayload, + logTag: 'client-brief', + notConfiguredMessage: 'Client brief endpoint is not configured.', + failureMessage: 'Client brief could not be submitted.', +}); 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 b4fea41992..dd8851ec04 100644 --- a/packages/twenty-website/src/app/api/partner-application/route.ts +++ b/packages/twenty-website/src/app/api/partner-application/route.ts @@ -1,190 +1,13 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; - import { buildLogicFunctionPayload } from '@/partner-application/build-logic-function-payload'; import { partnerApplicationRequestSchema } from '@/partner-application/partner-application-request-schema'; -import { - createRateLimiter, - fetchWithTimeout, - getClientIpKey, - readJsonBody, -} from '@/platform/http'; +import { createWebhookForwardingRoute } from '@/platform/http'; -// z.url (not z.httpUrl) so localhost webhook destinations are accepted in dev; -// z.httpUrl would reject http://localhost:2020/... by demanding a TLD hostname. -const webhookUrlSchema = z - .string() - .trim() - .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({ capacity: 5, refillPerSec: 1 / 60 }); - -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 || !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, - 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. After editing env vars restart the dev server — Next does not hot-reload them.', - }), - ); - return NextResponse.json( - { 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) { - const retryAfterSeconds = Math.max( - 1, - Math.ceil(rateLimit.retryAfterMs / 1000), - ); - return NextResponse.json( - { error: 'Too many requests. Please try again shortly.' }, - { status: 429, headers: { 'Retry-After': String(retryAfterSeconds) } }, - ); - } - - const bodyResult = await readJsonBody(request, { - maxBytes: MAX_BODY_BYTES, - }); - - if (!bodyResult.ok) { - switch (bodyResult.error) { - case 'wrong-content-type': - return NextResponse.json( - { error: 'Content-Type must be application/json.' }, - { status: 415 }, - ); - case 'too-large': - return NextResponse.json( - { error: 'Request body is too large.' }, - { status: 413 }, - ); - case 'invalid-json': - return NextResponse.json( - { error: 'Invalid JSON body.' }, - { status: 400 }, - ); - } - } - - const parsed = partnerApplicationRequestSchema.safeParse(bodyResult.value); - if (!parsed.success) { - const message = parsed.error.issues[0]?.message ?? 'Invalid request body.'; - return NextResponse.json({ error: message }, { status: 400 }); - } - - const payload = buildLogicFunctionPayload(parsed.data); - - const upstream = await fetchWithTimeout( - webhookUrl, - { - body: JSON.stringify(payload), - headers: { - 'Content-Type': 'application/json', - 'X-Application-Secret': applicationSecret, - }, - method: 'POST', - }, - { timeoutMs: WEBHOOK_TIMEOUT_MS }, - ); - - if (!upstream.ok) { - const status = upstream.error === 'timeout' ? 504 : 502; - console.error( - '[partner-application] upstream fetch failed', - JSON.stringify({ - error: upstream.error, - payloadKeys: Object.keys(payload), - }), - ); - return NextResponse.json( - { error: 'Partner application could not be submitted.' }, - { status }, - ); - } - - let upstreamBody: string; - try { - upstreamBody = await upstream.response.text(); - } catch { - upstreamBody = ''; - } - - if (!upstream.response.ok) { - console.error( - '[partner-application] upstream returned non-2xx', - JSON.stringify({ - status: upstream.response.status, - body: upstreamBody.slice(0, 2000), - payloadKeys: Object.keys(payload), - }), - ); - return NextResponse.json( - { error: 'Partner application could not be submitted.' }, - { status: 502 }, - ); - } - - let logicResult: unknown; - try { - logicResult = JSON.parse(upstreamBody); - } catch { - logicResult = null; - } - - if ( - typeof logicResult !== 'object' || - logicResult === null || - (logicResult as Record)['ok'] !== true - ) { - console.error( - '[partner-application] logic function returned non-ok result', - JSON.stringify({ - body: upstreamBody.slice(0, 2000), - payloadKeys: Object.keys(payload), - }), - ); - return NextResponse.json( - { error: 'Partner application could not be submitted.' }, - { status: 502 }, - ); - } - - return NextResponse.json({ success: true }); -} +export const POST = createWebhookForwardingRoute({ + webhookUrlEnvVar: 'PARTNER_APPLICATION_WEBHOOK_URL', + secretEnvVar: 'PARTNER_APPLICATION_SECRET', + schema: partnerApplicationRequestSchema, + buildPayload: buildLogicFunctionPayload, + logTag: 'partner-application', + notConfiguredMessage: 'Partner application endpoint is not configured.', + failureMessage: 'Partner application could not be submitted.', +}); diff --git a/packages/twenty-website/src/client-brief/build-client-brief-payload.ts b/packages/twenty-website/src/client-brief/build-client-brief-payload.ts new file mode 100644 index 0000000000..f1656ae42f --- /dev/null +++ b/packages/twenty-website/src/client-brief/build-client-brief-payload.ts @@ -0,0 +1,9 @@ +import { type ClientBriefRequest } from './client-brief-request-schema'; + +export function buildClientBriefPayload(data: ClientBriefRequest) { + const payload = { ...data }; + if (payload.languages?.length === 0) { + delete payload.languages; + } + return payload; +} diff --git a/packages/twenty-website/src/client-brief/build-client-brief-request-body.ts b/packages/twenty-website/src/client-brief/build-client-brief-request-body.ts new file mode 100644 index 0000000000..e2058e6f19 --- /dev/null +++ b/packages/twenty-website/src/client-brief/build-client-brief-request-body.ts @@ -0,0 +1,32 @@ +import { type ClientBriefRequest } from './client-brief-request-schema'; +import { type ClientBriefState } from './client-brief-state'; + +function splitLanguages(value: string): string[] { + return value + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +export function buildClientBriefRequestBody( + state: ClientBriefState, +): ClientBriefRequest { + const body: ClientBriefRequest = { + firstName: state.firstName.trim(), + lastName: state.lastName.trim(), + email: state.email.trim(), + companyName: state.companyName.trim(), + need: state.need.trim(), + }; + + if (state.requirements.trim()) body.requirements = state.requirements.trim(); + if (state.hostingType !== '') body.hostingType = state.hostingType; + if (state.country.trim()) body.country = state.country.trim(); + const languages = splitLanguages(state.languages); + if (languages.length > 0) body.languages = languages; + if (state.seatCount.trim()) body.seatCount = state.seatCount.trim(); + if (state.timeline.trim()) body.timeline = state.timeline.trim(); + if (state.budgetRange.trim()) body.budgetRange = state.budgetRange.trim(); + + return body; +} diff --git a/packages/twenty-website/src/client-brief/client-brief-copy.ts b/packages/twenty-website/src/client-brief/client-brief-copy.ts new file mode 100644 index 0000000000..f1257cd5ba --- /dev/null +++ b/packages/twenty-website/src/client-brief/client-brief-copy.ts @@ -0,0 +1,51 @@ +import { msg } from '@lingui/core/macro'; + +export const CLIENT_BRIEF_COPY = { + title: msg`Tell us what you need\n*We'll find the right partner*`, + subtitle: msg`Share your CRM project brief and our team will match you with a certified Twenty partner.`, + back: msg`← Back`, + next: msg`Continue →`, + skip: msg`Skip for now`, + submit: msg`Submit brief`, + submitInFlight: msg`Submitting…`, + successTitle: msg`Thanks — we received your brief.`, + successBody: msg`Our team will review your project and follow up by email. You won't hear from partners until we've matched you.`, + stepProgressLabel: (current: number, total: number) => + msg`Step ${current} of ${total}`, + validation: { + incompleteForm: msg`Please complete all required fields before continuing.`, + invalidEmail: msg`Enter a valid email address.`, + submitFailed: msg`We could not submit your brief. Please try again in a moment.`, + }, + fields: { + need: msg`What do you need help with? *`, + needPlaceholder: msg`e.g. Migrate from HubSpot, set up pipelines, custom integrations…`, + requirements: msg`Requirements or constraints`, + requirementsPlaceholder: msg`Must-haves, integrations, compliance, languages…`, + hostingType: msg`Hosting preference`, + hostingTypePlaceholder: msg`Cloud or self-hosted?`, + country: msg`Country / region`, + countryPlaceholder: msg`e.g. France, DACH, North America…`, + languages: msg`Languages needed`, + languagesPlaceholder: msg`e.g. French, English (comma-separated)`, + seatCount: msg`Team size / seats`, + seatCountPlaceholder: msg`e.g. ~30`, + timeline: msg`Timeline`, + timelinePlaceholder: msg`e.g. Before Q4, Q1 2027…`, + budgetRange: msg`Budget range`, + budgetRangePlaceholder: msg`e.g. $10k–$25k`, + firstName: msg`First name *`, + lastName: msg`Last name`, + email: msg`Work email *`, + companyName: msg`Company name *`, + }, + stepHeaders: { + brief: msg`Your brief`, + context: msg`Tell us more`, + identity: msg`You`, + }, + hostingOptions: { + cloud: msg`Cloud (Twenty-hosted)`, + selfHosting: msg`Self-hosting`, + }, +}; diff --git a/packages/twenty-website/src/client-brief/client-brief-reducer.test.ts b/packages/twenty-website/src/client-brief/client-brief-reducer.test.ts new file mode 100644 index 0000000000..1ce390f70f --- /dev/null +++ b/packages/twenty-website/src/client-brief/client-brief-reducer.test.ts @@ -0,0 +1,194 @@ +import { clientBriefReducer } from './client-brief-reducer'; +import { + INITIAL_CLIENT_BRIEF_STATE, + type ClientBriefState, +} from './client-brief-state'; + +const baseValidBrief: Partial = { + need: 'Migrate from HubSpot to Twenty', +}; + +const baseValidIdentity: Partial = { + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@acme.com', + companyName: 'Acme Real Estate', +}; + +describe('clientBriefReducer', () => { + it('starts at stepIndex 0 with empty fields', () => { + expect(INITIAL_CLIENT_BRIEF_STATE.stepIndex).toBe(0); + expect(INITIAL_CLIENT_BRIEF_STATE.need).toBe(''); + expect(INITIAL_CLIENT_BRIEF_STATE.firstName).toBe(''); + }); + + it('SET_FIELD updates the field and clears any prior error for it', () => { + const seeded: ClientBriefState = { + ...INITIAL_CLIENT_BRIEF_STATE, + fieldErrors: { email: 'invalid_email' }, + }; + const next = clientBriefReducer(seeded, { + type: 'SET_FIELD', + field: 'email', + value: 'jane@acme.com', + }); + expect(next.email).toBe('jane@acme.com'); + expect(next.fieldErrors.email).toBeUndefined(); + }); + + it('SET_FIELD_ERRORS updates fieldErrors without advancing the step', () => { + const onIdentity: ClientBriefState = { + ...INITIAL_CLIENT_BRIEF_STATE, + stepIndex: 2, + ...baseValidBrief, + }; + const next = clientBriefReducer(onIdentity, { + type: 'SET_FIELD_ERRORS', + errors: { email: 'invalid_email' }, + }); + expect(next.stepIndex).toBe(2); + expect(next.fieldErrors.email).toBe('invalid_email'); + }); + + it('GO_NEXT on brief with missing need fills errors and stays', () => { + const next = clientBriefReducer(INITIAL_CLIENT_BRIEF_STATE, { + type: 'GO_NEXT', + }); + expect(next.stepIndex).toBe(0); + expect(next.fieldErrors.need).toBe('required'); + }); + + it('GO_NEXT on brief with valid need advances to context', () => { + const next = clientBriefReducer( + { ...INITIAL_CLIENT_BRIEF_STATE, ...baseValidBrief }, + { type: 'GO_NEXT' }, + ); + expect(next.stepIndex).toBe(1); + expect(next.fieldErrors).toEqual({}); + }); + + it('SKIP_CONTEXT jumps from context to identity', () => { + const onContext: ClientBriefState = { + ...INITIAL_CLIENT_BRIEF_STATE, + stepIndex: 1, + ...baseValidBrief, + }; + const next = clientBriefReducer(onContext, { type: 'SKIP_CONTEXT' }); + expect(next.stepIndex).toBe(2); + expect(next.fieldErrors).toEqual({}); + }); + + it('SKIP_CONTEXT is a no-op when not on context step', () => { + const onBrief = clientBriefReducer(INITIAL_CLIENT_BRIEF_STATE, { + type: 'SKIP_CONTEXT', + }); + expect(onBrief.stepIndex).toBe(0); + + const onIdentity: ClientBriefState = { + ...INITIAL_CLIENT_BRIEF_STATE, + stepIndex: 2, + }; + const stillIdentity = clientBriefReducer(onIdentity, { + type: 'SKIP_CONTEXT', + }); + expect(stillIdentity.stepIndex).toBe(2); + }); + + it('GO_NEXT on context advances to identity without required fields', () => { + const onContext: ClientBriefState = { + ...INITIAL_CLIENT_BRIEF_STATE, + stepIndex: 1, + ...baseValidBrief, + }; + const next = clientBriefReducer(onContext, { type: 'GO_NEXT' }); + expect(next.stepIndex).toBe(2); + expect(next.fieldErrors).toEqual({}); + }); + + it('GO_NEXT on identity requires firstName, email, and companyName', () => { + const onIdentity: ClientBriefState = { + ...INITIAL_CLIENT_BRIEF_STATE, + stepIndex: 2, + ...baseValidBrief, + }; + const blocked = clientBriefReducer(onIdentity, { type: 'GO_NEXT' }); + expect(blocked.stepIndex).toBe(2); + expect(Object.keys(blocked.fieldErrors).toSorted()).toEqual([ + 'companyName', + 'email', + 'firstName', + ]); + }); + + it('GO_NEXT on identity rejects a malformed email', () => { + const next = clientBriefReducer( + { + ...INITIAL_CLIENT_BRIEF_STATE, + stepIndex: 2, + ...baseValidBrief, + ...baseValidIdentity, + email: 'not-an-email', + }, + { type: 'GO_NEXT' }, + ); + expect(next.stepIndex).toBe(2); + expect(next.fieldErrors.email).toBe('invalid_email'); + }); + + it('GO_NEXT on identity with valid fields stays on identity (submit step)', () => { + const next = clientBriefReducer( + { + ...INITIAL_CLIENT_BRIEF_STATE, + stepIndex: 2, + ...baseValidBrief, + ...baseValidIdentity, + }, + { type: 'GO_NEXT' }, + ); + expect(next.stepIndex).toBe(2); + expect(next.fieldErrors).toEqual({}); + }); + + it('GO_BACK reverses steps and clears errors', () => { + const onIdentity: ClientBriefState = { + ...INITIAL_CLIENT_BRIEF_STATE, + stepIndex: 2, + fieldErrors: { email: 'required' }, + }; + const onContext = clientBriefReducer(onIdentity, { type: 'GO_BACK' }); + expect(onContext.stepIndex).toBe(1); + expect(onContext.fieldErrors).toEqual({}); + + const onBrief = clientBriefReducer(onContext, { type: 'GO_BACK' }); + expect(onBrief.stepIndex).toBe(0); + + expect(clientBriefReducer(onBrief, { type: 'GO_BACK' }).stepIndex).toBe(0); + }); + + it('SET_SUBMITTED flips isSubmitted and clears submitError + isSubmitting', () => { + const next = clientBriefReducer( + { + ...INITIAL_CLIENT_BRIEF_STATE, + isSubmitting: true, + submitError: 'transient network error', + }, + { type: 'SET_SUBMITTED' }, + ); + expect(next.isSubmitted).toBe(true); + expect(next.isSubmitting).toBe(false); + expect(next.submitError).toBeNull(); + }); + + it('RESET returns to the initial state from any state', () => { + const dirty: ClientBriefState = { + ...INITIAL_CLIENT_BRIEF_STATE, + need: 'x', + stepIndex: 2, + isSubmitting: true, + isSubmitted: true, + }; + expect(clientBriefReducer(dirty, { type: 'RESET' })).toEqual( + INITIAL_CLIENT_BRIEF_STATE, + ); + }); +}); diff --git a/packages/twenty-website/src/client-brief/client-brief-reducer.ts b/packages/twenty-website/src/client-brief/client-brief-reducer.ts new file mode 100644 index 0000000000..0c57abf97b --- /dev/null +++ b/packages/twenty-website/src/client-brief/client-brief-reducer.ts @@ -0,0 +1,74 @@ +import { CLIENT_BRIEF_STEP_IDS } from './data/client-brief-step-ids'; +import { + INITIAL_CLIENT_BRIEF_STATE, + type ClientBriefAction, + type ClientBriefState, +} from './client-brief-state'; +import { validateClientBriefStep } from './validate-client-brief-step'; + +function dropError( + errors: Partial>, + field: string, +): Partial> { + if (errors[field] === undefined) return errors; + const { [field]: _dropped, ...rest } = errors; + return rest; +} + +export function clientBriefReducer( + state: ClientBriefState, + action: ClientBriefAction, +): ClientBriefState { + switch (action.type) { + case 'SET_FIELD': + return { + ...state, + [action.field]: action.value, + fieldErrors: dropError(state.fieldErrors, action.field), + }; + case 'SET_FIELD_ERRORS': + return { ...state, fieldErrors: action.errors }; + case 'GO_NEXT': { + const errors = validateClientBriefStep(state); + if (Object.keys(errors).length > 0) { + return { ...state, fieldErrors: errors }; + } + const lastIndex = CLIENT_BRIEF_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 'SKIP_CONTEXT': { + const contextIndex = CLIENT_BRIEF_STEP_IDS.indexOf('context'); + if (state.stepIndex !== contextIndex) return state; + return { + ...state, + stepIndex: contextIndex + 1, + 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_CLIENT_BRIEF_STATE; + default: + return state; + } +} diff --git a/packages/twenty-website/src/client-brief/client-brief-request-schema.test.ts b/packages/twenty-website/src/client-brief/client-brief-request-schema.test.ts new file mode 100644 index 0000000000..2f5ca6e0e4 --- /dev/null +++ b/packages/twenty-website/src/client-brief/client-brief-request-schema.test.ts @@ -0,0 +1,71 @@ +import { clientBriefRequestSchema } from './client-brief-request-schema'; + +const minimalValid = { + firstName: 'Jane', + lastName: '', + email: 'jane@acme.com', + companyName: 'Acme Real Estate', + need: 'Migrate from HubSpot', +}; + +const fullValid = { + ...minimalValid, + lastName: 'Smith', + requirements: 'French UI required', + hostingType: 'CLOUD' as const, + country: 'France', + languages: ['French', 'English'], + seatCount: '~30', + timeline: 'Before Q4', + budgetRange: '$10k–$25k', +}; + +describe('clientBriefRequestSchema', () => { + it('accepts the minimal required payload', () => { + expect(clientBriefRequestSchema.safeParse(minimalValid).success).toBe(true); + }); + + it('accepts the full payload with optional context fields', () => { + expect(clientBriefRequestSchema.safeParse(fullValid).success).toBe(true); + }); + + it('rejects unknown top-level keys (strictObject)', () => { + expect( + clientBriefRequestSchema.safeParse({ + ...minimalValid, + briefSource: 'website', + }).success, + ).toBe(false); + }); + + it('rejects an invalid email', () => { + expect( + clientBriefRequestSchema.safeParse({ + ...minimalValid, + email: 'not-an-email', + }).success, + ).toBe(false); + }); + + it('rejects a missing required field', () => { + expect( + clientBriefRequestSchema.safeParse({ email: 'jane@acme.com' }).success, + ).toBe(false); + }); + + it('rejects an unknown hostingType enum value', () => { + expect( + clientBriefRequestSchema.safeParse({ + ...minimalValid, + hostingType: 'ON_PREM', + }).success, + ).toBe(false); + }); + + it('rejects empty need after trim', () => { + expect( + clientBriefRequestSchema.safeParse({ ...minimalValid, need: ' ' }) + .success, + ).toBe(false); + }); +}); diff --git a/packages/twenty-website/src/client-brief/client-brief-request-schema.ts b/packages/twenty-website/src/client-brief/client-brief-request-schema.ts new file mode 100644 index 0000000000..844850c199 --- /dev/null +++ b/packages/twenty-website/src/client-brief/client-brief-request-schema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { emailFieldSchema } from '@/partner-application/email-field-schema'; + +import { CLIENT_BRIEF_HOSTING_TYPES } from './data/hosting-type-values'; + +const optionalNonEmptyString = z.string().trim().min(1).optional(); + +export const clientBriefRequestSchema = z.strictObject({ + firstName: z.string().trim().min(1, { error: 'First name is required.' }), + lastName: z.string(), + email: emailFieldSchema, + companyName: z.string().trim().min(1, { error: 'Company name is required.' }), + need: z.string().trim().min(1, { error: 'Need is required.' }), + requirements: optionalNonEmptyString, + hostingType: z.enum(CLIENT_BRIEF_HOSTING_TYPES).optional(), + country: optionalNonEmptyString, + languages: z.array(z.string().trim().min(1)).optional(), + seatCount: optionalNonEmptyString, + timeline: optionalNonEmptyString, + budgetRange: optionalNonEmptyString, +}); + +export type ClientBriefRequest = z.infer; diff --git a/packages/twenty-website/src/client-brief/client-brief-state.ts b/packages/twenty-website/src/client-brief/client-brief-state.ts new file mode 100644 index 0000000000..0150906f8e --- /dev/null +++ b/packages/twenty-website/src/client-brief/client-brief-state.ts @@ -0,0 +1,76 @@ +import { type ClientBriefHostingType } from './data/hosting-type-values'; + +export type HostingTypeValue = ClientBriefHostingType | ''; + +export type ClientBriefState = { + stepIndex: number; + + // Brief + need: string; + requirements: string; + + // Context + hostingType: HostingTypeValue; + country: string; + languages: string; + seatCount: string; + timeline: string; + budgetRange: string; + + // Identity + firstName: string; + lastName: string; + email: string; + companyName: string; + + // Meta + fieldErrors: Partial>; + submitError: string | null; + isSubmitting: boolean; + isSubmitted: boolean; +}; + +export type ScalarFieldName = + | 'need' + | 'requirements' + | 'hostingType' + | 'country' + | 'languages' + | 'seatCount' + | 'timeline' + | 'budgetRange' + | 'firstName' + | 'lastName' + | 'email' + | 'companyName'; + +export type ClientBriefAction = + | { type: 'SET_FIELD'; field: ScalarFieldName; value: string } + | { type: 'SET_FIELD_ERRORS'; errors: Partial> } + | { type: 'GO_NEXT' } + | { type: 'GO_BACK' } + | { type: 'SKIP_CONTEXT' } + | { type: 'SET_SUBMITTING'; value: boolean } + | { type: 'SET_SUBMIT_ERROR'; value: string | null } + | { type: 'SET_SUBMITTED' } + | { type: 'RESET' }; + +export const INITIAL_CLIENT_BRIEF_STATE: ClientBriefState = { + stepIndex: 0, + need: '', + requirements: '', + hostingType: '', + country: '', + languages: '', + seatCount: '', + timeline: '', + budgetRange: '', + firstName: '', + lastName: '', + email: '', + companyName: '', + fieldErrors: {}, + submitError: null, + isSubmitting: false, + isSubmitted: false, +}; diff --git a/packages/twenty-website/src/client-brief/data/client-brief-step-ids.ts b/packages/twenty-website/src/client-brief/data/client-brief-step-ids.ts new file mode 100644 index 0000000000..28c2984cac --- /dev/null +++ b/packages/twenty-website/src/client-brief/data/client-brief-step-ids.ts @@ -0,0 +1,7 @@ +export type ClientBriefStepId = 'brief' | 'context' | 'identity'; + +export const CLIENT_BRIEF_STEP_IDS: readonly ClientBriefStepId[] = [ + 'brief', + 'context', + 'identity', +]; diff --git a/packages/twenty-website/src/client-brief/data/hosting-type-options.ts b/packages/twenty-website/src/client-brief/data/hosting-type-options.ts new file mode 100644 index 0000000000..f0f81a315c --- /dev/null +++ b/packages/twenty-website/src/client-brief/data/hosting-type-options.ts @@ -0,0 +1,17 @@ +import { + CLIENT_BRIEF_HOSTING_TYPES, + type ClientBriefHostingType, +} from './hosting-type-values'; + +const HOSTING_TYPE_LABEL_KEYS: Record< + ClientBriefHostingType, + 'cloud' | 'selfHosting' +> = { + CLOUD: 'cloud', + SELF_HOSTING: 'selfHosting', +}; + +export const HOSTING_TYPE_OPTIONS = CLIENT_BRIEF_HOSTING_TYPES.map((value) => ({ + labelKey: HOSTING_TYPE_LABEL_KEYS[value], + value, +})); diff --git a/packages/twenty-website/src/client-brief/data/hosting-type-values.ts b/packages/twenty-website/src/client-brief/data/hosting-type-values.ts new file mode 100644 index 0000000000..c7caeb17dd --- /dev/null +++ b/packages/twenty-website/src/client-brief/data/hosting-type-values.ts @@ -0,0 +1,4 @@ +export const CLIENT_BRIEF_HOSTING_TYPES = ['CLOUD', 'SELF_HOSTING'] as const; + +export type ClientBriefHostingType = + (typeof CLIENT_BRIEF_HOSTING_TYPES)[number]; diff --git a/packages/twenty-website/src/client-brief/get-current-step-id.ts b/packages/twenty-website/src/client-brief/get-current-step-id.ts new file mode 100644 index 0000000000..ebb29b0db2 --- /dev/null +++ b/packages/twenty-website/src/client-brief/get-current-step-id.ts @@ -0,0 +1,9 @@ +import { + CLIENT_BRIEF_STEP_IDS, + type ClientBriefStepId, +} from './data/client-brief-step-ids'; +import { type ClientBriefState } from './client-brief-state'; + +export function getCurrentStepId(state: ClientBriefState): ClientBriefStepId { + return CLIENT_BRIEF_STEP_IDS[state.stepIndex]; +} diff --git a/packages/twenty-website/src/client-brief/index.ts b/packages/twenty-website/src/client-brief/index.ts new file mode 100644 index 0000000000..9455801fcc --- /dev/null +++ b/packages/twenty-website/src/client-brief/index.ts @@ -0,0 +1 @@ +export { ClientBriefWizard } from './wizard/ClientBriefWizard'; diff --git a/packages/twenty-website/src/client-brief/use-client-brief-state.ts b/packages/twenty-website/src/client-brief/use-client-brief-state.ts new file mode 100644 index 0000000000..c735ced1f5 --- /dev/null +++ b/packages/twenty-website/src/client-brief/use-client-brief-state.ts @@ -0,0 +1,58 @@ +'use client'; + +import { useCallback, useReducer } from 'react'; + +import { clientBriefReducer } from './client-brief-reducer'; +import { + INITIAL_CLIENT_BRIEF_STATE, + type ScalarFieldName, +} from './client-brief-state'; + +export function useClientBriefState() { + const [state, dispatch] = useReducer( + clientBriefReducer, + INITIAL_CLIENT_BRIEF_STATE, + ); + + const setField = useCallback( + (field: ScalarFieldName, value: string) => + dispatch({ type: 'SET_FIELD', field, value }), + [], + ); + const setFieldErrors = useCallback( + (errors: Partial>) => + dispatch({ type: 'SET_FIELD_ERRORS', errors }), + [], + ); + const goNext = useCallback(() => dispatch({ type: 'GO_NEXT' }), []); + const goBack = useCallback(() => dispatch({ type: 'GO_BACK' }), []); + const skipContext = useCallback(() => dispatch({ type: 'SKIP_CONTEXT' }), []); + 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, + setFieldErrors, + goNext, + goBack, + skipContext, + setSubmitting, + setSubmitError, + setSubmitted, + reset, + }; +} + +export type ClientBriefController = ReturnType; diff --git a/packages/twenty-website/src/client-brief/validate-client-brief-step.ts b/packages/twenty-website/src/client-brief/validate-client-brief-step.ts new file mode 100644 index 0000000000..355bbe3690 --- /dev/null +++ b/packages/twenty-website/src/client-brief/validate-client-brief-step.ts @@ -0,0 +1,43 @@ +import { emailFieldSchema } from '@/partner-application/email-field-schema'; + +import { + type ClientBriefState, + type ScalarFieldName, +} from './client-brief-state'; +import { + CLIENT_BRIEF_STEP_IDS, + type ClientBriefStepId, +} from './data/client-brief-step-ids'; + +const STEP_REQUIRED_FIELDS: Record< + ClientBriefStepId, + readonly ScalarFieldName[] +> = { + brief: ['need'], + context: [], + identity: ['firstName', 'email', 'companyName'], +}; + +export function validateClientBriefStep( + state: ClientBriefState, +): Partial> { + if (state.stepIndex < 0 || state.stepIndex >= CLIENT_BRIEF_STEP_IDS.length) { + return { step: 'invalid_step' }; + } + const stepId = CLIENT_BRIEF_STEP_IDS[state.stepIndex]; + const errors: Partial> = {}; + + for (const field of STEP_REQUIRED_FIELDS[stepId]) { + if (state[field].trim() === '') { + errors[field] = 'required'; + } + } + + if (stepId === 'identity' && state.email.trim()) { + if (!emailFieldSchema.safeParse(state.email).success) { + errors.email = 'invalid_email'; + } + } + + return errors; +} diff --git a/packages/twenty-website/src/client-brief/wizard/ClientBriefSuccess.tsx b/packages/twenty-website/src/client-brief/wizard/ClientBriefSuccess.tsx new file mode 100644 index 0000000000..44fbbb99fa --- /dev/null +++ b/packages/twenty-website/src/client-brief/wizard/ClientBriefSuccess.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useLingui } from '@lingui/react'; +import { styled } from '@linaria/react'; + +import { Body, Heading } from '@/ui'; +import { spacing } from '@/tokens'; + +import { CLIENT_BRIEF_COPY } from '../client-brief-copy'; + +const SuccessView = styled.div` + display: flex; + flex-direction: column; + margin-top: ${spacing(6)}; + + & > * + * { + margin-top: ${spacing(4)}; + } +`; + +export function ClientBriefSuccess() { + const { i18n } = useLingui(); + + return ( + <> + + {i18n._(CLIENT_BRIEF_COPY.successTitle)} + + + + {i18n._(CLIENT_BRIEF_COPY.successBody)} + + + + ); +} diff --git a/packages/twenty-website/src/client-brief/wizard/ClientBriefWizard.tsx b/packages/twenty-website/src/client-brief/wizard/ClientBriefWizard.tsx new file mode 100644 index 0000000000..d947b0cbfc --- /dev/null +++ b/packages/twenty-website/src/client-brief/wizard/ClientBriefWizard.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useLingui } from '@lingui/react'; +import { styled } from '@linaria/react'; +import { type FormEvent, useCallback } from 'react'; + +import { + color, + fontFamily, + fontSize, + radius, + semanticColor, + spacing, +} from '@/tokens'; +import { Body, Button, Heading, StepIndicator } from '@/ui'; + +import { buildClientBriefRequestBody } from '../build-client-brief-request-body'; +import { CLIENT_BRIEF_COPY } from '../client-brief-copy'; +import { CLIENT_BRIEF_STEP_IDS } from '../data/client-brief-step-ids'; +import { getCurrentStepId } from '../get-current-step-id'; +import { + type ClientBriefController, + useClientBriefState, +} from '../use-client-brief-state'; +import { validateClientBriefStep } from '../validate-client-brief-step'; +import { ClientBriefSuccess } from './ClientBriefSuccess'; +import { BriefStep } from './steps/BriefStep'; +import { ContextStep } from './steps/ContextStep'; +import { IdentityStep } from './steps/IdentityStep'; + +const COPY = CLIENT_BRIEF_COPY; +const STEPS = CLIENT_BRIEF_STEP_IDS; + +const WizardRoot = styled.div` + display: flex; + flex-direction: column; + + & > * + * { + margin-top: ${spacing(4)}; + } +`; + +const TitleBlock = styled.div` + display: flex; + flex-direction: column; + + & > * + * { + margin-top: ${spacing(6)}; + } +`; + +const IntroGroup = styled.div` + display: flex; + flex-direction: column; + + & > * + * { + margin-top: ${spacing(3)}; + } +`; + +const HeaderStrip = styled.div` + align-items: center; + display: flex; + gap: ${spacing(3)}; + justify-content: space-between; +`; + +const HeaderLabel = styled.span` + color: ${semanticColor.inkMuted}; + font-family: ${fontFamily('mono')}; + font-size: ${fontSize(3)}; + text-transform: uppercase; +`; + +const FieldsStack = styled.div` + display: flex; + flex-direction: column; + + & > * + * { + margin-top: ${spacing(4)}; + } +`; + +const Footer = styled.div` + display: flex; + flex-direction: column; + + & > * + * { + margin-top: ${spacing(2)}; + } +`; + +const SecondaryButton = styled.button` + background: none; + border: 1px solid ${semanticColor.lineStrong}; + border-radius: ${radius(2)}; + color: ${semanticColor.ink}; + cursor: pointer; + font-family: ${fontFamily('mono')}; + font-size: ${fontSize(3)}; + height: ${spacing(10)}; + padding: 0 ${spacing(4)}; + text-transform: uppercase; +`; + +const FooterControls = styled.div` + align-items: center; + display: flex; + gap: ${spacing(2)}; + justify-content: space-between; + width: 100%; +`; + +const FooterActions = styled.div` + align-items: center; + display: flex; + gap: ${spacing(2)}; + margin-left: auto; +`; + +const ErrorBanner = styled.p` + color: ${color('error')}; + font-family: ${fontFamily('sans')}; + font-size: ${fontSize(3)}; +`; + +function StepRenderer({ controller }: { controller: ClientBriefController }) { + switch (getCurrentStepId(controller.state)) { + case 'brief': + return ; + case 'context': + return ; + case 'identity': + return ; + } +} + +export function ClientBriefWizard() { + const { i18n } = useLingui(); + const controller = useClientBriefState(); + const { + goBack, + goNext, + setFieldErrors, + setSubmitError, + setSubmitted, + setSubmitting, + skipContext, + state, + } = controller; + + const stepId = getCurrentStepId(state); + const stepIndex = state.stepIndex; + const isLastStep = stepIndex === STEPS.length - 1; + const isContextStep = stepId === 'context'; + const errorValues = Object.values(state.fieldErrors); + const hasFieldErrors = errorValues.length > 0; + const fieldErrorMessage = errorValues.includes('invalid_email') + ? COPY.validation.invalidEmail + : COPY.validation.incompleteForm; + + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + if (!isLastStep) { + goNext(); + return; + } + if (state.isSubmitting) return; + + setSubmitError(null); + + const errors = validateClientBriefStep(state); + if (Object.keys(errors).length > 0) { + setFieldErrors(errors); + return; + } + + const payload = buildClientBriefRequestBody(state); + setSubmitting(true); + try { + const response = await fetch('/api/client-brief', { + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + if (!response.ok) { + setSubmitError(i18n._(COPY.validation.submitFailed)); + return; + } + setSubmitted(); + } catch { + setSubmitError(i18n._(COPY.validation.submitFailed)); + } finally { + setSubmitting(false); + } + }, + [ + goNext, + i18n, + isLastStep, + setFieldErrors, + setSubmitError, + setSubmitted, + setSubmitting, + state, + ], + ); + + if (state.isSubmitted) { + return ; + } + + const stepLabel = `${i18n._( + COPY.stepProgressLabel(stepIndex + 1, STEPS.length), + )} · ${i18n._(COPY.stepHeaders[stepId])}`; + + return ( + + + {stepIndex === 0 ? ( + + + {i18n._(COPY.title)} + + + {i18n._(COPY.subtitle)} + + + ) : null} + + {stepLabel} + + + + +
+ + +
+ {state.submitError !== null ? ( + {state.submitError} + ) : null} + {hasFieldErrors ? ( + + {i18n._(fieldErrorMessage)} + + ) : null} + + {stepIndex > 0 ? ( + + {i18n._(COPY.back)} + + ) : ( + + )} + + {isContextStep ? ( + + {i18n._(COPY.skip)} + + ) : null} +
+
+
+
+ ); +} diff --git a/packages/twenty-website/src/client-brief/wizard/steps/BriefStep.tsx b/packages/twenty-website/src/client-brief/wizard/steps/BriefStep.tsx new file mode 100644 index 0000000000..580e8324d5 --- /dev/null +++ b/packages/twenty-website/src/client-brief/wizard/steps/BriefStep.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useLingui } from '@lingui/react'; + +import { Field, TextareaField } from '@/ui'; + +import { CLIENT_BRIEF_COPY } from '../../client-brief-copy'; +import { type ClientBriefController } from '../../use-client-brief-state'; + +const FIELDS = CLIENT_BRIEF_COPY.fields; + +export function BriefStep({ + controller, +}: { + controller: ClientBriefController; +}) { + const { i18n } = useLingui(); + const { setField, state } = controller; + + return ( + <> + + setField('need', value)} + placeholder={i18n._(FIELDS.needPlaceholder)} + value={state.need} + /> + + + setField('requirements', value)} + placeholder={i18n._(FIELDS.requirementsPlaceholder)} + value={state.requirements} + /> + + + ); +} diff --git a/packages/twenty-website/src/client-brief/wizard/steps/ContextStep.tsx b/packages/twenty-website/src/client-brief/wizard/steps/ContextStep.tsx new file mode 100644 index 0000000000..0a3eb504d3 --- /dev/null +++ b/packages/twenty-website/src/client-brief/wizard/steps/ContextStep.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useLingui } from '@lingui/react'; + +import { Field, Select, TextField } from '@/ui'; + +import { CLIENT_BRIEF_COPY } from '../../client-brief-copy'; +import { HOSTING_TYPE_OPTIONS } from '../../data/hosting-type-options'; +import { type ClientBriefController } from '../../use-client-brief-state'; + +const FIELDS = CLIENT_BRIEF_COPY.fields; + +export function ContextStep({ + controller, +}: { + controller: ClientBriefController; +}) { + const { i18n } = useLingui(); + const { setField, state } = controller; + + const hostingOptions = HOSTING_TYPE_OPTIONS.map((option) => ({ + label: i18n._(CLIENT_BRIEF_COPY.hostingOptions[option.labelKey]), + value: option.value, + })); + + return ( + <> + +