@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,176 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Bid, AuctionProductMeta, PlaceBidInput } from '@commercejs/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CBidPanel — Bid placement panel for auction products.
|
|
6
|
+
* Shows current bid, bid input, auto-bidding toggle, and bid history.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface BidPanelProps {
|
|
10
|
+
/** Auction metadata */
|
|
11
|
+
auction: AuctionProductMeta
|
|
12
|
+
/** Recent bids */
|
|
13
|
+
bids?: Bid[]
|
|
14
|
+
/** Whether a bid is being submitted */
|
|
15
|
+
loading?: boolean
|
|
16
|
+
/** Currency symbol for display */
|
|
17
|
+
currencySymbol?: string
|
|
18
|
+
/** Per-instance theme overrides */
|
|
19
|
+
ui?: Partial<{
|
|
20
|
+
root: any
|
|
21
|
+
currentBid: any
|
|
22
|
+
form: any
|
|
23
|
+
history: any
|
|
24
|
+
}>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const props = withDefaults(defineProps<BidPanelProps>(), {
|
|
28
|
+
bids: () => [],
|
|
29
|
+
loading: false,
|
|
30
|
+
currencySymbol: '',
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{
|
|
34
|
+
'place-bid': [input: PlaceBidInput]
|
|
35
|
+
}>()
|
|
36
|
+
|
|
37
|
+
const bidAmount = ref<number>(0)
|
|
38
|
+
const enableAutoBid = ref(false)
|
|
39
|
+
const maxAutoBidAmount = ref<number>(0)
|
|
40
|
+
|
|
41
|
+
// Set default bid amount to current bid + increment
|
|
42
|
+
watchEffect(() => {
|
|
43
|
+
const current = props.auction.currentBid?.amount ?? props.auction.startingPrice.amount
|
|
44
|
+
const increment = props.auction.bidIncrement.amount
|
|
45
|
+
if (typeof current === 'number' && typeof increment === 'number') {
|
|
46
|
+
bidAmount.value = current + increment
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const minBid = computed(() => {
|
|
51
|
+
const current = props.auction.currentBid?.amount ?? props.auction.startingPrice.amount
|
|
52
|
+
const increment = props.auction.bidIncrement.amount
|
|
53
|
+
return typeof current === 'number' && typeof increment === 'number'
|
|
54
|
+
? current + increment
|
|
55
|
+
: 0
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
function handleSubmit() {
|
|
59
|
+
emit('place-bid', {
|
|
60
|
+
productId: '', // Set by parent
|
|
61
|
+
amount: bidAmount.value,
|
|
62
|
+
maxAutoBid: enableAutoBid.value ? maxAutoBidAmount.value : undefined,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Resolve theme from app.config
|
|
67
|
+
const appConfig = useAppConfig()
|
|
68
|
+
const theme = computed(() => (appConfig.ui as any)?.bidPanel ?? {})
|
|
69
|
+
|
|
70
|
+
const slotClasses = computed(() => {
|
|
71
|
+
const base = theme.value?.slots ?? {}
|
|
72
|
+
const merge = (slot: string) => [base[slot], props.ui?.[slot as keyof typeof props.ui]]
|
|
73
|
+
return {
|
|
74
|
+
root: merge('root'),
|
|
75
|
+
currentBid: merge('currentBid'),
|
|
76
|
+
form: merge('form'),
|
|
77
|
+
history: merge('history'),
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<template>
|
|
83
|
+
<div :class="['space-y-6', slotClasses.root]">
|
|
84
|
+
<!-- Current bid display -->
|
|
85
|
+
<div :class="['text-center p-6 rounded-xl bg-elevated ring ring-default', slotClasses.currentBid]">
|
|
86
|
+
<p class="text-sm text-muted mb-1">{{ auction.bidCount > 0 ? 'Current Highest Bid' : 'Starting Price' }}</p>
|
|
87
|
+
<p class="text-4xl font-bold text-highlighted">
|
|
88
|
+
{{ auction.currentBid?.formatted || auction.startingPrice.formatted }}
|
|
89
|
+
</p>
|
|
90
|
+
<p class="text-xs text-muted mt-2">
|
|
91
|
+
{{ auction.bidCount }} bid{{ auction.bidCount !== 1 ? 's' : '' }}
|
|
92
|
+
· Min increment: {{ auction.bidIncrement.formatted }}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<!-- Bid form -->
|
|
97
|
+
<form v-if="auction.status === 'active'" :class="['space-y-4', slotClasses.form]" @submit.prevent="handleSubmit">
|
|
98
|
+
<UFormField label="Your Bid">
|
|
99
|
+
<UInput
|
|
100
|
+
v-model.number="bidAmount"
|
|
101
|
+
type="number"
|
|
102
|
+
:min="minBid"
|
|
103
|
+
:step="auction.bidIncrement.amount"
|
|
104
|
+
:placeholder="`Min: ${minBid}`"
|
|
105
|
+
size="lg"
|
|
106
|
+
required
|
|
107
|
+
/>
|
|
108
|
+
</UFormField>
|
|
109
|
+
|
|
110
|
+
<!-- Auto-bidding toggle -->
|
|
111
|
+
<div v-if="auction.autoBiddingEnabled" class="space-y-2">
|
|
112
|
+
<UCheckbox v-model="enableAutoBid" label="Enable auto-bidding (proxy bid)" />
|
|
113
|
+
<UFormField v-if="enableAutoBid" label="Maximum Auto-Bid">
|
|
114
|
+
<UInput
|
|
115
|
+
v-model.number="maxAutoBidAmount"
|
|
116
|
+
type="number"
|
|
117
|
+
:min="bidAmount"
|
|
118
|
+
placeholder="Your maximum amount"
|
|
119
|
+
/>
|
|
120
|
+
<template #hint>
|
|
121
|
+
<span class="text-xs text-muted">We'll bid on your behalf up to this amount</span>
|
|
122
|
+
</template>
|
|
123
|
+
</UFormField>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<UButton type="submit" block size="lg" color="primary" :loading="loading">
|
|
127
|
+
Place Bid — {{ currencySymbol }}{{ bidAmount }}
|
|
128
|
+
</UButton>
|
|
129
|
+
|
|
130
|
+
<!-- Buy it now -->
|
|
131
|
+
<UButton
|
|
132
|
+
v-if="auction.buyNowPrice"
|
|
133
|
+
block
|
|
134
|
+
variant="outline"
|
|
135
|
+
color="neutral"
|
|
136
|
+
size="lg"
|
|
137
|
+
>
|
|
138
|
+
Buy It Now — {{ auction.buyNowPrice.formatted }}
|
|
139
|
+
</UButton>
|
|
140
|
+
</form>
|
|
141
|
+
|
|
142
|
+
<!-- Bid status messages -->
|
|
143
|
+
<UAlert
|
|
144
|
+
v-else-if="auction.status === 'ended'"
|
|
145
|
+
icon="i-heroicons-clock"
|
|
146
|
+
title="Auction ended"
|
|
147
|
+
color="neutral"
|
|
148
|
+
/>
|
|
149
|
+
<UAlert
|
|
150
|
+
v-else-if="auction.status === 'upcoming'"
|
|
151
|
+
icon="i-heroicons-calendar"
|
|
152
|
+
title="Auction hasn't started yet"
|
|
153
|
+
color="info"
|
|
154
|
+
/>
|
|
155
|
+
|
|
156
|
+
<!-- Recent bid history -->
|
|
157
|
+
<div v-if="bids.length > 0" :class="['space-y-2', slotClasses.history]">
|
|
158
|
+
<h4 class="text-sm font-semibold text-highlighted">Recent Bids</h4>
|
|
159
|
+
<div class="space-y-1 max-h-48 overflow-y-auto">
|
|
160
|
+
<div
|
|
161
|
+
v-for="bid in bids"
|
|
162
|
+
:key="bid.id"
|
|
163
|
+
class="flex items-center justify-between text-sm py-1.5 px-2 rounded-md"
|
|
164
|
+
:class="bid.isWinning ? 'bg-success/10' : 'bg-elevated'"
|
|
165
|
+
>
|
|
166
|
+
<div class="flex items-center gap-2">
|
|
167
|
+
<UIcon v-if="bid.isWinning" name="i-heroicons-trophy-20-solid" class="text-success text-xs" />
|
|
168
|
+
<span class="text-highlighted">{{ bid.bidderName }}</span>
|
|
169
|
+
<UBadge v-if="bid.isAutoBid" size="xs" variant="subtle" color="info">Auto</UBadge>
|
|
170
|
+
</div>
|
|
171
|
+
<span class="font-medium text-highlighted">{{ bid.amount.formatted }}</span>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</template>
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Cart, CartItem, LocalizedString } from '@commercejs/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CCartDrawer — Slide-over panel showing cart contents.
|
|
6
|
+
* Opens from the right, displays compact cart items, subtotal, and action buttons.
|
|
7
|
+
* Designed to be triggered by `onItemAdded` from the `useCart` composable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface CartDrawerProps {
|
|
11
|
+
/** Cart data */
|
|
12
|
+
cart: Cart | null
|
|
13
|
+
/** Cart line items (overrides cart.items if provided) */
|
|
14
|
+
items?: CartItem[]
|
|
15
|
+
/** Number of items in cart (overrides cart.itemCount if provided) */
|
|
16
|
+
itemCount?: number
|
|
17
|
+
/** Whether a cart operation is loading */
|
|
18
|
+
loading?: boolean
|
|
19
|
+
/** Title shown in the drawer header */
|
|
20
|
+
title?: string
|
|
21
|
+
/** Label for the "Checkout" button */
|
|
22
|
+
checkoutLabel?: string
|
|
23
|
+
/** Route for the "Checkout" button */
|
|
24
|
+
checkoutTo?: string
|
|
25
|
+
/** Label for the "View Cart" button */
|
|
26
|
+
viewCartLabel?: string
|
|
27
|
+
/** Route for the "View Cart" button */
|
|
28
|
+
viewCartTo?: string
|
|
29
|
+
/** Function to resolve the product URL from a cart item. Defaults to `/products/{productSlug || productId}` */
|
|
30
|
+
resolveProductUrl?: (item: CartItem) => string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const props = withDefaults(defineProps<CartDrawerProps>(), {
|
|
34
|
+
loading: false,
|
|
35
|
+
title: 'Your Cart',
|
|
36
|
+
checkoutLabel: 'Checkout',
|
|
37
|
+
checkoutTo: '/checkout',
|
|
38
|
+
viewCartLabel: 'View Full Cart',
|
|
39
|
+
viewCartTo: '/cart',
|
|
40
|
+
resolveProductUrl: (item: CartItem) => `/products/${item.productSlug || item.productId}`,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const emit = defineEmits<{
|
|
44
|
+
'update:quantity': [itemId: string, quantity: number]
|
|
45
|
+
'remove': [itemId: string]
|
|
46
|
+
}>()
|
|
47
|
+
|
|
48
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
49
|
+
|
|
50
|
+
function t(value: LocalizedString | string | null | undefined): string {
|
|
51
|
+
if (!value) return ''
|
|
52
|
+
if (typeof value === 'string') return value
|
|
53
|
+
return value.en || value.ar || Object.values(value)[0] || ''
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cartItems = computed(() => {
|
|
57
|
+
if (props.items) return props.items
|
|
58
|
+
return props.cart?.items ?? []
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const count = computed(() => {
|
|
62
|
+
if (props.itemCount != null) return props.itemCount
|
|
63
|
+
return props.cart?.itemCount ?? 0
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const subtotalFormatted = computed(() => {
|
|
67
|
+
return props.cart?.totals?.subtotal?.formatted ?? ''
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
function handleClose() {
|
|
71
|
+
open.value = false
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<USlideover
|
|
77
|
+
v-model:open="open"
|
|
78
|
+
:title="title"
|
|
79
|
+
side="right"
|
|
80
|
+
:ui="{
|
|
81
|
+
content: 'max-w-sm overflow-hidden',
|
|
82
|
+
body: 'flex flex-col p-0 overflow-hidden',
|
|
83
|
+
footer: 'p-4',
|
|
84
|
+
}"
|
|
85
|
+
>
|
|
86
|
+
<!-- Hidden default slot (controlled externally via v-model:open) -->
|
|
87
|
+
<template #default>
|
|
88
|
+
<slot name="trigger" />
|
|
89
|
+
</template>
|
|
90
|
+
|
|
91
|
+
<!-- Body: cart items list -->
|
|
92
|
+
<template #body>
|
|
93
|
+
<!-- Empty state -->
|
|
94
|
+
<div
|
|
95
|
+
v-if="cartItems.length === 0"
|
|
96
|
+
class="flex-1 flex flex-col items-center justify-center text-center py-16 px-6 gap-3"
|
|
97
|
+
>
|
|
98
|
+
<div class="w-16 h-16 rounded-full bg-elevated flex items-center justify-center mb-2">
|
|
99
|
+
<UIcon name="i-heroicons-shopping-cart" class="text-2xl text-muted" />
|
|
100
|
+
</div>
|
|
101
|
+
<p class="text-sm font-medium text-highlighted">Your cart is empty</p>
|
|
102
|
+
<p class="text-xs text-muted">Add items to get started</p>
|
|
103
|
+
<UButton
|
|
104
|
+
to="/products"
|
|
105
|
+
size="sm"
|
|
106
|
+
variant="soft"
|
|
107
|
+
color="primary"
|
|
108
|
+
class="mt-2"
|
|
109
|
+
@click="handleClose"
|
|
110
|
+
>
|
|
111
|
+
Browse Products
|
|
112
|
+
</UButton>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<!-- Items list -->
|
|
116
|
+
<div v-else class="flex-1 overflow-y-auto overflow-x-hidden">
|
|
117
|
+
<div
|
|
118
|
+
v-for="item in cartItems"
|
|
119
|
+
:key="item.id"
|
|
120
|
+
class="flex items-start gap-3 px-4 py-3 border-b border-(--ui-border) last:border-b-0 overflow-hidden"
|
|
121
|
+
>
|
|
122
|
+
<!-- Thumbnail -->
|
|
123
|
+
<NuxtLink
|
|
124
|
+
:to="resolveProductUrl(item)"
|
|
125
|
+
class="shrink-0 w-16 h-16 rounded-lg overflow-hidden bg-elevated"
|
|
126
|
+
@click="handleClose"
|
|
127
|
+
>
|
|
128
|
+
<img
|
|
129
|
+
v-if="item.image"
|
|
130
|
+
:src="item.image.url"
|
|
131
|
+
:alt="item.image.alt || t(item.name)"
|
|
132
|
+
class="w-full h-full object-cover"
|
|
133
|
+
loading="lazy"
|
|
134
|
+
/>
|
|
135
|
+
<div v-else class="w-full h-full flex items-center justify-center">
|
|
136
|
+
<UIcon name="i-heroicons-photo" class="text-lg text-muted" />
|
|
137
|
+
</div>
|
|
138
|
+
</NuxtLink>
|
|
139
|
+
|
|
140
|
+
<!-- Item details -->
|
|
141
|
+
<div class="flex-1 min-w-0">
|
|
142
|
+
<NuxtLink
|
|
143
|
+
:to="resolveProductUrl(item)"
|
|
144
|
+
class="text-sm font-medium text-highlighted hover:text-primary transition-colors line-clamp-1 block"
|
|
145
|
+
@click="handleClose"
|
|
146
|
+
>
|
|
147
|
+
{{ t(item.name) }}
|
|
148
|
+
</NuxtLink>
|
|
149
|
+
|
|
150
|
+
<p v-if="item.variantName" class="text-xs text-muted mt-0.5">
|
|
151
|
+
{{ t(item.variantName) }}
|
|
152
|
+
</p>
|
|
153
|
+
|
|
154
|
+
<!-- Price -->
|
|
155
|
+
<CProductPrice
|
|
156
|
+
v-if="item.price"
|
|
157
|
+
:price="item.price"
|
|
158
|
+
size="xs"
|
|
159
|
+
class="mt-0.5"
|
|
160
|
+
/>
|
|
161
|
+
|
|
162
|
+
<!-- Quantity + Remove -->
|
|
163
|
+
<div class="flex items-center justify-between mt-1.5">
|
|
164
|
+
<CQuantitySelector
|
|
165
|
+
:model-value="item.quantity"
|
|
166
|
+
:disabled="loading"
|
|
167
|
+
size="sm"
|
|
168
|
+
@update:model-value="emit('update:quantity', item.id, $event)"
|
|
169
|
+
/>
|
|
170
|
+
|
|
171
|
+
<UButton
|
|
172
|
+
icon="i-heroicons-trash-20-solid"
|
|
173
|
+
variant="ghost"
|
|
174
|
+
color="error"
|
|
175
|
+
size="xs"
|
|
176
|
+
:loading="loading"
|
|
177
|
+
@click="emit('remove', item.id)"
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</template>
|
|
184
|
+
|
|
185
|
+
<!-- Footer: subtotal + action buttons -->
|
|
186
|
+
<template #footer>
|
|
187
|
+
<div v-if="cartItems.length > 0" class="space-y-3">
|
|
188
|
+
<!-- Subtotal -->
|
|
189
|
+
<div class="flex items-center justify-between">
|
|
190
|
+
<span class="text-sm text-muted">Subtotal ({{ count }} items)</span>
|
|
191
|
+
<span class="text-base font-bold text-highlighted">{{ subtotalFormatted }}</span>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<!-- Shipping note -->
|
|
195
|
+
<p class="text-xs text-dimmed">Shipping & taxes calculated at checkout</p>
|
|
196
|
+
|
|
197
|
+
<!-- Action buttons -->
|
|
198
|
+
<div class="flex flex-col gap-2 pt-1">
|
|
199
|
+
<UButton
|
|
200
|
+
:to="checkoutTo"
|
|
201
|
+
color="primary"
|
|
202
|
+
size="lg"
|
|
203
|
+
block
|
|
204
|
+
@click="handleClose"
|
|
205
|
+
>
|
|
206
|
+
{{ checkoutLabel }}
|
|
207
|
+
</UButton>
|
|
208
|
+
|
|
209
|
+
<UButton
|
|
210
|
+
:to="viewCartTo"
|
|
211
|
+
variant="ghost"
|
|
212
|
+
color="neutral"
|
|
213
|
+
size="sm"
|
|
214
|
+
block
|
|
215
|
+
@click="handleClose"
|
|
216
|
+
>
|
|
217
|
+
{{ viewCartLabel }}
|
|
218
|
+
</UButton>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</template>
|
|
222
|
+
</USlideover>
|
|
223
|
+
</template>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { CartItem, LocalizedString } from '@commercejs/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CCartItem — Individual cart line item with image, details, quantity, and remove.
|
|
6
|
+
* Follows Nuxt UI conventions: ui prop, slot-based theming, semantic tokens.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface CartItemProps {
|
|
10
|
+
/** Cart item data from @commercejs/types */
|
|
11
|
+
item: CartItem
|
|
12
|
+
/** Whether an operation is loading */
|
|
13
|
+
loading?: boolean
|
|
14
|
+
/** Size variant */
|
|
15
|
+
size?: 'sm' | 'md' | 'lg'
|
|
16
|
+
/** Per-instance theme overrides */
|
|
17
|
+
ui?: Partial<{
|
|
18
|
+
root: any
|
|
19
|
+
imageWrapper: any
|
|
20
|
+
image: any
|
|
21
|
+
body: any
|
|
22
|
+
title: any
|
|
23
|
+
variant: any
|
|
24
|
+
priceWrapper: any
|
|
25
|
+
actions: any
|
|
26
|
+
}>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(defineProps<CartItemProps>(), {
|
|
30
|
+
loading: false,
|
|
31
|
+
size: 'md',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{
|
|
35
|
+
'update:quantity': [value: number]
|
|
36
|
+
'remove': []
|
|
37
|
+
}>()
|
|
38
|
+
|
|
39
|
+
function t(value: LocalizedString | string | null | undefined): string {
|
|
40
|
+
if (!value) return ''
|
|
41
|
+
if (typeof value === 'string') return value
|
|
42
|
+
return value.en || value.ar || Object.values(value)[0] || ''
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const itemName = computed(() => t(props.item.name))
|
|
46
|
+
|
|
47
|
+
// Resolve theme classes from app.config
|
|
48
|
+
const appConfig = useAppConfig()
|
|
49
|
+
const theme = computed(() => (appConfig.ui as any)?.cartItem ?? {})
|
|
50
|
+
|
|
51
|
+
const slotClasses = computed(() => {
|
|
52
|
+
const t = theme.value
|
|
53
|
+
const sizeStyles = t?.variants?.size?.[props.size] ?? {}
|
|
54
|
+
const base = t?.slots ?? {}
|
|
55
|
+
|
|
56
|
+
const merge = (slot: string) => [
|
|
57
|
+
base[slot],
|
|
58
|
+
sizeStyles[slot],
|
|
59
|
+
props.ui?.[slot as keyof typeof props.ui],
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
root: merge('root'),
|
|
64
|
+
imageWrapper: merge('imageWrapper'),
|
|
65
|
+
image: merge('image'),
|
|
66
|
+
body: merge('body'),
|
|
67
|
+
title: merge('title'),
|
|
68
|
+
variant: merge('variant'),
|
|
69
|
+
priceWrapper: merge('priceWrapper'),
|
|
70
|
+
actions: merge('actions'),
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<div :class="slotClasses.root">
|
|
77
|
+
<!-- Image -->
|
|
78
|
+
<slot name="image" :item="item">
|
|
79
|
+
<div :class="slotClasses.imageWrapper">
|
|
80
|
+
<img
|
|
81
|
+
v-if="item.image"
|
|
82
|
+
:src="item.image.url"
|
|
83
|
+
:alt="item.image.alt || itemName"
|
|
84
|
+
:class="slotClasses.image"
|
|
85
|
+
loading="lazy"
|
|
86
|
+
/>
|
|
87
|
+
<div v-else class="size-full flex items-center justify-center">
|
|
88
|
+
<UIcon name="i-heroicons-photo" class="text-xl text-muted" />
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</slot>
|
|
92
|
+
|
|
93
|
+
<!-- Details -->
|
|
94
|
+
<div :class="slotClasses.body">
|
|
95
|
+
<slot name="title" :name="itemName">
|
|
96
|
+
<h4 :class="slotClasses.title">{{ itemName }}</h4>
|
|
97
|
+
</slot>
|
|
98
|
+
|
|
99
|
+
<slot name="variant" :item="item">
|
|
100
|
+
<p v-if="item.variantId" :class="slotClasses.variant">
|
|
101
|
+
{{ item.variantId }}
|
|
102
|
+
</p>
|
|
103
|
+
</slot>
|
|
104
|
+
|
|
105
|
+
<!-- Price -->
|
|
106
|
+
<slot name="price" :price="item.price">
|
|
107
|
+
<div :class="slotClasses.priceWrapper">
|
|
108
|
+
<CProductPrice :price="item.price" size="sm" :show-discount="false" />
|
|
109
|
+
</div>
|
|
110
|
+
</slot>
|
|
111
|
+
|
|
112
|
+
<!-- Actions: quantity + remove -->
|
|
113
|
+
<div :class="slotClasses.actions">
|
|
114
|
+
<slot name="quantity" :quantity="item.quantity" :update="(v: number) => emit('update:quantity', v)">
|
|
115
|
+
<CQuantitySelector
|
|
116
|
+
:model-value="item.quantity"
|
|
117
|
+
:disabled="loading"
|
|
118
|
+
size="sm"
|
|
119
|
+
@update:model-value="emit('update:quantity', $event)"
|
|
120
|
+
/>
|
|
121
|
+
</slot>
|
|
122
|
+
|
|
123
|
+
<slot name="remove" :remove="() => emit('remove')">
|
|
124
|
+
<UButton
|
|
125
|
+
icon="i-heroicons-trash-20-solid"
|
|
126
|
+
variant="ghost"
|
|
127
|
+
color="error"
|
|
128
|
+
size="sm"
|
|
129
|
+
:loading="loading"
|
|
130
|
+
@click="emit('remove')"
|
|
131
|
+
/>
|
|
132
|
+
</slot>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</template>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Cart } from '@commercejs/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CCartSummary — Order summary sidebar with line items and totals.
|
|
6
|
+
* Follows Nuxt UI conventions: ui prop, slot-based theming, semantic tokens.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface CartSummaryProps {
|
|
10
|
+
/** Cart data from @commercejs/types */
|
|
11
|
+
cart: Cart
|
|
12
|
+
/** Show the checkout/action button */
|
|
13
|
+
showActions?: boolean
|
|
14
|
+
/** Label for the checkout button */
|
|
15
|
+
actionLabel?: string
|
|
16
|
+
/** Route for the checkout button */
|
|
17
|
+
actionTo?: string
|
|
18
|
+
/** Per-instance theme overrides */
|
|
19
|
+
ui?: Partial<{
|
|
20
|
+
root: any
|
|
21
|
+
title: any
|
|
22
|
+
lineItem: any
|
|
23
|
+
lineLabel: any
|
|
24
|
+
lineValue: any
|
|
25
|
+
separator: any
|
|
26
|
+
total: any
|
|
27
|
+
totalLabel: any
|
|
28
|
+
totalValue: any
|
|
29
|
+
actions: any
|
|
30
|
+
}>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const props = withDefaults(defineProps<CartSummaryProps>(), {
|
|
34
|
+
showActions: true,
|
|
35
|
+
actionLabel: 'Proceed to Checkout',
|
|
36
|
+
actionTo: '/checkout',
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Resolve theme classes from app.config
|
|
40
|
+
const appConfig = useAppConfig()
|
|
41
|
+
const theme = computed(() => (appConfig.ui as any)?.cartSummary ?? {})
|
|
42
|
+
|
|
43
|
+
const slotClasses = computed(() => {
|
|
44
|
+
const t = theme.value
|
|
45
|
+
const base = t?.slots ?? {}
|
|
46
|
+
const merge = (slot: string) => [
|
|
47
|
+
base[slot],
|
|
48
|
+
props.ui?.[slot as keyof typeof props.ui],
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
root: merge('root'),
|
|
53
|
+
title: merge('title'),
|
|
54
|
+
lineItem: merge('lineItem'),
|
|
55
|
+
lineLabel: merge('lineLabel'),
|
|
56
|
+
lineValue: merge('lineValue'),
|
|
57
|
+
separator: merge('separator'),
|
|
58
|
+
total: merge('total'),
|
|
59
|
+
totalLabel: merge('totalLabel'),
|
|
60
|
+
totalValue: merge('totalValue'),
|
|
61
|
+
actions: merge('actions'),
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<div :class="slotClasses.root">
|
|
68
|
+
<slot name="title">
|
|
69
|
+
<h3 :class="slotClasses.title">Order Summary</h3>
|
|
70
|
+
</slot>
|
|
71
|
+
|
|
72
|
+
<!-- Line items -->
|
|
73
|
+
<div class="space-y-2">
|
|
74
|
+
<slot name="subtotal" :subtotal="cart.totals.subtotal">
|
|
75
|
+
<div :class="slotClasses.lineItem">
|
|
76
|
+
<span :class="slotClasses.lineLabel">Subtotal ({{ cart.itemCount }} items)</span>
|
|
77
|
+
<span :class="slotClasses.lineValue">{{ cart.totals.subtotal.formatted }}</span>
|
|
78
|
+
</div>
|
|
79
|
+
</slot>
|
|
80
|
+
|
|
81
|
+
<slot name="shipping" :shipping="cart.totals.shipping">
|
|
82
|
+
<div v-if="cart.totals.shipping" :class="slotClasses.lineItem">
|
|
83
|
+
<span :class="slotClasses.lineLabel">Shipping</span>
|
|
84
|
+
<span :class="slotClasses.lineValue">{{ cart.totals.shipping.formatted }}</span>
|
|
85
|
+
</div>
|
|
86
|
+
</slot>
|
|
87
|
+
|
|
88
|
+
<slot name="tax" :tax="cart.totals.tax">
|
|
89
|
+
<div v-if="cart.totals.tax" :class="slotClasses.lineItem">
|
|
90
|
+
<span :class="slotClasses.lineLabel">Tax</span>
|
|
91
|
+
<span :class="slotClasses.lineValue">{{ cart.totals.tax.formatted }}</span>
|
|
92
|
+
</div>
|
|
93
|
+
</slot>
|
|
94
|
+
|
|
95
|
+
<slot name="discount" :discount="cart.totals.discount">
|
|
96
|
+
<div v-if="cart.totals.discount" :class="slotClasses.lineItem">
|
|
97
|
+
<span :class="slotClasses.lineLabel">Discount</span>
|
|
98
|
+
<span class="font-medium text-success">-{{ cart.totals.discount.formatted }}</span>
|
|
99
|
+
</div>
|
|
100
|
+
</slot>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<USeparator :class="slotClasses.separator" />
|
|
104
|
+
|
|
105
|
+
<!-- Total -->
|
|
106
|
+
<slot name="total" :total="cart.totals.total">
|
|
107
|
+
<div :class="slotClasses.total">
|
|
108
|
+
<span :class="slotClasses.totalLabel">Total</span>
|
|
109
|
+
<span :class="slotClasses.totalValue">{{ cart.totals.total.formatted }}</span>
|
|
110
|
+
</div>
|
|
111
|
+
</slot>
|
|
112
|
+
|
|
113
|
+
<!-- Actions -->
|
|
114
|
+
<slot v-if="showActions" name="actions">
|
|
115
|
+
<div :class="slotClasses.actions">
|
|
116
|
+
<UButton
|
|
117
|
+
:to="actionTo"
|
|
118
|
+
color="primary"
|
|
119
|
+
size="lg"
|
|
120
|
+
block
|
|
121
|
+
>
|
|
122
|
+
{{ actionLabel }}
|
|
123
|
+
</UButton>
|
|
124
|
+
</div>
|
|
125
|
+
</slot>
|
|
126
|
+
</div>
|
|
127
|
+
</template>
|