@mostajs/wagon-ui 0.1.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 ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog — @mostajs/wagon-ui
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
4
+
5
+ ## 0.1.0 — 2026-10-01
6
+ Première version : les pages du wagon de mdseal (mise en page, accueil, tarifs, inscription, connexion par mot
7
+ de passe ou par code, espace client, administration des paliers et de l'usage, pages légales, introuvable), en
8
+ français, anglais et arabe, sans rien de ce que mdseal vend. Identité, couleurs, navigation de la charge utile et
9
+ ressources mesurées par configuration. 9 essais, chacun confronté à un sabotage.
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @mostajs/wagon-ui
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
4
+
5
+ Les **pages du wagon** d'un SaaS : tout ce qu'il montre en dehors de ce qu'il vend. Extraites de mdseal,
6
+ où elles font, avec leurs traductions, environ 60 % du wagon — sans rien de ce que mdseal vend.
7
+
8
+ | vue | ce qu'elle rend |
9
+ |---|---|
10
+ | `page()` | la mise en page : langue et sens (arabe de droite à gauche), sélecteur de langues, navigation, connexion, pied légal, en-têtes pour les moteurs |
11
+ | `accueil()` | la vitrine : titre, chapeau, actions, preuves |
12
+ | `tarifs()` | les paliers, une ligne par **ressource déclarée**, les clés d'API, l'inscription par palier |
13
+ | `inscription()`, `inscriptionFaite()`, `connexion()`, `merci()` | le parcours d'entrée — mot de passe, ou code reçu par courriel |
14
+ | `espace()` | le compte du client : palier, jauges, clés, factures, gestion de l'abonnement |
15
+ | `adminPaliers()`, `adminUsage()` | l'administration : quotas par palier, tableau d'usage |
16
+ | `legal()`, `introuvable()` | les documents légaux, la page introuvable |
17
+
18
+ Trois langues : français, anglais, arabe. Vues pures, aucune dépendance, aucun script.
19
+
20
+ ## Greffer un nouveau SaaS
21
+
22
+ ```js
23
+ import { creerWagonUi } from '@mostajs/wagon-ui'
24
+
25
+ const ui = creerWagonUi({
26
+ marque: { nom: 'Acme Proof', symbole: '◆', slogan: { fr: 'la preuve, simplement', en: 'proof, simply' }, auteur: 'Jeanne Martin', courriel: 'contact@acme.test' },
27
+ couleurs: { brand: '#123456', lien: '#234567', ornement: '#ABCDEF' },
28
+ liens: [{ href: '/outil', libelle: { fr: 'L’outil', en: 'The tool' } }], // la charge utile
29
+ ressources: [{ cle: 'certificat', limite: 'certificatsParMois', periode: 'mois',
30
+ libelle: { fr: 'Certificats', en: 'Certificates' }, parPeriode: { fr: '{n} certificats par mois', en: '{n} certificates per month' } }],
31
+ })
32
+
33
+ res.end(ui.page({ actif: '/tarifs', titre: 'Tarifs', lang: 'fr', base: 'https://acme.test',
34
+ contenu: ui.tarifs({ lang: 'fr', paliers }) }))
35
+ ```
36
+
37
+ La logique vient des modules : comptes (`@mostajs/comptes`), abonnements et quotas
38
+ (`@mostajs/subscriptions-plan`), connexion (`@mostajs/auth-lite`, `@mostajs/auth-plug`).
39
+
40
+ Voir `llms.txt` pour l'API complète.
package/llms.txt ADDED
@@ -0,0 +1,31 @@
1
+ # @mostajs/wagon-ui — fiche pour assistants
2
+
3
+ > Les pages du wagon d'un SaaS (tout sauf ce qu'il vend), extraites de mdseal : mise en page, accueil, tarifs, inscription, connexion, espace client, administration, pages légales — fr, en, ar. Vues pures (données → HTML), zéro dépendance. Auteur : Dr Hamid MADANI <drmdh@msn.com>.
4
+
5
+ ## RÔLE
6
+ Rendre les écrans communs à tout SaaS, identiques d'un produit à l'autre, sans nommer le produit : l'identité et ce que le service mesure viennent de la configuration.
7
+
8
+ ## NE FAIT PAS
9
+ - router, sessions → @mostajs/auth-plug · mot de passe / code → @mostajs/auth-lite · écran de saisie du code → @mostajs/auth-ui
10
+ - quotas, paiement → @mostajs/subscriptions-plan · comptes, clés → @mostajs/comptes
11
+
12
+ ## API
13
+ `creerWagonUi({ marque: { nom, auteur, symbole?, slogan?, courriel?, licence? }, couleurs?: { brand, lien, ornement, encre, surface }, langues?: ['fr','en','ar'], liens?: [{ href, libelle }], ressources?: [{ cle, limite, periode: 'jour'|'mois', libelle, parPeriode?, sansLimite?, aucun? }], textes?: { fr: { … } } })` → `ui` :
14
+ - `ui.page({ actif, titre, contenu, lang, connecte?, base?, description? })` — la page entière ; `contenu` = HTML déjà rendu.
15
+ - `ui.accueil({ lang, titre, lede?, actions?: [{ href, libelle, principal? }], preuves?: [{ titre, texte }], note? })`
16
+ - `ui.tarifs({ lang, paliers: [{ slug, name, price (centimes), active?, limits: { <limite>: n, maxCles } }], sousTitre?, inscriptionHref? })`
17
+ - `ui.inscription({ lang, palier, erreur?, minMotDePasse?, methode?: 'motdepasse'|'code', action? })` · `ui.inscriptionFaite({ lang, cle?, base?, paiement?: { compteId, planSlug } })`
18
+ - `ui.connexion({ lang, erreur?, methode?: 'motdepasse'|'code', action? })` · `ui.merci({ lang })`
19
+ - `ui.espace({ lang, vue: { plan, quotas: { <cle>: { courant, limite|null, restant } }, cles, factures, portailPossible, limites }, erreur?, cleNeuve? })`
20
+ - `ui.adminPaliers({ lang, offres, message? })` — champs `<slug>.<limite>` et `<slug>.maxCles` (POST /admin/plans).
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
+ - `ui.legal({ lang, doc: 'mentions-legales'|'cgu'|'cgv'|'confidentialite', html })` · `ui.introuvable({ lang })`
23
+ - `ui.t(lang)(cle, vars)` · `CATALOGUES` · `dirDe(lang)` · `esc`
24
+
25
+ ## ROUTES ATTENDUES (liens et formulaires des vues)
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
+
28
+ ## PIÈGES
29
+ - `legal({ html })` n'échappe pas `html` : il doit venir des textes de l'éditeur (mostajs/templates), jamais d'un formulaire.
30
+ - Les prix sont en CENTIMES. `-1` = sans limite ; `0` = non compris.
31
+ - Une ressource sans `parPeriode` s'affiche « libellé : n ».
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@mostajs/wagon-ui",
3
+ "version": "0.1.0",
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
+ "license": "AGPL-3.0-or-later",
6
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md",
15
+ "llms.txt",
16
+ "CHANGELOG.md"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "scripts": {
22
+ "test": "mkdir -p test-scripts/.out && node node_modules/@mostajs/mjs-unit/bin/cli.mjs test-scripts/unit/*.test.mjs --json=test-scripts/.out/report.json --title=\"@mostajs/wagon-ui\""
23
+ },
24
+ "keywords": [
25
+ "mostajs",
26
+ "ui",
27
+ "html",
28
+ "saas",
29
+ "pricing",
30
+ "account",
31
+ "billing",
32
+ "i18n",
33
+ "rtl",
34
+ "ui-html-stack",
35
+ "wagon"
36
+ ],
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/apolocine/mosta-ui-html-stack"
40
+ },
41
+ "mostajs": {
42
+ "niveau": "N2",
43
+ "nature": "vue",
44
+ "reutilisabilite": "multi-domaine",
45
+ "depend_de": [],
46
+ "forme": "vues",
47
+ "question": "Quelles pages montre un SaaS en dehors de ce qu'il vend — et comment les reprendre telles quelles pour le suivant ?",
48
+ "verbes": [
49
+ "creerWagonUi",
50
+ "page",
51
+ "accueil",
52
+ "tarifs",
53
+ "inscription",
54
+ "connexion",
55
+ "espace",
56
+ "adminPaliers",
57
+ "adminUsage",
58
+ "legal",
59
+ "introuvable"
60
+ ],
61
+ "ne_fait_pas": [
62
+ "router, ouvrir une session → @mostajs/auth-plug",
63
+ "vérifier un mot de passe ou un code → @mostajs/auth-lite",
64
+ "compter un quota, encaisser → @mostajs/subscriptions-plan",
65
+ "créer un compte, une clé → @mostajs/comptes",
66
+ "écran de saisie du code par courriel → @mostajs/auth-ui"
67
+ ]
68
+ },
69
+ "devDependencies": {
70
+ "@mostajs/mjs-unit": "^0.7.0"
71
+ }
72
+ }
package/src/i18n/ar.js ADDED
@@ -0,0 +1,128 @@
1
+ // @mostajs/wagon-ui — catalogue ar. Libellés génériques repris de mdseal (01/10/2026),
2
+ // sans ce qui nomme la charge utile. Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ export const ar = {
4
+ "nav": {
5
+ "tarifs": "الأسعار",
6
+ "aide": "مساعدة",
7
+ "apropos": "حول",
8
+ "legal": "قانوني"
9
+ },
10
+ "layout": {
11
+ "auteur": "المؤلّف",
12
+ "propulse": "مبنيّ على منظومة",
13
+ "langue": "اللغة"
14
+ },
15
+ "tarifs": {
16
+ "titre": "الأسعار",
17
+ "parMois": "/شهريًّا",
18
+ "gratuit": "مجّاني",
19
+ "cles": "مفتاح واجهة واحد",
20
+ "clesPluriel": "{n} مفاتيح واجهة",
21
+ "choisir": "اختر هذه الخطّة",
22
+ "commencer": "ابدأ مجّانًا",
23
+ "encours": "الدفع غير متاح حاليًّا",
24
+ "note": "الأسعار دون رسوم. يمكن الإلغاء في أيّ وقت: يسري الاشتراك إلى نهاية المدّة المدفوعة.",
25
+ "aucun": "غير مشمول"
26
+ },
27
+ "inscription": {
28
+ "titre": "إنشاء حساب",
29
+ "plan": "الخطّة المختارة",
30
+ "nom": "الاسم (الذي سيظهر على الفاتورة)",
31
+ "courriel": "عنوان البريد الإلكتروني",
32
+ "courrielAide": "إليه تُرسَل الفاتورة، وهو كذلك معرّفك.",
33
+ "bouton": "أنشئ الحساب",
34
+ "boutonPayant": "أنشئ الحساب وادفع",
35
+ "deja": "يوجد حساب بهذا العنوان بالفعل.",
36
+ "faite": "أُنشئ الحساب",
37
+ "voiciLaCle": "هذا مفتاح واجهتك. لن يُعرَض مرّة أخرى أبدًا: انسخه الآن.",
38
+ "etEnsuite": "جرّبه في الحال:",
39
+ "versPaiement": "تابِع إلى الدفع",
40
+ "merci": "شكرًا",
41
+ "merciTexte": "سُجِّل الدفع. وتصير خطّتك نافذة بمجرّد أن يؤكّد لنا Stripe ذلك — وهو فوريّ في العادة.",
42
+ "merciVerif": "تحقّق من خطّتك:",
43
+ "gardezLaCle": "انسخ مفتاحك قبل المتابعة: لن يُعرَض مرّة أخرى.",
44
+ "planIndisponible": "هذه الخطّة غير متاحة."
45
+ },
46
+ "espace": {
47
+ "motDePasse": "كلمة السرّ",
48
+ "motDePasseAide": "{n} محارف على الأقلّ. وبها يُفتَح فضاؤك.",
49
+ "faible": "رُفضت كلمة السرّ: {motifs}",
50
+ "tropCourt": "قصيرة جدًّا",
51
+ "tropLong": "طويلة جدًّا",
52
+ "trivial": "شائعة جدًّا",
53
+ "seConnecter": "تسجيل الدخول",
54
+ "seDeconnecter": "تسجيل الخروج",
55
+ "compte": "حسابي",
56
+ "identifiantsFaux": "العنوان أو كلمة السرّ غير صحيح.",
57
+ "connexionRequise": "سجّل الدخول لترى فضاءك.",
58
+ "votrePlan": "خطّتك",
59
+ "consommationDuJour": "اليوم",
60
+ "consommationDuMois": "هذا الشهر",
61
+ "restant": "بقي {n}",
62
+ "illimite": "بلا حدّ",
63
+ "vosCles": "مفاتيح واجهتك",
64
+ "creerUneCle": "أنشئ مفتاحًا",
65
+ "libelleCle": "فيمَ يُستعمل هذا المفتاح؟",
66
+ "revoquer": "إلغاء",
67
+ "revoquee": "مُلغًى",
68
+ "jamaisUtilisee": "لم يُستعمَل قطّ",
69
+ "appels": "{n} نداء",
70
+ "plafondAtteint": "تسمح خطّتك بـ {n} مفتاح/مفاتيح نشِطة.",
71
+ "cleCreee": "أُنشئ المفتاح. انسخه: لن يُعرَض مرّة أخرى.",
72
+ "gererAbonnement": "إدارة اشتراكي",
73
+ "gererAide": "وسيلة الدفع والفواتير والإلغاء — لدى Stripe.",
74
+ "vosFactures": "فواتيرك",
75
+ "aucuneFacture": "لا فاتورة بعد.",
76
+ "voirFacture": "عرض",
77
+ "payee": "مدفوعة",
78
+ "impayee": "غير مدفوعة",
79
+ "ameliorer": "غيّر الخطّة",
80
+ "recevoirCode": "استلام رمز عبر البريد الإلكتروني"
81
+ },
82
+ "usage": {
83
+ "titre": "لوحة الاستعمال",
84
+ "sousTitre": "الحسابات والاستهلاك الجاري والاتّجاه على {jours} يومًا. الأرقام من سجلّ الاستهلاك — وهو الذي يُحاسِب.",
85
+ "comptes": "الحسابات",
86
+ "actifs": "منها النشطة",
87
+ "payants": "منها المدفوعة",
88
+ "erreurs": "الأخطاء",
89
+ "parCompte": "حسب الحساب",
90
+ "aucunCompte": "لا حساب بعد.",
91
+ "compte": "الحساب",
92
+ "plan": "الفئة",
93
+ "surFenetre": "على المدّة",
94
+ "dernierJour": "آخر يوم نشِط",
95
+ "suspendu": "(موقوف)",
96
+ "jamais": "أبدًا",
97
+ "erreursCourt": "خطأ",
98
+ "sansLimite": "بلا حدّ",
99
+ "anonyme": "حصّة التجربة (المجهول)",
100
+ "anonymeVolatil": "⚠ هذه الأرقام تعيش في الذاكرة وتعود إلى الصفر عند كلّ إعادة تشغيل — محسوبة منذ {depuis} بالتوقيت العالمي. لا تُحاسَب، ومن ثمّ لا تُكتب في القاعدة: خدمةٌ عموميّة تتلقّى من الزيارات المجهولة ما لا يبرّر كتابةً عند كلّ زيارة. فـ«0» هنا قد يعني «الخدمة أُعيد تشغيلها للتوّ»، لا «لم يأتِ أحد» أبدًا.",
101
+ "visiteursJour": "زوّار اليوم",
102
+ "adressesJour": "عناوين اليوم",
103
+ "seaux": "عدّادات في الذاكرة",
104
+ "operationsAnon": "العمليات المجهولة"
105
+ },
106
+ "admin": {
107
+ "titre": "المستويات والحصص",
108
+ "sousTitre": "ما يُضبط هنا هو ما يُطبَّق — وما تُعلنه صفحة الأسعار.",
109
+ "palier": "المستوى",
110
+ "cles": "مفاتيح الواجهة",
111
+ "nonVendu": "(لا يُباع)",
112
+ "sansLimite": "−1 تعني «بلا حدّ». و0 تُغلق العمليّة لهذا المستوى.",
113
+ "enregistrer": "حفظ",
114
+ "enregistre": "تمّ الحفظ. صفحة الأسعار والحصص تتبعان بالفعل."
115
+ },
116
+ "legal": {
117
+ "mentions": "البيانات القانونية",
118
+ "cgu": "شروط الاستخدام",
119
+ "cgv": "شروط البيع",
120
+ "confidentialite": "الخصوصية",
121
+ "indisponible": "لم تُنشر هذه الوثيقة بعد."
122
+ },
123
+ "erreur": {
124
+ "introuvable": "الصفحة غير موجودة",
125
+ "introuvableTexte": "هذا العنوان لا يؤدّي إلى أيّ صفحة.",
126
+ "retour": "العودة إلى الصفحة الرئيسية"
127
+ }
128
+ };
package/src/i18n/en.js ADDED
@@ -0,0 +1,128 @@
1
+ // @mostajs/wagon-ui — catalogue en. Libellés génériques repris de mdseal (01/10/2026),
2
+ // sans ce qui nomme la charge utile. Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ export const en = {
4
+ "nav": {
5
+ "tarifs": "Pricing",
6
+ "aide": "Help",
7
+ "apropos": "About",
8
+ "legal": "Legal"
9
+ },
10
+ "layout": {
11
+ "auteur": "Author",
12
+ "propulse": "built on the",
13
+ "langue": "Language"
14
+ },
15
+ "tarifs": {
16
+ "titre": "Pricing",
17
+ "parMois": "/month",
18
+ "gratuit": "Free",
19
+ "cles": "{n} API key",
20
+ "clesPluriel": "{n} API keys",
21
+ "choisir": "Choose this plan",
22
+ "commencer": "Start for free",
23
+ "encours": "Payment is not available yet",
24
+ "note": "Prices exclude tax. Cancel at any time: the subscription runs to the end of the period already paid for.",
25
+ "aucun": "Not included"
26
+ },
27
+ "inscription": {
28
+ "titre": "Create an account",
29
+ "plan": "Chosen plan",
30
+ "nom": "Name (the one that will appear on the invoice)",
31
+ "courriel": "Email address",
32
+ "courrielAide": "This is where the invoice goes, and it is also your identifier.",
33
+ "bouton": "Create the account",
34
+ "boutonPayant": "Create the account and pay",
35
+ "deja": "An account already exists for this address.",
36
+ "faite": "Account created",
37
+ "voiciLaCle": "Here is your API key. It will never be shown again: copy it now.",
38
+ "etEnsuite": "Try it straight away:",
39
+ "versPaiement": "Continue to payment",
40
+ "merci": "Thank you",
41
+ "merciTexte": "The payment is recorded. Your plan becomes active as soon as Stripe confirms it to us — usually at once.",
42
+ "merciVerif": "Check your plan:",
43
+ "gardezLaCle": "Copy your key before continuing: it will not be shown again.",
44
+ "planIndisponible": "That plan is not available."
45
+ },
46
+ "espace": {
47
+ "motDePasse": "Password",
48
+ "motDePasseAide": "At least {n} characters. This is what opens your account area.",
49
+ "faible": "Password refused: {motifs}",
50
+ "tropCourt": "too short",
51
+ "tropLong": "too long",
52
+ "trivial": "too common",
53
+ "seConnecter": "Sign in",
54
+ "seDeconnecter": "Sign out",
55
+ "compte": "My account",
56
+ "identifiantsFaux": "Incorrect address or password.",
57
+ "connexionRequise": "Sign in to see your account area.",
58
+ "votrePlan": "Your plan",
59
+ "consommationDuJour": "Today",
60
+ "consommationDuMois": "This month",
61
+ "restant": "{n} left",
62
+ "illimite": "unlimited",
63
+ "vosCles": "Your API keys",
64
+ "creerUneCle": "Create a key",
65
+ "libelleCle": "What is this key for?",
66
+ "revoquer": "Revoke",
67
+ "revoquee": "Revoked",
68
+ "jamaisUtilisee": "never used",
69
+ "appels": "{n} calls",
70
+ "plafondAtteint": "Your plan allows {n} active key(s).",
71
+ "cleCreee": "Key created. Copy it: it will not be shown again.",
72
+ "gererAbonnement": "Manage my subscription",
73
+ "gererAide": "Payment method, invoices, cancellation — at Stripe.",
74
+ "vosFactures": "Your invoices",
75
+ "aucuneFacture": "No invoice yet.",
76
+ "voirFacture": "View",
77
+ "payee": "paid",
78
+ "impayee": "unpaid",
79
+ "ameliorer": "Change plan",
80
+ "recevoirCode": "Receive a code by email"
81
+ },
82
+ "usage": {
83
+ "titre": "Usage dashboard",
84
+ "sousTitre": "Accounts, current consumption and the trend over {jours} days. The figures come from the usage log — the one that bills.",
85
+ "comptes": "Accounts",
86
+ "actifs": "Of which active",
87
+ "payants": "Of which paying",
88
+ "erreurs": "Errors",
89
+ "parCompte": "By account",
90
+ "aucunCompte": "No account yet.",
91
+ "compte": "Account",
92
+ "plan": "Tier",
93
+ "surFenetre": "Over the window",
94
+ "dernierJour": "Last active day",
95
+ "suspendu": "(suspended)",
96
+ "jamais": "never",
97
+ "erreursCourt": "err.",
98
+ "sansLimite": "no limit",
99
+ "anonyme": "The trial share (anonymous)",
100
+ "anonymeVolatil": "⚠ These figures live in MEMORY and restart from zero on every restart — counted since {depuis} UTC. They are not billed, so not written to the database: a public service receives far too many anonymous visits for one write per visit to be justified. A “0” here may therefore mean “the service has just restarted”, never “nobody came”.",
101
+ "visiteursJour": "Visitors today",
102
+ "adressesJour": "Addresses today",
103
+ "seaux": "In-memory counters",
104
+ "operationsAnon": "Anonymous operations"
105
+ },
106
+ "admin": {
107
+ "titre": "Tiers and quotas",
108
+ "sousTitre": "What is set here is what is enforced — and what the Pricing page announces.",
109
+ "palier": "Tier",
110
+ "cles": "API keys",
111
+ "nonVendu": "(not for sale)",
112
+ "sansLimite": "−1 means “no limit”. 0 closes the operation for that tier.",
113
+ "enregistrer": "Save",
114
+ "enregistre": "Saved. The Pricing page and the quotas already follow."
115
+ },
116
+ "legal": {
117
+ "mentions": "Legal notice",
118
+ "cgu": "Terms of use",
119
+ "cgv": "Terms of sale",
120
+ "confidentialite": "Privacy",
121
+ "indisponible": "This document is not published yet."
122
+ },
123
+ "erreur": {
124
+ "introuvable": "Page not found",
125
+ "introuvableTexte": "This address leads to no page.",
126
+ "retour": "Back to the home page"
127
+ }
128
+ };
package/src/i18n/fr.js ADDED
@@ -0,0 +1,128 @@
1
+ // @mostajs/wagon-ui — catalogue fr. Libellés génériques repris de mdseal (01/10/2026),
2
+ // sans ce qui nomme la charge utile. Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ export const fr = {
4
+ "nav": {
5
+ "tarifs": "Tarifs",
6
+ "aide": "Aide",
7
+ "apropos": "À propos",
8
+ "legal": "Légal"
9
+ },
10
+ "layout": {
11
+ "auteur": "Auteur",
12
+ "propulse": "propulsé par l’écosystème",
13
+ "langue": "Langue"
14
+ },
15
+ "tarifs": {
16
+ "titre": "Tarifs",
17
+ "parMois": "/mois",
18
+ "gratuit": "Gratuit",
19
+ "cles": "{n} clé d’API",
20
+ "clesPluriel": "{n} clés d’API",
21
+ "choisir": "Choisir ce plan",
22
+ "commencer": "Commencer gratuitement",
23
+ "encours": "Paiement indisponible pour l’instant",
24
+ "note": "Les prix sont hors taxes. Résiliable à tout moment : l’abonnement court jusqu’à la fin de la période déjà payée.",
25
+ "aucun": "Non compris"
26
+ },
27
+ "inscription": {
28
+ "titre": "Créer un compte",
29
+ "plan": "Plan choisi",
30
+ "nom": "Nom (celui qui figurera sur la facture)",
31
+ "courriel": "Adresse de courriel",
32
+ "courrielAide": "C’est là que part la facture, et c’est aussi votre identifiant.",
33
+ "bouton": "Créer le compte",
34
+ "boutonPayant": "Créer le compte et payer",
35
+ "deja": "Un compte existe déjà pour cette adresse.",
36
+ "faite": "Compte créé",
37
+ "voiciLaCle": "Voici votre clé d’API. Elle ne sera plus jamais affichée : copiez-la maintenant.",
38
+ "etEnsuite": "Essayez-la tout de suite :",
39
+ "versPaiement": "Continuer vers le paiement",
40
+ "merci": "Merci",
41
+ "merciTexte": "Le paiement est enregistré. Votre plan sera actif dès que Stripe nous l'aura confirmé — c'est généralement immédiat.",
42
+ "merciVerif": "Vérifiez votre plan :",
43
+ "gardezLaCle": "Copiez votre clé avant de continuer : elle ne sera plus affichée.",
44
+ "planIndisponible": "Ce plan n’est pas disponible."
45
+ },
46
+ "espace": {
47
+ "motDePasse": "Mot de passe",
48
+ "motDePasseAide": "Au moins {n} caractères. C’est lui qui ouvrira votre espace.",
49
+ "faible": "Mot de passe refusé : {motifs}",
50
+ "tropCourt": "trop court",
51
+ "tropLong": "trop long",
52
+ "trivial": "trop courant",
53
+ "seConnecter": "Se connecter",
54
+ "seDeconnecter": "Se déconnecter",
55
+ "compte": "Mon compte",
56
+ "identifiantsFaux": "Adresse ou mot de passe incorrect.",
57
+ "connexionRequise": "Connectez-vous pour voir votre espace.",
58
+ "votrePlan": "Votre plan",
59
+ "consommationDuJour": "Aujourd’hui",
60
+ "consommationDuMois": "Ce mois-ci",
61
+ "restant": "{n} restant(e)s",
62
+ "illimite": "sans limite",
63
+ "vosCles": "Vos clés d’API",
64
+ "creerUneCle": "Créer une clé",
65
+ "libelleCle": "À quoi sert cette clé ?",
66
+ "revoquer": "Révoquer",
67
+ "revoquee": "Révoquée",
68
+ "jamaisUtilisee": "jamais utilisée",
69
+ "appels": "{n} appels",
70
+ "plafondAtteint": "Votre plan permet {n} clé(s) active(s).",
71
+ "cleCreee": "Clé créée. Copiez-la : elle ne sera plus affichée.",
72
+ "gererAbonnement": "Gérer mon abonnement",
73
+ "gererAide": "Moyen de paiement, factures, résiliation — chez Stripe.",
74
+ "vosFactures": "Vos factures",
75
+ "aucuneFacture": "Aucune facture pour l’instant.",
76
+ "voirFacture": "Voir",
77
+ "payee": "payée",
78
+ "impayee": "impayée",
79
+ "ameliorer": "Changer de plan",
80
+ "recevoirCode": "Recevoir un code par courriel"
81
+ },
82
+ "usage": {
83
+ "titre": "Tableau de bord d’usage",
84
+ "sousTitre": "Comptes, consommation courante et tendance sur {jours} jours. Les chiffres viennent du journal de consommation — celui qui facture.",
85
+ "comptes": "Comptes",
86
+ "actifs": "Dont actifs",
87
+ "payants": "Dont payants",
88
+ "erreurs": "Erreurs",
89
+ "parCompte": "Par compte",
90
+ "aucunCompte": "Aucun compte pour l’instant.",
91
+ "compte": "Compte",
92
+ "plan": "Palier",
93
+ "surFenetre": "Sur la fenêtre",
94
+ "dernierJour": "Dernier jour actif",
95
+ "suspendu": "(suspendu)",
96
+ "jamais": "jamais",
97
+ "erreursCourt": "err.",
98
+ "sansLimite": "sans limite",
99
+ "anonyme": "La part d’essai (anonyme)",
100
+ "anonymeVolatil": "⚠ Ces chiffres vivent en MÉMOIRE et repartent de zéro à chaque redémarrage — comptés depuis {depuis} UTC. Ils ne sont pas facturés, donc pas écrits en base : un service public reçoit trop de visites anonymes pour qu’une écriture par visite se justifie. Un « 0 » ici peut donc vouloir dire « le service vient de redémarrer », jamais « personne n’est venu ».",
101
+ "visiteursJour": "Visiteurs du jour",
102
+ "adressesJour": "Adresses du jour",
103
+ "seaux": "Compteurs en mémoire",
104
+ "operationsAnon": "Opérations anonymes"
105
+ },
106
+ "admin": {
107
+ "titre": "Paliers et quotas",
108
+ "sousTitre": "Ce qui est réglé ici est ce qui est appliqué — et ce qui est annoncé sur la page Tarifs.",
109
+ "palier": "Palier",
110
+ "cles": "Clés d’API",
111
+ "nonVendu": "(ne se vend pas)",
112
+ "sansLimite": "−1 vaut « sans limite ». 0 ferme l’opération pour ce palier.",
113
+ "enregistrer": "Enregistrer",
114
+ "enregistre": "Enregistré. La page Tarifs et les quotas suivent déjà."
115
+ },
116
+ "legal": {
117
+ "mentions": "Mentions légales",
118
+ "cgu": "Conditions d’utilisation",
119
+ "cgv": "Conditions de vente",
120
+ "confidentialite": "Confidentialité",
121
+ "indisponible": "Ce document n’est pas encore publié."
122
+ },
123
+ "erreur": {
124
+ "introuvable": "Page introuvable",
125
+ "introuvableTexte": "Cette adresse ne mène à aucune page.",
126
+ "retour": "Retour à l’accueil"
127
+ }
128
+ };
package/src/index.js ADDED
@@ -0,0 +1,402 @@
1
+ /**
2
+ * @mostajs/wagon-ui — les PAGES du wagon : tout ce qu'un SaaS montre, sauf ce qu'il vend.
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ * @license AGPL-3.0-or-later
5
+ *
6
+ * Extrait de mdseal (01/10/2026), où ces pages font, avec leurs traductions, environ 60 % du wagon
7
+ * (saas-making/methode/AUDIT-WAGON-MDSEAL-01102026.md) : la mise en page (en-tête, langues, pied
8
+ * légal, en-têtes pour les moteurs), l'accueil, les tarifs, l'inscription, la connexion, le
9
+ * remerciement après paiement, l'espace du client (palier, jauges, clés, factures, gestion de
10
+ * l'abonnement), l'administration (paliers, tableau d'usage), les pages légales et l'introuvable.
11
+ *
12
+ * Vues PURES — des données en entrée, du HTML en sortie ; aucune route, aucune base, aucun script.
13
+ * Rien n'y nomme mdseal ni ce qu'il vend :
14
+ * - l'IDENTITÉ (nom, slogan, auteur, couleurs, liens de la charge utile) vient de la configuration ;
15
+ * - ce que le service MESURE (« certificats », « conversions »…) se déclare en `ressources`,
16
+ * avec ses libellés : les tarifs, les jauges et l'administration en font une ligne chacune ;
17
+ * - les libellés communs sont ceux de mdseal, en français, anglais et arabe (`src/i18n/`).
18
+ *
19
+ * Les règles que mdseal a payées restent dans ces vues : la couleur d'accent ne porte jamais de
20
+ * texte (elle va aux filets et aux jauges) ; une jauge écrit son pourcentage à côté (daltonisme,
21
+ * impression) ; le code reste de gauche à droite dans une page arabe ; une clé n'est montrée
22
+ * qu'une fois ; la gestion de l'abonnement n'apparaît que si elle est possible.
23
+ */
24
+ import { fr } from './i18n/fr.js';
25
+ import { en } from './i18n/en.js';
26
+ import { ar } from './i18n/ar.js';
27
+
28
+ export const CATALOGUES = Object.freeze({ fr, en, ar });
29
+ export const LIBELLES_LANGUES = Object.freeze({ fr: 'Français', en: 'English', ar: 'العربية' });
30
+ const RTL = new Set(['ar', 'he', 'fa', 'ur']);
31
+ export const dirDe = (lang) => (RTL.has(lang) ? 'rtl' : 'ltr');
32
+
33
+ export const esc = (s = '') => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
34
+ /** Une adresse sûre pour un href : relative, http(s) ou mailto/tel ; tout le reste devient « # ». */
35
+ const lien = (href) => {
36
+ const h = String(href ?? '').trim();
37
+ return /^(\/(?!\/)|https?:\/\/|mailto:|tel:|#|\?)/i.test(h) ? esc(h) : '#';
38
+ };
39
+ const NBSP = ' ';
40
+ const euros = (centimes) => `${(Number(centimes) || 0) / 100}${NBSP}€`;
41
+
42
+ const DERIVES = '--survol:#eef2f9;--bordure:#cbd5e0;--doux:#475569;--fond-doux:#f1f5f9;'
43
+ + '--succes-fond:#f0fdf4;--succes-bord:#bbf7d0;--succes-texte:#166534;'
44
+ + '--erreur-fond:#fef2f2;--erreur-bord:#fecaca;--erreur-texte:#991b1b';
45
+ const REPLI = { brand: '#06284A', lien: '#2c5282', ornement: '#2c5282', encre: '#1a1a1a', surface: '#fff' };
46
+ const COULEUR = /^#[0-9a-fA-F]{3,8}$/;
47
+
48
+ /** La feuille de style de mdseal, sans ce qui servait la charge utile. Aucune couleur en dur. */
49
+ function style(c) {
50
+ return `:root{--brand:${c.brand};--accent:${c.lien};--ornement:${c.ornement};--ink:${c.encre};--surface:${c.surface};--line:#e2e8f0;${DERIVES}}
51
+ *{box-sizing:border-box} body{font:15px/1.6 system-ui,sans-serif;margin:0;color:var(--ink);background:var(--surface)}
52
+ header{display:flex;align-items:center;gap:1.2rem;flex-wrap:wrap;max-width:920px;margin:0 auto;padding:1rem;border-bottom:3px solid var(--ornement)}
53
+ .brand{font-weight:800;font-size:1.25rem;color:var(--brand);text-decoration:none}
54
+ nav{display:flex;gap:.3rem;flex-wrap:wrap} nav a{color:var(--accent);text-decoration:none;padding:.3em .7em;border-radius:6px} nav a.on,nav a:hover{background:var(--survol)}
55
+ main{max-width:920px;margin:0 auto;padding:0 1rem 2rem}
56
+ h1{color:var(--brand)} h2{color:var(--accent);border-bottom:1px solid var(--line);padding-bottom:.2em;margin-top:1.6em}
57
+ h3{color:var(--brand);font-size:1.02rem;margin:1.2em 0 .3em}
58
+ fieldset{border:1px solid var(--line);border-radius:8px;margin:.8em 0;padding:.6em 1em} legend{color:var(--accent);font-weight:700}
59
+ input[type=text],input[type=email],input[type=password],input[type=number]{padding:5px 8px;border:1px solid var(--bordure);border-radius:6px;font-size:14px}
60
+ input[type=text],input[type=email],input[type=password]{min-width:15em} input[type=number]{width:7em}
61
+ .champs{display:flex;flex-wrap:wrap;gap:.8em 1.4em;align-items:flex-end}
62
+ .champs label{display:flex;flex-direction:column;gap:.25em;margin:0;font-size:.92em;color:var(--doux)}
63
+ .ok{background:var(--succes-fond);border:1px solid var(--succes-bord);color:var(--succes-texte);padding:.6em 1em;border-radius:6px}
64
+ .msg{background:var(--erreur-fond);border:1px solid var(--erreur-bord);color:var(--erreur-texte);padding:.6em 1em;border-radius:6px}
65
+ button{background:var(--brand);color:var(--surface);border:0;border-radius:6px;padding:.7em 1.4em;font-size:15px;cursor:pointer}
66
+ table{border-collapse:collapse;margin:.8em 0} th,td{border:1px solid var(--line);padding:5px 10px;text-align:start} th{background:var(--survol)}
67
+ .vitrine{border-bottom:1px solid var(--line);margin-bottom:1.6em;padding-bottom:1.2em}
68
+ .vitrine h1{margin-bottom:.2em} .vitrine .lede{font-size:1.15em;color:var(--doux);max-width:60ch;margin:.2em 0 1em}
69
+ .vitrine .actions a{margin-inline-end:1em}
70
+ a.bouton{display:inline-block;padding:.55em 1.2em;border-radius:6px;background:var(--brand);color:var(--surface);text-decoration:none}
71
+ .preuves{display:flex;flex-wrap:wrap;gap:1em} .preuves>div{flex:1 1 15em;min-width:15em;border:1px solid var(--line);border-radius:8px;padding:.8em 1em;background:var(--fond-doux)}
72
+ .preuves h2{font-size:1em;margin:0 0 .3em;border:0} .preuves p{margin:0;color:var(--doux);font-size:.94em}
73
+ table.chiffres td{font-size:1.15em} table.chiffres th{font-weight:normal;color:var(--doux)}
74
+ .jauge{display:inline-block;width:64px;height:8px;background:var(--fond-doux);border:1px solid var(--bordure);border-radius:4px;overflow:hidden;vertical-align:middle}
75
+ .jauge>span{display:block;height:100%;background:var(--ornement)} [dir="rtl"] .jauge>span{float:right}
76
+ code{background:var(--fond-doux);padding:1px 5px;border-radius:3px}
77
+ pre,code,kbd,samp{direction:ltr;text-align:left;unicode-bidi:isolate} [dir="rtl"] pre{text-align:left}
78
+ .grille-tarifs{display:grid;grid-template-columns:repeat(auto-fit,minmax(15em,1fr));gap:1em;margin:1.5em 0}
79
+ .plan{border:1px solid var(--line);border-radius:10px;padding:1em 1.2em} .plan h2{margin-top:0;border:0}
80
+ .plan .prix{font-size:1.6rem;color:var(--brand);margin:.2em 0} .plan .prix .hint{font-size:.9rem}
81
+ .plan ul{padding-inline-start:1.2em;margin:.6em 0} .plan li{margin:.2em 0}
82
+ .jauges{list-style:none;padding:0} .jauges li{margin:.5em 0}
83
+ .barre{display:inline-block;vertical-align:middle;width:9em;height:.55em;background:var(--line);border-radius:99px;margin-inline-start:.6em;overflow:hidden}
84
+ .barre i{display:block;height:100%;background:var(--ornement)}
85
+ .moi{margin-inline-start:auto;font-size:.9em} .moi a{color:var(--accent);text-decoration:none}
86
+ .langues{margin-inline-start:.8em;font-size:.9em;color:var(--doux)} .langues a{color:var(--accent);text-decoration:none} .langues b{color:var(--brand)}
87
+ .hint{color:var(--doux);font-size:.9em} footer{max-width:920px;margin:2rem auto 0;padding:1rem;border-top:1px solid var(--line);color:var(--doux);font-size:.9em}
88
+ footer a{color:var(--accent)}`;
89
+ }
90
+
91
+ /** Un texte multilingue de la configuration : chaîne simple, ou { fr, en, ar }. */
92
+ const dansLangue = (v, lang) => (v && typeof v === 'object' ? (v[lang] ?? v.fr ?? Object.values(v)[0] ?? '') : (v ?? ''));
93
+ const remplir = (s, vars = {}) => String(s).replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
94
+
95
+ const DOCUMENTS_LEGAUX = Object.freeze([['mentions-legales', 'mentions'], ['cgu', 'cgu'], ['cgv', 'cgv'], ['confidentialite', 'confidentialite']]);
96
+
97
+ /**
98
+ * @param {object} o
99
+ * @param {{ nom: string, auteur: string, symbole?: string, slogan?: string|object, courriel?: string, licence?: string }} o.marque
100
+ * @param {{ brand?: string, lien?: string, ornement?: string, encre?: string, surface?: string }} [o.couleurs]
101
+ * @param {string[]} [o.langues] parmi fr, en, ar
102
+ * @param {Array<{ href: string, libelle: string|object }>} [o.liens] la navigation de la charge utile
103
+ * @param {Array<{ cle: string, limite: string, periode: 'jour'|'mois', libelle: string|object,
104
+ * parPeriode?: string|object, sansLimite?: string|object, aucun?: string|object }>} [o.ressources]
105
+ * @param {object} [o.textes] surcharges des libellés, par langue : { fr: { tarifs: { note: '…' } } }
106
+ */
107
+ export function creerWagonUi({ marque, couleurs = {}, langues = ['fr', 'en', 'ar'], liens = [], ressources = [], textes = {} } = {}) {
108
+ if (!String(marque?.nom ?? '').trim()) throw new Error('@mostajs/wagon-ui : `marque.nom` requis — le nom du service');
109
+ if (!String(marque?.auteur ?? '').trim()) throw new Error('@mostajs/wagon-ui : `marque.auteur` requis — qui édite le service');
110
+ const LANGUES = langues.filter((l) => CATALOGUES[l]);
111
+ if (!LANGUES.length) throw new Error('@mostajs/wagon-ui : aucune langue connue (fr, en, ar)');
112
+ const C = Object.fromEntries(Object.entries({ ...REPLI, ...couleurs }).map(([k, v]) => [k, COULEUR.test(String(v)) ? v : REPLI[k]]));
113
+ const STYLE = style(C);
114
+
115
+ /** Le traducteur d'une langue : surcharges de l'application, puis le catalogue, puis le français. */
116
+ function t(lang) {
117
+ const L = LANGUES.includes(lang) ? lang : LANGUES[0];
118
+ const chercher = (cat, cle) => cle.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), cat);
119
+ return (cle, vars) => remplir(chercher(textes[L] ?? {}, cle) ?? chercher(CATALOGUES[L], cle) ?? chercher(CATALOGUES.fr, cle) ?? cle, vars);
120
+ }
121
+ const langueDe = (lang) => (LANGUES.includes(lang) ? lang : LANGUES[0]);
122
+
123
+ /** Une ligne de ressource pour un palier : « 20 certificats par mois », « sans limite », « non compris ». */
124
+ function ligneRessource(r, valeur, lang) {
125
+ const tr = t(lang);
126
+ if (Number(valeur) === -1) return dansLangue(r.sansLimite, lang) || `${dansLangue(r.libelle, lang)} · ${tr('espace.illimite')}`;
127
+ if (!Number(valeur)) return dansLangue(r.aucun, lang) || `${dansLangue(r.libelle, lang)} · ${tr('tarifs.aucun')}`;
128
+ return r.parPeriode ? remplir(dansLangue(r.parPeriode, lang), { n: valeur }) : `${dansLangue(r.libelle, lang)} : ${valeur}`;
129
+ }
130
+
131
+ const ui = {
132
+ t, dir: dirDe, langues: LANGUES, style: STYLE,
133
+
134
+ /**
135
+ * La page entière. `contenu` est du HTML déjà rendu (par une vue de ce module, ou par la charge
136
+ * utile). `base` — l'adresse publique — ajoute les en-têtes que lisent les moteurs et les aperçus.
137
+ */
138
+ page({ actif = '/', titre = '', contenu = '', lang, connecte = false, base = '', description = '' }) {
139
+ const L = langueDe(lang); const tr = t(L);
140
+ const nom = esc(marque.nom);
141
+ const slogan = dansLangue(marque.slogan, L);
142
+ const chemin = lien(actif);
143
+ const canonique = base ? `${String(base).replace(/\/$/, '')}${actif === '/' ? '/' : actif}` : '';
144
+ const tete = canonique ? `<meta name="description" content="${esc(description || slogan)}">
145
+ <link rel="canonical" href="${esc(canonique)}">${LANGUES.map((l) => `<link rel="alternate" hreflang="${l}" href="${esc(canonique)}?lang=${l}">`).join('')}<link rel="alternate" hreflang="x-default" href="${esc(canonique)}">
146
+ <meta property="og:type" content="website"><meta property="og:site_name" content="${nom}"><meta property="og:title" content="${esc(titre)} — ${nom}">
147
+ <meta property="og:description" content="${esc(description || slogan)}"><meta property="og:url" content="${esc(canonique)}"><meta property="og:locale" content="${L}">` : '';
148
+ const nav = [...liens.map((x) => [x.href, dansLangue(x.libelle, L)]), ['/tarifs', tr('nav.tarifs')], ['/aide', tr('nav.aide')], ['/apropos', tr('nav.apropos')]];
149
+ const choixLangue = LANGUES.length < 2 ? '' : `<span class="langues">${LANGUES.map((l) => (l === L
150
+ ? `<b lang="${l}">${esc(LIBELLES_LANGUES[l])}</b>`
151
+ : `<a href="${chemin}?lang=${l}" hreflang="${l}" lang="${l}" rel="alternate">${esc(LIBELLES_LANGUES[l])}</a>`)).join(' · ')}</span>`;
152
+ return `<!doctype html><html lang="${L}" dir="${dirDe(L)}"><head><meta charset="utf-8">
153
+ <meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(titre)} — ${nom}</title>
154
+ ${tete}<style>${STYLE}</style></head><body>
155
+ <header><a class="brand" href="/">${marque.symbole ? `${esc(marque.symbole)} ` : ''}${nom}</a><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}</header>
156
+ <main>${contenu}</main>
157
+ <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>
158
+ </body></html>`;
159
+ },
160
+
161
+ /** La vitrine. Ses phrases sont celles de l'application — et ne promettent que ce que l'aide garantit. */
162
+ accueil({ lang, titre = '', lede = '', actions = [], preuves = [], note = '' }) {
163
+ return `
164
+ <section class="vitrine">
165
+ <h1>${esc(titre)}</h1>
166
+ ${lede ? `<p class="lede">${esc(lede)}</p>` : ''}
167
+ ${actions.length ? `<p class="actions">${actions.map((a) => `<a${a.principal ? ' class="bouton"' : ''} href="${lien(a.href)}">${esc(a.libelle)}</a>`).join(' ')}</p>` : ''}
168
+ ${preuves.length ? `<div class="preuves">${preuves.map((p) => `<div><h2>${esc(p.titre)}</h2><p>${esc(p.texte)}</p></div>`).join('')}</div>` : ''}
169
+ ${note ? `<p class="hint">${esc(note)}</p>` : ''}
170
+ </section>`;
171
+ },
172
+
173
+ /** Les tarifs — nourris par les paliers EN BASE, jamais réécrits à la main (le prix affiché est le prix débité). */
174
+ tarifs({ lang, paliers = [], sousTitre = '', inscriptionHref = '/inscription' }) {
175
+ const L = langueDe(lang); const tr = t(L);
176
+ return `
177
+ <h1>${esc(tr('tarifs.titre'))}</h1>
178
+ ${sousTitre ? `<p class="hint">${esc(sousTitre)}</p>` : ''}
179
+ <div class="grille-tarifs">
180
+ ${paliers.filter((p) => p.active !== false).map((p) => {
181
+ const gratuit = !Number(p.price);
182
+ const cles = p.limits?.maxCles;
183
+ return `<section class="plan">
184
+ <h2>${esc(p.name)}</h2>
185
+ <p class="prix">${gratuit ? esc(tr('tarifs.gratuit')) : `<b>${esc(euros(p.price))}</b><span class="hint">${esc(tr('tarifs.parMois'))}</span>`}</p>
186
+ <ul>
187
+ ${ressources.map((r) => `<li>${esc(ligneRessource(r, p.limits?.[r.limite], L))}</li>`).join('\n ')}
188
+ ${cles !== undefined ? `<li>${esc(Number(cles) === -1 ? `${tr('tarifs.clesPluriel', { n: '∞' })}` : tr(Number(cles) > 1 ? 'tarifs.clesPluriel' : 'tarifs.cles', { n: cles }))}</li>` : ''}
189
+ </ul>
190
+ <p><a class="bouton" href="${lien(`${inscriptionHref}?plan=${encodeURIComponent(p.slug)}`)}">${esc(tr(gratuit ? 'tarifs.commencer' : 'tarifs.choisir'))}</a></p>
191
+ </section>`;
192
+ }).join('\n')}
193
+ </div>
194
+ <p class="hint">${esc(tr('tarifs.note'))}</p>`;
195
+ },
196
+
197
+ /** L'inscription. `methode: 'code'` : sans mot de passe — l'adresse se vérifie par un code reçu par courriel. */
198
+ inscription({ lang, palier, erreur = '', minMotDePasse = 12, methode = 'motdepasse', action = '/inscription' }) {
199
+ const L = langueDe(lang); const tr = t(L);
200
+ const gratuit = !Number(palier?.price);
201
+ return `
202
+ <h1>${esc(tr('inscription.titre'))}</h1>
203
+ ${erreur ? `<p class="msg">${esc(erreur)}</p>` : ''}
204
+ <form method="post" action="${lien(action)}">
205
+ <input type="hidden" name="plan" value="${esc(palier?.slug ?? '')}">
206
+ <fieldset><legend>${esc(tr('inscription.plan'))}</legend>
207
+ <p><b>${esc(palier?.name ?? '')}</b> — ${gratuit ? esc(tr('tarifs.gratuit')) : `${esc(euros(palier.price))}${esc(tr('tarifs.parMois'))}`}</p>
208
+ </fieldset>
209
+ <div class="champs">
210
+ <label>${esc(tr('inscription.nom'))}<input type="text" name="nom" required maxlength="80"></label>
211
+ <label>${esc(tr('inscription.courriel'))}<input type="email" name="courriel" required maxlength="120" autocomplete="username"></label>
212
+ ${methode === 'motdepasse' ? `<label>${esc(tr('espace.motDePasse'))}<input type="password" name="motdepasse" required minlength="${Number(minMotDePasse) || 12}" autocomplete="new-password"></label>` : ''}
213
+ </div>
214
+ <p class="hint">${esc(tr('inscription.courrielAide'))}</p>
215
+ ${methode === 'motdepasse' ? `<p class="hint">${esc(tr('espace.motDePasseAide', { n: Number(minMotDePasse) || 12 }))}</p>` : ''}
216
+ <button type="submit">${esc(tr(gratuit ? 'inscription.bouton' : 'inscription.boutonPayant'))}</button>
217
+ </form>`;
218
+ },
219
+
220
+ /** La clé, montrée UNE fois : seule son empreinte est conservée. */
221
+ inscriptionFaite({ lang, cle, base = '', paiement = null }) {
222
+ const tr = t(langueDe(lang));
223
+ return `
224
+ <h1>${esc(tr('inscription.faite'))}</h1>
225
+ ${cle ? `<p class="msg">${esc(tr('inscription.voiciLaCle'))}</p>
226
+ <pre><code>${esc(cle)}</code></pre>
227
+ <p>${esc(tr('inscription.etEnsuite'))}</p>
228
+ <pre><code>curl -H "Authorization: Bearer ${esc(cle)}" ${esc(base)}/api/v1/moi</code></pre>` : ''}
229
+ ${paiement ? `${cle ? `<p class="hint">⚠ ${esc(tr('inscription.gardezLaCle'))}</p>` : ''}
230
+ <form method="post" action="/paiement">
231
+ <input type="hidden" name="compte" value="${esc(paiement.compteId)}">
232
+ <input type="hidden" name="plan" value="${esc(paiement.planSlug)}">
233
+ <button type="submit">${esc(tr('inscription.versPaiement'))}</button>
234
+ </form>` : ''}`;
235
+ },
236
+
237
+ /** La connexion : mot de passe, ou code reçu par courriel (`methode: 'code'`). */
238
+ connexion({ lang, erreur = '', methode = 'motdepasse', action = '/connexion' }) {
239
+ const tr = t(langueDe(lang));
240
+ return `
241
+ <h1>${esc(tr('espace.seConnecter'))}</h1>
242
+ ${erreur ? `<p class="msg">${esc(erreur)}</p>` : ''}
243
+ <form method="post" action="${lien(action)}">
244
+ <div class="champs">
245
+ <label>${esc(tr('inscription.courriel'))}<input type="email" name="courriel" required autocomplete="username"></label>
246
+ ${methode === 'motdepasse' ? `<label>${esc(tr('espace.motDePasse'))}<input type="password" name="motdepasse" required autocomplete="current-password"></label>` : ''}
247
+ </div>
248
+ <button type="submit">${esc(tr(methode === 'motdepasse' ? 'espace.seConnecter' : 'espace.recevoirCode'))}</button>
249
+ </form>
250
+ <p class="hint"><a href="/tarifs">${esc(tr('tarifs.commencer'))}</a></p>`;
251
+ },
252
+
253
+ merci({ lang }) {
254
+ const tr = t(langueDe(lang));
255
+ return `
256
+ <h1>${esc(tr('inscription.merci'))}</h1>
257
+ <p>${esc(tr('inscription.merciTexte'))}</p>
258
+ <p><a href="/compte">${esc(tr('espace.compte'))}</a></p>`;
259
+ },
260
+
261
+ /**
262
+ * L'espace du client. `vue.quotas` : { [cle de ressource]: { courant, limite (null = sans), restant } }.
263
+ * La gestion de l'abonnement (portail du prestataire) n'apparaît que si `portailPossible`.
264
+ */
265
+ espace({ lang, vue, erreur = '', cleNeuve = null }) {
266
+ const L = langueDe(lang); const tr = t(L);
267
+ const v = vue ?? {};
268
+ const jauge = (r) => {
269
+ const q = v.quotas?.[r.cle];
270
+ if (!q) return '';
271
+ const titre = dansLangue(r.libelle, L);
272
+ const periode = tr(r.periode === 'jour' ? 'espace.consommationDuJour' : 'espace.consommationDuMois');
273
+ if (q.limite === null || Number(q.limite) === -1) return `<li><b>${esc(titre)}</b> · ${esc(periode)} : ${esc(q.courant)} · ${esc(tr('espace.illimite'))}</li>`;
274
+ const part = Number(q.limite) ? Math.min(100, Math.round((Number(q.courant) / Number(q.limite)) * 100)) : 100;
275
+ return `<li><b>${esc(titre)}</b> · ${esc(periode)} : ${esc(q.courant)}/${esc(q.limite)} (${part}%)
276
+ <span class="hint">${esc(tr('espace.restant', { n: q.restant ?? 0 }))}</span>
277
+ <span class="barre" role="img" aria-label="${part}%"><i style="width:${part}%"></i></span></li>`;
278
+ };
279
+ return `
280
+ <h1>${esc(tr('espace.compte'))}</h1>
281
+ ${erreur ? `<p class="msg">${esc(erreur)}</p>` : ''}
282
+ ${cleNeuve ? `<p class="ok">${esc(tr('espace.cleCreee'))}</p><pre><code>${esc(cleNeuve)}</code></pre>` : ''}
283
+ <h2>${esc(tr('espace.votrePlan'))}</h2>
284
+ <p><b>${esc(v.plan?.name ?? '—')}</b>${Number(v.plan?.price) ? ` — ${esc(euros(v.plan.price))}${esc(tr('tarifs.parMois'))}` : ''}</p>
285
+ <ul class="jauges">
286
+ ${ressources.map(jauge).join('\n ')}
287
+ </ul>
288
+ <p>
289
+ <a class="bouton" href="/tarifs">${esc(tr('espace.ameliorer'))}</a>
290
+ ${v.portailPossible ? `<form method="post" action="/compte/portail" style="display:inline"><button type="submit">${esc(tr('espace.gererAbonnement'))}</button></form>
291
+ <span class="hint">${esc(tr('espace.gererAide'))}</span>` : ''}
292
+ </p>
293
+ <h2>${esc(tr('espace.vosCles'))}</h2>
294
+ ${(v.cles ?? []).length ? `<table>
295
+ ${v.cles.map((c) => `<tr>
296
+ <td><code>${esc(c.prefix)}</code> · ${esc(c.label)}</td>
297
+ <td class="hint">${c.usageCount ? esc(tr('espace.appels', { n: c.usageCount })) : esc(tr('espace.jamaisUtilisee'))}</td>
298
+ <td>${c.enabled
299
+ ? `<form method="post" action="/compte/cles/revoquer" style="display:inline"><input type="hidden" name="prefix" value="${esc(c.prefix)}"><button type="submit">${esc(tr('espace.revoquer'))}</button></form>`
300
+ : `<span class="hint">${esc(tr('espace.revoquee'))}</span>`}</td>
301
+ </tr>`).join('')}
302
+ </table>` : ''}
303
+ <form method="post" action="/compte/cles">
304
+ <div class="champs"><label>${esc(tr('espace.libelleCle'))}<input type="text" name="libelle" required maxlength="60"></label></div>
305
+ <button type="submit">${esc(tr('espace.creerUneCle'))}</button>
306
+ ${v.limites?.maxCles !== undefined ? `<span class="hint">${esc(tr('espace.plafondAtteint', { n: v.limites.maxCles }))}</span>` : ''}
307
+ </form>
308
+ <h2>${esc(tr('espace.vosFactures'))}</h2>
309
+ ${(v.factures ?? []).length ? `<table>
310
+ ${v.factures.map((f) => `<tr>
311
+ <td>${esc(String(f.createdAt ?? '').slice(0, 10))}</td>
312
+ <td>${esc((Number(f.amount) || 0) / 100)}${NBSP}${esc(String(f.currency ?? 'eur').toUpperCase())} · ${esc(tr(f.status === 'paid' ? 'espace.payee' : 'espace.impayee'))}</td>
313
+ <td>${f.hostedUrl && /^https:\/\//i.test(f.hostedUrl) ? `<a href="${esc(f.hostedUrl)}" rel="noopener">${esc(tr('espace.voirFacture'))}</a>` : ''}</td>
314
+ </tr>`).join('')}
315
+ </table>` : `<p class="hint">${esc(tr('espace.aucuneFacture'))}</p>`}`;
316
+ },
317
+
318
+ /** Les paliers : un champ par ressource et par palier, et les clés. Le prix vit chez le prestataire de paiement. */
319
+ adminPaliers({ lang, offres = [], message = '' }) {
320
+ const L = langueDe(lang); const tr = t(L);
321
+ const colonnes = [...ressources.map((r) => [r.limite, dansLangue(r.libelle, L)]), ['maxCles', tr('admin.cles')]];
322
+ return `
323
+ <h1>${esc(tr('admin.titre'))}</h1>
324
+ <p class="hint">${esc(tr('admin.sousTitre'))}</p>
325
+ <p><a href="/admin/usage">${esc(tr('usage.titre'))}</a></p>
326
+ ${message ? `<p class="msg">${esc(message)}</p>` : ''}
327
+ <form method="post" action="/admin/plans">
328
+ <table>
329
+ <thead><tr><th>${esc(tr('admin.palier'))}</th>${colonnes.map(([, l]) => `<th>${esc(l)}</th>`).join('')}</tr></thead>
330
+ <tbody>${offres.map((p) => `
331
+ <tr><th scope="row">${esc(p.name)}${p.active === false ? ` <span class="hint">${esc(tr('admin.nonVendu'))}</span>` : ''}</th>
332
+ ${colonnes.map(([cle]) => `<td><input type="number" name="${esc(p.slug)}.${esc(cle)}" value="${esc(String(p.limits?.[cle] ?? 0))}" step="1" min="-1" inputmode="numeric"></td>`).join('')}
333
+ </tr>`).join('')}
334
+ </tbody>
335
+ </table>
336
+ <p class="hint">${esc(tr('admin.sansLimite'))}</p>
337
+ <button type="submit">${esc(tr('admin.enregistrer'))}</button>
338
+ </form>`;
339
+ },
340
+
341
+ /**
342
+ * Le tableau d'usage. Les deux sources restent SÉPARÉES : le journal (durable, facturé) et la part
343
+ * anonyme (en mémoire, perdue au redémarrage) — additionnées, un « 0 » se lirait « personne n'est venu ».
344
+ */
345
+ adminUsage({ lang, usage, anonyme = null }) {
346
+ const L = langueDe(lang); const tr = t(L);
347
+ const u = usage ?? { jours: 0, totaux: {}, lignes: [] };
348
+ const pourcent = (p) => (p === null || p === undefined ? '—' : `${p}${NBSP}%`);
349
+ const jauge = (p) => (p === null || p === undefined
350
+ ? `<span class="hint">${esc(tr('usage.sansLimite'))}</span>`
351
+ : `<span class="jauge" role="img" aria-label="${esc(pourcent(p))}"><span style="width:${Math.min(100, Number(p))}%"></span></span> ${esc(pourcent(p))}`);
352
+ return `
353
+ <h1>${esc(tr('usage.titre'))}</h1>
354
+ <p class="hint">${esc(tr('usage.sousTitre', { jours: String(u.jours ?? '') }))}</p>
355
+ <p><a href="/admin/plans">${esc(tr('admin.titre'))}</a></p>
356
+ <table class="chiffres"><tbody>
357
+ <tr><th scope="row">${esc(tr('usage.comptes'))}</th><td><b>${esc(u.totaux?.comptes ?? 0)}</b></td>
358
+ <th scope="row">${esc(tr('usage.actifs'))}</th><td><b>${esc(u.totaux?.actifs ?? 0)}</b></td>
359
+ <th scope="row">${esc(tr('usage.payants'))}</th><td><b>${esc(u.totaux?.payants ?? 0)}</b></td></tr>
360
+ <tr>${ressources.map((r) => `<th scope="row">${esc(dansLangue(r.libelle, L))}</th><td><b>${esc(u.totaux?.parRessource?.[r.cle] ?? 0)}</b></td>`).join('')}
361
+ <th scope="row">${esc(tr('usage.erreurs'))}</th><td><b>${esc(u.totaux?.erreurs ?? 0)}</b></td></tr>
362
+ </tbody></table>
363
+ <h2>${esc(tr('usage.parCompte'))}</h2>
364
+ ${!(u.lignes ?? []).length ? `<p class="hint">${esc(tr('usage.aucunCompte'))}</p>` : `<table>
365
+ <thead><tr><th>${esc(tr('usage.compte'))}</th><th>${esc(tr('usage.plan'))}</th>${ressources.map((r) => `<th>${esc(dansLangue(r.libelle, L))}</th>`).join('')}<th>${esc(tr('usage.surFenetre'))}</th><th>${esc(tr('usage.dernierJour'))}</th></tr></thead>
366
+ <tbody>${u.lignes.map((l) => `
367
+ <tr><th scope="row">${esc(l.nom)}${l.actif === false ? ` <span class="hint">${esc(tr('usage.suspendu'))}</span>` : ''}</th>
368
+ <td>${esc(l.plan)}</td>
369
+ ${ressources.map((r) => { const q = l.parRessource?.[r.cle] ?? {}; return `<td>${esc(q.courant ?? 0)}${q.limite === null || q.limite === undefined ? '' : ` / ${esc(q.limite)}`}<br>${jauge(q.part)}</td>`; }).join('')}
370
+ <td>${ressources.map((r) => esc(l.parRessource?.[r.cle]?.fenetre ?? 0)).join(' + ')}${l.erreursFenetre ? ` <span class="hint">(${esc(l.erreursFenetre)} ${esc(tr('usage.erreursCourt'))})</span>` : ''}</td>
371
+ <td>${l.dernierJour ? esc(l.dernierJour) : `<span class="hint">${esc(tr('usage.jamais'))}</span>`}</td>
372
+ </tr>`).join('')}
373
+ </tbody>
374
+ </table>`}
375
+ ${anonyme ? `<h2>${esc(tr('usage.anonyme'))}</h2>
376
+ <p class="msg">${esc(tr('usage.anonymeVolatil', { depuis: new Date(anonyme.depuis).toISOString().slice(0, 16).replace('T', ' ') }))}</p>
377
+ <table class="chiffres"><tbody>
378
+ <tr><th scope="row">${esc(tr('usage.visiteursJour'))}</th><td><b>${esc(anonyme.visiteursJour ?? 0)}</b></td>
379
+ <th scope="row">${esc(tr('usage.adressesJour'))}</th><td><b>${esc(anonyme.adressesJour ?? 0)}</b></td></tr>
380
+ <tr><th scope="row">${esc(tr('usage.operationsAnon'))}</th><td><b>${esc(anonyme.operationsJour ?? 0)}</b></td>
381
+ <th scope="row">${esc(tr('usage.seaux'))}</th><td><b>${esc(anonyme.seaux ?? 0)}</b></td></tr>
382
+ </tbody></table>` : ''}`;
383
+ },
384
+
385
+ /**
386
+ * Un document légal. `html` est rendu par l'application (depuis ses textes, `mostajs/templates`) :
387
+ * il est écrit par l'éditeur, jamais reçu d'un formulaire — il n'est donc pas échappé. Absent : on le dit.
388
+ */
389
+ legal({ lang, doc, html = '' }) {
390
+ const tr = t(langueDe(lang));
391
+ const cle = (DOCUMENTS_LEGAUX.find(([h, k]) => h === doc || k === doc) ?? [doc, doc])[1];
392
+ return html ? `<article class="legal">${html}</article>`.replace('<article class="legal">', `<article class="legal"><p class="hint">${esc(tr(`legal.${cle}`))}</p>`)
393
+ : `<h1>${esc(tr(`legal.${cle}`))}</h1><p class="hint">${esc(tr('legal.indisponible'))}</p>`;
394
+ },
395
+
396
+ introuvable({ lang }) {
397
+ const tr = t(langueDe(lang));
398
+ return `<h1>${esc(tr('erreur.introuvable'))}</h1><p>${esc(tr('erreur.introuvableTexte'))}</p><p><a href="/">${esc(tr('erreur.retour'))}</a></p>`;
399
+ },
400
+ };
401
+ return ui;
402
+ }