@jjlmoya/utils-travel 1.16.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/tests/seo_parity.test.ts +60 -0
- package/src/tests/spanish_leakage.test.ts +153 -0
- package/src/tests/translation_copy.test.ts +124 -0
- package/src/tool/luggage-calculator/i18n/fr.ts +25 -0
- package/src/tool/mini-adventures/i18n/es.ts +0 -1
- package/src/tool/mini-adventures/i18n/fr.ts +9 -2
- package/src/tool/suitcase-checklist/i18n/fr.ts +3 -1
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
import type { KnownLocale } from '../types';
|
|
4
|
+
|
|
5
|
+
interface ExpectedCounts {
|
|
6
|
+
seo: number;
|
|
7
|
+
faq: number;
|
|
8
|
+
howTo: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function countItems(arr: unknown[] | undefined): number {
|
|
12
|
+
return arr?.length ?? 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function verifyLocaleParity(
|
|
16
|
+
entry: typeof ALL_ENTRIES[number],
|
|
17
|
+
loc: KnownLocale,
|
|
18
|
+
expected: ExpectedCounts,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const locContent = await entry.i18n[loc]?.();
|
|
21
|
+
expect(locContent, `Locale ${loc} missing content`).toBeDefined();
|
|
22
|
+
|
|
23
|
+
const locSeoCount = countItems(locContent?.seo);
|
|
24
|
+
const locFaqCount = countItems(locContent?.faq);
|
|
25
|
+
const locHowToCount = countItems(locContent?.howTo);
|
|
26
|
+
|
|
27
|
+
expect(
|
|
28
|
+
locSeoCount,
|
|
29
|
+
`Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
|
|
30
|
+
).toBe(expected.seo);
|
|
31
|
+
expect(
|
|
32
|
+
locFaqCount,
|
|
33
|
+
`Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
|
|
34
|
+
).toBe(expected.faq);
|
|
35
|
+
expect(
|
|
36
|
+
locHowToCount,
|
|
37
|
+
`Locale ${loc} HowTo steps count (${locHowToCount}) must match EN (${expected.howTo})`,
|
|
38
|
+
).toBe(expected.howTo);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('SEO & i18n Structural Parity Suite', () => {
|
|
42
|
+
ALL_ENTRIES.forEach((entry) => {
|
|
43
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
44
|
+
it('all 15 locales should have identical SEO section counts and types as English', async () => {
|
|
45
|
+
const enContent = await entry.i18n.en?.();
|
|
46
|
+
expect(enContent).toBeDefined();
|
|
47
|
+
const expected: ExpectedCounts = {
|
|
48
|
+
seo: countItems(enContent?.seo),
|
|
49
|
+
faq: countItems(enContent?.faq),
|
|
50
|
+
howTo: countItems(enContent?.howTo),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const locales = Object.keys(entry.i18n) as KnownLocale[];
|
|
54
|
+
for (const loc of locales) {
|
|
55
|
+
await verifyLocaleParity(entry, loc, expected);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
|
|
4
|
+
const STRUCTURAL_KEYS = new Set([
|
|
5
|
+
'id',
|
|
6
|
+
'slug',
|
|
7
|
+
'url',
|
|
8
|
+
'icon',
|
|
9
|
+
'image',
|
|
10
|
+
'imageUrl',
|
|
11
|
+
'keywords',
|
|
12
|
+
'category',
|
|
13
|
+
'tags',
|
|
14
|
+
'toolId',
|
|
15
|
+
'type',
|
|
16
|
+
'locale',
|
|
17
|
+
'language',
|
|
18
|
+
'direction',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const TRANSLATABLE_KEYS = [
|
|
22
|
+
'title',
|
|
23
|
+
'description',
|
|
24
|
+
'faqTitle',
|
|
25
|
+
'faq',
|
|
26
|
+
'howTo',
|
|
27
|
+
'seo',
|
|
28
|
+
'schemas',
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
const SPANISH_MARKERS = [
|
|
32
|
+
['sangre', /\bsangre\b/gi],
|
|
33
|
+
['molino', /\bmolino\b/gi],
|
|
34
|
+
['grano', /\bgrano\b/gi],
|
|
35
|
+
['paladar', /\bpaladar\b/gi],
|
|
36
|
+
['cuchillas', /\bcuchillas\b/gi],
|
|
37
|
+
['trozos', /\btrozos\b/gi],
|
|
38
|
+
['frescura', /\bfrescura\b/gi],
|
|
39
|
+
['ingresa', /\bingresa\b/gi],
|
|
40
|
+
['selecciona', /\bselecciona\b/gi],
|
|
41
|
+
['herramienta', /\bherramienta\b/gi],
|
|
42
|
+
['según', /\bsegún\b/gi],
|
|
43
|
+
['después', /\bdespués\b/gi],
|
|
44
|
+
['puedes', /\bpuedes\b/gi],
|
|
45
|
+
['debes', /\bdebes\b/gi],
|
|
46
|
+
['tus', /\btus\b/gi],
|
|
47
|
+
['a la vez', /\ba la vez\b/gi],
|
|
48
|
+
['los datos', /\blos datos\b/gi],
|
|
49
|
+
['las opciones', /\blas opciones\b/gi],
|
|
50
|
+
['el resultado', /\bel resultado\b/gi],
|
|
51
|
+
['método de extracción', /\bmétodo de extracción\b/gi],
|
|
52
|
+
['uniformidad del molino', /\buniformidad del molino\b/gi],
|
|
53
|
+
['café recién tostado', /\bcafé recién tostado\b/gi],
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
type UnknownRecord = Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
function normalize(value: string): string {
|
|
59
|
+
return value
|
|
60
|
+
.replace(/<[^>]*>/g, ' ')
|
|
61
|
+
.replace(/ /gi, ' ')
|
|
62
|
+
.replace(/\s+/g, ' ')
|
|
63
|
+
.trim()
|
|
64
|
+
.toLocaleLowerCase('es');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isTechnicalInvariant(text: string): boolean {
|
|
68
|
+
return [
|
|
69
|
+
'data:image/svg+xml;base64',
|
|
70
|
+
'background-image: url',
|
|
71
|
+
'.layout-playground {',
|
|
72
|
+
'const samplerate =',
|
|
73
|
+
].some((pattern) => text.includes(pattern)) || (text.includes('presets') && text.includes('hz'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectString(value: string, output: string[]): void {
|
|
77
|
+
const text = normalize(value);
|
|
78
|
+
if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function collectObject(value: UnknownRecord, output: string[]): void {
|
|
82
|
+
Object.entries(value).forEach(([childKey, childValue]) => {
|
|
83
|
+
collectText(childValue, output, childKey);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function collectText(value: unknown, output: string[], key?: string): void {
|
|
88
|
+
if (key && STRUCTURAL_KEYS.has(key)) return;
|
|
89
|
+
if (typeof value === 'string') return collectString(value, output);
|
|
90
|
+
if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
|
|
91
|
+
if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function loadText(loader: unknown): Promise<string[]> {
|
|
95
|
+
if (typeof loader !== 'function') return [];
|
|
96
|
+
const module = await (loader as () => Promise<unknown>)();
|
|
97
|
+
const output: string[] = [];
|
|
98
|
+
if (module && typeof module === 'object') {
|
|
99
|
+
const record = module as UnknownRecord;
|
|
100
|
+
for (const key of TRANSLATABLE_KEYS) {
|
|
101
|
+
collectText(record[key], output, key);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return output;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function findSpanishMarkers(text: string[]): string[] {
|
|
108
|
+
const corpus = text.join(' ');
|
|
109
|
+
return SPANISH_MARKERS.flatMap(([label, pattern]) =>
|
|
110
|
+
pattern.test(corpus) ? [label] : [],
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function findCopiedFragments(spanish: string[], translated: string[]): string[] {
|
|
115
|
+
const corpus = translated.join(' ');
|
|
116
|
+
return spanish
|
|
117
|
+
.filter((fragment) => fragment.length >= 80 && corpus.includes(fragment))
|
|
118
|
+
.sort((a, b) => b.length - a.length)
|
|
119
|
+
.slice(0, 3);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe('Locales must not contain copied Spanish content', () => {
|
|
123
|
+
for (const entry of ALL_ENTRIES) {
|
|
124
|
+
it(`${entry.id} has no untranslated Spanish blocks`, async () => {
|
|
125
|
+
const spanish = await loadText(entry.i18n.es);
|
|
126
|
+
const failures: string[] = [];
|
|
127
|
+
|
|
128
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
129
|
+
if (locale === 'es') continue;
|
|
130
|
+
|
|
131
|
+
const translated = await loadText(loader);
|
|
132
|
+
const copiedFragments = findCopiedFragments(spanish, translated);
|
|
133
|
+
const markerHits = findSpanishMarkers(translated);
|
|
134
|
+
|
|
135
|
+
if (copiedFragments.length > 0 || markerHits.length >= 2) {
|
|
136
|
+
const details = [
|
|
137
|
+
copiedFragments.length > 0
|
|
138
|
+
? `copied fragments: ${copiedFragments
|
|
139
|
+
.map((fragment) => JSON.stringify(fragment.slice(0, 120)))
|
|
140
|
+
.join(', ')}`
|
|
141
|
+
: '',
|
|
142
|
+
markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
|
|
143
|
+
]
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.join('; ');
|
|
146
|
+
failures.push(`${locale}: ${details}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
expect(failures, failures.join('\n')).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
|
|
4
|
+
const COPY_THRESHOLD = 0.9;
|
|
5
|
+
|
|
6
|
+
const STRUCTURAL_KEYS = new Set([
|
|
7
|
+
'@context',
|
|
8
|
+
'@type',
|
|
9
|
+
'applicationCategory',
|
|
10
|
+
'columns',
|
|
11
|
+
'highlight',
|
|
12
|
+
'icon',
|
|
13
|
+
'level',
|
|
14
|
+
'operatingSystem',
|
|
15
|
+
'position',
|
|
16
|
+
'positive',
|
|
17
|
+
'price',
|
|
18
|
+
'priceCurrency',
|
|
19
|
+
'slug',
|
|
20
|
+
'trend',
|
|
21
|
+
'type',
|
|
22
|
+
'url',
|
|
23
|
+
'value',
|
|
24
|
+
'variant',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function normalizeText(value: string): string {
|
|
28
|
+
return value
|
|
29
|
+
.replace(/<[^>]*>/g, ' ')
|
|
30
|
+
.replace(/&(?:amp|lt|gt|quot|apos|nbsp);/gi, ' ')
|
|
31
|
+
.replace(/[\u2018\u2019]/g, "'")
|
|
32
|
+
.replace(/[\u201c\u201d]/g, '"')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim()
|
|
35
|
+
.toLocaleLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function collectText(value: unknown, path: string, parts: string[]): void {
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
const normalized = normalizeText(value);
|
|
41
|
+
if (normalized.length >= 2) parts.push(normalized);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
value.forEach((item, index) => collectText(item, `${path}[${index}]`, parts));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!value || typeof value !== 'object') return;
|
|
51
|
+
|
|
52
|
+
Object.entries(value).forEach(([key, child]) => {
|
|
53
|
+
if (STRUCTURAL_KEYS.has(key)) return;
|
|
54
|
+
collectText(child, `${path}.${key}`, parts);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function localeCorpus(content: unknown): string {
|
|
59
|
+
if (!content || typeof content !== 'object') return '';
|
|
60
|
+
|
|
61
|
+
const record = content as Record<string, unknown>;
|
|
62
|
+
const parts: string[] = [];
|
|
63
|
+
collectText(record.title, 'title', parts);
|
|
64
|
+
collectText(record.description, 'description', parts);
|
|
65
|
+
collectText(record.faqTitle, 'faqTitle', parts);
|
|
66
|
+
collectText(record.faq, 'faq', parts);
|
|
67
|
+
collectText(record.seo, 'seo', parts);
|
|
68
|
+
collectText(record.schemas, 'schemas', parts);
|
|
69
|
+
return parts.join(' ');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tokenCounts(text: string): Map<string, number> {
|
|
73
|
+
const counts = new Map<string, number>();
|
|
74
|
+
for (const token of text.match(/[\p{L}\p{N}]+/gu) ?? []) {
|
|
75
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
76
|
+
}
|
|
77
|
+
return counts;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function copySimilarity(left: string, right: string): number {
|
|
81
|
+
const leftCounts = tokenCounts(left);
|
|
82
|
+
const rightCounts = tokenCounts(right);
|
|
83
|
+
const leftTotal = [...leftCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
84
|
+
const rightTotal = [...rightCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
85
|
+
if (leftTotal === 0 || rightTotal === 0) return 0;
|
|
86
|
+
|
|
87
|
+
let shared = 0;
|
|
88
|
+
for (const [token, count] of leftCounts) {
|
|
89
|
+
shared += Math.min(count, rightCounts.get(token) ?? 0);
|
|
90
|
+
}
|
|
91
|
+
return (2 * shared) / (leftTotal + rightTotal);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('Locales must not copy another locale wholesale', () => {
|
|
95
|
+
ALL_ENTRIES.forEach((entry) => {
|
|
96
|
+
it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
|
|
97
|
+
const corpora = new Map<string, string>();
|
|
98
|
+
|
|
99
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
100
|
+
if (!loader) continue;
|
|
101
|
+
corpora.set(locale, localeCorpus(await loader()));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const locales = [...corpora.keys()];
|
|
105
|
+
const violations: string[] = [];
|
|
106
|
+
|
|
107
|
+
for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
|
|
108
|
+
for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
|
|
109
|
+
const left = locales[leftIndex];
|
|
110
|
+
const right = locales[rightIndex];
|
|
111
|
+
const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
|
|
112
|
+
|
|
113
|
+
if (similarity >= COPY_THRESHOLD) {
|
|
114
|
+
violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
|
|
@@ -384,6 +384,14 @@ const faq: LuggageCalculatorLocaleContent['faq'] = [
|
|
|
384
384
|
question: "Puis-je emporter un sac à dos et une valise cabine gratuitement ?",
|
|
385
385
|
answer: "Sur des compagnies comme Air France ou Lufthansa, oui. Sur des compagnies comme Ryanair ou Vueling (tarif de base), vous ne pouvez emporter qu'un petit sac sous le siège.",
|
|
386
386
|
},
|
|
387
|
+
{
|
|
388
|
+
question: "Les dimensions des compagnies changent-elles souvent ?",
|
|
389
|
+
answer: "Oui. Vérifiez les conditions de votre billet et la page officielle de la compagnie avant le départ, car les franchises peuvent évoluer.",
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
question: "Comment mesurer une valise cabine correctement ?",
|
|
393
|
+
answer: "Mesurez la hauteur avec les roues et la poignée comprise, puis la largeur et la profondeur aux points les plus larges.",
|
|
394
|
+
},
|
|
387
395
|
];
|
|
388
396
|
|
|
389
397
|
const howTo: LuggageCalculatorLocaleContent['howTo'] = [
|
|
@@ -391,6 +399,14 @@ const howTo: LuggageCalculatorLocaleContent['howTo'] = [
|
|
|
391
399
|
name: "Recherchez votre compagnie",
|
|
392
400
|
text: "Utilisez notre moteur de recherche pour voir les limites spécifiques de votre transporteur.",
|
|
393
401
|
},
|
|
402
|
+
{
|
|
403
|
+
name: "Mesurez votre bagage",
|
|
404
|
+
text: "Mesurez la hauteur, la largeur et la profondeur avec les roues et les poignées dans leur position habituelle.",
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
name: "Vérifiez le poids",
|
|
408
|
+
text: "Pesez le bagage complet et comparez le résultat avec la franchise de votre billet.",
|
|
409
|
+
},
|
|
394
410
|
];
|
|
395
411
|
|
|
396
412
|
const seo: LuggageCalculatorLocaleContent['seo'] = [
|
|
@@ -484,6 +500,15 @@ const seo: LuggageCalculatorLocaleContent['seo'] = [
|
|
|
484
500
|
type: "paragraph",
|
|
485
501
|
html: "Vérifier les mesures de vos bagages avant de partir pour l'aéroport vous fera économiser non seulement de l'argent (les frais de porte dépassent souvent 50€), mais aussi le stress de commencer vos vacances par une dispute au comptoir.",
|
|
486
502
|
},
|
|
503
|
+
{ type: "title", text: "Comparer les règles des compagnies", level: 3 },
|
|
504
|
+
{ type: "paragraph", html: "Chaque transporteur applique sa propre combinaison de dimensions et de poids. Utilisez le moteur de recherche avant de fermer votre valise." },
|
|
505
|
+
{ type: "title", text: "Mesurer l'article personnel", level: 3 },
|
|
506
|
+
{ type: "paragraph", html: "L'article personnel doit généralement entrer sous le siège devant vous. Mesurez-le rempli, car les poches et les côtés bombés comptent." },
|
|
507
|
+
{ type: "title", text: "Comprendre le bagage cabine", level: 3 },
|
|
508
|
+
{ type: "paragraph", html: "Le trolley cabine est souvent inclus seulement avec certains tarifs. Vérifiez l'option de priorité ou le forfait bagage de votre réservation." },
|
|
509
|
+
{ type: "title", text: "Pourquoi les centimètres comptent", level: 3 },
|
|
510
|
+
{ type: "paragraph", html: "Un écart de quelques centimètres peut empêcher le bagage d'entrer dans le gabarit à la porte. Ne vous fiez pas uniquement au volume annoncé." },
|
|
511
|
+
{ type: "tip", title: "Conseils pour le poids", html: "Placez les objets lourds près des roues et laissez une petite marge sous la limite autorisée. Vérifiez aussi les règles concernant les liquides et les batteries avant le départ." },
|
|
487
512
|
];
|
|
488
513
|
|
|
489
514
|
const faqSchema: WithContext<FAQPage> = {
|
|
@@ -228,7 +228,6 @@ const faq: ToolLocaleContent<MiniAdventuresUI>['faq'] = [
|
|
|
228
228
|
|
|
229
229
|
const howTo: ToolLocaleContent<MiniAdventuresUI>['howTo'] = [
|
|
230
230
|
{ name: "Generar", text: "Pulsa el botón de generación para que el algoritmo seleccione una categoría y un reto aleatorio." },
|
|
231
|
-
{ name: "Leer", text: "El sistema te dará una instrucción clara sobre qué hacer hoy (ej. ir a un lugar nuevo, probar un sabor diferente)." },
|
|
232
231
|
{ name: "Realizar", text: "Lo divertido es la espontaneidad. Intenta realizar el reto antes de que termine el día." },
|
|
233
232
|
{ name: "Completar", text: "Marca el reto como hecho para ganar insignias exclusivas y progresar." }
|
|
234
233
|
];
|
|
@@ -76,13 +76,20 @@ const seo: ToolLocaleContent<MiniAdventuresUI>['seo'] = [
|
|
|
76
76
|
columns: 2
|
|
77
77
|
},
|
|
78
78
|
{ type: "card", icon: "mdi:clock-fast", title: "Pas d'excuses", html: "Des aventures qui demandent moins de 15 minutes et zéro euro. Le temps n'est pas une barrière à la curiosité." },
|
|
79
|
-
{ type: "card", icon: "mdi:lock-outline", title: "Confidentialité Totale", html: "Vos progrès sont sauvegardés uniquement sur votre appareil." }
|
|
79
|
+
{ type: "card", icon: "mdi:lock-outline", title: "Confidentialité Totale", html: "Vos progrès sont sauvegardés uniquement sur votre appareil." },
|
|
80
|
+
{ type: "title", text: "Le cerveau aime la nouveauté", level: 3 },
|
|
81
|
+
{ type: "paragraph", html: "Une variation simple dans une journée prévisible attire l'attention et crée une occasion d'apprentissage sans exiger un grand voyage." },
|
|
82
|
+
{ type: "title", text: "Le bien-être émotionnel", level: 3 },
|
|
83
|
+
{ type: "paragraph", html: "Une surprise positive et maîtrisée peut améliorer l'humeur. Choisissez un défi compatible avec votre énergie et votre environnement." },
|
|
84
|
+
{ type: "title", text: "Explorer son quartier", level: 3 },
|
|
85
|
+
{ type: "paragraph", html: "Les rues et bâtiments proches offrent souvent des détails inconnus. L'objectif est de regarder autrement ce que vous connaissez déjà." },
|
|
80
86
|
];
|
|
81
87
|
|
|
82
88
|
const faq: ToolLocaleContent<MiniAdventuresUI>['faq'] = [
|
|
83
89
|
{ question: "Qu'est-ce qu'une micro-aventure ?", answer: "C'est une petite aventure proche de chez vous, peu coûteuse et simple. Le terme cherche à démontrer qu'il n'est pas nécessaire d'aller à l'autre bout du monde pour vivre des expériences passionnantes." },
|
|
84
90
|
{ question: "À quoi sert ce générateur ?", answer: "Il sert à combattre la paralysie de l'analyse. Parfois, nous voulons faire quelque chose de différent mais nous ne savons pas quoi." },
|
|
85
|
-
{ question: "Dois-je m'enregistrer ?", answer: "Non. C'est un outil de confidentialité totale. Les défis sont générés localement." }
|
|
91
|
+
{ question: "Dois-je m'enregistrer ?", answer: "Non. C'est un outil de confidentialité totale. Les défis sont générés localement." },
|
|
92
|
+
{ question: "Comment choisir un défi ?", answer: "Générez un défi adapté à votre quotidien, puis recommencez si les conditions de sécurité ou de temps ne conviennent pas." }
|
|
86
93
|
];
|
|
87
94
|
|
|
88
95
|
const howTo: ToolLocaleContent<MiniAdventuresUI>['howTo'] = [
|
|
@@ -190,7 +190,9 @@ const seo: ToolLocaleContent<SuitcaseChecklistUI>['seo'] = [
|
|
|
190
190
|
"Réduction drastique du stress logistique avec une checklist interactive.",
|
|
191
191
|
"Optimisation de l'espace pour éviter le syndrome du 'au cas où'."
|
|
192
192
|
]
|
|
193
|
-
}
|
|
193
|
+
},
|
|
194
|
+
{ type: "title", text: "Vérifiez la météo et le contexte local", level: 3 },
|
|
195
|
+
{ type: "paragraph", html: "Adaptez votre liste aux prévisions, aux activités prévues et aux règles locales de votre destination. Cette vérification finale permet d'emporter exactement ce dont vous aurez besoin, sans ajouter de poids inutile." }
|
|
194
196
|
];
|
|
195
197
|
|
|
196
198
|
const faq: ToolLocaleContent<SuitcaseChecklistUI>['faq'] = [
|