@mostajs/wagon-ui 0.3.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@
5
5
 
6
6
 
7
7
 
8
+ ## 0.5.0 — 2026-10-08
9
+
10
+ L'en-tête de la maquette Bolt de travel, devenu disponible pour tous les sites du wagon. Trois options, toutes
11
+ désactivées par défaut : un site qui ne les demande pas reste identique.
12
+
13
+ **Ajouté**
14
+ - `collant: true` : l'en-tête reste en haut de l'écran au défilement (`position: sticky`).
15
+ - `appel: { href, libelle: { fr, en, ar } }` : un bouton d'action dans l'en-tête (« Demander un devis »).
16
+ - `selecteurLangue: 'menu'` : la langue se choisit dans un menu déroulant (`<details>`, donc sans script),
17
+ langue courante marquée par `aria-current` ; sur mobile, le menu se déplie dans le hamburger.
18
+
19
+ **Essais** : T-WUI-19 à 21.
20
+
21
+ ## 0.4.0 — 2026-10-08
22
+
23
+ - **Les textes passent par `@mostajs/i18n`** (≥ 0.2.0) au lieu d'un traducteur maison : même ordre de résolution
24
+ (surcharges `textes` de l'application, catalogue de la langue, français), même API `ui.t(lang)(cle, vars)`.
25
+ - **Pluriels CLDR** : `{n}` sert de compte. Fini « clé(s) active(s) », « restant(e)s » et la paire
26
+ `cles`/`clesPluriel` ; l'arabe a ses six formes — « بمفتاحين » pour deux, « 11 مفتاحًا » de 11 à 99, au lieu de
27
+ « مفتاح/مفاتيح ». Clés touchées : `tarifs.cles` (+ `tarifs.clesIllimitees`), `espace.appels`,
28
+ `espace.plafondAtteint`, `espace.restant` (« Reste : {n} »). ⚠️ Une application qui surchargeait `tarifs.clesPluriel`
29
+ dans `textes` doit passer à `tarifs.cles_other`.
30
+ - **Prix formatés dans la langue** : « 9,99 € », « €9.99 », et en arabe `ar-DZ` aux chiffres latins — au lieu de
31
+ « 9.99 € » avec un point partout. `ui.euros(centimes, lang)` exposé (pour les suites de conformité).
32
+ - Essais T-WUI-16 (catalogues vérifiés par `checkCatalogues`, clés construites `legal.*` comprises), T-WUI-17
33
+ (pluriels, duel arabe), T-WUI-18 (prix) ; T-WUI-7 compare désormais les clés LOGIQUES. Trois sabotages, détectés.
34
+
8
35
  ## 0.3.1 — 2026-10-02
9
36
 
10
37
  - **Correctif** : l'en-tête `entete: 'barre'` portait la classe `barre`, déjà prise par la jauge de consommation
package/llms.txt CHANGED
@@ -20,12 +20,14 @@ Rendre les écrans communs à tout SaaS, identiques d'un produit à l'autre, san
20
20
  - `ui.adminPaliers({ lang, offres, message? })` — champs `<slug>.<limite>` et `<slug>.maxCles` (POST /admin/plans).
21
21
  - `ui.adminUsage({ lang, usage: { jours, totaux: { comptes, actifs, payants, erreurs, parRessource }, lignes: [{ nom, plan, actif, dernierJour, erreursFenetre, parRessource: { <cle>: { courant, limite, part, fenetre } } }] }, anonyme? })`
22
22
  - `ui.legal({ lang, doc: 'mentions-legales'|'cgu'|'cgv'|'confidentialite', html })` · `ui.introuvable({ lang })`
23
- - `ui.t(lang)(cle, vars)` · `CATALOGUES` · `dirDe(lang)` · `esc`
23
+ - `ui.t(lang)(cle, vars)` (par @mostajs/i18n : `{n}` = compte, pluriels CLDR, six formes en arabe) · `ui.euros(centimes, lang)` · `CATALOGUES` · `dirDe(lang)` · `esc`
24
24
 
25
25
  ## ROUTES ATTENDUES (liens et formulaires des vues)
26
26
  `/`, `/tarifs`, `/aide`, `/apropos`, `/inscription` (POST : plan, nom, courriel, motdepasse), `/connexion` (POST : courriel, motdepasse), `/deconnexion`, `/compte`, `/compte/cles` (POST : libelle), `/compte/cles/revoquer` (POST : prefix), `/compte/portail` (POST), `/paiement` (POST : compte, plan), `/admin/plans` (POST), `/admin/usage`, `/mentions-legales`, `/cgu`, `/cgv`, `/confidentialite`.
27
27
 
28
28
  ## PIÈGES
29
+ - 0.5.0 : `creerWagonUi({ …, collant: true, appel: { href, libelle }, selecteurLangue: 'menu' })` — en-tête collant, bouton d'appel, langue en menu déroulant sans script ; défauts inchangés.
30
+ - 0.4.0 : `tarifs.clesPluriel` n'existe plus → `tarifs.cles_one` / `tarifs.cles_other` (+ formes arabes) ; prix formatés par langue (« 9,99 € »).
29
31
  - `legal({ html })` n'échappe pas `html` : il doit venir des textes de l'éditeur (mostajs/templates), jamais d'un formulaire.
30
32
  - Les prix sont en CENTIMES. `-1` = sans limite ; `0` = non compris.
31
33
  - Une ressource sans `parPeriode` s'affiche « libellé : n ».
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mostajs/wagon-ui",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Les pages du wagon d'un SaaS — mise en page (langues, pied légal, en-têtes pour les moteurs), accueil, tarifs, inscription, connexion (mot de passe ou code), espace client (palier, jauges, clés, factures, gestion de l'abonnement), administration (paliers, usage), pages légales — en fr, en, ar. Extraites de mdseal, sans ce qu'il vend : l'identité et les ressources mesurées viennent de la configuration. Vues pures, zéro dépendance.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Dr Hamid MADANI <drmdh@msn.com>",
@@ -68,5 +68,8 @@
68
68
  },
