@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 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
- socials?: {
123
- name: string;
124
- url: string;
125
- icon: string;
126
- }[];
127
- columns?: {
128
- headerLabel: string;
129
- links: {
130
- label: string;
131
- to: string;
132
- target?: string;
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-gray-400 font-medium leading-relaxed max-w-lg mx-auto lg:mx-0"
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-gray-500 uppercase font-semibold leading-normal tracking-wide"
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-gray-300 font-medium leading-relaxed transition-colors"
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 class="group">
3
- <NuxtLink :to="`/blog/${post.slug}`" class="block">
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="post.img?.src"
7
- class="aspect-[16/9] rounded-xl overflow-hidden mb-4 xHover-zoom shadow-md transition-shadow duration-300 group-hover:shadow-xl"
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="post.img.src"
11
- :alt="post.img.alt || post.title"
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 :datetime="post.date" class="text-sm text-neutral-500">
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 class="text-sm text-neutral-500"
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="xText-title text-neutral-900 dark:text-white group-hover:text-primary-500 transition-colors"
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 && post.description"
42
- class="mt-2 text-neutral-600 dark:text-neutral-400 line-clamp-2"
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
- {{ post.description }}
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="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center"
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.charAt(0) }}
99
+ {{ post.author.name?.charAt(0) }}
63
100
  </span>
64
101
  </div>
65
- <span class="text-sm font-medium text-neutral-900 dark:text-white">
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="`/blog/${post.slug}`"
115
+ :to="postHref"
75
116
  :label="button.label"
76
- :color="button.color || 'neutral'"
77
- :variant="button.variant || 'outline'"
117
+ :color="button.color || 'primary'"
118
+ :variant="button.variant || 'solid'"
78
119
  :icon="button.icon"
79
120
  block
80
- class="mt-4 justify-center"
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
- /** Blog post object { title, slug, img?, excerpt?, date?, category?, author?, tags? } */
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
- /** Has category badge */
142
+ /** Show category badge */
100
143
  hasCategory: {
101
144
  type: Boolean,
102
145
  default: true,
103
146
  },
104
- /** Has author info */
147
+ /** Show author info */
105
148
  hasAuthor: {
106
149
  type: Boolean,
107
150
  default: true,
108
151
  },
109
- /** Has description */
152
+ /** Show excerpt / description */
110
153
  hasExcerpt: {
111
154
  type: Boolean,
112
155
  default: true,
113
156
  },
114
- /** Optional full-width button { label, color?, variant?, icon? } */
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
- if (!props.post.date) return "";
123
- const date = new Date(props.post.date);
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
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@xenterprises/nuxt-x-marketing",
3
3
  "license": "SEE LICENSE IN LICENSE",
4
4
  "type": "module",
5
- "version": "1.1.2",
5
+ "version": "1.2.2",
6
6
  "main": "./nuxt.config.ts",
7
7
  "scripts": {
8
8
  "dev": "nuxi dev .playground",