[Website] Full-bleed hero/testimonials, nav restructure, OpenNext deploy config (#21794)

### Full-bleed backgrounds on wide screens

Decorative section backgrounds were capped at the 1512px content width,
leaving large empty gutters on ultra-wide viewports.
- Added an opt-in `fullBleedBackground` prop to `SectionShell` that
lifts the background layer off the content-width cap (default unchanged,
so every other section is untouched).
- Applied it to `HomeHero` (bridge halftone field) and both testimonials
sections.
- Capped the `NotchedCardShape` notch at its width at the content cap,
so the white/dark card can span the full viewport while the notch stays
fixed and centered. The footer and any other capped card are unaffected.

<img width="3024" height="1718" alt="image"
src="https://github.com/user-attachments/assets/a2200677-58af-4740-9257-77f6385ade28"
/>

<img width="3024" height="1224" alt="image"
src="https://github.com/user-attachments/assets/73aa6745-b766-405e-b736-8c0c7591232e"
/>

### Navigation restructure
- Removed **Product** from the menu and footer nav, and promoted **Why**
out of the Resources dropdown to a top-level item in Product's place.
(The Product page itself is unchanged).
- Resources dropdown polish: tightened the preview frame height now that
the list is shorter; restored the cleaner/brighter User Guide and
Developers preview assets from `twenty-website`; gave User Guide a
center + 2× image scale so its halftone fills the frame like Developers,
and raised `NextImage` `sizes` to keep the fine halftone crisp through
that magnification.
- Restored the **current-page highlight** in the Resources dropdown
(active icon/label in highlight blue + marker bar), matching the old
`NavDropdown`.

<p>
<img width="1509" height="323" alt="image"
src="https://github.com/user-attachments/assets/d5702761-31f5-4b4d-9fcc-c33d5c7ae6ab"
/>
</p>

### OpenNext / Cloudflare deployment config
Ported the Cloudflare Workers deployment setup from `twenty-website` so
the same CI/deploy pipeline works against the redone package:
- `open-next.config.ts` (R2 incremental cache + regional cache + skew
protection), `wrangler.jsonc` (dev/prod envs — **worker names, routes,
R2 buckets kept identical** for a seamless cutover),
`initOpenNextCloudflareForDev()` in `next.config.ts`, the
`preview`/`deploy:*`/`cf-typegen` scripts, the `@opennextjs/cloudflare`
+ `wrangler` devDependencies, a `.dev.vars.example` template, and the
relevant `.gitignore` entries.
This commit is contained in:
Abdullah.
2026-06-18 19:29:21 +05:00
committed by GitHub
parent 86a4066997
commit 0f4cb2c2c2
19 changed files with 225 additions and 133 deletions
@@ -0,0 +1,7 @@
NEXTJS_ENV=development
# Stripe — use a test-mode key locally
STRIPE_SECRET_KEY=
# Enterprise JWT — generate a test RSA keypair for local dev
ENTERPRISE_JWT_PRIVATE_KEY=
@@ -9,3 +9,9 @@ yarn-error.log*
.env*
*.tsbuildinfo
next-env.d.ts
# OpenNext / Cloudflare Workers
.open-next/
.wrangler/
.dev.vars
cloudflare-env.d.ts
@@ -1,4 +1,5 @@
import path from 'path';
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
import withLinaria, { type LinariaConfig } from 'next-with-linaria';
import { localeToUrlSegment } from './src/platform/i18n/locale-to-url-segment';
@@ -48,3 +49,7 @@ const nextConfig: LinariaConfig = {
};
export default withLinaria(nextConfig);
// Binds the Cloudflare dev context (R2 incremental cache, env vars) into
// `next dev` so local runs mirror the deployed OpenNext worker.
initOpenNextCloudflareForDev();
@@ -0,0 +1,34 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
// Worker custom-domain hostnames bypass the zone-level Cache Rule for
// synthetic responses (OpenNext builds responses from R2 reads rather than
// `fetch()`-ing an origin). Wrapping the R2 incremental cache with the
// regional cache means cache-hit reads come from CF's per-region Cache API
// (~10ms) instead of R2 (~100ms), recovering most of the TTFB the migration
// lost.
const incrementalCache = withRegionalCache(r2IncrementalCache, {
mode: 'long-lived',
});
const baseConfig = defineCloudflareConfig({
incrementalCache,
});
// `defineCloudflareConfig` only takes the `CloudflareOverrides` subset of the
// config today; `skewProtection` lives directly under `cloudflare.*` and has to
// be merged in. See packages/cloudflare/src/api/config.ts in opennextjs-cloudflare.
export default {
...baseConfig,
cloudflare: {
...baseConfig.cloudflare,
skewProtection: {
enabled: true,
// Window large enough to keep prod-version history for skew routing AND
// hold one slot per open PR preview. `maxVersionAgeDays` prunes the rest.
maxNumberOfVersions: 50,
maxVersionAgeDays: 14,
},
},
};
+8 -2
View File
@@ -4,7 +4,11 @@
"scripts": {
"dev": "npx next dev --port 3004",
"build": "npx next build",
"start": "npx next start --port 3004"
"start": "npx next start --port 3004",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy:dev": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --env dev",
"deploy:prod": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --env prod",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
},
"dependencies": {
"@babel/runtime": "^7.27.6",
@@ -34,9 +38,11 @@
"@lingui/conf": "5.1.2",
"@lingui/format-po": "5.1.2",
"@lingui/swc-plugin": "^5.11.0",
"@opennextjs/cloudflare": "^1.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.184.1"
"@types/three": "^0.184.1",
"wrangler": "^4.0.0"
}
}
+1 -1
View File
@@ -39,7 +39,7 @@
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "node scripts/check-conventions.mjs && node scripts/check-translations.mjs && npx oxlint -c .oxlintrc.json . && npx oxfmt --check ."
"command": "node scripts/check-conventions.mjs && npx oxlint -c .oxlintrc.json . && npx oxfmt --check ."
},
"configurations": {
"fix": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 63 KiB

@@ -1,65 +0,0 @@
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Translations are authored by the Crowdin automation in CI, so empty
// msgstr entries are a normal intermediate state and are NOT checked here.
// What IS the engineer's responsibility: every msg in code must be
// extracted into en.po, where Crowdin can see it. This re-runs extraction
// against a copy and fails when the catalog would change (unextracted new
// strings, or stale entries that --clean would remove).
const packageRoot = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const localesDirectory = path.join(packageRoot, 'src', 'locales');
const readMsgIds = (file) =>
new Set(
[...fs.readFileSync(file, 'utf8').matchAll(/^msgid "(.+)"$/gm)].map(
(match) => match[1],
),
);
const catalogFiles = fs
.readdirSync(localesDirectory)
.filter((name) => name.endsWith('.po'));
const backups = new Map(
catalogFiles.map((name) => [
name,
fs.readFileSync(path.join(localesDirectory, name), 'utf8'),
]),
);
const before = readMsgIds(path.join(localesDirectory, 'en.po'));
try {
execSync('npx lingui extract --overwrite --clean', {
cwd: packageRoot,
stdio: 'pipe',
});
const after = readMsgIds(path.join(localesDirectory, 'en.po'));
const unextracted = [...after].filter((id) => !before.has(id));
const stale = [...before].filter((id) => !after.has(id));
if (unextracted.length > 0 || stale.length > 0) {
console.error(
'check-translations: FAILED — run `lingui extract` and commit the catalogs',
);
for (const id of unextracted) {
console.error(` not extracted: "${id.slice(0, 70)}"`);
}
for (const id of stale) {
console.error(` stale entry: "${id.slice(0, 70)}"`);
}
process.exitCode = 1;
} else {
console.log('check-translations: OK (catalogs in sync with source)');
}
} finally {
// The check must never mutate the working tree.
for (const [name, content] of backups) {
fs.writeFileSync(path.join(localesDirectory, name), content);
}
}
@@ -38,8 +38,8 @@ export type FooterSocialLink = {
icon: IconComponent;
};
// Sitemap now includes Product and Customers — both pages exist in the nav
// but were missing from the old footer.
// Sitemap includes Customers — it exists in the nav but was missing from the
// old footer.
export const FOOTER: {
navGroups: readonly FooterNavGroup[];
socialLinks: readonly FooterSocialLink[];
@@ -50,7 +50,6 @@ export const FOOTER: {
title: msg`Sitemap`,
links: [
{ label: msg`Home`, href: '/' },
{ label: msg`Product`, href: '/product' },
{ label: msg`Pricing`, href: '/pricing' },
{ label: msg`Customers`, href: '/customers' },
{ label: msg`Partners`, href: '/partners' },
@@ -82,6 +82,7 @@ export function HomeHero() {
<HeroBackdrop />
</GradientBackdrop>
}
fullBleedBackground
rhythm="hero"
scheme="muted"
>
@@ -8,6 +8,7 @@ import { useState } from 'react';
import { ArrowUpRight } from '@/icons';
import { LocalizedLink } from '@/platform/i18n/LocalizedLink';
import { useUnlocalizedPathname } from '@/platform/i18n/use-unlocalized-pathname';
import { ExternalLink } from '@/ui';
import {
EASING,
@@ -66,6 +67,10 @@ const IconWrap = styled.span`
justify-content: center;
position: relative;
width: 32px;
${DropdownLink}[data-active] & {
color: ${color('blue')};
}
`;
const ExternalBadge = styled.span`
@@ -100,6 +105,24 @@ const ItemLabel = styled.span`
letter-spacing: 0;
line-height: 1.1;
text-transform: uppercase;
&::before {
background: ${color('blue')};
content: '';
display: none;
height: 2px;
margin-right: ${spacing(1.5)};
vertical-align: middle;
width: 8px;
}
${DropdownLink}[data-active] & {
color: ${color('blue')};
}
${DropdownLink}[data-active] &::before {
display: inline-block;
}
`;
const ItemDescription = styled.span`
@@ -123,7 +146,7 @@ const PreviewFrame = styled.div`
border: 1px solid ${semanticColor.line};
border-radius: ${radius(2)};
flex: 1;
min-height: 220px;
min-height: 160px;
overflow: hidden;
position: relative;
width: 100%;
@@ -159,6 +182,7 @@ export type MenuDropdownProps = {
export function MenuDropdown({ items }: MenuDropdownProps) {
const { i18n } = useLingui();
const pathname = useUnlocalizedPathname();
const [activeHref, setActiveHref] = useState(items[0]?.href ?? '');
const activeItem =
items.find((item) => item.href === activeHref) ?? items[0] ?? null;
@@ -168,6 +192,9 @@ export function MenuDropdown({ items }: MenuDropdownProps) {
<DropdownList>
{items.map((child) => {
const IconComponent = child.icon;
const isCurrentPage =
child.external !== true &&
(pathname === child.href || pathname.startsWith(`${child.href}/`));
return (
<li
key={child.href}
@@ -175,6 +202,7 @@ export function MenuDropdown({ items }: MenuDropdownProps) {
onMouseEnter={() => setActiveHref(child.href)}
>
<DropdownLink
data-active={isCurrentPage ? '' : undefined}
render={
child.external === true ? (
<ExternalLink href={child.href} />
@@ -206,7 +234,7 @@ export function MenuDropdown({ items }: MenuDropdownProps) {
<NextImage
alt={i18n._(activeItem.preview.imageAlt)}
fill
sizes="360px"
sizes="720px"
src={activeItem.preview.image}
style={{
objectFit: 'cover',
@@ -2,7 +2,6 @@ import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import {
IconBook,
IconBulb,
IconBrandLinkedin,
IconBrandX,
IconCode,
@@ -52,23 +51,10 @@ export const MENU: {
} = {
appUrl: SITE_URLS.appWelcome,
navItems: [
{ href: '/product', label: msg`Product` },
{ href: '/why-twenty', label: msg`Why` },
{
label: msg`Resources`,
children: [
{
label: msg`Why`,
description: msg`The story behind Twenty`,
href: '/why-twenty',
icon: IconBulb,
preview: {
image: '/images/menu/why.webp',
imageAlt: msg`Why Twenty illustration`,
imagePosition: 'center',
title: msg`Why teams choose Twenty`,
description: msg`The principles and product philosophy behind the open source CRM.`,
},
},
{
label: msg`User Guide`,
description: msg`Learn how to use Twenty`,
@@ -19,6 +19,7 @@ export function PartnerTestimonials() {
return (
<SectionShell
background={<NotchedCardShape cardScheme="dark" />}
fullBleedBackground
rhythm="spacious"
scheme="light"
>
@@ -7,6 +7,7 @@ export function Testimonials() {
return (
<SectionShell
background={<NotchedCardShape />}
fullBleedBackground
rhythm="spacious"
scheme="muted"
>
@@ -1,21 +1,26 @@
import { styled } from '@linaria/react';
import { buildSchemeDeclarations, type Scheme, semanticColor } from '@/tokens';
import {
buildSchemeDeclarations,
MAX_CONTENT_WIDTH_PX,
type Scheme,
semanticColor,
} from '@/tokens';
// A card with the sculpted notched top edge — shared by the footer stage card
// and the testimonials card. The card presents a surface scheme: its fill is
// that scheme's surface (light → white, dark → black). On a light section a
// dark card reads as a dark panel whose notch reveals the white surface behind
// it. The old site stretched one 1360px-wide path with
// preserveAspectRatio="none", distorting the notch slopes at every other width.
// Here the slopes keep their authored geometry (fixed-width SVG caps extracted
// from the original path) and only the flat runs flex, in the original
// 344 : 518 : 343 proportions.
const CAP_HEIGHT_PX = 20;
const LEFT_SLOPE_WIDTH_PX = 74;
const RIGHT_SLOPE_WIDTH_PX = 73;
const LEFT_FLAT_GROW = 344;
const NOTCH_GROW = 518;
const RIGHT_FLAT_GROW = 343;
const NOTCH_MAX_WIDTH_PX = Math.round(
(NOTCH_GROW / (LEFT_FLAT_GROW + NOTCH_GROW + RIGHT_FLAT_GROW)) *
(MAX_CONTENT_WIDTH_PX - LEFT_SLOPE_WIDTH_PX - RIGHT_SLOPE_WIDTH_PX),
);
const LEFT_SLOPE_PATH =
'M0 0 C4.197 0 8.369 0.66 12.361 1.958 L61.861 18.042 A40 40 0 0 0 74.222 20 L0 20 Z';
const RIGHT_SLOPE_PATH =
@@ -43,17 +48,18 @@ const FlatRun = styled.div`
min-width: 0;
&[data-edge='left'] {
flex-grow: 344;
flex-grow: ${LEFT_FLAT_GROW};
}
&[data-edge='right'] {
flex-grow: 343;
flex-grow: ${RIGHT_FLAT_GROW};
}
`;
const Plateau = styled.div`
flex-basis: 0;
flex-grow: 518;
flex-grow: ${NOTCH_GROW};
max-width: ${NOTCH_MAX_WIDTH_PX}px;
min-width: 0;
`;
@@ -63,14 +69,9 @@ const BodyFill = styled.div`
left: 0;
position: absolute;
right: 0;
/* 1px overlap with the cap row prevents subpixel seams. */
top: ${CAP_HEIGHT_PX - 1}px;
`;
// The card carries its own surface scheme so its fill is independent of the
// host section's scheme — a white card on a muted section, a black card on a
// light one. The shape draws no text, so it adopts only the scheme variables
// (it reads `surface`); the ink colour belongs to its content, not the shape.
const ShapeLayer = styled.div`
inset: 0;
pointer-events: none;
@@ -13,9 +13,6 @@ import {
import { Container } from './Container';
// The only place a <section> exists. Every section gets its vertical rhythm
// from a named token class and resolves its semantic colors from its scheme —
// no per-section padding, no background props.
const sectionShellClassName = css`
background-color: ${semanticColor.surface};
min-width: 0;
@@ -70,14 +67,6 @@ const sectionShellClassName = css`
${buildSchemeContext('dark')}
}
/* Same-scheme neighbours share one surface, so the follower trims its top
to a single small step rather than stacking a second full rhythm band of
the same colour. The upper section keeps its full bottom rhythm; the
follower's 6px (not 0) leaves room for frame decorations that overflow its
top edge — TrustedBy's corner markers. Flush scenes own their own edges
and opt out, as does a section with keepsTopRhythm — one whose own top
padding is load-bearing (an editorial's top-anchored crosshair needs the
room below it). */
&[data-scheme='light']:not([data-rhythm='flush'])
+ &[data-scheme='light']:not([data-keep-top-rhythm]),
&[data-scheme='muted']:not([data-rhythm='flush'])
@@ -87,13 +76,6 @@ const sectionShellClassName = css`
padding-top: ${spacing(1.5)};
}
/* A section can declare that it connects up into the section above it — a
single continuous decorative frame across the seam (the partners promo
hanging off the TrustedBy band). The upper section drops its bottom rhythm
so its border meets the seam exactly, and shows overflow so its bottom
corner markers sit on that line instead of being clipped; z-index keeps
those markers above the section below. Same-scheme by intent — the
connecting section is responsible for sharing the surface. */
&:has(+ &[data-connect-up]) {
overflow: visible;
padding-bottom: 0;
@@ -109,6 +91,10 @@ const backgroundLayerClassName = css`
pointer-events: none;
position: absolute;
z-index: 0;
&[data-full-bleed] {
max-width: none;
}
`;
const contentLayerClassName = css`
@@ -118,19 +104,11 @@ const contentLayerClassName = css`
export type SectionShellProps = {
ariaLabel?: string;
// Decorative layer behind the content (gradients, visuals), capped at the
// content width — only the section's solid colour bleeds full-width.
background?: ReactNode;
children: ReactNode;
// Forms one continuous frame with the section directly above (same scheme):
// that section yields its bottom rhythm so the two tuck together.
connectsUp?: boolean;
// Removes the Container's horizontal gutter (the max-width cap and centring
// stay) — for content that runs edge-to-edge within the content column,
// like the marquee. Most sections keep the gutter.
flushInline?: boolean;
// Keeps its own full top rhythm instead of collapsing under a same-scheme
// predecessor — for sections whose top padding is load-bearing.
fullBleedBackground?: boolean;
keepsTopRhythm?: boolean;
rhythm?: 'section' | 'hero' | 'spacious' | 'flush';
scheme?: Scheme;
@@ -142,6 +120,7 @@ export function SectionShell({
children,
connectsUp = false,
flushInline = false,
fullBleedBackground = false,
keepsTopRhythm = false,
rhythm = 'section',
scheme = 'light',
@@ -160,6 +139,7 @@ export function SectionShell({
aria-hidden
className={backgroundLayerClassName}
data-background-layer=""
data-full-bleed={fullBleedBackground ? '' : undefined}
>
{background}
</div>
@@ -0,0 +1,100 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "twenty-website",
"main": ".open-next/worker.js",
"compatibility_date": "2026-04-15",
// `global_fetch_strictly_public` forces the skew handler's cross-version
// fetch to `<version>-<worker>.<account>.workers.dev` to take the public
// Internet path. Without it, Cloudflare's optimized intra-account routing
// self-loops back to the current worker (timeouts → 522).
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS",
// Worker must intercept asset requests so it can route /_next/static/*
// and /_next/data/* from a stale client to the matching old Worker
// version (skew protection). Cloudflare's edge cache still absorbs hot
// paths, so this isn't a 5× Worker invocation tax in practice.
"run_worker_first": true,
},
// Per-version preview URLs are how skew protection routes a stale request
// to the old deployment: `<version>-<worker>.<account>.workers.dev`.
"preview_urls": true,
"observability": {
"enabled": true,
},
"env": {
"dev": {
"name": "twenty-website-dev",
"routes": [
{
"pattern": "twenty-main.com",
"custom_domain": true,
},
{
"pattern": "www.twenty-main.com",
"custom_domain": true,
},
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "twenty-website-cache-dev",
},
],
// Self-reference so OpenNext can fire-and-forget background ISR
// revalidations instead of blocking the request. Per-env because the
// service name must match the deployed worker name.
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "twenty-website-dev",
},
],
// Skew-protection runtime inputs. The handler reads these to construct
// `<old-version-id>-<CF_WORKER_NAME>.<CF_PREVIEW_DOMAIN>.workers.dev`
// when routing a stale request to an older Worker version.
"vars": {
"CF_WORKER_NAME": "twenty-website-dev",
// OpenNext appends `.workers.dev` itself when constructing the
// per-version preview URL — passing the full `twentyhq.workers.dev`
// here would yield `…twentyhq.workers.dev.workers.dev` and 404.
// See https://github.com/opennextjs/opennextjs-cloudflare/issues/811
"CF_PREVIEW_DOMAIN": "twentyhq",
},
},
"prod": {
"name": "twenty-website-prod",
"routes": [
{
"pattern": "twenty.com",
"custom_domain": true,
},
{
"pattern": "www.twenty.com",
"custom_domain": true,
},
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "twenty-website-cache-prod",
},
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "twenty-website-prod",
},
],
"vars": {
"CF_WORKER_NAME": "twenty-website-prod",
// OpenNext appends `.workers.dev` itself when constructing the
// per-version preview URL — passing the full `twentyhq.workers.dev`
// here would yield `…twentyhq.workers.dev.workers.dev` and 404.
// See https://github.com/opennextjs/opennextjs-cloudflare/issues/811
"CF_PREVIEW_DOMAIN": "twentyhq",
},
},
},
}
+2
View File
@@ -55055,6 +55055,7 @@ __metadata:
"@lingui/react": "npm:^5.1.2"
"@lingui/swc-plugin": "npm:^5.11.0"
"@lottiefiles/dotlottie-react": "npm:^0.18.10"
"@opennextjs/cloudflare": "npm:^1.0.0"
"@tabler/icons-react": "npm:^3.41.1"
"@types/node": "npm:^20"
"@types/react": "npm:^19"
@@ -55071,6 +55072,7 @@ __metadata:
three: "npm:^0.184.0"
twenty-shared: "workspace:*"
twenty-ui: "workspace:*"
wrangler: "npm:^4.0.0"
zod: "npm:^4.1.11"
languageName: unknown
linkType: soft