@xenterprises/nuxt-x-marketing 1.2.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,382 +0,0 @@
1
- <template>
2
- <section>
3
- <!-- Disclosure -->
4
- <XMarkAffiliateDisclosure
5
- v-if="disclosure"
6
- :text="disclosure"
7
- variant="subtle"
8
- class="mb-6"
9
- />
10
-
11
- <!-- Desktop Table -->
12
- <div
13
- class="hidden md:block overflow-x-auto rounded-2xl border border-neutral-200 dark:border-neutral-700"
14
- >
15
- <table class="w-full text-sm">
16
- <thead>
17
- <tr class="border-b border-neutral-200 dark:border-neutral-700">
18
- <!-- Spec label column header -->
19
- <th
20
- class="px-5 py-4 text-left font-semibold text-neutral-500 dark:text-neutral-400 bg-neutral-50 dark:bg-neutral-800/50 w-40"
21
- >
22
- Feature
23
- </th>
24
- <!-- Product column headers -->
25
- <th
26
- v-for="product in products"
27
- :key="product.name"
28
- class="px-5 py-4 text-center bg-neutral-50 dark:bg-neutral-800/50"
29
- >
30
- <div class="flex flex-col items-center gap-2">
31
- <figure
32
- v-if="product.image"
33
- class="w-14 h-14 rounded-xl overflow-hidden bg-white dark:bg-neutral-700 border border-neutral-200 dark:border-neutral-600"
34
- >
35
- <img
36
- :src="product.image"
37
- :alt="product.name"
38
- class="w-full h-full object-contain p-1"
39
- />
40
- </figure>
41
- <div
42
- v-else
43
- class="w-14 h-14 rounded-xl bg-primary-100 dark:bg-primary-900/40 flex items-center justify-center"
44
- >
45
- <span
46
- class="text-primary-600 dark:text-primary-400 font-bold text-xl"
47
- >
48
- {{ product.name.charAt(0) }}
49
- </span>
50
- </div>
51
- <span
52
- class="font-semibold text-neutral-900 dark:text-white text-sm leading-snug text-center"
53
- >
54
- {{ product.name }}
55
- </span>
56
- <span
57
- v-if="product.price"
58
- class="font-bold text-primary-600 dark:text-primary-400 text-base"
59
- >
60
- {{ product.price }}
61
- </span>
62
- </div>
63
- </th>
64
- </tr>
65
- </thead>
66
- <tbody>
67
- <!-- Spec rows -->
68
- <tr
69
- v-for="(spec, specIdx) in specs"
70
- :key="spec.key"
71
- :class="
72
- specIdx % 2 === 0
73
- ? 'bg-white dark:bg-neutral-900'
74
- : 'bg-neutral-50/50 dark:bg-neutral-800/30'
75
- "
76
- class="border-b border-neutral-100 dark:border-neutral-800 last:border-0"
77
- >
78
- <!-- Spec label -->
79
- <td
80
- class="px-5 py-3.5 font-medium text-neutral-600 dark:text-neutral-400 border-r border-neutral-100 dark:border-neutral-800"
81
- >
82
- {{ spec.label }}
83
- </td>
84
- <!-- Product values -->
85
- <td
86
- v-for="product in products"
87
- :key="product.name"
88
- class="px-5 py-3.5 text-center transition-colors"
89
- :class="
90
- highlightBest && isBestValue(spec, product)
91
- ? 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400 font-semibold'
92
- : 'text-neutral-800 dark:text-neutral-200'
93
- "
94
- >
95
- <span v-if="product.specs?.[spec.key] !== undefined">
96
- {{ product.specs[spec.key] }}
97
- </span>
98
- <span v-else class="text-neutral-300 dark:text-neutral-600"
99
- >—</span
100
- >
101
- </td>
102
- </tr>
103
-
104
- <!-- Buy buttons row -->
105
- <tr
106
- v-if="showBuyButtons"
107
- class="bg-neutral-50 dark:bg-neutral-800/50"
108
- >
109
- <td
110
- class="px-5 py-4 font-medium text-neutral-600 dark:text-neutral-400 border-r border-neutral-100 dark:border-neutral-800"
111
- >
112
- Buy
113
- </td>
114
- <td
115
- v-for="product in products"
116
- :key="product.name"
117
- class="px-5 py-4 text-center"
118
- >
119
- <a
120
- :href="getAffiliateHref(product)"
121
- target="_blank"
122
- rel="noopener noreferrer"
123
- >
124
- <UButton
125
- :label="buttonLabel"
126
- trailing-icon="i-lucide-external-link"
127
- color="primary"
128
- variant="solid"
129
- size="sm"
130
- />
131
- </a>
132
- </td>
133
- </tr>
134
- </tbody>
135
- </table>
136
- </div>
137
-
138
- <!-- Mobile: one product at a time with prev/next -->
139
- <div class="md:hidden">
140
- <!-- Navigation -->
141
- <div class="flex items-center justify-between mb-4">
142
- <button
143
- class="p-2 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
144
- :disabled="mobileIndex === 0"
145
- aria-label="Previous product"
146
- @click="mobileIndex--"
147
- >
148
- <UIcon
149
- name="i-lucide-chevron-left"
150
- class="w-5 h-5 text-neutral-600 dark:text-neutral-400"
151
- />
152
- </button>
153
-
154
- <div class="text-center">
155
- <div class="font-semibold text-neutral-900 dark:text-white">
156
- {{ products[mobileIndex]?.name }}
157
- </div>
158
- <div class="text-xs text-neutral-500 dark:text-neutral-400">
159
- {{ mobileIndex + 1 }} of {{ products.length }}
160
- </div>
161
- </div>
162
-
163
- <button
164
- class="p-2 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
165
- :disabled="mobileIndex === products.length - 1"
166
- aria-label="Next product"
167
- @click="mobileIndex++"
168
- >
169
- <UIcon
170
- name="i-lucide-chevron-right"
171
- class="w-5 h-5 text-neutral-600 dark:text-neutral-400"
172
- />
173
- </button>
174
- </div>
175
-
176
- <!-- Mobile product card -->
177
- <div
178
- class="rounded-2xl border border-neutral-200 dark:border-neutral-700 overflow-hidden"
179
- >
180
- <!-- Product header -->
181
- <div
182
- class="p-5 bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-700 flex items-center gap-4"
183
- >
184
- <figure
185
- v-if="products[mobileIndex]?.image"
186
- class="w-16 h-16 rounded-xl overflow-hidden bg-white dark:bg-neutral-700 border border-neutral-200 dark:border-neutral-600 flex-shrink-0"
187
- >
188
- <img
189
- :src="products[mobileIndex].image"
190
- :alt="products[mobileIndex].name"
191
- class="w-full h-full object-contain p-1"
192
- />
193
- </figure>
194
- <div>
195
- <div class="font-semibold text-neutral-900 dark:text-white">
196
- {{ products[mobileIndex]?.name }}
197
- </div>
198
- <div
199
- v-if="products[mobileIndex]?.price"
200
- class="font-bold text-primary-600 dark:text-primary-400 text-lg"
201
- >
202
- {{ products[mobileIndex].price }}
203
- </div>
204
- </div>
205
- </div>
206
-
207
- <!-- Spec rows -->
208
- <div>
209
- <div
210
- v-for="(spec, specIdx) in specs"
211
- :key="spec.key"
212
- class="flex items-center justify-between px-5 py-3 border-b border-neutral-100 dark:border-neutral-800 last:border-0"
213
- :class="
214
- specIdx % 2 === 0
215
- ? 'bg-white dark:bg-neutral-900'
216
- : 'bg-neutral-50/50 dark:bg-neutral-800/30'
217
- "
218
- >
219
- <span
220
- class="font-medium text-neutral-600 dark:text-neutral-400 text-sm"
221
- >{{ spec.label }}</span
222
- >
223
- <span
224
- class="text-sm font-medium"
225
- :class="
226
- highlightBest && isBestValue(spec, products[mobileIndex])
227
- ? 'text-primary-600 dark:text-primary-400 font-semibold'
228
- : 'text-neutral-800 dark:text-neutral-200'
229
- "
230
- >
231
- {{ products[mobileIndex]?.specs?.[spec.key] ?? "—" }}
232
- </span>
233
- </div>
234
- </div>
235
-
236
- <!-- Buy button -->
237
- <div
238
- v-if="showBuyButtons"
239
- class="p-4 bg-neutral-50 dark:bg-neutral-800/50"
240
- >
241
- <a
242
- :href="getAffiliateHref(products[mobileIndex])"
243
- target="_blank"
244
- rel="noopener noreferrer"
245
- >
246
- <UButton
247
- :label="buttonLabel"
248
- trailing-icon="i-lucide-external-link"
249
- color="primary"
250
- variant="solid"
251
- size="md"
252
- block
253
- class="justify-center"
254
- />
255
- </a>
256
- </div>
257
- </div>
258
-
259
- <!-- Dot indicators -->
260
- <div class="flex justify-center gap-1.5 mt-4">
261
- <button
262
- v-for="(_, idx) in products"
263
- :key="idx"
264
- class="w-2 h-2 rounded-full transition-all duration-200"
265
- :class="
266
- idx === mobileIndex
267
- ? 'bg-primary-500 w-4'
268
- : 'bg-neutral-300 dark:bg-neutral-600'
269
- "
270
- :aria-label="`Go to product ${idx + 1}`"
271
- @click="mobileIndex = idx"
272
- />
273
- </div>
274
- </div>
275
- </section>
276
- </template>
277
-
278
- <script setup>
279
- const props = defineProps({
280
- /** Array of 2–4 products to compare */
281
- products: {
282
- type: Array,
283
- default: () => [
284
- {
285
- name: "Product A",
286
- price: "$79.99",
287
- affiliateUrl: "#",
288
- specs: {
289
- price: "$79.99",
290
- rating: "4.5/5",
291
- battery: "20 hours",
292
- weight: "250g",
293
- },
294
- },
295
- {
296
- name: "Product B",
297
- price: "$99.99",
298
- affiliateUrl: "#",
299
- specs: {
300
- price: "$99.99",
301
- rating: "4.8/5",
302
- battery: "30 hours",
303
- weight: "220g",
304
- },
305
- },
306
- ],
307
- },
308
- /** Spec rows to display [{ label, key }] */
309
- specs: {
310
- type: Array,
311
- default: () => [
312
- { label: "Price", key: "price" },
313
- { label: "Rating", key: "rating" },
314
- { label: "Battery Life", key: "battery" },
315
- ],
316
- },
317
- /** Show buy buttons in last row */
318
- showBuyButtons: {
319
- type: Boolean,
320
- default: true,
321
- },
322
- /** Highlight best value in each row */
323
- highlightBest: {
324
- type: Boolean,
325
- default: true,
326
- },
327
- /** Buy button label */
328
- buttonLabel: {
329
- type: String,
330
- default: "Buy on Amazon",
331
- },
332
- /** Disclosure text shown above table */
333
- disclosure: {
334
- type: String,
335
- default: "",
336
- },
337
- });
338
-
339
- /** Mobile carousel index */
340
- const mobileIndex = ref(0);
341
-
342
- /** Build affiliate URL with optional tag */
343
- const getAffiliateHref = (product) => {
344
- if (!product) return "#";
345
- const url = product.affiliateUrl || "#";
346
- const tag = product.affiliateTag;
347
- if (!tag || url === "#") return url;
348
- const separator = url.includes("?") ? "&" : "?";
349
- return `${url}${separator}tag=${tag}`;
350
- };
351
-
352
- /**
353
- * Determine if a product has the "best" value for a given spec.
354
- * For numeric values: lowest price wins, highest rating wins.
355
- * For non-numeric: no highlighting.
356
- */
357
- const isBestValue = (spec, product) => {
358
- if (!props.highlightBest || !product?.specs) return false;
359
- const rawValue = product.specs[spec.key];
360
- if (rawValue === undefined || rawValue === null) return false;
361
-
362
- // Extract numeric portion (strip $, %, /5, /10, etc.)
363
- const numericValue = parseFloat(String(rawValue).replace(/[^0-9.]/g, ""));
364
- if (isNaN(numericValue)) return false;
365
-
366
- const allValues = props.products
367
- .map((p) =>
368
- parseFloat(String(p.specs?.[spec.key] ?? "").replace(/[^0-9.]/g, ""))
369
- )
370
- .filter((v) => !isNaN(v));
371
-
372
- if (allValues.length < 2) return false;
373
-
374
- const key = spec.key.toLowerCase();
375
- // Price: lower is better
376
- if (key.includes("price") || key.includes("cost")) {
377
- return numericValue === Math.min(...allValues);
378
- }
379
- // Everything else: higher is better (rating, battery, etc.)
380
- return numericValue === Math.max(...allValues);
381
- };
382
- </script>
@@ -1,138 +0,0 @@
1
- <template>
2
- <div>
3
- <!-- Subtle variant -->
4
- <Transition name="fade">
5
- <div
6
- v-if="visible && variant === 'subtle'"
7
- class="flex items-start gap-2.5 px-4 py-2.5 rounded-lg bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 text-sm"
8
- >
9
- <UIcon
10
- :name="icon"
11
- class="w-4 h-4 flex-shrink-0 mt-0.5 text-neutral-500 dark:text-neutral-400"
12
- />
13
- <p class="flex-1 leading-relaxed">{{ text }}</p>
14
- <button
15
- v-if="dismissible"
16
- class="flex-shrink-0 p-0.5 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
17
- aria-label="Dismiss disclosure"
18
- @click="dismiss"
19
- >
20
- <UIcon name="i-lucide-x" class="w-3.5 h-3.5" />
21
- </button>
22
- </div>
23
- </Transition>
24
-
25
- <!-- Banner variant -->
26
- <Transition name="slide-down">
27
- <div
28
- v-if="visible && variant === 'banner'"
29
- class="relative w-full bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800"
30
- >
31
- <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5">
32
- <div
33
- class="flex items-center justify-center gap-x-3 text-sm text-amber-800 dark:text-amber-300"
34
- >
35
- <UIcon
36
- :name="icon"
37
- class="w-4 h-4 flex-shrink-0 text-amber-600 dark:text-amber-400"
38
- />
39
- <p class="text-center leading-relaxed">{{ text }}</p>
40
- <button
41
- v-if="dismissible"
42
- class="absolute right-4 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-amber-100 dark:hover:bg-amber-800/50 transition-colors"
43
- aria-label="Dismiss disclosure"
44
- @click="dismiss"
45
- >
46
- <UIcon name="i-lucide-x" class="w-4 h-4" />
47
- </button>
48
- </div>
49
- </div>
50
- </div>
51
- </Transition>
52
-
53
- <!-- Inline variant -->
54
- <span
55
- v-if="visible && variant === 'inline'"
56
- class="inline-flex items-start gap-1.5 text-xs text-neutral-500 dark:text-neutral-400 italic"
57
- >
58
- <UIcon :name="icon" class="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
59
- <span>{{ text }}</span>
60
- </span>
61
- </div>
62
- </template>
63
-
64
- <script setup>
65
- const props = defineProps({
66
- /** Disclosure text shown to users */
67
- text: {
68
- type: String,
69
- default:
70
- "This page contains affiliate links. As an Amazon Associate, we earn from qualifying purchases at no extra cost to you.",
71
- },
72
- /** Visual variant of the disclosure */
73
- variant: {
74
- type: String,
75
- default: "subtle",
76
- validator: (v) => ["subtle", "banner", "inline"].includes(v),
77
- },
78
- /** Icon to display alongside the text */
79
- icon: {
80
- type: String,
81
- default: "i-lucide-info",
82
- },
83
- /** Whether the disclosure can be dismissed */
84
- dismissible: {
85
- type: Boolean,
86
- default: false,
87
- },
88
- /** localStorage key for persisting dismissed state */
89
- storageKey: {
90
- type: String,
91
- default: "affiliate-disclosure-dismissed",
92
- },
93
- });
94
-
95
- const emit = defineEmits(["dismiss"]);
96
-
97
- const visible = ref(true);
98
-
99
- onMounted(() => {
100
- if (props.dismissible && props.storageKey && import.meta.client) {
101
- const dismissed = localStorage.getItem(props.storageKey);
102
- if (dismissed) {
103
- visible.value = false;
104
- }
105
- }
106
- });
107
-
108
- const dismiss = () => {
109
- if (props.storageKey && import.meta.client) {
110
- localStorage.setItem(props.storageKey, "true");
111
- }
112
- visible.value = false;
113
- emit("dismiss");
114
- };
115
- </script>
116
-
117
- <style scoped>
118
- .fade-enter-active,
119
- .fade-leave-active {
120
- transition: opacity 0.3s ease;
121
- }
122
-
123
- .fade-enter-from,
124
- .fade-leave-to {
125
- opacity: 0;
126
- }
127
-
128
- .slide-down-enter-active,
129
- .slide-down-leave-active {
130
- transition: transform 0.3s ease, opacity 0.3s ease;
131
- }
132
-
133
- .slide-down-enter-from,
134
- .slide-down-leave-to {
135
- transform: translateY(-100%);
136
- opacity: 0;
137
- }
138
- </style>