Client brief wizard — /partners/brief (#22291)

## 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.
This commit is contained in:
Rashad Karanouh
2026-07-17 19:13:16 +02:00
committed by GitHub
parent 0d13db1d9c
commit ccbd3b6c46
44 changed files with 2195 additions and 387 deletions
+5
View File
@@ -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=
@@ -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 (
<BriefBackground data-scheme="dark">
<BriefContainer>
<ClientBriefWizard />
</BriefContainer>
</BriefBackground>
);
}
@@ -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<LocaleRouteParams>;
}) {
await getRouteI18n(params);
return <ClientBriefPageContent />;
}
@@ -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<Response>,
count: number,
): Promise<number[]> {
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);
});
});
@@ -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.',
});
@@ -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<unknown>(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<string, unknown>)['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.',
});
@@ -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;
}
@@ -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;
}
@@ -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`,
},
};
@@ -0,0 +1,194 @@
import { clientBriefReducer } from './client-brief-reducer';
import {
INITIAL_CLIENT_BRIEF_STATE,
type ClientBriefState,
} from './client-brief-state';
const baseValidBrief: Partial<ClientBriefState> = {
need: 'Migrate from HubSpot to Twenty',
};
const baseValidIdentity: Partial<ClientBriefState> = {
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,
);
});
});
@@ -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<Record<string, string>>,
field: string,
): Partial<Record<string, string>> {
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;
}
}
@@ -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);
});
});
@@ -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<typeof clientBriefRequestSchema>;
@@ -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<Record<string, string>>;
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<Record<string, string>> }
| { 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,
};
@@ -0,0 +1,7 @@
export type ClientBriefStepId = 'brief' | 'context' | 'identity';
export const CLIENT_BRIEF_STEP_IDS: readonly ClientBriefStepId[] = [
'brief',
'context',
'identity',
];
@@ -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,
}));
@@ -0,0 +1,4 @@
export const CLIENT_BRIEF_HOSTING_TYPES = ['CLOUD', 'SELF_HOSTING'] as const;
export type ClientBriefHostingType =
(typeof CLIENT_BRIEF_HOSTING_TYPES)[number];
@@ -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];
}
@@ -0,0 +1 @@
export { ClientBriefWizard } from './wizard/ClientBriefWizard';
@@ -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<Record<string, string>>) =>
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<typeof useClientBriefState>;
@@ -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<Record<string, string>> {
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<Record<string, string>> = {};
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;
}
@@ -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 (
<>
<Heading as="h2" size="lg" weight="light">
{i18n._(CLIENT_BRIEF_COPY.successTitle)}
</Heading>
<SuccessView>
<Body muted size="md">
{i18n._(CLIENT_BRIEF_COPY.successBody)}
</Body>
</SuccessView>
</>
);
}
@@ -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 <BriefStep controller={controller} />;
case 'context':
return <ContextStep controller={controller} />;
case 'identity':
return <IdentityStep controller={controller} />;
}
}
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<HTMLFormElement>) => {
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 <ClientBriefSuccess />;
}
const stepLabel = `${i18n._(
COPY.stepProgressLabel(stepIndex + 1, STEPS.length),
)} · ${i18n._(COPY.stepHeaders[stepId])}`;
return (
<WizardRoot>
<TitleBlock>
{stepIndex === 0 ? (
<IntroGroup>
<Heading as="h2" size="lg" weight="light">
{i18n._(COPY.title)}
</Heading>
<Body muted size="md">
{i18n._(COPY.subtitle)}
</Body>
</IntroGroup>
) : null}
<HeaderStrip>
<HeaderLabel>{stepLabel}</HeaderLabel>
<StepIndicator activeStepIndex={stepIndex} stepCount={STEPS.length} />
</HeaderStrip>
</TitleBlock>
<form autoComplete="off" noValidate onSubmit={handleSubmit}>
<FieldsStack>
<StepRenderer controller={controller} />
<Footer>
{state.submitError !== null ? (
<ErrorBanner role="alert">{state.submitError}</ErrorBanner>
) : null}
{hasFieldErrors ? (
<ErrorBanner role="alert">
{i18n._(fieldErrorMessage)}
</ErrorBanner>
) : null}
<FooterControls>
{stepIndex > 0 ? (
<SecondaryButton onClick={goBack} type="button">
{i18n._(COPY.back)}
</SecondaryButton>
) : (
<span />
)}
<FooterActions>
{isContextStep ? (
<SecondaryButton onClick={skipContext} type="button">
{i18n._(COPY.skip)}
</SecondaryButton>
) : null}
<Button
disabled={state.isSubmitting}
label={
isLastStep
? state.isSubmitting
? i18n._(COPY.submitInFlight)
: i18n._(COPY.submit)
: i18n._(COPY.next)
}
type="submit"
variant="filled"
/>
</FooterActions>
</FooterControls>
</Footer>
</FieldsStack>
</form>
</WizardRoot>
);
}
@@ -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 (
<>
<Field label={i18n._(FIELDS.need)}>
<TextareaField
ariaLabel={i18n._(FIELDS.need)}
invalid={state.fieldErrors.need !== undefined}
name="need"
onValueChange={(value) => setField('need', value)}
placeholder={i18n._(FIELDS.needPlaceholder)}
value={state.need}
/>
</Field>
<Field label={i18n._(FIELDS.requirements)}>
<TextareaField
ariaLabel={i18n._(FIELDS.requirements)}
name="requirements"
onValueChange={(value) => setField('requirements', value)}
placeholder={i18n._(FIELDS.requirementsPlaceholder)}
value={state.requirements}
/>
</Field>
</>
);
}
@@ -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 (
<>
<Field label={i18n._(FIELDS.hostingType)}>
<Select
ariaLabel={i18n._(FIELDS.hostingType)}
onValueChange={(value) => setField('hostingType', value)}
options={hostingOptions}
placeholder={i18n._(FIELDS.hostingTypePlaceholder)}
scheme="dark"
value={state.hostingType}
/>
</Field>
<Field label={i18n._(FIELDS.country)}>
<TextField
ariaLabel={i18n._(FIELDS.country)}
name="country"
onValueChange={(value) => setField('country', value)}
placeholder={i18n._(FIELDS.countryPlaceholder)}
value={state.country}
/>
</Field>
<Field label={i18n._(FIELDS.languages)}>
<TextField
ariaLabel={i18n._(FIELDS.languages)}
name="languages"
onValueChange={(value) => setField('languages', value)}
placeholder={i18n._(FIELDS.languagesPlaceholder)}
value={state.languages}
/>
</Field>
<Field label={i18n._(FIELDS.seatCount)}>
<TextField
ariaLabel={i18n._(FIELDS.seatCount)}
name="seatCount"
onValueChange={(value) => setField('seatCount', value)}
placeholder={i18n._(FIELDS.seatCountPlaceholder)}
value={state.seatCount}
/>
</Field>
<Field label={i18n._(FIELDS.timeline)}>
<TextField
ariaLabel={i18n._(FIELDS.timeline)}
name="timeline"
onValueChange={(value) => setField('timeline', value)}
placeholder={i18n._(FIELDS.timelinePlaceholder)}
value={state.timeline}
/>
</Field>
<Field label={i18n._(FIELDS.budgetRange)}>
<TextField
ariaLabel={i18n._(FIELDS.budgetRange)}
name="budgetRange"
onValueChange={(value) => setField('budgetRange', value)}
placeholder={i18n._(FIELDS.budgetRangePlaceholder)}
value={state.budgetRange}
/>
</Field>
</>
);
}
@@ -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 (
<>
<Field label={i18n._(FIELDS.firstName)}>
<TextField
ariaLabel={i18n._(FIELDS.firstName)}
invalid={state.fieldErrors.firstName !== undefined}
name="firstName"
onValueChange={(value) => setField('firstName', value)}
placeholder={i18n._(FIELDS.firstName)}
value={state.firstName}
/>
</Field>
<Field label={i18n._(FIELDS.lastName)}>
<TextField
ariaLabel={i18n._(FIELDS.lastName)}
name="lastName"
onValueChange={(value) => setField('lastName', value)}
placeholder={i18n._(FIELDS.lastName)}
value={state.lastName}
/>
</Field>
<Field label={i18n._(FIELDS.email)}>
<TextField
ariaLabel={i18n._(FIELDS.email)}
inputMode="email"
invalid={state.fieldErrors.email !== undefined}
name="email"
onValueChange={(value) => setField('email', value)}
placeholder={i18n._(FIELDS.email)}
value={state.email}
/>
</Field>
<Field label={i18n._(FIELDS.companyName)}>
<TextField
ariaLabel={i18n._(FIELDS.companyName)}
invalid={state.fieldErrors.companyName !== undefined}
name="companyName"
onValueChange={(value) => setField('companyName', value)}
placeholder={i18n._(FIELDS.companyName)}
value={state.companyName}
/>
</Field>
</>
);
}
@@ -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)};
`;
@@ -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;
`;
@@ -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 (
<EngagementBand
rhythm="section"
heading={i18n._(msg`Didn't find the *right partner*?`)}
body={i18n._(
msg`Tell us what you need and we'll match you with a certified Twenty partner.`,
)}
actions={
<Button href="/partners/brief" label={i18n._(msg`Submit a brief`)} />
}
/>
);
}
@@ -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;
}
}
`;
@@ -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 (
<SectionShell rhythm="section" scheme="light">
<FilterBar
criteria={criteria}
hasAnyFilter={hasAnyFilter}
onClearAll={clearAll}
onToggleCategory={toggleCategory}
onToggleLanguage={toggleLanguage}
onToggleRegion={toggleRegion}
totalCount={partners.length}
visibleCount={filteredPartners.length}
/>
<Results>
{filteredPartners.length > 0 ? (
<>
<SectionShell rhythm="section" scheme="light">
<FilterBar
criteria={criteria}
hasAnyFilter={hasAnyFilter}
onClearAll={clearAll}
onToggleCategory={toggleCategory}
onToggleLanguage={toggleLanguage}
onToggleRegion={toggleRegion}
totalCount={partners.length}
visibleCount={filteredPartners.length}
/>
<Results>
<MarketplaceGrid partners={filteredPartners} />
) : (
<MarketplaceEmptyState onClearFilters={clearAll} />
)}
</Results>
</SectionShell>
{filteredPartners.length === 0 && partners.length > 0 && (
<MarketplaceEmptyState onClearFilters={clearAll} />
)}
</Results>
</SectionShell>
{partners.length > 0 && <MarketplaceBriefPrompt />}
</>
);
}
@@ -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 (
<CardGrid>
<MarketplaceMatchCard index={0} />
{partners.map((partner, index) => (
<PartnerCard key={partner.slug} partner={partner} index={index} />
<PartnerCard key={partner.slug} partner={partner} index={index + 1} />
))}
</CardGrid>
);
@@ -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() {
</Heading>
<HeaderBody>
<Body muted size="md">
{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.`,
)}
<Trans>
Twenty&apos;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{' '}
<MarketplaceBriefLink href="/partners/brief">
tell us what you need
</MarketplaceBriefLink>{' '}
and we&apos;ll match you.
</Trans>
</Body>
</HeaderBody>
</HeaderStack>
@@ -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 (
<CardArticle style={style}>
<AccentBar aria-hidden />
<TextureLayer aria-hidden>
<TextureScrim />
<NextImage
alt=""
className={textureImageClassName}
fill
sizes={`(min-width: ${BREAKPOINT_PX.md}px) 280px, 50vw`}
src="/images/pricing/engagement-band/halftone-on-white.webp"
/>
</TextureLayer>
<Content>
<Copy>
<Eyebrow>{i18n._(msg`Partner matching`)}</Eyebrow>
<Heading as="h2" size="sm" weight="light">
{i18n._(msg`Let us *match* you`)}
</Heading>
<Body muted size="sm">
{i18n._(
msg`Share your project in a few minutes. We pair you with a certified partner who fits.`,
)}
</Body>
</Copy>
<CtaWrapper>
<Button
href="/partners/brief"
label={i18n._(msg`Get matched`)}
variant="filled"
/>
</CtaWrapper>
</Content>
</CardArticle>
);
}
@@ -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}`;
@@ -86,6 +86,11 @@ export function PartnerProfileCtas({
variant="filled"
/>
)}
<Button
href="/partners/brief"
label={i18n._(msg`Submit a brief`)}
variant="outlined"
/>
</ButtonStack>
</Wrapper>
);
@@ -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<TSchema extends z.ZodType> = {
webhookUrlEnvVar: string;
secretEnvVar: string;
schema: TSchema;
buildPayload: (data: z.infer<TSchema>) => object;
logTag: string;
notConfiguredMessage: string;
failureMessage: string;
};
export function createWebhookForwardingRoute<TSchema extends z.ZodType>({
webhookUrlEnvVar,
secretEnvVar,
schema,
buildPayload,
logTag,
notConfiguredMessage,
failureMessage,
}: WebhookForwardingRouteConfig<TSchema>) {
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<unknown>(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<string, unknown>)['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 });
};
}
@@ -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';
@@ -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.`,
@@ -9,6 +9,7 @@ export type WebsiteRouteId =
| 'home'
| 'partners'
| 'partnersApply'
| 'partnersBrief'
| 'partnersList'
| 'pricing'
| 'privacyPolicy'
@@ -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 (
<SectionShell scheme="muted">
<Band>
<OverlayLayer aria-hidden>
<OverlayImageFrame>
<NextImage
alt=""
className={overlayImageClassName}
fill
sizes={`(min-width: ${BREAKPOINT_PX.md}px) 308px, 50vw`}
src="/images/pricing/engagement-band/halftone-on-white.webp"
/>
</OverlayImageFrame>
</OverlayLayer>
<Content>
<Copy>
<Heading as="h2" size="sm" weight="light">
{i18n._(msg`Need help with customization?`)}
</Heading>
<Body muted size="sm">
{i18n._(
msg`Find the right partner to implement, customize, and tailor Twenty to your team.`,
)}
</Body>
</Copy>
<Actions>
<Button
href="/partners/list"
label={i18n._(msg`Find a partner`)}
variant="outlined"
/>
</Actions>
</Content>
</Band>
</SectionShell>
<EngagementBand
heading={i18n._(msg`Need help with customization?`)}
body={i18n._(
msg`Find the right partner to implement, customize, and tailor Twenty to your team.`,
)}
actions={
<Button href="/partners/list" label={i18n._(msg`Browse partners`)} />
}
/>
);
}
@@ -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<typeof SectionShell>['rhythm'];
};
export function EngagementBand({
heading,
body,
actions,
rhythm,
}: EngagementBandProps) {
return (
<SectionShell rhythm={rhythm} scheme="muted">
<Band>
<OverlayLayer aria-hidden>
<OverlayImageFrame>
<NextImage
alt=""
className={overlayImageClassName}
fill
sizes={`(min-width: ${BREAKPOINT_PX.md}px) 308px, 50vw`}
src="/images/pricing/engagement-band/halftone-on-white.webp"
/>
</OverlayImageFrame>
</OverlayLayer>
<Content>
<Copy>
<Heading as="h2" size="sm" weight="light">
{heading}
</Heading>
<Body muted size="sm">
{body}
</Body>
</Copy>
<Actions>{actions}</Actions>
</Content>
</Band>
</SectionShell>
);
}
@@ -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 (
<Textarea
aria-invalid={invalid ? true : undefined}
aria-label={ariaLabel}
autoComplete="off"
className={fieldControlClassName}
+1
View File
@@ -16,6 +16,7 @@ export { Container } from './Container';
export { ExternalArrow } from './ExternalArrow';
export { ExternalLink } from './ExternalLink';
export { CornerMarkers } from './CornerMarkers';
export { EngagementBand } from './EngagementBand';
export { Eyebrow, type EyebrowProps } from './Eyebrow';
export { IconButton, type IconButtonProps } from './IconButton';
export {