Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85effd6750 | ||
|
|
80c0e5603d | ||
|
|
47710908a8 |
+1
-1
@@ -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
|
||||
{
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+23
@@ -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({
|
||||
|
||||
+4
-2
@@ -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 });
|
||||
|
||||
|
||||
+27
-1
@@ -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();
|
||||
|
||||
|
||||
+7
-1
@@ -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 {}
|
||||
|
||||
+23
@@ -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(
|
||||
|
||||
+4
-3
@@ -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);
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
PARTNER_APPLICATION_WEBHOOK_URL=
|
||||
|
||||
# Public site URL (used for Stripe checkout success URL and billing portal return)
|
||||
# Optional GitHub personal-access token (no scopes required) used by the
|
||||
# community-stats fetcher (`src/lib/community/fetch-github-star-count.ts`).
|
||||
# When set, requests use the 5000/hr authenticated limit instead of the
|
||||
# 60/hr unauthenticated per-IP limit. Both responses are also cached for
|
||||
# one hour via `unstable_cache`, so dev without a token still works.
|
||||
# GITHUB_TOKEN=
|
||||
|
||||
# Public site URL — canonical origin used by `metadataBase`, sitemap, robots,
|
||||
# OG/Twitter card URLs, Stripe checkout success URL, and billing portal return.
|
||||
# No trailing slash. Defaults to https://twenty.com when unset.
|
||||
NEXT_PUBLIC_WEBSITE_URL=
|
||||
|
||||
# --- Visual runtime kill switches ---------------------------------------
|
||||
# Hard kill switch for every WebGL/Three/R3F decorative visual on the site.
|
||||
# Set to "1" / "true" to ship a build with all heavy visuals statically
|
||||
# replaced by their fallbacks (e.g. during a GPU-driver-related incident).
|
||||
# NEXT_PUBLIC_DISABLE_HEAVY_VISUALS=
|
||||
|
||||
# Soft cap on the number of concurrent WebGL contexts the page is allowed
|
||||
# to spin up. The browser's own hard cap is typically 8–16; we default to 8
|
||||
# so a single page that mounts every illustration cannot exhaust the GPU
|
||||
# context pool on integrated graphics.
|
||||
# NEXT_PUBLIC_MAX_WEBGL_CONTEXTS=8
|
||||
|
||||
# Stripe — self-hosted enterprise checkout & subscription APIs
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=
|
||||
|
||||
@@ -41,3 +41,7 @@ next-env.d.ts
|
||||
|
||||
# DB
|
||||
*.sqlite
|
||||
|
||||
# local-only memory files (not for commit)
|
||||
/Documentation.md
|
||||
/Todo.md
|
||||
|
||||
@@ -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.)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+2
-2
@@ -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();
|
||||
|
||||
+16
-7
@@ -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 />;
|
||||
|
||||
@@ -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';
|
||||
+1
-2
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { TalkToUsButton } from '@/app/components/ContactCalModal';
|
||||
import { TalkToUsButton } from '@/lib/contact-cal';
|
||||
|
||||
import { BecomePartnerButton } from './BecomePartnerButton';
|
||||
|
||||
|
||||
+1
-1
@@ -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} />
|
||||
|
||||
+2
-2
@@ -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
Reference in New Issue
Block a user