Last active
November 27, 2023 23:40
-
-
Save smlparry/5fff32faf67eb7f6dad875cd2856eb04 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <template> | |
| <div class="payment-element mt-3" data-cy="payment-element"> | |
| <div v-if="showAddressElement" class="payment-element__address mb-2"> | |
| <div ref="addressElement" id="address-element"></div> | |
| </div> | |
| <div class="payment-element__wrapper"> | |
| <div ref="paymentElement" id="payment-element"></div> | |
| </div> | |
| <div | |
| v-if="error" | |
| data-cy="payment-element-errors" | |
| class="payment-element__errors" | |
| role="alert" | |
| > | |
| <v-icon icon="error" /> | |
| <span>{{ error }}</span> | |
| </div> | |
| <div class="payment-element__components"> | |
| <transition name="fade" mode="out-in"> | |
| <template v-if="isInitialized"> | |
| <div class="payment-element__cta-row my-2"> | |
| <slot name="before-button" /> | |
| <div class="d-flex align-items-center"> | |
| <v-button | |
| data-cy="payment-element-back" | |
| class="mr-1" | |
| secondary | |
| round | |
| @click="handleBack" | |
| > | |
| <v-icon icon="arrow_back" /> | |
| </v-button> | |
| <v-button | |
| data-cy="payment-element-submit" | |
| primary | |
| block | |
| :disabled="disabled" | |
| :is-loading="isLoading || isLoadingInfo" | |
| @click="handleSubmit" | |
| > | |
| {{ buttonText }} | |
| </v-button> | |
| </div> | |
| </div> | |
| </template> | |
| <template v-else> | |
| <div class="payment-element__placeholder"> | |
| <div class="d-flex w-100"> | |
| <div class="payment-element__placeholder-element"></div> | |
| <div class="payment-element__placeholder-element"></div> | |
| </div> | |
| <div class="d-flex w-100 my-3"> | |
| <div class="payment-element__placeholder-element"></div> | |
| </div> | |
| <div class="d-flex w-100 my-3"> | |
| <div class="payment-element__placeholder-element"></div> | |
| <div class="payment-element__placeholder-element"></div> | |
| </div> | |
| </div> | |
| </template> | |
| </transition> | |
| </div> | |
| </div> | |
| </template> | |
| <script> | |
| import { mapActions, mapGetters } from "vuex" | |
| import { isPresent } from "@/lib/utils" | |
| import useStripe from "@/lib/mixins/use-stripe" | |
| import { generateReturnURL, INTENT_TYPES } from "@/lib/checkout-utils" | |
| import debounce from "@/lib/debounce" | |
| import EventBus, { EVENTS } from "@/lib/event-bus" | |
| import { ACTIVITY_TYPES } from "@/store/analytics" | |
| import ERROR_REASONS from "@shared/payments/error-reasons.json" | |
| export default { | |
| mixins: [useStripe("setupForm")], | |
| props: { | |
| intentWillBeGiven: { type: Boolean, default: false }, | |
| intentType: { type: String, default: null }, // null triggers loading state | |
| intentClientSecret: { type: String, default: null }, // null triggers loading state | |
| productId: { type: [String, Number], default: null }, | |
| isSubscription: { type: Boolean, default: false }, | |
| amount: { type: [String, Number], default: null }, | |
| amountCents: { type: Number, default: null }, | |
| currency: { type: String, default: null }, | |
| symbol: { type: String, default: "$" }, | |
| discountInfo: { type: Object, default: () => ({}) }, | |
| hasTrial: { type: Boolean, default: false }, | |
| confirmText: { type: String, default: null }, | |
| isLoadingInfo: { type: Boolean, default: false } | |
| }, | |
| data() { | |
| return { | |
| error: null, | |
| isValid: false, | |
| isAddressValid: false, | |
| isInitialized: false, | |
| isLoading: false, | |
| userDetails: null, // name + address | |
| elements: null, | |
| paymentElement: null | |
| } | |
| }, | |
| computed: { | |
| ...mapGetters(["hasEnabledTaxCollection"]), | |
| ...mapGetters("auth", ["currentUser"]), | |
| buttonText() { | |
| return isPresent(this.confirmText) | |
| ? this.confirmText | |
| : this.hasTrial | |
| ? "Start free trial" | |
| : isPresent(this.amount) && isPresent(this.currency) | |
| ? `Pay ${this.symbol}${this.amount} ${this.currency?.toUpperCase()}` | |
| : "Pay" | |
| }, | |
| isIntentGiven() { | |
| return this.intentClientSecret && this.intentType | |
| }, | |
| showAddressElement() { | |
| return this.hasEnabledTaxCollection && !this.isIntentGiven | |
| }, | |
| disabled() { | |
| return ( | |
| !this.isValid || | |
| (this.showAddressElement ? !this.isAddressValid : false) || | |
| this.isLoadingInfo | |
| ) | |
| } | |
| }, | |
| watch: { | |
| isIntentGiven() { | |
| if (this.isIntentGiven) this.setupForm() | |
| } | |
| }, | |
| methods: { | |
| ...mapActions("analytics", ["trackActivity"]), | |
| ...mapActions("products", ["fetchIntent"]), | |
| setupForm() { | |
| if (!this.$refs.paymentElement) { | |
| setTimeout(() => this.setupForm(), 500) | |
| } else { | |
| try { | |
| if (this.intentWillBeGiven && !this.isIntentGiven) { | |
| return | |
| } | |
| const elementOptions = this.isIntentGiven | |
| ? { | |
| clientSecret: this.intentClientSecret | |
| } | |
| : { | |
| mode: | |
| this.amountCents === 0 | |
| ? "setup" | |
| : this.isSubscription | |
| ? "subscription" | |
| : "payment", | |
| amount: this.amountCents, | |
| currency: this.currency.toLowerCase(), | |
| appearance: { theme: "stripe" } | |
| } | |
| if (!this.isIntentGiven && !this.isSubscription) { | |
| // should be same as in PaymentService one_off | |
| elementOptions["setupFutureUsage"] = "off_session" | |
| } | |
| this.elements = this.useStripe_stripe.elements(elementOptions) | |
| this.paymentElement = this.elements.create("payment") | |
| this.paymentElement.mount(this.$refs.paymentElement) | |
| this.paymentElement.on("change", this.handlePaymentElementChange) | |
| this.paymentElement.on("loaderstart", () => { | |
| if (!this.showAddressElement) { | |
| this.isInitialized = true | |
| } | |
| }) | |
| if (this.showAddressElement) { | |
| this.addressElement = this.elements.create("address", { | |
| mode: "billing", | |
| defaultValues: { | |
| name: this.currentUser.name || "" | |
| } | |
| }) | |
| this.addressElement.mount(this.$refs.addressElement) | |
| this.addressElement.on("change", this.handleAddressElementChange) | |
| this.addressElement.on("loaderstart", () => { | |
| this.isInitialized = true | |
| }) | |
| } | |
| } catch (e) { | |
| console.error(e) | |
| } | |
| } | |
| }, | |
| async handleSubmit(e) { | |
| e.preventDefault() | |
| this.hideMessage() | |
| this.isLoading = true | |
| if (this.isIntentGiven) { | |
| await this.confirm(this.intentType) | |
| } else { | |
| try { | |
| // https://stripe.com/docs/payments/accept-a-payment-deferred | |
| const { error: submitError } = await this.elements.submit() | |
| if (submitError) { | |
| this.handleError(submitError) | |
| return | |
| } | |
| const { clientSecret, intentType } = await this.fetchIntent({ | |
| productId: this.productId, | |
| coupon: this.discountInfo?.code | |
| }) | |
| await this.confirm(intentType, clientSecret) | |
| } catch (e) { | |
| if (e?.response?.data?.reason) { | |
| this.showMessage(e.response.data.message) | |
| if ( | |
| e.response.data.reason === ERROR_REASONS.existing_subscription | |
| ) { | |
| EventBus.$emit(EVENTS.EXISTING_PRODUCT_SUBSCRIPTION, { | |
| productId: this.productId | |
| }) | |
| this.isLoading = false | |
| } | |
| } else { | |
| console.error(e) | |
| } | |
| } | |
| } | |
| }, | |
| async confirm(intentType, clientSecret = null) { | |
| const returnUrl = generateReturnURL(window.location, this.productId) | |
| const opts = { | |
| elements: this.elements, | |
| confirmParams: { | |
| return_url: returnUrl | |
| } | |
| } | |
| if (clientSecret) { | |
| opts.clientSecret = clientSecret | |
| } | |
| const { error } = | |
| intentType === INTENT_TYPES.setup_intent | |
| ? await this.useStripe_stripe.confirmSetup(opts) | |
| : await this.useStripe_stripe.confirmPayment(opts) | |
| if (error) { | |
| this.handleError(error) | |
| } | |
| }, | |
| handleError(error) { | |
| // This point will only be reached if there is an immediate error when | |
| // confirming the payment | |
| if ( | |
| error.type === "card_error" || | |
| error.type === "validation_error" || | |
| error.type === "invalid_request_error" | |
| ) { | |
| this.showMessage(error.message) | |
| this.trackError(error.type, error.message) | |
| } else { | |
| // Report to bugsnag | |
| this.showMessage("An unexpected error occurred.") | |
| this.trackError("unknown_error", "An unexpected error occurred.") | |
| } | |
| this.isLoading = false | |
| }, | |
| showMessage(message) { | |
| this.error = message | |
| }, | |
| hideMessage() { | |
| this.error = null | |
| }, | |
| handleBack() { | |
| this.isInitialized = false | |
| this.$nextTick(() => { | |
| this.$emit("back") | |
| }) | |
| }, | |
| handlePaymentElementChange(event) { | |
| this.isValid = event.complete | |
| }, | |
| emitAddressChanged() { | |
| return this.$emit("change", this.userDetails) | |
| }, | |
| handleAddressElementChange(event) { | |
| this.isAddressValid = event.complete | |
| if ( | |
| event.complete && | |
| JSON.stringify(event.value) !== JSON.stringify(this.userDetails) | |
| ) { | |
| this.userDetails = event.value | |
| debounce(this.emitAddressChanged, 600) | |
| } | |
| }, | |
| trackError(reason, message) { | |
| this.trackActivity({ | |
| activityType: ACTIVITY_TYPES.CHECKOUT_ERROR, | |
| data: { | |
| reason, | |
| message, | |
| product: { id: this.productId } | |
| } | |
| }) | |
| } | |
| }, | |
| beforeDestroy() { | |
| this.isInitialized = false | |
| this.elements?.getElement("payment")?.destroy() | |
| } | |
| } | |
| </script> | |
| <style lang="scss" scoped> | |
| @import "@/assets/styles/variables"; | |
| .payment-element__errors { | |
| margin: $size--4 0; | |
| display: flex; | |
| border: 1px solid $color-ui--red; | |
| box-shadow: $box-shadow--small; | |
| padding: $size--4; | |
| border-radius: 5px; | |
| color: $color-ui--grey-90; | |
| font-size: $font-size--base; | |
| animation: shake-x 0.6s ease; | |
| .v-icon { | |
| color: $color-ui--red; | |
| font-size: $font-size--large; | |
| margin-right: $size--2; | |
| } | |
| } | |
| .payment-element__placeholder-element { | |
| border: 1px solid $color-ui--grey-30; | |
| box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.03), 0px 3px 6px rgba(0, 0, 0, 0.02); | |
| border-radius: 5px; | |
| padding: $size--6; | |
| flex-grow: 1; | |
| margin: 0 $size--1; | |
| animation: load-pulse 1.2s infinite ease-in-out; | |
| position: relative; | |
| &:after { | |
| content: ""; | |
| position: absolute; | |
| left: $size--3; | |
| top: $size--3; | |
| height: $size--5; | |
| width: 33%; | |
| background: $color-ui--grey-15; | |
| } | |
| } | |
| .payment-element__cta-row { | |
| opacity: 0; | |
| animation: fade-in 0.4s ease forwards; | |
| animation-delay: 0.8s; | |
| } | |
| </style> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { loadStripe } from "@stripe/stripe-js" | |
| import EventBus, { EVENTS } from "@/lib/event-bus" | |
| import { VARIABLES } from "@/lib/globals" | |
| import { mapGetters } from "vuex" | |
| export const setupStripe = accountId => { | |
| if (window[VARIABLES.STRIPE]) return Promise.resolve() | |
| loadStripe(process.env.VUE_APP_STRIPE_PUBLIC_KEY, { | |
| stripeAccount: accountId | |
| }).then(stripe => { | |
| // Prevent any occurence of race conditions while loading async | |
| if (!window[VARIABLES.STRIPE]) { | |
| window[VARIABLES.STRIPE] = stripe | |
| EventBus.$emit(EVENTS.STRIPE_LOADED) | |
| } | |
| }) | |
| } | |
| export default onReadyFunc => ({ | |
| data() { | |
| return { | |
| useStripe_stripe: window[VARIABLES.STRIPE] | |
| } | |
| }, | |
| computed: { | |
| ...mapGetters(["canAcceptPayments"]) | |
| }, | |
| mounted() { | |
| EventBus.$on(EVENTS.STRIPE_LOADED, this.handleStripeLoaded) | |
| }, | |
| beforeDestroy() { | |
| EventBus.$off(EVENTS.STRIPE_LOADED, this.handleStripeLoaded) | |
| }, | |
| methods: { | |
| handleStripeLoaded() { | |
| this.useStripe_stripe = window[VARIABLES.STRIPE] | |
| } | |
| }, | |
| watch: { | |
| canAcceptPayments: { | |
| immediate: true, | |
| async handler(canAcceptPayments) { | |
| if (canAcceptPayments) { | |
| if (this[onReadyFunc]) { | |
| this[onReadyFunc](this.useStripe_stripe) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment