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}
+
+
+
+
+
+
+ );
+}
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 (
+ <>
+
+
+
+ setField('country', value)}
+ placeholder={i18n._(FIELDS.countryPlaceholder)}
+ value={state.country}
+ />
+
+
+ setField('languages', value)}
+ placeholder={i18n._(FIELDS.languagesPlaceholder)}
+ value={state.languages}
+ />
+
+
+ setField('seatCount', value)}
+ placeholder={i18n._(FIELDS.seatCountPlaceholder)}
+ value={state.seatCount}
+ />
+
+
+ setField('timeline', value)}
+ placeholder={i18n._(FIELDS.timelinePlaceholder)}
+ value={state.timeline}
+ />
+
+
+ setField('budgetRange', value)}
+ placeholder={i18n._(FIELDS.budgetRangePlaceholder)}
+ value={state.budgetRange}
+ />
+
+ >
+ );
+}
diff --git a/packages/twenty-website/src/client-brief/wizard/steps/IdentityStep.tsx b/packages/twenty-website/src/client-brief/wizard/steps/IdentityStep.tsx
new file mode 100644
index 0000000000..ae30f6f717
--- /dev/null
+++ b/packages/twenty-website/src/client-brief/wizard/steps/IdentityStep.tsx
@@ -0,0 +1,64 @@
+'use client';
+
+import { useLingui } from '@lingui/react';
+
+import { Field, TextField } 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 IdentityStep({
+ controller,
+}: {
+ controller: ClientBriefController;
+}) {
+ const { i18n } = useLingui();
+ const { setField, state } = controller;
+
+ return (
+ <>
+
+ setField('firstName', value)}
+ placeholder={i18n._(FIELDS.firstName)}
+ value={state.firstName}
+ />
+
+
+ setField('lastName', value)}
+ placeholder={i18n._(FIELDS.lastName)}
+ value={state.lastName}
+ />
+
+
+ setField('email', value)}
+ placeholder={i18n._(FIELDS.email)}
+ value={state.email}
+ />
+
+
+ setField('companyName', value)}
+ placeholder={i18n._(FIELDS.companyName)}
+ value={state.companyName}
+ />
+
+ >
+ );
+}
diff --git a/packages/twenty-website/src/partners-marketplace/EmptyState.tsx b/packages/twenty-website/src/partners-marketplace/EmptyState.tsx
index aa108dcded..0a7e22c9fc 100644
--- a/packages/twenty-website/src/partners-marketplace/EmptyState.tsx
+++ b/packages/twenty-website/src/partners-marketplace/EmptyState.tsx
@@ -38,6 +38,10 @@ const Subtitle = styled.p`
`;
const ClearRow = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${spacing(3)};
+ justify-content: center;
margin-top: ${spacing(4)};
`;
diff --git a/packages/twenty-website/src/partners-marketplace/MarketplaceBriefLink.tsx b/packages/twenty-website/src/partners-marketplace/MarketplaceBriefLink.tsx
new file mode 100644
index 0000000000..d4c988a02d
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/MarketplaceBriefLink.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import { styled } from '@linaria/react';
+
+import { LocalizedLink } from '@/platform/i18n/LocalizedLink';
+import { color } from '@/tokens';
+
+export const MarketplaceBriefLink = styled(LocalizedLink)`
+ color: ${color('blue')};
+ text-decoration: underline;
+ text-underline-offset: 2px;
+`;
diff --git a/packages/twenty-website/src/partners-marketplace/MarketplaceBriefPrompt.tsx b/packages/twenty-website/src/partners-marketplace/MarketplaceBriefPrompt.tsx
new file mode 100644
index 0000000000..c435faab7c
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/MarketplaceBriefPrompt.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+
+import { Button, EngagementBand } from '@/ui';
+
+export function MarketplaceBriefPrompt() {
+ const { i18n } = useLingui();
+
+ return (
+
+ }
+ />
+ );
+}
diff --git a/packages/twenty-website/src/partners-marketplace/MarketplaceCardFrame.tsx b/packages/twenty-website/src/partners-marketplace/MarketplaceCardFrame.tsx
new file mode 100644
index 0000000000..d3bb3459c3
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/MarketplaceCardFrame.tsx
@@ -0,0 +1,61 @@
+import { styled } from '@linaria/react';
+import { type CSSProperties } from 'react';
+
+import {
+ color,
+ EASING,
+ radius,
+ REDUCED_MOTION,
+ semanticColor,
+ SHADOW,
+} from '@/tokens';
+
+export type PartnerCardIndexStyle = CSSProperties & {
+ '--partner-card-index': number;
+};
+
+// Shared marketplace card shell: staggered entrance, hover lift, reduced-motion
+// opt-out. The animation-delay formula is the single source of truth for the
+// match card and every partner card (MarketplaceGrid offsets partner cards by
+// +1 so they trail the match card at index 0).
+export const CardFrame = styled.article`
+ @keyframes partnerCardEnter {
+ from {
+ opacity: 0;
+ transform: translate3d(0, 18px, 0);
+ }
+ to {
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ }
+ }
+
+ animation: partnerCardEnter 700ms ${EASING.standard} both;
+ animation-delay: calc(var(--partner-card-index) * 90ms + 180ms);
+ background-color: ${color('white')};
+ border: 1px solid ${semanticColor.line};
+ border-radius: ${radius(2)};
+ display: flex;
+ flex-direction: column;
+ isolation: isolate;
+ position: relative;
+ transition:
+ border-color 0.25s ease,
+ box-shadow 0.25s ease,
+ transform 0.25s ease;
+
+ &:hover {
+ border-color: ${semanticColor.lineStrong};
+ box-shadow: ${SHADOW.card};
+ transform: translateY(-2px);
+ }
+
+ ${REDUCED_MOTION} {
+ animation: none;
+ transition: none;
+
+ &:hover {
+ transform: none;
+ }
+ }
+`;
diff --git a/packages/twenty-website/src/partners-marketplace/MarketplaceClient.tsx b/packages/twenty-website/src/partners-marketplace/MarketplaceClient.tsx
index c8b4e2f94e..a6872405a0 100644
--- a/packages/twenty-website/src/partners-marketplace/MarketplaceClient.tsx
+++ b/packages/twenty-website/src/partners-marketplace/MarketplaceClient.tsx
@@ -6,6 +6,7 @@ import { useMemo } from 'react';
import { spacing } from '@/tokens';
import { SectionShell } from '@/ui';
+import { MarketplaceBriefPrompt } from './MarketplaceBriefPrompt';
import { MarketplaceEmptyState } from './EmptyState';
import { FilterBar } from './FilterBar';
import { filterPartners } from './filter-partners';
@@ -37,24 +38,26 @@ export function MarketplaceClient({
);
return (
-
-
-
- {filteredPartners.length > 0 ? (
+ <>
+
+
+
- ) : (
-
- )}
-
-
+ {filteredPartners.length === 0 && partners.length > 0 && (
+
+ )}
+
+
+ {partners.length > 0 && }
+ >
);
}
diff --git a/packages/twenty-website/src/partners-marketplace/MarketplaceGrid.tsx b/packages/twenty-website/src/partners-marketplace/MarketplaceGrid.tsx
index bcf6dee4b4..2b979657b2 100644
--- a/packages/twenty-website/src/partners-marketplace/MarketplaceGrid.tsx
+++ b/packages/twenty-website/src/partners-marketplace/MarketplaceGrid.tsx
@@ -2,6 +2,7 @@ import { styled } from '@linaria/react';
import { mediaUp, spacing } from '@/tokens';
+import { MarketplaceMatchCard } from './MarketplaceMatchCard';
import { type MarketplacePartner } from './marketplace-partner';
import { PartnerCard } from './PartnerCard';
@@ -27,8 +28,9 @@ type MarketplaceGridProps = {
export function MarketplaceGrid({ partners }: MarketplaceGridProps) {
return (
+
{partners.map((partner, index) => (
-
+
))}
);
diff --git a/packages/twenty-website/src/partners-marketplace/MarketplaceHeader.tsx b/packages/twenty-website/src/partners-marketplace/MarketplaceHeader.tsx
index 7553a31543..bb9b44180e 100644
--- a/packages/twenty-website/src/partners-marketplace/MarketplaceHeader.tsx
+++ b/packages/twenty-website/src/partners-marketplace/MarketplaceHeader.tsx
@@ -1,10 +1,13 @@
import { msg } from '@lingui/core/macro';
+import { Trans } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import { getServerI18n } from '@/platform/i18n/get-server-i18n';
import { mediaUp, spacing } from '@/tokens';
import { Body, Eyebrow, Heading, SectionShell } from '@/ui';
+import { MarketplaceBriefLink } from './MarketplaceBriefLink';
+
// Left-aligned page intro on the shared hero rhythm (the old marketplace
// header's larger one-off top padding normalizes onto rhythm="hero", the
// ratified rhythm system). The grid section follows on the same light surface.
@@ -39,9 +42,15 @@ export function MarketplaceHeader() {
- {i18n._(
- msg`Twenty's certified partners help teams migrate, customise, and operate the open source CRM across regions, languages, and deployment models. Browse profiles and book a call.`,
- )}
+
+ Twenty's certified partners help teams migrate, customise,
+ and operate the open source CRM across regions, languages, and
+ deployment models. Browse profiles and book a call, or{' '}
+
+ tell us what you need
+ {' '}
+ and we'll match you.
+
diff --git a/packages/twenty-website/src/partners-marketplace/MarketplaceMatchCard.tsx b/packages/twenty-website/src/partners-marketplace/MarketplaceMatchCard.tsx
new file mode 100644
index 0000000000..683dca3654
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/MarketplaceMatchCard.tsx
@@ -0,0 +1,125 @@
+'use client';
+
+import { css } from '@linaria/core';
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+import { styled } from '@linaria/react';
+import NextImage from 'next/image';
+
+import { BREAKPOINT_PX, color, mediaUp, spacing } from '@/tokens';
+import { Body, Button, Eyebrow, Heading } from '@/ui';
+
+import { CardFrame, type PartnerCardIndexStyle } from './MarketplaceCardFrame';
+
+const CardArticle = styled(CardFrame)`
+ min-height: 100%;
+ overflow: hidden;
+`;
+
+const AccentBar = styled.div`
+ background-color: ${color('blue')};
+ height: 3px;
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: 0;
+ z-index: 2;
+`;
+
+const TextureLayer = styled.div`
+ bottom: 0;
+ left: 30%;
+ pointer-events: none;
+ position: absolute;
+ right: 0;
+ top: 0;
+ z-index: 0;
+
+ ${mediaUp('md')} {
+ left: 20%;
+ }
+`;
+
+const TextureScrim = styled.div`
+ background: linear-gradient(
+ 118deg,
+ ${color('white')} 0%,
+ ${color('white')} 38%,
+ ${color('white-70')} 58%,
+ ${color('white-20')} 100%
+ );
+ inset: 0;
+ position: absolute;
+ z-index: 1;
+`;
+
+const textureImageClassName = css`
+ object-fit: cover;
+ object-position: right center;
+ opacity: 0.9;
+`;
+
+const Content = styled.div`
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: ${spacing(5)};
+ padding: ${spacing(6)};
+ padding-top: calc(${spacing(6)} + 3px);
+ position: relative;
+ z-index: 2;
+`;
+
+const Copy = styled.div`
+ display: flex;
+ flex-direction: column;
+
+ & > * + * {
+ margin-top: ${spacing(3)};
+ }
+`;
+
+const CtaWrapper = styled.div`
+ margin-top: auto;
+`;
+
+export function MarketplaceMatchCard({ index = 0 }: { index?: number }) {
+ const { i18n } = useLingui();
+ const style: PartnerCardIndexStyle = { '--partner-card-index': index };
+
+ return (
+
+
+
+
+
+
+
+
+ {i18n._(msg`Partner matching`)}
+
+ {i18n._(msg`Let us *match* you`)}
+
+
+ {i18n._(
+ msg`Share your project in a few minutes. We pair you with a certified partner who fits.`,
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/twenty-website/src/partners-marketplace/PartnerCard.tsx b/packages/twenty-website/src/partners-marketplace/PartnerCard.tsx
index 1fcf64991f..785aa09f56 100644
--- a/packages/twenty-website/src/partners-marketplace/PartnerCard.tsx
+++ b/packages/twenty-website/src/partners-marketplace/PartnerCard.tsx
@@ -4,24 +4,20 @@ import { IconBrandLinkedin } from '@tabler/icons-react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
-import { type CSSProperties } from 'react';
import { LocalizedLink } from '@/platform/i18n/LocalizedLink';
import {
- color,
- EASING,
FONT_WEIGHT,
fontFamily,
fontSize,
radius,
- REDUCED_MOTION,
semanticColor,
- SHADOW,
spacing,
} from '@/tokens';
import { Button, ExternalLink } from '@/ui';
import { isSafeHttpUrl } from './is-safe-http-url';
+import { CardFrame, type PartnerCardIndexStyle } from './MarketplaceCardFrame';
import { type MarketplacePartner } from './marketplace-partner';
import { PartnerAvatar } from './PartnerAvatar';
import { PartnerChipRow } from './PartnerChipRow';
@@ -31,53 +27,10 @@ import { SERVED_GEO_LABELS } from './served-geo-labels';
import { SPOKEN_LANGUAGE_LABELS } from './spoken-language-labels';
import { titleCaseFallback } from './title-case-fallback';
-type PartnerCardStyle = CSSProperties & {
- '--partner-card-index': number;
-};
-
-const CardArticle = styled.article`
- @keyframes partnerCardEnter {
- from {
- opacity: 0;
- transform: translate3d(0, 18px, 0);
- }
- to {
- opacity: 1;
- transform: translate3d(0, 0, 0);
- }
- }
-
- animation: partnerCardEnter 700ms ${EASING.standard} both;
- animation-delay: calc(var(--partner-card-index) * 90ms + 180ms);
- background-color: ${color('white')};
- border: 1px solid ${semanticColor.line};
- border-radius: ${radius(2)};
- display: flex;
- flex-direction: column;
+const CardArticle = styled(CardFrame)`
gap: ${spacing(5)};
- isolation: isolate;
padding: ${spacing(6)};
- position: relative;
- transition:
- border-color 0.25s ease,
- box-shadow 0.25s ease,
- transform 0.25s ease;
will-change: transform;
-
- &:hover {
- border-color: ${semanticColor.lineStrong};
- box-shadow: ${SHADOW.card};
- transform: translateY(-2px);
- }
-
- ${REDUCED_MOTION} {
- animation: none;
- transition: none;
-
- &:hover {
- transform: none;
- }
- }
`;
const CardHeader = styled.div`
@@ -207,7 +160,7 @@ type PartnerCardProps = {
export function PartnerCard({ partner, index }: PartnerCardProps) {
const { i18n } = useLingui();
const headingId = `partner-card-heading-${partner.slug}`;
- const style: PartnerCardStyle = { '--partner-card-index': index };
+ const style: PartnerCardIndexStyle = { '--partner-card-index': index };
// Unprefixed; LocalizedLink (the name link) and the Button add the locale.
const profileHref = `/partners/profile/${partner.slug}`;
diff --git a/packages/twenty-website/src/partners-marketplace/PartnerProfileCtas.tsx b/packages/twenty-website/src/partners-marketplace/PartnerProfileCtas.tsx
index 582db72872..bb94d49ca0 100644
--- a/packages/twenty-website/src/partners-marketplace/PartnerProfileCtas.tsx
+++ b/packages/twenty-website/src/partners-marketplace/PartnerProfileCtas.tsx
@@ -86,6 +86,11 @@ export function PartnerProfileCtas({
variant="filled"
/>
)}
+
);
diff --git a/packages/twenty-website/src/platform/http/create-webhook-forwarding-route.ts b/packages/twenty-website/src/platform/http/create-webhook-forwarding-route.ts
new file mode 100644
index 0000000000..32f5abf98e
--- /dev/null
+++ b/packages/twenty-website/src/platform/http/create-webhook-forwarding-route.ts
@@ -0,0 +1,201 @@
+import { NextResponse } from 'next/server';
+import { z } from 'zod';
+
+import { getClientIpKey } from './client-ip-key';
+import { fetchWithTimeout } from './fetch-with-timeout';
+import { createRateLimiter } from './rate-limit';
+import { readJsonBody } from './read-json-body';
+
+// 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;
+
+type WebhookForwardingRouteConfig = {
+ webhookUrlEnvVar: string;
+ secretEnvVar: string;
+ schema: TSchema;
+ buildPayload: (data: z.infer) => object;
+ logTag: string;
+ notConfiguredMessage: string;
+ failureMessage: string;
+};
+
+export function createWebhookForwardingRoute({
+ webhookUrlEnvVar,
+ secretEnvVar,
+ schema,
+ buildPayload,
+ logTag,
+ notConfiguredMessage,
+ failureMessage,
+}: WebhookForwardingRouteConfig) {
+ const checkRateLimit = createRateLimiter({
+ capacity: 5,
+ refillPerSec: 1 / 60,
+ });
+
+ return async function POST(request: Request) {
+ const webhookUrlResult = webhookUrlSchema.safeParse(
+ process.env[webhookUrlEnvVar],
+ );
+ const secretResult = applicationSecretSchema.safeParse(
+ process.env[secretEnvVar],
+ );
+
+ if (!webhookUrlResult.success || !secretResult.success) {
+ const rawWebhookUrl = process.env[webhookUrlEnvVar];
+ const rawSecret = process.env[secretEnvVar];
+ console.error(
+ `[${logTag}] 503 — endpoint env vars failed validation`,
+ JSON.stringify({
+ [webhookUrlEnvVar]: {
+ present: rawWebhookUrl !== undefined,
+ isEmptyAfterTrim: (rawWebhookUrl ?? '').trim() === '',
+ length: (rawWebhookUrl ?? '').length,
+ parseError: webhookUrlResult.success
+ ? null
+ : (webhookUrlResult.error.issues[0]?.message ?? 'unknown'),
+ },
+ [secretEnvVar]: {
+ 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: notConfiguredMessage },
+ { 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 = schema.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 = buildPayload(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(
+ `[${logTag}] upstream fetch failed`,
+ JSON.stringify({
+ error: upstream.error,
+ payloadKeys: Object.keys(payload),
+ }),
+ );
+ return NextResponse.json({ error: failureMessage }, { status });
+ }
+
+ let upstreamBody: string;
+ try {
+ upstreamBody = await upstream.response.text();
+ } catch {
+ upstreamBody = '';
+ }
+
+ if (!upstream.response.ok) {
+ console.error(
+ `[${logTag}] upstream returned non-2xx`,
+ JSON.stringify({
+ status: upstream.response.status,
+ body: upstreamBody.slice(0, 2000),
+ payloadKeys: Object.keys(payload),
+ }),
+ );
+ return NextResponse.json({ error: failureMessage }, { 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(
+ `[${logTag}] logic function returned non-ok result`,
+ JSON.stringify({
+ body: upstreamBody.slice(0, 2000),
+ payloadKeys: Object.keys(payload),
+ }),
+ );
+ return NextResponse.json({ error: failureMessage }, { status: 502 });
+ }
+
+ return NextResponse.json({ success: true });
+ };
+}
diff --git a/packages/twenty-website/src/platform/http/index.ts b/packages/twenty-website/src/platform/http/index.ts
index 266c27d494..cb56d233a9 100644
--- a/packages/twenty-website/src/platform/http/index.ts
+++ b/packages/twenty-website/src/platform/http/index.ts
@@ -2,3 +2,4 @@ export { getClientIpKey } from './client-ip-key';
export { fetchWithTimeout } from './fetch-with-timeout';
export { readJsonBody } from './read-json-body';
export { createRateLimiter } from './rate-limit';
+export { createWebhookForwardingRoute } from './create-webhook-forwarding-route';
diff --git a/packages/twenty-website/src/platform/routing/static-website-routes.ts b/packages/twenty-website/src/platform/routing/static-website-routes.ts
index 3a2f7c9b03..5062661b25 100644
--- a/packages/twenty-website/src/platform/routing/static-website-routes.ts
+++ b/packages/twenty-website/src/platform/routing/static-website-routes.ts
@@ -68,6 +68,15 @@ export const STATIC_WEBSITE_ROUTES: readonly WebsiteRoute[] = [
priority: 0.3,
title: msg`Become a Twenty Partner — Apply`,
},
+ {
+ changeFrequency: 'yearly',
+ description: msg`Submit a project brief and get matched with a certified Twenty partner for migration, customisation, and CRM implementation.`,
+ id: 'partnersBrief',
+ indexed: false,
+ path: '/partners/brief',
+ priority: 0.3,
+ title: msg`Submit a Client Brief — Twenty Partners`,
+ },
{
changeFrequency: 'monthly',
description: msg`Packaged CRMs make every company look the same. Twenty is the open source CRM teams shape around their workflow, with a modern UI and a developer-first platform.`,
diff --git a/packages/twenty-website/src/platform/routing/website-route.ts b/packages/twenty-website/src/platform/routing/website-route.ts
index 6123c96b77..656d73e4c3 100644
--- a/packages/twenty-website/src/platform/routing/website-route.ts
+++ b/packages/twenty-website/src/platform/routing/website-route.ts
@@ -9,6 +9,7 @@ export type WebsiteRouteId =
| 'home'
| 'partners'
| 'partnersApply'
+ | 'partnersBrief'
| 'partnersList'
| 'pricing'
| 'privacyPolicy'
diff --git a/packages/twenty-website/src/sections/pricing-engagement-band/PricingEngagementBand.tsx b/packages/twenty-website/src/sections/pricing-engagement-band/PricingEngagementBand.tsx
index 1154e73db8..ed55d9971e 100644
--- a/packages/twenty-website/src/sections/pricing-engagement-band/PricingEngagementBand.tsx
+++ b/packages/twenty-website/src/sections/pricing-engagement-band/PricingEngagementBand.tsx
@@ -1,138 +1,20 @@
-import { css } from '@linaria/core';
-import { styled } from '@linaria/react';
import { msg } from '@lingui/core/macro';
-import NextImage from 'next/image';
import { getServerI18n } from '@/platform/i18n/get-server-i18n';
-import {
- BREAKPOINT_PX,
- buildSchemeDeclarations,
- color,
- mediaUp,
- radius,
- spacing,
-} from '@/tokens';
-import { Body, Button, Heading, SectionShell } from '@/ui';
-
-const Band = styled.div`
- ${buildSchemeDeclarations('light')}
- background-color: ${color('white')};
- border-radius: ${radius(1)};
- color: ${color('black')};
- overflow: hidden;
- padding: ${spacing(6)} ${spacing(4)} ${spacing(6)} ${spacing(6)};
- position: relative;
-
- ${mediaUp('md')} {
- padding-right: ${spacing(12)};
- }
-`;
-
-const Content = styled.div`
- display: grid;
- grid-template-columns: 1fr;
- position: relative;
- z-index: 1;
-
- & > * + * {
- margin-top: ${spacing(6)};
- }
-
- ${mediaUp('md')} {
- align-items: center;
- column-gap: ${spacing(2)};
- grid-template-columns: fit-content(60%) minmax(0, 1fr);
-
- & > * + * {
- margin-top: 0;
- }
- }
-`;
-
-const Copy = styled.div`
- display: flex;
- flex-direction: column;
-
- & > * + * {
- margin-top: ${spacing(2)};
- }
-`;
-
-const Actions = styled.div`
- display: flex;
- justify-content: flex-start;
-
- ${mediaUp('md')} {
- justify-content: flex-end;
- }
-`;
-
-const OverlayLayer = styled.div`
- bottom: 0;
- left: 50%;
- pointer-events: none;
- position: absolute;
- right: 0;
- top: 0;
- z-index: 0;
-
- ${mediaUp('md')} {
- left: auto;
- width: clamp(252px, 24.5%, 332px);
- }
-`;
-
-const OverlayImageFrame = styled.div`
- inset: 0;
- position: absolute;
-
- ${mediaUp('md')} {
- inset: ${spacing(3)};
- }
-`;
-
-const overlayImageClassName = css`
- object-fit: cover;
- object-position: right center;
-`;
+import { Button, EngagementBand } from '@/ui';
export function PricingEngagementBand() {
const i18n = getServerI18n();
return (
-
-
-
-
-
-
-
-
-
-
- {i18n._(msg`Need help with customization?`)}
-
-
- {i18n._(
- msg`Find the right partner to implement, customize, and tailor Twenty to your team.`,
- )}
-
-
-
-
-
-
-
-
+
+ }
+ />
);
}
diff --git a/packages/twenty-website/src/ui/EngagementBand.tsx b/packages/twenty-website/src/ui/EngagementBand.tsx
new file mode 100644
index 0000000000..44c5b02d33
--- /dev/null
+++ b/packages/twenty-website/src/ui/EngagementBand.tsx
@@ -0,0 +1,144 @@
+import { css } from '@linaria/core';
+import { styled } from '@linaria/react';
+import NextImage from 'next/image';
+import { type ComponentProps, type ReactNode } from 'react';
+
+import {
+ BREAKPOINT_PX,
+ buildSchemeDeclarations,
+ color,
+ mediaUp,
+ radius,
+ spacing,
+} from '@/tokens';
+
+import { Body } from './Body';
+import { Heading } from './Heading';
+import { SectionShell } from './SectionShell';
+
+const Band = styled.div`
+ ${buildSchemeDeclarations('light')}
+ background-color: ${color('white')};
+ border-radius: ${radius(1)};
+ color: ${color('black')};
+ overflow: hidden;
+ padding: ${spacing(6)} ${spacing(4)} ${spacing(6)} ${spacing(6)};
+ position: relative;
+
+ ${mediaUp('md')} {
+ padding-right: ${spacing(12)};
+ }
+`;
+
+const Content = styled.div`
+ display: grid;
+ grid-template-columns: 1fr;
+ position: relative;
+ z-index: 1;
+
+ & > * + * {
+ margin-top: ${spacing(6)};
+ }
+
+ ${mediaUp('md')} {
+ align-items: center;
+ column-gap: ${spacing(2)};
+ grid-template-columns: fit-content(60%) minmax(0, 1fr);
+
+ & > * + * {
+ margin-top: 0;
+ }
+ }
+`;
+
+const Copy = styled.div`
+ display: flex;
+ flex-direction: column;
+
+ & > * + * {
+ margin-top: ${spacing(2)};
+ }
+`;
+
+const Actions = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${spacing(3)};
+ justify-content: flex-start;
+
+ ${mediaUp('md')} {
+ justify-content: flex-end;
+ }
+`;
+
+const OverlayLayer = styled.div`
+ bottom: 0;
+ left: 50%;
+ pointer-events: none;
+ position: absolute;
+ right: 0;
+ top: 0;
+ z-index: 0;
+
+ ${mediaUp('md')} {
+ left: auto;
+ width: clamp(252px, 24.5%, 332px);
+ }
+`;
+
+const OverlayImageFrame = styled.div`
+ inset: 0;
+ position: absolute;
+
+ ${mediaUp('md')} {
+ inset: ${spacing(3)};
+ }
+`;
+
+const overlayImageClassName = css`
+ object-fit: cover;
+ object-position: right center;
+`;
+
+type EngagementBandProps = {
+ heading: string;
+ body: string;
+ actions: ReactNode;
+ rhythm?: ComponentProps['rhythm'];
+};
+
+export function EngagementBand({
+ heading,
+ body,
+ actions,
+ rhythm,
+}: EngagementBandProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {heading}
+
+
+ {body}
+
+
+ {actions}
+
+
+
+ );
+}
diff --git a/packages/twenty-website/src/ui/TextareaField.tsx b/packages/twenty-website/src/ui/TextareaField.tsx
index dc66b4a19a..1ca03554f0 100644
--- a/packages/twenty-website/src/ui/TextareaField.tsx
+++ b/packages/twenty-website/src/ui/TextareaField.tsx
@@ -14,12 +14,14 @@ const Textarea = styled.textarea`
export function TextareaField({
ariaLabel,
+ invalid = false,
name,
onValueChange,
placeholder,
value,
}: {
ariaLabel: string;
+ invalid?: boolean;
name: string;
onValueChange: (value: string) => void;
placeholder?: string;
@@ -27,6 +29,7 @@ export function TextareaField({
}) {
return (