@commercejs/ui 0.1.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.
Files changed (80) hide show
  1. package/dist/module.cjs +5 -0
  2. package/dist/module.d.mts +15 -0
  3. package/dist/module.d.ts +15 -0
  4. package/dist/module.json +12 -0
  5. package/dist/module.mjs +30 -0
  6. package/dist/runtime/app.config.d.ts +0 -0
  7. package/dist/runtime/app.config.js +341 -0
  8. package/dist/runtime/components/auction/CAuctionCard.vue +213 -0
  9. package/dist/runtime/components/auction/CBidPanel.vue +176 -0
  10. package/dist/runtime/components/cart/CCartDrawer.vue +223 -0
  11. package/dist/runtime/components/cart/CCartItem.vue +136 -0
  12. package/dist/runtime/components/cart/CCartSummary.vue +127 -0
  13. package/dist/runtime/components/cart/CQuantitySelector.vue +110 -0
  14. package/dist/runtime/components/category/CCategoryFilter.vue +123 -0
  15. package/dist/runtime/components/checkout/CAddressForm.vue +186 -0
  16. package/dist/runtime/components/checkout/CCheckoutStepper.vue +84 -0
  17. package/dist/runtime/components/common/CEmptyState.vue +81 -0
  18. package/dist/runtime/components/common/CProductTypeBadge.vue +37 -0
  19. package/dist/runtime/components/event/CEventCard.vue +129 -0
  20. package/dist/runtime/components/gift-card/CGiftCardBalance.vue +119 -0
  21. package/dist/runtime/components/gift-card/CGiftCardForm.vue +157 -0
  22. package/dist/runtime/components/gift-card/CGiftCardForm.vue.backup +138 -0
  23. package/dist/runtime/components/marketing/CHeroBanner.vue +142 -0
  24. package/dist/runtime/components/navigation/CSearchBar.vue +127 -0
  25. package/dist/runtime/components/order/COrderCard.vue +117 -0
  26. package/dist/runtime/components/order/COrderTimeline.vue +99 -0
  27. package/dist/runtime/components/product/CProductCard.vue +206 -0
  28. package/dist/runtime/components/product/CProductGallery.vue +110 -0
  29. package/dist/runtime/components/product/CProductGrid.vue +82 -0
  30. package/dist/runtime/components/product/CProductOptions.vue +101 -0
  31. package/dist/runtime/components/product/CProductPrice.vue +87 -0
  32. package/dist/runtime/components/promotion/CCouponInput.vue +104 -0
  33. package/dist/runtime/components/promotion/CPromoBanner.vue +153 -0
  34. package/dist/runtime/components/rental/CRentalBookingForm.vue +214 -0
  35. package/dist/runtime/components/rental/CRentalCard.vue +146 -0
  36. package/dist/runtime/components/review/CReviewCard.vue +96 -0
  37. package/dist/runtime/components/review/CReviewStars.vue +106 -0
  38. package/dist/runtime/components/subscription/CSubscriptionCard.vue +137 -0
  39. package/dist/runtime/components/wholesale/CPriceTierTable.vue +88 -0
  40. package/dist/runtime/components/wholesale/CQuoteRequestForm.vue +148 -0
  41. package/dist/runtime/components/wishlist/CWishlistGrid.vue +96 -0
  42. package/dist/types.d.mts +7 -0
  43. package/dist/types.d.ts +7 -0
  44. package/package.json +41 -0
  45. package/src/module.ts +52 -0
  46. package/src/runtime/app.config.ts +392 -0
  47. package/src/runtime/components/auction/CAuctionCard.vue +213 -0
  48. package/src/runtime/components/auction/CBidPanel.vue +176 -0
  49. package/src/runtime/components/cart/CCartDrawer.vue +223 -0
  50. package/src/runtime/components/cart/CCartItem.vue +136 -0
  51. package/src/runtime/components/cart/CCartSummary.vue +127 -0
  52. package/src/runtime/components/cart/CQuantitySelector.vue +110 -0
  53. package/src/runtime/components/category/CCategoryFilter.vue +123 -0
  54. package/src/runtime/components/checkout/CAddressForm.vue +186 -0
  55. package/src/runtime/components/checkout/CCheckoutStepper.vue +84 -0
  56. package/src/runtime/components/common/CEmptyState.vue +81 -0
  57. package/src/runtime/components/common/CProductTypeBadge.vue +37 -0
  58. package/src/runtime/components/event/CEventCard.vue +129 -0
  59. package/src/runtime/components/gift-card/CGiftCardBalance.vue +119 -0
  60. package/src/runtime/components/gift-card/CGiftCardForm.vue +157 -0
  61. package/src/runtime/components/gift-card/CGiftCardForm.vue.backup +138 -0
  62. package/src/runtime/components/marketing/CHeroBanner.vue +142 -0
  63. package/src/runtime/components/navigation/CSearchBar.vue +127 -0
  64. package/src/runtime/components/order/COrderCard.vue +117 -0
  65. package/src/runtime/components/order/COrderTimeline.vue +99 -0
  66. package/src/runtime/components/product/CProductCard.vue +206 -0
  67. package/src/runtime/components/product/CProductGallery.vue +110 -0
  68. package/src/runtime/components/product/CProductGrid.vue +82 -0
  69. package/src/runtime/components/product/CProductOptions.vue +101 -0
  70. package/src/runtime/components/product/CProductPrice.vue +87 -0
  71. package/src/runtime/components/promotion/CCouponInput.vue +104 -0
  72. package/src/runtime/components/promotion/CPromoBanner.vue +153 -0
  73. package/src/runtime/components/rental/CRentalBookingForm.vue +214 -0
  74. package/src/runtime/components/rental/CRentalCard.vue +146 -0
  75. package/src/runtime/components/review/CReviewCard.vue +96 -0
  76. package/src/runtime/components/review/CReviewStars.vue +106 -0
  77. package/src/runtime/components/subscription/CSubscriptionCard.vue +137 -0
  78. package/src/runtime/components/wholesale/CPriceTierTable.vue +88 -0
  79. package/src/runtime/components/wholesale/CQuoteRequestForm.vue +148 -0
  80. package/src/runtime/components/wishlist/CWishlistGrid.vue +96 -0
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ import type { Product } from '@commercejs/types'
3
+
4
+ /**
5
+ * CProductGrid — Responsive product grid layout.
6
+ * Wraps CProductCard in a CSS grid with configurable columns.
7
+ */
8
+
9
+ export interface ProductGridProps {
10
+ /** Products to display */
11
+ products: Product[]
12
+ /** Number of columns (responsive breakpoints) */
13
+ columns?: 2 | 3 | 4 | 5 | 6
14
+ /** Gap between items */
15
+ gap?: 'sm' | 'md' | 'lg'
16
+ /** Props to pass through to each CProductCard */
17
+ cardProps?: Record<string, any>
18
+ /** Per-instance theme overrides */
19
+ ui?: Partial<{
20
+ root: any
21
+ empty: any
22
+ }>
23
+ }
24
+
25
+ const props = withDefaults(defineProps<ProductGridProps>(), {
26
+ columns: 4,
27
+ gap: 'md',
28
+ })
29
+
30
+ const emit = defineEmits<{
31
+ 'add-to-cart': [product: Product]
32
+ 'toggle-wishlist': [product: Product]
33
+ }>()
34
+
35
+ const gridCols = computed(() => {
36
+ const map: Record<number, string> = {
37
+ 2: 'grid-cols-1 sm:grid-cols-2',
38
+ 3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
39
+ 4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
40
+ 5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
41
+ 6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6',
42
+ }
43
+ return map[props.columns] || map[4]
44
+ })
45
+
46
+ const gapClass = computed(() => {
47
+ const map = { sm: 'gap-3', md: 'gap-4 md:gap-6', lg: 'gap-6 md:gap-8' }
48
+ return map[props.gap] || map.md
49
+ })
50
+
51
+ // Resolve theme from app.config
52
+ const appConfig = useAppConfig()
53
+ const theme = computed(() => (appConfig.ui as any)?.productGrid ?? {})
54
+
55
+ const slotClasses = computed(() => {
56
+ const base = theme.value?.slots ?? {}
57
+ return {
58
+ root: [base.root, props.ui?.root],
59
+ empty: [base.empty, props.ui?.empty],
60
+ }
61
+ })
62
+ </script>
63
+
64
+ <template>
65
+ <div v-if="products.length > 0" :class="['grid', gridCols, gapClass, slotClasses.root]">
66
+ <slot name="item" v-for="product in products" :key="product.id" :product="product">
67
+ <CProductCard
68
+ :product="product"
69
+ v-bind="cardProps"
70
+ @add-to-cart="emit('add-to-cart', $event)"
71
+ @toggle-wishlist="emit('toggle-wishlist', $event)"
72
+ />
73
+ </slot>
74
+ </div>
75
+
76
+ <slot v-else name="empty">
77
+ <div :class="['text-center py-16', slotClasses.empty]">
78
+ <UIcon name="i-heroicons-shopping-bag" class="text-4xl text-muted mb-3" />
79
+ <p class="text-muted">No products found</p>
80
+ </div>
81
+ </slot>
82
+ </template>
@@ -0,0 +1,101 @@
1
+ <script setup lang="ts">
2
+ import type { ProductOption, LocalizedString, Id } from '@commercejs/types'
3
+
4
+ /**
5
+ * CProductOptions — Variant option selector (size, color, etc.)
6
+ * Renders each option group with selectable values.
7
+ */
8
+
9
+ export interface ProductOptionsProps {
10
+ /** Available product options */
11
+ items: ProductOption[]
12
+ /** Currently selected option values: { optionId: valueId } */
13
+ modelValue: Record<string, string>
14
+ /** Size variant */
15
+ size?: 'sm' | 'md' | 'lg'
16
+ /** Per-instance theme overrides */
17
+ ui?: Partial<{
18
+ root: any
19
+ group: any
20
+ label: any
21
+ values: any
22
+ value: any
23
+ valueActive: any
24
+ }>
25
+ }
26
+
27
+ const props = withDefaults(defineProps<ProductOptionsProps>(), {
28
+ size: 'md',
29
+ })
30
+
31
+ const emit = defineEmits<{
32
+ 'update:modelValue': [value: Record<string, string>]
33
+ }>()
34
+
35
+ function t(value: LocalizedString | string | null | undefined): string {
36
+ if (!value) return ''
37
+ if (typeof value === 'string') return value
38
+ return value.en || value.ar || Object.values(value)[0] || ''
39
+ }
40
+
41
+ function selectValue(optionId: string, valueId: string) {
42
+ emit('update:modelValue', {
43
+ ...props.modelValue,
44
+ [optionId]: valueId,
45
+ })
46
+ }
47
+
48
+ function isSelected(optionId: string, valueId: string): boolean {
49
+ return props.modelValue[optionId] === valueId
50
+ }
51
+
52
+ // Resolve theme from app.config
53
+ const appConfig = useAppConfig()
54
+ const theme = computed(() => (appConfig.ui as any)?.productOptions ?? {})
55
+
56
+ const slotClasses = computed(() => {
57
+ const base = theme.value?.slots ?? {}
58
+ const sizeStyles = theme.value?.variants?.size?.[props.size] ?? {}
59
+ const merge = (slot: string) => [
60
+ base[slot],
61
+ sizeStyles[slot],
62
+ props.ui?.[slot as keyof typeof props.ui],
63
+ ]
64
+ return {
65
+ root: merge('root'),
66
+ group: merge('group'),
67
+ label: merge('label'),
68
+ values: merge('values'),
69
+ value: merge('value'),
70
+ valueActive: merge('valueActive'),
71
+ }
72
+ })
73
+ </script>
74
+
75
+ <template>
76
+ <div :class="slotClasses.root">
77
+ <div v-for="option in items" :key="option.id" :class="slotClasses.group">
78
+ <slot name="label" :option="option">
79
+ <label :class="slotClasses.label">
80
+ {{ t(option.name) }}
81
+ <span v-if="modelValue[option.id]" class="text-muted font-normal">
82
+ — {{ t(option.values.find(v => v.id === modelValue[option.id])?.name) }}
83
+ </span>
84
+ </label>
85
+ </slot>
86
+
87
+ <div :class="slotClasses.values">
88
+ <slot name="value" v-for="val in option.values" :key="val.id" :option="option" :value="val" :selected="isSelected(option.id, val.id)" :select="() => selectValue(option.id, val.id)">
89
+ <UButton
90
+ :variant="isSelected(option.id, val.id) ? 'soft' : 'outline'"
91
+ :color="isSelected(option.id, val.id) ? 'primary' : 'neutral'"
92
+ size="sm"
93
+ @click="selectValue(option.id, val.id)"
94
+ >
95
+ {{ t(val.name) }}
96
+ </UButton>
97
+ </slot>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ </template>
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ import type { DiscountablePrice, Price } from '@commercejs/types'
3
+
4
+ /**
5
+ * CProductPrice — Display a product price with optional discount.
6
+ * Follows Nuxt UI conventions: ui prop, slot-based theming, semantic tokens.
7
+ */
8
+
9
+ export interface ProductPriceProps {
10
+ /** The price to display */
11
+ price: DiscountablePrice | Price
12
+ /** Size variant */
13
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
14
+ /** Show discount percentage badge */
15
+ showDiscount?: boolean
16
+ /** Per-instance theme overrides */
17
+ ui?: Partial<{
18
+ root: any
19
+ current: any
20
+ original: any
21
+ discount: any
22
+ }>
23
+ }
24
+
25
+ const props = withDefaults(defineProps<ProductPriceProps>(), {
26
+ size: 'md',
27
+ showDiscount: true,
28
+ })
29
+
30
+ const hasDiscount = computed(() => {
31
+ const p = props.price as DiscountablePrice
32
+ return p.originalAmount != null && p.originalAmount > p.amount
33
+ })
34
+
35
+ const discountPercent = computed(() => {
36
+ const p = props.price as DiscountablePrice
37
+ if (p.discountPercent) return p.discountPercent
38
+ if (p.originalAmount && p.originalAmount > p.amount) {
39
+ return Math.round(((p.originalAmount - p.amount) / p.originalAmount) * 100)
40
+ }
41
+ return 0
42
+ })
43
+
44
+ const originalFormatted = computed(() => {
45
+ if (!hasDiscount.value) return ''
46
+ const p = props.price as DiscountablePrice
47
+ // Build a formatted original price string
48
+ if (p.formatted && p.originalAmount) {
49
+ return p.formatted.replace(String(p.amount), String(p.originalAmount))
50
+ }
51
+ return `${p.originalAmount} ${p.currency}`
52
+ })
53
+
54
+ // Resolve theme classes from app.config
55
+ const appConfig = useAppConfig()
56
+ const theme = computed(() => (appConfig.ui as any)?.productPrice ?? {})
57
+
58
+ const slotClasses = computed(() => {
59
+ const t = theme.value
60
+ const sizeVariant = t?.variants?.size?.[props.size] ?? {}
61
+ const base = t?.slots ?? {}
62
+ return {
63
+ root: [base.root, sizeVariant.root, props.ui?.root],
64
+ current: [base.current, sizeVariant.current, props.ui?.current],
65
+ original: [base.original, sizeVariant.original, props.ui?.original],
66
+ discount: [base.discount, sizeVariant.discount, props.ui?.discount],
67
+ }
68
+ })
69
+ </script>
70
+
71
+ <template>
72
+ <span :class="slotClasses.root">
73
+ <slot name="current" :price="price" :formatted="price.formatted">
74
+ <span :class="slotClasses.current">{{ price.formatted }}</span>
75
+ </slot>
76
+
77
+ <template v-if="hasDiscount">
78
+ <slot name="original" :original="originalFormatted">
79
+ <span :class="slotClasses.original">{{ originalFormatted }}</span>
80
+ </slot>
81
+
82
+ <slot v-if="showDiscount" name="discount" :percent="discountPercent">
83
+ <span :class="slotClasses.discount">-{{ discountPercent }}%</span>
84
+ </slot>
85
+ </template>
86
+ </span>
87
+ </template>
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ import type { Coupon } from '@commercejs/types'
3
+
4
+ /**
5
+ * CCouponInput — Coupon code input with validation feedback.
6
+ * Used at cart/checkout to apply discount codes.
7
+ */
8
+
9
+ export interface CouponInputProps {
10
+ /** Applied coupon (if already applied) */
11
+ appliedCoupon?: Coupon | null
12
+ /** Whether the coupon is being validated */
13
+ loading?: boolean
14
+ /** Error message */
15
+ error?: string
16
+ /** Per-instance theme overrides */
17
+ ui?: Partial<{
18
+ root: any
19
+ input: any
20
+ applied: any
21
+ }>
22
+ }
23
+
24
+ const props = withDefaults(defineProps<CouponInputProps>(), {
25
+ appliedCoupon: null,
26
+ loading: false,
27
+ error: '',
28
+ })
29
+
30
+ const emit = defineEmits<{
31
+ 'apply': [code: string]
32
+ 'remove': []
33
+ }>()
34
+
35
+ const code = ref('')
36
+
37
+ function t(value: any): string {
38
+ if (!value) return ''
39
+ if (typeof value === 'string') return value
40
+ return value.en || value.ar || Object.values(value)[0] || ''
41
+ }
42
+
43
+ function handleApply() {
44
+ if (code.value.trim()) {
45
+ emit('apply', code.value.trim().toUpperCase())
46
+ }
47
+ }
48
+
49
+ // Resolve theme from app.config
50
+ const appConfig = useAppConfig()
51
+ const theme = computed(() => (appConfig.ui as any)?.couponInput ?? {})
52
+
53
+ const slotClasses = computed(() => {
54
+ const base = theme.value?.slots ?? {}
55
+ const merge = (slot: string) => [base[slot], props.ui?.[slot as keyof typeof props.ui]]
56
+ return {
57
+ root: merge('root'),
58
+ input: merge('input'),
59
+ applied: merge('applied'),
60
+ }
61
+ })
62
+ </script>
63
+
64
+ <template>
65
+ <div :class="['space-y-2', slotClasses.root]">
66
+ <!-- Applied coupon -->
67
+ <div v-if="appliedCoupon" :class="['flex items-center justify-between p-3 rounded-lg bg-success/10 ring ring-success/30', slotClasses.applied]">
68
+ <div class="flex items-center gap-2">
69
+ <UIcon name="i-heroicons-ticket" class="text-success" />
70
+ <span class="font-mono font-semibold text-sm text-highlighted">{{ appliedCoupon.code }}</span>
71
+ <UBadge color="success" size="xs" variant="subtle">
72
+ {{ appliedCoupon.promotion.discountType === 'percentage'
73
+ ? `${appliedCoupon.promotion.discountValue}% off`
74
+ : t(appliedCoupon.promotion.name)
75
+ }}
76
+ </UBadge>
77
+ </div>
78
+ <UButton
79
+ icon="i-heroicons-x-mark-20-solid"
80
+ variant="ghost"
81
+ color="error"
82
+ size="xs"
83
+ @click="emit('remove')"
84
+ />
85
+ </div>
86
+
87
+ <!-- Input form -->
88
+ <form v-else class="flex gap-2" @submit.prevent="handleApply">
89
+ <UInput
90
+ v-model="code"
91
+ :class="['flex-1 font-mono uppercase', slotClasses.input]"
92
+ placeholder="Enter coupon code"
93
+ :disabled="loading"
94
+ :color="error ? 'error' : undefined"
95
+ />
96
+ <UButton type="submit" :loading="loading" variant="outline" color="neutral">
97
+ Apply
98
+ </UButton>
99
+ </form>
100
+
101
+ <!-- Error message -->
102
+ <p v-if="error" class="text-xs text-error">{{ error }}</p>
103
+ </div>
104
+ </template>
@@ -0,0 +1,153 @@
1
+ <script setup lang="ts">
2
+ import type { Promotion } from '@commercejs/types'
3
+
4
+ /**
5
+ * CPromoBanner — Promotional banner with countdown timer and CTA.
6
+ * Used for flash sales, limited-time offers, and campaign banners.
7
+ */
8
+
9
+ export interface PromoBannerProps {
10
+ promotion: Promotion
11
+ /** Banner variant */
12
+ variant?: 'inline' | 'full-width' | 'compact'
13
+ /** Custom background image URL */
14
+ backgroundImage?: string
15
+ /** Per-instance theme overrides */
16
+ ui?: Partial<{
17
+ root: any
18
+ content: any
19
+ timer: any
20
+ cta: any
21
+ }>
22
+ }
23
+
24
+ const props = withDefaults(defineProps<PromoBannerProps>(), {
25
+ variant: 'inline',
26
+ })
27
+
28
+ const emit = defineEmits<{
29
+ 'click': [promotion: Promotion]
30
+ }>()
31
+
32
+ function t(value: any): string {
33
+ if (!value) return ''
34
+ if (typeof value === 'string') return value
35
+ return value.en || value.ar || Object.values(value)[0] || ''
36
+ }
37
+
38
+ // Countdown
39
+ const timeRemaining = ref({ days: 0, hours: 0, mins: 0, secs: 0 })
40
+ const hasEnded = ref(false)
41
+ let timer: ReturnType<typeof setInterval>
42
+
43
+ function updateTimer() {
44
+ if (!props.promotion.endsAt) return
45
+ const diff = new Date(props.promotion.endsAt).getTime() - Date.now()
46
+ if (diff <= 0) {
47
+ hasEnded.value = true
48
+ clearInterval(timer)
49
+ return
50
+ }
51
+ timeRemaining.value = {
52
+ days: Math.floor(diff / 86400000),
53
+ hours: Math.floor((diff % 86400000) / 3600000),
54
+ mins: Math.floor((diff % 3600000) / 60000),
55
+ secs: Math.floor((diff % 60000) / 1000),
56
+ }
57
+ }
58
+
59
+ onMounted(() => {
60
+ updateTimer()
61
+ timer = setInterval(updateTimer, 1000)
62
+ })
63
+
64
+ onUnmounted(() => clearInterval(timer))
65
+
66
+ // Discount display
67
+ const discountDisplay = computed(() => {
68
+ if (props.promotion.discountType === 'percentage') return `${props.promotion.discountValue}% OFF`
69
+ if (props.promotion.discountType === 'fixed_amount') return `${props.promotion.currency || ''} ${props.promotion.discountValue} OFF`
70
+ if (props.promotion.discountType === 'free_shipping') return 'FREE SHIPPING'
71
+ if (props.promotion.discountType === 'buy_x_get_y') return 'BOGO'
72
+ return 'SPECIAL OFFER'
73
+ })
74
+
75
+ // Resolve theme from app.config
76
+ const appConfig = useAppConfig()
77
+ const theme = computed(() => (appConfig.ui as any)?.promoBanner ?? {})
78
+
79
+ const slotClasses = computed(() => {
80
+ const base = theme.value?.slots ?? {}
81
+ const merge = (slot: string) => [base[slot], props.ui?.[slot as keyof typeof props.ui]]
82
+ return {
83
+ root: merge('root'),
84
+ content: merge('content'),
85
+ timer: merge('timer'),
86
+ cta: merge('cta'),
87
+ }
88
+ })
89
+ </script>
90
+
91
+ <template>
92
+ <div
93
+ v-if="promotion.isActive && !hasEnded"
94
+ :class="[
95
+ 'relative overflow-hidden rounded-xl',
96
+ variant === 'full-width' ? 'rounded-none' : '',
97
+ variant === 'compact' ? 'py-2 px-4' : 'py-6 px-6',
98
+ slotClasses.root,
99
+ ]"
100
+ :style="backgroundImage ? { backgroundImage: `url(${backgroundImage})`, backgroundSize: 'cover', backgroundPosition: 'center' } : {}"
101
+ >
102
+ <!-- Gradient overlay for bg images -->
103
+ <div v-if="backgroundImage" class="absolute inset-0 bg-gradient-to-r from-black/80 to-black/40" />
104
+
105
+ <div :class="[
106
+ 'relative z-10 flex items-center justify-between gap-4',
107
+ variant === 'compact' ? 'flex-row' : 'flex-col md:flex-row',
108
+ slotClasses.content,
109
+ ]">
110
+ <!-- Left: Promo info -->
111
+ <div :class="variant === 'compact' ? '' : 'text-center md:text-start'">
112
+ <span :class="[
113
+ 'font-black tracking-tight',
114
+ variant === 'compact' ? 'text-lg' : 'text-2xl md:text-3xl',
115
+ backgroundImage ? 'text-white' : 'text-highlighted',
116
+ ]">
117
+ {{ discountDisplay }}
118
+ </span>
119
+ <p :class="[
120
+ 'text-sm mt-0.5',
121
+ backgroundImage ? 'text-white/80' : 'text-muted',
122
+ ]">
123
+ {{ t(promotion.name) }}
124
+ </p>
125
+ </div>
126
+
127
+ <!-- Center: Countdown -->
128
+ <div v-if="promotion.endsAt" :class="['flex gap-2', slotClasses.timer]">
129
+ <div v-for="(val, label) in { d: timeRemaining.days, h: timeRemaining.hours, m: timeRemaining.mins, s: timeRemaining.secs }" :key="label"
130
+ :class="[
131
+ 'text-center rounded-lg px-2 py-1',
132
+ backgroundImage ? 'bg-white/20 backdrop-blur-sm text-white' : 'bg-elevated text-highlighted',
133
+ ]"
134
+ >
135
+ <span class="text-lg font-bold font-mono block leading-tight">{{ String(val).padStart(2, '0') }}</span>
136
+ <span class="text-[10px] uppercase opacity-70">{{ label }}</span>
137
+ </div>
138
+ </div>
139
+
140
+ <!-- Right: CTA -->
141
+ <slot name="action">
142
+ <UButton
143
+ :color="backgroundImage ? 'white' : 'primary'"
144
+ size="sm"
145
+ :class="slotClasses.cta"
146
+ @click="emit('click', promotion)"
147
+ >
148
+ Shop Now
149
+ </UButton>
150
+ </slot>
151
+ </div>
152
+ </div>
153
+ </template>