[Website] locale-segment routing and shared Lingui factory (#20079)
**1. Shared Lingui factory in `twenty-shared`**
- Extracted `createI18nInstanceFactory` into
`packages/twenty-shared/src/i18n/create-i18n-instance-factory.ts` so
every package gets the same per-render Lingui bootstrap with a
per-locale singleton cache and a `SOURCE_LOCALE` fallback.
- `twenty-emails/src/utils/i18n.utils.ts` now consumes the shared
factory.
**2. `twenty-website-new` Lingui bootstrap + Crowdin wiring**
- `lingui.config.ts`, `src/lib/i18n/*`, `nx run
twenty-website-new:lingui:{extract,compile}`.
- 31 locale PO files generated; minified compiled output kept out of
Prettier and Oxlint.
- `i18n-{push,pull}.yaml` workflows updated to include
`twenty-website-new` in Crowdin sync.
**3. `app/[locale]/...` segment routing with English at the root**
- All marketing routes moved under `src/app/[locale]/`; static
generation preserved (15 routes × 31 locales = 465 prerendered URLs).
- Middleware behavior:
- `/{en}/...` → 301 redirect to unprefixed canonical.
- `/{non-en}/...` → pass through, set `NEXT_LOCALE` cookie.
### What this PR explicitly does not do (deferred)
- Lingui-wrapping the actual marketing copy. Keys, build pipeline, and
runtime are wired; copy migration is a separate, reviewer-friendlier
PR.
This commit is contained in:
@@ -58,6 +58,7 @@ jobs:
|
||||
npx nx run twenty-server:lingui:compile --strict
|
||||
npx nx run twenty-emails:lingui:compile --strict
|
||||
npx nx run twenty-front:lingui:compile --strict
|
||||
npx nx run twenty-website-new:lingui:compile --strict
|
||||
continue-on-error: true
|
||||
|
||||
- name: Stash any changes before pulling translations
|
||||
@@ -115,6 +116,7 @@ jobs:
|
||||
npx nx run twenty-server:lingui:compile
|
||||
npx nx run twenty-emails:lingui:compile
|
||||
npx nx run twenty-front:lingui:compile
|
||||
npx nx run twenty-website-new:lingui:compile
|
||||
git status
|
||||
git add .
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
npx nx run twenty-server:lingui:extract
|
||||
npx nx run twenty-emails:lingui:extract
|
||||
npx nx run twenty-front:lingui:extract
|
||||
npx nx run twenty-website-new:lingui:extract
|
||||
|
||||
- name: Check and commit extracted files
|
||||
id: check_extract_changes
|
||||
@@ -60,6 +61,7 @@ jobs:
|
||||
npx nx run twenty-server:lingui:compile
|
||||
npx nx run twenty-emails:lingui:compile
|
||||
npx nx run twenty-front:lingui:compile
|
||||
npx nx run twenty-website-new:lingui:compile
|
||||
|
||||
- name: Check and commit compiled files
|
||||
id: check_compile_changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { setupI18n, type I18n, type Messages } from '@lingui/core';
|
||||
import { type Messages } from '@lingui/core';
|
||||
import { createI18nInstanceFactory } from 'twenty-shared/i18n';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { messages as afMessages } from '@/locales/generated/af-ZA';
|
||||
import { messages as arMessages } from '@/locales/generated/ar-SA';
|
||||
import { messages as caMessages } from '@/locales/generated/ca-ES';
|
||||
@@ -67,20 +67,4 @@ const messages: Record<keyof typeof APP_LOCALES, Messages> = {
|
||||
'zh-TW': zhHantMessages,
|
||||
};
|
||||
|
||||
const i18nInstancesMap: Partial<Record<keyof typeof APP_LOCALES, I18n>> = {};
|
||||
|
||||
export const createI18nInstance = (locale: keyof typeof APP_LOCALES): I18n => {
|
||||
if (isDefined(i18nInstancesMap[locale])) {
|
||||
return i18nInstancesMap[locale];
|
||||
}
|
||||
|
||||
const i18nInstance = setupI18n();
|
||||
const localeMessages = messages[locale] ?? messages.en;
|
||||
|
||||
i18nInstance.load(locale, localeMessages);
|
||||
i18nInstance.activate(locale);
|
||||
|
||||
i18nInstancesMap[locale] = i18nInstance;
|
||||
|
||||
return i18nInstance;
|
||||
};
|
||||
export const createI18nInstance = createI18nInstanceFactory(messages);
|
||||
|
||||
@@ -66,6 +66,11 @@
|
||||
"import": "./dist/database-events.mjs",
|
||||
"require": "./dist/database-events.cjs"
|
||||
},
|
||||
"./i18n": {
|
||||
"types": "./dist/i18n/index.d.ts",
|
||||
"import": "./dist/i18n.mjs",
|
||||
"require": "./dist/i18n.cjs"
|
||||
},
|
||||
"./logic-function": {
|
||||
"types": "./dist/logic-function/index.d.ts",
|
||||
"import": "./dist/logic-function.mjs",
|
||||
@@ -118,6 +123,7 @@
|
||||
"application",
|
||||
"constants",
|
||||
"database-events",
|
||||
"i18n",
|
||||
"logic-function",
|
||||
"metadata",
|
||||
"testing",
|
||||
@@ -142,6 +148,9 @@
|
||||
"database-events": [
|
||||
"dist/database-events/index.d.ts"
|
||||
],
|
||||
"i18n": [
|
||||
"dist/i18n/index.d.ts"
|
||||
],
|
||||
"logic-function": [
|
||||
"dist/logic-function/index.d.ts"
|
||||
],
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"{projectRoot}/constants/dist",
|
||||
"{projectRoot}/database-events/package.json",
|
||||
"{projectRoot}/database-events/dist",
|
||||
"{projectRoot}/i18n/package.json",
|
||||
"{projectRoot}/i18n/dist",
|
||||
"{projectRoot}/logic-function/package.json",
|
||||
"{projectRoot}/logic-function/dist",
|
||||
"{projectRoot}/metadata/package.json",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { setupI18n, type I18n, type Messages } from '@lingui/core';
|
||||
|
||||
import { type AppLocale } from '@/translations/constants/AppLocales';
|
||||
import { SOURCE_LOCALE } from '@/translations/constants/SourceLocale';
|
||||
|
||||
export type LocaleMessagesMap = Partial<Record<AppLocale, Messages>>;
|
||||
|
||||
export const createI18nInstanceFactory = (
|
||||
messagesByLocale: LocaleMessagesMap,
|
||||
) => {
|
||||
const cache: Partial<Record<AppLocale, I18n>> = {};
|
||||
|
||||
return (locale: AppLocale): I18n => {
|
||||
const cached = cache[locale];
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const fallbackMessages = messagesByLocale[SOURCE_LOCALE] ?? {};
|
||||
const localeMessages = messagesByLocale[locale] ?? fallbackMessages;
|
||||
|
||||
const i18n = setupI18n();
|
||||
i18n.load(locale, localeMessages);
|
||||
i18n.activate(locale);
|
||||
|
||||
cache[locale] = i18n;
|
||||
return i18n;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* _____ _
|
||||
*|_ _|_ _____ _ __ | |_ _ _
|
||||
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
|
||||
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
|
||||
* |_| \_/\_/ \___|_| |_|\__|\__, |
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export type { LocaleMessagesMap } from './create-i18n-instance-factory';
|
||||
export { createI18nInstanceFactory } from './create-i18n-instance-factory';
|
||||
@@ -4,7 +4,7 @@
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"ignorePatterns": ["node_modules"],
|
||||
"ignorePatterns": ["node_modules", "src/locales/generated"],
|
||||
"rules": {
|
||||
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
|
||||
"no-console": "off",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
src/locales/generated
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from '@lingui/conf';
|
||||
import { formatter } from '@lingui/format-po';
|
||||
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: SOURCE_LOCALE,
|
||||
locales: Object.values(APP_LOCALES),
|
||||
pseudoLocale: 'pseudo-en',
|
||||
fallbackLocales: {
|
||||
'pseudo-en': 'en',
|
||||
default: SOURCE_LOCALE,
|
||||
},
|
||||
catalogs: [
|
||||
{
|
||||
path: '<rootDir>/src/locales/{locale}',
|
||||
include: ['src'],
|
||||
},
|
||||
],
|
||||
catalogsMergePath: '<rootDir>/src/locales/generated/{locale}',
|
||||
compileNamespace: 'ts',
|
||||
format: formatter({ lineNumbers: false, printLinguiId: true }),
|
||||
});
|
||||
@@ -36,6 +36,19 @@ const nextConfig: LinariaConfig = {
|
||||
configFile: path.resolve(__dirname, 'wyw-in-js.config.cjs'),
|
||||
},
|
||||
reactCompiler: true,
|
||||
experimental: {
|
||||
swcPlugins: [
|
||||
[
|
||||
'@lingui/swc-plugin',
|
||||
{
|
||||
runtimeModules: {
|
||||
i18n: ['@lingui/core', 'i18n'],
|
||||
trans: ['@lingui/react', 'Trans'],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"@gsap/react": "2.1.2",
|
||||
"@linaria/core": "^7.0.0",
|
||||
"@linaria/react": "^7.0.1",
|
||||
"@lingui/core": "^5.1.2",
|
||||
"@lingui/react": "^5.1.2",
|
||||
"@lottiefiles/dotlottie-react": "^0.18.10",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
@@ -31,9 +33,14 @@
|
||||
"sharp": "^0.33.5",
|
||||
"stripe": "^20.3.1",
|
||||
"three": "^0.183.2",
|
||||
"twenty-shared": "workspace:*",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lingui/cli": "^5.1.2",
|
||||
"@lingui/conf": "5.1.2",
|
||||
"@lingui/format-po": "5.1.2",
|
||||
"@lingui/swc-plugin": "^5.11.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -117,6 +117,22 @@
|
||||
"configurations": {
|
||||
"fix": {}
|
||||
}
|
||||
},
|
||||
"lingui:extract": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": ["^build"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "lingui extract --overwrite --clean"
|
||||
}
|
||||
},
|
||||
"lingui:compile": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": ["^build"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "lingui compile --typescript"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-11
@@ -1,10 +1,10 @@
|
||||
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 { HELPED_DATA } from '@/app/[locale]/(home)/helped.data';
|
||||
import { HERO_DATA } from '@/app/[locale]/(home)/hero.data';
|
||||
import { HOME_STEPPER_DATA } from '@/app/[locale]/(home)/home-stepper.data';
|
||||
import { PROBLEM_DATA } from '@/app/[locale]/(home)/problem.data';
|
||||
import { TESTIMONIALS_DATA } from '@/app/[locale]/(home)/testimonials.data';
|
||||
import { THREE_CARDS_FEATURE_DATA } from '@/app/[locale]/(home)/three-cards-feature.data';
|
||||
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/(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';
|
||||
@@ -22,15 +22,18 @@ import { Menu } from '@/sections/Menu/components';
|
||||
import { Problem } from '@/sections/Problem/components';
|
||||
import { Testimonials } from '@/sections/Testimonials/components';
|
||||
import { ThreeCards } from '@/sections/ThreeCards/components';
|
||||
import { buildLocalizedMetadata } from '@/lib/seo';
|
||||
import { TrustedBy } from '@/sections/TrustedBy/components';
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
alternates: { canonical: '/' },
|
||||
};
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/',
|
||||
title: 'Twenty | #1 open source CRM',
|
||||
description:
|
||||
'The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.',
|
||||
});
|
||||
|
||||
const HOME_TOP_BACKGROUND_COLOR = '#F4F4F4';
|
||||
const PRODUCT_HUNT_LAUNCH_URL =
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
|
||||
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
|
||||
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';
|
||||
import { buildLocalizedMetadata } from '@/lib/seo';
|
||||
|
||||
const PLACEHOLDER_HERO =
|
||||
'https://images.unsplash.com/photo-1733244766159-f58f4184fd38?w=1600&q=80';
|
||||
@@ -99,7 +98,7 @@ const CASE_STUDY: CaseStudyData = {
|
||||
},
|
||||
};
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/customers/9dots',
|
||||
title: CASE_STUDY.meta.title,
|
||||
description: CASE_STUDY.meta.description,
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
|
||||
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
|
||||
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';
|
||||
import { buildLocalizedMetadata } from '@/lib/seo';
|
||||
|
||||
const PLACEHOLDER_HERO =
|
||||
'https://images.unsplash.com/photo-1687600154329-150952c73169?w=1600&q=80';
|
||||
@@ -99,7 +98,7 @@ const CASE_STUDY: CaseStudyData = {
|
||||
},
|
||||
};
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/customers/act-education',
|
||||
title: CASE_STUDY.meta.title,
|
||||
description: CASE_STUDY.meta.description,
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
|
||||
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
|
||||
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';
|
||||
import { buildLocalizedMetadata } from '@/lib/seo';
|
||||
|
||||
const PLACEHOLDER_HERO =
|
||||
'https://images.unsplash.com/photo-1702047149248-a6049168d2a8?w=1600&q=80';
|
||||
@@ -73,7 +72,7 @@ const CASE_STUDY: CaseStudyData = {
|
||||
},
|
||||
};
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/customers/alternative-partners',
|
||||
title: CASE_STUDY.meta.title,
|
||||
description: CASE_STUDY.meta.description,
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
|
||||
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
|
||||
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';
|
||||
import { buildLocalizedMetadata } from '@/lib/seo';
|
||||
|
||||
const PLACEHOLDER_HERO =
|
||||
'https://images.unsplash.com/photo-1758873269035-aae0e1fd3422?w=1600&q=80';
|
||||
@@ -113,7 +112,7 @@ const CASE_STUDY: CaseStudyData = {
|
||||
},
|
||||
};
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/customers/elevate-consulting',
|
||||
title: CASE_STUDY.meta.title,
|
||||
description: CASE_STUDY.meta.description,
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
|
||||
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
|
||||
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';
|
||||
import { buildLocalizedMetadata } from '@/lib/seo';
|
||||
|
||||
const PLACEHOLDER_HERO =
|
||||
'https://images.unsplash.com/photo-1744830343976-ce690ba2a67c?w=1600&q=80';
|
||||
@@ -103,7 +102,7 @@ const CASE_STUDY: CaseStudyData = {
|
||||
},
|
||||
};
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/customers/netzero',
|
||||
title: CASE_STUDY.meta.title,
|
||||
description: CASE_STUDY.meta.description,
|
||||
+2
-3
@@ -14,11 +14,10 @@ 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 { buildLocalizedMetadata } from '@/lib/seo';
|
||||
import { css } from '@linaria/core';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/customers',
|
||||
title: 'Customers | Twenty',
|
||||
description:
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
|
||||
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
|
||||
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';
|
||||
import { buildLocalizedMetadata } from '@/lib/seo';
|
||||
|
||||
const PLACEHOLDER_HERO =
|
||||
'https://images.unsplash.com/photo-1756830231350-3b501f63c5c1?w=1600&q=80';
|
||||
@@ -100,7 +99,7 @@ const CASE_STUDY: CaseStudyData = {
|
||||
},
|
||||
};
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/customers/w3villa',
|
||||
title: CASE_STUDY.meta.title,
|
||||
description: CASE_STUDY.meta.description,
|
||||
+3
-4
@@ -1,5 +1,5 @@
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { EnterpriseActivateClient } from '@/app/enterprise/activate/EnterpriseActivateClient';
|
||||
import { EnterpriseActivateClient } from '@/app/[locale]/enterprise/activate/EnterpriseActivateClient';
|
||||
import { Body, Container, Eyebrow } from '@/design-system/components';
|
||||
import type { HeadingType } from '@/design-system/components/Heading';
|
||||
import { Pages } from '@/lib/pages';
|
||||
@@ -8,12 +8,11 @@ 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 { buildLocalizedMetadata } from '@/lib/seo';
|
||||
import { Suspense } from 'react';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/enterprise/activate',
|
||||
title: 'Enterprise activation | Twenty',
|
||||
description:
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactExportSettings } from '@/app/halftone/_lib/exporters';
|
||||
import type { ReactExportSettings } from '@/app/[locale]/halftone/_lib/exporters';
|
||||
import { IconLayoutSidebarRightCollapse, IconShare } from '@tabler/icons-react';
|
||||
import { styled } from '@linaria/react';
|
||||
import type {
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ControlsPanel } from '@/app/halftone/_components/ControlsPanel';
|
||||
import { ControlsPanel } from '@/app/[locale]/halftone/_components/ControlsPanel';
|
||||
import {
|
||||
DEFAULT_REACT_EXPORT_SETTINGS,
|
||||
deriveExportComponentName,
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
getExportedModelFile,
|
||||
parseExportedPreset,
|
||||
type ReactExportSettings,
|
||||
} from '@/app/halftone/_lib/exporters';
|
||||
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
|
||||
import { generateImageHalftoneSvg } from '@/app/halftone/_lib/imageSvgExport';
|
||||
} from '@/app/[locale]/halftone/_lib/exporters';
|
||||
import { resolveExportArtifactNames } from '@/app/[locale]/halftone/_lib/exportNames';
|
||||
import { generateImageHalftoneSvg } from '@/app/[locale]/halftone/_lib/imageSvgExport';
|
||||
import {
|
||||
buildShareUrl,
|
||||
decodeShareState,
|
||||
encodeShareState,
|
||||
} from '@/app/halftone/_lib/share';
|
||||
} from '@/app/[locale]/halftone/_lib/share';
|
||||
import {
|
||||
HalftoneCanvas,
|
||||
type HalftoneSnapshotFn,
|
||||
@@ -39,9 +39,9 @@ import {
|
||||
normalizeHalftoneStudioSettings,
|
||||
} from '@/lib/halftone/state';
|
||||
import { Logo as LogoIcon } from '@/icons';
|
||||
import { LocalizedLink } from '@/lib/i18n';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -89,7 +89,7 @@ const CanvasLayer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const LogoLink = styled(Link)`
|
||||
const LogoLink = styled(LocalizedLink)`
|
||||
display: grid;
|
||||
left: 24px;
|
||||
position: absolute;
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
formatAngle,
|
||||
formatDecimal,
|
||||
formatPercent,
|
||||
} from '@/app/halftone/_lib/formatters';
|
||||
} from '@/app/[locale]/halftone/_lib/formatters';
|
||||
import type { HalftoneStudioSettings } from '@/lib/halftone/state';
|
||||
import {
|
||||
ColorControlLabel,
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { formatAngle, formatDecimal } from '@/app/halftone/_lib/formatters';
|
||||
import {
|
||||
formatAngle,
|
||||
formatDecimal,
|
||||
} from '@/app/[locale]/halftone/_lib/formatters';
|
||||
import {
|
||||
DEFAULT_GLASS_ANIMATION_SETTINGS,
|
||||
DEFAULT_GLASS_LIGHTING_SETTINGS,
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactExportSettings } from '@/app/halftone/_lib/exporters';
|
||||
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
|
||||
import { formatAnimationName } from '@/app/halftone/_lib/formatters';
|
||||
import type { ReactExportSettings } from '@/app/[locale]/halftone/_lib/exporters';
|
||||
import { resolveExportArtifactNames } from '@/app/[locale]/halftone/_lib/exportNames';
|
||||
import { formatAnimationName } from '@/app/[locale]/halftone/_lib/formatters';
|
||||
import type {
|
||||
HalftoneGeometrySpec,
|
||||
HalftoneStudioSettings,
|
||||
+2
-2
@@ -3,8 +3,8 @@ import {
|
||||
generateStandaloneHtml,
|
||||
parseExportedPreset,
|
||||
type ReactExportSettings,
|
||||
} from '@/app/halftone/_lib/exporters';
|
||||
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
|
||||
} from '@/app/[locale]/halftone/_lib/exporters';
|
||||
import { resolveExportArtifactNames } from '@/app/[locale]/halftone/_lib/exportNames';
|
||||
import { REFERENCE_PREVIEW_DISTANCE } from '@/lib/halftone/footprint';
|
||||
import {
|
||||
DEFAULT_HALFTONE_SETTINGS,
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { normalizeExportComponentName } from '@/app/halftone/_lib/exportNames';
|
||||
import { GLASS_ENVIRONMENT_DATA_URL } from '@/app/halftone/_lib/glassEnvironmentData';
|
||||
import { normalizeExportComponentName } from '@/app/[locale]/halftone/_lib/exportNames';
|
||||
import { GLASS_ENVIRONMENT_DATA_URL } from '@/app/[locale]/halftone/_lib/glassEnvironmentData';
|
||||
import {
|
||||
HALFTONE_FOOTPRINT_RUNTIME_SOURCE,
|
||||
REFERENCE_PREVIEW_DISTANCE,
|
||||
@@ -0,0 +1,12 @@
|
||||
import { HalftoneStudio } from '@/app/[locale]/halftone/_components/HalftoneStudio';
|
||||
import { buildLocalizedMetadata } from '@/lib/seo';
|
||||
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/halftone',
|
||||
title: 'Halftone Generator | Twenty',
|
||||
description: 'Interactive halftone generator exported from Twenty.',
|
||||
});
|
||||
|
||||
export default function HalftonePage() {
|
||||
return <HalftoneStudio />;
|
||||
}
|
||||
+55
-34
@@ -1,17 +1,25 @@
|
||||
import { FooterVisibilityGate } from '@/app/_components/FooterVisibilityGate';
|
||||
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';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { Metadata } from 'next';
|
||||
import { Aleo, Azeret_Mono, Host_Grotesk, VT323 } from 'next/font/google';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { FooterVisibilityGate } from '@/app/_components/FooterVisibilityGate';
|
||||
import { ScrollToTopOnRouteChange } from '@/app/_components/ScrollToTopOnRouteChange';
|
||||
import { ContactCalModalRoot } from '@/lib/contact-cal';
|
||||
import {
|
||||
I18nProvider,
|
||||
PUBLIC_APP_LOCALE_LIST,
|
||||
getLocaleMessages,
|
||||
resolveLocaleParam,
|
||||
} from '@/lib/i18n';
|
||||
import { PartnerApplicationModalRoot } from '@/lib/partner-application';
|
||||
import { Footer } from '@/sections/Footer/components';
|
||||
import { FOOTER_DATA } from '@/sections/Footer/data';
|
||||
|
||||
const hostGrotesk = Host_Grotesk({
|
||||
subsets: ['latin'],
|
||||
@@ -59,9 +67,6 @@ const _globalStyles = 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;
|
||||
@@ -70,6 +75,8 @@ const _globalStyles = css`
|
||||
`;
|
||||
|
||||
const StyledMain = styled.main`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
@@ -101,17 +108,27 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
type LocaleLayoutParams = { locale: string };
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export const generateStaticParams = (): LocaleLayoutParams[] =>
|
||||
PUBLIC_APP_LOCALE_LIST.map((locale) => ({ locale }));
|
||||
|
||||
const LocaleLayout = async ({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<LocaleLayoutParams>;
|
||||
}) => {
|
||||
const { locale: rawLocale } = await params;
|
||||
const locale = resolveLocaleParam(rawLocale);
|
||||
const messages = getLocaleMessages(locale);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang={locale}>
|
||||
<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}
|
||||
@@ -122,23 +139,27 @@ export default function RootLayout({
|
||||
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable} ${vt323.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ContactCalModalRoot>
|
||||
<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>
|
||||
<I18nProvider locale={locale} messages={messages}>
|
||||
<ContactCalModalRoot>
|
||||
<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>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default LocaleLayout;
|
||||
+7
-8
@@ -3,14 +3,14 @@ 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 } from '@/app/[locale]/partners/three-cards-illustration.data';
|
||||
import { HERO_DATA } from '@/app/[locale]/partners/hero.data';
|
||||
import { SIGNOFF_DATA } from '@/app/[locale]/partners/signoff.data';
|
||||
import { TESTIMONIALS_DATA } from '@/app/[locale]/partners/testimonials.data';
|
||||
import {
|
||||
PartnerHeroCtas,
|
||||
PartnerSignoffCtas,
|
||||
} from '@/app/partners/components/PartnerApplication';
|
||||
} from '@/app/[locale]/partners/components/PartnerApplication';
|
||||
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
@@ -25,9 +25,8 @@ 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 { buildLocalizedMetadata } from '@/lib/seo';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const PARTNER_ILLUSTRATION_CARDS_SCROLL_LAYOUT_OPTIONS: ThreeCardsScrollLayoutOptions =
|
||||
{
|
||||
@@ -46,7 +45,7 @@ const PromoSpacing = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/partners',
|
||||
title: 'Partners | Twenty',
|
||||
description:
|
||||
+7
-8
@@ -1,11 +1,11 @@
|
||||
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 { BecomePartnerButton } from '@/app/[locale]/partners/components/PartnerApplication';
|
||||
import { ENGAGEMENT_BAND_DATA } from '@/app/[locale]/pricing/engagement-band.data';
|
||||
import { HERO_DATA } from '@/app/[locale]/pricing/hero.data';
|
||||
import { PLAN_TABLE_DATA } from '@/app/[locale]/pricing/plan-table.data';
|
||||
import { SALESFORCE_DATA } from '@/app/[locale]/pricing/salesforce.data';
|
||||
import { Eyebrow, LinkButton } from '@/design-system/components';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
@@ -19,9 +19,8 @@ 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 { buildLocalizedMetadata } from '@/lib/seo';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const PricingPlansContainer = styled.div`
|
||||
display: grid;
|
||||
@@ -35,7 +34,7 @@ const PricingBannerContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/pricing',
|
||||
title: 'Pricing | Twenty',
|
||||
description:
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import { LocalizedLink } from '@/lib/i18n';
|
||||
|
||||
export function PrivacyPolicyDocument() {
|
||||
return (
|
||||
@@ -56,7 +56,7 @@ export function PrivacyPolicyDocument() {
|
||||
{
|
||||
'By using the Service, you agree to the collection and use of information in accordance with this Privacy Policy and our '
|
||||
}
|
||||
<Link href={'/terms'}>{'Terms of Service'}</Link>
|
||||
<LocalizedLink href={'/terms'}>{'Terms of Service'}</LocalizedLink>
|
||||
{'.'}
|
||||
</p>
|
||||
|
||||
+2
-4
@@ -1,14 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
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 { LegalDocument } from '@/sections/LegalDocument/components';
|
||||
import { buildPageMetadata } from '@/lib/seo';
|
||||
import { buildLocalizedMetadata } from '@/lib/seo';
|
||||
|
||||
import { PrivacyPolicyDocument } from './_components';
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/privacy-policy',
|
||||
title: 'Privacy Policy | Twenty',
|
||||
description:
|
||||
+7
-8
@@ -2,11 +2,11 @@ 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 { FEATURE_DATA } from '@/app/[locale]/product/feature.data';
|
||||
import { HERO_DATA } from '@/app/[locale]/product/hero.data';
|
||||
import { SIGNOFF_DATA } from '@/app/[locale]/product/signoff.data';
|
||||
import { STEPPER_DATA } from '@/app/[locale]/product/stepper.data';
|
||||
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/product/three-cards.data';
|
||||
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
@@ -20,10 +20,9 @@ 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';
|
||||
import { buildLocalizedMetadata } from '@/lib/seo';
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/product',
|
||||
title: 'Product | Twenty',
|
||||
description:
|
||||
+3
-4
@@ -2,7 +2,7 @@ import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import {
|
||||
RELEASE_NOTES_HERO_BODY,
|
||||
RELEASE_NOTES_HERO_HEADING,
|
||||
} from '@/app/releases/hero.data';
|
||||
} from '@/app/[locale]/releases/hero.data';
|
||||
import { LinkButton } from '@/design-system/components';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { GitHubIcon } from '@/icons';
|
||||
@@ -15,11 +15,10 @@ 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 { buildLocalizedMetadata } from '@/lib/seo';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/releases',
|
||||
title: 'Releases | Twenty',
|
||||
description:
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import { LocalizedLink } from '@/lib/i18n';
|
||||
|
||||
export function TermsDocument() {
|
||||
return (
|
||||
@@ -20,7 +20,7 @@ export function TermsDocument() {
|
||||
Our Privacy Policy also governs your use of our Service and explains how
|
||||
we collect, safeguard and disclose information that results from your
|
||||
use of our web pages. Please read it here:{' '}
|
||||
<Link href="/privacy-policy">Privacy Policy</Link>.
|
||||
<LocalizedLink href="/privacy-policy">Privacy Policy</LocalizedLink>.
|
||||
</p>
|
||||
<p>
|
||||
Your agreement with us includes these Terms and our Privacy Policy
|
||||
+2
-4
@@ -1,14 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
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 { LegalDocument } from '@/sections/LegalDocument/components';
|
||||
import { buildPageMetadata } from '@/lib/seo';
|
||||
import { buildLocalizedMetadata } from '@/lib/seo';
|
||||
|
||||
import { TermsDocument } from './_components';
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/terms',
|
||||
title: 'Terms of Service | Twenty',
|
||||
description:
|
||||
+8
-9
@@ -1,10 +1,10 @@
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { EDITORIAL_FOUR } from '@/app/why-twenty/editorial-four.data';
|
||||
import { EDITORIAL_ONE } from '@/app/why-twenty/editorial-one.data';
|
||||
import { EDITORIAL_THREE } from '@/app/why-twenty/editorial-three.data';
|
||||
import { HERO_DATA } from '@/app/why-twenty/hero.data';
|
||||
import { MARQUEE_DATA } from '@/app/why-twenty/marquee.data';
|
||||
import { SIGNOFF_DATA } from '@/app/why-twenty/signoff.data';
|
||||
import { EDITORIAL_FOUR } from '@/app/[locale]/why-twenty/editorial-four.data';
|
||||
import { EDITORIAL_ONE } from '@/app/[locale]/why-twenty/editorial-one.data';
|
||||
import { EDITORIAL_THREE } from '@/app/[locale]/why-twenty/editorial-three.data';
|
||||
import { HERO_DATA } from '@/app/[locale]/why-twenty/hero.data';
|
||||
import { MARQUEE_DATA } from '@/app/[locale]/why-twenty/marquee.data';
|
||||
import { SIGNOFF_DATA } from '@/app/[locale]/why-twenty/signoff.data';
|
||||
import { LinkButton } from '@/design-system/components';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
@@ -15,9 +15,8 @@ import { Marquee } from '@/sections/Marquee/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
import { Signoff } from '@/sections/Signoff/components';
|
||||
import { theme } from '@/theme';
|
||||
import { buildPageMetadata } from '@/lib/seo';
|
||||
import { buildLocalizedMetadata } from '@/lib/seo';
|
||||
import { css } from '@linaria/core';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const editorialOneIntroClass = css`
|
||||
margin-bottom: ${theme.spacing(4)};
|
||||
@@ -58,7 +57,7 @@ const sectionCrosshairRight = {
|
||||
lineColor: crosshairLineColor,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
export const generateMetadata = buildLocalizedMetadata({
|
||||
path: '/why-twenty',
|
||||
title: 'Why Twenty | Twenty',
|
||||
description:
|
||||
@@ -1,16 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useUnlocalizedPathname } from '@/lib/i18n';
|
||||
|
||||
type FooterVisibilityGateProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function FooterVisibilityGate({ children }: FooterVisibilityGateProps) {
|
||||
const pathname = usePathname();
|
||||
const route = useUnlocalizedPathname();
|
||||
|
||||
if (pathname === '/halftone') {
|
||||
if (route === '/halftone') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { HalftoneStudio } from '@/app/halftone/_components/HalftoneStudio';
|
||||
import { buildPageMetadata } from '@/lib/seo';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata({
|
||||
path: '/halftone',
|
||||
title: 'Halftone Generator | Twenty',
|
||||
description: 'Interactive halftone generator exported from Twenty.',
|
||||
});
|
||||
|
||||
export default function HalftonePage() {
|
||||
return <HalftoneStudio />;
|
||||
}
|
||||
@@ -10,7 +10,13 @@ export default function robots(): MetadataRoute.Robots {
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/halftone', '/enterprise/activate', '/api/'],
|
||||
disallow: [
|
||||
'/api/',
|
||||
'/halftone',
|
||||
'/*/halftone',
|
||||
'/enterprise/activate',
|
||||
'/*/enterprise/activate',
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
|
||||
import { PUBLIC_APP_LOCALE_LIST } from '@/lib/i18n';
|
||||
import { getSiteUrl } from '@/lib/seo';
|
||||
|
||||
const SITE_URL = getSiteUrl();
|
||||
|
||||
const STATIC_ROUTES: ReadonlyArray<{
|
||||
type StaticRoute = {
|
||||
path: string;
|
||||
changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'];
|
||||
priority: number;
|
||||
}> = [
|
||||
};
|
||||
|
||||
const STATIC_ROUTES: readonly StaticRoute[] = [
|
||||
{ path: '/', changeFrequency: 'weekly', priority: 1.0 },
|
||||
{ path: '/why-twenty', changeFrequency: 'monthly', priority: 0.8 },
|
||||
{ path: '/product', changeFrequency: 'monthly', priority: 0.8 },
|
||||
@@ -21,20 +25,42 @@ const STATIC_ROUTES: ReadonlyArray<{
|
||||
{ path: '/terms', changeFrequency: 'yearly', priority: 0.3 },
|
||||
];
|
||||
|
||||
const buildLocalizedUrl = (locale: AppLocale, path: string): string => {
|
||||
const prefix = locale === SOURCE_LOCALE ? '' : `/${locale}`;
|
||||
const tail = path === '/' ? '' : path;
|
||||
return `${SITE_URL}${prefix}${tail}`;
|
||||
};
|
||||
|
||||
const buildLanguageAlternates = (path: string): Record<string, string> => {
|
||||
const alternates: Record<string, string> = {};
|
||||
for (const locale of PUBLIC_APP_LOCALE_LIST) {
|
||||
alternates[locale] = buildLocalizedUrl(locale, path);
|
||||
}
|
||||
alternates['x-default'] = buildLocalizedUrl(SOURCE_LOCALE, path);
|
||||
return alternates;
|
||||
};
|
||||
|
||||
const localize = (
|
||||
path: string,
|
||||
changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'],
|
||||
priority: number,
|
||||
): MetadataRoute.Sitemap =>
|
||||
PUBLIC_APP_LOCALE_LIST.map((locale) => ({
|
||||
url: buildLocalizedUrl(locale, path),
|
||||
changeFrequency,
|
||||
priority,
|
||||
alternates: { languages: buildLanguageAlternates(path) },
|
||||
}));
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const staticEntries = STATIC_ROUTES.map(
|
||||
({ path, changeFrequency, priority }) => ({
|
||||
url: `${SITE_URL}${path}`,
|
||||
changeFrequency,
|
||||
priority,
|
||||
}),
|
||||
const staticEntries = STATIC_ROUTES.flatMap(
|
||||
({ path, changeFrequency, priority }) =>
|
||||
localize(path, changeFrequency, priority),
|
||||
);
|
||||
|
||||
const caseStudyEntries = CASE_STUDY_CATALOG_ENTRIES.map((entry) => ({
|
||||
url: `${SITE_URL}${entry.href}`,
|
||||
changeFrequency: 'yearly' as const,
|
||||
priority: 0.5,
|
||||
}));
|
||||
const caseStudyEntries = CASE_STUDY_CATALOG_ENTRIES.flatMap((entry) =>
|
||||
localize(entry.href, 'yearly', 0.5),
|
||||
);
|
||||
|
||||
return [...staticEntries, ...caseStudyEntries];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LinkButtonType } from '@/design-system/components/Button/types/LinkButtonType';
|
||||
import { LocalizedLink } from '@/lib/i18n';
|
||||
import { styled } from '@linaria/react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
BaseButton,
|
||||
type BaseButtonProps,
|
||||
@@ -11,7 +11,7 @@ const StyledButtonAnchor = styled.a`
|
||||
${buttonBaseStyles}
|
||||
`;
|
||||
|
||||
const StyledButtonLink = styled(Link)`
|
||||
const StyledButtonLink = styled(LocalizedLink)`
|
||||
${buttonBaseStyles}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LocalizedLink } from '@/lib/i18n';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import Link from 'next/link';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
type IconComponent =
|
||||
@@ -59,7 +59,7 @@ const StyledButton = styled.button<SurfaceProps>`
|
||||
width: ${({ $size }) => `${$size}px`};
|
||||
`;
|
||||
|
||||
const StyledIconLink = styled(Link)<SurfaceProps>`
|
||||
const StyledIconLink = styled(LocalizedLink)<SurfaceProps>`
|
||||
${iconButtonSurfaceStyles}
|
||||
border: 1px solid ${({ $borderColor }) => $borderColor};
|
||||
color: inherit;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { setupI18n, type Messages } from '@lingui/core';
|
||||
import { I18nProvider as LinguiI18nProvider } from '@lingui/react';
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { LocaleContext } from './LocaleContext';
|
||||
|
||||
type I18nProviderProps = {
|
||||
locale: AppLocale;
|
||||
messages: Messages;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const I18nProvider = ({
|
||||
locale,
|
||||
messages,
|
||||
children,
|
||||
}: I18nProviderProps) => {
|
||||
const i18n = useMemo(() => {
|
||||
const instance = setupI18n();
|
||||
instance.load(locale, messages);
|
||||
instance.activate(locale);
|
||||
return instance;
|
||||
}, [locale, messages]);
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={locale}>
|
||||
<LinguiI18nProvider i18n={i18n}>{children}</LinguiI18nProvider>
|
||||
</LocaleContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { createContext } from 'react';
|
||||
import { type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
export const LocaleContext = createContext<AppLocale | null>(null);
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
import { localizeHref } from './localize-href';
|
||||
import { useLocale } from './use-locale';
|
||||
|
||||
type LocalizedLinkProps = Omit<ComponentProps<typeof Link>, 'href'> & {
|
||||
href: string;
|
||||
};
|
||||
|
||||
export const LocalizedLink = ({ href, ...rest }: LocalizedLinkProps) => {
|
||||
const locale = useLocale();
|
||||
// oxlint-disable-next-line eslint-plugin-react(jsx-props-no-spreading)
|
||||
return <Link href={localizeHref(locale, href)} {...rest} />;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { detectLocale } from '../detect-locale';
|
||||
|
||||
describe('detectLocale', () => {
|
||||
it('returns the cookie value when it is a supported locale', () => {
|
||||
expect(
|
||||
detectLocale({
|
||||
cookieValue: 'fr-FR',
|
||||
acceptLanguageHeader: 'de-DE,de;q=0.9',
|
||||
}),
|
||||
).toBe('fr-FR');
|
||||
});
|
||||
|
||||
it('ignores an unsupported cookie value and falls back to Accept-Language', () => {
|
||||
expect(
|
||||
detectLocale({
|
||||
cookieValue: 'xx-YY',
|
||||
acceptLanguageHeader: 'de-DE,de;q=0.9',
|
||||
}),
|
||||
).toBe('de-DE');
|
||||
});
|
||||
|
||||
it('matches the highest-quality Accept-Language entry first', () => {
|
||||
expect(
|
||||
detectLocale({
|
||||
acceptLanguageHeader: 'en;q=0.5,fr-FR;q=0.9,de;q=0.7',
|
||||
}),
|
||||
).toBe('fr-FR');
|
||||
});
|
||||
|
||||
it('falls back from a regional tag to the language family', () => {
|
||||
expect(
|
||||
detectLocale({
|
||||
acceptLanguageHeader: 'fr-CA',
|
||||
}),
|
||||
).toBe('fr-FR');
|
||||
});
|
||||
|
||||
it('falls back from a bare language tag to the first matching regional locale', () => {
|
||||
expect(
|
||||
detectLocale({
|
||||
acceptLanguageHeader: 'pt;q=1.0,en;q=0.5',
|
||||
}),
|
||||
).toBe('pt-BR');
|
||||
});
|
||||
|
||||
it('returns the source locale when no input is provided', () => {
|
||||
expect(detectLocale({})).toBe('en');
|
||||
});
|
||||
|
||||
it('returns the source locale when nothing in Accept-Language is supported', () => {
|
||||
expect(
|
||||
detectLocale({
|
||||
acceptLanguageHeader: 'xx-YY,zz-ZZ;q=0.5',
|
||||
}),
|
||||
).toBe('en');
|
||||
});
|
||||
|
||||
it('treats a malformed quality value as zero (deprioritized)', () => {
|
||||
expect(
|
||||
detectLocale({
|
||||
acceptLanguageHeader: 'fr-FR;q=not-a-number,de-DE;q=0.5',
|
||||
}),
|
||||
).toBe('de-DE');
|
||||
});
|
||||
|
||||
it('parses quality values when parameters carry leading whitespace around the semicolon', () => {
|
||||
expect(
|
||||
detectLocale({
|
||||
acceptLanguageHeader: 'en;q=0.1, fr-FR ; q=0.9, de-DE ; q=0.5',
|
||||
}),
|
||||
).toBe('fr-FR');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { localizeHref, stripLocale } from '../localize-href';
|
||||
|
||||
describe('localizeHref', () => {
|
||||
it('prefixes a non-default locale onto an internal absolute path', () => {
|
||||
expect(localizeHref('fr-FR', '/pricing')).toBe('/fr-FR/pricing');
|
||||
});
|
||||
|
||||
it('returns paths unprefixed for the default locale (English at root)', () => {
|
||||
expect(localizeHref('en', '/pricing')).toBe('/pricing');
|
||||
expect(localizeHref('en', '/')).toBe('/');
|
||||
});
|
||||
|
||||
it('prefixes a non-default locale onto the root path', () => {
|
||||
expect(localizeHref('fr-FR', '/')).toBe('/fr-FR/');
|
||||
});
|
||||
|
||||
it('preserves query strings and hash fragments', () => {
|
||||
expect(localizeHref('de-DE', '/customers?ref=hero#top')).toBe(
|
||||
'/de-DE/customers?ref=hero#top',
|
||||
);
|
||||
expect(localizeHref('en', '/customers?ref=hero#top')).toBe(
|
||||
'/customers?ref=hero#top',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not double-prefix paths that already start with a non-default locale', () => {
|
||||
expect(localizeHref('fr-FR', '/de-DE/why-twenty')).toBe(
|
||||
'/de-DE/why-twenty',
|
||||
);
|
||||
expect(localizeHref('fr-FR', '/fr-FR/pricing')).toBe('/fr-FR/pricing');
|
||||
});
|
||||
|
||||
it('strips a redundant /en prefix when targeting the default locale', () => {
|
||||
expect(localizeHref('en', '/en/why-twenty')).toBe('/why-twenty');
|
||||
expect(localizeHref('en', '/en')).toBe('/');
|
||||
});
|
||||
|
||||
it('rewrites an /en-prefixed path onto the active non-default locale', () => {
|
||||
expect(localizeHref('fr-FR', '/en/why-twenty')).toBe('/fr-FR/why-twenty');
|
||||
expect(localizeHref('fr-FR', '/en')).toBe('/fr-FR/');
|
||||
});
|
||||
|
||||
it('passes external https URLs through unchanged', () => {
|
||||
expect(localizeHref('en', 'https://docs.twenty.com')).toBe(
|
||||
'https://docs.twenty.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes protocol-relative URLs through unchanged', () => {
|
||||
expect(localizeHref('en', '//cdn.twenty.com/asset.png')).toBe(
|
||||
'//cdn.twenty.com/asset.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes mailto and tel links through unchanged', () => {
|
||||
expect(localizeHref('en', 'mailto:contact@twenty.com')).toBe(
|
||||
'mailto:contact@twenty.com',
|
||||
);
|
||||
expect(localizeHref('en', 'tel:+1234567890')).toBe('tel:+1234567890');
|
||||
});
|
||||
|
||||
it('passes hash-only and relative hrefs through unchanged', () => {
|
||||
expect(localizeHref('en', '#top')).toBe('#top');
|
||||
expect(localizeHref('en', './sibling')).toBe('./sibling');
|
||||
expect(localizeHref('en', '../parent')).toBe('../parent');
|
||||
});
|
||||
|
||||
it('handles a locale segment immediately followed by a query string', () => {
|
||||
expect(localizeHref('en', '/en?ref=hero')).toBe('/?ref=hero');
|
||||
expect(localizeHref('fr-FR', '/en?ref=hero')).toBe('/fr-FR/?ref=hero');
|
||||
expect(localizeHref('fr-FR', '/de-DE?ref=hero')).toBe('/de-DE?ref=hero');
|
||||
});
|
||||
|
||||
it('handles a locale segment immediately followed by a hash fragment', () => {
|
||||
expect(localizeHref('en', '/en#anchor')).toBe('/#anchor');
|
||||
expect(localizeHref('fr-FR', '/en#anchor')).toBe('/fr-FR/#anchor');
|
||||
expect(localizeHref('fr-FR', '/de-DE#anchor')).toBe('/de-DE#anchor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripLocale', () => {
|
||||
it('removes a known locale prefix from the pathname', () => {
|
||||
expect(stripLocale('/en/why-twenty')).toBe('/why-twenty');
|
||||
expect(stripLocale('/fr-FR/customers/9dots')).toBe('/customers/9dots');
|
||||
});
|
||||
|
||||
it('returns the root path when the pathname is just the locale segment', () => {
|
||||
expect(stripLocale('/en')).toBe('/');
|
||||
expect(stripLocale('/zh-CN')).toBe('/');
|
||||
});
|
||||
|
||||
it('returns the pathname unchanged when no known locale prefix is present', () => {
|
||||
expect(stripLocale('/why-twenty')).toBe('/why-twenty');
|
||||
expect(stripLocale('/')).toBe('/');
|
||||
});
|
||||
|
||||
it('returns the pathname unchanged when the input does not start with a slash', () => {
|
||||
expect(stripLocale('not-a-path')).toBe('not-a-path');
|
||||
expect(stripLocale('')).toBe('');
|
||||
});
|
||||
|
||||
it('preserves query and hash when the locale segment is immediately followed by them', () => {
|
||||
expect(stripLocale('/en?ref=hero')).toBe('/?ref=hero');
|
||||
expect(stripLocale('/fr-FR#anchor')).toBe('/#anchor');
|
||||
expect(stripLocale('/fr-FR/customers?ref=hero#top')).toBe(
|
||||
'/customers?ref=hero#top',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { APP_LOCALES, type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
const APP_LOCALE_VALUES: readonly AppLocale[] = Object.values(APP_LOCALES);
|
||||
|
||||
export const isPublicAppLocale = (locale: AppLocale): boolean =>
|
||||
!locale.startsWith('pseudo-');
|
||||
|
||||
export const APP_LOCALE_LIST: readonly AppLocale[] = APP_LOCALE_VALUES;
|
||||
|
||||
export const PUBLIC_APP_LOCALE_LIST: readonly AppLocale[] =
|
||||
APP_LOCALE_VALUES.filter(isPublicAppLocale);
|
||||
|
||||
export const APP_LOCALE_BY_RAW: ReadonlyMap<string, AppLocale> = new Map(
|
||||
PUBLIC_APP_LOCALE_LIST.map((locale) => [locale, locale]),
|
||||
);
|
||||
|
||||
export const APP_LOCALE_BY_LANGUAGE: ReadonlyMap<string, AppLocale> = (() => {
|
||||
const byLanguage = new Map<string, AppLocale>();
|
||||
for (const locale of PUBLIC_APP_LOCALE_LIST) {
|
||||
const [languageSubtag] = locale.split('-');
|
||||
if (languageSubtag === undefined) continue;
|
||||
const key = languageSubtag.toLowerCase();
|
||||
if (!byLanguage.has(key)) {
|
||||
byLanguage.set(key, locale);
|
||||
}
|
||||
}
|
||||
return byLanguage;
|
||||
})();
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createI18nInstanceFactory } from 'twenty-shared/i18n';
|
||||
|
||||
import { MESSAGES_BY_LOCALE } from './messages-by-locale';
|
||||
|
||||
export const createI18nInstance = createI18nInstanceFactory(MESSAGES_BY_LOCALE);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { APP_LOCALE_BY_LANGUAGE, APP_LOCALE_BY_RAW } from './app-locale-set';
|
||||
|
||||
export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE';
|
||||
|
||||
type AcceptLanguageEntry = { tag: string; quality: number };
|
||||
|
||||
const parseAcceptLanguage = (header: string): AcceptLanguageEntry[] =>
|
||||
header
|
||||
.split(',')
|
||||
.flatMap((part): AcceptLanguageEntry[] => {
|
||||
const [rawTag, ...params] = part.trim().split(';');
|
||||
if (rawTag === undefined || rawTag.length === 0) return [];
|
||||
const qParam = params.find((p) => p.trim().startsWith('q='))?.trim();
|
||||
const parsedQuality =
|
||||
qParam !== undefined ? Number.parseFloat(qParam.slice(2)) : 1;
|
||||
return [
|
||||
{
|
||||
tag: rawTag.trim(),
|
||||
quality: Number.isFinite(parsedQuality) ? parsedQuality : 0,
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((a, b) => b.quality - a.quality);
|
||||
|
||||
const matchTag = (tag: string): AppLocale | undefined => {
|
||||
const exact = APP_LOCALE_BY_RAW.get(tag);
|
||||
if (exact !== undefined) return exact;
|
||||
|
||||
const [languageSubtag] = tag.split('-');
|
||||
if (languageSubtag === undefined) return undefined;
|
||||
return APP_LOCALE_BY_LANGUAGE.get(languageSubtag.toLowerCase());
|
||||
};
|
||||
|
||||
type DetectLocaleInput = {
|
||||
cookieValue?: string;
|
||||
acceptLanguageHeader?: string;
|
||||
};
|
||||
|
||||
export const detectLocale = ({
|
||||
cookieValue,
|
||||
acceptLanguageHeader,
|
||||
}: DetectLocaleInput): AppLocale => {
|
||||
if (cookieValue !== undefined) {
|
||||
const fromCookie = APP_LOCALE_BY_RAW.get(cookieValue);
|
||||
if (fromCookie !== undefined) return fromCookie;
|
||||
}
|
||||
|
||||
if (acceptLanguageHeader !== undefined && acceptLanguageHeader.length > 0) {
|
||||
for (const { tag } of parseAcceptLanguage(acceptLanguageHeader)) {
|
||||
const match = matchTag(tag);
|
||||
if (match !== undefined) return match;
|
||||
}
|
||||
}
|
||||
|
||||
return SOURCE_LOCALE;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
APP_LOCALE_BY_LANGUAGE,
|
||||
APP_LOCALE_BY_RAW,
|
||||
APP_LOCALE_LIST,
|
||||
PUBLIC_APP_LOCALE_LIST,
|
||||
isPublicAppLocale,
|
||||
} from './app-locale-set';
|
||||
export { createI18nInstance } from './create-i18n-instance';
|
||||
export { detectLocale, LOCALE_COOKIE_NAME } from './detect-locale';
|
||||
export { I18nProvider } from './I18nProvider';
|
||||
export { LocaleContext } from './LocaleContext';
|
||||
export { LocalizedLink } from './LocalizedLink';
|
||||
export { localizeHref, stripLocale } from './localize-href';
|
||||
export { getLocaleMessages } from './messages-by-locale';
|
||||
export { resolveLocaleParam } from './resolve-locale-param';
|
||||
export { useLocale } from './use-locale';
|
||||
export { useUnlocalizedPathname } from './use-unlocalized-pathname';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { APP_LOCALE_BY_RAW } from './app-locale-set';
|
||||
|
||||
const findFirstSegmentEnd = (path: string): number => {
|
||||
for (let i = 1; i < path.length; i += 1) {
|
||||
const ch = path[i];
|
||||
if (ch === '/' || ch === '?' || ch === '#') return i;
|
||||
}
|
||||
return path.length;
|
||||
};
|
||||
|
||||
const buildTailFromSegmentEnd = (path: string, segmentEnd: number): string => {
|
||||
const tail = path.slice(segmentEnd);
|
||||
if (tail.length === 0) return '/';
|
||||
if (tail.startsWith('?') || tail.startsWith('#')) return `/${tail}`;
|
||||
return tail;
|
||||
};
|
||||
|
||||
export const localizeHref = (locale: AppLocale, href: string): string => {
|
||||
if (!href.startsWith('/') || href.startsWith('//')) return href;
|
||||
|
||||
const segmentEnd = findFirstSegmentEnd(href);
|
||||
const firstSegment = href.slice(1, segmentEnd);
|
||||
const existingLocale = APP_LOCALE_BY_RAW.get(firstSegment);
|
||||
|
||||
if (existingLocale !== undefined && existingLocale !== SOURCE_LOCALE) {
|
||||
return href;
|
||||
}
|
||||
|
||||
const unprefixed =
|
||||
existingLocale === SOURCE_LOCALE
|
||||
? buildTailFromSegmentEnd(href, segmentEnd)
|
||||
: href;
|
||||
|
||||
return locale === SOURCE_LOCALE ? unprefixed : `/${locale}${unprefixed}`;
|
||||
};
|
||||
|
||||
export const stripLocale = (pathname: string): string => {
|
||||
if (!pathname.startsWith('/')) return pathname;
|
||||
|
||||
const segmentEnd = findFirstSegmentEnd(pathname);
|
||||
const firstSegment = pathname.slice(1, segmentEnd);
|
||||
if (!APP_LOCALE_BY_RAW.has(firstSegment)) return pathname;
|
||||
|
||||
return buildTailFromSegmentEnd(pathname, segmentEnd);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type Messages } from '@lingui/core';
|
||||
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { messages as enMessages } from '@/locales/generated/en';
|
||||
|
||||
const MESSAGES_BY_LOCALE: Partial<Record<AppLocale, Messages>> = {
|
||||
en: enMessages,
|
||||
};
|
||||
|
||||
export const getLocaleMessages = (locale: AppLocale): Messages =>
|
||||
MESSAGES_BY_LOCALE[locale] ?? MESSAGES_BY_LOCALE[SOURCE_LOCALE] ?? {};
|
||||
|
||||
export { MESSAGES_BY_LOCALE };
|
||||
@@ -0,0 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { APP_LOCALE_BY_RAW } from './app-locale-set';
|
||||
|
||||
export const resolveLocaleParam = (raw: string): AppLocale => {
|
||||
const locale = APP_LOCALE_BY_RAW.get(raw);
|
||||
if (locale === undefined) notFound();
|
||||
return locale;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user