@commercejs/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/module.cjs +5 -0
  2. package/dist/module.d.mts +15 -0
  3. package/dist/module.d.ts +15 -0
  4. package/dist/module.json +12 -0
  5. package/dist/module.mjs +30 -0
  6. package/dist/runtime/app.config.d.ts +0 -0
  7. package/dist/runtime/app.config.js +341 -0
  8. package/dist/runtime/components/auction/CAuctionCard.vue +213 -0
  9. package/dist/runtime/components/auction/CBidPanel.vue +176 -0
  10. package/dist/runtime/components/cart/CCartDrawer.vue +223 -0
  11. package/dist/runtime/components/cart/CCartItem.vue +136 -0
  12. package/dist/runtime/components/cart/CCartSummary.vue +127 -0
  13. package/dist/runtime/components/cart/CQuantitySelector.vue +110 -0
  14. package/dist/runtime/components/category/CCategoryFilter.vue +123 -0
  15. package/dist/runtime/components/checkout/CAddressForm.vue +186 -0
  16. package/dist/runtime/components/checkout/CCheckoutStepper.vue +84 -0
  17. package/dist/runtime/components/common/CEmptyState.vue +81 -0
  18. package/dist/runtime/components/common/CProductTypeBadge.vue +37 -0
  19. package/dist/runtime/components/event/CEventCard.vue +129 -0
  20. package/dist/runtime/components/gift-card/CGiftCardBalance.vue +119 -0
  21. package/dist/runtime/components/gift-card/CGiftCardForm.vue +157 -0
  22. package/dist/runtime/components/gift-card/CGiftCardForm.vue.backup +138 -0
  23. package/dist/runtime/components/marketing/CHeroBanner.vue +142 -0
  24. package/dist/runtime/components/navigation/CSearchBar.vue +127 -0
  25. package/dist/runtime/components/order/COrderCard.vue +117 -0
  26. package/dist/runtime/components/order/COrderTimeline.vue +99 -0
  27. package/dist/runtime/components/product/CProductCard.vue +206 -0
  28. package/dist/runtime/components/product/CProductGallery.vue +110 -0
  29. package/dist/runtime/components/product/CProductGrid.vue +82 -0
  30. package/dist/runtime/components/product/CProductOptions.vue +101 -0
  31. package/dist/runtime/components/product/CProductPrice.vue +87 -0
  32. package/dist/runtime/components/promotion/CCouponInput.vue +104 -0
  33. package/dist/runtime/components/promotion/CPromoBanner.vue +153 -0
  34. package/dist/runtime/components/rental/CRentalBookingForm.vue +214 -0
  35. package/dist/runtime/components/rental/CRentalCard.vue +146 -0
  36. package/dist/runtime/components/review/CReviewCard.vue +96 -0
  37. package/dist/runtime/components/review/CReviewStars.vue +106 -0
  38. package/dist/runtime/components/subscription/CSubscriptionCard.vue +137 -0
  39. package/dist/runtime/components/wholesale/CPriceTierTable.vue +88 -0
  40. package/dist/runtime/components/wholesale/CQuoteRequestForm.vue +148 -0
  41. package/dist/runtime/components/wishlist/CWishlistGrid.vue +96 -0
  42. package/dist/types.d.mts +7 -0
  43. package/dist/types.d.ts +7 -0
  44. package/package.json +41 -0
  45. package/src/module.ts +52 -0
  46. package/src/runtime/app.config.ts +392 -0
  47. package/src/runtime/components/auction/CAuctionCard.vue +213 -0
  48. package/src/runtime/components/auction/CBidPanel.vue +176 -0
  49. package/src/runtime/components/cart/CCartDrawer.vue +223 -0
  50. package/src/runtime/components/cart/CCartItem.vue +136 -0
  51. package/src/runtime/components/cart/CCartSummary.vue +127 -0
  52. package/src/runtime/components/cart/CQuantitySelector.vue +110 -0
  53. package/src/runtime/components/category/CCategoryFilter.vue +123 -0
  54. package/src/runtime/components/checkout/CAddressForm.vue +186 -0
  55. package/src/runtime/components/checkout/CCheckoutStepper.vue +84 -0
  56. package/src/runtime/components/common/CEmptyState.vue +81 -0
  57. package/src/runtime/components/common/CProductTypeBadge.vue +37 -0
  58. package/src/runtime/components/event/CEventCard.vue +129 -0
  59. package/src/runtime/components/gift-card/CGiftCardBalance.vue +119 -0
  60. package/src/runtime/components/gift-card/CGiftCardForm.vue +157 -0
  61. package/src/runtime/components/gift-card/CGiftCardForm.vue.backup +138 -0
  62. package/src/runtime/components/marketing/CHeroBanner.vue +142 -0
  63. package/src/runtime/components/navigation/CSearchBar.vue +127 -0
  64. package/src/runtime/components/order/COrderCard.vue +117 -0
  65. package/src/runtime/components/order/COrderTimeline.vue +99 -0
  66. package/src/runtime/components/product/CProductCard.vue +206 -0
  67. package/src/runtime/components/product/CProductGallery.vue +110 -0
  68. package/src/runtime/components/product/CProductGrid.vue +82 -0
  69. package/src/runtime/components/product/CProductOptions.vue +101 -0
  70. package/src/runtime/components/product/CProductPrice.vue +87 -0
  71. package/src/runtime/components/promotion/CCouponInput.vue +104 -0
  72. package/src/runtime/components/promotion/CPromoBanner.vue +153 -0
  73. package/src/runtime/components/rental/CRentalBookingForm.vue +214 -0
  74. package/src/runtime/components/rental/CRentalCard.vue +146 -0
  75. package/src/runtime/components/review/CReviewCard.vue +96 -0
  76. package/src/runtime/components/review/CReviewStars.vue +106 -0
  77. package/src/runtime/components/subscription/CSubscriptionCard.vue +137 -0
  78. package/src/runtime/components/wholesale/CPriceTierTable.vue +88 -0
  79. package/src/runtime/components/wholesale/CQuoteRequestForm.vue +148 -0
  80. package/src/runtime/components/wishlist/CWishlistGrid.vue +96 -0
@@ -0,0 +1,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>