@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,82 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Product } from '@commercejs/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CProductGrid — Responsive product grid layout.
|
|
6
|
+
* Wraps CProductCard in a CSS grid with configurable columns.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ProductGridProps {
|
|
10
|
+
/** Products to display */
|
|
11
|
+
products: Product[]
|
|
12
|
+
/** Number of columns (responsive breakpoints) */
|
|
13
|
+
columns?: 2 | 3 | 4 | 5 | 6
|
|
14
|
+
/** Gap between items */
|
|
15
|
+
gap?: 'sm' | 'md' | 'lg'
|
|
16
|
+
/** Props to pass through to each CProductCard */
|
|
17
|
+
cardProps?: Record<string, any>
|
|
18
|
+
/** Per-instance theme overrides */
|
|
19
|
+
ui?: Partial<{
|
|
20
|
+
root: any
|
|
21
|
+
empty: any
|
|
22
|
+
}>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<ProductGridProps>(), {
|
|
26
|
+
columns: 4,
|
|
27
|
+
gap: 'md',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{
|
|
31
|
+
'add-to-cart': [product: Product]
|
|
32
|
+
'toggle-wishlist': [product: Product]
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
const gridCols = computed(() => {
|
|
36
|
+
const map: Record<number, string> = {
|
|
37
|
+
2: 'grid-cols-1 sm:grid-cols-2',
|
|
38
|
+
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
|
39
|
+
4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
|
|
40
|
+
5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
|
|
41
|
+
6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6',
|
|
42
|
+
}
|
|
43
|
+
return map[props.columns] || map[4]
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const gapClass = computed(() => {
|
|
47
|
+
const map = { sm: 'gap-3', md: 'gap-4 md:gap-6', lg: 'gap-6 md:gap-8' }
|
|
48
|
+
return map[props.gap] || map.md
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Resolve theme from app.config
|
|
52
|
+
const appConfig = useAppConfig()
|
|
53
|
+
const theme = computed(() => (appConfig.ui as any)?.productGrid ?? {})
|
|
54
|
+
|
|
55
|
+
const slotClasses = computed(() => {
|
|
56
|
+
const base = theme.value?.slots ?? {}
|
|
57
|
+
return {
|
|
58
|
+
root: [base.root, props.ui?.root],
|
|
59
|
+
empty: [base.empty, props.ui?.empty],
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<div v-if="products.length > 0" :class="['grid', gridCols, gapClass, slotClasses.root]">
|
|
66
|
+
<slot name="item" v-for="product in products" :key="product.id" :product="product">
|
|
67
|
+
<CProductCard
|
|
68
|
+
:product="product"
|
|
69
|
+
v-bind="cardProps"
|
|
70
|
+
@add-to-cart="emit('add-to-cart', $event)"
|
|
71
|
+
@toggle-wishlist="emit('toggle-wishlist', $event)"
|
|
72
|
+
/>
|
|
73
|
+
</slot>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<slot v-else name="empty">
|
|
77
|
+
<div :class="['text-center py-16', slotClasses.empty]">
|
|
78
|
+
<UIcon name="i-heroicons-shopping-bag" class="text-4xl text-muted mb-3" />
|
|
79
|
+
<p class="text-muted">No products found</p>
|
|
80
|
+
</div>
|
|
81
|
+
</slot>
|
|
82
|
+
</template>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ProductOption, LocalizedString, Id } from '@commercejs/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CProductOptions — Variant option selector (size, color, etc.)
|
|
6
|
+
* Renders each option group with selectable values.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ProductOptionsProps {
|
|
10
|
+
/** Available product options */
|
|
11
|
+
items: ProductOption[]
|
|
12
|
+
/** Currently selected option values: { optionId: valueId } */
|
|
13
|
+
modelValue: Record<string, string>
|
|
14
|
+
/** Size variant */
|
|
15
|
+
size?: 'sm' | 'md' | 'lg'
|
|
16
|
+
/** Per-instance theme overrides */
|
|
17
|
+
ui?: Partial<{
|
|
18
|
+
root: any
|
|
19
|
+
group: any
|
|
20
|
+
label: any
|
|
21
|
+
values: any
|
|
22
|
+
value: any
|
|
23
|
+
valueActive: any
|
|
24
|
+
}>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const props = withDefaults(defineProps<ProductOptionsProps>(), {
|
|
28
|
+
size: 'md',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const emit = defineEmits<{
|
|
32
|
+
'update:modelValue': [value: Record<string, string>]
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
function t(value: LocalizedString | string | null | undefined): string {
|
|
36
|
+
if (!value) return ''
|
|
37
|
+
if (typeof value === 'string') return value
|
|
38
|
+
return value.en || value.ar || Object.values(value)[0] || ''
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function selectValue(optionId: string, valueId: string) {
|
|
42
|
+
emit('update:modelValue', {
|
|
43
|
+
...props.modelValue,
|
|
44
|
+
[optionId]: valueId,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isSelected(optionId: string, valueId: string): boolean {
|
|
49
|
+
return props.modelValue[optionId] === valueId
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Resolve theme from app.config
|
|
53
|
+
const appConfig = useAppConfig()
|
|
54
|
+
const theme = computed(() => (appConfig.ui as any)?.productOptions ?? {})
|
|
55
|
+
|
|
56
|
+
const slotClasses = computed(() => {
|
|
57
|
+
const base = theme.value?.slots ?? {}
|
|
58
|
+
const sizeStyles = theme.value?.variants?.size?.[props.size] ?? {}
|
|
59
|
+
const merge = (slot: string) => [
|
|
60
|
+
base[slot],
|
|
61
|
+
sizeStyles[slot],
|
|
62
|
+
props.ui?.[slot as keyof typeof props.ui],
|
|
63
|
+
]
|
|
64
|
+
return {
|
|
65
|
+
root: merge('root'),
|
|
66
|
+
group: merge('group'),
|
|
67
|
+
label: merge('label'),
|
|
68
|
+
values: merge('values'),
|
|
69
|
+
value: merge('value'),
|
|
70
|
+
valueActive: merge('valueActive'),
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<div :class="slotClasses.root">
|
|
77
|
+
<div v-for="option in items" :key="option.id" :class="slotClasses.group">
|
|
78
|
+
<slot name="label" :option="option">
|
|
79
|
+
<label :class="slotClasses.label">
|
|
80
|
+
{{ t(option.name) }}
|
|
81
|
+
<span v-if="modelValue[option.id]" class="text-muted font-normal">
|
|
82
|
+
— {{ t(option.values.find(v => v.id === modelValue[option.id])?.name) }}
|
|
83
|
+
</span>
|
|
84
|
+
</label>
|
|
85
|
+
</slot>
|
|
86
|
+
|
|
87
|
+
<div :class="slotClasses.values">
|
|
88
|
+
<slot name="value" v-for="val in option.values" :key="val.id" :option="option" :value="val" :selected="isSelected(option.id, val.id)" :select="() => selectValue(option.id, val.id)">
|
|
89
|
+
<UButton
|
|
90
|
+
:variant="isSelected(option.id, val.id) ? 'soft' : 'outline'"
|
|
91
|
+
:color="isSelected(option.id, val.id) ? 'primary' : 'neutral'"
|
|
92
|
+
size="sm"
|
|
93
|
+
@click="selectValue(option.id, val.id)"
|
|
94
|
+
>
|
|
95
|
+
{{ t(val.name) }}
|
|
96
|
+
</UButton>
|
|
97
|
+
</slot>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</template>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { DiscountablePrice, Price } from '@commercejs/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CProductPrice — Display a product price with optional discount.
|
|
6
|
+
* Follows Nuxt UI conventions: ui prop, slot-based theming, semantic tokens.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ProductPriceProps {
|
|
10
|
+
/** The price to display */
|
|
11
|
+
price: DiscountablePrice | Price
|
|
12
|
+
/** Size variant */
|
|
13
|
+
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
14
|
+
/** Show discount percentage badge */
|
|
15
|
+
showDiscount?: boolean
|
|
16
|
+
/** Per-instance theme overrides */
|
|
17
|
+
ui?: Partial<{
|
|
18
|
+
root: any
|
|
19
|
+
current: any
|
|
20
|
+
original: any
|
|
21
|
+
discount: any
|
|
22
|
+
}>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<ProductPriceProps>(), {
|
|
26
|
+
size: 'md',
|
|
27
|
+
showDiscount: true,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const hasDiscount = computed(() => {
|
|
31
|
+
const p = props.price as DiscountablePrice
|
|
32
|
+
return p.originalAmount != null && p.originalAmount > p.amount
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const discountPercent = computed(() => {
|
|
36
|
+
const p = props.price as DiscountablePrice
|
|
37
|
+
if (p.discountPercent) return p.discountPercent
|
|
38
|
+
if (p.originalAmount && p.originalAmount > p.amount) {
|
|
39
|
+
return Math.round(((p.originalAmount - p.amount) / p.originalAmount) * 100)
|
|
40
|
+
}
|
|
41
|
+
return 0
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const originalFormatted = computed(() => {
|
|
45
|
+
if (!hasDiscount.value) return ''
|
|
46
|
+
const p = props.price as DiscountablePrice
|
|
47
|
+
// Build a formatted original price string
|
|
48
|
+
if (p.formatted && p.originalAmount) {
|
|
49
|
+
return p.formatted.replace(String(p.amount), String(p.originalAmount))
|
|
50
|
+
}
|
|
51
|
+
return `${p.originalAmount} ${p.currency}`
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Resolve theme classes from app.config
|
|
55
|
+
const appConfig = useAppConfig()
|
|
56
|
+
const theme = computed(() => (appConfig.ui as any)?.productPrice ?? {})
|
|
57
|
+
|
|
58
|
+
const slotClasses = computed(() => {
|
|
59
|
+
const t = theme.value
|
|
60
|
+
const sizeVariant = t?.variants?.size?.[props.size] ?? {}
|
|
61
|
+
const base = t?.slots ?? {}
|
|
62
|
+
return {
|
|
63
|
+
root: [base.root, sizeVariant.root, props.ui?.root],
|
|
64
|
+
current: [base.current, sizeVariant.current, props.ui?.current],
|
|
65
|
+
original: [base.original, sizeVariant.original, props.ui?.original],
|
|
66
|
+
discount: [base.discount, sizeVariant.discount, props.ui?.discount],
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<span :class="slotClasses.root">
|
|
73
|
+
<slot name="current" :price="price" :formatted="price.formatted">
|
|
74
|
+
<span :class="slotClasses.current">{{ price.formatted }}</span>
|
|
75
|
+
</slot>
|
|
76
|
+
|
|
77
|
+
<template v-if="hasDiscount">
|
|
78
|
+
<slot name="original" :original="originalFormatted">
|
|
79
|
+
<span :class="slotClasses.original">{{ originalFormatted }}</span>
|
|
80
|
+
</slot>
|
|
81
|
+
|
|
82
|
+
<slot v-if="showDiscount" name="discount" :percent="discountPercent">
|
|
83
|
+
<span :class="slotClasses.discount">-{{ discountPercent }}%</span>
|
|
84
|
+
</slot>
|
|
85
|
+
</template>
|
|
86
|
+
</span>
|
|
87
|
+
</template>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Coupon } from '@commercejs/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CCouponInput — Coupon code input with validation feedback.
|
|
6
|
+
* Used at cart/checkout to apply discount codes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface CouponInputProps {
|
|
10
|
+
/** Applied coupon (if already applied) */
|
|
11
|
+
appliedCoupon?: Coupon | null
|
|
12
|
+
/** Whether the coupon is being validated */
|
|
13
|
+
loading?: boolean
|
|
14
|
+
/** Error message */
|
|
15
|
+
error?: string
|
|
16
|
+
/** Per-instance theme overrides */
|
|
17
|
+
ui?: Partial<{
|
|
18
|
+
root: any
|
|
19
|
+
input: any
|
|
20
|
+
applied: any
|
|
21
|
+
}>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<CouponInputProps>(), {
|
|
25
|
+
appliedCoupon: null,
|
|
26
|
+
loading: false,
|
|
27
|
+
error: '',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{
|
|
31
|
+
'apply': [code: string]
|
|
32
|
+
'remove': []
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
const code = ref('')
|
|
36
|
+
|
|
37
|
+
function t(value: any): string {
|
|
38
|
+
if (!value) return ''
|
|
39
|
+
if (typeof value === 'string') return value
|
|
40
|
+
return value.en || value.ar || Object.values(value)[0] || ''
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handleApply() {
|
|
44
|
+
if (code.value.trim()) {
|
|
45
|
+
emit('apply', code.value.trim().toUpperCase())
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Resolve theme from app.config
|
|
50
|
+
const appConfig = useAppConfig()
|
|
51
|
+
const theme = computed(() => (appConfig.ui as any)?.couponInput ?? {})
|
|
52
|
+
|
|
53
|
+
const slotClasses = computed(() => {
|
|
54
|
+
const base = theme.value?.slots ?? {}
|
|
55
|
+
const merge = (slot: string) => [base[slot], props.ui?.[slot as keyof typeof props.ui]]
|
|
56
|
+
return {
|
|
57
|
+
root: merge('root'),
|
|
58
|
+
input: merge('input'),
|
|
59
|
+
applied: merge('applied'),
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<div :class="['space-y-2', slotClasses.root]">
|
|
66
|
+
<!-- Applied coupon -->
|
|
67
|
+
<div v-if="appliedCoupon" :class="['flex items-center justify-between p-3 rounded-lg bg-success/10 ring ring-success/30', slotClasses.applied]">
|
|
68
|
+
<div class="flex items-center gap-2">
|
|
69
|
+
<UIcon name="i-heroicons-ticket" class="text-success" />
|
|
70
|
+
<span class="font-mono font-semibold text-sm text-highlighted">{{ appliedCoupon.code }}</span>
|
|
71
|
+
<UBadge color="success" size="xs" variant="subtle">
|
|
72
|
+
{{ appliedCoupon.promotion.discountType === 'percentage'
|
|
73
|
+
? `${appliedCoupon.promotion.discountValue}% off`
|
|
74
|
+
: t(appliedCoupon.promotion.name)
|
|
75
|
+
}}
|
|
76
|
+
</UBadge>
|
|
77
|
+
</div>
|
|
78
|
+
<UButton
|
|
79
|
+
icon="i-heroicons-x-mark-20-solid"
|
|
80
|
+
variant="ghost"
|
|
81
|
+
color="error"
|
|
82
|
+
size="xs"
|
|
83
|
+
@click="emit('remove')"
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<!-- Input form -->
|
|
88
|
+
<form v-else class="flex gap-2" @submit.prevent="handleApply">
|
|
89
|
+
<UInput
|
|
90
|
+
v-model="code"
|
|
91
|
+
:class="['flex-1 font-mono uppercase', slotClasses.input]"
|
|
92
|
+
placeholder="Enter coupon code"
|
|
93
|
+
:disabled="loading"
|
|
94
|
+
:color="error ? 'error' : undefined"
|
|
95
|
+
/>
|
|
96
|
+
<UButton type="submit" :loading="loading" variant="outline" color="neutral">
|
|
97
|
+
Apply
|
|
98
|
+
</UButton>
|
|
99
|
+
</form>
|
|
100
|
+
|
|
101
|
+
<!-- Error message -->
|
|
102
|
+
<p v-if="error" class="text-xs text-error">{{ error }}</p>
|
|
103
|
+
</div>
|
|
104
|
+
</template>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Promotion } from '@commercejs/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CPromoBanner — Promotional banner with countdown timer and CTA.
|
|
6
|
+
* Used for flash sales, limited-time offers, and campaign banners.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface PromoBannerProps {
|
|
10
|
+
promotion: Promotion
|
|
11
|
+
/** Banner variant */
|
|
12
|
+
variant?: 'inline' | 'full-width' | 'compact'
|
|
13
|
+
/** Custom background image URL */
|
|
14
|
+
backgroundImage?: string
|
|
15
|
+
/** Per-instance theme overrides */
|
|
16
|
+
ui?: Partial<{
|
|
17
|
+
root: any
|
|
18
|
+
content: any
|
|
19
|
+
timer: any
|
|
20
|
+
cta: any
|
|
21
|
+
}>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<PromoBannerProps>(), {
|
|
25
|
+
variant: 'inline',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const emit = defineEmits<{
|
|
29
|
+
'click': [promotion: Promotion]
|
|
30
|
+
}>()
|
|
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
|
+
// Countdown
|
|
39
|
+
const timeRemaining = ref({ days: 0, hours: 0, mins: 0, secs: 0 })
|
|
40
|
+
const hasEnded = ref(false)
|
|
41
|
+
let timer: ReturnType<typeof setInterval>
|
|
42
|
+
|
|
43
|
+
function updateTimer() {
|
|
44
|
+
if (!props.promotion.endsAt) return
|
|
45
|
+
const diff = new Date(props.promotion.endsAt).getTime() - Date.now()
|
|
46
|
+
if (diff <= 0) {
|
|
47
|
+
hasEnded.value = true
|
|
48
|
+
clearInterval(timer)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
timeRemaining.value = {
|
|
52
|
+
days: Math.floor(diff / 86400000),
|
|
53
|
+
hours: Math.floor((diff % 86400000) / 3600000),
|
|
54
|
+
mins: Math.floor((diff % 3600000) / 60000),
|
|
55
|
+
secs: Math.floor((diff % 60000) / 1000),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onMounted(() => {
|
|
60
|
+
updateTimer()
|
|
61
|
+
timer = setInterval(updateTimer, 1000)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
onUnmounted(() => clearInterval(timer))
|
|
65
|
+
|
|
66
|
+
// Discount display
|
|
67
|
+
const discountDisplay = computed(() => {
|
|
68
|
+
if (props.promotion.discountType === 'percentage') return `${props.promotion.discountValue}% OFF`
|
|
69
|
+
if (props.promotion.discountType === 'fixed_amount') return `${props.promotion.currency || ''} ${props.promotion.discountValue} OFF`
|
|
70
|
+
if (props.promotion.discountType === 'free_shipping') return 'FREE SHIPPING'
|
|
71
|
+
if (props.promotion.discountType === 'buy_x_get_y') return 'BOGO'
|
|
72
|
+
return 'SPECIAL OFFER'
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Resolve theme from app.config
|
|
76
|
+
const appConfig = useAppConfig()
|
|
77
|
+
const theme = computed(() => (appConfig.ui as any)?.promoBanner ?? {})
|
|
78
|
+
|
|
79
|
+
const slotClasses = computed(() => {
|
|
80
|
+
const base = theme.value?.slots ?? {}
|
|
81
|
+
const merge = (slot: string) => [base[slot], props.ui?.[slot as keyof typeof props.ui]]
|
|
82
|
+
return {
|
|
83
|
+
root: merge('root'),
|
|
84
|
+
content: merge('content'),
|
|
85
|
+
timer: merge('timer'),
|
|
86
|
+
cta: merge('cta'),
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<template>
|
|
92
|
+
<div
|
|
93
|
+
v-if="promotion.isActive && !hasEnded"
|
|
94
|
+
:class="[
|
|
95
|
+
'relative overflow-hidden rounded-xl',
|
|
96
|
+
variant === 'full-width' ? 'rounded-none' : '',
|
|
97
|
+
variant === 'compact' ? 'py-2 px-4' : 'py-6 px-6',
|
|
98
|
+
slotClasses.root,
|
|
99
|
+
]"
|
|
100
|
+
:style="backgroundImage ? { backgroundImage: `url(${backgroundImage})`, backgroundSize: 'cover', backgroundPosition: 'center' } : {}"
|
|
101
|
+
>
|
|
102
|
+
<!-- Gradient overlay for bg images -->
|
|
103
|
+
<div v-if="backgroundImage" class="absolute inset-0 bg-gradient-to-r from-black/80 to-black/40" />
|
|
104
|
+
|
|
105
|
+
<div :class="[
|
|
106
|
+
'relative z-10 flex items-center justify-between gap-4',
|
|
107
|
+
variant === 'compact' ? 'flex-row' : 'flex-col md:flex-row',
|
|
108
|
+
slotClasses.content,
|
|
109
|
+
]">
|
|
110
|
+
<!-- Left: Promo info -->
|
|
111
|
+
<div :class="variant === 'compact' ? '' : 'text-center md:text-start'">
|
|
112
|
+
<span :class="[
|
|
113
|
+
'font-black tracking-tight',
|
|
114
|
+
variant === 'compact' ? 'text-lg' : 'text-2xl md:text-3xl',
|
|
115
|
+
backgroundImage ? 'text-white' : 'text-highlighted',
|
|
116
|
+
]">
|
|
117
|
+
{{ discountDisplay }}
|
|
118
|
+
</span>
|
|
119
|
+
<p :class="[
|
|
120
|
+
'text-sm mt-0.5',
|
|
121
|
+
backgroundImage ? 'text-white/80' : 'text-muted',
|
|
122
|
+
]">
|
|
123
|
+
{{ t(promotion.name) }}
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<!-- Center: Countdown -->
|
|
128
|
+
<div v-if="promotion.endsAt" :class="['flex gap-2', slotClasses.timer]">
|
|
129
|
+
<div v-for="(val, label) in { d: timeRemaining.days, h: timeRemaining.hours, m: timeRemaining.mins, s: timeRemaining.secs }" :key="label"
|
|
130
|
+
:class="[
|
|
131
|
+
'text-center rounded-lg px-2 py-1',
|
|
132
|
+
backgroundImage ? 'bg-white/20 backdrop-blur-sm text-white' : 'bg-elevated text-highlighted',
|
|
133
|
+
]"
|
|
134
|
+
>
|
|
135
|
+
<span class="text-lg font-bold font-mono block leading-tight">{{ String(val).padStart(2, '0') }}</span>
|
|
136
|
+
<span class="text-[10px] uppercase opacity-70">{{ label }}</span>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<!-- Right: CTA -->
|
|
141
|
+
<slot name="action">
|
|
142
|
+
<UButton
|
|
143
|
+
:color="backgroundImage ? 'white' : 'primary'"
|
|
144
|
+
size="sm"
|
|
145
|
+
:class="slotClasses.cta"
|
|
146
|
+
@click="emit('click', promotion)"
|
|
147
|
+
>
|
|
148
|
+
Shop Now
|
|
149
|
+
</UButton>
|
|
150
|
+
</slot>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</template>
|