@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.
- package/dist/module.cjs +5 -0
- package/dist/module.d.mts +15 -0
- package/dist/module.d.ts +15 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +30 -0
- package/dist/runtime/app.config.d.ts +0 -0
- package/dist/runtime/app.config.js +341 -0
- package/dist/runtime/components/auction/CAuctionCard.vue +213 -0
- package/dist/runtime/components/auction/CBidPanel.vue +176 -0
- package/dist/runtime/components/cart/CCartDrawer.vue +223 -0
- package/dist/runtime/components/cart/CCartItem.vue +136 -0
- package/dist/runtime/components/cart/CCartSummary.vue +127 -0
- package/dist/runtime/components/cart/CQuantitySelector.vue +110 -0
- package/dist/runtime/components/category/CCategoryFilter.vue +123 -0
- package/dist/runtime/components/checkout/CAddressForm.vue +186 -0
- package/dist/runtime/components/checkout/CCheckoutStepper.vue +84 -0
- package/dist/runtime/components/common/CEmptyState.vue +81 -0
- package/dist/runtime/components/common/CProductTypeBadge.vue +37 -0
- package/dist/runtime/components/event/CEventCard.vue +129 -0
- package/dist/runtime/components/gift-card/CGiftCardBalance.vue +119 -0
- package/dist/runtime/components/gift-card/CGiftCardForm.vue +157 -0
- package/dist/runtime/components/gift-card/CGiftCardForm.vue.backup +138 -0
- package/dist/runtime/components/marketing/CHeroBanner.vue +142 -0
- package/dist/runtime/components/navigation/CSearchBar.vue +127 -0
- package/dist/runtime/components/order/COrderCard.vue +117 -0
- package/dist/runtime/components/order/COrderTimeline.vue +99 -0
- package/dist/runtime/components/product/CProductCard.vue +206 -0
- package/dist/runtime/components/product/CProductGallery.vue +110 -0
- package/dist/runtime/components/product/CProductGrid.vue +82 -0
- package/dist/runtime/components/product/CProductOptions.vue +101 -0
- package/dist/runtime/components/product/CProductPrice.vue +87 -0
- package/dist/runtime/components/promotion/CCouponInput.vue +104 -0
- package/dist/runtime/components/promotion/CPromoBanner.vue +153 -0
- package/dist/runtime/components/rental/CRentalBookingForm.vue +214 -0
- package/dist/runtime/components/rental/CRentalCard.vue +146 -0
- package/dist/runtime/components/review/CReviewCard.vue +96 -0
- package/dist/runtime/components/review/CReviewStars.vue +106 -0
- package/dist/runtime/components/subscription/CSubscriptionCard.vue +137 -0
- package/dist/runtime/components/wholesale/CPriceTierTable.vue +88 -0
- package/dist/runtime/components/wholesale/CQuoteRequestForm.vue +148 -0
- package/dist/runtime/components/wishlist/CWishlistGrid.vue +96 -0
- package/dist/types.d.mts +7 -0
- package/dist/types.d.ts +7 -0
- package/package.json +41 -0
- package/src/module.ts +52 -0
- package/src/runtime/app.config.ts +392 -0
- package/src/runtime/components/auction/CAuctionCard.vue +213 -0
- package/src/runtime/components/auction/CBidPanel.vue +176 -0
- package/src/runtime/components/cart/CCartDrawer.vue +223 -0
- package/src/runtime/components/cart/CCartItem.vue +136 -0
- package/src/runtime/components/cart/CCartSummary.vue +127 -0
- package/src/runtime/components/cart/CQuantitySelector.vue +110 -0
- package/src/runtime/components/category/CCategoryFilter.vue +123 -0
- package/src/runtime/components/checkout/CAddressForm.vue +186 -0
- package/src/runtime/components/checkout/CCheckoutStepper.vue +84 -0
- package/src/runtime/components/common/CEmptyState.vue +81 -0
- package/src/runtime/components/common/CProductTypeBadge.vue +37 -0
- package/src/runtime/components/event/CEventCard.vue +129 -0
- package/src/runtime/components/gift-card/CGiftCardBalance.vue +119 -0
- package/src/runtime/components/gift-card/CGiftCardForm.vue +157 -0
- package/src/runtime/components/gift-card/CGiftCardForm.vue.backup +138 -0
- package/src/runtime/components/marketing/CHeroBanner.vue +142 -0
- package/src/runtime/components/navigation/CSearchBar.vue +127 -0
- package/src/runtime/components/order/COrderCard.vue +117 -0
- package/src/runtime/components/order/COrderTimeline.vue +99 -0
- package/src/runtime/components/product/CProductCard.vue +206 -0
- package/src/runtime/components/product/CProductGallery.vue +110 -0
- package/src/runtime/components/product/CProductGrid.vue +82 -0
- package/src/runtime/components/product/CProductOptions.vue +101 -0
- package/src/runtime/components/product/CProductPrice.vue +87 -0
- package/src/runtime/components/promotion/CCouponInput.vue +104 -0
- package/src/runtime/components/promotion/CPromoBanner.vue +153 -0
- package/src/runtime/components/rental/CRentalBookingForm.vue +214 -0
- package/src/runtime/components/rental/CRentalCard.vue +146 -0
- package/src/runtime/components/review/CReviewCard.vue +96 -0
- package/src/runtime/components/review/CReviewStars.vue +106 -0
- package/src/runtime/components/subscription/CSubscriptionCard.vue +137 -0
- package/src/runtime/components/wholesale/CPriceTierTable.vue +88 -0
- package/src/runtime/components/wholesale/CQuoteRequestForm.vue +148 -0
- 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>
|