@agence-zef/astro 0.7.1 → 0.8.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.
@@ -1,12 +1,17 @@
1
1
  ---
2
2
  /**
3
- * Bandeau cookies (C-28, CNIL), sans style imposé : réglages lus à l'exécution au CMS.
4
- * « Tout refuser » au même niveau que « Tout accepter », choix par catégorie, gardé 6 mois
5
- * (redemandé si les catégories changent). Google Analytics 4 (`gaId`) n'est chargé
6
- * qu'après accord sur « Mesure d'audience ». Un élément `data-zef-cookies` (lien « Gérer
7
- * les cookies » du footer) rouvre le bandeau. Le choix est publié dans `window.zefConsent`
8
- * et l'événement `zef:consent`. À placer une fois dans le gabarit, avant `</body>`.
3
+ * Bandeau cookies (C-28, CNIL) avec vanilla-cookieconsent : encart en bas à droite, réglages
4
+ * lus à l'exécution au CMS. « Tout refuser » au même niveau que « Tout accepter », choix par
5
+ * catégorie dans « Personnaliser » (aucune catégorie facultative cochée d'avance), gardé 6 mois
6
+ * dans le cookie `zef-consent` (redemandé si les catégories changent). Google Analytics 4
7
+ * (`gaId`) n'est chargé qu'après accord sur « Mesure d'audience » ; un retrait efface ses
8
+ * cookies `_ga*` et recharge la page. Un élément `data-zef-cookies` (lien « Gérer les cookies »
9
+ * du footer) rouvre les préférences. Le choix est publié dans `window.zefConsent` et
10
+ * l'événement `zef:consent`. À placer une fois dans le gabarit, avant `</body>`. Habillage :
11
+ * variables `--zef-*` (`@agence-zef/astro/styles.css`) ou `--cc-*` de vanilla-cookieconsent.
9
12
  */
13
+ import "vanilla-cookieconsent/dist/cookieconsent.css";
14
+
10
15
  interface Props {
11
16
  /** Identifiant de mesure GA4 (`G-XXXXXXX`), chargé seulement après accord. */
12
17
  gaId?: string | null;
@@ -20,6 +25,8 @@ const locale = Astro.currentLocale ?? "";
20
25
  {cms && <div data-zef-cookie-banner={`${cms}/api/public/cookies`} data-locale={locale} data-ga={gaId ?? undefined} hidden />}
21
26
 
22
27
  <script>
28
+ import * as CookieConsent from "vanilla-cookieconsent";
29
+
23
30
  type Texts = {
24
31
  title: string;
25
32
  message: string;
@@ -30,7 +37,6 @@ const locale = Astro.currentLocale ?? "";
30
37
  policyUrl: string | null;
31
38
  };
32
39
  type Banner = {
33
- layout: string;
34
40
  consentDays: number;
35
41
  version: string;
36
42
  texts: Record<string, Texts>;
@@ -44,13 +50,12 @@ const locale = Astro.currentLocale ?? "";
44
50
  };
45
51
  type Consent = { version: string; at: number; choices: Record<string, boolean> };
46
52
 
47
- const KEY = "zef-consent";
48
- const read = (): Consent | null => {
49
- try {
50
- return JSON.parse(localStorage.getItem(KEY) ?? "null");
51
- } catch {
52
- return null;
53
- }
53
+ /** Catégorie qui porte Google Analytics 4. */
54
+ const ANALYTICS = "mesure";
55
+
56
+ const UI: Record<string, { save: string; close: string }> = {
57
+ fr: { save: "Enregistrer mes choix", close: "Fermer" },
58
+ en: { save: "Save my choices", close: "Close" },
54
59
  };
55
60
 
56
61
  function loadGa(id: string) {
@@ -69,17 +74,14 @@ const locale = Astro.currentLocale ?? "";
69
74
  w.gtag("config", id, { anonymize_ip: true });
70
75
  }
71
76
 
72
- function apply(consent: Consent, gaId: string | undefined) {
73
- (window as unknown as { zefConsent: Consent }).zefConsent = consent;
74
- document.dispatchEvent(new CustomEvent("zef:consent", { detail: consent.choices }));
75
- if (gaId && consent.choices.mesure) loadGa(gaId);
76
- }
77
+ const escape = (text: string) =>
78
+ text.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] ?? c);
77
79
 
