@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,127 @@
1
+ <script setup lang="ts">
2
+ import type { CommandPaletteGroup } from '#ui/types'
3
+
4
+ /**
5
+ * CSearchBar — Modal CommandPalette search (⌘K).
6
+ * Opens a modal with UCommandPalette for fuzzy search,
7
+ * keyboard navigation, grouped results, and highlight matching.
8
+ */
9
+
10
+ export interface SearchSuggestion {
11
+ label: string
12
+ suffix?: string
13
+ to?: string
14
+ icon?: string
15
+ avatar?: Record<string, any>
16
+ value?: string
17
+ onSelect?: (e: Event) => void
18
+ }
19
+
20
+ export interface SearchGroup {
21
+ id: string
22
+ label?: string
23
+ items: SearchSuggestion[]
24
+ /** If true, skip client-side filtering (for server-side search) */
25
+ ignoreFilter?: boolean
26
+ }
27
+
28
+ export interface SearchBarProps {
29
+ /** Placeholder text */
30
+ placeholder?: string
31
+ /** Grouped suggestions */
32
+ groups?: SearchGroup[]
33
+ /** Whether results are loading */
34
+ loading?: boolean
35
+ /** Close modal on select */
36
+ closeOnSelect?: boolean
37
+ /** Per-instance theme overrides */
38
+ ui?: Record<string, any>
39
+ }
40
+
41
+ const props = withDefaults(defineProps<SearchBarProps>(), {
42
+ placeholder: 'Search products...',
43
+ groups: () => [],
44
+ loading: false,
45
+ closeOnSelect: true,
46
+ })
47
+
48
+ const emit = defineEmits<{
49
+ 'search': [query: string]
50
+ 'select': [item: SearchSuggestion]
51
+ }>()
52
+
53
+ const open = ref(false)
54
+ const searchTerm = ref('')
55
+
56
+ // Emit search when user types
57
+ watch(searchTerm, (q) => {
58
+ emit('search', q)
59
+ })
60
+
61
+ function handleSelect(item: SearchSuggestion) {
62
+ emit('select', item)
63
+ if (props.closeOnSelect) {
64
+ open.value = false
65
+ searchTerm.value = ''
66
+ }
67
+ }
68
+
69
+ // ⌘K / Ctrl+K global shortcut
70
+ defineShortcuts({
71
+ meta_k: () => {
72
+ open.value = !open.value
73
+ },
74
+ })
75
+
76
+ // Resolve theme from app.config
77
+ const appConfig = useAppConfig()
78
+ const theme = computed(() => (appConfig.ui as any)?.searchBar ?? {})
79
+
80
+ const paletteUi = computed(() => ({
81
+ ...theme.value?.slots,
82
+ ...props.ui,
83
+ }))
84
+ </script>
85
+
86
+ <template>
87
+ <UModal v-model:open="open" class="w-full">
88
+ <button
89
+ class="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg ring ring-accented bg-default text-dimmed text-sm cursor-pointer hover:bg-elevated/50 transition-colors"
90
+ >
91
+ <UIcon name="i-lucide-search" class="shrink-0 size-5" />
92
+ <span class="flex-1 text-left">Search...</span>
93
+ <span class="flex items-center gap-0.5 ms-auto">
94
+ <UKbd value="meta" />
95
+ <UKbd value="K" />
96
+ </span>
97
+ </button>
98
+
99
+ <!-- Modal content: CommandPalette -->
100
+ <template #content>
101
+ <UCommandPalette
102
+ :groups="groups as CommandPaletteGroup[]"
103
+ :placeholder="placeholder"
104
+ :loading="loading"
105
+ :ui="paletteUi"
106
+ :close="true"
107
+ :fuse="{ fuseOptions: { includeMatches: true } }"
108
+ v-model:search-term="searchTerm"
109
+ class="h-80"
110
+ @update:model-value="handleSelect"
111
+ @update:open="open = $event"
112
+ >
113
+ <template #empty>
114
+ <div class="flex flex-col items-center justify-center py-8 gap-2 text-center">
115
+ <UIcon name="i-lucide-search" class="text-dimmed text-2xl" />
116
+ <p v-if="searchTerm" class="text-sm text-muted">
117
+ No results for "<span class="font-medium text-highlighted">{{ searchTerm }}</span>"
118
+ </p>
119
+ <p v-else class="text-sm text-muted">
120
+ Start typing to search...
121
+ </p>
122
+ </div>
123
+ </template>
124
+ </UCommandPalette>
125
+ </template>
126
+ </UModal>
127
+ </template>
@@ -0,0 +1,117 @@
1
+ <script setup lang="ts">
2
+ import type { Order, OrderStatus } from '@commercejs/types'
3
+
4
+ /**
5
+ * COrderCard — Order summary card for order listing pages.
6
+ * Shows order number, status, date, item count, and total.
7
+ */
8
+
9
+ export interface OrderCardProps {
10
+ order: Order
11
+ /** Per-instance theme overrides */
12
+ ui?: Partial<{
13
+ root: any
14
+ header: any
15
+ items: any
16
+ footer: any
17
+ }>
18
+ }
19
+
20
+ const props = defineProps<OrderCardProps>()
21
+
22
+ function t(value: any): string {
23
+ if (!value) return ''
24
+ if (typeof value === 'string') return value
25
+ return value.en || value.ar || Object.values(value)[0] || ''
26
+ }
27
+
28
+ const statusColor = computed(() => {
29
+ const map: Record<OrderStatus, string> = {
30
+ pending: 'warning',
31
+ processing: 'info',
32
+ shipped: 'primary',
33
+ delivered: 'success',
34
+ cancelled: 'error',
35
+ refunded: 'neutral',
36
+ returned: 'neutral',
37
+ }
38
+ return map[props.order.status] || 'neutral'
39
+ })
40
+
41
+ const statusLabel = computed(() => {
42
+ const map: Record<OrderStatus, string> = {
43
+ pending: 'Pending',
44
+ processing: 'Processing',
45
+ shipped: 'Shipped',
46
+ delivered: 'Delivered',
47
+ cancelled: 'Cancelled',
48
+ refunded: 'Refunded',
49
+ returned: 'Returned',
50
+ }
51
+ return map[props.order.status] || props.order.status
52
+ })
53
+
54
+ const formattedDate = computed(() => new Date(props.order.createdAt).toLocaleDateString())
55
+
56
+ // Resolve theme from app.config
57
+ const appConfig = useAppConfig()
58
+ const theme = computed(() => (appConfig.ui as any)?.orderCard ?? {})
59
+
60
+ const slotClasses = computed(() => {
61
+ const base = theme.value?.slots ?? {}
62
+ const merge = (slot: string) => [base[slot], props.ui?.[slot as keyof typeof props.ui]]
63
+ return {
64
+ root: merge('root'),
65
+ header: merge('header'),
66
+ items: merge('items'),
67
+ footer: merge('footer'),
68
+ }
69
+ })
70
+ </script>
71
+
72
+ <template>
73
+ <div :class="['rounded-xl ring ring-default bg-default overflow-hidden', slotClasses.root]">
74
+ <!-- Header -->
75
+ <div :class="['flex items-center justify-between px-5 py-3 bg-elevated', slotClasses.header]">
76
+ <div>
77
+ <span class="text-sm font-medium text-highlighted">#{{ order.orderNumber }}</span>
78
+ <span class="text-xs text-muted ms-2">{{ formattedDate }}</span>
79
+ </div>
80
+ <UBadge :color="statusColor as any" size="sm" variant="subtle">{{ statusLabel }}</UBadge>
81
+ </div>
82
+
83
+ <!-- Item thumbnails -->
84
+ <div :class="['px-5 py-4', slotClasses.items]">
85
+ <slot name="items" :items="order.items">
86
+ <div class="flex items-center gap-3">
87
+ <div class="flex -space-x-2">
88
+ <div
89
+ v-for="(item, i) in order.items.slice(0, 4)"
90
+ :key="item.id"
91
+ class="size-10 rounded-lg ring-2 ring-default overflow-hidden bg-elevated shrink-0"
92
+ >
93
+ <img v-if="item.image" :src="item.image.url" :alt="t(item.name)" class="size-full object-cover" />
94
+ <div v-else class="size-full flex items-center justify-center text-xs text-muted">
95
+ {{ t(item.name).charAt(0) }}
96
+ </div>
97
+ </div>
98
+ </div>
99
+ <span class="text-sm text-muted">
100
+ {{ order.items.length }} item{{ order.items.length !== 1 ? 's' : '' }}
101
+ <span v-if="order.items.length > 4" class="text-xs">(+{{ order.items.length - 4 }} more)</span>
102
+ </span>
103
+ </div>
104
+ </slot>
105
+ </div>
106
+
107
+ <!-- Footer -->
108
+ <div :class="['flex items-center justify-between px-5 py-3 bg-elevated', slotClasses.footer]">
109
+ <span class="text-sm font-semibold text-highlighted">{{ order.totals.total?.formatted }}</span>
110
+ <slot name="actions">
111
+ <UButton :to="`/orders/${order.id}`" size="xs" variant="outline" color="neutral" trailing-icon="i-heroicons-chevron-right-20-solid">
112
+ View Details
113
+ </UButton>
114
+ </slot>
115
+ </div>
116
+ </div>
117
+ </template>
@@ -0,0 +1,99 @@
1
+ <script setup lang="ts">
2
+ import type { OrderHistoryEntry } from '@commercejs/types'
3
+
4
+ /**
5
+ * COrderTimeline — Displays order status history as a timeline.
6
+ */
7
+
8
+ export interface OrderTimelineProps {
9
+ entries: OrderHistoryEntry[]
10
+ /** Per-instance theme overrides */
11
+ ui?: Partial<{
12
+ root: any
13
+ entry: any
14
+ dot: any
15
+ line: any
16
+ }>
17
+ }
18
+
19
+ const props = defineProps<OrderTimelineProps>()
20
+
21
+ const statusIcon = computed(() => (status: string) => {
22
+ const map: Record<string, string> = {
23
+ pending: 'i-heroicons-clock',
24
+ processing: 'i-heroicons-cog-6-tooth',
25
+ shipped: 'i-heroicons-truck',
26
+ delivered: 'i-heroicons-check-circle',
27
+ cancelled: 'i-heroicons-x-circle',
28
+ refunded: 'i-heroicons-arrow-uturn-left',
29
+ returned: 'i-heroicons-arrow-path',
30
+ }
31
+ return map[status] || 'i-heroicons-ellipsis-horizontal-circle'
32
+ })
33
+
34
+ const statusColor = computed(() => (status: string) => {
35
+ const map: Record<string, string> = {
36
+ pending: 'text-warning',
37
+ processing: 'text-info',
38
+ shipped: 'text-primary',
39
+ delivered: 'text-success',
40
+ cancelled: 'text-error',
41
+ refunded: 'text-muted',
42
+ returned: 'text-muted',
43
+ }
44
+ return map[status] || 'text-muted'
45
+ })
46
+
47
+ // Resolve theme from app.config
48
+ const appConfig = useAppConfig()
49
+ const theme = computed(() => (appConfig.ui as any)?.orderTimeline ?? {})
50
+
51
+ const slotClasses = computed(() => {
52
+ const base = theme.value?.slots ?? {}
53
+ const merge = (slot: string) => [base[slot], props.ui?.[slot as keyof typeof props.ui]]
54
+ return {
55
+ root: merge('root'),
56
+ entry: merge('entry'),
57
+ dot: merge('dot'),
58
+ line: merge('line'),
59
+ }
60
+ })
61
+ </script>
62
+
63
+ <template>
64
+ <div :class="['relative', slotClasses.root]">
65
+ <div
66
+ v-for="(entry, i) in entries"
67
+ :key="entry.timestamp || i"
68
+ :class="['flex gap-4 pb-6 last:pb-0', slotClasses.entry]"
69
+ >
70
+ <!-- Timeline dot & line -->
71
+ <div class="flex flex-col items-center">
72
+ <div
73
+ :class="[
74
+ 'size-8 rounded-full flex items-center justify-center ring-4 ring-default bg-default z-10',
75
+ statusColor(entry.status),
76
+ slotClasses.dot,
77
+ ]"
78
+ >
79
+ <UIcon :name="statusIcon(entry.status)" class="text-lg" />
80
+ </div>
81
+ <div
82
+ v-if="i < entries.length - 1"
83
+ :class="['w-0.5 flex-1 bg-default mt-1', slotClasses.line]"
84
+ />
85
+ </div>
86
+
87
+ <!-- Content -->
88
+ <div class="flex-1 pt-1 min-w-0">
89
+ <div class="flex items-center justify-between gap-2">
90
+ <span class="font-medium text-sm text-highlighted capitalize">{{ entry.status.replace(/_/g, ' ') }}</span>
91
+ <time class="text-xs text-muted shrink-0">
92
+ {{ new Date(entry.timestamp).toLocaleString() }}
93
+ </time>
94
+ </div>
95
+ <p v-if="entry.note" class="text-sm text-muted mt-0.5">{{ entry.note }}</p>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </template>
@@ -0,0 +1,206 @@
1
+ <script setup lang="ts">
2
+ import type { Product, LocalizedString } from '@commercejs/types'
3
+
4
+ /**
5
+ * CProductCard — Ecommerce product card.
6
+ * Follows Nuxt UI conventions: as prop, ui prop, slot-based theming, variants.
7
+ */
8
+
9
+ export interface ProductCardProps {
10
+ /** The element or component this component should render as */
11
+ as?: any
12
+ /** Product data from @commercejs/types */
13
+ product: Product
14
+ /** Visual variant */
15
+ variant?: 'outline' | 'soft' | 'ghost'
16
+ /** Size variant */
17
+ size?: 'sm' | 'md' | 'lg'
18
+ /** Show quick-add button on hover */
19
+ showQuickAdd?: boolean
20
+ /** Show wishlist button */
21
+ showWishlist?: boolean
22
+ /** Show star rating */
23
+ showRating?: boolean
24
+ /** Image aspect ratio class */
25
+ imageAspect?: string
26
+ /** Per-instance theme overrides */
27
+ ui?: Partial<{
28
+ root: any
29
+ imageWrapper: any
30
+ image: any
31
+ badge: any
32
+ overlay: any
33
+ body: any
34
+ title: any
35
+ price: any
36
+ originalPrice: any
37
+ priceWrapper: any
38
+ rating: any
39
+ }>
40
+ }
41
+
42
+ const props = withDefaults(defineProps<ProductCardProps>(), {
43
+ variant: 'outline',
44
+ size: 'md',
45
+ showQuickAdd: true,
46
+ showWishlist: false,
47
+ showRating: false,
48
+ imageAspect: 'aspect-square',
49
+ })
50
+
51
+ const emit = defineEmits<{
52
+ 'add-to-cart': [product: Product]
53
+ 'toggle-wishlist': [product: Product]
54
+ }>()
55
+
56
+ // Resolve localized strings — first try current locale, fallback chain
57
+ function t(value: LocalizedString | string | null | undefined): string {
58
+ if (!value) return ''
59
+ if (typeof value === 'string') return value
60
+ // Simple fallback: en → ar → first available
61
+ return value.en || value.ar || Object.values(value)[0] || ''
62
+ }
63
+
64
+ const productName = computed(() => t(props.product.name))
65
+ const mainImage = computed(() => props.product.primaryImage || props.product.gallery?.[0])
66
+
67
+ const hasDiscount = computed(() => {
68
+ const p = props.product.price
69
+ return p?.originalAmount != null && p.originalAmount > p.amount
70
+ })
71
+
72
+ const discountPercent = computed(() => {
73
+ const p = props.product.price
74
+ if (!p) return 0
75
+ if (p.discountPercent) return p.discountPercent
76
+ if (p.originalAmount && p.originalAmount > p.amount) {
77
+ return Math.round(((p.originalAmount - p.amount) / p.originalAmount) * 100)
78
+ }
79
+ return 0
80
+ })
81
+
82
+ // Resolve theme classes from app.config
83
+ const appConfig = useAppConfig()
84
+ const theme = computed(() => (appConfig.ui as any)?.productCard ?? {})
85
+
86
+ const slotClasses = computed(() => {
87
+ const t = theme.value
88
+ const variantStyles = t?.variants?.variant?.[props.variant] ?? {}
89
+ const sizeStyles = t?.variants?.size?.[props.size] ?? {}
90
+ const base = t?.slots ?? {}
91
+
92
+ // Merge: base → variant → size → instance ui prop
93
+ const merge = (slot: string) => [
94
+ base[slot],
95
+ variantStyles[slot],
96
+ sizeStyles[slot],
97
+ props.ui?.[slot as keyof typeof props.ui],
98
+ ]
99
+
100
+ return {
101
+ root: merge('root'),
102
+ imageWrapper: merge('imageWrapper'),
103
+ image: merge('image'),
104
+ badge: merge('badge'),
105
+ overlay: merge('overlay'),
106
+ body: merge('body'),
107
+ title: merge('title'),
108
+ price: merge('price'),
109
+ originalPrice: merge('originalPrice'),
110
+ priceWrapper: merge('priceWrapper'),
111
+ rating: merge('rating'),
112
+ }
113
+ })
114
+
115
+ const productUrl = computed(() => `/products/${props.product.slug || props.product.id}`)
116
+ const rootTag = computed(() => props.as || resolveComponent('NuxtLink'))
117
+ </script>
118
+
119
+ <template>
120
+ <component
121
+ :is="rootTag"
122
+ :to="rootTag !== 'div' ? productUrl : undefined"
123
+ :class="slotClasses.root"
124
+ >
125
+ <!-- Image -->
126
+ <div :class="[slotClasses.imageWrapper, imageAspect]">
127
+ <slot name="image" :product="product" :image="mainImage">
128
+ <img
129
+ v-if="mainImage"
130
+ :src="mainImage.url"
131
+ :alt="mainImage.alt || productName"
132
+ :class="slotClasses.image"
133
+ loading="lazy"
134
+ />
135
+ <div v-else class="size-full bg-elevated flex items-center justify-center">
136
+ <UIcon name="i-heroicons-photo" class="text-3xl text-muted" />
137
+ </div>
138
+ </slot>
139
+
140
+ <!-- Discount badge -->
141
+ <slot name="badge" :discount="discountPercent" :has-discount="hasDiscount">
142
+ <UBadge
143
+ v-if="hasDiscount"
144
+ color="error"
145
+ variant="solid"
146
+ size="sm"
147
+ :class="slotClasses.badge"
148
+ >
149
+ -{{ discountPercent }}%
150
+ </UBadge>
151
+ </slot>
152
+
153
+ <!-- Hover overlay (quick add, wishlist) -->
154
+ <div v-if="showQuickAdd || showWishlist" :class="slotClasses.overlay">
155
+ <slot name="actions" :product="product">
156
+ <UButton
157
+ v-if="showWishlist"
158
+ icon="i-heroicons-heart"
159
+ variant="soft"
160
+ color="neutral"
161
+ size="sm"
162
+ class="me-2"
163
+ @click.prevent="emit('toggle-wishlist', product)"
164
+ />
165
+ <UButton
166
+ v-if="showQuickAdd"
167
+ icon="i-heroicons-shopping-cart-20-solid"
168
+ variant="solid"
169
+ color="primary"
170
+ size="sm"
171
+ @click.prevent="emit('add-to-cart', product)"
172
+ />
173
+ </slot>
174
+ </div>
175
+ </div>
176
+
177
+ <!-- Body -->
178
+ <div :class="slotClasses.body">
179
+ <slot name="title" :name="productName">
180
+ <h3 :class="slotClasses.title">{{ productName }}</h3>
181
+ </slot>
182
+
183
+ <!-- Rating -->
184
+ <slot v-if="showRating && product.rating" name="rating" :rating="product.rating">
185
+ <div :class="slotClasses.rating">
186
+ <UIcon name="i-heroicons-star-20-solid" class="text-yellow-400" />
187
+ <span>{{ product.rating.average.toFixed(1) }}</span>
188
+ <span class="text-muted">({{ product.rating.count }})</span>
189
+ </div>
190
+ </slot>
191
+
192
+ <!-- Price -->
193
+ <slot name="price" :price="product.price">
194
+ <div v-if="product.price" :class="slotClasses.priceWrapper">
195
+ <span :class="slotClasses.price">{{ product.price.formatted }}</span>
196
+ <span
197
+ v-if="hasDiscount && product.price.originalAmount"
198
+ :class="slotClasses.originalPrice"
199
+ >
200
+ {{ product.price.originalAmount }} {{ product.price.currency }}
201
+ </span>
202
+ </div>
203
+ </slot>
204
+ </div>
205
+ </component>
206
+ </template>
@@ -0,0 +1,110 @@
1
+ <script setup lang="ts">
2
+ import type { Image } from '@commercejs/types'
3
+
4
+ /**
5
+ * CProductGallery — Product image gallery with thumbnails and main image.
6
+ * Uses Nuxt UI's UCarousel when available, falls back to manual implementation.
7
+ */
8
+
9
+ export interface ProductGalleryProps {
10
+ /** Array of product images */
11
+ images: Image[]
12
+ /** Selected image index */
13
+ modelValue?: number
14
+ /** Show thumbnail strip */
15
+ showThumbnails?: boolean
16
+ /** Thumbnail position */
17
+ thumbnailPosition?: 'bottom' | 'start'
18
+ /** Enable zoom on hover */
19
+ zoomable?: boolean
20
+ /** Per-instance theme overrides */
21
+ ui?: Partial<{
22
+ root: any
23
+ main: any
24
+ mainImage: any
25
+ thumbnails: any
26
+ thumbnail: any
27
+ thumbnailActive: any
28
+ }>
29
+ }
30
+
31
+ const props = withDefaults(defineProps<ProductGalleryProps>(), {
32
+ modelValue: 0,
33
+ showThumbnails: true,
34
+ thumbnailPosition: 'bottom',
35
+ zoomable: false,
36
+ })
37
+
38
+ const emit = defineEmits<{
39
+ 'update:modelValue': [index: number]
40
+ }>()
41
+
42
+ const selectedIndex = ref(props.modelValue)
43
+
44
+ watch(() => props.modelValue, (v) => { selectedIndex.value = v })
45
+
46
+ function selectImage(index: number) {
47
+ selectedIndex.value = index
48
+ emit('update:modelValue', index)
49
+ }
50
+
51
+ const currentImage = computed(() => props.images[selectedIndex.value] || props.images[0])
52
+
53
+ // Resolve theme from app.config
54
+ const appConfig = useAppConfig()
55
+ const theme = computed(() => (appConfig.ui as any)?.productGallery ?? {})
56
+
57
+ const slotClasses = computed(() => {
58
+ const base = theme.value?.slots ?? {}
59
+ const positionStyles = theme.value?.variants?.thumbnailPosition?.[props.thumbnailPosition] ?? {}
60
+ const merge = (slot: string) => [
61
+ base[slot],
62
+ positionStyles[slot],
63
+ props.ui?.[slot as keyof typeof props.ui],
64
+ ]
65
+ return {
66
+ root: merge('root'),
67
+ main: merge('main'),
68
+ mainImage: merge('mainImage'),
69
+ thumbnails: merge('thumbnails'),
70
+ thumbnail: merge('thumbnail'),
71
+ thumbnailActive: merge('thumbnailActive'),
72
+ }
73
+ })
74
+ </script>
75
+
76
+ <template>
77
+ <div :class="slotClasses.root">
78
+ <!-- Main image -->
79
+ <slot name="main" :image="currentImage" :index="selectedIndex">
80
+ <div :class="slotClasses.main">
81
+ <img
82
+ v-if="currentImage"
83
+ :src="currentImage.url"
84
+ :alt="currentImage.alt"
85
+ :class="slotClasses.mainImage"
86
+ />
87
+ <div v-else class="size-full bg-elevated flex items-center justify-center">
88
+ <UIcon name="i-heroicons-photo" class="text-5xl text-muted" />
89
+ </div>
90
+ </div>
91
+ </slot>
92
+
93
+ <!-- Thumbnails -->
94
+ <slot v-if="showThumbnails && images.length > 1" name="thumbnails" :images="images" :selected="selectedIndex" :select="selectImage">
95
+ <div :class="slotClasses.thumbnails">
96
+ <button
97
+ v-for="(img, i) in images"
98
+ :key="i"
99
+ :class="[
100
+ slotClasses.thumbnail,
101
+ selectedIndex === i ? slotClasses.thumbnailActive : ''
102
+ ]"
103
+ @click="selectImage(i)"
104
+ >
105
+ <img :src="img.url" :alt="img.alt" class="size-full object-cover" />
106
+ </button>
107
+ </div>
108
+ </slot>
109
+ </div>
110
+ </template>