@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,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 &amp; 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>