@xenterprises/nuxt-x-marketing 1.1.2 → 1.2.2
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/README.md +76 -0
- package/app/app.config.ts +81 -13
- package/app/app.vue +5 -0
- package/app/components/X/Footer/index.vue +10 -4
- package/app/components/X/Mark/Blog/Card.vue +142 -35
- package/app/components/X/Mark/Privacy/CookieConsent.vue +377 -0
- package/app/composables/useConsentTracking.ts +297 -0
- package/app/plugins/consent-tracking.client.ts +16 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ A comprehensive Nuxt layer for building marketing websites with 38+ pre-built co
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **38+ Marketing Components** - Hero, Features, Pricing, Testimonials, Blog, Affiliate/Review, and more
|
|
8
|
+
- **Consent-aware tracking** - Drop GTM/GA4/Clarity IDs in `app.config`, the cookie banner auto-fires them on accept
|
|
8
9
|
- **Zero Required Props** - Every component works out of the box with sensible defaults
|
|
9
10
|
- **Dark Mode Support** - All components support light and dark themes
|
|
10
11
|
- **Responsive Design** - Mobile-first approach with tablet and desktop breakpoints
|
|
@@ -714,6 +715,81 @@ Cookie toast notification.
|
|
|
714
715
|
/>
|
|
715
716
|
```
|
|
716
717
|
|
|
718
|
+
#### XMarkPrivacyCookieConsent
|
|
719
|
+
|
|
720
|
+
**Consent-aware cookie banner + preferences modal that auto-fires tracking scripts.** Drop your GTM/GA4/Clarity IDs into `app.config.ts`, drop `<XMarkPrivacyCookieConsent />` into your layout, and the component handles banner display, consent storage, and dynamic script injection.
|
|
721
|
+
|
|
722
|
+
```vue
|
|
723
|
+
<!-- app.vue -->
|
|
724
|
+
<template>
|
|
725
|
+
<div>
|
|
726
|
+
<NuxtPage />
|
|
727
|
+
<XMarkPrivacyCookieConsent
|
|
728
|
+
policy-url="/cookies"
|
|
729
|
+
privacy-url="/privacy"
|
|
730
|
+
/>
|
|
731
|
+
</div>
|
|
732
|
+
</template>
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
```ts
|
|
736
|
+
// app.config.ts
|
|
737
|
+
export default defineAppConfig({
|
|
738
|
+
xMarketing: {
|
|
739
|
+
tracking: {
|
|
740
|
+
gtmId: 'GTM-XXXXXXX',
|
|
741
|
+
ga4Id: 'G-XXXXXXXX',
|
|
742
|
+
clarityId: 'abc123def4',
|
|
743
|
+
// Optional escape hatch for anything else (Meta Pixel, Hotjar, etc.)
|
|
744
|
+
scripts: [
|
|
745
|
+
{
|
|
746
|
+
id: 'meta-pixel',
|
|
747
|
+
src: 'https://connect.facebook.net/en_US/fbevents.js',
|
|
748
|
+
category: 'marketing',
|
|
749
|
+
attrs: { async: '' },
|
|
750
|
+
},
|
|
751
|
+
],
|
|
752
|
+
autoInject: true, // default
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
})
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
Scripts only fire after the visitor grants consent for the matching category (analytics or marketing). On revisit, the client plugin auto-loads stored scripts immediately — no flash of banner.
|
|
759
|
+
|
|
760
|
+
| Prop | Type | Default | Description |
|
|
761
|
+
|------|------|---------|-------------|
|
|
762
|
+
| `title` | `String` | `'We use cookies'` | Banner heading |
|
|
763
|
+
| `message` | `String` | (see source) | Banner body text |
|
|
764
|
+
| `acceptLabel` | `String` | `'Accept all'` | Accept button text |
|
|
765
|
+
| `rejectLabel` | `String` | `'Reject all'` | Reject button text |
|
|
766
|
+
| `saveLabel` | `String` | `'Save preferences'` | Save button text (modal) |
|
|
767
|
+
| `policyLabel` | `String` | `'Read our policy'` | Policy link text |
|
|
768
|
+
| `policyUrl` | `String` | — | Cookie policy URL |
|
|
769
|
+
| `privacyUrl` | `String` | — | Privacy policy URL |
|
|
770
|
+
| `prefsTitle` | `String` | `'Cookie preferences'` | Modal title |
|
|
771
|
+
| `prefsDescription` | `String` | (see source) | Modal description |
|
|
772
|
+
| `showCustomize` | `Boolean` | `true` | Show the Customize button |
|
|
773
|
+
| `categories` | `Category[]` | (4 standard) | Categories shown in the modal |
|
|
774
|
+
| `storageKey` | `String` | `'xMarketing.consent'` | localStorage key |
|
|
775
|
+
| `forceShow` | `Boolean` | `false` | Force the banner open (e.g. from a "Manage cookies" link) |
|
|
776
|
+
|
|
777
|
+
| Emit | Payload | When |
|
|
778
|
+
|------|---------|------|
|
|
779
|
+
| `@accept` | `Record<CategoryId, boolean>` | Visitor accepted all |
|
|
780
|
+
| `@reject` | `Record<CategoryId, boolean>` | Visitor rejected all |
|
|
781
|
+
| `@save` | `Record<CategoryId, boolean>` | Visitor saved preferences |
|
|
782
|
+
|
|
783
|
+
**Underlying composable** — `useConsentTracking()` is auto-imported. Use it directly if you need to programmatically grant consent or read state:
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
const consent = useConsentTracking()
|
|
787
|
+
consent.acceptAll() // grant every category
|
|
788
|
+
consent.rejectAll() // grant only necessary
|
|
789
|
+
consent.hasConsent('analytics')
|
|
790
|
+
consent.state.value // reactive { necessary, analytics, marketing, preferences }
|
|
791
|
+
```
|
|
792
|
+
|
|
717
793
|
#### XMarkGDPR
|
|
718
794
|
|
|
719
795
|
GDPR cookie preference modal.
|
package/app/app.config.ts
CHANGED
|
@@ -71,6 +71,31 @@ export default defineAppConfig({
|
|
|
71
71
|
"Welcome to our blog. Here you can find the latest news, updates, and articles.",
|
|
72
72
|
},
|
|
73
73
|
},
|
|
74
|
+
/**
|
|
75
|
+
* Tracking + analytics configuration consumed by
|
|
76
|
+
* `<XMarkPrivacyCookieConsent>` and `useConsentTracking()`.
|
|
77
|
+
*
|
|
78
|
+
* Convenience IDs (gtmId / ga4Id / clarityId) auto-generate the
|
|
79
|
+
* standard snippet for that tool. Use `scripts[]` for everything
|
|
80
|
+
* else (Meta Pixel, Hotjar, Segment, LinkedIn Insight, etc.).
|
|
81
|
+
*
|
|
82
|
+
* Scripts only fire after the visitor grants consent for the
|
|
83
|
+
* matching category (analytics or marketing).
|
|
84
|
+
*/
|
|
85
|
+
tracking: {
|
|
86
|
+
// ---- Convenience IDs (string-only, zero-config) ----
|
|
87
|
+
/** Google Tag Manager container ID, e.g. "GTM-XXXXXXX". */
|
|
88
|
+
gtmId: undefined,
|
|
89
|
+
/** Google Analytics 4 measurement ID, e.g. "G-XXXXXXXX". */
|
|
90
|
+
ga4Id: undefined,
|
|
91
|
+
/** Microsoft Clarity project ID, e.g. "abc123def4". */
|
|
92
|
+
clarityId: undefined,
|
|
93
|
+
// ---- Escape hatch: arbitrary scripts ----
|
|
94
|
+
scripts: [],
|
|
95
|
+
// ---- Behavior ----
|
|
96
|
+
/** Auto-inject configured scripts on consent. Default: true. */
|
|
97
|
+
autoInject: true,
|
|
98
|
+
},
|
|
74
99
|
},
|
|
75
100
|
});
|
|
76
101
|
|
|
@@ -119,24 +144,67 @@ declare module "@nuxt/schema" {
|
|
|
119
144
|
src?: string;
|
|
120
145
|
alt?: string;
|
|
121
146
|
};
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}[];
|
|
147
|
+
socials?: {
|
|
148
|
+
name: string;
|
|
149
|
+
url: string;
|
|
150
|
+
icon: string;
|
|
151
|
+
}[];
|
|
152
|
+
columns?: {
|
|
153
|
+
headerLabel: string;
|
|
154
|
+
links: {
|
|
155
|
+
label: string;
|
|
156
|
+
to: string;
|
|
157
|
+
target?: string;
|
|
134
158
|
}[];
|
|
135
|
-
};
|
|
159
|
+
}[];
|
|
160
|
+
/**
|
|
161
|
+
* Tracking + analytics config. Read by
|
|
162
|
+
* `<XMarkPrivacyCookieConsent>` and `useConsentTracking()`.
|
|
163
|
+
* Only fires after the matching consent category is granted.
|
|
164
|
+
*/
|
|
165
|
+
tracking?: XMarketingTracking;
|
|
136
166
|
};
|
|
137
167
|
}
|
|
138
168
|
}
|
|
139
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Tracking entry — use the convenience IDs for the common tools
|
|
172
|
+
* (GTM, GA4, Clarity) or `scripts[]` for everything else. Scripts
|
|
173
|
+
* are injected only after the visitor grants consent for the
|
|
174
|
+
* matching `category` (default: "analytics").
|
|
175
|
+
*/
|
|
176
|
+
interface XMarketingTracking {
|
|
177
|
+
/** Google Tag Manager container ID, e.g. "GTM-XXXXXXX". */
|
|
178
|
+
gtmId?: string;
|
|
179
|
+
/** Google Analytics 4 measurement ID, e.g. "G-XXXXXXXX". */
|
|
180
|
+
ga4Id?: string;
|
|
181
|
+
/** Microsoft Clarity project ID, e.g. "abc123def4". */
|
|
182
|
+
clarityId?: string;
|
|
183
|
+
/**
|
|
184
|
+
* Arbitrary scripts (Meta Pixel, Hotjar, Segment, LinkedIn Insight,
|
|
185
|
+
* Pinterest, TikTok, etc.). Each fires once the matching consent
|
|
186
|
+
* category is granted. Use `inline` for hand-written snippets like
|
|
187
|
+
* `gtag('js', new Date())`.
|
|
188
|
+
*/
|
|
189
|
+
scripts?: XMarketingTrackingScript[];
|
|
190
|
+
/** Auto-inject configured scripts on consent. Default: true. */
|
|
191
|
+
autoInject?: boolean;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface XMarketingTrackingScript {
|
|
195
|
+
/** Stable ID — used to dedupe re-injection. */
|
|
196
|
+
id: string;
|
|
197
|
+
/** External script URL (e.g. "https://www.googletagmanager.com/gtag/js?id=G-XXX"). */
|
|
198
|
+
src?: string;
|
|
199
|
+
/** Inline script body (no `src` required). */
|
|
200
|
+
inline?: string;
|
|
201
|
+
/** Consent category gate. "necessary" fires immediately; "analytics" / "marketing" gated. */
|
|
202
|
+
category?: "necessary" | "analytics" | "marketing";
|
|
203
|
+
/** Extra attributes applied to the injected <script>. */
|
|
204
|
+
attrs?: Record<string, string>;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
140
208
|
interface NavLink {
|
|
141
209
|
label: string;
|
|
142
210
|
to?: string;
|
package/app/app.vue
CHANGED
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
:columns="footerColumns"
|
|
43
43
|
:legal-links="legalLinks"
|
|
44
44
|
/>
|
|
45
|
+
|
|
46
|
+
<!-- Cookie consent banner — auto-shows on first visit and fires
|
|
47
|
+
any tracking scripts configured in app.config.xMarketing.tracking
|
|
48
|
+
once the visitor accepts. -->
|
|
49
|
+
<XMarkPrivacyCookieConsent policy-url="/cookies" privacy-url="/privacy" />
|
|
45
50
|
</div>
|
|
46
51
|
</template>
|
|
47
52
|
<script setup>
|
|
@@ -12,8 +12,14 @@
|
|
|
12
12
|
class="relative pt-24 overflow-hidden"
|
|
13
13
|
:class="footer?.bg?.color ? footer?.bg?.color : 'bg-gray-900'"
|
|
14
14
|
>
|
|
15
|
+
<!-- Dark overlay to guarantee readable white text regardless of bg image -->
|
|
16
|
+
<div
|
|
17
|
+
class="absolute inset-0 bg-gray-900/80"
|
|
18
|
+
aria-hidden="true"
|
|
19
|
+
v-if="footer?.bg?.img?.src"
|
|
20
|
+
></div>
|
|
15
21
|
<img
|
|
16
|
-
class="absolute left-1/2 bottom-0 h-full transform -translate-x-1/2"
|
|
22
|
+
class="absolute left-1/2 bottom-0 h-full transform -translate-x-1/2 opacity-30"
|
|
17
23
|
:src="footer?.bg?.img?.src"
|
|
18
24
|
:alt="footer?.bg?.img?.alt"
|
|
19
25
|
v-if="footer?.bg?.img?.src"
|
|
@@ -34,7 +40,7 @@
|
|
|
34
40
|
/>
|
|
35
41
|
</a>
|
|
36
42
|
<p
|
|
37
|
-
class="mb-8 text-
|
|
43
|
+
class="mb-8 text-white font-medium leading-relaxed max-w-lg mx-auto lg:mx-0"
|
|
38
44
|
>
|
|
39
45
|
{{ footer?.body }}
|
|
40
46
|
</p>
|
|
@@ -66,7 +72,7 @@
|
|
|
66
72
|
:key="index"
|
|
67
73
|
>
|
|
68
74
|
<h3
|
|
69
|
-
class="mb-6 text-sm text-
|
|
75
|
+
class="mb-6 text-sm text-white uppercase font-semibold leading-normal tracking-wide"
|
|
70
76
|
>
|
|
71
77
|
{{ column?.headerLabel }}
|
|
72
78
|
</h3>
|
|
@@ -75,7 +81,7 @@
|
|
|
75
81
|
<nuxt-link
|
|
76
82
|
:to="link?.to"
|
|
77
83
|
:target="link?.target ? link?.target : '_self'"
|
|
78
|
-
class="text-white hover:text-
|
|
84
|
+
class="text-white hover:text-primary-300 font-medium leading-relaxed transition-colors"
|
|
79
85
|
>{{ link?.label }}</nuxt-link
|
|
80
86
|
>
|
|
81
87
|
</li>
|
|
@@ -1,47 +1,82 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<article
|
|
3
|
-
|
|
2
|
+
<article
|
|
3
|
+
class="group flex flex-col h-full"
|
|
4
|
+
:class="featured && 'p-0'"
|
|
5
|
+
>
|
|
6
|
+
<!-- Clickable card surface (image, meta, title, excerpt, author) -->
|
|
7
|
+
<NuxtLink :to="postHref" class="block flex-1">
|
|
8
|
+
<!-- Featured ribbon -->
|
|
9
|
+
<div v-if="featured" class="mb-3">
|
|
10
|
+
<UBadge
|
|
11
|
+
label="Featured"
|
|
12
|
+
color="primary"
|
|
13
|
+
variant="solid"
|
|
14
|
+
size="md"
|
|
15
|
+
/>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
4
18
|
<!-- Image -->
|
|
5
19
|
<figure
|
|
6
|
-
v-if="
|
|
7
|
-
class="
|
|
20
|
+
v-if="imageSrc"
|
|
21
|
+
:class="[
|
|
22
|
+
'rounded-xl overflow-hidden xHover-zoom shadow-md transition-shadow duration-300 group-hover:shadow-xl',
|
|
23
|
+
featured
|
|
24
|
+
? 'aspect-[21/9] mb-6'
|
|
25
|
+
: 'aspect-[16/9] mb-4',
|
|
26
|
+
]"
|
|
8
27
|
>
|
|
9
28
|
<img
|
|
10
|
-
:src="
|
|
11
|
-
:alt="
|
|
29
|
+
:src="imageSrc"
|
|
30
|
+
:alt="imageAlt"
|
|
12
31
|
class="w-full h-full object-cover"
|
|
13
32
|
/>
|
|
14
33
|
</figure>
|
|
15
34
|
|
|
16
35
|
<!-- Meta -->
|
|
17
|
-
<div class="flex items-center gap-3 mb-3">
|
|
36
|
+
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
|
18
37
|
<UBadge
|
|
19
38
|
v-if="hasCategory && post.category"
|
|
20
39
|
:label="post.category"
|
|
21
40
|
variant="subtle"
|
|
22
41
|
/>
|
|
23
|
-
<time
|
|
42
|
+
<time
|
|
43
|
+
v-if="post.date || post.publishedAt"
|
|
44
|
+
:datetime="post.date || post.publishedAt"
|
|
45
|
+
:class="featured ? 'text-base text-neutral-500' : 'text-sm text-neutral-500'"
|
|
46
|
+
>
|
|
24
47
|
{{ formattedDate }}
|
|
25
48
|
</time>
|
|
26
|
-
<span class="text-sm text-neutral-400">·</span>
|
|
27
|
-
<span
|
|
49
|
+
<span v-if="post.readingTime" class="text-sm text-neutral-400">·</span>
|
|
50
|
+
<span
|
|
51
|
+
v-if="post.readingTime"
|
|
52
|
+
:class="featured ? 'text-base text-neutral-500' : 'text-sm text-neutral-500'"
|
|
28
53
|
>{{ post.readingTime }} min read</span
|
|
29
54
|
>
|
|
30
55
|
</div>
|
|
31
56
|
|
|
32
57
|
<!-- Title -->
|
|
33
58
|
<h3
|
|
34
|
-
class="
|
|
59
|
+
:class="[
|
|
60
|
+
'text-neutral-900 dark:text-white group-hover:text-primary-500 transition-colors',
|
|
61
|
+
featured
|
|
62
|
+
? 'text-2xl sm:text-3xl lg:text-4xl font-bold leading-tight'
|
|
63
|
+
: 'xText-title',
|
|
64
|
+
]"
|
|
35
65
|
>
|
|
36
66
|
{{ post.title }}
|
|
37
67
|
</h3>
|
|
38
68
|
|
|
39
|
-
<!-- Excerpt -->
|
|
69
|
+
<!-- Excerpt / Description -->
|
|
40
70
|
<p
|
|
41
|
-
v-if="hasExcerpt &&
|
|
42
|
-
class="
|
|
71
|
+
v-if="hasExcerpt && excerpt"
|
|
72
|
+
:class="[
|
|
73
|
+
'text-neutral-600 dark:text-neutral-400',
|
|
74
|
+
featured
|
|
75
|
+
? 'mt-4 text-base sm:text-lg leading-relaxed'
|
|
76
|
+
: 'mt-2 text-sm line-clamp-2',
|
|
77
|
+
]"
|
|
43
78
|
>
|
|
44
|
-
{{
|
|
79
|
+
{{ excerpt }}
|
|
45
80
|
</p>
|
|
46
81
|
|
|
47
82
|
<!-- Author -->
|
|
@@ -50,77 +85,149 @@
|
|
|
50
85
|
v-if="post.author.avatar"
|
|
51
86
|
:src="post.author.avatar"
|
|
52
87
|
:alt="post.author.name"
|
|
53
|
-
class="w-8 h-8 rounded-full object-cover"
|
|
88
|
+
:class="featured ? 'w-10 h-10 rounded-full object-cover' : 'w-8 h-8 rounded-full object-cover'"
|
|
54
89
|
/>
|
|
55
90
|
<div
|
|
56
91
|
v-else
|
|
57
|
-
class="
|
|
92
|
+
:class="featured
|
|
93
|
+
? 'w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center'
|
|
94
|
+
: 'w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center'"
|
|
58
95
|
>
|
|
59
96
|
<span
|
|
60
97
|
class="text-primary-600 dark:text-primary-400 font-medium text-sm"
|
|
61
98
|
>
|
|
62
|
-
{{ post.author.name
|
|
99
|
+
{{ post.author.name?.charAt(0) }}
|
|
63
100
|
</span>
|
|
64
101
|
</div>
|
|
65
|
-
<span
|
|
102
|
+
<span
|
|
103
|
+
:class="featured
|
|
104
|
+
? 'text-base font-medium text-neutral-900 dark:text-white'
|
|
105
|
+
: 'text-sm font-medium text-neutral-900 dark:text-white'"
|
|
106
|
+
>
|
|
66
107
|
{{ post.author.name }}
|
|
67
108
|
</span>
|
|
68
109
|
</div>
|
|
69
110
|
</NuxtLink>
|
|
70
111
|
|
|
71
|
-
<!-- Full Width Button -->
|
|
112
|
+
<!-- Full Width Button (always visible when `button` prop is passed) -->
|
|
72
113
|
<UButton
|
|
73
114
|
v-if="button?.label"
|
|
74
|
-
:to="
|
|
115
|
+
:to="postHref"
|
|
75
116
|
:label="button.label"
|
|
76
|
-
:color="button.color || '
|
|
77
|
-
:variant="button.variant || '
|
|
117
|
+
:color="button.color || 'primary'"
|
|
118
|
+
:variant="button.variant || 'solid'"
|
|
78
119
|
:icon="button.icon"
|
|
79
120
|
block
|
|
80
|
-
|
|
121
|
+
:size="featured ? 'xl' : 'md'"
|
|
122
|
+
class="mt-4 w-full justify-center"
|
|
81
123
|
/>
|
|
82
124
|
</article>
|
|
83
125
|
</template>
|
|
84
126
|
|
|
85
127
|
<script setup>
|
|
86
128
|
const props = defineProps({
|
|
87
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Blog post object.
|
|
131
|
+
* Accepted shapes (auto-detected):
|
|
132
|
+
* - { title, slug, img: { src, alt }?, excerpt?, date?, category?, author?, tags? }
|
|
133
|
+
* - Nuxt Content: { title, _path, path, image?, description?, publishedAt?, category?, author? }
|
|
134
|
+
*/
|
|
88
135
|
post: {
|
|
89
136
|
type: Object,
|
|
90
137
|
default: () => ({
|
|
91
138
|
title: "Blog Post Title",
|
|
92
139
|
slug: "#",
|
|
93
|
-
excerpt:
|
|
94
|
-
"A short description of this blog post that gives readers a preview of the content.",
|
|
95
|
-
date: new Date().toISOString(),
|
|
96
|
-
category: "General",
|
|
97
140
|
}),
|
|
98
141
|
},
|
|
99
|
-
/**
|
|
142
|
+
/** Show category badge */
|
|
100
143
|
hasCategory: {
|
|
101
144
|
type: Boolean,
|
|
102
145
|
default: true,
|
|
103
146
|
},
|
|
104
|
-
/**
|
|
147
|
+
/** Show author info */
|
|
105
148
|
hasAuthor: {
|
|
106
149
|
type: Boolean,
|
|
107
150
|
default: true,
|
|
108
151
|
},
|
|
109
|
-
/**
|
|
152
|
+
/** Show excerpt / description */
|
|
110
153
|
hasExcerpt: {
|
|
111
154
|
type: Boolean,
|
|
112
155
|
default: true,
|
|
113
156
|
},
|
|
114
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Optional full-width read-more button.
|
|
159
|
+
* Shape: { label: string, color?: string, variant?: string, icon?: string }
|
|
160
|
+
* Default theme uses `primary`/`solid`.
|
|
161
|
+
*/
|
|
115
162
|
button: {
|
|
116
163
|
type: Object,
|
|
117
164
|
default: null,
|
|
118
165
|
},
|
|
166
|
+
/**
|
|
167
|
+
* Optional path prefix. Defaults to "/blog".
|
|
168
|
+
* Posts with a full `_path` / `path` (e.g. "/blog/my-post") are used as-is.
|
|
169
|
+
*/
|
|
170
|
+
pathPrefix: {
|
|
171
|
+
type: String,
|
|
172
|
+
default: "/blog",
|
|
173
|
+
},
|
|
174
|
+
/**
|
|
175
|
+
* Featured variant: wider image (21:9), larger title (up to 4xl),
|
|
176
|
+
* un-clamped excerpt, larger author, "Featured" ribbon, xl button.
|
|
177
|
+
*/
|
|
178
|
+
featured: {
|
|
179
|
+
type: Boolean,
|
|
180
|
+
default: false,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// --- Resolved post fields (handle Nuxt Content + custom shapes) ------------
|
|
185
|
+
|
|
186
|
+
const slug = computed(() => {
|
|
187
|
+
const p = props.post;
|
|
188
|
+
if (p.slug) return p.slug;
|
|
189
|
+
// Derive slug from Nuxt Content _path / path
|
|
190
|
+
const raw = p._path || p.path || "";
|
|
191
|
+
const stripped = raw.replace(/^\/+/, "");
|
|
192
|
+
if (!stripped) return "";
|
|
193
|
+
// _path might already be "/blog/foo" — return tail
|
|
194
|
+
return stripped.includes("/") ? stripped.split("/").pop() : stripped;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const postHref = computed(() => {
|
|
198
|
+
const p = props.post;
|
|
199
|
+
if (p._path) return p._path;
|
|
200
|
+
if (p.path) return p.path;
|
|
201
|
+
const s = slug.value;
|
|
202
|
+
if (!s || s === "#") return props.pathPrefix;
|
|
203
|
+
return `${props.pathPrefix}/${s}`;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const imageSrc = computed(() => {
|
|
207
|
+
const p = props.post;
|
|
208
|
+
if (p.img?.src) return p.img.src;
|
|
209
|
+
if (typeof p.img === "string" && p.img) return p.img;
|
|
210
|
+
if (typeof p.image === "string" && p.image) return p.image;
|
|
211
|
+
if (p.cover?.src) return p.cover.src;
|
|
212
|
+
return "";
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const imageAlt = computed(() => {
|
|
216
|
+
const p = props.post;
|
|
217
|
+
if (p.img?.alt) return p.img.alt;
|
|
218
|
+
return p.title || "";
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const excerpt = computed(() => {
|
|
222
|
+
const p = props.post;
|
|
223
|
+
return p.excerpt || p.description || "";
|
|
119
224
|
});
|
|
120
225
|
|
|
121
226
|
const formattedDate = computed(() => {
|
|
122
|
-
|
|
123
|
-
|
|
227
|
+
const raw = props.post.date || props.post.publishedAt || "";
|
|
228
|
+
if (!raw) return "";
|
|
229
|
+
const date = new Date(raw);
|
|
230
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
124
231
|
return date.toLocaleDateString("en-US", {
|
|
125
232
|
year: "numeric",
|
|
126
233
|
month: "short",
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<!-- Bottom banner — shown until visitor makes a decision. -->
|
|
4
|
+
<Transition
|
|
5
|
+
enter-active-class="transition duration-200 ease-out"
|
|
6
|
+
enter-from-class="translate-y-full opacity-0"
|
|
7
|
+
enter-to-class="translate-y-0 opacity-100"
|
|
8
|
+
leave-active-class="transition duration-150 ease-in"
|
|
9
|
+
leave-from-class="translate-y-0 opacity-100"
|
|
10
|
+
leave-to-class="translate-y-full opacity-0"
|
|
11
|
+
>
|
|
12
|
+
<div
|
|
13
|
+
v-if="bannerVisible"
|
|
14
|
+
class="x-cookie-consent fixed inset-x-0 bottom-0 z-50 border-t border-neutral-200 bg-white shadow-2xl dark:border-neutral-800 dark:bg-neutral-950"
|
|
15
|
+
role="dialog"
|
|
16
|
+
aria-label="Cookie consent"
|
|
17
|
+
>
|
|
18
|
+
<div class="mx-auto flex max-w-6xl flex-col gap-3 p-4 sm:flex-row sm:items-center sm:p-6">
|
|
19
|
+
<div class="flex-1 text-sm text-neutral-700 dark:text-neutral-300">
|
|
20
|
+
<p class="font-semibold text-neutral-900 dark:text-white">
|
|
21
|
+
{{ title }}
|
|
22
|
+
</p>
|
|
23
|
+
<p class="mt-1">
|
|
24
|
+
{{ message }}
|
|
25
|
+
<NuxtLink
|
|
26
|
+
v-if="policyUrl"
|
|
27
|
+
:to="policyUrl"
|
|
28
|
+
class="text-primary hover:underline"
|
|
29
|
+
>
|
|
30
|
+
{{ policyLabel }}
|
|
31
|
+
</NuxtLink>
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="flex flex-wrap gap-2">
|
|
35
|
+
<UButton
|
|
36
|
+
v-if="showCustomize"
|
|
37
|
+
color="neutral"
|
|
38
|
+
variant="ghost"
|
|
39
|
+
size="sm"
|
|
40
|
+
@click="prefsOpen = true"
|
|
41
|
+
>
|
|
42
|
+
Customize
|
|
43
|
+
</UButton>
|
|
44
|
+
<UButton
|
|
45
|
+
color="neutral"
|
|
46
|
+
variant="outline"
|
|
47
|
+
size="sm"
|
|
48
|
+
@click="onRejectAll"
|
|
49
|
+
>
|
|
50
|
+
{{ rejectLabel }}
|
|
51
|
+
</UButton>
|
|
52
|
+
<UButton color="primary" size="sm" @click="onAcceptAll">
|
|
53
|
+
{{ acceptLabel }}
|
|
54
|
+
</UButton>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</Transition>
|
|
59
|
+
|
|
60
|
+
<!-- Preferences modal — shown when visitor clicks "Customize". -->
|
|
61
|
+
<UModal v-model:open="prefsOpen">
|
|
62
|
+
<template #content>
|
|
63
|
+
<div class="p-6">
|
|
64
|
+
<header class="mb-6">
|
|
65
|
+
<h3 class="text-xl font-semibold text-neutral-900 dark:text-white">
|
|
66
|
+
{{ prefsTitle }}
|
|
67
|
+
</h3>
|
|
68
|
+
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
|
69
|
+
{{ prefsDescription }}
|
|
70
|
+
</p>
|
|
71
|
+
</header>
|
|
72
|
+
|
|
73
|
+
<div class="space-y-4">
|
|
74
|
+
<div
|
|
75
|
+
v-for="category in effectiveCategories"
|
|
76
|
+
:key="category.id"
|
|
77
|
+
class="rounded-xl border border-neutral-200 p-4 dark:border-neutral-700"
|
|
78
|
+
>
|
|
79
|
+
<div class="flex items-start justify-between gap-4">
|
|
80
|
+
<div class="flex-1">
|
|
81
|
+
<div class="flex items-center gap-2">
|
|
82
|
+
<h4 class="font-medium text-neutral-900 dark:text-white">
|
|
83
|
+
{{ category.label }}
|
|
84
|
+
</h4>
|
|
85
|
+
<UBadge
|
|
86
|
+
v-if="category.required"
|
|
87
|
+
label="Required"
|
|
88
|
+
size="xs"
|
|
89
|
+
variant="subtle"
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
|
93
|
+
{{ category.description }}
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
<UToggle
|
|
97
|
+
:model-value="selections[category.id]"
|
|
98
|
+
:disabled="category.required"
|
|
99
|
+
@update:model-value="(val) => (selections[category.id] = val)"
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<footer class="mt-6 flex flex-col gap-3 sm:flex-row">
|
|
106
|
+
<UButton
|
|
107
|
+
:label="rejectLabel"
|
|
108
|
+
variant="ghost"
|
|
109
|
+
color="neutral"
|
|
110
|
+
class="flex-1"
|
|
111
|
+
@click="onRejectAll"
|
|
112
|
+
/>
|
|
113
|
+
<UButton
|
|
114
|
+
:label="saveLabel"
|
|
115
|
+
variant="outline"
|
|
116
|
+
class="flex-1"
|
|
117
|
+
@click="onSaveSelected"
|
|
118
|
+
/>
|
|
119
|
+
<UButton :label="acceptLabel" class="flex-1" @click="onAcceptAll" />
|
|
120
|
+
</footer>
|
|
121
|
+
|
|
122
|
+
<div
|
|
123
|
+
v-if="policyUrl || privacyUrl"
|
|
124
|
+
class="mt-4 border-t border-neutral-200 pt-4 text-center dark:border-neutral-700"
|
|
125
|
+
>
|
|
126
|
+
<div class="flex justify-center gap-4 text-sm">
|
|
127
|
+
<NuxtLink
|
|
128
|
+
v-if="privacyUrl"
|
|
129
|
+
:to="privacyUrl"
|
|
130
|
+
class="text-primary hover:underline"
|
|
131
|
+
>
|
|
132
|
+
Privacy Policy
|
|
133
|
+
</NuxtLink>
|
|
134
|
+
<NuxtLink
|
|
135
|
+
v-if="policyUrl"
|
|
136
|
+
:to="policyUrl"
|
|
137
|
+
class="text-primary hover:underline"
|
|
138
|
+
>
|
|
139
|
+
Cookie Policy
|
|
140
|
+
</NuxtLink>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</template>
|
|
145
|
+
</UModal>
|
|
146
|
+
</Teleport>
|
|
147
|
+
</template>
|
|
148
|
+
|
|
149
|
+
<script setup lang="ts">
|
|
150
|
+
type CategoryId = "necessary" | "analytics" | "marketing" | "preferences";
|
|
151
|
+
|
|
152
|
+
interface Category {
|
|
153
|
+
id: CategoryId;
|
|
154
|
+
label: string;
|
|
155
|
+
description: string;
|
|
156
|
+
required?: boolean;
|
|
157
|
+
/** Default toggle state on first show. Ignored when `required: true`. */
|
|
158
|
+
enabledByDefault?: boolean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const props = withDefaults(
|
|
162
|
+
defineProps<{
|
|
163
|
+
/** Banner heading. */
|
|
164
|
+
title?: string;
|
|
165
|
+
/** Banner body text. */
|
|
166
|
+
message?: string;
|
|
167
|
+
/** Label on the Accept button. */
|
|
168
|
+
acceptLabel?: string;
|
|
169
|
+
/** Label on the Reject button. */
|
|
170
|
+
rejectLabel?: string;
|
|
171
|
+
/** Label on the Save Preferences button (in modal). */
|
|
172
|
+
saveLabel?: string;
|
|
173
|
+
/** Label of the policy link. */
|
|
174
|
+
policyLabel?: string;
|
|
175
|
+
/** URL of the cookie policy page. */
|
|
176
|
+
policyUrl?: string;
|
|
177
|
+
/** URL of the privacy policy page. */
|
|
178
|
+
privacyUrl?: string;
|
|
179
|
+
/** Modal title. */
|
|
180
|
+
prefsTitle?: string;
|
|
181
|
+
/** Modal description. */
|
|
182
|
+
prefsDescription?: string;
|
|
183
|
+
/** Show the Customize button in the banner. */
|
|
184
|
+
showCustomize?: boolean;
|
|
185
|
+
/** Categories shown in the preferences modal. */
|
|
186
|
+
categories?: Category[];
|
|
187
|
+
/** Cookie storage key. Override if you ship multiple consent UIs. */
|
|
188
|
+
storageKey?: string;
|
|
189
|
+
/** Force show the banner (e.g. via a "Manage cookies" link). */
|
|
190
|
+
forceShow?: boolean;
|
|
191
|
+
}>(),
|
|
192
|
+
{
|
|
193
|
+
title: "We use cookies",
|
|
194
|
+
message:
|
|
195
|
+
"We use cookies to enhance your browsing experience, analyze traffic, and improve our service. You can choose which categories to allow.",
|
|
196
|
+
acceptLabel: "Accept all",
|
|
197
|
+
rejectLabel: "Reject all",
|
|
198
|
+
saveLabel: "Save preferences",
|
|
199
|
+
policyLabel: "Read our policy",
|
|
200
|
+
prefsTitle: "Cookie preferences",
|
|
201
|
+
prefsDescription:
|
|
202
|
+
"Choose which categories of cookies you allow. You can change this anytime.",
|
|
203
|
+
showCustomize: true,
|
|
204
|
+
storageKey: "xMarketing.consent",
|
|
205
|
+
categories: () => [
|
|
206
|
+
{
|
|
207
|
+
id: "necessary",
|
|
208
|
+
label: "Strictly necessary",
|
|
209
|
+
description:
|
|
210
|
+
"Required for the site to function (auth, security, basic page state). Cannot be disabled.",
|
|
211
|
+
required: true,
|
|
212
|
+
enabledByDefault: true,
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: "analytics",
|
|
216
|
+
label: "Analytics",
|
|
217
|
+
description:
|
|
218
|
+
"Helps us understand how visitors interact with the site so we can improve it.",
|
|
219
|
+
enabledByDefault: false,
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: "marketing",
|
|
223
|
+
label: "Marketing",
|
|
224
|
+
description:
|
|
225
|
+
"Used to deliver personalized ads and measure campaign effectiveness.",
|
|
226
|
+
enabledByDefault: false,
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "preferences",
|
|
230
|
+
label: "Preferences",
|
|
231
|
+
description: "Remembers your settings such as dark mode and locale.",
|
|
232
|
+
enabledByDefault: false,
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const emit = defineEmits<{
|
|
239
|
+
/** Emitted with the granted categories when the visitor accepts all. */
|
|
240
|
+
(e: "accept", state: Record<CategoryId, boolean>): void;
|
|
241
|
+
/** Emitted with the granted categories when the visitor rejects all. */
|
|
242
|
+
(e: "reject", state: Record<CategoryId, boolean>): void;
|
|
243
|
+
/** Emitted with the granted categories on Save Preferences. */
|
|
244
|
+
(e: "save", state: Record<CategoryId, boolean>): void;
|
|
245
|
+
}>();
|
|
246
|
+
|
|
247
|
+
const tracking = useConsentTracking();
|
|
248
|
+
|
|
249
|
+
// The banner is visible if:
|
|
250
|
+
// - The parent forces it (forceShow), OR
|
|
251
|
+
// - The visitor hasn't made a decision yet (no localStorage key).
|
|
252
|
+
const bannerVisible = ref(false);
|
|
253
|
+
const prefsOpen = ref(false);
|
|
254
|
+
const selections = ref<Record<CategoryId, boolean>>({});
|
|
255
|
+
|
|
256
|
+
const effectiveCategories = computed(() => props.categories);
|
|
257
|
+
|
|
258
|
+
function buildStateFromSelections(): Record<CategoryId, boolean> {
|
|
259
|
+
const out: Record<CategoryId, boolean> = {
|
|
260
|
+
necessary: true,
|
|
261
|
+
analytics: false,
|
|
262
|
+
marketing: false,
|
|
263
|
+
preferences: false,
|
|
264
|
+
};
|
|
265
|
+
for (const c of props.categories) {
|
|
266
|
+
out[c.id] = c.required ? true : Boolean(selections.value[c.id]);
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildStateAll(value: boolean): Record<CategoryId, boolean> {
|
|
272
|
+
const out: Record<CategoryId, boolean> = {
|
|
273
|
+
necessary: true,
|
|
274
|
+
analytics: false,
|
|
275
|
+
marketing: false,
|
|
276
|
+
preferences: false,
|
|
277
|
+
};
|
|
278
|
+
for (const c of props.categories) {
|
|
279
|
+
out[c.id] = c.required ? true : value;
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function initSelections() {
|
|
285
|
+
for (const c of props.categories) {
|
|
286
|
+
selections.value[c.id] = c.required
|
|
287
|
+
? true
|
|
288
|
+
: Boolean(c.enabledByDefault ?? false);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function syncFromStored() {
|
|
293
|
+
if (typeof window === "undefined") return false;
|
|
294
|
+
const raw = window.localStorage.getItem(props.storageKey);
|
|
295
|
+
if (!raw) return false;
|
|
296
|
+
try {
|
|
297
|
+
const parsed = JSON.parse(raw);
|
|
298
|
+
for (const c of props.categories) {
|
|
299
|
+
selections.value[c.id] = c.required
|
|
300
|
+
? true
|
|
301
|
+
: Boolean(parsed[c.id] ?? c.enabledByDefault ?? false);
|
|
302
|
+
}
|
|
303
|
+
return true;
|
|
304
|
+
} catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
onMounted(() => {
|
|
310
|
+
if (typeof window === "undefined") return;
|
|
311
|
+
// Defer so we don't fight hydration.
|
|
312
|
+
queueMicrotask(() => {
|
|
313
|
+
const hadDecision = syncFromStored();
|
|
314
|
+
if (!hadDecision || props.forceShow) {
|
|
315
|
+
initSelections();
|
|
316
|
+
bannerVisible.value = true;
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// If `forceShow` flips to true at runtime, show the banner.
|
|
322
|
+
watch(
|
|
323
|
+
() => props.forceShow,
|
|
324
|
+
(v) => {
|
|
325
|
+
if (v) bannerVisible.value = true;
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
function persistAndBroadcast(state: Record<CategoryId, boolean>) {
|
|
330
|
+
if (typeof window === "undefined") return;
|
|
331
|
+
// Use the composable to handle storage + event dispatch + injection.
|
|
332
|
+
// We pass a shallow copy that matches ConsentState shape.
|
|
333
|
+
tracking.setConsent({
|
|
334
|
+
necessary: true,
|
|
335
|
+
analytics: Boolean(state.analytics),
|
|
336
|
+
marketing: Boolean(state.marketing),
|
|
337
|
+
preferences: Boolean(state.preferences),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function onAcceptAll() {
|
|
342
|
+
const state = buildStateAll(true);
|
|
343
|
+
for (const k of Object.keys(state)) {
|
|
344
|
+
selections.value[k as CategoryId] = state[k as CategoryId];
|
|
345
|
+
}
|
|
346
|
+
persistAndBroadcast(state);
|
|
347
|
+
bannerVisible.value = false;
|
|
348
|
+
prefsOpen.value = false;
|
|
349
|
+
emit("accept", state);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function onRejectAll() {
|
|
353
|
+
const state = buildStateAll(false);
|
|
354
|
+
for (const k of Object.keys(state)) {
|
|
355
|
+
selections.value[k as CategoryId] = state[k as CategoryId];
|
|
356
|
+
}
|
|
357
|
+
persistAndBroadcast(state);
|
|
358
|
+
bannerVisible.value = false;
|
|
359
|
+
prefsOpen.value = false;
|
|
360
|
+
emit("reject", state);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function onSaveSelected() {
|
|
364
|
+
const state = buildStateFromSelections();
|
|
365
|
+
persistAndBroadcast(state);
|
|
366
|
+
bannerVisible.value = false;
|
|
367
|
+
prefsOpen.value = false;
|
|
368
|
+
emit("save", state);
|
|
369
|
+
}
|
|
370
|
+
</script>
|
|
371
|
+
|
|
372
|
+
<style scoped>
|
|
373
|
+
.x-cookie-consent {
|
|
374
|
+
/* Sit above any sticky footer/CTA; matches the affiliate layer's banner. */
|
|
375
|
+
z-index: 50;
|
|
376
|
+
}
|
|
377
|
+
</style>
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useConsentTracking
|
|
3
|
+
*
|
|
4
|
+
* Reads `app.config.xMarketing.tracking` and dynamically injects
|
|
5
|
+
* scripts (GTM, GA4, Clarity, Meta Pixel, etc.) only after the
|
|
6
|
+
* matching consent category is granted.
|
|
7
|
+
*
|
|
8
|
+
* Decoupled from any specific UI — the cookie consent component
|
|
9
|
+
* fires a `consent:updated` window event with the granted category
|
|
10
|
+
* map, and this composable listens + reacts. Auto-runs on plugin
|
|
11
|
+
* load so re-visitors get scripts immediately (no flash of banner).
|
|
12
|
+
*
|
|
13
|
+
* Consent storage is shared with `<XMarkPrivacyCookieConsent>` via
|
|
14
|
+
* the `xMarketing.consent` localStorage key (JSON shape:
|
|
15
|
+
* `{ necessary: true, analytics: bool, marketing: bool, preferences: bool }`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { computed, ref, watch } from "vue";
|
|
19
|
+
|
|
20
|
+
export type ConsentCategory = "necessary" | "analytics" | "marketing" | "preferences";
|
|
21
|
+
export type ConsentState = Record<ConsentCategory, boolean>;
|
|
22
|
+
|
|
23
|
+
const STORAGE_KEY = "xMarketing.consent";
|
|
24
|
+
const EVENT_NAME = "consent:updated";
|
|
25
|
+
|
|
26
|
+
/** Default state — visitor hasn't decided yet. */
|
|
27
|
+
const DEFAULT_STATE: ConsentState = {
|
|
28
|
+
necessary: true, // always true; required for the site to function
|
|
29
|
+
analytics: false,
|
|
30
|
+
marketing: false,
|
|
31
|
+
preferences: false,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** SSR-safe no-op shared ref so server + client first-render agree. */
|
|
35
|
+
const consentState = ref<ConsentState>(DEFAULT_STATE);
|
|
36
|
+
let initialized = false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read consent from localStorage and re-fire any tracking scripts the
|
|
40
|
+
* granted categories unlock. Called by the plugin on client mount.
|
|
41
|
+
*/
|
|
42
|
+
export function useConsentTracking() {
|
|
43
|
+
const appConfig = useAppConfig();
|
|
44
|
+
const tracking = computed(() => appConfig.xMarketing?.tracking ?? null);
|
|
45
|
+
|
|
46
|
+
const hasDecision = computed(() =>
|
|
47
|
+
typeof window !== "undefined" && window.localStorage.getItem(STORAGE_KEY) !== null,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
function readStored(): ConsentState | null {
|
|
51
|
+
if (typeof window === "undefined") return null;
|
|
52
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
53
|
+
if (!raw) return null;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
return { ...DEFAULT_STATE, ...parsed };
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeStored(state: ConsentState) {
|
|
63
|
+
if (typeof window === "undefined") return;
|
|
64
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Apply a new consent decision — writes to storage, updates the
|
|
69
|
+
* shared ref, fires scripts, and notifies listeners via window event.
|
|
70
|
+
*/
|
|
71
|
+
function setConsent(next: ConsentState) {
|
|
72
|
+
const merged = { ...DEFAULT_STATE, ...next, necessary: true };
|
|
73
|
+
consentState.value = merged;
|
|
74
|
+
writeStored(merged);
|
|
75
|
+
injectForState(merged);
|
|
76
|
+
if (typeof window !== "undefined") {
|
|
77
|
+
window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: merged }));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function acceptAll(): ConsentState {
|
|
82
|
+
const next: ConsentState = {
|
|
83
|
+
necessary: true,
|
|
84
|
+
analytics: true,
|
|
85
|
+
marketing: true,
|
|
86
|
+
preferences: true,
|
|
87
|
+
};
|
|
88
|
+
setConsent(next);
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function rejectAll(): ConsentState {
|
|
93
|
+
const next: ConsentState = {
|
|
94
|
+
necessary: true,
|
|
95
|
+
analytics: false,
|
|
96
|
+
marketing: false,
|
|
97
|
+
preferences: false,
|
|
98
|
+
};
|
|
99
|
+
setConsent(next);
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Re-apply stored consent — used on revisit to fire scripts immediately. */
|
|
104
|
+
function rehydrate() {
|
|
105
|
+
if (typeof window === "undefined") return;
|
|
106
|
+
const stored = readStored();
|
|
107
|
+
if (!stored) return;
|
|
108
|
+
consentState.value = stored;
|
|
109
|
+
injectForState(stored);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** True when the visitor has granted consent for the given category. */
|
|
113
|
+
function hasConsent(category: ConsentCategory): boolean {
|
|
114
|
+
return Boolean(consentState.value[category]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ----------------------------------------------------------------
|
|
118
|
+
// Script injection
|
|
119
|
+
// ----------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
const injectedIds = new Set<string>();
|
|
122
|
+
|
|
123
|
+
function isInjected(id: string): boolean {
|
|
124
|
+
return injectedIds.has(id);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function markInjected(id: string) {
|
|
128
|
+
injectedIds.add(id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildScriptList(): Array<{
|
|
132
|
+
id: string;
|
|
133
|
+
src?: string;
|
|
134
|
+
inline?: string;
|
|
135
|
+
category: ConsentCategory;
|
|
136
|
+
attrs?: Record<string, string>;
|
|
137
|
+
}> {
|
|
138
|
+
const cfg = tracking.value;
|
|
139
|
+
if (!cfg) return [];
|
|
140
|
+
const list: Array<{
|
|
141
|
+
id: string;
|
|
142
|
+
src?: string;
|
|
143
|
+
inline?: string;
|
|
144
|
+
category: ConsentCategory;
|
|
145
|
+
attrs?: Record<string, string>;
|
|
146
|
+
}> = [];
|
|
147
|
+
|
|
148
|
+
// GTM (loaded head + body noscript; GA4 / Clarity ride inside GTM
|
|
149
|
+
// when both are set, so we skip the standalone snippet to avoid
|
|
150
|
+
// double counting).
|
|
151
|
+
if (cfg.gtmId) {
|
|
152
|
+
const hasGtmHostsOthers = Boolean(cfg.ga4Id || cfg.clarityId);
|
|
153
|
+
if (!hasGtmHostsOthers) {
|
|
154
|
+
// No other IDs — still load GTM itself, but skip standalone GA4/Clarity below.
|
|
155
|
+
}
|
|
156
|
+
list.push({
|
|
157
|
+
id: `gtm-${cfg.gtmId}`,
|
|
158
|
+
src: `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(cfg.gtmId)}`,
|
|
159
|
+
category: "analytics",
|
|
160
|
+
attrs: { async: "", id: "gtm-script" },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// GA4 — only emit the standalone snippet if GTM isn't already
|
|
165
|
+
// managing it (avoids double page-view).
|
|
166
|
+
if (cfg.ga4Id && !cfg.gtmId) {
|
|
167
|
+
list.push({
|
|
168
|
+
id: `ga4-${cfg.ga4Id}`,
|
|
169
|
+
src: `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(cfg.ga4Id)}`,
|
|
170
|
+
category: "analytics",
|
|
171
|
+
attrs: { async: "" },
|
|
172
|
+
});
|
|
173
|
+
list.push({
|
|
174
|
+
id: `ga4-init-${cfg.ga4Id}`,
|
|
175
|
+
inline: `window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', '${cfg.ga4Id}', { anonymize_ip: true });`,
|
|
176
|
+
category: "analytics",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Microsoft Clarity — only if GTM isn't managing it.
|
|
181
|
+
if (cfg.clarityId && !cfg.gtmId) {
|
|
182
|
+
list.push({
|
|
183
|
+
id: `clarity-${cfg.clarityId}`,
|
|
184
|
+
inline: `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);})(window, document, "clarity", "script", "${cfg.clarityId}");`,
|
|
185
|
+
category: "analytics",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// User-defined scripts. `Array.isArray` + iteration narrows the
|
|
190
|
+
// element type to `never` when the layer default is `[]`, so cast
|
|
191
|
+
// to the augmented type explicitly.
|
|
192
|
+
const customScripts = (cfg.scripts ?? []) as Array<{
|
|
193
|
+
id: string;
|
|
194
|
+
src?: string;
|
|
195
|
+
inline?: string;
|
|
196
|
+
category?: "necessary" | "analytics" | "marketing";
|
|
197
|
+
attrs?: Record<string, string>;
|
|
198
|
+
}>;
|
|
199
|
+
for (const s of customScripts) {
|
|
200
|
+
if (!s || !s.id) continue;
|
|
201
|
+
list.push({
|
|
202
|
+
id: s.id,
|
|
203
|
+
src: s.src,
|
|
204
|
+
inline: s.inline,
|
|
205
|
+
category: s.category ?? "analytics",
|
|
206
|
+
attrs: s.attrs,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return list;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function injectScript(entry: ReturnType<typeof buildScriptList>[number]) {
|
|
214
|
+
if (typeof document === "undefined") return;
|
|
215
|
+
if (isInjected(entry.id)) return;
|
|
216
|
+
|
|
217
|
+
const el = document.createElement("script");
|
|
218
|
+
if (entry.attrs) {
|
|
219
|
+
for (const [k, v] of Object.entries(entry.attrs)) {
|
|
220
|
+
if (v === "") {
|
|
221
|
+
// boolean attribute (e.g. `async`)
|
|
222
|
+
(el as unknown as Record<string, unknown>)[k] = true;
|
|
223
|
+
el.setAttribute(k, "");
|
|
224
|
+
} else {
|
|
225
|
+
el.setAttribute(k, v);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (entry.src) {
|
|
230
|
+
el.src = entry.src;
|
|
231
|
+
} else if (entry.inline) {
|
|
232
|
+
el.textContent = entry.inline;
|
|
233
|
+
}
|
|
234
|
+
document.head.appendChild(el);
|
|
235
|
+
markInjected(entry.id);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function injectForState(state: ConsentState) {
|
|
239
|
+
if (typeof window === "undefined") return;
|
|
240
|
+
if (tracking.value?.autoInject === false) return;
|
|
241
|
+
for (const entry of buildScriptList()) {
|
|
242
|
+
if (state[entry.category]) injectScript(entry);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ----------------------------------------------------------------
|
|
247
|
+
// Plugin init — listen for events from the banner + auto-load on revisit
|
|
248
|
+
// ----------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
function init() {
|
|
251
|
+
if (typeof window === "undefined" || initialized) return;
|
|
252
|
+
initialized = true;
|
|
253
|
+
rehydrate();
|
|
254
|
+
window.addEventListener(EVENT_NAME, ((ev: CustomEvent<ConsentState>) => {
|
|
255
|
+
consentState.value = ev.detail;
|
|
256
|
+
injectForState(ev.detail);
|
|
257
|
+
}) as EventListener);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// React to app.config changes — if the site adds a tracking ID at
|
|
261
|
+
// runtime (e.g. via HMR), honor it without requiring a full reload.
|
|
262
|
+
if (typeof window !== "undefined") {
|
|
263
|
+
watch(
|
|
264
|
+
() => [
|
|
265
|
+
tracking.value?.gtmId,
|
|
266
|
+
tracking.value?.ga4Id,
|
|
267
|
+
tracking.value?.clarityId,
|
|
268
|
+
tracking.value?.scripts,
|
|
269
|
+
],
|
|
270
|
+
() => {
|
|
271
|
+
if (hasDecision.value) {
|
|
272
|
+
injectForState(consentState.value);
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
{ deep: true },
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
/** Reactive consent state — necessary/analytics/marketing/preferences. */
|
|
281
|
+
state: consentState,
|
|
282
|
+
/** Has the visitor made any decision yet? */
|
|
283
|
+
hasDecision,
|
|
284
|
+
/** Apply a full consent map. */
|
|
285
|
+
setConsent,
|
|
286
|
+
/** Accept all categories. */
|
|
287
|
+
acceptAll,
|
|
288
|
+
/** Reject non-essential categories. */
|
|
289
|
+
rejectAll,
|
|
290
|
+
/** Re-read storage and fire scripts (no event broadcast). */
|
|
291
|
+
rehydrate,
|
|
292
|
+
/** Has consent been granted for a single category? */
|
|
293
|
+
hasConsent,
|
|
294
|
+
/** Initialize listeners + rehydrate. Called by the client plugin. */
|
|
295
|
+
init,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent Tracking — Client Plugin
|
|
3
|
+
*
|
|
4
|
+
* Bootstraps the `useConsentTracking` composable on client mount so:
|
|
5
|
+
* 1. Returning visitors (already consented) get tracking scripts
|
|
6
|
+
* loaded immediately, without waiting for any banner interaction.
|
|
7
|
+
* 2. First-time visitors see the banner; their accept/reject fires
|
|
8
|
+
* the scripts via the `consent:updated` window event.
|
|
9
|
+
*
|
|
10
|
+
* Pair with `<XMarkPrivacyCookieConsent>` in your layout — the banner
|
|
11
|
+
* component itself uses the same composable and storage key.
|
|
12
|
+
*/
|
|
13
|
+
export default defineNuxtPlugin(() => {
|
|
14
|
+
const consent = useConsentTracking();
|
|
15
|
+
consent.init();
|
|
16
|
+
});
|