srcdev-nuxt-components 9.3.13 → 9.4.1
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/.claude/skills/components/cookie-consent-banner.md +88 -0
- package/.claude/skills/composable-cookie-consent.md +78 -0
- package/.claude/skills/composable-google-analytics.md +87 -0
- package/.claude/skills/index.md +4 -1
- package/app/app.config.ts +10 -1
- package/app/components/01.atoms/cookie-consent-banner/CookieConsentBanner.vue +148 -0
- package/app/components/01.atoms/cookie-consent-banner/stories/CookieConsentBanner.stories.ts +93 -0
- package/app/components/01.atoms/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +118 -0
- package/app/composables/tests/useCookieConsent.spec.ts +87 -0
- package/app/composables/useCookieConsent.ts +54 -0
- package/app/composables/useGoogleAnalytics.ts +26 -0
- package/app/types/components/cookie-consent-banner.d.ts +8 -0
- package/app/types/components/index.ts +1 -0
- package/nuxt.config.ts +6 -0
- package/package.json +2 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# CookieConsentBanner
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Fixed, non-modal banner shown while cookie consent is undecided (`useCookieConsent().status === 'unset'`). Accept/Reject buttons call `acceptAll()`/`rejectAll()` on [[composable-cookie-consent]] directly — no `v-model`, no emits to wire up. Pair with [[composable-google-analytics]] (or any other consent-gated script) which reads the same consent state.
|
|
6
|
+
|
|
7
|
+
**Location**: `app/components/01.atoms/cookie-consent-banner/CookieConsentBanner.vue`
|
|
8
|
+
|
|
9
|
+
**Types**: `~/types/components` — `CookieConsentBannerProps`, `CookieConsentStatus`
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Props
|
|
14
|
+
|
|
15
|
+
| Prop | Type | Default | Notes |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| `theme` | `SemanticTheme` | `"info"` | Colours the banner's accent border and accept button via `--theme-accent`. |
|
|
18
|
+
| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes on the root element. |
|
|
19
|
+
|
|
20
|
+
### app.config defaults
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// Consumer's app.config.ts
|
|
24
|
+
export default defineAppConfig({
|
|
25
|
+
srcdev: {
|
|
26
|
+
cookieConsentBanner: {
|
|
27
|
+
theme: "info",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Resolution chain: **prop → app.config → hardcoded fallback**.
|
|
34
|
+
|
|
35
|
+
## Slots
|
|
36
|
+
|
|
37
|
+
| Slot | Purpose | Default |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `message` | The consent message body | "This site uses cookies to understand how it's used. You can accept or reject them." |
|
|
40
|
+
| `acceptLabel` | Accept button text | "Accept" |
|
|
41
|
+
| `rejectLabel` | Reject button text | "Reject" |
|
|
42
|
+
|
|
43
|
+
Per [[feedback_i18n_required]] in consuming apps, always fill these slots with `t()`-sourced copy rather than relying on the English defaults.
|
|
44
|
+
|
|
45
|
+
## Basic usage
|
|
46
|
+
|
|
47
|
+
Register once, in the app's default layout, alongside `DisplayToastProvider` and a call to `useGoogleAnalytics()`:
|
|
48
|
+
|
|
49
|
+
```vue
|
|
50
|
+
<!-- layouts/default.vue -->
|
|
51
|
+
<script setup lang="ts">
|
|
52
|
+
const { t } = useI18n();
|
|
53
|
+
useGoogleAnalytics();
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<div class="page-layout">
|
|
58
|
+
<slot />
|
|
59
|
+
<DisplayToastProvider position="top" alignment="right" :max-visible="3" />
|
|
60
|
+
<CookieConsentBanner>
|
|
61
|
+
<template #message>{{ t("global.cookieConsent.message") }}</template>
|
|
62
|
+
<template #acceptLabel>{{ t("global.cookieConsent.accept") }}</template>
|
|
63
|
+
<template #rejectLabel>{{ t("global.cookieConsent.reject") }}</template>
|
|
64
|
+
</CookieConsentBanner>
|
|
65
|
+
</div>
|
|
66
|
+
</template>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## CSS / styling
|
|
70
|
+
|
|
71
|
+
Public tokens (all on `.privacy-notice-banner`, `var(--privacy-notice-banner-*, fallback)` pattern):
|
|
72
|
+
|
|
73
|
+
| Token | Default |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `--privacy-notice-banner-z-index` | `999999` |
|
|
76
|
+
| `--privacy-notice-banner-gutter` | `1.6rem` |
|
|
77
|
+
| `--privacy-notice-banner-max-width` | `64rem` |
|
|
78
|
+
| `--privacy-notice-banner-border-radius` | `0.8rem` |
|
|
79
|
+
| `--privacy-notice-banner-border` | `0.1rem solid light-dark(var(--slate-10), var(--slate-02))` |
|
|
80
|
+
| `--privacy-notice-banner-background` | `light-dark(var(--slate-00), var(--slate-10))` |
|
|
81
|
+
| `--privacy-notice-banner-transition-duration` | `200ms` |
|
|
82
|
+
|
|
83
|
+
## Notes
|
|
84
|
+
|
|
85
|
+
- **Teleported to `<body>`** — like `DisplayToastProvider`, query it in tests via `document.querySelector(".privacy-notice-banner")`, not `wrapper.find(...)`.
|
|
86
|
+
- **No focus trap / backdrop** — this is a dismiss-by-decision banner, not a modal. It collapses via a `grid-template-rows` transition (same mechanic as `DisplayPrompt`) once `status` leaves `"unset"`, rather than unmounting.
|
|
87
|
+
- **Only ever one instance** — `useCookieConsent()`'s underlying state is a module-scope singleton, so mounting the banner twice in one app just duplicates the UI, it doesn't create separate consent state.
|
|
88
|
+
- To let a visitor change their mind later (e.g. from a cookie-policy page), call `useCookieConsent().rejectAll()` or clear the `cookie-consent` cookie — the banner reappears since `status` returns to `"unset"` only once the cookie is gone; `rejectAll()` itself sets it to `"denied"`, which keeps the banner hidden but stops GA. Expose a dedicated "reset my choice" affordance if you want the banner itself to resurface.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# useCookieConsent Composable
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`useCookieConsent` tracks the visitor's cookie-consent decision (`unset` / `granted` / `denied`), persists it in a `privacy-notice-consent` cookie, and wraps `@nuxt/scripts`' `useScriptTriggerConsent()` gate so any consent-dependent script (Google Analytics via [[composable-google-analytics]], or anything else added later) can be wired to it.
|
|
6
|
+
|
|
7
|
+
**This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useCookieConsent.ts`). Consuming apps get it via Nuxt's layer auto-import — **do not create a local copy** in the consuming app.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- `@nuxt/scripts` — the layer itself declares this as a dependency and registers it in its own `modules` array, so it's present in any app that extends this layer. No action needed in the consuming app, but be aware `useScriptTriggerConsent`/`useScriptGoogleAnalytics` are auto-imported from that module, not this layer.
|
|
12
|
+
|
|
13
|
+
## Setup in the consuming app
|
|
14
|
+
|
|
15
|
+
### 1. No import needed
|
|
16
|
+
|
|
17
|
+
`useCookieConsent` is auto-imported by Nuxt from the layer. Use it directly in `<script setup>` or any composable without an explicit import.
|
|
18
|
+
|
|
19
|
+
### 2. Render the banner
|
|
20
|
+
|
|
21
|
+
Pair it with [[cookie-consent-banner]] (`CookieConsentBanner.vue`), registered once in the app's default layout — see that component's skill doc for placement and copy slots.
|
|
22
|
+
|
|
23
|
+
## Composable reference
|
|
24
|
+
|
|
25
|
+
Source lives at `app/composables/useCookieConsent.ts` in the layer. Shown here for reference only — do not recreate it in the consuming app.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const consentTrigger = useScriptTriggerConsent(); // module-scope singleton
|
|
29
|
+
|
|
30
|
+
export function useCookieConsent() {
|
|
31
|
+
const stored = useCookie<"granted" | "denied" | null>("privacy-notice-consent", {
|
|
32
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
33
|
+
sameSite: "lax",
|
|
34
|
+
default: () => null,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (stored.value === "granted" && !consentTrigger.consented.value) {
|
|
38
|
+
consentTrigger.accept();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const status = computed(() => stored.value ?? "unset");
|
|
42
|
+
|
|
43
|
+
const acceptAll = () => {
|
|
44
|
+
stored.value = "granted";
|
|
45
|
+
consentTrigger.accept();
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const rejectAll = () => {
|
|
49
|
+
stored.value = "denied";
|
|
50
|
+
consentTrigger.revoke();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return { status, acceptAll, rejectAll, trigger: consentTrigger };
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Key rules
|
|
58
|
+
|
|
59
|
+
- **`useScriptTriggerConsent()` is called once at module scope**, mirroring `@nuxt/scripts`' own documented pattern — it's a single shared gate for the app's lifetime, not a fresh instance per call-site. `useCookie()` is read fresh inside the function body on every call instead, which stays SSR-request-safe (Nuxt dedupes `useCookie()` by key within a single request).
|
|
60
|
+
- **`status` is the public read API.** `trigger` is exposed only so `useGoogleAnalytics` (or another consent-gated script composable) can pass it straight into `scriptOptions.trigger` — don't read/mutate `trigger` directly from app code, use `status`/`acceptAll`/`rejectAll`.
|
|
61
|
+
- A prior "granted" cookie is replayed into the trigger on init, since the in-memory trigger resets on every full page load but the cookie doesn't.
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const { status, acceptAll, rejectAll } = useCookieConsent();
|
|
67
|
+
|
|
68
|
+
if (status.value === "unset") {
|
|
69
|
+
// show the banner
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
To let a user change their mind later (e.g. a "cookie preferences" link in the footer or a cookie-policy page), call `rejectAll()` or clear the `privacy-notice-consent` cookie to bring the banner back.
|
|
74
|
+
|
|
75
|
+
## Notes
|
|
76
|
+
|
|
77
|
+
- Cookie name `privacy-notice-consent` is fixed by the layer, not configurable per app.
|
|
78
|
+
- This composable only tracks the yes/no decision — it does not itself load any script. See [[composable-google-analytics]] for the GA4 integration that consumes `trigger`.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# useGoogleAnalytics Composable
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`useGoogleAnalytics` loads Google Analytics 4 (`gtag.js`) via `@nuxt/scripts`' `useScriptGoogleAnalytics()`, gated behind [[composable-cookie-consent]]'s consent state and Google Consent Mode v2's `defaultConsent`. The script does not fetch, and no analytics cookie is set, until the visitor accepts via [[cookie-consent-banner]] (or your own call to `useCookieConsent().acceptAll()`).
|
|
6
|
+
|
|
7
|
+
**This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useGoogleAnalytics.ts`). Consuming apps get it via Nuxt's layer auto-import — **do not create a local copy** in the consuming app.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- `NUXT_PUBLIC_GOOGLE_ANALYTICS_ID` env var set to your GA4 measurement ID (`G-XXXXXXXXXX`).
|
|
12
|
+
- `@nuxt/scripts` — already a layer dependency, registered in the layer's own `modules` array. Nothing to add in the consuming app.
|
|
13
|
+
- The app's CSP (if using `nuxt-security`) must allow `https://www.googletagmanager.com` in `script-src`/`connect-src` and `https://www.google-analytics.com` in `connect-src`, or the script will be blocked once consent is granted.
|
|
14
|
+
|
|
15
|
+
## Setup in the consuming app
|
|
16
|
+
|
|
17
|
+
### 1. Runtime config
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// nuxt.config.ts
|
|
21
|
+
runtimeConfig: {
|
|
22
|
+
public: {
|
|
23
|
+
googleAnalytics: {
|
|
24
|
+
id: "", // NUXT_PUBLIC_GOOGLE_ANALYTICS_ID
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### 2. Call it once
|
|
31
|
+
|
|
32
|
+
Call `useGoogleAnalytics()` once, near the app root — e.g. in the default layout's `<script setup>`, alongside where [[cookie-consent-banner]] is registered:
|
|
33
|
+
|
|
34
|
+
```vue
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
useGoogleAnalytics();
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<div class="page-layout">
|
|
41
|
+
<slot />
|
|
42
|
+
<CookieConsentBanner />
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
It no-ops (with a console warning) if `googleAnalytics.id` is unset — safe to call unconditionally in every environment, including local dev without a real measurement ID.
|
|
48
|
+
|
|
49
|
+
## Composable reference
|
|
50
|
+
|
|
51
|
+
Source lives at `app/composables/useGoogleAnalytics.ts` in the layer. Shown here for reference only — do not recreate it in the consuming app.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
export const useGoogleAnalytics = () => {
|
|
55
|
+
const config = useRuntimeConfig();
|
|
56
|
+
const id = config.public.googleAnalytics?.id;
|
|
57
|
+
|
|
58
|
+
if (!id) {
|
|
59
|
+
console.warn("[useGoogleAnalytics] public.googleAnalytics.id is not configured");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { trigger } = useCookieConsent();
|
|
64
|
+
|
|
65
|
+
useScriptGoogleAnalytics({
|
|
66
|
+
id,
|
|
67
|
+
scriptOptions: { trigger },
|
|
68
|
+
defaultConsent: {
|
|
69
|
+
ad_storage: "denied",
|
|
70
|
+
ad_user_data: "denied",
|
|
71
|
+
ad_personalization: "denied",
|
|
72
|
+
analytics_storage: "denied",
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Key rules
|
|
79
|
+
|
|
80
|
+
- **`useRuntimeConfig()` inside the function body**, not module scope — same reasoning as `useWhatsApp` (see [[composable-whatsapp]]).
|
|
81
|
+
- **`defaultConsent` is all `"denied"`** — this is what makes the script Consent Mode v2 compliant: gtag.js can load (if triggered) but won't set cookies or send identifiable pings until `useCookieConsent().acceptAll()` calls `trigger.accept()`, which flips consent to granted via `@nuxt/scripts`' own consent-update wiring.
|
|
82
|
+
- Does not return anything — it's a side-effecting setup call, not a value composable. Read GA-related state (if ever needed) via `useCookieConsent()` instead.
|
|
83
|
+
|
|
84
|
+
## Notes
|
|
85
|
+
|
|
86
|
+
- Only loads GA4 (`gtag.js`) — no other Google tags (Ads, Tag Manager container, etc.). Extend `defaultConsent`/add a second `useScript*` call if those are needed later.
|
|
87
|
+
- The measurement ID in `public` config is visible in the client bundle — expected, GA4 IDs are not secret.
|
package/.claude/skills/index.md
CHANGED
|
@@ -59,6 +59,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
59
59
|
├── composable-dialog-controls.md — useDialogControls: single-call setup with config object, openDialog/closeDialog API, confirm/cancel callbacks
|
|
60
60
|
├── composable-anchor-scroll.md — useAnchorScroll: smooth anchor scrolling with reduced-motion support, dynamic offset, and TabNavigation integration
|
|
61
61
|
├── composable-tooltips-guide.md — useTooltipsGuide: sequential popover guide with auto-start, dismiss-to-advance, manual controls
|
|
62
|
+
├── composable-cookie-consent.md — useCookieConsent: unset/granted/denied state, cookie persistence, wraps @nuxt/scripts' useScriptTriggerConsent
|
|
63
|
+
├── composable-google-analytics.md — useGoogleAnalytics: GA4 via @nuxt/scripts' useScriptGoogleAnalytics, Consent Mode v2 defaultConsent, gated on useCookieConsent
|
|
62
64
|
└── components/
|
|
63
65
|
├── alert-content-inner.md — AlertContentInner: shared icon/body/dismiss molecule under AlertContent + AlertMaskedContent; app.config icon map (alertContent.icons + dismissIcon) covers all consumers
|
|
64
66
|
├── accordian-core.md — AccordianCore indexed dynamic slots (accordian-{n}-summary/icon/content), exclusive-open grouping
|
|
@@ -106,7 +108,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
106
108
|
├── expanding-panel-classic.md — ExpandingPanelClassic: grid-template-rows animation (no Baseline-2025 dependency), same API as ExpandingPanel, cross-browser animation parity trade-off
|
|
107
109
|
├── site-header.md — SiteHeader: PageRow + SkipLinks + ResponsiveHeader composition, #branding/#secondaryNavigation slots, dual styleClassPassthrough hooks
|
|
108
110
|
├── responsive-header.md — ResponsiveHeader: overflow-collapsing adaptive nav, measurement-pipeline gotchas (unsized icons, vw font-size drift), full CSS token API
|
|
109
|
-
|
|
111
|
+
├── navigation-items.md — NavigationItems: internal overflow-panel renderer for ResponsiveHeader, complement-visibility logic, not used standalone
|
|
112
|
+
└── cookie-consent-banner.md — CookieConsentBanner: fixed non-modal Accept/Reject banner driven by useCookieConsent, message/acceptLabel/rejectLabel slots, CSS token API
|
|
110
113
|
```
|
|
111
114
|
|
|
112
115
|
## Skill file template
|
package/app/app.config.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
SemanticTheme,
|
|
3
|
+
DisplayPromptTheme,
|
|
4
|
+
DisplayToastTheme,
|
|
5
|
+
DisplayToastPosition,
|
|
6
|
+
DisplayToastAlignment,
|
|
7
|
+
} from "./types/components";
|
|
2
8
|
|
|
3
9
|
export default defineAppConfig({
|
|
4
10
|
srcdev: {
|
|
@@ -40,5 +46,8 @@ export default defineAppConfig({
|
|
|
40
46
|
theme: undefined as SemanticTheme | undefined,
|
|
41
47
|
closeIcon: "bitcoin-icons:cross-filled",
|
|
42
48
|
},
|
|
49
|
+
cookieConsentBanner: {
|
|
50
|
+
theme: "info" as SemanticTheme,
|
|
51
|
+
},
|
|
43
52
|
},
|
|
44
53
|
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<div
|
|
4
|
+
class="privacy-notice-banner"
|
|
5
|
+
:class="[{ closed: status !== 'unset' }, elementClasses]"
|
|
6
|
+
:data-theme="resolved.theme"
|
|
7
|
+
data-test-id="privacy-notice-banner"
|
|
8
|
+
>
|
|
9
|
+
<div class="privacy-notice-banner-inner" role="region" :aria-label="ariaLabel">
|
|
10
|
+
<div class="privacy-notice-banner-message">
|
|
11
|
+
<slot name="message">This site uses cookies to understand how it's used. You can accept or reject them.</slot>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="privacy-notice-banner-actions">
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
class="privacy-notice-banner-reject"
|
|
17
|
+
data-test-id="privacy-notice-banner-reject"
|
|
18
|
+
@click="rejectAll()"
|
|
19
|
+
>
|
|
20
|
+
<slot name="rejectLabel">Reject</slot>
|
|
21
|
+
</button>
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
class="privacy-notice-banner-accept"
|
|
25
|
+
data-test-id="privacy-notice-banner-accept"
|
|
26
|
+
@click="acceptAll()"
|
|
27
|
+
>
|
|
28
|
+
<slot name="acceptLabel">Accept</slot>
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</Teleport>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<script setup lang="ts">
|
|
37
|
+
import type { CookieConsentBannerProps } from "../../../types/components";
|
|
38
|
+
|
|
39
|
+
const props = withDefaults(defineProps<CookieConsentBannerProps>(), {
|
|
40
|
+
theme: undefined,
|
|
41
|
+
styleClassPassthrough: () => [],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const appConfig = useAppConfig();
|
|
45
|
+
|
|
46
|
+
const resolved = computed(() => {
|
|
47
|
+
const config = appConfig.srcdev?.cookieConsentBanner;
|
|
48
|
+
return {
|
|
49
|
+
theme: props.theme ?? config?.theme ?? "info",
|
|
50
|
+
} as const;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
54
|
+
const { status, acceptAll, rejectAll } = useCookieConsent();
|
|
55
|
+
const ariaLabel = "Cookie consent";
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<style lang="css">
|
|
59
|
+
@layer components {
|
|
60
|
+
.privacy-notice-banner {
|
|
61
|
+
/* Matches DisplayToastProvider/DisplayDialog's z-index convention so
|
|
62
|
+
this clears ordinary page chrome (and a consumer's sticky header) but
|
|
63
|
+
still sits below an active modal dialog if one somehow overlaps. */
|
|
64
|
+
--_z-index: var(--privacy-notice-banner-z-index, 999999);
|
|
65
|
+
--_gutter: var(--privacy-notice-banner-gutter, 1.6rem);
|
|
66
|
+
--_max-width: var(--privacy-notice-banner-max-width, 64rem);
|
|
67
|
+
--_border-radius: var(--privacy-notice-banner-border-radius, 0.8rem);
|
|
68
|
+
--_border: var(--privacy-notice-banner-border, 0.1rem solid light-dark(var(--slate-10), var(--slate-02)));
|
|
69
|
+
--_background: var(--privacy-notice-banner-background, light-dark(var(--slate-00), var(--slate-10)));
|
|
70
|
+
--_transition-duration: var(--privacy-notice-banner-transition-duration, 200ms);
|
|
71
|
+
|
|
72
|
+
position: fixed;
|
|
73
|
+
inset-inline: var(--_gutter);
|
|
74
|
+
inset-block-end: var(--_gutter);
|
|
75
|
+
z-index: var(--_z-index);
|
|
76
|
+
margin-inline: auto;
|
|
77
|
+
max-width: var(--_max-width);
|
|
78
|
+
|
|
79
|
+
display: grid;
|
|
80
|
+
grid-template-rows: 1fr;
|
|
81
|
+
opacity: 1;
|
|
82
|
+
transition: all var(--_transition-duration) ease-in-out;
|
|
83
|
+
|
|
84
|
+
&.closed {
|
|
85
|
+
grid-template-rows: 0fr;
|
|
86
|
+
opacity: 0;
|
|
87
|
+
pointer-events: none;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.privacy-notice-banner-inner {
|
|
91
|
+
overflow: hidden;
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-wrap: wrap;
|
|
94
|
+
align-items: center;
|
|
95
|
+
gap: 1.2rem;
|
|
96
|
+
padding: 1.6rem;
|
|
97
|
+
border-radius: var(--_border-radius);
|
|
98
|
+
border: var(--_border);
|
|
99
|
+
background-color: var(--_background);
|
|
100
|
+
|
|
101
|
+
&[aria-label] {
|
|
102
|
+
border-block-start: 0.2rem solid var(--theme-accent);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.privacy-notice-banner-message {
|
|
107
|
+
flex: 1 1 24rem;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.privacy-notice-banner-actions {
|
|
111
|
+
display: flex;
|
|
112
|
+
gap: 0.8rem;
|
|
113
|
+
margin-inline-start: auto;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.privacy-notice-banner-reject,
|
|
117
|
+
.privacy-notice-banner-accept {
|
|
118
|
+
padding: 0.8rem 1.6rem;
|
|
119
|
+
border-radius: 0.4rem;
|
|
120
|
+
border: 0.1rem solid transparent;
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
transition:
|
|
123
|
+
border-color var(--_transition-duration),
|
|
124
|
+
background-color var(--_transition-duration);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.privacy-notice-banner-reject {
|
|
128
|
+
background-color: transparent;
|
|
129
|
+
border: 0.1rem solid light-dark(var(--slate-08), var(--slate-04));
|
|
130
|
+
|
|
131
|
+
&:hover,
|
|
132
|
+
&:focus-visible {
|
|
133
|
+
border-color: var(--theme-accent);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.privacy-notice-banner-accept {
|
|
138
|
+
background-color: var(--theme-accent);
|
|
139
|
+
color: light-dark(var(--slate-00), var(--slate-12));
|
|
140
|
+
|
|
141
|
+
&:hover,
|
|
142
|
+
&:focus-visible {
|
|
143
|
+
opacity: 0.9;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
</style>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
+
import StorybookComponent from "../CookieConsentBanner.vue";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
title: "Atoms/CookieConsentBanner",
|
|
6
|
+
component: StorybookComponent,
|
|
7
|
+
argTypes: {
|
|
8
|
+
theme: {
|
|
9
|
+
control: { type: "inline-radio" },
|
|
10
|
+
options: ["info", "success", "warning", "error"],
|
|
11
|
+
description: "Semantic theme for the banner's accent border/accept button",
|
|
12
|
+
table: { category: "Appearance" },
|
|
13
|
+
},
|
|
14
|
+
styleClassPassthrough: {
|
|
15
|
+
control: { type: "object" },
|
|
16
|
+
description: "Extra classes applied to the banner root",
|
|
17
|
+
table: { category: "Styling" },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
args: {
|
|
21
|
+
theme: "info",
|
|
22
|
+
styleClassPassthrough: [],
|
|
23
|
+
},
|
|
24
|
+
parameters: {
|
|
25
|
+
docs: {
|
|
26
|
+
description: {
|
|
27
|
+
// Drives itself from the real useCookieConsent()/useCookie() state, so
|
|
28
|
+
// once accepted/rejected in this browser it stays hidden across story
|
|
29
|
+
// reloads. Each story below has "Show banner again" (resets state
|
|
30
|
+
// in-place) and "Delete cookie" (clears document.cookie directly, for
|
|
31
|
+
// verifying the cookie itself is actually gone, e.g. in devtools).
|
|
32
|
+
component: "Reads/writes a real 'privacy-notice-consent' cookie via useCookieConsent(). Use the story's reset controls to bring the banner back.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
} as Meta<typeof StorybookComponent>;
|
|
37
|
+
|
|
38
|
+
// Resets the same "privacy-notice-consent" cookie useCookieConsent() reads, so the
|
|
39
|
+
// banner's status goes back to "unset" and reappears without a page reload
|
|
40
|
+
// (Nuxt dedupes useCookie() by key, so this shares the ref CookieConsentBanner
|
|
41
|
+
// itself reads).
|
|
42
|
+
function useResetConsent() {
|
|
43
|
+
const consentCookie = useCookie<"granted" | "denied" | null>("privacy-notice-consent");
|
|
44
|
+
const showAgain = () => {
|
|
45
|
+
consentCookie.value = null;
|
|
46
|
+
};
|
|
47
|
+
// Belt-and-braces alongside showAgain(): clears the actual browser cookie
|
|
48
|
+
// rather than just the in-memory ref, so devtools/Application tab reflects
|
|
49
|
+
// it too, not only the story's live re-render.
|
|
50
|
+
const deleteCookie = () => {
|
|
51
|
+
document.cookie = "privacy-notice-consent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
|
|
52
|
+
consentCookie.value = null;
|
|
53
|
+
};
|
|
54
|
+
return { showAgain, deleteCookie };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const Template: StoryFn<typeof StorybookComponent> = (args) => ({
|
|
58
|
+
components: { StorybookComponent },
|
|
59
|
+
setup() {
|
|
60
|
+
const { showAgain, deleteCookie } = useResetConsent();
|
|
61
|
+
return { args, showAgain, deleteCookie };
|
|
62
|
+
},
|
|
63
|
+
template: `
|
|
64
|
+
<div style="padding: 2rem; min-height: 240px; position: relative;">
|
|
65
|
+
<button type="button" @click="showAgain">Show banner again</button>
|
|
66
|
+
<button type="button" @click="deleteCookie" style="margin-inline-start: 0.8rem;">Delete cookie</button>
|
|
67
|
+
<StorybookComponent :theme="args.theme" :style-class-passthrough="args.styleClassPassthrough">
|
|
68
|
+
<template #message>This site uses cookies for analytics. You can accept or reject them.</template>
|
|
69
|
+
</StorybookComponent>
|
|
70
|
+
</div>
|
|
71
|
+
`,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const Default = Template.bind({});
|
|
75
|
+
|
|
76
|
+
export const CustomCopy: StoryFn<typeof StorybookComponent> = (args) => ({
|
|
77
|
+
components: { StorybookComponent },
|
|
78
|
+
setup() {
|
|
79
|
+
const { showAgain, deleteCookie } = useResetConsent();
|
|
80
|
+
return { args, showAgain, deleteCookie };
|
|
81
|
+
},
|
|
82
|
+
template: `
|
|
83
|
+
<div style="padding: 2rem; min-height: 240px; position: relative;">
|
|
84
|
+
<button type="button" @click="showAgain">Show banner again</button>
|
|
85
|
+
<button type="button" @click="deleteCookie" style="margin-inline-start: 0.8rem;">Delete cookie</button>
|
|
86
|
+
<StorybookComponent :theme="args.theme">
|
|
87
|
+
<template #message>We use cookies to understand traffic to this site. No personal data is sold.</template>
|
|
88
|
+
<template #acceptLabel>Allow cookies</template>
|
|
89
|
+
<template #rejectLabel>No thanks</template>
|
|
90
|
+
</StorybookComponent>
|
|
91
|
+
</div>
|
|
92
|
+
`,
|
|
93
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
|
|
4
|
+
import CookieConsentBanner from "../CookieConsentBanner.vue";
|
|
5
|
+
|
|
6
|
+
const { useAppConfigMock } = vi.hoisted(() => ({
|
|
7
|
+
useAppConfigMock: vi.fn(() => ({ srcdev: undefined as Record<string, unknown> | undefined, icon: {} as object })),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const statusRef = ref<"unset" | "granted" | "denied">("unset");
|
|
11
|
+
const acceptAllSpy = vi.fn(() => {
|
|
12
|
+
statusRef.value = "granted";
|
|
13
|
+
});
|
|
14
|
+
const rejectAllSpy = vi.fn(() => {
|
|
15
|
+
statusRef.value = "denied";
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
mockNuxtImport("useAppConfig", () => useAppConfigMock);
|
|
19
|
+
mockNuxtImport("useCookieConsent", () => () => ({
|
|
20
|
+
status: statusRef,
|
|
21
|
+
acceptAll: acceptAllSpy,
|
|
22
|
+
rejectAll: rejectAllSpy,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
function banner() {
|
|
26
|
+
return document.querySelector(".privacy-notice-banner");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function acceptButton() {
|
|
30
|
+
return document.querySelector("[data-test-id='privacy-notice-banner-accept']") as HTMLElement;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rejectButton() {
|
|
34
|
+
return document.querySelector("[data-test-id='privacy-notice-banner-reject']") as HTMLElement;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("CookieConsentBanner", () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
statusRef.value = "unset";
|
|
40
|
+
acceptAllSpy.mockClear();
|
|
41
|
+
rejectAllSpy.mockClear();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
document.body.innerHTML = "";
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("mounts without error", async () => {
|
|
49
|
+
const w = await mountSuspended(CookieConsentBanner);
|
|
50
|
+
expect(w.vm).toBeTruthy();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders the banner, teleported to body", async () => {
|
|
54
|
+
await mountSuspended(CookieConsentBanner);
|
|
55
|
+
expect(banner()).not.toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("is visible (no closed class) while status is 'unset'", async () => {
|
|
59
|
+
await mountSuspended(CookieConsentBanner);
|
|
60
|
+
expect(banner()!.classList).not.toContain("closed");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("adds the closed class once status is no longer 'unset'", async () => {
|
|
64
|
+
statusRef.value = "granted";
|
|
65
|
+
await mountSuspended(CookieConsentBanner);
|
|
66
|
+
expect(banner()!.classList).toContain("closed");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("defaults to data-theme='info'", async () => {
|
|
70
|
+
await mountSuspended(CookieConsentBanner);
|
|
71
|
+
expect(banner()!.getAttribute("data-theme")).toBe("info");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("uses app.config theme when no prop is supplied", async () => {
|
|
75
|
+
useAppConfigMock.mockReturnValue({ icon: {}, srcdev: { cookieConsentBanner: { theme: "success" } } });
|
|
76
|
+
await mountSuspended(CookieConsentBanner);
|
|
77
|
+
expect(banner()!.getAttribute("data-theme")).toBe("success");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("explicit theme prop takes precedence over app.config", async () => {
|
|
81
|
+
useAppConfigMock.mockReturnValue({ icon: {}, srcdev: { cookieConsentBanner: { theme: "warning" } } });
|
|
82
|
+
await mountSuspended(CookieConsentBanner, { props: { theme: "error" } });
|
|
83
|
+
expect(banner()!.getAttribute("data-theme")).toBe("error");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("renders default message copy", async () => {
|
|
87
|
+
await mountSuspended(CookieConsentBanner);
|
|
88
|
+
expect(banner()!.textContent).toContain("cookies");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("renders message slot content", async () => {
|
|
92
|
+
await mountSuspended(CookieConsentBanner, { slots: { message: "Custom cookie copy" } });
|
|
93
|
+
expect(banner()!.textContent).toContain("Custom cookie copy");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("renders acceptLabel/rejectLabel slot content", async () => {
|
|
97
|
+
await mountSuspended(CookieConsentBanner, { slots: { acceptLabel: "Yes please", rejectLabel: "No thanks" } });
|
|
98
|
+
expect(acceptButton().textContent).toContain("Yes please");
|
|
99
|
+
expect(rejectButton().textContent).toContain("No thanks");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("calls acceptAll() when the accept button is clicked", async () => {
|
|
103
|
+
await mountSuspended(CookieConsentBanner);
|
|
104
|
+
acceptButton().click();
|
|
105
|
+
expect(acceptAllSpy).toHaveBeenCalledOnce();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("calls rejectAll() when the reject button is clicked", async () => {
|
|
109
|
+
await mountSuspended(CookieConsentBanner);
|
|
110
|
+
rejectButton().click();
|
|
111
|
+
expect(rejectAllSpy).toHaveBeenCalledOnce();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("applies a styleClassPassthrough class to the root", async () => {
|
|
115
|
+
await mountSuspended(CookieConsentBanner, { props: { styleClassPassthrough: "outlined" } });
|
|
116
|
+
expect(banner()!.classList).toContain("outlined");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
import { mockNuxtImport } from "@nuxt/test-utils/runtime";
|
|
4
|
+
|
|
5
|
+
// useCookieConsent creates its useScriptTriggerConsent() gate once at module
|
|
6
|
+
// scope (mirrors @nuxt/scripts' own documented pattern), so both mocks must
|
|
7
|
+
// be in place before the module under test is first imported. vi.hoisted's
|
|
8
|
+
// factory runs before any import, so it can only hold vi.fn() spies — real
|
|
9
|
+
// Vue refs are built below, after imports, and captured by the mock
|
|
10
|
+
// factories' closures instead.
|
|
11
|
+
const { acceptSpy, revokeSpy } = vi.hoisted(() => ({
|
|
12
|
+
acceptSpy: vi.fn(),
|
|
13
|
+
revokeSpy: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const consentedRef = ref(false);
|
|
17
|
+
const cookieRef = ref<"granted" | "denied" | null>(null);
|
|
18
|
+
|
|
19
|
+
mockNuxtImport("useScriptTriggerConsent", () => () => ({
|
|
20
|
+
consented: consentedRef,
|
|
21
|
+
accept: () => {
|
|
22
|
+
consentedRef.value = true;
|
|
23
|
+
acceptSpy();
|
|
24
|
+
},
|
|
25
|
+
revoke: () => {
|
|
26
|
+
consentedRef.value = false;
|
|
27
|
+
revokeSpy();
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
mockNuxtImport("useCookie", () => () => cookieRef);
|
|
32
|
+
|
|
33
|
+
const { useCookieConsent } = await import("../useCookieConsent");
|
|
34
|
+
|
|
35
|
+
describe("useCookieConsent", () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
cookieRef.value = null;
|
|
38
|
+
consentedRef.value = false;
|
|
39
|
+
acceptSpy.mockClear();
|
|
40
|
+
revokeSpy.mockClear();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reports status='unset' when no cookie has been stored", () => {
|
|
44
|
+
const { status } = useCookieConsent();
|
|
45
|
+
expect(status.value).toBe("unset");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("reports status='granted' when the cookie already says granted", () => {
|
|
49
|
+
cookieRef.value = "granted";
|
|
50
|
+
const { status } = useCookieConsent();
|
|
51
|
+
expect(status.value).toBe("granted");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reports status='denied' when the cookie already says denied", () => {
|
|
55
|
+
cookieRef.value = "denied";
|
|
56
|
+
const { status } = useCookieConsent();
|
|
57
|
+
expect(status.value).toBe("denied");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("restores a prior granted decision into the trigger on init", () => {
|
|
61
|
+
cookieRef.value = "granted";
|
|
62
|
+
useCookieConsent();
|
|
63
|
+
expect(acceptSpy).toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("acceptAll() stores 'granted' and accepts the trigger", () => {
|
|
67
|
+
const { acceptAll, status } = useCookieConsent();
|
|
68
|
+
acceptAll();
|
|
69
|
+
expect(cookieRef.value).toBe("granted");
|
|
70
|
+
expect(status.value).toBe("granted");
|
|
71
|
+
expect(acceptSpy).toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejectAll() stores 'denied' and revokes the trigger", () => {
|
|
75
|
+
const { rejectAll, status } = useCookieConsent();
|
|
76
|
+
rejectAll();
|
|
77
|
+
expect(cookieRef.value).toBe("denied");
|
|
78
|
+
expect(status.value).toBe("denied");
|
|
79
|
+
expect(revokeSpy).toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("exposes the raw trigger for useGoogleAnalytics to consume", () => {
|
|
83
|
+
const { trigger } = useCookieConsent();
|
|
84
|
+
expect(trigger).toBeTruthy();
|
|
85
|
+
expect(typeof trigger.accept).toBe("function");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { CookieConsentStatus } from "~/types/components";
|
|
2
|
+
|
|
3
|
+
// Singleton, same pattern @nuxt/scripts itself documents for
|
|
4
|
+
// useScriptTriggerConsent (a single shared gate for the app's lifetime, not a
|
|
5
|
+
// fresh instance per call-site). This is what useGoogleAnalytics()'s
|
|
6
|
+
// `trigger` option is wired to.
|
|
7
|
+
//
|
|
8
|
+
// Built lazily on first call rather than at module scope — module-scope
|
|
9
|
+
// evaluation runs the instant this file is imported, which can happen before
|
|
10
|
+
// Nuxt's app/@nuxt/scripts context is ready in some environments (observed in
|
|
11
|
+
// Storybook), silently breaking the composable.
|
|
12
|
+
let consentTrigger: ReturnType<typeof useScriptTriggerConsent> | undefined;
|
|
13
|
+
|
|
14
|
+
export function useCookieConsent() {
|
|
15
|
+
consentTrigger ??= useScriptTriggerConsent();
|
|
16
|
+
const trigger = consentTrigger;
|
|
17
|
+
|
|
18
|
+
// Persists the decision across visits. Read fresh on every call so this
|
|
19
|
+
// stays request-safe during SSR (Nuxt dedupes useCookie() by key within a
|
|
20
|
+
// request, so this is cheap to call repeatedly).
|
|
21
|
+
const stored = useCookie<"granted" | "denied" | null>("privacy-notice-consent", {
|
|
22
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
23
|
+
sameSite: "lax",
|
|
24
|
+
default: () => null,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Restore a prior "granted" decision into the trigger (e.g. after a full
|
|
28
|
+
// page reload, where the in-memory trigger resets but the cookie doesn't).
|
|
29
|
+
if (stored.value === "granted" && !trigger.consented.value) {
|
|
30
|
+
trigger.accept();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const status = computed<CookieConsentStatus>(() => stored.value ?? "unset");
|
|
34
|
+
|
|
35
|
+
const acceptAll = () => {
|
|
36
|
+
stored.value = "granted";
|
|
37
|
+
trigger.accept();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const rejectAll = () => {
|
|
41
|
+
stored.value = "denied";
|
|
42
|
+
trigger.revoke();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
status,
|
|
47
|
+
acceptAll,
|
|
48
|
+
rejectAll,
|
|
49
|
+
// Internal API: pass straight into useScriptGoogleAnalytics's
|
|
50
|
+
// scriptOptions.trigger (see useGoogleAnalytics()). Not for consuming
|
|
51
|
+
// app code to read/mutate directly — use status/acceptAll/rejectAll.
|
|
52
|
+
trigger,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Call once from the consuming app's layout/app.vue. Loading is gated behind
|
|
2
|
+
// useCookieConsent() — the gtag.js script only actually fetches once the
|
|
3
|
+
// visitor accepts, and Google Consent Mode v2's default state starts denied
|
|
4
|
+
// so no analytics cookie is set before that.
|
|
5
|
+
export const useGoogleAnalytics = () => {
|
|
6
|
+
const config = useRuntimeConfig();
|
|
7
|
+
const id = config.public.googleAnalytics?.id;
|
|
8
|
+
|
|
9
|
+
if (!id) {
|
|
10
|
+
console.warn("[useGoogleAnalytics] public.googleAnalytics.id is not configured");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { trigger } = useCookieConsent();
|
|
15
|
+
|
|
16
|
+
useScriptGoogleAnalytics({
|
|
17
|
+
id,
|
|
18
|
+
scriptOptions: { trigger },
|
|
19
|
+
defaultConsent: {
|
|
20
|
+
ad_storage: "denied",
|
|
21
|
+
ad_user_data: "denied",
|
|
22
|
+
ad_personalization: "denied",
|
|
23
|
+
analytics_storage: "denied",
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
};
|
package/nuxt.config.ts
CHANGED
|
@@ -26,6 +26,11 @@ export default defineNuxtConfig({
|
|
|
26
26
|
colourScheme: {
|
|
27
27
|
enabled: true,
|
|
28
28
|
},
|
|
29
|
+
// Consumer apps set NUXT_PUBLIC_GOOGLE_ANALYTICS_ID to their GA4 measurement ID
|
|
30
|
+
// (G-XXXXXXXXXX) to enable useGoogleAnalytics(); left empty it no-ops.
|
|
31
|
+
googleAnalytics: {
|
|
32
|
+
id: "",
|
|
33
|
+
},
|
|
29
34
|
},
|
|
30
35
|
},
|
|
31
36
|
css: ["./app/assets/styles/main.css"],
|
|
@@ -36,6 +41,7 @@ export default defineNuxtConfig({
|
|
|
36
41
|
"@nuxt/icon",
|
|
37
42
|
...(process.env.STORYBOOK ? [] : ["@nuxt/fonts"]),
|
|
38
43
|
"@nuxt/image",
|
|
44
|
+
"@nuxt/scripts",
|
|
39
45
|
"@pinia/nuxt",
|
|
40
46
|
"@vueuse/motion/nuxt",
|
|
41
47
|
"pinia-plugin-persistedstate/nuxt",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "srcdev-nuxt-components",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.4.1",
|
|
5
5
|
"main": "nuxt.config.ts",
|
|
6
6
|
"types": "types.d.ts",
|
|
7
7
|
"license": "MIT",
|
|
@@ -137,6 +137,7 @@
|
|
|
137
137
|
"@nuxt/fonts": "0.14.0",
|
|
138
138
|
"@nuxt/icon": "2.5.0",
|
|
139
139
|
"@nuxt/image": "2.1.0",
|
|
140
|
+
"@nuxt/scripts": "1.3.0",
|
|
140
141
|
"@oddbird/css-anchor-positioning": "0.10.2",
|
|
141
142
|
"@pinia/nuxt": "1.0.2",
|
|
142
143
|
"@vueuse/motion": "3.0.3",
|