@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,410 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<article class="product-detail">
|
|
3
|
-
<!-- Disclosure -->
|
|
4
|
-
<XMarkAffiliateDisclosure
|
|
5
|
-
v-if="disclosure"
|
|
6
|
-
:text="disclosure"
|
|
7
|
-
variant="subtle"
|
|
8
|
-
class="mb-8"
|
|
9
|
-
/>
|
|
10
|
-
|
|
11
|
-
<!-- Product Header -->
|
|
12
|
-
<header class="mb-8">
|
|
13
|
-
<div class="flex flex-wrap items-start gap-4">
|
|
14
|
-
<div class="flex-1 min-w-0">
|
|
15
|
-
<h1
|
|
16
|
-
class="text-2xl sm:text-3xl lg:text-4xl font-bold text-neutral-900 dark:text-white leading-tight"
|
|
17
|
-
>
|
|
18
|
-
{{ product.name }}
|
|
19
|
-
</h1>
|
|
20
|
-
|
|
21
|
-
<!-- Rating + Review Count -->
|
|
22
|
-
<div
|
|
23
|
-
v-if="product.rating"
|
|
24
|
-
class="flex items-center gap-3 mt-3 flex-wrap"
|
|
25
|
-
>
|
|
26
|
-
<div class="flex items-center gap-1">
|
|
27
|
-
<UIcon
|
|
28
|
-
v-for="i in 5"
|
|
29
|
-
:key="i"
|
|
30
|
-
name="i-lucide-star"
|
|
31
|
-
class="w-4 h-4"
|
|
32
|
-
:class="
|
|
33
|
-
i <= Math.round(product.rating)
|
|
34
|
-
? 'text-yellow-400'
|
|
35
|
-
: 'text-neutral-300 dark:text-neutral-600'
|
|
36
|
-
"
|
|
37
|
-
/>
|
|
38
|
-
</div>
|
|
39
|
-
<span class="font-semibold text-neutral-800 dark:text-neutral-200">
|
|
40
|
-
{{ product.rating.toFixed(1) }}
|
|
41
|
-
</span>
|
|
42
|
-
<span
|
|
43
|
-
v-if="product.reviewCount"
|
|
44
|
-
class="text-sm text-neutral-500 dark:text-neutral-400"
|
|
45
|
-
>
|
|
46
|
-
({{ product.reviewCount.toLocaleString() }} reviews)
|
|
47
|
-
</span>
|
|
48
|
-
</div>
|
|
49
|
-
|
|
50
|
-
<!-- Price -->
|
|
51
|
-
<div class="flex items-baseline gap-3 mt-3">
|
|
52
|
-
<span
|
|
53
|
-
v-if="product.price"
|
|
54
|
-
class="text-2xl font-bold text-neutral-900 dark:text-white"
|
|
55
|
-
>
|
|
56
|
-
{{ product.price }}
|
|
57
|
-
</span>
|
|
58
|
-
<span
|
|
59
|
-
v-if="product.originalPrice"
|
|
60
|
-
class="text-lg text-neutral-400 dark:text-neutral-500 line-through"
|
|
61
|
-
>
|
|
62
|
-
{{ product.originalPrice }}
|
|
63
|
-
</span>
|
|
64
|
-
</div>
|
|
65
|
-
</div>
|
|
66
|
-
|
|
67
|
-
<!-- Editorial Score Badge -->
|
|
68
|
-
<div
|
|
69
|
-
v-if="score !== null"
|
|
70
|
-
class="flex-shrink-0 w-20 h-20 rounded-2xl bg-primary-500 dark:bg-primary-600 flex flex-col items-center justify-center shadow-lg"
|
|
71
|
-
aria-label="Editorial score"
|
|
72
|
-
>
|
|
73
|
-
<span class="text-2xl font-bold text-white leading-none">{{
|
|
74
|
-
score
|
|
75
|
-
}}</span>
|
|
76
|
-
<span class="text-xs text-primary-100 mt-0.5">/10</span>
|
|
77
|
-
</div>
|
|
78
|
-
</div>
|
|
79
|
-
</header>
|
|
80
|
-
|
|
81
|
-
<!-- Content Grid: main + sidebar -->
|
|
82
|
-
<div class="grid lg:grid-cols-[1fr_300px] gap-10 lg:gap-14">
|
|
83
|
-
<!-- Main Content -->
|
|
84
|
-
<div class="min-w-0 space-y-10">
|
|
85
|
-
<!-- Image Gallery -->
|
|
86
|
-
<section v-if="allImages.length" aria-label="Product images">
|
|
87
|
-
<!-- Main Image -->
|
|
88
|
-
<figure
|
|
89
|
-
class="aspect-4/3 rounded-2xl overflow-hidden bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 mb-3"
|
|
90
|
-
>
|
|
91
|
-
<img
|
|
92
|
-
:src="activeImage"
|
|
93
|
-
:alt="product.name"
|
|
94
|
-
class="w-full h-full object-contain p-6 transition-opacity duration-200"
|
|
95
|
-
/>
|
|
96
|
-
</figure>
|
|
97
|
-
|
|
98
|
-
<!-- Thumbnails -->
|
|
99
|
-
<div v-if="allImages.length > 1" class="flex gap-2 flex-wrap">
|
|
100
|
-
<button
|
|
101
|
-
v-for="(img, idx) in allImages"
|
|
102
|
-
:key="idx"
|
|
103
|
-
class="w-16 h-16 rounded-lg overflow-hidden border-2 transition-all duration-200 bg-neutral-50 dark:bg-neutral-800"
|
|
104
|
-
:class="
|
|
105
|
-
activeImage === img
|
|
106
|
-
? 'border-primary-500 dark:border-primary-400'
|
|
107
|
-
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-400'
|
|
108
|
-
"
|
|
109
|
-
:aria-label="`View image ${idx + 1}`"
|
|
110
|
-
@click="activeImage = img"
|
|
111
|
-
>
|
|
112
|
-
<img
|
|
113
|
-
:src="img"
|
|
114
|
-
:alt="`${product.name} image ${idx + 1}`"
|
|
115
|
-
class="w-full h-full object-contain p-1"
|
|
116
|
-
/>
|
|
117
|
-
</button>
|
|
118
|
-
</div>
|
|
119
|
-
</section>
|
|
120
|
-
|
|
121
|
-
<!-- Description -->
|
|
122
|
-
<section v-if="product.description">
|
|
123
|
-
<p
|
|
124
|
-
class="text-neutral-700 dark:text-neutral-300 leading-relaxed text-base"
|
|
125
|
-
>
|
|
126
|
-
{{ product.description }}
|
|
127
|
-
</p>
|
|
128
|
-
</section>
|
|
129
|
-
|
|
130
|
-
<!-- Pros & Cons Table -->
|
|
131
|
-
<section
|
|
132
|
-
v-if="
|
|
133
|
-
hasProsConsTable && (product.pros?.length || product.cons?.length)
|
|
134
|
-
"
|
|
135
|
-
aria-label="Pros and cons"
|
|
136
|
-
>
|
|
137
|
-
<h2 class="text-xl font-bold text-neutral-900 dark:text-white mb-4">
|
|
138
|
-
Pros & Cons
|
|
139
|
-
</h2>
|
|
140
|
-
<div class="grid sm:grid-cols-2 gap-4">
|
|
141
|
-
<!-- Pros -->
|
|
142
|
-
<div
|
|
143
|
-
class="rounded-xl border border-green-200 dark:border-green-900/50 bg-green-50 dark:bg-green-900/10 p-4"
|
|
144
|
-
>
|
|
145
|
-
<h3
|
|
146
|
-
class="font-semibold text-green-800 dark:text-green-400 mb-3 flex items-center gap-2"
|
|
147
|
-
>
|
|
148
|
-
<UIcon name="i-lucide-thumbs-up" class="w-4 h-4" />
|
|
149
|
-
Pros
|
|
150
|
-
</h3>
|
|
151
|
-
<ul class="space-y-2">
|
|
152
|
-
<li
|
|
153
|
-
v-for="pro in product.pros"
|
|
154
|
-
:key="pro"
|
|
155
|
-
class="flex items-start gap-2 text-sm text-neutral-700 dark:text-neutral-300"
|
|
156
|
-
>
|
|
157
|
-
<UIcon
|
|
158
|
-
name="i-lucide-check"
|
|
159
|
-
class="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5"
|
|
160
|
-
/>
|
|
161
|
-
<span>{{ pro }}</span>
|
|
162
|
-
</li>
|
|
163
|
-
</ul>
|
|
164
|
-
</div>
|
|
165
|
-
|
|
166
|
-
<!-- Cons -->
|
|
167
|
-
<div
|
|
168
|
-
class="rounded-xl border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 p-4"
|
|
169
|
-
>
|
|
170
|
-
<h3
|
|
171
|
-
class="font-semibold text-red-800 dark:text-red-400 mb-3 flex items-center gap-2"
|
|
172
|
-
>
|
|
173
|
-
<UIcon name="i-lucide-thumbs-down" class="w-4 h-4" />
|
|
174
|
-
Cons
|
|
175
|
-
</h3>
|
|
176
|
-
<ul class="space-y-2">
|
|
177
|
-
<li
|
|
178
|
-
v-for="con in product.cons"
|
|
179
|
-
:key="con"
|
|
180
|
-
class="flex items-start gap-2 text-sm text-neutral-700 dark:text-neutral-300"
|
|
181
|
-
>
|
|
182
|
-
<UIcon
|
|
183
|
-
name="i-lucide-x"
|
|
184
|
-
class="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5"
|
|
185
|
-
/>
|
|
186
|
-
<span>{{ con }}</span>
|
|
187
|
-
</li>
|
|
188
|
-
</ul>
|
|
189
|
-
</div>
|
|
190
|
-
</div>
|
|
191
|
-
</section>
|
|
192
|
-
|
|
193
|
-
<!-- Specs Table -->
|
|
194
|
-
<section
|
|
195
|
-
v-if="hasSpecs && product.specs && Object.keys(product.specs).length"
|
|
196
|
-
aria-label="Product specifications"
|
|
197
|
-
>
|
|
198
|
-
<h2 class="text-xl font-bold text-neutral-900 dark:text-white mb-4">
|
|
199
|
-
Specifications
|
|
200
|
-
</h2>
|
|
201
|
-
<div
|
|
202
|
-
class="rounded-xl border border-neutral-200 dark:border-neutral-700 overflow-hidden"
|
|
203
|
-
>
|
|
204
|
-
<table class="w-full text-sm">
|
|
205
|
-
<tbody>
|
|
206
|
-
<tr
|
|
207
|
-
v-for="(value, key, idx) in product.specs"
|
|
208
|
-
:key="key"
|
|
209
|
-
:class="
|
|
210
|
-
idx % 2 === 0
|
|
211
|
-
? 'bg-neutral-50 dark:bg-neutral-800/50'
|
|
212
|
-
: 'bg-white dark:bg-neutral-900'
|
|
213
|
-
"
|
|
214
|
-
>
|
|
215
|
-
<th
|
|
216
|
-
class="px-4 py-3 text-left font-medium text-neutral-600 dark:text-neutral-400 w-2/5 border-r border-neutral-200 dark:border-neutral-700"
|
|
217
|
-
>
|
|
218
|
-
{{ key }}
|
|
219
|
-
</th>
|
|
220
|
-
<td class="px-4 py-3 text-neutral-900 dark:text-white">
|
|
221
|
-
{{ value }}
|
|
222
|
-
</td>
|
|
223
|
-
</tr>
|
|
224
|
-
</tbody>
|
|
225
|
-
</table>
|
|
226
|
-
</div>
|
|
227
|
-
</section>
|
|
228
|
-
|
|
229
|
-
<!-- Verdict -->
|
|
230
|
-
<section v-if="product.verdict" aria-label="Editorial verdict">
|
|
231
|
-
<h2 class="text-xl font-bold text-neutral-900 dark:text-white mb-4">
|
|
232
|
-
Our Verdict
|
|
233
|
-
</h2>
|
|
234
|
-
<div
|
|
235
|
-
class="relative pl-5 border-l-4 border-primary-400 dark:border-primary-500"
|
|
236
|
-
>
|
|
237
|
-
<blockquote
|
|
238
|
-
class="text-lg italic text-neutral-700 dark:text-neutral-300 leading-relaxed"
|
|
239
|
-
>
|
|
240
|
-
{{ product.verdict }}
|
|
241
|
-
</blockquote>
|
|
242
|
-
<div v-if="score !== null" class="mt-4 flex items-center gap-3">
|
|
243
|
-
<div
|
|
244
|
-
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 font-semibold text-sm"
|
|
245
|
-
>
|
|
246
|
-
<UIcon name="i-lucide-award" class="w-4 h-4" />
|
|
247
|
-
Editorial Score: {{ score }}/10
|
|
248
|
-
</div>
|
|
249
|
-
</div>
|
|
250
|
-
</div>
|
|
251
|
-
</section>
|
|
252
|
-
|
|
253
|
-
<!-- After Verdict Slot -->
|
|
254
|
-
<slot name="after-verdict" />
|
|
255
|
-
</div>
|
|
256
|
-
|
|
257
|
-
<!-- Sidebar -->
|
|
258
|
-
<aside class="lg:block">
|
|
259
|
-
<div class="sticky top-24 space-y-6">
|
|
260
|
-
<!-- Buy Box -->
|
|
261
|
-
<slot name="buy-box">
|
|
262
|
-
<div
|
|
263
|
-
v-if="hasBuyBox"
|
|
264
|
-
class="rounded-2xl border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 p-5 shadow-sm"
|
|
265
|
-
>
|
|
266
|
-
<h3 class="font-semibold text-neutral-900 dark:text-white mb-1">
|
|
267
|
-
{{ product.name }}
|
|
268
|
-
</h3>
|
|
269
|
-
|
|
270
|
-
<!-- Price in buy box -->
|
|
271
|
-
<div class="flex items-baseline gap-2 mb-4">
|
|
272
|
-
<span
|
|
273
|
-
v-if="product.price"
|
|
274
|
-
class="text-2xl font-bold text-neutral-900 dark:text-white"
|
|
275
|
-
>
|
|
276
|
-
{{ product.price }}
|
|
277
|
-
</span>
|
|
278
|
-
<span
|
|
279
|
-
v-if="product.originalPrice"
|
|
280
|
-
class="text-sm text-neutral-400 line-through"
|
|
281
|
-
>
|
|
282
|
-
{{ product.originalPrice }}
|
|
283
|
-
</span>
|
|
284
|
-
</div>
|
|
285
|
-
|
|
286
|
-
<!-- Score in buy box -->
|
|
287
|
-
<div v-if="score !== null" class="flex items-center gap-2 mb-4">
|
|
288
|
-
<div class="flex items-center gap-0.5">
|
|
289
|
-
<UIcon
|
|
290
|
-
v-for="i in 5"
|
|
291
|
-
:key="i"
|
|
292
|
-
name="i-lucide-star"
|
|
293
|
-
class="w-3.5 h-3.5"
|
|
294
|
-
:class="
|
|
295
|
-
i <= Math.round((score / 10) * 5)
|
|
296
|
-
? 'text-yellow-400'
|
|
297
|
-
: 'text-neutral-300 dark:text-neutral-600'
|
|
298
|
-
"
|
|
299
|
-
/>
|
|
300
|
-
</div>
|
|
301
|
-
<span
|
|
302
|
-
class="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
|
303
|
-
>
|
|
304
|
-
{{ score }}/10
|
|
305
|
-
</span>
|
|
306
|
-
</div>
|
|
307
|
-
|
|
308
|
-
<!-- CTA Button -->
|
|
309
|
-
<a
|
|
310
|
-
:href="affiliateHref"
|
|
311
|
-
target="_blank"
|
|
312
|
-
rel="noopener noreferrer"
|
|
313
|
-
class="block"
|
|
314
|
-
>
|
|
315
|
-
<UButton
|
|
316
|
-
label="Check Price on Amazon"
|
|
317
|
-
trailing-icon="i-lucide-external-link"
|
|
318
|
-
color="primary"
|
|
319
|
-
variant="solid"
|
|
320
|
-
size="lg"
|
|
321
|
-
block
|
|
322
|
-
class="justify-center"
|
|
323
|
-
/>
|
|
324
|
-
</a>
|
|
325
|
-
|
|
326
|
-
<!-- Disclosure in buy box -->
|
|
327
|
-
<p
|
|
328
|
-
v-if="disclosure"
|
|
329
|
-
class="mt-3 text-xs text-neutral-400 dark:text-neutral-500 text-center italic leading-relaxed"
|
|
330
|
-
>
|
|
331
|
-
{{ disclosure }}
|
|
332
|
-
</p>
|
|
333
|
-
</div>
|
|
334
|
-
</slot>
|
|
335
|
-
|
|
336
|
-
<!-- Sidebar Slot -->
|
|
337
|
-
<slot name="sidebar" />
|
|
338
|
-
</div>
|
|
339
|
-
</aside>
|
|
340
|
-
</div>
|
|
341
|
-
</article>
|
|
342
|
-
</template>
|
|
343
|
-
|
|
344
|
-
<script setup>
|
|
345
|
-
const props = defineProps({
|
|
346
|
-
/** Full product data for the review page */
|
|
347
|
-
product: {
|
|
348
|
-
type: Object,
|
|
349
|
-
default: () => ({
|
|
350
|
-
name: "Product Review",
|
|
351
|
-
price: "$99.99",
|
|
352
|
-
rating: 4.5,
|
|
353
|
-
reviewCount: 1200,
|
|
354
|
-
pros: ["Excellent build quality", "Long battery life", "Great value"],
|
|
355
|
-
cons: ["No IP rating", "Bulky design"],
|
|
356
|
-
description: "A comprehensive review of this product.",
|
|
357
|
-
verdict: "A solid choice for most users.",
|
|
358
|
-
}),
|
|
359
|
-
},
|
|
360
|
-
/** Editorial score 0–10 */
|
|
361
|
-
score: {
|
|
362
|
-
type: Number,
|
|
363
|
-
default: null,
|
|
364
|
-
},
|
|
365
|
-
/** Show the buy box sidebar widget */
|
|
366
|
-
hasBuyBox: {
|
|
367
|
-
type: Boolean,
|
|
368
|
-
default: true,
|
|
369
|
-
},
|
|
370
|
-
/** Show the specs table */
|
|
371
|
-
hasSpecs: {
|
|
372
|
-
type: Boolean,
|
|
373
|
-
default: true,
|
|
374
|
-
},
|
|
375
|
-
/** Show the pros/cons two-column table */
|
|
376
|
-
hasProsConsTable: {
|
|
377
|
-
type: Boolean,
|
|
378
|
-
default: true,
|
|
379
|
-
},
|
|
380
|
-
/** Affiliate disclosure text */
|
|
381
|
-
disclosure: {
|
|
382
|
-
type: String,
|
|
383
|
-
default: "",
|
|
384
|
-
},
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
/** Build the affiliate URL with optional tag param */
|
|
388
|
-
const affiliateHref = computed(() => {
|
|
389
|
-
const url = props.product.affiliateUrl || "#";
|
|
390
|
-
const tag = props.product.affiliateTag;
|
|
391
|
-
if (!tag || url === "#") return url;
|
|
392
|
-
const separator = url.includes("?") ? "&" : "?";
|
|
393
|
-
return `${url}${separator}tag=${tag}`;
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
/** Collect all images: images array or single image */
|
|
397
|
-
const allImages = computed(() => {
|
|
398
|
-
if (props.product.images?.length) return props.product.images;
|
|
399
|
-
if (props.product.image) return [props.product.image];
|
|
400
|
-
return [];
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
/** Currently displayed main image */
|
|
404
|
-
const activeImage = ref(allImages.value[0] || "");
|
|
405
|
-
|
|
406
|
-
/** Update active image when product changes */
|
|
407
|
-
watch(allImages, (imgs) => {
|
|
408
|
-
if (imgs.length) activeImage.value = imgs[0];
|
|
409
|
-
});
|
|
410
|
-
</script>
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<section>
|
|
3
|
-
<!-- Disclosure at top -->
|
|
4
|
-
<XMarkAffiliateDisclosure
|
|
5
|
-
v-if="disclosure"
|
|
6
|
-
:text="disclosure"
|
|
7
|
-
variant="subtle"
|
|
8
|
-
class="mb-6"
|
|
9
|
-
/>
|
|
10
|
-
|
|
11
|
-
<!-- Grid layout -->
|
|
12
|
-
<div v-if="layout === 'grid'" :class="gridClasses">
|
|
13
|
-
<XMarkAffiliateProductCard
|
|
14
|
-
v-for="(product, index) in rankedProducts"
|
|
15
|
-
:key="product.name + index"
|
|
16
|
-
:product="product"
|
|
17
|
-
:layout="cardLayout"
|
|
18
|
-
:button-label="buttonLabel"
|
|
19
|
-
class="xFadeUp"
|
|
20
|
-
:style="{ animationDelay: `${index * 80}ms` }"
|
|
21
|
-
/>
|
|
22
|
-
</div>
|
|
23
|
-
|
|
24
|
-
<!-- List layout -->
|
|
25
|
-
<div v-else-if="layout === 'list'" class="space-y-4">
|
|
26
|
-
<XMarkAffiliateProductCard
|
|
27
|
-
v-for="(product, index) in rankedProducts"
|
|
28
|
-
:key="product.name + index"
|
|
29
|
-
:product="product"
|
|
30
|
-
layout="row"
|
|
31
|
-
:button-label="buttonLabel"
|
|
32
|
-
class="xFadeUp"
|
|
33
|
-
:style="{ animationDelay: `${index * 60}ms` }"
|
|
34
|
-
/>
|
|
35
|
-
</div>
|
|
36
|
-
</section>
|
|
37
|
-
</template>
|
|
38
|
-
|
|
39
|
-
<script setup>
|
|
40
|
-
const props = defineProps({
|
|
41
|
-
/** Array of product objects */
|
|
42
|
-
products: {
|
|
43
|
-
type: Array,
|
|
44
|
-
default: () => [],
|
|
45
|
-
},
|
|
46
|
-
/** Grid or list display */
|
|
47
|
-
layout: {
|
|
48
|
-
type: String,
|
|
49
|
-
default: "grid",
|
|
50
|
-
validator: (v) => ["grid", "list"].includes(v),
|
|
51
|
-
},
|
|
52
|
-
/** Number of columns in grid mode (2 or 3) */
|
|
53
|
-
columns: {
|
|
54
|
-
type: Number,
|
|
55
|
-
default: 3,
|
|
56
|
-
validator: (v) => [2, 3].includes(v),
|
|
57
|
-
},
|
|
58
|
-
/** Show rank badges (#1 Pick, #2 Pick, etc.) on first 3 products */
|
|
59
|
-
showRanking: {
|
|
60
|
-
type: Boolean,
|
|
61
|
-
default: true,
|
|
62
|
-
},
|
|
63
|
-
/** Disclosure text shown once at top of grid */
|
|
64
|
-
disclosure: {
|
|
65
|
-
type: String,
|
|
66
|
-
default: "",
|
|
67
|
-
},
|
|
68
|
-
/** CTA button label passed to each card */
|
|
69
|
-
buttonLabel: {
|
|
70
|
-
type: String,
|
|
71
|
-
default: "Check Price on Amazon",
|
|
72
|
-
},
|
|
73
|
-
/** Card layout variant passed to each ProductCard */
|
|
74
|
-
cardLayout: {
|
|
75
|
-
type: String,
|
|
76
|
-
default: "card",
|
|
77
|
-
validator: (v) => ["card", "row", "compact"].includes(v),
|
|
78
|
-
},
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
const rankLabels = ["#1 Pick", "#2 Pick", "#3 Pick"];
|
|
82
|
-
|
|
83
|
-
/** Inject rank badges into first 3 products when showRanking is true */
|
|
84
|
-
const rankedProducts = computed(() => {
|
|
85
|
-
if (!props.showRanking) return props.products;
|
|
86
|
-
return props.products.map((product, index) => {
|
|
87
|
-
if (index < 3 && !product.badge) {
|
|
88
|
-
return { ...product, badge: rankLabels[index] };
|
|
89
|
-
}
|
|
90
|
-
return product;
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
const gridClasses = computed(() => {
|
|
95
|
-
const colMap = {
|
|
96
|
-
2: "grid grid-cols-1 sm:grid-cols-2 gap-6",
|
|
97
|
-
3: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6",
|
|
98
|
-
};
|
|
99
|
-
return colMap[props.columns] || colMap[3];
|
|
100
|
-
});
|
|
101
|
-
</script>
|