[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.
This commit is contained in:
Abdullah.
2026-04-24 21:40:09 +05:00
committed by GitHub
parent 0bb3660844
commit 47710908a8
571 changed files with 15258 additions and 14988 deletions
+22 -1
View File
@@ -1,8 +1,29 @@
PARTNER_APPLICATION_WEBHOOK_URL=
# Public site URL (used for Stripe checkout success URL and billing portal return)
# Optional GitHub personal-access token (no scopes required) used by the
# community-stats fetcher (`src/lib/community/fetch-github-star-count.ts`).
# When set, requests use the 5000/hr authenticated limit instead of the
# 60/hr unauthenticated per-IP limit. Both responses are also cached for
# one hour via `unstable_cache`, so dev without a token still works.
# GITHUB_TOKEN=
# Public site URL — canonical origin used by `metadataBase`, sitemap, robots,
# OG/Twitter card URLs, Stripe checkout success URL, and billing portal return.
# No trailing slash. Defaults to https://twenty.com when unset.
NEXT_PUBLIC_WEBSITE_URL=
# --- Visual runtime kill switches ---------------------------------------
# Hard kill switch for every WebGL/Three/R3F decorative visual on the site.
# Set to "1" / "true" to ship a build with all heavy visuals statically
# replaced by their fallbacks (e.g. during a GPU-driver-related incident).
# NEXT_PUBLIC_DISABLE_HEAVY_VISUALS=
# Soft cap on the number of concurrent WebGL contexts the page is allowed
# to spin up. The browser's own hard cap is typically 816; we default to 8
# so a single page that mounts every illustration cannot exhaust the GPU
# context pool on integrated graphics.
# NEXT_PUBLIC_MAX_WEBGL_CONTEXTS=8
# Stripe — self-hosted enterprise checkout & subscription APIs
STRIPE_SECRET_KEY=
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=
+4
View File
@@ -41,3 +41,7 @@ next-env.d.ts
# DB
*.sqlite
# local-only memory files (not for commit)
/Documentation.md
/Todo.md
+74 -15
View File
@@ -2,7 +2,7 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
"correctness": "error"
},
"ignorePatterns": ["node_modules"],
"rules": {
@@ -17,23 +17,32 @@
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}],
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
],
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
@@ -44,5 +53,55 @@
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
},
"overrides": [
{
"files": ["**/sections/**/*.ts", "**/sections/**/*.tsx"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@/app/**"],
"message": "Sections are page-agnostic primitives — they must not depend on a specific route. Move shared halftone primitives into `src/lib/halftone/`, shared types into `src/sections/<Section>/types/`, and shared data into `src/lib/`. (See ARCHITECTURE.md → Layering rules.)"
}
]
}
]
}
},
{
"files": ["**/lib/**/*.ts", "**/lib/**/*.tsx"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@/app/**", "@/sections/**"],
"message": "`lib/` is the leaf layer. It must not depend on routes (`app/`) or page sections (`sections/`). Invert the dependency: have the consumer pass values in. (See ARCHITECTURE.md → Layering rules.)"
}
]
}
]
}
},
{
"files": ["**/design-system/**/*.ts", "**/design-system/**/*.tsx"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@/app/**", "@/sections/**", "@/lib/**"],
"message": "Design-system primitives must only depend on `@/theme` (and `@/icons` for components that legitimately render an icon). They must not reach into routes, sections, or lib. (See ARCHITECTURE.md → Layering rules.)"
}
]
}
]
}
}
]
}
+8 -3
View File
@@ -1,4 +1,9 @@
# Twenty-Website
# twenty-website-new
This is used for the marketing website (twenty.com).
This is not related in any way to the main app, which you can find in twenty-front and twenty-server.
```bash
yarn install
yarn nx run twenty-website-new:dev
yarn nx run twenty-website-new:build
yarn nx run twenty-website-new:lint
yarn nx run twenty-website-new:typecheck
```
+3 -2
View File
@@ -6,8 +6,9 @@ import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const tsConfigPath = resolve(__dirname, './tsconfig.json');
const tsConfig = JSON.parse(readFileSync(tsConfigPath, 'utf8'));
const tsConfig = JSON.parse(
readFileSync(resolve(__dirname, './tsconfig.json'), 'utf8'),
);
const jestConfig = {
displayName: 'twenty-website-new',
+23 -6
View File
@@ -1,6 +1,21 @@
import path from 'path';
import withLinaria, { type LinariaConfig } from 'next-with-linaria';
const SECURITY_HEADERS = [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), payment=()',
},
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
] as const;
const nextConfig: LinariaConfig = {
images: {
formats: ['image/avif', 'image/webp'],
@@ -21,10 +36,16 @@ const nextConfig: LinariaConfig = {
configFile: path.resolve(__dirname, 'wyw-in-js.config.cjs'),
},
reactCompiler: true,
async headers() {
return [
{
source: '/:path*',
headers: SECURITY_HEADERS.map((h) => ({ ...h })),
},
];
},
async redirects() {
return [
// Documentation moved to docs.twenty.com (carried over from the
// legacy twenty-website Next.js app).
{
source: '/user-guide',
destination: 'https://docs.twenty.com/user-guide/introduction',
@@ -80,10 +101,6 @@ const nextConfig: LinariaConfig = {
destination: 'https://docs.twenty.com/twenty-ui/:slug',
permanent: true,
},
// Renamed/restructured pages on the new website. Mappings derived
// from the old twenty.com sitemap so existing inbound links and
// search results keep working.
{
source: '/story',
destination: '/why-twenty',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

+2 -3
View File
@@ -22,13 +22,13 @@
"@wyw-in-js/babel-preset": "^0.8.1",
"axios": "^1.14.0",
"gray-matter": "^4.0.3",
"gsap": "^3.14.2",
"next": "16.1.7",
"next-with-linaria": "^1.3.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.33.5",
"stripe": "^20.3.1",
"three": "^0.183.2",
"zod": "^4.1.11"
@@ -38,7 +38,6 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.183.1",
"babel-plugin-react-compiler": "1.0.0",
"sharp": "^0.33.5"
"babel-plugin-react-compiler": "1.0.0"
}
}
+53 -2
View File
@@ -33,8 +33,59 @@
"command": "npx next start"
}
},
"lint": {},
"lint:diff-with-main": {},
"check-boundaries": {
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"{projectRoot}/src/**/*",
"{projectRoot}/scripts/check-boundaries.mjs"
],
"options": {
"cwd": "{projectRoot}",
"command": "node scripts/check-boundaries.mjs"
}
},
"check-section-shape": {
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"{projectRoot}/src/sections/**/*",
"{projectRoot}/scripts/check-section-shape.mjs"
],
"options": {
"cwd": "{projectRoot}",
"command": "node scripts/check-section-shape.mjs"
}
},
"check-lottie-frames": {
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"{projectRoot}/public/lottie/stepper/stepper.lottie",
"{projectRoot}/src/sections/HomeStepper/utils/home-stepper-lottie-frame-map.ts",
"{projectRoot}/scripts/check-lottie-frames.mjs"
],
"options": {
"cwd": "{projectRoot}",
"command": "node scripts/check-lottie-frames.mjs"
}
},
"lint": {
"dependsOn": [
"check-boundaries",
"check-section-shape",
"check-lottie-frames",
"^build",
"twenty-oxlint-rules:build"
]
},
"lint:diff-with-main": {
"dependsOn": [
"check-boundaries",
"check-section-shape",
"check-lottie-frames"
]
},
"typecheck": {},
"test": {
"executor": "@nx/jest:jest",
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

@@ -0,0 +1,227 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SRC = path.join(ROOT, 'src');
const KNOWN_VIOLATIONS = new Set([]);
const ALLOW_DIRECTIVE_REGEX =
/\/\/\s*boundary-allow-next-line:([\w-]+)(?:\s+--\s+.+)?/;
const RULES = [
{
id: 'no-raw-webgl-renderer',
description:
'`new THREE.WebGLRenderer(...)` may only be instantiated inside `src/lib/visual-runtime/`.',
pattern: /new\s+(?:THREE\.)?WebGLRenderer\s*\(/,
appliesTo: (rel) =>
rel.startsWith('src/') && /\.(ts|tsx|mjs|js|jsx)$/.test(rel),
exempt: (rel) =>
rel.startsWith('src/lib/visual-runtime/') ||
rel.includes('__tests__') ||
rel.endsWith('.d.ts'),
help: [
'Use `createSiteWebGlRenderer` from `@/lib/visual-runtime/create-site-webgl-renderer`',
'instead. The factory enforces the site-wide context cap, attaches the kill',
'switch (NEXT_PUBLIC_DISABLE_HEAVY_VISUALS), and centralises GPU / power',
'preference defaults.',
].join('\n '),
},
];
const SKIP_DIRS = new Set([
'node_modules',
'.next',
'.turbo',
'dist',
'build',
'storybook-static',
'public',
'.git',
]);
async function* walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
yield* walk(path.join(dir, entry.name));
} else if (entry.isFile()) {
yield path.join(dir, entry.name);
}
}
}
function findMatches(contents, pattern, ruleId) {
const matches = [];
const directives = [];
const lines = contents.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const directiveMatch = line.match(ALLOW_DIRECTIVE_REGEX);
if (directiveMatch && directiveMatch[1] === ruleId) {
directives.push({ line: i + 1, ruleId, suppressed: false });
}
const m = line.match(pattern);
if (m && m.index != null) {
const prevDirective = directives.find((d) => d.line === i);
const suppressed = prevDirective !== undefined;
if (prevDirective) prevDirective.suppressed = true;
matches.push({
line: i + 1,
column: m.index + 1,
snippet: line.trim(),
suppressed,
});
}
}
return { matches, directives };
}
async function main() {
const violations = [];
const staleDirectives = [];
for await (const absPath of walk(SRC)) {
const rel = path.relative(ROOT, absPath).split(path.sep).join('/');
for (const rule of RULES) {
if (!rule.appliesTo(rel)) continue;
if (rule.exempt(rel)) continue;
let contents;
try {
contents = await fs.readFile(absPath, 'utf8');
} catch {
continue;
}
const { matches, directives } = findMatches(
contents,
rule.pattern,
rule.id,
);
for (const m of matches) {
if (m.suppressed) continue;
violations.push({
rule,
file: rel,
line: m.line,
column: m.column,
snippet: m.snippet,
});
}
for (const d of directives) {
if (!d.suppressed) {
staleDirectives.push({ rule, file: rel, line: d.line });
}
}
}
}
const newViolations = [];
const seenKnown = new Set();
for (const v of violations) {
const key = `${v.rule.id}:${v.file}`;
if (KNOWN_VIOLATIONS.has(key)) {
seenKnown.add(key);
} else {
newViolations.push(v);
}
}
const staleKnown = [];
for (const key of KNOWN_VIOLATIONS) {
if (!seenKnown.has(key)) staleKnown.push(key);
}
let exitCode = 0;
if (newViolations.length > 0) {
exitCode = 1;
const byRule = new Map();
for (const v of newViolations) {
if (!byRule.has(v.rule.id)) byRule.set(v.rule.id, []);
byRule.get(v.rule.id).push(v);
}
console.error('');
console.error(
`\u001b[31mcheck-boundaries: ${newViolations.length} new violation(s) found\u001b[0m`,
);
console.error('');
for (const [ruleId, list] of byRule.entries()) {
const rule = list[0].rule;
console.error(`\u001b[1m${ruleId}\u001b[0m — ${rule.description}`);
console.error('');
for (const v of list) {
console.error(` ${v.file}:${v.line}:${v.column}`);
console.error(` > ${v.snippet}`);
}
console.error('');
console.error(` help:`);
console.error(` ${rule.help}`);
console.error('');
}
}
if (staleKnown.length > 0) {
exitCode = 1;
console.error('');
console.error(
`\u001b[31mcheck-boundaries: ${staleKnown.length} stale entry/entries in KNOWN_VIOLATIONS\u001b[0m`,
);
console.error(
' Remove the following from KNOWN_VIOLATIONS in scripts/check-boundaries.mjs:',
);
console.error('');
for (const key of staleKnown) {
console.error(` - ${key}`);
}
console.error('');
}
if (staleDirectives.length > 0) {
exitCode = 1;
console.error('');
console.error(
`\u001b[31mcheck-boundaries: ${staleDirectives.length} stale boundary-allow-next-line directive(s)\u001b[0m`,
);
console.error(
' The directive on these lines no longer suppresses any violation. Remove it:',
);
console.error('');
for (const d of staleDirectives) {
console.error(` - ${d.file}:${d.line} (${d.rule.id})`);
}
console.error('');
}
if (exitCode === 0) {
if (KNOWN_VIOLATIONS.size > 0) {
console.error(
`check-boundaries: OK (${KNOWN_VIOLATIONS.size} grandfathered file-level violation(s) tolerated; drive to zero).`,
);
} else {
console.error('check-boundaries: OK');
}
}
return exitCode;
}
main()
.then((code) => process.exit(code))
.catch((err) => {
console.error('check-boundaries: unexpected error');
console.error(err);
process.exit(2);
});
@@ -0,0 +1,118 @@
#!/usr/bin/env node
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const LOTTIE_PATH = path.join(
ROOT,
'public',
'lottie',
'stepper',
'stepper.lottie',
);
const ANIMATION_ENTRY = 'animations/main.json';
const FRAME_MAP_PATH = path.join(
ROOT,
'src',
'sections',
'HomeStepper',
'utils',
'home-stepper-lottie-frame-map.ts',
);
const EXPECTED_CONSTANT_REGEX =
/export\s+const\s+HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES\s*=\s*(\d+)\s*;/;
function fail(message) {
// eslint-disable-next-line no-console
console.error(`\n check-lottie-frames: ${message}\n`);
process.exitCode = 1;
}
async function readExpectedTotalFrames() {
const source = await readFile(FRAME_MAP_PATH, 'utf8');
const match = source.match(EXPECTED_CONSTANT_REGEX);
if (match === null) {
throw new Error(
`couldn't find HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES in ${path.relative(ROOT, FRAME_MAP_PATH)}. ` +
'Has the constant been renamed? Update both the TS file and the regex in this script.',
);
}
return Number.parseInt(match[1], 10);
}
async function readActualTotalFrames() {
let stdout;
try {
const result = await execFileAsync(
'unzip',
['-p', LOTTIE_PATH, ANIMATION_ENTRY],
{ maxBuffer: 32 * 1024 * 1024, encoding: 'buffer' },
);
stdout = result.stdout;
} catch (error) {
if (error?.code === 'ENOENT') {
throw new Error(
'`unzip` binary not found on PATH. Install it (Debian/Ubuntu: `apt install unzip`, macOS ships with it) — ' +
'this script needs it to read the Lottie animation JSON.',
);
}
throw new Error(
`failed to extract ${ANIMATION_ENTRY} from ${path.relative(ROOT, LOTTIE_PATH)}: ${error?.message ?? error}`,
);
}
let animation;
try {
animation = JSON.parse(stdout.toString('utf8'));
} catch (error) {
throw new Error(
`${ANIMATION_ENTRY} inside the .lottie file is not valid JSON: ${error?.message ?? error}`,
);
}
const ip = animation?.ip;
const op = animation?.op;
if (typeof ip !== 'number' || typeof op !== 'number') {
throw new Error(
`${ANIMATION_ENTRY} is missing numeric \`ip\` / \`op\` fields ` +
`(got ip=${JSON.stringify(ip)}, op=${JSON.stringify(op)}). ` +
'Has the Lottie schema changed?',
);
}
return Math.floor(op - ip);
}
async function main() {
const [expected, actual] = await Promise.all([
readExpectedTotalFrames(),
readActualTotalFrames(),
]);
if (expected !== actual) {
fail(
`Lottie totalFrames mismatch — expected ${expected} (per HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES), ` +
`got ${actual} from ${path.relative(ROOT, LOTTIE_PATH)}.\n ` +
' The home-stepper scroll → frame map is keyed to the authored timeline; ' +
'either:\n 1. revert the Lottie re-export, or\n 2. update HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES ' +
'and the STEP_*_END anchors in home-stepper-lottie-frame-map.ts together.',
);
return;
}
// eslint-disable-next-line no-console
console.log(
`check-lottie-frames: OK (stepper.lottie totalFrames = ${actual}).`,
);
}
main().catch((error) => {
fail(error?.message ?? String(error));
});
@@ -0,0 +1,202 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SECTIONS_DIR = path.join(ROOT, 'src', 'sections');
const SECTIONS_USING_NAMED_SLOTS = new Map([
['Marquee', new Set(['Heading'])],
['TrustedBy', new Set(['Separator', 'Logos', 'ClientCount'])],
]);
const LEAF_SECTIONS = new Set([
'CaseStudy',
'CaseStudyCatalog',
'LegalDocument',
]);
async function listSections() {
const entries = await fs.readdir(SECTIONS_DIR, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
async function fileExists(absPath) {
try {
await fs.stat(absPath);
return true;
} catch {
return false;
}
}
async function readFileOrNull(absPath) {
try {
return await fs.readFile(absPath, 'utf8');
} catch {
return null;
}
}
async function findBarrel(sectionDir) {
const candidates = [
path.join(sectionDir, 'components', 'index.ts'),
path.join(sectionDir, 'components', 'index.tsx'),
];
for (const candidate of candidates) {
if (await fileExists(candidate)) return candidate;
}
return null;
}
async function findRoot(sectionDir) {
const candidate = path.join(sectionDir, 'components', 'Root.tsx');
if (await fileExists(candidate)) return candidate;
return null;
}
function parseSlotIdentifiers(barrelContents) {
const exportMatch = barrelContents.match(
/export\s+const\s+\w+\s*=\s*\{([^}]+)\}/m,
);
if (!exportMatch) return null;
const body = exportMatch[1];
return body
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.map((entry) => {
const colon = entry.indexOf(':');
return colon === -1 ? entry : entry.slice(0, colon).trim();
});
}
const TOARRAY_REGEX = /Children\.toArray\s*\(/;
function stripComments(source) {
let out = source.replace(/\/\*[\s\S]*?\*\//g, '');
out = out.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
return out;
}
async function checkSection(name) {
const sectionDir = path.join(SECTIONS_DIR, name);
const violations = [];
const barrel = await findBarrel(sectionDir);
if (barrel === null) {
violations.push(
`${name}: missing components/index.{ts,tsx} barrel — every section must expose a single compound export.`,
);
return violations;
}
if (LEAF_SECTIONS.has(name)) return violations;
const root = await findRoot(sectionDir);
if (root === null) {
violations.push(
`${name}: missing components/Root.tsx — every section needs a Root that owns the outer <section> element.`,
);
} else {
const rootContents = await readFileOrNull(root);
if (
rootContents !== null &&
TOARRAY_REGEX.test(stripComments(rootContents))
) {
violations.push(
`${name}: components/Root.tsx uses Children.toArray(...) positional indexing — match slots by displayName instead (see TrustedBy.Root for the pattern).`,
);
}
}
const slotsToCheck = SECTIONS_USING_NAMED_SLOTS.get(name);
if (slotsToCheck !== undefined) {
const barrelContents = await readFileOrNull(barrel);
const exportedSlotNames = barrelContents
? parseSlotIdentifiers(barrelContents)
: null;
for (const slot of slotsToCheck) {
if (exportedSlotNames !== null && !exportedSlotNames.includes(slot)) {
violations.push(
`${name}: slot "${slot}" is declared in SECTIONS_USING_NAMED_SLOTS but is not exported from ${path.relative(
ROOT,
barrel,
)}. Either export it or remove the entry from check-section-shape.mjs.`,
);
continue;
}
const expected = `${name}.${slot}`;
const slotFile = await locateSlotFile(sectionDir, slot);
if (slotFile === null) {
violations.push(
`${name}: slot "${slot}" is declared in SECTIONS_USING_NAMED_SLOTS but no source file matches the conventional path (components/${slot}.tsx or components/${slot}/${slot}.tsx).`,
);
continue;
}
const contents = await readFileOrNull(slotFile);
if (contents === null) continue;
if (!contents.includes(`displayName = '${expected}'`)) {
violations.push(
`${name}: slot "${slot}" source (${path.relative(
ROOT,
slotFile,
)}) does not set ${slot}.displayName = '${expected}'. Root looks slots up by displayName; without it the slot silently fails to render.`,
);
}
}
}
return violations;
}
async function locateSlotFile(sectionDir, slot) {
const candidates = [
path.join(sectionDir, 'components', `${slot}.tsx`),
path.join(sectionDir, 'components', slot, `${slot}.tsx`),
];
for (const candidate of candidates) {
if (await fileExists(candidate)) return candidate;
}
return null;
}
async function main() {
const sections = await listSections();
const allViolations = [];
for (const name of sections) {
const sectionViolations = await checkSection(name);
for (const v of sectionViolations) allViolations.push(v);
}
if (allViolations.length === 0) {
console.error(
`check-section-shape: OK (${sections.length} sections inspected).`,
);
return 0;
}
console.error('');
console.error(
`\u001b[31mcheck-section-shape: ${allViolations.length} violation(s)\u001b[0m`,
);
console.error('');
for (const v of allViolations) {
console.error(` - ${v}`);
}
console.error('');
return 1;
}
main()
.then((code) => process.exit(code))
.catch((err) => {
console.error('check-section-shape: unexpected error');
console.error(err);
process.exit(2);
});
@@ -1,7 +0,0 @@
export { HELPED_DATA } from './helped';
export { HERO_DATA } from './hero';
export { HOME_STEPPER_DATA } from './home-stepper';
export { PROBLEM_DATA } from './problem';
export { TESTIMONIALS_DATA } from './testimonials';
export { THREE_CARDS_FEATURE_DATA } from './three-cards-feature';
export { THREE_CARDS_ILLUSTRATION_DATA } from './three-cards-illustration';
@@ -5,7 +5,7 @@ import type {
HeroKanbanPageDefinition,
HeroTablePageDefinition,
} from '@/sections/Hero/types';
import { SHARED_PEOPLE_AVATAR_URLS } from '@/lib/shared-asset-paths';
import { SHARED_PEOPLE_AVATAR_URLS } from '@/content/site/asset-paths';
const PEOPLE_AVATAR_URLS = {
anonymousIndira: SHARED_PEOPLE_AVATAR_URLS.anonymousIndira,
@@ -1,16 +1,16 @@
import {
HELPED_DATA,
HERO_DATA,
HOME_STEPPER_DATA,
PROBLEM_DATA,
TESTIMONIALS_DATA,
THREE_CARDS_FEATURE_DATA,
THREE_CARDS_ILLUSTRATION_DATA,
} from '@/app/(home)/_constants';
import { TalkToUsButton } from '@/app/components/ContactCalModal';
import { FAQ_DATA, MENU_DATA, TRUSTED_BY_DATA } from '@/app/_constants';
import { HELPED_DATA } from '@/app/(home)/helped.data';
import { HERO_DATA } from '@/app/(home)/hero.data';
import { HOME_STEPPER_DATA } from '@/app/(home)/home-stepper.data';
import { PROBLEM_DATA } from '@/app/(home)/problem.data';
import { TESTIMONIALS_DATA } from '@/app/(home)/testimonials.data';
import { THREE_CARDS_FEATURE_DATA } from '@/app/(home)/three-cards-feature.data';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/(home)/three-cards-illustration.data';
import { TalkToUsButton } from '@/lib/contact-cal';
import { FAQ_DATA } from '@/sections/Faq/data';
import { MENU_DATA } from '@/sections/Menu/data';
import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Pages } from '@/lib/pages';
import { ArrowRightUpIcon } from '@/icons';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
@@ -29,9 +29,7 @@ import { styled } from '@linaria/react';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Twenty | #1 open source CRM',
description:
'The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.',
alternates: { canonical: '/' },
};
const HOME_TOP_BACKGROUND_COLOR = '#F4F4F4';
@@ -129,7 +127,7 @@ const threeCardsIllustrationHeadingClassName = css`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 921px;
max-width: ${theme.layout.editorial};
}
[data-family='sans'] {
@@ -151,6 +149,16 @@ export default async function HomePage() {
return (
<>
{/*
* Above-the-fold home hero background texture. Preload warms the
* HTTP cache so it is ready by the time HomeBackgroundHalftone
* binds it to the WebGL pipeline.
*/}
<link
as="image"
href="/illustrations/generated/home-background-bridge.png"
rel="preload"
/>
<Menu.Root
backgroundColor={HOME_TOP_BACKGROUND_COLOR}
scheme="primary"
@@ -278,7 +286,7 @@ export default async function HomePage() {
</Testimonials.Carousel>
</Testimonials.Root>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Root>
<Faq.Intro>
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
<Faq.Heading segments={FAQ_DATA.heading} />
@@ -7,9 +7,7 @@ type FooterVisibilityGateProps = {
children: ReactNode;
};
export function FooterVisibilityGate({
children,
}: FooterVisibilityGateProps) {
export function FooterVisibilityGate({ children }: FooterVisibilityGateProps) {
const pathname = usePathname();
if (pathname === '/halftone') {
@@ -0,0 +1,24 @@
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
export function ScrollToTopOnRouteChange() {
const pathname = usePathname();
const isInitialRenderRef = useRef(true);
useEffect(() => {
if (isInitialRenderRef.current) {
isInitialRenderRef.current = false;
return;
}
if (window.location.hash !== '') {
return;
}
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}, [pathname]);
return null;
}
@@ -1,4 +0,0 @@
export { FAQ_DATA } from './faq';
export { FOOTER_DATA } from './footer';
export { MENU_DATA } from './menu';
export { TRUSTED_BY_DATA } from './trusted-by';
@@ -1,5 +1,5 @@
import { signEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
import { getStripeClient } from '@/shared/enterprise/stripe-client';
import { signEnterpriseKey } from '@/lib/enterprise/enterprise-jwt';
import { getStripeClient } from '@/lib/enterprise/stripe-client';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
@@ -22,8 +22,6 @@ export async function GET(request: Request) {
expand: ['subscription', 'customer'],
});
// Subscriptions that begin in a free trial complete with
// `payment_status: 'no_payment_required'`, so accept both successful states.
const SUCCESSFUL_PAYMENT_STATUSES: Array<typeof session.payment_status> = [
'paid',
'no_payment_required',
@@ -1,7 +1,7 @@
import {
getEnterprisePriceId,
getStripeClient,
} from '@/shared/enterprise/stripe-client';
} from '@/lib/enterprise/stripe-client';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
@@ -1,5 +1,5 @@
import { verifyEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
import { getStripeClient } from '@/shared/enterprise/stripe-client';
import { verifyEnterpriseKey } from '@/lib/enterprise/enterprise-jwt';
import { getStripeClient } from '@/lib/enterprise/stripe-client';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
@@ -1,5 +1,5 @@
import { verifyEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
import { getStripeClient } from '@/shared/enterprise/stripe-client';
import { verifyEnterpriseKey } from '@/lib/enterprise/enterprise-jwt';
import { getStripeClient } from '@/lib/enterprise/stripe-client';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
@@ -1,6 +1,6 @@
import { verifyEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
import { getStripeClient } from '@/shared/enterprise/stripe-client';
import { getSubscriptionCurrentPeriodEnd } from '@/shared/enterprise/stripe-subscription-helpers';
import { verifyEnterpriseKey } from '@/lib/enterprise/enterprise-jwt';
import { getStripeClient } from '@/lib/enterprise/stripe-client';
import { getSubscriptionCurrentPeriodEnd } from '@/lib/enterprise/stripe-subscription-helpers';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
@@ -1,9 +1,9 @@
import {
signValidityToken,
verifyEnterpriseKey,
} from '@/shared/enterprise/enterprise-jwt';
import { getStripeClient } from '@/shared/enterprise/stripe-client';
import { getSubscriptionCurrentPeriodEnd } from '@/shared/enterprise/stripe-subscription-helpers';
} from '@/lib/enterprise/enterprise-jwt';
import { getStripeClient } from '@/lib/enterprise/stripe-client';
import { getSubscriptionCurrentPeriodEnd } from '@/lib/enterprise/stripe-subscription-helpers';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
@@ -0,0 +1,299 @@
const ORIGINAL_FETCH = global.fetch;
const ORIGINAL_WEBHOOK_URL = process.env.PARTNER_APPLICATION_WEBHOOK_URL;
const VALID_PAYLOAD = {
email: 'a@b.co',
name: 'Ada Lovelace',
company: 'Analytical Engines',
website: 'https://analytical.example/',
message: 'We would like to integrate Twenty with our analytical engine.',
programId: 'technology' as const,
};
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/partner-application', {
method: 'POST',
headers,
body,
});
}
async function loadRoute() {
jest.resetModules();
const mod = await import('@/app/api/partner-application/route');
return mod;
}
describe('POST /api/partner-application', () => {
beforeEach(() => {
process.env.PARTNER_APPLICATION_WEBHOOK_URL = 'https://hooks.example/test';
});
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
process.env.PARTNER_APPLICATION_WEBHOOK_URL = ORIGINAL_WEBHOOK_URL;
});
it('returns 503 when the webhook URL is not configured', async () => {
delete process.env.PARTNER_APPLICATION_WEBHOOK_URL;
const { POST } = await loadRoute();
const response = await POST(buildRequest());
expect(response.status).toBe(503);
});
it('returns 503 when the webhook URL is not a valid URL', async () => {
process.env.PARTNER_APPLICATION_WEBHOOK_URL = 'not-a-url';
const { POST } = await loadRoute();
const response = await POST(buildRequest());
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: 'a@b.co' }),
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 extra fields are present (strict schema)', async () => {
const { POST } = await loadRoute();
const response = await POST(
buildRequest({
body: JSON.stringify({ ...VALID_PAYLOAD, extra: 'nope' }),
ip: '203.0.113.15',
}),
);
expect(response.status).toBe(400);
});
it('returns 400 when company is missing', async () => {
const { POST } = await loadRoute();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { company: _omitted, ...withoutCompany } = VALID_PAYLOAD;
const response = await POST(
buildRequest({
body: JSON.stringify(withoutCompany),
ip: '203.0.113.16',
}),
);
expect(response.status).toBe(400);
});
it('returns 400 when website is not a URL', async () => {
const { POST } = await loadRoute();
const response = await POST(
buildRequest({
body: JSON.stringify({ ...VALID_PAYLOAD, website: 'not-a-url' }),
ip: '203.0.113.17',
}),
);
expect(response.status).toBe(400);
});
it('returns 400 when programId is unknown', async () => {
const { POST } = await loadRoute();
const response = await POST(
buildRequest({
body: JSON.stringify({ ...VALID_PAYLOAD, programId: 'wat' }),
ip: '203.0.113.18',
}),
);
expect(response.status).toBe(400);
});
it('forwards a valid submission to the webhook with all fields and returns 200', async () => {
const fetchSpy = jest
.fn()
.mockResolvedValue(new Response(null, { 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/test');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body as string)).toEqual({
Email: 'a@b.co',
FirstName: 'Ada',
LastName: 'Lovelace',
Company: 'Analytical Engines',
Website: 'https://analytical.example/',
Message: 'We would like to integrate Twenty with our analytical engine.',
ProgramId: 'technology',
});
expect(init.signal).toBeInstanceOf(AbortSignal);
});
it('forwards optional Opportunities when provided', async () => {
const fetchSpy = jest
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
global.fetch = fetchSpy;
const { POST } = await loadRoute();
const response = await POST(
buildRequest({
body: JSON.stringify({
...VALID_PAYLOAD,
opportunities: '50/month',
}),
ip: '203.0.113.24',
}),
);
expect(response.status).toBe(200);
const [, init] = fetchSpy.mock.calls[0];
expect(JSON.parse(init.body as string)).toMatchObject({
Opportunities: '50/month',
});
});
it('omits optional Opportunities when not provided', async () => {
const fetchSpy = jest
.fn()
.mockResolvedValue(new Response(null, { status: 200 }));
global.fetch = fetchSpy;
const { POST } = await loadRoute();
const response = await POST(buildRequest({ ip: '203.0.113.25' }));
expect(response.status).toBe(200);
const [, init] = fetchSpy.mock.calls[0];
expect(JSON.parse(init.body as string)).not.toHaveProperty('Opportunities');
});
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 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()
.mockResolvedValue(new Response(null, { status: 200 }));
const { POST } = await loadRoute();
const ip = '203.0.113.99';
const statuses: number[] = [];
for (let i = 0; i < 6; i++) {
const r = await POST(buildRequest({ ip }));
statuses.push(r.status);
}
expect(statuses.slice(0, 5).every((s) => s === 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(null, { status: 200 }));
const { POST } = await loadRoute();
const ip = '203.0.113.100';
for (let i = 0; i < 5; i++) {
await POST(buildRequest({ ip }));
}
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);
});
});
@@ -1,7 +1,15 @@
import { splitFullName } from '@/lib/partner-application/split-full-name';
import {
createRateLimiter,
fetchWithTimeout,
getClientIpKey,
readJsonBody,
} from '@/lib/api';
import { splitFullName } from '@/lib/partner-application';
import { NextResponse } from 'next/server';
import { z } from 'zod';
const PARTNER_PROGRAM_IDS = ['technology', 'content', 'solutions'] as const;
const partnerApplicationRequestSchema = z.strictObject({
email: z
.string()
@@ -9,6 +17,15 @@ const partnerApplicationRequestSchema = z.strictObject({
.min(1, { error: 'Email is required.' })
.pipe(z.email({ error: 'Invalid email address.' })),
name: z.string().trim().min(1, { error: 'Name is required.' }),
company: z.string().trim().min(1, { error: 'Company is required.' }),
website: z
.string()
.trim()
.min(1, { error: 'Website is required.' })
.pipe(z.httpUrl({ error: 'Invalid website URL.' })),
message: z.string().trim().min(1, { error: 'Message is required.' }),
programId: z.enum(PARTNER_PROGRAM_IDS).optional(),
opportunities: z.string().trim().optional(),
});
const webhookUrlSchema = z
@@ -16,6 +33,15 @@ const webhookUrlSchema = z
.trim()
.pipe(z.httpUrl({ error: 'Invalid webhook URL.' }));
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,
@@ -30,50 +56,90 @@ export async function POST(request: Request) {
const webhookUrl = webhookUrlResult.data;
let raw: unknown;
try {
raw = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
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 = partnerApplicationRequestSchema.safeParse(raw);
const bodyResult = await readJsonBody<unknown>(request, {
maxBytes: MAX_BODY_BYTES,
});
if (!bodyResult.success) {
const message =
bodyResult.error.issues[0]?.message ?? 'Invalid request body.';
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 { name, email } = bodyResult.data;
const { name, email, company, website, message, programId, opportunities } =
parsed.data;
const { firstName, lastName } = splitFullName(name);
const webhookPayload = {
Email: email,
FirstName: firstName,
LastName: lastName,
};
try {
const upstreamResponse = await fetch(webhookUrl, {
body: JSON.stringify(webhookPayload),
const upstream = await fetchWithTimeout(
webhookUrl,
{
body: JSON.stringify({
Email: email,
FirstName: firstName,
LastName: lastName,
Company: company,
Website: website,
Message: message,
...(programId !== undefined && { ProgramId: programId }),
...(opportunities !== undefined &&
opportunities !== '' && { Opportunities: opportunities }),
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
},
{ timeoutMs: WEBHOOK_TIMEOUT_MS },
);
if (!upstreamResponse.ok) {
return NextResponse.json(
{ error: 'Partner application could not be submitted.' },
{ status: 502 },
);
}
if (!upstream.ok) {
const status = upstream.error === 'timeout' ? 504 : 502;
return NextResponse.json(
{ error: 'Partner application could not be submitted.' },
{ status },
);
}
return NextResponse.json({ success: true });
} catch {
if (!upstream.response.ok) {
return NextResponse.json(
{ error: 'Partner application could not be submitted.' },
{ status: 502 },
);
}
return NextResponse.json({ success: true });
}
@@ -1,145 +0,0 @@
'use client';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import dynamic from 'next/dynamic';
import { useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
const EmbedFallback = styled.p`
color: ${theme.colors.secondary.text[60]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(4)};
margin: 0;
padding-bottom: ${theme.spacing(8)};
padding-top: ${theme.spacing(8)};
text-align: center;
`;
const CalFormEmbed = dynamic(
() =>
import('./CalFormEmbed').then((mod) => ({
default: mod.CalFormEmbed,
})),
{
loading: () => <EmbedFallback>Loading form</EmbedFallback>,
ssr: false,
},
);
const Overlay = styled.div`
align-items: center;
backdrop-filter: blur(4px);
background: rgba(28, 28, 28, 0.8);
box-sizing: border-box;
display: flex;
inset: 0;
justify-content: center;
padding-bottom: ${theme.spacing(4)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(4)};
position: fixed;
z-index: 300;
`;
const Panel = styled.div`
background: #0c0c0c;
border-radius: ${theme.radius(2)};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${theme.spacing(4)};
max-height: 100%;
max-width: 100%;
overflow-y: auto;
padding-bottom: ${theme.spacing(6)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(5)};
position: relative;
width: min(100%, 720px);
@media (min-width: ${theme.breakpoints.md}px) {
padding-bottom: ${theme.spacing(8)};
padding-left: ${theme.spacing(6)};
padding-right: ${theme.spacing(6)};
padding-top: ${theme.spacing(6)};
}
`;
const Title = styled.h2`
color: ${theme.colors.secondary.text[100]};
font-family: ${theme.font.family.serif};
font-size: ${theme.font.size(10)};
font-weight: ${theme.font.weight.light};
line-height: ${theme.lineHeight(11.5)};
margin: 0;
@media (min-width: ${theme.breakpoints.md}px) {
font-size: ${theme.font.size(12)};
line-height: ${theme.lineHeight(14)};
}
`;
const EmbedShell = styled.div`
min-height: 400px;
width: 100%;
`;
type ContactCalModalProps = {
open: boolean;
onClose: () => void;
};
export function ContactCalModal({ open, onClose }: ContactCalModalProps) {
const handleOverlayPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
onClose();
}
},
[onClose],
);
useEffect(() => {
if (!open) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [open, onClose]);
if (!open) {
return null;
}
return createPortal(
<Overlay onPointerDown={handleOverlayPointerDown}>
<Panel
aria-labelledby="contact-cal-modal-title"
aria-modal="true"
role="dialog"
>
<Title id="contact-cal-modal-title">Talk to us</Title>
<EmbedShell>
<CalFormEmbed />
</EmbedShell>
</Panel>
</Overlay>,
document.body,
);
}
@@ -1,12 +1,12 @@
import { MENU_DATA } from '@/app/_constants';
import { MENU_DATA } from '@/sections/Menu/data';
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
import { getCaseStudyPalette } from '@/app/customers/_constants';
import type { CaseStudyData } from '@/app/customers/_constants/types';
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
const PLACEHOLDER_HERO =
@@ -99,10 +99,11 @@ const CASE_STUDY: CaseStudyData = {
},
};
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/customers/9dots',
title: CASE_STUDY.meta.title,
description: CASE_STUDY.meta.description,
};
});
export default async function NineDotsCaseStudyPage() {
const stats = await fetchCommunityStats();
@@ -1,6 +1,6 @@
import { TalkToUsButton } from '@/app/components/ContactCalModal';
import { TalkToUsButton } from '@/lib/contact-cal';
import { LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Pages } from '@/lib/pages';
import { Signoff } from '@/sections/Signoff/components';
import { theme } from '@/theme';
@@ -1,12 +1,12 @@
import { MENU_DATA } from '@/app/_constants';
import { MENU_DATA } from '@/sections/Menu/data';
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
import { getCaseStudyPalette } from '@/app/customers/_constants';
import type { CaseStudyData } from '@/app/customers/_constants/types';
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
const PLACEHOLDER_HERO =
@@ -99,10 +99,11 @@ const CASE_STUDY: CaseStudyData = {
},
};
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/customers/act-education',
title: CASE_STUDY.meta.title,
description: CASE_STUDY.meta.description,
};
});
export default async function ActEducationCaseStudyPage() {
const stats = await fetchCommunityStats();
@@ -1,12 +1,12 @@
import { MENU_DATA } from '@/app/_constants';
import { MENU_DATA } from '@/sections/Menu/data';
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
import { getCaseStudyPalette } from '@/app/customers/_constants';
import type { CaseStudyData } from '@/app/customers/_constants/types';
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
const PLACEHOLDER_HERO =
@@ -73,10 +73,11 @@ const CASE_STUDY: CaseStudyData = {
},
};
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/customers/alternative-partners',
title: CASE_STUDY.meta.title,
description: CASE_STUDY.meta.description,
};
});
export default async function AlternativePartnersCaseStudyPage() {
const stats = await fetchCommunityStats();
@@ -1,12 +1,12 @@
import { MENU_DATA } from '@/app/_constants';
import { MENU_DATA } from '@/sections/Menu/data';
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
import { getCaseStudyPalette } from '@/app/customers/_constants';
import type { CaseStudyData } from '@/app/customers/_constants/types';
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
const PLACEHOLDER_HERO =
@@ -113,10 +113,11 @@ const CASE_STUDY: CaseStudyData = {
},
};
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/customers/elevate-consulting',
title: CASE_STUDY.meta.title,
description: CASE_STUDY.meta.description,
};
});
export default async function ElevateConsultingCaseStudyPage() {
const stats = await fetchCommunityStats();
@@ -1,12 +1,12 @@
import { MENU_DATA } from '@/app/_constants';
import { MENU_DATA } from '@/sections/Menu/data';
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
import { getCaseStudyPalette } from '@/app/customers/_constants';
import type { CaseStudyData } from '@/app/customers/_constants/types';
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
const PLACEHOLDER_HERO =
@@ -103,10 +103,11 @@ const CASE_STUDY: CaseStudyData = {
},
};
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/customers/netzero',
title: CASE_STUDY.meta.title,
description: CASE_STUDY.meta.description,
};
});
export default async function NetZeroCaseStudyPage() {
const stats = await fetchCommunityStats();
@@ -1,8 +1,10 @@
import { FAQ_DATA, MENU_DATA, TRUSTED_BY_DATA } from '@/app/_constants';
import { TalkToUsButton } from '@/app/components/ContactCalModal';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/app/customers/_constants';
import { FAQ_DATA } from '@/sections/Faq/data';
import { MENU_DATA } from '@/sections/Menu/data';
import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
import { TalkToUsButton } from '@/lib/contact-cal';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
import { Eyebrow, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Pages } from '@/lib/pages';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog/components';
@@ -12,28 +14,16 @@ import { Menu } from '@/sections/Menu/components';
import { Signoff } from '@/sections/Signoff/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import { css } from '@linaria/core';
import type { Metadata } from 'next';
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/customers',
title: 'Customers | Twenty',
description:
'Meet the teams running their business on Twenty. Real customer stories on how they shaped the CRM to fit their workflow.',
alternates: { canonical: '/customers' },
openGraph: {
title: 'Customers | Twenty',
description:
'Meet the teams running their business on Twenty. Real customer stories on how they shaped the CRM to fit their workflow.',
url: '/customers',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'Customers | Twenty',
description:
'Meet the teams running their business on Twenty. Real customer stories on how they shaped the CRM to fit their workflow.',
},
};
});
const HERO_HEADING = [
{ text: 'See how teams ', fontFamily: 'serif' as const },
@@ -142,7 +132,7 @@ export default async function CaseStudiesCatalogPage() {
</Signoff.Cta>
</Signoff.Root>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Root>
<Faq.Intro>
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
<Faq.Heading segments={FAQ_DATA.heading} />
@@ -1,12 +1,12 @@
import { MENU_DATA } from '@/app/_constants';
import { MENU_DATA } from '@/sections/Menu/data';
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
import { getCaseStudyPalette } from '@/app/customers/_constants';
import type { CaseStudyData } from '@/app/customers/_constants/types';
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
const PLACEHOLDER_HERO =
@@ -100,10 +100,11 @@ const CASE_STUDY: CaseStudyData = {
},
};
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/customers/w3villa',
title: CASE_STUDY.meta.title,
description: CASE_STUDY.meta.description,
};
});
export default async function W3villaCaseStudyPage() {
const stats = await fetchCommunityStats();
@@ -70,25 +70,34 @@ const CopyTrigger = styled.button<{ $copied: boolean }>`
right: ${theme.spacing(2)};
top: ${theme.spacing(2)};
/*
* When showing the "Copied!" success state we override the inner
* BaseButton's appearance through its documented data-slot hooks.
* CSS rules cleanly win over SVG presentation attributes (fill on
* a path element) and over the Label's CSS-driven color, so no
* !important is needed for those.
*
* For the hover-fill we cannot override its inline opacity from CSS
* (inline style always wins) so we suppress it via visibility: hidden
* instead — that property is not set inline anywhere, so the cascade
* resolves correctly without !important.
*/
${({ $copied }) =>
$copied
? `
& [data-slot='button-base-shape'] path,
& [data-slot='button-base-shape'] rect {
fill: ${theme.colors.accent.green[100]} !important;
fill: ${theme.colors.accent.green[100]};
}
& [data-slot='button-hover-fill'] {
opacity: 0 !important;
visibility: hidden;
pointer-events: none;
}
& [data-slot='button-label'] {
color: ${theme.colors.primary.background[100]} !important;
}
& [data-slot='button-label'],
&:is(:hover, :focus-visible) [data-slot='button-label'] {
color: ${theme.colors.primary.background[100]} !important;
color: ${theme.colors.primary.background[100]};
}
`
: ''}
@@ -1,22 +1,24 @@
import { MENU_DATA } from '@/app/_constants';
import { MENU_DATA } from '@/sections/Menu/data';
import { EnterpriseActivateClient } from '@/app/enterprise/activate/EnterpriseActivateClient';
import { Body, Container, Eyebrow } from '@/design-system/components';
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
import { Pages } from '@/enums/pages';
import type { HeadingType } from '@/design-system/components/Heading';
import { Pages } from '@/lib/pages';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Hero } from '@/sections/Hero/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
import { Suspense } from 'react';
import { styled } from '@linaria/react';
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/enterprise/activate',
title: 'Enterprise activation | Twenty',
description:
'Complete activation for your Twenty self-hosted enterprise license.',
};
});
const ENTERPRISE_ACTIVATE_HEADING: HeadingType[] = [
{ text: 'Enterprise ', fontFamily: 'serif' },
@@ -9,7 +9,7 @@ import type {
HalftoneSourceMode,
HalftoneStudioSettings,
HalftoneTabId,
} from '@/app/halftone/_lib/state';
} from '@/lib/halftone/state';
import { AnimationsTab } from './controls/AnimationsTab';
import { DesignTab } from './controls/DesignTab';
import { ExportTab } from './controls/ExportTab';
@@ -1,18 +1,6 @@
'use client';
import {
HalftoneCanvas,
type HalftoneSnapshotFn,
} from '@/app/halftone/_components/HalftoneCanvas';
import { ControlsPanel } from '@/app/halftone/_components/ControlsPanel';
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
import {
createFallbackGeometry,
disposeGeometryCache,
getGeometryForSpec,
} from '@/app/halftone/_lib/geometry-registry';
import { REFERENCE_PREVIEW_DISTANCE } from '@/app/halftone/_lib/footprint';
import { generateImageHalftoneSvg } from '@/app/halftone/_lib/imageSvgExport';
import {
DEFAULT_REACT_EXPORT_SETTINGS,
deriveExportComponentName,
@@ -22,6 +10,23 @@ import {
parseExportedPreset,
type ReactExportSettings,
} from '@/app/halftone/_lib/exporters';
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
import { generateImageHalftoneSvg } from '@/app/halftone/_lib/imageSvgExport';
import {
buildShareUrl,
decodeShareState,
encodeShareState,
} from '@/app/halftone/_lib/share';
import {
HalftoneCanvas,
type HalftoneSnapshotFn,
} from '@/lib/halftone/halftone-canvas';
import { REFERENCE_PREVIEW_DISTANCE } from '@/lib/halftone/footprint';
import {
createFallbackGeometry,
disposeGeometryCache,
getGeometryForSpec,
} from '@/lib/halftone/geometry-registry';
import {
DEFAULT_IMAGE_HALFTONE_SETTINGS,
DEFAULT_SHAPE_HALFTONE_SETTINGS,
@@ -32,12 +37,7 @@ import {
type HalftoneModelLoader,
type HalftoneSourceMode,
normalizeHalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
import {
buildShareUrl,
decodeShareState,
encodeShareState,
} from '@/app/halftone/_lib/share';
} from '@/lib/halftone/state';
import { Logo as LogoIcon } from '@/icons';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
@@ -60,7 +60,10 @@ const DESKTOP_CONTROLS_PANEL_FOOTPRINT =
const StudioShell = styled.div<{ $background: string }>`
background: ${(props) => props.$background};
/* Full-screen tool: track the visible viewport so the bottom controls bar
* is reachable on mobile Safari (where 100vh extends behind the URL bar). */
height: 100vh;
height: 100dvh;
overflow: hidden;
position: relative;
width: 100%;
@@ -142,6 +145,7 @@ const ControlsPositioner = styled.div`
bottom: 20px;
display: flex;
height: calc(100vh - 40px);
height: calc(100dvh - 40px);
justify-content: flex-end;
pointer-events: none;
position: fixed;
@@ -471,9 +475,6 @@ export function HalftoneStudio() {
};
}, [state.settings.halftone, state.settings.sourceMode]);
// Hydrate from URL hash on first mount. Done in an effect (not the reducer
// initializer) so the server-rendered HTML matches the client and we don't
// touch `window` during render.
const hashHydratedReference = useRef(false);
useEffect(() => {
if (hashHydratedReference.current) {
@@ -493,9 +494,6 @@ export function HalftoneStudio() {
setExportName(decoded.exportName);
}, []);
// Mirror the current design state to the URL hash so a refresh (and a copy
// of the URL) restores the same look. We skip the very first effect run so
// the URL stays clean until the user actually changes something.
const hashSyncInitializedReference = useRef(false);
useEffect(() => {
if (!hashSyncInitializedReference.current) {
@@ -1182,12 +1180,7 @@ export function HalftoneStudio() {
height,
});
},
[
exportBackground,
imageElement,
previewDistance,
state.settings,
],
[exportBackground, imageElement, previewDistance, state.settings],
);
const handleExportHalftoneSvg = useCallback(
@@ -1213,10 +1206,7 @@ export function HalftoneStudio() {
});
window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000);
},
[
buildHalftoneSvg,
exportArtifactNames.fileBaseName,
],
[buildHalftoneSvg, exportArtifactNames.fileBaseName],
);
const handleCopyHalftoneSvg = useCallback(
@@ -5,7 +5,7 @@ import {
formatDecimal,
formatPercent,
} from '@/app/halftone/_lib/formatters';
import type { HalftoneStudioSettings } from '@/app/halftone/_lib/state';
import type { HalftoneStudioSettings } from '@/lib/halftone/state';
import {
ColorControlLabel,
ColorControlRow,
@@ -11,7 +11,7 @@ import {
type HalftoneBackgroundSettings,
type HalftoneSourceMode,
type HalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
} from '@/lib/halftone/state';
import { styled } from '@linaria/react';
import {
ColorControlLabel,
@@ -6,7 +6,7 @@ import { formatAnimationName } from '@/app/halftone/_lib/formatters';
import type {
HalftoneGeometrySpec,
HalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
} from '@/lib/halftone/state';
import { useState } from 'react';
import {
ExportButton,
@@ -16,6 +16,8 @@ const TAB_LABEL_WIDTH = 72;
export const PanelShell = styled.aside<{ $collapsed?: boolean }>`
background: rgba(18, 18, 22, 0.88);
/* -webkit- prefix is required for the blur to render on Safari < 18. */
-webkit-backdrop-filter: blur(24px) saturate(1.4);
backdrop-filter: blur(24px) saturate(1.4);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
@@ -228,7 +230,7 @@ const EditableControlValueInput = styled.input`
}
`;
export const SliderInput = styled.input`
export const SliderInput = styled.input<{ $fillPercent: number }>`
appearance: none;
background: transparent;
cursor: pointer;
@@ -241,8 +243,8 @@ export const SliderInput = styled.input`
&::-webkit-slider-runnable-track {
background: linear-gradient(
to right,
rgba(255, 255, 255, 0.35) var(--fill, 50%),
rgba(255, 255, 255, 0.08) var(--fill, 50%)
rgba(255, 255, 255, 0.35) ${({ $fillPercent }) => $fillPercent}%,
rgba(255, 255, 255, 0.08) ${({ $fillPercent }) => $fillPercent}%
);
border-radius: 999px;
height: 6px;
@@ -844,11 +846,11 @@ export function SliderControl({
<SliderLabel>
<span>{children}</span>
<SliderInput
$fillPercent={fillPercent}
max={max}
min={min}
onChange={onChange}
step={step}
style={{ '--fill': `${fillPercent}%` } as React.CSSProperties}
type="range"
value={value}
/>
@@ -5,11 +5,12 @@ import {
type ReactExportSettings,
} from '@/app/halftone/_lib/exporters';
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
import { REFERENCE_PREVIEW_DISTANCE } from '@/lib/halftone/footprint';
import {
DEFAULT_HALFTONE_SETTINGS,
normalizeHalftoneStudioSettings,
type HalftoneGeometrySpec,
} from '@/app/halftone/_lib/state';
} from '@/lib/halftone/state';
const IMPORTED_GLB_SHAPE: HalftoneGeometrySpec = {
key: 'userUpload_connect',
@@ -222,3 +223,45 @@ describe('halftone react export presets', () => {
expect(output).not.toContain("'use client';");
});
});
describe('parseExportedPreset legacy presets', () => {
it('falls back to the reference preview distance for legacy presets', () => {
const content = `
const settings = ${JSON.stringify(DEFAULT_HALFTONE_SETTINGS, null, 2)};
const shape = ${JSON.stringify(
{
filename: null,
key: 'torusKnot',
kind: 'builtin',
label: 'Torus Knot',
loader: null,
},
null,
2,
)};
const initialPose = ${JSON.stringify(
{
autoElapsed: 0,
rotateElapsed: 0,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
targetRotationX: 0,
targetRotationY: 0,
timeElapsed: 0,
},
null,
2,
)};
const VIRTUAL_RENDER_HEIGHT = 768;
export default function LegacyHalftone() {
return null;
}
`;
expect(parseExportedPreset(content).previewDistance).toBe(
REFERENCE_PREVIEW_DISTANCE,
);
});
});
@@ -1,17 +1,18 @@
import { normalizeExportComponentName } from '@/app/halftone/_lib/exportNames';
import { GLASS_ENVIRONMENT_DATA_URL } from '@/app/halftone/_lib/glassEnvironmentData';
import {
HALFTONE_FOOTPRINT_RUNTIME_SOURCE,
REFERENCE_PREVIEW_DISTANCE,
VIRTUAL_RENDER_HEIGHT,
} from '@/app/halftone/_lib/footprint';
} from '@/lib/halftone/footprint';
import {
LEGACY_HALFTONE_SETTING_KEYS,
isRoundedBandHalftoneSettings,
type HalftoneExportPose,
type HalftoneGeometrySpec,
type HalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
import { GLASS_ENVIRONMENT_DATA_URL } from '@/app/halftone/_lib/glassEnvironmentData';
} from '@/lib/halftone/state';
import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path';
export type ReactExportSettings = {
includeNamedAndDefaultExport: boolean;
@@ -1155,8 +1156,7 @@ function parseFbxGeometry(buffer, label) {
`;
const IMPORTED_GLB_RUNTIME_SOURCE = String.raw`
const DRACO_DECODER_PATH =
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
const DRACO_DECODER_PATH = ${JSON.stringify(DRACO_DECODER_PATH)};
function parseGlbGeometry(buffer, label) {
return new Promise((resolve, reject) => {
@@ -2144,6 +2144,7 @@ async function mountHalftoneCanvas(options) {
return () => {};
}
// boundary-allow-next-line:no-raw-webgl-renderer -- emitted into the standalone HTML export; runs in the user's downloaded file with no access to lib/visual-runtime
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
@@ -2752,6 +2753,7 @@ async function mountHalftoneCanvas(options) {
img.src = imageUrl;
});
// boundary-allow-next-line:no-raw-webgl-renderer -- emitted into the standalone HTML export; runs in the user's downloaded file with no access to lib/visual-runtime
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
@@ -3198,8 +3200,22 @@ export function generateReactComponent(
pose,
options.previewDistance,
);
const generatedBanner = exportSettings.includeTsNoCheck
? `/**
* @generated halftone-studio
* This file is autogenerated by the in-app halftone studio
* (src/app/halftone). Do NOT hand-edit — regenerate the export
* from the studio if the visual or runtime behaviour needs to change.
*
* Type checking is disabled at the file level because the serialized
* Three.js runtime below contains thousands of lines of legacy
* implicitly-typed code that the generator does not annotate. See
* src/app/halftone/_lib/exporters.ts for the generator source.
*/
// @ts-nocheck`
: null;
const directiveLines = [
exportSettings.includeTsNoCheck ? '// @ts-nocheck' : null,
generatedBanner,
exportSettings.includeUseClientDirective ? "'use client';" : null,
]
.filter((line): line is string => line !== null)
@@ -10,21 +10,24 @@ export function formatPercent(value: number, digits = 0) {
return `${(value * 100).toFixed(digits)}%`;
}
export function formatAnimationName(animation: {
autoRotateEnabled: boolean;
breatheEnabled: boolean;
cameraParallaxEnabled: boolean;
dragFlowEnabled: boolean;
followHoverEnabled: boolean;
followDragEnabled: boolean;
floatEnabled: boolean;
hoverHalftoneEnabled: boolean;
hoverLightEnabled: boolean;
lightSweepEnabled: boolean;
rotateEnabled: boolean;
rotatePreset: string;
springReturnEnabled: boolean;
}, sourceMode: 'shape' | 'image') {
export function formatAnimationName(
animation: {
autoRotateEnabled: boolean;
breatheEnabled: boolean;
cameraParallaxEnabled: boolean;
dragFlowEnabled: boolean;
followHoverEnabled: boolean;
followDragEnabled: boolean;
floatEnabled: boolean;
hoverHalftoneEnabled: boolean;
hoverLightEnabled: boolean;
lightSweepEnabled: boolean;
rotateEnabled: boolean;
rotatePreset: string;
springReturnEnabled: boolean;
},
sourceMode: 'shape' | 'image',
) {
const activeModes: string[] = [];
if (sourceMode === 'image') {
@@ -2,8 +2,8 @@ import {
getContainedImageRect,
getImageFootprintScale,
getImagePreviewZoom,
} from '@/app/halftone/_lib/footprint';
import type { HalftoneStudioSettings } from '@/app/halftone/_lib/state';
} from '@/lib/halftone/footprint';
import type { HalftoneStudioSettings } from '@/lib/halftone/state';
type PixelBounds = {
maxX: number;
@@ -160,8 +160,7 @@ export function generateImageHalftoneSvg({
);
const localPower = clamp(settings.halftone.power, -1.5, 1.5);
const localWidth = clamp(settings.halftone.width, 0.05, 1.4);
const toneTargetMultiplier =
settings.halftone.toneTarget === 'dark' ? -1 : 1;
const toneTargetMultiplier = settings.halftone.toneTarget === 'dark' ? -1 : 1;
const lineColor = settings.halftone.dashColor;
const columns = Math.ceil(width / halftoneSize);
const rows = Math.ceil(height / halftoneSize);
@@ -183,17 +182,17 @@ export function generateImageHalftoneSvg({
const contrast = settings.halftone.imageContrast;
const red = clamp(
((pixels[sampleIndex] / 255 - 0.5) * contrast) + 0.5,
(pixels[sampleIndex] / 255 - 0.5) * contrast + 0.5,
0,
1,
);
const green = clamp(
((pixels[sampleIndex + 1] / 255 - 0.5) * contrast) + 0.5,
(pixels[sampleIndex + 1] / 255 - 0.5) * contrast + 0.5,
0,
1,
);
const blue = clamp(
((pixels[sampleIndex + 2] / 255 - 0.5) * contrast) + 0.5,
(pixels[sampleIndex + 2] / 255 - 0.5) * contrast + 0.5,
0,
1,
);
@@ -203,8 +202,6 @@ export function generateImageHalftoneSvg({
toneValue = 1 - toneValue;
}
// Preserve the pre-toneTarget light-mode response by keeping the power
// bias inside the averaged tone calculation.
const bandRadius =
clamp(toneValue + (localPower * Math.SQRT1_2) / 3, 0, 1) * 0.93;
@@ -3,7 +3,7 @@ import {
DEFAULT_HALFTONE_SETTINGS,
normalizeHalftoneStudioSettings,
type HalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
} from '@/lib/halftone/state';
export type ShareableHalftoneState = {
settings: HalftoneStudioSettings;
@@ -57,9 +57,6 @@ export function encodeShareState(state: ShareableHalftoneState): string {
return toUrlSafeBase64(encodeUtf8ToBase64(JSON.stringify(payload)));
}
// Imported geometry only exists in the user's local session, so a shared URL
// can never reproduce a custom upload — fall back to the default shape if the
// hash points at one.
function sanitizeShapeKey(shapeKey: string): string {
const isBuiltinShape = DEFAULT_GEOMETRY_SPECS.some(
(spec) => spec.key === shapeKey,
@@ -1,10 +1,12 @@
import { HalftoneStudio } from '@/app/halftone/_components/HalftoneStudio';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/halftone',
title: 'Halftone Generator | Twenty',
description: 'Interactive halftone generator exported from Twenty.',
};
});
export default function HalftonePage() {
return <HalftoneStudio />;
+62 -17
View File
@@ -1,6 +1,10 @@
import { FooterVisibilityGate } from '@/app/_components/FooterVisibilityGate';
import { FOOTER_DATA } from '@/app/_constants/footer';
import { ContactCalModalRoot } from '@/app/components/ContactCalModal';
import { ScrollToTopOnRouteChange } from '@/app/_components/ScrollToTopOnRouteChange';
import { FOOTER_DATA } from '@/sections/Footer/data';
import { ContactCalModalRoot } from '@/lib/contact-cal';
import { PartnerApplicationModalRoot } from '@/lib/partner-application';
import { getSiteUrl } from '@/lib/seo';
import { DRACO_DECODER_ORIGIN } from '@/lib/visual-runtime/draco-decoder-path';
import { Footer } from '@/sections/Footer/components';
import { theme } from '@/theme';
import { cssVariables } from '@/theme/css-variables';
@@ -37,7 +41,7 @@ const vt323 = VT323({
display: 'swap',
});
css`
const _globalStyles = css`
:global(*),
:global(*::before),
:global(*::after) {
@@ -55,7 +59,11 @@ css`
display: flex;
font-family: ${theme.font.family.sans};
flex-direction: column;
/* dvh keeps the footer pinned to the visible viewport bottom on mobile
* Safari (where 100vh = large viewport with chrome hidden, leaving a
* gap when the URL bar is showing). vh fallback for older browsers. */
min-height: 100vh;
min-height: 100dvh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -65,10 +73,32 @@ const StyledMain = styled.main`
flex-grow: 1;
`;
const SITE_TITLE = 'Twenty | #1 open source CRM';
const SITE_DESCRIPTION =
'The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.';
export const metadata: Metadata = {
title: 'Twenty | #1 open source CRM',
description:
'The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.',
metadataBase: new URL(getSiteUrl()),
title: {
default: SITE_TITLE,
template: '%s | Twenty',
},
description: SITE_DESCRIPTION,
applicationName: 'Twenty',
openGraph: {
title: SITE_TITLE,
description: SITE_DESCRIPTION,
url: '/',
siteName: 'Twenty',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: SITE_TITLE,
description: SITE_DESCRIPTION,
site: '@twentycrm',
creator: '@twentycrm',
},
};
export default function RootLayout({
@@ -76,22 +106,37 @@ export default function RootLayout({
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<head>
{/*
* Warm up the connection to the DRACO decoder host so the first 3D
* model on the page does not pay the full TLS handshake cost the
* moment it starts decoding.
*/}
<link
crossOrigin="anonymous"
href={DRACO_DECODER_ORIGIN}
rel="preconnect"
/>
</head>
<body
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable} ${vt323.variable}`}
suppressHydrationWarning
>
<ContactCalModalRoot>
<StyledMain>{children}</StyledMain>
<FooterVisibilityGate>
<Footer.Root illustration={FOOTER_DATA.illustration}>
<Footer.Logo />
<Footer.Nav groups={FOOTER_DATA.navGroups} />
<Footer.Bottom
copyright={FOOTER_DATA.bottom.copyright}
links={FOOTER_DATA.socialLinks}
/>
</Footer.Root>
</FooterVisibilityGate>
<PartnerApplicationModalRoot>
<ScrollToTopOnRouteChange />
<StyledMain>{children}</StyledMain>
<FooterVisibilityGate>
<Footer.Root>
<Footer.Logo />
<Footer.Nav groups={FOOTER_DATA.navGroups} />
<Footer.Bottom
copyright={FOOTER_DATA.bottom.copyright}
links={FOOTER_DATA.socialLinks}
/>
</Footer.Root>
</FooterVisibilityGate>
</PartnerApplicationModalRoot>
</ContactCalModalRoot>
</body>
</html>
@@ -1,5 +0,0 @@
export { ENGAGEMENT_BAND_DATA } from './engagement-band';
export { HERO_DATA } from './hero';
export { SIGNOFF_DATA } from './signoff';
export { TESTIMONIALS_DATA } from './testimonials';
export { THREE_CARDS_ILLUSTRATION_DATA } from './three-cards-illustration';
@@ -4,10 +4,9 @@ import {
BaseButton,
buttonBaseStyles,
} from '@/design-system/components/Button/BaseButton';
import { usePartnerApplicationModal } from '@/lib/partner-application';
import { styled } from '@linaria/react';
import { usePartnerApplicationModal } from './PartnerApplicationModalRoot';
const StyledTrigger = styled.button`
${buttonBaseStyles}
`;
@@ -1,6 +1,6 @@
'use client';
import { TalkToUsButton } from '@/app/components/ContactCalModal';
import { TalkToUsButton } from '@/lib/contact-cal';
import { BecomePartnerButton } from './BecomePartnerButton';
@@ -1,6 +1,6 @@
'use client';
import { TalkToUsButton } from '@/app/components/ContactCalModal';
import { TalkToUsButton } from '@/lib/contact-cal';
import { BecomePartnerButton } from './BecomePartnerButton';
@@ -1,4 +1,3 @@
export { BecomePartnerButton } from './BecomePartnerButton';
export { PartnerApplicationModalRoot } from './PartnerApplicationModalRoot';
export { PartnerHeroCtas } from './PartnerHeroCtas';
export { PartnerSignoffCtas } from './PartnerSignoffCtas';
@@ -1,19 +1,18 @@
import { FAQ_DATA, MENU_DATA, TRUSTED_BY_DATA } from '@/app/_constants';
import { TalkToUsButton } from '@/app/components/ContactCalModal';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/app/customers/_constants';
import { FAQ_DATA } from '@/sections/Faq/data';
import { MENU_DATA } from '@/sections/Menu/data';
import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
import { TalkToUsButton } from '@/lib/contact-cal';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/partners/three-cards-illustration.data';
import { HERO_DATA } from '@/app/partners/hero.data';
import { SIGNOFF_DATA } from '@/app/partners/signoff.data';
import { TESTIMONIALS_DATA } from '@/app/partners/testimonials.data';
import {
THREE_CARDS_ILLUSTRATION_DATA,
HERO_DATA,
SIGNOFF_DATA,
TESTIMONIALS_DATA,
} from '@/app/partners/_constants';
import {
PartnerApplicationModalRoot,
PartnerHeroCtas,
PartnerSignoffCtas,
} from '@/app/partners/components/PartnerApplication';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Pages } from '@/lib/pages';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog/components';
@@ -26,6 +25,7 @@ import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import type { ThreeCardsScrollLayoutOptions } from '@/sections/ThreeCards/utils/three-cards-scroll-layout';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import { styled } from '@linaria/react';
import type { Metadata } from 'next';
@@ -46,18 +46,19 @@ const PromoSpacing = styled.div`
}
`;
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/partners',
title: 'Partners | Twenty',
description:
'Join our partner ecosystem and grow with us as we build the #1 open source CRM.',
};
});
export default async function PartnerPage() {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
<PartnerApplicationModalRoot>
<>
<Menu.Root
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
@@ -89,7 +90,10 @@ export default async function PartnerPage() {
</TrustedBy.Root>
<PromoSpacing>
<CaseStudyCatalog.Promo compactTop entries={CASE_STUDY_CATALOG_ENTRIES} />
<CaseStudyCatalog.Promo
compactTop
entries={CASE_STUDY_CATALOG_ENTRIES}
/>
</PromoSpacing>
<ThreeCards.Root backgroundColor={theme.colors.secondary.background[5]}>
@@ -130,14 +134,17 @@ export default async function PartnerPage() {
color={theme.colors.primary.text[100]}
page={Pages.Partners}
>
<Signoff.Heading page={Pages.Partners} segments={SIGNOFF_DATA.heading} />
<Signoff.Heading
page={Pages.Partners}
segments={SIGNOFF_DATA.heading}
/>
<Signoff.Body body={SIGNOFF_DATA.body} page={Pages.Partners} />
<Signoff.Cta>
<PartnerSignoffCtas />
</Signoff.Cta>
</Signoff.Root>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Root>
<Faq.Intro>
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
<Faq.Heading segments={FAQ_DATA.heading} />
@@ -158,6 +165,6 @@ export default async function PartnerPage() {
</Faq.Intro>
<Faq.Items questions={FAQ_DATA.questions} />
</Faq.Root>
</PartnerApplicationModalRoot>
</>
);
}
@@ -1,5 +0,0 @@
export { ENGAGEMENT_BAND_DATA } from './engagement-band';
export { HERO_DATA } from './hero';
export { PLAN_TABLE_DATA } from './plan-table';
export { PLANS_DATA } from './plans';
export { SALESFORCE_DATA } from './salesforce';
@@ -1,17 +1,13 @@
import { FAQ_DATA, MENU_DATA } from '@/app/_constants';
import { TalkToUsButton } from '@/app/components/ContactCalModal';
import {
BecomePartnerButton,
PartnerApplicationModalRoot,
} from '@/app/partners/components/PartnerApplication';
import {
ENGAGEMENT_BAND_DATA,
HERO_DATA,
PLAN_TABLE_DATA,
SALESFORCE_DATA,
} from '@/app/pricing/_constants';
import { FAQ_DATA } from '@/sections/Faq/data';
import { MENU_DATA } from '@/sections/Menu/data';
import { TalkToUsButton } from '@/lib/contact-cal';
import { BecomePartnerButton } from '@/app/partners/components/PartnerApplication';
import { ENGAGEMENT_BAND_DATA } from '@/app/pricing/engagement-band.data';
import { HERO_DATA } from '@/app/pricing/hero.data';
import { PLAN_TABLE_DATA } from '@/app/pricing/plan-table.data';
import { SALESFORCE_DATA } from '@/app/pricing/salesforce.data';
import { Eyebrow, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Pages } from '@/lib/pages';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { EngagementBand } from '@/sections/EngagementBand/components';
@@ -23,6 +19,7 @@ import { PricingStateProvider } from '@/sections/Plans/context/PricingStateConte
import { PlanTable } from '@/sections/PlanTable/components';
import { Salesforce } from '@/sections/Salesforce/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import { styled } from '@linaria/react';
import type { Metadata } from 'next';
@@ -38,18 +35,19 @@ const PricingBannerContainer = styled.div`
width: 100%;
`;
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/pricing',
title: 'Pricing | Twenty',
description:
'Plans that scale with your team. Compare tiers of the #1 open source CRM.',
};
});
export default async function PricingPage() {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
<PartnerApplicationModalRoot>
<>
<Menu.Root
backgroundColor="#F3F3F3"
scheme="primary"
@@ -118,7 +116,7 @@ export default async function PricingPage() {
pricing={SALESFORCE_DATA.pricing}
/>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Root>
<Faq.Intro>
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
<Faq.Heading segments={FAQ_DATA.heading} />
@@ -139,6 +137,6 @@ export default async function PricingPage() {
</Faq.Intro>
<Faq.Items questions={FAQ_DATA.questions} />
</Faq.Root>
</PartnerApplicationModalRoot>
</>
);
}
@@ -1,28 +1,30 @@
import type { Metadata } from 'next';
import { MENU_DATA } from '@/app/_constants';
import { MENU_DATA } from '@/sections/Menu/data';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { LegalDocumentPage } from '@/sections/LegalDocument/legal-document-page';
import { LegalDocument } from '@/sections/LegalDocument/components';
import { buildPageMetadata } from '@/lib/seo';
import { PrivacyPolicyDocument } from './_components';
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/privacy-policy',
title: 'Privacy Policy | Twenty',
description:
'How Twenty collects, uses, safeguards, and discloses information when you use Twenty.com and related services.',
};
});
export default async function PrivacyPolicyPage() {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
<LegalDocumentPage
<LegalDocument.Page
menuData={{ navItems: MENU_DATA.navItems, socialLinks: menuSocialLinks }}
title="Privacy Policy"
>
<PrivacyPolicyDocument />
</LegalDocumentPage>
</LegalDocument.Page>
);
}
@@ -1,24 +0,0 @@
import type { DemoDataType } from '@/sections/Demo/types';
export const DEMO_DATA: DemoDataType = {
eyebrow: {
heading: {
text: 'Try it live',
fontFamily: 'sans',
},
},
heading: [
{
text: 'A demo worth a',
fontFamily: 'serif',
},
{
text: ' thousand words',
fontFamily: 'sans',
},
],
image: {
src: '/images/product/demo/kanban.webp',
alt: '',
},
};
@@ -1,7 +0,0 @@
export { DEMO_DATA } from './demo';
export { FEATURE_DATA } from './feature';
export { HERO_DATA } from './hero';
export { SIGNOFF_DATA } from './signoff';
export { STEPPER_DATA } from './stepper';
export { TABS_DATA } from './tabs';
export { THREE_CARDS_ILLUSTRATION_DATA } from './three-cards';
@@ -1,65 +0,0 @@
import type { TabsDataType } from '@/sections/Tabs/types';
export const TABS_DATA: TabsDataType = {
eyebrow: {
heading: {
text: 'AI & Automation',
fontFamily: 'sans',
},
},
heading: [
{
text: 'AI that actually\nhelps you ',
fontFamily: 'serif',
},
{
text: 'work faster',
fontFamily: 'sans',
},
],
body: {
text: 'The AI understands your CRM and takes action.',
},
tabs: [
{
body: {
text: 'Show me all deals closing this month',
},
icon: 'search',
image: {
src: '/images/product/tabs/deals.webp',
alt: 'Deals view',
},
},
{
body: {
text: 'Create follow-up tasks for my top 10 accounts',
},
icon: 'eye',
image: {
src: '/images/product/tabs/tasks.webp',
alt: 'Tasks view',
},
},
{
body: {
text: "Summarize this customer's history",
},
icon: 'edit',
image: {
src: '/images/product/tabs/history.webp',
alt: 'History view',
},
},
{
body: {
text: 'Create a workflow that send an email sequence',
},
icon: 'check',
image: {
src: '/images/product/tabs/workflow.webp',
alt: 'Workflow view',
},
},
],
};
@@ -1,14 +1,14 @@
import { FAQ_DATA, MENU_DATA, TRUSTED_BY_DATA } from '@/app/_constants';
import { TalkToUsButton } from '@/app/components/ContactCalModal';
import {
FEATURE_DATA,
HERO_DATA,
SIGNOFF_DATA,
STEPPER_DATA,
THREE_CARDS_ILLUSTRATION_DATA,
} from '@/app/product/_constants';
import { FAQ_DATA } from '@/sections/Faq/data';
import { MENU_DATA } from '@/sections/Menu/data';
import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
import { TalkToUsButton } from '@/lib/contact-cal';
import { FEATURE_DATA } from '@/app/product/feature.data';
import { HERO_DATA } from '@/app/product/hero.data';
import { SIGNOFF_DATA } from '@/app/product/signoff.data';
import { STEPPER_DATA } from '@/app/product/stepper.data';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/product/three-cards.data';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Pages } from '@/lib/pages';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Faq } from '@/sections/Faq/components';
@@ -20,13 +20,15 @@ import { Signoff } from '@/sections/Signoff/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/product',
title: 'Product | Twenty',
description:
'Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.',
};
});
export default async function ProductPage() {
const stats = await fetchCommunityStats();
@@ -34,6 +36,16 @@ export default async function ProductPage() {
return (
<>
{/*
* Above-the-fold hero scene. Preload kicks off the GLB fetch in
* parallel with the JS chunk download, so the model is already in
* the browser cache by the time Three.js asks for it.
*/}
<link
as="fetch"
href="/illustrations/product/hero/hero.glb"
rel="preload"
/>
<Menu.Root
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
@@ -106,10 +118,10 @@ export default async function ProductPage() {
<Signoff.Root
backgroundColor={theme.colors.secondary.background[5]}
color={theme.colors.primary.text[100]}
page={Pages.Partners}
page={Pages.Product}
>
<Signoff.Heading page={Pages.Partners} segments={SIGNOFF_DATA.heading} />
<Signoff.Body body={SIGNOFF_DATA.body} page={Pages.Partners} />
<Signoff.Heading page={Pages.Product} segments={SIGNOFF_DATA.heading} />
<Signoff.Body body={SIGNOFF_DATA.body} page={Pages.Product} />
<Signoff.Cta>
<LinkButton
color="secondary"
@@ -126,7 +138,7 @@ export default async function ProductPage() {
</Signoff.Cta>
</Signoff.Root>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Root>
<Faq.Intro>
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
<Faq.Heading segments={FAQ_DATA.heading} />
@@ -1,5 +1,5 @@
import type { BodyType } from '@/design-system/components/Body/types/Body';
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
import type { BodyType } from '@/design-system/components/Body';
import type { HeadingType } from '@/design-system/components/Heading';
export const RELEASE_NOTES_HERO_HEADING: HeadingType[] = [
{ fontFamily: 'serif', text: 'Latest ' },
@@ -1,28 +1,30 @@
import { MENU_DATA } from '@/app/_constants';
import { MENU_DATA } from '@/sections/Menu/data';
import {
RELEASE_NOTES_HERO_BODY,
RELEASE_NOTES_HERO_HEADING,
} from '@/app/releases/_constants/hero';
} from '@/app/releases/hero.data';
import { LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Pages } from '@/lib/pages';
import { GitHubIcon } from '@/icons';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { fetchLatestGithubReleaseTag } from '@/lib/github/fetch-latest-release-tag';
import { fetchLatestGithubReleaseTag } from '@/lib/releases/fetch-latest-release-tag';
import { getVisibleReleaseNotes } from '@/lib/releases/get-visible-releases';
import { loadLocalReleaseNotes } from '@/lib/releases/load-local-release-notes';
import { Hero } from '@/sections/Hero/components';
import { Menu } from '@/sections/Menu/components';
import { ReleaseNotes } from '@/sections/ReleaseNotes/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
import { Fragment } from 'react';
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/releases',
title: 'Releases | Twenty',
description:
'Discover the newest features and improvements in Twenty, the #1 open source CRM.',
};
});
export default async function ReleasesPage() {
const allNotes = loadLocalReleaseNotes();
@@ -38,6 +40,15 @@ export default async function ReleasesPage() {
return (
<>
{/*
* Above-the-fold milestone scene texture. Preload kicks off the
* fetch in parallel with the JS chunk download.
*/}
<link
as="image"
href="/illustrations/generated/milestone.jpg"
rel="preload"
/>
<Menu.Root
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
@@ -1,7 +1,8 @@
import type { MetadataRoute } from 'next';
const SITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, '') ?? 'https://twenty.com';
import { getSiteUrl } from '@/lib/seo';
const SITE_URL = getSiteUrl();
export default function robots(): MetadataRoute.Robots {
return {
@@ -9,6 +10,7 @@ export default function robots(): MetadataRoute.Robots {
{
userAgent: '*',
allow: '/',
disallow: ['/halftone', '/enterprise/activate', '/api/'],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
@@ -1,9 +1,9 @@
import type { MetadataRoute } from 'next';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/app/customers/_constants/case-study-catalog';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
import { getSiteUrl } from '@/lib/seo';
const SITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, '') ?? 'https://twenty.com';
const SITE_URL = getSiteUrl();
const STATIC_ROUTES: ReadonlyArray<{
path: string;
@@ -1,28 +1,30 @@
import type { Metadata } from 'next';
import { MENU_DATA } from '@/app/_constants';
import { MENU_DATA } from '@/sections/Menu/data';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { LegalDocumentPage } from '@/sections/LegalDocument/legal-document-page';
import { LegalDocument } from '@/sections/LegalDocument/components';
import { buildPageMetadata } from '@/lib/seo';
import { TermsDocument } from './_components';
export const metadata: Metadata = {
export const metadata: Metadata = buildPageMetadata({
path: '/terms',
title: 'Terms of Service | Twenty',
description:
'Terms of Service for Twenty.com PBC, including use of Twenty.com, sub-domains, and related services.',
};
});
export default async function TermsPage() {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
<LegalDocumentPage
<LegalDocument.Page
menuData={{ navItems: MENU_DATA.navItems, socialLinks: menuSocialLinks }}
title="Terms of Service"
>
<TermsDocument />
</LegalDocumentPage>
</LegalDocument.Page>
);
}
@@ -1,7 +0,0 @@
import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData';
export const EDITORIAL_TWO: EditorialDataType = {
body: {
text: 'Every company on Salesforce runs the same objects, the same lifecycle stages, the same routing logic. They bought "best practices." But best practices are just the average, packaged and sold back as wisdom. When your CRM is identical to your competitor\'s, your AI agents will make the same decisions as theirs. Same scoring, same timing, same emails. You automated yourself into mediocrity.',
},
};
@@ -1,11 +0,0 @@
export { EDITORIAL_FOUR } from './editorial-four';
export { EDITORIAL_ONE } from './editorial-one';
export { EDITORIAL_THREE } from './editorial-three';
export { EDITORIAL_TWO } from './editorial-two';
export { HERO_DATA } from './hero';
export { MARQUEE_DATA } from './marquee';
export { QUOTE_DATA } from './quote';
export { SIGNOFF_DATA } from './signoff';
export { STATEMENT_ONE } from './statement-one';
export { STATEMENT_TWO } from './statement-two';
export { STEPPER_DATA } from './stepper';
@@ -1,9 +0,0 @@
import type { QuoteDataType } from '@/sections/Quote/types/QuoteData';
export const QUOTE_DATA: QuoteDataType = {
illustration: 'quoteQuotes',
heading: [
{ text: '“Best teams will run\n', fontFamily: 'serif' },
{ text: ' systems nobody else has.”', fontFamily: 'sans' },
],
};
@@ -1,8 +0,0 @@
import type { StatementDataType } from '@/sections/Statement/types';
export const STATEMENT_ONE: StatementDataType = {
heading: {
text: 'The best go-to-market teams will run systems nobody else has.',
fontFamily: 'serif',
},
};
@@ -1,8 +0,0 @@
import type { StatementDataType } from '@/sections/Statement/types';
export const STATEMENT_TWO: StatementDataType = {
heading: {
text: "Not because they're bigger. Because they built exactly what they needed.",
fontFamily: 'serif',
},
};
@@ -1,17 +0,0 @@
import type { WhyTwentyStepperDataType } from '@/sections/WhyTwentyStepper/types';
export const STEPPER_DATA: WhyTwentyStepperDataType = {
heading: { text: 'Our vision', fontFamily: 'serif' },
body: [
{
text: 'We believe every serious company will need a malleable system of record for customers. Not a collection of disconnected tools.',
},
{
text: 'Not a frozen monolith that only specialists can change. A living system that can evolve as fast as strategy evolves, while remaining trustworthy enough for humans, agents, and regulators to rely on.',
},
{
text: 'Because in the AI age, the winners will not be the companies with the most dashboards. They will be the companies whose systems turn information into decisions, and decisions into action, faster than everyone else.',
},
],
illustration: 'whyTwentyStepperLogo',
};

Some files were not shown because too many files have changed in this diff Show More