Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

rework payment card components for stripe #1068

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
},
"dependencies": {
"@gtm-support/vue-gtm": "^2.0.0",
"@stripe/stripe-js": "^4.10.0",
"@types/stripe": "^8.0.417",
"@vuestic/compiler": "latest",
"@vuestic/tailwind": "^0.1.3",
"@vueuse/core": "^10.6.1",
Expand All @@ -35,10 +37,12 @@
"register-service-worker": "^1.7.1",
"sass": "^1.69.5",
"serve": "^14.2.1",
"stripe": "^17.3.1",
"vue": "3.5.8",
"vue-chartjs": "^5.3.0",
"vue-i18n": "^9.6.2",
"vue-router": "^4.2.5",
"vue-stripe-js": "^1.0.3",
"vuestic-ui": "^1.10.2"
},
"devDependencies": {
Expand Down
38 changes: 38 additions & 0 deletions src/components/stripe/StripeCardItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<template>
<div
class="min-h-[114px] p-4 rounded-lg border border-dashed border-backgroundBorder flex flex-col sm:flex-row items-start sm:items-center gap-6"
>
<div class="flex flex-col gap-2 flex-grow">
<div class="text-lg font-bold">{{ billingDetails?.name || 'Unnamed Card' }}</div>
<div class="flex gap-4 items-center">
<PaymentSystem :type="card.brand" />
<div>
<div class="text-[15px] font-bold mb-2 capitalize">{{ card.display_brand }} **** {{ card.last4 }}</div>
<div class="text-secondary">Expires {{ card.exp_month }}/{{ card.exp_year }}</div>
</div>
</div>
<div class="text-sm text-secondary">Added on {{ creationDate }}</div>
</div>
<div class="w-full sm:w-auto flex-none flex sm:block">
<slot></slot>
</div>
</div>
</template>

<script lang="ts" setup>
import { computed } from 'vue'

import type { PaymentCard } from '../../pages/payments/types'
import PaymentSystem from '../../pages/payments/payment-system/PaymentSystem.vue'

const props = defineProps<{
card: PaymentCard
billingDetails?: {
name?: string
}
}>()

const creationDate = computed(() => {
return props.card.created ? new Date(props.card.created * 1000).toLocaleDateString() : 'Unknown'
})
</script>
60 changes: 60 additions & 0 deletions src/components/stripe/StripeCardList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<template>
<div class="grid md:grid-cols-2 grid-cols-1 gap-4">
<template v-if="loading">
<div
v-for="i in 4"
:key="i"
class="min-h-[114px] p-4 rounded-lg border border-dashed border-backgroundBorder flex flex-row items-center gap-6"
>
<div class="flex flex-col gap-2 flex-grow">
<VaSkeleton height="1.5rem" variant="text" width="10rem" />
<div class="flex gap-4">
<VaSkeleton height="3rem" variant="rounded" width="5rem" />
<VaSkeleton :lines="2" variant="text" />
</div>
</div>
</div>
</template>
<template v-else>
<CardListItem
v-for="paymentCard in list"
:key="paymentCard.id"
:card="paymentCard"
:billing-details="paymentCard.billingDetails"
:class="{ 'border-primary': selectedCardId === paymentCard.id }"
@click="selectCard(paymentCard.id)"
>
<slot name="card-actions" :card="paymentCard"></slot>
</CardListItem>
<slot></slot>
</template>
</div>
</template>

<script lang="ts" setup>
import { onMounted, computed, ref } from 'vue'

const emits = defineEmits(['select'])

import CardListItem from './StripeCardItem.vue'

import { useStripePaymentCardsStore } from '../../stores/stripe-cards'

const store = useStripePaymentCardsStore()

const selectedCardId = ref<string | null>(null)

const list = computed(() => store.paymentCards)
const loading = computed(() => store.loading)

// Select card function
const selectCard = (cardId: string) => {
selectedCardId.value = cardId
emits('select', cardId)
}

// Fetch cards on component mount
onMounted(() => {
store.load()
})
</script>
78 changes: 78 additions & 0 deletions src/components/stripe/StripePaymentModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<template>
<VaModal :model-value="display" close-button hide-default-actions title="Select a Payment Method">
<CardList>
<template #card-actions="{ card }">
<VaButton v-if="stripeLoaded" :disabled="paymentProcessing" @click.stop="pay(card.id)">
{{ paymentProcessing && payingCardId == card.id ? 'Processing...' : 'Pay' }}
</VaButton>
</template>
</CardList>
</VaModal>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { useToast } from 'vuestic-ui'
import { useStripePaymentCardsStore } from '../../stores/stripe-cards'
import { useStripe } from '../../composables/useStripe'
import CardList from './StripeCardList.vue'

enum PaymentStatus {
Paid = 'paid',
Void = 'void',
Uncollectible = 'uncollectible',
}

const props = defineProps<{
display: boolean
amount: number
billingDetails?: Record<string, unknown>
}>()

const emits = defineEmits<{
(e: 'success', invoice: unknown): void
(e: 'close'): void
}>()

const { init } = useToast()
const store = useStripePaymentCardsStore()
const { stripeLoaded } = useStripe()

const paymentProcessing = ref<boolean>(false)
const payingCardId = ref<string | null>(null)

const handlePaymentStatus = (status: PaymentStatus) => {
switch (status) {
case PaymentStatus.Paid:
init({ message: 'Payment Successful!', color: 'success' })
break
case PaymentStatus.Void:
init({ message: 'Payment Failed. Please try again.', color: 'error' })
break
case PaymentStatus.Uncollectible:
init({
message: 'Payment cannot be completed due to insufficient funds or an invalid/blocked card.',
color: 'warning',
})
break
default:
init({ message: 'Unknown payment status.', color: 'info' })
}
}

const pay = async (cardId: string) => {
paymentProcessing.value = true
payingCardId.value = cardId
try {
const invoice = await store.processPayment(cardId, props.amount * 100)
handlePaymentStatus(invoice?.status)
emits('success', invoice)
} catch (error) {
console.error('Error processing payment:', error)
init({ message: 'An error occurred during payment processing.', color: 'error' })
} finally {
paymentProcessing.value = false
emits('close')
}
}
</script>
29 changes: 29 additions & 0 deletions src/composables/useStripe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { onBeforeMount, ref } from 'vue'
import { loadStripe } from '@stripe/stripe-js'

import { STRIPE_PUBLIC_KEY } from '../config/stripe'

const stripeLoaded = ref(false)
const stripeInstance = ref(null) // Store the actual Stripe instance

export const useStripe = () => {
onBeforeMount(async () => {
if (stripeInstance.value) {
stripeLoaded.value = true

return
}

loadStripe(STRIPE_PUBLIC_KEY)
.then((stripe) => {
console.log('stripe loaded', stripe)
stripeInstance.value = stripe // Save the stripe instance
stripeLoaded.value = true
})
.catch((error) => {
console.error('stripe load error', error)
})
})

return { stripeLoaded, stripeInstance }
}
2 changes: 2 additions & 0 deletions src/config/stripe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const STRIPE_PUBLIC_KEY =
'pk_test_51QMQglG0RyyEe7XHRvDnZREz1UjN8w3IM0X8klwml2yDf2JcCpHM7WzqFNK1UpXJRTo0jr7uUNDM5CD2syxGLojV00wrOHlZb9'
2 changes: 1 addition & 1 deletion src/pages/billing/BillingPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import MembeshipTier from './MembeshipTier.vue'
import PaymentInfo from './PaymentInfo.vue'
import { usePaymentCardsStore } from '../../stores/payment-cards'
import Invoices from './Invoices.vue'
import Invoices from './InvoicesStripe.vue'

const cardStore = usePaymentCardsStore()
cardStore.load()
Expand Down
64 changes: 64 additions & 0 deletions src/pages/billing/InvoicesStripe.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<template>
<VaCard class="mb-6">
<VaCardContent>
<h2 class="page-sub-title">Invoices</h2>
<template v-for="(item, index) in invoices" :key="item.id">
<div class="flex items-center justify-between md:justify-items-stretch">
<div class="flex items-center w-48">
{{ formatDate(item.created) }}
</div>
<div class="w-20">
{{ formatCurrency(item.amount_due, item.currency) }}
</div>
<div>
<VaButton preset="primary" @click="download(item.id)">Download</VaButton>
</div>
</div>

<VaDivider v-if="index !== invoices.length - 1" />
</template>
</VaCardContent>
<VaCardActions vertical class="flex flex-wrap content-center mt-4">
<VaButton v-if="store.hasMore && !store.loading" preset="primary" @click="store.loadMore"> Load more</VaButton>
<VaButton v-if="store.loading" preset="secondary" disabled> Loading...</VaButton>
</VaCardActions>
</VaCard>
</template>

<script lang="ts" setup>
import { onMounted } from 'vue'
import { useToast } from 'vuestic-ui'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useInvoicesStore } from '../../stores/invoices' // Import the Pinia store for invoices

