@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,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>
|