@jjlmoya/utils-travel 1.15.0 → 1.17.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 +2 -2
- package/src/entries.ts +4 -1
- package/src/index.ts +1 -0
- package/src/pages/[locale]/[slug].astro +1 -1
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/seo_parity.test.ts +60 -0
- package/src/tests/tool_validation.test.ts +2 -2
- 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/schengen-calculator/bibliography.astro +6 -0
- package/src/tool/schengen-calculator/bibliography.ts +18 -0
- package/src/tool/schengen-calculator/component.astro +199 -0
- package/src/tool/schengen-calculator/controller.ts +232 -0
- package/src/tool/schengen-calculator/dom-views.ts +122 -0
- package/src/tool/schengen-calculator/entry.ts +31 -0
- package/src/tool/schengen-calculator/evaluator.ts +74 -0
- package/src/tool/schengen-calculator/i18n/de.ts +274 -0
- package/src/tool/schengen-calculator/i18n/en.ts +274 -0
- package/src/tool/schengen-calculator/i18n/es.ts +274 -0
- package/src/tool/schengen-calculator/i18n/fr.ts +274 -0
- package/src/tool/schengen-calculator/i18n/id.ts +274 -0
- package/src/tool/schengen-calculator/i18n/it.ts +274 -0
- package/src/tool/schengen-calculator/i18n/ja.ts +273 -0
- package/src/tool/schengen-calculator/i18n/ko.ts +273 -0
- package/src/tool/schengen-calculator/i18n/nl.ts +274 -0
- package/src/tool/schengen-calculator/i18n/pl.ts +274 -0
- package/src/tool/schengen-calculator/i18n/pt.ts +274 -0
- package/src/tool/schengen-calculator/i18n/ru.ts +274 -0
- package/src/tool/schengen-calculator/i18n/sv.ts +274 -0
- package/src/tool/schengen-calculator/i18n/tr.ts +274 -0
- package/src/tool/schengen-calculator/i18n/zh.ts +271 -0
- package/src/tool/schengen-calculator/index.ts +10 -0
- package/src/tool/schengen-calculator/logic.test.ts +79 -0
- package/src/tool/schengen-calculator/logic.ts +164 -0
- package/src/tool/schengen-calculator/schengen-calculator.css +565 -0
- package/src/tool/schengen-calculator/seo.astro +14 -0
- package/src/tool/schengen-calculator/storage.ts +38 -0
- package/src/tool/schengen-calculator/ui.ts +38 -0
- package/src/tool/suitcase-checklist/i18n/fr.ts +3 -1
- package/src/tools.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-travel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"check": "astro check",
|
|
29
29
|
"type-check": "astro check",
|
|
30
30
|
"test": "vitest run",
|
|
31
|
-
"preversion": "npm run lint && npm run test",
|
|
31
|
+
"preversion": "npm run lint && npm run test && npm run build",
|
|
32
32
|
"postversion": "git push && git push --tags",
|
|
33
33
|
"patch": "npm version patch",
|
|
34
34
|
"minor": "npm version minor",
|
package/src/entries.ts
CHANGED
|
@@ -6,9 +6,12 @@ export { suitcaseChecklist } from './tool/suitcase-checklist/entry';
|
|
|
6
6
|
export type { ChecklistItem, ChecklistCategory, SuitcaseChecklistUI } from './tool/suitcase-checklist/entry';
|
|
7
7
|
export { tipCalculator } from './tool/tip-calculator/entry';
|
|
8
8
|
export type { TipCountry, TipCalculatorUI } from './tool/tip-calculator/entry';
|
|
9
|
+
export { schengenCalculator } from './tool/schengen-calculator/entry';
|
|
10
|
+
export type { SchengenCalculatorUI } from './tool/schengen-calculator/entry';
|
|
9
11
|
export { travelCategory } from './category';
|
|
10
12
|
import { luggageCalculator } from './tool/luggage-calculator/entry';
|
|
11
13
|
import { miniAdventures } from './tool/mini-adventures/entry';
|
|
12
14
|
import { suitcaseChecklist } from './tool/suitcase-checklist/entry';
|
|
13
15
|
import { tipCalculator } from './tool/tip-calculator/entry';
|
|
14
|
-
|
|
16
|
+
import { schengenCalculator } from './tool/schengen-calculator/entry';
|
|
17
|
+
export const ALL_ENTRIES = [luggageCalculator, miniAdventures, suitcaseChecklist, tipCalculator, schengenCalculator];
|
package/src/index.ts
CHANGED
|
@@ -20,3 +20,4 @@ export { luggageCalculator, LUGGAGE_CALCULATOR_TOOL } from './tool/luggage-calcu
|
|
|
20
20
|
export { tipCalculator, TIP_CALCULATOR_TOOL } from './tool/tip-calculator';
|
|
21
21
|
export { suitcaseChecklist, SUITCASE_CHECKLIST_TOOL } from './tool/suitcase-checklist';
|
|
22
22
|
export { miniAdventures, MINI_ADVENTURES_TOOL } from './tool/mini-adventures';
|
|
23
|
+
export { schengenCalculator, SCHENGEN_CALCULATOR_TOOL } from './tool/schengen-calculator';
|
|
@@ -15,7 +15,7 @@ export async function getStaticPaths() {
|
|
|
15
15
|
const paths = [];
|
|
16
16
|
|
|
17
17
|
for (const { entry, Component: lazyComp } of ALL_TOOLS) {
|
|
18
|
-
const { default: Component } = await lazyComp();
|
|
18
|
+
const { default: Component } = (await lazyComp()) as { default: unknown };
|
|
19
19
|
const localeEntries = Object.entries(entry.i18n) as [
|
|
20
20
|
KnownLocale,
|
|
21
21
|
() => Promise<ToolLocaleContent>,
|
|
@@ -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
|
+
});
|
|
@@ -4,8 +4,8 @@ import { travelCategory } from '../category';
|
|
|
4
4
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
|
-
it('should have
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 5 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(5);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('travelCategory should be defined', () => {
|
|
@@ -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'] = [
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliographyEntries: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'European Commission - Visa Policy and Schengen Borders Code',
|
|
6
|
+
url: 'https://home-affairs.ec.europa.eu/policies/schengen/visa-policy_en',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'EUR-Lex - Regulation (EU) 2016/399 (Schengen Borders Code)',
|
|
10
|
+
url: 'https://eur-lex.europa.eu/eli/reg/2016/399/oj/eng',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'European External Action Service (EEAS) - Travelling to the Schengen Area',
|
|
14
|
+
url: 'https://www.eeas.europa.eu/eeas/travel-europe-european-entryexit-system-ees_en',
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const bibliography = bibliographyEntries;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { SchengenCalculatorUI } from './ui';
|
|
3
|
+
import './schengen-calculator.css';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
ui?: SchengenCalculatorUI;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { ui: t } = Astro.props;
|
|
10
|
+
const defaultUI: SchengenCalculatorUI = {
|
|
11
|
+
verdictSafeTitle: 'Safe to Travel (Within Legal Limits)',
|
|
12
|
+
verdictWarningTitle: 'Caution: Approaching 90-Day Limit',
|
|
13
|
+
verdictOverstayTitle: 'Illegal Overstay Detected',
|
|
14
|
+
daysRemainingSub: 'Days Allowed Remaining',
|
|
15
|
+
daysUsedSub: 'Days Used in 180-Day Window',
|
|
16
|
+
maxStaySub: 'Max Continuous Stay from Date',
|
|
17
|
+
fullResetSub: 'Full 90-Day Reset Date',
|
|
18
|
+
plannerHeading: '1. Check Status on Target Date',
|
|
19
|
+
plannerEntryLabel: 'Evaluation Date (Entry / Flight Date)',
|
|
20
|
+
quickDatesLabel: 'Jump to Date',
|
|
21
|
+
presetToday: 'Today',
|
|
22
|
+
presetPlus7: '+1 Week',
|
|
23
|
+
presetPlus14: '+2 Weeks',
|
|
24
|
+
presetPlus30: '+1 Month',
|
|
25
|
+
tripsHeading: '2. Your Schengen Trips (Past & Planned)',
|
|
26
|
+
addTripBtn: '+ Add Trip',
|
|
27
|
+
emptyTripsMsg: 'No trips added yet. Add past or planned trips to calculate your Schengen allowance.',
|
|
28
|
+
colArrival: 'Entry (Arrival)',
|
|
29
|
+
colDeparture: 'Exit (Departure)',
|
|
30
|
+
colDestination: 'Country / Notes',
|
|
31
|
+
colDays: 'Days',
|
|
32
|
+
sampleBtn: 'Load Sample Trips',
|
|
33
|
+
clearBtn: 'Clear All',
|
|
34
|
+
timelineTitle: '180-Day Rolling Window',
|
|
35
|
+
legendInSchengen: 'In Schengen',
|
|
36
|
+
legendOutside: 'Outside',
|
|
37
|
+
legendOverstay: 'Overstay',
|
|
38
|
+
bannerSafe: 'On {date}, you will have used {used} ({rem} available).',
|
|
39
|
+
bannerWarning: 'On {date}, you will have used {used} (only {rem} remaining).',
|
|
40
|
+
bannerOverstay: 'Overstay violation detected starting on {date}. Your itinerary exceeds the legal limit by {days}.',
|
|
41
|
+
unitDays: 'days',
|
|
42
|
+
notesPlaceholder: 'e.g. France, Spain',
|
|
43
|
+
sampleNotes1: 'Italy Roadtrip (20 days)',
|
|
44
|
+
sampleNotes2: 'Germany & Austria (20 days)',
|
|
45
|
+
sampleNotesDefault: 'France & Spain',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ui = { ...defaultUI, ...(t ?? {}) };
|
|
49
|
+
const uiJson = JSON.stringify(ui);
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
<div class="schengen-card" id="sc-app" data-i18n={uiJson}>
|
|
53
|
+
<div class="sc-hero">
|
|
54
|
+
<div id="sc-status-badge" class="sc-status-pill legal">
|
|
55
|
+
<span id="sc-status-text">{ui.verdictSafeTitle}</span>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="sc-explanation-banner" id="sc-explanation-banner">
|
|
59
|
+
On <strong id="sc-banner-date">--</strong>, you will have used <strong id="sc-banner-used">0 {ui.unitDays}</strong> (<strong id="sc-banner-rem">90 {ui.unitDays} available</strong>).
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div class="sc-gauge-wrap">
|
|
63
|
+
<svg class="sc-gauge-svg" viewBox="0 0 120 120">
|
|
64
|
+
<circle class="sc-gauge-track" cx="60" cy="60" r="50" />
|
|
65
|
+
<circle
|
|
66
|
+
class="sc-gauge-bar"
|
|
67
|
+
id="sc-gauge-circle"
|
|
68
|
+
cx="60"
|
|
69
|
+
cy="60"
|
|
70
|
+
r="50"
|
|
71
|
+
stroke-dasharray="314.159"
|
|
72
|
+
stroke-dashoffset="314.159"
|
|
73
|
+
/>
|
|
74
|
+
</svg>
|
|
75
|
+
<div class="sc-gauge-content">
|
|
76
|
+
<span class="sc-gauge-num" id="sc-days-remaining">90</span>
|
|
77
|
+
<span class="sc-gauge-caption" id="sc-gauge-caption">{ui.daysRemainingSub}</span>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="sc-metrics-grid">
|
|
82
|
+
<div class="sc-metric-card">
|
|
83
|
+
<span class="sc-metric-title">{ui.daysUsedSub}</span>
|
|
84
|
+
<span class="sc-metric-val" id="sc-days-used">0 / 90</span>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="sc-metric-card">
|
|
87
|
+
<span class="sc-metric-title">{ui.maxStaySub}</span>
|
|
88
|
+
<span class="sc-metric-val" id="sc-max-continuous">90 {ui.unitDays}</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="sc-metric-card">
|
|
91
|
+
<span class="sc-metric-title">{ui.fullResetSub}</span>
|
|
92
|
+
<span class="sc-metric-val" id="sc-reset-date">--</span>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div class="sc-divider"></div>
|
|
98
|
+
|
|
99
|
+
<div class="sc-body">
|
|
100
|
+
<div class="sc-section-block">
|
|
101
|
+
<div class="sc-section-header">
|
|
102
|
+
<span class="sc-section-title">{ui.plannerHeading}</span>
|
|
103
|
+
<div class="sc-chip-group">
|
|
104
|
+
<button type="button" class="sc-chip-mini" id="sc-preset-today">{ui.presetToday}</button>
|
|
105
|
+
<button type="button" class="sc-chip-mini" id="sc-preset-plus7">{ui.presetPlus7}</button>
|
|
106
|
+
<button type="button" class="sc-chip-mini" id="sc-preset-plus14">{ui.presetPlus14}</button>
|
|
107
|
+
<button type="button" class="sc-chip-mini" id="sc-preset-plus30">{ui.presetPlus30}</button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="sc-planner-box">
|
|
111
|
+
<div class="sc-field-group">
|
|
112
|
+
<label for="sc-ref-date-input" class="sc-field-label">{ui.plannerEntryLabel}</label>
|
|
113
|
+
<div class="sc-date-wrap">
|
|
114
|
+
<input type="date" id="sc-ref-date-input" class="sc-input-control" />
|
|
115
|
+
<span class="sc-date-icon-btn" aria-hidden="true">
|
|
116
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
|
117
|
+
<path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2zM9 14H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2zm-8 4H7v-2h2v2zm4 4h-2v-2h2v2zm4 0h-2v-2h2v2z"/>
|
|
118
|
+
</svg>
|
|
119
|
+
</span>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div class="sc-section-block">
|
|
126
|
+
<div class="sc-section-header">
|
|
127
|
+
<span class="sc-section-title">{ui.tripsHeading}</span>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="sc-trips-table-header">
|
|
130
|
+
<span>{ui.colArrival}</span>
|
|
131
|
+
<span>{ui.colDeparture}</span>
|
|
132
|
+
<span>{ui.colDestination}</span>
|
|
133
|
+
<span style="text-align:center;">{ui.colDays}</span>
|
|
134
|
+
<span></span>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="sc-trips-container" id="sc-trips-container"></div>
|
|
137
|
+
<div id="sc-empty-msg" class="sc-empty-box">{ui.emptyTripsMsg}</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div class="sc-timeline-bar-wrap">
|
|
141
|
+
<div class="sc-ribbon-legend">
|
|
142
|
+
<span>{ui.timelineTitle}</span>
|
|
143
|
+
<div class="sc-legend-items">
|
|
144
|
+
<div class="sc-leg-item">
|
|
145
|
+
<span class="sc-dot spent"></span>
|
|
146
|
+
<span>{ui.legendInSchengen}</span>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="sc-leg-item">
|
|
149
|
+
<span class="sc-dot free"></span>
|
|
150
|
+
<span>{ui.legendOutside}</span>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="sc-leg-item">
|
|
153
|
+
<span class="sc-dot overstay"></span>
|
|
154
|
+
<span>{ui.legendOverstay}</span>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
<div class="sc-ribbon" id="sc-timeline-ribbon"></div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="sc-footer-actions">
|
|
163
|
+
<div class="sc-chip-group">
|
|
164
|
+
<button type="button" class="sc-btn-ghost" id="sc-sample-btn">{ui.sampleBtn}</button>
|
|
165
|
+
<button type="button" class="sc-btn-ghost" id="sc-clear-btn">{ui.clearBtn}</button>
|
|
166
|
+
</div>
|
|
167
|
+
<button type="button" class="sc-btn-main" id="sc-add-trip-btn">
|
|
168
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
169
|
+
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
|
170
|
+
</svg>
|
|
171
|
+
<span>{ui.addTripBtn}</span>
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<script>
|
|
177
|
+
import type { SchengenCalculatorUI } from './ui';
|
|
178
|
+
import { SchengenCalculatorController } from './controller';
|
|
179
|
+
|
|
180
|
+
function initApp() {
|
|
181
|
+
const appEl = document.getElementById('sc-app');
|
|
182
|
+
let i18n = {} as SchengenCalculatorUI;
|
|
183
|
+
if (appEl && appEl.dataset.i18n) {
|
|
184
|
+
try {
|
|
185
|
+
i18n = JSON.parse(appEl.dataset.i18n);
|
|
186
|
+
} catch {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const controller = new SchengenCalculatorController(i18n);
|
|
191
|
+
controller.init();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (document.readyState === 'loading') {
|
|
195
|
+
document.addEventListener('DOMContentLoaded', initApp);
|
|
196
|
+
} else {
|
|
197
|
+
initApp();
|
|
198
|
+
}
|
|
199
|
+
</script>
|