78
- function el<K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string) {
79
- const node = document.createElement(tag);
80
- node.className = className;
81
- if (text !== undefined) node.textContent = text;
82
- return node;
80
+ /** Numéro de révision (entier ≥ 1) tiré de la liste des catégories. */
81
+ function revision(version: string) {
82
+ let hash = 0;
83
+ for (const char of version) hash = (hash * 31 + char.charCodeAt(0)) % 1_000_000;
84
+ return hash + 1;
83
85
  }
84
86
 
85
87
  async function start(root: HTMLElement) {
@@ -92,90 +94,97 @@ const locale = Astro.currentLocale ?? "";
92
94
  }
93
95
  if (!banner) return;
94
96
  const b = banner;
95
- const locale = root.dataset.locale || document.documentElement.lang || "fr";
96
- const texts = b.texts[locale] ?? Object.values(b.texts)[0];
97
- const pick = (v: Record<string, string>) => v[locale] || Object.values(v)[0] || "";
98
-
99
- const save = (choices: Record<string, boolean>) => {
100
- const consent = { version: b.version, at: Date.now(), choices };
101
- try {
102
- localStorage.setItem(KEY, JSON.stringify(consent));
103
- } catch {
104
- // Navigation privée : le choix vaut pour la page.
105
- }
106
- apply(consent, gaId);
107
- document.querySelector(".zef-cookies")?.remove();
108
- };
109
- const all = (value: boolean) =>
110
- Object.fromEntries(b.categories.map((c) => [c.key, c.required || value]));
111
-
112
- const show = (customize = false) => {
113
- document.querySelector(".zef-cookies")?.remove();
114
- const current = read();
115
- const box = el("section", "zef-cookies");
116
- box.dataset.layout = b.layout;
117
- box.setAttribute("role", "dialog");
118
- box.setAttribute("aria-label", texts.title);
119
- box.append(el("h2", "zef-cookies__title", texts.title), el("p", "zef-cookies__message", texts.message));
120
- if (texts.policyUrl) {
121
- const a = el("a", "zef-cookies__policy", texts.policyLabel);
122
- a.href = texts.policyUrl;
123
- box.append(a);
124
- }
125
- const boxes = new Map<string, HTMLInputElement>();
126
- if (customize) {
127
- const list = el("ul", "zef-cookies__categories");
128
- for (const c of b.categories) {
129
- const item = el("li", "zef-cookies__category");
130
- const label = el("label", "zef-cookies__check");
131
- const input = document.createElement("input");
132
- input.type = "checkbox";
133
- input.checked = c.required || Boolean(current?.choices[c.key]);
134
- input.disabled = c.required;
135
- boxes.set(c.key, input);
136
- label.append(input, el("span", "", pick(c.label)));
137
- item.append(label, el("p", "zef-cookies__description", pick(c.description)));
138
- if (c.services) item.append(el("p", "zef-cookies__services", c.services));
139
- list.append(item);
140
- }
141
- box.append(list);
142
- }
143
- const actions = el("div", "zef-cookies__actions");
144
- const button = (text: string, kind: string, onClick: () => void) => {
145
- const btn = el("button", "zef-cookies__button", text);
146
- btn.type = "button";
147
- btn.dataset.kind = kind;
148
- btn.addEventListener("click", onClick);
149
- actions.append(btn);
97
+ const locales = Object.keys(b.texts);
98
+ const wanted = root.dataset.locale || document.documentElement.lang || "fr";
99
+ const locale = locales.includes(wanted) ? wanted : locales[0];
100
+
101
+ const apply = () => {
102
+ const consent: Consent = {
103
+ version: b.version,
104
+ at: Date.now(),
105
+ choices: Object.fromEntries(
106
+ b.categories.map((c) => [c.key, CookieConsent.acceptedCategory(c.key)]),
107
+ ),
150
108
  };
151
- button(texts.reject, "reject", () => save(all(false)));
152
- button(texts.accept, "accept", () => save(all(true)));
153
- if (customize) {
154
- button(locale === "en" ? "Save my choices" : "Enregistrer mes choix", "save", () =>
155
- save(Object.fromEntries([...boxes].map(([k, input]) => [k, input.checked]))),
156
- );
157
- } else {
158
- button(texts.customize, "customize", () => show(true));
159
- }
160
- box.append(actions);
161
- document.body.append(box);
162
- box.querySelector<HTMLButtonElement>("button")?.focus();
109
+ (window as unknown as { zefConsent: Consent }).zefConsent = consent;
110
+ document.dispatchEvent(new CustomEvent("zef:consent", { detail: consent.choices }));
111
+ if (gaId && consent.choices[ANALYTICS]) loadGa(gaId);
163
112
  };
164
113
 
165
- // « Gérer les cookies » (footer) rouvre le bandeau avec les choix en cours.
114
+ const translations = Object.fromEntries(
115
+ locales.map((l) => {
116
+ const t = b.texts[l];
117
+ const ui = UI[l] ?? UI.fr;
118
+ const pick = (v: Record<string, string>) => v[l] || Object.values(v)[0] || "";
119
+ const policy = t.policyUrl
120
+ ? `<a href="${escape(t.policyUrl)}">${escape(t.policyLabel)}</a>`
121
+ : undefined;
122
+ return [
123
+ l,
124
+ {
125
+ consentModal: {
126
+ title: t.title,
127
+ description: escape(t.message),
128
+ acceptAllBtn: t.accept,
129
+ acceptNecessaryBtn: t.reject,
130
+ showPreferencesBtn: t.customize,
131
+ footer: policy,
132
+ },
133
+ preferencesModal: {
134
+ title: t.title,
135
+ acceptAllBtn: t.accept,
136
+ acceptNecessaryBtn: t.reject,
137
+ savePreferencesBtn: ui.save,
138
+ closeIconLabel: ui.close,
139
+ sections: [
140
+ { description: escape(t.message) + (policy ? ` ${policy}` : "") },
141
+ ...b.categories.map((c) => ({
142
+ title: pick(c.label),
143
+ description:
144
+ escape(pick(c.description)) +
145
+ (c.services ? `<br><small>${escape(c.services)}</small>` : ""),
146
+ linkedCategory: c.key,
147
+ })),
148
+ ],
149
+ },
150
+ },
151
+ ];
152
+ }),
153
+ );
154
+
155
+ await CookieConsent.run({
156
+ cookie: { name: "zef-consent", expiresAfterDays: b.consentDays },
157
+ revision: revision(b.version),
158
+ guiOptions: {
159
+ consentModal: { layout: "box", position: "bottom right", equalWeightButtons: true },
160
+ preferencesModal: { layout: "box", equalWeightButtons: true },
161
+ },
162
+ categories: Object.fromEntries(
163
+ b.categories.map((c) => [
164
+ c.key,
165
+ c.required
166
+ ? { enabled: true, readOnly: true }
167
+ : {
168
+ // Jamais cochée d'avance : le consentement doit être un acte positif (CNIL).
169
+ enabled: false,
170
+ ...(c.key === ANALYTICS && {
171
+ autoClear: { cookies: [{ name: /^_ga/ }], reloadPage: true },
172
+ }),
173
+ },
174
+ ]),
175
+ ),
176
+ language: { default: locale, translations },
177
+ // `onConsent` part aussi à chaque page quand le choix est déjà fait.
178
+ onConsent: apply,
179
+ onChange: apply,
180
+ });
181
+
182
+ // « Gérer les cookies » (footer) rouvre les préférences avec les choix en cours.
166
183
  document.addEventListener("click", (e) => {
167
184
  if (!(e.target as Element | null)?.closest("[data-zef-cookies]")) return;
168
185
  e.preventDefault();
169
- show(true);
186
+ CookieConsent.showPreferences();
170
187
  });
171
-
172
- const current = read();
173
- const valid =
174
- current &&
175
- current.version === b.version &&
176
- Date.now() - current.at < b.consentDays * 86_400_000;
177
- if (valid) apply(current, gaId);
178
- else show();
179
188
  }
180
189
 
181
190
  const root = document.querySelector<HTMLElement>("[data-zef-cookie-banner]");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agence-zef/astro",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Intégration Astro du CMS ZEF : routes, rendu des sections, aperçu, composants réutilisables sans style.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -80,13 +80,14 @@
80
80
  "astro": "^6.0.0 || ^7.0.0"
81
81
  },
82
82
  "devDependencies": {
83
- "astro": "^7.4.4",
84
- "@types/node": "^24.0.0"
83
+ "@types/node": "^24.0.0",
84
+ "astro": "^7.4.4"
85
85
  },
86
86
  "dependencies": {
87
87
  "jiti": "^2.6.1",
88
- "@agence-zef/schema": "0.7.0",
89
- "@agence-zef/sdk": "0.7.0"
88
+ "vanilla-cookieconsent": "3.1.0",
89
+ "@agence-zef/schema": "0.8.0",
90
+ "@agence-zef/sdk": "0.8.0"
90
91
  },
91
92
  "homepage": "https://agence-zef.fr",
92
93
  "scripts": {
package/styles.css CHANGED
@@ -171,7 +171,7 @@
171
171
  font-weight: 400;
172
172
  cursor: pointer;
173
173
  }
174
- :where(.zef-check, .zef-form__check, .zef-account, .zef-checkout, .zef-cart-add, .zef-cookies)
174
+ :where(.zef-check, .zef-form__check, .zef-account, .zef-checkout, .zef-cart-add)
175
175
  :where(input[type="checkbox"], input[type="radio"]) {
176
176
  flex-shrink: 0;
177
177
  width: 1.125rem;
@@ -646,6 +646,41 @@
646
646
  }
647
647
  }
