Skip to content

Instantly share code, notes, and snippets.

@smlparry
Last active November 27, 2023 23:40
Show Gist options
  • Select an option

  • Save smlparry/5fff32faf67eb7f6dad875cd2856eb04 to your computer and use it in GitHub Desktop.

Select an option

Save smlparry/5fff32faf67eb7f6dad875cd2856eb04 to your computer and use it in GitHub Desktop.
<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>
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