diff --git a/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/ClientBriefPageContent.tsx b/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/ClientBriefPageContent.tsx index 738ea3e644..cf5c6a6cd1 100644 --- a/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/ClientBriefPageContent.tsx +++ b/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/ClientBriefPageContent.tsx @@ -25,11 +25,15 @@ const BriefContainer = styled.div` } `; -export function ClientBriefPageContent() { +export function ClientBriefPageContent({ + partnerSlug, +}: { + partnerSlug?: string; +}) { return ( - + ); diff --git a/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/page.tsx b/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/page.tsx index 3b3c15f78d..28c101c2f5 100644 --- a/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/page.tsx +++ b/packages/twenty-website/src/app/[locale]/(focused)/partners/brief/page.tsx @@ -1,3 +1,4 @@ +import { normalizePartnerSlug } from '@/client-brief/normalize-partner-slug'; import { getRouteI18n, type LocaleRouteParams, @@ -10,10 +11,13 @@ export const generateMetadata = buildRouteMetadata('partnersBrief'); export default async function ClientBriefPage({ params, + searchParams, }: { params: Promise; + searchParams: Promise<{ partner?: string | string[] }>; }) { await getRouteI18n(params); + const { partner } = await searchParams; - return ; + return ; } diff --git a/packages/twenty-website/src/client-brief/build-client-brief-request-body.test.ts b/packages/twenty-website/src/client-brief/build-client-brief-request-body.test.ts new file mode 100644 index 0000000000..95a60f764a --- /dev/null +++ b/packages/twenty-website/src/client-brief/build-client-brief-request-body.test.ts @@ -0,0 +1,23 @@ +import { buildClientBriefRequestBody } from './build-client-brief-request-body'; +import { INITIAL_CLIENT_BRIEF_STATE } from './client-brief-state'; + +const state = { + ...INITIAL_CLIENT_BRIEF_STATE, + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@acme.com', + companyName: 'Acme Real Estate', + need: 'Migrate from HubSpot', +}; + +describe('buildClientBriefRequestBody', () => { + it('puts the referring partner slug on the wire', () => { + expect( + buildClientBriefRequestBody(state, 'acme-consulting').partnerSlug, + ).toBe('acme-consulting'); + }); + + it('omits the key entirely when no slug is given', () => { + expect('partnerSlug' in buildClientBriefRequestBody(state)).toBe(false); + }); +}); diff --git a/packages/twenty-website/src/client-brief/build-client-brief-request-body.ts b/packages/twenty-website/src/client-brief/build-client-brief-request-body.ts index e2058e6f19..c4dccfd6cd 100644 --- a/packages/twenty-website/src/client-brief/build-client-brief-request-body.ts +++ b/packages/twenty-website/src/client-brief/build-client-brief-request-body.ts @@ -10,6 +10,7 @@ function splitLanguages(value: string): string[] { export function buildClientBriefRequestBody( state: ClientBriefState, + partnerSlug?: string, ): ClientBriefRequest { const body: ClientBriefRequest = { firstName: state.firstName.trim(), @@ -27,6 +28,7 @@ export function buildClientBriefRequestBody( 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(); + if (partnerSlug) body.partnerSlug = partnerSlug; return body; } diff --git a/packages/twenty-website/src/client-brief/client-brief-request-schema.test.ts b/packages/twenty-website/src/client-brief/client-brief-request-schema.test.ts index 2f5ca6e0e4..53dd429305 100644 --- a/packages/twenty-website/src/client-brief/client-brief-request-schema.test.ts +++ b/packages/twenty-website/src/client-brief/client-brief-request-schema.test.ts @@ -68,4 +68,22 @@ describe('clientBriefRequestSchema', () => { .success, ).toBe(false); }); + + it('accepts a payload carrying a referring partner slug', () => { + expect( + clientBriefRequestSchema.safeParse({ + ...minimalValid, + partnerSlug: 'acme-consulting', + }).success, + ).toBe(true); + }); + + it('rejects a malformed partner slug', () => { + expect( + clientBriefRequestSchema.safeParse({ + ...minimalValid, + partnerSlug: 'Acme Consulting!', + }).success, + ).toBe(false); + }); }); diff --git a/packages/twenty-website/src/client-brief/client-brief-request-schema.ts b/packages/twenty-website/src/client-brief/client-brief-request-schema.ts index 844850c199..be7ab9fca8 100644 --- a/packages/twenty-website/src/client-brief/client-brief-request-schema.ts +++ b/packages/twenty-website/src/client-brief/client-brief-request-schema.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { emailFieldSchema } from '@/partner-application/email-field-schema'; import { CLIENT_BRIEF_HOSTING_TYPES } from './data/hosting-type-values'; +import { partnerSlugSchema } from './partner-slug-schema'; const optionalNonEmptyString = z.string().trim().min(1).optional(); @@ -19,6 +20,7 @@ export const clientBriefRequestSchema = z.strictObject({ seatCount: optionalNonEmptyString, timeline: optionalNonEmptyString, budgetRange: optionalNonEmptyString, + partnerSlug: partnerSlugSchema.optional(), }); export type ClientBriefRequest = z.infer; diff --git a/packages/twenty-website/src/client-brief/normalize-partner-slug.test.ts b/packages/twenty-website/src/client-brief/normalize-partner-slug.test.ts new file mode 100644 index 0000000000..a85bf6abc6 --- /dev/null +++ b/packages/twenty-website/src/client-brief/normalize-partner-slug.test.ts @@ -0,0 +1,29 @@ +import { normalizePartnerSlug } from './normalize-partner-slug'; + +describe('normalizePartnerSlug', () => { + it('returns a well-formed slug unchanged', () => { + expect(normalizePartnerSlug('acme-consulting')).toBe('acme-consulting'); + }); + + it('returns undefined when the param is absent', () => { + expect(normalizePartnerSlug(undefined)).toBeUndefined(); + }); + + it('returns undefined for an empty string', () => { + expect(normalizePartnerSlug('')).toBeUndefined(); + }); + + it('drops a slug with unsupported characters instead of forwarding it', () => { + expect(normalizePartnerSlug('Acme Consulting!')).toBeUndefined(); + }); + + it('drops an over-long slug', () => { + expect(normalizePartnerSlug('a'.repeat(101))).toBeUndefined(); + }); + + it('takes the first value when the param is repeated', () => { + expect(normalizePartnerSlug(['acme-consulting', 'other'])).toBe( + 'acme-consulting', + ); + }); +}); diff --git a/packages/twenty-website/src/client-brief/normalize-partner-slug.ts b/packages/twenty-website/src/client-brief/normalize-partner-slug.ts new file mode 100644 index 0000000000..0156aa7f2d --- /dev/null +++ b/packages/twenty-website/src/client-brief/normalize-partner-slug.ts @@ -0,0 +1,9 @@ +import { partnerSlugSchema } from './partner-slug-schema'; + +// The API schema is strict: a malformed value must be dropped here, not +// forwarded, or it fails the whole brief submission. +export function normalizePartnerSlug( + raw: string | string[] | undefined, +): string | undefined { + return partnerSlugSchema.safeParse(Array.isArray(raw) ? raw[0] : raw).data; +} diff --git a/packages/twenty-website/src/client-brief/partner-slug-schema.ts b/packages/twenty-website/src/client-brief/partner-slug-schema.ts new file mode 100644 index 0000000000..47f4bf2344 --- /dev/null +++ b/packages/twenty-website/src/client-brief/partner-slug-schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +// Mirrors the backend `slugify` output. Shared by the request schema and +// normalizePartnerSlug so the rule has one owner. +export const partnerSlugSchema = z + .string() + .trim() + .regex(/^[a-z0-9-]+$/) + .max(100); diff --git a/packages/twenty-website/src/client-brief/wizard/ClientBriefWizard.tsx b/packages/twenty-website/src/client-brief/wizard/ClientBriefWizard.tsx index d947b0cbfc..004955c669 100644 --- a/packages/twenty-website/src/client-brief/wizard/ClientBriefWizard.tsx +++ b/packages/twenty-website/src/client-brief/wizard/ClientBriefWizard.tsx @@ -135,7 +135,7 @@ function StepRenderer({ controller }: { controller: ClientBriefController }) { } } -export function ClientBriefWizard() { +export function ClientBriefWizard({ partnerSlug }: { partnerSlug?: string }) { const { i18n } = useLingui(); const controller = useClientBriefState(); const { @@ -176,7 +176,7 @@ export function ClientBriefWizard() { return; } - const payload = buildClientBriefRequestBody(state); + const payload = buildClientBriefRequestBody(state, partnerSlug); setSubmitting(true); try { const response = await fetch('/api/client-brief', { @@ -199,6 +199,7 @@ export function ClientBriefWizard() { goNext, i18n, isLastStep, + partnerSlug, setFieldErrors, setSubmitError, setSubmitted, diff --git a/packages/twenty-website/src/partners-marketplace/PartnerProfile.tsx b/packages/twenty-website/src/partners-marketplace/PartnerProfile.tsx index 34bca7b141..0b5b7e40a3 100644 --- a/packages/twenty-website/src/partners-marketplace/PartnerProfile.tsx +++ b/packages/twenty-website/src/partners-marketplace/PartnerProfile.tsx @@ -144,6 +144,7 @@ export function PartnerProfile({ partner }: { partner: MarketplacePartner }) { calendarLink={partner.calendarLink} links={partner.links} linkUrls={partner.linkUrls} + slug={partner.slug} />