648
648
 
649
+ /* ---------- Bandeau cookies (vanilla-cookieconsent) ---------- */
650
+ /* Ses variables `--cc-*` suivent celles du site ; `:where()` laisse le site les redéfinir. */
651
+
652
+ :where(#cc-main) {
653
+ --cc-font-family: var(--zef-font);
654
+ --cc-bg: var(--zef-surface);
655
+ --cc-primary-color: var(--zef-text);
656
+ --cc-secondary-color: var(--zef-muted);
657
+ --cc-link-color: var(--zef-text);
658
+ --cc-modal-border-radius: var(--zef-radius);
659
+ --cc-btn-border-radius: var(--zef-radius-small);
660
+ --cc-btn-primary-bg: var(--zef-accent);
661
+ --cc-btn-primary-color: var(--zef-on-accent);
662
+ --cc-btn-primary-border-color: var(--zef-accent);
663
+ --cc-btn-primary-hover-bg: color-mix(in srgb, var(--zef-accent) 85%, black);
664
+ --cc-btn-primary-hover-color: var(--zef-on-accent);
665
+ --cc-btn-primary-hover-border-color: color-mix(in srgb, var(--zef-accent) 85%, black);
666
+ --cc-btn-secondary-bg: var(--zef-surface);
667
+ --cc-btn-secondary-color: var(--zef-text);
668
+ --cc-btn-secondary-border-color: var(--zef-border-strong);
669
+ --cc-btn-secondary-hover-bg: var(--zef-surface-muted);
670
+ --cc-btn-secondary-hover-color: var(--zef-text);
671
+ --cc-btn-secondary-hover-border-color: var(--zef-border-strong);
672
+ --cc-separator-border-color: var(--zef-border);
673
+ --cc-toggle-on-bg: var(--zef-accent);
674
+ --cc-cookie-category-block-bg: var(--zef-surface-muted);
675
+ --cc-cookie-category-block-border: var(--zef-border);
676
+ --cc-cookie-category-block-hover-bg: var(--zef-surface-muted);
677
+ --cc-cookie-category-block-hover-border: var(--zef-border-strong);
678
+ --cc-footer-bg: var(--zef-surface);
679
+ --cc-footer-color: var(--zef-muted);
680
+ --cc-footer-border-color: var(--zef-border);
681
+ --cc-overlay-bg: var(--zef-backdrop);
682
+ }
683
+
649
684
  @media (max-width: 40rem) {
650
685
  :where(.zef-form__field[data-width="half"]) {
651
686
  flex-basis: 100%;