Skip to content

Commit d50624d

Browse files
committed
feat: implement multilingual support with locale management and translations
1 parent df8fc49 commit d50624d

32 files changed

Lines changed: 6347 additions & 1617 deletions

‎docusaurus.config.js‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,16 @@ const config = {
2424
// to replace "en" with "zh-Hans".
2525
i18n: {
2626
defaultLocale: 'en',
27-
locales: ['en'],
27+
locales: ['en', 'fr', 'de', 'es', 'it', 'pt', 'ru'],
28+
localeConfigs: {
29+
en: {label: 'English', direction: 'ltr', htmlLang: 'en-US'},
30+
fr: {label: 'Français', direction: 'ltr', htmlLang: 'fr-FR'},
31+
de: {label: 'Deutsch', direction: 'ltr', htmlLang: 'de-DE'},
32+
es: {label: 'Español', direction: 'ltr', htmlLang: 'es-ES'},
33+
it: {label: 'Italiano', direction: 'ltr', htmlLang: 'it-IT'},
34+
pt: {label: 'Português', direction: 'ltr', htmlLang: 'pt-PT'},
35+
ru: {label: 'Русский', direction: 'ltr', htmlLang: 'ru-RU'},
36+
},
2837
},
2938

3039
presets: [
@@ -53,6 +62,9 @@ const config = {
5362
trackingID: 'G-RQEHKSPQ6Z',
5463
anonymizeIP: true,
5564
},
65+
// Disable the built-in single-locale sitemap; we ship a custom
66+
// multilingual sitemap with hreflang via plugins/multilingual-sitemap.js
67+
sitemap: false,
5668
}),
5769
],
5870
// Redocusaurus config
@@ -74,6 +86,12 @@ const config = {
7486
],
7587
],
7688

