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:
@@ -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=
|
||||
|
||||
+36
@@ -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'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'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'
|
||||
|
||||
+10
-128
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user