@tekkare/romulus 0.1.95 → 0.1.96

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/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.94",
7
+ "version": "0.1.95",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,307 @@
1
+ <!--
2
+ RmTermsGate - conditions d'utilisation a accepter avant d'entrer.
3
+
4
+ Surface bloquante posee une fois dans l'application. Elle parait a la
5
+ premiere connexion, puis a chaque nouvelle version publiee, et ne se ferme
6
+ que par l'acceptation ou le refus. Le refus n'est pas une fermeture : il
7
+ emet `declined`, a l'application de deconnecter.
8
+
9
+ Elle ne sait ni s'authentifier ni appeler un serveur : l'application fournit
10
+ son propre client par `api`, ce qui laisse le composant hors de toute
11
+ plomberie d'authentification et permet de le monter dans n'importe quel
12
+ portail.
13
+
14
+ Les pages `/auth` sont sautees expres : l'accueil de premiere connexion y
15
+ vit deja, et deux ecrans bloquants se superposeraient.
16
+
17
+ ORIGINE AURIGA
18
+ --------------
19
+ Remplace `<arg-terms-gate>` (`argTermsGate.vue`). Les props et les
20
+ evenements gardent leur nom, `lang` est nouveau :
21
+
22
+ | Auriga | Romulus | Note |
23
+ |------------|------------|--------------------------------------------|
24
+ | `api` | `api` | meme contrat : `get()` et `accept(id)` |
25
+ | `enabled` | `enabled` | inchange |
26
+ | - | `lang` | Auriga n'ecrivait qu'en anglais |
27
+ | `@accepted`| `@accepted`| meme charge `{ version }` |
28
+ | `@declined`| `@declined`| inchange |
29
+
30
+ Repris a l'occasion : les couleurs viennent des tokens et non des variables
31
+ Auriga (`--primary`, `--darkest`, `--white`), `--z-modal` remplace le `1000`
32
+ en dur, la carte est un vrai `role="dialog"` avec piege a focus, et le
33
+ bouton de refus n'est plus un `<button>` nu sans etat visible.
34
+ -->
35
+ <script setup lang="ts">
36
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
37
+ import { useRoute } from '#app'
38
+ import { useRmDialog } from '../composables/useRmDialog'
39
+
40
+ /** Une version publiee des conditions, telle que l'API la rend. */
41
+ export interface RmTermsVersion {
42
+ id: string
43
+ version: string
44
+ title: string | null
45
+ url: string
46
+ summary: string | null
47
+ publishedAt: string
48
+ }
49
+
50
+ /** Ce que l'application doit savoir faire pour alimenter la porte. */
51
+ export interface RmTermsApi {
52
+ get: () => Promise<{
53
+ current: RmTermsVersion | null
54
+ accepted: boolean
55
+ previouslyAccepted?: boolean
56
+ }>
57
+ accept: (termsVersionId: string) => Promise<unknown>
58
+ }
59
+
60
+ const props = withDefaults(
61
+ defineProps<{
62
+ /** Client de l'application. Voir `RmTermsApi`. */
63
+ api: RmTermsApi
64
+ /** Ne consulter l'API qu'une fois le lecteur authentifie. */
65
+ enabled?: boolean
66
+ lang?: 'fr' | 'en'
67
+ }>(),
68
+ { enabled: true, lang: 'fr' },
69
+ )
70
+
71
+ const emit = defineEmits<{
72
+ accepted: [payload: { version: string }]
73
+ declined: []
74
+ }>()
75
+
76
+ const WORDS = {
77
+ fr: {
78
+ eyebrow: 'Conditions d’utilisation',
79
+ eyebrowUpdated: 'Conditions d’utilisation mises à jour',
80
+ title: 'Avant de continuer',
81
+ titleUpdated: 'Merci de relire les nouvelles conditions',
82
+ intro: 'Merci de lire et d’accepter nos conditions d’utilisation pour commencer à utiliser la plateforme.',
83
+ introUpdated: 'Nos conditions d’utilisation ont changé. Merci de lire et d’accepter la nouvelle version pour continuer à utiliser la plateforme.',
84
+ fallbackTitle: 'Conditions d’utilisation',
85
+ version: 'Version',
86
+ read: 'Lire le document complet',
87
+ check: 'J’ai lu et j’accepte les conditions d’utilisation.',
88
+ accept: 'Accepter et continuer',
89
+ saving: 'Enregistrement…',
90
+ decline: 'Refuser et se déconnecter',
91
+ stale: 'Une version plus récente vient d’être publiée. Rechargement…',
92
+ failed: 'Impossible d’enregistrer votre acceptation. Merci de réessayer.',
93
+ },
94
+ en: {
95
+ eyebrow: 'Terms of use',
96
+ eyebrowUpdated: 'Terms of use updated',
97
+ title: 'Before you continue',
98
+ titleUpdated: 'Please review the new terms',
99
+ intro: 'Please read and accept our terms of use to start using the platform.',
100
+ introUpdated: 'Our terms of use have changed. Please read and accept the new version to keep using the platform.',
101
+ fallbackTitle: 'Terms of use',
102
+ version: 'Version',
103
+ read: 'Read the full document',
104
+ check: 'I have read and I accept the terms of use.',
105
+ accept: 'Accept and continue',
106
+ saving: 'Saving…',
107
+ decline: 'Decline and sign out',
108
+ stale: 'A newer version was just published. Reloading…',
109
+ failed: 'Could not record your acceptance. Please try again.',
110
+ },
111
+ } as const
112
+
113
+ const t = computed(() => WORDS[props.lang] ?? WORDS.fr)
114
+
115
+ /** Par onglet : une fois accepte, ne pas rappeler l'API a chaque navigation. */
116
+ const CACHE_KEY = 'terms_accepted'
117
+
118
+ /** Les pages d'authentification ont leurs propres ecrans bloquants. */
119
+ const IGNORED_PREFIX = '/auth'
120
+
121
+ const route = useRoute()
122
+ const open = ref(false)
123
+ const terms = ref<RmTermsVersion | null>(null)
124
+ const updated = ref(false)
125
+ const checked = ref(false)
126
+ const saving = ref(false)
127
+ const error = ref('')
128
+ const surface = ref<HTMLElement | null>(null)
129
+
130
+ // Le piege a focus et le retour du focus a la fermeture viennent de la, mais
131
+ // pas `Escape` : une porte qui se ferme sur une touche ne bloque rien.
132
+ useRmDialog({ open, surface, close: () => {} })
133
+
134
+ const publishedLabel = computed(() => {
135
+ if (!terms.value?.publishedAt) return ''
136
+ const date = new Date(terms.value.publishedAt)
137
+ return Number.isNaN(date.getTime())
138
+ ? ''
139
+ : date.toLocaleDateString(props.lang === 'fr' ? 'fr-FR' : 'en-GB')
140
+ })
141
+
142
+ function cache(value: boolean) {
143
+ try {
144
+ if (value) sessionStorage.setItem(CACHE_KEY, '1')
145
+ else sessionStorage.removeItem(CACHE_KEY)
146
+ }
147
+ catch {
148
+ // navigation privee ou stockage coupe : on redemandera au prochain passage
149
+ }
150
+ }
151
+
152
+ function cached() {
153
+ try {
154
+ return !!sessionStorage.getItem(CACHE_KEY)
155
+ }
156
+ catch {
157
+ return false
158
+ }
159
+ }
160
+
161
+ async function check() {
162
+ if (!import.meta.client || !props.enabled || open.value || cached()) return
163
+ if (route.path.startsWith(IGNORED_PREFIX)) return
164
+ try {
165
+ const status = await props.api.get()
166
+ if (status?.accepted) {
167
+ cache(true)
168
+ return
169
+ }
170
+ // Rien de publie : personne n'est arrete.
171
+ if (!status?.current) return
172
+ terms.value = status.current
173
+ updated.value = !!status.previouslyAccepted
174
+ open.value = true
175
+ }
176
+ catch {
177
+ // Au mieux : une panne passagere de l'API ne doit pas enfermer le lecteur
178
+ }
179
+ }
180
+
181
+ async function accept() {
182
+ if (!terms.value || !checked.value) return
183
+ error.value = ''
184
+ saving.value = true
185
+ try {
186
+ await props.api.accept(terms.value.id)
187
+ cache(true)
188
+ open.value = false
189
+ emit('accepted', { version: terms.value.version })
190
+ }
191
+ catch (e: any) {
192
+ const code = e?.statusCode || e?.response?.status
193
+ if (code === 409) {
194
+ // Une version plus recente est arrivee pendant que la porte etait ouverte
195
+ error.value = t.value.stale
196
+ setTimeout(() => window.location.reload(), 1200)
197
+ }
198
+ else {
199
+ error.value = e?.data?.message || t.value.failed
200
+ }
201
+ }
202
+ finally {
203
+ saving.value = false
204
+ }
205
+ }
206
+
207
+ function decline() {
208
+ cache(false)
209
+ emit('declined')
210
+ }
211
+
212
+ // Rien ne doit defiler derriere une surface qui bloque l'application.
213
+ let previousOverflow = ''
214
+ watch(open, (value) => {
215
+ if (!import.meta.client) return
216
+ if (value) {
217
+ previousOverflow = document.body.style.overflow
218
+ document.body.style.overflow = 'hidden'
219
+ }
220
+ else {
221
+ document.body.style.overflow = previousOverflow
222
+ }
223
+ })
224
+
225
+ watch(() => props.enabled, check)
226
+ watch(() => route.path, check)
227
+ onMounted(check)
228
+ onBeforeUnmount(() => {
229
+ if (import.meta.client && open.value) document.body.style.overflow = previousOverflow
230
+ })
231
+ </script>
232
+
233
+ <template>
234
+ <Teleport to="body">
235
+ <div v-if="open" class="rm-terms-gate">
236
+ <div
237
+ ref="surface"
238
+ class="rm-terms-gate__card"
239
+ role="dialog"
240
+ aria-modal="true"
241
+ aria-labelledby="rm-terms-gate-title"
242
+ >
243
+ <span class="rm-terms-gate__eyebrow">
244
+ {{ updated ? t.eyebrowUpdated : t.eyebrow }}
245
+ </span>
246
+
247
+ <h2 id="rm-terms-gate-title" class="rm-terms-gate__title">
248
+ {{ updated ? t.titleUpdated : t.title }}
249
+ </h2>
250
+
251
+ <p class="rm-terms-gate__intro">
252
+ {{ updated ? t.introUpdated : t.intro }}
253
+ </p>
254
+
255
+ <div class="rm-terms-gate__doc">
256
+ <div class="rm-terms-gate__doc-head">
257
+ <strong>{{ terms?.title || t.fallbackTitle }}</strong>
258
+ <span class="rm-terms-gate__version">
259
+ {{ t.version }} {{ terms?.version
260
+ }}<template v-if="publishedLabel">, {{ publishedLabel }}</template>
261
+ </span>
262
+ </div>
263
+ <p v-if="terms?.summary" class="rm-terms-gate__summary">{{ terms.summary }}</p>
264
+ <a
265
+ v-if="terms?.url"
266
+ :href="terms.url"
267
+ target="_blank"
268
+ rel="noopener"
269
+ class="rm-terms-gate__link"
270
+ >
271
+ {{ t.read }}
272
+ <RmIcon name="ArrowSquareOut" :size="14" color="var(--color-primary)" />
273
+ </a>
274
+ </div>
275
+
276
+ <label class="rm-terms-gate__check">
277
+ <input v-model="checked" type="checkbox" :disabled="saving">
278
+ <span>{{ t.check }}</span>
279
+ </label>
280
+
281
+ <!-- `alert` et non `status` : l'echec arrete l'action en cours, il doit
282
+ couper ce que le lecteur d'ecran est en train de dire. -->
283
+ <p v-if="error" class="rm-terms-gate__error" role="alert">{{ error }}</p>
284
+
285
+ <RmButton
286
+ variant="primary"
287
+ block
288
+ :disabled="!checked || saving"
289
+ :loading="saving"
290
+ @click="accept"
291
+ >
292
+ {{ saving ? t.saving : t.accept }}
293
+ </RmButton>
294
+
295
+ <div class="rm-terms-gate__decline">
296
+ <RmButton variant="text" block :disabled="saving" @click="decline">
297
+ {{ t.decline }}
298
+ </RmButton>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ </Teleport>
303
+ </template>
304
+
305
+ <style scoped>
306
+ .rm-terms-gate{align-items:center;backdrop-filter:blur(12px);background:rgba(0,0,0,.6);display:flex;inset:0;justify-content:center;padding:var(--space-24);position:fixed;z-index:var(--z-modal)}.rm-terms-gate__card{background:var(--bg-surface);border-radius:var(--radius-2xl);box-shadow:var(--shadow-lg);font-family:var(--font-sans);max-height:calc(100vh - 48px);max-width:480px;overflow-y:auto;padding:var(--space-24);width:100%}.rm-terms-gate__eyebrow{color:var(--color-primary);display:inline-block;font-size:var(--font-13);font-weight:600;margin-bottom:var(--space-8)}.rm-terms-gate__title{color:var(--text-primary);font-size:var(--font-18);font-weight:700;margin:0 0 var(--space-8)}.rm-terms-gate__intro{color:var(--text-secondary);font-size:var(--font-14);line-height:1.6;margin:0 0 var(--space-20)}.rm-terms-gate__doc{border:1px solid var(--border-default);border-radius:var(--radius-lg);display:flex;flex-direction:column;gap:var(--space-8);margin-bottom:var(--space-16);padding:var(--space-16)}.rm-terms-gate__doc-head{color:var(--text-primary);display:flex;flex-direction:column;font-size:var(--font-14);gap:var(--space-2)}.rm-terms-gate__version{color:var(--text-muted);font-size:var(--font-12);font-weight:400}.rm-terms-gate__summary{color:var(--text-secondary);font-size:var(--font-14);line-height:1.6;margin:0;white-space:pre-line}.rm-terms-gate__link{align-items:center;color:var(--color-primary);display:inline-flex;font-size:var(--font-14);font-weight:600;gap:var(--space-6);text-decoration:none}.rm-terms-gate__link:hover{text-decoration:underline}.rm-terms-gate__check{align-items:flex-start;color:var(--text-primary);cursor:pointer;display:flex;font-size:var(--font-14);gap:var(--space-8);line-height:1.5;margin-bottom:var(--space-16)}.rm-terms-gate__check input{accent-color:var(--color-primary);cursor:pointer;flex-shrink:0;height:18px;margin:2px 0 0;width:18px}.rm-terms-gate__decline{margin-top:var(--space-8)}.rm-terms-gate__error{color:var(--color-danger);font-size:var(--font-13);margin:0 0 var(--space-12)}
307
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.95",
3
+ "version": "0.1.96",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",