diff --git a/package.json b/package.json index 82226f019..a1abff867 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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": { diff --git a/src/components/stripe/StripeCardItem.vue b/src/components/stripe/StripeCardItem.vue new file mode 100644 index 000000000..7b0719e86 --- /dev/null +++ b/src/components/stripe/StripeCardItem.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/stripe/StripeCardList.vue b/src/components/stripe/StripeCardList.vue new file mode 100644 index 000000000..06aa0d7f4 --- /dev/null +++ b/src/components/stripe/StripeCardList.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/components/stripe/StripePaymentModal.vue b/src/components/stripe/StripePaymentModal.vue new file mode 100644 index 000000000..813333c5a --- /dev/null +++ b/src/components/stripe/StripePaymentModal.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/composables/useStripe.js b/src/composables/useStripe.js new file mode 100644 index 000000000..81a1d9786 --- /dev/null +++ b/src/composables/useStripe.js @@ -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 } +} diff --git a/src/config/stripe.ts b/src/config/stripe.ts new file mode 100644 index 000000000..c51380908 --- /dev/null +++ b/src/config/stripe.ts @@ -0,0 +1,2 @@ +export const STRIPE_PUBLIC_KEY = + 'pk_test_51QMQglG0RyyEe7XHRvDnZREz1UjN8w3IM0X8klwml2yDf2JcCpHM7WzqFNK1UpXJRTo0jr7uUNDM5CD2syxGLojV00wrOHlZb9' diff --git a/src/pages/billing/BillingPage.vue b/src/pages/billing/BillingPage.vue index 42493ae96..ac5df669f 100644 --- a/src/pages/billing/BillingPage.vue +++ b/src/pages/billing/BillingPage.vue @@ -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() diff --git a/src/pages/billing/InvoicesStripe.vue b/src/pages/billing/InvoicesStripe.vue new file mode 100644 index 000000000..2369b2c93 --- /dev/null +++ b/src/pages/billing/InvoicesStripe.vue @@ -0,0 +1,64 @@ + + + diff --git a/src/pages/payments/PaymentsPage.vue b/src/pages/payments/PaymentsPage.vue index 000978b29..da1e4b028 100644 --- a/src/pages/payments/PaymentsPage.vue +++ b/src/pages/payments/PaymentsPage.vue @@ -27,6 +27,6 @@ diff --git a/src/pages/payments/types.ts b/src/pages/payments/types.ts index 5b037ad05..7c57dcf6f 100644 --- a/src/pages/payments/types.ts +++ b/src/pages/payments/types.ts @@ -1,3 +1,5 @@ +import type { Stripe } from 'stripe' + export enum PaymentSystemType { Visa = 'visa', MasterCard = 'mastercard', @@ -5,13 +7,13 @@ export enum PaymentSystemType { 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 { diff --git a/src/pages/payments/widgets/my-cards/PaymentStripeCardAdd.vue b/src/pages/payments/widgets/my-cards/PaymentStripeCardAdd.vue new file mode 100644 index 000000000..df27a63ba --- /dev/null +++ b/src/pages/payments/widgets/my-cards/PaymentStripeCardAdd.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/src/pages/payments/widgets/my-cards/PaymentStripeCardCreateModal.vue b/src/pages/payments/widgets/my-cards/PaymentStripeCardCreateModal.vue new file mode 100644 index 000000000..879961070 --- /dev/null +++ b/src/pages/payments/widgets/my-cards/PaymentStripeCardCreateModal.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/pages/payments/widgets/my-cards/PaymentStripeCardList.vue b/src/pages/payments/widgets/my-cards/PaymentStripeCardList.vue new file mode 100644 index 000000000..e02110b0e --- /dev/null +++ b/src/pages/payments/widgets/my-cards/PaymentStripeCardList.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/pages/pricing-plans/PricingPlans.vue b/src/pages/pricing-plans/PricingPlans.vue index 4b1ed205e..557c8e628 100644 --- a/src/pages/pricing-plans/PricingPlans.vue +++ b/src/pages/pricing-plans/PricingPlans.vue @@ -23,7 +23,7 @@ :class="{ 'md:!py-10 !bg-backgroundCardSecondary': plan.model === 'Advanced', '!bg-backgroundCardPrimary': plan.model !== 'Advanced', - 'ring-2 ring-primary ring-offset-2': plan.model === selectedPlan, + 'ring-2 ring-primary ring-offset-2': plan.model === selectedPlan.model, }" class="flex w-[326px] md:w-[349px] h-fit p-6 rounded-[13px]" > @@ -59,9 +59,9 @@
Select @@ -69,34 +69,53 @@
+ diff --git a/src/services/stripe.ts b/src/services/stripe.ts new file mode 100644 index 000000000..cb6084a13 --- /dev/null +++ b/src/services/stripe.ts @@ -0,0 +1,117 @@ +// Fetch payment cards from Stripe +import type { Stripe } from 'stripe' + +const SERVER_BASE = ' http://localhost:3000' + +export const stripeFetchCards = async (customerId = 'cus_test_hardcoded_id') => { + try { + const response = await fetch(`${SERVER_BASE}/api/payment-method?customerId=${customerId}`) + if (!response.ok) { + throw new Error(`Error fetching payment methods: ${response.statusText}`) + } + const data = (await response.json()).data as Stripe.PaymentMethod[] + return data + } catch (error) { + console.error('stripeFetchCards error:', error) + throw error // Re-throw the error so it can be handled by the caller + } +} + +export const stripeAddCard = async (paymentMethodId: string) => { + try { + const response = await fetch(`${SERVER_BASE}/api/payment-method`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + // customerId: 'cus_test_hardcoded_id', + paymentMethodId, + }), + }) + + if (!response.ok) { + throw new Error(`Error adding payment method: ${response.statusText}`) + } + + const data = (await response.json()).data as Stripe.PaymentMethod + + return data + } catch (error) { + console.error('stripeAddCard error:', error) + throw error // Re-throw the error so it can be handled by the caller + } +} + +export const stripeRemoveCard = async (paymentMethodId: string) => { + try { + const response = await fetch(`${SERVER_BASE}/api/payment-method/${paymentMethodId}`, { + method: 'DELETE', + }) + + if (!response.ok) { + throw new Error(`Error removing payment method: ${response.statusText}`) + } + } catch (error) { + console.error('stripeRemoveCard error:', error) + throw error // Re-throw the error so it can be handled by the caller + } +} + +export const stripeCreatePaymentIntent = async (payload: any) => { + try { + const response = await fetch(`${SERVER_BASE}/api/payment-intent`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + throw new Error(`Error creating payment intent: ${response.statusText}`) + } + + return await response.json() + } catch (error) { + console.error('stripeRemoveCard error:', error) + throw error // Re-throw the error so it can be handled by the caller + } +} + +export const stripeCreatePayment = async (payload: any) => { + try { + const response = await fetch(`${SERVER_BASE}/api/payment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + throw new Error(`Error creating payment intent: ${response.statusText}`) + } + + return await response.json() + } catch (error) { + console.error('stripeRemoveCard error:', error) + throw error // Re-throw the error so it can be handled by the caller + } +} + +export const stripeFetchInvoices = async (startingAfter: string | null = null) => { + try { + // Construct the URL with query params + const url = new URL(`${SERVER_BASE}/api/invoices`) + if (startingAfter) { + url.searchParams.append('starting_after', startingAfter) + } + + const response = await fetch(url.toString()) + if (!response.ok) { + throw new Error(`Error fetching invoices: ${response.statusText}`) + } + + const data = await response.json() + + return data + } catch (error) { + console.error('stripeFetchInvoices error:', error) + throw error // Re-throw the error so it can be handled by the caller + } +} diff --git a/src/stores/invoices.ts b/src/stores/invoices.ts new file mode 100644 index 000000000..2a6a2811d --- /dev/null +++ b/src/stores/invoices.ts @@ -0,0 +1,69 @@ +import { defineStore } from 'pinia' +import { stripeFetchInvoices } from '../services/stripe' + +export interface Invoice { + id: string + amount_due: number + currency: string + status: string + created: number +} + +export const useInvoicesStore = defineStore({ + id: 'invoices', + state: () => ({ + invoices: [] as Invoice[], + loading: false, + hasMore: true, + lastInvoiceId: null as string | null, // The ID of the last invoice for pagination + }), + actions: { + async load() { + await this.loadInvoices(null) + }, + + async loadMore() { + if (!this.hasMore) return + + await this.loadInvoices(this.lastInvoiceId) + }, + + async loadInvoices(startingAfter: string | null) { + // Avoid duplicate requests or unnecessary requests + if (this.loading) return + + this.loading = true + + try { + const response = await stripeFetchInvoices(startingAfter) + const { invoices, has_more } = response.data + + const newInvoices = invoices.map((invoice: Invoice) => ({ + id: invoice.id, + amount_due: invoice.amount_due, + currency: invoice.currency, + status: invoice.status, + created: invoice.created, + })) as Invoice[] + // Update invoices (only append if not the initial load) + + this.invoices = [].concat(this.invoices, newInvoices) + + this.hasMore = has_more + this.lastInvoiceId = invoices.length > 0 ? invoices[invoices.length - 1].id : this.lastInvoiceId + } catch (error) { + console.error('Error loading invoices:', error) + } finally { + this.loading = false + } + }, + + // Clear all invoices and reset state + clear() { + this.invoices = [] + this.loading = false + this.hasMore = true + this.lastInvoiceId = null + }, + }, +}) diff --git a/src/stores/stripe-cards.ts b/src/stores/stripe-cards.ts new file mode 100644 index 000000000..b7ae5f4c2 --- /dev/null +++ b/src/stores/stripe-cards.ts @@ -0,0 +1,68 @@ +import { defineStore } from 'pinia' +import { stripeAddCard, stripeFetchCards, stripeRemoveCard, stripeCreatePayment } from '../services/stripe' + +import type { PaymentCard } from '../pages/payments/types' + +export const useStripePaymentCardsStore = defineStore({ + id: 'stripeCards', + state: () => ({ + paymentCards: [] as PaymentCard[], + loading: false, + }), + actions: { + async load() { + this.loading = true + try { + const paymentMethods = await stripeFetchCards() + this.paymentCards = paymentMethods.map((paymentMethod) => ({ + id: paymentMethod.id, + created: paymentMethod.created, + ...paymentMethod.card, + billingDetails: paymentMethod.billing_details, + })) as PaymentCard[] + } catch (error) { + console.error('Error loading payment cards:', error) + } finally { + this.loading = false + } + }, + async create(paymentMethodId: string) { + try { + const paymentMethod = await stripeAddCard(paymentMethodId) + const card = { + id: paymentMethod.id, + created: paymentMethod.created, + ...paymentMethod.card, + billingDetails: paymentMethod.billing_details, + } as PaymentCard + + this.paymentCards.push(card) + } catch (error) { + console.error('Error creating payment card:', error) + throw error + } + }, + async remove(paymentMethodId: string) { + try { + await stripeRemoveCard(paymentMethodId) + this.paymentCards = this.paymentCards.filter((card) => card.id !== paymentMethodId) + } catch (error) { + console.error('Error removing payment card:', error) + } + }, + async processPayment(paymentMethodId: string, amount: number) { + try { + const response = await stripeCreatePayment({ + amount, // Replace with actual amount + currency: 'usd', + paymentMethod: paymentMethodId, + confirm: true, + }) + return response + } catch (error) { + console.error('Error processing payment:', error) + throw error + } + }, + }, +}) diff --git a/yarn.lock b/yarn.lock index 9047bbeba..55dbc8f71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2746,6 +2746,11 @@ type-fest "~2.19" vue-component-type-helpers latest +"@stripe/stripe-js@^4.10.0": + version "4.10.0" + resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-4.10.0.tgz#5c785f9a5a500113d69d98c16061e0addd1c0305" + integrity sha512-KrMOL+sH69htCIXCaZ4JluJ35bchuCCznyPyrbN8JXSGQfwBI1SuIEMZNwvy8L8ykj29t6sa5BAAiL7fNoLZ8A== + "@testing-library/dom@^9.0.0": version "9.3.3" resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz" @@ -2980,6 +2985,13 @@ dependencies: undici-types "~5.26.4" +"@types/node@>=8.1.0": + version "22.9.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.9.0.tgz#b7f16e5c3384788542c72dc3d561a7ceae2c0365" + integrity sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ== + dependencies: + undici-types "~6.19.8" + "@types/node@^18.0.0": version "18.18.9" resolved "https://registry.npmjs.org/@types/node/-/node-18.18.9.tgz" @@ -3048,6 +3060,13 @@ "@types/mime" "*" "@types/node" "*" +"@types/stripe@^8.0.417": + version "8.0.417" + resolved "https://registry.yarnpkg.com/@types/stripe/-/stripe-8.0.417.tgz#b651677a9fc33be8ce8fd5bceadd7ca077214244" + integrity sha512-PTuqskh9YKNENnOHGVJBm4sM0zE8B1jZw1JIskuGAPkMB+OH236QeN8scclhYGPA4nG6zTtPXgwpXdp+HPDTVw== + dependencies: + stripe "*" + "@types/topojson-client@^3.1.4": version "3.1.4" resolved "https://registry.yarnpkg.com/@types/topojson-client/-/topojson-client-3.1.4.tgz#81b83f9ecd6542dc5c3df21967f992e3fe192c33" @@ -7465,7 +7484,7 @@ puppeteer-core@^2.1.1: rimraf "^2.6.1" ws "^6.1.0" -qs@6.13.0: +qs@6.13.0, qs@^6.11.0: version "6.13.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== @@ -8243,6 +8262,14 @@ strip-json-comments@~2.0.1: resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== +stripe@*, stripe@^17.3.1: + version "17.3.1" + resolved "https://registry.yarnpkg.com/stripe/-/stripe-17.3.1.tgz#901e91e85f6bdbbe10094debab13ef2a9d4c0fc0" + integrity sha512-E9/u+GFBPkYnTmfFCoKX3+gP4R3SkZoGunHe4cw9J+sqkj5uxpLFf1LscuI9BuEyIQ0PFAgPTHavgQwRtOvnag== + dependencies: + "@types/node" ">=8.1.0" + qs "^6.11.0" + sucrase@^3.32.0: version "3.34.0" resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz" @@ -8583,6 +8610,11 @@ undici-types@~5.26.4: resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici-types@~6.19.8: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz" @@ -8831,6 +8863,11 @@ vue-inbrowser-compiler-independent-utils@^4.69.0: dependencies: "@vue/devtools-api" "^6.5.0" +vue-stripe-js@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/vue-stripe-js/-/vue-stripe-js-1.0.3.tgz#35c7f4f6e4e89caea7dcf48fe05526021bb57436" + integrity sha512-vp9+EJYY0NPJsnyQJ+FsGJ85m/EaFQM0J0m6+y+KjEPOq1OoPp5jkA57UlfHD6fKJ28STeafNnQGoCMdijF5yg== + vue-tsc@^2.1.6: version "2.1.6" resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-2.1.6.tgz#d93fdc617da6546674301a746fd7089ea6d4543d"