diff --git a/packages/twenty-website-new/.env.example b/packages/twenty-website-new/.env.example index b035dbf41c..4a4f9e322b 100644 --- a/packages/twenty-website-new/.env.example +++ b/packages/twenty-website-new/.env.example @@ -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 8–16; 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= diff --git a/packages/twenty-website-new/.gitignore b/packages/twenty-website-new/.gitignore index 3adcfc17c5..c641bc1572 100644 --- a/packages/twenty-website-new/.gitignore +++ b/packages/twenty-website-new/.gitignore @@ -41,3 +41,7 @@ next-env.d.ts # DB *.sqlite + +# local-only memory files (not for commit) +/Documentation.md +/Todo.md diff --git a/packages/twenty-website-new/.oxlintrc.json b/packages/twenty-website-new/.oxlintrc.json index 3402a729a8..f366c9222f 100644 --- a/packages/twenty-website-new/.oxlintrc.json +++ b/packages/twenty-website-new/.oxlintrc.json @@ -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/
/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.)" + } + ] + } + ] + } + } + ] } diff --git a/packages/twenty-website-new/README.md b/packages/twenty-website-new/README.md index 663a198e11..b4dc7f1c32 100644 --- a/packages/twenty-website-new/README.md +++ b/packages/twenty-website-new/README.md @@ -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 +``` diff --git a/packages/twenty-website-new/jest.config.mjs b/packages/twenty-website-new/jest.config.mjs index 00867abc80..a96081bf01 100644 --- a/packages/twenty-website-new/jest.config.mjs +++ b/packages/twenty-website-new/jest.config.mjs @@ -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', diff --git a/packages/twenty-website-new/next.config.ts b/packages/twenty-website-new/next.config.ts index e7a0fdef2c..a1fed9ad44 100644 --- a/packages/twenty-website-new/next.config.ts +++ b/packages/twenty-website-new/next.config.ts @@ -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', diff --git a/packages/twenty-website-new/output/playwright/customers-reference.png b/packages/twenty-website-new/output/playwright/customers-reference.png deleted file mode 100644 index 7e0706b19d..0000000000 Binary files a/packages/twenty-website-new/output/playwright/customers-reference.png and /dev/null differ diff --git a/packages/twenty-website-new/output/playwright/partners-full-after.png b/packages/twenty-website-new/output/playwright/partners-full-after.png deleted file mode 100644 index 32671e79f9..0000000000 Binary files a/packages/twenty-website-new/output/playwright/partners-full-after.png and /dev/null differ diff --git a/packages/twenty-website-new/output/playwright/partners-seam-after.png b/packages/twenty-website-new/output/playwright/partners-seam-after.png deleted file mode 100644 index e03549ae2c..0000000000 Binary files a/packages/twenty-website-new/output/playwright/partners-seam-after.png and /dev/null differ diff --git a/packages/twenty-website-new/package.json b/packages/twenty-website-new/package.json index f0eff2953d..42478f69dd 100644 --- a/packages/twenty-website-new/package.json +++ b/packages/twenty-website-new/package.json @@ -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" } } diff --git a/packages/twenty-website-new/project.json b/packages/twenty-website-new/project.json index 1b1adda042..1b73738b73 100644 --- a/packages/twenty-website-new/project.json +++ b/packages/twenty-website-new/project.json @@ -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", diff --git a/packages/twenty-website-new/public/illustrations/common/glass-environment.jpg b/packages/twenty-website-new/public/illustrations/common/glass-environment.jpg new file mode 100644 index 0000000000..0ddd056788 Binary files /dev/null and b/packages/twenty-website-new/public/illustrations/common/glass-environment.jpg differ diff --git a/packages/twenty-website-new/scripts/check-boundaries.mjs b/packages/twenty-website-new/scripts/check-boundaries.mjs new file mode 100644 index 0000000000..fe0836e5d4 --- /dev/null +++ b/packages/twenty-website-new/scripts/check-boundaries.mjs @@ -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); + }); diff --git a/packages/twenty-website-new/scripts/check-lottie-frames.mjs b/packages/twenty-website-new/scripts/check-lottie-frames.mjs new file mode 100644 index 0000000000..02e56de4ad --- /dev/null +++ b/packages/twenty-website-new/scripts/check-lottie-frames.mjs @@ -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)); +}); diff --git a/packages/twenty-website-new/scripts/check-section-shape.mjs b/packages/twenty-website-new/scripts/check-section-shape.mjs new file mode 100644 index 0000000000..ab49bf41a1 --- /dev/null +++ b/packages/twenty-website-new/scripts/check-section-shape.mjs @@ -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
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); + }); diff --git a/packages/twenty-website-new/src/app/(home)/_constants/index.ts b/packages/twenty-website-new/src/app/(home)/_constants/index.ts deleted file mode 100644 index 3c289b35cb..0000000000 --- a/packages/twenty-website-new/src/app/(home)/_constants/index.ts +++ /dev/null @@ -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'; diff --git a/packages/twenty-website-new/src/app/(home)/_constants/helped.ts b/packages/twenty-website-new/src/app/(home)/helped.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/(home)/_constants/helped.ts rename to packages/twenty-website-new/src/app/(home)/helped.data.ts diff --git a/packages/twenty-website-new/src/app/(home)/_constants/hero.ts b/packages/twenty-website-new/src/app/(home)/hero.data.ts similarity index 99% rename from packages/twenty-website-new/src/app/(home)/_constants/hero.ts rename to packages/twenty-website-new/src/app/(home)/hero.data.ts index bf9322fa80..cb76db6bf8 100644 --- a/packages/twenty-website-new/src/app/(home)/_constants/hero.ts +++ b/packages/twenty-website-new/src/app/(home)/hero.data.ts @@ -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, diff --git a/packages/twenty-website-new/src/app/(home)/_constants/home-stepper.ts b/packages/twenty-website-new/src/app/(home)/home-stepper.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/(home)/_constants/home-stepper.ts rename to packages/twenty-website-new/src/app/(home)/home-stepper.data.ts diff --git a/packages/twenty-website-new/src/app/(home)/page.tsx b/packages/twenty-website-new/src/app/(home)/page.tsx index 61a6bf829d..7814a5c74f 100644 --- a/packages/twenty-website-new/src/app/(home)/page.tsx +++ b/packages/twenty-website-new/src/app/(home)/page.tsx @@ -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. + */} + - + diff --git a/packages/twenty-website-new/src/app/(home)/_constants/problem.ts b/packages/twenty-website-new/src/app/(home)/problem.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/(home)/_constants/problem.ts rename to packages/twenty-website-new/src/app/(home)/problem.data.ts diff --git a/packages/twenty-website-new/src/app/(home)/_constants/testimonials.ts b/packages/twenty-website-new/src/app/(home)/testimonials.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/(home)/_constants/testimonials.ts rename to packages/twenty-website-new/src/app/(home)/testimonials.data.ts diff --git a/packages/twenty-website-new/src/app/(home)/_constants/three-cards-feature.ts b/packages/twenty-website-new/src/app/(home)/three-cards-feature.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/(home)/_constants/three-cards-feature.ts rename to packages/twenty-website-new/src/app/(home)/three-cards-feature.data.ts diff --git a/packages/twenty-website-new/src/app/(home)/_constants/three-cards-illustration.ts b/packages/twenty-website-new/src/app/(home)/three-cards-illustration.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/(home)/_constants/three-cards-illustration.ts rename to packages/twenty-website-new/src/app/(home)/three-cards-illustration.data.ts diff --git a/packages/twenty-website-new/src/app/_components/FooterVisibilityGate.tsx b/packages/twenty-website-new/src/app/_components/FooterVisibilityGate.tsx index aa2ffb7d39..fe1f37df53 100644 --- a/packages/twenty-website-new/src/app/_components/FooterVisibilityGate.tsx +++ b/packages/twenty-website-new/src/app/_components/FooterVisibilityGate.tsx @@ -7,9 +7,7 @@ type FooterVisibilityGateProps = { children: ReactNode; }; -export function FooterVisibilityGate({ - children, -}: FooterVisibilityGateProps) { +export function FooterVisibilityGate({ children }: FooterVisibilityGateProps) { const pathname = usePathname(); if (pathname === '/halftone') { diff --git a/packages/twenty-website-new/src/app/_components/ScrollToTopOnRouteChange.tsx b/packages/twenty-website-new/src/app/_components/ScrollToTopOnRouteChange.tsx new file mode 100644 index 0000000000..4753f5f836 --- /dev/null +++ b/packages/twenty-website-new/src/app/_components/ScrollToTopOnRouteChange.tsx @@ -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; +} diff --git a/packages/twenty-website-new/src/app/_constants/index.ts b/packages/twenty-website-new/src/app/_constants/index.ts deleted file mode 100644 index a33e6458f9..0000000000 --- a/packages/twenty-website-new/src/app/_constants/index.ts +++ /dev/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'; diff --git a/packages/twenty-website-new/src/app/api/enterprise/activate/route.ts b/packages/twenty-website-new/src/app/api/enterprise/activate/route.ts index 2d6d30603e..1ad37aac67 100644 --- a/packages/twenty-website-new/src/app/api/enterprise/activate/route.ts +++ b/packages/twenty-website-new/src/app/api/enterprise/activate/route.ts @@ -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 = [ 'paid', 'no_payment_required', diff --git a/packages/twenty-website-new/src/app/api/enterprise/checkout/route.ts b/packages/twenty-website-new/src/app/api/enterprise/checkout/route.ts index f0870ecc7e..4778010a9c 100644 --- a/packages/twenty-website-new/src/app/api/enterprise/checkout/route.ts +++ b/packages/twenty-website-new/src/app/api/enterprise/checkout/route.ts @@ -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'; diff --git a/packages/twenty-website-new/src/app/api/enterprise/portal/route.ts b/packages/twenty-website-new/src/app/api/enterprise/portal/route.ts index 42f4362e0a..1e8c0d0edb 100644 --- a/packages/twenty-website-new/src/app/api/enterprise/portal/route.ts +++ b/packages/twenty-website-new/src/app/api/enterprise/portal/route.ts @@ -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'; diff --git a/packages/twenty-website-new/src/app/api/enterprise/seats/route.ts b/packages/twenty-website-new/src/app/api/enterprise/seats/route.ts index 529b18ec26..55cdfa933c 100644 --- a/packages/twenty-website-new/src/app/api/enterprise/seats/route.ts +++ b/packages/twenty-website-new/src/app/api/enterprise/seats/route.ts @@ -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'; diff --git a/packages/twenty-website-new/src/app/api/enterprise/status/route.ts b/packages/twenty-website-new/src/app/api/enterprise/status/route.ts index 33f0c2bbc7..17f87004a6 100644 --- a/packages/twenty-website-new/src/app/api/enterprise/status/route.ts +++ b/packages/twenty-website-new/src/app/api/enterprise/status/route.ts @@ -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'; diff --git a/packages/twenty-website-new/src/app/api/enterprise/validate/route.ts b/packages/twenty-website-new/src/app/api/enterprise/validate/route.ts index 7c841792bb..1a7eb890b0 100644 --- a/packages/twenty-website-new/src/app/api/enterprise/validate/route.ts +++ b/packages/twenty-website-new/src/app/api/enterprise/validate/route.ts @@ -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'; diff --git a/packages/twenty-website-new/src/app/api/partner-application/__tests__/route.test.ts b/packages/twenty-website-new/src/app/api/partner-application/__tests__/route.test.ts new file mode 100644 index 0000000000..96594b1385 --- /dev/null +++ b/packages/twenty-website-new/src/app/api/partner-application/__tests__/route.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-website-new/src/app/api/partner-application/route.ts b/packages/twenty-website-new/src/app/api/partner-application/route.ts index b47110a831..21f801147d 100644 --- a/packages/twenty-website-new/src/app/api/partner-application/route.ts +++ b/packages/twenty-website-new/src/app/api/partner-application/route.ts @@ -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(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 }); } diff --git a/packages/twenty-website-new/src/app/components/ContactCalModal/ContactCalModal.tsx b/packages/twenty-website-new/src/app/components/ContactCalModal/ContactCalModal.tsx deleted file mode 100644 index e2e4eb3848..0000000000 --- a/packages/twenty-website-new/src/app/components/ContactCalModal/ContactCalModal.tsx +++ /dev/null @@ -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: () => Loading form…, - 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) => { - 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( - - - Talk to us - - - - - , - document.body, - ); -} diff --git a/packages/twenty-website-new/src/app/customers/9dots/page.tsx b/packages/twenty-website-new/src/app/customers/9dots/page.tsx index 344e530eb3..93732df179 100644 --- a/packages/twenty-website-new/src/app/customers/9dots/page.tsx +++ b/packages/twenty-website-new/src/app/customers/9dots/page.tsx @@ -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(); diff --git a/packages/twenty-website-new/src/app/customers/_components/CustomersCaseStudySignoff.tsx b/packages/twenty-website-new/src/app/customers/_components/CustomersCaseStudySignoff.tsx index 7ab4079d1c..4f344ededc 100644 --- a/packages/twenty-website-new/src/app/customers/_components/CustomersCaseStudySignoff.tsx +++ b/packages/twenty-website-new/src/app/customers/_components/CustomersCaseStudySignoff.tsx @@ -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'; diff --git a/packages/twenty-website-new/src/app/customers/act-education/page.tsx b/packages/twenty-website-new/src/app/customers/act-education/page.tsx index fd4bc61d69..3e020a6fe6 100644 --- a/packages/twenty-website-new/src/app/customers/act-education/page.tsx +++ b/packages/twenty-website-new/src/app/customers/act-education/page.tsx @@ -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(); diff --git a/packages/twenty-website-new/src/app/customers/alternative-partners/page.tsx b/packages/twenty-website-new/src/app/customers/alternative-partners/page.tsx index 6e5a63465e..f79c0b87e4 100644 --- a/packages/twenty-website-new/src/app/customers/alternative-partners/page.tsx +++ b/packages/twenty-website-new/src/app/customers/alternative-partners/page.tsx @@ -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(); diff --git a/packages/twenty-website-new/src/app/customers/elevate-consulting/page.tsx b/packages/twenty-website-new/src/app/customers/elevate-consulting/page.tsx index 3d97936427..b3fd65cd9a 100644 --- a/packages/twenty-website-new/src/app/customers/elevate-consulting/page.tsx +++ b/packages/twenty-website-new/src/app/customers/elevate-consulting/page.tsx @@ -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(); diff --git a/packages/twenty-website-new/src/app/customers/netzero/page.tsx b/packages/twenty-website-new/src/app/customers/netzero/page.tsx index c50831f1cf..0bd7aafad4 100644 --- a/packages/twenty-website-new/src/app/customers/netzero/page.tsx +++ b/packages/twenty-website-new/src/app/customers/netzero/page.tsx @@ -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(); diff --git a/packages/twenty-website-new/src/app/customers/page.tsx b/packages/twenty-website-new/src/app/customers/page.tsx index cb197c852c..0a176f1340 100644 --- a/packages/twenty-website-new/src/app/customers/page.tsx +++ b/packages/twenty-website-new/src/app/customers/page.tsx @@ -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() { - + diff --git a/packages/twenty-website-new/src/app/customers/w3villa/page.tsx b/packages/twenty-website-new/src/app/customers/w3villa/page.tsx index d2847bf54b..90ac376afa 100644 --- a/packages/twenty-website-new/src/app/customers/w3villa/page.tsx +++ b/packages/twenty-website-new/src/app/customers/w3villa/page.tsx @@ -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(); diff --git a/packages/twenty-website-new/src/app/enterprise/activate/EnterpriseActivateClient.tsx b/packages/twenty-website-new/src/app/enterprise/activate/EnterpriseActivateClient.tsx index 044c064231..680865edf5 100644 --- a/packages/twenty-website-new/src/app/enterprise/activate/EnterpriseActivateClient.tsx +++ b/packages/twenty-website-new/src/app/enterprise/activate/EnterpriseActivateClient.tsx @@ -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]}; } ` : ''} diff --git a/packages/twenty-website-new/src/app/enterprise/activate/page.tsx b/packages/twenty-website-new/src/app/enterprise/activate/page.tsx index bfd80968fe..7c2f5e031e 100644 --- a/packages/twenty-website-new/src/app/enterprise/activate/page.tsx +++ b/packages/twenty-website-new/src/app/enterprise/activate/page.tsx @@ -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' }, diff --git a/packages/twenty-website-new/src/app/halftone/_components/ControlsPanel.tsx b/packages/twenty-website-new/src/app/halftone/_components/ControlsPanel.tsx index c46255ec0f..c3d3d9230f 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/ControlsPanel.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/ControlsPanel.tsx @@ -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'; diff --git a/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx b/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx index 8aff753f17..4a93ba086c 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx @@ -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( diff --git a/packages/twenty-website-new/src/app/halftone/_components/controls/AnimationsTab.tsx b/packages/twenty-website-new/src/app/halftone/_components/controls/AnimationsTab.tsx index 78b9178797..e158cda298 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/controls/AnimationsTab.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/controls/AnimationsTab.tsx @@ -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, diff --git a/packages/twenty-website-new/src/app/halftone/_components/controls/DesignTab.tsx b/packages/twenty-website-new/src/app/halftone/_components/controls/DesignTab.tsx index 4ede596c5a..7b873370cd 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/controls/DesignTab.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/controls/DesignTab.tsx @@ -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, diff --git a/packages/twenty-website-new/src/app/halftone/_components/controls/ExportTab.tsx b/packages/twenty-website-new/src/app/halftone/_components/controls/ExportTab.tsx index d9630a3653..43074066a8 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/controls/ExportTab.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/controls/ExportTab.tsx @@ -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, diff --git a/packages/twenty-website-new/src/app/halftone/_components/controls/controls-ui.tsx b/packages/twenty-website-new/src/app/halftone/_components/controls/controls-ui.tsx index 9cb253e6ea..ed947b2158 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/controls/controls-ui.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/controls/controls-ui.tsx @@ -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({ {children} diff --git a/packages/twenty-website-new/src/app/halftone/_lib/exporters.test.ts b/packages/twenty-website-new/src/app/halftone/_lib/exporters.test.ts index 47aa790ff6..32f97f61f1 100644 --- a/packages/twenty-website-new/src/app/halftone/_lib/exporters.test.ts +++ b/packages/twenty-website-new/src/app/halftone/_lib/exporters.test.ts @@ -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, + ); + }); +}); diff --git a/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts b/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts index 21da7c5115..3419084c55 100644 --- a/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts +++ b/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts @@ -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) diff --git a/packages/twenty-website-new/src/app/halftone/_lib/formatters.ts b/packages/twenty-website-new/src/app/halftone/_lib/formatters.ts index d6d15dd7b7..005de23689 100644 --- a/packages/twenty-website-new/src/app/halftone/_lib/formatters.ts +++ b/packages/twenty-website-new/src/app/halftone/_lib/formatters.ts @@ -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') { diff --git a/packages/twenty-website-new/src/app/halftone/_lib/imageSvgExport.ts b/packages/twenty-website-new/src/app/halftone/_lib/imageSvgExport.ts index 495ae15070..6018c1674d 100644 --- a/packages/twenty-website-new/src/app/halftone/_lib/imageSvgExport.ts +++ b/packages/twenty-website-new/src/app/halftone/_lib/imageSvgExport.ts @@ -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; diff --git a/packages/twenty-website-new/src/app/halftone/_lib/share.ts b/packages/twenty-website-new/src/app/halftone/_lib/share.ts index 7ce1637e07..ea741c4c7e 100644 --- a/packages/twenty-website-new/src/app/halftone/_lib/share.ts +++ b/packages/twenty-website-new/src/app/halftone/_lib/share.ts @@ -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, diff --git a/packages/twenty-website-new/src/app/halftone/page.tsx b/packages/twenty-website-new/src/app/halftone/page.tsx index 2933ad6908..19809ce3d1 100644 --- a/packages/twenty-website-new/src/app/halftone/page.tsx +++ b/packages/twenty-website-new/src/app/halftone/page.tsx @@ -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 ; diff --git a/packages/twenty-website-new/src/app/layout.tsx b/packages/twenty-website-new/src/app/layout.tsx index 4668c60cc7..9a6bf9c157 100644 --- a/packages/twenty-website-new/src/app/layout.tsx +++ b/packages/twenty-website-new/src/app/layout.tsx @@ -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 ( + + {/* + * 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. + */} + + - {children} - - - - - - - + + + {children} + + + + + + + + diff --git a/packages/twenty-website-new/src/app/partners/_constants/index.ts b/packages/twenty-website-new/src/app/partners/_constants/index.ts deleted file mode 100644 index 2f6d0ca04f..0000000000 --- a/packages/twenty-website-new/src/app/partners/_constants/index.ts +++ /dev/null @@ -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'; diff --git a/packages/twenty-website-new/src/app/partners/components/PartnerApplication/BecomePartnerButton.tsx b/packages/twenty-website-new/src/app/partners/components/PartnerApplication/BecomePartnerButton.tsx index b80d57e1c4..a7b54b02ba 100644 --- a/packages/twenty-website-new/src/app/partners/components/PartnerApplication/BecomePartnerButton.tsx +++ b/packages/twenty-website-new/src/app/partners/components/PartnerApplication/BecomePartnerButton.tsx @@ -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} `; diff --git a/packages/twenty-website-new/src/app/partners/components/PartnerApplication/PartnerHeroCtas.tsx b/packages/twenty-website-new/src/app/partners/components/PartnerApplication/PartnerHeroCtas.tsx index aecc54bba7..83a9a009b5 100644 --- a/packages/twenty-website-new/src/app/partners/components/PartnerApplication/PartnerHeroCtas.tsx +++ b/packages/twenty-website-new/src/app/partners/components/PartnerApplication/PartnerHeroCtas.tsx @@ -1,6 +1,6 @@ 'use client'; -import { TalkToUsButton } from '@/app/components/ContactCalModal'; +import { TalkToUsButton } from '@/lib/contact-cal'; import { BecomePartnerButton } from './BecomePartnerButton'; diff --git a/packages/twenty-website-new/src/app/partners/components/PartnerApplication/PartnerSignoffCtas.tsx b/packages/twenty-website-new/src/app/partners/components/PartnerApplication/PartnerSignoffCtas.tsx index eb3ade4002..9382601c2e 100644 --- a/packages/twenty-website-new/src/app/partners/components/PartnerApplication/PartnerSignoffCtas.tsx +++ b/packages/twenty-website-new/src/app/partners/components/PartnerApplication/PartnerSignoffCtas.tsx @@ -1,6 +1,6 @@ 'use client'; -import { TalkToUsButton } from '@/app/components/ContactCalModal'; +import { TalkToUsButton } from '@/lib/contact-cal'; import { BecomePartnerButton } from './BecomePartnerButton'; diff --git a/packages/twenty-website-new/src/app/partners/components/PartnerApplication/index.ts b/packages/twenty-website-new/src/app/partners/components/PartnerApplication/index.ts index 33093992d9..bcf824c41b 100644 --- a/packages/twenty-website-new/src/app/partners/components/PartnerApplication/index.ts +++ b/packages/twenty-website-new/src/app/partners/components/PartnerApplication/index.ts @@ -1,4 +1,3 @@ export { BecomePartnerButton } from './BecomePartnerButton'; -export { PartnerApplicationModalRoot } from './PartnerApplicationModalRoot'; export { PartnerHeroCtas } from './PartnerHeroCtas'; export { PartnerSignoffCtas } from './PartnerSignoffCtas'; diff --git a/packages/twenty-website-new/src/app/partners/_constants/engagement-band.ts b/packages/twenty-website-new/src/app/partners/engagement-band.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/partners/_constants/engagement-band.ts rename to packages/twenty-website-new/src/app/partners/engagement-band.data.ts diff --git a/packages/twenty-website-new/src/app/partners/_constants/hero.ts b/packages/twenty-website-new/src/app/partners/hero.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/partners/_constants/hero.ts rename to packages/twenty-website-new/src/app/partners/hero.data.ts diff --git a/packages/twenty-website-new/src/app/partners/page.tsx b/packages/twenty-website-new/src/app/partners/page.tsx index 74c41103c6..a1e6a484a7 100644 --- a/packages/twenty-website-new/src/app/partners/page.tsx +++ b/packages/twenty-website-new/src/app/partners/page.tsx @@ -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 ( - + <> - + @@ -130,14 +134,17 @@ export default async function PartnerPage() { color={theme.colors.primary.text[100]} page={Pages.Partners} > - + - + @@ -158,6 +165,6 @@ export default async function PartnerPage() { - + ); } diff --git a/packages/twenty-website-new/src/app/partners/_constants/signoff.ts b/packages/twenty-website-new/src/app/partners/signoff.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/partners/_constants/signoff.ts rename to packages/twenty-website-new/src/app/partners/signoff.data.ts diff --git a/packages/twenty-website-new/src/app/partners/_constants/testimonials.ts b/packages/twenty-website-new/src/app/partners/testimonials.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/partners/_constants/testimonials.ts rename to packages/twenty-website-new/src/app/partners/testimonials.data.ts diff --git a/packages/twenty-website-new/src/app/partners/_constants/three-cards-illustration.ts b/packages/twenty-website-new/src/app/partners/three-cards-illustration.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/partners/_constants/three-cards-illustration.ts rename to packages/twenty-website-new/src/app/partners/three-cards-illustration.data.ts diff --git a/packages/twenty-website-new/src/app/pricing/_constants/index.ts b/packages/twenty-website-new/src/app/pricing/_constants/index.ts deleted file mode 100644 index ddc989ef03..0000000000 --- a/packages/twenty-website-new/src/app/pricing/_constants/index.ts +++ /dev/null @@ -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'; diff --git a/packages/twenty-website-new/src/app/pricing/_constants/engagement-band.ts b/packages/twenty-website-new/src/app/pricing/engagement-band.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/pricing/_constants/engagement-band.ts rename to packages/twenty-website-new/src/app/pricing/engagement-band.data.ts diff --git a/packages/twenty-website-new/src/app/pricing/_constants/hero.ts b/packages/twenty-website-new/src/app/pricing/hero.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/pricing/_constants/hero.ts rename to packages/twenty-website-new/src/app/pricing/hero.data.ts diff --git a/packages/twenty-website-new/src/app/pricing/page.tsx b/packages/twenty-website-new/src/app/pricing/page.tsx index 8151e9e742..868337e1a0 100644 --- a/packages/twenty-website-new/src/app/pricing/page.tsx +++ b/packages/twenty-website-new/src/app/pricing/page.tsx @@ -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 ( - + <> - + @@ -139,6 +137,6 @@ export default async function PricingPage() { - + ); } diff --git a/packages/twenty-website-new/src/app/pricing/_constants/plan-table.ts b/packages/twenty-website-new/src/app/pricing/plan-table.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/pricing/_constants/plan-table.ts rename to packages/twenty-website-new/src/app/pricing/plan-table.data.ts diff --git a/packages/twenty-website-new/src/app/pricing/_constants/salesforce.ts b/packages/twenty-website-new/src/app/pricing/salesforce.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/pricing/_constants/salesforce.ts rename to packages/twenty-website-new/src/app/pricing/salesforce.data.ts diff --git a/packages/twenty-website-new/src/app/privacy-policy/page.tsx b/packages/twenty-website-new/src/app/privacy-policy/page.tsx index 8d7e532b3f..5d79252f7e 100644 --- a/packages/twenty-website-new/src/app/privacy-policy/page.tsx +++ b/packages/twenty-website-new/src/app/privacy-policy/page.tsx @@ -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 ( - - + ); } diff --git a/packages/twenty-website-new/src/app/product/_constants/demo.ts b/packages/twenty-website-new/src/app/product/_constants/demo.ts deleted file mode 100644 index 3bc70f9652..0000000000 --- a/packages/twenty-website-new/src/app/product/_constants/demo.ts +++ /dev/null @@ -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: '', - }, -}; diff --git a/packages/twenty-website-new/src/app/product/_constants/index.ts b/packages/twenty-website-new/src/app/product/_constants/index.ts deleted file mode 100644 index 48894f9486..0000000000 --- a/packages/twenty-website-new/src/app/product/_constants/index.ts +++ /dev/null @@ -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'; diff --git a/packages/twenty-website-new/src/app/product/_constants/tabs.ts b/packages/twenty-website-new/src/app/product/_constants/tabs.ts deleted file mode 100644 index 88564edd87..0000000000 --- a/packages/twenty-website-new/src/app/product/_constants/tabs.ts +++ /dev/null @@ -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', - }, - }, - ], -}; diff --git a/packages/twenty-website-new/src/app/product/_constants/feature.ts b/packages/twenty-website-new/src/app/product/feature.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/product/_constants/feature.ts rename to packages/twenty-website-new/src/app/product/feature.data.ts diff --git a/packages/twenty-website-new/src/app/product/_constants/hero.ts b/packages/twenty-website-new/src/app/product/hero.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/product/_constants/hero.ts rename to packages/twenty-website-new/src/app/product/hero.data.ts diff --git a/packages/twenty-website-new/src/app/product/page.tsx b/packages/twenty-website-new/src/app/product/page.tsx index 1f3aca658e..5b1a6f4349 100644 --- a/packages/twenty-website-new/src/app/product/page.tsx +++ b/packages/twenty-website-new/src/app/product/page.tsx @@ -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. + */} + - - + + - + diff --git a/packages/twenty-website-new/src/app/product/_constants/signoff.ts b/packages/twenty-website-new/src/app/product/signoff.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/product/_constants/signoff.ts rename to packages/twenty-website-new/src/app/product/signoff.data.ts diff --git a/packages/twenty-website-new/src/app/product/_constants/stepper.ts b/packages/twenty-website-new/src/app/product/stepper.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/product/_constants/stepper.ts rename to packages/twenty-website-new/src/app/product/stepper.data.ts diff --git a/packages/twenty-website-new/src/app/product/_constants/three-cards.ts b/packages/twenty-website-new/src/app/product/three-cards.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/product/_constants/three-cards.ts rename to packages/twenty-website-new/src/app/product/three-cards.data.ts diff --git a/packages/twenty-website-new/src/app/releases/_constants/hero.ts b/packages/twenty-website-new/src/app/releases/hero.data.ts similarity index 79% rename from packages/twenty-website-new/src/app/releases/_constants/hero.ts rename to packages/twenty-website-new/src/app/releases/hero.data.ts index bc97891a82..3072c83113 100644 --- a/packages/twenty-website-new/src/app/releases/_constants/hero.ts +++ b/packages/twenty-website-new/src/app/releases/hero.data.ts @@ -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 ' }, diff --git a/packages/twenty-website-new/src/app/releases/page.tsx b/packages/twenty-website-new/src/app/releases/page.tsx index 01a480333f..4030c8d124 100644 --- a/packages/twenty-website-new/src/app/releases/page.tsx +++ b/packages/twenty-website-new/src/app/releases/page.tsx @@ -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. + */} + - + ); } diff --git a/packages/twenty-website-new/src/app/why-twenty/_constants/editorial-two.ts b/packages/twenty-website-new/src/app/why-twenty/_constants/editorial-two.ts deleted file mode 100644 index 1b66aa30ec..0000000000 --- a/packages/twenty-website-new/src/app/why-twenty/_constants/editorial-two.ts +++ /dev/null @@ -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.', - }, -}; diff --git a/packages/twenty-website-new/src/app/why-twenty/_constants/index.ts b/packages/twenty-website-new/src/app/why-twenty/_constants/index.ts deleted file mode 100644 index d2ad5a8504..0000000000 --- a/packages/twenty-website-new/src/app/why-twenty/_constants/index.ts +++ /dev/null @@ -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'; diff --git a/packages/twenty-website-new/src/app/why-twenty/_constants/quote.ts b/packages/twenty-website-new/src/app/why-twenty/_constants/quote.ts deleted file mode 100644 index aeb6c38c14..0000000000 --- a/packages/twenty-website-new/src/app/why-twenty/_constants/quote.ts +++ /dev/null @@ -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' }, - ], -}; diff --git a/packages/twenty-website-new/src/app/why-twenty/_constants/statement-one.ts b/packages/twenty-website-new/src/app/why-twenty/_constants/statement-one.ts deleted file mode 100644 index 565f9a4963..0000000000 --- a/packages/twenty-website-new/src/app/why-twenty/_constants/statement-one.ts +++ /dev/null @@ -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', - }, -}; diff --git a/packages/twenty-website-new/src/app/why-twenty/_constants/statement-two.ts b/packages/twenty-website-new/src/app/why-twenty/_constants/statement-two.ts deleted file mode 100644 index 98c1b7fd1c..0000000000 --- a/packages/twenty-website-new/src/app/why-twenty/_constants/statement-two.ts +++ /dev/null @@ -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', - }, -}; diff --git a/packages/twenty-website-new/src/app/why-twenty/_constants/stepper.ts b/packages/twenty-website-new/src/app/why-twenty/_constants/stepper.ts deleted file mode 100644 index 263ab378dd..0000000000 --- a/packages/twenty-website-new/src/app/why-twenty/_constants/stepper.ts +++ /dev/null @@ -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', -}; diff --git a/packages/twenty-website-new/src/app/why-twenty/_constants/editorial-four.ts b/packages/twenty-website-new/src/app/why-twenty/editorial-four.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/why-twenty/_constants/editorial-four.ts rename to packages/twenty-website-new/src/app/why-twenty/editorial-four.data.ts diff --git a/packages/twenty-website-new/src/app/why-twenty/_constants/editorial-one.ts b/packages/twenty-website-new/src/app/why-twenty/editorial-one.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/why-twenty/_constants/editorial-one.ts rename to packages/twenty-website-new/src/app/why-twenty/editorial-one.data.ts diff --git a/packages/twenty-website-new/src/app/why-twenty/_constants/editorial-three.ts b/packages/twenty-website-new/src/app/why-twenty/editorial-three.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/why-twenty/_constants/editorial-three.ts rename to packages/twenty-website-new/src/app/why-twenty/editorial-three.data.ts diff --git a/packages/twenty-website-new/src/app/why-twenty/_constants/hero.ts b/packages/twenty-website-new/src/app/why-twenty/hero.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/why-twenty/_constants/hero.ts rename to packages/twenty-website-new/src/app/why-twenty/hero.data.ts diff --git a/packages/twenty-website-new/src/app/why-twenty/_constants/marquee.ts b/packages/twenty-website-new/src/app/why-twenty/marquee.data.ts similarity index 100% rename from packages/twenty-website-new/src/app/why-twenty/_constants/marquee.ts rename to packages/twenty-website-new/src/app/why-twenty/marquee.data.ts diff --git a/packages/twenty-website-new/src/app/why-twenty/page.tsx b/packages/twenty-website-new/src/app/why-twenty/page.tsx index e95221850d..3c3d6703d2 100644 --- a/packages/twenty-website-new/src/app/why-twenty/page.tsx +++ b/packages/twenty-website-new/src/app/why-twenty/page.tsx @@ -1,14 +1,12 @@ -import { MENU_DATA } from '@/app/_constants'; -import { - EDITORIAL_FOUR, - EDITORIAL_ONE, - EDITORIAL_THREE, - HERO_DATA, - MARQUEE_DATA, - SIGNOFF_DATA, -} from '@/app/why-twenty/_constants'; +import { MENU_DATA } from '@/sections/Menu/data'; +import { EDITORIAL_FOUR } from '@/app/why-twenty/editorial-four.data'; +import { EDITORIAL_ONE } from '@/app/why-twenty/editorial-one.data'; +import { EDITORIAL_THREE } from '@/app/why-twenty/editorial-three.data'; +import { HERO_DATA } from '@/app/why-twenty/hero.data'; +import { MARQUEE_DATA } from '@/app/why-twenty/marquee.data'; +import { SIGNOFF_DATA } from '@/app/why-twenty/signoff.data'; import { 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 { Editorial } from '@/sections/Editorial/components'; @@ -17,6 +15,7 @@ import { Marquee } from '@/sections/Marquee/components'; import { Menu } from '@/sections/Menu/components'; import { Signoff } from '@/sections/Signoff/components'; import { theme } from '@/theme'; +import { buildPageMetadata } from '@/lib/seo'; import { css } from '@linaria/core'; import type { Metadata } from 'next'; @@ -59,11 +58,12 @@ const sectionCrosshairRight = { lineColor: crosshairLineColor, }; -export const metadata: Metadata = { +export const metadata: Metadata = buildPageMetadata({ + path: '/why-twenty', title: 'Why Twenty | Twenty', description: 'Most packaged software makes companies more similar. Learn why the future of CRM is built, not bought.', -}; +}); export default async function WhyTwentyPage() { const stats = await fetchCommunityStats(); @@ -71,6 +71,16 @@ export default async function WhyTwentyPage() { 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. + */} + - + {/* - + */} @@ -126,7 +142,6 @@ export default async function WhyTwentyPage() { backgroundColor={theme.colors.secondary.background[100]} color={theme.colors.secondary.text[100]} crosshair={sectionCrosshairLeft} - mutedColor={theme.colors.secondary.text[60]} > - + {/* @@ -147,7 +166,6 @@ export default async function WhyTwentyPage() { backgroundColor={theme.colors.secondary.background[100]} color={theme.colors.secondary.text[100]} crosshair={sectionCrosshairRight} - mutedColor={theme.colors.secondary.text[60]} > - + + > + + { - const syncTabVisibility = () => { - setIsTabActive(getIsTabActive()); - }; - - syncTabVisibility(); - document.addEventListener('visibilitychange', syncTabVisibility); - - return () => { - document.removeEventListener('visibilitychange', syncTabVisibility); - }; - }, []); - - if (!isTabActive) { - return null; - } - - return <>{children}; -} diff --git a/packages/twenty-website-new/src/components/WebGlWhenInViewport.tsx b/packages/twenty-website-new/src/components/WebGlWhenInViewport.tsx deleted file mode 100644 index b9a56ab744..0000000000 --- a/packages/twenty-website-new/src/components/WebGlWhenInViewport.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'use client'; - -import { styled } from '@linaria/react'; -import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; - -const VERTICAL_MARGIN_PX = 100; -const ROOT_MARGIN = `${VERTICAL_MARGIN_PX}px 0px ${VERTICAL_MARGIN_PX}px 0px`; -const OUT_OF_VIEW_DISPOSE_MS = 300; - -const ObserverRoot = styled.div<{ detachFromLayout: boolean }>` - height: 100%; - min-height: 1px; - pointer-events: none; - width: 100%; - - & > * { - pointer-events: auto; - } - - ${({ detachFromLayout }) => - detachFromLayout - ? ` - bottom: 0; - left: 0; - position: absolute; - right: 0; - top: 0; - ` - : ` - position: relative; - `} -`; - -function isLikelyInViewport(element: HTMLElement) { - const rect = element.getBoundingClientRect(); - const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0; - const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 0; - - return ( - rect.bottom > -VERTICAL_MARGIN_PX && - rect.top < viewportHeight + VERTICAL_MARGIN_PX && - rect.right > 0 && - rect.left < viewportWidth - ); -} - -type WebGlWhenInViewportProps = { - children: ReactNode; - detachFromLayout?: boolean; -}; - -export function WebGlWhenInViewport({ - children, - detachFromLayout = false, -}: WebGlWhenInViewportProps) { - const rootReference = useRef(null); - const [shouldRender, setShouldRender] = useState(false); - const disposeTimerReference = useRef | null>( - null, - ); - - useLayoutEffect(() => { - const element = rootReference.current; - - if (!element) { - return; - } - - const clearDisposeTimer = () => { - if (disposeTimerReference.current !== null) { - clearTimeout(disposeTimerReference.current); - disposeTimerReference.current = null; - } - }; - - if (isLikelyInViewport(element)) { - setShouldRender(true); - } - - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - clearDisposeTimer(); - setShouldRender(true); - return; - } - - clearDisposeTimer(); - disposeTimerReference.current = setTimeout(() => { - setShouldRender(false); - disposeTimerReference.current = null; - }, OUT_OF_VIEW_DISPOSE_MS); - }, - { root: null, rootMargin: ROOT_MARGIN, threshold: 0 }, - ); - - observer.observe(element); - - return () => { - clearDisposeTimer(); - observer.disconnect(); - }; - }, []); - - return ( - - {shouldRender ? children : null} - - ); -} diff --git a/packages/twenty-website-new/src/content/releases/0.10.0.mdx b/packages/twenty-website-new/src/content/releases/0.10.0.mdx index 07b84ad4d3..8605f9d14a 100644 --- a/packages/twenty-website-new/src/content/releases/0.10.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.10.0.mdx @@ -37,4 +37,4 @@ The `JSON Field` allows for the storage of complex, structured data within a sin **Example Use Case**: Store configurable data for a product, such as feature flags or customization options, directly within a CRM record. -![](/images/releases/0.10/0.10-json.webp) \ No newline at end of file +![](/images/releases/0.10/0.10-json.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.11.0.mdx b/packages/twenty-website-new/src/content/releases/0.11.0.mdx index 2b28ce5816..a2ab68447a 100644 --- a/packages/twenty-website-new/src/content/releases/0.11.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.11.0.mdx @@ -13,4 +13,4 @@ With Google Calendar integration, you can track all your team's events with a co We have improved app performance, shaving off over 500ms on each page. -![](/images/releases/0.11/0.11-speed.webp) \ No newline at end of file +![](/images/releases/0.11/0.11-speed.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.12.0.mdx b/packages/twenty-website-new/src/content/releases/0.12.0.mdx index 97bdfc901e..91f0b4ff3d 100644 --- a/packages/twenty-website-new/src/content/releases/0.12.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.12.0.mdx @@ -5,7 +5,7 @@ Date: 2024-05-24 # Notifications -Introduced a new design for notifications featuring lighter colors. +Introduced a new design for notifications featuring lighter colors. ![](/images/releases/0.12/0.12-notifications.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.2.3.mdx b/packages/twenty-website-new/src/content/releases/0.2.3.mdx index e620520a2e..5d3d5fad6e 100644 --- a/packages/twenty-website-new/src/content/releases/0.2.3.mdx +++ b/packages/twenty-website-new/src/content/releases/0.2.3.mdx @@ -13,4 +13,4 @@ Developers can now use webhooks to synchronize customer data updates in real-tim You can now navigate from one object to another directly from the record detail page. -![Webhooks](/images/releases/0.2.3_relations.webp) \ No newline at end of file +![Webhooks](/images/releases/0.2.3_relations.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.21.0.mdx b/packages/twenty-website-new/src/content/releases/0.21.0.mdx index ed94a1e2a5..e296ff7756 100644 --- a/packages/twenty-website-new/src/content/releases/0.21.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.21.0.mdx @@ -9,7 +9,6 @@ You can now edit one-to-many relations directly from the "many side". This means ![](/images/releases/0.21/0.21-many-many.webp) - # Advanced Email and Calendar Settings We've introduced advanced settings for email and calendar management: diff --git a/packages/twenty-website-new/src/content/releases/0.23.0.mdx b/packages/twenty-website-new/src/content/releases/0.23.0.mdx index af1a0e7683..1c405f163e 100644 --- a/packages/twenty-website-new/src/content/releases/0.23.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.23.0.mdx @@ -19,4 +19,4 @@ Quickly identify who created a given record and what was the origin of the creat Filter the content a webhook is returning so it only pings your URL when a specific action occurs, such as on creating a company. -![](/images/releases/0.23/0.23-filter-webhooks.webp) \ No newline at end of file +![](/images/releases/0.23/0.23-filter-webhooks.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.24.0.mdx b/packages/twenty-website-new/src/content/releases/0.24.0.mdx index 12e45c5d76..580a5ef899 100644 --- a/packages/twenty-website-new/src/content/releases/0.24.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.24.0.mdx @@ -7,4 +7,4 @@ Date: 2024-08-29 Soft delete feature added: Deleted records are now hidden from view but recoverable from the "Deleted record" option in any object options menu. No more drama! -![](/images/releases/0.24/0.24-soft-delete.webp) \ No newline at end of file +![](/images/releases/0.24/0.24-soft-delete.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.3.0.mdx b/packages/twenty-website-new/src/content/releases/0.3.0.mdx index 6c51311977..f6ea741305 100644 --- a/packages/twenty-website-new/src/content/releases/0.3.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.3.0.mdx @@ -7,4 +7,4 @@ Date: 2024-02-03 The new Rating field represents a numeric value from zero to five, it can be useful for various use-cases such as scoring leads. -![rating](/images/releases/0.3.0_rating.webp) \ No newline at end of file +![rating](/images/releases/0.3.0_rating.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.3.1.mdx b/packages/twenty-website-new/src/content/releases/0.3.1.mdx index afd382e831..b10525eb2c 100644 --- a/packages/twenty-website-new/src/content/releases/0.3.1.mdx +++ b/packages/twenty-website-new/src/content/releases/0.3.1.mdx @@ -8,4 +8,3 @@ Date: 2024-02-16 Contributors now have their very own [hall of fame](https://twenty.com/contributors). ![rating](/images/releases/0.3.1_contributors.webp) - diff --git a/packages/twenty-website-new/src/content/releases/0.3.2.mdx b/packages/twenty-website-new/src/content/releases/0.3.2.mdx index b07baae396..09e4949e09 100644 --- a/packages/twenty-website-new/src/content/releases/0.3.2.mdx +++ b/packages/twenty-website-new/src/content/releases/0.3.2.mdx @@ -7,4 +7,4 @@ Date: 2024-02-29 The record page now features a clearer layout with increased space for content -![](/images/releases/0.3.2_new_layout.webp) \ No newline at end of file +![](/images/releases/0.3.2_new_layout.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.3.3.mdx b/packages/twenty-website-new/src/content/releases/0.3.3.mdx index 6d53333c21..652818f5f0 100644 --- a/packages/twenty-website-new/src/content/releases/0.3.3.mdx +++ b/packages/twenty-website-new/src/content/releases/0.3.3.mdx @@ -9,7 +9,6 @@ Connect your Gmail account to automatically associate emails with relevant 'Peop ![](/images/releases/0.3.3_emails.webp) - # Kanbans on any object Create a Kanban view on any object and streamline processes like recruitment or onboarding. @@ -20,4 +19,4 @@ Create a Kanban view on any object and streamline processes like recruitment or We are pleased to reopen the cloud subscription to everyone. No more waiting! -![](/images/releases/0.3.3_sign_up.webp) \ No newline at end of file +![](/images/releases/0.3.3_sign_up.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.30.0.mdx b/packages/twenty-website-new/src/content/releases/0.30.0.mdx index c66d3d61d2..d7be173a6e 100644 --- a/packages/twenty-website-new/src/content/releases/0.30.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.30.0.mdx @@ -17,6 +17,6 @@ Enhance your contact management by adding many email addresses for a single cont # New Array field type -Developers can now take advantage of the new array field type to store non-predefined values. +Developers can now take advantage of the new array field type to store non-predefined values. -![](/images/releases/0.30/0.30-array-field.webp) \ No newline at end of file +![](/images/releases/0.30/0.30-array-field.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.31.0.mdx b/packages/twenty-website-new/src/content/releases/0.31.0.mdx index 74a272bb28..0126803d5d 100644 --- a/packages/twenty-website-new/src/content/releases/0.31.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.31.0.mdx @@ -13,4 +13,4 @@ To maintain the simplicity of Twenty, we are introducing "Advanced Settings." Th We have significantly enhanced our search performance, making it feel instantaneous when searching for records such as people, companies, or tasks. -![](/images/releases/0.31/0.31-search.webp) \ No newline at end of file +![](/images/releases/0.31/0.31-search.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.32.0.mdx b/packages/twenty-website-new/src/content/releases/0.32.0.mdx index 97f63226fa..4b3d1d77c1 100644 --- a/packages/twenty-website-new/src/content/releases/0.32.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.32.0.mdx @@ -13,4 +13,4 @@ We started a major ⌘K revamp that now understands the context to display appro You can now filter multiple actions simultaneously with a single webhook. For example, you can create a webhook that triggers only when a person or company is updated or created. -![](/images/releases/0.32/0.32-webhooks.webp) \ No newline at end of file +![](/images/releases/0.32/0.32-webhooks.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.33.0.mdx b/packages/twenty-website-new/src/content/releases/0.33.0.mdx index 2fd90a349a..4bfb445106 100644 --- a/packages/twenty-website-new/src/content/releases/0.33.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.33.0.mdx @@ -9,8 +9,8 @@ You can now filter an object (People, Companies, Opportunities, etc.) using any ![](/images/releases/0.33/0.33-multiselect-filter.webp) -# Percentage in number fields +# Percentage in number fields You can now create number fields that display a percentage instead of a regular number. -![](/images/releases/0.33/0.33-percentage-number.webp) \ No newline at end of file +![](/images/releases/0.33/0.33-percentage-number.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.34.0.mdx b/packages/twenty-website-new/src/content/releases/0.34.0.mdx index 9ee4b65e89..e6c3a899bf 100644 --- a/packages/twenty-website-new/src/content/releases/0.34.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.34.0.mdx @@ -7,4 +7,4 @@ Date: 2024-12-12 Each workspace now gets a dedicated sub-domain for a more secure experience. And soon you will be able to set your own domain. -![](/images/releases/0.34/0.34-subdomains.webp) \ No newline at end of file +![](/images/releases/0.34/0.34-subdomains.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.35.0.mdx b/packages/twenty-website-new/src/content/releases/0.35.0.mdx index 34df93c6d5..69de9a81d2 100644 --- a/packages/twenty-website-new/src/content/releases/0.35.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.35.0.mdx @@ -7,4 +7,4 @@ Date: 2024-12-20 You can now add your views to favorites for quick access and organize your favorites into folders for better management. -![](/images/releases/0.35/0.35-Favorites.webp) \ No newline at end of file +![](/images/releases/0.35/0.35-Favorites.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.4.0.mdx b/packages/twenty-website-new/src/content/releases/0.4.0.mdx index 09fbcadf56..dee5a41a66 100644 --- a/packages/twenty-website-new/src/content/releases/0.4.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.4.0.mdx @@ -9,7 +9,6 @@ On record pages, you can now expand relation cards to view their fields without ![](/images/releases/0.4/0.4-expand-relation-card.webp) - # Address Field Type The new `Address Field` Type enables entry of a full address in one field, while structurally storing each address part - such as street name and number - in separate subfields. @@ -20,4 +19,4 @@ The new `Address Field` Type enables entry of a full address in one field, while You can now switch between workspaces by clicking your workspace name at the top left of the screen. This feature will only appear if you have been invited to join another workspace. -![](/images/releases/0.4/0.4-multi-workspace.webp) \ No newline at end of file +![](/images/releases/0.4/0.4-multi-workspace.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.40.0.mdx b/packages/twenty-website-new/src/content/releases/0.40.0.mdx index fbc0820a20..1cd708394e 100644 --- a/packages/twenty-website-new/src/content/releases/0.40.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.40.0.mdx @@ -9,9 +9,8 @@ Added "Group By" in tables to better organize entries, like grouping companies b ![](/images/releases/0.40/0.40-group-by.webp) - # Aggregates Introduced a feature to calculate and display data summaries, such as sums and latest entries, for quick insights and streamlined data analysis. -![](/images/releases/0.40/0.40-aggregates.webp) \ No newline at end of file +![](/images/releases/0.40/0.40-aggregates.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.41.0.mdx b/packages/twenty-website-new/src/content/releases/0.41.0.mdx index 65007dcd66..ff19015b45 100644 --- a/packages/twenty-website-new/src/content/releases/0.41.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.41.0.mdx @@ -7,4 +7,4 @@ Date: 2025-02-04 Enable beta features using the new Labs tab in settings. The first beta release introduces our workflow engine. Enjoy! -![](/images/releases/0.41/0.41-labs.webp) \ No newline at end of file +![](/images/releases/0.41/0.41-labs.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.42.0.mdx b/packages/twenty-website-new/src/content/releases/0.42.0.mdx index 35202c377c..442b8d8097 100644 --- a/packages/twenty-website-new/src/content/releases/0.42.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.42.0.mdx @@ -5,14 +5,14 @@ Date: 2025-02-18 # Microsoft o365 Integration -You can now link your Microsoft account to easily manage your messages and events right within your workspace. +You can now link your Microsoft account to easily manage your messages and events right within your workspace. ![](/images/releases/0.42/0.42-microsoft.webp) # Translation in 30+ Languages Expanded support for 30+ languages, so users can navigate and use the software in their preferred language. - + ![](/images/releases/0.42/0.42-translation.webp) # Attachment Visualizer diff --git a/packages/twenty-website-new/src/content/releases/0.43.0.mdx b/packages/twenty-website-new/src/content/releases/0.43.0.mdx index f49cba854b..9529731977 100644 --- a/packages/twenty-website-new/src/content/releases/0.43.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.43.0.mdx @@ -13,4 +13,4 @@ The search feature now includes ranking scores. This helps you find the most rel Internal team emails won't sync, protecting privacy by preventing access to internal discussions. -![](/images/releases/0.43.0/email-privacy.webp) \ No newline at end of file +![](/images/releases/0.43.0/email-privacy.webp) diff --git a/packages/twenty-website-new/src/content/releases/0.44.0.mdx b/packages/twenty-website-new/src/content/releases/0.44.0.mdx index b062adaaa4..d65f91ab6e 100644 --- a/packages/twenty-website-new/src/content/releases/0.44.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.44.0.mdx @@ -20,5 +20,3 @@ Date: 2025-03-17 **Environment Variables**: Admin panel now has read-only access to environment variables. Better transparency, easier config management. ![](/images/releases/0.44/0.44-admin-panel.webp) - - diff --git a/packages/twenty-website-new/src/content/releases/0.50.0.mdx b/packages/twenty-website-new/src/content/releases/0.50.0.mdx index f614959adc..ef0c2a88ee 100644 --- a/packages/twenty-website-new/src/content/releases/0.50.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.50.0.mdx @@ -14,5 +14,3 @@ Ability to set User and Admin permissions for each user. Admins can edit workspa Advanced filter enables precise database content filtering through nested conditional operators (AND/OR), multiple field filters, and customizable filter groups for complex query construction. ![](/images/releases/0.50/0.50-advanced-filters.webp) - - diff --git a/packages/twenty-website-new/src/content/releases/0.52.0.mdx b/packages/twenty-website-new/src/content/releases/0.52.0.mdx index 86a222d035..6b61d7794a 100644 --- a/packages/twenty-website-new/src/content/releases/0.52.0.mdx +++ b/packages/twenty-website-new/src/content/releases/0.52.0.mdx @@ -5,7 +5,7 @@ Date: 2025-04-25 # Add records to filtered views -Creating records on filtered views now applies the view filter to the newly created record. This feature is compatible with Text, Date\_Time, Date, Number, Select, Rating, Multi\_Select, Array, and Boolean fields. +Creating records on filtered views now applies the view filter to the newly created record. This feature is compatible with Text, Date_Time, Date, Number, Select, Rating, Multi_Select, Array, and Boolean fields. ![](/images/releases/0.52.0/0.52-filtered-views-records.webp) diff --git a/packages/twenty-website-new/src/content/releases/1.00.0.mdx b/packages/twenty-website-new/src/content/releases/1.00.0.mdx index f365e905c9..3b0d2927d3 100644 --- a/packages/twenty-website-new/src/content/releases/1.00.0.mdx +++ b/packages/twenty-website-new/src/content/releases/1.00.0.mdx @@ -5,7 +5,7 @@ Date: 2025-06-25 # Permissions V2 -Create and manage custom roles. Grant or revoke access for each object to Read/Create/Edit/Delete records. Give granular access to settings like the ability to manage users, data models or APIs. +Create and manage custom roles. Grant or revoke access for each object to Read/Create/Edit/Delete records. Give granular access to settings like the ability to manage users, data models or APIs. ![](/images/releases/1.00/1.00-permissions.webp) @@ -32,6 +32,3 @@ Sub-field filtering is now supported for currency, address, name, email, link, p We’ve cut key load and interaction times by over 3,000ms, which means pages now load 2x faster! ![](/images/releases/1.00/1.00-performance-improvement.webp) - - - diff --git a/packages/twenty-website-new/src/content/releases/1.10.0.mdx b/packages/twenty-website-new/src/content/releases/1.10.0.mdx index 64b4d93f31..07871f001c 100644 --- a/packages/twenty-website-new/src/content/releases/1.10.0.mdx +++ b/packages/twenty-website-new/src/content/releases/1.10.0.mdx @@ -14,4 +14,3 @@ You can now visualize your records in a monthly calendar view. This new view typ Create custom charts and visualizations using your workspace data with the new Dashboards feature. Available in Labs, this beta feature lets you build powerful analytics and insights to monitor your business metrics. ![](/images/releases/1.10/1.10.0-dashboards.webp) - diff --git a/packages/twenty-website-new/src/content/releases/1.11.0.mdx b/packages/twenty-website-new/src/content/releases/1.11.0.mdx index 980b051593..ab19ba172f 100644 --- a/packages/twenty-website-new/src/content/releases/1.11.0.mdx +++ b/packages/twenty-website-new/src/content/releases/1.11.0.mdx @@ -14,5 +14,3 @@ You can now create personal views that stay out of the shared Workspace section Create flexible relationships where a single field can connect to multiple different object types. For example, an Opportunity can now relate to either a Person or a Company, giving you more versatile data modeling capabilities. ![](/images/releases/1.11/1.11.0-morph-relations.webp) - - diff --git a/packages/twenty-website-new/src/content/releases/1.12.0.mdx b/packages/twenty-website-new/src/content/releases/1.12.0.mdx index 76da3c8cfe..46cca6a868 100644 --- a/packages/twenty-website-new/src/content/releases/1.12.0.mdx +++ b/packages/twenty-website-new/src/content/releases/1.12.0.mdx @@ -14,4 +14,3 @@ The side panel now opens next to your content rather than above it, giving you a Choose exactly which Gmail labels or Outlook folders to sync on your workspace. This gives you more control over your email data and better privacy by importing only the folders you need. ![](/images/releases/1.12/1.12.0-folder-sync.webp) - diff --git a/packages/twenty-website-new/src/content/releases/1.2.0.mdx b/packages/twenty-website-new/src/content/releases/1.2.0.mdx index 4682bfab88..7b62eb346e 100644 --- a/packages/twenty-website-new/src/content/releases/1.2.0.mdx +++ b/packages/twenty-website-new/src/content/releases/1.2.0.mdx @@ -12,4 +12,4 @@ When importing records, you can now import relations between records. For exampl We’ve added an "any field search" filter that lets you search across all fields at once. For example, it can allow you to locate a customer by their phone number, whether it's stored in the "Mobile," "Office," or "Direct Line" field. -![](/images/releases/1.2/1.2-any-fields.webp) \ No newline at end of file +![](/images/releases/1.2/1.2-any-fields.webp) diff --git a/packages/twenty-website-new/src/content/releases/1.5.0.mdx b/packages/twenty-website-new/src/content/releases/1.5.0.mdx index 94d2fda38e..b2aa7d97bf 100644 --- a/packages/twenty-website-new/src/content/releases/1.5.0.mdx +++ b/packages/twenty-website-new/src/content/releases/1.5.0.mdx @@ -7,4 +7,4 @@ Date: 2025-09-11 Workflow branches allow workflows to split paths, enabling conditional logic and multiple outcome flows in automation. -![](/images/releases/1.5/1.5-workflow-branches.webp) \ No newline at end of file +![](/images/releases/1.5/1.5-workflow-branches.webp) diff --git a/packages/twenty-website-new/src/content/releases/1.6.0.mdx b/packages/twenty-website-new/src/content/releases/1.6.0.mdx index 3f93da9397..c6a9cf8961 100644 --- a/packages/twenty-website-new/src/content/releases/1.6.0.mdx +++ b/packages/twenty-website-new/src/content/releases/1.6.0.mdx @@ -3,7 +3,7 @@ release: 1.6.0 Date: 2025-09-19 --- -# Workflow improvements +# Workflow improvements You now have the ability to duplicate nodes, change node types, and use a streamlined filter design in your workflows. diff --git a/packages/twenty-website-new/src/content/releases/1.7.0.mdx b/packages/twenty-website-new/src/content/releases/1.7.0.mdx index 8e7f0a1a50..e3409cf08d 100644 --- a/packages/twenty-website-new/src/content/releases/1.7.0.mdx +++ b/packages/twenty-website-new/src/content/releases/1.7.0.mdx @@ -5,12 +5,12 @@ Date: 2025-10-02 # User impersonation -You can now impersonate a workspace user as an admin. This allows you to see the workspace as that user would, which is useful for troubleshooting issues or understanding user experience. +You can now impersonate a workspace user as an admin. This allows you to see the workspace as that user would, which is useful for troubleshooting issues or understanding user experience. ![](/images/releases/1.7/1.7-impersonating.webp) # Record is created or updated trigger -You can now trigger workflows when a record is created or updated. +You can now trigger workflows when a record is created or updated. ![](/images/releases/1.7/1.7-upsert.webp) diff --git a/packages/twenty-website-new/src/content/releases/1.8.0.mdx b/packages/twenty-website-new/src/content/releases/1.8.0.mdx index c4a759615c..99615bd05c 100644 --- a/packages/twenty-website-new/src/content/releases/1.8.0.mdx +++ b/packages/twenty-website-new/src/content/releases/1.8.0.mdx @@ -20,4 +20,3 @@ Manual trigger workflows now support bulk selection, allowing you to select mult The search node now lets you customize the result limit above 1, enabling you to retrieve multiple records in a single search operation. This enhancement works seamlessly with the iterator node for processing search results. ![](/images/releases/1.8/1.8-search-limit.webp) - diff --git a/packages/twenty-website-new/src/lib/shared-asset-paths.ts b/packages/twenty-website-new/src/content/site/asset-paths.ts similarity index 98% rename from packages/twenty-website-new/src/lib/shared-asset-paths.ts rename to packages/twenty-website-new/src/content/site/asset-paths.ts index bf38c5c231..ab03b3eb0f 100644 --- a/packages/twenty-website-new/src/lib/shared-asset-paths.ts +++ b/packages/twenty-website-new/src/content/site/asset-paths.ts @@ -72,8 +72,7 @@ export const SHARED_PEOPLE_AVATAR_URLS = { roelofBotha: '/images/shared/people/avatars/roelof-botha.jpg', ryanRoslansky: '/images/shared/people/avatars/ryan-roslansky.jpg', steveAnavi: '/images/shared/people/avatars/steve-anavi.jpg', - stewartButterfield: - '/images/shared/people/avatars/stewart-butterfield.jpg', + stewartButterfield: '/images/shared/people/avatars/stewart-butterfield.jpg', sundarPichai: '/images/shared/people/avatars/sundar-pichai.jpg', thomasDohmke: '/images/shared/people/avatars/thomas-dohmke.jpg', timCook: '/images/shared/people/avatars/tim-cook.jpg', diff --git a/packages/twenty-website-new/src/design-system/components/Body/Body.tsx b/packages/twenty-website-new/src/design-system/components/Body.tsx similarity index 98% rename from packages/twenty-website-new/src/design-system/components/Body/Body.tsx rename to packages/twenty-website-new/src/design-system/components/Body.tsx index e3e3660b1e..8c9d1e2d00 100644 --- a/packages/twenty-website-new/src/design-system/components/Body/Body.tsx +++ b/packages/twenty-website-new/src/design-system/components/Body.tsx @@ -1,6 +1,9 @@ import { theme } from '@/theme'; import { css } from '@linaria/core'; -import { BodyType } from './types/Body'; + +export type BodyType = { + text: string; +}; const bodyClassName = css` color: inherit; diff --git a/packages/twenty-website-new/src/design-system/components/Body/types/Body.ts b/packages/twenty-website-new/src/design-system/components/Body/types/Body.ts deleted file mode 100644 index 183f9de787..0000000000 --- a/packages/twenty-website-new/src/design-system/components/Body/types/Body.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type BodyType = { - text: string; -} diff --git a/packages/twenty-website-new/src/design-system/components/Button/BaseButton.tsx b/packages/twenty-website-new/src/design-system/components/Button/BaseButton.tsx index 40796acf6d..63788e21c6 100644 --- a/packages/twenty-website-new/src/design-system/components/Button/BaseButton.tsx +++ b/packages/twenty-website-new/src/design-system/components/Button/BaseButton.tsx @@ -98,7 +98,8 @@ export type BaseButtonProps = { variant: 'contained' | 'outlined'; }; -const secondaryContainedHoverFillColor = theme.colors.secondary.background.hover; +const secondaryContainedHoverFillColor = + theme.colors.secondary.background.hover; const primaryOutlinedHoverFillColor = theme.colors.primary.background[100]; const primaryOutlinedHoverFillOpacity = 0.05; const secondaryOutlinedHoverFillColor = theme.colors.primary.text[100]; @@ -167,8 +168,15 @@ export function BaseButton({ height={height} strokeColor={strokeColor} /> - - + + {leadingIcon ? {leadingIcon} : null} diff --git a/packages/twenty-website-new/src/design-system/components/Button/ButtonShape.tsx b/packages/twenty-website-new/src/design-system/components/Button/ButtonShape.tsx index e9054dce3a..dca911791c 100644 --- a/packages/twenty-website-new/src/design-system/components/Button/ButtonShape.tsx +++ b/packages/twenty-website-new/src/design-system/components/Button/ButtonShape.tsx @@ -7,11 +7,8 @@ type ButtonShapeProps = { strokeColor: string; }; -// The bottom-right corner taper is a fixed design element; only the straight -// vertical segment between top-right arc and the taper changes with height. const TAPER_HEIGHT = 15.477; const TAPER_TOP_OFFSET = 4; -const STRAIGHT_V_AT_FORTY = 20.523; function getLeftFillPath(height: number) { return `M4 0 A4 4 0 0 0 0 4 V${height - 4} A4 4 0 0 0 4 ${height} Z`; @@ -22,23 +19,15 @@ function getLeftOutlinePath(height: number) { } function getRightFillPath(height: number) { - const straight = Math.max( - height - TAPER_TOP_OFFSET - TAPER_HEIGHT, - 0, - ); + const straight = Math.max(height - TAPER_TOP_OFFSET - TAPER_HEIGHT, 0); return `M0 0 h11 a4 4 0 0 1 4 4 v${straight} a6 6 0 0 1 -1.544 4.019 l-8.548 9.477 A6 6 0 0 1 0.453 ${height} H0 Z`; } function getRightOutlinePath(height: number) { - const straight = Math.max( - height - TAPER_TOP_OFFSET - TAPER_HEIGHT, - 0, - ); + const straight = Math.max(height - TAPER_TOP_OFFSET - TAPER_HEIGHT, 0); return `M0 0.5 h11 a3.5 3.5 0 0 1 3.5 3.5 v${straight} a5.5 5.5 0 0 1 -1.416 3.684 l-8.547 9.477 a5.5 5.5 0 0 1 -4.084 1.816 H0`; } -void STRAIGHT_V_AT_FORTY; - const ShapeContainer = styled.div` display: flex; inset: 0; diff --git a/packages/twenty-website-new/src/design-system/components/Container/Container.tsx b/packages/twenty-website-new/src/design-system/components/Container.tsx similarity index 100% rename from packages/twenty-website-new/src/design-system/components/Container/Container.tsx rename to packages/twenty-website-new/src/design-system/components/Container.tsx diff --git a/packages/twenty-website-new/src/design-system/components/Eyebrow/Eyebrow.tsx b/packages/twenty-website-new/src/design-system/components/Eyebrow.tsx similarity index 91% rename from packages/twenty-website-new/src/design-system/components/Eyebrow/Eyebrow.tsx rename to packages/twenty-website-new/src/design-system/components/Eyebrow.tsx index 15d00df007..64e8dfbf90 100644 --- a/packages/twenty-website-new/src/design-system/components/Eyebrow/Eyebrow.tsx +++ b/packages/twenty-website-new/src/design-system/components/Eyebrow.tsx @@ -1,6 +1,7 @@ -import { Heading } from '@/design-system/components/Heading/Heading'; -import { HeadingType } from '@/design-system/components/Heading/types/Heading'; +import { Heading, type HeadingType } from '@/design-system/components/Heading'; import { RectangleFillIcon } from '@/icons'; + +export type EyebrowType = { heading: HeadingType }; import { theme } from '@/theme'; import { css } from '@linaria/core'; import { styled } from '@linaria/react'; diff --git a/packages/twenty-website-new/src/design-system/components/Eyebrow/types/Eyebrow.ts b/packages/twenty-website-new/src/design-system/components/Eyebrow/types/Eyebrow.ts deleted file mode 100644 index b13030e201..0000000000 --- a/packages/twenty-website-new/src/design-system/components/Eyebrow/types/Eyebrow.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { HeadingType } from '@/design-system/components/Heading/types/Heading'; - -export type EyebrowType = { heading: HeadingType }; diff --git a/packages/twenty-website-new/src/design-system/components/Form/Form.tsx b/packages/twenty-website-new/src/design-system/components/Form/Form.tsx new file mode 100644 index 0000000000..a4c2e841c7 --- /dev/null +++ b/packages/twenty-website-new/src/design-system/components/Form/Form.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { theme } from '@/theme'; +import { Field } from '@base-ui/react/field'; +import { styled } from '@linaria/react'; +import { type ComponentPropsWithoutRef, type ReactNode } from 'react'; + +const FieldRootBase = styled(Field.Root)` + display: flex; + flex-direction: column; + gap: ${theme.spacing(1.5)}; + width: 100%; +`; + +const FieldLabel = styled(Field.Label)` + color: ${theme.colors.secondary.text[100]}; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(3)}; + font-weight: ${theme.font.weight.medium}; + line-height: ${theme.lineHeight(4.5)}; +`; + +const FieldHint = styled(Field.Description)` + color: ${theme.colors.secondary.text[60]}; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(3)}; + font-weight: ${theme.font.weight.regular}; + line-height: ${theme.lineHeight(4)}; + margin: 0; +`; + +const FieldError = styled(Field.Error)` + color: #ff9a9a; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(3)}; + font-weight: ${theme.font.weight.regular}; + line-height: ${theme.lineHeight(3.5)}; + margin: 0; +`; + +export type FormFieldProps = ComponentPropsWithoutRef & { + label?: ReactNode; + hint?: ReactNode; + error?: ReactNode; +}; + +function FormField({ + children, + error, + hint, + invalid, + label, + ...rootProps +}: FormFieldProps) { + const hasHint = hint !== undefined && hint !== null && hint !== ''; + const hasError = error !== undefined && error !== null && error !== ''; + const computedInvalid = + invalid !== undefined ? invalid : hasError || undefined; + + return ( + // oxlint-disable-next-line eslint-plugin-react(jsx-props-no-spreading) + + {label !== undefined && label !== null ? ( + {label} + ) : null} + {children} + {hasHint ? {hint} : null} + {hasError ? {error} : null} + + ); +} + +export const FormInput = styled(Field.Control)` + background: transparent; + border: 1px solid ${theme.colors.secondary.border[20]}; + border-radius: ${theme.radius(2)}; + box-sizing: border-box; + color: ${theme.colors.secondary.text[100]}; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(4)}; + font-weight: ${theme.font.weight.regular}; + height: clamp(40px, 5.5vh, 56px); + line-height: ${theme.lineHeight(5.5)}; + padding-bottom: ${theme.spacing(1)}; + padding-left: ${theme.spacing(3)}; + padding-right: ${theme.spacing(3)}; + padding-top: ${theme.spacing(1)}; + width: 100%; + + &::placeholder { + color: ${theme.colors.secondary.text[40]}; + } + + &:focus-visible { + border-color: ${theme.colors.highlight[100]}; + outline: none; + } + + &[aria-invalid='true'] { + border-color: #ff9a9a; + } +`; + +const StyledTextareaElement = styled.textarea` + background: transparent; + border: 1px solid ${theme.colors.secondary.border[20]}; + border-radius: ${theme.radius(2)}; + box-sizing: border-box; + color: ${theme.colors.secondary.text[100]}; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(4)}; + font-weight: ${theme.font.weight.regular}; + line-height: ${theme.lineHeight(5.5)}; + min-height: clamp(80px, 18vh, 185px); + padding-bottom: ${theme.spacing(1)}; + padding-left: ${theme.spacing(3)}; + padding-right: ${theme.spacing(3)}; + padding-top: ${theme.spacing(1)}; + resize: vertical; + width: 100%; + + &::placeholder { + color: ${theme.colors.secondary.text[40]}; + } + + &:focus-visible { + border-color: ${theme.colors.highlight[100]}; + outline: none; + } + + &[aria-invalid='true'] { + border-color: #ff9a9a; + } +`; + +const TEXTAREA_RENDER = ; + +export type FormTextareaProps = Omit< + ComponentPropsWithoutRef, + 'render' +>; + +function FormTextarea(props: FormTextareaProps) { + // oxlint-disable-next-line eslint-plugin-react(jsx-props-no-spreading) + return ; +} + +export const Form = { + Field: FormField, + Input: FormInput, + Textarea: FormTextarea, + Label: FieldLabel, + Hint: FieldHint, + Error: FieldError, +}; diff --git a/packages/twenty-website-new/src/design-system/components/Form/index.ts b/packages/twenty-website-new/src/design-system/components/Form/index.ts new file mode 100644 index 0000000000..da1ac1a027 --- /dev/null +++ b/packages/twenty-website-new/src/design-system/components/Form/index.ts @@ -0,0 +1,2 @@ +export { Form } from './Form'; +export type { FormFieldProps, FormTextareaProps } from './Form'; diff --git a/packages/twenty-website-new/src/design-system/components/GuideCrosshair.tsx b/packages/twenty-website-new/src/design-system/components/GuideCrosshair.tsx new file mode 100644 index 0000000000..94d854d5df --- /dev/null +++ b/packages/twenty-website-new/src/design-system/components/GuideCrosshair.tsx @@ -0,0 +1,185 @@ +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; + +type RootProps = { $zIndex: number }; + +const Root = styled.div` + display: none; + + @media (min-width: ${theme.breakpoints.md}px) { + display: block; + inset: 0; + pointer-events: none; + position: absolute; + z-index: ${({ $zIndex }) => $zIndex}; + } +`; + +type HorizontalLineProps = { + $color: string; + $stroke: string; + $crossY: string; + $crossX: string; + $gap: string; +}; + +const HorizontalLineLeft = styled.div` + @media (min-width: ${theme.breakpoints.md}px) { + background-color: ${({ $color }) => $color}; + height: ${({ $stroke }) => $stroke}; + left: 0; + position: absolute; + top: ${({ $crossY }) => $crossY}; + width: calc(${({ $crossX }) => $crossX} - ${({ $gap }) => $gap}); + } +`; + +const HorizontalLineRight = styled.div` + @media (min-width: ${theme.breakpoints.md}px) { + background-color: ${({ $color }) => $color}; + height: ${({ $stroke }) => $stroke}; + position: absolute; + right: 0; + top: ${({ $crossY }) => $crossY}; + width: calc(100% - ${({ $crossX }) => $crossX} - ${({ $gap }) => $gap}); + } +`; + +type VerticalLineProps = { + $color: string; + $stroke: string; + $crossX: string; + $crossY: string; + $gap: string; +}; + +const VerticalLineTop = styled.div` + @media (min-width: ${theme.breakpoints.md}px) { + background-color: ${({ $color }) => $color}; + height: calc(${({ $crossY }) => $crossY} - ${({ $gap }) => $gap}); + left: ${({ $crossX }) => $crossX}; + position: absolute; + top: 0; + width: ${({ $stroke }) => $stroke}; + } +`; + +const VerticalLineBottom = styled.div` + @media (min-width: ${theme.breakpoints.md}px) { + background-color: ${({ $color }) => $color}; + bottom: 0; + height: calc(100% - ${({ $crossY }) => $crossY} - ${({ $gap }) => $gap}); + left: ${({ $crossX }) => $crossX}; + position: absolute; + width: ${({ $stroke }) => $stroke}; + } +`; + +type PlusProps = { + $crossX: string; + $crossY: string; + $offset: string; + $size: string; + $color: string; + $stroke: string; +}; + +const Plus = styled.div` + @media (min-width: ${theme.breakpoints.md}px) { + height: ${({ $size }) => $size}; + left: calc(${({ $crossX }) => $crossX} - ${({ $offset }) => $offset}); + position: absolute; + top: calc(${({ $crossY }) => $crossY} - ${({ $offset }) => $offset}); + width: ${({ $size }) => $size}; + + &::before, + &::after { + background-color: ${({ $color }) => $color}; + content: ''; + position: absolute; + } + + &::before { + height: ${({ $size }) => $size}; + left: 50%; + top: 0; + transform: translateX(-50%); + width: ${({ $stroke }) => $stroke}; + } + + &::after { + height: ${({ $stroke }) => $stroke}; + left: 0; + top: 50%; + transform: translateY(-50%); + width: ${({ $size }) => $size}; + } + } +`; + +type GuideCrosshairProps = { + className?: string; + crossX: string; + crossY: string; + gap?: string; + lineColor?: string; + plusColor?: string; + plusOffset?: string; + plusSize?: string; + strokeWidth?: string; + zIndex?: number; +}; + +export function GuideCrosshair({ + className, + crossX, + crossY, + gap = '18px', + lineColor = theme.colors.primary.border[10], + plusColor = theme.colors.highlight[100], + plusOffset = '6px', + plusSize = '12px', + strokeWidth = '1px', + zIndex = 1, +}: GuideCrosshairProps) { + return ( + + + + + + + + ); +} diff --git a/packages/twenty-website-new/src/design-system/components/GuideCrosshair/GuideCrosshair.tsx b/packages/twenty-website-new/src/design-system/components/GuideCrosshair/GuideCrosshair.tsx deleted file mode 100644 index cd8f9d52bc..0000000000 --- a/packages/twenty-website-new/src/design-system/components/GuideCrosshair/GuideCrosshair.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import type { CSSProperties } from 'react'; - -const Root = styled.div` - display: none; - - @media (min-width: ${theme.breakpoints.md}px) { - display: block; - inset: 0; - pointer-events: none; - position: absolute; - z-index: var(--guide-z-index, 1); - } -`; - -const HorizontalLineLeft = styled.div` - @media (min-width: ${theme.breakpoints.md}px) { - background-color: var(--guide-line-color); - height: var(--guide-stroke); - left: 0; - position: absolute; - top: var(--guide-cross-y); - width: calc(var(--guide-cross-x) - var(--guide-gap)); - } -`; - -const HorizontalLineRight = styled.div` - @media (min-width: ${theme.breakpoints.md}px) { - background-color: var(--guide-line-color); - height: var(--guide-stroke); - position: absolute; - right: 0; - top: var(--guide-cross-y); - width: calc(100% - var(--guide-cross-x) - var(--guide-gap)); - } -`; - -const VerticalLineTop = styled.div` - @media (min-width: ${theme.breakpoints.md}px) { - background-color: var(--guide-line-color); - height: calc(var(--guide-cross-y) - var(--guide-gap)); - left: var(--guide-cross-x); - position: absolute; - top: 0; - width: var(--guide-stroke); - } -`; - -const VerticalLineBottom = styled.div` - @media (min-width: ${theme.breakpoints.md}px) { - background-color: var(--guide-line-color); - bottom: 0; - height: calc(100% - var(--guide-cross-y) - var(--guide-gap)); - left: var(--guide-cross-x); - position: absolute; - width: var(--guide-stroke); - } -`; - -const Plus = styled.div` - @media (min-width: ${theme.breakpoints.md}px) { - height: var(--guide-plus-size); - left: calc(var(--guide-cross-x) - var(--guide-plus-offset)); - position: absolute; - top: calc(var(--guide-cross-y) - var(--guide-plus-offset)); - width: var(--guide-plus-size); - - &::before, - &::after { - background-color: var(--guide-plus-color); - content: ''; - position: absolute; - } - - &::before { - height: var(--guide-plus-size); - left: 50%; - top: 0; - transform: translateX(-50%); - width: var(--guide-stroke); - } - - &::after { - height: var(--guide-stroke); - left: 0; - top: 50%; - transform: translateY(-50%); - width: var(--guide-plus-size); - } - } -`; - -type GuideCrosshairProps = { - className?: string; - crossX: string; - crossY: string; - gap?: string; - lineColor?: string; - plusColor?: string; - plusOffset?: string; - plusSize?: string; - strokeWidth?: string; - zIndex?: number; -}; - -export function GuideCrosshair({ - className, - crossX, - crossY, - gap = '18px', - lineColor = theme.colors.primary.border[10], - plusColor = theme.colors.highlight[100], - plusOffset = '6px', - plusSize = '12px', - strokeWidth = '1px', - zIndex = 1, -}: GuideCrosshairProps) { - const style = { - '--guide-cross-x': crossX, - '--guide-cross-y': crossY, - '--guide-gap': gap, - '--guide-line-color': lineColor, - '--guide-plus-color': plusColor, - '--guide-plus-offset': plusOffset, - '--guide-plus-size': plusSize, - '--guide-stroke': strokeWidth, - '--guide-z-index': String(zIndex), - } as CSSProperties; - - return ( - - - - - - - - ); -} diff --git a/packages/twenty-website-new/src/design-system/components/Heading/Heading.tsx b/packages/twenty-website-new/src/design-system/components/Heading.tsx similarity index 95% rename from packages/twenty-website-new/src/design-system/components/Heading/Heading.tsx rename to packages/twenty-website-new/src/design-system/components/Heading.tsx index d84771ac12..70b6bfe970 100644 --- a/packages/twenty-website-new/src/design-system/components/Heading/Heading.tsx +++ b/packages/twenty-website-new/src/design-system/components/Heading.tsx @@ -2,7 +2,14 @@ import { theme } from '@/theme'; import { css } from '@linaria/core'; import { styled } from '@linaria/react'; import { Fragment } from 'react'; -import { HeadingType } from './types/Heading'; + +export type HeadingType = { + fontFamily: 'sans' | 'serif' | 'mono'; + text: string; + fontWeight?: 'light' | 'regular' | 'medium'; + newLine?: boolean; + lineBreakBefore?: boolean; +}; const headingRootClassName = css` margin: 0; diff --git a/packages/twenty-website-new/src/design-system/components/Heading/types/Heading.ts b/packages/twenty-website-new/src/design-system/components/Heading/types/Heading.ts deleted file mode 100644 index 8456ad0135..0000000000 --- a/packages/twenty-website-new/src/design-system/components/Heading/types/Heading.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type HeadingType = { - fontFamily: 'sans' | 'serif' | 'mono'; - text: string; - fontWeight?: 'light' | 'regular' | 'medium'; - newLine?: boolean; - lineBreakBefore?: boolean; -}; diff --git a/packages/twenty-website-new/src/design-system/components/IconButton/IconButton.tsx b/packages/twenty-website-new/src/design-system/components/IconButton.tsx similarity index 72% rename from packages/twenty-website-new/src/design-system/components/IconButton/IconButton.tsx rename to packages/twenty-website-new/src/design-system/components/IconButton.tsx index 87638e6e4c..8d1956ec18 100644 --- a/packages/twenty-website-new/src/design-system/components/IconButton/IconButton.tsx +++ b/packages/twenty-website-new/src/design-system/components/IconButton.tsx @@ -1,12 +1,7 @@ -import type { ComponentType, CSSProperties } from 'react'; import { theme } from '@/theme'; import { styled } from '@linaria/react'; import Link from 'next/link'; - -type IconButtonSurfaceStyle = CSSProperties & { - '--icon-button-border-color': string; - '--icon-button-size': string; -}; +import type { ComponentType } from 'react'; type IconComponent = | ComponentType<{ size: number; fillColor: string }> @@ -52,19 +47,24 @@ const iconButtonSurfaceStyles = ` } `; -const StyledButton = styled.button` +type SurfaceProps = { + $borderColor: string; + $size: number; +}; + +const StyledButton = styled.button` ${iconButtonSurfaceStyles} - border: 1px solid var(--icon-button-border-color); - height: var(--icon-button-size); - width: var(--icon-button-size); + border: 1px solid ${({ $borderColor }) => $borderColor}; + height: ${({ $size }) => `${$size}px`}; + width: ${({ $size }) => `${$size}px`}; `; -const StyledIconLink = styled(Link)` +const StyledIconLink = styled(Link)` ${iconButtonSurfaceStyles} - border: 1px solid var(--icon-button-border-color); + border: 1px solid ${({ $borderColor }) => $borderColor}; color: inherit; - height: var(--icon-button-size); - width: var(--icon-button-size); + height: ${({ $size }) => `${$size}px`}; + width: ${({ $size }) => `${$size}px`}; `; export function IconButton({ @@ -81,24 +81,20 @@ export function IconButton({ }: IconButtonProps) { const icon = (