@mundogamernetwork/shared-ui 1.1.4 → 1.1.7
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/components/checkout/MgCartItemList.vue +94 -43
- package/components/checkout/MgCartSummary.vue +100 -137
- package/components/checkout/MgCouponInput.vue +211 -0
- package/components/ui/MgConfirmationPage.vue +415 -0
- package/composables/useConfirmation.ts +34 -5
- package/composables/useMgCheckout.ts +398 -166
- package/package.json +2 -2
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* MgCouponInput — standalone coupon input component.
|
|
4
|
+
*
|
|
5
|
+
* Can be used in two modes:
|
|
6
|
+
* 1. Wired mode: inject useMgCheckout context (call provideMgCheckout in parent)
|
|
7
|
+
* 2. Prop/event mode: pass couponInfo, couponLoading, couponError as props
|
|
8
|
+
* and listen to apply-coupon / remove-coupon events.
|
|
9
|
+
*/
|
|
10
|
+
import { ref, computed } from "vue";
|
|
11
|
+
import { injectMgCheckout } from "../../composables/useMgCheckout";
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
/** Applied coupon object (prop mode). If null, shows the input. */
|
|
15
|
+
couponInfo?: { code: string; discount?: number; name?: string } | null;
|
|
16
|
+
loading?: boolean;
|
|
17
|
+
error?: string | null;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
/** If true, the component manages its own state via injectMgCheckout */
|
|
20
|
+
autoWire?: boolean;
|
|
21
|
+
}>();
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits<{
|
|
24
|
+
"apply-coupon": [code: string];
|
|
25
|
+
"remove-coupon": [];
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
// Try to auto-wire to parent checkout context
|
|
29
|
+
const checkout = injectMgCheckout();
|
|
30
|
+
|
|
31
|
+
const couponInput = ref("");
|
|
32
|
+
const _localLoading = ref(false);
|
|
33
|
+
const _localError = ref<string | null>(null);
|
|
34
|
+
|
|
35
|
+
const activeCoupon = computed(() =>
|
|
36
|
+
props.autoWire ? checkout?.couponInfo.value : props.couponInfo
|
|
37
|
+
);
|
|
38
|
+
const isLoading = computed(() =>
|
|
39
|
+
props.autoWire ? checkout?.couponLoading.value : (props.loading ?? _localLoading.value)
|
|
40
|
+
);
|
|
41
|
+
const couponError = computed(() =>
|
|
42
|
+
props.autoWire ? checkout?.couponError.value : (props.error ?? _localError.value)
|
|
43
|
+
);
|
|
44
|
+
const isDisabled = computed(() => props.disabled || isLoading.value);
|
|
45
|
+
|
|
46
|
+
async function handleApply() {
|
|
47
|
+
const code = couponInput.value.trim().toUpperCase();
|
|
48
|
+
if (!code || isDisabled.value) return;
|
|
49
|
+
|
|
50
|
+
if (props.autoWire && checkout) {
|
|
51
|
+
checkout.couponCode.value = code;
|
|
52
|
+
await checkout.applyCoupon(code);
|
|
53
|
+
} else {
|
|
54
|
+
emit("apply-coupon", code);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function handleRemove() {
|
|
59
|
+
if (props.autoWire && checkout) {
|
|
60
|
+
await checkout.removeCoupon();
|
|
61
|
+
couponInput.value = "";
|
|
62
|
+
} else {
|
|
63
|
+
couponInput.value = "";
|
|
64
|
+
emit("remove-coupon");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Clear input after coupon applied successfully
|
|
69
|
+
watch(
|
|
70
|
+
() => activeCoupon.value,
|
|
71
|
+
(v) => { if (v) couponInput.value = ""; }
|
|
72
|
+
);
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<div class="mg-coupon">
|
|
77
|
+
<!-- Applied state -->
|
|
78
|
+
<div v-if="activeCoupon" class="mg-coupon__applied">
|
|
79
|
+
<span class="mg-coupon__badge">
|
|
80
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
81
|
+
<polyline points="20 6 9 17 4 12" />
|
|
82
|
+
</svg>
|
|
83
|
+
{{ activeCoupon.code }}
|
|
84
|
+
<span v-if="activeCoupon.discount">(-{{ activeCoupon.discount }}%)</span>
|
|
85
|
+
</span>
|
|
86
|
+
<button class="mg-coupon__remove" :disabled="isLoading" @click="handleRemove" :title="$t?.('checkout.coupon.remove') ?? 'Remove coupon'">
|
|
87
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
88
|
+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
89
|
+
</svg>
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<!-- Input state -->
|
|
94
|
+
<div v-else class="mg-coupon__form">
|
|
95
|
+
<input
|
|
96
|
+
v-model="couponInput"
|
|
97
|
+
type="text"
|
|
98
|
+
class="mg-coupon__input"
|
|
99
|
+
:class="{ 'mg-coupon__input--error': couponError }"
|
|
100
|
+
:placeholder="$t?.('checkout.coupon.placeholder') ?? 'Discount code'"
|
|
101
|
+
:disabled="isDisabled"
|
|
102
|
+
style="text-transform: uppercase"
|
|
103
|
+
@keyup.enter="handleApply"
|
|
104
|
+
/>
|
|
105
|
+
<button
|
|
106
|
+
class="mg-coupon__btn"
|
|
107
|
+
:disabled="!couponInput.trim() || isDisabled"
|
|
108
|
+
@click="handleApply"
|
|
109
|
+
>
|
|
110
|
+
<span v-if="isLoading" class="mg-coupon__spinner" />
|
|
111
|
+
<template v-else>{{ $t?.('checkout.coupon.apply') ?? 'Apply' }}</template>
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<p v-if="couponError" class="mg-coupon__error">{{ couponError }}</p>
|
|
116
|
+
</div>
|
|
117
|
+
</template>
|
|
118
|
+
|
|
119
|
+
<style lang="scss" scoped>
|
|
120
|
+
.mg-coupon {
|
|
121
|
+
width: 100%;
|
|
122
|
+
|
|
123
|
+
&__form {
|
|
124
|
+
display: flex;
|
|
125
|
+
gap: 8px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
&__input {
|
|
129
|
+
flex: 1;
|
|
130
|
+
padding: 10px 12px;
|
|
131
|
+
border: 1px solid var(--inactive, #808080);
|
|
132
|
+
background: transparent;
|
|
133
|
+
color: var(--active, #fff);
|
|
134
|
+
font-size: 14px;
|
|
135
|
+
outline: none;
|
|
136
|
+
transition: border-color 0.15s;
|
|
137
|
+
|
|
138
|
+
&::placeholder { color: var(--inactive, #9ca3af); text-transform: none; }
|
|
139
|
+
&:focus { border-color: var(--highlight-color, #4f46e5); }
|
|
140
|
+
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
141
|
+
&--error { border-color: #ee3831; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
&__btn {
|
|
145
|
+
padding: 10px 18px;
|
|
146
|
+
background: var(--highlight-color, #4f46e5);
|
|
147
|
+
color: #fff;
|
|
148
|
+
border: none;
|
|
149
|
+
font-size: 14px;
|
|
150
|
+
font-weight: 600;
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
white-space: nowrap;
|
|
153
|
+
transition: opacity 0.15s;
|
|
154
|
+
|
|
155
|
+
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
156
|
+
&:hover:not(:disabled) { opacity: 0.85; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
&__applied {
|
|
160
|
+
display: flex;
|
|
161
|
+
align-items: center;
|
|
162
|
+
gap: 8px;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
&__badge {
|
|
166
|
+
display: inline-flex;
|
|
167
|
+
align-items: center;
|
|
168
|
+
gap: 5px;
|
|
169
|
+
padding: 6px 12px;
|
|
170
|
+
background: rgba(34, 197, 94, 0.12);
|
|
171
|
+
color: #22c55e;
|
|
172
|
+
font-size: 13px;
|
|
173
|
+
font-weight: 600;
|
|
174
|
+
border: 1px solid rgba(34, 197, 94, 0.3);
|
|
175
|
+
|
|
176
|
+
svg { flex-shrink: 0; }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
&__remove {
|
|
180
|
+
background: none;
|
|
181
|
+
border: none;
|
|
182
|
+
color: var(--inactive, #6b7280);
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
padding: 4px;
|
|
185
|
+
display: flex;
|
|
186
|
+
align-items: center;
|
|
187
|
+
transition: color 0.15s;
|
|
188
|
+
|
|
189
|
+
&:hover { color: #ee3831; }
|
|
190
|
+
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
&__error {
|
|
194
|
+
color: #ee3831;
|
|
195
|
+
font-size: 12px;
|
|
196
|
+
margin-top: 4px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
&__spinner {
|
|
200
|
+
display: inline-block;
|
|
201
|
+
width: 14px;
|
|
202
|
+
height: 14px;
|
|
203
|
+
border: 2px solid rgba(255,255,255,0.3);
|
|
204
|
+
border-top-color: #fff;
|
|
205
|
+
border-radius: 50%;
|
|
206
|
+
animation: mg-coupon-spin 0.6s linear infinite;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
@keyframes mg-coupon-spin { to { transform: rotate(360deg); } }
|
|
211
|
+
</style>
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="mg-confirmation">
|
|
3
|
+
<!-- Section 1: Status -->
|
|
4
|
+
<div class="mg-confirmation__status-card">
|
|
5
|
+
<h2>
|
|
6
|
+
<slot v-if="displayStatus === 'paid'" name="title-paid">{{ $t("confirmation_page.section_1.title") }}</slot>
|
|
7
|
+
<slot v-else-if="displayStatus === 'pending'" name="title-pending">{{ $t("confirmation_page.section_1.title_pending") }}</slot>
|
|
8
|
+
<slot v-else name="title-failed">{{ $t("confirmation_page.section_1.title_failed") }}</slot>
|
|
9
|
+
</h2>
|
|
10
|
+
|
|
11
|
+
<div class="mg-confirmation__status-card__desc">
|
|
12
|
+
<template v-if="displayStatus === 'paid'">
|
|
13
|
+
<slot name="desc-paid">
|
|
14
|
+
{{ $t("confirmation_page.section_1.description_1") }}<br />
|
|
15
|
+
{{ $t("confirmation_page.section_1.description_2") }}
|
|
16
|
+
{{ cartData.user?.email ?? cartData.user_email }}
|
|
17
|
+
{{ $t("confirmation_page.section_1.description_3") }}
|
|
18
|
+
</slot>
|
|
19
|
+
</template>
|
|
20
|
+
<template v-else-if="displayStatus === 'pending'">
|
|
21
|
+
<slot name="desc-pending">
|
|
22
|
+
{{ $t("confirmation_page.section_1.description_pending") }}
|
|
23
|
+
<p v-if="processingTooLong" class="mg-confirmation__too-long">
|
|
24
|
+
{{ $t("confirmation_page.section_1.processing_too_long") }}
|
|
25
|
+
</p>
|
|
26
|
+
</slot>
|
|
27
|
+
</template>
|
|
28
|
+
<template v-else>
|
|
29
|
+
<slot name="desc-failed">{{ $t("confirmation_page.section_1.description_failed") }}</slot>
|
|
30
|
+
</template>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<!-- Status badge -->
|
|
34
|
+
<div class="mg-confirmation__status-badge" :class="displayStatus">
|
|
35
|
+
<span v-if="displayStatus === 'pending'" class="mg-confirmation__loader" />
|
|
36
|
+
<svg v-else-if="displayStatus === 'paid'" xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
|
|
37
|
+
<path d="M15.1405 26.0394L8.81875 19.7176L6.66602 21.8552L15.1405 30.3297L33.3327 12.1376L31.1951 10L15.1405 26.0394Z" fill="var(--text-primary-color, #fff)" />
|
|
38
|
+
</svg>
|
|
39
|
+
<svg v-else width="40" height="41" viewBox="0 0 40 41" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
40
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.3696 20.4707L8.82244 11.9395L11.1043 9.65332L19.6696 18.2026L28.2008 9.65545L30.487 11.9373L21.9376 20.5026L30.4848 29.0338L28.203 31.32L19.6377 22.7707L11.1065 31.3179L8.82031 29.036L17.3696 20.4707Z" fill="#fff" />
|
|
41
|
+
</svg>
|
|
42
|
+
<span>{{ statusLabel }}</span>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<!-- Actions -->
|
|
46
|
+
<div class="mg-confirmation__actions">
|
|
47
|
+
<template v-if="displayStatus === 'pending'">
|
|
48
|
+
<span class="mg-confirmation__actions__hint">
|
|
49
|
+
{{ $t("confirmation_page.section_1.obs") }}
|
|
50
|
+
<a v-if="cartData.payment_link" :href="cartData.payment_link" target="_blank" class="mg-confirmation__link">
|
|
51
|
+
{{ $t("confirmation_page.section_1.retry_payment") }}
|
|
52
|
+
</a>
|
|
53
|
+
</span>
|
|
54
|
+
</template>
|
|
55
|
+
<template v-if="displayStatus === 'failed' || displayStatus === 'canceled'">
|
|
56
|
+
<a v-if="cartData.payment_link" :href="cartData.payment_link" target="_blank" class="mg-confirmation__btn-retry">
|
|
57
|
+
{{ $t("confirmation_page.section_1.retry_payment") }}
|
|
58
|
+
</a>
|
|
59
|
+
<button class="mg-confirmation__btn-back" @click="$emit('back-to-pricing')">
|
|
60
|
+
{{ $t("confirmation_page.back_to_cart") }}
|
|
61
|
+
</button>
|
|
62
|
+
</template>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- Section 2: CTA / Guest slot -->
|
|
67
|
+
<slot name="cta" />
|
|
68
|
+
|
|
69
|
+
<!-- Section 2 default: guest create account -->
|
|
70
|
+
<div v-if="!signedIn && !$slots.cta" class="mg-confirmation__guest-card">
|
|
71
|
+
<div>
|
|
72
|
+
<h4>
|
|
73
|
+
{{ $t("confirmation_page.section_2.offline.title1") }}
|
|
74
|
+
<span>{{ $t("confirmation_page.section_2.offline.title2") }}</span>
|
|
75
|
+
</h4>
|
|
76
|
+
<p>{{ $t("confirmation_page.section_2.offline.text") }}</p>
|
|
77
|
+
</div>
|
|
78
|
+
<button class="btn primary" @click="$emit('create-account')">
|
|
79
|
+
{{ $t("confirmation_page.section_2.offline.create_account") }}
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<!-- Section 3: Order summary -->
|
|
84
|
+
<div v-if="showOrderSummary" class="mg-confirmation__summary">
|
|
85
|
+
<h4>{{ $t("confirmation_page.section_3.title") }}</h4>
|
|
86
|
+
<div class="mg-confirmation__summary__cards">
|
|
87
|
+
<!-- Items card -->
|
|
88
|
+
<div class="mg-confirmation__summary__card">
|
|
89
|
+
<div v-if="cartItems.length" class="mg-confirmation__summary__items">
|
|
90
|
+
<div v-for="(item, i) in cartItems" :key="i" class="mg-confirmation__summary__item">
|
|
91
|
+
<span>{{ item.item?.localized_name ?? item.item?.name ?? $t("confirmation_page.section_3.item") }}</span>
|
|
92
|
+
<span>{{ cartData.currency?.data?.symbol }}{{ item.price }}</span>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
<!-- Coupon / totals -->
|
|
96
|
+
<div v-if="cartData.discount_coupon" class="mg-confirmation__summary__coupon">
|
|
97
|
+
{{ $t("confirmation_page.section_3.text_2") }} {{ cartData.discount_coupon.code }}
|
|
98
|
+
</div>
|
|
99
|
+
<div v-if="cartData.discount_coupon" class="mg-confirmation__summary__discount">
|
|
100
|
+
<s>{{ cartData.formatted_original_total }}</s>
|
|
101
|
+
- {{ cartData.formatted_total_discount }}
|
|
102
|
+
</div>
|
|
103
|
+
<div class="mg-confirmation__summary__total">
|
|
104
|
+
<span>{{ $t("confirmation_page.section_3.text_3") }} {{ cartData.formatted_total ?? cartData.formatted_total_with_discount }}</span>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<!-- Meta card -->
|
|
109
|
+
<div class="mg-confirmation__summary__card">
|
|
110
|
+
<div v-if="cartData.reference" class="mg-confirmation__summary__meta-row">
|
|
111
|
+
{{ $t("confirmation_page.section_3.text_4") }}
|
|
112
|
+
<span>{{ cartData.reference }}</span>
|
|
113
|
+
</div>
|
|
114
|
+
<div v-if="displayStatus === 'paid' && (cartData.paid_at || cartData.created_at)" class="mg-confirmation__summary__meta-row">
|
|
115
|
+
{{ $t("confirmation_page.section_3.text_5") }}
|
|
116
|
+
<span>{{ formatDate(cartData.paid_at ?? cartData.created_at) }}</span>
|
|
117
|
+
</div>
|
|
118
|
+
<div v-if="hasKnownPaymentMethod" class="mg-confirmation__summary__meta-row">
|
|
119
|
+
{{ $t("confirmation_page.section_3.text_6") }}:
|
|
120
|
+
<span>
|
|
121
|
+
<slot name="payment-method-icon" :method-id="paymentMethodId">
|
|
122
|
+
<!-- Fallback: show gateway name -->
|
|
123
|
+
{{ paymentMethodId === 3 ? 'Stripe' : 'PayPal' }}
|
|
124
|
+
</slot>
|
|
125
|
+
</span>
|
|
126
|
+
</div>
|
|
127
|
+
<slot name="meta-extra" />
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<!-- Custom extra sections -->
|
|
133
|
+
<slot name="extra" />
|
|
134
|
+
</div>
|
|
135
|
+
</template>
|
|
136
|
+
|
|
137
|
+
<script setup lang="ts">
|
|
138
|
+
import type { PropType } from "vue";
|
|
139
|
+
import type { ConfirmationInvoice, OrderStatus } from "../../composables/useConfirmation";
|
|
140
|
+
|
|
141
|
+
const props = defineProps({
|
|
142
|
+
displayStatus: {
|
|
143
|
+
type: String as PropType<OrderStatus>,
|
|
144
|
+
default: "",
|
|
145
|
+
},
|
|
146
|
+
cartData: {
|
|
147
|
+
type: Object as PropType<ConfirmationInvoice>,
|
|
148
|
+
default: () => ({}),
|
|
149
|
+
},
|
|
150
|
+
processingTooLong: {
|
|
151
|
+
type: Boolean,
|
|
152
|
+
default: false,
|
|
153
|
+
},
|
|
154
|
+
paymentMethodId: {
|
|
155
|
+
type: Number,
|
|
156
|
+
default: 2,
|
|
157
|
+
},
|
|
158
|
+
hasKnownPaymentMethod: {
|
|
159
|
+
type: Boolean,
|
|
160
|
+
default: false,
|
|
161
|
+
},
|
|
162
|
+
signedIn: {
|
|
163
|
+
type: Boolean,
|
|
164
|
+
default: false,
|
|
165
|
+
},
|
|
166
|
+
showOrderSummary: {
|
|
167
|
+
type: Boolean,
|
|
168
|
+
default: true,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
defineEmits<{
|
|
173
|
+
"back-to-pricing": [];
|
|
174
|
+
"create-account": [];
|
|
175
|
+
}>();
|
|
176
|
+
|
|
177
|
+
const { $i18n } = useNuxtApp();
|
|
178
|
+
const $t = $i18n.t;
|
|
179
|
+
|
|
180
|
+
const cartItems = computed(() => {
|
|
181
|
+
const items = props.cartData?.items;
|
|
182
|
+
if (!items) return [];
|
|
183
|
+
if (Array.isArray(items)) return items;
|
|
184
|
+
return (items as any).data ?? [];
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const statusLabel = computed(() => {
|
|
188
|
+
const map: Record<string, string> = {
|
|
189
|
+
paid: $t("confirmation_page.section_1.confirmation"),
|
|
190
|
+
pending: $t("confirmation_page.section_1.processing"),
|
|
191
|
+
failed: $t("confirmation_page.section_1.failed"),
|
|
192
|
+
canceled: $t("confirmation_page.section_1.canceled"),
|
|
193
|
+
};
|
|
194
|
+
return map[props.displayStatus] ?? "";
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
function formatDate(raw?: string): string {
|
|
198
|
+
if (!raw) return "";
|
|
199
|
+
try {
|
|
200
|
+
return new Date(raw).toLocaleString($i18n.locale.value, {
|
|
201
|
+
year: "numeric", month: "short", day: "numeric",
|
|
202
|
+
hour: "2-digit", minute: "2-digit",
|
|
203
|
+
});
|
|
204
|
+
} catch { return raw; }
|
|
205
|
+
}
|
|
206
|
+
</script>
|
|
207
|
+
|
|
208
|
+
<style lang="scss" scoped>
|
|
209
|
+
.mg-confirmation {
|
|
210
|
+
display: flex;
|
|
211
|
+
flex-direction: column;
|
|
212
|
+
gap: 2rem;
|
|
213
|
+
|
|
214
|
+
&__status-card {
|
|
215
|
+
display: flex;
|
|
216
|
+
flex-direction: column;
|
|
217
|
+
gap: 1.5rem;
|
|
218
|
+
padding: 2.5rem;
|
|
219
|
+
background: var(--body-bg-card, var(--card-article-bg, #1a1a2e));
|
|
220
|
+
|
|
221
|
+
h2 {
|
|
222
|
+
font-size: 1.75rem;
|
|
223
|
+
font-weight: 600;
|
|
224
|
+
color: var(--active, var(--text-primary-color, #fff));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
&__status-card__desc {
|
|
229
|
+
font-size: 1rem;
|
|
230
|
+
line-height: 1.5;
|
|
231
|
+
color: var(--active, var(--text-primary-color, #ccc));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
&__too-long {
|
|
235
|
+
font-size: 0.75rem;
|
|
236
|
+
color: var(--secondary-info-fg, #999);
|
|
237
|
+
margin-top: 0.5rem;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
&__status-badge {
|
|
241
|
+
display: flex;
|
|
242
|
+
align-items: center;
|
|
243
|
+
gap: 1rem;
|
|
244
|
+
padding: 1.5rem;
|
|
245
|
+
font-size: 1.125rem;
|
|
246
|
+
font-weight: 600;
|
|
247
|
+
color: #fff;
|
|
248
|
+
|
|
249
|
+
&.paid { background: #1f7700; }
|
|
250
|
+
&.pending { background: #ffd600; color: #000; }
|
|
251
|
+
&.failed,
|
|
252
|
+
&.canceled { background: #a22a17; }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
&__loader {
|
|
256
|
+
display: inline-block;
|
|
257
|
+
width: 20px;
|
|
258
|
+
height: 20px;
|
|
259
|
+
border-radius: 50%;
|
|
260
|
+
animation: mg-rotate 1s linear infinite;
|
|
261
|
+
position: relative;
|
|
262
|
+
|
|
263
|
+
&::before {
|
|
264
|
+
content: "";
|
|
265
|
+
box-sizing: border-box;
|
|
266
|
+
position: absolute;
|
|
267
|
+
inset: 0;
|
|
268
|
+
border-radius: 50%;
|
|
269
|
+
border: 2px solid currentColor;
|
|
270
|
+
animation: mg-clip 2s linear infinite;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
&__actions {
|
|
275
|
+
display: flex;
|
|
276
|
+
flex-direction: column;
|
|
277
|
+
align-items: center;
|
|
278
|
+
gap: 0.75rem;
|
|
279
|
+
|
|
280
|
+
&__hint {
|
|
281
|
+
font-size: 0.8125rem;
|
|
282
|
+
color: var(--secondary-info-fg, #999);
|
|
283
|
+
text-align: center;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
&__link {
|
|
288
|
+
color: var(--highlight-color, #1c5d6f);
|
|
289
|
+
text-decoration: underline;
|
|
290
|
+
cursor: pointer;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
&__btn-retry {
|
|
294
|
+
display: inline-flex;
|
|
295
|
+
align-items: center;
|
|
296
|
+
justify-content: center;
|
|
297
|
+
padding: 10px 24px;
|
|
298
|
+
background: var(--highlight-color, #1c5d6f);
|
|
299
|
+
color: #fff;
|
|
300
|
+
border: 1px solid var(--highlight-color, #1c5d6f);
|
|
301
|
+
font-size: 0.875rem;
|
|
302
|
+
cursor: pointer;
|
|
303
|
+
text-decoration: none;
|
|
304
|
+
transition: opacity 0.2s;
|
|
305
|
+
|
|
306
|
+
&:hover { opacity: 0.85; }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
&__btn-back {
|
|
310
|
+
display: inline-flex;
|
|
311
|
+
align-items: center;
|
|
312
|
+
justify-content: center;
|
|
313
|
+
padding: 10px 24px;
|
|
314
|
+
background: transparent;
|
|
315
|
+
color: var(--highlight-color, #1c5d6f);
|
|
316
|
+
border: 1px solid var(--highlight-color, #1c5d6f);
|
|
317
|
+
font-size: 0.875rem;
|
|
318
|
+
cursor: pointer;
|
|
319
|
+
transition: all 0.2s;
|
|
320
|
+
|
|
321
|
+
&:hover { background: var(--highlight-color, #1c5d6f); color: #fff; }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
&__guest-card {
|
|
325
|
+
display: flex;
|
|
326
|
+
justify-content: space-between;
|
|
327
|
+
align-items: center;
|
|
328
|
+
gap: 1.5rem;
|
|
329
|
+
padding: 1.5rem 2.5rem;
|
|
330
|
+
background: var(--body-bg-card, #1a1a2e);
|
|
331
|
+
border-bottom: 2px solid var(--highlight-color, #1c5d6f);
|
|
332
|
+
|
|
333
|
+
h4 { font-size: 1.125rem; font-weight: 600; color: var(--active, #fff); span { color: var(--highlight-color); } }
|
|
334
|
+
p { font-size: 0.75rem; color: var(--secondary-info-fg, #999); margin: 0.25rem 0 0; }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
&__summary {
|
|
338
|
+
display: flex;
|
|
339
|
+
flex-direction: column;
|
|
340
|
+
gap: 1.5rem;
|
|
341
|
+
|
|
342
|
+
h4 {
|
|
343
|
+
font-size: 1.125rem;
|
|
344
|
+
font-weight: 600;
|
|
345
|
+
color: var(--active, #fff);
|
|
346
|
+
padding-left: 1rem;
|
|
347
|
+
border-left: 2px solid var(--highlight-color, #1c5d6f);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
&__cards {
|
|
351
|
+
display: flex;
|
|
352
|
+
gap: 0.5rem;
|
|
353
|
+
align-items: flex-start;
|
|
354
|
+
|
|
355
|
+
@media (max-width: 768px) { flex-direction: column; }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
&__card {
|
|
359
|
+
flex: 1;
|
|
360
|
+
padding: 2.5rem;
|
|
361
|
+
background: var(--body-bg-card, #1a1a2e);
|
|
362
|
+
display: flex;
|
|
363
|
+
flex-direction: column;
|
|
364
|
+
gap: 1rem;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
&__item {
|
|
368
|
+
display: flex;
|
|
369
|
+
justify-content: space-between;
|
|
370
|
+
font-size: 1rem;
|
|
371
|
+
color: var(--active, #fff);
|
|
372
|
+
padding-bottom: 1rem;
|
|
373
|
+
border-bottom: 1px solid #808080;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
&__coupon,
|
|
377
|
+
&__discount {
|
|
378
|
+
font-size: 0.875rem;
|
|
379
|
+
color: var(--secondary-info-fg, #999);
|
|
380
|
+
text-align: right;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
&__total {
|
|
384
|
+
text-align: right;
|
|
385
|
+
span {
|
|
386
|
+
font-size: 1.5rem;
|
|
387
|
+
font-weight: 600;
|
|
388
|
+
color: var(--highlight-color, #1c5d6f);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
&__meta-row {
|
|
393
|
+
font-size: 1rem;
|
|
394
|
+
font-weight: 600;
|
|
395
|
+
color: var(--active, #fff);
|
|
396
|
+
display: flex;
|
|
397
|
+
align-items: center;
|
|
398
|
+
gap: 0.25rem;
|
|
399
|
+
padding-bottom: 1.25rem;
|
|
400
|
+
border-bottom: 1px solid #808080;
|
|
401
|
+
|
|
402
|
+
span { font-size: 0.875rem; font-weight: 400; color: var(--secondary-info-fg, #999); }
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
@keyframes mg-rotate { 100% { transform: rotate(360deg); } }
|
|
408
|
+
@keyframes mg-clip {
|
|
409
|
+
0% { clip-path: polygon(50% 50%, 0 0, 0 0, 0 0, 0 0, 0 0); }
|
|
410
|
+
25% { clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 0, 100% 0, 100% 0); }
|
|
411
|
+
50% { clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%); }
|
|
412
|
+
75% { clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%); }
|
|
413
|
+
100% { clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0); }
|
|
414
|
+
}
|
|
415
|
+
</style>
|
|
@@ -22,7 +22,19 @@ export interface ConfirmationInvoice {
|
|
|
22
22
|
[key: string]: any;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export
|
|
25
|
+
export interface UseConfirmationOptions {
|
|
26
|
+
/** Called once when displayStatus transitions to "paid". Use to refresh subscription/auth. */
|
|
27
|
+
onPaid?: () => Promise<void> | void;
|
|
28
|
+
/** Called once when displayStatus transitions to "failed". */
|
|
29
|
+
onFailed?: () => Promise<void> | void;
|
|
30
|
+
/** Called once when displayStatus transitions to "canceled". */
|
|
31
|
+
onCanceled?: () => Promise<void> | void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useConfirmation(
|
|
35
|
+
fetchInvoice: (id: string) => Promise<ConfirmationInvoice>,
|
|
36
|
+
options?: UseConfirmationOptions,
|
|
37
|
+
) {
|
|
26
38
|
const invoiceId = ref<string>("");
|
|
27
39
|
const cartData = ref<ConfirmationInvoice>({});
|
|
28
40
|
const displayStatus = ref<OrderStatus>("");
|
|
@@ -30,6 +42,16 @@ export function useConfirmation(fetchInvoice: (id: string) => Promise<Confirmati
|
|
|
30
42
|
|
|
31
43
|
const LONG_PENDING_MS = 60_000;
|
|
32
44
|
let longPendingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
45
|
+
let _paidFired = false;
|
|
46
|
+
|
|
47
|
+
async function _firePaid() {
|
|
48
|
+
if (_paidFired) return;
|
|
49
|
+
_paidFired = true;
|
|
50
|
+
displayStatus.value = "paid";
|
|
51
|
+
if (options?.onPaid) {
|
|
52
|
+
try { await options.onPaid(); } catch { /* silent — don't block UI */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
33
55
|
|
|
34
56
|
// PayPal = 2, Stripe = 3
|
|
35
57
|
const paymentMethodId = computed(() => {
|
|
@@ -62,8 +84,13 @@ export function useConfirmation(fetchInvoice: (id: string) => Promise<Confirmati
|
|
|
62
84
|
try {
|
|
63
85
|
const data = await fetchInvoice(invoiceId.value);
|
|
64
86
|
cartData.value = data;
|
|
65
|
-
|
|
66
|
-
|
|
87
|
+
const status = (data.status as OrderStatus) ?? "";
|
|
88
|
+
if (status === "paid") {
|
|
89
|
+
await _firePaid();
|
|
90
|
+
} else {
|
|
91
|
+
displayStatus.value = status;
|
|
92
|
+
}
|
|
93
|
+
return status !== "pending" && status !== "";
|
|
67
94
|
} catch {
|
|
68
95
|
return false;
|
|
69
96
|
}
|
|
@@ -79,15 +106,17 @@ export function useConfirmation(fetchInvoice: (id: string) => Promise<Confirmati
|
|
|
79
106
|
const { cancelSafetyNets } = usePaymentListener(
|
|
80
107
|
invoiceId,
|
|
81
108
|
() => getInvoiceData(),
|
|
82
|
-
() => {
|
|
109
|
+
async () => {
|
|
83
110
|
cancelSafetyNets();
|
|
84
111
|
cancelLongPendingTimer();
|
|
85
112
|
displayStatus.value = "failed";
|
|
113
|
+
if (options?.onFailed) try { await options.onFailed(); } catch { /* silent */ }
|
|
86
114
|
},
|
|
87
|
-
() => {
|
|
115
|
+
async () => {
|
|
88
116
|
cancelSafetyNets();
|
|
89
117
|
cancelLongPendingTimer();
|
|
90
118
|
displayStatus.value = "canceled";
|
|
119
|
+
if (options?.onCanceled) try { await options.onCanceled(); } catch { /* silent */ }
|
|
91
120
|
},
|
|
92
121
|
);
|
|
93
122
|
|