47710908a8
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.
576 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|