69
69
  "devDependencies": {
70
70
  "@mostajs/mjs-unit": "^0.7.0"
71
+ },
72
+ "dependencies": {
73
+ "@mostajs/i18n": "^0.2.0"
71
74
  }
72
75
  }
package/src/i18n/ar.js CHANGED
@@ -26,8 +26,13 @@ export const ar = {
26
26
  "titre": "الأسعار",
27
27
  "parMois": "/شهريًّا",
28
28
  "gratuit": "مجّاني",
29
- "cles": "مفتاح واجهة واحد",
30
- "clesPluriel": "{n} مفاتيح واجهة",
29
+ "cles_zero": "بلا مفتاح واجهة",
30
+ "cles_one": "مفتاح واجهة واحد",
31
+ "cles_two": "مفتاحا واجهة",
32
+ "cles_few": "{n} مفاتيح واجهة",
33
+ "cles_many": "{n} مفتاحًا للواجهة",
34
+ "cles_other": "{n} مفتاح واجهة",
35
+ "clesIllimitees": "مفاتيح واجهة بلا حدّ",
31
36
  "choisir": "اختر هذه الخطّة",
32
37
  "commencer": "ابدأ مجّانًا",
33
38
  "encours": "الدفع غير متاح حاليًّا",
@@ -76,8 +81,18 @@ export const ar = {
76
81
  "revoquer": "إلغاء",
77
82
  "revoquee": "مُلغًى",
78
83
  "jamaisUtilisee": "لم يُستعمَل قطّ",
79
- "appels": "{n} نداء",
80
- "plafondAtteint": "تسمح خطّتك بـ {n} مفتاح/مفاتيح نشِطة.",
84
+ "appels_zero": "لا نداءات",
85
+ "appels_one": "نداء واحد",
86
+ "appels_two": "نداءان",
87
+ "appels_few": "{n} نداءات",
88
+ "appels_many": "{n} نداءً",
89
+ "appels_other": "{n} نداء",
90
+ "plafondAtteint_zero": "لا تسمح خطّتك بأيّ مفتاح نشِط.",
91
+ "plafondAtteint_one": "تسمح خطّتك بمفتاح نشِط واحد.",
92
+ "plafondAtteint_two": "تسمح خطّتك بمفتاحين نشِطين.",
93
+ "plafondAtteint_few": "تسمح خطّتك بـ {n} مفاتيح نشِطة.",
94
+ "plafondAtteint_many": "تسمح خطّتك بـ {n} مفتاحًا نشِطًا.",
95
+ "plafondAtteint_other": "تسمح خطّتك بـ {n} مفتاح نشِط.",
81
96
  "cleCreee": "أُنشئ المفتاح. انسخه: لن يُعرَض مرّة أخرى.",
82
97
  "gererAbonnement": "إدارة اشتراكي",
83
98
  "gererAide": "وسيلة الدفع والفواتير والإلغاء — لدى Stripe.",
package/src/i18n/en.js CHANGED
@@ -26,8 +26,9 @@ export const en = {
26
26
  "titre": "Pricing",
27
27
  "parMois": "/month",
28
28
  "gratuit": "Free",
29
- "cles": "{n} API key",
30
- "clesPluriel": "{n} API keys",
29
+ "cles_one": "{n} API key",
30
+ "cles_other": "{n} API keys",
31
+ "clesIllimitees": "Unlimited API keys",
31
32
  "choisir": "Choose this plan",
32
33
  "commencer": "Start for free",
33
34
  "encours": "Payment is not available yet",
@@ -76,8 +77,10 @@ export const en = {
76
77
  "revoquer": "Revoke",
77
78
  "revoquee": "Revoked",
78
79
  "jamaisUtilisee": "never used",
79
- "appels": "{n} calls",
80
- "plafondAtteint": "Your plan allows {n} active key(s).",
80
+ "appels_one": "{n} call",
81
+ "appels_other": "{n} calls",
82
+ "plafondAtteint_one": "Your plan allows {n} active key.",
83
+ "plafondAtteint_other": "Your plan allows {n} active keys.",
81
84
  "cleCreee": "Key created. Copy it: it will not be shown again.",
82
85
  "gererAbonnement": "Manage my subscription",
83
86
  "gererAide": "Payment method, invoices, cancellation — at Stripe.",
package/src/i18n/fr.js CHANGED
@@ -26,8 +26,9 @@ export const fr = {
26
26
  "titre": "Tarifs",
27
27
  "parMois": "/mois",
28
28
  "gratuit": "Gratuit",
29
- "cles": "{n} clé d’API",
30
- "clesPluriel": "{n} clés d’API",
29
+ "cles_one": "{n} clé d’API",
30
+ "cles_other": "{n} clés d’API",
31
+ "clesIllimitees": "Clés d’API sans limite",
31
32
  "choisir": "Choisir ce plan",
32
33
  "commencer": "Commencer gratuitement",
33
34
  "encours": "Paiement indisponible pour l’instant",
@@ -68,7 +69,7 @@ export const fr = {
68
69
  "votrePlan": "Votre plan",
69
70
  "consommationDuJour": "Aujourd’hui",
70
71
  "consommationDuMois": "Ce mois-ci",
71
- "restant": "{n} restant(e)s",
72
+ "restant": "Reste : {n}",
72
73
  "illimite": "sans limite",
73
74
  "vosCles": "Vos clés d’API",
74
75
  "creerUneCle": "Créer une clé",
@@ -76,8 +77,10 @@ export const fr = {
76
77
  "revoquer": "Révoquer",
77
78
  "revoquee": "Révoquée",
78
79
  "jamaisUtilisee": "jamais utilisée",
79
- "appels": "{n} appels",
80
- "plafondAtteint": "Votre plan permet {n} clé(s) active(s).",
80
+ "appels_one": "{n} appel",
81
+ "appels_other": "{n} appels",
82
+ "plafondAtteint_one": "Votre plan permet {n} clé active.",
83
+ "plafondAtteint_other": "Votre plan permet {n} clés actives.",
81
84
  "cleCreee": "Clé créée. Copiez-la : elle ne sera plus affichée.",
82
85
  "gererAbonnement": "Gérer mon abonnement",
83
86
  "gererAide": "Moyen de paiement, factures, résiliation — chez Stripe.",
package/src/index.js CHANGED
@@ -21,6 +21,7 @@
21
21
  * impression) ; le code reste de gauche à droite dans une page arabe ; une clé n'est montrée
22
22
  * qu'une fois ; la gestion de l'abonnement n'apparaît que si elle est possible.
23
23
  */
24
+ import { createI18n, formatNumber, resolveFormatLocale } from '@mostajs/i18n';
24
25
  import { fr } from './i18n/fr.js';
25
26
  import { en } from './i18n/en.js';
26
27
  import { ar } from './i18n/ar.js';
@@ -37,7 +38,11 @@ const lien = (href) => {
37
38
  return /^(\/(?!\/)|https?:\/\/|mailto:|tel:|#|\?)/i.test(h) ? esc(h) : '#';
38
39
  };
39
40
  const NBSP = ' ';
40
- const euros = (centimes) => `${(Number(centimes) || 0) / 100}${NBSP}€`;
41
+ /**
42
+ * Un prix en euros, formaté dans la langue (0.4.0) : « 9,99 € », « €9.99 », et en arabe la locale ar-DZ aux
43
+ * chiffres latins (@mostajs/i18n) — plus « 9.99 € » avec un point, en français comme ailleurs.
44
+ */
45
+ const euros = (centimes, lang = 'fr') => formatNumber(resolveFormatLocale(lang), (Number(centimes) || 0) / 100, { style: 'currency', currency: 'EUR' });
41
46
 
42
47
  // 0.3 : les jetons portent les NOMS de Palette Studio (palette.amia.fr) — un thème qui les redéfinit habille le site
43
48
  // entier ; les anciens noms en dérivent par var(), donc le suivent. Contrastes des replis vérifiés sur blanc.
@@ -111,7 +116,18 @@ header.bande{max-width:none;background:var(--brand);border-bottom:0;padding:.8re
111
116
  header.bande .brand,header.bande nav a,header.bande .moi a,header.bande .langues a,header.bande .langues b,header.bande .langues{color:var(--text-on-brand)}
112
117
  header.bande nav a{opacity:.9} header.bande nav a.on,header.bande nav a:hover{background:rgba(255,255,255,.16);opacity:1}
113
118
  header.bande .burger{color:var(--text-on-brand);border-color:rgba(255,255,255,.4)}
114
- header.bande + main{padding-top:1.2rem}`;
119
+ header.bande + main{padding-top:1.2rem}
120
+ header.collant{position:sticky;top:0;z-index:50;box-shadow:0 6px 20px -14px rgba(15,23,42,.45)}
121
+ header .appel{display:inline-block;padding:.45em 1.1em;border-radius:99px;background:var(--surface);color:var(--brand);font-weight:700;text-decoration:none;white-space:nowrap;border:1px solid var(--line)}
122
+ header.bande .appel{background:var(--surface);color:var(--brand);border-color:transparent}
123
+ .choix-langue{position:relative;font-size:.9em}
124
+ .choix-langue summary{list-style:none;cursor:pointer;padding:.35em .8em;border-radius:99px;border:1px solid var(--line);color:var(--link);user-select:none;white-space:nowrap}
125
+ .choix-langue summary::-webkit-details-marker{display:none;}
126
+ header.bande .choix-langue summary{color:var(--text-on-brand);border-color:rgba(255,255,255,.35);background:rgba(255,255,255,.1)}
127
+ .choix-langue ul{position:absolute;inset-inline-end:0;top:calc(100% + .4em);margin:0;padding:.3em 0;list-style:none;min-width:10em;background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:0 18px 35px -18px rgba(15,23,42,.45);z-index:60}
128
+ .choix-langue li a{display:block;padding:.45em 1em;color:var(--ink,var(--text));text-decoration:none}
129
+ .choix-langue li a:hover{background:var(--fond-doux)} .choix-langue li a[aria-current]{font-weight:700;color:var(--brand)}
130
+ @media (max-width: 760px){ .choix-langue ul{position:static;box-shadow:none;margin-top:.3em} header .appel{text-align:center} }`;
115
131
  }
116
132
 
117
133
  /** Un thème de Palette Studio n'est accepté que s'il ne fait que redéfinir des jetons dans :root{…}. */
@@ -156,7 +172,7 @@ const DOCUMENTS_LEGAUX = Object.freeze([['mentions-legales', 'mentions'], ['cgu'
156
172
  * parPeriode?: string|object, sansLimite?: string|object, aucun?: string|object }>} [o.ressources]
157
173
  * @param {object} [o.textes] surcharges des libellés, par langue : { fr: { tarifs: { note: '…' } } }
158
174
  */
159
- export function creerWagonUi({ marque, couleurs = {}, langues = ['fr', 'en', 'ar'], liens = [], ressources = [], textes = {}, theme = null, entete = 'simple' } = {}) {
175
+ export function creerWagonUi({ marque, couleurs = {}, langues = ['fr', 'en', 'ar'], liens = [], ressources = [], textes = {}, theme = null, entete = 'simple', collant = false, appel = null, selecteurLangue = 'liens' } = {}) {
160
176
  if (!String(marque?.nom ?? '').trim()) throw new Error('@mostajs/wagon-ui : `marque.nom` requis — le nom du service');
161
177
  if (!String(marque?.auteur ?? '').trim()) throw new Error('@mostajs/wagon-ui : `marque.auteur` requis — qui édite le service');
162
178
  const LANGUES = langues.filter((l) => CATALOGUES[l]);
@@ -166,11 +182,26 @@ export function creerWagonUi({ marque, couleurs = {}, langues = ['fr', 'en', 'ar
166
182
  /** Le thème courant (Palette Studio), relu à chaque page ; rejeté s'il sort de :root{…}. */
167
183
  const themeCss = () => { try { const t = typeof theme === 'function' ? String(theme() ?? '') : ''; return THEME_SAIN.test(t) ? t : ''; } catch { return ''; } };
168
184
 
169
- /** Le traducteur d'une langue : surcharges de l'application, puis le catalogue, puis le français. */
185
+ /**
186
+ * Le traducteur d'une langue, par @mostajs/i18n (0.4.0) : surcharges de l'application, puis le catalogue, puis
187
+ * le français. Pluriels CLDR — `{n}` sert de compte : « 1 clé active », « 3 clés actives », et en arabe les six
188
+ * formes (« بمفتاحين » pour deux). Une clé absente se rend telle quelle, comme avant.
189
+ */
190
+ const i18n = Object.fromEntries(LANGUES.map((l) => [l, createI18n({
191
+ locale: l, fallback: 'fr',
192
+ bundles: [
193
+ { locale: 'fr', namespace: 'wagon', messages: CATALOGUES.fr },
194
+ ...(l === 'fr' ? [] : [{ locale: l, namespace: 'wagon', messages: CATALOGUES[l] }]),
195
+ ...['fr', l].filter((x, k, a) => a.indexOf(x) === k && textes[x]).map((x) => ({ locale: x, namespace: 'wagon', messages: textes[x] })),
196
+ ],
197
+ })]));
170
198
  function t(lang) {
171
199
  const L = LANGUES.includes(lang) ? lang : LANGUES[0];
172
- const chercher = (cat, cle) => cle.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), cat);
173
- return (cle, vars) => remplir(chercher(textes[L] ?? {}, cle) ?? chercher(CATALOGUES[L], cle) ?? chercher(CATALOGUES.fr, cle) ?? cle, vars);
200
+ return (cle, vars) => {
201
+ const v = vars && typeof vars.n === 'number' && vars.count === undefined ? { ...vars, count: vars.n } : vars;
202
+ const r = i18n[L].t(`wagon:${cle}`, v);
203
+ return r === `wagon:${cle}` ? cle : r;
204
+ };
174
205
  }
175
206
  const langueDe = (lang) => (LANGUES.includes(lang) ? lang : LANGUES[0]);
176
207
 
@@ -184,6 +215,8 @@ export function creerWagonUi({ marque, couleurs = {}, langues = ['fr', 'en', 'ar
184
215
 
185
216
  const ui = {
186
217
  t, dir: dirDe, langues: LANGUES, style: STYLE,
218
+ /** Un prix en euros dans la langue : `ui.euros(999, 'fr')` → « 9,99 € ». */
219
+ euros: (centimes, lang) => euros(centimes, langueDe(lang)),
187
220
 
188
221
  /**
189
222
  * La page entière. `contenu` est du HTML déjà rendu (par une vue de ce module, ou par la charge
@@ -200,13 +233,19 @@ export function creerWagonUi({ marque, couleurs = {}, langues = ['fr', 'en', 'ar
200
233
  <meta property="og:type" content="website"><meta property="og:site_name" content="${nom}"><meta property="og:title" content="${esc(titre)} — ${nom}">
201
234
  <meta property="og:description" content="${esc(description || slogan)}"><meta property="og:url" content="${esc(canonique)}"><meta property="og:locale" content="${L}">` : '';
202
235
  const nav = [...liens.map((x) => [x.href, dansLangue(x.libelle, L)]), ['/tarifs', tr('nav.tarifs')], ['/aide', tr('nav.aide')], ['/apropos', tr('nav.apropos')]];
203
- const choixLangue = LANGUES.length < 2 ? '' : `<span class="langues">${LANGUES.map((l) => (l === L
204
- ? `<b lang="${l}">${esc(LIBELLES_LANGUES[l])}</b>`
205
- : `<a href="${chemin}?lang=${l}" hreflang="${l}" lang="${l}" rel="alternate">${esc(LIBELLES_LANGUES[l])}</a>`)).join(' · ')}</span>`;
236
+ // 0.5 — le choix de langue en menu déroulant (`selecteurLangue: 'menu'`) : <details>, donc SANS script.
237
+ const choixLangue = LANGUES.length < 2 ? '' : selecteurLangue === 'menu'
238
+ ? `<details class="choix-langue"><summary aria-label="${esc(tr('layout.langue'))}">🌐 ${L.toUpperCase()} ▾</summary><ul>${LANGUES.map((l) => `<li><a href="${chemin}?lang=${l}" hreflang="${l}" lang="${l}" rel="alternate"${l === L ? ' aria-current="true"' : ''}>${esc(LIBELLES_LANGUES[l])}</a></li>`).join('')}</ul></details>`
239
+ : `<span class="langues">${LANGUES.map((l) => (l === L
240
+ ? `<b lang="${l}">${esc(LIBELLES_LANGUES[l])}</b>`
241
+ : `<a href="${chemin}?lang=${l}" hreflang="${l}" lang="${l}" rel="alternate">${esc(LIBELLES_LANGUES[l])}</a>`)).join(' · ')}</span>`;
242
+ // 0.5 — l'en-tête collant (`collant`) et le bouton d'appel (`appel: { href, libelle }`).
243
+ const classesTete = [entete === 'barre' ? 'bande' : '', collant ? 'collant' : ''].filter(Boolean).join(' ');
244
+ const boutonAppel = appel?.href ? `<a class="appel" href="${lien(appel.href)}">${esc(dansLangue(appel.libelle, L))}</a>` : '';
206
245
  return `<!doctype html><html lang="${L}" dir="${dirDe(L)}"><head><meta charset="utf-8">
207
246
  <meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(titre)} — ${nom}</title>
208
247
  ${tete}<style>${STYLE}</style>${themeCss() ? `<style id="theme-palette">${themeCss()}</style>` : ''}</head><body>
209
- <header${entete === 'barre' ? ' class="bande"' : ''}><a class="brand" href="/">${marque.symbole ? `${esc(marque.symbole)} ` : ''}${nom}</a><input type="checkbox" id="wagon-menu" class="bascule"><label for="wagon-menu" class="burger" aria-label="${esc(tr('layout.menu'))}">☰</label><div class="menu"><nav>${nav.map(([h, l]) => `<a href="${lien(h)}"${actif === h ? ' class="on"' : ''}>${esc(l)}</a>`).join('')}</nav><span class="moi"><a href="${connecte ? '/compte' : '/connexion'}">${esc(tr(connecte ? 'espace.compte' : 'espace.seConnecter'))}</a>${connecte ? ` · <a href="/deconnexion">${esc(tr('espace.seDeconnecter'))}</a>` : ''}</span>${choixLangue}</div></header>
248
+ <header${classesTete ? ` class="${classesTete}"` : ''}><a class="brand" href="/">${marque.symbole ? `${esc(marque.symbole)} ` : ''}${nom}</a><input type="checkbox" id="wagon-menu" class="bascule"><label for="wagon-menu" class="burger" aria-label="${esc(tr('layout.menu'))}">☰</label><div class="menu"><nav>${nav.map(([h, l]) => `<a href="${lien(h)}"${actif === h ? ' class="on"' : ''}>${esc(l)}</a>`).join('')}</nav><span class="moi"><a href="${connecte ? '/compte' : '/connexion'}">${esc(tr(connecte ? 'espace.compte' : 'espace.seConnecter'))}</a>${connecte ? ` · <a href="/deconnexion">${esc(tr('espace.seDeconnecter'))}</a>` : ''}</span>${boutonAppel}${choixLangue}</div></header>
210
249
  <main>${contenu}</main>
211
250
  <footer><b>${nom}</b>${slogan ? ` — ${esc(slogan)}` : ''} · ${esc(tr('layout.auteur'))} : <b>${esc(marque.auteur)}</b>${marque.courriel ? ` &lt;${esc(marque.courriel)}&gt;` : ''} · ${esc(marque.licence ?? 'AGPL-3.0')} · ${esc(tr('layout.propulse'))} <code>@mostajs/*</code><br><span class="legal">${DOCUMENTS_LEGAUX.map(([h, k]) => `<a href="/${h}">${esc(tr(`legal.${k}`))}</a>`).join(' · ')}</span></footer>
212
251
  </body></html>`;
@@ -236,10 +275,10 @@ ${paliers.filter((p) => p.active !== false).map((p) => {
236
275
  const cles = p.limits?.maxCles;
237
276
  return `<section class="plan">
238
277
  <h2>${esc(p.name)}</h2>
239
- <p class="prix">${gratuit ? esc(tr('tarifs.gratuit')) : `<b>${esc(euros(p.price))}</b><span class="hint">${esc(tr('tarifs.parMois'))}</span>`}</p>
278
+ <p class="prix">${gratuit ? esc(tr('tarifs.gratuit')) : `<b>${esc(euros(p.price, L))}</b><span class="hint">${esc(tr('tarifs.parMois'))}</span>`}</p>
240
279
  <ul>
241
280
  ${ressources.map((r) => `<li>${esc(ligneRessource(r, p.limits?.[r.limite], L))}</li>`).join('\n ')}
242
- ${cles !== undefined ? `<li>${esc(Number(cles) === -1 ? `${tr('tarifs.clesPluriel', { n: '∞' })}` : tr(Number(cles) > 1 ? 'tarifs.clesPluriel' : 'tarifs.cles', { n: cles }))}</li>` : ''}
281
+ ${cles !== undefined ? `<li>${esc(Number(cles) === -1 ? tr('tarifs.clesIllimitees') : tr('tarifs.cles', { n: Number(cles) }))}</li>` : ''}
243
282
  </ul>
244
283
  <p><a class="bouton" href="${lien(`${inscriptionHref}?plan=${encodeURIComponent(p.slug)}`)}">${esc(tr(gratuit ? 'tarifs.commencer' : 'tarifs.choisir'))}</a></p>
245
284
  </section>`;
@@ -258,7 +297,7 @@ ${erreur ? `<p class="msg">${esc(erreur)}</p>` : ''}
258
297
  <form method="post" action="${lien(action)}">
259
298
  <input type="hidden" name="plan" value="${esc(palier?.slug ?? '')}">
260
299
  <fieldset><legend>${esc(tr('inscription.plan'))}</legend>
261
- <p><b>${esc(palier?.name ?? '')}</b> — ${gratuit ? esc(tr('tarifs.gratuit')) : `${esc(euros(palier.price))}${esc(tr('tarifs.parMois'))}`}</p>
300
+ <p><b>${esc(palier?.name ?? '')}</b> — ${gratuit ? esc(tr('tarifs.gratuit')) : `${esc(euros(palier.price, L))}${esc(tr('tarifs.parMois'))}`}</p>
262
301
  </fieldset>
263
302
  <div class="champs">
264
303
  <label>${esc(tr('inscription.nom'))}<input type="text" name="nom" required maxlength="80"></label>
@@ -335,7 +374,7 @@ ${erreur ? `<p class="msg">${esc(erreur)}</p>` : ''}
335
374
  ${erreur ? `<p class="msg">${esc(erreur)}</p>` : ''}
336
375
  ${cleNeuve ? `<p class="ok">${esc(tr('espace.cleCreee'))}</p><pre><code>${esc(cleNeuve)}</code></pre>` : ''}
337
376
  <h2>${esc(tr('espace.votrePlan'))}</h2>
338
- <p><b>${esc(v.plan?.name ?? '—')}</b>${Number(v.plan?.price) ? ` — ${esc(euros(v.plan.price))}${esc(tr('tarifs.parMois'))}` : ''}</p>
377
+ <p><b>${esc(v.plan?.name ?? '—')}</b>${Number(v.plan?.price) ? ` — ${esc(euros(v.plan.price, L))}${esc(tr('tarifs.parMois'))}` : ''}</p>
339
378
  <ul class="jauges">
340
379
  ${ressources.map(jauge).join('\n ')}
341
380
  </ul>