@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,214 @@
1
+ <script setup lang="ts">
2
+ import type { RentalProductMeta, AvailabilitySlot, CreateRentalBookingInput } from '@commercejs/types'
3
+ import { today, getLocalTimeZone } from '@internationalized/date'
4
+ import type { DateValue } from '@internationalized/date'
5
+
6
+ /**
7
+ * CRentalBookingForm — Date range picker for rentals.
8
+ * Uses Nuxt UI InputDate (range) with Calendar DatePicker for date selection.
9
+ */
10
+
11
+ export interface RentalBookingFormProps {
12
+ /** Rental metadata for the product */
13
+ rental: RentalProductMeta
14
+ /** Available time slots (optional — for availability display) */
15
+ availability?: AvailabilitySlot[]
16
+ /** Product ID */
17
+ productId: string
18
+ /** Whether booking is being submitted */
19
+ loading?: boolean
20
+ /** Per-instance theme overrides */
21
+ ui?: Partial<{
22
+ root: any
23
+ dates: any
24
+ summary: any
25
+ }>
26
+ }
27
+
28
+ const props = withDefaults(defineProps<RentalBookingFormProps>(), {
29
+ availability: () => [],
30
+ loading: false,
31
+ })
32
+
33
+ const emit = defineEmits<{
34
+ 'submit': [input: CreateRentalBookingInput]
35
+ }>()
36
+
37
+ const inputDate = useTemplateRef('inputDate')
38
+
39
+ const dateRange = shallowRef<{ start: DateValue, end: DateValue } | undefined>()
40
+ const quantity = ref(1)
41
+
42
+ const todayDate = today(getLocalTimeZone())
43
+
44
+ const unitLabel = computed(() => {
45
+ const map: Record<string, string> = { hourly: 'hour', daily: 'day', weekly: 'week', monthly: 'month' }
46
+ return map[props.rental.pricingUnit] || props.rental.pricingUnit
47
+ })
48
+
49
+ // Calculate duration between dates
50
+ const duration = computed(() => {
51
+ if (!dateRange.value?.start || !dateRange.value?.end) return 0
52
+
53
+ const s = dateRange.value.start
54
+ const e = dateRange.value.end
55
+ const startMs = new Date(s.year, s.month - 1, s.day).getTime()
56
+ const endMs = new Date(e.year, e.month - 1, e.day).getTime()
57
+ const diffMs = endMs - startMs
58
+ if (diffMs <= 0) return 0
59
+
60
+ const map: Record<string, number> = {
61
+ hourly: 3600000,
62
+ daily: 86400000,
63
+ weekly: 604800000,
64
+ monthly: 2592000000,
65
+ }
66
+ return Math.ceil(diffMs / (map[props.rental.pricingUnit] || 86400000))
67
+ })
68
+
69
+ // Calculate effective price per unit (considering tiers)
70
+ const effectiveUnitPrice = computed(() => {
71
+ if (props.rental.pricingTiers?.length) {
72
+ const sorted = [...props.rental.pricingTiers].sort((a, b) => b.minUnits - a.minUnits)
73
+ const tier = sorted.find(t => duration.value >= t.minUnits)
74
+ return tier?.pricePerUnit || props.rental.pricePerUnit
75
+ }
76
+ return props.rental.pricePerUnit
77
+ })
78
+
79
+ const isValid = computed(() => {
80
+ return duration.value >= props.rental.minDuration
81
+ && (!props.rental.maxDuration || duration.value <= props.rental.maxDuration)
82
+ })
83
+
84
+ // Check availability for dates
85
+ const isDateUnavailable = computed(() => {
86
+ if (!props.availability?.length) return undefined
87
+ const unavailableDates = new Set(
88
+ props.availability
89
+ .filter(s => !s.available)
90
+ .map(s => s.date)
91
+ )
92
+ return (date: DateValue) => {
93
+ const iso = `${date.year}-${String(date.month).padStart(2, '0')}-${String(date.day).padStart(2, '0')}`
94
+ return unavailableDates.has(iso)
95
+ }
96
+ })
97
+
98
+ function toISO(date: DateValue): string {
99
+ return `${date.year}-${String(date.month).padStart(2, '0')}-${String(date.day).padStart(2, '0')}`
100
+ }
101
+
102
+ function handleSubmit() {
103
+ if (!isValid.value || !dateRange.value?.start || !dateRange.value?.end) return
104
+ emit('submit', {
105
+ productId: props.productId,
106
+ startDate: toISO(dateRange.value.start),
107
+ endDate: toISO(dateRange.value.end),
108
+ quantity: quantity.value,
109
+ })
110
+ }
111
+
112
+ // Resolve theme from app.config
113
+ const appConfig = useAppConfig()
114
+ const theme = computed(() => (appConfig.ui as any)?.rentalBookingForm ?? {})
115
+
116
+ const slotClasses = computed(() => {
117
+ const base = theme.value?.slots ?? {}
118
+ const merge = (slot: string) => [base[slot], props.ui?.[slot as keyof typeof props.ui]]
119
+ return {
120
+ root: merge('root'),
121
+ dates: merge('dates'),
122
+ summary: merge('summary'),
123
+ }
124
+ })
125
+ </script>
126
+
127
+ <template>
128
+ <form :class="['space-y-5', slotClasses.root]" @submit.prevent="handleSubmit">
129
+ <!-- Date range picker -->
130
+ <div :class="slotClasses.dates">
131
+ <label class="block text-sm font-medium text-default mb-1">Rental Period</label>
132
+ <UInputDate
133
+ ref="inputDate"
134
+ v-model="dateRange"
135
+ range
136
+ :min-value="todayDate"
137
+ :is-date-unavailable="isDateUnavailable"
138
+ :disabled="loading"
139
+ >
140
+ <template #trailing>
141
+ <UPopover :reference="(inputDate as any)?.inputsRef?.[0]?.$el">
142
+ <UButton
143
+ color="neutral"
144
+ variant="link"
145
+ size="sm"
146
+ icon="i-heroicons-calendar-days"
147
+ aria-label="Select rental dates"
148
+ class="px-0"
149
+ />
150
+
151
+ <template #content>
152
+ <UCalendar
153
+ v-model="dateRange"
154
+ class="p-2"
155
+ :number-of-months="2"
156
+ range
157
+ :min-value="todayDate"
158
+ :is-date-unavailable="isDateUnavailable"
159
+ />
160
+ </template>
161
+ </UPopover>
162
+ </template>
163
+ </UInputDate>
164
+ </div>
165
+
166
+ <!-- Duration & pricing summary -->
167
+ <div v-if="duration > 0" :class="['rounded-xl bg-elevated p-4 space-y-2', slotClasses.summary]">
168
+ <div class="flex justify-between text-sm">
169
+ <span class="text-muted">Duration</span>
170
+ <span class="font-medium text-highlighted">{{ duration }} {{ unitLabel }}{{ duration > 1 ? 's' : '' }}</span>
171
+ </div>
172
+ <div class="flex justify-between text-sm">
173
+ <span class="text-muted">Rate</span>
174
+ <span class="font-medium text-highlighted">{{ effectiveUnitPrice.formatted }} / {{ unitLabel }}</span>
175
+ </div>
176
+ <div v-if="rental.securityDeposit" class="flex justify-between text-sm">
177
+ <span class="text-muted">Security Deposit</span>
178
+ <span class="font-medium text-highlighted">{{ rental.securityDeposit.formatted }}</span>
179
+ </div>
180
+ <USeparator />
181
+ <div class="flex justify-between text-base font-bold">
182
+ <span>Estimated Total</span>
183
+ <span class="text-primary">{{ effectiveUnitPrice.formatted }} × {{ duration }}</span>
184
+ </div>
185
+
186
+ <!-- Validation messages -->
187
+ <UAlert
188
+ v-if="duration < rental.minDuration"
189
+ icon="i-heroicons-exclamation-triangle"
190
+ color="warning"
191
+ :title="`Minimum rental: ${rental.minDuration} ${unitLabel}${rental.minDuration > 1 ? 's' : ''}`"
192
+ size="sm"
193
+ />
194
+ <UAlert
195
+ v-if="rental.maxDuration && duration > rental.maxDuration"
196
+ icon="i-heroicons-exclamation-triangle"
197
+ color="warning"
198
+ :title="`Maximum rental: ${rental.maxDuration} ${unitLabel}${rental.maxDuration > 1 ? 's' : ''}`"
199
+ size="sm"
200
+ />
201
+ </div>
202
+
203
+ <UButton
204
+ type="submit"
205
+ block
206
+ size="lg"
207
+ color="primary"
208
+ :loading="loading"
209
+ :disabled="!isValid || !duration"
210
+ >
211
+ Book Rental
212
+ </UButton>
213
+ </form>
214
+ </template>
@@ -0,0 +1,146 @@
1
+ <script setup lang="ts">
2
+ import type { Product, RentalProductMeta, AvailabilitySlot } from '@commercejs/types'
3
+
4
+ /**
5
+ * CRentalCard — Product card for rental items.
6
+ * Shows pricing per unit, deposit requirement, and availability status.
7
+ */
8
+
9
+ export interface RentalCardProps {
10
+ product: Product
11
+ rental?: RentalProductMeta
12
+ /** Per-instance theme overrides */
13
+ ui?: Partial<{
14
+ root: any
15
+ imageWrapper: any
16
+ image: any
17
+ body: any
18
+ title: any
19
+ pricing: any
20
+ meta: any
21
+ actions: any
22
+ }>
23
+ }
24
+
25
+ const props = defineProps<RentalCardProps>()
26
+
27
+ const emit = defineEmits<{
28
+ 'book': [product: Product]
29
+ }>()
30
+
31
+ const rentalMeta = computed(() => props.rental || props.product.rental)
32
+
33
+ function t(value: any): string {
34
+ if (!value) return ''
35
+ if (typeof value === 'string') return value
36
+ return value.en || value.ar || Object.values(value)[0] || ''
37
+ }
38
+
39
+ const productName = computed(() => t(props.product.name))
40
+ const mainImage = computed(() => props.product.primaryImage || props.product.gallery?.[0])
41
+
42
+ const unitLabel = computed(() => {
43
+ const map: Record<string, string> = {
44
+ hourly: 'hour',
45
+ daily: 'day',
46
+ weekly: 'week',
47
+ monthly: 'month',
48
+ }
49
+ return map[rentalMeta.value?.pricingUnit || ''] || rentalMeta.value?.pricingUnit
50
+ })
51
+
52
+ // Resolve theme from app.config
53
+ const appConfig = useAppConfig()
54
+ const theme = computed(() => (appConfig.ui as any)?.rentalCard ?? {})
55
+
56
+ const slotClasses = computed(() => {
57
+ const base = theme.value?.slots ?? {}
58
+ const merge = (slot: string) => [base[slot], props.ui?.[slot as keyof typeof props.ui]]
59
+ return {
60
+ root: merge('root'),
61
+ imageWrapper: merge('imageWrapper'),
62
+ image: merge('image'),
63
+ body: merge('body'),
64
+ title: merge('title'),
65
+ pricing: merge('pricing'),
66
+ meta: merge('meta'),
67
+ actions: merge('actions'),
68
+ }
69
+ })
70
+ </script>
71
+
72
+ <template>
73
+ <div :class="['group relative rounded-lg overflow-hidden ring ring-default bg-default hover:shadow-lg transition-all duration-200', slotClasses.root]">
74
+ <!-- Image -->
75
+ <div :class="['relative aspect-[4/3] overflow-hidden bg-elevated', slotClasses.imageWrapper]">
76
+ <img
77
+ v-if="mainImage"
78
+ :src="mainImage.url"
79
+ :alt="mainImage.alt || productName"
80
+ :class="['size-full object-cover transition-transform duration-300 group-hover:scale-105', slotClasses.image]"
81
+ />
82
+ <!-- Rental badge -->
83
+ <UBadge color="info" size="sm" class="absolute top-3 start-3">
84
+ <UIcon name="i-heroicons-calendar-days" class="me-0.5" />
85
+ Rental
86
+ </UBadge>
87
+ </div>
88
+
89
+ <!-- Body -->
90
+ <div :class="['p-4 space-y-2', slotClasses.body]">
91
+ <slot name="title" :name="productName">
92
+ <h3 :class="['font-medium text-sm text-highlighted line-clamp-2', slotClasses.title]">{{ productName }}</h3>
93
+ </slot>
94
+
95
+ <!-- Pricing -->
96
+ <slot name="pricing" :rental="rentalMeta">
97
+ <div v-if="rentalMeta" :class="['flex items-baseline gap-1', slotClasses.pricing]">
98
+ <span class="text-lg font-bold text-highlighted">{{ rentalMeta.pricePerUnit.formatted }}</span>
99
+ <span class="text-xs text-muted">/ {{ unitLabel }}</span>
100
+ </div>
101
+ </slot>
102
+
103
+ <!-- Meta info -->
104
+ <slot name="meta" :rental="rentalMeta">
105
+ <div v-if="rentalMeta" :class="['flex flex-wrap gap-2 text-xs', slotClasses.meta]">
106
+ <span v-if="rentalMeta.securityDeposit" class="inline-flex items-center gap-1 text-muted">
107
+ <UIcon name="i-heroicons-shield-check" />
108
+ {{ rentalMeta.securityDeposit.formatted }} deposit
109
+ </span>
110
+ <span class="inline-flex items-center gap-1 text-muted">
111
+ <UIcon name="i-heroicons-clock" />
112
+ Min {{ rentalMeta.minDuration }} {{ unitLabel }}{{ rentalMeta.minDuration > 1 ? 's' : '' }}
113
+ </span>
114
+ <span v-if="rentalMeta.requiresPickup" class="inline-flex items-center gap-1 text-muted">
115
+ <UIcon name="i-heroicons-map-pin" />
116
+ Pickup required
117
+ </span>
118
+ </div>
119
+ </slot>
120
+
121
+ <!-- Tiered pricing -->
122
+ <slot name="tiers" :tiers="rentalMeta?.pricingTiers">
123
+ <div v-if="rentalMeta?.pricingTiers?.length" class="text-xs text-muted space-y-0.5 pt-1">
124
+ <div v-for="(tier, i) in rentalMeta.pricingTiers" :key="i" class="flex justify-between">
125
+ <span>{{ tier.minUnits }}+ {{ unitLabel }}s</span>
126
+ <span class="font-medium text-highlighted">{{ tier.pricePerUnit.formatted }}/{{ unitLabel }}</span>
127
+ </div>
128
+ </div>
129
+ </slot>
130
+
131
+ <!-- Actions -->
132
+ <slot name="actions">
133
+ <UButton
134
+ block
135
+ size="sm"
136
+ color="primary"
137
+ class="mt-2"
138
+ @click="emit('book', product)"
139
+ >
140
+ <UIcon name="i-heroicons-calendar-days-20-solid" class="me-1" />
141
+ Book Now
142
+ </UButton>
143
+ </slot>
144
+ </div>
145
+ </div>
146
+ </template>
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ import type { Review } from '@commercejs/types'
3
+
4
+ /**
5
+ * CReviewCard — Individual product review card.
6
+ * Displays reviewer name, rating, title, body, and verification badge.
7
+ */
8
+
9
+ export interface ReviewCardProps {
10
+ /** Review data from @commercejs/types */
11
+ review: Review
12
+ /** Per-instance theme overrides */
13
+ ui?: Partial<{
14
+ root: any
15
+ header: any
16
+ author: any
17
+ date: any
18
+ title: any
19
+ body: any
20
+ verified: any
21
+ }>
22
+ }
23
+
24
+ const props = defineProps<ReviewCardProps>()
25
+
26
+ const formattedDate = computed(() => {
27
+ try {
28
+ return new Intl.DateTimeFormat(undefined, {
29
+ year: 'numeric',
30
+ month: 'short',
31
+ day: 'numeric',
32
+ }).format(new Date(props.review.createdAt))
33
+ } catch {
34
+ return props.review.createdAt
35
+ }
36
+ })
37
+
38
+ // Resolve theme from app.config
39
+ const appConfig = useAppConfig()
40
+ const theme = computed(() => (appConfig.ui as any)?.reviewCard ?? {})
41
+
42
+ const slotClasses = computed(() => {
43
+ const base = theme.value?.slots ?? {}
44
+ const merge = (slot: string) => [
45
+ base[slot],
46
+ props.ui?.[slot as keyof typeof props.ui],
47
+ ]
48
+ return {
49
+ root: merge('root'),
50
+ header: merge('header'),
51
+ author: merge('author'),
52
+ date: merge('date'),
53
+ title: merge('title'),
54
+ body: merge('body'),
55
+ verified: merge('verified'),
56
+ }
57
+ })
58
+ </script>
59
+
60
+ <template>
61
+ <div :class="slotClasses.root">
62
+ <!-- Header: stars + author + date -->
63
+ <div :class="slotClasses.header">
64
+ <slot name="rating" :rating="review.rating">
65
+ <CReviewStars :model-value="review.rating" size="sm" />
66
+ </slot>
67
+
68
+ <div class="flex items-center gap-2 text-sm">
69
+ <slot name="author" :author="review.authorName">
70
+ <span :class="slotClasses.author">{{ review.authorName }}</span>
71
+ </slot>
72
+
73
+ <slot name="verified" :verified="review.verified">
74
+ <UBadge v-if="review.verified" color="success" variant="subtle" size="xs">
75
+ <UIcon name="i-heroicons-check-badge-20-solid" class="me-0.5" />
76
+ Verified
77
+ </UBadge>
78
+ </slot>
79
+
80
+ <slot name="date" :date="formattedDate">
81
+ <span :class="slotClasses.date">{{ formattedDate }}</span>
82
+ </slot>
83
+ </div>
84
+ </div>
85
+
86
+ <!-- Title -->
87
+ <slot v-if="review.title" name="title" :title="review.title">
88
+ <h4 :class="slotClasses.title">{{ review.title }}</h4>
89
+ </slot>
90
+
91
+ <!-- Body -->
92
+ <slot v-if="review.body" name="body" :body="review.body">
93
+ <p :class="slotClasses.body">{{ review.body }}</p>
94
+ </slot>
95
+ </div>
96
+ </template>
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * CReviewStars — Star rating display (read-only or interactive).
4
+ * Uses Heroicons star icons via UIcon.
5
+ */
6
+
7
+ export interface ReviewStarsProps {
8
+ /** Rating value (0-5, supports half values) */
9
+ modelValue?: number
10
+ /** Maximum stars */
11
+ max?: number
12
+ /** Whether the user can click to rate */
13
+ interactive?: boolean
14
+ /** Size variant */
15
+ size?: 'xs' | 'sm' | 'md' | 'lg'
16
+ /** Per-instance theme overrides */
17
+ ui?: Partial<{
18
+ root: any
19
+ star: any
20
+ starFilled: any
21
+ starEmpty: any
22
+ count: any
23
+ }>
24
+ }
25
+
26
+ const props = withDefaults(defineProps<ReviewStarsProps>(), {
27
+ modelValue: 0,
28
+ max: 5,
29
+ interactive: false,
30
+ size: 'md',
31
+ })
32
+
33
+ const emit = defineEmits<{
34
+ 'update:modelValue': [value: number]
35
+ }>()
36
+
37
+ const hoverValue = ref<number | null>(null)
38
+
39
+ const displayValue = computed(() => hoverValue.value ?? props.modelValue)
40
+
41
+ function handleClick(star: number) {
42
+ if (props.interactive) {
43
+ emit('update:modelValue', star)
44
+ }
45
+ }
46
+
47
+ function handleHover(star: number) {
48
+ if (props.interactive) {
49
+ hoverValue.value = star
50
+ }
51
+ }
52
+
53
+ function handleLeave() {
54
+ hoverValue.value = null
55
+ }
56
+
57
+ const iconSize = computed(() => {
58
+ const map = { xs: 'text-xs', sm: 'text-sm', md: 'text-base', lg: 'text-lg' }
59
+ return map[props.size] || map.md
60
+ })
61
+
62
+ // Resolve theme from app.config
63
+ const appConfig = useAppConfig()
64
+ const theme = computed(() => (appConfig.ui as any)?.reviewStars ?? {})
65
+
66
+ const slotClasses = computed(() => {
67
+ const base = theme.value?.slots ?? {}
68
+ return {
69
+ root: [base.root, props.ui?.root],
70
+ star: [base.star, props.ui?.star],
71
+ starFilled: [base.starFilled, props.ui?.starFilled],
72
+ starEmpty: [base.starEmpty, props.ui?.starEmpty],
73
+ count: [base.count, props.ui?.count],
74
+ }
75
+ })
76
+ </script>
77
+
78
+ <template>
79
+ <div
80
+ :class="['inline-flex items-center gap-0.5', slotClasses.root]"
81
+ :role="interactive ? 'radiogroup' : undefined"
82
+ :aria-label="interactive ? 'Rating' : undefined"
83
+ @mouseleave="handleLeave"
84
+ >
85
+ <button
86
+ v-for="star in max"
87
+ :key="star"
88
+ :class="[
89
+ slotClasses.star,
90
+ iconSize,
91
+ interactive ? 'cursor-pointer' : 'cursor-default',
92
+ ]"
93
+ :disabled="!interactive"
94
+ :aria-label="`${star} star${star > 1 ? 's' : ''}`"
95
+ @click="handleClick(star)"
96
+ @mouseenter="handleHover(star)"
97
+ >
98
+ <UIcon
99
+ :name="star <= displayValue ? 'i-heroicons-star-20-solid' : 'i-heroicons-star'"
100
+ :class="star <= displayValue ? ['text-amber-400', slotClasses.starFilled] : ['text-muted/30', slotClasses.starEmpty]"
101
+ />
102
+ </button>
103
+
104
+ <slot name="count" />
105
+ </div>
106
+ </template>
@@ -0,0 +1,137 @@
1
+ <script setup lang="ts">
2
+ import type { Product, SubscriptionProductMeta } from '@commercejs/types'
3
+
4
+ /**
5
+ * CSubscriptionCard — Product card for subscription products.
6
+ * Shows recurring price, interval, trial info, and subscribe CTA.
7
+ */
8
+
9
+ export interface SubscriptionCardProps {
10
+ product: Product
11
+ subscription?: SubscriptionProductMeta
12
+ /** Highlight as recommended/popular */
13
+ featured?: boolean
14
+ /** Per-instance theme overrides */
15
+ ui?: Partial<{
16
+ root: any
17
+ header: any
18
+ pricing: any
19
+ features: any
20
+ actions: any
21
+ }>
22
+ }
23
+
24
+ const props = withDefaults(defineProps<SubscriptionCardProps>(), {
25
+ featured: false,
26
+ })
27
+
28
+ const emit = defineEmits<{
29
+ 'subscribe': [product: Product]
30
+ }>()
31
+
32
+ const subMeta = computed(() => props.subscription || props.product.subscription)
33
+
34
+ function t(value: any): string {
35
+ if (!value) return ''
36
+ if (typeof value === 'string') return value
37
+ return value.en || value.ar || Object.values(value)[0] || ''
38
+ }
39
+
40
+ const productName = computed(() => t(props.product.name))
41
+
42
+ const intervalLabel = computed(() => {
43
+ if (!subMeta.value) return ''
44
+ const count = subMeta.value.intervalCount
45
+ const map: Record<string, string> = {
46
+ daily: count > 1 ? `${count} days` : 'day',
47
+ weekly: count > 1 ? `${count} weeks` : 'week',
48
+ monthly: count > 1 ? `${count} months` : 'month',
49
+ quarterly: 'quarter',
50
+ yearly: count > 1 ? `${count} years` : 'year',
51
+ }
52
+ return map[subMeta.value.interval] || subMeta.value.interval
53
+ })
54
+
55
+ // Resolve theme from app.config
56
+ const appConfig = useAppConfig()
57
+ const theme = computed(() => (appConfig.ui as any)?.subscriptionCard ?? {})
58
+
59
+ const slotClasses = computed(() => {
60
+ const base = theme.value?.slots ?? {}
61
+ const merge = (slot: string) => [base[slot], props.ui?.[slot as keyof typeof props.ui]]
62
+ return {
63
+ root: merge('root'),
64
+ header: merge('header'),
65
+ pricing: merge('pricing'),
66
+ features: merge('features'),
67
+ actions: merge('actions'),
68
+ }
69
+ })
70
+ </script>
71
+
72
+ <template>
73
+ <div :class="[
74
+ 'relative rounded-xl overflow-hidden ring transition-all duration-200',
75
+ featured ? 'ring-2 ring-primary shadow-lg shadow-primary/10' : 'ring-default',
76
+ 'bg-default hover:shadow-lg',
77
+ slotClasses.root,
78
+ ]">
79
+ <!-- Featured badge -->
80
+ <div v-if="featured" class="bg-primary text-white text-center text-xs font-semibold py-1.5">
81
+ Most Popular
82
+ </div>
83
+
84
+ <div class="p-6 space-y-4">
85
+ <!-- Header -->
86
+ <div :class="['text-center', slotClasses.header]">
87
+ <slot name="title" :name="productName">
88
+ <h3 class="text-lg font-semibold text-highlighted">{{ productName }}</h3>
89
+ </slot>
90
+ <slot name="description">
91
+ <p v-if="product.shortDescription" class="text-sm text-muted mt-1">
92
+ {{ t(product.shortDescription) }}
93
+ </p>
94
+ </slot>
95
+ </div>
96
+
97
+ <!-- Pricing -->
98
+ <div :class="['text-center py-4', slotClasses.pricing]">
99
+ <slot name="pricing" :subscription="subMeta">
100
+ <div v-if="subMeta" class="space-y-1">
101
+ <div class="flex items-baseline justify-center gap-1">
102
+ <span class="text-4xl font-bold text-highlighted">{{ subMeta.recurringPrice.formatted }}</span>
103
+ <span class="text-muted text-sm">/ {{ intervalLabel }}</span>
104
+ </div>
105
+ <p v-if="subMeta.trialDays > 0" class="text-sm text-success font-medium">
106
+ {{ subMeta.trialDays }}-day free trial
107
+ </p>
108
+ </div>
109
+ </slot>
110
+ </div>
111
+
112
+ <!-- Features / description -->
113
+ <slot name="features">
114
+ <div
115
+ v-if="product.description"
116
+ :class="['prose prose-sm max-w-none text-muted', slotClasses.features]"
117
+ v-html="t(product.description)"
118
+ />
119
+ </slot>
120
+
121
+ <!-- Actions -->
122
+ <div :class="slotClasses.actions">
123
+ <slot name="actions">
124
+ <UButton
125
+ block
126
+ size="lg"
127
+ :color="featured ? 'primary' : 'neutral'"
128
+ :variant="featured ? 'solid' : 'outline'"
129
+ @click="emit('subscribe', product)"
130
+ >
131
+ {{ subMeta?.trialDays ? 'Start Free Trial' : 'Subscribe' }}
132
+ </UButton>
133
+ </slot>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </template>