diff --git a/components/StripeWrapper.tsx b/components/StripeWrapper.tsx new file mode 100644 index 000000000..bcb0ff191 --- /dev/null +++ b/components/StripeWrapper.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { Appearance, loadStripe, StripeElementsOptions } from '@stripe/stripe-js'; +import { Elements } from '@stripe/react-stripe-js'; +import { ReactNode } from 'react'; +import { colors } from '@/app/styles/colors'; + +const appearance: Appearance = { + theme: 'stripe', + variables: { + // Use your color system from colors.ts + colorPrimary: colors.rhBlue[500], // #3971ff + colorBackground: '#ffffff', + colorText: colors.gray[900], // #111827 + colorTextSecondary: colors.gray[600], // #4b5563 + colorTextPlaceholder: colors.gray[400], // #9ca3af + colorDanger: '#ef4444', // red-500 + colorSuccess: '#10b981', // green-500 + colorWarning: '#f59e0b', // amber-500 + + // Typography + fontFamily: 'Inter, system-ui, sans-serif', + fontSizeBase: '14px', + + // Spacing and sizing + spacingUnit: '4px', + borderRadius: '8px', + }, + rules: { + // Custom styling rules for specific components + '.Tab': { + border: `1px solid ${colors.gray[200]}`, + borderRadius: '8px', + padding: '12px 16px', + marginBottom: '8px', + backgroundColor: '#ffffff', + display: 'none', + }, + '.Tab:hover': { + backgroundColor: colors.gray[50], + borderColor: colors.gray[300], + }, + '.Tab--selected': { + borderColor: colors.rhBlue[500], + backgroundColor: colors.rhBlue[50], + }, + '.TabLabel': { + color: colors.gray[700], + fontWeight: '500', + }, + '.TabLabel--selected': { + color: colors.rhBlue[700], + fontWeight: '600', + }, + '.Input': { + border: `1px solid ${colors.gray[200]}`, + borderRadius: '8px', + padding: '12px 16px', + fontSize: '14px', + color: colors.gray[900], + backgroundColor: '#ffffff', + }, + '.Input:focus': { + borderColor: colors.rhBlue[500], + outline: 'none', + }, + '.Input:hover': { + borderColor: colors.gray[300], + }, + '.Input--invalid': { + borderColor: '#ef4444', + }, + '.Label': { + color: colors.gray[700], + fontSize: '14px', + fontWeight: '500', + marginBottom: '6px', + }, + '.ErrorText': { + color: '#ef4444', + fontSize: '12px', + marginTop: '4px', + }, + '.PaymentRequestButton': { + backgroundColor: colors.rhBlue[500], + color: '#ffffff', + borderRadius: '8px', + padding: '12px 16px', + fontSize: '14px', + fontWeight: '500', + border: 'none', + }, + '.PaymentRequestButton:hover': { + backgroundColor: colors.rhBlue[600], + }, + '.PaymentRequestButton:active': { + backgroundColor: colors.rhBlue[700], + }, + }, +}; + +const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); + +interface StripeWrapperProps { + children: ReactNode; + options?: StripeElementsOptions; +} + +export const StripeWrapper: React.FC = ({ children, options }) => { + return ( + + {children} + + ); +}; diff --git a/components/modals/ContributeToFundraiseModalV2.tsx b/components/modals/ContributeToFundraiseModalV2.tsx new file mode 100644 index 000000000..1de1ab5b3 --- /dev/null +++ b/components/modals/ContributeToFundraiseModalV2.tsx @@ -0,0 +1,848 @@ +'use client'; + +import { FC, useState, useEffect } from 'react'; +import { BaseModal } from '@/components/ui/BaseModal'; +import { Button } from '@/components/ui/Button'; +import { useUser } from '@/contexts/UserContext'; +import { useCreateContribution } from '@/hooks/useFundraise'; +import { usePaymentIntent } from '@/hooks/usePayment'; +import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import { X, TrendingUp, ArrowLeft, AlertCircle } from 'lucide-react'; +import { toast } from 'react-hot-toast'; +import { Fundraise } from '@/types/funding'; +import { CurrencyInput } from '../ui/form/CurrencyInput'; +import { StripeWrapper } from '@/components/StripeWrapper'; +import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'; + +const PROCESSING_FEE_PERCENTAGE = 0.07; // 7% + +interface ContributeToFundraiseModalProps { + isOpen: boolean; + onClose: () => void; + onContributeSuccess?: () => void; + fundraise: Fundraise; + fundraiseTitle?: string; +} + +// Payment Intent Loading Skeleton Component +const PaymentIntentSkeleton: FC = () => ( +
+
+
+ {/* Title skeleton */} +
+ + {/* Description skeleton */} +
+
+
+
+ + {/* Amount breakdown skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Payment form skeleton */} +
+
+
+ + {/* Button skeleton */} +
+
+
+
+); + +// Header Component +const ModalHeader: FC<{ + onClose: () => void; + onBack?: () => void; + showBackButton: boolean; + totalAvailableBalance: number; + currentStep: string; +}> = ({ onClose, onBack, showBackButton, totalAvailableBalance, currentStep }) => ( +
+ {/* Close button - always on the right */} + + + {/* Back button - only show on second step */} + {showBackButton && ( + + )} + +
+
+

Fund Proposal

+ + RSC + +
+

Support cutting-edge research.

+
+ + {/* Your Balance Section */} + {currentStep === 'contribute' && ( +
+
+

Your Balance

+

+ {totalAvailableBalance.toLocaleString()}{' '} + RSC +

+
+
+ )} +
+); + +// Payment Form Component +const PaymentForm: FC<{ + amount: number; + inputAmount: number; + totalAvailableBalance: number; + totalAmountWithFee: number; + fundraiseId?: string; + userId?: string; + onClose: () => void; +}> = ({ + amount, + inputAmount, + totalAvailableBalance, + totalAmountWithFee, + fundraiseId, + userId, + onClose, +}) => { + const stripe = useStripe(); + const elements = useElements(); + const [isProcessing, setIsProcessing] = useState(false); + const [message, setMessage] = useState(null); + const { exchangeRate } = useExchangeRate(); + + // Helper function to format USD amounts + const formatUSD = (rscAmount: number) => { + if (!exchangeRate) return ''; + const usdAmount = (rscAmount * exchangeRate).toFixed(2); + return `~$${usdAmount}`; + }; + + // Calculate the RSC needed to buy + const rscNeededToBuy = Math.round(totalAmountWithFee - totalAvailableBalance); + const processingFee = Math.round(inputAmount * PROCESSING_FEE_PERCENTAGE); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!stripe || !elements) { + // Stripe.js hasn't yet loaded. + // Make sure to disable form submission until Stripe.js has loaded. + return; + } + + setIsProcessing(true); + + try { + const { error } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: `${window.location.href}?success-funded=true`, // TODO: should never happen but better have it + }, + redirect: 'if_required', // This is key - only redirect if absolutely necessary + }); + + // This point will only be reached if there is an immediate error when + // confirming the payment. Otherwise, your customer will be redirected to + // your `return_url`. For some payment methods like iDEAL, your customer will + // be redirected to an intermediate site first to authorize the payment, then + // redirected to the `return_url`. + if (error) { + if (error.type === 'card_error' || error.type === 'validation_error') { + setMessage(error.message || 'An unexpected error occurred.'); + } else { + setMessage('An unexpected error occurred.'); + } + } else { + // Payment successful - user will be redirected to return_url + setMessage('Payment successful! RSC will be added to your account shortly.'); + setTimeout(() => { + onClose(); + }, 2000); + } + } catch (error) { + setMessage('An error occurred. Please try again.'); + console.error('Payment error:', error); + } finally { + setIsProcessing(false); + } + }; + + return ( +
+
+

