diff --git a/packages/twenty-website-new/src/__tests__/proxy.test.ts b/packages/twenty-website-new/src/__tests__/proxy.test.ts new file mode 100644 index 0000000000..ecb3dae1f3 --- /dev/null +++ b/packages/twenty-website-new/src/__tests__/proxy.test.ts @@ -0,0 +1,137 @@ +import { NextRequest } from 'next/server'; + +import { proxy } from '@/proxy'; + +const SITE_ORIGIN = 'https://example.test'; + +type Cookies = Record; +type Headers = Record; + +const buildRequest = ( + pathname: string, + { cookies = {}, headers = {} }: { cookies?: Cookies; headers?: Headers } = {}, +): NextRequest => { + const url = new URL(pathname, SITE_ORIGIN); + const cookieHeader = Object.entries(cookies) + .map(([key, value]) => `${key}=${value}`) + .join('; '); + const allHeaders = new Headers(headers); + if (cookieHeader.length > 0) { + allHeaders.set('cookie', cookieHeader); + } + return new NextRequest(url, { headers: allHeaders }); +}; + +const getSetCookie = (response: Response): string | null => + response.headers.get('set-cookie'); + +describe('proxy: locale routing', () => { + describe('canonicalisation of explicit prefixes', () => { + it('301s /en/foo to /foo so the source locale never appears in URLs', () => { + const response = proxy(buildRequest('/en/pricing')); + + expect(response.status).toBe(301); + expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/pricing`); + }); + + it('301s the bare /en root to /', () => { + const response = proxy(buildRequest('/en')); + + expect(response.status).toBe(301); + expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/`); + }); + + it('preserves the query string on /en canonicalisation', () => { + const response = proxy(buildRequest('/en/pricing?utm_source=newsletter')); + + expect(response.status).toBe(301); + expect(response.headers.get('location')).toBe( + `${SITE_ORIGIN}/pricing?utm_source=newsletter`, + ); + }); + + it('308s an unsupported but recognised locale prefix down to the canonical path', () => { + const response = proxy(buildRequest('/de-DE/pricing')); + + expect(response.status).toBe(308); + expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/pricing`); + }); + + it('308s the legacy fr-FR URL form to bare path (the public segment is /fr now)', () => { + const response = proxy(buildRequest('/fr-FR/pricing')); + + expect(response.status).toBe(308); + expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/pricing`); + }); + }); + + describe('canonical published-locale segments render directly', () => { + it('renders /fr/foo without any redirect or rewrite', () => { + const response = proxy(buildRequest('/fr/pricing')); + + expect(response.status).toBe(200); + expect(response.headers.get('location')).toBeNull(); + expect(response.headers.get('x-middleware-rewrite')).toBeNull(); + }); + + it('does not write a NEXT_LOCALE cookie on locale-prefixed visits', () => { + const response = proxy(buildRequest('/fr/pricing')); + + expect(getSetCookie(response)).toBeNull(); + }); + }); + + describe('bare URLs always render the source locale', () => { + it('rewrites /pricing to the internal /en/pricing for anonymous visitors', () => { + const response = proxy(buildRequest('/pricing')); + + expect(response.status).toBe(200); + expect(response.headers.get('x-middleware-rewrite')).toBe( + `${SITE_ORIGIN}/en/pricing`, + ); + }); + + it('ignores Accept-Language entirely — French browsers still see English at /pricing', () => { + const response = proxy( + buildRequest('/pricing', { + headers: { 'accept-language': 'fr-FR,fr;q=0.9' }, + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get('x-middleware-rewrite')).toBe( + `${SITE_ORIGIN}/en/pricing`, + ); + expect(response.headers.get('location')).toBeNull(); + }); + + it('ignores stored NEXT_LOCALE cookie — bare URLs are never cookie-redirected', () => { + const response = proxy( + buildRequest('/pricing', { cookies: { NEXT_LOCALE: 'fr-FR' } }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get('x-middleware-rewrite')).toBe( + `${SITE_ORIGIN}/en/pricing`, + ); + expect(response.headers.get('location')).toBeNull(); + }); + + it('rewrites the bare root / to the internal /en path', () => { + const response = proxy(buildRequest('/')); + + expect(response.status).toBe(200); + expect(response.headers.get('x-middleware-rewrite')).toBe( + `${SITE_ORIGIN}/en`, + ); + }); + + it('preserves query strings when rewriting bare URLs', () => { + const response = proxy(buildRequest('/pricing?utm_source=newsletter')); + + expect(response.headers.get('x-middleware-rewrite')).toBe( + `${SITE_ORIGIN}/en/pricing?utm_source=newsletter`, + ); + }); + }); +}); diff --git a/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx b/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx index 69db981d22..1d9eab4cbd 100644 --- a/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx +++ b/packages/twenty-website-new/src/app/[locale]/(home)/page.tsx @@ -33,7 +33,7 @@ import { Menu } from '@/sections/Menu/components'; import { Problem } from '@/sections/Problem/components'; import { Testimonials } from '@/sections/Testimonials/components'; import { ThreeCards } from '@/sections/ThreeCards/components'; -import { buildRouteMetadata } from '@/lib/seo'; +import { buildFaqPageJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo'; import { TrustedBy } from '@/sections/TrustedBy/components'; import { theme } from '@/theme'; import { css } from '@linaria/core'; @@ -111,6 +111,7 @@ export default async function HomePage({ params }: HomePageProps) { return ( <> + {/* * Above-the-fold home hero background texture. Preload warms the * HTTP cache so it is ready by the time HomeBackgroundHalftone diff --git a/packages/twenty-website-new/src/app/[locale]/layout.tsx b/packages/twenty-website-new/src/app/[locale]/layout.tsx index 016dbfc9bf..54d9313bec 100644 --- a/packages/twenty-website-new/src/app/[locale]/layout.tsx +++ b/packages/twenty-website-new/src/app/[locale]/layout.tsx @@ -1,4 +1,9 @@ -import { getSiteUrl } from '@/lib/seo'; +import { + buildOrganizationJsonLd, + buildSoftwareApplicationJsonLd, + getSiteUrl, + JsonLd, +} from '@/lib/seo'; import { DRACO_DECODER_ORIGIN } from '@/lib/visual-runtime/draco-decoder-path'; import { theme } from '@/theme'; import { cssVariables } from '@/theme/css-variables'; @@ -15,6 +20,7 @@ import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descr import { I18nProvider, PUBLIC_APP_LOCALE_LIST, + localeToUrlSegment, resolveLocaleParam, } from '@/lib/i18n'; import { getLocaleMessages } from '@/lib/i18n/messages-by-locale'; @@ -82,9 +88,9 @@ const StyledMain = styled.main` flex-grow: 1; `; -const SITE_TITLE = 'Twenty | #1 open source CRM'; +const SITE_TITLE = 'Twenty | #1 Open Source CRM'; const SITE_DESCRIPTION = - 'The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.'; + 'The #1 Open Source CRM for modern teams. Modular, scalable, and built to fit your business.'; export const metadata: Metadata = { metadataBase: new URL(getSiteUrl()), @@ -115,7 +121,9 @@ type LocaleLayoutParams = { locale: string }; export const dynamicParams = false; export const generateStaticParams = (): LocaleLayoutParams[] => - PUBLIC_APP_LOCALE_LIST.map((locale) => ({ locale })); + PUBLIC_APP_LOCALE_LIST.map((locale) => ({ + locale: localeToUrlSegment(locale), + })); const LocaleLayout = async ({ children, @@ -138,6 +146,9 @@ const LocaleLayout = async ({ href={DRACO_DECODER_ORIGIN} rel="preconnect" /> + + + {visibleNotes.length > 0 ? ( + + ) : null} {/* * Above-the-fold milestone scene texture. Preload kicks off the * fetch in parallel with the JS chunk download. diff --git a/packages/twenty-website-new/src/app/__tests__/sitemap.test.ts b/packages/twenty-website-new/src/app/__tests__/sitemap.test.ts index 168b767c37..ad810e70e1 100644 --- a/packages/twenty-website-new/src/app/__tests__/sitemap.test.ts +++ b/packages/twenty-website-new/src/app/__tests__/sitemap.test.ts @@ -9,21 +9,22 @@ describe('sitemap', () => { expect(pathnames).toContain('/'); expect(pathnames).toContain('/product'); - expect(pathnames).toContain('/fr-FR/product'); + expect(pathnames).toContain('/fr/product'); + expect(pathnames).not.toContain('/fr-FR/product'); expect(pathnames).toContain('/customers/9dots'); expect(pathnames).not.toContain('/de-DE/product'); expect(pathnames).not.toContain('/halftone'); expect(pathnames).not.toContain('/enterprise/activate'); }); - it('emits hreflang alternates only for published website locales', () => { + it('emits hreflang alternates with AppLocale keys but URL-segment paths', () => { const productEntry = sitemap().find( (entry) => pathnameOf(entry.url) === '/product', ); expect(productEntry?.alternates?.languages).toMatchObject({ en: expect.stringMatching(/\/product$/), - 'fr-FR': expect.stringMatching(/\/fr-FR\/product$/), + 'fr-FR': expect.stringMatching(/\/fr\/product$/), 'x-default': expect.stringMatching(/\/product$/), }); expect(productEntry?.alternates?.languages).not.toHaveProperty('de-DE'); diff --git a/packages/twenty-website-new/src/app/sitemap.ts b/packages/twenty-website-new/src/app/sitemap.ts index 480387761d..82f54bd863 100644 --- a/packages/twenty-website-new/src/app/sitemap.ts +++ b/packages/twenty-website-new/src/app/sitemap.ts @@ -1,14 +1,15 @@ import type { MetadataRoute } from 'next'; import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; -import { PUBLIC_APP_LOCALE_LIST } from '@/lib/i18n'; +import { PUBLIC_APP_LOCALE_LIST, localeToUrlSegment } from '@/lib/i18n'; import { getSiteUrl } from '@/lib/seo'; import { getIndexedWebsiteRoutes } from '@/lib/website-routing'; const SITE_URL = getSiteUrl(); const buildLocalizedUrl = (locale: AppLocale, path: string): string => { - const prefix = locale === SOURCE_LOCALE ? '' : `/${locale}`; + const prefix = + locale === SOURCE_LOCALE ? '' : `/${localeToUrlSegment(locale)}`; const tail = path === '/' ? '' : path; return `${SITE_URL}${prefix}${tail}`; }; diff --git a/packages/twenty-website-new/src/design-system/components/Heading.tsx b/packages/twenty-website-new/src/design-system/components/Heading.tsx index 29b5177a12..8bf91aa9de 100644 --- a/packages/twenty-website-new/src/design-system/components/Heading.tsx +++ b/packages/twenty-website-new/src/design-system/components/Heading.tsx @@ -146,7 +146,7 @@ export type HeadingProps = { } & HeadingTextRenderer; export function Heading({ - as: Tag = 'h1', + as: Tag = 'h2', children, inlineSegmentSeparator = ' ', renderText, diff --git a/packages/twenty-website-new/src/design-system/components/__tests__/Heading.test.tsx b/packages/twenty-website-new/src/design-system/components/__tests__/Heading.test.tsx new file mode 100644 index 0000000000..03f4f02313 --- /dev/null +++ b/packages/twenty-website-new/src/design-system/components/__tests__/Heading.test.tsx @@ -0,0 +1,19 @@ +import { renderToStaticMarkup } from 'react-dom/server'; + +import { Heading } from '@/design-system/components'; + +describe('design-system Heading defaults', () => { + it('renders

by default', () => { + const html = renderToStaticMarkup(Section title); + expect(html).toMatch(/^]*>.*Section title.*<\/h2>$/s); + }); + + it.each(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const)( + 'honours an explicit as=%s override', + (tag) => { + const html = renderToStaticMarkup(Override); + expect(html).toContain(`<${tag}`); + expect(html).toContain(``); + }, + ); +}); diff --git a/packages/twenty-website-new/src/lib/i18n/__tests__/locale-display-names.test.ts b/packages/twenty-website-new/src/lib/i18n/__tests__/locale-display-names.test.ts new file mode 100644 index 0000000000..fb82bb0612 --- /dev/null +++ b/packages/twenty-website-new/src/lib/i18n/__tests__/locale-display-names.test.ts @@ -0,0 +1,24 @@ +import { + getEnglishLocaleName, + getNativeLocaleName, +} from '../locale-display-names'; + +describe('getNativeLocaleName', () => { + it('returns the language name in its own language with the first character upper-cased', () => { + expect(getNativeLocaleName('en')).toBe('English'); + expect(getNativeLocaleName('fr-FR')).toBe('Français'); + }); + + it('does not break on scripts that have no concept of letter case', () => { + const value = getNativeLocaleName('ja-JP'); + expect(value).not.toBe(''); + expect(value).not.toContain('('); + }); +}); + +describe('getEnglishLocaleName', () => { + it('returns the English name for the language regardless of the input locale', () => { + expect(getEnglishLocaleName('en')).toBe('English'); + expect(getEnglishLocaleName('fr-FR')).toBe('French'); + }); +}); diff --git a/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts b/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts index a47cea35aa..34e916b19c 100644 --- a/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts +++ b/packages/twenty-website-new/src/lib/i18n/__tests__/localize-href.test.ts @@ -5,9 +5,14 @@ describe('localizeHref', () => { 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('emits the URL-segment prefix (not the AppLocale) for published non-default locales', () => { + expect(localizeHref('fr-FR', '/pricing')).toBe('/fr/pricing'); + expect(localizeHref('fr-FR', '/')).toBe('/fr'); + }); + + it('strips the canonical /fr URL segment when re-localising to the default locale', () => { + expect(localizeHref('en', '/fr/pricing')).toBe('/pricing'); + expect(localizeHref('en', '/fr')).toBe('/'); }); it('returns paths unprefixed for the default locale (English at root)', () => { @@ -28,11 +33,6 @@ describe('localizeHref', () => { ); }); - it('strips legacy locale prefixes when targeting an unpublished locale', () => { - 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', () => { expect(localizeHref('en', '/en/why-twenty')).toBe('/why-twenty'); expect(localizeHref('en', '/en')).toBe('/'); @@ -68,33 +68,33 @@ describe('localizeHref', () => { expect(localizeHref('en', '../parent')).toBe('../parent'); }); - it('handles a locale segment immediately followed by a query string', () => { + it('handles a published locale segment immediately followed by a query string', () => { expect(localizeHref('en', '/en?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'); + expect(localizeHref('en', '/fr?ref=hero')).toBe('/?ref=hero'); }); - it('handles a locale segment immediately followed by a hash fragment', () => { + it('handles a published locale segment immediately followed by a hash fragment', () => { expect(localizeHref('en', '/en#anchor')).toBe('/#anchor'); - expect(localizeHref('de-DE', '/en#anchor')).toBe('/#anchor'); - expect(localizeHref('de-DE', '/fr-FR#anchor')).toBe('/#anchor'); + expect(localizeHref('en', '/fr#anchor')).toBe('/#anchor'); }); }); describe('stripLocale', () => { - it('removes a known locale prefix from the pathname', () => { + it('removes a published-locale URL-segment prefix from the pathname', () => { expect(stripLocale('/en/why-twenty')).toBe('/why-twenty'); - expect(stripLocale('/fr-FR/customers/9dots')).toBe('/customers/9dots'); + expect(stripLocale('/fr/customers/9dots')).toBe('/customers/9dots'); }); it('returns the root path when the pathname is just the locale segment', () => { expect(stripLocale('/en')).toBe('/'); - expect(stripLocale('/zh-CN')).toBe('/'); + expect(stripLocale('/fr')).toBe('/'); }); - it('returns the pathname unchanged when no known locale prefix is present', () => { + it('returns the pathname unchanged when no published locale prefix is present', () => { expect(stripLocale('/why-twenty')).toBe('/why-twenty'); expect(stripLocale('/')).toBe('/'); + expect(stripLocale('/fr-FR/foo')).toBe('/fr-FR/foo'); + expect(stripLocale('/zh-CN')).toBe('/zh-CN'); }); it('returns the pathname unchanged when the input does not start with a slash', () => { @@ -104,8 +104,8 @@ describe('stripLocale', () => { it('preserves query and hash when the locale segment is immediately followed by them', () => { expect(stripLocale('/en?ref=hero')).toBe('/?ref=hero'); - expect(stripLocale('/fr-FR#anchor')).toBe('/#anchor'); - expect(stripLocale('/fr-FR/customers?ref=hero#top')).toBe( + expect(stripLocale('/fr#anchor')).toBe('/#anchor'); + expect(stripLocale('/fr/customers?ref=hero#top')).toBe( '/customers?ref=hero#top', ); }); diff --git a/packages/twenty-website-new/src/lib/i18n/index.ts b/packages/twenty-website-new/src/lib/i18n/index.ts index 355f93fca2..b614310740 100644 --- a/packages/twenty-website-new/src/lib/i18n/index.ts +++ b/packages/twenty-website-new/src/lib/i18n/index.ts @@ -17,7 +17,15 @@ export { LocaleContext } from './LocaleContext'; export { LocalizedLink } from './LocalizedLink'; export { LocalizedLinkButton } from './LocalizedLinkButton'; export { localizeHref, stripLocale } from './localize-href'; +export { + getEnglishLocaleName, + getNativeLocaleName, +} from './locale-display-names'; export { resolveLocaleParam } from './resolve-locale-param'; +export { + LOCALE_BY_URL_SEGMENT, + localeToUrlSegment, +} from './website-locale-segments'; export { useLocale } from './use-locale'; export { useRenderMessage } from './use-render-message'; export { useUnlocalizedPathname } from './use-unlocalized-pathname'; diff --git a/packages/twenty-website-new/src/lib/i18n/locale-display-names.ts b/packages/twenty-website-new/src/lib/i18n/locale-display-names.ts new file mode 100644 index 0000000000..6eca3ddfb8 --- /dev/null +++ b/packages/twenty-website-new/src/lib/i18n/locale-display-names.ts @@ -0,0 +1,19 @@ +import { type AppLocale } from 'twenty-shared/translations'; + +const languageCode = (locale: AppLocale): string => locale.split('-')[0]; + +const capitalizeFirstChar = (value: string): string => + value.length === 0 + ? value + : value.charAt(0).toLocaleUpperCase() + value.slice(1); + +export const getNativeLocaleName = (locale: AppLocale): string => { + const display = new Intl.DisplayNames([locale], { type: 'language' }); + const name = display.of(languageCode(locale)) ?? locale; + return capitalizeFirstChar(name); +}; + +export const getEnglishLocaleName = (locale: AppLocale): string => { + const display = new Intl.DisplayNames(['en'], { type: 'language' }); + return display.of(languageCode(locale)) ?? locale; +}; diff --git a/packages/twenty-website-new/src/lib/i18n/localize-href.ts b/packages/twenty-website-new/src/lib/i18n/localize-href.ts index 42365fd6f9..faf62f3a1c 100644 --- a/packages/twenty-website-new/src/lib/i18n/localize-href.ts +++ b/packages/twenty-website-new/src/lib/i18n/localize-href.ts @@ -1,9 +1,10 @@ import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; +import { isPublicAppLocale } from './app-locale-set'; import { - KNOWN_PUBLIC_APP_LOCALE_BY_RAW, - isPublicAppLocale, -} from './app-locale-set'; + LOCALE_BY_URL_SEGMENT, + localeToUrlSegment, +} from './website-locale-segments'; const findFirstSegmentEnd = (path: string): number => { for (let i = 1; i < path.length; i += 1) { @@ -20,23 +21,25 @@ const buildTailFromSegmentEnd = (path: string, segmentEnd: number): string => { return tail; }; +const isLocalePrefixSegment = (segment: string): boolean => + LOCALE_BY_URL_SEGMENT.has(segment); + export const localizeHref = (locale: AppLocale, href: string): string => { if (!href.startsWith('/') || href.startsWith('//')) return href; const segmentEnd = findFirstSegmentEnd(href); const firstSegment = href.slice(1, segmentEnd); - const existingLocale = KNOWN_PUBLIC_APP_LOCALE_BY_RAW.get(firstSegment); - const unprefixed = - existingLocale !== undefined - ? buildTailFromSegmentEnd(href, segmentEnd) - : href; + const unprefixed = isLocalePrefixSegment(firstSegment) + ? buildTailFromSegmentEnd(href, segmentEnd) + : href; if (locale === SOURCE_LOCALE || !isPublicAppLocale(locale)) { return unprefixed; } - return unprefixed === '/' ? `/${locale}` : `/${locale}${unprefixed}`; + const segment = localeToUrlSegment(locale); + return unprefixed === '/' ? `/${segment}` : `/${segment}${unprefixed}`; }; export const stripLocale = (pathname: string): string => { @@ -44,7 +47,7 @@ export const stripLocale = (pathname: string): string => { const segmentEnd = findFirstSegmentEnd(pathname); const firstSegment = pathname.slice(1, segmentEnd); - if (!KNOWN_PUBLIC_APP_LOCALE_BY_RAW.has(firstSegment)) return pathname; + if (!isLocalePrefixSegment(firstSegment)) return pathname; return buildTailFromSegmentEnd(pathname, segmentEnd); }; diff --git a/packages/twenty-website-new/src/lib/i18n/resolve-locale-param.ts b/packages/twenty-website-new/src/lib/i18n/resolve-locale-param.ts index b348fa4f04..5fcb0fde56 100644 --- a/packages/twenty-website-new/src/lib/i18n/resolve-locale-param.ts +++ b/packages/twenty-website-new/src/lib/i18n/resolve-locale-param.ts @@ -1,10 +1,10 @@ import { notFound } from 'next/navigation'; import { type AppLocale } from 'twenty-shared/translations'; -import { APP_LOCALE_BY_RAW } from './app-locale-set'; +import { LOCALE_BY_URL_SEGMENT } from './website-locale-segments'; export const resolveLocaleParam = (raw: string): AppLocale => { - const locale = APP_LOCALE_BY_RAW.get(raw); + const locale = LOCALE_BY_URL_SEGMENT.get(raw); if (locale === undefined) notFound(); return locale; }; diff --git a/packages/twenty-website-new/src/lib/i18n/website-locale-segments.ts b/packages/twenty-website-new/src/lib/i18n/website-locale-segments.ts new file mode 100644 index 0000000000..b3bad52ded --- /dev/null +++ b/packages/twenty-website-new/src/lib/i18n/website-locale-segments.ts @@ -0,0 +1,14 @@ +import { type AppLocale } from 'twenty-shared/translations'; + +import { WEBSITE_LOCALE_LIST } from './website-locale-list'; + +const URL_SEGMENT_OVERRIDES: Partial> = { + 'fr-FR': 'fr', +}; + +export const localeToUrlSegment = (locale: AppLocale): string => + URL_SEGMENT_OVERRIDES[locale] ?? locale; + +export const LOCALE_BY_URL_SEGMENT: ReadonlyMap = new Map( + WEBSITE_LOCALE_LIST.map((locale) => [localeToUrlSegment(locale), locale]), +); diff --git a/packages/twenty-website-new/src/lib/seo/__tests__/build-page-metadata.test.ts b/packages/twenty-website-new/src/lib/seo/__tests__/build-page-metadata.test.ts index c3149241b6..e04780519f 100644 --- a/packages/twenty-website-new/src/lib/seo/__tests__/build-page-metadata.test.ts +++ b/packages/twenty-website-new/src/lib/seo/__tests__/build-page-metadata.test.ts @@ -56,23 +56,23 @@ describe('buildPageMetadata', () => { path: '/', title: { id: 'seo.test.title', - message: 'Twenty | #1 open source CRM', + message: 'Twenty | #1 Open Source CRM', }, description: { id: 'seo.test.description', - message: 'The #1 open source CRM for modern teams.', + message: 'The #1 Open Source CRM for modern teams.', }, }); expect(metadata.title).toEqual({ - absolute: 'Twenty | #1 open source CRM', + absolute: 'Twenty | #1 Open Source CRM', }); expect(metadata.description).toBe( - 'The #1 open source CRM for modern teams.', + '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.', + title: 'Twenty | #1 Open Source CRM', + description: 'The #1 Open Source CRM for modern teams.', }); }); @@ -88,7 +88,7 @@ describe('buildPageMetadata', () => { | Record | undefined; expect(languages?.en).toBe('/pricing'); - expect(languages?.['fr-FR']).toBe('/fr-FR/pricing'); + expect(languages?.['fr-FR']).toBe('/fr/pricing'); expect(languages?.['zh-CN']).toBeUndefined(); expect(languages?.['x-default']).toBe('/pricing'); }); @@ -120,7 +120,7 @@ describe('buildPageMetadata', () => { | Record | undefined; expect(languages?.['de-DE']).toBeUndefined(); - expect(languages?.['fr-FR']).toBe('/fr-FR'); + expect(languages?.['fr-FR']).toBe('/fr'); expect(languages?.en).toBe('/'); expect(languages?.['x-default']).toBe('/'); }); diff --git a/packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx b/packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx new file mode 100644 index 0000000000..018af62ba6 --- /dev/null +++ b/packages/twenty-website-new/src/lib/seo/__tests__/json-ld.test.tsx @@ -0,0 +1,168 @@ +import { + buildFaqPageJsonLd, + buildOrganizationJsonLd, + buildReleaseListJsonLd, + buildSoftwareApplicationJsonLd, +} 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'; +}); + +afterAll(() => { + process.env.NEXT_PUBLIC_WEBSITE_URL = ORIGINAL_SITE_URL; +}); + +describe('buildOrganizationJsonLd', () => { + it('emits the canonical Organization shape with the configured site URL', () => { + const data = buildOrganizationJsonLd(); + + expect(data).toMatchObject({ + '@context': 'https://schema.org', + '@type': 'Organization', + name: 'Twenty', + url: 'https://example.test', + logo: 'https://example.test/images/core/logo.svg', + }); + }); +}); + +describe('buildSoftwareApplicationJsonLd', () => { + it('declares Twenty as a CRM SoftwareApplication with the pricing page as its Offer URL', () => { + const data = buildSoftwareApplicationJsonLd() as Record; + + expect(data['@type']).toBe('SoftwareApplication'); + expect(data['applicationCategory']).toBe('BusinessApplication'); + expect(data['applicationSubCategory']).toBe( + 'Customer Relationship Management', + ); + expect(data['offers']).toMatchObject({ + url: 'https://example.test/pricing', + }); + }); +}); + +describe('buildFaqPageJsonLd', () => { + const renderText = (d: { message?: string; id: string }) => d.message ?? d.id; + + it('produces a FAQPage with one Question per input, each carrying an Answer', () => { + const questions = [ + { + question: { fontFamily: 'sans' as const, text: descriptor('Q1?') }, + answer: { text: descriptor('A1.') }, + }, + { + question: { fontFamily: 'sans' as const, text: descriptor('Q2?') }, + answer: { text: descriptor('A2.') }, + }, + ]; + + const data = buildFaqPageJsonLd(questions, renderText) as { + '@type': string; + mainEntity: Array>; + }; + + expect(data['@type']).toBe('FAQPage'); + expect(data.mainEntity).toHaveLength(2); + expect(data.mainEntity[0]).toMatchObject({ + '@type': 'Question', + name: 'Q1?', + acceptedAnswer: { '@type': 'Answer', text: 'A1.' }, + }); + expect(data.mainEntity[1]).toMatchObject({ + name: 'Q2?', + acceptedAnswer: { text: 'A2.' }, + }); + }); + + it('renders descriptors through the supplied renderText, not the descriptor id', () => { + const data = buildFaqPageJsonLd( + [ + { + question: { + text: { id: 'faq.q', message: 'Resolved question' }, + }, + answer: { text: { id: 'faq.a', message: 'Resolved answer' } }, + }, + ], + renderText, + ) as { mainEntity: Array> }; + + expect(data.mainEntity[0]).toMatchObject({ + name: 'Resolved question', + acceptedAnswer: { text: 'Resolved answer' }, + }); + }); +}); + +describe('buildReleaseListJsonLd', () => { + it('builds a descending ItemList of TechArticles, anchored to /releases#', () => { + const notes = [ + { + slug: '1.18.0', + release: '1.18.0', + date: '2026-04-01', + content: '# Highlight one\n\nBody text\n\n# Highlight two\n', + }, + { + slug: '1.17.0', + release: '1.17.0', + date: '2026-03-15', + content: '## Smaller heading\n\n# Real headline\n', + }, + ]; + + const data = buildReleaseListJsonLd(notes) as { + '@type': string; + numberOfItems: number; + itemListElement: Array>; + }; + + expect(data['@type']).toBe('ItemList'); + expect(data.numberOfItems).toBe(2); + expect(data.itemListElement[0]).toMatchObject({ + '@type': 'ListItem', + position: 1, + url: 'https://example.test/releases#1.18.0', + item: { + '@type': 'TechArticle', + '@id': 'https://example.test/releases#1.18.0', + headline: 'Highlight one', + name: 'Twenty 1.18.0', + datePublished: '2026-04-01', + }, + }); + }); + + it('falls back to "Twenty " as the headline when the body has no h1', () => { + const data = buildReleaseListJsonLd([ + { + slug: '0.1.0', + release: '0.1.0', + date: '2025-01-01', + content: 'Just paragraphs, no headings.\n', + }, + ]) as { itemListElement: Array> }; + + expect(data.itemListElement[0].item.headline).toBe('Twenty 0.1.0'); + }); + + it('omits datePublished when the frontmatter date is missing', () => { + const data = buildReleaseListJsonLd([ + { + slug: '0.2.0', + release: '0.2.0', + date: '', + content: '# Headline\n', + }, + ]) as { + itemListElement: Array>>; + }; + + expect(data.itemListElement[0].item).not.toHaveProperty('datePublished'); + }); +}); diff --git a/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts b/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts index 4cb9416fcd..2a4f142b69 100644 --- a/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts +++ b/packages/twenty-website-new/src/lib/seo/build-page-metadata.ts @@ -7,6 +7,7 @@ import { } from '@/lib/i18n/app-locale-set'; import { createI18nInstance } from '@/lib/i18n/create-i18n-instance'; import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer'; +import { localeToUrlSegment } from '@/lib/i18n/website-locale-segments'; import type { MessageDescriptor } from '@lingui/core'; import { getSiteUrl } from './site-url'; @@ -31,7 +32,10 @@ const localizePath = (locale: AppLocale, normalizedPath: string): string => { if (locale === SOURCE_LOCALE || !isPublicAppLocale(locale)) { return normalizedPath; } - return normalizedPath === '/' ? `/${locale}` : `/${locale}${normalizedPath}`; + const segment = localeToUrlSegment(locale); + return normalizedPath === '/' + ? `/${segment}` + : `/${segment}${normalizedPath}`; }; const buildLanguageAlternates = ( diff --git a/packages/twenty-website-new/src/lib/seo/index.ts b/packages/twenty-website-new/src/lib/seo/index.ts index 232200f171..d88747d635 100644 --- a/packages/twenty-website-new/src/lib/seo/index.ts +++ b/packages/twenty-website-new/src/lib/seo/index.ts @@ -4,4 +4,11 @@ export { type BuildPageMetadataInput, } from './build-page-metadata'; export { buildRouteMetadata } from './build-route-metadata'; +export { + buildFaqPageJsonLd, + buildOrganizationJsonLd, + buildReleaseListJsonLd, + buildSoftwareApplicationJsonLd, + JsonLd, +} from './json-ld'; export { getAbsoluteUrl, getSiteUrl } from './site-url'; diff --git a/packages/twenty-website-new/src/lib/seo/json-ld.tsx b/packages/twenty-website-new/src/lib/seo/json-ld.tsx new file mode 100644 index 0000000000..3e61cd2c5e --- /dev/null +++ b/packages/twenty-website-new/src/lib/seo/json-ld.tsx @@ -0,0 +1,137 @@ +import type { MessageDescriptor } from '@lingui/core'; + +import type { LocalReleaseNote } from '@/lib/releases/types'; + +import { getSiteUrl } from './site-url'; + +type FaqEntryLike = { + question: { text: MessageDescriptor }; + answer: { text: MessageDescriptor }; +}; + +type JsonLdPrimitive = boolean | number | string | null; + +export type JsonLdValue = + | JsonLdPrimitive + | JsonLdValue[] + | { [key: string]: JsonLdValue | undefined }; + +const serializeJsonLd = (data: JsonLdValue): string => + JSON.stringify(data).replace(/ + ); +} + +export const buildOrganizationJsonLd = (): JsonLdValue => { + const siteUrl = getSiteUrl(); + + return { + '@context': 'https://schema.org', + '@type': 'Organization', + name: 'Twenty', + url: siteUrl, + logo: `${siteUrl}/images/core/logo.svg`, + sameAs: [ + 'https://github.com/twentyhq/twenty', + 'https://www.linkedin.com/company/twenty', + 'https://x.com/twentycrm', + ], + }; +}; + +export const buildSoftwareApplicationJsonLd = (): JsonLdValue => { + const siteUrl = getSiteUrl(); + + return { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: 'Twenty', + applicationCategory: 'BusinessApplication', + applicationSubCategory: 'Customer Relationship Management', + operatingSystem: 'Web', + description: + 'Twenty is an open source CRM for teams that want a modern, customizable, and extensible customer platform.', + url: siteUrl, + offers: { + '@type': 'Offer', + priceCurrency: 'USD', + url: `${siteUrl}/pricing`, + }, + publisher: { + '@type': 'Organization', + name: 'Twenty', + url: siteUrl, + }, + }; +}; + +type RenderText = (descriptor: MessageDescriptor) => string; + +export const buildFaqPageJsonLd = ( + questions: readonly FaqEntryLike[], + renderText: RenderText, +): JsonLdValue => ({ + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: questions.map((question) => ({ + '@type': 'Question', + name: renderText(question.question.text), + acceptedAnswer: { + '@type': 'Answer', + text: renderText(question.answer.text), + }, + })), +}); + +const extractReleaseHeadline = (note: LocalReleaseNote): string => { + const match = note.content.match(/^\s*#\s+(.+?)\s*$/m); + if (match && match[1]) { + return match[1].trim(); + } + return `Twenty ${note.release}`; +}; + +export const buildReleaseListJsonLd = ( + notes: readonly LocalReleaseNote[], +): JsonLdValue => { + const siteUrl = getSiteUrl(); + const releasesUrl = `${siteUrl}/releases`; + + return { + '@context': 'https://schema.org', + '@type': 'ItemList', + name: 'Twenty Releases', + url: releasesUrl, + itemListOrder: 'https://schema.org/ItemListOrderDescending', + numberOfItems: notes.length, + itemListElement: notes.map((note, index) => ({ + '@type': 'ListItem', + position: index + 1, + url: `${releasesUrl}#${note.release}`, + item: { + '@type': 'TechArticle', + '@id': `${releasesUrl}#${note.release}`, + headline: extractReleaseHeadline(note), + name: `Twenty ${note.release}`, + url: `${releasesUrl}#${note.release}`, + ...(note.date ? { datePublished: note.date } : {}), + author: { + '@type': 'Organization', + name: 'Twenty', + url: siteUrl, + }, + publisher: { + '@type': 'Organization', + name: 'Twenty', + url: siteUrl, + }, + }, + })), + }; +}; diff --git a/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts b/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts index f8bf9edc86..b8fd9b73d3 100644 --- a/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts +++ b/packages/twenty-website-new/src/lib/website-routing/static-website-routes.ts @@ -6,8 +6,8 @@ export const STATIC_WEBSITE_ROUTES = [ { id: 'home', path: '/', - 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.`, + 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, @@ -15,8 +15,8 @@ export const STATIC_WEBSITE_ROUTES = [ { id: 'whyTwenty', path: '/why-twenty', - title: msg`Why Twenty | Twenty`, - description: msg`Most packaged software makes companies more similar. Learn why the future of CRM is built, not bought.`, + title: msg`Why Twenty — The Open Source CRM Built to Be Customised`, + description: msg`Packaged CRMs make every company look the same. Twenty is the open source CRM teams shape around their workflow, with a modern UI and a developer-first platform.`, changeFrequency: 'monthly', priority: 0.8, indexed: true, @@ -24,8 +24,8 @@ export const STATIC_WEBSITE_ROUTES = [ { id: 'product', path: '/product', - title: msg`Product | Twenty`, - description: msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`, + title: msg`Twenty CRM Features — Modern Open Source CRM Platform`, + description: msg`Pipelines, custom objects, AI assistants, and a native API on top of Postgres. Twenty is the open source CRM with the modern UX teams actually want to use.`, changeFrequency: 'monthly', priority: 0.8, indexed: true, @@ -33,8 +33,8 @@ export const STATIC_WEBSITE_ROUTES = [ { id: 'pricing', path: '/pricing', - title: msg`Pricing | Twenty`, - description: msg`Plans that scale with your team. Compare tiers of the #1 open source CRM.`, + title: msg`Twenty CRM Pricing — Plans from $9 per User per Month`, + description: msg`Cloud Pro starts at $9/user/month with unlimited custom objects. Self-host the open source core for free, or upgrade to Organization for SSO and row-level permissions.`, changeFrequency: 'monthly', priority: 0.9, indexed: true, @@ -42,8 +42,8 @@ export const STATIC_WEBSITE_ROUTES = [ { id: 'partners', path: '/partners', - title: msg`Partners | Twenty`, - description: msg`Join our partner ecosystem and grow with us as we build the #1 open source CRM.`, + title: msg`Twenty Partners — Certified Open Source CRM Implementers`, + description: msg`Find a certified Twenty partner to migrate, customise, and operate your open source CRM, or join the ecosystem and grow your practice with us.`, changeFrequency: 'monthly', priority: 0.7, indexed: true, @@ -51,8 +51,8 @@ export const STATIC_WEBSITE_ROUTES = [ { id: 'releases', path: '/releases', - title: msg`Releases | Twenty`, - description: msg`Discover the newest features and improvements in Twenty, the #1 open source CRM.`, + title: msg`Twenty Releases — What's New in the Open Source CRM`, + description: msg`Every new release of Twenty, the #1 Open Source CRM, with changelogs, demos, and the highlights teams care about most.`, changeFrequency: 'weekly', priority: 0.7, indexed: true, @@ -60,8 +60,8 @@ export const STATIC_WEBSITE_ROUTES = [ { id: 'customers', path: '/customers', - 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.`, + title: msg`Twenty Customers — How Modern Teams Run Their CRM`, + description: msg`Real customer stories from teams running their business on Twenty: how they migrated, what they customised, and what changed once their CRM finally fit.`, changeFrequency: 'monthly', priority: 0.7, indexed: true, @@ -97,7 +97,7 @@ export const STATIC_WEBSITE_ROUTES = [ { id: 'enterpriseActivate', path: '/enterprise/activate', - title: msg`Enterprise activation | Twenty`, + title: msg`Enterprise Activation | Twenty`, description: msg`Complete activation for your Twenty self-hosted enterprise license.`, changeFrequency: 'yearly', priority: 0, diff --git a/packages/twenty-website-new/src/proxy.ts b/packages/twenty-website-new/src/proxy.ts index 088857c2c2..cadd25fa06 100644 --- a/packages/twenty-website-new/src/proxy.ts +++ b/packages/twenty-website-new/src/proxy.ts @@ -1,23 +1,8 @@ import { NextResponse, type NextRequest } from 'next/server'; -import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; -import { - APP_LOCALE_BY_RAW, - KNOWN_PUBLIC_APP_LOCALE_BY_RAW, -} from '@/lib/i18n/app-locale-set'; -import { LOCALE_COOKIE_NAME, detectLocale } from '@/lib/i18n/detect-locale'; - -const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; - -const setLocaleCookie = (response: NextResponse, locale: AppLocale) => { - response.cookies.set({ - name: LOCALE_COOKIE_NAME, - value: locale, - path: '/', - maxAge: LOCALE_COOKIE_MAX_AGE, - sameSite: 'lax', - }); -}; +import { KNOWN_PUBLIC_APP_LOCALE_BY_RAW } from '@/lib/i18n/app-locale-set'; +import { LOCALE_BY_URL_SEGMENT } from '@/lib/i18n/website-locale-segments'; export const proxy = (request: NextRequest) => { const { pathname, search } = request.nextUrl; @@ -25,55 +10,32 @@ export const proxy = (request: NextRequest) => { const firstSlash = pathname.indexOf('/', 1); const firstSegment = firstSlash === -1 ? pathname.slice(1) : pathname.slice(1, firstSlash); - const localeFromPath = APP_LOCALE_BY_RAW.get(firstSegment); - const knownLocaleFromPath = KNOWN_PUBLIC_APP_LOCALE_BY_RAW.get(firstSegment); + const tail = firstSlash === -1 ? '/' : pathname.slice(firstSlash); - if (localeFromPath === SOURCE_LOCALE) { - const canonicalPath = firstSlash === -1 ? '/' : pathname.slice(firstSlash); + const localeFromSegment = LOCALE_BY_URL_SEGMENT.get(firstSegment); + + if (localeFromSegment === SOURCE_LOCALE) { const target = request.nextUrl.clone(); - target.pathname = canonicalPath; + target.pathname = tail; target.search = search; return NextResponse.redirect(target, 301); } - if (localeFromPath !== undefined) { - const cookieLocale = request.cookies.get(LOCALE_COOKIE_NAME)?.value; - if (cookieLocale === localeFromPath) { - return NextResponse.next(); - } - const response = NextResponse.next(); - setLocaleCookie(response, localeFromPath); - return response; + if (localeFromSegment !== undefined) { + return NextResponse.next(); } - if (knownLocaleFromPath !== undefined) { - const canonicalPath = firstSlash === -1 ? '/' : pathname.slice(firstSlash); + if (KNOWN_PUBLIC_APP_LOCALE_BY_RAW.has(firstSegment)) { const target = request.nextUrl.clone(); - target.pathname = canonicalPath; + target.pathname = tail; target.search = search; return NextResponse.redirect(target, 308); } - const detected = detectLocale({ - cookieValue: request.cookies.get(LOCALE_COOKIE_NAME)?.value, - acceptLanguageHeader: request.headers.get('accept-language') ?? undefined, - }); - - if (detected === SOURCE_LOCALE) { - const target = request.nextUrl.clone(); - target.pathname = `/${SOURCE_LOCALE}${pathname === '/' ? '' : pathname}`; - const response = NextResponse.rewrite(target); - const cookieLocale = request.cookies.get(LOCALE_COOKIE_NAME)?.value; - if (cookieLocale !== SOURCE_LOCALE) { - setLocaleCookie(response, SOURCE_LOCALE); - } - return response; - } - const target = request.nextUrl.clone(); - target.pathname = `/${detected}${pathname === '/' ? '' : pathname}`; + target.pathname = `/${SOURCE_LOCALE}${pathname === '/' ? '' : pathname}`; target.search = search; - return NextResponse.redirect(target, 308); + return NextResponse.rewrite(target); }; export const config = { diff --git a/packages/twenty-website-new/src/sections/Faq/data.ts b/packages/twenty-website-new/src/sections/Faq/data.ts index c32a65f208..7c73b96e06 100644 --- a/packages/twenty-website-new/src/sections/Faq/data.ts +++ b/packages/twenty-website-new/src/sections/Faq/data.ts @@ -15,7 +15,7 @@ export const FAQ_DATA: FaqDataType = { fontFamily: 'sans', }, answer: { - text: msg`Yes. Twenty is the #1 open source CRM on GitHub. You can self-host to fully own your infrastructure, or run it on our managed cloud for a zero-ops setup.`, + text: msg`Yes. Twenty is the #1 Open Source CRM on GitHub. You can self-host to fully own your infrastructure, or run it on our managed cloud for a zero-ops setup.`, }, }, { diff --git a/packages/twenty-website-new/src/sections/Footer/components/Bottom.tsx b/packages/twenty-website-new/src/sections/Footer/components/Bottom.tsx index a32da17bc5..721e369942 100644 --- a/packages/twenty-website-new/src/sections/Footer/components/Bottom.tsx +++ b/packages/twenty-website-new/src/sections/Footer/components/Bottom.tsx @@ -1,6 +1,7 @@ import { ArrowRightUpIcon, SOCIAL_ICONS } from '@/icons'; import type { MessageDescriptor } from '@lingui/core'; import type { FooterSocialLinkType } from '@/sections/Footer/types'; +import { LocaleSwitcher } from '@/sections/Footer/components/LocaleSwitcher'; import { theme } from '@/theme'; import { Separator } from '@base-ui/react/separator'; import { styled } from '@linaria/react'; @@ -16,13 +17,15 @@ const BottomGrid = styled.div` width: 100%; `; -const Copyright = styled.div` - color: ${theme.colors.primary.text[100]}; - font-family: ${theme.font.family.mono}; +const CopyrightRow = styled.div` + align-items: center; + display: flex; + flex-wrap: wrap; + gap: ${theme.spacing(4)}; grid-column: 1 / -1; grid-row: 2; justify-self: start; - text-transform: uppercase; + min-width: 0; @media (min-width: ${theme.breakpoints.md}px) { grid-column: 1; @@ -30,6 +33,12 @@ const Copyright = styled.div` } `; +const Copyright = styled.div` + color: ${theme.colors.primary.text[100]}; + font-family: ${theme.font.family.mono}; + text-transform: uppercase; +`; + const SocialNav = styled.nav` align-items: center; display: flex; @@ -83,7 +92,10 @@ type BottomProps = { export function Bottom({ copyright, links, renderText }: BottomProps) { return ( - {renderText(copyright)} + + {renderText(copyright)} + + {links.map((link, index) => { const IconComponent = SOCIAL_ICONS[link.icon]; diff --git a/packages/twenty-website-new/src/sections/Footer/components/LocaleSwitcher.tsx b/packages/twenty-website-new/src/sections/Footer/components/LocaleSwitcher.tsx new file mode 100644 index 0000000000..76c1757dae --- /dev/null +++ b/packages/twenty-website-new/src/sections/Footer/components/LocaleSwitcher.tsx @@ -0,0 +1,284 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { IconCheck, IconChevronDown, IconWorld } from '@tabler/icons-react'; +import Link from 'next/link'; +import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { type AppLocale } from 'twenty-shared/translations'; + +import { WEBSITE_LOCALE_LIST } from '@/lib/i18n/app-locale-set'; +import { localizeHref } from '@/lib/i18n/localize-href'; +import { + getEnglishLocaleName, + getNativeLocaleName, +} from '@/lib/i18n/locale-display-names'; +import { useLocale } from '@/lib/i18n/use-locale'; +import { useUnlocalizedPathname } from '@/lib/i18n/use-unlocalized-pathname'; +import { theme } from '@/theme'; + +const SEARCH_THRESHOLD = 6; + +type LocaleEntry = { + locale: AppLocale; + nativeName: string; + englishName: string; +}; + +const ALL_LOCALES: readonly LocaleEntry[] = WEBSITE_LOCALE_LIST.map( + (locale) => ({ + locale, + nativeName: getNativeLocaleName(locale), + englishName: getEnglishLocaleName(locale), + }), +).sort((a, b) => a.nativeName.localeCompare(b.nativeName)); + +const matchesQuery = (entry: LocaleEntry, normalisedQuery: string): boolean => + entry.nativeName.toLocaleLowerCase().includes(normalisedQuery) || + entry.englishName.toLocaleLowerCase().includes(normalisedQuery); + +const Wrapper = styled.div` + position: relative; +`; + +const TriggerButton = styled.button` + align-items: center; + background: transparent; + border: 1px solid ${theme.colors.primary.border[40]}; + border-radius: ${theme.radius(1)}; + color: ${theme.colors.primary.text[100]}; + cursor: pointer; + display: inline-flex; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(3)}; + font-weight: ${theme.font.weight.medium}; + gap: ${theme.spacing(2)}; + line-height: 1; + padding: ${theme.spacing(2)} ${theme.spacing(3)}; + + &:hover, + &[aria-expanded='true'] { + border-color: ${theme.colors.primary.border[100]}; + } + + &:focus-visible { + outline: 1px solid ${theme.colors.highlight[100]}; + outline-offset: 1px; + } +`; + +const TriggerLabel = styled.span` + white-space: nowrap; +`; + +const Popup = styled.div` + background: #0c0c0c; + border: 1px solid ${theme.colors.secondary.border[20]}; + border-radius: ${theme.radius(2)}; + bottom: calc(100% + ${theme.spacing(2)}); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); + color: ${theme.colors.secondary.text[100]}; + display: flex; + flex-direction: column; + left: 0; + max-width: 320px; + min-width: 240px; + overflow: hidden; + padding: ${theme.spacing(2)}; + position: absolute; + z-index: ${theme.zIndex.modal}; +`; + +const SearchInput = styled.input` + background: ${theme.colors.secondary.text[10]}; + border: 1px solid ${theme.colors.secondary.border[20]}; + border-radius: ${theme.radius(1)}; + color: ${theme.colors.secondary.text[100]}; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(3)}; + margin-bottom: ${theme.spacing(2)}; + padding: ${theme.spacing(2)} ${theme.spacing(3)}; + width: 100%; + + &::placeholder { + color: ${theme.colors.secondary.text[60]}; + } + + &:focus-visible { + outline: 1px solid ${theme.colors.highlight[100]}; + outline-offset: 1px; + } +`; + +const ItemList = styled.nav` + display: flex; + flex-direction: column; + max-height: 320px; + overflow-y: auto; +`; + +const ItemLink = styled(Link)` + align-items: center; + border-radius: ${theme.radius(1)}; + color: ${theme.colors.secondary.text[100]}; + display: flex; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(3)}; + gap: ${theme.spacing(2)}; + justify-content: space-between; + padding: ${theme.spacing(2)} ${theme.spacing(3)}; + text-decoration: none; + + &:hover { + background: ${theme.colors.secondary.text[10]}; + } + + &:focus-visible { + outline: 1px solid ${theme.colors.highlight[100]}; + outline-offset: 1px; + } + + &[aria-current='true'] { + background: ${theme.colors.secondary.text[10]}; + pointer-events: none; + } +`; + +const ItemPrimary = styled.span` + font-weight: ${theme.font.weight.medium}; +`; + +const ItemSecondary = styled.span` + color: ${theme.colors.secondary.text[60]}; + flex: 1; + font-size: ${theme.font.size(2)}; + margin-left: ${theme.spacing(2)}; + text-align: right; +`; + +const Empty = styled.div` + color: ${theme.colors.secondary.text[60]}; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(3)}; + padding: ${theme.spacing(3)}; + text-align: center; +`; + +export function LocaleSwitcher() { + const currentLocale = useLocale(); + const unlocalizedPathname = useUnlocalizedPathname(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const wrapperRef = useRef(null); + const triggerRef = useRef(null); + const searchRef = useRef(null); + + const filteredItems = useMemo(() => { + const trimmed = query.trim().toLocaleLowerCase(); + if (trimmed.length === 0) return ALL_LOCALES; + return ALL_LOCALES.filter((entry) => matchesQuery(entry, trimmed)); + }, [query]); + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: MouseEvent) => { + if (wrapperRef.current?.contains(event.target as Node)) return; + setOpen(false); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + triggerRef.current?.focus(); + } + }; + + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + useEffect(() => { + if (open && ALL_LOCALES.length > SEARCH_THRESHOLD) { + searchRef.current?.focus(); + } + }, [open]); + + if (ALL_LOCALES.length < 2) return null; + + const triggerLabel = getNativeLocaleName(currentLocale); + const showSearch = ALL_LOCALES.length > SEARCH_THRESHOLD; + + const handleToggle = () => { + setOpen((prev) => { + if (prev) setQuery(''); + return !prev; + }); + }; + + const handleSelect = () => { + setOpen(false); + setQuery(''); + }; + + const handleQueryChange = (event: ChangeEvent) => { + setQuery(event.target.value); + }; + + return ( + + + + {triggerLabel} + + + {open && ( + + {showSearch && ( + + )} + + {filteredItems.map(({ locale, nativeName, englishName }) => { + const isActive = locale === currentLocale; + return ( + + {nativeName} + {englishName !== nativeName && ( + {englishName} + )} + {isActive && } + + ); + })} + {filteredItems.length === 0 && No matches} + + + )} + + ); +} diff --git a/packages/twenty-website-new/src/sections/Footer/components/__tests__/LocaleSwitcher.test.tsx b/packages/twenty-website-new/src/sections/Footer/components/__tests__/LocaleSwitcher.test.tsx new file mode 100644 index 0000000000..72203622c4 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Footer/components/__tests__/LocaleSwitcher.test.tsx @@ -0,0 +1,67 @@ +import { renderToStaticMarkup } from 'react-dom/server'; + +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(), +})); + +jest.mock('@/lib/i18n/use-locale', () => ({ + useLocale: jest.fn(), +})); + +import { usePathname } from 'next/navigation'; + +import { useLocale } from '@/lib/i18n/use-locale'; +import { LocaleSwitcher } from '@/sections/Footer/components/LocaleSwitcher'; + +const mockUsePathname = usePathname as jest.MockedFunction; +const mockUseLocale = useLocale as jest.MockedFunction; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('LocaleSwitcher', () => { + it('renders a labelled trigger button showing the active locale in its native name', () => { + mockUsePathname.mockReturnValue('/pricing'); + mockUseLocale.mockReturnValue('en'); + + const html = renderToStaticMarkup(); + + expect(html).toMatch(/aria-label="Change language"/i); + expect(html).toContain('>English<'); + }); + + it('shows the native French label when the active locale is French', () => { + mockUsePathname.mockReturnValue('/fr/pricing'); + mockUseLocale.mockReturnValue('fr-FR'); + + const html = renderToStaticMarkup(); + + expect(html).toContain('>Français<'); + }); + + it('renders the trigger as a real button so it is keyboard-focusable and screen-reader-discoverable', () => { + mockUsePathname.mockReturnValue('/pricing'); + mockUseLocale.mockReturnValue('en'); + + const html = renderToStaticMarkup(); + const triggerTag = html.match( + /]*aria-label="Change language"[^>]*>/i, + ); + + expect(triggerTag).not.toBeNull(); + expect(triggerTag?.[0]).toMatch(/type="button"/); + expect(triggerTag?.[0]).toMatch(/aria-haspopup="dialog"/); + expect(triggerTag?.[0]).toMatch(/aria-expanded="false"/); + }); + + it('does not render the popup contents server-side when closed (avoids hydration drift)', () => { + mockUsePathname.mockReturnValue('/pricing'); + mockUseLocale.mockReturnValue('en'); + + const html = renderToStaticMarkup(); + + expect(html).not.toContain('href="/pricing"'); + expect(html).not.toContain('href="/fr/pricing"'); + }); +}); diff --git a/packages/twenty-website-new/src/sections/Hero/components/Heading.tsx b/packages/twenty-website-new/src/sections/Hero/components/Heading.tsx index d30879913d..c408badc63 100644 --- a/packages/twenty-website-new/src/sections/Hero/components/Heading.tsx +++ b/packages/twenty-website-new/src/sections/Hero/components/Heading.tsx @@ -27,7 +27,7 @@ export type HeroHeadingProps = HeadingProps & { }; export function Heading({ - as, + as = 'h1', children, className, page, diff --git a/packages/twenty-website-new/src/sections/Hero/components/__tests__/Heading.test.tsx b/packages/twenty-website-new/src/sections/Hero/components/__tests__/Heading.test.tsx new file mode 100644 index 0000000000..a7f3c8e540 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Hero/components/__tests__/Heading.test.tsx @@ -0,0 +1,25 @@ +import { renderToStaticMarkup } from 'react-dom/server'; + +import { Heading as HeroHeading } from '@/sections/Hero/components/Heading'; +import { Pages } from '@/lib/pages'; + +describe('Hero.Heading defaults', () => { + it('renders

by default', () => { + const html = renderToStaticMarkup( + Page title, + ); + expect(html).toContain(''); + }); + + it('still allows callers to override as for legacy or special cases', () => { + const html = renderToStaticMarkup( + + Forced h2 + , + ); + expect(html).toContain(' + {release} {displayDate || 'TBD'}