Skip to content

Commit d0e0e27

Browse files
rashadetiennejouan
andauthored
[Website] Partner application wizard + logic-function handover (#21039)
## Summary Replaces the single-screen partner-application modal with a **4-step wizard** on the public form, and points the route's upstream at the new `submit-partner-application` HTTP logic function in the twenty-partners SDK app. After design review, the Expertise step landed on the validated **Category + Skills** model: a small set of *stable* macro categories the partner operates in, plus a *free, semi-structured* Skills field for the concrete things that differentiate them (React, SAP, Shopify, …). Companion PR (partners-app side): #21040 ## ⚠️ Deployment notes Before this can ship to prod, the website worker needs a new env var: - **Add `PARTNER_APPLICATION_SECRET`** to the deploy config at https://github.com/twentyhq/twenty-infra/tree/main/cloudflare/website. Without it the route returns `503` ("Partner application endpoint is not configured."). - The value must **match** the `PARTNER_APPLICATION_SECRET` workspace variable set in the partners workspace UI (Settings → Apps → Twenty Partners → Variables) — that's how the handler authenticates the incoming `X-Application-Secret` header. - `PARTNER_APPLICATION_WEBHOOK_URL` also needs repointing from the TFT webhook to the logic-function URL (`https://partner.twenty.com/s/partner-applications` or equivalent) at the same time. ## Wizard - 4 steps inside `Modal.Root`: **Identity → Profile → Expertise → Commercials**. Step-dot indicator, per-step required-field gating, reset on close. The big serif hero shows **only on step 1**; later steps use the compact `STEP n OF 4 · NAME` strip to reclaim vertical space. - **Profile** captures Type of team (Solo/Agency), LinkedIn, City, Country, Languages. Country uses the searchable Select (placeholder-only label). - **Expertise = Category + Skills + Notes:** - **Category** — multi-select cards over 5 macro categories (`ADVISORY`, `SOLUTIONING`, `DEVELOPMENT`, `HOSTING`, `SUPPORT`), each with a one-line description + examples. (Replaces the old draft `partnerScope` enum; the backend keeps the field name — see #21040.) - **Skills** — free tag input with a clickable suggestion row + keyboard autocomplete (↑/↓/Enter/Esc) and "add your own". Empty by default. - **Notes** — one free textarea (merges the former `workspaceUrl` + `customerReferences`), reviewed manually. - `deploymentExpertise` removed from the form (covered by the Hosting category). - **In-modal success view** on submit ("Thanks, / we'll be in touch!") with a Close button — replaces the old silent close. - Removes the partners-page "Which partner program is right for you?" three-cards section. ## Design-system primitives - **`Form.Select`** — searchable popup whose dropdown is **portaled to `<body>`** (fixed, anchored to the trigger, flips up, height-capped) so the modal's `overflow`/`transform` can't clip it; pointer events are stopped so clicking inside it doesn't dismiss the dialog. - **`Form.TagInput`** — optional `suggestions` prop adds the suggestion row + autocomplete menu (used by Skills); behaviour unchanged when no suggestions are passed. - **`CategoryCardSelect`** — compact multi-select cards. - `Form.MultiSelect`, `Form.Currency`. ## Validation & payload - **Single validation source:** client and server share Zod field schemas (`partner-application-field-schemas.ts`). The reducer validates via those instead of hand-rolled regexes, so client and server agree by construction (e.g. both reject non-TLD URLs). - **Typed request body:** `buildPartnerApplicationRequestBody(state)` returns a typed `PartnerApplicationRequest` (unit-tested); `handleSubmit` just serializes it. - Payload is camelCase matching the logic-function input; `applicationNotes` replaces `workspaceUrl`/`customerReferences`. - Auth: the upstream call carries an `X-Application-Secret` header backed by `PARTNER_APPLICATION_SECRET` (handler-enforced — the SDK's `isAuthRequired` only accepts user-session JWTs, not workspace API keys). The webhook-URL env uses `z.url()` (not `z.httpUrl()`) so `http://localhost:2020/...` dev destinations parse. ## Demo 📹 _Screen recording of the wizard end-to-end (open → walk steps → submit → Partner record lands):_ https://github.com/user-attachments/assets/7458dd86-e3ff-47b5-9878-0eb134ff38e3 ## Tests - **62 passing** across reducer, Zod schema, route, the new payload-builder suite, and Form helper suites. `npx tsc` clean, `nx lint:diff-with-main` clean, Lingui catalogs regenerated (French slots are a follow-up). ## Test plan - [ ] `/partners` → "Become a partner" → wizard opens on Step 1 (full hero) - [ ] Identity: name / work email / company → Next - [ ] Profile: pick **Type of team**; search country ("fra" → France); pick languages → Next (compact header from here on) - [ ] Expertise: select 1+ **Category** cards; add **Skills** (click a suggestion, type one + Enter, drive the ↑/↓ autocomplete); optionally fill **Notes** - [ ] Country dropdown opens without being clipped by the modal, and clicking inside it does **not** close the wizard - [ ] Commercials → Submit → **in-modal "Thanks, we'll be in touch!"**; Network shows POST `/api/partner-application` `200` - [ ] Partner record lands with the chosen categories in `partnerScope`, plus `skills`, `applicationNotes`, `slug` from company, `reviewed: false`, `partnerTier: 'NEW'` - [ ] Re-submit same email + different city → Partner updates; `validationStage`/`reviewed`/`partnerTier` preserved - [ ] Back/Next preserves entered values; Reset on close; mobile single-column / chips wrap --------- Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
1 parent 6a908b7 commit d0e0e27

35 files changed

Lines changed: 3342 additions & 1014 deletions

packages/twenty-website/.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ yarn-error.log*
3030

3131
# local env files
3232
.env
33-
.env*.local
33+
.env*
3434

3535
# vercel
3636
.vercel

packages/twenty-website/src/app/[locale]/partners/components/PartnerThreeCards.tsx

Lines changed: 0 additions & 46 deletions
This file was deleted.

packages/twenty-website/src/app/[locale]/partners/page.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { CaseStudyCatalogPromo } from '@/sections/CaseStudyCatalog';
99
import { Menu, MENU_DATA } from '@/sections/Menu';
1010
import { PartnerSignoff } from '@/app/[locale]/partners/components/PartnerSignoff';
1111
import { PartnerTestimonials } from '@/app/[locale]/partners/components/PartnerTestimonials';
12-
import { PartnerThreeCards } from '@/app/[locale]/partners/components/PartnerThreeCards';
1312
import { theme } from '@/theme';
1413
import { buildRouteMetadata } from '@/lib/seo';
1514
import { styled } from '@linaria/react';
@@ -58,8 +57,6 @@ export default async function PartnerPage({ params }: PartnerPageProps) {
5857
/>
5958
</PromoSpacing>
6059

61-
<PartnerThreeCards />
62-
6360
<PartnerTestimonials />
6461

6562
<PartnerSignoff />

packages/twenty-website/src/app/[locale]/partners/three-cards-illustration.data.ts

Lines changed: 0 additions & 56 deletions
This file was deleted.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import {
2+
buildLogicFunctionPayload,
3+
partnerApplicationRequestSchema,
4+
} from '@/app/api/partner-application/partner-application-schema';
5+
6+
const minimalValid = {
7+
name: 'Ada Lovelace',
8+
email: 'ada@example.com',
9+
company: 'Analytical Engines Ltd',
10+
};
11+
12+
const fullValid = {
13+
...minimalValid,
14+
website: 'https://analyticalengines.example',
15+
linkedin: 'https://www.linkedin.com/in/ada',
16+
city: 'London',
17+
country: 'UNITED_KINGDOM',
18+
languages: ['ENGLISH', 'FRENCH'],
19+
typeOfTeam: 'SOLO',
20+
partnerScope: ['ADVISORY', 'SOLUTIONING'],
21+
skills: ['React', 'TypeScript'],
22+
applicationNotes:
23+
'Workspace https://app.twenty.com/ws/ada · refs: Acme, Globex',
24+
hourlyRate: 150,
25+
projectBudgetMin: 5000,
26+
calendarLink: 'https://cal.com/ada',
27+
};
28+
29+
describe('partnerApplicationRequestSchema', () => {
30+
it('accepts the minimal required payload', () => {
31+
const parsed = partnerApplicationRequestSchema.safeParse(minimalValid);
32+
expect(parsed.success).toBe(true);
33+
});
34+
35+
it('accepts the full payload', () => {
36+
const parsed = partnerApplicationRequestSchema.safeParse(fullValid);
37+
expect(parsed.success).toBe(true);
38+
});
39+
40+
it('rejects an unknown country enum value', () => {
41+
const parsed = partnerApplicationRequestSchema.safeParse({
42+
...minimalValid,
43+
country: 'ATLANTIS',
44+
});
45+
expect(parsed.success).toBe(false);
46+
});
47+
48+
it('rejects an unknown scope enum value', () => {
49+
const parsed = partnerApplicationRequestSchema.safeParse({
50+
...minimalValid,
51+
partnerScope: ['NOPE'],
52+
});
53+
expect(parsed.success).toBe(false);
54+
});
55+
56+
it('rejects a legacy scope enum value', () => {
57+
const parsed = partnerApplicationRequestSchema.safeParse({
58+
...minimalValid,
59+
partnerScope: ['APPS'],
60+
});
61+
expect(parsed.success).toBe(false);
62+
});
63+
64+
it('forwards applicationNotes through to the payload', () => {
65+
const payload = buildLogicFunctionPayload(fullValid as never);
66+
expect(payload.applicationNotes).toContain('Acme');
67+
});
68+
69+
it('rejects the removed deploymentExpertise key (strictObject)', () => {
70+
const parsed = partnerApplicationRequestSchema.safeParse({
71+
...minimalValid,
72+
deploymentExpertise: ['CLOUD'],
73+
});
74+
expect(parsed.success).toBe(false);
75+
});
76+
77+
it('rejects unknown top-level keys (strictObject)', () => {
78+
const parsed = partnerApplicationRequestSchema.safeParse({
79+
...minimalValid,
80+
countryOther: 'Republic of Examples',
81+
});
82+
expect(parsed.success).toBe(false);
83+
});
84+
85+
it('rejects an invalid email', () => {
86+
const parsed = partnerApplicationRequestSchema.safeParse({
87+
...minimalValid,
88+
email: 'not-an-email',
89+
});
90+
expect(parsed.success).toBe(false);
91+
});
92+
});
93+
94+
describe('buildLogicFunctionPayload', () => {
95+
it('splits firstName/lastName from name and uses camelCase keys', () => {
96+
const payload = buildLogicFunctionPayload(fullValid as never);
97+
expect(payload.firstName).toBe('Ada');
98+
expect(payload.lastName).toBe('Lovelace');
99+
expect(payload.email).toBe('ada@example.com');
100+
expect(payload.companyName).toBe('Analytical Engines Ltd');
101+
expect(payload.hourlyRate).toBe(150);
102+
expect((payload as Record<string, unknown>).CurrencyCode).toBeUndefined();
103+
});
104+
105+
it('omits keys for undefined optional fields', () => {
106+
const payload = buildLogicFunctionPayload(minimalValid as never);
107+
expect('linkedin' in payload).toBe(false);
108+
expect('country' in payload).toBe(false);
109+
expect('languages' in payload).toBe(false);
110+
expect('partnerScope' in payload).toBe(false);
111+
expect('domainName' in payload).toBe(false);
112+
});
113+
114+
it('forwards website as domainName when provided', () => {
115+
const payload = buildLogicFunctionPayload(fullValid as never);
116+
expect(payload.domainName).toBe('https://analyticalengines.example');
117+
});
118+
});

0 commit comments

Comments
 (0)