@xenterprises/nuxt-x-marketing 1.0.0 → 1.0.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.
Files changed (49) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/.playground/app.vue +12 -6
  3. package/.playground/pages/blog/detail.vue +341 -249
  4. package/.playground/pages/index.vue +2 -2
  5. package/.playground/pages/layout/x-legal.vue +12 -12
  6. package/.playground/pages/notifications/social-proof-toast.vue +330 -70
  7. package/LICENSE +60 -0
  8. package/README.md +210 -64
  9. package/app/app.config.ts +2 -72
  10. package/app/assets/css/x-marketing.css +1 -0
  11. package/app/components/X/Footer/index.vue +3 -4
  12. package/app/components/X/Mark/Affiliate/ComparisonTable.vue +382 -0
  13. package/app/components/X/Mark/Affiliate/Disclosure.vue +138 -0
  14. package/app/components/X/Mark/Affiliate/ProductCard.vue +431 -0
  15. package/app/components/X/Mark/Affiliate/ProductDetail.vue +410 -0
  16. package/app/components/X/Mark/Affiliate/ProductGrid.vue +101 -0
  17. package/app/components/X/Mark/Blog/Author.vue +26 -20
  18. package/app/components/X/Mark/Blog/Card.vue +28 -15
  19. package/app/components/X/Mark/Blog/Detail.vue +82 -127
  20. package/app/components/X/Mark/Blog/List.vue +17 -16
  21. package/app/components/X/Mark/CTA/index.vue +74 -70
  22. package/app/components/X/Mark/Card/Promo.vue +149 -139
  23. package/app/components/X/Mark/CodeBlock/index.vue +69 -0
  24. package/app/components/X/Mark/Directory/CategoryHero.vue +124 -0
  25. package/app/components/X/Mark/Directory/ListingCard.vue +336 -0
  26. package/app/components/X/Mark/Directory/ListingDetail.vue +306 -0
  27. package/app/components/X/Mark/Directory/ListingGrid.vue +190 -0
  28. package/app/components/X/Mark/Directory/SubmitCTA.vue +201 -0
  29. package/app/components/X/Mark/FAQ/index.vue +191 -0
  30. package/app/components/X/Mark/Features/index.vue +87 -35
  31. package/app/components/X/Mark/Hero/index.vue +41 -38
  32. package/app/components/X/Mark/Layout/AnnouncementBar.vue +20 -28
  33. package/app/components/X/Mark/Modal/Chat.vue +124 -124
  34. package/app/components/X/Mark/Modal/Video.vue +46 -36
  35. package/app/components/X/Mark/Pricing/Comparison.vue +76 -21
  36. package/app/components/X/Mark/Pricing/Plans.vue +75 -25
  37. package/app/components/X/Mark/SocialProof/Testimonials.vue +89 -21
  38. package/app/components/X/Mark/SocialProof/Toast.vue +155 -102
  39. package/app/components/X/X/Footer/index.vue +101 -0
  40. package/app/components/X/X/Header/Nav/index.vue +62 -0
  41. package/app/components/X/X/Header/index.vue +72 -0
  42. package/app/components/X/X/Legal/index.vue +94 -0
  43. package/app/composables/useParallax.ts +32 -19
  44. package/app/composables/useScrollReveal.ts +20 -13
  45. package/app/pages/index.vue +230 -28
  46. package/componentList.json +1856 -238
  47. package/nuxt.config.ts +12 -3
  48. package/package.json +5 -2
  49. package/screenshots/playground-home.png +0 -0
@@ -0,0 +1,410 @@
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>
@@ -0,0 +1,101 @@
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>
@@ -11,14 +11,14 @@
11
11
  :alt="author.name"
12
12
  :class="[
13
13
  'rounded-full object-cover',
14
- variant === 'sm' ? 'w-8 h-8' : 'w-10 h-10'
14
+ variant === 'sm' ? 'w-8 h-8' : 'w-10 h-10',
15
15
  ]"
16
16
  />
17
17
  <div
18
18
  v-else
19
19
  :class="[
20
20
  'rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center',
21
- variant === 'sm' ? 'w-8 h-8' : 'w-10 h-10'
21
+ variant === 'sm' ? 'w-8 h-8' : 'w-10 h-10',
22
22
  ]"
23
23
  >
24
24
  <span class="text-primary-600 dark:text-primary-400 font-medium">
@@ -52,7 +52,9 @@
52
52
  v-else
53
53
  class="w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center"
54
54
  >
55
- <span class="text-primary-600 dark:text-primary-400 font-medium text-2xl">
55
+ <span
56
+ class="text-primary-600 dark:text-primary-400 font-medium text-2xl"
57
+ >
56
58
  {{ author.name?.charAt(0) }}
57
59
  </span>
58
60
  </div>
@@ -101,7 +103,9 @@
101
103
  v-else
102
104
  class="w-24 h-24 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center mx-auto"
103
105
  >
104
- <span class="text-primary-600 dark:text-primary-400 font-medium text-3xl">
106
+ <span
107
+ class="text-primary-600 dark:text-primary-400 font-medium text-3xl"
108
+ >
105
109
  {{ author.name?.charAt(0) }}
106
110
  </span>
107
111
  </div>
@@ -112,7 +116,10 @@
112
116
  <p v-if="author.title" class="text-sm text-neutral-500 mt-1">
113
117
  {{ author.title }}
114
118
  </p>