Complete Your Purchase

+

+ You need {rscNeededToBuy.toLocaleString()} RSC to complete your + contribution. +

+
+ + {/* Amounts Section - matching the styling from first step */} +
+
+ {/* Funding Amount */} +
+ Funding amount +
+ + {inputAmount.toLocaleString()} RSC + +
+
+ + {/* Payment Processing Fee */} +
+ Payment processing fee +
+ + {processingFee.toFixed(0)} RSC + +
+
+ + {/* Total Amount */} +
+ Total amount +
+ + {totalAmountWithFee.toFixed(0)} RSC + + {exchangeRate && ( +
{formatUSD(totalAmountWithFee)}
+ )} +
+
+ + {/* Your Balance */} +
+ Your balance +
+ + {totalAvailableBalance.toLocaleString()} RSC + +
+
+ + {/* RSC Needed to Buy */} +
+ RSC needed to buy +
+ + {rscNeededToBuy.toLocaleString()} RSC + + {exchangeRate && ( +
{formatUSD(rscNeededToBuy)}
+ )} +
+
+
+
+ + { + setMessage( + 'Unable to load payment form. Please refresh the page and try again. If the problem persists, please contact support.' + ); + }} + /> + + {message && ( +
+ {message} +
+ )} + + + + ); +}; + +// Purchase Step Component +const PurchaseStep: FC<{ + onClose: () => void; + onBack: () => void; + totalAvailableBalance: number; + inputAmount: number; + totalAmountWithFee: number; + fundraiseId?: string; + userId?: string; +}> = ({ + onClose, + onBack, + totalAvailableBalance, + inputAmount, + totalAmountWithFee, + fundraiseId, + userId, +}) => { + const { + createPaymentIntent, + isLoading: isCreatingIntent, + error: intentError, + paymentIntent, + } = usePaymentIntent(); + + // Calculate the amount needed in RSC + const amountNeeded = Math.round(totalAmountWithFee - totalAvailableBalance); + + useEffect(() => { + const createIntent = async () => { + try { + await createPaymentIntent(amountNeeded, 'RSC'); + } catch (error) { + console.error('Failed to create payment intent:', error); + toast.error('Failed to initialize payment. Please try again.'); + } + }; + + if (!isCreatingIntent) { + createIntent(); + } + }, [createPaymentIntent]); + + // Show skeleton while creating payment intent + if (isCreatingIntent) { + return ( + <> + + + + ); + } + + // TODO: Show error if payment intent creation failed + // Show error if payment intent creation failed + if (intentError || !paymentIntent) { + return ( + <> + +
+
+
+
+
+ +
+
+

