@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,129 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Product, EventProductMeta } from '@commercejs/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CEventCard — Product card for event/ticket products.
|
|
6
|
+
* Shows event date, venue, virtual badge, and ticket purchase CTA.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface EventCardProps {
|
|
10
|
+
product: Product
|
|
11
|
+
event?: EventProductMeta
|
|
12
|
+
/** Per-instance theme overrides */
|
|
13
|
+
ui?: Partial<{
|
|
14
|
+
root: any
|
|
15
|
+
imageWrapper: any
|
|
16
|
+
dateOverlay: any
|
|
17
|
+
body: any
|
|
18
|
+
title: any
|
|
19
|
+
meta: any
|
|
20
|
+
actions: any
|
|
21
|
+
}>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const props = defineProps<EventCardProps>()
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
'get-tickets': [product: Product]
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const eventMeta = computed(() => props.event || props.product.event)
|
|
31
|
+
|
|
32
|
+
function t(value: any): string {
|
|
33
|
+
if (!value) return ''
|
|
34
|
+
if (typeof value === 'string') return value
|
|
35
|
+
return value.en || value.ar || Object.values(value)[0] || ''
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const productName = computed(() => t(props.product.name))
|
|
39
|
+
const mainImage = computed(() => props.product.primaryImage || props.product.gallery?.[0])
|
|
40
|
+
|
|
41
|
+
const eventDate = computed(() => {
|
|
42
|
+
if (!eventMeta.value?.startDate) return null
|
|
43
|
+
return new Date(eventMeta.value.startDate)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const formattedMonth = computed(() => eventDate.value?.toLocaleString('en', { month: 'short' }).toUpperCase())
|
|
47
|
+
const formattedDay = computed(() => eventDate.value?.getDate())
|
|
48
|
+
const formattedTime = computed(() => eventDate.value?.toLocaleString('en', { hour: 'numeric', minute: '2-digit' }))
|
|
49
|
+
|
|
50
|
+
const spotsLeft = computed(() => {
|
|
51
|
+
if (!eventMeta.value?.capacity) return null
|
|
52
|
+
return eventMeta.value.capacity
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Resolve theme
|
|
56
|
+
const appConfig = useAppConfig()
|
|
57
|
+
const theme = computed(() => (appConfig.ui as any)?.eventCard ?? {})
|
|
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
|
+
imageWrapper: merge('imageWrapper'),
|
|
65
|
+
dateOverlay: merge('dateOverlay'),
|
|
66
|
+
body: merge('body'),
|
|
67
|
+
title: merge('title'),
|
|
68
|
+
meta: merge('meta'),
|
|
69
|
+
actions: merge('actions'),
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<template>
|
|
75
|
+
<div :class="['group relative rounded-lg overflow-hidden ring ring-default bg-default hover:shadow-lg transition-all duration-200', slotClasses.root]">
|
|
76
|
+
<!-- Image -->
|
|
77
|
+
<div :class="['relative aspect-[16/9] overflow-hidden bg-elevated', slotClasses.imageWrapper]">
|
|
78
|
+
<img
|
|
79
|
+
v-if="mainImage"
|
|
80
|
+
:src="mainImage.url"
|
|
81
|
+
:alt="mainImage.alt || productName"
|
|
82
|
+
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
83
|
+
/>
|
|
84
|
+
|
|
85
|
+
<!-- Date overlay -->
|
|
86
|
+
<div v-if="eventDate" :class="['absolute top-3 start-3 bg-default/90 backdrop-blur-sm rounded-lg w-14 text-center py-1.5 ring ring-default', slotClasses.dateOverlay]">
|
|
87
|
+
<span class="text-[10px] font-bold text-primary block leading-none">{{ formattedMonth }}</span>
|
|
88
|
+
<span class="text-xl font-black text-highlighted block leading-tight">{{ formattedDay }}</span>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<!-- Virtual badge -->
|
|
92
|
+
<UBadge v-if="eventMeta?.isVirtual" color="info" size="sm" class="absolute top-3 end-3">
|
|
93
|
+
<UIcon name="i-heroicons-video-camera" class="me-0.5" />
|
|
94
|
+
Online
|
|
95
|
+
</UBadge>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<!-- Body -->
|
|
99
|
+
<div :class="['p-4 space-y-2', slotClasses.body]">
|
|
100
|
+
<slot name="title" :name="productName">
|
|
101
|
+
<h3 :class="['font-medium text-sm text-highlighted line-clamp-2', slotClasses.title]">{{ productName }}</h3>
|
|
102
|
+
</slot>
|
|
103
|
+
|
|
104
|
+
<!-- Event meta -->
|
|
105
|
+
<div :class="['space-y-1 text-xs text-muted', slotClasses.meta]">
|
|
106
|
+
<div v-if="formattedTime" class="flex items-center gap-1">
|
|
107
|
+
<UIcon name="i-heroicons-clock" />
|
|
108
|
+
<span>{{ formattedTime }}</span>
|
|
109
|
+
</div>
|
|
110
|
+
<div v-if="eventMeta?.venue || eventMeta?.location" class="flex items-center gap-1">
|
|
111
|
+
<UIcon :name="eventMeta.isVirtual ? 'i-heroicons-globe-alt' : 'i-heroicons-map-pin'" />
|
|
112
|
+
<span class="line-clamp-1">{{ t(eventMeta.venue) || eventMeta.location }}</span>
|
|
113
|
+
</div>
|
|
114
|
+
<div v-if="spotsLeft" class="flex items-center gap-1">
|
|
115
|
+
<UIcon name="i-heroicons-users" />
|
|
116
|
+
<span>{{ spotsLeft }} spots available</span>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<!-- Price + CTA -->
|
|
121
|
+
<div class="flex items-center justify-between pt-1">
|
|
122
|
+
<CProductPrice v-if="product.price" :price="product.price" size="sm" />
|
|
123
|
+
<UButton size="xs" color="primary" @click="emit('get-tickets', product)">
|
|
124
|
+
Get Tickets
|
|
125
|
+
</UButton>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</template>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { GiftCard, RedeemGiftCardInput } from '@commercejs/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CGiftCardBalance — Displays gift card balance and redeem form.
|
|
6
|
+
* Used at checkout to apply gift card codes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface GiftCardBalanceProps {
|
|
10
|
+
/** Applied gift card (after lookup) */
|
|
11
|
+
giftCard?: GiftCard | null
|
|
12
|
+
/** Whether a lookup or redeem is in progress */
|
|
13
|
+
loading?: boolean
|
|
14
|
+
/** Per-instance theme overrides */
|
|
15
|
+
ui?: Partial<{
|
|
16
|
+
root: any
|
|
17
|
+
card: any
|
|
18
|
+
form: any
|
|
19
|
+
}>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(defineProps<GiftCardBalanceProps>(), {
|
|
23
|
+
giftCard: null,
|
|
24
|
+
loading: false,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits<{
|
|
28
|
+
'lookup': [code: string]
|
|
29
|
+
'redeem': [input: RedeemGiftCardInput]
|
|
30
|
+
}>()
|
|
31
|
+
|
|
32
|
+
const code = ref('')
|
|
33
|
+
|
|
34
|
+
function handleLookup() {
|
|
35
|
+
if (code.value.trim()) {
|
|
36
|
+
emit('lookup', code.value.trim())
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function handleRedeem() {
|
|
41
|
+
emit('redeem', { code: code.value.trim() })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const statusColor = computed(() => {
|
|
45
|
+
if (!props.giftCard) return 'neutral'
|
|
46
|
+
const map: Record<string, string> = {
|
|
47
|
+
active: 'success',
|
|
48
|
+
inactive: 'warning',
|
|
49
|
+
redeemed: 'neutral',
|
|
50
|
+
expired: 'error',
|
|
51
|
+
cancelled: 'error',
|
|
52
|
+
}
|
|
53
|
+
return map[props.giftCard.status] || 'neutral'
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Resolve theme from app.config
|
|
57
|
+
const appConfig = useAppConfig()
|
|
58
|
+
const theme = computed(() => (appConfig.ui as any)?.giftCardBalance ?? {})
|
|
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
|
+
card: merge('card'),
|
|
66
|
+
form: merge('form'),
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<div :class="['space-y-4', slotClasses.root]">
|
|
73
|
+
<!-- Code input -->
|
|
74
|
+
<div :class="['flex gap-2', slotClasses.form]">
|
|
75
|
+
<UInput
|
|
76
|
+
v-model="code"
|
|
77
|
+
placeholder="Enter gift card code"
|
|
78
|
+
class="flex-1"
|
|
79
|
+
:disabled="loading"
|
|
80
|
+
@keyup.enter="handleLookup"
|
|
81
|
+
/>
|
|
82
|
+
<UButton @click="handleLookup" :loading="loading" color="neutral" variant="outline">
|
|
83
|
+
Check Balance
|
|
84
|
+
</UButton>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<!-- Card details -->
|
|
88
|
+
<div v-if="giftCard" :class="['rounded-xl bg-elevated ring ring-default p-5 space-y-3', slotClasses.card]">
|
|
89
|
+
<div class="flex items-center justify-between">
|
|
90
|
+
<div class="flex items-center gap-2">
|
|
91
|
+
<UIcon name="i-heroicons-gift" class="text-primary text-lg" />
|
|
92
|
+
<span class="font-mono text-sm text-muted">{{ giftCard.code }}</span>
|
|
93
|
+
</div>
|
|
94
|
+
<UBadge :color="statusColor as any" size="sm">{{ giftCard.status }}</UBadge>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div class="flex items-baseline gap-2">
|
|
98
|
+
<span class="text-3xl font-bold text-highlighted">{{ giftCard.currentBalance.formatted }}</span>
|
|
99
|
+
<span v-if="giftCard.currentBalance.amount !== giftCard.initialBalance.amount" class="text-sm text-muted">
|
|
100
|
+
of {{ giftCard.initialBalance.formatted }}
|
|
101
|
+
</span>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div v-if="giftCard.expiresAt" class="text-xs text-muted">
|
|
105
|
+
Expires: {{ new Date(giftCard.expiresAt).toLocaleDateString() }}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<UButton
|
|
109
|
+
v-if="giftCard.status === 'active'"
|
|
110
|
+
block
|
|
111
|
+
color="primary"
|
|
112
|
+
:loading="loading"
|
|
113
|
+
@click="handleRedeem"
|
|
114
|
+
>
|
|
115
|
+
Apply to Order
|
|
116
|
+
</UButton>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { GiftCard, PurchaseGiftCardInput } from '@commercejs/types'
|
|
3
|
+
import { useFormField } from '@nuxt/ui/runtime/composables/useFormField.js'
|
|
4
|
+
import { ref, computed, nextTick } from 'vue'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CGiftCardForm — Gift card purchase form.
|
|
8
|
+
* Allows customers to select amount, recipient details, and personalize the card.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface GiftCardFormProps {
|
|
12
|
+
/** Pre-defined amount options */
|
|
13
|
+
amounts?: number[]
|
|
14
|
+
/** Currency code */
|
|
15
|
+
currency?: string
|
|
16
|
+
/** Whether the form is submitting */
|
|
17
|
+
loading?: boolean
|
|
18
|
+
/** Per-instance theme overrides */
|
|
19
|
+
ui?: Partial<{
|
|
20
|
+
root: any
|
|
21
|
+
amounts: any
|
|
22
|
+
recipient: any
|
|
23
|
+
}>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const props = withDefaults(defineProps<GiftCardFormProps>(), {
|
|
27
|
+
amounts: () => [25, 50, 100, 150, 200, 500],
|
|
28
|
+
currency: 'SAR',
|
|
29
|
+
loading: false,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
'submit': [input: PurchaseGiftCardInput]
|
|
34
|
+
}>()
|
|
35
|
+
|
|
36
|
+
const selectedAmount = ref<number | null>(null)
|
|
37
|
+
const customAmount = ref<number | null>(null)
|
|
38
|
+
const recipientEmail = ref('')
|
|
39
|
+
const recipientName = ref('')
|
|
40
|
+
const senderName = ref('')
|
|
41
|
+
const message = ref('')
|
|
42
|
+
const isDigital = ref(true)
|
|
43
|
+
|
|
44
|
+
const finalAmount = computed(() => customAmount.value || selectedAmount.value || 0)
|
|
45
|
+
const isCustom = computed(() => selectedAmount.value === null)
|
|
46
|
+
const customInputRef = ref<any>(null)
|
|
47
|
+
|
|
48
|
+
function selectAmount(amount: number) {
|
|
49
|
+
selectedAmount.value = amount
|
|
50
|
+
customAmount.value = null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function handleCustomAmount() {
|
|
54
|
+
selectedAmount.value = null
|
|
55
|
+
await nextTick()
|
|
56
|
+
customInputRef.value?.$el?.querySelector('input')?.focus()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function handleSubmit() {
|
|
60
|
+
if (!finalAmount.value) return
|
|
61
|
+
emit('submit', {
|
|
62
|
+
amount: finalAmount.value,
|
|
63
|
+
currency: props.currency,
|
|
64
|
+
recipientEmail: recipientEmail.value || undefined,
|
|
65
|
+
recipientName: recipientName.value || undefined,
|
|
66
|
+
senderName: senderName.value || undefined,
|
|
67
|
+
message: message.value || undefined,
|
|
68
|
+
isDigital: isDigital.value,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Resolve theme from app.config
|
|
73
|
+
const appConfig = useAppConfig()
|
|
74
|
+
const theme = computed(() => (appConfig.ui as any)?.giftCardForm ?? {})
|
|
75
|
+
|
|
76
|
+
const slotClasses = computed(() => {
|
|
77
|
+
const base = theme.value?.slots ?? {}
|
|
78
|
+
const merge = (slot: string) => [base[slot], props.ui?.[slot as keyof typeof props.ui]]
|
|
79
|
+
return {
|
|
80
|
+
root: merge('root'),
|
|
81
|
+
amounts: merge('amounts'),
|
|
82
|
+
recipient: merge('recipient'),
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<template>
|
|
89
|
+
<UForm :class="['space-y-6', slotClasses.root]" @submit.prevent="handleSubmit">
|
|
90
|
+
<!-- Amount selection -->
|
|
91
|
+
<div :class="['space-y-3', slotClasses.amounts]">
|
|
92
|
+
<!-- <UFormField label="Select Amount"> -->
|
|
93
|
+
<URadioGroup v-model="selectedAmount" :items="amounts" value-key="value" indicator="hidden"
|
|
94
|
+
legend="Select Amount">
|
|
95
|
+
<template #label="{ item }">
|
|
96
|
+
<UButton size="lg" class="justify-center font-semibold w-full"
|
|
97
|
+
:color="selectedAmount === item.value ? 'primary' : 'neutral'" variant="outline"
|
|
98
|
+
@click="selectAmount(item.value)">
|
|
99
|
+
{{ currency }} {{ item.value }}
|
|
100
|
+
</UButton>
|
|
101
|
+
</template>
|
|
102
|
+
</URadioGroup>
|
|
103
|
+
<UCollapsible>
|
|
104
|
+
<UButton size="lg" class="justify-center font-semibold w-full"
|
|
105
|
+
:color="isCustom ? 'primary' : 'neutral'" variant="outline"
|
|
106
|
+
@click="handleCustomAmount">
|
|
107
|
+
Custom Amount
|
|
108
|
+
</UButton>
|
|
109
|
+
<template #content>
|
|
110
|
+
<UFormField label="Enter amount" class="py-1">
|
|
111
|
+
<UInput ref="customInputRef" v-model.number="customAmount" type="number" min="1"
|
|
112
|
+
:placeholder="`${currency} amount`" size="lg" class="w-full" />
|
|
113
|
+
</UFormField>
|
|
114
|
+
</template>
|
|
115
|
+
</UCollapsible>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<USeparator />
|
|
119
|
+
|
|
120
|
+
<!-- Card type -->
|
|
121
|
+
<UFormField>
|
|
122
|
+
<URadioGroup v-model="isDigital" :items="[{label: 'Digital (Email)', value: true}, {label: 'Physical Card', value: false}]" value-key="value" indicator="hidden" legend="Select Card Type">
|
|
123
|
+
<template #label="{ item }">
|
|
124
|
+
<UButton size="lg" class="justify-center font-semibold w-full"
|
|
125
|
+
:color="isDigital === item.value ? 'primary' : 'neutral'" variant="outline"
|
|
126
|
+
@click="isDigital = item.value">
|
|
127
|
+
{{ item.label }}
|
|
128
|
+
</UButton>
|
|
129
|
+
</template>
|
|
130
|
+
</URadioGroup>
|
|
131
|
+
</UFormField>
|
|
132
|
+
|
|
133
|
+
<!-- Recipient -->
|
|
134
|
+
<div :class="['space-y-3', slotClasses.recipient]">
|
|
135
|
+
<h4 class="text-sm font-medium text-highlighted">Recipient Details</h4>
|
|
136
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
137
|
+
<UFormField label="Recipient Name">
|
|
138
|
+
<UInput v-model="recipientName" placeholder="Their name" />
|
|
139
|
+
</UFormField>
|
|
140
|
+
<UFormField v-if="isDigital" label="Recipient Email">
|
|
141
|
+
<UInput v-model="recipientEmail" type="email" placeholder="their@email.com" />
|
|
142
|
+
</UFormField>
|
|
143
|
+
</div>
|
|
144
|
+
<UFormField label="Your Name">
|
|
145
|
+
<UInput v-model="senderName" placeholder="From…" />
|
|
146
|
+
</UFormField>
|
|
147
|
+
<UFormField label="Personal Message">
|
|
148
|
+
<UTextarea v-model="message" placeholder="Add a personal message…" rows="3" class="w-full" />
|
|
149
|
+
</UFormField>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<UButton type="submit" block size="lg" color="primary" :loading="loading" :disabled="!finalAmount">
|
|
153
|
+
<UIcon name="i-heroicons-gift-20-solid" class="me-2" />
|
|
154
|
+
Purchase Gift Card — {{ currency }} {{ finalAmount }}
|
|
155
|
+
</UButton>
|
|
156
|
+
</UForm>
|
|
157
|
+
</template>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { GiftCard, PurchaseGiftCardInput } from '@commercejs/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CGiftCardForm — Gift card purchase form.
|
|
6
|
+
* Allows customers to select amount, recipient details, and personalize the card.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface GiftCardFormProps {
|
|
10
|
+
/** Pre-defined amount options */
|
|
11
|
+
amounts?: number[]
|
|
12
|
+
/** Currency code */
|
|
13
|
+
currency?: string
|
|
14
|
+
/** Whether the form is submitting */
|
|
15
|
+
loading?: boolean
|
|
16
|
+
/** Per-instance theme overrides */
|
|
17
|
+
ui?: Partial<{
|
|
18
|
+
root: any
|
|
19
|
+
amounts: any
|
|
20
|
+
recipient: any
|
|
21
|
+
}>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<GiftCardFormProps>(), {
|
|
25
|
+
amounts: () => [25, 50, 100, 150, 200, 500],
|
|
26
|
+
currency: 'SAR',
|
|
27
|
+
loading: false,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{
|
|
31
|
+
'submit': [input: PurchaseGiftCardInput]
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const selectedAmount = ref<number | null>(null)
|
|
35
|
+
const customAmount = ref<number | null>(null)
|
|
36
|
+
const recipientEmail = ref('')
|
|
37
|
+
const recipientName = ref('')
|
|
38
|
+
const senderName = ref('')
|
|
39
|
+
const message = ref('')
|
|
40
|
+
const isDigital = ref(true)
|
|
41
|
+
|
|
42
|
+
const finalAmount = computed(() => customAmount.value || selectedAmount.value || 0)
|
|
43
|
+
|
|
44
|
+
function selectAmount(amount: number) {
|
|
45
|
+
selectedAmount.value = amount
|
|
46
|
+
customAmount.value = null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function handleSubmit() {
|
|
50
|
+
if (!finalAmount.value) return
|
|
51
|
+
emit('submit', {
|
|
52
|
+
amount: finalAmount.value,
|
|
53
|
+
currency: props.currency,
|
|
54
|
+
recipientEmail: recipientEmail.value || undefined,
|
|
55
|
+
recipientName: recipientName.value || undefined,
|
|
56
|
+
senderName: senderName.value || undefined,
|
|
57
|
+
message: message.value || undefined,
|
|
58
|
+
isDigital: isDigital.value,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Resolve theme from app.config
|
|
63
|
+
const appConfig = useAppConfig()
|
|
64
|
+
const theme = computed(() => (appConfig.ui as any)?.giftCardForm ?? {})
|
|
65
|
+
|
|
66
|
+
const slotClasses = computed(() => {
|
|
67
|
+
const base = theme.value?.slots ?? {}
|
|
68
|
+
const merge = (slot: string) => [base[slot], props.ui?.[slot as keyof typeof props.ui]]
|
|
69
|
+
return {
|
|
70
|
+
root: merge('root'),
|
|
71
|
+
amounts: merge('amounts'),
|
|
72
|
+
recipient: merge('recipient'),
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<form :class="['space-y-6', slotClasses.root]" @submit.prevent="handleSubmit">
|
|
79
|
+
<!-- Amount selection -->
|
|
80
|
+
<div :class="['space-y-3', slotClasses.amounts]">
|
|
81
|
+
<label class="text-sm font-medium text-highlighted">Select Amount</label>
|
|
82
|
+
<div class="grid grid-cols-3 gap-2">
|
|
83
|
+
<UButton
|
|
84
|
+
v-for="amount in amounts"
|
|
85
|
+
:key="amount"
|
|
86
|
+
:variant="selectedAmount === amount && !customAmount ? 'soft' : 'outline'"
|
|
87
|
+
:color="selectedAmount === amount && !customAmount ? 'primary' : 'neutral'"
|
|
88
|
+
size="lg"
|
|
89
|
+
class="justify-center font-semibold"
|
|
90
|
+
@click="selectAmount(amount)"
|
|
91
|
+
>
|
|
92
|
+
{{ currency }} {{ amount }}
|
|
93
|
+
</UButton>
|
|
94
|
+
</div>
|
|
95
|
+
<UFormField label="Or enter custom amount">
|
|
96
|
+
<UInput
|
|
97
|
+
v-model.number="customAmount"
|
|
98
|
+
type="number"
|
|
99
|
+
min="1"
|
|
100
|
+
:placeholder="`${currency} amount`"
|
|
101
|
+
@focus="selectedAmount = null"
|
|
102
|
+
/>
|
|
103
|
+
</UFormField>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<USeparator />
|
|
107
|
+
|
|
108
|
+
<!-- Card type -->
|
|
109
|
+
<div class="flex items-center gap-4">
|
|
110
|
+
<URadio v-model="isDigital" :value="true" label="Digital (Email)" />
|
|
111
|
+
<URadio v-model="isDigital" :value="false" label="Physical Card" />
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<!-- Recipient -->
|
|
115
|
+
<div :class="['space-y-3', slotClasses.recipient]">
|
|
116
|
+
<h4 class="text-sm font-medium text-highlighted">Recipient Details</h4>
|
|
117
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
118
|
+
<UFormField label="Recipient Name">
|
|
119
|
+
<UInput v-model="recipientName" placeholder="Their name" />
|
|
120
|
+
</UFormField>
|
|
121
|
+
<UFormField v-if="isDigital" label="Recipient Email">
|
|
122
|
+
<UInput v-model="recipientEmail" type="email" placeholder="their@email.com" />
|
|
123
|
+
</UFormField>
|
|
124
|
+
</div>
|
|
125
|
+
<UFormField label="Your Name">
|
|
126
|
+
<UInput v-model="senderName" placeholder="From…" />
|
|
127
|
+
</UFormField>
|
|
128
|
+
<UFormField label="Personal Message">
|
|
129
|
+
<UTextarea v-model="message" placeholder="Add a personal message…" rows="3" class="w-full" />
|
|
130
|
+
</UFormField>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<UButton type="submit" block size="lg" color="primary" :loading="loading" :disabled="!finalAmount">
|
|
134
|
+
<UIcon name="i-heroicons-gift-20-solid" class="me-2" />
|
|
135
|
+
Purchase Gift Card — {{ currency }} {{ finalAmount }}
|
|
136
|
+
</UButton>
|
|
137
|
+
</form>
|
|
138
|
+
</template>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* CHeroBanner — Full-width marketing hero banner with CTA.
|
|
4
|
+
* Supports image/video backgrounds, overlays, and multiple content slots.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface HeroBannerProps {
|
|
8
|
+
/** Background image URL */
|
|
9
|
+
imageUrl?: string
|
|
10
|
+
/** Optional video URL for background */
|
|
11
|
+
videoUrl?: string
|
|
12
|
+
/** Title text */
|
|
13
|
+
title?: string
|
|
14
|
+
/** Subtitle text */
|
|
15
|
+
subtitle?: string
|
|
16
|
+
/** Primary CTA label */
|
|
17
|
+
ctaLabel?: string
|
|
18
|
+
/** Primary CTA link */
|
|
19
|
+
ctaTo?: string
|
|
20
|
+
/** Secondary CTA label */
|
|
21
|
+
secondaryCtaLabel?: string
|
|
22
|
+
/** Secondary CTA link */
|
|
23
|
+
secondaryCtaTo?: string
|
|
24
|
+
/** Content alignment */
|
|
25
|
+
align?: 'start' | 'center' | 'end'
|
|
26
|
+
/** Overlay intensity */
|
|
27
|
+
overlay?: 'none' | 'light' | 'dark'
|
|
28
|
+
/** Height variant */
|
|
29
|
+
height?: 'sm' | 'md' | 'lg' | 'full'
|
|
30
|
+
/** Per-instance theme overrides */
|
|
31
|
+
ui?: Partial<{
|
|
32
|
+
root: any
|
|
33
|
+
background: any
|
|
34
|
+
overlay: any
|
|
35
|
+
content: any
|
|
36
|
+
title: any
|
|
37
|
+
subtitle: any
|
|
38
|
+
actions: any
|
|
39
|
+
}>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const props = withDefaults(defineProps<HeroBannerProps>(), {
|
|
43
|
+
align: 'center',
|
|
44
|
+
overlay: 'dark',
|
|
45
|
+
height: 'lg',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const heightClass = computed(() => {
|
|
49
|
+
const map = {
|
|
50
|
+
sm: 'min-h-[240px] md:min-h-[320px]',
|
|
51
|
+
md: 'min-h-[320px] md:min-h-[480px]',
|
|
52
|
+
lg: 'min-h-[400px] md:min-h-[560px]',
|
|
53
|
+
full: 'min-h-screen',
|
|
54
|
+
}
|
|
55
|
+
return map[props.height] || map.lg
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const alignClass = computed(() => {
|
|
59
|
+
const map = {
|
|
60
|
+
start: 'items-start text-start',
|
|
61
|
+
center: 'items-center text-center',
|
|
62
|
+
end: 'items-end text-end',
|
|
63
|
+
}
|
|
64
|
+
return map[props.align] || map.center
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const overlayClass = computed(() => {
|
|
68
|
+
const map = {
|
|
69
|
+
none: '',
|
|
70
|
+
light: 'bg-white/30',
|
|
71
|
+
dark: 'bg-black/50',
|
|
72
|
+
}
|
|
73
|
+
return map[props.overlay] || map.dark
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Resolve theme from app.config
|
|
77
|
+
const appConfig = useAppConfig()
|
|
78
|
+
const theme = computed(() => (appConfig.ui as any)?.heroBanner ?? {})
|
|
79
|
+
|
|
80
|
+
const slotClasses = computed(() => {
|
|
81
|
+
const base = theme.value?.slots ?? {}
|
|
82
|
+
const merge = (slot: string) => [
|
|
83
|
+
base[slot],
|
|
84
|
+
props.ui?.[slot as keyof typeof props.ui],
|
|
85
|
+
]
|
|
86
|
+
return {
|
|
87
|
+
root: merge('root'),
|
|
88
|
+
background: merge('background'),
|
|
89
|
+
overlay: merge('overlay'),
|
|
90
|
+
content: merge('content'),
|
|
91
|
+
title: merge('title'),
|
|
92
|
+
subtitle: merge('subtitle'),
|
|
93
|
+
actions: merge('actions'),
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<template>
|
|
99
|
+
<div :class="['relative overflow-hidden flex', heightClass, slotClasses.root]">
|
|
100
|
+
<!-- Background -->
|
|
101
|
+
<slot name="background" :image-url="imageUrl" :video-url="videoUrl">
|
|
102
|
+
<video
|
|
103
|
+
v-if="videoUrl"
|
|
104
|
+
:src="videoUrl"
|
|
105
|
+
autoplay
|
|
106
|
+
muted
|
|
107
|
+
loop
|
|
108
|
+
playsinline
|
|
109
|
+
:class="['absolute inset-0 size-full object-cover', slotClasses.background]"
|
|
110
|
+
/>
|
|
111
|
+
<img
|
|
112
|
+
v-else-if="imageUrl"
|
|
113
|
+
:src="imageUrl"
|
|
114
|
+
alt=""
|
|
115
|
+
:class="['absolute inset-0 size-full object-cover', slotClasses.background]"
|
|
116
|
+
/>
|
|
117
|
+
</slot>
|
|
118
|
+
|
|
119
|
+
<!-- Overlay -->
|
|
120
|
+
<div :class="['absolute inset-0 z-10', overlayClass, slotClasses.overlay]" />
|
|
121
|
+
|
|
122
|
+
<!-- Content -->
|
|
123
|
+
<div :class="['relative z-20 flex flex-col justify-center px-6 md:px-12 lg:px-24 w-full', alignClass, slotClasses.content]">
|
|
124
|
+
<slot>
|
|
125
|
+
<h1 v-if="title" :class="['text-3xl md:text-5xl lg:text-6xl font-bold text-white mb-4', slotClasses.title]">
|
|
126
|
+
{{ title }}
|
|
127
|
+
</h1>
|
|
128
|
+
<p v-if="subtitle" :class="['text-lg md:text-xl text-white/80 max-w-2xl mb-8', slotClasses.subtitle]">
|
|
129
|
+
{{ subtitle }}
|
|
130
|
+
</p>
|
|
131
|
+
<div v-if="ctaLabel || secondaryCtaLabel" :class="['flex gap-3 flex-wrap', alignClass.includes('center') ? 'justify-center' : '', slotClasses.actions]">
|
|
132
|
+
<UButton v-if="ctaLabel" :to="ctaTo" size="lg" color="primary">
|
|
133
|
+
{{ ctaLabel }}
|
|
134
|
+
</UButton>
|
|
135
|
+
<UButton v-if="secondaryCtaLabel" :to="secondaryCtaTo" size="lg" variant="outline" color="neutral" class="text-white border-white/30 hover:bg-white/10">
|
|
136
|
+
{{ secondaryCtaLabel }}
|
|
137
|
+
</UButton>
|
|
138
|
+
</div>
|
|
139
|
+
</slot>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</template>
|