115
- <p v-if="author.bio" class="text-sm text-neutral-600 dark:text-neutral-400 mt-3">
119
+ <p
120
+ v-if="author.bio"
121
+ class="text-sm text-neutral-600 dark:text-neutral-400 mt-3"
122
+ >
116
123
  {{ author.bio }}
117
124
  </p>
118
125
 
@@ -135,17 +142,16 @@
135
142
 
136
143
  <script setup>
137
144
  const props = defineProps({
138
- /** Author object */
145
+ /** Author object { name, avatar?, title?, bio?, social?: { twitter?, linkedin?, github?, website? } } */
139
146
  author: {
140
147
  type: Object,
141
- required: true,
142
- // { name, avatar?, title?, bio?, social?: { twitter?, linkedin?, github?, website? } }
148
+ default: () => ({ name: "Author Name", title: "Contributor" }),
143
149
  },
144
150
  /** Display variant */
145
151
  variant: {
146
152
  type: String,
147
- default: 'inline',
148
- validator: (v) => ['inline', 'sm', 'full', 'card'].includes(v),
153
+ default: "inline",
154
+ validator: (v) => ["inline", "sm", "full", "card"].includes(v),
149
155
  },
150
156
  });
151
157
 
@@ -155,16 +161,16 @@ const hasSocialLinks = computed(() => {
155
161
 
156
162
  const getSocialIcon = (platform) => {
157
163
  const icons = {
158
- twitter: 'i-lucide-twitter',
159
- x: 'i-lucide-twitter',
160
- linkedin: 'i-lucide-linkedin',
161
- github: 'i-lucide-github',
162
- website: 'i-lucide-globe',
163
- instagram: 'i-lucide-instagram',
164
- facebook: 'i-lucide-facebook',
165
- youtube: 'i-lucide-youtube',
166
- email: 'i-lucide-mail',
164
+ twitter: "i-lucide-twitter",
165
+ x: "i-lucide-twitter",
166
+ linkedin: "i-lucide-linkedin",
167
+ github: "i-lucide-github",
168
+ website: "i-lucide-globe",
169
+ instagram: "i-lucide-instagram",
170
+ facebook: "i-lucide-facebook",
171
+ youtube: "i-lucide-youtube",
172
+ email: "i-lucide-mail",
167
173
  };
168
- return icons[platform.toLowerCase()] || 'i-lucide-link';
174
+ return icons[platform.toLowerCase()] || "i-lucide-link";
169
175
  };
170
176
  </script>
@@ -2,7 +2,10 @@
2
2
  <article class="group">
3
3
  <NuxtLink :to="`/blog/${post.slug}`" class="block">
4
4
  <!-- Image -->
5
- <figure v-if="post.img?.src" class="aspect-[16/9] rounded-xl overflow-hidden mb-4 xHover-zoom shadow-md transition-shadow duration-300 group-hover:shadow-xl">
5
+ <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"
8
+ >
6
9
  <img
7
10
  :src="post.img.src"
8
11
  :alt="post.img.alt || post.title"
@@ -17,18 +20,19 @@
17
20
  :label="post.category"
18
21
  variant="subtle"
19
22
  />
20
- <time
21
- :datetime="post.date"
22
- class="text-sm text-neutral-500"
23
- >
23
+ <time :datetime="post.date" class="text-sm text-neutral-500">
24
24
  {{ formattedDate }}
25
25
  </time>
26
26
  <span class="text-sm text-neutral-400">·</span>
27
- <span class="text-sm text-neutral-500">{{ post.readingTime }} min read</span>
27
+ <span class="text-sm text-neutral-500"
28
+ >{{ post.readingTime }} min read</span
29
+ >
28
30
  </div>
29
31
 
30
32
  <!-- Title -->
31
- <h3 class="xText-title text-neutral-900 dark:text-white group-hover:text-primary-500 transition-colors">
33
+ <h3
34
+ class="xText-title text-neutral-900 dark:text-white group-hover:text-primary-500 transition-colors"
35
+ >
32
36
  {{ post.title }}
33
37
  </h3>
34
38
 
@@ -52,7 +56,9 @@
52
56
  v-else
53
57
  class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center"
54
58
  >
55
- <span class="text-primary-600 dark:text-primary-400 font-medium text-sm">
59
+ <span
60
+ class="text-primary-600 dark:text-primary-400 font-medium text-sm"
61
+ >
56
62
  {{ post.author.name.charAt(0) }}
57
63
  </span>
58
64
  </div>
@@ -78,10 +84,17 @@
78
84
 
79
85
  <script setup>
80
86
  const props = defineProps({
81
- /** Blog post object with img: { src, alt? } */
87
+ /** Blog post object { title, slug, img?, excerpt?, date?, category?, author?, tags? } */
82
88
  post: {
83
89
  type: Object,
84
- required: true,
90
+ default: () => ({
91
+ title: "Blog Post Title",
92
+ 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
+ }),
85
98
  },
86
99
  /** Has category badge */
87
100
  hasCategory: {
@@ -106,12 +119,12 @@ const props = defineProps({
106
119
  });
107
120
 
108
121
  const formattedDate = computed(() => {
109
- if (!props.post.date) return '';
122
+ if (!props.post.date) return "";
110
123
  const date = new Date(props.post.date);
111
- return date.toLocaleDateString('en-US', {
112
- year: 'numeric',
113
- month: 'short',
114
- day: 'numeric',
124
+ return date.toLocaleDateString("en-US", {
125
+ year: "numeric",
126
+ month: "short",
127
+ day: "numeric",
115
128
  });
116
129
  });
117
130
  </script>