@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.
- package/CHANGELOG.md +64 -0
- package/README.md +107 -284
- package/app/app.config.ts +29 -123
- package/app/app.vue +31 -100
- package/app/assets/css/x-marketing.css +10 -0
- package/app/components/X/Footer/index.vue +1 -1
- package/app/components/X/Mark/Button/BackToTop.vue +2 -2
- package/app/components/X/Mark/CTA/index.vue +19 -6
- package/app/components/X/Mark/Privacy/CookieConsent.vue +15 -16
- package/app/components/X/Mark/Privacy/GDPR.vue +1 -1
- package/app/composables/useXBlog.js +168 -8
- package/app/pages/blog/[...slug].vue +81 -69
- package/app/pages/blog/index.vue +46 -50
- package/app/pages/index.vue +7 -209
- package/app/types/config.ts +134 -0
- package/content.config.ts +35 -0
- package/nuxt.config.ts +18 -5
- package/package.json +33 -11
- package/playwright-report-smoke/index.html +90 -0
- package/app/components/X/Mark/Affiliate/ComparisonTable.vue +0 -382
- package/app/components/X/Mark/Affiliate/Disclosure.vue +0 -138
- package/app/components/X/Mark/Affiliate/ProductCard.vue +0 -431
- package/app/components/X/Mark/Affiliate/ProductDetail.vue +0 -410
- package/app/components/X/Mark/Affiliate/ProductGrid.vue +0 -101
|
@@ -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>
|