|
| 1 | +// Copyright (C) 2025 Storj Labs, Inc. |
| 2 | +// See LICENSE for copying information. |
| 3 | + |
| 4 | +<template> |
| 5 | + <v-expansion-panels class="mt-4"> |
| 6 | + <v-expansion-panel eager static @group:selected="({value}) => onPanelToggle(value)"> |
| 7 | + <v-expansion-panel-title>Billing info (optional)</v-expansion-panel-title> |
| 8 | + <v-expansion-panel-text> |
| 9 | + <v-progress-linear rounded indeterminate :active="isLoading" /> |
| 10 | + <v-row> |
| 11 | + <v-col> |
| 12 | + <div id="billing-address-element"> |
| 13 | + <!-- A Stripe Address Element will be inserted here. --> |
| 14 | + </div> |
| 15 | + </v-col> |
| 16 | + <v-col> |
| 17 | + <template v-if="addressElementReady"> |
| 18 | + <v-select |
| 19 | + v-model="selectedTax" |
| 20 | + label="Tax ID type" |
| 21 | + placeholder="Choose tax ID type" |
| 22 | + :disabled="!countryCode" |
| 23 | + :items="taxes" |
| 24 | + :item-title="(item: Tax) => item.name" |
| 25 | + :item-value="(item: Tax) => item" |
| 26 | + hide-details |
| 27 | + class="mb-3" |
| 28 | + /> |
| 29 | + |
| 30 | + <v-text-field |
| 31 | + v-model="taxID" |
| 32 | + :disabled="!selectedTax" |
| 33 | + variant="outlined" |
| 34 | + label="Tax ID" |
| 35 | + placeholder="Enter your Tax ID" |
| 36 | + :hint="'e.g.: ' + selectedTax?.example" |
| 37 | + :hide-details="false" |
| 38 | + :maxlength="50" |
| 39 | + class="custom" |
| 40 | + /> |
| 41 | + </template> |
| 42 | + </v-col> |
| 43 | + </v-row> |
| 44 | + </v-expansion-panel-text> |
| 45 | + </v-expansion-panel> |
| 46 | + </v-expansion-panels> |
| 47 | +</template> |
| 48 | + |
| 49 | +<script setup lang="ts"> |
| 50 | +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; |
| 51 | +import { loadStripe } from '@stripe/stripe-js/pure'; |
| 52 | +import { |
| 53 | + Stripe, |
| 54 | + StripeAddressElement, |
| 55 | + StripeElements, |
| 56 | + StripeElementsOptionsMode, |
| 57 | + StripeAddressElementChangeEvent, |
| 58 | +} from '@stripe/stripe-js'; |
| 59 | +import { useTheme } from 'vuetify'; |
| 60 | +import { |
| 61 | + VRow, |
| 62 | + VCol, |
| 63 | + VProgressLinear, |
| 64 | + VExpansionPanels, |
| 65 | + VExpansionPanel, |
| 66 | + VExpansionPanelTitle, |
| 67 | + VExpansionPanelText, |
| 68 | + VSelect, |
| 69 | + VTextField, |
| 70 | +} from 'vuetify/components'; |
| 71 | +
|
| 72 | +import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames'; |
| 73 | +import { useNotify } from '@/composables/useNotify'; |
| 74 | +import { useConfigStore } from '@/store/modules/configStore'; |
| 75 | +import { useLoading } from '@/composables/useLoading'; |
| 76 | +import { PurchaseBillingInfo, Tax } from '@/types/payments'; |
| 77 | +import { useBillingStore } from '@/store/modules/billingStore'; |
| 78 | +
|
| 79 | +const configStore = useConfigStore(); |
| 80 | +const billingStore = useBillingStore(); |
| 81 | +
|
| 82 | +const notify = useNotify(); |
| 83 | +const theme = useTheme(); |
| 84 | +const { isLoading, withLoading } = useLoading(); |
| 85 | +
|
| 86 | +const addressElement = ref<StripeAddressElement>(); |
| 87 | +const stripe = ref<Stripe | null>(null); |
| 88 | +const elements = ref<StripeElements | null>(null); |
| 89 | +const addressElementReady = ref(false); |
| 90 | +
|
| 91 | +const countryCode = ref<string>(); |
| 92 | +const selectedTax = ref<Tax>(); |
| 93 | +const taxID = ref<string>(); |
| 94 | +
|
| 95 | +const taxes = computed<Tax[]>(() => billingStore.state.taxes); |
| 96 | +
|
| 97 | +function initStripe(): void { |
| 98 | + withLoading(async () => { |
| 99 | + const stripePublicKey = configStore.state.config.stripePublicKey; |
| 100 | +
|
| 101 | + try { |
| 102 | + stripe.value = await loadStripe(stripePublicKey); |
| 103 | + } catch (error) { |
| 104 | + notify.error(error.message, AnalyticsErrorEventSource.BILLING_STRIPE_INFO_FORM); |
| 105 | + return; |
| 106 | + } |
| 107 | +
|
| 108 | + if (!stripe.value) { |
| 109 | + notify.error('Unable to initialize stripe', AnalyticsErrorEventSource.BILLING_STRIPE_INFO_FORM); |
| 110 | + return; |
| 111 | + } |
| 112 | +
|
| 113 | + const options: StripeElementsOptionsMode = { |
| 114 | + appearance: { |
| 115 | + theme: theme.global.current.value.dark ? 'night' : 'stripe', |
| 116 | + labels: 'floating', |
| 117 | + }, |
| 118 | + }; |
| 119 | + elements.value = stripe.value.elements(options); |
| 120 | + if (!elements.value) { |
| 121 | + notify.error('Unable to instantiate elements', AnalyticsErrorEventSource.BILLING_STRIPE_INFO_FORM); |
| 122 | + } |
| 123 | + }); |
| 124 | +} |
| 125 | +
|
| 126 | +function onPanelToggle(val: boolean): void { |
| 127 | + if (!val) { |
| 128 | + addressElement.value?.destroy(); |
| 129 | + addressElementReady.value = false; |
| 130 | + taxID.value = undefined; |
| 131 | + return; |
| 132 | + } |
| 133 | +
|
| 134 | + if (!elements.value) { |
| 135 | + notify.error('Unable to instantiate elements', AnalyticsErrorEventSource.BILLING_STRIPE_INFO_FORM); |
| 136 | + return; |
| 137 | + } |
| 138 | +
|
| 139 | + addressElement.value = elements.value.create('address', { mode: 'billing' }); |
| 140 | + addressElement.value.on('ready', () => { |
| 141 | + addressElementReady.value = true; |
| 142 | + }); |
| 143 | + addressElement.value.on('change', (event: StripeAddressElementChangeEvent) => { |
| 144 | + if (countryCode.value !== event.value.address?.country) countryCode.value = event.value.address?.country; |
| 145 | + }); |
| 146 | + addressElement.value.mount('#billing-address-element'); |
| 147 | +} |
| 148 | +
|
| 149 | +/** |
| 150 | + * To be called by parent element to |
| 151 | + * validate and get filled form data. |
| 152 | + */ |
| 153 | +async function onSubmit(): Promise<PurchaseBillingInfo> { |
| 154 | + if (!(stripe.value && elements.value)) { |
| 155 | + throw new Error('Stripe is not initialized'); |
| 156 | + } |
| 157 | +
|
| 158 | + if (!addressElement.value) { |
| 159 | + return { |
| 160 | + address: undefined, |
| 161 | + tax: undefined, |
| 162 | + }; |
| 163 | + } |
| 164 | +
|
| 165 | + const { complete, value } = await addressElement.value.getValue(); |
| 166 | +
|
| 167 | + return { |
| 168 | + address: complete ? { |
| 169 | + name: value.name, |
| 170 | + city: value.address.city, |
| 171 | + country: value.address.country, |
| 172 | + line1: value.address.line1, |
| 173 | + line2: value.address.line2, |
| 174 | + postalCode: value.address.postal_code, |
| 175 | + state: value.address.state, |
| 176 | + } : undefined, |
| 177 | + tax: selectedTax.value && taxID.value ? { |
| 178 | + type: selectedTax.value.code, |
| 179 | + value: taxID.value, |
| 180 | + } : undefined, |
| 181 | + }; |
| 182 | +} |
| 183 | +
|
| 184 | +watch(countryCode, (code) => { |
| 185 | + withLoading(async () => { |
| 186 | + if (!code) { |
| 187 | + return; |
| 188 | + } |
| 189 | + selectedTax.value = undefined; |
| 190 | + try { |
| 191 | + await billingStore.getCountryTaxes(code ?? ''); |
| 192 | + if (taxes.value.length === 1) { |
| 193 | + selectedTax.value = taxes.value[0]; |
| 194 | + } |
| 195 | + } catch (e) { |
| 196 | + notify.notifyError(e); |
| 197 | + } |
| 198 | + }); |
| 199 | +}); |
| 200 | +
|
| 201 | +onMounted(() => { |
| 202 | + initStripe(); |
| 203 | +}); |
| 204 | +
|
| 205 | +onBeforeUnmount(() => { |
| 206 | + addressElement.value?.off('change'); |
| 207 | + addressElement.value?.off('ready'); |
| 208 | +}); |
| 209 | +
|
| 210 | +defineExpose({ |
| 211 | + onSubmit, |
| 212 | +}); |
| 213 | +</script> |
| 214 | + |
| 215 | +<style scoped lang="scss"> |
| 216 | +:deep(.v-field__input) { |
| 217 | + min-height: 64px; |
| 218 | +} |
| 219 | +</style> |
0 commit comments