89+
plugins: [
90+
// Custom multilingual sitemap — overwrites the default sitemap.xml
91+
// with one URL per (page × locale) plus hreflang alternates.
92+
require.resolve('./plugins/multilingual-sitemap.js'),
93+
],
94+
7795
themeConfig:
7896
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
7997
({
@@ -90,6 +108,10 @@ const config = {
90108
label: 'Docs',
91109
},
92110
{to: '/api', label: 'Api', position: 'left'},
111+
{
112+
type: 'localeDropdown',
113+
position: 'right',
114+
},
93115
{
94116
href: 'https://github.com/tictactrip/documentation',
95117
label: 'GitHub',

‎plugins/multilingual-sitemap.js‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// @ts-check
2+
/**
3+
* Custom Docusaurus plugin that overwrites build/sitemap.xml with a full
4+
* multilingual sitemap: one <url> per (page × locale), each entry pointing
5+
* to its translated peers via <xhtml:link rel="alternate" hreflang="..."/>.
6+
*
7+
* Docusaurus 2.4.1's built-in @docusaurus/plugin-sitemap only emits the
8+
* default-locale URLs and has no hreflang awareness — this plugin closes
9+
* that gap. It runs in postBuild, so it overwrites whatever the classic
10+
* preset wrote a moment earlier.
11+
*/
12+
13+
const fs = require('fs');
14+
const path = require('path');
15+
16+
const SITE_URL = 'https://api.tictactrip.eu';
17+
18+
const LOCALES = ['en', 'fr', 'de', 'es', 'it', 'pt', 'ru'];
19+
20+
const HREFLANG = {
21+
en: 'en-US',
22+
fr: 'fr-FR',
23+
de: 'de-DE',
24+
es: 'es-ES',
25+
it: 'it-IT',
26+
pt: 'pt-PT',
27+
ru: 'ru-RU',
28+
};
29+
30+
/**
31+
* @typedef {{path: string, priority: number, changefreq?: string}} SitemapPage
32+
*/
33+
34+
/** @type {SitemapPage[]} */
35+
const PAGES = [
36+
{path: '/', priority: 1.0, changefreq: 'daily'},
37+
38+
// Feature pages
39+
{path: '/features/multimodal-coverage', priority: 0.9, changefreq: 'weekly'},
40+
{path: '/features/eco-responsible', priority: 0.9, changefreq: 'weekly'},
41+
{path: '/features/real-time-availability', priority: 0.9, changefreq: 'weekly'},
42+
{path: '/features/smart-combinations', priority: 0.9, changefreq: 'weekly'},
43+
{path: '/features/end-to-end-booking', priority: 0.9, changefreq: 'weekly'},
44+
{path: '/features/developer-first', priority: 0.9, changefreq: 'weekly'},
45+
46+
// Solution pages
47+
{path: '/solutions/online-travel-agencies', priority: 0.9, changefreq: 'weekly'},
48+
{path: '/solutions/corporate-travel-tmc', priority: 0.9, changefreq: 'weekly'},
49+
{path: '/solutions/fintech-super-apps', priority: 0.9, changefreq: 'weekly'},
50+
{path: '/solutions/mobility-maas', priority: 0.9, changefreq: 'weekly'},
51+
{path: '/solutions/green-tech-sustainability', priority: 0.9, changefreq: 'weekly'},
52+
{path: '/solutions/custom-internal-tools', priority: 0.9, changefreq: 'weekly'},
53+
54+
// API reference (Redocusaurus)
55+
{path: '/api/', priority: 0.8, changefreq: 'weekly'},
56+
57+
// Docs pages (markdown — falls back to English for non-en locales until
58+
// we provide translated copies under i18n/<locale>/docusaurus-plugin-content-docs/)
59+
{path: '/docs/intro', priority: 0.8, changefreq: 'weekly'},
60+
{path: '/docs/search', priority: 0.7, changefreq: 'weekly'},
61+
{path: '/docs/book', priority: 0.7, changefreq: 'weekly'},
62+
{path: '/docs/cache-export', priority: 0.6, changefreq: 'monthly'},
63+
{path: '/docs/glossary', priority: 0.6, changefreq: 'monthly'},
64+
];
65+
66+
function urlFor(locale, urlPath) {
67+
const prefix = locale === 'en' ? '' : `/${locale}`;
68+
return `${SITE_URL}${prefix}${urlPath}`;
69+
}
70+
71+
function escapeXml(s) {
72+
return s
73+
.replace(/&/g, '&amp;')
74+
.replace(/</g, '&lt;')
75+
.replace(/>/g, '&gt;')
76+
.replace(/"/g, '&quot;')
77+
.replace(/'/g, '&apos;');
78+
}
79+
80+
/**
81+
* @param {SitemapPage} page
82+
* @param {string} locale
83+
* @param {string} lastmod
84+
*/
85+
function buildEntry(page, locale, lastmod) {
86+
const loc = escapeXml(urlFor(locale, page.path));
87+
const alternates = LOCALES.map(
88+
(l) =>
89+
` <xhtml:link rel="alternate" hreflang="${HREFLANG[l]}" href="${escapeXml(urlFor(l, page.path))}"/>`,
90+
).join('\n');
91+
const xdefault = ` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(urlFor('en', page.path))}"/>`;
92+
return ` <url>
93+
<loc>${loc}</loc>
94+
<lastmod>${lastmod}</lastmod>
95+
<changefreq>${page.changefreq || 'weekly'}</changefreq>
96+
<priority>${page.priority.toFixed(1)}</priority>
97+
${alternates}
98+
${xdefault}
99+
</url>`;
100+
}
101+
102+
module.exports = function pluginMultilingualSitemap() {
103+
return {
104+
name: 'multilingual-sitemap',
105+
async postBuild({outDir}) {
106+
const lastmod = new Date().toISOString().slice(0, 10);
107+
108+
const urls = [];
109+
for (const page of PAGES) {
110+
for (const locale of LOCALES) {
111+
urls.push(buildEntry(page, locale, lastmod));
112+
}
113+
}
114+
115+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
116+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
117+
xmlns:xhtml="http://www.w3.org/1999/xhtml">
118+
${urls.join('\n')}
119+
</urlset>
120+
`;
121+
122+
const target = path.join(outDir, 'sitemap.xml');
123+
fs.writeFileSync(target, xml, 'utf8');
124+
// eslint-disable-next-line no-console
125+
console.log(
126+
`[multilingual-sitemap] wrote ${urls.length} URLs (${PAGES.length} pages × ${LOCALES.length} locales) to ${target}`,
127+
);
128+
},
129+
};
130+
};

‎src/components/SolutionPage.tsx‎

Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,33 @@ import Link from '@docusaurus/Link';
44
import Head from '@docusaurus/Head';
55
import Layout from '@theme/Layout';
66

7+
import {useLocale, useTranslation, type Locale} from '../i18n/useTranslation';
8+
import {solutionShell} from '../i18n/content/solutionShell';
79
import styles from './SolutionPage.module.css';
810

911
const SALES_EMAIL = 'sales@tictactrip.eu';
1012
const SITE_URL = 'https://api.tictactrip.eu';
1113

14+
const OG_LOCALE: Record<Locale, string> = {
15+
en: 'en_US',
16+
fr: 'fr_FR',
17+
de: 'de_DE',
18+
es: 'es_ES',
19+
it: 'it_IT',
20+
pt: 'pt_PT',
21+
ru: 'ru_RU',
22+
};
23+
24+
const SCHEMA_LANG: Record<Locale, string> = {
25+
en: 'en',
26+
fr: 'fr',
27+
de: 'de',
28+
es: 'es',
29+
it: 'it',
30+
pt: 'pt',
31+
ru: 'ru',
32+
};
33+
1234
export type SolutionFaq = {
1335
q: string;
1436
a: string;
@@ -41,21 +63,21 @@ export type SolutionPageProps = {
4163
serviceType: string;
4264
audienceType: string;
4365
related: Array<{title: string; slug: string}>;
44-
/** Breadcrumb category. Defaults to "Solutions". */
4566
breadcrumbCategory?: string;
46-
/** Href for the breadcrumb category link. Defaults to "/#use-cases". */
4767
breadcrumbCategoryHref?: string;
48-
/** Override the "Why Tictactrip for ..." section heading. */
4968
benefitsHeading?: string;
50-
/** Override the "Other Tictactrip solutions" related-cards heading. */
5169
relatedHeading?: string;
5270
};
5371

5472
export default function SolutionPage(props: SolutionPageProps): JSX.Element {
55-
const canonical = `${SITE_URL}${props.slug}`;
73+
const locale = useLocale();
74+
const shell = useTranslation(solutionShell);
75+
const localePrefix = locale === 'en' ? '' : `/${locale}`;
76+
const canonical = `${SITE_URL}${localePrefix}${props.slug}`;
5677
const breadcrumbCategory = props.breadcrumbCategory ?? 'Solutions';
5778
const breadcrumbCategoryHref = props.breadcrumbCategoryHref ?? '/#use-cases';
58-
const benefitsHeading = props.benefitsHeading ?? `Why Tictactrip for ${props.title.toLowerCase()}`;
79+
const benefitsHeading =
80+
props.benefitsHeading ?? `Why Tictactrip for ${props.title.toLowerCase()}`;
5981
const relatedHeading = props.relatedHeading ?? 'Other Tictactrip solutions';
6082

6183
const breadcrumb = {
@@ -66,13 +88,13 @@ export default function SolutionPage(props: SolutionPageProps): JSX.Element {
6688
'@type': 'ListItem',
6789
position: 1,
6890
name: 'Tictactrip',
69-
item: `${SITE_URL}/`,
91+
item: `${SITE_URL}${localePrefix}/`,
7092
},
7193
{
7294
'@type': 'ListItem',
7395
position: 2,
7496
name: breadcrumbCategory,
75-
item: `${SITE_URL}${breadcrumbCategoryHref}`,
97+
item: `${SITE_URL}${localePrefix}${breadcrumbCategoryHref}`,
7698
},
7799
{
78100
'@type': 'ListItem',
@@ -121,7 +143,7 @@ export default function SolutionPage(props: SolutionPageProps): JSX.Element {
121143
name: 'Tictactrip Documentation',
122144
url: `${SITE_URL}/`,
123145
},
124-
inLanguage: 'en',
146+
inLanguage: SCHEMA_LANG[locale],
125147
primaryImageOfPage: {
126148
'@type': 'ImageObject',
127149
url: `${SITE_URL}/img/logoTextBlack.svg`,
@@ -150,7 +172,7 @@ export default function SolutionPage(props: SolutionPageProps): JSX.Element {
150172
<meta property="og:description" content={props.metaDescription} />
151173
<meta property="og:url" content={canonical} />
152174
<meta property="og:site_name" content="Tictactrip Documentation" />
153-
<meta property="og:locale" content="en_US" />
175+
<meta property="og:locale" content={OG_LOCALE[locale]} />
154176
<meta name="twitter:card" content="summary_large_image" />
155177
<meta name="twitter:title" content={props.metaTitle} />
156178
<meta name="twitter:description" content={props.metaDescription} />
@@ -161,7 +183,7 @@ export default function SolutionPage(props: SolutionPageProps): JSX.Element {
161183
</Head>
162184

163185
<main className={styles.main}>
164-
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
186+
<nav className={styles.breadcrumb} aria-label={shell.breadcrumbAriaLabel}>
165187
<div className="container">
166188
<ol>
167189
<li>
@@ -184,12 +206,12 @@ export default function SolutionPage(props: SolutionPageProps): JSX.Element {
184206
<p className={styles.heroLead}>{props.heroLead}</p>
185207
<div className={styles.heroCtas}>
186208
<Link className={clsx('button button--lg', styles.btnPrimary)} to="/docs/intro">
187-
Read the API tutorial
209+
{shell.heroPrimaryCta}
188210
</Link>
189211
<a
190212
className={clsx('button button--lg', styles.btnSecondary)}
191213
href={`mailto:${SALES_EMAIL}?subject=Tictactrip%20API%20-%20${encodeURIComponent(props.title)}`}>
192-
Request API access
214+
{shell.heroSecondaryCta}
193215
</a>
194216
</div>
195217
</div>
@@ -211,9 +233,7 @@ export default function SolutionPage(props: SolutionPageProps): JSX.Element {
211233
<section className={clsx(styles.section, styles.sectionAlt)}>
212234
<div className="container">
213235
<h2 className={styles.sectionTitle}>{benefitsHeading}</h2>
214-
<p className={styles.sectionSubtitle}>
215-
The capabilities partners ship on day one.
216-
</p>
236+
<p className={styles.sectionSubtitle}>{shell.benefitsSubtitle}</p>
217237
<div className={styles.benefitGrid}>
218238
{props.benefits.map((b) => (
219239
<article key={b.title} className={styles.benefitCard}>
@@ -230,19 +250,15 @@ export default function SolutionPage(props: SolutionPageProps): JSX.Element {
230250
<div className="container">
231251
<div className={styles.codeGrid}>
232252
<div>
233-
<span className={styles.kicker}>Integration</span>
234-
<h2 className={styles.sectionTitle}>One REST call, multimodal results</h2>
235-
<p className={styles.lead}>
236-
The same endpoint powers every Tictactrip integration. Authenticate, send a search
237-
payload, and receive itineraries with price, duration, carrier and CO₂ emissions.
238-
Then book through <code>/book</code> and deliver tickets to your users.
239-
</p>
253+
<span className={styles.kicker}>{shell.integrationKicker}</span>
254+
<h2 className={styles.sectionTitle}>{shell.integrationHeading}</h2>
255+
<p className={styles.lead}>{shell.integrationCopy}</p>
240256
<div className={styles.heroCtas}>
241257
<Link className={clsx('button button--lg', styles.btnPrimary)} to="/docs/intro">
242-
Start the tutorial
258+
{shell.startTutorialCta}
243259
</Link>
244260
<Link className={clsx('button button--lg', styles.btnGhost)} to="/api">
245-
Browse the API reference
261+
{shell.apiReferenceCta}
246262
</Link>
247263
</div>
248264
</div>
@@ -261,10 +277,8 @@ export default function SolutionPage(props: SolutionPageProps): JSX.Element {
261277

262278
<section className={clsx(styles.section, styles.sectionAlt)} id="faq">
263279
<div className="container">
264-
<h2 className={styles.sectionTitle}>Frequently asked questions</h2>
265-
<p className={styles.sectionSubtitle}>
266-
The points product and engineering teams check before integrating.
267-
</p>
280+
<h2 className={styles.sectionTitle}>{shell.faqHeading}</h2>
281+
<p className={styles.sectionSubtitle}>{shell.faqSubtitle}</p>
268282
<div className={styles.faqList}>
269283
{props.faqs.map((item) => (
270284
<details key={item.q} className={styles.faqItem}>
@@ -294,18 +308,16 @@ export default function SolutionPage(props: SolutionPageProps): JSX.Element {
294308

295309
<section className={clsx(styles.section, styles.sectionCta)}>
296310
<div className="container">
297-
<h2 className={styles.ctaTitle}>Ready to integrate Tictactrip?</h2>
298-
<p className={styles.ctaLead}>
299-
Tell us about your project and we'll come back within one business day with a tailored plan.
300-
</p>
311+
<h2 className={styles.ctaTitle}>{shell.ctaHeading}</h2>
312+
<p className={styles.ctaLead}>{shell.ctaLead}</p>
301313
<div className={styles.heroCtas}>
302314
<a
303315
className={clsx('button button--lg', styles.btnPrimary)}
304316
href={`mailto:${SALES_EMAIL}?subject=Tictactrip%20API%20-%20${encodeURIComponent(props.title)}`}>
305-
Contact sales
317+
{shell.contactSalesCta}
306318
</a>
307319
<Link className={clsx('button button--lg', styles.btnSecondary)} to="/">
308-
Back to homepage
320+
{shell.backToHomeCta}
309321
</Link>
310322
</div>
311323
</div>

0 commit comments

Comments
 (0)