PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / 2.4.6
Booking for Appointments and Events Calendar – Amelia v2.4.6
2.4.8 2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 2.4.1 2.4 trunk 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.15 1.2.16 1.2.17 1.2.18 1.2.19 1.2.2 1.2.20 1.2.21 1.2.22 1.2.23 1.2.24 1.2.25 1.2.26 1.2.27 1.2.28 1.2.29 1.2.3 1.2.30 1.2.31 1.2.32 1.2.33 1.2.34 1.2.35 1.2.36 1.2.37 1.2.38 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.1.3 2.2 2.2.1 2.3
ameliabooking / v3 / src / views / public / Parts / Payment / Methods / PaymentSquare.vue
ameliabooking / v3 / src / views / public / Parts / Payment / Methods Last commit date
PaymentCommon.vue 1 month ago PaymentOnSite.vue 1 month ago PaymentPayPal.vue 1 month ago PaymentSquare.vue 1 month ago PaymentStripe.vue 1 month ago PaymentWc.vue 1 month ago
PaymentSquare.vue
485 lines
1 <template>
2 <div>
3 <div v-show="squareLoading" class="am-fs__square-loading" :style="cssVars">
4 <!-- Skeleton -->
5 <el-skeleton animated>
6 <el-skeleton-item />
7 </el-skeleton>
8 <!-- /Skeleton -->
9 </div>
10 <!-- Credit Card via Square-->
11 <div v-show="!squareLoading" class="am-fs__payment-square" :style="cssVars">
12 <div class="am-fs__payment-square__google-pay">
13 <div id="google-pay-button" />
14 </div>
15
16 <div v-if="applePayReady" class="am-fs__payment-square__apple-pay">
17 <div id="apple-pay-button" />
18 </div>
19
20 <div class="am-fs__payment-divider">
21 <span class="am-divider-text">{{ amLabels.payment_or_pay_with_card }}</span>
22 </div>
23
24 <div id="payment-status-container"></div>
25 <div id="card-container"></div>
26 </div>
27 <!-- /Credit Card via Square-->
28 </div>
29 </template>
30
31 <script setup>
32 import { computed, inject, onMounted, ref, watchEffect, nextTick, watch } from 'vue'
33 import {
34 getErrorMessage,
35 useBookingData,
36 useCreateBooking,
37 useCreateBookingError,
38 useCreateBookingSuccess,
39 } from '../../../../../assets/js/public/booking.js'
40 import { useColorTransparency } from '../../../../../assets/js/common/colorManipulation.js'
41 import { useStore } from 'vuex'
42 import httpClient from '../../../../../plugins/axios'
43 import { useScrollTo } from '../../../../../assets/js/common/scrollElements'
44
45 // * Global settings
46 const amSettings = inject('settings')
47 const store = useStore()
48
49 // * Labels
50 const amLabels = inject('amLabels')
51
52 // * Colors
53 let amColors = inject('amColors')
54
55 // * Css variables
56 let cssVars = computed(() => {
57 return {
58 '--am-c-pay-text': amColors.value.colorMainText,
59 '--am-c-pay-text-op60': useColorTransparency(amColors.value.colorMainText, 0.6),
60 }
61 })
62
63 // * Components Emits
64 const emits = defineEmits(['payment-error'])
65
66 const { nextStep, footerButtonReset, footerButtonClicked } = inject('changingStepsFunctions', {
67 nextStep: () => {},
68 footerButtonReset: () => {},
69 footerButtonClicked: {
70 value: false,
71 },
72 })
73
74 const cardInstance = ref(null)
75
76 async function continueWithBooking() {
77 footerButtonReset()
78 store.commit('setLoading', true)
79
80 if (!cardInstance.value) {
81 store.commit('setLoading', true)
82 }
83
84 const totalAmount = await payingNow()
85 const token = await squareTokenize(cardInstance.value, totalAmount.formattedAmount)
86 if (!token) {
87 store.commit('setLoading', false)
88 return
89 }
90 await createSquarePayment(token)
91 }
92
93 // * Watching when footer button was clicked
94 watchEffect(() => {
95 if (footerButtonClicked.value) {
96 if (!store.getters['booking/getCouponValidated']) {
97 footerButtonReset()
98 emits('payment-error', amLabels.value.coupon_mandatory)
99 } else {
100 continueWithBooking()
101 }
102 }
103 })
104
105 const cardReady = ref(false)
106 const googlePayReady = ref(false)
107 const squareLoading = computed(() => !(cardReady.value && googlePayReady.value))
108 const applePayReady = ref(true)
109 const paymentTotalAmount = ref(null)
110 let paymentRequest = null
111 const walletInstance = ref({ googlePay: null, applePay: null })
112
113 // * Watch coupon changes and payment deposit changes to update paymentRequest amount
114 // Apple Pay - No async operations can be called between the user gesture (click) and tokenize().
115 watch(
116 [() => store.getters['coupon/getCoupon'], () => store.getters['payment/getPaymentDeposit']],
117 async (newValues, oldValues) => {
118 const [newCoupon, newDeposit] = newValues || []
119 const [, oldDeposit] = oldValues || []
120
121 if (
122 paymentRequest &&
123 (newCoupon.deduction || newCoupon.discount || newDeposit !== oldDeposit)
124 ) {
125 // Wait for next tick to ensure DOM is updated
126 await nextTick()
127 paymentRequest = await buildPaymentRequest()
128 const initWallet = async (method, target, onError) => {
129 try {
130 walletInstance.value[target] = await payments[method](paymentRequest)
131 } catch (err) {
132 console.log(err)
133 if (onError) onError()
134 }
135 }
136
137 await initWallet('googlePay', 'googlePay')
138 await initWallet('applePay', 'applePay', () => {
139 applePayReady.value = false
140 })
141 }
142 },
143 )
144
145 async function getAmountToPay() {
146 let checkoutPaymentData = null
147
148 await httpClient
149 .post('/payments/amount', useBookingData(store, null, true, {}, null)['data'])
150 .then((response) => {
151 checkoutPaymentData = response.data.data
152 })
153 .catch((e) => {
154 const message = e?.response?.data?.message || e.message || 'Unknown error'
155 emits('payment-error', message)
156 })
157
158 const totalPriceParts = new Intl.NumberFormat('en-US', {
159 style: 'currency',
160 currency: checkoutPaymentData.currency,
161 }).formatToParts(checkoutPaymentData.amount)
162
163 const IntegerPart = totalPriceParts.find((part) => part.type === 'integer')?.value || ''
164 const fractionPart = totalPriceParts.find((part) => part.type === 'fraction')?.value || ''
165 const decimalPart = totalPriceParts.find((part) => part.type === 'decimal')?.value || ''
166 const formattedAmount = `${IntegerPart}${decimalPart}${fractionPart}`
167 paymentTotalAmount.value = formattedAmount
168
169 return {
170 formattedAmount,
171 rawAmount: checkoutPaymentData.amount,
172 countryCode: checkoutPaymentData.countryCode,
173 }
174 }
175
176 async function payingNow() {
177 return await getAmountToPay()
178 }
179
180 onMounted(async () => {
181 // Defer mounting logic until DOM is visible
182 cardReady.value = false
183 googlePayReady.value = false
184
185 await nextTick()
186 await initSquarePayment()
187 })
188
189 const squareLocationId = amSettings.payments.square.locationId
190 const squareClientId = amSettings.payments.square.testMode
191 ? amSettings.payments.square.clientTestId
192 : amSettings.payments.square.clientLiveId
193
194 const bookingData = useBookingData(store, null, true, {}, null)
195
196 let paymentStepRef = inject('paymentRef')
197
198 const payments = window.Square.payments(squareClientId, squareLocationId)
199
200 async function buildPaymentRequest() {
201 try {
202 const paymentInfo = await payingNow()
203 if (!paymentInfo) return null
204 return payments.paymentRequest({
205 countryCode: paymentInfo.countryCode,
206 currencyCode: bookingData.data.payment.currency,
207 total: {
208 amount: paymentInfo.formattedAmount.toString(),
209 label: 'Total',
210 },
211 })
212 } catch (e) {
213 console.log(e)
214 return null
215 }
216 }
217 async function setupDigitalWallet({ buttonId, type, readyRef }) {
218 try {
219 const btnEl = document.getElementById(buttonId)
220 if (!btnEl) return
221 if (type === 'googlePay') {
222 btnEl.innerHTML = ''
223 }
224
225 if (!paymentRequest) return
226 walletInstance.value[type] = await payments[type](paymentRequest)
227
228 // Google Pay needs to render its own button via attach
229 if (type === 'googlePay') {
230 await walletInstance.value[type].attach(`#${buttonId}`)
231 }
232 readyRef.value = true
233
234 btnEl.addEventListener('click', async () => {
235 store.commit('setLoading', true)
236
237 const token = await squareTokenize(walletInstance.value[type], paymentTotalAmount.value)
238 if (!token) {
239 store.commit('setLoading', false)
240 return
241 }
242 await createSquarePayment(token)
243 })
244 } catch (e) {
245 console.log(e)
246 if (type === 'applePay') {
247 readyRef.value = false
248 }
249 }
250 }
251
252 async function initSquarePayment() {
253 const squareCardStyle = {
254 '.input-container': {
255 borderColor: '#d9d9d9',
256 borderRadius: '6px',
257 },
258 }
259
260 try {
261 const cardContainer = document.getElementById('card-container')
262 if (cardContainer) {
263 cardContainer.innerHTML = ''
264 }
265 const card = await payments.card({
266 style: squareCardStyle,
267 })
268 await card.attach('#card-container')
269 cardInstance.value = card
270 cardReady.value = true
271 } catch (e) {
272 const statusContainer = document.getElementById('payment-status-container')
273 console.error(e)
274 store.commit('setLoading', false)
275 if (statusContainer) {
276 statusContainer.className = 'missing-credentials'
277 statusContainer.style.visibility = 'visible'
278 }
279 }
280
281 paymentRequest = await buildPaymentRequest()
282 await setupDigitalWallet({
283 buttonId: 'google-pay-button',
284 type: 'googlePay',
285 readyRef: googlePayReady,
286 })
287 await setupDigitalWallet({
288 buttonId: 'apple-pay-button',
289 type: 'applePay',
290 readyRef: applePayReady,
291 })
292 }
293
294 const squareTokenize = async (payments, totalAmount) => {
295 try {
296 const { token, status, errors } = await payments.tokenize({
297 amount: totalAmount.toString(),
298 billingContact: {
299 familyName: bookingData.data.bookings[0].customer.lastName,
300 givenName: bookingData.data.bookings[0].customer.firstName,
301 email: bookingData.data.bookings[0].customer.email,
302 phone: bookingData.data.bookings[0].customer.phone,
303 },
304 customerInitiated: true,
305 sellerKeyedIn: false,
306 currencyCode: bookingData.data.payment.currency,
307 intent: 'CHARGE',
308 })
309
310 if (status === 'OK') {
311 return token
312 } else if (status === 'Invalid' && errors.length > 0) {
313 const messages = errors.map((err) => err.message)
314 emits('payment-error', messages.join(', '))
315 useScrollTo(paymentStepRef.value, paymentStepRef.value, 20, 300)
316 return ''
317 }
318 } catch (e) {
319 console.log(e)
320 }
321 }
322
323 const createSquarePayment = async (token) => {
324 if (!token) {
325 return
326 }
327 useCreateBooking(
328 store,
329 useBookingData(
330 store,
331 null,
332 false,
333 {
334 locationId: squareLocationId,
335 sourceId: token,
336 idempotencyKey: window.crypto.randomUUID(),
337 },
338 null,
339 ),
340 function (response) {
341 successBooking(response)
342 },
343 (response) => {
344 errorBooking(response)
345 },
346 )
347 }
348
349 function successBooking(response) {
350 useCreateBookingSuccess(store, response, function () {
351 nextStep()
352 })
353 }
354
355 function getSquareBookingErrorResponse(error) {
356 if (error?.response?.data) {
357 return error.response.data
358 }
359
360 if (error?.data) {
361 return error
362 }
363
364 const labels = amLabels.value || amLabels
365
366 return {
367 data: {
368 message: error?.message || labels.payment_error,
369 },
370 }
371 }
372
373 function errorBooking(error) {
374 useCreateBookingError(store, getSquareBookingErrorResponse(error), () => {
375 emits('payment-error', getErrorMessage())
376 })
377 }
378 </script>
379
380 <script>
381 export default {
382 name: 'PaymentSquare',
383 }
384 </script>
385
386 <style lang="scss">
387 .amelia-v2-booking {
388 #amelia-container {
389 .am-fs__square-loading {
390 display: flex;
391 flex-direction: column;
392 align-items: center;
393 justify-content: center;
394 width: 100%;
395
396 .el-skeleton {
397 display: flex;
398 align-items: center;
399 flex-direction: column;
400 gap: 10px;
401 width: 100%;
402
403 &__item {
404 width: 100%;
405 height: 40px;
406 }
407 }
408 }
409
410 .am-fs__payment-square {
411 display: flex;
412 flex-direction: column;
413 gap: 6px;
414
415 span {
416 display: inline-flex;
417 font-size: 14px;
418 font-weight: 500;
419 line-height: 1.42857;
420 color: #6772e5;
421 }
422
423 &__google-pay {
424 #google-pay-button {
425 width: 100%;
426 div {
427 button {
428 width: 100%;
429 display: flex;
430 justify-content: center;
431 align-items: center;
432 }
433 }
434 }
435 }
436
437 // Apple Pay button styling
438 &__apple-pay {
439 #apple-pay-button {
440 height: 40px;
441 width: 100%;
442 font-size: 15px;
443 display: inline-block;
444 -webkit-appearance: -apple-pay-button;
445 -apple-pay-button-type: plain;
446 -apple-pay-button-style: black;
447 }
448 }
449
450 #card-container {
451 .sq-card-iframe-container {
452 border: 1px solid #d9d9d9;
453 }
454 }
455
456 .am-fs__payment-divider {
457 display: flex;
458 align-items: center;
459 justify-content: center;
460 width: 100%;
461 margin: 16px 0;
462 text-align: center;
463 }
464
465 .am-fs__payment-divider::before,
466 .am-fs__payment-divider::after {
467 content: '';
468 flex-grow: 1;
469 height: 1px;
470 background-color: var(--am-c-pay-text-op60);
471 margin: 0 8px;
472 }
473
474 .am-divider-text {
475 font-size: 14px;
476 color: var(--am-c-pay-text-op60);
477 text-transform: uppercase;
478 line-height: 1.33333;
479 font-weight: 500;
480 }
481 }
482 }
483 }
484 </style>
485