const { init } = useToast()
const { locale } = useI18n()
const store = useInvoicesStore() // Initialize the Pinia store

// Compute the list of invoices to display based on the user's selection
const { invoices } = storeToRefs(store)

// Format the invoice date
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000) // Stripe uses Unix timestamps
return date.toLocaleDateString(locale.value, { year: 'numeric', month: 'long', day: 'numeric' })
}

// Format the currency display
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat(locale.value, { style: 'currency', currency }).format(amount / 100)
}

// Download the invoice (placeholder)
const download = (invoiceId: string) => {
store.loadMore()
init({ message: `Download invoice ${invoiceId} (not implemented)`, color: 'info' })
}

// Load the invoices when the component is mounted
onMounted(() => {
store.clear()
store.load()
})
</script>
2 changes: 1 addition & 1 deletion src/pages/payments/PaymentsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@
</template>

<script lang="ts" setup>
import PaymentCardList from './widgets/my-cards/PaymentCardList.vue'
import PaymentCardList from './widgets/my-cards/PaymentStripeCardList.vue'
import BillingAddressList from './widgets/billing-address/BillingAddressList.vue'
</script>
16 changes: 9 additions & 7 deletions src/pages/payments/types.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import type { Stripe } from 'stripe'

export enum PaymentSystemType {
Visa = 'visa',
MasterCard = 'mastercard',
}

export const paymentSystemTypeOptions = Object.values(PaymentSystemType)

export interface PaymentCard {
id: string
name: string
isPrimary: boolean // show Primary badge
paymentSystem: PaymentSystemType // Enum or union type for various payment systems
cardNumberMasked: string // ****1679
expirationDate: string // 09/24
export type PaymentCard = Stripe.PaymentMethod.Card & {
id: string // Unique payment method ID from Stripe
isPrimary?: boolean // Custom property for UI logic (optional)
created: number // Unix timestamp
billingDetails: {
name: string
}
}

export interface BillingAddress {
Expand Down
Loading