mirror of
https://github.com/twentyhq/twenty.git
synced 2026-08-04 03:32:47 -04:00
[Website] Implement translations. (#20171)
As title. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
25
.github/crowdin-app.yml
vendored
25
.github/crowdin-app.yml
vendored
@@ -4,20 +4,19 @@
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
"preserve_hierarchy": true
|
||||
"base_path": ".."
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter - PO files for Lingui
|
||||
#
|
||||
"source": "**/en.po",
|
||||
preserve_hierarchy: true
|
||||
base_path: ..
|
||||
|
||||
files:
|
||||
#
|
||||
# Source files filter - PO files for Lingui
|
||||
#
|
||||
- source: packages/twenty-front/src/locales/en.po
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
}
|
||||
]
|
||||
|
||||
translation: '%original_path%/%locale%.po'
|
||||
- source: packages/twenty-server/src/engine/core-modules/i18n/locales/en.po
|
||||
translation: '%original_path%/%locale%.po'
|
||||
- source: packages/twenty-emails/src/locales/en.po
|
||||
translation: '%original_path%/%locale%.po'
|
||||
|
||||
23
.github/crowdin-website.yml
vendored
Normal file
23
.github/crowdin-website.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# Crowdin CLI configuration for Website translations (twenty-website-new)
|
||||
# Project ID: 4
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
project_id: 4
|
||||
preserve_hierarchy: true
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
base_path: ..
|
||||
languages_mapping:
|
||||
locale:
|
||||
fr: fr-FR
|
||||
|
||||
files:
|
||||
#
|
||||
# Source file - PO file for Lingui
|
||||
#
|
||||
- source: packages/twenty-website-new/src/locales/en.po
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
translation: '%original_path%/%locale%.po'
|
||||
4
.github/workflows/i18n-pull.yaml
vendored
4
.github/workflows/i18n-pull.yaml
vendored
@@ -58,7 +58,6 @@ 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
|
||||
@@ -75,8 +74,6 @@ jobs:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
source: '**/en.po'
|
||||
translation: '%original_path%/%locale%.po'
|
||||
export_only_approved: false
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
@@ -116,7 +113,6 @@ 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
|
||||
|
||||
2
.github/workflows/i18n-push.yaml
vendored
2
.github/workflows/i18n-push.yaml
vendored
@@ -41,7 +41,6 @@ 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
|
||||
@@ -61,7 +60,6 @@ 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
|
||||
|
||||
135
.github/workflows/website-i18n-pull.yaml
vendored
Normal file
135
.github/workflows/website-i18n-pull.yaml
vendored
Normal file
@@ -0,0 +1,135 @@
|
||||
# Pull down website translations from Crowdin every two hours or when triggered manually.
|
||||
# When force_pull input is true, translations will be pulled regardless of compilation status.
|
||||
|
||||
name: 'Pull website translations from Crowdin'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */2 * * *' # Every two hours.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_pull:
|
||||
description: 'Force pull translations regardless of compilation status'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
force_pull:
|
||||
description: 'Force pull translations regardless of compilation status'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
pull_website_translations:
|
||||
name: Pull website translations
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
|
||||
- name: Setup website i18n branch
|
||||
run: |
|
||||
git fetch origin i18n-website || true
|
||||
git checkout -B i18n-website origin/i18n-website || git checkout -b i18n-website
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
# Strict mode fails if there are missing website translations.
|
||||
- name: Compile website translations
|
||||
id: compile_translations_strict
|
||||
run: npx nx run twenty-website-new:lingui:compile --strict
|
||||
continue-on-error: true
|
||||
|
||||
- name: Stash any changes before pulling translations
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add .
|
||||
git stash
|
||||
|
||||
- name: Pull website translations from Crowdin
|
||||
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
source: 'packages/twenty-website-new/src/locales/en.po'
|
||||
translation: 'packages/twenty-website-new/src/locales/%locale%.po'
|
||||
export_only_approved: false
|
||||
localization_branch_name: i18n-website
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
download_sources: false
|
||||
push_sources: false
|
||||
skip_untranslated_strings: false
|
||||
skip_untranslated_files: false
|
||||
push_translations: false
|
||||
create_pull_request: false
|
||||
skip_ref_checkout: true
|
||||
dryrun_action: false
|
||||
config: '.github/crowdin-website.yml'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# Website translations project
|
||||
CROWDIN_PROJECT_ID: '4'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
# As the files are extracted from a Docker container, they belong to root:root.
|
||||
# We need to fix this before the next steps.
|
||||
- name: Fix file permissions
|
||||
run: sudo chown -R runner:docker .
|
||||
|
||||
- name: Compile website translations
|
||||
id: compile_translations
|
||||
run: |
|
||||
npx nx run twenty-website-new:lingui:compile
|
||||
git status
|
||||
git add packages/twenty-website-new/src/locales
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changes_detected=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Push changes
|
||||
if: steps.compile_translations.outputs.changes_detected == 'true'
|
||||
run: git push origin HEAD:i18n-website
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.compile_translations.outputs.changes_detected == 'true'
|
||||
run: |
|
||||
if git diff --name-only origin/main..HEAD | grep -q .; then
|
||||
gh pr create -B main -H i18n-website --title 'i18n - website translations' --body 'Created by Github action' || true
|
||||
else
|
||||
echo "No file differences between branches, skipping PR creation"
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger i18n automerge
|
||||
if: steps.compile_translations.outputs.changes_detected == 'true'
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
|
||||
repository: twentyhq/twenty-infra
|
||||
event-type: i18n-pr-ready
|
||||
111
.github/workflows/website-i18n-push.yaml
vendored
Normal file
111
.github/workflows/website-i18n-push.yaml
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
name: 'Push website translations to Crowdin'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'packages/twenty-website-new/**'
|
||||
- '.github/crowdin-website.yml'
|
||||
- '.github/workflows/website-i18n-push.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
extract_website_translations:
|
||||
name: Extract and upload website translations
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
ref: main
|
||||
|
||||
- name: Setup website i18n branch
|
||||
run: |
|
||||
git fetch origin i18n-website || true
|
||||
git checkout -B i18n-website origin/i18n-website || git checkout -b i18n-website
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build dependencies
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Extract website translations
|
||||
run: npx nx run twenty-website-new:lingui:extract
|
||||
|
||||
- name: Check and commit extracted files
|
||||
id: check_extract_changes
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add packages/twenty-website-new/src/locales
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: extract website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changes_detected=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Compile website translations
|
||||
run: npx nx run twenty-website-new:lingui:compile
|
||||
|
||||
- name: Check and commit compiled files
|
||||
id: check_compile_changes
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add packages/twenty-website-new/src/locales/generated
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changes_detected=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Push changes and create remote branch if needed
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
run: git push origin HEAD:i18n-website
|
||||
|
||||
- name: Upload missing website translations
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true'
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: false
|
||||
localization_branch_name: i18n-website
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
config: '.github/crowdin-website.yml'
|
||||
env:
|
||||
# Website translations project
|
||||
CROWDIN_PROJECT_ID: '4'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Create a pull request
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
run: |
|
||||
if git diff --name-only origin/main..HEAD | grep -q .; then
|
||||
gh pr create -B main -H i18n-website --title 'i18n - website translations' --body 'Created by Github action' || true
|
||||
else
|
||||
echo "No file differences between branches, skipping PR creation"
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger i18n automerge
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
|
||||
repository: twentyhq/twenty-infra
|
||||
event-type: i18n-pr-ready
|
||||
@@ -21,6 +21,19 @@ const jestConfig = {
|
||||
{
|
||||
jsc: {
|
||||
parser: { syntax: 'typescript', tsx: true },
|
||||
experimental: {
|
||||
plugins: [
|
||||
[
|
||||
'@lingui/swc-plugin',
|
||||
{
|
||||
runtimeModules: {
|
||||
i18n: ['@lingui/core', 'i18n'],
|
||||
trans: ['@lingui/react', 'Trans'],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
transform: { react: { runtime: 'automatic' } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { defineConfig } from '@lingui/conf';
|
||||
import { formatter } from '@lingui/format-po';
|
||||
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { WEBSITE_LOCALE_LIST } from './src/lib/i18n/website-locale-list';
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: SOURCE_LOCALE,
|
||||
locales: Object.values(APP_LOCALES),
|
||||
pseudoLocale: 'pseudo-en',
|
||||
locales: [...WEBSITE_LOCALE_LIST],
|
||||
fallbackLocales: {
|
||||
'pseudo-en': 'en',
|
||||
default: SOURCE_LOCALE,
|
||||
},
|
||||
catalogs: [
|
||||
|
||||
@@ -35,7 +35,8 @@ const RULES = [
|
||||
id: 'no-raw-animation-frame',
|
||||
description:
|
||||
'`requestAnimationFrame(...)` / `cancelAnimationFrame(...)` may only be used inside shared runtime primitives.',
|
||||
pattern: /\b(?:window\.)?(?:requestAnimationFrame|cancelAnimationFrame)\s*\(/,
|
||||
pattern:
|
||||
/\b(?:window\.)?(?:requestAnimationFrame|cancelAnimationFrame)\s*\(/,
|
||||
appliesTo: (rel) =>
|
||||
rel.startsWith('src/') && /\.(ts|tsx|mjs|js|jsx)$/.test(rel),
|
||||
exempt: (rel) =>
|
||||
|
||||
@@ -1,46 +1,40 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { HelpedDataType } from '@/sections/Helped/types/HelpedData';
|
||||
|
||||
export const HELPED_DATA: HelpedDataType = {
|
||||
eyebrow: {
|
||||
heading: {
|
||||
text: 'In production.',
|
||||
text: msg`In production.`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
},
|
||||
heading: [
|
||||
{
|
||||
text: 'Dev teams power\ncompany-wide\n',
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
{
|
||||
text: 'change with Twenty',
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
icon: 'w3villa',
|
||||
heading: { text: 'Ship a product on Twenty', fontFamily: 'sans' },
|
||||
heading: { text: msg`Ship a product on Twenty`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'W3villa built W3Grads for AI mock interviews at scale, with Twenty as the operational backbone.',
|
||||
text: msg`W3villa built W3Grads for AI mock interviews at scale, with Twenty as the operational backbone.`,
|
||||
},
|
||||
illustration: 'target',
|
||||
href: '/customers/w3villa',
|
||||
},
|
||||
{
|
||||
icon: 'act-education',
|
||||
heading: { text: 'Own your CRM end to end', fontFamily: 'sans' },
|
||||
heading: { text: msg`Own your CRM end to end`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'AC&T replaced a shuttered vendor CRM with self-hosted Twenty and cut CRM costs by more than 90%.',
|
||||
text: msg`AC&T replaced a shuttered vendor CRM with self-hosted Twenty and cut CRM costs by more than 90%.`,
|
||||
},
|
||||
illustration: 'spaceship',
|
||||
href: '/customers/act-education',
|
||||
},
|
||||
{
|
||||
icon: 'netzero',
|
||||
heading: { text: 'Grow with a flexible foundation', fontFamily: 'sans' },
|
||||
heading: {
|
||||
text: msg`Grow with a flexible foundation`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
body: {
|
||||
text: 'NetZero runs a modular Twenty setup across carbon credits, ag products, and industrial systems.',
|
||||
text: msg`NetZero runs a modular Twenty setup across carbon credits, ag products, and industrial systems.`,
|
||||
},
|
||||
illustration: 'money',
|
||||
href: '/customers/netzero',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import type {
|
||||
HeroDashboardDataType,
|
||||
HeroDashboardPageDefinition,
|
||||
HeroHomeDataType,
|
||||
HeroKanbanPageDefinition,
|
||||
HeroTablePageDefinition,
|
||||
HeroVisualType,
|
||||
} from '@/sections/Hero/types';
|
||||
import { SHARED_PEOPLE_AVATAR_URLS } from '@/content/site/asset-paths';
|
||||
|
||||
@@ -323,15 +325,11 @@ function createTablePage({
|
||||
};
|
||||
}
|
||||
|
||||
export const HERO_DATA: HeroHomeDataType = {
|
||||
heading: [
|
||||
{ text: 'Build', fontFamily: 'serif' },
|
||||
{ text: ' your Enterprise CRM ', fontFamily: 'serif' },
|
||||
{ text: 'at\u00A0AI\u00A0Speed', fontFamily: 'sans' },
|
||||
],
|
||||
body: {
|
||||
text: 'Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves.',
|
||||
},
|
||||
export const HERO_COPY = {
|
||||
body: msg`Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves.`,
|
||||
};
|
||||
|
||||
export const HERO_DATA: { visual: HeroVisualType } = {
|
||||
visual: {
|
||||
workspace: { icon: 'apple', name: 'Apple' },
|
||||
tableWidth: 1700,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { HomeStepperDataType } from '@/sections/HomeStepper/types';
|
||||
|
||||
export const HOME_STEPPER_DATA: HomeStepperDataType = {
|
||||
@@ -5,46 +6,46 @@ export const HOME_STEPPER_DATA: HomeStepperDataType = {
|
||||
{
|
||||
heading: [
|
||||
{
|
||||
text: 'Begin with production-grade',
|
||||
text: msg`Begin with production-grade`,
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
{
|
||||
text: ' building blocks',
|
||||
text: msg`building blocks`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
],
|
||||
body: {
|
||||
text: 'Compose your CRM and internal apps with a single extensibility toolkit. Data model, layout, and automation.',
|
||||
text: msg`Compose your CRM and internal apps with a single extensibility toolkit. Data model, layout, and automation.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: [
|
||||
{
|
||||
text: 'Continue iteration',
|
||||
text: msg`Continue iteration`,
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
{
|
||||
text: ' without friction',
|
||||
text: msg`without friction`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
],
|
||||
body: {
|
||||
text: 'Enjoy unlimited customization using the AI coding tools you already love. Adapt your CRM to fit the way your business grows and wins.',
|
||||
text: msg`Enjoy unlimited customization using the AI coding tools you already love. Adapt your CRM to fit the way your business grows and wins.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: [
|
||||
{
|
||||
text: 'Stay in control with our',
|
||||
text: msg`Stay in control with our`,
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
{
|
||||
text: ' open-source software',
|
||||
text: msg`open-source software`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
],
|
||||
body: {
|
||||
text: "Don't get locked into someone else's ecosystem. Twenty's developer experience looks like normal software, with local setup, real data, live testing, and no proprietary tooling.",
|
||||
text: msg`Don't get locked into someone else's ecosystem. Twenty's developer experience looks like normal software, with local setup, real data, live testing, and no proprietary tooling.`,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { HELPED_DATA } from '@/app/[locale]/(home)/helped.data';
|
||||
import { HERO_DATA } from '@/app/[locale]/(home)/hero.data';
|
||||
import { HERO_COPY, 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';
|
||||
@@ -9,9 +10,20 @@ 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 '@/lib/pages';
|
||||
import {
|
||||
Body,
|
||||
Eyebrow,
|
||||
Heading,
|
||||
HeadingPart,
|
||||
LinkButton,
|
||||
} from '@/design-system/components';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { Faq } from '@/sections/Faq/components';
|
||||
import { Helped } from '@/sections/Helped/components';
|
||||
@@ -85,8 +97,16 @@ const threeCardsIllustrationBodyClassName = css`
|
||||
}
|
||||
`;
|
||||
|
||||
export default async function HomePage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type HomePageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function HomePage({ params }: HomePageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
|
||||
return (
|
||||
@@ -116,20 +136,31 @@ export default async function HomePage() {
|
||||
<Hero.Root backgroundColor={HOME_TOP_BACKGROUND_COLOR} showHomeBackground>
|
||||
<HeroIntroGroup data-halftone-exclude>
|
||||
<HeroHeadingGroup>
|
||||
<Hero.Heading page={Pages.Home} segments={HERO_DATA.heading} />
|
||||
<Hero.Body page={Pages.Home} body={HERO_DATA.body} size="sm" />
|
||||
<Hero.Heading page={Pages.Home}>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Build your Enterprise CRM`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`at\u00A0AI\u00A0Speed`)}
|
||||
</HeadingPart>
|
||||
</Hero.Heading>
|
||||
<Hero.Body
|
||||
page={Pages.Home}
|
||||
body={{ text: HERO_COPY.body }}
|
||||
renderText={renderText}
|
||||
size="sm"
|
||||
/>
|
||||
</HeroHeadingGroup>
|
||||
<Hero.Cta>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Get started"
|
||||
type="anchor"
|
||||
label={renderText(msg`Get started`)}
|
||||
variant="contained"
|
||||
/>
|
||||
<TalkToUsButton
|
||||
color="secondary"
|
||||
label="Talk to us"
|
||||
label={msg`Talk to us`}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Hero.Cta>
|
||||
@@ -138,9 +169,15 @@ export default async function HomePage() {
|
||||
</Hero.Root>
|
||||
|
||||
<TrustedBy.Root>
|
||||
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
|
||||
<TrustedBy.Separator
|
||||
renderText={renderText}
|
||||
separator={TRUSTED_BY_DATA.separator}
|
||||
/>
|
||||
<TrustedBy.Logos logos={TRUSTED_BY_DATA.logos} />
|
||||
<TrustedBy.ClientCount label={TRUSTED_BY_DATA.clientCountLabel.text} />
|
||||
<TrustedBy.ClientCount
|
||||
label={TRUSTED_BY_DATA.clientCountLabel.text}
|
||||
renderText={renderText}
|
||||
/>
|
||||
</TrustedBy.Root>
|
||||
|
||||
<Problem.Root>
|
||||
@@ -149,9 +186,26 @@ export default async function HomePage() {
|
||||
<Eyebrow
|
||||
colorScheme="primary"
|
||||
heading={PROBLEM_DATA.eyebrow.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Problem.Heading>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`A custom CRM gives your org an edge,`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`but building one`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`comes with`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`tradeoffs`)}
|
||||
</HeadingPart>
|
||||
</Problem.Heading>
|
||||
<Problem.Points
|
||||
points={PROBLEM_DATA.points}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Problem.Heading segments={PROBLEM_DATA.heading} />
|
||||
<Problem.Points points={PROBLEM_DATA.points} />
|
||||
</Problem.Content>
|
||||
</Problem.Root>
|
||||
|
||||
@@ -162,19 +216,29 @@ export default async function HomePage() {
|
||||
<Eyebrow
|
||||
colorScheme="primary"
|
||||
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Heading
|
||||
className={threeCardsIllustrationHeadingClassName}
|
||||
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
|
||||
size="lg"
|
||||
weight="light"
|
||||
/>
|
||||
>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Assemble, iterate and adapt a robust CRM,`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`that's quick to flex`)}
|
||||
</HeadingPart>
|
||||
</Heading>
|
||||
</ThreeCardsIllustrationIntroHeader>
|
||||
<Body
|
||||
body={THREE_CARDS_ILLUSTRATION_DATA.body}
|
||||
className={threeCardsIllustrationBodyClassName}
|
||||
size="sm"
|
||||
/>
|
||||
{THREE_CARDS_ILLUSTRATION_DATA.body && (
|
||||
<Body
|
||||
body={THREE_CARDS_ILLUSTRATION_DATA.body}
|
||||
className={threeCardsIllustrationBodyClassName}
|
||||
renderText={renderText}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</ThreeCardsIllustrationIntroContent>
|
||||
</ThreeCards.Intro>
|
||||
<ThreeCards.IllustrationCards
|
||||
@@ -189,12 +253,20 @@ export default async function HomePage() {
|
||||
<Eyebrow
|
||||
colorScheme="primary"
|
||||
heading={THREE_CARDS_FEATURE_DATA.eyebrow.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Heading
|
||||
segments={THREE_CARDS_FEATURE_DATA.heading}
|
||||
size="lg"
|
||||
weight="light"
|
||||
/>
|
||||
<Heading size="lg" weight="light">
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Make your GTM team happy`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`with`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`a CRM they'll love`)}
|
||||
</HeadingPart>
|
||||
</Heading>
|
||||
</ThreeCards.Intro>
|
||||
<ThreeCards.FeatureCards
|
||||
featureCards={THREE_CARDS_FEATURE_DATA.featureCards}
|
||||
@@ -219,19 +291,30 @@ export default async function HomePage() {
|
||||
|
||||
<Faq.Root>
|
||||
<Faq.Intro>
|
||||
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
|
||||
<Faq.Heading segments={FAQ_DATA.heading} />
|
||||
<Eyebrow
|
||||
colorScheme="secondary"
|
||||
heading={FAQ_DATA.eyebrow.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Faq.Heading>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Stop fighting custom.`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`Start building, with Twenty`)}
|
||||
</HeadingPart>
|
||||
</Faq.Heading>
|
||||
<Faq.Cta>
|
||||
<LinkButton
|
||||
color="primary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Get started"
|
||||
type="anchor"
|
||||
label={renderText(msg`Get started`)}
|
||||
variant="contained"
|
||||
/>
|
||||
<TalkToUsButton
|
||||
color="primary"
|
||||
label="Talk to us"
|
||||
label={msg`Talk to us`}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Faq.Cta>
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { ProblemDataType } from '@/sections/Problem/types';
|
||||
|
||||
export const PROBLEM_DATA: ProblemDataType = {
|
||||
eyebrow: { heading: { text: 'The Problem.', fontFamily: 'sans' } },
|
||||
heading: [
|
||||
{ text: 'A custom CRM gives your org an edge, ', fontFamily: 'serif' },
|
||||
{ text: 'but building one ', fontFamily: 'sans' },
|
||||
{ text: 'comes with ', fontFamily: 'serif' },
|
||||
{ text: 'tradeoffs', fontFamily: 'sans' },
|
||||
],
|
||||
eyebrow: { heading: { text: msg`The Problem.`, fontFamily: 'sans' } },
|
||||
points: [
|
||||
{
|
||||
heading: { text: 'The Giant Monolith', fontFamily: 'sans' },
|
||||
heading: { text: msg`The Giant Monolith`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'Proprietary languages, slow deployment cycles, and "black box" logic.',
|
||||
text: msg`Proprietary languages, slow deployment cycles, and "black box" logic.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { text: 'The In-house Burden', fontFamily: 'sans' },
|
||||
heading: { text: msg`The In-house Burden`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: "It's fragile. V1 ships quickly, but maintaining and making changes is a long term burden.",
|
||||
text: msg`It's fragile. V1 ships quickly, but maintaining and making changes is a long term burden.`,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { TestimonialsDataType } from '@/sections/Testimonials/types';
|
||||
|
||||
export const TESTIMONIALS_DATA: TestimonialsDataType = {
|
||||
eyebrow: {
|
||||
heading: { text: 'They are the real sales', fontFamily: 'sans' },
|
||||
heading: { text: msg`They are the real sales`, fontFamily: 'sans' },
|
||||
},
|
||||
testimonials: [
|
||||
{
|
||||
heading: {
|
||||
text: 'The flexibility is really what made the difference. Our needs evolve very fast. I discover a new need and in two clicks I can address it. That is a real advantage when you are moving quickly.',
|
||||
text: msg`The flexibility is really what made the difference. Our needs evolve very fast. I discover a new need and in two clicks I can address it. That is a real advantage when you are moving quickly.`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
author: {
|
||||
name: { text: 'Olivier Reinaud' },
|
||||
designation: { text: 'Co-founder at NetZero' },
|
||||
name: { text: msg`Olivier Reinaud` },
|
||||
designation: { text: msg`Co-founder at NetZero` },
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: {
|
||||
text: "We didn't want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind.",
|
||||
text: msg`We didn't want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind.`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
author: {
|
||||
name: { text: 'Amrendra Pratap Singh' },
|
||||
designation: { text: 'VP of Engineering at W3villa Technologies' },
|
||||
name: { text: msg`Amrendra Pratap Singh` },
|
||||
designation: { text: msg`VP of Engineering at W3villa Technologies` },
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: {
|
||||
text: 'It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other. Twenty made that possible in a way older CRM platforms simply do not.',
|
||||
text: msg`It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other. Twenty made that possible in a way older CRM platforms simply do not.`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
author: {
|
||||
name: { text: 'Justin Beadle' },
|
||||
name: { text: msg`Justin Beadle` },
|
||||
designation: {
|
||||
text: 'Director of Digital and Information, Elevate Consulting',
|
||||
text: msg`Director of Digital and Information, Elevate Consulting`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { ThreeCardsFeatureCardsDataType } from '@/sections/ThreeCards/types';
|
||||
|
||||
export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
|
||||
eyebrow: {
|
||||
heading: {
|
||||
text: 'Skip the clunky UX that always comes with custom.',
|
||||
text: msg`Skip the clunky UX that always comes with custom.`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
},
|
||||
heading: [
|
||||
{ text: 'Make your GTM team happy', fontFamily: 'serif' },
|
||||
{ text: 'with ', fontFamily: 'serif', lineBreakBefore: true },
|
||||
{ text: "a CRM they'll love", fontFamily: 'sans' },
|
||||
],
|
||||
featureCards: [
|
||||
{
|
||||
heading: { text: 'Familiar, modern interface', fontFamily: 'sans' },
|
||||
heading: { text: msg`Familiar, modern interface`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: "Twenty makes it simple. It's clean, intuitive, and built to feel like Notion.",
|
||||
text: msg`Twenty makes it simple. It's clean, intuitive, and built to feel like Notion.`,
|
||||
},
|
||||
backgroundImageSrc:
|
||||
'/images/home/three-cards-feature/familiar-interface-gradient.webp',
|
||||
@@ -24,9 +20,9 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
|
||||
illustration: 'familiar-interface',
|
||||
},
|
||||
{
|
||||
heading: { text: 'Live data and AI built', fontFamily: 'sans' },
|
||||
heading: { text: msg`Live data and AI built`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'Everything updates in real time, with AI chat always ready to help you move faster.',
|
||||
text: msg`Everything updates in real time, with AI chat always ready to help you move faster.`,
|
||||
},
|
||||
backgroundImageSrc:
|
||||
'/images/home/three-cards-feature/live-data-gradient.webp',
|
||||
@@ -34,9 +30,9 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
|
||||
illustration: 'live-data',
|
||||
},
|
||||
{
|
||||
heading: { text: 'Fast path to action', fontFamily: 'sans' },
|
||||
heading: { text: msg`Fast path to action`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.',
|
||||
text: msg`Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.`,
|
||||
},
|
||||
backgroundImageSrc:
|
||||
'/images/home/three-cards-feature/fast-path-gradient.webp',
|
||||
|
||||
@@ -1,52 +1,49 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
|
||||
|
||||
export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
|
||||
eyebrow: {
|
||||
heading: { text: 'Stop settling for trade-offs.', fontFamily: 'sans' },
|
||||
heading: { text: msg`Stop settling for trade-offs.`, fontFamily: 'sans' },
|
||||
},
|
||||
heading: [
|
||||
{ text: 'Assemble, iterate and adapt a robust CRM,', fontFamily: 'serif' },
|
||||
{ text: " that's quick to flex", fontFamily: 'sans' },
|
||||
],
|
||||
body: {
|
||||
text: 'Compose your CRM and internal apps with a single extensibility toolkit.',
|
||||
text: msg`Compose your CRM and internal apps with a single extensibility toolkit.`,
|
||||
},
|
||||
illustrationCards: [
|
||||
{
|
||||
heading: { text: 'Production grade quality', fontFamily: 'sans' },
|
||||
heading: { text: msg`Production grade quality`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'W3villa used Twenty as a production-grade framework for the data model, permissions, authentication, and workflow engine they would otherwise have rebuilt themselves.',
|
||||
text: msg`W3villa used Twenty as a production-grade framework for the data model, permissions, authentication, and workflow engine they would otherwise have rebuilt themselves.`,
|
||||
},
|
||||
benefits: undefined,
|
||||
attribution: {
|
||||
role: { text: 'VP of Engineering' },
|
||||
company: { text: 'W3villa Technologies' },
|
||||
role: { text: msg`VP of Engineering` },
|
||||
company: { text: msg`W3villa Technologies` },
|
||||
},
|
||||
illustration: 'diamond',
|
||||
caseStudySlug: 'w3villa',
|
||||
},
|
||||
{
|
||||
heading: { text: 'AI for rapid iterations', fontFamily: 'sans' },
|
||||
heading: { text: msg`AI for rapid iterations`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'Alternative Partners used agentic AI to compress what would typically be weeks of Salesforce migration work into something a single person could oversee.',
|
||||
text: msg`Alternative Partners used agentic AI to compress what would typically be weeks of Salesforce migration work into something a single person could oversee.`,
|
||||
},
|
||||
benefits: undefined,
|
||||
attribution: {
|
||||
role: { text: 'Principal and Founder' },
|
||||
company: { text: 'Alternative Partners' },
|
||||
role: { text: msg`Principal and Founder` },
|
||||
company: { text: msg`Alternative Partners` },
|
||||
},
|
||||
illustration: 'flash',
|
||||
caseStudySlug: 'alternative-partners',
|
||||
},
|
||||
{
|
||||
heading: { text: 'Control without drag', fontFamily: 'sans' },
|
||||
heading: { text: msg`Control without drag`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'AC&T moved to a self-hosted Twenty instance with no vendor risk, no forced migration, and CRM costs reduced by more than 90%.',
|
||||
text: msg`AC&T moved to a self-hosted Twenty instance with no vendor risk, no forced migration, and CRM costs reduced by more than 90%.`,
|
||||
},
|
||||
benefits: undefined,
|
||||
attribution: {
|
||||
role: { text: 'CRM Engineer' },
|
||||
company: { text: 'AC&T Education Migration' },
|
||||
role: { text: msg`CRM Engineer` },
|
||||
company: { text: msg`AC&T Education Migration` },
|
||||
},
|
||||
illustration: 'lock',
|
||||
caseStudySlug: 'act-education',
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
|
||||
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { CaseStudy } from '@/sections/CaseStudy/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
@@ -13,88 +19,94 @@ const PLACEHOLDER_HERO =
|
||||
|
||||
const CASE_STUDY: CaseStudyData = {
|
||||
meta: {
|
||||
title:
|
||||
'Homeseller, WhatsApp, and a CRM built around the business | Nine Dots & Twenty',
|
||||
description:
|
||||
'How Nine Dots Ventures rebuilt a Singapore real estate agency on Twenty with APIs, n8n, Grafana, and AI on top of 2,000+ WhatsApp messages a day.',
|
||||
title: msg`Homeseller, WhatsApp, and a CRM built around the business | Nine Dots & Twenty`,
|
||||
description: msg`How Nine Dots Ventures rebuilt a Singapore real estate agency on Twenty with APIs, n8n, Grafana, and AI on top of 2,000+ WhatsApp messages a day.`,
|
||||
},
|
||||
hero: {
|
||||
readingTime: '9 min',
|
||||
title: [
|
||||
{ text: 'A real estate agency on WhatsApp ', fontFamily: 'serif' },
|
||||
{ text: 'built a ', fontFamily: 'serif' },
|
||||
{ text: 'CRM', fontFamily: 'sans', newLine: true },
|
||||
{ text: ' around it', fontFamily: 'serif' },
|
||||
{
|
||||
text: msg`A real estate agency on WhatsApp`,
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
{ text: msg`built a`, fontFamily: 'serif' },
|
||||
{ text: msg`CRM`, fontFamily: 'sans', newLine: true },
|
||||
{ text: msg`around it`, fontFamily: 'serif' },
|
||||
],
|
||||
author: 'Mike Babiy & Azmat Parveen',
|
||||
authorAvatarSrc: '/images/partner/testimonials/mike-babiy.png',
|
||||
authorRole: 'Founder, Nine Dots Ventures',
|
||||
authorRole: msg`Founder, Nine Dots Ventures`,
|
||||
clientIcon: 'nine-dots',
|
||||
heroImageSrc: PLACEHOLDER_HERO,
|
||||
industry: 'Real Estate',
|
||||
industry: msg`Real Estate`,
|
||||
kpis: [
|
||||
{ value: '150 hrs', label: 'Saved / month' },
|
||||
{ value: '2,000+', label: 'Daily messages' },
|
||||
{ value: 'Q1 2026', label: 'Record quarter' },
|
||||
{ value: msg`150 hrs`, label: msg`Saved / month` },
|
||||
{ value: msg`2,000+`, label: msg`Daily messages` },
|
||||
{ value: msg`Q1 2026`, label: msg`Record quarter` },
|
||||
],
|
||||
quote: {
|
||||
text: 'Twenty lets us build a CRM around the business and not the business around the CRM.',
|
||||
text: msg`Twenty lets us build a CRM around the business and not the business around the CRM.`,
|
||||
author: 'Mike Babiy',
|
||||
role: 'Founder, Nine Dots Ventures',
|
||||
role: msg`Founder, Nine Dots Ventures`,
|
||||
},
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Homeseller',
|
||||
eyebrow: msg`Homeseller`,
|
||||
heading: [
|
||||
{ text: 'When the channel is ', fontFamily: 'serif' },
|
||||
{ text: 'the business', fontFamily: 'sans' },
|
||||
{
|
||||
text: msg`When the channel is`,
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
{ text: msg`the business`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
"Homeseller is a high-volume real estate agency in Singapore, founded by one of the country's top-performing property agents. The whole operation runs on WhatsApp: no email, no calendars, just group chats, thousands of them, with clients, agents, and leads together.",
|
||||
'That works until you need to understand the business underneath. Which deals are stuck? Where are leads coming from? What is the close rate? With spreadsheets and a legacy custom CRM that could not keep up, those questions were nearly impossible to answer.',
|
||||
'Mike and Azmat from Nine Dots stepped in to fix that, not by changing how Homeseller works, but by building a system that finally fit around it.',
|
||||
msg`Homeseller is a high-volume real estate agency in Singapore, founded by one of the country's top-performing property agents. The whole operation runs on WhatsApp: no email, no calendars, just group chats, thousands of them, with clients, agents, and leads together.`,
|
||||
msg`That works until you need to understand the business underneath. Which deals are stuck? Where are leads coming from? What is the close rate? With spreadsheets and a legacy custom CRM that could not keep up, those questions were nearly impossible to answer.`,
|
||||
msg`Mike and Azmat from Nine Dots stepped in to fix that, not by changing how Homeseller works, but by building a system that finally fit around it.`,
|
||||
],
|
||||
callout:
|
||||
'"Twenty lets us build a CRM around the business and not the business around the CRM." - Mike Babiy, Founder, Nine Dots Ventures',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Architecture',
|
||||
eyebrow: msg`Architecture`,
|
||||
heading: [
|
||||
{ text: 'The CRM as a ', fontFamily: 'serif' },
|
||||
{ text: 'control hub', fontFamily: 'sans' },
|
||||
{ text: msg`The CRM as a`, fontFamily: 'serif' },
|
||||
{ text: msg`control hub`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
"Nine Dots rebuilt Homeseller's operations on Twenty, with a custom data model shaped around their sales flow. Because Twenty is open and everything is accessible via API, they connected it to what the business actually needed: n8n for automated workflows (in-app workflows were not available at that time), Grafana for live dashboards fed from Twenty, and a custom AI layer to parse and extract structured insights from more than 2,000 WhatsApp messages a day.",
|
||||
'Homeseller kept their habits. WhatsApp stayed WhatsApp. What changed is that everything flowing through those conversations now lands in a structured system, tracked, classified, and visible in real time.',
|
||||
msg`Nine Dots rebuilt Homeseller's operations on Twenty, with a custom data model shaped around their sales flow. Because Twenty is open and everything is accessible via API, they connected it to what the business actually needed: n8n for automated workflows (in-app workflows were not available at that time), Grafana for live dashboards fed from Twenty, and a custom AI layer to parse and extract structured insights from more than 2,000 WhatsApp messages a day.`,
|
||||
msg`Homeseller kept their habits. WhatsApp stayed WhatsApp. What changed is that everything flowing through those conversations now lands in a structured system, tracked, classified, and visible in real time.`,
|
||||
],
|
||||
callout:
|
||||
'"Twenty is the heart of the system. Everything branches from it." - Azmat Parveen, Nine Dots Ventures',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'The result',
|
||||
eyebrow: msg`The result`,
|
||||
heading: [
|
||||
{ text: '150 hours', fontFamily: 'sans' },
|
||||
{ text: ' saved every month', fontFamily: 'serif' },
|
||||
{ text: msg`150 hours`, fontFamily: 'sans' },
|
||||
{
|
||||
text: msg`saved every month`,
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
],
|
||||
paragraphs: [
|
||||
'About 150 hours per month saved in manual operations. Real-time metrics for the business owner. Growth readiness without adding operational headcount. A team that can answer questions that used to take days to piece together.',
|
||||
'The full rollout landed in July 2025. Since then, Nine Dots built a Smart Assistant on top of the system, nudging agents with tasks, reminders, and on-demand market analysis. Some agents never open Twenty directly, yet they are powered by it, outperforming peers on manual processes alone. By Q1 2026, Homeseller had recorded its best sales quarter ever.',
|
||||
msg`About 150 hours per month saved in manual operations. Real-time metrics for the business owner. Growth readiness without adding operational headcount. A team that can answer questions that used to take days to piece together.`,
|
||||
msg`The full rollout landed in July 2025. Since then, Nine Dots built a Smart Assistant on top of the system, nudging agents with tasks, reminders, and on-demand market analysis. Some agents never open Twenty directly, yet they are powered by it, outperforming peers on manual processes alone. By Q1 2026, Homeseller had recorded its best sales quarter ever.`,
|
||||
],
|
||||
},
|
||||
],
|
||||
tableOfContents: [
|
||||
'When the channel is the business',
|
||||
'The CRM as a control hub',
|
||||
'The result',
|
||||
msg`When the channel is the business`,
|
||||
msg`The CRM as a control hub`,
|
||||
msg`The result`,
|
||||
],
|
||||
catalogCard: {
|
||||
summary:
|
||||
"Nine Dots put Twenty at the center of Homeseller's stack with APIs, automation, and AI on top of WhatsApp-heavy operations.",
|
||||
date: 'Jul 2025',
|
||||
summary: msg`Nine Dots put Twenty at the center of Homeseller's stack with APIs, automation, and AI on top of WhatsApp-heavy operations.`,
|
||||
date: msg`Jul 2025`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -104,8 +116,18 @@ export const generateMetadata = buildLocalizedMetadata({
|
||||
description: CASE_STUDY.meta.description,
|
||||
});
|
||||
|
||||
export default async function NineDotsCaseStudyPage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type CaseStudyPageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function NineDotsCaseStudyPage({
|
||||
params,
|
||||
}: CaseStudyPageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
const palette = getCaseStudyPalette('/customers/9dots');
|
||||
|
||||
@@ -119,6 +141,7 @@ export default async function NineDotsCaseStudyPage() {
|
||||
key={index}
|
||||
block={block}
|
||||
isLast={index === CASE_STUDY.sections.length - 1}
|
||||
renderText={renderText}
|
||||
sectionId={sectionId}
|
||||
/>
|
||||
);
|
||||
@@ -150,17 +173,19 @@ export default async function NineDotsCaseStudyPage() {
|
||||
dashColor={palette.dashColor}
|
||||
hero={CASE_STUDY.hero}
|
||||
hoverDashColor={palette.hoverDashColor}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<CaseStudy.Highlights
|
||||
industry={CASE_STUDY.hero.industry}
|
||||
kpis={CASE_STUDY.hero.kpis}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<CaseStudy.Body>{sectionBlocks}</CaseStudy.Body>
|
||||
|
||||
<CaseStudy.SectionNav items={CASE_STUDY.tableOfContents} />
|
||||
<CustomersCaseStudySignoff />
|
||||
<CustomersCaseStudySignoff renderText={renderText} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,48 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { TalkToUsButton } from '@/lib/contact-cal';
|
||||
import { LinkButton } from '@/design-system/components';
|
||||
import { HeadingPart, LinkButton } from '@/design-system/components';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { Signoff } from '@/sections/Signoff/components';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
const SIGNOFF_HEADING = [
|
||||
{ text: 'Ready to grow\nwith ', fontFamily: 'serif' as const },
|
||||
{ text: 'Twenty?', fontFamily: 'sans' as const },
|
||||
];
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
|
||||
const SIGNOFF_BODY = {
|
||||
text: 'Join the teams that chose to own their CRM.\nStart building with Twenty today.',
|
||||
text: msg`Join the teams that chose to own their CRM.\nStart building with Twenty today.`,
|
||||
};
|
||||
|
||||
export function CustomersCaseStudySignoff() {
|
||||
type CustomersCaseStudySignoffProps = {
|
||||
renderText: (descriptor: MessageDescriptor) => string;
|
||||
};
|
||||
|
||||
export function CustomersCaseStudySignoff({
|
||||
renderText,
|
||||
}: CustomersCaseStudySignoffProps) {
|
||||
return (
|
||||
<Signoff.Root
|
||||
backgroundColor={theme.colors.secondary.background[5]}
|
||||
color={theme.colors.primary.text[100]}
|
||||
page={Pages.Partners}
|
||||
>
|
||||
<Signoff.Heading page={Pages.Partners} segments={SIGNOFF_HEADING} />
|
||||
<Signoff.Body body={SIGNOFF_BODY} page={Pages.Partners} />
|
||||
<Signoff.Heading page={Pages.Partners}>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Ready to grow\nwith Twenty?`)}
|
||||
</HeadingPart>
|
||||
</Signoff.Heading>
|
||||
<Signoff.Body
|
||||
body={SIGNOFF_BODY}
|
||||
page={Pages.Partners}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Signoff.Cta>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Get started"
|
||||
type="anchor"
|
||||
label={renderText(msg`Get started`)}
|
||||
variant="contained"
|
||||
/>
|
||||
<TalkToUsButton
|
||||
color="secondary"
|
||||
label="Talk to us"
|
||||
label={msg`Talk to us`}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Signoff.Cta>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
|
||||
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { CaseStudy } from '@/sections/CaseStudy/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
@@ -13,88 +19,91 @@ const PLACEHOLDER_HERO =
|
||||
|
||||
const CASE_STUDY: CaseStudyData = {
|
||||
meta: {
|
||||
title:
|
||||
'Burned by vendor lock-in, AC&T built a CRM they actually own | Twenty',
|
||||
description:
|
||||
'How AC&T Education Migration and Flycoder replaced a shuttered vendor CRM with self-hosted Twenty, with 90%+ lower cost and full ownership.',
|
||||
title: msg`Burned by vendor lock-in, AC&T built a CRM they actually own | Twenty`,
|
||||
description: msg`How AC&T Education Migration and Flycoder replaced a shuttered vendor CRM with self-hosted Twenty, with 90%+ lower cost and full ownership.`,
|
||||
},
|
||||
hero: {
|
||||
readingTime: '7 min',
|
||||
title: [
|
||||
{ text: 'A CRM they ', fontFamily: 'serif' },
|
||||
{ text: 'actually own', fontFamily: 'sans', newLine: true },
|
||||
{ text: msg`A CRM they`, fontFamily: 'serif' },
|
||||
{ text: msg`actually own`, fontFamily: 'sans', newLine: true },
|
||||
],
|
||||
author: 'Joseph Chiang',
|
||||
authorAvatarSrc: '/images/partner/testimonials/joseph-chiang.jpg',
|
||||
authorRole: 'CRM Engineer, AC&T Education Migration',
|
||||
authorRole: msg`CRM Engineer, AC&T Education Migration`,
|
||||
clientIcon: 'act-education',
|
||||
heroImageSrc: PLACEHOLDER_HERO,
|
||||
industry: 'Education',
|
||||
kpis: [{ value: '90%+', label: 'Lower CRM cost' }],
|
||||
industry: msg`Education`,
|
||||
kpis: [{ value: msg`90%+`, label: msg`Lower CRM cost` }],
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'AC&T Education Migration',
|
||||
eyebrow: msg`AC&T Education Migration`,
|
||||
heading: [
|
||||
{ text: 'When the vendor ', fontFamily: 'serif' },
|
||||
{ text: 'pulled the plug', fontFamily: 'sans' },
|
||||
{ text: msg`When the vendor`, fontFamily: 'serif' },
|
||||
{ text: msg`pulled the plug`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'AC&T Education Migration (actimmi.com) is an education agency in Australia. They help international students with applications to education providers and visas. They had been on a previous CRM until the vendor shut the system down, leaving nothing but a CSV export.',
|
||||
'Whatever came next had to be something they could own.',
|
||||
msg`AC&T Education Migration (actimmi.com) is an education agency in Australia. They help international students with applications to education providers and visas. They had been on a previous CRM until the vendor shut the system down, leaving nothing but a CSV export.`,
|
||||
msg`Whatever came next had to be something they could own.`,
|
||||
],
|
||||
callout:
|
||||
'"They did not want to learn someone else\'s system. They wanted to keep working the way they already did and make it smoother." - Joseph Chiang, CRM Engineer, AC&T Education Migration',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Implementation',
|
||||
eyebrow: msg`Implementation`,
|
||||
heading: [
|
||||
{ text: "No more renting someone else's ", fontFamily: 'serif' },
|
||||
{ text: 'structure', fontFamily: 'sans' },
|
||||
{
|
||||
text: msg`No more renting someone else's`,
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
{ text: msg`structure`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'They evaluated Salesforce, Zoho, Pipedrive, and SuiteCRM. Each came with the same tradeoffs: too expensive, too rigid, or too generic, and none fixed the underlying problem. They were still renting a structure they did not control.',
|
||||
'Flycoder, a full-stack development partner, helped them set up Twenty as a self-hosted instance shaped around how AC&T actually operates. The data model centers on students, not a generic contact-and-deal pipeline. Statuses update automatically: a workflow runs nightly to keep enrollment records current. Automated email reminders cover important dates. Adding a new record takes under a minute.',
|
||||
'The result is a system that fits how AC&T already worked, instead of the other way around.',
|
||||
msg`They evaluated Salesforce, Zoho, Pipedrive, and SuiteCRM. Each came with the same tradeoffs: too expensive, too rigid, or too generic, and none fixed the underlying problem. They were still renting a structure they did not control.`,
|
||||
msg`Flycoder, a full-stack development partner, helped them set up Twenty as a self-hosted instance shaped around how AC&T actually operates. The data model centers on students, not a generic contact-and-deal pipeline. Statuses update automatically: a workflow runs nightly to keep enrollment records current. Automated email reminders cover important dates. Adding a new record takes under a minute.`,
|
||||
msg`The result is a system that fits how AC&T already worked, instead of the other way around.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Control',
|
||||
eyebrow: msg`Control`,
|
||||
heading: [
|
||||
{ text: 'Control without ', fontFamily: 'serif' },
|
||||
{ text: 'the overhead', fontFamily: 'sans' },
|
||||
{ text: msg`Control without`, fontFamily: 'serif' },
|
||||
{ text: msg`the overhead`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'Self-hosted means AC&T carries no vendor risk: no pricing model that can change, no platform that can disappear, no forced migration. The system is theirs.',
|
||||
"Because everything is built on Twenty's open foundation, Flycoder could wire the exact logic AC&T needed without fighting the platform.",
|
||||
msg`Self-hosted means AC&T carries no vendor risk: no pricing model that can change, no platform that can disappear, no forced migration. The system is theirs.`,
|
||||
msg`Because everything is built on Twenty's open foundation, Flycoder could wire the exact logic AC&T needed without fighting the platform.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'The result',
|
||||
eyebrow: msg`The result`,
|
||||
heading: [
|
||||
{ text: 'Costs down more than ', fontFamily: 'serif' },
|
||||
{ text: '90%', fontFamily: 'sans' },
|
||||
{
|
||||
text: msg`Costs down more than`,
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
{ text: msg`90%`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'CRM costs dropped by more than 90%. Manual overhead tied to the old system is gone. For the first time, AC&T has a CRM they will not lose again.',
|
||||
'They did not just replace a tool. They took back ownership of how their business runs.',
|
||||
msg`CRM costs dropped by more than 90%. Manual overhead tied to the old system is gone. For the first time, AC&T has a CRM they will not lose again.`,
|
||||
msg`They did not just replace a tool. They took back ownership of how their business runs.`,
|
||||
],
|
||||
},
|
||||
],
|
||||
tableOfContents: [
|
||||
'When the vendor pulled the plug',
|
||||
"No more renting someone else's structure",
|
||||
'Control without the overhead',
|
||||
'The result',
|
||||
msg`When the vendor pulled the plug`,
|
||||
msg`No more renting someone else's structure`,
|
||||
msg`Control without the overhead`,
|
||||
msg`The result`,
|
||||
],
|
||||
catalogCard: {
|
||||
summary:
|
||||
'AC&T and Flycoder moved from a dead vendor export to self-hosted Twenty, with over 90% lower CRM cost and full control.',
|
||||
date: '2025',
|
||||
summary: msg`AC&T and Flycoder moved from a dead vendor export to self-hosted Twenty, with over 90% lower CRM cost and full control.`,
|
||||
date: msg`2025`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -104,8 +113,18 @@ export const generateMetadata = buildLocalizedMetadata({
|
||||
description: CASE_STUDY.meta.description,
|
||||
});
|
||||
|
||||
export default async function ActEducationCaseStudyPage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type CaseStudyPageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function ActEducationCaseStudyPage({
|
||||
params,
|
||||
}: CaseStudyPageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
const palette = getCaseStudyPalette('/customers/act-education');
|
||||
|
||||
@@ -119,6 +138,7 @@ export default async function ActEducationCaseStudyPage() {
|
||||
key={index}
|
||||
block={block}
|
||||
isLast={index === CASE_STUDY.sections.length - 1}
|
||||
renderText={renderText}
|
||||
sectionId={sectionId}
|
||||
/>
|
||||
);
|
||||
@@ -150,17 +170,19 @@ export default async function ActEducationCaseStudyPage() {
|
||||
dashColor={palette.dashColor}
|
||||
hero={CASE_STUDY.hero}
|
||||
hoverDashColor={palette.hoverDashColor}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<CaseStudy.Highlights
|
||||
industry={CASE_STUDY.hero.industry}
|
||||
kpis={CASE_STUDY.hero.kpis}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<CaseStudy.Body>{sectionBlocks}</CaseStudy.Body>
|
||||
|
||||
<CaseStudy.SectionNav items={CASE_STUDY.tableOfContents} />
|
||||
<CustomersCaseStudySignoff />
|
||||
<CustomersCaseStudySignoff renderText={renderText} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
|
||||
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { CaseStudy } from '@/sections/CaseStudy/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
@@ -13,62 +19,59 @@ const PLACEHOLDER_HERO =
|
||||
|
||||
const CASE_STUDY: CaseStudyData = {
|
||||
meta: {
|
||||
title:
|
||||
'From Salesforce to self-hosted Twenty, powered by AI | Alternative Partners',
|
||||
description:
|
||||
'How Alternative Partners migrated from Salesforce to self-hosted Twenty using agentic AI in the implementation loop: fast migration, durable ownership.',
|
||||
title: msg`From Salesforce to self-hosted Twenty, powered by AI | Alternative Partners`,
|
||||
description: msg`How Alternative Partners migrated from Salesforce to self-hosted Twenty using agentic AI in the implementation loop: fast migration, durable ownership.`,
|
||||
},
|
||||
hero: {
|
||||
readingTime: '7 min',
|
||||
title: [
|
||||
{ text: 'From Salesforce to ', fontFamily: 'serif' },
|
||||
{ text: 'self-hosted Twenty', fontFamily: 'sans', newLine: true },
|
||||
{ text: msg`From Salesforce to`, fontFamily: 'serif' },
|
||||
{ text: msg`self-hosted Twenty`, fontFamily: 'sans', newLine: true },
|
||||
],
|
||||
author: 'Benjamin Reynolds',
|
||||
authorAvatarSrc: '/images/partner/testimonials/benjamin-reynolds.webp',
|
||||
authorRole: 'Principal and Founder, Alternative Partners',
|
||||
authorRole: msg`Principal and Founder, Alternative Partners`,
|
||||
clientIcon: 'alternative-partners',
|
||||
heroImageSrc: PLACEHOLDER_HERO,
|
||||
industry: 'Consulting',
|
||||
industry: msg`Consulting`,
|
||||
kpis: [
|
||||
{ value: 'AI-assisted', label: 'Salesforce migration' },
|
||||
{ value: 'Self-hosted', label: 'Full ownership' },
|
||||
{ value: msg`AI-assisted`, label: msg`Salesforce migration` },
|
||||
{ value: msg`Self-hosted`, label: msg`Full ownership` },
|
||||
],
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Alternative Partners',
|
||||
eyebrow: msg`Alternative Partners`,
|
||||
heading: [
|
||||
{ text: 'AI in the ', fontFamily: 'serif' },
|
||||
{ text: 'migration workflow', fontFamily: 'sans' },
|
||||
{ text: msg`AI in the`, fontFamily: 'serif' },
|
||||
{ text: msg`migration workflow`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
"Alternative Partners is a consulting firm that moved from Salesforce to a self-hosted Twenty instance. Benjamin Reynolds led the migration. He had already become a Twenty expert implementing Twenty for one of Twenty's first cloud customers.",
|
||||
'His approach was unconventional. Instead of mapping fields manually, scripting transforms, and validating data step by step, he handed the job to agentic AI tools with a brief: where the data lives, the GitHub repo for the target platform, and the Railway deployment. Start, and only return if something breaks beyond a 70% confidence fix.',
|
||||
'It worked. This is AI-assisted iteration in practice: not AI as a product feature, but as part of implementation work, compressing what would typically be weeks into something one person can oversee without being the bottleneck.',
|
||||
msg`Alternative Partners is a consulting firm that moved from Salesforce to a self-hosted Twenty instance. Benjamin Reynolds led the migration. He had already become a Twenty expert implementing Twenty for one of Twenty's first cloud customers.`,
|
||||
msg`His approach was unconventional. Instead of mapping fields manually, scripting transforms, and validating data step by step, he handed the job to agentic AI tools with a brief: where the data lives, the GitHub repo for the target platform, and the Railway deployment. Start, and only return if something breaks beyond a 70% confidence fix.`,
|
||||
msg`It worked. This is AI-assisted iteration in practice: not AI as a product feature, but as part of implementation work, compressing what would typically be weeks into something one person can oversee without being the bottleneck.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Ownership',
|
||||
eyebrow: msg`Ownership`,
|
||||
heading: [
|
||||
{ text: 'Self-hosted ', fontFamily: 'serif' },
|
||||
{ text: 'means control', fontFamily: 'sans' },
|
||||
{ text: msg`Self-hosted`, fontFamily: 'serif' },
|
||||
{ text: msg`means control`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'The self-hosted setup means Alternative Partners owns the full stack: no vendor access to their data, no dependency on a SaaS pricing model, full control over how the system evolves. The migration was fast because of AI; the result is durable because the stack is open source.',
|
||||
msg`The self-hosted setup means Alternative Partners owns the full stack: no vendor access to their data, no dependency on a SaaS pricing model, full control over how the system evolves. The migration was fast because of AI; the result is durable because the stack is open source.`,
|
||||
],
|
||||
},
|
||||
],
|
||||
tableOfContents: [
|
||||
'AI in the migration workflow',
|
||||
'Self-hosted means control',
|
||||
msg`AI in the migration workflow`,
|
||||
msg`Self-hosted means control`,
|
||||
],
|
||||
catalogCard: {
|
||||
summary:
|
||||
'Alternative Partners replaced Salesforce with self-hosted Twenty, using agentic AI to compress migration work.',
|
||||
date: '2025',
|
||||
summary: msg`Alternative Partners replaced Salesforce with self-hosted Twenty, using agentic AI to compress migration work.`,
|
||||
date: msg`2025`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -78,8 +81,18 @@ export const generateMetadata = buildLocalizedMetadata({
|
||||
description: CASE_STUDY.meta.description,
|
||||
});
|
||||
|
||||
export default async function AlternativePartnersCaseStudyPage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type CaseStudyPageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function AlternativePartnersCaseStudyPage({
|
||||
params,
|
||||
}: CaseStudyPageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
const palette = getCaseStudyPalette('/customers/alternative-partners');
|
||||
|
||||
@@ -93,6 +106,7 @@ export default async function AlternativePartnersCaseStudyPage() {
|
||||
key={index}
|
||||
block={block}
|
||||
isLast={index === CASE_STUDY.sections.length - 1}
|
||||
renderText={renderText}
|
||||
sectionId={sectionId}
|
||||
/>
|
||||
);
|
||||
@@ -124,17 +138,19 @@ export default async function AlternativePartnersCaseStudyPage() {
|
||||
dashColor={palette.dashColor}
|
||||
hero={CASE_STUDY.hero}
|
||||
hoverDashColor={palette.hoverDashColor}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<CaseStudy.Highlights
|
||||
industry={CASE_STUDY.hero.industry}
|
||||
kpis={CASE_STUDY.hero.kpis}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<CaseStudy.Body>{sectionBlocks}</CaseStudy.Body>
|
||||
|
||||
<CaseStudy.SectionNav items={CASE_STUDY.tableOfContents} />
|
||||
<CustomersCaseStudySignoff />
|
||||
<CustomersCaseStudySignoff renderText={renderText} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
|
||||
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { CaseStudy } from '@/sections/CaseStudy/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
@@ -13,102 +19,102 @@ const PLACEHOLDER_HERO =
|
||||
|
||||
const CASE_STUDY: CaseStudyData = {
|
||||
meta: {
|
||||
title:
|
||||
'Twenty as the API backbone of a go-to-market stack | Elevate Consulting',
|
||||
description:
|
||||
'How Elevate Consulting moved off documents and spreadsheets to Twenty as the API-connected CRM at the center of their stack.',
|
||||
title: msg`Twenty as the API backbone of a go-to-market stack | Elevate Consulting`,
|
||||
description: msg`How Elevate Consulting moved off documents and spreadsheets to Twenty as the API-connected CRM at the center of their stack.`,
|
||||
},
|
||||
hero: {
|
||||
readingTime: '8 min',
|
||||
title: [
|
||||
{ text: 'Twenty as the ', fontFamily: 'serif' },
|
||||
{ text: 'API backbone', fontFamily: 'sans', newLine: true },
|
||||
{ text: ' of a go-to-market stack', fontFamily: 'serif' },
|
||||
{ text: msg`Twenty as the`, fontFamily: 'serif' },
|
||||
{ text: msg`API backbone`, fontFamily: 'sans', newLine: true },
|
||||
{
|
||||
text: msg`of a go-to-market stack`,
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
],
|
||||
author: 'Justin Beadle',
|
||||
authorRole: 'Director of Digital and Information, Elevate Consulting',
|
||||
authorRole: msg`Director of Digital and Information, Elevate Consulting`,
|
||||
clientIcon: 'elevate-consulting',
|
||||
heroImageSrc: PLACEHOLDER_HERO,
|
||||
industry: 'Management Consulting',
|
||||
industry: msg`Management Consulting`,
|
||||
kpis: [
|
||||
{ value: '1 click', label: 'Proposal automation' },
|
||||
{ value: '4 tools', label: 'Connected via API' },
|
||||
{ value: 'API-first', label: 'Tool integration' },
|
||||
{ value: msg`1 click`, label: msg`Proposal automation` },
|
||||
{ value: msg`4 tools`, label: msg`Connected via API` },
|
||||
{ value: msg`API-first`, label: msg`Tool integration` },
|
||||
],
|
||||
quote: {
|
||||
text: 'It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other.',
|
||||
text: msg`It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other.`,
|
||||
author: 'Justin Beadle',
|
||||
role: 'Director of Digital and Information, Elevate Consulting',
|
||||
role: msg`Director of Digital and Information, Elevate Consulting`,
|
||||
},
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'The situation',
|
||||
eyebrow: msg`The situation`,
|
||||
heading: [
|
||||
{ text: 'From documents to ', fontFamily: 'serif' },
|
||||
{ text: 'open APIs', fontFamily: 'sans' },
|
||||
{ text: msg`From documents to`, fontFamily: 'serif' },
|
||||
{ text: msg`open APIs`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'Elevate Consulting is a management consultancy based in Canada. When Justin Beadle, Director of Digital and Information, joined, the company ran entirely on Word documents, Excel spreadsheets, sticky notes, emails, and reliance on people. There was no CRM, no API-accessible tools, only a patchwork trying to stand in for a single source of truth.',
|
||||
'The CEO had resisted bringing in a CRM for years. The business development team had no experience using one, and the licensing costs of well-known CRMs like Salesforce or HubSpot were hard to justify without any guarantee of adoption: CRMs are only as good as the maintenance of the data inside them.',
|
||||
'In June 2025, Justin learned Twenty v1 had shipped. Within two or three days, the CEO asked him to look into setting up a CRM. The shift came from the potential of what could be built on top of fully open APIs. The timing was perfect.',
|
||||
msg`Elevate Consulting is a management consultancy based in Canada. When Justin Beadle, Director of Digital and Information, joined, the company ran entirely on Word documents, Excel spreadsheets, sticky notes, emails, and reliance on people. There was no CRM, no API-accessible tools, only a patchwork trying to stand in for a single source of truth.`,
|
||||
msg`The CEO had resisted bringing in a CRM for years. The business development team had no experience using one, and the licensing costs of well-known CRMs like Salesforce or HubSpot were hard to justify without any guarantee of adoption: CRMs are only as good as the maintenance of the data inside them.`,
|
||||
msg`In June 2025, Justin learned Twenty v1 had shipped. Within two or three days, the CEO asked him to look into setting up a CRM. The shift came from the potential of what could be built on top of fully open APIs. The timing was perfect.`,
|
||||
],
|
||||
callout:
|
||||
'"It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other. Twenty made that possible in a way older CRM platforms simply do not." - Justin Beadle, Director of Digital and Information, Elevate Consulting',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Integration',
|
||||
eyebrow: msg`Integration`,
|
||||
heading: [
|
||||
{ text: 'One ', fontFamily: 'serif' },
|
||||
{ text: 'API', fontFamily: 'sans' },
|
||||
{ text: ' to rule them all', fontFamily: 'serif' },
|
||||
{ text: msg`One`, fontFamily: 'serif' },
|
||||
{ text: msg`API`, fontFamily: 'sans' },
|
||||
{ text: msg`to rule them all`, fontFamily: 'serif' },
|
||||
],
|
||||
paragraphs: [
|
||||
"Justin's broader mission at Elevate has been to move the company off static documents and onto tools with API access. By the end of 2025, that was in place: time billing, resource planning, Microsoft Teams, and project management were all accessible via API, with Twenty at the center holding client and opportunity data. Team members could use that information strategically instead of re-keying it.",
|
||||
'That opened the door to something more powerful. Justin built a custom front end that pulls live data from those systems into a single view, tailored to each role. When a proposal is won, what used to require four separate people manually setting up instances across four different tools now happens in a single click, drawing on data collected in Twenty across the full opportunity lifecycle. It is another shift toward higher-value work for clients.',
|
||||
'Twenty is not only where CRM data lives. It is the API backbone that makes the rest of the stack possible.',
|
||||
msg`Justin's broader mission at Elevate has been to move the company off static documents and onto tools with API access. By the end of 2025, that was in place: time billing, resource planning, Microsoft Teams, and project management were all accessible via API, with Twenty at the center holding client and opportunity data. Team members could use that information strategically instead of re-keying it.`,
|
||||
msg`That opened the door to something more powerful. Justin built a custom front end that pulls live data from those systems into a single view, tailored to each role. When a proposal is won, what used to require four separate people manually setting up instances across four different tools now happens in a single click, drawing on data collected in Twenty across the full opportunity lifecycle. It is another shift toward higher-value work for clients.`,
|
||||
msg`Twenty is not only where CRM data lives. It is the API backbone that makes the rest of the stack possible.`,
|
||||
],
|
||||
callout:
|
||||
'"Because Twenty\'s API is fully open, I could connect it to every other tool in our stack. When a proposal is won, what used to take four people manually setting things up across four different tools now happens in a single click. That is the kind of time saving that only becomes possible when everything is connected." - Justin Beadle, Director of Digital and Information, Elevate Consulting',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Adoption',
|
||||
eyebrow: msg`Adoption`,
|
||||
heading: [
|
||||
{ text: 'Workflows that ', fontFamily: 'serif' },
|
||||
{ text: 'actually get used', fontFamily: 'sans' },
|
||||
{ text: msg`Workflows that`, fontFamily: 'serif' },
|
||||
{ text: msg`actually get used`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'The business development team finally had the CRM they had been asking for. Adoption came naturally: their data was already there when they logged in.',
|
||||
'Justin built workflows for notifications across the team, alerting the right people in Teams when a prospect becomes a lead or when project milestones are reached. Forms in Twenty let the business development team log activity without leaving the tool. The impact is real for the organization. The tool has been adaptable from opportunity-level work at a client to executive-level decisions.',
|
||||
'The flexibility to wire this together, without outside help and without fighting the platform, is what made it possible for a single person to stand up and maintain a connected stack across an entire consultancy.',
|
||||
msg`The business development team finally had the CRM they had been asking for. Adoption came naturally: their data was already there when they logged in.`,
|
||||
msg`Justin built workflows for notifications across the team, alerting the right people in Teams when a prospect becomes a lead or when project milestones are reached. Forms in Twenty let the business development team log activity without leaving the tool. The impact is real for the organization. The tool has been adaptable from opportunity-level work at a client to executive-level decisions.`,
|
||||
msg`The flexibility to wire this together, without outside help and without fighting the platform, is what made it possible for a single person to stand up and maintain a connected stack across an entire consultancy.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'What is next',
|
||||
eyebrow: msg`What is next`,
|
||||
heading: [
|
||||
{ text: 'Beyond ', fontFamily: 'serif' },
|
||||
{ text: 'internal rollout', fontFamily: 'sans' },
|
||||
{ text: msg`Beyond`, fontFamily: 'serif' },
|
||||
{ text: msg`internal rollout`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
"Elevate's CEO was so impressed with Twenty he started recommending it to clients before the internal setup was even complete. The team is exploring bringing Twenty to client projects as part of their consulting practice, including as the backend for custom-built products tailored to specific operational needs.",
|
||||
'For a firm that once ran on sticky notes, this is more than an upgrade. It is a complete transformation.',
|
||||
msg`Elevate's CEO was so impressed with Twenty he started recommending it to clients before the internal setup was even complete. The team is exploring bringing Twenty to client projects as part of their consulting practice, including as the backend for custom-built products tailored to specific operational needs.`,
|
||||
msg`For a firm that once ran on sticky notes, this is more than an upgrade. It is a complete transformation.`,
|
||||
],
|
||||
},
|
||||
],
|
||||
tableOfContents: [
|
||||
'From documents to open APIs',
|
||||
'One API to rule them all',
|
||||
'Workflows that actually get used',
|
||||
'What is next',
|
||||
msg`From documents to open APIs`,
|
||||
msg`One API to rule them all`,
|
||||
msg`Workflows that actually get used`,
|
||||
msg`What is next`,
|
||||
],
|
||||
catalogCard: {
|
||||
summary:
|
||||
'Elevate Consulting uses Twenty as the API backbone connecting billing, Teams, resourcing, and a custom front end around client and opportunity data.',
|
||||
date: 'Jun 2025',
|
||||
summary: msg`Elevate Consulting uses Twenty as the API backbone connecting billing, Teams, resourcing, and a custom front end around client and opportunity data.`,
|
||||
date: msg`Jun 2025`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -118,8 +124,18 @@ export const generateMetadata = buildLocalizedMetadata({
|
||||
description: CASE_STUDY.meta.description,
|
||||
});
|
||||
|
||||
export default async function ElevateConsultingCaseStudyPage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type CaseStudyPageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function ElevateConsultingCaseStudyPage({
|
||||
params,
|
||||
}: CaseStudyPageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
const palette = getCaseStudyPalette('/customers/elevate-consulting');
|
||||
|
||||
@@ -133,6 +149,7 @@ export default async function ElevateConsultingCaseStudyPage() {
|
||||
key={index}
|
||||
block={block}
|
||||
isLast={index === CASE_STUDY.sections.length - 1}
|
||||
renderText={renderText}
|
||||
sectionId={sectionId}
|
||||
/>
|
||||
);
|
||||
@@ -164,17 +181,19 @@ export default async function ElevateConsultingCaseStudyPage() {
|
||||
dashColor={palette.dashColor}
|
||||
hero={CASE_STUDY.hero}
|
||||
hoverDashColor={palette.hoverDashColor}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<CaseStudy.Highlights
|
||||
industry={CASE_STUDY.hero.industry}
|
||||
kpis={CASE_STUDY.hero.kpis}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<CaseStudy.Body>{sectionBlocks}</CaseStudy.Body>
|
||||
|
||||
<CaseStudy.SectionNav items={CASE_STUDY.tableOfContents} />
|
||||
<CustomersCaseStudySignoff />
|
||||
<CustomersCaseStudySignoff renderText={renderText} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
|
||||
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { CaseStudy } from '@/sections/CaseStudy/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
@@ -13,92 +19,93 @@ const PLACEHOLDER_HERO =
|
||||
|
||||
const CASE_STUDY: CaseStudyData = {
|
||||
meta: {
|
||||
title: 'A CRM that grows with you | NetZero & Twenty',
|
||||
description:
|
||||
'How NetZero uses Twenty across carbon credits, agricultural products, and franchised industrial systems with a modular CRM and a roadmap toward AI-assisted workflows.',
|
||||
title: msg`A CRM that grows with you | NetZero & Twenty`,
|
||||
description: msg`How NetZero uses Twenty across carbon credits, agricultural products, and franchised industrial systems with a modular CRM and a roadmap toward AI-assisted workflows.`,
|
||||
},
|
||||
hero: {
|
||||
readingTime: '8 min',
|
||||
title: [
|
||||
{ text: 'A CRM that ', fontFamily: 'serif' },
|
||||
{ text: 'grows', fontFamily: 'sans', newLine: true },
|
||||
{ text: ' with you', fontFamily: 'serif' },
|
||||
{ text: msg`A CRM that`, fontFamily: 'serif' },
|
||||
{ text: msg`grows`, fontFamily: 'sans', newLine: true },
|
||||
{ text: msg`with you`, fontFamily: 'serif' },
|
||||
],
|
||||
author: 'Olivier Reinaud',
|
||||
authorAvatarSrc: '/images/partner/testimonials/olivier-reinaud.jpg',
|
||||
authorRole: 'Co-founder, NetZero',
|
||||
authorRole: msg`Co-founder, NetZero`,
|
||||
clientIcon: 'netzero',
|
||||
heroImageSrc: PLACEHOLDER_HERO,
|
||||
industry: 'Agribusiness',
|
||||
industry: msg`Agribusiness`,
|
||||
kpis: [
|
||||
{ value: '3 product lines', label: 'On a single CRM' },
|
||||
{ value: 'No-code', label: 'Customizations' },
|
||||
{ value: msg`3 product lines`, label: msg`On a single CRM` },
|
||||
{ value: msg`No-code`, label: msg`Customizations` },
|
||||
],
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'NetZero',
|
||||
eyebrow: msg`NetZero`,
|
||||
heading: [
|
||||
{ text: 'The right ', fontFamily: 'serif' },
|
||||
{ text: 'foundation', fontFamily: 'sans' },
|
||||
{ text: msg`The right`, fontFamily: 'serif' },
|
||||
{ text: msg`foundation`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'NetZero works with the agro-industry, serving clients from multinationals to smallholder farmers. They sell carbon credits, agricultural products, and franchised industrial systems across three different product lines, multiple countries, and multiple company sizes. When Olivier Reinaud, co-founder of NetZero, started looking at CRMs in late 2024, he was not chasing the most feature-rich platform. He wanted the right foundation.',
|
||||
msg`NetZero works with the agro-industry, serving clients from multinationals to smallholder farmers. They sell carbon credits, agricultural products, and franchised industrial systems across three different product lines, multiple countries, and multiple company sizes. When Olivier Reinaud, co-founder of NetZero, started looking at CRMs in late 2024, he was not chasing the most feature-rich platform. He wanted the right foundation.`,
|
||||
],
|
||||
callout:
|
||||
'"Twenty delivers on what CRMs should have always been: fairly priced software with a fully modular and customizable model, a clean and modern UI, granular permissions, automations, enterprise features. A compelling solution with high potential to rightfully disrupt the CRM market." - Olivier Reinaud, co-founder of NetZero',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Flexibility',
|
||||
eyebrow: msg`Flexibility`,
|
||||
heading: [
|
||||
{ text: 'A business that does not fit a ', fontFamily: 'serif' },
|
||||
{ text: 'template', fontFamily: 'sans' },
|
||||
{
|
||||
text: msg`A business that does not fit a`,
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
{ text: msg`template`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'What convinced Olivier was the flexibility of the platform and where it was headed. Even when initial needs were basic record-keeping, he still needed a custom data model with granular permissions to manage the wide range of NetZero activities. He also needed a system that could adapt quickly to a fast-iteration company.',
|
||||
'With Twenty, when a new need appears, he can address it himself: no developer required, no support ticket.',
|
||||
msg`What convinced Olivier was the flexibility of the platform and where it was headed. Even when initial needs were basic record-keeping, he still needed a custom data model with granular permissions to manage the wide range of NetZero activities. He also needed a system that could adapt quickly to a fast-iteration company.`,
|
||||
msg`With Twenty, when a new need appears, he can address it himself: no developer required, no support ticket.`,
|
||||
],
|
||||
callout:
|
||||
'"The flexibility is really what made the difference. Our needs evolve very fast. I discover a new need and in two clicks I can address it. That is a real advantage when you are moving quickly." - Olivier Reinaud, co-founder of NetZero',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Roadmap',
|
||||
eyebrow: msg`Roadmap`,
|
||||
heading: [
|
||||
{ text: 'From simple to ', fontFamily: 'serif' },
|
||||
{ text: 'advanced', fontFamily: 'sans' },
|
||||
{ text: msg`From simple to`, fontFamily: 'serif' },
|
||||
{ text: msg`advanced`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
"Olivier recognizes that NetZero's current use of Twenty is still relatively simple: workflows and integrations are not yet as deep as he eventually wants, because he prioritized getting foundations right first.",
|
||||
'What is planned is significant. NetZero has a data lake, online forms, and multiple internal systems that he wants to connect to Twenty. The pipes are there; the next step is automations that tie them together.',
|
||||
'What is coming in April 2026 is what he has been waiting for: AI-assisted workflow creation, describing what he needs and iterating from there instead of building complex logic from scratch. For a founder who runs the CRM himself, that changes what is realistically possible.',
|
||||
msg`Olivier recognizes that NetZero's current use of Twenty is still relatively simple: workflows and integrations are not yet as deep as he eventually wants, because he prioritized getting foundations right first.`,
|
||||
msg`What is planned is significant. NetZero has a data lake, online forms, and multiple internal systems that he wants to connect to Twenty. The pipes are there; the next step is automations that tie them together.`,
|
||||
msg`What is coming in April 2026 is what he has been waiting for: AI-assisted workflow creation, describing what he needs and iterating from there instead of building complex logic from scratch. For a founder who runs the CRM himself, that changes what is realistically possible.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Results',
|
||||
eyebrow: msg`Results`,
|
||||
heading: [
|
||||
{ text: 'The bet is ', fontFamily: 'serif' },
|
||||
{ text: 'paying off', fontFamily: 'sans' },
|
||||
{ text: msg`The bet is`, fontFamily: 'serif' },
|
||||
{ text: msg`paying off`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'While NetZero still runs a second CRM in parallel for WhatsApp-heavy operations with farmers in Brazil, they expect to migrate all of it to Twenty as features and the ecosystem grow. Already, their structured, multinational pipeline is powered by Twenty.',
|
||||
'The early bet on the architecture is holding, and upcoming AI features are expected to make it even more relevant.',
|
||||
msg`While NetZero still runs a second CRM in parallel for WhatsApp-heavy operations with farmers in Brazil, they expect to migrate all of it to Twenty as features and the ecosystem grow. Already, their structured, multinational pipeline is powered by Twenty.`,
|
||||
msg`The early bet on the architecture is holding, and upcoming AI features are expected to make it even more relevant.`,
|
||||
],
|
||||
},
|
||||
],
|
||||
tableOfContents: [
|
||||
'The right foundation',
|
||||
'A business that does not fit a template',
|
||||
'From simple to advanced',
|
||||
'The bet is paying off',
|
||||
msg`The right foundation`,
|
||||
msg`A business that does not fit a template`,
|
||||
msg`From simple to advanced`,
|
||||
msg`The bet is paying off`,
|
||||
],
|
||||
catalogCard: {
|
||||
summary:
|
||||
'NetZero uses Twenty as a modular CRM across product lines and countries, with a roadmap into AI-assisted workflows.',
|
||||
date: '2025',
|
||||
summary: msg`NetZero uses Twenty as a modular CRM across product lines and countries, with a roadmap into AI-assisted workflows.`,
|
||||
date: msg`2025`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -108,8 +115,18 @@ export const generateMetadata = buildLocalizedMetadata({
|
||||
description: CASE_STUDY.meta.description,
|
||||
});
|
||||
|
||||
export default async function NetZeroCaseStudyPage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type CaseStudyPageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function NetZeroCaseStudyPage({
|
||||
params,
|
||||
}: CaseStudyPageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
const palette = getCaseStudyPalette('/customers/netzero');
|
||||
|
||||
@@ -123,6 +140,7 @@ export default async function NetZeroCaseStudyPage() {
|
||||
key={index}
|
||||
block={block}
|
||||
isLast={index === CASE_STUDY.sections.length - 1}
|
||||
renderText={renderText}
|
||||
sectionId={sectionId}
|
||||
/>
|
||||
);
|
||||
@@ -154,17 +172,19 @@ export default async function NetZeroCaseStudyPage() {
|
||||
dashColor={palette.dashColor}
|
||||
hero={CASE_STUDY.hero}
|
||||
hoverDashColor={palette.hoverDashColor}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<CaseStudy.Highlights
|
||||
industry={CASE_STUDY.hero.industry}
|
||||
kpis={CASE_STUDY.hero.kpis}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<CaseStudy.Body>{sectionBlocks}</CaseStudy.Body>
|
||||
|
||||
<CaseStudy.SectionNav items={CASE_STUDY.tableOfContents} />
|
||||
<CustomersCaseStudySignoff />
|
||||
<CustomersCaseStudySignoff renderText={renderText} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
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 '@/lib/pages';
|
||||
import { Eyebrow, HeadingPart, LinkButton } from '@/design-system/components';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog/components';
|
||||
import { Faq } from '@/sections/Faq/components';
|
||||
@@ -19,23 +25,12 @@ import { css } from '@linaria/core';
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('customers');
|
||||
|
||||
const HERO_HEADING = [
|
||||
{ text: 'See how teams ', fontFamily: 'serif' as const },
|
||||
{ text: 'build ', fontFamily: 'serif' as const, newLine: true },
|
||||
{ text: 'on Twenty', fontFamily: 'sans' as const },
|
||||
];
|
||||
|
||||
const HERO_BODY = {
|
||||
text: 'Real stories from real teams about how they shaped Twenty to fit their workflow and accelerated their growth.',
|
||||
text: msg`Real stories from real teams about how they shaped Twenty to fit their workflow and accelerated their growth.`,
|
||||
};
|
||||
|
||||
const SIGNOFF_HEADING = [
|
||||
{ text: 'Ready to build\n', fontFamily: 'serif' as const },
|
||||
{ text: 'your own story?', fontFamily: 'sans' as const },
|
||||
];
|
||||
|
||||
const SIGNOFF_BODY = {
|
||||
text: 'Join the teams that chose to own their CRM.\nStart building with Twenty today.',
|
||||
text: msg`Join the teams that chose to own their CRM.\nStart building with Twenty today.`,
|
||||
};
|
||||
|
||||
const CUSTOMERS_TOP_BACKGROUND_COLOR = '#F4F4F4';
|
||||
@@ -66,8 +61,18 @@ const pageRevealClassName = css`
|
||||
}
|
||||
`;
|
||||
|
||||
export default async function CaseStudiesCatalogPage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type CaseStudiesCatalogPageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function CaseStudiesCatalogPage({
|
||||
params,
|
||||
}: CaseStudiesCatalogPageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
|
||||
return (
|
||||
@@ -86,41 +91,75 @@ export default async function CaseStudiesCatalogPage() {
|
||||
|
||||
<div className={pageRevealClassName}>
|
||||
<Hero.Root backgroundColor={CUSTOMERS_TOP_BACKGROUND_COLOR}>
|
||||
<Hero.Heading page={Pages.CaseStudies} segments={HERO_HEADING} />
|
||||
<Hero.Body body={HERO_BODY} page={Pages.CaseStudies} />
|
||||
<Hero.Heading page={Pages.CaseStudies}>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`See how teams`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`build`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`on Twenty`)}
|
||||
</HeadingPart>
|
||||
</Hero.Heading>
|
||||
<Hero.Body
|
||||
body={HERO_BODY}
|
||||
page={Pages.CaseStudies}
|
||||
renderText={renderText}
|
||||
/>
|
||||
</Hero.Root>
|
||||
<TrustedBy.Root
|
||||
cardBackgroundColor={CUSTOMERS_TOP_BACKGROUND_COLOR}
|
||||
compactBottom
|
||||
>
|
||||
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
|
||||
<TrustedBy.Separator
|
||||
renderText={renderText}
|
||||
separator={TRUSTED_BY_DATA.separator}
|
||||
/>
|
||||
<TrustedBy.Logos logos={TRUSTED_BY_DATA.logos} />
|
||||
<TrustedBy.ClientCount
|
||||
label={TRUSTED_BY_DATA.clientCountLabel.text}
|
||||
renderText={renderText}
|
||||
/>
|
||||
</TrustedBy.Root>
|
||||
</div>
|
||||
|
||||
<CaseStudyCatalog.Grid compactTop entries={CASE_STUDY_CATALOG_ENTRIES} />
|
||||
<CaseStudyCatalog.Grid
|
||||
compactTop
|
||||
entries={CASE_STUDY_CATALOG_ENTRIES}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<Signoff.Root
|
||||
backgroundColor={theme.colors.primary.background[100]}
|
||||
color={theme.colors.primary.text[100]}
|
||||
page={Pages.Partners}
|
||||
>
|
||||
<Signoff.Heading page={Pages.Partners} segments={SIGNOFF_HEADING} />
|
||||
<Signoff.Body body={SIGNOFF_BODY} page={Pages.Partners} />
|
||||
<Signoff.Heading page={Pages.Partners}>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Ready to build`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`your own story?`)}
|
||||
</HeadingPart>
|
||||
</Signoff.Heading>
|
||||
<Signoff.Body
|
||||
body={SIGNOFF_BODY}
|
||||
page={Pages.Partners}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Signoff.Cta>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Get started"
|
||||
type="anchor"
|
||||
label={renderText(msg`Get started`)}
|
||||
variant="contained"
|
||||
/>
|
||||
<TalkToUsButton
|
||||
color="secondary"
|
||||
label="Talk to us"
|
||||
label={msg`Talk to us`}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Signoff.Cta>
|
||||
@@ -128,19 +167,30 @@ export default async function CaseStudiesCatalogPage() {
|
||||
|
||||
<Faq.Root>
|
||||
<Faq.Intro>
|
||||
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
|
||||
<Faq.Heading segments={FAQ_DATA.heading} />
|
||||
<Eyebrow
|
||||
colorScheme="secondary"
|
||||
heading={FAQ_DATA.eyebrow.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Faq.Heading>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Stop fighting custom.`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`Start building, with Twenty`)}
|
||||
</HeadingPart>
|
||||
</Faq.Heading>
|
||||
<Faq.Cta>
|
||||
<LinkButton
|
||||
color="primary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Get started"
|
||||
type="anchor"
|
||||
label={renderText(msg`Get started`)}
|
||||
variant="contained"
|
||||
/>
|
||||
<TalkToUsButton
|
||||
color="primary"
|
||||
label="Talk to us"
|
||||
label={msg`Talk to us`}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Faq.Cta>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
|
||||
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { CaseStudy } from '@/sections/CaseStudy/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
@@ -13,89 +19,92 @@ const PLACEHOLDER_HERO =
|
||||
|
||||
const CASE_STUDY: CaseStudyData = {
|
||||
meta: {
|
||||
title:
|
||||
'When your CRM is the product: W3Grads on Twenty | W3villa Technologies',
|
||||
description:
|
||||
'How W3villa Technologies shipped W3Grads, an AI mock interview platform for institutions, on Twenty as the operational backbone.',
|
||||
title: msg`When your CRM is the product: W3Grads on Twenty | W3villa Technologies`,
|
||||
description: msg`How W3villa Technologies shipped W3Grads, an AI mock interview platform for institutions, on Twenty as the operational backbone.`,
|
||||
},
|
||||
hero: {
|
||||
readingTime: '8 min',
|
||||
title: [
|
||||
{ text: 'When your CRM is ', fontFamily: 'serif' },
|
||||
{ text: 'the product', fontFamily: 'sans', newLine: true },
|
||||
{ text: msg`When your CRM is`, fontFamily: 'serif' },
|
||||
{ text: msg`the product`, fontFamily: 'sans', newLine: true },
|
||||
],
|
||||
author: 'Amrendra Pratap Singh',
|
||||
authorAvatarSrc: '/images/partner/testimonials/amrendra-singh.webp',
|
||||
authorRole: 'VP of Engineering, W3villa Technologies',
|
||||
authorRole: msg`VP of Engineering, W3villa Technologies`,
|
||||
clientIcon: 'w3villa',
|
||||
heroImageSrc: PLACEHOLDER_HERO,
|
||||
industry: 'EdTech',
|
||||
kpis: [{ value: 'Zero', label: 'Manual work at core' }],
|
||||
industry: msg`EdTech`,
|
||||
kpis: [{ value: msg`Zero`, label: msg`Manual work at core` }],
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'W3Grads',
|
||||
eyebrow: msg`W3Grads`,
|
||||
heading: [
|
||||
{ text: 'Scale without ', fontFamily: 'serif' },
|
||||
{ text: 'breaking operations', fontFamily: 'sans' },
|
||||
{ text: msg`Scale without`, fontFamily: 'serif' },
|
||||
{ text: msg`breaking operations`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'Running mock interview programs for hundreds of students sounds straightforward. In practice, universities and training institutes hit the same wall: registrations entered by hand, interview links sent one by one, faculty reviewing every session without scoring or classification. At real scale, it breaks.',
|
||||
'W3villa Technologies set out to solve it properly, not with a workaround, but with a product.',
|
||||
msg`Running mock interview programs for hundreds of students sounds straightforward. In practice, universities and training institutes hit the same wall: registrations entered by hand, interview links sent one by one, faculty reviewing every session without scoring or classification. At real scale, it breaks.`,
|
||||
msg`W3villa Technologies set out to solve it properly, not with a workaround, but with a product.`,
|
||||
],
|
||||
callout:
|
||||
'"We did not want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind." - Amrendra Pratap Singh, VP of Engineering, W3villa Technologies',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Architecture',
|
||||
eyebrow: msg`Architecture`,
|
||||
heading: [
|
||||
{ text: 'Focus on the use case, not the ', fontFamily: 'serif' },
|
||||
{ text: 'plumbing', fontFamily: 'sans' },
|
||||
{
|
||||
text: msg`Focus on the use case, not the`,
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
{ text: msg`plumbing`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'W3villa built W3Grads (w3grads.com), an AI-powered mock interview platform for universities and training institutes, using Twenty as its operational backbone.',
|
||||
'The key decision was not to build everything from scratch. Twenty covers the data model, permissions, authentication, and workflow engine, the parts that would have taken months to rebuild, so the team could focus on product-specific logic.',
|
||||
'When a student registers via QR at a campus event, the system assigns a plan, generates an interview session, and sends a link. The AI conducts the interview, scores the candidate, and classifies the result. Faculty see where each student stands without manually reviewing every session. Building and iterating on these workflows was faster with AI in the loop.',
|
||||
msg`W3villa built W3Grads (w3grads.com), an AI-powered mock interview platform for universities and training institutes, using Twenty as its operational backbone.`,
|
||||
msg`The key decision was not to build everything from scratch. Twenty covers the data model, permissions, authentication, and workflow engine, the parts that would have taken months to rebuild, so the team could focus on product-specific logic.`,
|
||||
msg`When a student registers via QR at a campus event, the system assigns a plan, generates an interview session, and sends a link. The AI conducts the interview, scores the candidate, and classifies the result. Faculty see where each student stands without manually reviewing every session. Building and iterating on these workflows was faster with AI in the loop.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'Scale',
|
||||
eyebrow: msg`Scale`,
|
||||
heading: [
|
||||
{ text: 'A platform ready to ', fontFamily: 'serif' },
|
||||
{ text: 'grow', fontFamily: 'sans' },
|
||||
{
|
||||
text: msg`A platform ready to`,
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
{ text: msg`grow`, fontFamily: 'sans' },
|
||||
],
|
||||
paragraphs: [
|
||||
'Because the foundation is solid, W3Grads is architected for what comes next, including a payment layer for future paid interview plans and nationwide scale without structural rewrites.',
|
||||
msg`Because the foundation is solid, W3Grads is architected for what comes next, including a payment layer for future paid interview plans and nationwide scale without structural rewrites.`,
|
||||
],
|
||||
callout:
|
||||
'"Twenty gave us the flexibility to model the entire interview lifecycle as custom objects and workflows. We could build something genuinely complex without fighting the platform to do it." - Piyush Khandelwal, Director, W3villa Technologies, Partner',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
eyebrow: 'The result',
|
||||
eyebrow: msg`The result`,
|
||||
heading: [
|
||||
{ text: 'Zero manual work', fontFamily: 'sans' },
|
||||
{ text: ' at the core', fontFamily: 'serif' },
|
||||
{ text: msg`Zero manual work`, fontFamily: 'sans' },
|
||||
{ text: msg`at the core`, fontFamily: 'serif' },
|
||||
],
|
||||
paragraphs: [
|
||||
'Programs that previously needed heavy manual coordination now run end-to-end with automation. Institutions get a scalable, intelligent system; students get faster preparation for interviews that matter; W3villa shipped a product institutions can build revenue around.',
|
||||
'Zero manual work at the core. Full automation. Built on Twenty.',
|
||||
msg`Programs that previously needed heavy manual coordination now run end-to-end with automation. Institutions get a scalable, intelligent system; students get faster preparation for interviews that matter; W3villa shipped a product institutions can build revenue around.`,
|
||||
msg`Zero manual work at the core. Full automation. Built on Twenty.`,
|
||||
],
|
||||
},
|
||||
],
|
||||
tableOfContents: [
|
||||
'Scale without breaking operations',
|
||||
'Focus on the use case, not the plumbing',
|
||||
'A platform ready to grow',
|
||||
'The result',
|
||||
msg`Scale without breaking operations`,
|
||||
msg`Focus on the use case, not the plumbing`,
|
||||
msg`A platform ready to grow`,
|
||||
msg`The result`,
|
||||
],
|
||||
catalogCard: {
|
||||
summary:
|
||||
'W3villa shipped W3Grads on Twenty for AI interviews, scoring, and institution-scale workflows without rebuilding CRM plumbing.',
|
||||
date: '2025',
|
||||
summary: msg`W3villa shipped W3Grads on Twenty for AI interviews, scoring, and institution-scale workflows without rebuilding CRM plumbing.`,
|
||||
date: msg`2025`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -105,8 +114,18 @@ export const generateMetadata = buildLocalizedMetadata({
|
||||
description: CASE_STUDY.meta.description,
|
||||
});
|
||||
|
||||
export default async function W3villaCaseStudyPage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type CaseStudyPageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function W3villaCaseStudyPage({
|
||||
params,
|
||||
}: CaseStudyPageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
const palette = getCaseStudyPalette('/customers/w3villa');
|
||||
|
||||
@@ -120,6 +139,7 @@ export default async function W3villaCaseStudyPage() {
|
||||
key={index}
|
||||
block={block}
|
||||
isLast={index === CASE_STUDY.sections.length - 1}
|
||||
renderText={renderText}
|
||||
sectionId={sectionId}
|
||||
/>
|
||||
);
|
||||
@@ -151,17 +171,19 @@ export default async function W3villaCaseStudyPage() {
|
||||
dashColor={palette.dashColor}
|
||||
hero={CASE_STUDY.hero}
|
||||
hoverDashColor={palette.hoverDashColor}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<CaseStudy.Highlights
|
||||
industry={CASE_STUDY.hero.industry}
|
||||
kpis={CASE_STUDY.hero.kpis}
|
||||
renderText={renderText}
|
||||
/>
|
||||
|
||||
<CaseStudy.Body>{sectionBlocks}</CaseStudy.Body>
|
||||
|
||||
<CaseStudy.SectionNav items={CASE_STUDY.tableOfContents} />
|
||||
<CustomersCaseStudySignoff />
|
||||
<CustomersCaseStudySignoff renderText={renderText} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import {
|
||||
BaseButton,
|
||||
buttonBaseStyles,
|
||||
} from '@/design-system/components/Button/BaseButton';
|
||||
import { Body, Heading } from '@/design-system/components';
|
||||
import { useRenderMessage } from '@/lib/i18n/use-render-message';
|
||||
import { useTimeoutRegistry } from '@/lib/react';
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
@@ -46,6 +48,12 @@ const LicenseeRow = styled.div`
|
||||
gap: ${theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const LicenseeValue = styled.span`
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(4)};
|
||||
line-height: 1.55;
|
||||
`;
|
||||
|
||||
const KeySection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -132,6 +140,7 @@ const nextStepItemClassName = css`
|
||||
`;
|
||||
|
||||
export function EnterpriseActivateClient() {
|
||||
const renderText = useRenderMessage();
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams.get('session_id');
|
||||
const timeoutRegistry = useTimeoutRegistry();
|
||||
@@ -214,7 +223,8 @@ export function EnterpriseActivateClient() {
|
||||
<ContentStack>
|
||||
{loading && (
|
||||
<Body
|
||||
body={{ text: 'Activating your enterprise license…' }}
|
||||
body={{ text: msg`Activating your enterprise license…` }}
|
||||
renderText={renderText}
|
||||
size="sm"
|
||||
variant="body-paragraph"
|
||||
/>
|
||||
@@ -226,9 +236,10 @@ export function EnterpriseActivateClient() {
|
||||
<>
|
||||
<Body
|
||||
body={{
|
||||
text: 'Your enterprise license has been activated successfully.',
|
||||
text: msg`Your enterprise license has been activated successfully.`,
|
||||
}}
|
||||
className={successLeadClassName}
|
||||
renderText={renderText}
|
||||
size="md"
|
||||
weight="medium"
|
||||
/>
|
||||
@@ -236,24 +247,27 @@ export function EnterpriseActivateClient() {
|
||||
<LicenseeRow>
|
||||
<Body
|
||||
as="span"
|
||||
body={{ text: 'Licensee: ' }}
|
||||
body={{ text: msg`Licensee:` }}
|
||||
renderText={renderText}
|
||||
size="sm"
|
||||
weight="medium"
|
||||
/>
|
||||
<Body as="span" body={{ text: result.licensee }} size="sm" />
|
||||
/>{' '}
|
||||
<LicenseeValue>{result.licensee}</LicenseeValue>
|
||||
</LicenseeRow>
|
||||
|
||||
<KeySection>
|
||||
<Heading
|
||||
as="h2"
|
||||
segments={{ fontFamily: 'sans', text: 'Your enterprise key' }}
|
||||
renderText={renderText}
|
||||
segments={{ fontFamily: 'sans', text: msg`Your enterprise key` }}
|
||||
size="xs"
|
||||
weight="medium"
|
||||
/>
|
||||
<Body
|
||||
body={{
|
||||
text: 'Copy this key and paste it into your Twenty self-hosted instance settings.',
|
||||
text: msg`Copy this key and paste it into your Twenty self-hosted instance settings.`,
|
||||
}}
|
||||
renderText={renderText}
|
||||
size="sm"
|
||||
variant="body-paragraph"
|
||||
/>
|
||||
@@ -270,7 +284,7 @@ export function EnterpriseActivateClient() {
|
||||
>
|
||||
<BaseButton
|
||||
color="primary"
|
||||
label={copied ? 'Copied!' : 'Copy'}
|
||||
label={renderText(copied ? msg`Copied!` : msg`Copy`)}
|
||||
size="small"
|
||||
variant="contained"
|
||||
/>
|
||||
@@ -281,28 +295,32 @@ export function EnterpriseActivateClient() {
|
||||
<NextStepsBox>
|
||||
<Heading
|
||||
as="h3"
|
||||
segments={{ fontFamily: 'sans', text: 'Next steps' }}
|
||||
renderText={renderText}
|
||||
segments={{ fontFamily: 'sans', text: msg`Next steps` }}
|
||||
size="xs"
|
||||
weight="medium"
|
||||
/>
|
||||
<NextStepsList>
|
||||
<li className={nextStepItemClassName}>
|
||||
<Body
|
||||
body={{ text: 'Copy the enterprise key above.' }}
|
||||
body={{ text: msg`Copy the enterprise key above.` }}
|
||||
renderText={renderText}
|
||||
size="sm"
|
||||
/>
|
||||
</li>
|
||||
<li className={nextStepItemClassName}>
|
||||
<Body
|
||||
body={{
|
||||
text: 'Open your Twenty self-hosted instance Settings → Enterprise.',
|
||||
text: msg`Open your Twenty self-hosted instance Settings → Enterprise.`,
|
||||
}}
|
||||
renderText={renderText}
|
||||
size="sm"
|
||||
/>
|
||||
</li>
|
||||
<li className={nextStepItemClassName}>
|
||||
<Body
|
||||
body={{ text: 'Paste the key and click Activate.' }}
|
||||
body={{ text: msg`Paste the key and click Activate.` }}
|
||||
renderText={renderText}
|
||||
size="sm"
|
||||
/>
|
||||
</li>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
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';
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Eyebrow,
|
||||
HeadingPart,
|
||||
} from '@/design-system/components';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { Hero } from '@/sections/Hero/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
@@ -14,13 +24,8 @@ import { styled } from '@linaria/react';
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('enterpriseActivate');
|
||||
|
||||
const ENTERPRISE_ACTIVATE_HEADING: HeadingType[] = [
|
||||
{ text: 'Enterprise ', fontFamily: 'serif' },
|
||||
{ text: 'activation', fontFamily: 'sans' },
|
||||
];
|
||||
|
||||
const ENTERPRISE_ACTIVATE_BODY = {
|
||||
text: 'Your checkout is complete. Follow the steps below to copy your license key into your Twenty instance.',
|
||||
text: msg`Your checkout is complete. Follow the steps below to copy your license key into your Twenty instance.`,
|
||||
};
|
||||
|
||||
const ActivatePageContent = styled.section`
|
||||
@@ -38,18 +43,30 @@ const ActivateContentInner = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
function EnterpriseActivateFallback() {
|
||||
type EnterpriseActivateFallbackProps = {
|
||||
loadingLabel: string;
|
||||
};
|
||||
|
||||
function EnterpriseActivateFallback({
|
||||
loadingLabel,
|
||||
}: EnterpriseActivateFallbackProps) {
|
||||
return (
|
||||
<Body
|
||||
body={{ text: 'Loading activation…' }}
|
||||
size="sm"
|
||||
variant="body-paragraph"
|
||||
/>
|
||||
<Body body={{ text: loadingLabel }} size="sm" variant="body-paragraph" />
|
||||
);
|
||||
}
|
||||
|
||||
export default async function EnterpriseActivatePage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type EnterpriseActivatePageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function EnterpriseActivatePage({
|
||||
params,
|
||||
}: EnterpriseActivatePageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
|
||||
return (
|
||||
@@ -72,19 +89,34 @@ export default async function EnterpriseActivatePage() {
|
||||
>
|
||||
<Eyebrow
|
||||
colorScheme="primary"
|
||||
heading={{ text: 'Self-hosting', fontFamily: 'sans' }}
|
||||
heading={{ text: msg`Self-hosting`, fontFamily: 'sans' }}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Hero.Heading
|
||||
<Hero.Heading page={Pages.Pricing}>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Enterprise`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`activation`)}
|
||||
</HeadingPart>
|
||||
</Hero.Heading>
|
||||
<Hero.Body
|
||||
body={ENTERPRISE_ACTIVATE_BODY}
|
||||
page={Pages.Pricing}
|
||||
segments={ENTERPRISE_ACTIVATE_HEADING}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Hero.Body body={ENTERPRISE_ACTIVATE_BODY} page={Pages.Pricing} />
|
||||
</Hero.Root>
|
||||
|
||||
<ActivatePageContent>
|
||||
<Container>
|
||||
<ActivateContentInner>
|
||||
<Suspense fallback={<EnterpriseActivateFallback />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<EnterpriseActivateFallback
|
||||
loadingLabel={renderText(msg`Loading activation…`)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EnterpriseActivateClient />
|
||||
</Suspense>
|
||||
</ActivateContentInner>
|
||||
|
||||
@@ -11,12 +11,14 @@ import { type ReactNode } from 'react';
|
||||
import { FooterVisibilityGate } from '@/app/_components/FooterVisibilityGate';
|
||||
import { ScrollToTopOnRouteChange } from '@/app/_components/ScrollToTopOnRouteChange';
|
||||
import { ContactCalModalRoot } from '@/lib/contact-cal';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
I18nProvider,
|
||||
PUBLIC_APP_LOCALE_LIST,
|
||||
getLocaleMessages,
|
||||
resolveLocaleParam,
|
||||
} from '@/lib/i18n';
|
||||
import { getLocaleMessages } from '@/lib/i18n/messages-by-locale';
|
||||
import { setServerI18n } from '@/lib/i18n/set-server-i18n';
|
||||
import { PartnerApplicationModalRoot } from '@/lib/partner-application';
|
||||
import { Footer } from '@/sections/Footer/components';
|
||||
import { FOOTER_DATA } from '@/sections/Footer/data';
|
||||
@@ -124,6 +126,8 @@ const LocaleLayout = async ({
|
||||
}) => {
|
||||
const { locale: rawLocale } = await params;
|
||||
const locale = resolveLocaleParam(rawLocale);
|
||||
const i18n = setServerI18n(locale);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const messages = getLocaleMessages(locale);
|
||||
|
||||
return (
|
||||
@@ -147,10 +151,14 @@ const LocaleLayout = async ({
|
||||
<FooterVisibilityGate>
|
||||
<Footer.Root>
|
||||
<Footer.Logo />
|
||||
<Footer.Nav groups={FOOTER_DATA.navGroups} />
|
||||
<Footer.Nav
|
||||
groups={FOOTER_DATA.navGroups}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Footer.Bottom
|
||||
copyright={FOOTER_DATA.bottom.copyright}
|
||||
links={FOOTER_DATA.socialLinks}
|
||||
renderText={renderText}
|
||||
/>
|
||||
</Footer.Root>
|
||||
</FooterVisibilityGate>
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
BaseButton,
|
||||
buttonBaseStyles,
|
||||
} from '@/design-system/components/Button/BaseButton';
|
||||
import { useRenderMessage } from '@/lib/i18n/use-render-message';
|
||||
import { usePartnerApplicationModal } from '@/lib/partner-application';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
const StyledTrigger = styled.button`
|
||||
@@ -13,15 +16,16 @@ const StyledTrigger = styled.button`
|
||||
|
||||
type BecomePartnerButtonProps = {
|
||||
color?: 'primary' | 'secondary';
|
||||
label?: string;
|
||||
label?: MessageDescriptor;
|
||||
variant?: 'contained' | 'outlined';
|
||||
};
|
||||
|
||||
export function BecomePartnerButton({
|
||||
color = 'secondary',
|
||||
label = 'Become a partner',
|
||||
label = msg`Become a partner`,
|
||||
variant = 'contained',
|
||||
}: BecomePartnerButtonProps) {
|
||||
const renderText = useRenderMessage();
|
||||
const { openPartnerApplicationModal } = usePartnerApplicationModal();
|
||||
|
||||
return (
|
||||
@@ -33,7 +37,7 @@ export function BecomePartnerButton({
|
||||
openPartnerApplicationModal();
|
||||
}}
|
||||
>
|
||||
<BaseButton color={color} label={label} variant={variant} />
|
||||
<BaseButton color={color} label={renderText(label)} variant={variant} />
|
||||
</StyledTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { TalkToUsButton } from '@/lib/contact-cal';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { BecomePartnerButton } from './BecomePartnerButton';
|
||||
|
||||
@@ -10,7 +11,7 @@ export function PartnerHeroCtas() {
|
||||
<BecomePartnerButton variant="contained" />
|
||||
<TalkToUsButton
|
||||
color="secondary"
|
||||
label="Find a partner"
|
||||
label={msg`Find a partner`}
|
||||
variant="outlined"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { TalkToUsButton } from '@/lib/contact-cal';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { BecomePartnerButton } from './BecomePartnerButton';
|
||||
|
||||
@@ -10,7 +11,7 @@ export function PartnerSignoffCtas() {
|
||||
<BecomePartnerButton variant="outlined" />
|
||||
<TalkToUsButton
|
||||
color="secondary"
|
||||
label="Talk to us"
|
||||
label={msg`Talk to us`}
|
||||
variant="contained"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { EngagementBandDataType } from '@/sections/EngagementBand/types';
|
||||
|
||||
export const ENGAGEMENT_BAND_DATA: EngagementBandDataType = {
|
||||
heading: {
|
||||
text: 'See what they built',
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
body: {
|
||||
text: 'Discover how our partners implement, customize, and scale Twenty in real-world deployments.',
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { HeroBaseDataType } from '@/sections/Hero/types';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const HERO_DATA = {
|
||||
heading: [
|
||||
{ text: 'Become ', fontFamily: 'serif' },
|
||||
{ text: 'our partner', fontFamily: 'sans', newLine: true },
|
||||
],
|
||||
body: {
|
||||
text: "We're building the #1 open source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.",
|
||||
},
|
||||
} satisfies HeroBaseDataType;
|
||||
export const HERO_COPY = {
|
||||
body: msg`We're building the #1 open source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.`,
|
||||
};
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
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/[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 { HERO_COPY } from '@/app/[locale]/partners/hero.data';
|
||||
import { SIGNOFF_COPY } from '@/app/[locale]/partners/signoff.data';
|
||||
import { TESTIMONIALS_DATA } from '@/app/[locale]/partners/testimonials.data';
|
||||
import {
|
||||
PartnerHeroCtas,
|
||||
PartnerSignoffCtas,
|
||||
} from '@/app/[locale]/partners/components/PartnerApplication';
|
||||
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import {
|
||||
Body,
|
||||
Eyebrow,
|
||||
Heading,
|
||||
HeadingPart,
|
||||
LinkButton,
|
||||
} from '@/design-system/components';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog/components';
|
||||
import { Faq } from '@/sections/Faq/components';
|
||||
@@ -47,8 +59,16 @@ const PromoSpacing = styled.div`
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('partners');
|
||||
|
||||
export default async function PartnerPage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type PartnerPageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function PartnerPage({ params }: PartnerPageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
|
||||
return (
|
||||
@@ -66,8 +86,20 @@ export default async function PartnerPage() {
|
||||
</Menu.Root>
|
||||
|
||||
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
|
||||
<Hero.Heading page={Pages.Partners} segments={HERO_DATA.heading} />
|
||||
<Hero.Body page={Pages.Partners} body={HERO_DATA.body} />
|
||||
<Hero.Heading page={Pages.Partners}>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Become`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`our partner`)}
|
||||
</HeadingPart>
|
||||
</Hero.Heading>
|
||||
<Hero.Body
|
||||
page={Pages.Partners}
|
||||
body={{ text: HERO_COPY.body }}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Hero.Cta>
|
||||
<PartnerHeroCtas />
|
||||
</Hero.Cta>
|
||||
@@ -78,15 +110,22 @@ export default async function PartnerPage() {
|
||||
backgroundColor={theme.colors.primary.background[100]}
|
||||
compactBottom
|
||||
>
|
||||
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
|
||||
<TrustedBy.Separator
|
||||
renderText={renderText}
|
||||
separator={TRUSTED_BY_DATA.separator}
|
||||
/>
|
||||
<TrustedBy.Logos logos={TRUSTED_BY_DATA.logos} />
|
||||
<TrustedBy.ClientCount label={TRUSTED_BY_DATA.clientCountLabel.text} />
|
||||
<TrustedBy.ClientCount
|
||||
label={TRUSTED_BY_DATA.clientCountLabel.text}
|
||||
renderText={renderText}
|
||||
/>
|
||||
</TrustedBy.Root>
|
||||
|
||||
<PromoSpacing>
|
||||
<CaseStudyCatalog.Promo
|
||||
compactTop
|
||||
entries={CASE_STUDY_CATALOG_ENTRIES}
|
||||
renderText={renderText}
|
||||
/>
|
||||
</PromoSpacing>
|
||||
|
||||
@@ -95,13 +134,23 @@ export default async function PartnerPage() {
|
||||
<Eyebrow
|
||||
colorScheme="primary"
|
||||
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Heading
|
||||
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
|
||||
size="lg"
|
||||
weight="light"
|
||||
/>
|
||||
<Body body={THREE_CARDS_ILLUSTRATION_DATA.body} size="sm" />
|
||||
<Heading size="lg" weight="light">
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Find the program that fits your business`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`and unlock new opportunities with Twenty`)}
|
||||
</HeadingPart>
|
||||
</Heading>
|
||||
{THREE_CARDS_ILLUSTRATION_DATA.body && (
|
||||
<Body
|
||||
body={THREE_CARDS_ILLUSTRATION_DATA.body}
|
||||
renderText={renderText}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</ThreeCards.Intro>
|
||||
<ThreeCards.IllustrationCards
|
||||
illustrationCards={THREE_CARDS_ILLUSTRATION_DATA.illustrationCards}
|
||||
@@ -128,11 +177,20 @@ export default async function PartnerPage() {
|
||||
color={theme.colors.primary.text[100]}
|
||||
page={Pages.Partners}
|
||||
>
|
||||
<Signoff.Heading
|
||||
<Signoff.Heading page={Pages.Partners}>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Ready to grow`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`with Twenty?`)}
|
||||
</HeadingPart>
|
||||
</Signoff.Heading>
|
||||
<Signoff.Body
|
||||
body={{ text: SIGNOFF_COPY.body }}
|
||||
page={Pages.Partners}
|
||||
segments={SIGNOFF_DATA.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Signoff.Body body={SIGNOFF_DATA.body} page={Pages.Partners} />
|
||||
<Signoff.Cta>
|
||||
<PartnerSignoffCtas />
|
||||
</Signoff.Cta>
|
||||
@@ -140,19 +198,30 @@ export default async function PartnerPage() {
|
||||
|
||||
<Faq.Root>
|
||||
<Faq.Intro>
|
||||
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
|
||||
<Faq.Heading segments={FAQ_DATA.heading} />
|
||||
<Eyebrow
|
||||
colorScheme="secondary"
|
||||
heading={FAQ_DATA.eyebrow.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Faq.Heading>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Stop fighting custom.`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`Start building, with Twenty`)}
|
||||
</HeadingPart>
|
||||
</Faq.Heading>
|
||||
<Faq.Cta>
|
||||
<LinkButton
|
||||
color="primary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Get started"
|
||||
type="anchor"
|
||||
label={renderText(msg`Get started`)}
|
||||
variant="contained"
|
||||
/>
|
||||
<TalkToUsButton
|
||||
color="primary"
|
||||
label="Talk to us"
|
||||
label={msg`Talk to us`}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Faq.Cta>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { SignoffDataType } from '@/sections/Signoff/types';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const SIGNOFF_DATA: SignoffDataType = {
|
||||
heading: [
|
||||
{ text: 'Ready to grow\n', fontFamily: 'serif' },
|
||||
{ text: 'with Twenty?', fontFamily: 'sans' },
|
||||
],
|
||||
body: {
|
||||
text: 'Join our partner ecosystem and help businesses\ntake control of their CRM.',
|
||||
},
|
||||
export const SIGNOFF_COPY = {
|
||||
body: msg`Join our partner ecosystem and help businesses\ntake control of their CRM.`,
|
||||
};
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { TestimonialsDataType } from '@/sections/Testimonials/types';
|
||||
|
||||
export const TESTIMONIALS_DATA: TestimonialsDataType = {
|
||||
eyebrow: {
|
||||
heading: { text: 'Join our growing partner ecosystem', fontFamily: 'sans' },
|
||||
heading: {
|
||||
text: msg`Join our growing partner ecosystem`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
},
|
||||
testimonials: [
|
||||
{
|
||||
heading: {
|
||||
text: "Twenty gives you the kind of flexibility that actually changes what you can offer your clients. The dev experience is clean, the APIs are open, and when something needs to be customized, you can just do it. There's no fighting the platform.",
|
||||
text: msg`Twenty gives you the kind of flexibility that actually changes what you can offer your clients. The dev experience is clean, the APIs are open, and when something needs to be customized, you can just do it. There's no fighting the platform.`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
author: {
|
||||
name: { text: 'Benjamin Reynolds' },
|
||||
designation: { text: 'Principal and Founder, Alternative Partners' },
|
||||
name: { text: msg`Benjamin Reynolds` },
|
||||
designation: { text: msg`Principal and Founder, Alternative Partners` },
|
||||
avatar: {
|
||||
src: '/images/partner/testimonials/benjamin-reynolds.webp',
|
||||
alt: 'Portrait of Benjamin Reynolds',
|
||||
@@ -21,12 +25,12 @@ export const TESTIMONIALS_DATA: TestimonialsDataType = {
|
||||
},
|
||||
{
|
||||
heading: {
|
||||
text: "The flexibility is just amazing. Literally, there's nothing you cannot do. You can create objects, access everything through the API, pull notes and send them to the portal. Try doing that in HubSpot. No way. It's the true ability to build exactly what's actually needed.",
|
||||
text: msg`The flexibility is just amazing. Literally, there's nothing you cannot do. You can create objects, access everything through the API, pull notes and send them to the portal. Try doing that in HubSpot. No way. It's the true ability to build exactly what's actually needed.`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
author: {
|
||||
name: { text: 'Bertrams' },
|
||||
designation: { text: 'Founder, Wintactix' },
|
||||
name: { text: msg`Bertrams` },
|
||||
designation: { text: msg`Founder, Wintactix` },
|
||||
avatar: {
|
||||
src: '/images/partner/testimonials/bertrams.jpeg',
|
||||
alt: 'Portrait of Bertrams',
|
||||
@@ -35,12 +39,12 @@ export const TESTIMONIALS_DATA: TestimonialsDataType = {
|
||||
},
|
||||
{
|
||||
heading: {
|
||||
text: "Twenty Apps opens the door to building products, not just implementations. For example, we're developing a WhatsApp Business integration that any Twenty’s client could get. That's a recurring revenue stream we wouldn't have if we were just configuring someone else's platform.",
|
||||
text: msg`Twenty Apps opens the door to building products, not just implementations. For example, we're developing a WhatsApp Business integration that any Twenty’s client could get. That's a recurring revenue stream we wouldn't have if we were just configuring someone else's platform.`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
author: {
|
||||
name: { text: 'Mike Babiy' },
|
||||
designation: { text: 'Founder, Nine Dots Ventures' },
|
||||
name: { text: msg`Mike Babiy` },
|
||||
designation: { text: msg`Founder, Nine Dots Ventures` },
|
||||
avatar: {
|
||||
src: '/images/partner/testimonials/mike-babiy.png',
|
||||
alt: 'Photo featuring Mike Babiy',
|
||||
|
||||
@@ -1,67 +1,66 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
|
||||
|
||||
export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
|
||||
eyebrow: {
|
||||
heading: {
|
||||
text: 'Which partner program is right for you?',
|
||||
text: msg`Which partner program is right for you?`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
},
|
||||
heading: [
|
||||
{ text: 'Find the program that fits your business ', fontFamily: 'serif' },
|
||||
{ text: 'and unlock new opportunities with Twenty', fontFamily: 'sans' },
|
||||
],
|
||||
body: { text: '' },
|
||||
illustrationCards: [
|
||||
{
|
||||
heading: { text: 'Technology Partners', fontFamily: 'sans' },
|
||||
heading: { text: msg`Technology Partners`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'Build integrations that connect Twenty with the tools your customers already use. Help us expand the Twenty ecosystem.',
|
||||
text: msg`Build integrations that connect Twenty with the tools your customers already use. Help us expand the Twenty ecosystem.`,
|
||||
},
|
||||
benefits: [
|
||||
{ text: 'Co-marketing opportunities', icon: 'users' },
|
||||
{ text: 'Listing on Twenty integrations page', icon: 'search' },
|
||||
{ text: 'Soon: earn revenue', icon: 'tag' },
|
||||
{ text: msg`Co-marketing opportunities`, icon: 'users' },
|
||||
{ text: msg`Listing on Twenty integrations page`, icon: 'search' },
|
||||
{ text: msg`Soon: earn revenue`, icon: 'tag' },
|
||||
],
|
||||
action: {
|
||||
kind: 'partnerApplication',
|
||||
label: 'Become a Technology Partner',
|
||||
label: msg`Become a Technology Partner`,
|
||||
programId: 'technology',
|
||||
},
|
||||
attribution: undefined,
|
||||
illustration: 'programming',
|
||||
},
|
||||
{
|
||||
heading: { text: 'Content & Community Partners', fontFamily: 'sans' },
|
||||
heading: { text: msg`Content & Community Partners`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: "Share Twenty with your audience and help shape the future of the #1 open source CRM. We're looking for creators, educators, and community builders who want to showcase great software.",
|
||||
text: msg`Share Twenty with your audience and help shape the future of the #1 open source CRM. We're looking for creators, educators, and community builders who want to showcase great software.`,
|
||||
},
|
||||
benefits: [
|
||||
{ text: 'Revenue share for referred customers', icon: 'tag' },
|
||||
{ text: 'Exclusive content collaboration opportunities', icon: 'edit' },
|
||||
{ text: 'Marketing assets & brand resources', icon: 'book' },
|
||||
{ text: msg`Revenue share for referred customers`, icon: 'tag' },
|
||||
{
|
||||
text: msg`Exclusive content collaboration opportunities`,
|
||||
icon: 'edit',
|
||||
},
|
||||
{ text: msg`Marketing assets & brand resources`, icon: 'book' },
|
||||
],
|
||||
action: {
|
||||
kind: 'partnerApplication',
|
||||
label: 'Become a Content Partner',
|
||||
label: msg`Become a Content Partner`,
|
||||
programId: 'content',
|
||||
},
|
||||
attribution: undefined,
|
||||
illustration: 'connect',
|
||||
},
|
||||
{
|
||||
heading: { text: 'Solutions Partners', fontFamily: 'sans' },
|
||||
heading: { text: msg`Solutions Partners`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'Help customers implement, customize, and succeed with Twenty. Combine sales and services to grow your business.',
|
||||
text: msg`Help customers implement, customize, and succeed with Twenty. Combine sales and services to grow your business.`,
|
||||
},
|
||||
benefits: [
|
||||
{ text: 'Resale discounts & revenue share', icon: 'tag' },
|
||||
{ text: 'Marketplace listing', icon: 'search' },
|
||||
{ text: 'Dedicated partner support', icon: 'users' },
|
||||
{ text: msg`Resale discounts & revenue share`, icon: 'tag' },
|
||||
{ text: msg`Marketplace listing`, icon: 'search' },
|
||||
{ text: msg`Dedicated partner support`, icon: 'users' },
|
||||
],
|
||||
action: {
|
||||
kind: 'partnerApplication',
|
||||
label: 'Become a Solution Partner',
|
||||
label: msg`Become a Solution Partner`,
|
||||
programId: 'solutions',
|
||||
},
|
||||
attribution: undefined,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { EngagementBandDataType } from '@/sections/EngagementBand/types';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const ENGAGEMENT_BAND_DATA: EngagementBandDataType = {
|
||||
heading: {
|
||||
text: 'Need help with customization?',
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
body: {
|
||||
text: 'Find the right partner to implement, customize, and tailor Twenty to your team.',
|
||||
},
|
||||
export const ENGAGEMENT_BAND_COPY = {
|
||||
body: msg`Find the right partner to implement, customize, and tailor Twenty to your team.`,
|
||||
heading: msg`Need help with customization?`,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import type { HeroBaseDataType } from '@/sections/Hero/types';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
const PRICING_HERO_SUBTAGLINE = {
|
||||
text: 'Start your free trial today\nwithout credit card.',
|
||||
export const HERO_COPY = {
|
||||
body: msg`Start your free trial today\nwithout credit card.`,
|
||||
};
|
||||
|
||||
export const HERO_DATA = {
|
||||
heading: [
|
||||
{ text: 'Simple', fontFamily: 'serif' },
|
||||
{ text: 'Pricing', fontFamily: 'sans', newLine: true },
|
||||
],
|
||||
body: PRICING_HERO_SUBTAGLINE,
|
||||
} satisfies HeroBaseDataType;
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { FAQ_DATA } from '@/sections/Faq/data';
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import { TalkToUsButton } from '@/lib/contact-cal';
|
||||
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 { ENGAGEMENT_BAND_COPY } from '@/app/[locale]/pricing/engagement-band.data';
|
||||
import { HERO_COPY } 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 { Eyebrow, HeadingPart, LinkButton } from '@/design-system/components';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { EngagementBand } from '@/sections/EngagementBand/components';
|
||||
import { Faq } from '@/sections/Faq/components';
|
||||
@@ -36,8 +42,16 @@ const PricingBannerContainer = styled.div`
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('pricing');
|
||||
|
||||
export default async function PricingPage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type PricingPageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function PricingPage({ params }: PricingPageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
|
||||
return (
|
||||
@@ -55,11 +69,20 @@ export default async function PricingPage() {
|
||||
</Menu.Root>
|
||||
|
||||
<Hero.Root backgroundColor={theme.colors.secondary.background[5]}>
|
||||
<Hero.Heading page={Pages.Pricing} segments={HERO_DATA.heading} />
|
||||
<Hero.Heading page={Pages.Pricing}>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Simple`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`Pricing`)}
|
||||
</HeadingPart>
|
||||
</Hero.Heading>
|
||||
<Hero.Body
|
||||
body={HERO_DATA.body}
|
||||
body={{ text: HERO_COPY.body }}
|
||||
page={Pages.Pricing}
|
||||
preserveLineBreaks
|
||||
renderText={renderText}
|
||||
/>
|
||||
</Hero.Root>
|
||||
|
||||
@@ -81,14 +104,21 @@ export default async function PricingPage() {
|
||||
>
|
||||
<EngagementBand.Copy>
|
||||
<EngagementBand.Heading
|
||||
segments={ENGAGEMENT_BAND_DATA.heading}
|
||||
renderText={renderText}
|
||||
segments={{
|
||||
fontFamily: 'serif',
|
||||
text: ENGAGEMENT_BAND_COPY.heading,
|
||||
}}
|
||||
/>
|
||||
<EngagementBand.Body
|
||||
body={{ text: ENGAGEMENT_BAND_COPY.body }}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<EngagementBand.Body body={ENGAGEMENT_BAND_DATA.body} />
|
||||
</EngagementBand.Copy>
|
||||
<EngagementBand.Actions>
|
||||
<BecomePartnerButton
|
||||
color="secondary"
|
||||
label="Find a partner"
|
||||
label={msg`Find a partner`}
|
||||
variant="outlined"
|
||||
/>
|
||||
</EngagementBand.Actions>
|
||||
@@ -106,25 +136,40 @@ export default async function PricingPage() {
|
||||
<Salesforce.Flow
|
||||
backgroundColor={theme.colors.secondary.background[5]}
|
||||
body={SALESFORCE_DATA.body}
|
||||
heading={SALESFORCE_DATA.heading}
|
||||
pricing={SALESFORCE_DATA.pricing}
|
||||
/>
|
||||
>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Trust the n°1 CRM,`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">{renderText(msg`or not !`)}</HeadingPart>
|
||||
</Salesforce.Flow>
|
||||
|
||||
<Faq.Root>
|
||||
<Faq.Intro>
|
||||
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
|
||||
<Faq.Heading segments={FAQ_DATA.heading} />
|
||||
<Eyebrow
|
||||
colorScheme="secondary"
|
||||
heading={FAQ_DATA.eyebrow.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Faq.Heading>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Stop fighting custom.`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`Start building, with Twenty`)}
|
||||
</HeadingPart>
|
||||
</Faq.Heading>
|
||||
<Faq.Cta>
|
||||
<LinkButton
|
||||
color="primary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Get started"
|
||||
type="anchor"
|
||||
label={renderText(msg`Get started`)}
|
||||
variant="contained"
|
||||
/>
|
||||
<TalkToUsButton
|
||||
color="primary"
|
||||
label="Talk to us"
|
||||
label={msg`Talk to us`}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Faq.Cta>
|
||||
|
||||
@@ -1,402 +1,403 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { PlanTableDataType } from '@/sections/PlanTable/types';
|
||||
|
||||
export const PLAN_TABLE_DATA: PlanTableDataType = {
|
||||
featureColumnLabel: 'Name',
|
||||
featureColumnLabel: msg`Name`,
|
||||
rows: [
|
||||
{
|
||||
featureLabel: 'Price',
|
||||
featureLabel: msg`Price`,
|
||||
selfHostTiers: {
|
||||
organization: { kind: 'text', text: '$19' },
|
||||
pro: { kind: 'text', text: '$0' },
|
||||
organization: { kind: 'text', text: msg`$19` },
|
||||
pro: { kind: 'text', text: msg`$0` },
|
||||
},
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: '$19' },
|
||||
pro: { kind: 'text', text: '$9' },
|
||||
organization: { kind: 'text', text: msg`$19` },
|
||||
pro: { kind: 'text', text: msg`$9` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Seats limit',
|
||||
featureLabel: msg`Seats limit`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'text', text: msg`Unlimited` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
title: msg`Workspace`,
|
||||
type: 'category',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Custom objects',
|
||||
featureLabel: msg`Custom objects`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'text', text: msg`Unlimited` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Custom fields',
|
||||
featureLabel: msg`Custom fields`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'text', text: msg`Unlimited` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Custom views',
|
||||
featureLabel: msg`Custom views`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'text', text: msg`Unlimited` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'View types',
|
||||
featureLabel: msg`View types`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Table, Kanban, Calendar' },
|
||||
pro: { kind: 'text', text: 'Table, Kanban, Calendar' },
|
||||
organization: { kind: 'text', text: msg`Table, Kanban, Calendar` },
|
||||
pro: { kind: 'text', text: msg`Table, Kanban, Calendar` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Custom layout',
|
||||
featureLabel: msg`Custom layout`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'text', text: msg`Unlimited` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Records',
|
||||
featureLabel: msg`Records`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'text', text: msg`Unlimited` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'CSV import & export',
|
||||
featureLabel: msg`CSV import & export`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Languages',
|
||||
featureLabel: msg`Languages`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: '30+' },
|
||||
pro: { kind: 'text', text: '30+' },
|
||||
organization: { kind: 'text', text: msg`30+` },
|
||||
pro: { kind: 'text', text: msg`30+` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
title: 'Reports',
|
||||
title: msg`Reports`,
|
||||
type: 'category',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Number of dashboards',
|
||||
featureLabel: msg`Number of dashboards`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'text', text: msg`Unlimited` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
title: 'Emails & Calendar',
|
||||
title: msg`Emails & Calendar`,
|
||||
type: 'category',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Internet accounts per user',
|
||||
featureLabel: msg`Internet accounts per user`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'text', text: msg`Unlimited` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Folder/Label import',
|
||||
featureLabel: msg`Folder/Label import`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Email sharing',
|
||||
featureLabel: msg`Email sharing`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Fully customizable' },
|
||||
pro: { kind: 'text', text: 'Fully customizable' },
|
||||
organization: { kind: 'text', text: msg`Fully customizable` },
|
||||
pro: { kind: 'text', text: msg`Fully customizable` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
title: 'AI & Automations',
|
||||
title: msg`AI & Automations`,
|
||||
type: 'category',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Workflows',
|
||||
featureLabel: msg`Workflows`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'AI agents',
|
||||
featureLabel: msg`AI agents`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Custom AI models',
|
||||
featureLabel: msg`Custom AI models`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
title: 'Security',
|
||||
title: msg`Security`,
|
||||
type: 'category',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Two-factor authentication',
|
||||
featureLabel: msg`Two-factor authentication`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'User roles',
|
||||
featureLabel: msg`User roles`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'text', text: msg`Unlimited` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Read/Edit/Delete permissions',
|
||||
featureLabel: msg`Read/Edit/Delete permissions`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'text', text: msg`Unlimited` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Field-level permissions',
|
||||
featureLabel: msg`Field-level permissions`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'text', text: msg`Unlimited` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Row-level permissions',
|
||||
featureLabel: msg`Row-level permissions`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'SSO',
|
||||
featureLabel: msg`SSO`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Advanced Encryption',
|
||||
featureLabel: msg`Advanced Encryption`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Audit logs',
|
||||
featureLabel: msg`Audit logs`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Environments',
|
||||
featureLabel: msg`Environments`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Local, Production' },
|
||||
pro: { kind: 'text', text: 'Local, Production' },
|
||||
organization: { kind: 'text', text: msg`Local, Production` },
|
||||
pro: { kind: 'text', text: msg`Local, Production` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Impersonate users',
|
||||
featureLabel: msg`Impersonate users`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
title: 'Support',
|
||||
title: msg`Support`,
|
||||
type: 'category',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Community',
|
||||
featureLabel: msg`Community`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Help center',
|
||||
featureLabel: msg`Help center`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Email and Chat',
|
||||
featureLabel: msg`Email and Chat`,
|
||||
selfHostTiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Priority support',
|
||||
featureLabel: msg`Priority support`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Onboarding Packs',
|
||||
featureLabel: msg`Onboarding Packs`,
|
||||
selfHostTiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Implementation partners',
|
||||
featureLabel: msg`Implementation partners`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
title: 'Customization',
|
||||
title: msg`Customization`,
|
||||
type: 'category',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Custom apps',
|
||||
featureLabel: msg`Custom apps`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
organization: { kind: 'text', text: msg`Unlimited` },
|
||||
pro: { kind: 'text', text: msg`Unlimited` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
appliesTo: 'cloud',
|
||||
featureLabel: 'Subdomain (yourco.twenty.com)',
|
||||
featureLabel: msg`Subdomain (yourco.twenty.com)`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
appliesTo: 'cloud',
|
||||
featureLabel: 'Custom domain (crm.yourco.com)',
|
||||
featureLabel: msg`Custom domain (crm.yourco.com)`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
title: 'Developers',
|
||||
title: msg`Developers`,
|
||||
type: 'category',
|
||||
},
|
||||
{
|
||||
featureLabel: 'REST & GraphQL API',
|
||||
featureLabel: msg`REST & GraphQL API`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Webhooks',
|
||||
featureLabel: msg`Webhooks`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'MCP server',
|
||||
featureLabel: msg`MCP server`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Install shared tarball app',
|
||||
featureLabel: msg`Install shared tarball app`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
appliesTo: 'cloud',
|
||||
featureLabel: 'API calls',
|
||||
featureLabel: msg`API calls`,
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: '200 per minute' },
|
||||
pro: { kind: 'text', text: '100 per minute' },
|
||||
organization: { kind: 'text', text: msg`200 per minute` },
|
||||
pro: { kind: 'text', text: msg`100 per minute` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
appliesTo: 'selfHost',
|
||||
title: 'Self-hosting',
|
||||
title: msg`Self-hosting`,
|
||||
type: 'category',
|
||||
},
|
||||
{
|
||||
appliesTo: 'selfHost',
|
||||
featureLabel: 'Source code access',
|
||||
featureLabel: msg`Source code access`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'yes', label: msg`Yes` },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
appliesTo: 'selfHost',
|
||||
featureLabel: 'Commercial license (no AGPL obligations)',
|
||||
featureLabel: msg`Commercial license (no AGPL obligations)`,
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
organization: { kind: 'yes', label: msg`Yes` },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
@@ -404,11 +405,11 @@ export const PLAN_TABLE_DATA: PlanTableDataType = {
|
||||
],
|
||||
initialVisibleRowCount: 15,
|
||||
seeMoreFeaturesCta: {
|
||||
collapseLabel: 'Show less',
|
||||
expandLabel: 'See more features',
|
||||
collapseLabel: msg`Show less`,
|
||||
expandLabel: msg`See more features`,
|
||||
},
|
||||
tierColumns: [
|
||||
{ id: 'pro', label: 'Pro' },
|
||||
{ id: 'organization', label: 'Organization' },
|
||||
{ id: 'pro', label: msg`Pro` },
|
||||
{ id: 'organization', label: msg`Organization` },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,51 +1,48 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { SalesforceDataType } from '@/sections/Salesforce/types';
|
||||
|
||||
const SALESFORCE_POPUP_TITLE = 'Good choice!';
|
||||
const SALESFORCE_POPUP_TITLE = msg`Good choice!`;
|
||||
|
||||
export const SALESFORCE_DATA: SalesforceDataType = {
|
||||
body: {
|
||||
text: "Some call this enterprise pricing. We prefer a CRM where API access, webhooks, and workflows don't show up as surprise add-ons.",
|
||||
text: msg`Some call this enterprise pricing. We prefer a CRM where API access, webhooks, and workflows don't show up as surprise add-ons.`,
|
||||
},
|
||||
heading: [
|
||||
{ text: 'Trust the n°1 CRM,', fontFamily: 'serif' },
|
||||
{ text: ' or not !', fontFamily: 'sans' },
|
||||
],
|
||||
pricing: {
|
||||
addons: [
|
||||
{
|
||||
cost: 35,
|
||||
id: 'api-access',
|
||||
label: 'API access',
|
||||
label: msg`API access`,
|
||||
popup: {
|
||||
body: 'APIs are extra. Simplicity has a price.',
|
||||
body: msg`APIs are extra. Simplicity has a price.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: '+$35/user per month',
|
||||
rightLabel: msg`+$35/user per month`,
|
||||
},
|
||||
{
|
||||
cost: 0,
|
||||
fixedCost: 7000,
|
||||
id: 'webhooks',
|
||||
label: 'Webhooks (Change Data Capture)',
|
||||
label: msg`Webhooks (Change Data Capture)`,
|
||||
popup: {
|
||||
body: 'Real-time changes? That will be a premium surprise.',
|
||||
body: msg`Real-time changes? That will be a premium surprise.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: '+$7000/org per month',
|
||||
rightLabel: msg`+$7000/org per month`,
|
||||
},
|
||||
{
|
||||
cost: 0,
|
||||
disabled: true,
|
||||
id: 'live-updates',
|
||||
label: 'Live updates',
|
||||
label: msg`Live updates`,
|
||||
popup: {
|
||||
body: 'Live updates are unavailable, which is almost more honest.',
|
||||
body: msg`Live updates are unavailable, which is almost more honest.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: 'Unavailable',
|
||||
rightLabel: msg`Unavailable`,
|
||||
tooltip: {
|
||||
title: 'Unavailable',
|
||||
body: 'Real-time is a state of mind, not a feature.',
|
||||
title: msg`Unavailable`,
|
||||
body: msg`Real-time is a state of mind, not a feature.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -53,71 +50,71 @@ export const SALESFORCE_DATA: SalesforceDataType = {
|
||||
defaultChecked: true,
|
||||
disabled: true,
|
||||
id: 'ui-theme',
|
||||
label: 'UI theme',
|
||||
label: msg`UI theme`,
|
||||
popup: {
|
||||
body: 'A retro theme as a paid add-on is somehow the most believable part.',
|
||||
body: msg`A retro theme as a paid add-on is somehow the most believable part.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: 'Retro 2015',
|
||||
rightLabel: msg`Retro 2015`,
|
||||
tooltip: {
|
||||
title: 'Included!',
|
||||
body: 'Better than Liquid Glass!',
|
||||
title: msg`Included!`,
|
||||
body: msg`Better than Liquid Glass!`,
|
||||
},
|
||||
},
|
||||
{
|
||||
cost: 5,
|
||||
id: 'sso',
|
||||
label: 'SSO',
|
||||
label: msg`SSO`,
|
||||
popup: {
|
||||
body: 'Only $5 for SSO. Practically a charity program.',
|
||||
body: msg`Only $5 for SSO. Practically a charity program.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: '+$5/user per month',
|
||||
rightLabel: msg`+$5/user per month`,
|
||||
},
|
||||
{
|
||||
cost: 75,
|
||||
id: 'permissions',
|
||||
label: '11 permissions\ngroups',
|
||||
label: msg`11 permissions\ngroups`,
|
||||
popup: {
|
||||
body: 'Experience enterprise-grade granularity, starting with an 11th permission.',
|
||||
body: msg`Experience enterprise-grade granularity, starting with an 11th permission.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: '+$75/user per month\nSwitch to enterprise!',
|
||||
rightLabel: msg`+$75/user per month\nSwitch to enterprise!`,
|
||||
sharedCostKey: 'enterprise-plan',
|
||||
},
|
||||
{
|
||||
cost: 105,
|
||||
id: 'maps',
|
||||
label: 'Maps view',
|
||||
label: msg`Maps view`,
|
||||
popup: {
|
||||
body: 'Visualize your customers on a map!',
|
||||
body: msg`Visualize your customers on a map!`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: '+$105/user per month',
|
||||
rightLabel: msg`+$105/user per month`,
|
||||
},
|
||||
{
|
||||
cost: 75,
|
||||
id: 'workflows',
|
||||
label: '6 workflows',
|
||||
label: msg`6 workflows`,
|
||||
popup: {
|
||||
body: 'Start automating at huge scale!',
|
||||
body: msg`Start automating at huge scale!`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: '+$75/user per month\nSwitch to enterprise!',
|
||||
rightLabel: msg`+$75/user per month\nSwitch to enterprise!`,
|
||||
sharedCostKey: 'enterprise-plan',
|
||||
},
|
||||
{
|
||||
cost: 0,
|
||||
id: 'lock-in',
|
||||
label: 'Lock-in',
|
||||
label: msg`Lock-in`,
|
||||
popup: {
|
||||
body: 'They call it customer loyalty. We call it a very affectionate cage.',
|
||||
body: msg`They call it customer loyalty. We call it a very affectionate cage.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: '3 2 years contract\n-33% off',
|
||||
rightLabel: msg`3 2 years contract\n-33% off`,
|
||||
rightLabelParts: [
|
||||
[{ strike: true, text: '3' }, { text: ' 2 years contract' }],
|
||||
[{ text: '-33% off' }],
|
||||
[{ strike: true, text: msg`3` }, { text: msg`2 years contract` }],
|
||||
[{ text: msg`-33% off` }],
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -125,30 +122,30 @@ export const SALESFORCE_DATA: SalesforceDataType = {
|
||||
defaultChecked: true,
|
||||
disabled: true,
|
||||
id: 'apex-tutorials',
|
||||
label: 'APEX tutorials',
|
||||
label: msg`APEX tutorials`,
|
||||
popup: {
|
||||
body: 'Even the training material is a feature worth celebrating.',
|
||||
body: msg`Even the training material is a feature worth celebrating.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: 'Free for you!',
|
||||
rightLabel: msg`Free for you!`,
|
||||
tooltip: {
|
||||
title: 'Included!',
|
||||
body: 'Available on YouTube!',
|
||||
title: msg`Included!`,
|
||||
body: msg`Available on YouTube!`,
|
||||
},
|
||||
},
|
||||
{
|
||||
cost: 0,
|
||||
disabled: true,
|
||||
id: 'self-hosting',
|
||||
label: 'Self-hosting',
|
||||
label: msg`Self-hosting`,
|
||||
popup: {
|
||||
body: 'Owning your stack remains mysteriously out of stock.',
|
||||
body: msg`Owning your stack remains mysteriously out of stock.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: 'Out of stock',
|
||||
rightLabel: msg`Out of stock`,
|
||||
tooltip: {
|
||||
title: 'Out of stock',
|
||||
body: 'Self-hosting, now for rent!',
|
||||
title: msg`Out of stock`,
|
||||
body: msg`Self-hosting, now for rent!`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -156,81 +153,79 @@ export const SALESFORCE_DATA: SalesforceDataType = {
|
||||
defaultChecked: true,
|
||||
disabled: true,
|
||||
id: 'salesforce-classic',
|
||||
label: 'Salesforce Classic',
|
||||
label: msg`Salesforce Classic`,
|
||||
popup: {
|
||||
body: 'Classic never dies. It just gets extended one more time.',
|
||||
body: msg`Classic never dies. It just gets extended one more time.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: 'Extended run!',
|
||||
rightLabel: msg`Extended run!`,
|
||||
tooltip: {
|
||||
title: 'Included!',
|
||||
body: 'Outlived every redesign since 2004.',
|
||||
title: msg`Included!`,
|
||||
body: msg`Outlived every redesign since 2004.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
cost: 75,
|
||||
id: 'flow-orchestration',
|
||||
label: 'Flow\norchestration',
|
||||
label: msg`Flow\norchestration`,
|
||||
popup: {
|
||||
body: 'Because true orchestration means putting a dollar sign on every dramatic entrance.',
|
||||
body: msg`Because true orchestration means putting a dollar sign on every dramatic entrance.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel:
|
||||
'$1/orchestration run/org\n+$75/user per month\nSwitch to enterprise!',
|
||||
rightLabel: msg`$1/orchestration run/org\n+$75/user per month\nSwitch to enterprise!`,
|
||||
sharedCostKey: 'enterprise-plan',
|
||||
},
|
||||
{
|
||||
cost: 0,
|
||||
disabled: true,
|
||||
id: 'infinite-scroll',
|
||||
label: 'Infinite scroll',
|
||||
label: msg`Infinite scroll`,
|
||||
popup: {
|
||||
body: 'Infinite scroll is still coming soon, unlike the invoice.',
|
||||
body: msg`Infinite scroll is still coming soon, unlike the invoice.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: 'Coming soon!',
|
||||
rightLabel: msg`Coming soon!`,
|
||||
tooltip: {
|
||||
title: 'Coming soon!',
|
||||
body: 'Pagination builds character.',
|
||||
title: msg`Coming soon!`,
|
||||
body: msg`Pagination builds character.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
cost: 75,
|
||||
id: 'ai-einstein',
|
||||
label: 'AI (Einstein)',
|
||||
label: msg`AI (Einstein)`,
|
||||
popup: {
|
||||
body: 'become a genius!',
|
||||
body: msg`become a genius!`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel: '+$75/user per month\nSwitch to enterprise!',
|
||||
rightLabel: msg`+$75/user per month\nSwitch to enterprise!`,
|
||||
sharedCostKey: 'enterprise-plan',
|
||||
},
|
||||
{
|
||||
cost: 75,
|
||||
id: 'encrypt-data',
|
||||
label: 'Encrypt your data',
|
||||
label: msg`Encrypt your data`,
|
||||
netSpendRate: 0.2,
|
||||
popup: {
|
||||
body: 'Because apparently privacy feels more premium with a surcharge.',
|
||||
body: msg`Because apparently privacy feels more premium with a surcharge.`,
|
||||
titleBar: SALESFORCE_POPUP_TITLE,
|
||||
},
|
||||
rightLabel:
|
||||
'+20% of net spend\n+$75/user per month\nSwitch to enterprise!',
|
||||
rightLabel: msg`+20% of net spend\n+$75/user per month\nSwitch to enterprise!`,
|
||||
sharedCostKey: 'enterprise-plan',
|
||||
},
|
||||
],
|
||||
basePriceAmount: 100,
|
||||
promoTag: '1‑800‑YES‑SOFTWARE',
|
||||
featureSectionHeading: 'Add-ons',
|
||||
promoTag: msg`1‑800‑YES‑SOFTWARE`,
|
||||
featureSectionHeading: msg`Add-ons`,
|
||||
productIconAlt: 'Retro help document icon',
|
||||
productIconSrc: '/images/pricing/salesforce/help-icon.webp',
|
||||
priceSuffix: ' / seat / month - billed yearly',
|
||||
productTitle: 'Salesfarce Pro',
|
||||
secondaryCtaNote: 'More options available!',
|
||||
priceSuffix: msg`/ seat / month - billed yearly`,
|
||||
productTitle: msg`Salesfarce Pro`,
|
||||
secondaryCtaNote: msg`More options available!`,
|
||||
secondaryCtaHref:
|
||||
'https://www.salesforce.com/en-us/wp-content/uploads/sites/4/documents/pricing/all-add-ons.pdf',
|
||||
secondaryCtaLabel: 'Check more add-ons',
|
||||
totalPriceLabel: 'total per month with fixed cost',
|
||||
windowTitle: 'Salesfarce Add-on Center',
|
||||
secondaryCtaLabel: msg`Check more add-ons`,
|
||||
totalPriceLabel: msg`total per month with fixed cost`,
|
||||
windowTitle: msg`Salesfarce Add-on Center`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,86 +1,83 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { FeatureDataType } from '@/sections/Feature/types';
|
||||
|
||||
export const FEATURE_DATA: FeatureDataType = {
|
||||
eyebrow: {
|
||||
heading: {
|
||||
text: 'Core Features',
|
||||
text: msg`Core Features`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
},
|
||||
heading: [
|
||||
{ text: 'Everything you need,', fontFamily: 'serif' },
|
||||
{ text: ' out of the box', fontFamily: 'sans' },
|
||||
],
|
||||
mask: { src: '/images/product/feature/mask.webp', alt: '' },
|
||||
tiles: [
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/dashboards.webp', alt: '' },
|
||||
heading: { text: 'Reports & Dashboards', fontFamily: 'sans' },
|
||||
heading: { text: msg`Reports & Dashboards`, fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'Build custom dashboards' },
|
||||
{ text: 'Aggregate, bar, line, pie, and gauge widgets' },
|
||||
{ text: 'Filtered metrics from live CRM data' },
|
||||
{ text: msg`Build custom dashboards` },
|
||||
{ text: msg`Aggregate, bar, line, pie, and gauge widgets` },
|
||||
{ text: msg`Filtered metrics from live CRM data` },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/tasks.webp', alt: '' },
|
||||
heading: { text: 'Tasks & Activities', fontFamily: 'sans' },
|
||||
heading: { text: msg`Tasks & Activities`, fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'Create tasks from records' },
|
||||
{ text: 'Assign owners and due dates' },
|
||||
{ text: 'Rich notes attached to records' },
|
||||
{ text: msg`Create tasks from records` },
|
||||
{ text: msg`Assign owners and due dates` },
|
||||
{ text: msg`Rich notes attached to records` },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/emails.webp', alt: '' },
|
||||
heading: { text: 'Email & Calendar', fontFamily: 'sans' },
|
||||
heading: { text: msg`Email & Calendar`, fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'Connect Google or Microsoft accounts' },
|
||||
{ text: 'Emails and events linked to CRM records' },
|
||||
{ text: 'Full communication history in one place' },
|
||||
{ text: msg`Connect Google or Microsoft accounts` },
|
||||
{ text: msg`Emails and events linked to CRM records` },
|
||||
{ text: msg`Full communication history in one place` },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/contacts.webp', alt: '' },
|
||||
heading: { text: 'Contacts & Companies', fontFamily: 'sans' },
|
||||
heading: { text: msg`Contacts & Companies`, fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'Custom fields and relationships' },
|
||||
{ text: 'Unified timeline (emails, events, tasks, notes, files)' },
|
||||
{ text: 'Email/calendar activity on each record' },
|
||||
{ text: msg`Custom fields and relationships` },
|
||||
{ text: msg`Unified timeline (emails, events, tasks, notes, files)` },
|
||||
{ text: msg`Email/calendar activity on each record` },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/pipeline.webp', alt: '' },
|
||||
heading: { text: 'Pipeline Management', fontFamily: 'sans' },
|
||||
heading: { text: msg`Pipeline Management`, fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'Custom deal stages for your process' },
|
||||
{ text: 'Drag-and-drop deals between stages' },
|
||||
{ text: 'Track amount and close date' },
|
||||
{ text: msg`Custom deal stages for your process` },
|
||||
{ text: msg`Drag-and-drop deals between stages` },
|
||||
{ text: msg`Track amount and close date` },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/files.webp', alt: '' },
|
||||
heading: { text: 'Files', fontFamily: 'sans' },
|
||||
heading: { text: msg`Files`, fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'Multi-file upload on records' },
|
||||
{ text: 'Rename, download, and delete attachments' },
|
||||
{ text: 'In-app preview for supported file types (when enabled)' },
|
||||
{ text: msg`Multi-file upload on records` },
|
||||
{ text: msg`Rename, download, and delete attachments` },
|
||||
{ text: msg`In-app preview for supported file types (when enabled)` },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/data.webp', alt: '' },
|
||||
heading: { text: 'Data import', fontFamily: 'sans' },
|
||||
heading: { text: msg`Data import`, fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'CSV import flow' },
|
||||
{ text: 'Column-to-field mapping (including relations)' },
|
||||
{ text: 'CSV export anytime' },
|
||||
{ text: msg`CSV import flow` },
|
||||
{ text: msg`Column-to-field mapping (including relations)` },
|
||||
{ text: msg`CSV export anytime` },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { HeroBaseDataType } from '@/sections/Hero/types';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const HERO_DATA = {
|
||||
heading: [
|
||||
{ text: 'A CRM for teams', fontFamily: 'serif' },
|
||||
{ text: 'that ', fontFamily: 'serif', newLine: true },
|
||||
{ text: 'moves fast', fontFamily: 'sans' },
|
||||
],
|
||||
body: {
|
||||
text: 'Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.',
|
||||
},
|
||||
} satisfies HeroBaseDataType;
|
||||
export const HERO_COPY = {
|
||||
body: msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
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/[locale]/product/feature.data';
|
||||
import { HERO_DATA } from '@/app/[locale]/product/hero.data';
|
||||
import { SIGNOFF_DATA } from '@/app/[locale]/product/signoff.data';
|
||||
import { HERO_COPY } from '@/app/[locale]/product/hero.data';
|
||||
import { SIGNOFF_COPY } 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 {
|
||||
Body,
|
||||
Eyebrow,
|
||||
Heading,
|
||||
HeadingPart,
|
||||
LinkButton,
|
||||
} from '@/design-system/components';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { Faq } from '@/sections/Faq/components';
|
||||
import { Feature } from '@/sections/Feature/components';
|
||||
@@ -24,8 +36,16 @@ import { buildRouteMetadata } from '@/lib/seo';
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('product');
|
||||
|
||||
export default async function ProductPage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type ProductPageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
|
||||
return (
|
||||
@@ -53,14 +73,28 @@ export default async function ProductPage() {
|
||||
</Menu.Root>
|
||||
|
||||
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
|
||||
<Hero.Heading page={Pages.Product} segments={HERO_DATA.heading} />
|
||||
<Hero.Body body={HERO_DATA.body} page={Pages.Product} />
|
||||
<Hero.Heading page={Pages.Product}>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`A CRM for teams`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`that`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`moves fast`)}
|
||||
</HeadingPart>
|
||||
</Hero.Heading>
|
||||
<Hero.Body
|
||||
body={{ text: HERO_COPY.body }}
|
||||
page={Pages.Product}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Hero.Cta>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Get started"
|
||||
type="anchor"
|
||||
label={renderText(msg`Get started`)}
|
||||
variant="contained"
|
||||
/>
|
||||
</Hero.Cta>
|
||||
@@ -68,9 +102,15 @@ export default async function ProductPage() {
|
||||
</Hero.Root>
|
||||
|
||||
<TrustedBy.Root>
|
||||
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
|
||||
<TrustedBy.Separator
|
||||
renderText={renderText}
|
||||
separator={TRUSTED_BY_DATA.separator}
|
||||
/>
|
||||
<TrustedBy.Logos logos={TRUSTED_BY_DATA.logos} />
|
||||
<TrustedBy.ClientCount label={TRUSTED_BY_DATA.clientCountLabel.text} />
|
||||
<TrustedBy.ClientCount
|
||||
label={TRUSTED_BY_DATA.clientCountLabel.text}
|
||||
renderText={renderText}
|
||||
/>
|
||||
</TrustedBy.Root>
|
||||
|
||||
<Feature.Root backgroundColor={theme.colors.primary.background[100]}>
|
||||
@@ -78,10 +118,22 @@ export default async function ProductPage() {
|
||||
<Eyebrow
|
||||
colorScheme="primary"
|
||||
heading={FEATURE_DATA.eyebrow.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Heading segments={FEATURE_DATA.heading} size="lg" weight="light" />
|
||||
<Heading size="lg" weight="light">
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Everything you need,`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`out of the box`)}
|
||||
</HeadingPart>
|
||||
</Heading>
|
||||
</Feature.Intro>
|
||||
<Feature.Tiles mask={FEATURE_DATA.mask} tiles={FEATURE_DATA.tiles} />
|
||||
<Feature.Tiles
|
||||
mask={FEATURE_DATA.mask}
|
||||
renderText={renderText}
|
||||
tiles={FEATURE_DATA.tiles}
|
||||
/>
|
||||
</Feature.Root>
|
||||
|
||||
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
|
||||
@@ -89,13 +141,23 @@ export default async function ProductPage() {
|
||||
<Eyebrow
|
||||
colorScheme="primary"
|
||||
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Heading
|
||||
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
|
||||
size="lg"
|
||||
weight="light"
|
||||
/>
|
||||
<Body body={THREE_CARDS_ILLUSTRATION_DATA.body} size="sm" />
|
||||
<Heading size="lg" weight="light">
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`A modern CRM with`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`an intuitive interface`)}
|
||||
</HeadingPart>
|
||||
</Heading>
|
||||
{THREE_CARDS_ILLUSTRATION_DATA.body && (
|
||||
<Body
|
||||
body={THREE_CARDS_ILLUSTRATION_DATA.body}
|
||||
renderText={renderText}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</ThreeCards.Intro>
|
||||
<ThreeCards.IllustrationCards
|
||||
illustrationCards={THREE_CARDS_ILLUSTRATION_DATA.illustrationCards}
|
||||
@@ -105,28 +167,48 @@ export default async function ProductPage() {
|
||||
<ProductStepper.Flow
|
||||
body={STEPPER_DATA.body}
|
||||
eyebrow={STEPPER_DATA.eyebrow}
|
||||
heading={STEPPER_DATA.heading}
|
||||
steps={STEPPER_DATA.steps}
|
||||
/>
|
||||
>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Go the extra mile`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`with no-code`)}
|
||||
</HeadingPart>
|
||||
</ProductStepper.Flow>
|
||||
|
||||
<Signoff.Root
|
||||
backgroundColor={theme.colors.secondary.background[5]}
|
||||
color={theme.colors.primary.text[100]}
|
||||
page={Pages.Product}
|
||||
>
|
||||
<Signoff.Heading page={Pages.Product} segments={SIGNOFF_DATA.heading} />
|
||||
<Signoff.Body body={SIGNOFF_DATA.body} page={Pages.Product} />
|
||||
<Signoff.Heading page={Pages.Product}>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Ready to grow`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`with`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`Twenty?`)}
|
||||
</HeadingPart>
|
||||
</Signoff.Heading>
|
||||
<Signoff.Body
|
||||
body={{ text: SIGNOFF_COPY.body }}
|
||||
page={Pages.Product}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Signoff.Cta>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Get started"
|
||||
type="anchor"
|
||||
label={renderText(msg`Get started`)}
|
||||
variant="contained"
|
||||
/>
|
||||
<TalkToUsButton
|
||||
color="secondary"
|
||||
label="Talk to us"
|
||||
label={msg`Talk to us`}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Signoff.Cta>
|
||||
@@ -134,19 +216,30 @@ export default async function ProductPage() {
|
||||
|
||||
<Faq.Root>
|
||||
<Faq.Intro>
|
||||
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
|
||||
<Faq.Heading segments={FAQ_DATA.heading} />
|
||||
<Eyebrow
|
||||
colorScheme="secondary"
|
||||
heading={FAQ_DATA.eyebrow.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Faq.Heading>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Stop fighting custom.`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`Start building, with Twenty`)}
|
||||
</HeadingPart>
|
||||
</Faq.Heading>
|
||||
<Faq.Cta>
|
||||
<LinkButton
|
||||
color="primary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Get started"
|
||||
type="anchor"
|
||||
label={renderText(msg`Get started`)}
|
||||
variant="contained"
|
||||
/>
|
||||
<TalkToUsButton
|
||||
color="primary"
|
||||
label="Talk to us"
|
||||
label={msg`Talk to us`}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Faq.Cta>
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { SignoffDataType } from '@/sections/Signoff/types';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const SIGNOFF_DATA: SignoffDataType = {
|
||||
heading: [
|
||||
{ text: 'Ready to grow', fontFamily: 'serif' },
|
||||
{ text: 'with ', fontFamily: 'serif', newLine: true },
|
||||
{ text: 'Twenty?', fontFamily: 'sans' },
|
||||
],
|
||||
body: {
|
||||
text: 'Join the teams that chose to own their CRM. Start building with Twenty today.',
|
||||
},
|
||||
export const SIGNOFF_COPY = {
|
||||
body: msg`Join the teams that chose to own their CRM. Start building with Twenty today.`,
|
||||
};
|
||||
|
||||
@@ -1,34 +1,25 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { ProductStepperDataType } from '@/sections/ProductStepper/types';
|
||||
|
||||
export const STEPPER_DATA: ProductStepperDataType = {
|
||||
eyebrow: {
|
||||
heading: {
|
||||
text: 'Customization',
|
||||
text: msg`Customization`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
},
|
||||
heading: [
|
||||
{
|
||||
text: 'Go the extra mile',
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
{
|
||||
text: ' with no-code',
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
],
|
||||
body: {
|
||||
text: 'Need a quick change? Skip the engineering ticket. Customize your workspace in minutes.',
|
||||
text: msg`Need a quick change? Skip the engineering ticket. Customize your workspace in minutes.`,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
icon: 'users',
|
||||
heading: {
|
||||
text: 'Data model',
|
||||
text: msg`Data model`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
body: {
|
||||
text: 'Add objects and fields',
|
||||
text: msg`Add objects and fields`,
|
||||
},
|
||||
image: {
|
||||
src: '/images/product/stepper/step-one.webp',
|
||||
@@ -38,11 +29,11 @@ export const STEPPER_DATA: ProductStepperDataType = {
|
||||
{
|
||||
icon: 'check',
|
||||
heading: {
|
||||
text: 'Automation',
|
||||
text: msg`Automation`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
body: {
|
||||
text: 'Create a workflow',
|
||||
text: msg`Create a workflow`,
|
||||
},
|
||||
image: {
|
||||
src: '/images/product/stepper/step-two.webp',
|
||||
@@ -52,11 +43,11 @@ export const STEPPER_DATA: ProductStepperDataType = {
|
||||
{
|
||||
icon: 'eye',
|
||||
heading: {
|
||||
text: 'Layout',
|
||||
text: msg`Layout`,
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
body: {
|
||||
text: 'Tailor record pages, menus, and views',
|
||||
text: msg`Tailor record pages, menus, and views`,
|
||||
},
|
||||
image: {
|
||||
src: '/images/product/stepper/step-three.webp',
|
||||
|
||||
@@ -1,37 +1,33 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
|
||||
|
||||
export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
|
||||
eyebrow: {
|
||||
heading: { text: 'Stop settling for trade-offs.', fontFamily: 'sans' },
|
||||
heading: { text: msg`Stop settling for trade-offs.`, fontFamily: 'sans' },
|
||||
},
|
||||
heading: [
|
||||
{ text: 'A modern CRM with ', fontFamily: 'serif' },
|
||||
{ text: 'an intuitive interface', fontFamily: 'sans' },
|
||||
],
|
||||
body: { text: '' },
|
||||
illustrationCards: [
|
||||
{
|
||||
heading: { text: 'Built for speed', fontFamily: 'sans' },
|
||||
heading: { text: msg`Built for speed`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'Fly through your workspace with shortcuts and short load times.',
|
||||
text: msg`Fly through your workspace with shortcuts and short load times.`,
|
||||
},
|
||||
benefits: undefined,
|
||||
attribution: undefined,
|
||||
illustration: 'speed',
|
||||
},
|
||||
{
|
||||
heading: { text: 'Real-time data', fontFamily: 'sans' },
|
||||
heading: { text: msg`Real-time data`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'See updates as they happen. Work with your team and agents seamlessly.',
|
||||
text: msg`See updates as they happen. Work with your team and agents seamlessly.`,
|
||||
},
|
||||
benefits: undefined,
|
||||
attribution: undefined,
|
||||
illustration: 'eye',
|
||||
},
|
||||
{
|
||||
heading: { text: 'Stay in Flow', fontFamily: 'sans' },
|
||||
heading: { text: msg`Stay in Flow`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: 'AI chat, settings, and records in a side panels for fast, single-screen access.',
|
||||
text: msg`AI chat, settings, and records in a side panels for fast, single-screen access.`,
|
||||
},
|
||||
benefits: undefined,
|
||||
attribution: undefined,
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { BodyType } from '@/design-system/components/Body';
|
||||
import type { HeadingType } from '@/design-system/components/Heading';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const RELEASE_NOTES_HERO_HEADING: HeadingType[] = [
|
||||
{ fontFamily: 'serif', text: 'Latest ' },
|
||||
{ fontFamily: 'sans', text: 'Releases', newLine: true },
|
||||
];
|
||||
|
||||
export const RELEASE_NOTES_HERO_BODY: BodyType = {
|
||||
text: 'Discover the newest features and improvements in Twenty,\nthe #1 open source CRM.',
|
||||
export const RELEASE_NOTES_HERO_COPY = {
|
||||
body: msg`Discover the newest features and improvements in Twenty,\nthe #1 open source CRM.`,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { MENU_DATA } from '@/sections/Menu/data';
|
||||
import {
|
||||
RELEASE_NOTES_HERO_BODY,
|
||||
RELEASE_NOTES_HERO_HEADING,
|
||||
} from '@/app/[locale]/releases/hero.data';
|
||||
import { LinkButton } from '@/design-system/components';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { RELEASE_NOTES_HERO_COPY } from '@/app/[locale]/releases/hero.data';
|
||||
import { HeadingPart, LinkButton } from '@/design-system/components';
|
||||
import { GitHubIcon } from '@/icons';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { fetchLatestGithubReleaseTag } from '@/lib/releases/fetch-latest-release-tag';
|
||||
import { getVisibleReleaseNotes } from '@/lib/releases/get-visible-releases';
|
||||
@@ -20,12 +23,18 @@ import { Fragment } from 'react';
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('releases');
|
||||
|
||||
export default async function ReleasesPage() {
|
||||
type ReleasesPageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function ReleasesPage({ params }: ReleasesPageProps) {
|
||||
const allNotes = loadLocalReleaseNotes();
|
||||
const [latestTag, stats] = await Promise.all([
|
||||
const [i18n, latestTag, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchLatestGithubReleaseTag(),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
const visibleNotes =
|
||||
process.env.NODE_ENV === 'development'
|
||||
@@ -56,24 +65,27 @@ export default async function ReleasesPage() {
|
||||
</Menu.Root>
|
||||
|
||||
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
|
||||
<Hero.Heading
|
||||
page={Pages.ReleaseNotes}
|
||||
segments={RELEASE_NOTES_HERO_HEADING}
|
||||
size="lg"
|
||||
weight="light"
|
||||
/>
|
||||
<Hero.Heading page={Pages.ReleaseNotes} size="lg" weight="light">
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Latest`)}
|
||||
</HeadingPart>
|
||||
<br />
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`Releases`)}
|
||||
</HeadingPart>
|
||||
</Hero.Heading>
|
||||
<Hero.Body
|
||||
page={Pages.ReleaseNotes}
|
||||
body={RELEASE_NOTES_HERO_BODY}
|
||||
body={{ text: RELEASE_NOTES_HERO_COPY.body }}
|
||||
renderText={renderText}
|
||||
size="sm"
|
||||
/>
|
||||
<Hero.Cta>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href="https://github.com/twentyhq/twenty/releases"
|
||||
label="Technical notes"
|
||||
label={renderText(msg`Technical notes`)}
|
||||
leadingIcon={<GitHubIcon fillColor="currentColor" size={14} />}
|
||||
type="anchor"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Hero.Cta>
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData';
|
||||
|
||||
export const EDITORIAL_FOUR: EditorialDataType = {
|
||||
eyebrow: {
|
||||
heading: {
|
||||
fontFamily: 'sans',
|
||||
text: 'What this means',
|
||||
text: msg`What this means`,
|
||||
},
|
||||
},
|
||||
heading: [
|
||||
{
|
||||
fontFamily: 'serif',
|
||||
text: 'Differentiation now ',
|
||||
},
|
||||
{
|
||||
fontFamily: 'sans',
|
||||
text: 'lives in the code you own.',
|
||||
},
|
||||
],
|
||||
body: [
|
||||
{
|
||||
text: "You don't buy your deployment pipeline off the shelf. You don't rent your data warehouse from a vendor who decides the schema. You build it, you own it, you iterate on it every week. CRM is going the same way. The teams that treat it as infrastructure they own will compound an advantage every quarter.",
|
||||
text: msg`You don't buy your deployment pipeline off the shelf. You don't rent your data warehouse from a vendor who decides the schema. You build it, you own it, you iterate on it every week. CRM is going the same way. The teams that treat it as infrastructure they own will compound an advantage every quarter.`,
|
||||
},
|
||||
{
|
||||
text: 'Tuesday your team learns that deals with a technical champion close 3x faster. Wednesday you add the field, wire up the scoring, adjust the workflow. By Thursday your agents are acting on it. That feedback loop is the edge. And it only works if the CRM is yours.',
|
||||
text: msg`Tuesday your team learns that deals with a technical champion close 3x faster. Wednesday you add the field, wire up the scoring, adjust the workflow. By Thursday your agents are acting on it. That feedback loop is the edge. And it only works if the CRM is yours.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData';
|
||||
|
||||
export const EDITORIAL_ONE: EditorialDataType = {
|
||||
eyebrow: {
|
||||
heading: {
|
||||
fontFamily: 'sans',
|
||||
text: 'The shift',
|
||||
text: msg`The shift`,
|
||||
},
|
||||
},
|
||||
heading: [
|
||||
{ fontFamily: 'serif', text: 'CRM was a ledger.' },
|
||||
{ fontFamily: 'sans', text: ' AI turned it into an operating system.' },
|
||||
],
|
||||
body: [
|
||||
{
|
||||
text: "For twenty years, CRM meant the same thing: a place to log calls, track deals, and pull reports on Friday. The real work happened in people's heads, in Slack threads, in hallway conversations. The CRM kept score. Nobody expected more from it.",
|
||||
text: msg`For twenty years, CRM meant the same thing: a place to log calls, track deals, and pull reports on Friday. The real work happened in people's heads, in Slack threads, in hallway conversations. The CRM kept score. Nobody expected more from it.`,
|
||||
},
|
||||
{
|
||||
text: 'AI agents are starting to draft outreach, score leads, research accounts, write follow-ups, update deal stages. Every one of these actions reads from and writes to the CRM. The scoreboard became the playbook. The database became the brain.',
|
||||
text: msg`AI agents are starting to draft outreach, score leads, research accounts, write follow-ups, update deal stages. Every one of these actions reads from and writes to the CRM. The scoreboard became the playbook. The database became the brain.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData';
|
||||
|
||||
export const EDITORIAL_THREE: EditorialDataType = {
|
||||
eyebrow: {
|
||||
heading: { fontFamily: 'sans', text: 'The opportunity' },
|
||||
heading: { fontFamily: 'sans', text: msg`The opportunity` },
|
||||
},
|
||||
heading: [
|
||||
{
|
||||
fontFamily: 'serif',
|
||||
text: 'Build it in an afternoon.',
|
||||
},
|
||||
{
|
||||
fontFamily: 'sans',
|
||||
text: ' AI made the gap that small.',
|
||||
},
|
||||
],
|
||||
body: [
|
||||
{
|
||||
text: 'A year ago, customizing your CRM meant hiring a Salesforce consultant, learning Apex, waiting months. The gap between "I want this" and "it\'s live" was measured in quarters and invoices. So people settled. They bent their process to fit the tool and called it adoption.',
|
||||
text: msg`A year ago, customizing your CRM meant hiring a Salesforce consultant, learning Apex, waiting months. The gap between "I want this" and "it\'s live" was measured in quarters and invoices. So people settled. They bent their process to fit the tool and called it adoption.`,
|
||||
},
|
||||
{
|
||||
text: "Now a developer can describe what they want to Claude Code and have a working app in an afternoon. A custom object, a scoring workflow, a new view, an integration. The bottleneck isn't building anymore. It's whether your platform lets you.",
|
||||
text: msg`Now a developer can describe what they want to Claude Code and have a working app in an afternoon. A custom object, a scoring workflow, a new view, an integration. The bottleneck isn't building anymore. It's whether your platform lets you.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import type { HeroWhyTwentyDataType } from '@/sections/Hero/types';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const HERO_DATA: HeroWhyTwentyDataType = {
|
||||
heading: [
|
||||
{ text: 'The future of CRM is built,', fontFamily: 'serif' },
|
||||
{ text: ' not bought.', fontFamily: 'sans' },
|
||||
],
|
||||
body: {
|
||||
text:
|
||||
'CRM was a database you filled on Fridays. ' +
|
||||
'AI turned it into the system that runs your go-to-market. ' +
|
||||
"To differentiate, you have to build what your competitors can't buy.",
|
||||
},
|
||||
export const HERO_COPY = {
|
||||
body: msg`CRM was a database you filled on Fridays. AI turned it into the system that runs your go-to-market. To differentiate, you have to build what your competitors can't buy.`,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { MarqueeDataType } from '@/sections/Marquee/types';
|
||||
|
||||
export const MARQUEE_DATA: MarqueeDataType = {
|
||||
heading: [
|
||||
{ fontFamily: 'serif', text: 'Same CRM' },
|
||||
{ fontFamily: 'sans', text: 'Same output' },
|
||||
{ fontFamily: 'serif', text: 'Same results' },
|
||||
{ fontFamily: 'serif', text: msg`Same CRM` },
|
||||
{ fontFamily: 'sans', text: msg`Same output` },
|
||||
{ fontFamily: 'serif', text: msg`Same results` },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { MENU_DATA } from '@/sections/Menu/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 { HERO_COPY } 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 { SIGNOFF_COPY } from '@/app/[locale]/why-twenty/signoff.data';
|
||||
import { HeadingPart, LinkButton } from '@/design-system/components';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/lib/i18n/get-route-i18n';
|
||||
import { Pages } from '@/lib/pages';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { Editorial } from '@/sections/Editorial/components';
|
||||
import { Hero } from '@/sections/Hero/components';
|
||||
@@ -59,8 +65,16 @@ const sectionCrosshairRight = {
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('whyTwenty');
|
||||
|
||||
export default async function WhyTwentyPage() {
|
||||
const stats = await fetchCommunityStats();
|
||||
type WhyTwentyPageProps = {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
};
|
||||
|
||||
export default async function WhyTwentyPage({ params }: WhyTwentyPageProps) {
|
||||
const [i18n, stats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
fetchCommunityStats(),
|
||||
]);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
|
||||
|
||||
return (
|
||||
@@ -91,12 +105,19 @@ export default async function WhyTwentyPage() {
|
||||
backgroundColor={theme.colors.secondary.background[100]}
|
||||
colorScheme="secondary"
|
||||
>
|
||||
<Hero.Heading
|
||||
<Hero.Heading page={Pages.WhyTwenty} size="xl">
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`The future of CRM is built,`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`not bought.`)}
|
||||
</HeadingPart>
|
||||
</Hero.Heading>
|
||||
<Hero.Body
|
||||
body={{ text: HERO_COPY.body }}
|
||||
page={Pages.WhyTwenty}
|
||||
segments={HERO_DATA.heading}
|
||||
size="xl"
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Hero.Body body={HERO_DATA.body} page={Pages.WhyTwenty} />
|
||||
<Hero.WhyTwentyVisual />
|
||||
</Hero.Root>
|
||||
|
||||
@@ -109,13 +130,22 @@ export default async function WhyTwentyPage() {
|
||||
<Editorial.Eyebrow
|
||||
colorScheme="secondary"
|
||||
eyebrow={EDITORIAL_ONE.eyebrow!}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Editorial.Heading segments={EDITORIAL_ONE.heading!} />
|
||||
<Editorial.Heading>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`CRM was a ledger.`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`AI turned it into an operating system.`)}
|
||||
</HeadingPart>
|
||||
</Editorial.Heading>
|
||||
</Editorial.Intro>
|
||||
<Editorial.Body
|
||||
body={EDITORIAL_ONE.body}
|
||||
color={theme.colors.secondary.text[60]}
|
||||
layout="two-column-left"
|
||||
renderText={renderText}
|
||||
/>
|
||||
</Editorial.Root>
|
||||
|
||||
@@ -128,6 +158,7 @@ export default async function WhyTwentyPage() {
|
||||
body={EDITORIAL_TWO.body}
|
||||
color={theme.colors.secondary.text[60]}
|
||||
layout="centered"
|
||||
renderText={renderText}
|
||||
/>
|
||||
</Editorial.Root>
|
||||
*/}
|
||||
@@ -141,13 +172,22 @@ export default async function WhyTwentyPage() {
|
||||
<Editorial.Eyebrow
|
||||
colorScheme="secondary"
|
||||
eyebrow={EDITORIAL_FOUR.eyebrow!}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Editorial.Heading segments={EDITORIAL_FOUR.heading!} />
|
||||
<Editorial.Heading>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Differentiation now`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`lives in the code you own.`)}
|
||||
</HeadingPart>
|
||||
</Editorial.Heading>
|
||||
</Editorial.Intro>
|
||||
<Editorial.Body
|
||||
body={EDITORIAL_FOUR.body}
|
||||
color={theme.colors.secondary.text[60]}
|
||||
layout="two-column-right"
|
||||
renderText={renderText}
|
||||
/>
|
||||
</Editorial.Root>
|
||||
|
||||
@@ -160,19 +200,29 @@ export default async function WhyTwentyPage() {
|
||||
<Editorial.Eyebrow
|
||||
colorScheme="secondary"
|
||||
eyebrow={EDITORIAL_THREE.eyebrow!}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Editorial.Heading segments={EDITORIAL_THREE.heading!} />
|
||||
<Editorial.Heading>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Build it in an afternoon.`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`AI made the gap that small.`)}
|
||||
</HeadingPart>
|
||||
</Editorial.Heading>
|
||||
</Editorial.Intro>
|
||||
<Editorial.Body
|
||||
body={EDITORIAL_THREE.body}
|
||||
color={theme.colors.secondary.text[60]}
|
||||
layout="two-column-left"
|
||||
renderText={renderText}
|
||||
/>
|
||||
</Editorial.Root>
|
||||
|
||||
<Marquee.Root
|
||||
backgroundColor={theme.colors.secondary.background[100]}
|
||||
color={theme.colors.secondary.text[100]}
|
||||
renderText={renderText}
|
||||
>
|
||||
<Marquee.Heading segments={MARQUEE_DATA.heading} />
|
||||
</Marquee.Root>
|
||||
@@ -182,17 +232,24 @@ export default async function WhyTwentyPage() {
|
||||
color={theme.colors.secondary.text[100]}
|
||||
page={Pages.WhyTwenty}
|
||||
>
|
||||
<Signoff.Heading
|
||||
<Signoff.Heading page={Pages.WhyTwenty}>
|
||||
<HeadingPart fontFamily="serif">
|
||||
{renderText(msg`Build a CRM your competitors`)}
|
||||
</HeadingPart>{' '}
|
||||
<HeadingPart fontFamily="sans">
|
||||
{renderText(msg`can't buy.`)}
|
||||
</HeadingPart>
|
||||
</Signoff.Heading>
|
||||
<Signoff.Body
|
||||
body={{ text: SIGNOFF_COPY.body }}
|
||||
page={Pages.WhyTwenty}
|
||||
segments={SIGNOFF_DATA.heading}
|
||||
renderText={renderText}
|
||||
/>
|
||||
<Signoff.Body body={SIGNOFF_DATA.body} page={Pages.WhyTwenty} />
|
||||
<Signoff.Cta>
|
||||
<LinkButton
|
||||
color="primary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Get started"
|
||||
type="anchor"
|
||||
label={renderText(msg`Get started`)}
|
||||
variant="contained"
|
||||
/>
|
||||
</Signoff.Cta>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { SignoffDataType } from '@/sections/Signoff/types';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const SIGNOFF_DATA: SignoffDataType = {
|
||||
heading: [
|
||||
{ text: 'Build a CRM your competitors ', fontFamily: 'serif' },
|
||||
{ text: "can't buy.", fontFamily: 'sans' },
|
||||
],
|
||||
body: {
|
||||
text: 'Open-source, AI-ready, and yours to shape.',
|
||||
},
|
||||
export const SIGNOFF_COPY = {
|
||||
body: msg`Open-source, AI-ready, and yours to shape.`,
|
||||
};
|
||||
|
||||
@@ -9,8 +9,9 @@ describe('sitemap', () => {
|
||||
|
||||
expect(pathnames).toContain('/');
|
||||
expect(pathnames).toContain('/product');
|
||||
expect(pathnames).toContain('/fr-FR/product');
|
||||
expect(pathnames).toContain('/customers/9dots');
|
||||
expect(pathnames).not.toContain('/fr-FR/product');
|
||||
expect(pathnames).not.toContain('/de-DE/product');
|
||||
expect(pathnames).not.toContain('/halftone');
|
||||
expect(pathnames).not.toContain('/enterprise/activate');
|
||||
});
|
||||
@@ -22,8 +23,9 @@ describe('sitemap', () => {
|
||||
|
||||
expect(productEntry?.alternates?.languages).toMatchObject({
|
||||
en: expect.stringMatching(/\/product$/),
|
||||
'fr-FR': expect.stringMatching(/\/fr-FR\/product$/),
|
||||
'x-default': expect.stringMatching(/\/product$/),
|
||||
});
|
||||
expect(productEntry?.alternates?.languages).not.toHaveProperty('fr-FR');
|
||||
expect(productEntry?.alternates?.languages).not.toHaveProperty('de-DE');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type BodyType = {
|
||||
text: string;
|
||||
export type BodyType<TText = ReactNode> = {
|
||||
text: TText;
|
||||
};
|
||||
|
||||
const bodyClassName = css`
|
||||
@@ -80,26 +81,33 @@ export type BodyWeight = 'light' | 'regular' | 'medium';
|
||||
export type BodySize = 'md' | 'sm' | 'xs';
|
||||
export type BodyVariant = 'default' | 'body-paragraph';
|
||||
|
||||
export type BodyProps = {
|
||||
type BodyTextRenderer<TText> = [TText] extends [ReactNode]
|
||||
? { renderText?: (text: TText) => ReactNode }
|
||||
: { renderText: (text: TText) => ReactNode };
|
||||
|
||||
export type BodyProps<TText = ReactNode> = {
|
||||
as?: BodyAs;
|
||||
body: BodyType;
|
||||
body: BodyType<TText>;
|
||||
family?: BodyFamily;
|
||||
weight?: BodyWeight;
|
||||
size?: BodySize;
|
||||
variant?: BodyVariant;
|
||||
className?: string;
|
||||
};
|
||||
} & BodyTextRenderer<TText>;
|
||||
|
||||
export function Body({
|
||||
export function Body<TText = ReactNode>({
|
||||
as: Tag = 'p',
|
||||
body,
|
||||
family = 'sans',
|
||||
renderText,
|
||||
weight = 'regular',
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
className,
|
||||
}: BodyProps) {
|
||||
}: BodyProps<TText>) {
|
||||
const rootClassName = [bodyClassName, className].filter(Boolean).join(' ');
|
||||
const content =
|
||||
renderText === undefined ? (body.text as ReactNode) : renderText(body.text);
|
||||
|
||||
return (
|
||||
<Tag
|
||||
@@ -109,7 +117,7 @@ export function Body({
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
>
|
||||
{body.text}
|
||||
{content}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ const Label = styled.span`
|
||||
|
||||
export type BaseButtonProps = {
|
||||
color: 'primary' | 'secondary';
|
||||
label: string;
|
||||
label: ReactNode;
|
||||
leadingIcon?: ReactNode;
|
||||
size?: ButtonSize;
|
||||
variant: 'contained' | 'outlined';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { LinkButtonType } from '@/design-system/components/Button/types/LinkButtonType';
|
||||
import { LocalizedLink } from '@/lib/i18n';
|
||||
import { styled } from '@linaria/react';
|
||||
import {
|
||||
BaseButton,
|
||||
@@ -11,14 +10,7 @@ const StyledButtonAnchor = styled.a`
|
||||
${buttonBaseStyles}
|
||||
`;
|
||||
|
||||
const StyledButtonLink = styled(LocalizedLink)`
|
||||
${buttonBaseStyles}
|
||||
`;
|
||||
|
||||
export type LinkButtonPresentation = 'anchor' | 'link';
|
||||
|
||||
export type LinkButtonProps = Omit<BaseButtonProps, 'label'> &
|
||||
LinkButtonType & { type: LinkButtonPresentation };
|
||||
export type LinkButtonProps = Omit<BaseButtonProps, 'label'> & LinkButtonType;
|
||||
|
||||
export function LinkButton({
|
||||
color,
|
||||
@@ -26,7 +18,6 @@ export function LinkButton({
|
||||
label,
|
||||
leadingIcon,
|
||||
size = 'regular',
|
||||
type,
|
||||
variant,
|
||||
}: LinkButtonProps) {
|
||||
const inner = (
|
||||
@@ -39,29 +30,16 @@ export function LinkButton({
|
||||
/>
|
||||
);
|
||||
|
||||
if (type === 'anchor') {
|
||||
return (
|
||||
<StyledButtonAnchor
|
||||
data-color={color}
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{inner}
|
||||
</StyledButtonAnchor>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledButtonLink
|
||||
<StyledButtonAnchor
|
||||
data-color={color}
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{inner}
|
||||
</StyledButtonLink>
|
||||
</StyledButtonAnchor>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export type LinkButtonType = { href: string; label: string };
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type LinkButtonType = { href: string; label: ReactNode };
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export type SubmitButtonType = { label: string };
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type SubmitButtonType = { label: ReactNode };
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Heading, type HeadingType } from '@/design-system/components/Heading';
|
||||
import { RectangleFillIcon } from '@/icons';
|
||||
|
||||
export type EyebrowType = { heading: HeadingType };
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type EyebrowType<TText = ReactNode> = { heading: HeadingType<TText> };
|
||||
|
||||
const EyebrowRow = styled.div`
|
||||
align-items: center;
|
||||
@@ -37,22 +38,31 @@ const eyebrowLabelClassName = css`
|
||||
}
|
||||
`;
|
||||
|
||||
type EyebrowProps = {
|
||||
heading: HeadingType;
|
||||
type EyebrowTextRenderer<TText> = [TText] extends [ReactNode]
|
||||
? { renderText?: (text: TText) => ReactNode }
|
||||
: { renderText: (text: TText) => ReactNode };
|
||||
|
||||
type EyebrowProps<TText = ReactNode> = {
|
||||
heading: HeadingType<TText>;
|
||||
colorScheme: 'primary' | 'secondary';
|
||||
markerHeight?: number;
|
||||
markerWidth?: number;
|
||||
};
|
||||
} & EyebrowTextRenderer<TText>;
|
||||
|
||||
export function Eyebrow({
|
||||
export function Eyebrow<TText = ReactNode>({
|
||||
heading,
|
||||
colorScheme,
|
||||
markerHeight,
|
||||
markerWidth,
|
||||
}: EyebrowProps) {
|
||||
renderText,
|
||||
}: EyebrowProps<TText>) {
|
||||
const colorClassName =
|
||||
colorScheme === 'primary' ? eyebrowColorPrimary : eyebrowColorSecondary;
|
||||
const headingClassName = [eyebrowLabelClassName, colorClassName].join(' ');
|
||||
const headingSegment = {
|
||||
fontFamily: heading.fontFamily,
|
||||
text: heading.text,
|
||||
};
|
||||
|
||||
return (
|
||||
<EyebrowRow>
|
||||
@@ -64,13 +74,24 @@ export function Eyebrow({
|
||||
width={markerWidth}
|
||||
/>
|
||||
</IconWrapper>
|
||||
<Heading
|
||||
as="h3"
|
||||
className={headingClassName}
|
||||
segments={{ fontFamily: heading.fontFamily, text: heading.text }}
|
||||
size="xs"
|
||||
weight="medium"
|
||||
/>
|
||||
{renderText === undefined ? (
|
||||
<Heading
|
||||
as="h3"
|
||||
className={headingClassName}
|
||||
segments={headingSegment as HeadingType}
|
||||
size="xs"
|
||||
weight="medium"
|
||||
/>
|
||||
) : (
|
||||
<Heading<TText>
|
||||
as="h3"
|
||||
className={headingClassName}
|
||||
renderText={renderText}
|
||||
segments={headingSegment}
|
||||
size="xs"
|
||||
weight="medium"
|
||||
/>
|
||||
)}
|
||||
</EyebrowRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import { Fragment } from 'react';
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
|
||||
export type HeadingType = {
|
||||
export type HeadingType<TText = ReactNode> = {
|
||||
fontFamily: 'sans' | 'serif' | 'mono';
|
||||
text: string;
|
||||
text: TText;
|
||||
fontWeight?: 'light' | 'regular' | 'medium';
|
||||
newLine?: boolean;
|
||||
lineBreakBefore?: boolean;
|
||||
@@ -108,57 +108,91 @@ const StyledSpan = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
type HeadingPartProps = {
|
||||
children: ReactNode;
|
||||
fontFamily: HeadingFamily;
|
||||
fontWeight?: HeadingWeight;
|
||||
};
|
||||
|
||||
export function HeadingPart({
|
||||
children,
|
||||
fontFamily,
|
||||
fontWeight,
|
||||
}: HeadingPartProps) {
|
||||
return (
|
||||
<StyledSpan data-family={fontFamily} data-weight={fontWeight}>
|
||||
{children}
|
||||
</StyledSpan>
|
||||
);
|
||||
}
|
||||
|
||||
export type HeadingAs = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
export type HeadingFamily = 'sans' | 'serif' | 'mono';
|
||||
export type HeadingWeight = 'light' | 'regular' | 'medium';
|
||||
export type HeadingSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs';
|
||||
|
||||
export type HeadingProps = {
|
||||
type HeadingTextRenderer<TText> = [TText] extends [ReactNode]
|
||||
? { renderText?: (text: TText) => ReactNode }
|
||||
: { renderText: (text: TText) => ReactNode };
|
||||
|
||||
export type HeadingProps<TText = ReactNode> = {
|
||||
as?: HeadingAs;
|
||||
segments: HeadingType | HeadingType[];
|
||||
children?: ReactNode;
|
||||
inlineSegmentSeparator?: ReactNode;
|
||||
segments?: HeadingType<TText> | HeadingType<TText>[];
|
||||
weight?: HeadingWeight;
|
||||
size?: HeadingSize;
|
||||
className?: string;
|
||||
};
|
||||
} & HeadingTextRenderer<TText>;
|
||||
|
||||
export function Heading({
|
||||
export function Heading<TText = ReactNode>({
|
||||
as: Tag = 'h1',
|
||||
children,
|
||||
inlineSegmentSeparator = ' ',
|
||||
renderText,
|
||||
segments,
|
||||
weight = 'regular',
|
||||
size = 'md',
|
||||
className,
|
||||
}: HeadingProps) {
|
||||
}: HeadingProps<TText>) {
|
||||
const rootClassName = [headingRootClassName, className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const renderSegmentText = (text: TText) =>
|
||||
renderText === undefined ? (text as ReactNode) : renderText(text);
|
||||
|
||||
return (
|
||||
<Tag className={rootClassName} data-weight={weight} data-size={size}>
|
||||
{Array.isArray(segments) ? (
|
||||
{children !== undefined ? (
|
||||
children
|
||||
) : Array.isArray(segments) ? (
|
||||
segments.map((segment, index) => {
|
||||
const lineBreakBefore =
|
||||
segment.newLine === true || segment.lineBreakBefore === true;
|
||||
const joinWithPreviousInlineSegment =
|
||||
index > 0 && lineBreakBefore === false;
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{lineBreakBefore ? <br /> : null}
|
||||
{joinWithPreviousInlineSegment ? inlineSegmentSeparator : null}
|
||||
<StyledSpan
|
||||
data-family={segment.fontFamily}
|
||||
data-weight={segment.fontWeight}
|
||||
>
|
||||
{segment.text}
|
||||
{renderSegmentText(segment.text)}
|
||||
</StyledSpan>
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
) : segments !== undefined ? (
|
||||
<StyledSpan
|
||||
data-family={segments.fontFamily}
|
||||
data-weight={segments.fontWeight}
|
||||
>
|
||||
{segments.text}
|
||||
{renderSegmentText(segments.text)}
|
||||
</StyledSpan>
|
||||
)}
|
||||
) : null}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { LocalizedLink } from '@/lib/i18n';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { ComponentType } from 'react';
|
||||
@@ -15,7 +14,6 @@ interface IconButtonProps {
|
||||
iconSize: number;
|
||||
iconStrokeColor: string;
|
||||
size: number;
|
||||
href?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
ariaExpanded?: boolean;
|
||||
}
|
||||
@@ -59,14 +57,6 @@ const StyledButton = styled.button<SurfaceProps>`
|
||||
width: ${({ $size }) => `${$size}px`};
|
||||
`;
|
||||
|
||||
const StyledIconLink = styled(LocalizedLink)<SurfaceProps>`
|
||||
${iconButtonSurfaceStyles}
|
||||
border: 1px solid ${({ $borderColor }) => $borderColor};
|
||||
color: inherit;
|
||||
height: ${({ $size }) => `${$size}px`};
|
||||
width: ${({ $size }) => `${$size}px`};
|
||||
`;
|
||||
|
||||
export function IconButton({
|
||||
icon: Icon,
|
||||
ariaLabel,
|
||||
@@ -75,7 +65,6 @@ export function IconButton({
|
||||
iconSize,
|
||||
iconStrokeColor,
|
||||
size,
|
||||
href,
|
||||
onClick,
|
||||
ariaExpanded,
|
||||
}: IconButtonProps) {
|
||||
@@ -88,19 +77,6 @@ export function IconButton({
|
||||
/>
|
||||
);
|
||||
|
||||
if (href !== undefined && href !== '') {
|
||||
return (
|
||||
<StyledIconLink
|
||||
$borderColor={borderColor}
|
||||
$size={size}
|
||||
aria-label={ariaLabel}
|
||||
href={href}
|
||||
>
|
||||
{icon}
|
||||
</StyledIconLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
$borderColor={borderColor}
|
||||
|
||||
@@ -6,7 +6,7 @@ export { Eyebrow } from './Eyebrow';
|
||||
export { Form } from './Form';
|
||||
export type { FormFieldProps, FormTextareaProps } from './Form';
|
||||
export { GuideCrosshair } from './GuideCrosshair';
|
||||
export { Heading } from './Heading';
|
||||
export { Heading, HeadingPart } from './Heading';
|
||||
export { IconButton } from './IconButton';
|
||||
export { Image } from './Image';
|
||||
export { Grid, Inline, Stack } from './Layout';
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
BaseButton,
|
||||
buttonBaseStyles,
|
||||
} from '@/design-system/components/Button/BaseButton';
|
||||
import { useRenderMessage } from '@/lib/i18n/use-render-message';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { useContactCalModal } from './ContactCalModalRoot';
|
||||
@@ -14,11 +16,12 @@ const StyledTrigger = styled.button`
|
||||
|
||||
type TalkToUsButtonProps = {
|
||||
color: 'primary' | 'secondary';
|
||||
label: string;
|
||||
label: MessageDescriptor;
|
||||
variant: 'contained' | 'outlined';
|
||||
};
|
||||
|
||||
export function TalkToUsButton({ color, label, variant }: TalkToUsButtonProps) {
|
||||
const renderText = useRenderMessage();
|
||||
const { openContactCalModal } = useContactCalModal();
|
||||
|
||||
return (
|
||||
@@ -28,7 +31,7 @@ export function TalkToUsButton({ color, label, variant }: TalkToUsButtonProps) {
|
||||
type="button"
|
||||
onClick={openContactCalModal}
|
||||
>
|
||||
<BaseButton color={color} label={label} variant={variant} />
|
||||
<BaseButton color={color} label={renderText(label)} variant={variant} />
|
||||
</StyledTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { CaseStudyCatalogEntry } from '@/lib/customers/types';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
@@ -38,23 +39,23 @@ export function getCaseStudyPalette(href: string) {
|
||||
export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
|
||||
{
|
||||
href: '/customers/9dots',
|
||||
industry: 'Real Estate',
|
||||
authorRole: 'Founder, Nine Dots Ventures',
|
||||
industry: msg`Real Estate`,
|
||||
authorRole: msg`Founder, Nine Dots Ventures`,
|
||||
kpis: [
|
||||
{ value: '150 hrs', label: 'Saved / month' },
|
||||
{ value: '2,000+', label: 'Daily messages' },
|
||||
{ value: 'Q1 2026', label: 'Record quarter' },
|
||||
{ value: msg`150 hrs`, label: msg`Saved / month` },
|
||||
{ value: msg`2,000+`, label: msg`Daily messages` },
|
||||
{ value: msg`Q1 2026`, label: msg`Record quarter` },
|
||||
],
|
||||
quote: {
|
||||
text: 'Twenty lets us build a CRM around the business and not the business around the CRM.',
|
||||
text: msg`Twenty lets us build a CRM around the business and not the business around the CRM.`,
|
||||
author: 'Mike Babiy',
|
||||
role: 'Founder, Nine Dots Ventures',
|
||||
role: msg`Founder, Nine Dots Ventures`,
|
||||
},
|
||||
hero: {
|
||||
readingTime: '9 min',
|
||||
title: [
|
||||
{ text: 'A real estate agency on WhatsApp ', fontFamily: 'serif' },
|
||||
{ text: 'built a CRM around it', fontFamily: 'sans' },
|
||||
{ text: msg`A real estate agency on WhatsApp`, fontFamily: 'serif' },
|
||||
{ text: msg`built a CRM around it`, fontFamily: 'sans' },
|
||||
],
|
||||
author: 'Mike Babiy & Azmat Parveen',
|
||||
authorAvatarSrc: '/images/partner/testimonials/mike-babiy.png',
|
||||
@@ -62,26 +63,25 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
|
||||
heroImageSrc: PLACEHOLDER_HERO,
|
||||
},
|
||||
catalogCard: {
|
||||
summary:
|
||||
"Nine Dots put Twenty at the center of Homeseller's stack with APIs, automation, and AI on top of WhatsApp-heavy operations.",
|
||||
date: 'Jul 2025',
|
||||
summary: msg`Nine Dots put Twenty at the center of Homeseller's stack with APIs, automation, and AI on top of WhatsApp-heavy operations.`,
|
||||
date: msg`Jul 2025`,
|
||||
coverImageSrc:
|
||||
'https://images.unsplash.com/photo-1733244766159-f58f4184fd38?w=1600&q=80',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/customers/alternative-partners',
|
||||
industry: 'Consulting',
|
||||
authorRole: 'Principal and Founder, Alternative Partners',
|
||||
industry: msg`Consulting`,
|
||||
authorRole: msg`Principal and Founder, Alternative Partners`,
|
||||
kpis: [
|
||||
{ value: 'AI-assisted', label: 'Salesforce migration' },
|
||||
{ value: 'Self-hosted', label: 'Full ownership' },
|
||||
{ value: msg`AI-assisted`, label: msg`Salesforce migration` },
|
||||
{ value: msg`Self-hosted`, label: msg`Full ownership` },
|
||||
],
|
||||
hero: {
|
||||
readingTime: '7 min',
|
||||
title: [
|
||||
{ text: 'From Salesforce to ', fontFamily: 'serif' },
|
||||
{ text: 'self-hosted Twenty', fontFamily: 'sans' },
|
||||
{ text: msg`From Salesforce to`, fontFamily: 'serif' },
|
||||
{ text: msg`self-hosted Twenty`, fontFamily: 'sans' },
|
||||
],
|
||||
author: 'Benjamin Reynolds',
|
||||
authorAvatarSrc: '/images/partner/testimonials/benjamin-reynolds.webp',
|
||||
@@ -89,26 +89,25 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
|
||||
heroImageSrc: PLACEHOLDER_HERO,
|
||||
},
|
||||
catalogCard: {
|
||||
summary:
|
||||
'Alternative Partners replaced Salesforce with self-hosted Twenty, using agentic AI to compress migration work.',
|
||||
date: '2025',
|
||||
summary: msg`Alternative Partners replaced Salesforce with self-hosted Twenty, using agentic AI to compress migration work.`,
|
||||
date: msg`2025`,
|
||||
coverImageSrc:
|
||||
'https://images.unsplash.com/photo-1702047149248-a6049168d2a8?w=1600&q=80',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/customers/netzero',
|
||||
industry: 'Agribusiness',
|
||||
authorRole: 'Co-founder, NetZero',
|
||||
industry: msg`Agribusiness`,
|
||||
authorRole: msg`Co-founder, NetZero`,
|
||||
kpis: [
|
||||
{ value: '3 product lines', label: 'On a single CRM' },
|
||||
{ value: 'No-code', label: 'Customizations' },
|
||||
{ value: msg`3 product lines`, label: msg`On a single CRM` },
|
||||
{ value: msg`No-code`, label: msg`Customizations` },
|
||||
],
|
||||
hero: {
|
||||
readingTime: '8 min',
|
||||
title: [
|
||||
{ text: 'A CRM that ', fontFamily: 'serif' },
|
||||
{ text: 'grows with you', fontFamily: 'sans' },
|
||||
{ text: msg`A CRM that`, fontFamily: 'serif' },
|
||||
{ text: msg`grows with you`, fontFamily: 'sans' },
|
||||
],
|
||||
author: 'Olivier Reinaud',
|
||||
authorAvatarSrc: '/images/partner/testimonials/olivier-reinaud.jpg',
|
||||
@@ -116,23 +115,22 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
|
||||
heroImageSrc: PLACEHOLDER_HERO,
|
||||
},
|
||||
catalogCard: {
|
||||
summary:
|
||||
'NetZero uses Twenty as a modular CRM across product lines and countries, with a roadmap into AI-assisted workflows.',
|
||||
date: '2025',
|
||||
summary: msg`NetZero uses Twenty as a modular CRM across product lines and countries, with a roadmap into AI-assisted workflows.`,
|
||||
date: msg`2025`,
|
||||
coverImageSrc:
|
||||
'https://images.unsplash.com/photo-1744830343976-ce690ba2a67c?w=1600&q=80',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/customers/act-education',
|
||||
industry: 'Education',
|
||||
authorRole: 'CRM Engineer, AC&T Education Migration',
|
||||
kpis: [{ value: '90%+', label: 'Lower CRM cost' }],
|
||||
industry: msg`Education`,
|
||||
authorRole: msg`CRM Engineer, AC&T Education Migration`,
|
||||
kpis: [{ value: msg`90%+`, label: msg`Lower CRM cost` }],
|
||||
hero: {
|
||||
readingTime: '7 min',
|
||||
title: [
|
||||
{ text: 'A CRM they ', fontFamily: 'serif' },
|
||||
{ text: 'actually own', fontFamily: 'sans' },
|
||||
{ text: msg`A CRM they`, fontFamily: 'serif' },
|
||||
{ text: msg`actually own`, fontFamily: 'sans' },
|
||||
],
|
||||
author: 'Joseph Chiang',
|
||||
authorAvatarSrc: '/images/partner/testimonials/joseph-chiang.jpg',
|
||||
@@ -140,23 +138,22 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
|
||||
heroImageSrc: PLACEHOLDER_HERO,
|
||||
},
|
||||
catalogCard: {
|
||||
summary:
|
||||
'AC&T and Flycoder moved from a dead vendor export to self-hosted Twenty, with over 90% lower CRM cost and full control.',
|
||||
date: '2025',
|
||||
summary: msg`AC&T and Flycoder moved from a dead vendor export to self-hosted Twenty, with over 90% lower CRM cost and full control.`,
|
||||
date: msg`2025`,
|
||||
coverImageSrc:
|
||||
'https://images.unsplash.com/photo-1687600154329-150952c73169?w=1600&q=80',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/customers/w3villa',
|
||||
industry: 'EdTech',
|
||||
authorRole: 'VP of Engineering, W3villa Technologies',
|
||||
kpis: [{ value: 'Zero', label: 'Manual work at core' }],
|
||||
industry: msg`EdTech`,
|
||||
authorRole: msg`VP of Engineering, W3villa Technologies`,
|
||||
kpis: [{ value: msg`Zero`, label: msg`Manual work at core` }],
|
||||
hero: {
|
||||
readingTime: '8 min',
|
||||
title: [
|
||||
{ text: 'When your CRM ', fontFamily: 'serif' },
|
||||
{ text: 'is the product', fontFamily: 'sans' },
|
||||
{ text: msg`When your CRM`, fontFamily: 'serif' },
|
||||
{ text: msg`is the product`, fontFamily: 'sans' },
|
||||
],
|
||||
author: 'Amrendra Pratap Singh',
|
||||
authorAvatarSrc: '/images/partner/testimonials/amrendra-singh.webp',
|
||||
@@ -164,41 +161,39 @@ export const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
|
||||
heroImageSrc: PLACEHOLDER_HERO,
|
||||
},
|
||||
catalogCard: {
|
||||
summary:
|
||||
'W3villa shipped W3Grads on Twenty for AI interviews, scoring, and institution-scale workflows without rebuilding CRM plumbing.',
|
||||
date: '2025',
|
||||
summary: msg`W3villa shipped W3Grads on Twenty for AI interviews, scoring, and institution-scale workflows without rebuilding CRM plumbing.`,
|
||||
date: msg`2025`,
|
||||
coverImageSrc:
|
||||
'https://images.unsplash.com/photo-1756830231350-3b501f63c5c1?w=1600&q=80',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/customers/elevate-consulting',
|
||||
industry: 'Management Consulting',
|
||||
authorRole: 'Director of Digital and Information, Elevate Consulting',
|
||||
industry: msg`Management Consulting`,
|
||||
authorRole: msg`Director of Digital and Information, Elevate Consulting`,
|
||||
kpis: [
|
||||
{ value: '1 click', label: 'Proposal automation' },
|
||||
{ value: '4 tools', label: 'Connected via API' },
|
||||
{ value: 'API-first', label: 'Tool integration' },
|
||||
{ value: msg`1 click`, label: msg`Proposal automation` },
|
||||
{ value: msg`4 tools`, label: msg`Connected via API` },
|
||||
{ value: msg`API-first`, label: msg`Tool integration` },
|
||||
],
|
||||
quote: {
|
||||
text: 'It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other.',
|
||||
text: msg`It is just such a nicer experience than dealing with a Salesforce or a HubSpot. My mission has been to get every tool API-accessible, so everything talks to each other.`,
|
||||
author: 'Justin Beadle',
|
||||
role: 'Director of Digital and Information, Elevate Consulting',
|
||||
role: msg`Director of Digital and Information, Elevate Consulting`,
|
||||
},
|
||||
hero: {
|
||||
readingTime: '8 min',
|
||||
title: [
|
||||
{ text: 'Twenty as the API backbone ', fontFamily: 'serif' },
|
||||
{ text: 'of a go-to-market stack', fontFamily: 'sans' },
|
||||
{ text: msg`Twenty as the API backbone`, fontFamily: 'serif' },
|
||||
{ text: msg`of a go-to-market stack`, fontFamily: 'sans' },
|
||||
],
|
||||
author: 'Justin Beadle',
|
||||
clientIcon: 'elevate-consulting',
|
||||
heroImageSrc: PLACEHOLDER_HERO,
|
||||
},
|
||||
catalogCard: {
|
||||
summary:
|
||||
'Elevate Consulting uses Twenty as the API backbone connecting billing, Teams, resourcing, and a custom front end around client and opportunity data.',
|
||||
date: 'Jun 2025',
|
||||
summary: msg`Elevate Consulting uses Twenty as the API backbone connecting billing, Teams, resourcing, and a custom front end around client and opportunity data.`,
|
||||
date: msg`Jun 2025`,
|
||||
coverImageSrc:
|
||||
'https://images.unsplash.com/photo-1758873269035-aae0e1fd3422?w=1600&q=80',
|
||||
},
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { HeadingType } from '@/design-system/components/Heading';
|
||||
import type { MessageHeadingSegment } from '@/lib/i18n/message-heading-segment';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
|
||||
export type CaseStudyTextBlock = {
|
||||
type: 'text';
|
||||
eyebrow?: string;
|
||||
heading: HeadingType[];
|
||||
paragraphs: string[];
|
||||
eyebrow?: MessageDescriptor;
|
||||
heading: MessageHeadingSegment[];
|
||||
paragraphs: MessageDescriptor[];
|
||||
callout?: string;
|
||||
};
|
||||
|
||||
@@ -17,44 +18,44 @@ export type CaseStudyVisualBlock = {
|
||||
export type CaseStudyContentBlock = CaseStudyTextBlock | CaseStudyVisualBlock;
|
||||
|
||||
export type CaseStudyKpi = {
|
||||
value: string;
|
||||
label: string;
|
||||
value: MessageDescriptor;
|
||||
label: MessageDescriptor;
|
||||
};
|
||||
|
||||
export type CaseStudyQuote = {
|
||||
text: string;
|
||||
text: MessageDescriptor;
|
||||
author: string;
|
||||
role: string;
|
||||
role: MessageDescriptor;
|
||||
};
|
||||
|
||||
export type CaseStudyData = {
|
||||
meta: { title: string; description: string };
|
||||
meta: { title: MessageDescriptor; description: MessageDescriptor };
|
||||
hero: {
|
||||
readingTime: string;
|
||||
title: HeadingType[];
|
||||
title: MessageHeadingSegment[];
|
||||
author: string;
|
||||
authorAvatarSrc?: string;
|
||||
clientIcon: string;
|
||||
heroImageSrc: string;
|
||||
industry?: string;
|
||||
authorRole?: string;
|
||||
industry?: MessageDescriptor;
|
||||
authorRole?: MessageDescriptor;
|
||||
kpis?: CaseStudyKpi[];
|
||||
quote?: CaseStudyQuote;
|
||||
};
|
||||
sections: CaseStudyContentBlock[];
|
||||
tableOfContents: string[];
|
||||
tableOfContents: MessageDescriptor[];
|
||||
catalogCard: {
|
||||
summary: string;
|
||||
date: string;
|
||||
summary: MessageDescriptor;
|
||||
date: MessageDescriptor;
|
||||
coverImageSrc?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CaseStudyCatalogEntry = {
|
||||
href: string;
|
||||
industry: string;
|
||||
industry: MessageDescriptor;
|
||||
kpis: CaseStudyKpi[];
|
||||
authorRole: string;
|
||||
authorRole: MessageDescriptor;
|
||||
quote?: CaseStudyQuote;
|
||||
hero: CaseStudyData['hero'];
|
||||
catalogCard: CaseStudyData['catalogCard'];
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
BaseButton,
|
||||
type BaseButtonProps,
|
||||
buttonBaseStyles,
|
||||
} from '@/design-system/components/Button/BaseButton';
|
||||
import type { LinkButtonType } from '@/design-system/components/Button/types/LinkButtonType';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { LocalizedLink } from './LocalizedLink';
|
||||
|
||||
const StyledButtonLink = styled(LocalizedLink)`
|
||||
${buttonBaseStyles}
|
||||
`;
|
||||
|
||||
export type LocalizedLinkButtonProps = Omit<BaseButtonProps, 'label'> &
|
||||
LinkButtonType;
|
||||
|
||||
export function LocalizedLinkButton({
|
||||
color,
|
||||
href,
|
||||
label,
|
||||
leadingIcon,
|
||||
size = 'regular',
|
||||
variant,
|
||||
}: LocalizedLinkButtonProps) {
|
||||
return (
|
||||
<StyledButtonLink
|
||||
data-color={color}
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
href={href}
|
||||
>
|
||||
<BaseButton
|
||||
color={color}
|
||||
label={label}
|
||||
leadingIcon={leadingIcon}
|
||||
size={size}
|
||||
variant={variant}
|
||||
/>
|
||||
</StyledButtonLink>
|
||||
);
|
||||
}
|
||||
@@ -24,15 +24,15 @@ describe('detectLocale', () => {
|
||||
detectLocale({
|
||||
acceptLanguageHeader: 'en;q=0.5,fr-FR;q=0.9,de;q=0.7',
|
||||
}),
|
||||
).toBe('en');
|
||||
).toBe('fr-FR');
|
||||
});
|
||||
|
||||
it('falls back to the source locale when a regional language is not published', () => {
|
||||
it('matches a regional language to a published locale with the same language subtag', () => {
|
||||
expect(
|
||||
detectLocale({
|
||||
acceptLanguageHeader: 'fr-CA',
|
||||
}),
|
||||
).toBe('en');
|
||||
).toBe('fr-FR');
|
||||
});
|
||||
|
||||
it('falls back to the source locale when a bare language is not published', () => {
|
||||
@@ -68,6 +68,6 @@ describe('detectLocale', () => {
|
||||
detectLocale({
|
||||
acceptLanguageHeader: 'en;q=0.1, fr-FR ; q=0.9, de-DE ; q=0.5',
|
||||
}),
|
||||
).toBe('en');
|
||||
).toBe('fr-FR');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,17 +2,21 @@ import { localizeHref, stripLocale } from '../localize-href';
|
||||
|
||||
describe('localizeHref', () => {
|
||||
it('does not emit locale prefixes for locales the website does not publish yet', () => {
|
||||
expect(localizeHref('fr-FR', '/pricing')).toBe('/pricing');
|
||||
expect(localizeHref('de-DE', '/pricing')).toBe('/pricing');
|
||||
});
|
||||
|
||||
it('emits locale prefixes for published non-default locales', () => {
|
||||
expect(localizeHref('fr-FR', '/pricing')).toBe('/fr-FR/pricing');
|
||||
expect(localizeHref('fr-FR', '/')).toBe('/fr-FR');
|
||||
});
|
||||
|
||||
it('returns paths unprefixed for the default locale (English at root)', () => {
|
||||
expect(localizeHref('en', '/pricing')).toBe('/pricing');
|
||||
expect(localizeHref('en', '/')).toBe('/');
|
||||
});
|
||||
|
||||
it('keeps the root path unprefixed for unpublished locales', () => {
|
||||
expect(localizeHref('fr-FR', '/')).toBe('/');
|
||||
expect(localizeHref('de-DE', '/')).toBe('/');
|
||||
});
|
||||
|
||||
it('preserves query strings and hash fragments', () => {
|
||||
@@ -25,8 +29,8 @@ describe('localizeHref', () => {
|
||||
});
|
||||
|
||||
it('strips legacy locale prefixes when targeting an unpublished locale', () => {
|
||||
expect(localizeHref('fr-FR', '/de-DE/why-twenty')).toBe('/why-twenty');
|
||||
expect(localizeHref('fr-FR', '/fr-FR/pricing')).toBe('/pricing');
|
||||
expect(localizeHref('de-DE', '/fr-FR/why-twenty')).toBe('/why-twenty');
|
||||
expect(localizeHref('de-DE', '/de-DE/pricing')).toBe('/pricing');
|
||||
});
|
||||
|
||||
it('strips a redundant /en prefix when targeting the default locale', () => {
|
||||
@@ -35,8 +39,8 @@ describe('localizeHref', () => {
|
||||
});
|
||||
|
||||
it('strips an /en-prefixed path when targeting an unpublished locale', () => {
|
||||
expect(localizeHref('fr-FR', '/en/why-twenty')).toBe('/why-twenty');
|
||||
expect(localizeHref('fr-FR', '/en')).toBe('/');
|
||||
expect(localizeHref('de-DE', '/en/why-twenty')).toBe('/why-twenty');
|
||||
expect(localizeHref('de-DE', '/en')).toBe('/');
|
||||
});
|
||||
|
||||
it('passes external https URLs through unchanged', () => {
|
||||
@@ -66,14 +70,14 @@ describe('localizeHref', () => {
|
||||
|
||||
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('/?ref=hero');
|
||||
expect(localizeHref('fr-FR', '/de-DE?ref=hero')).toBe('/?ref=hero');
|
||||
expect(localizeHref('de-DE', '/en?ref=hero')).toBe('/?ref=hero');
|
||||
expect(localizeHref('de-DE', '/fr-FR?ref=hero')).toBe('/?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('/#anchor');
|
||||
expect(localizeHref('fr-FR', '/de-DE#anchor')).toBe('/#anchor');
|
||||
expect(localizeHref('de-DE', '/en#anchor')).toBe('/#anchor');
|
||||
expect(localizeHref('de-DE', '/fr-FR#anchor')).toBe('/#anchor');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
APP_LOCALES,
|
||||
SOURCE_LOCALE,
|
||||
type AppLocale,
|
||||
} from 'twenty-shared/translations';
|
||||
import { APP_LOCALES, type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { WEBSITE_LOCALE_LIST } from './website-locale-list';
|
||||
|
||||
export { WEBSITE_LOCALE_LIST } from './website-locale-list';
|
||||
|
||||
const APP_LOCALE_VALUES: readonly AppLocale[] = Object.values(APP_LOCALES);
|
||||
|
||||
@@ -12,8 +12,6 @@ const isKnownPublicLocale = (locale: AppLocale): boolean =>
|
||||
export const KNOWN_PUBLIC_APP_LOCALE_LIST: readonly AppLocale[] =
|
||||
APP_LOCALE_VALUES.filter(isKnownPublicLocale);
|
||||
|
||||
export const WEBSITE_LOCALE_LIST: readonly AppLocale[] = [SOURCE_LOCALE];
|
||||
|
||||
const WEBSITE_LOCALE_SET: ReadonlySet<AppLocale> = new Set(WEBSITE_LOCALE_LIST);
|
||||
|
||||
export const isPublicAppLocale = (locale: AppLocale): boolean =>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { I18n, MessageDescriptor } from '@lingui/core';
|
||||
|
||||
export const createMessageDescriptorRenderer =
|
||||
(i18n: I18n) =>
|
||||
(descriptor: MessageDescriptor): string =>
|
||||
i18n._(descriptor);
|
||||
@@ -48,7 +48,8 @@ export const detectLocale = ({
|
||||
}
|
||||
|
||||
if (acceptLanguageHeader !== undefined && acceptLanguageHeader.length > 0) {
|
||||
for (const { tag } of parseAcceptLanguage(acceptLanguageHeader)) {
|
||||
for (const { tag, quality } of parseAcceptLanguage(acceptLanguageHeader)) {
|
||||
if (quality <= 0) continue;
|
||||
const match = matchTag(tag);
|
||||
if (match !== undefined) return match;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
|
||||
export const getMessageDescriptorSource = (
|
||||
descriptor: MessageDescriptor,
|
||||
): string => descriptor.message ?? descriptor.id;
|
||||
17
packages/twenty-website-new/src/lib/i18n/get-route-i18n.ts
Normal file
17
packages/twenty-website-new/src/lib/i18n/get-route-i18n.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { createI18nInstance } from './create-i18n-instance';
|
||||
import { resolveLocaleParam } from './resolve-locale-param';
|
||||
|
||||
export type LocaleRouteParams = {
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export const getRouteI18n = async (
|
||||
params: Promise<LocaleRouteParams>,
|
||||
): Promise<ReturnType<typeof createI18nInstance>> => {
|
||||
const { locale: rawLocale } = await params;
|
||||
const locale: AppLocale = resolveLocaleParam(rawLocale);
|
||||
|
||||
return createI18nInstance(locale);
|
||||
};
|
||||
@@ -9,12 +9,15 @@ export {
|
||||
isPublicAppLocale,
|
||||
} from './app-locale-set';
|
||||
export { createI18nInstance } from './create-i18n-instance';
|
||||
export { createMessageDescriptorRenderer } from './create-message-descriptor-renderer';
|
||||
export { detectLocale, LOCALE_COOKIE_NAME } from './detect-locale';
|
||||
export { getRouteI18n, type LocaleRouteParams } from './get-route-i18n';
|
||||
export { I18nProvider } from './I18nProvider';
|
||||
export { LocaleContext } from './LocaleContext';
|
||||
export { LocalizedLink } from './LocalizedLink';
|
||||
export { LocalizedLinkButton } from './LocalizedLinkButton';
|
||||
export { localizeHref, stripLocale } from './localize-href';
|
||||
export { getLocaleMessages } from './messages-by-locale';
|
||||
export { resolveLocaleParam } from './resolve-locale-param';
|
||||
export { useLocale } from './use-locale';
|
||||
export { useRenderMessage } from './use-render-message';
|
||||
export { useUnlocalizedPathname } from './use-unlocalized-pathname';
|
||||
|
||||
@@ -32,9 +32,11 @@ export const localizeHref = (locale: AppLocale, href: string): string => {
|
||||
? buildTailFromSegmentEnd(href, segmentEnd)
|
||||
: href;
|
||||
|
||||
return locale === SOURCE_LOCALE || !isPublicAppLocale(locale)
|
||||
? unprefixed
|
||||
: `/${locale}${unprefixed}`;
|
||||
if (locale === SOURCE_LOCALE || !isPublicAppLocale(locale)) {
|
||||
return unprefixed;
|
||||
}
|
||||
|
||||
return unprefixed === '/' ? `/${locale}` : `/${locale}${unprefixed}`;
|
||||
};
|
||||
|
||||
export const stripLocale = (pathname: string): string => {
|
||||
|
||||
5
packages/twenty-website-new/src/lib/i18n/message-body.ts
Normal file
5
packages/twenty-website-new/src/lib/i18n/message-body.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
|
||||
export type MessageBody = {
|
||||
text: MessageDescriptor;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { MessageHeadingSegment } from './message-heading-segment';
|
||||
|
||||
export type MessageEyebrow = {
|
||||
heading: MessageHeadingSegment;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
|
||||
export type MessageHeadingSegment = {
|
||||
fontFamily: 'sans' | 'serif' | 'mono';
|
||||
text: MessageDescriptor;
|
||||
fontWeight?: 'light' | 'regular' | 'medium';
|
||||
newLine?: boolean;
|
||||
lineBreakBefore?: boolean;
|
||||
};
|
||||
@@ -2,9 +2,11 @@ import { type Messages } from '@lingui/core';
|
||||
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { messages as enMessages } from '@/locales/generated/en';
|
||||
import { messages as frMessages } from '@/locales/generated/fr-FR';
|
||||
|
||||
const MESSAGES_BY_LOCALE: Partial<Record<AppLocale, Messages>> = {
|
||||
en: enMessages,
|
||||
'fr-FR': frMessages,
|
||||
};
|
||||
|
||||
export const getLocaleMessages = (locale: AppLocale): Messages =>
|
||||
|
||||
10
packages/twenty-website-new/src/lib/i18n/set-server-i18n.ts
Normal file
10
packages/twenty-website-new/src/lib/i18n/set-server-i18n.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { setI18n } from '@lingui/react/server';
|
||||
import type { AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { createI18nInstance } from './create-i18n-instance';
|
||||
|
||||
export const setServerI18n = (locale: AppLocale) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
setI18n(i18n);
|
||||
return i18n;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const useRenderMessage = () => {
|
||||
const { i18n } = useLingui();
|
||||
return useCallback(
|
||||
(descriptor: MessageDescriptor) => i18n._(descriptor),
|
||||
[i18n],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
APP_LOCALES,
|
||||
SOURCE_LOCALE,
|
||||
type AppLocale,
|
||||
} from 'twenty-shared/translations';
|
||||
|
||||
// Website localization is intentionally rolled out separately from the app.
|
||||
// Keep this allowlist small until Crowdin sync, SEO signals, and QA are proven.
|
||||
export const WEBSITE_LOCALE_LIST = [
|
||||
SOURCE_LOCALE,
|
||||
APP_LOCALES['fr-FR'],
|
||||
] as const satisfies readonly AppLocale[];
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
buttonBaseStyles,
|
||||
} from '@/design-system/components/Button/BaseButton';
|
||||
import { ButtonShape } from '@/design-system/components/Button/ButtonShape';
|
||||
import { useRenderMessage } from '@/lib/i18n/use-render-message';
|
||||
import {
|
||||
PARTNER_APPLICATION_MODAL_COPY,
|
||||
PARTNER_PROGRAM_OPTIONS,
|
||||
PARTNER_PROGRAM_IDS,
|
||||
PARTNER_PROGRAM_LABELS,
|
||||
type PartnerProgramId,
|
||||
} from '@/lib/partner-application/partner-application-modal-data';
|
||||
import { theme } from '@/theme';
|
||||
@@ -266,6 +268,7 @@ export function PartnerApplicationModal({
|
||||
onClose,
|
||||
initialProgramId = 'technology',
|
||||
}: PartnerApplicationModalProps) {
|
||||
const renderText = useRenderMessage();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [programId, setProgramId] =
|
||||
@@ -273,6 +276,7 @@ export function PartnerApplicationModal({
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const copy = PARTNER_APPLICATION_MODAL_COPY;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -336,12 +340,12 @@ export function PartnerApplicationModal({
|
||||
setSubmitError(null);
|
||||
|
||||
if (!name || !email || !company || !website || !message) {
|
||||
setSubmitError(validationCopy.incompleteForm);
|
||||
setSubmitError(renderText(validationCopy.incompleteForm));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!emailLooksValid) {
|
||||
setSubmitError(validationCopy.invalidEmail);
|
||||
setSubmitError(renderText(validationCopy.invalidEmail));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -363,22 +367,20 @@ export function PartnerApplicationModal({
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setSubmitError(validationCopy.submitFailed);
|
||||
setSubmitError(renderText(validationCopy.submitFailed));
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch {
|
||||
setSubmitError(validationCopy.submitFailed);
|
||||
setSubmitError(renderText(validationCopy.submitFailed));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[isSubmitting, onClose, programId],
|
||||
[isSubmitting, onClose, programId, renderText],
|
||||
);
|
||||
|
||||
const copy = PARTNER_APPLICATION_MODAL_COPY;
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
open={open}
|
||||
@@ -406,6 +408,7 @@ export function PartnerApplicationModal({
|
||||
newLine: true,
|
||||
},
|
||||
]}
|
||||
renderText={renderText}
|
||||
size="lg"
|
||||
weight="light"
|
||||
/>
|
||||
@@ -415,8 +418,16 @@ export function PartnerApplicationModal({
|
||||
<Modal.Description
|
||||
render={
|
||||
<SubtitleStack>
|
||||
<Body body={{ text: copy.subtitleLine1 }} size="md" />
|
||||
<Body body={{ text: copy.subtitleLine2 }} size="md" />
|
||||
<Body
|
||||
body={{ text: copy.subtitleLine1 }}
|
||||
renderText={renderText}
|
||||
size="md"
|
||||
/>
|
||||
<Body
|
||||
body={{ text: copy.subtitleLine2 }}
|
||||
renderText={renderText}
|
||||
size="md"
|
||||
/>
|
||||
</SubtitleStack>
|
||||
}
|
||||
/>
|
||||
@@ -424,19 +435,19 @@ export function PartnerApplicationModal({
|
||||
|
||||
<form ref={formRef} autoComplete="off" noValidate onSubmit={handleSubmit}>
|
||||
<FormFields>
|
||||
<Segments role="radiogroup" aria-label={copy.selectLabel}>
|
||||
{PARTNER_PROGRAM_OPTIONS.map((option) => (
|
||||
<Segments role="radiogroup" aria-label={renderText(copy.selectLabel)}>
|
||||
{PARTNER_PROGRAM_IDS.map((id) => (
|
||||
<SegmentButton
|
||||
key={option.id}
|
||||
aria-checked={programId === option.id}
|
||||
data-active={programId === option.id}
|
||||
key={id}
|
||||
aria-checked={programId === id}
|
||||
data-active={programId === id}
|
||||
role="radio"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProgramId(option.id);
|
||||
setProgramId(id);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
{renderText(PARTNER_PROGRAM_LABELS[id])}
|
||||
</SegmentButton>
|
||||
))}
|
||||
</Segments>
|
||||
@@ -446,20 +457,16 @@ export function PartnerApplicationModal({
|
||||
<DropdownTrigger
|
||||
aria-expanded={dropdownOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={copy.selectLabel}
|
||||
aria-label={renderText(copy.selectLabel)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDropdownOpen((previous) => !previous);
|
||||
}}
|
||||
>
|
||||
<DropdownTriggerContent>
|
||||
<DropdownLabel>{copy.selectLabel}</DropdownLabel>
|
||||
<DropdownLabel>{renderText(copy.selectLabel)}</DropdownLabel>
|
||||
<DropdownValue>
|
||||
{
|
||||
PARTNER_PROGRAM_OPTIONS.find(
|
||||
(option) => option.id === programId,
|
||||
)?.label
|
||||
}
|
||||
{renderText(PARTNER_PROGRAM_LABELS[programId])}
|
||||
</DropdownValue>
|
||||
</DropdownTriggerContent>
|
||||
<DropdownIconContainer aria-hidden>
|
||||
@@ -468,20 +475,23 @@ export function PartnerApplicationModal({
|
||||
</DropdownTrigger>
|
||||
|
||||
{dropdownOpen && (
|
||||
<DropdownPanel role="listbox" aria-label={copy.selectLabel}>
|
||||
{PARTNER_PROGRAM_OPTIONS.map((option) => (
|
||||
<DropdownPanel
|
||||
role="listbox"
|
||||
aria-label={renderText(copy.selectLabel)}
|
||||
>
|
||||
{PARTNER_PROGRAM_IDS.map((id) => (
|
||||
<DropdownOption
|
||||
key={option.id}
|
||||
aria-selected={programId === option.id}
|
||||
data-selected={programId === option.id}
|
||||
key={id}
|
||||
aria-selected={programId === id}
|
||||
data-selected={programId === id}
|
||||
role="option"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProgramId(option.id);
|
||||
setProgramId(id);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
{renderText(PARTNER_PROGRAM_LABELS[id])}
|
||||
</DropdownOption>
|
||||
))}
|
||||
</DropdownPanel>
|
||||
@@ -494,7 +504,7 @@ export function PartnerApplicationModal({
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
name="name"
|
||||
placeholder={copy.fields.name}
|
||||
placeholder={renderText(copy.fields.name)}
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
@@ -506,7 +516,7 @@ export function PartnerApplicationModal({
|
||||
autoComplete="off"
|
||||
inputMode="email"
|
||||
name="email"
|
||||
placeholder={copy.fields.email}
|
||||
placeholder={renderText(copy.fields.email)}
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
@@ -515,7 +525,7 @@ export function PartnerApplicationModal({
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
name="company"
|
||||
placeholder={copy.fields.company}
|
||||
placeholder={renderText(copy.fields.company)}
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
@@ -526,7 +536,7 @@ export function PartnerApplicationModal({
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
name="website"
|
||||
placeholder={copy.fields.website}
|
||||
placeholder={renderText(copy.fields.website)}
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
@@ -535,7 +545,7 @@ export function PartnerApplicationModal({
|
||||
<Form.Input
|
||||
autoComplete="off"
|
||||
name="opportunities"
|
||||
placeholder={copy.fields.opportunities}
|
||||
placeholder={renderText(copy.fields.opportunities)}
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
@@ -545,7 +555,7 @@ export function PartnerApplicationModal({
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
name="message"
|
||||
placeholder={`${copy.fields.messageLabel}\n\n${copy.fields.messageHint}`}
|
||||
placeholder={`${renderText(copy.fields.messageLabel)}\n\n${renderText(copy.fields.messageHint)}`}
|
||||
/>
|
||||
</Form.Field>
|
||||
|
||||
@@ -564,7 +574,7 @@ export function PartnerApplicationModal({
|
||||
strokeColor="none"
|
||||
/>
|
||||
<SubmitLabel>
|
||||
{isSubmitting ? copy.submitInFlight : copy.submit}
|
||||
{renderText(isSubmitting ? copy.submitInFlight : copy.submit)}
|
||||
</SubmitLabel>
|
||||
</SubmitButton>
|
||||
</Modal.Footer>
|
||||
|
||||
@@ -5,7 +5,8 @@ export {
|
||||
|
||||
export {
|
||||
PARTNER_APPLICATION_MODAL_COPY,
|
||||
PARTNER_PROGRAM_OPTIONS,
|
||||
PARTNER_PROGRAM_IDS,
|
||||
PARTNER_PROGRAM_LABELS,
|
||||
type PartnerProgramId,
|
||||
} from './partner-application-modal-data';
|
||||
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
export type PartnerProgramId = 'technology' | 'content' | 'solutions';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const PARTNER_PROGRAM_OPTIONS: ReadonlyArray<{
|
||||
id: PartnerProgramId;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'technology', label: 'Technology Partner' },
|
||||
{ id: 'content', label: 'Content & Community Partner' },
|
||||
{ id: 'solutions', label: 'Solutions Partner' },
|
||||
];
|
||||
export const PARTNER_PROGRAM_IDS = [
|
||||
'technology',
|
||||
'content',
|
||||
'solutions',
|
||||
] as const;
|
||||
|
||||
export type PartnerProgramId = (typeof PARTNER_PROGRAM_IDS)[number];
|
||||
|
||||
export const PARTNER_PROGRAM_LABELS: Record<
|
||||
PartnerProgramId,
|
||||
MessageDescriptor
|
||||
> = {
|
||||
technology: msg`Technology Partner`,
|
||||
content: msg`Content & Community Partner`,
|
||||
solutions: msg`Solutions Partner`,
|
||||
};
|
||||
|
||||
export const PARTNER_APPLICATION_MODAL_COPY = {
|
||||
titleSerif: 'Apply to build',
|
||||
titleSans: 'the future of CRM',
|
||||
subtitleLine1:
|
||||
'Join our ecosystem and help businesses take control of their customer data with',
|
||||
subtitleLine2: 'open-source primitives.',
|
||||
selectLabel: 'Select your team',
|
||||
titleSerif: msg`Apply to build`,
|
||||
titleSans: msg`the future of CRM`,
|
||||
subtitleLine1: msg`Join our ecosystem and help businesses take control of their customer data with`,
|
||||
subtitleLine2: msg`open-source primitives.`,
|
||||
selectLabel: msg`Select your team`,
|
||||
fields: {
|
||||
name: 'Your name *',
|
||||
email: 'Work email *',
|
||||
company: 'Company or brand *',
|
||||
website: 'Website or github link *',
|
||||
opportunities: 'Estimated monthly opportunities (optional)',
|
||||
messageLabel: 'How do you want to partner with Twenty? *',
|
||||
messageHint:
|
||||
'Tell us about the custom solutions or integrations you plan to build.',
|
||||
name: msg`Your name *`,
|
||||
email: msg`Work email *`,
|
||||
company: msg`Company or brand *`,
|
||||
website: msg`Website or github link *`,
|
||||
opportunities: msg`Estimated monthly opportunities (optional)`,
|
||||
messageLabel: msg`How do you want to partner with Twenty? *`,
|
||||
messageHint: msg`Tell us about the custom solutions or integrations you plan to build.`,
|
||||
},
|
||||
submit: 'Submit application',
|
||||
submitInFlight: 'Submitting…',
|
||||
submit: msg`Submit application`,
|
||||
submitInFlight: msg`Submitting…`,
|
||||
validation: {
|
||||
incompleteForm: 'Please complete all required fields before submitting.',
|
||||
invalidEmail: 'Enter a valid email address.',
|
||||
submitFailed:
|
||||
'We could not submit your application. Please try again in a moment.',
|
||||
incompleteForm: msg`Please complete all required fields before submitting.`,
|
||||
invalidEmail: msg`Enter a valid email address.`,
|
||||
submitFailed: msg`We could not submit your application. Please try again in a moment.`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { loadLocalReleaseNotes } from '@/lib/releases/load-local-release-notes';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
const IMAGE_REGEX_GLOBAL = /!\[[^\]]*\]\(([^)]+)\)/g;
|
||||
const HEADING_REGEX_GLOBAL = /^#\s+(.+)$/gm;
|
||||
@@ -7,8 +9,8 @@ export type LatestReleasePreview = {
|
||||
image: string;
|
||||
imageAlt: string;
|
||||
imageScale?: number;
|
||||
title: string;
|
||||
description: string;
|
||||
title: MessageDescriptor;
|
||||
description: MessageDescriptor;
|
||||
};
|
||||
|
||||
export function getLatestReleasePreview(): LatestReleasePreview | null {
|
||||
@@ -37,8 +39,7 @@ export function getLatestReleasePreview(): LatestReleasePreview | null {
|
||||
image,
|
||||
imageAlt: `Twenty release ${latest.release}${featureTitle ? ` — ${featureTitle}` : ''}`,
|
||||
imageScale: 1.04,
|
||||
title: `See what shipped in ${latest.release}`,
|
||||
description:
|
||||
'Track every release with changelogs, highlights and demos of the newest features.',
|
||||
title: msg`See what shipped in ${latest.release}`,
|
||||
description: msg`Track every release with changelogs, highlights and demos of the newest features.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { buildPageMetadata } from '@/lib/seo';
|
||||
|
||||
const ORIGINAL_SITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL;
|
||||
|
||||
const descriptor = (message: string) => ({ id: message, message });
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL = 'https://example.test';
|
||||
});
|
||||
@@ -15,8 +17,8 @@ describe('buildPageMetadata', () => {
|
||||
const metadata = buildPageMetadata({
|
||||
locale: 'en',
|
||||
path: '/product',
|
||||
title: 'Product | Twenty',
|
||||
description: 'Product page description.',
|
||||
title: descriptor('Product | Twenty'),
|
||||
description: descriptor('Product page description.'),
|
||||
});
|
||||
|
||||
expect(metadata.title).toEqual({ absolute: 'Product | Twenty' });
|
||||
@@ -38,29 +40,55 @@ describe('buildPageMetadata', () => {
|
||||
|
||||
it('falls back to the source canonical for locales the website does not publish', () => {
|
||||
const metadata = buildPageMetadata({
|
||||
locale: 'fr-FR',
|
||||
locale: 'de-DE',
|
||||
path: '/product',
|
||||
title: 't',
|
||||
description: 'd',
|
||||
title: descriptor('t'),
|
||||
description: descriptor('d'),
|
||||
});
|
||||
|
||||
expect(metadata.alternates).toMatchObject({ canonical: '/product' });
|
||||
expect(metadata.openGraph).toMatchObject({ locale: 'en' });
|
||||
});
|
||||
|
||||
it('resolves Lingui message descriptors before emitting SEO fields', () => {
|
||||
const metadata = buildPageMetadata({
|
||||
locale: 'en',
|
||||
path: '/',
|
||||
title: {
|
||||
id: 'seo.test.title',
|
||||
message: 'Twenty | #1 open source CRM',
|
||||
},
|
||||
description: {
|
||||
id: 'seo.test.description',
|
||||
message: 'The #1 open source CRM for modern teams.',
|
||||
},
|
||||
});
|
||||
|
||||
expect(metadata.title).toEqual({
|
||||
absolute: 'Twenty | #1 open source CRM',
|
||||
});
|
||||
expect(metadata.description).toBe(
|
||||
'The #1 open source CRM for modern teams.',
|
||||
);
|
||||
expect(metadata.openGraph).toMatchObject({
|
||||
title: 'Twenty | #1 open source CRM',
|
||||
description: 'The #1 open source CRM for modern teams.',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits hreflang alternates only for published website locales', () => {
|
||||
const metadata = buildPageMetadata({
|
||||
locale: 'fr-FR',
|
||||
path: '/pricing',
|
||||
title: 't',
|
||||
description: 'd',
|
||||
title: descriptor('t'),
|
||||
description: descriptor('d'),
|
||||
});
|
||||
|
||||
const languages = metadata.alternates?.languages as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
expect(languages?.en).toBe('/pricing');
|
||||
expect(languages?.['fr-FR']).toBeUndefined();
|
||||
expect(languages?.['fr-FR']).toBe('/fr-FR/pricing');
|
||||
expect(languages?.['zh-CN']).toBeUndefined();
|
||||
expect(languages?.['x-default']).toBe('/pricing');
|
||||
});
|
||||
@@ -69,8 +97,8 @@ describe('buildPageMetadata', () => {
|
||||
const metadata = buildPageMetadata({
|
||||
locale: 'en',
|
||||
path: '/pricing',
|
||||
title: 't',
|
||||
description: 'd',
|
||||
title: descriptor('t'),
|
||||
description: descriptor('d'),
|
||||
});
|
||||
|
||||
const languages = metadata.alternates?.languages as
|
||||
@@ -83,8 +111,8 @@ describe('buildPageMetadata', () => {
|
||||
const metadata = buildPageMetadata({
|
||||
locale: 'de-DE',
|
||||
path: '/',
|
||||
title: 't',
|
||||
description: 'd',
|
||||
title: descriptor('t'),
|
||||
description: descriptor('d'),
|
||||
});
|
||||
|
||||
expect(metadata.alternates).toMatchObject({ canonical: '/' });
|
||||
@@ -92,6 +120,7 @@ describe('buildPageMetadata', () => {
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
expect(languages?.['de-DE']).toBeUndefined();
|
||||
expect(languages?.['fr-FR']).toBe('/fr-FR');
|
||||
expect(languages?.en).toBe('/');
|
||||
expect(languages?.['x-default']).toBe('/');
|
||||
});
|
||||
@@ -100,8 +129,8 @@ describe('buildPageMetadata', () => {
|
||||
const metadata = buildPageMetadata({
|
||||
locale: 'en',
|
||||
path: '/x',
|
||||
title: 't',
|
||||
description: 'd',
|
||||
title: descriptor('t'),
|
||||
description: descriptor('d'),
|
||||
ogImage: '/og/x.png',
|
||||
});
|
||||
|
||||
@@ -115,8 +144,8 @@ describe('buildPageMetadata', () => {
|
||||
const metadata = buildPageMetadata({
|
||||
locale: 'en',
|
||||
path: '/x',
|
||||
title: 't',
|
||||
description: 'd',
|
||||
title: descriptor('t'),
|
||||
description: descriptor('d'),
|
||||
ogImage: 'https://cdn.example/og.png',
|
||||
});
|
||||
|
||||
@@ -129,8 +158,8 @@ describe('buildPageMetadata', () => {
|
||||
const metadata = buildPageMetadata({
|
||||
locale: 'en',
|
||||
path: '/x',
|
||||
title: 'Default title',
|
||||
description: 'Default description',
|
||||
title: descriptor('Default title'),
|
||||
description: descriptor('Default description'),
|
||||
ogImage: '/og/default.png',
|
||||
extend: {
|
||||
openGraph: { title: 'Override OG title' },
|
||||
@@ -150,8 +179,8 @@ describe('buildPageMetadata', () => {
|
||||
const metadata = buildPageMetadata({
|
||||
locale: 'en',
|
||||
path: '/x',
|
||||
title: 't',
|
||||
description: 'd',
|
||||
title: descriptor('t'),
|
||||
description: descriptor('d'),
|
||||
ogImage: '/og/x.png',
|
||||
extend: {
|
||||
twitter: { card: 'summary' },
|
||||
@@ -171,8 +200,8 @@ describe('buildPageMetadata', () => {
|
||||
const metadata = buildPageMetadata({
|
||||
locale: 'en',
|
||||
path: '/x',
|
||||
title: 't',
|
||||
description: 'd',
|
||||
title: descriptor('t'),
|
||||
description: descriptor('d'),
|
||||
extend: {
|
||||
alternates: { canonical: '/custom-canonical' },
|
||||
},
|
||||
@@ -191,8 +220,8 @@ describe('buildPageMetadata', () => {
|
||||
const metadata = buildPageMetadata({
|
||||
locale: 'en',
|
||||
path: '/x',
|
||||
title: 't',
|
||||
description: 'd',
|
||||
title: descriptor('t'),
|
||||
description: descriptor('d'),
|
||||
extend: {
|
||||
robots: { index: false, follow: false },
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { resolveLocaleParam } from '@/lib/i18n/resolve-locale-param';
|
||||
import { setServerI18n } from '@/lib/i18n/set-server-i18n';
|
||||
|
||||
import {
|
||||
buildPageMetadata,
|
||||
@@ -16,5 +17,6 @@ export const buildLocalizedMetadata =
|
||||
async ({ params }: LocalizedMetadataArgs): Promise<Metadata> => {
|
||||
const { locale: rawLocale } = await params;
|
||||
const locale = resolveLocaleParam(rawLocale);
|
||||
setServerI18n(locale);
|
||||
return buildPageMetadata({ ...input, locale });
|
||||
};
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
PUBLIC_APP_LOCALE_LIST,
|
||||
isPublicAppLocale,
|
||||
} from '@/lib/i18n/app-locale-set';
|
||||
import { createI18nInstance } from '@/lib/i18n/create-i18n-instance';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { getSiteUrl } from './site-url';
|
||||
|
||||
@@ -14,8 +17,8 @@ const TWITTER_HANDLE = '@twentycrm';
|
||||
export type BuildPageMetadataInput = {
|
||||
locale: AppLocale;
|
||||
path: string;
|
||||
title: string;
|
||||
description: string;
|
||||
title: MessageDescriptor;
|
||||
description: MessageDescriptor;
|
||||
ogImage?: string;
|
||||
type?: 'website' | 'article';
|
||||
extend?: Metadata;
|
||||
@@ -55,6 +58,10 @@ export function buildPageMetadata({
|
||||
const normalizedPath = normalizePath(path);
|
||||
const metadataLocale = isPublicAppLocale(locale) ? locale : SOURCE_LOCALE;
|
||||
const canonical = localizePath(metadataLocale, normalizedPath);
|
||||
const i18n = createI18nInstance(metadataLocale);
|
||||
const renderText = createMessageDescriptorRenderer(i18n);
|
||||
const resolvedTitle = renderText(title);
|
||||
const resolvedDescription = renderText(description);
|
||||
|
||||
const ogImages =
|
||||
ogImage === undefined
|
||||
@@ -68,15 +75,15 @@ export function buildPageMetadata({
|
||||
];
|
||||
|
||||
const baseMetadata: Metadata = {
|
||||
title: { absolute: title },
|
||||
description,
|
||||
title: { absolute: resolvedTitle },
|
||||
description: resolvedDescription,
|
||||
alternates: {
|
||||
canonical,
|
||||
languages: buildLanguageAlternates(normalizedPath),
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
title: resolvedTitle,
|
||||
description: resolvedDescription,
|
||||
url: canonical,
|
||||
siteName: SITE_NAME,
|
||||
locale: metadataLocale,
|
||||
@@ -85,8 +92,8 @@ export function buildPageMetadata({
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
title: resolvedTitle,
|
||||
description: resolvedDescription,
|
||||
site: TWITTER_HANDLE,
|
||||
creator: TWITTER_HANDLE,
|
||||
...(ogImages && { images: ogImages.map((image) => image.url) }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import type { WebsiteRoute } from './types';
|
||||
|
||||
@@ -6,7 +7,7 @@ export const CUSTOMER_STORY_ROUTES: readonly WebsiteRoute[] =
|
||||
CASE_STUDY_CATALOG_ENTRIES.map((entry) => ({
|
||||
id: `customer:${entry.href.slice('/customers/'.length)}`,
|
||||
path: entry.href,
|
||||
title: `${entry.hero.author} | Twenty Customer Story`,
|
||||
title: msg`${entry.hero.author} | Twenty Customer Story`,
|
||||
description: entry.catalogCard.summary,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.5,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import type { WebsiteRoute } from './types';
|
||||
|
||||
export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'home',
|
||||
path: '/',
|
||||
title: 'Twenty | #1 open source CRM',
|
||||
description:
|
||||
'The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.',
|
||||
title: msg`Twenty | #1 open source CRM`,
|
||||
description: msg`The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.`,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1,
|
||||
indexed: true,
|
||||
@@ -14,9 +15,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'whyTwenty',
|
||||
path: '/why-twenty',
|
||||
title: 'Why Twenty | Twenty',
|
||||
description:
|
||||
'Most packaged software makes companies more similar. Learn why the future of CRM is built, not bought.',
|
||||
title: msg`Why Twenty | Twenty`,
|
||||
description: msg`Most packaged software makes companies more similar. Learn why the future of CRM is built, not bought.`,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
indexed: true,
|
||||
@@ -24,9 +24,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'product',
|
||||
path: '/product',
|
||||
title: 'Product | Twenty',
|
||||
description:
|
||||
'Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.',
|
||||
title: msg`Product | Twenty`,
|
||||
description: msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
indexed: true,
|
||||
@@ -34,9 +33,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'pricing',
|
||||
path: '/pricing',
|
||||
title: 'Pricing | Twenty',
|
||||
description:
|
||||
'Plans that scale with your team. Compare tiers of the #1 open source CRM.',
|
||||
title: msg`Pricing | Twenty`,
|
||||
description: msg`Plans that scale with your team. Compare tiers of the #1 open source CRM.`,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.9,
|
||||
indexed: true,
|
||||
@@ -44,9 +42,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'partners',
|
||||
path: '/partners',
|
||||
title: 'Partners | Twenty',
|
||||
description:
|
||||
'Join our partner ecosystem and grow with us as we build the #1 open source CRM.',
|
||||
title: msg`Partners | Twenty`,
|
||||
description: msg`Join our partner ecosystem and grow with us as we build the #1 open source CRM.`,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
indexed: true,
|
||||
@@ -54,9 +51,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'releases',
|
||||
path: '/releases',
|
||||
title: 'Releases | Twenty',
|
||||
description:
|
||||
'Discover the newest features and improvements in Twenty, the #1 open source CRM.',
|
||||
title: msg`Releases | Twenty`,
|
||||
description: msg`Discover the newest features and improvements in Twenty, the #1 open source CRM.`,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.7,
|
||||
indexed: true,
|
||||
@@ -64,9 +60,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'customers',
|
||||
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.',
|
||||
title: msg`Customers | Twenty`,
|
||||
description: msg`Meet the teams running their business on Twenty. Real customer stories on how they shaped the CRM to fit their workflow.`,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
indexed: true,
|
||||
@@ -74,9 +69,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'privacyPolicy',
|
||||
path: '/privacy-policy',
|
||||
title: 'Privacy Policy | Twenty',
|
||||
description:
|
||||
'How Twenty collects, uses, safeguards, and discloses information when you use Twenty.com and related services.',
|
||||
title: msg`Privacy Policy | Twenty`,
|
||||
description: msg`How Twenty collects, uses, safeguards, and discloses information when you use Twenty.com and related services.`,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
indexed: true,
|
||||
@@ -84,9 +78,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'terms',
|
||||
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.',
|
||||
title: msg`Terms of Service | Twenty`,
|
||||
description: msg`Terms of Service for Twenty.com PBC, including use of Twenty.com, sub-domains, and related services.`,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
indexed: true,
|
||||
@@ -94,8 +87,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'halftone',
|
||||
path: '/halftone',
|
||||
title: 'Halftone Generator | Twenty',
|
||||
description: 'Interactive halftone generator exported from Twenty.',
|
||||
title: msg`Halftone Generator | Twenty`,
|
||||
description: msg`Interactive halftone generator exported from Twenty.`,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0,
|
||||
indexed: false,
|
||||
@@ -104,9 +97,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'enterpriseActivate',
|
||||
path: '/enterprise/activate',
|
||||
title: 'Enterprise activation | Twenty',
|
||||
description:
|
||||
'Complete activation for your Twenty self-hosted enterprise license.',
|
||||
title: msg`Enterprise activation | Twenty`,
|
||||
description: msg`Complete activation for your Twenty self-hosted enterprise license.`,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0,
|
||||
indexed: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
|
||||
export type WebsiteRouteId =
|
||||
| 'home'
|
||||
| 'whyTwenty'
|
||||
@@ -16,11 +18,11 @@ export type WebsiteRouteId =
|
||||
|
||||
export type WebsiteRoute = {
|
||||
changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'];
|
||||
description: string;
|
||||
description: MessageDescriptor;
|
||||
id: WebsiteRouteId;
|
||||
indexed: boolean;
|
||||
path: string;
|
||||
priority: number;
|
||||
robotsDisallow?: boolean;
|
||||
title: string;
|
||||
title: MessageDescriptor;
|
||||
};
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-04-27 14:31+0500\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: af-ZA\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
@@ -1,14 +0,0 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-04-27 14:31+0500\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: ar-SA\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
@@ -1,14 +0,0 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-04-27 14:31+0500\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: ca-ES\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user