Compare commits

...
Author SHA1 Message Date
Félix MalfaitandClaude Opus 4.7 85effd6750 fix(billing): gate AI credit-cap at entry points instead of workflow executor
Restores the credit-cap enforcement that was lifted in #19904 (which had
correctly identified that gating per-workflow-step was too coarse), but
moves it to the AI entry points where the actual cost lives.

- agent-async-executor.service: gate executeAgent.
- ai-generate-text.controller: gate the REST handler.
- agent-title-generation.service: gate generateThreadTitle.
- workflow-executor.workspace-service: replace #19904's TODO with an
  absolute-behavior comment documenting why the gate is not here.

Chat resolver was already gated; no change there. Repair-tool-call util
is a sub-call inside an already-gated flow and is left ungated.

Net effect: a workspace that exhausts credits via chat or AI agent stops
making AI calls, but its non-AI workflows (DB CRUD, branching, action
steps) continue running normally.

Addresses the root cause of the 2026-04-26 token-usage incident.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:52:00 +02:00
Charles BochetandGitHub 80c0e5603d Move isPreInstalled applicationRegistration instance command to 2.1 (#20037)
## Summary

The fast instance command adding `isPreInstalled` to
`core.applicationRegistration` was added after 2.0 was released, so it
must run as part of the 2.1 upgrade rather than 2.0.

- Renamed
`2-0/2-0-instance-command-fast-1776886452831-add-is-pre-installed-to-application-registration.ts`
to
`2-1/2-1-instance-command-fast-1776886452831-add-is-pre-installed-to-application-registration.ts`
- Updated `@RegisteredInstanceCommand` version from `'2.0.0'` to
`'2.1.0'`
- Updated the import path in `instance-commands.constant.ts`

The original timestamp (`1776886452831`) was kept; it is smaller than
the existing 2.1 fast command timestamp, so this command will simply run
first within the 2.1.0 batch.
2026-04-24 17:52:58 +00:00
Abdullah.andGitHub 47710908a8 [Website] Architecture, hardening, and perf pass. (#20020)
This PR is the result of a critical architectural review of
`twenty-website-new` and the
follow-on cleanup work. It does not touch any other package. Scope is
everything except
the enterprise/billing routes (deferred to a separate PR) and CI wiring
(also deferred).

### Why

The package had grown organically: ad-hoc scroll/motion/halftone code
per section,
WebGL renderers instantiated raw, sections without a shared shape
contract, route-scoped
files leaking across layers, public API routes without rate limiting /
timeouts /
schema validation, unbounded module-level caches in visual code,
drag/resize handlers
re-rendering React 60x/sec, no security headers, and a Lottie
scroll-mapping that would
silently drift if the asset got re-exported. We needed contracts and
primitives in
place before further work (i18n, decomposition of the giant visual
files, MDX migration
of customer/legal pages) is safe to do.

### What changed

**Layering and contracts (now enforced, not just documented).**
- New layering rule: `app → sections → lib → design-system / theme /
icons`.
Cross-section reuse goes through `lib/`. Flipped the design-system
import rule
  from warn to error.
- Extracted shared primitives into `lib/`: `scroll`, `motion`,
`halftone`, `customers`,
`partner-application`, `api`, `seo`, `semver`, `community`,
`visual-runtime`.
- Lifted route-scoped data/types out of `app/` into `lib/` +
`sections/`.
- Section shape contract: every section exposes a single compound export
from
`components/index.ts(x)`; non-leaf sections own the outer `<section>`
from
`Root.tsx`; named slots are matched by `displayName` (no
`Children.toArray`
  positional indexing). Enforced by `scripts/check-section-shape.mjs`.
- WebGL boundary: `new THREE.WebGLRenderer(...)` is forbidden outside
`src/lib/visual-runtime/`. Everything goes through
`createSiteWebGlRenderer`,
which enforces the site-wide context cap, the
`NEXT_PUBLIC_DISABLE_HEAVY_VISUALS`
  kill switch, and GPU/power-preference defaults. Enforced by
`scripts/check-boundaries.mjs` with per-line
`boundary-allow-next-line:<rule-id>`
  escape hatches and stale-directive detection.

**Design system grew to cover real cases.**
- Added Modal, Form, and Layout primitives (Stack / Inline / Grid) so
sections stop
reinventing them. Built on `@base-ui/react` for accessibility + focus
management.

**Public API hardening (non-enterprise).**
- New `lib/api/` primitives: `createRateLimiter` (in-memory token
bucket),
`fetchWithTimeout`, `readJsonBody` (Zod-validated). Applied to
newsletter,
community, and partner-application routes. `/api/partner-application`
specifically
  got a per-IP rate limit, body cap, and timeout.

**Performance.**
- `DraggableTerminal` and `DraggableAppWindow`: `pointermove` now
mutates `transform`
(via `translate3d`) and `width`/`height` directly on the DOM ref. React
state
commits only on `pointerup`. Eliminates per-frame re-renders during
interaction.
- `createBoundedFailureCache` (FIFO, 256 entries) replaces unbounded
module-level
failure caches in four visual components. Bounds memory growth from bad
asset URLs.

**Lottie frame-map guard.**
- `dotlottie-react`'s `player.totalFrames` returns a raw float (`op -
ip`), not an
integer. The HomeStepper scroll → frame map is keyed to the authored
timeline,
  so silent drift would desync every step boundary.
- Reads now `Math.floor(player.totalFrames)` consistently.
- `scripts/check-lottie-frames.mjs` extracts `op - ip` from
`public/lottie/stepper/stepper.lottie` at build time and asserts it
against
`HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES`. If anyone re-exports the
Lottie,
the build fails until both that constant and every `STEP_*_END` are
updated together.

**Security headers (`next.config.ts`).**
- HSTS, `X-Content-Type-Options: nosniff`, `Referrer-Policy:
strict-origin-when-cross-origin`,
`Permissions-Policy` (camera/mic/geolocation/payment off),
`X-Frame-Options: DENY`,
  `Content-Security-Policy: frame-ancestors 'none'`.
- Full CSP intentionally deferred until we enumerate all third-party
origins
  (Cal.com, Stripe, GitHub avatars, twenty-icons.com, etc.).

**Build / config quirks documented in code.**
- `tsconfig.json` is standalone (does NOT extend the monorepo base) —
Next.js +
  React Compiler require options that conflict with the base config.
- `sharp` moved from `devDependencies` to `dependencies` so production
image
  optimization works.

### What's deliberately NOT in this PR

- **Enterprise / billing routes** — open redirect on
`/api/enterprise/checkout`,
indefinite-bearer JWT, non-idempotent Stripe seat updates, unpinned
Stripe
`apiVersion`, missing webhook reconciliation, inconsistent error
envelope.
  Going out as a separate, security-focused PR.
- **CI workflow for `twenty-website-new`** (`lint` / `typecheck` /
`test` / `build`
  targets) — separate follow-up PR.
- **i18n via Lingui** — decision made (we need internationalization and
we already
use Lingui in `twenty-front` / `twenty-emails`); 4-phase migration plan
exists
  but does not land here.
- **Decomposition of giant visual files** (HomeVisual, ThreeCards
visuals) — blocked
  on the i18n landing first; otherwise we'd rebase the world twice.
- **Customer / legal pages → MDX** — same reason.
- **Selective memoization pass** — needs browser profiling, not blind
`useMemo`.
- **Pre-existing lint errors / typecheck noise** (~44 errors, ~41
warnings, plus
generated Next.js types and `@ts-nocheck` files) are unchanged. The
cleanup
  did not introduce new ones.

### Test plan

- [ ] `yarn install`
- [ ] `yarn nx run twenty-website-new:dev` — homepage, customers,
partner,
enterprise activate, blog, why-twenty, plans/pricing, legal pages
render.
- [ ] HomeStepper: scroll through, confirm the Lottie animation lines up
with
every step boundary. Console must NOT log a `totalFrames` mismatch.
- [ ] HomeVisual: drag and resize the terminal + app window; verify
smoothness
(no per-frame React re-renders) and that final position/size persists on
release.
- [ ] Public API endpoints: hit `/api/newsletter`, `/api/community`,
`/api/partner-application` with bad payloads → expect 4xx with Zod
errors,
not 500s. Hammer `/api/partner-application` past the per-IP limit → 429.
- [ ] Response headers on any page include HSTS, nosniff, referrer
policy,
permissions policy, X-Frame-Options, and `frame-ancestors 'none'` CSP.
- [ ] `yarn nx run twenty-website-new:lint` — error/warning count must
not exceed
      the pre-existing baseline.
- [ ] `yarn nx run twenty-website-new:typecheck` — same baseline rule.
- [ ] `node packages/twenty-website-new/scripts/check-boundaries.mjs` —
passes,
      no stale directives.
- [ ] `node packages/twenty-website-new/scripts/check-section-shape.mjs`
— passes.
- [ ] `node packages/twenty-website-new/scripts/check-lottie-frames.mjs`
— passes.
- [ ] `yarn nx run twenty-website-new:build` — green, including the
three checks
      above if wired into the build target.
2026-04-24 16:40:09 +00:00
580 changed files with 15350 additions and 14997 deletions
@@ -3,7 +3,7 @@ import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.0.0', 1776886452831)
@RegisteredInstanceCommand('2.1.0', 1776886452831)
export class AddIsPreInstalledToApplicationRegistrationFastInstanceCommand
implements FastInstanceCommand
{
@@ -15,7 +15,7 @@ import { DropWorkspaceVersionColumnFastInstanceCommand } from 'src/database/comm
import { AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1776090711153-add-global-object-context-to-command-menu-item-availability-type';
import { AddPageLayoutIdToCommandMenuItemFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1776168404836-add-page-layout-id-to-command-menu-item';
import { AddConditionalAvailabilityExpressionToPageLayoutWidgetFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1775654781000-add-conditional-availability-expression-to-page-layout-widget';
import { AddIsPreInstalledToApplicationRegistrationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-0/2-0-instance-command-fast-1776886452831-add-is-pre-installed-to-application-registration';
import { AddIsPreInstalledToApplicationRegistrationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-instance-command-fast-1776886452831-add-is-pre-installed-to-application-registration';
import { AddProviderExecutedToAgentMessagePartFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-instance-command-fast-1777012800000-add-provider-executed-to-agent-message-part';
import { BackfillPageLayoutWidgetPositionSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-instance-command-slow-1795000002000-backfill-page-layout-widget-position';
@@ -1,6 +1,7 @@
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module';
@@ -27,6 +28,7 @@ import { AgentAsyncExecutorService } from './services/agent-async-executor.servi
AiBillingModule,
AiModelsModule,
AiAgentModule,
BillingModule,
FileUrlModule,
WorkspaceDomainsModule,
UserWorkspaceModule,
@@ -14,9 +14,16 @@ import { type Repository } from 'typeorm';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { NativeToolBinderService } from 'src/engine/core-modules/tool-provider/native/native-tool-binder.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES } from 'src/engine/metadata-modules/ai/ai-agent-execution/constants/workflow-agent-registry-tool-categories.const';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
@@ -49,6 +56,8 @@ export class AgentAsyncExecutorService {
private readonly aiModelConfigService: AiModelConfigService,
private readonly toolRegistry: ToolRegistryService,
private readonly nativeToolBinder: NativeToolBinderService,
private readonly billingService: BillingService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
@InjectRepository(WorkspaceEntity)
@@ -113,6 +122,20 @@ export class AgentAsyncExecutorService {
rolePermissionConfig?: RolePermissionConfig;
authContext?: WorkspaceAuthContext;
}): Promise<AgentExecutionResult> {
if (agent && this.twentyConfigService.get('IS_BILLING_ENABLED')) {
const canBill = await this.billingService.canBillMeteredProduct(
agent.workspaceId,
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!canBill) {
throw new BillingException(
'Credits exhausted',
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
);
}
}
try {
if (agent) {
const workspace = await this.workspaceRepository.findOneBy({
@@ -328,8 +328,10 @@ export class AgentChatService {
return null;
}
const title =
await this.titleGenerationService.generateThreadTitle(messageContent);
const title = await this.titleGenerationService.generateThreadTitle(
messageContent,
workspaceId,
);
await this.threadRepository.update(threadId, { title });
@@ -2,6 +2,13 @@ import { Injectable, Logger } from '@nestjs/common';
import { generateText } from 'ai';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@@ -11,9 +18,28 @@ export class AgentTitleGenerationService {
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly billingService: BillingService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async generateThreadTitle(messageContent: string): Promise<string> {
async generateThreadTitle(
messageContent: string,
workspaceId: string,
): Promise<string> {
if (this.twentyConfigService.get('IS_BILLING_ENABLED')) {
const canBill = await this.billingService.canBillMeteredProduct(
workspaceId,
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!canBill) {
throw new BillingException(
'Credits exhausted',
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
);
}
}
try {
const defaultModel = this.aiModelRegistryService.getDefaultSpeedModel();
@@ -1,13 +1,19 @@
import { Module } from '@nestjs/common';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { AiGenerateTextController } from './controllers/ai-generate-text.controller';
@Module({
imports: [TokenModule, WorkspaceCacheStorageModule, PermissionsModule],
imports: [
TokenModule,
WorkspaceCacheStorageModule,
PermissionsModule,
BillingModule,
],
controllers: [AiGenerateTextController],
})
export class AiGenerateTextModule {}
@@ -4,6 +4,13 @@ import { generateText } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
@@ -23,6 +30,8 @@ import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models
export class AiGenerateTextController {
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly billingService: BillingService,
private readonly twentyConfigService: TwentyConfigService,
) {}
@Post('generate-text')
@@ -38,6 +47,20 @@ export class AiGenerateTextController {
);
}
if (this.twentyConfigService.get('IS_BILLING_ENABLED')) {
const canBill = await this.billingService.canBillMeteredProduct(
workspace.id,
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!canBill) {
throw new BillingException(
'Credits exhausted',
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
);
}
}
const resolvedModelId = body.modelId ?? workspace.fastModel;
this.aiModelRegistryService.validateModelAvailability(
@@ -453,9 +453,10 @@ export class WorkflowExecutorWorkspaceService {
workflowRunId: string;
workspaceId: string;
}) {
// TODO: re-enable workflow node execution credit cap once billing limits are revisited.
// Previously gated on BillingService.canBillMeteredProduct(WORKFLOW_NODE_EXECUTION);
// temporarily disabled so workflows keep running when the period cap is reached.
// Credit-cap enforcement lives at the AI entry points (chat resolver,
// executeAgent, generate-text controller, title generation). Cheap
// workflow steps (DB CRUD, branching, actions) are not gated here so a
// chat-driven cap exhaustion does not block non-AI automations.
const stepId = step.id;
const workflowAction = this.workflowActionFactory.get(step.type);
+22 -1
View File
@@ -1,8 +1,29 @@
PARTNER_APPLICATION_WEBHOOK_URL=
# Public site URL (used for Stripe checkout success URL and billing portal return)
# Optional GitHub personal-access token (no scopes required) used by the
# community-stats fetcher (`src/lib/community/fetch-github-star-count.ts`).
# When set, requests use the 5000/hr authenticated limit instead of the
# 60/hr unauthenticated per-IP limit. Both responses are also cached for
# one hour via `unstable_cache`, so dev without a token still works.
# GITHUB_TOKEN=
# Public site URL — canonical origin used by `metadataBase`, sitemap, robots,
# OG/Twitter card URLs, Stripe checkout success URL, and billing portal return.
# No trailing slash. Defaults to https://twenty.com when unset.
NEXT_PUBLIC_WEBSITE_URL=
# --- Visual runtime kill switches ---------------------------------------
# Hard kill switch for every WebGL/Three/R3F decorative visual on the site.
# Set to "1" / "true" to ship a build with all heavy visuals statically
# replaced by their fallbacks (e.g. during a GPU-driver-related incident).
# NEXT_PUBLIC_DISABLE_HEAVY_VISUALS=
# Soft cap on the number of concurrent WebGL contexts the page is allowed
# to spin up. The browser's own hard cap is typically 816; we default to 8
# so a single page that mounts every illustration cannot exhaust the GPU
# context pool on integrated graphics.
# NEXT_PUBLIC_MAX_WEBGL_CONTEXTS=8
# Stripe — self-hosted enterprise checkout & subscription APIs
STRIPE_SECRET_KEY=
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=
+4
View File
@@ -41,3 +41,7 @@ next-env.d.ts
# DB
*.sqlite
# local-only memory files (not for commit)
/Documentation.md
/Todo.md
+74 -15
View File
@@ -2,7 +2,7 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
"correctness": "error"
},
"ignorePatterns": ["node_modules"],
"rules": {
@@ -17,23 +17,32 @@
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}],
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
],
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
@@ -44,5 +53,55 @@
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
},
"overrides": [
{
"files": ["**/sections/**/*.ts", "**/sections/**/*.tsx"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@/app/**"],
"message": "Sections are page-agnostic primitives — they must not depend on a specific route. Move shared halftone primitives into `src/lib/halftone/`, shared types into `src/sections/<Section>/types/`, and shared data into `src/lib/`. (See ARCHITECTURE.md → Layering rules.)"
}
]
}
]
}
},
{
"files": ["**/lib/**/*.ts", "**/lib/**/*.tsx"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@/app/**", "@/sections/**"],
"message": "`lib/` is the leaf layer. It must not depend on routes (`app/`) or page sections (`sections/`). Invert the dependency: have the consumer pass values in. (See ARCHITECTURE.md → Layering rules.)"
}
]
}
]
}
},
{
"files": ["**/design-system/**/*.ts", "**/design-system/**/*.tsx"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@/app/**", "@/sections/**", "@/lib/**"],
"message": "Design-system primitives must only depend on `@/theme` (and `@/icons` for components that legitimately render an icon). They must not reach into routes, sections, or lib. (See ARCHITECTURE.md → Layering rules.)"
}
]
}
]
}
}
]
}
+8 -3
View File
@@ -1,4 +1,9 @@
# Twenty-Website
# twenty-website-new
This is used for the marketing website (twenty.com).
This is not related in any way to the main app, which you can find in twenty-front and twenty-server.
```bash
yarn install
yarn nx run twenty-website-new:dev
yarn nx run twenty-website-new:build
yarn nx run twenty-website-new:lint
yarn nx run twenty-website-new:typecheck
```
+3 -2
View File
@@ -6,8 +6,9 @@ import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const tsConfigPath = resolve(__dirname, './tsconfig.json');
const tsConfig = JSON.parse(readFileSync(tsConfigPath, 'utf8'));
const tsConfig = JSON.parse(
readFileSync(resolve(__dirname, './tsconfig.json'), 'utf8'),
);
const jestConfig = {
displayName: 'twenty-website-new',
+23 -6
View File
@@ -1,6 +1,21 @@
import path from 'path';
import withLinaria, { type LinariaConfig } from 'next-with-linaria';
const SECURITY_HEADERS = [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), payment=()',
},
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
] as const;
const nextConfig: LinariaConfig = {
images: {
formats: ['image/avif', 'image/webp'],
@@ -21,10 +36,16 @@ const nextConfig: LinariaConfig = {
configFile: path.resolve(__dirname, 'wyw-in-js.config.cjs'),
},
reactCompiler: true,
async headers() {
return [
{
source: '/:path*',
headers: SECURITY_HEADERS.map((h) => ({ ...h })),
},
];
},
async redirects() {
return [
// Documentation moved to docs.twenty.com (carried over from the
// legacy twenty-website Next.js app).
{
source: '/user-guide',
destination: 'https://docs.twenty.com/user-guide/introduction',
@@ -80,10 +101,6 @@ const nextConfig: LinariaConfig = {
destination: 'https://docs.twenty.com/twenty-ui/:slug',
permanent: true,
},
// Renamed/restructured pages on the new website. Mappings derived
// from the old twenty.com sitemap so existing inbound links and
// search results keep working.
{
source: '/story',
destination: '/why-twenty',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

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

After

Width:  |  Height:  |  Size: 176 KiB

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

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