diff --git a/packages/twenty-website-redone/.dev.vars.example b/packages/twenty-website-redone/.dev.vars.example new file mode 100644 index 0000000000000..9cd2773ff6c25 --- /dev/null +++ b/packages/twenty-website-redone/.dev.vars.example @@ -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= diff --git a/packages/twenty-website-redone/.gitignore b/packages/twenty-website-redone/.gitignore index 048c8bda80d28..2f66d3a5c20a3 100644 --- a/packages/twenty-website-redone/.gitignore +++ b/packages/twenty-website-redone/.gitignore @@ -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 diff --git a/packages/twenty-website-redone/next.config.ts b/packages/twenty-website-redone/next.config.ts index 114bdc7b6c40f..f366dddfa1cc0 100644 --- a/packages/twenty-website-redone/next.config.ts +++ b/packages/twenty-website-redone/next.config.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(); diff --git a/packages/twenty-website-redone/open-next.config.ts b/packages/twenty-website-redone/open-next.config.ts new file mode 100644 index 0000000000000..34c3cbffa7295 --- /dev/null +++ b/packages/twenty-website-redone/open-next.config.ts @@ -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, + }, + }, +}; diff --git a/packages/twenty-website-redone/package.json b/packages/twenty-website-redone/package.json index c632b51a0442f..9b53be79ce1c6 100644 --- a/packages/twenty-website-redone/package.json +++ b/packages/twenty-website-redone/package.json @@ -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" } } diff --git a/packages/twenty-website-redone/project.json b/packages/twenty-website-redone/project.json index 4d15ae39c215d..9779c41f081a8 100644 --- a/packages/twenty-website-redone/project.json +++ b/packages/twenty-website-redone/project.json @@ -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": { diff --git a/packages/twenty-website-redone/public/images/menu/developers.webp b/packages/twenty-website-redone/public/images/menu/developers.webp index b521308da65a5..d5048ad9af37d 100644 Binary files a/packages/twenty-website-redone/public/images/menu/developers.webp and b/packages/twenty-website-redone/public/images/menu/developers.webp differ diff --git a/packages/twenty-website-redone/public/images/menu/user-guide.webp b/packages/twenty-website-redone/public/images/menu/user-guide.webp index 98e171701ce1b..35e119c8e2d91 100644 Binary files a/packages/twenty-website-redone/public/images/menu/user-guide.webp and b/packages/twenty-website-redone/public/images/menu/user-guide.webp differ diff --git a/packages/twenty-website-redone/scripts/check-translations.mjs b/packages/twenty-website-redone/scripts/check-translations.mjs deleted file mode 100644 index 3821dfdfd7f01..0000000000000 --- a/packages/twenty-website-redone/scripts/check-translations.mjs +++ /dev/null @@ -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); - } -} diff --git a/packages/twenty-website-redone/src/sections/footer/footer.data.ts b/packages/twenty-website-redone/src/sections/footer/footer.data.ts index d48f9cbfdd289..6ee0d7129ba59 100644 --- a/packages/twenty-website-redone/src/sections/footer/footer.data.ts +++ b/packages/twenty-website-redone/src/sections/footer/footer.data.ts @@ -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' }, diff --git a/packages/twenty-website-redone/src/sections/home-hero/HomeHero.tsx b/packages/twenty-website-redone/src/sections/home-hero/HomeHero.tsx index 46bc94716ba47..ef1e8a944e087 100644 --- a/packages/twenty-website-redone/src/sections/home-hero/HomeHero.tsx +++ b/packages/twenty-website-redone/src/sections/home-hero/HomeHero.tsx @@ -82,6 +82,7 @@ export function HomeHero() { } + fullBleedBackground rhythm="hero" scheme="muted" > diff --git a/packages/twenty-website-redone/src/sections/menu/MenuDropdown.tsx b/packages/twenty-website-redone/src/sections/menu/MenuDropdown.tsx index 16fd4b7c07fca..4fe951da485cd 100644 --- a/packages/twenty-website-redone/src/sections/menu/MenuDropdown.tsx +++ b/packages/twenty-website-redone/src/sections/menu/MenuDropdown.tsx @@ -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) { {items.map((child) => { const IconComponent = child.icon; + const isCurrentPage = + child.external !== true && + (pathname === child.href || pathname.startsWith(`${child.href}/`)); return (
  • setActiveHref(child.href)} > @@ -206,7 +234,7 @@ export function MenuDropdown({ items }: MenuDropdownProps) { } + fullBleedBackground rhythm="spacious" scheme="light" > diff --git a/packages/twenty-website-redone/src/sections/testimonials/Testimonials.tsx b/packages/twenty-website-redone/src/sections/testimonials/Testimonials.tsx index 68c0d9c896a68..740207620291d 100644 --- a/packages/twenty-website-redone/src/sections/testimonials/Testimonials.tsx +++ b/packages/twenty-website-redone/src/sections/testimonials/Testimonials.tsx @@ -7,6 +7,7 @@ export function Testimonials() { return ( } + fullBleedBackground rhythm="spacious" scheme="muted" > diff --git a/packages/twenty-website-redone/src/ui/NotchedCardShape.tsx b/packages/twenty-website-redone/src/ui/NotchedCardShape.tsx index 7e9c5d6256022..462fcbd72cef7 100644 --- a/packages/twenty-website-redone/src/ui/NotchedCardShape.tsx +++ b/packages/twenty-website-redone/src/ui/NotchedCardShape.tsx @@ -1,21 +1,26 @@ import { styled } from '@linaria/react'; -import { buildSchemeDeclarations, 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. +import { + buildSchemeDeclarations, + MAX_CONTENT_WIDTH_PX, + type Scheme, + semanticColor, +} from '@/tokens'; + 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; diff --git a/packages/twenty-website-redone/src/ui/SectionShell.tsx b/packages/twenty-website-redone/src/ui/SectionShell.tsx index e035237ecd59d..4389653ead105 100644 --- a/packages/twenty-website-redone/src/ui/SectionShell.tsx +++ b/packages/twenty-website-redone/src/ui/SectionShell.tsx @@ -13,9 +13,6 @@ import { import { Container } from './Container'; -// The only place a
    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} diff --git a/packages/twenty-website-redone/wrangler.jsonc b/packages/twenty-website-redone/wrangler.jsonc new file mode 100644 index 0000000000000..f2f0bd76b0489 --- /dev/null +++ b/packages/twenty-website-redone/wrangler.jsonc @@ -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 `-..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: `-..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 + // `-..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", + }, + }, + }, +} diff --git a/yarn.lock b/yarn.lock index 8ef1284879931..dd5c8fc1af81c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -55054,6 +55054,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" @@ -55070,6 +55071,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