Files
twenty/packages/twenty-website-new/src/lib/partner-application/PartnerApplicationModal.tsx
T
Abdullah. 47710908a8 [Website] Architecture, hardening, and perf pass. (#20020)
This PR is the result of a critical architectural review of
`twenty-website-new` and the
follow-on cleanup work. It does not touch any other package. Scope is
everything except
the enterprise/billing routes (deferred to a separate PR) and CI wiring
(also deferred).

### Why

The package had grown organically: ad-hoc scroll/motion/halftone code
per section,
WebGL renderers instantiated raw, sections without a shared shape
contract, route-scoped
files leaking across layers, public API routes without rate limiting /
timeouts /
schema validation, unbounded module-level caches in visual code,
drag/resize handlers
re-rendering React 60x/sec, no security headers, and a Lottie
scroll-mapping that would
silently drift if the asset got re-exported. We needed contracts and
primitives in
place before further work (i18n, decomposition of the giant visual
files, MDX migration
of customer/legal pages) is safe to do.

### What changed

**Layering and contracts (now enforced, not just documented).**
- New layering rule: `app → sections → lib → design-system / theme /
icons`.
Cross-section reuse goes through `lib/`. Flipped the design-system
import rule
  from warn to error.
- Extracted shared primitives into `lib/`: `scroll`, `motion`,
`halftone`, `customers`,
`partner-application`, `api`, `seo`, `semver`, `community`,
`visual-runtime`.
- Lifted route-scoped data/types out of `app/` into `lib/` +
`sections/`.
- Section shape contract: every section exposes a single compound export
from
`components/index.ts(x)`; non-leaf sections own the outer `<section>`
from
`Root.tsx`; named slots are matched by `displayName` (no
`Children.toArray`
  positional indexing). Enforced by `scripts/check-section-shape.mjs`.
- WebGL boundary: `new THREE.WebGLRenderer(...)` is forbidden outside
`src/lib/visual-runtime/`. Everything goes through
`createSiteWebGlRenderer`,
which enforces the site-wide context cap, the
`NEXT_PUBLIC_DISABLE_HEAVY_VISUALS`
  kill switch, and GPU/power-preference defaults. Enforced by
`scripts/check-boundaries.mjs` with per-line
`boundary-allow-next-line:<rule-id>`
  escape hatches and stale-directive detection.

**Design system grew to cover real cases.**
- Added Modal, Form, and Layout primitives (Stack / Inline / Grid) so
sections stop
reinventing them. Built on `@base-ui/react` for accessibility + focus
management.

**Public API hardening (non-enterprise).**
- New `lib/api/` primitives: `createRateLimiter` (in-memory token
bucket),
`fetchWithTimeout`, `readJsonBody` (Zod-validated). Applied to
newsletter,
community, and partner-application routes. `/api/partner-application`
specifically
  got a per-IP rate limit, body cap, and timeout.

**Performance.**
- `DraggableTerminal` and `DraggableAppWindow`: `pointermove` now
mutates `transform`
(via `translate3d`) and `width`/`height` directly on the DOM ref. React
state
commits only on `pointerup`. Eliminates per-frame re-renders during
interaction.
- `createBoundedFailureCache` (FIFO, 256 entries) replaces unbounded
module-level
failure caches in four visual components. Bounds memory growth from bad
asset URLs.

**Lottie frame-map guard.**
- `dotlottie-react`'s `player.totalFrames` returns a raw float (`op -
ip`), not an
integer. The HomeStepper scroll → frame map is keyed to the authored
timeline,
  so silent drift would desync every step boundary.
- Reads now `Math.floor(player.totalFrames)` consistently.
- `scripts/check-lottie-frames.mjs` extracts `op - ip` from
`public/lottie/stepper/stepper.lottie` at build time and asserts it
against
`HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES`. If anyone re-exports the
Lottie,
the build fails until both that constant and every `STEP_*_END` are
updated together.

**Security headers (`next.config.ts`).**
- HSTS, `X-Content-Type-Options: nosniff`, `Referrer-Policy:
strict-origin-when-cross-origin`,
`Permissions-Policy` (camera/mic/geolocation/payment off),
`X-Frame-Options: DENY`,
  `Content-Security-Policy: frame-ancestors 'none'`.
- Full CSP intentionally deferred until we enumerate all third-party
origins
  (Cal.com, Stripe, GitHub avatars, twenty-icons.com, etc.).

**Build / config quirks documented in code.**
- `tsconfig.json` is standalone (does NOT extend the monorepo base) —
Next.js +
  React Compiler require options that conflict with the base config.
- `sharp` moved from `devDependencies` to `dependencies` so production
image
  optimization works.

### What's deliberately NOT in this PR

- **Enterprise / billing routes** — open redirect on
`/api/enterprise/checkout`,
indefinite-bearer JWT, non-idempotent Stripe seat updates, unpinned
Stripe
`apiVersion`, missing webhook reconciliation, inconsistent error
envelope.
  Going out as a separate, security-focused PR.
- **CI workflow for `twenty-website-new`** (`lint` / `typecheck` /
`test` / `build`
  targets) — separate follow-up PR.
- **i18n via Lingui** — decision made (we need internationalization and
we already
use Lingui in `twenty-front` / `twenty-emails`); 4-phase migration plan
exists
  but does not land here.
- **Decomposition of giant visual files** (HomeVisual, ThreeCards
visuals) — blocked
  on the i18n landing first; otherwise we'd rebase the world twice.
- **Customer / legal pages → MDX** — same reason.
- **Selective memoization pass** — needs browser profiling, not blind
`useMemo`.
- **Pre-existing lint errors / typecheck noise** (~44 errors, ~41
warnings, plus
generated Next.js types and `@ts-nocheck` files) are unchanged. The
cleanup
  did not introduce new ones.

### Test plan

- [ ] `yarn install`
- [ ] `yarn nx run twenty-website-new:dev` — homepage, customers,
partner,
enterprise activate, blog, why-twenty, plans/pricing, legal pages
render.
- [ ] HomeStepper: scroll through, confirm the Lottie animation lines up
with
every step boundary. Console must NOT log a `totalFrames` mismatch.
- [ ] HomeVisual: drag and resize the terminal + app window; verify
smoothness
(no per-frame React re-renders) and that final position/size persists on
release.
- [ ] Public API endpoints: hit `/api/newsletter`, `/api/community`,
`/api/partner-application` with bad payloads → expect 4xx with Zod
errors,
not 500s. Hammer `/api/partner-application` past the per-IP limit → 429.
- [ ] Response headers on any page include HSTS, nosniff, referrer
policy,
permissions policy, X-Frame-Options, and `frame-ancestors 'none'` CSP.
- [ ] `yarn nx run twenty-website-new:lint` — error/warning count must
not exceed
      the pre-existing baseline.
- [ ] `yarn nx run twenty-website-new:typecheck` — same baseline rule.
- [ ] `node packages/twenty-website-new/scripts/check-boundaries.mjs` —
passes,
      no stale directives.
- [ ] `node packages/twenty-website-new/scripts/check-section-shape.mjs`
— passes.
- [ ] `node packages/twenty-website-new/scripts/check-lottie-frames.mjs`
— passes.
- [ ] `yarn nx run twenty-website-new:build` — green, including the
three checks
      above if wired into the build target.
2026-04-24 16:40:09 +00:00

576 lines
15 KiB
TypeScript

'use client';
import { Body, Form, Heading, Modal } from '@/design-system/components';
import {
BUTTON_HEIGHTS_PX,
buttonBaseStyles,
} from '@/design-system/components/Button/BaseButton';
import { ButtonShape } from '@/design-system/components/Button/ButtonShape';
import {
PARTNER_APPLICATION_MODAL_COPY,
PARTNER_PROGRAM_OPTIONS,
type PartnerProgramId,
} from '@/lib/partner-application/partner-application-modal-data';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import { IconChevronDown } from '@tabler/icons-react';
import { useCallback, useEffect, useRef, useState } from 'react';
const partnerPanelClass = css`
--modal-panel-width: min(360px, 100%);
@media (min-width: ${theme.breakpoints.md}px) {
--modal-panel-width: min(902px, 100%);
}
`;
const TitleBlock = styled.div`
display: flex;
flex-direction: column;
gap: clamp(8px, 2vh, 24px);
`;
const TitleHeadingWrapper = styled.div`
color: ${theme.colors.secondary.text[100]};
`;
const SubtitleStack = styled.div`
color: ${theme.colors.secondary.text[60]};
display: flex;
flex-direction: column;
gap: 0;
`;
const Segments = styled.div`
display: none;
gap: ${theme.spacing(4)};
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
display: flex;
}
`;
const SegmentButton = styled.button`
align-items: center;
background: rgba(255, 255, 255, 0.1);
border: 1px solid ${theme.colors.secondary.border[10]};
border-radius: ${theme.radius(2)};
box-sizing: border-box;
color: ${theme.colors.secondary.text[100]};
cursor: pointer;
display: flex;
flex: 1 1 0;
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(3.5)};
font-weight: ${theme.font.weight.regular};
height: clamp(40px, 5.5vh, 56px);
justify-content: center;
line-height: ${theme.lineHeight(3.5)};
min-width: 0;
padding-left: ${theme.spacing(2)};
padding-right: ${theme.spacing(2)};
&[data-active='true'] {
background: ${theme.colors.primary.background[100]};
color: ${theme.colors.primary.text[100]};
}
&:focus-visible {
outline: 2px solid ${theme.colors.highlight[100]};
outline-offset: 2px;
}
`;
const MobileProgramField = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing(2)};
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
display: none;
}
`;
const DropdownRoot = styled.div`
position: relative;
width: 100%;
`;
const DropdownTrigger = styled.button`
align-items: center;
background: ${theme.colors.secondary.background[100]};
border: 1px solid ${theme.colors.highlight[100]};
border-radius: ${theme.radius(2)};
box-sizing: border-box;
color: ${theme.colors.secondary.text[100]};
cursor: pointer;
display: flex;
font-family: ${theme.font.family.sans};
height: clamp(40px, 5.5vh, 56px);
justify-content: space-between;
padding-left: ${theme.spacing(3)};
padding-right: ${theme.spacing(1)};
width: 100%;
&:focus-visible {
outline: 2px solid ${theme.colors.highlight[100]};
outline-offset: 2px;
}
`;
const DropdownTriggerContent = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
`;
const DropdownLabel = styled.span`
color: ${theme.colors.secondary.text[40]};
font-size: ${theme.font.size(2.5)};
font-weight: ${theme.font.weight.regular};
line-height: ${theme.lineHeight(4)};
text-align: left;
`;
const DropdownValue = styled.span`
font-size: ${theme.font.size(4)};
font-weight: ${theme.font.weight.regular};
line-height: ${theme.lineHeight(5.5)};
text-align: left;
`;
const DropdownIconContainer = styled.span`
align-items: center;
display: flex;
flex-shrink: 0;
height: 48px;
justify-content: center;
width: 48px;
`;
const DropdownPanel = styled.div`
background: ${theme.colors.secondary.background[100]};
border: 1px solid ${theme.colors.highlight[100]};
border-radius: ${theme.radius(2)};
box-shadow: 0 0 16px 0 rgba(15, 15, 15, 0.25);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
left: 0;
margin-top: ${theme.spacing(2)};
overflow: hidden;
padding: ${theme.spacing(1)};
position: absolute;
right: 0;
z-index: 1;
`;
const DropdownOption = styled.button`
align-items: center;
background: transparent;
border: none;
border-radius: ${theme.radius(1)};
box-sizing: border-box;
color: ${theme.colors.secondary.text[100]};
cursor: pointer;
display: flex;
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(4)};
font-weight: ${theme.font.weight.regular};
line-height: ${theme.lineHeight(5.5)};
padding-bottom: ${theme.spacing(3)};
padding-left: ${theme.spacing(2)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(3)};
text-align: left;
width: 100%;
&[data-selected='true'] {
background: rgba(74, 56, 245, 0.3);
}
&:hover {
background: rgba(74, 56, 245, 0.15);
}
&[data-selected='true']:hover {
background: rgba(74, 56, 245, 0.3);
}
&:focus-visible {
outline: 2px solid ${theme.colors.highlight[100]};
outline-offset: -2px;
}
`;
const FieldRow = styled.div`
display: flex;
flex-direction: column;
gap: clamp(8px, 1.5vh, 16px);
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
flex-direction: row;
gap: ${theme.spacing(6)};
}
`;
const SubmitError = styled.p`
color: #ff9a9a;
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.regular};
line-height: ${theme.lineHeight(3.5)};
margin: 0;
`;
const SubmitButton = styled.button`
${buttonBaseStyles}
position: relative;
width: 100%;
&:disabled {
cursor: not-allowed;
opacity: 0.65;
}
`;
const SubmitLabel = styled.span`
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
position: relative;
text-transform: uppercase;
z-index: 1;
`;
const FormFields = styled.div`
display: flex;
flex-direction: column;
gap: clamp(8px, 1.5vh, 16px);
`;
type PartnerApplicationModalProps = {
open: boolean;
onClose: () => void;
initialProgramId?: PartnerProgramId;
};
export function PartnerApplicationModal({
open,
onClose,
initialProgramId = 'technology',
}: PartnerApplicationModalProps) {
const formRef = useRef<HTMLFormElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [programId, setProgramId] =
useState<PartnerProgramId>(initialProgramId);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (open) {
setProgramId(initialProgramId);
}
}, [open, initialProgramId]);
useEffect(() => {
if (open) {
setSubmitError(null);
return;
}
formRef.current?.reset();
setProgramId('technology');
setDropdownOpen(false);
setSubmitError(null);
setIsSubmitting(false);
}, [open]);
const handleDropdownBlur = useCallback(
(event: React.FocusEvent<HTMLDivElement>) => {
if (!dropdownRef.current?.contains(event.relatedTarget as Node)) {
setDropdownOpen(false);
}
},
[],
);
const handleSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitting) {
return;
}
const formData = new FormData(event.currentTarget);
const nameValue = formData.get('name');
const emailValue = formData.get('email');
const companyValue = formData.get('company');
const websiteValue = formData.get('website');
const messageValue = formData.get('message');
const opportunitiesValue = formData.get('opportunities');
const name = typeof nameValue === 'string' ? nameValue.trim() : '';
const email = typeof emailValue === 'string' ? emailValue.trim() : '';
const company =
typeof companyValue === 'string' ? companyValue.trim() : '';
const website =
typeof websiteValue === 'string' ? websiteValue.trim() : '';
const message =
typeof messageValue === 'string' ? messageValue.trim() : '';
const opportunities =
typeof opportunitiesValue === 'string' ? opportunitiesValue.trim() : '';
const validationCopy = PARTNER_APPLICATION_MODAL_COPY.validation;
const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
setSubmitError(null);
if (!name || !email || !company || !website || !message) {
setSubmitError(validationCopy.incompleteForm);
return;
}
if (!emailLooksValid) {
setSubmitError(validationCopy.invalidEmail);
return;
}
setIsSubmitting(true);
try {
const response = await fetch('/api/partner-application', {
body: JSON.stringify({
email,
name,
company,
website,
message,
programId,
...(opportunities !== '' && { opportunities }),
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
setSubmitError(validationCopy.submitFailed);
return;
}
onClose();
} catch {
setSubmitError(validationCopy.submitFailed);
} finally {
setIsSubmitting(false);
}
},
[isSubmitting, onClose, programId],
);
const copy = PARTNER_APPLICATION_MODAL_COPY;
return (
<Modal.Root
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
className={partnerPanelClass}
>
<TitleBlock>
<Modal.Title
render={
<TitleHeadingWrapper>
<Heading
as="h2"
segments={[
{
fontFamily: 'serif',
text: copy.titleSerif,
fontWeight: 'light',
},
{
fontFamily: 'sans',
text: copy.titleSans,
fontWeight: 'light',
newLine: true,
},
]}
size="lg"
weight="light"
/>
</TitleHeadingWrapper>
}
/>
<Modal.Description
render={
<SubtitleStack>
<Body body={{ text: copy.subtitleLine1 }} size="md" />
<Body body={{ text: copy.subtitleLine2 }} size="md" />
</SubtitleStack>
}
/>
</TitleBlock>
<form ref={formRef} autoComplete="off" noValidate onSubmit={handleSubmit}>
<FormFields>
<Segments role="radiogroup" aria-label={copy.selectLabel}>
{PARTNER_PROGRAM_OPTIONS.map((option) => (
<SegmentButton
key={option.id}
aria-checked={programId === option.id}
data-active={programId === option.id}
role="radio"
type="button"
onClick={() => {
setProgramId(option.id);
}}
>
{option.label}
</SegmentButton>
))}
</Segments>
<MobileProgramField>
<DropdownRoot ref={dropdownRef} onBlur={handleDropdownBlur}>
<DropdownTrigger
aria-expanded={dropdownOpen}
aria-haspopup="listbox"
aria-label={copy.selectLabel}
type="button"
onClick={() => {
setDropdownOpen((previous) => !previous);
}}
>
<DropdownTriggerContent>
<DropdownLabel>{copy.selectLabel}</DropdownLabel>
<DropdownValue>
{
PARTNER_PROGRAM_OPTIONS.find(
(option) => option.id === programId,
)?.label
}
</DropdownValue>
</DropdownTriggerContent>
<DropdownIconContainer aria-hidden>
<IconChevronDown size={20} stroke={1.5} />
</DropdownIconContainer>
</DropdownTrigger>
{dropdownOpen && (
<DropdownPanel role="listbox" aria-label={copy.selectLabel}>
{PARTNER_PROGRAM_OPTIONS.map((option) => (
<DropdownOption
key={option.id}
aria-selected={programId === option.id}
data-selected={programId === option.id}
role="option"
type="button"
onClick={() => {
setProgramId(option.id);
setDropdownOpen(false);
}}
>
{option.label}
</DropdownOption>
))}
</DropdownPanel>
)}
</DropdownRoot>
</MobileProgramField>
<Form.Field>
<Form.Input
aria-required="true"
autoComplete="off"
name="name"
placeholder={copy.fields.name}
type="text"
/>
</Form.Field>
<FieldRow>
<Form.Field>
<Form.Input
aria-required="true"
autoComplete="off"
inputMode="email"
name="email"
placeholder={copy.fields.email}
type="text"
/>
</Form.Field>
<Form.Field>
<Form.Input
aria-required="true"
autoComplete="off"
name="company"
placeholder={copy.fields.company}
type="text"
/>
</Form.Field>
</FieldRow>
<Form.Field>
<Form.Input
aria-required="true"
autoComplete="off"
name="website"
placeholder={copy.fields.website}
type="text"
/>
</Form.Field>
<Form.Field>
<Form.Input
autoComplete="off"
name="opportunities"
placeholder={copy.fields.opportunities}
type="text"
/>
</Form.Field>
<Form.Field>
<Form.Textarea
aria-required="true"
autoComplete="off"
name="message"
placeholder={`${copy.fields.messageLabel}\n\n${copy.fields.messageHint}`}
/>
</Form.Field>
<Modal.Footer>
{submitError ? (
<SubmitError role="alert">{submitError}</SubmitError>
) : null}
<SubmitButton
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
>
<ButtonShape
fillColor={theme.colors.primary.background[100]}
height={BUTTON_HEIGHTS_PX.regular}
strokeColor="none"
/>
<SubmitLabel>
{isSubmitting ? copy.submitInFlight : copy.submit}
</SubmitLabel>
</SubmitButton>
</Modal.Footer>
</FormFields>
</form>
</Modal.Root>
);
}