+ Payment Initialization Failed +

+
+

{intentError}

+
+
+
+
+
+ +
+
+
+ + ); + } + + // Render Stripe Elements with payment intent + return ( + <> + +
+
+
+ + + +
+
+
+ + ); +}; + +// Contribute Step Component +const ContributeStep: FC<{ + onClose: () => void; + onBuyRSC: () => void; + onContribute: () => void; + fundraiseTitle?: string; + inputAmount: number; + amountError?: string; + onAmountChange: (e: React.ChangeEvent) => void; + insufficientBalance: boolean; + isContributing: boolean; + isLoading: boolean; + progressPercentage: number; + amountRaised: number; + goalAmount: number; + userImpact: string; + processingFee: number; + totalAmountWithFee: number; + totalAvailableBalance: number; +}> = ({ + onClose, + onBuyRSC, + onContribute, + fundraiseTitle, + inputAmount, + amountError, + onAmountChange, + insufficientBalance, + isContributing, + isLoading, + progressPercentage, + amountRaised, + goalAmount, + userImpact, + processingFee, + totalAmountWithFee, + totalAvailableBalance, +}) => { + const { exchangeRate } = useExchangeRate(); + + // Helper function to format USD amounts + const formatUSD = (rscAmount: number) => { + if (!exchangeRate) return ''; + const usdAmount = (rscAmount * exchangeRate).toFixed(2); + return `~$${usdAmount}`; + }; + + return ( + <> + + {/* Scrollable content area */} +
+ {/* Proposal Details Section */} +
+
+ {/* Title */} +

