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