+ {fundraiseTitle} +

+ + {/* Amount to Fund Section */} +
+ {}} + disableCurrencyToggle={true} + required={true} + helperText={ + exchangeRate + ? `${formatUSD(inputAmount)} (${exchangeRate.toFixed(2)} RSC/USD)` + : undefined + } + /> +
+ + {/* Progress Bar */} +
+
+ Funding Progress + + {progressPercentage.toFixed(0)}% + +
+
+
+ {/* Preview of new progress after contribution */} + {inputAmount > 0 && ( +
+ )} +
+ {inputAmount > 0 && ( +

+ +{((inputAmount / goalAmount) * 100).toFixed(1)}% with your contribution +

+ )} +
+ + {/* Key Metrics */} +
+
+

{amountRaised.toLocaleString()}

+

RSC Raised

+
+
+

{goalAmount.toLocaleString()}

+

RSC Goal

+
+
+

{userImpact}

+

Your Impact

+
+
+ + {/* Amounts Section */} +
+
+ {/* Funding Amount */} +
+ Funding amount +
+ + {inputAmount.toLocaleString()} RSC + +
+
+ + {/* Payment Processing Fee */} +
+ Payment processing fee +
+ + {processingFee.toFixed(0)} RSC + +
+
+ + {/* Total Amount */} +
+ Total amount +
+ + {totalAmountWithFee.toFixed(0)} RSC + + {exchangeRate && ( +
{formatUSD(totalAmountWithFee)}
+ )} +
+
+
+
+
+
+ + {/* Action Buttons */} +
+ {insufficientBalance && ( +
+
+
+
+ +
+
+
+

Insufficient Balance

+
+

+ You need{' '} + + {Math.round(totalAmountWithFee - totalAvailableBalance).toLocaleString()}{' '} + more RSC + {' '} + to complete this contribution. +

+
+
+
+
+ )} + + {insufficientBalance ? ( + + ) : ( + + )} +
+
+ + ); +}; + +// Main Modal Component +export const ContributeToFundraiseModalV2: FC = ({ + isOpen, + onClose, + onContributeSuccess, + fundraise, + fundraiseTitle, +}) => { + const { user } = useUser(); + const { exchangeRate } = useExchangeRate(); + const [inputAmount, setInputAmount] = useState(100); + const [isContributing, setIsContributing] = useState(false); + const [amountError, setAmountError] = useState(undefined); + const [currentStep, setCurrentStep] = useState<'contribute' | 'purchase'>('contribute'); + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + setInputAmount(100); + setIsContributing(false); + setAmountError(undefined); + setCurrentStep('contribute'); + } + }, [isOpen]); + + const [{ isLoading, error }, createContribution] = useCreateContribution(); + + // Calculate user balance and fundraise progress + const userBalance = user?.balance || 0; + const lockedBalance = user?.lockedBalance || 0; + const totalAvailableBalance = Math.round(userBalance + lockedBalance); + + const goalAmount = fundraise.goalAmount.rsc; + const amountRaised = fundraise.amountRaised.rsc; + const progressPercentage = Math.min((amountRaised / goalAmount) * 100, 100); + + // Calculate total amount including processing fee + const processingFee = Math.round(inputAmount * PROCESSING_FEE_PERCENTAGE); + const totalAmountWithFee = inputAmount + processingFee; + + // Check if user has insufficient balance (including fee) + const insufficientBalance = totalAmountWithFee > totalAvailableBalance; + + // Calculate user's impact based on the contribution amount + const calculateUserImpact = (amount: number) => { + if (amount === 0 || goalAmount === 0) return '0.0%'; + const impactPercentage = (amount / goalAmount) * 100; + return `+${impactPercentage.toFixed(1)}%`; + }; + + const userImpact = calculateUserImpact(inputAmount); + + const handleAmountChange = (e: React.ChangeEvent) => { + const rawValue = e.target.value.replace(/[^0-9]/g, ''); + const numValue = parseInt(rawValue); + + if (!isNaN(numValue)) { + setInputAmount(numValue); + + // Validate minimum amount + if (numValue < 10) { + setAmountError('Minimum contribution amount is 10 RSC'); + } else { + const fee = Math.round(numValue * PROCESSING_FEE_PERCENTAGE); + const totalWithFee = numValue + fee; + + if (totalWithFee > totalAvailableBalance) { + setAmountError('Amount exceeds your available balance (including processing fee)'); + } else { + setAmountError(undefined); + } + } + } else { + setInputAmount(0); + setAmountError('Please enter a valid amount'); + } + }; + + const handleContribute = async () => { + if (amountError || inputAmount < 10) { + return; + } + + try { + setIsContributing(true); + await createContribution(fundraise.id, { amount: inputAmount }); + + toast.success('Contribution successful!'); + + if (onContributeSuccess) { + onContributeSuccess(); + } + + onClose(); + } catch (error) { + console.error('Contribution failed:', error); + toast.error('Failed to contribute. Please try again.'); + } finally { + setIsContributing(false); + } + }; + + const handleBuyRSC = () => { + setCurrentStep('purchase'); + }; + + const handleBackToContribute = () => { + setCurrentStep('contribute'); + }; + + // Helper function to format USD amounts + const formatUSD = (rscAmount: number) => { + if (!exchangeRate) return ''; + const usdAmount = (rscAmount * exchangeRate).toFixed(2); + return `~$${usdAmount}`; + }; + + return ( + + {currentStep === 'contribute' ? ( + + ) : ( + + )} + + ); +}; diff --git a/components/ui/BaseModal.tsx b/components/ui/BaseModal.tsx index 86bb1b4ed..2be3e3020 100644 --- a/components/ui/BaseModal.tsx +++ b/components/ui/BaseModal.tsx @@ -17,6 +17,7 @@ interface BaseModalProps { padding?: string; // e.g., 'p-4', 'p-6', 'p-8' footer?: ReactNode; headerAction?: ReactNode; + fixedWidth?: boolean; } export const BaseModal: FC = ({ @@ -30,6 +31,7 @@ export const BaseModal: FC = ({ padding = 'p-6', // Default padding footer, headerAction, + fixedWidth = false, }) => { const headerRef = useRef(null); const footerRef = useRef(null); @@ -89,14 +91,13 @@ export const BaseModal: FC = ({ = ({ )} {/* Modal Content */}
{children}
diff --git a/components/ui/form/CurrencyInput.tsx b/components/ui/form/CurrencyInput.tsx index 946a7bd4b..10d97eca9 100644 --- a/components/ui/form/CurrencyInput.tsx +++ b/components/ui/form/CurrencyInput.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { Input } from './Input'; +import { Input, InputProps } from './Input'; import { ChevronDown } from 'lucide-react'; import { Currency } from '@/types/root'; -interface CurrencyInputProps { +interface CurrencyInputProps extends InputProps { value: string | number; onChange: (e: React.ChangeEvent) => void; currency: Currency; @@ -14,6 +14,8 @@ interface CurrencyInputProps { isExchangeRateLoading?: boolean; label?: string; className?: string; + disableCurrencyToggle?: boolean; + required?: boolean; } export const CurrencyInput = ({ @@ -27,6 +29,9 @@ export const CurrencyInput = ({ isExchangeRateLoading, label = 'I am offering', className = '', + disableCurrencyToggle = false, + required = false, + ...props }: CurrencyInputProps) => { const currentAmount = typeof value === 'string' ? parseFloat(value.replace(/,/g, '')) || 0 : value; @@ -47,22 +52,29 @@ export const CurrencyInput = ({ name="amount" value={value === 0 ? '' : value.toString()} onChange={onChange} - required + required={required} label={label} placeholder="0.00" type="text" inputMode="numeric" className={`w-full text-left h-12 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 ${error ? 'border-red-500' : ''} ${className}`} rightElement={ - + disableCurrencyToggle ? ( +
+ {currency} +
+ ) : ( + + ) } + {...props} /> {error &&

{error}

} {suggestedAmount && !error && ( diff --git a/components/work/components/FundersSection.tsx b/components/work/components/FundersSection.tsx index 83f886625..23bf0cc2d 100644 --- a/components/work/components/FundersSection.tsx +++ b/components/work/components/FundersSection.tsx @@ -7,11 +7,11 @@ import { Fundraise } from '@/types/funding'; import { isDeadlineInFuture } from '@/utils/date'; import { ContributorModal } from '@/components/modals/ContributorModal'; import { Users } from 'lucide-react'; -import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFundraiseModal'; import { CurrencyBadge } from '@/components/ui/CurrencyBadge'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { useRouter } from 'next/navigation'; import { useShareModalContext } from '@/contexts/ShareContext'; +import { ContributeToFundraiseModalV2 } from '@/components/modals/ContributeToFundraiseModalV2'; interface FundersSectionProps { fundraise: Fundraise; @@ -103,15 +103,16 @@ export const FundersSection: FC = ({ fundraise, fundraiseTi
))}
- - {hasMoreContributors && ( - - )} + {/* NICKDEV */} + {/* TODO: Add this back in */} + {/* {hasMoreContributors && ( */} + + {/* )} */} ) : (
@@ -130,11 +131,12 @@ export const FundersSection: FC = ({ fundraise, fundraiseTi /> )} - setIsContributeModalOpen(false)} onContributeSuccess={handleContributeSuccess} fundraise={fundraise} + fundraiseTitle={fundraiseTitle} /> ); diff --git a/hooks/usePayment.ts b/hooks/usePayment.ts new file mode 100644 index 000000000..a05e6e8bc --- /dev/null +++ b/hooks/usePayment.ts @@ -0,0 +1,63 @@ +import { useState, useCallback } from 'react'; +import { PaymentService } from '@/services/payment.service'; +import type { TransformedPaymentIntent } from '@/types/payment'; + +interface UsePaymentIntentReturn { + paymentIntent: TransformedPaymentIntent | null; + isLoading: boolean; + error: string | null; + createPaymentIntent: ( + amount: number, + currency: 'USD' | 'RSC' + ) => Promise; + reset: () => void; +} + +/** + * Hook to create and manage payment intents + * @returns Object containing payment intent data, loading state, error state, and functions + */ +export function usePaymentIntent(): UsePaymentIntentReturn { + const [paymentIntent, setPaymentIntent] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const createPaymentIntent = useCallback( + async (amount: number, currency: 'USD' | 'RSC'): Promise => { + try { + setIsLoading(true); + setError(null); + + const response = await PaymentService.createPaymentIntent({ + amount, + currency, + }); + + setPaymentIntent(response.paymentIntent); + return response.paymentIntent; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to create payment intent'; + setError(errorMessage); + console.error('Error creating payment intent:', err); + throw err; // Re-throw to allow component-level error handling + } finally { + setIsLoading(false); + } + }, + [] + ); // Empty dependency array since this function doesn't depend on any props or state + + const reset = useCallback(() => { + setPaymentIntent(null); + setError(null); + setIsLoading(false); + }, []); + + return { + paymentIntent, + isLoading, + error, + createPaymentIntent, + reset, + }; +} diff --git a/package-lock.json b/package-lock.json index d07fd8466..508e81e84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,8 @@ "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-slider": "^1.2.3", "@radix-ui/react-slot": "^1.1.0", + "@stripe/react-stripe-js": "^3.9.1", + "@stripe/stripe-js": "^7.8.0", "@svgr/webpack": "^8.1.0", "@tippyjs/react": "^4.2.6", "@tiptap-pro/extension-ai": "^2.16.0", @@ -5094,6 +5096,29 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@stripe/react-stripe-js": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.9.1.tgz", + "integrity": "sha512-t5KZiu7jkUTHOx0adGSlSj4xPpFSvW6BsgIRQHNXqhHeYBH0mpddVUZsO33WM1m6Vyd1Wl96JoBhwEsw8jMHTQ==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=1.44.1 <8.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.8.0.tgz", + "integrity": "sha512-DNXRfYUgkZlrniQORbA/wH8CdFRhiBSE0R56gYU0V5vvpJ9WZwvGrz9tBAZmfq2aTgw6SK7mNpmTizGzLWVezw==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", diff --git a/package.json b/package.json index 5fd667872..88ea6fa8b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-slider": "^1.2.3", "@radix-ui/react-slot": "^1.1.0", + "@stripe/react-stripe-js": "^3.9.1", + "@stripe/stripe-js": "^7.8.0", "@svgr/webpack": "^8.1.0", "@tippyjs/react": "^4.2.6", "@tiptap-pro/extension-ai": "^2.16.0", diff --git a/services/payment.service.ts b/services/payment.service.ts new file mode 100644 index 000000000..e707ef5cc --- /dev/null +++ b/services/payment.service.ts @@ -0,0 +1,63 @@ +import { ApiClient } from './client'; +import type { TransformedPaymentIntent } from '@/types/payment'; +import { transformPaymentIntent } from '@/types/payment'; + +export class PaymentError extends Error { + constructor( + message: string, + public readonly code?: string + ) { + super(message); + this.name = 'PaymentError'; + } +} + +export interface CreatePaymentIntentParams { + amount: number; + currency: 'USD' | 'RSC'; +} + +export interface CreatePaymentIntentResponse { + success: boolean; + paymentIntent: TransformedPaymentIntent; + message?: string; +} + +export class PaymentService { + private static readonly BASE_PATH = '/api/payment'; + + /** + * Creates a payment intent for processing payments + * @param params - The amount and currency for the payment + * @throws {PaymentError} When the request fails or parameters are invalid + */ + static async createPaymentIntent( + params: CreatePaymentIntentParams + ): Promise { + if (!params.amount || params.amount <= 0) { + throw new PaymentError('Amount must be greater than 0', 'INVALID_AMOUNT'); + } + + if (!params.currency || !['USD', 'RSC'].includes(params.currency)) { + throw new PaymentError('Currency must be either USD or RSC', 'INVALID_CURRENCY'); + } + + try { + const response = await ApiClient.post(`${this.BASE_PATH}/payment-intent/`, { + amount: params.amount, + currency: params.currency, + }); + + return { + success: true, + paymentIntent: transformPaymentIntent(response), + message: 'Payment intent created successfully', + }; + } catch (error) { + throw new PaymentError( + 'Failed to create payment intent', + error instanceof Error ? error.message : 'UNKNOWN_ERROR' + ); + } + } +} diff --git a/types/payment.ts b/types/payment.ts new file mode 100644 index 000000000..29f772f7b --- /dev/null +++ b/types/payment.ts @@ -0,0 +1,30 @@ +import { createTransformer, BaseTransformed } from './transformer'; + +export interface PaymentIntent { + clientSecret: string; + paymentIntentId: string; + lockedRscAmount: number; + stripeAmountCents: number; +} + +export type TransformedPaymentIntent = PaymentIntent & BaseTransformed; + +const baseTransformPaymentIntent = (raw: any): PaymentIntent => { + if (!raw) { + return { + clientSecret: '', + paymentIntentId: '', + lockedRscAmount: 0, + stripeAmountCents: 0, + }; + } + + return { + clientSecret: raw.client_secret || '', + paymentIntentId: raw.payment_intent_id || '', + lockedRscAmount: raw.locked_rsc_amount || 0, + stripeAmountCents: raw.stripe_amount_cents || 0, + }; +}; + +export const transformPaymentIntent = createTransformer(baseTransformPaymentIntent);