From 00907ce601f177f8510ecbdede1d2c7b37aa1dea Mon Sep 17 00:00:00 2001 From: Nick Tytarenko Date: Sat, 23 Aug 2025 09:09:14 +0300 Subject: [PATCH 1/7] WIP --- .../modals/ContributeToFundraiseModalV2.tsx | 225 ++++++++++++++++++ components/work/components/FundersSection.tsx | 23 +- 2 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 components/modals/ContributeToFundraiseModalV2.tsx diff --git a/components/modals/ContributeToFundraiseModalV2.tsx b/components/modals/ContributeToFundraiseModalV2.tsx new file mode 100644 index 000000000..16675ce14 --- /dev/null +++ b/components/modals/ContributeToFundraiseModalV2.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { FC, useState } from 'react'; +import { BaseModal } from '@/components/ui/BaseModal'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/form/Input'; +import { useUser } from '@/contexts/UserContext'; +import { useCreateContribution } from '@/hooks/useFundraise'; +import { X, TrendingUp } from 'lucide-react'; +import { toast } from 'react-hot-toast'; + +interface ContributeToFundraiseModalProps { + isOpen: boolean; + onClose: () => void; + onContributeSuccess?: () => void; + fundraise: any; // Using any for now as per your request +} + +export const ContributeToFundraiseModalV2: FC = ({ + isOpen, + onClose, + onContributeSuccess, + fundraise, +}) => { + const { user } = useUser(); + const [inputAmount, setInputAmount] = useState(1000); + const [isContributing, setIsContributing] = useState(false); + const [amountError, setAmountError] = useState(undefined); + + const [{ isLoading, error }, createContribution] = useCreateContribution(); + + // Calculate user balance and fundraise progress + const userBalance = user?.balance || 0; + const lockedBalance = user?.lockedBalance || 0; + const totalAvailableBalance = userBalance + lockedBalance; + + const goalAmount = fundraise?.goalAmount?.rsc || 500000; + const amountRaised = fundraise?.amountRaised?.rsc || 50000; + const progressPercentage = Math.min((amountRaised / goalAmount) * 100, 100); + + // Calculate user's impact (this would need to be calculated based on actual user contributions) + const userImpact = '+0.2%'; // Placeholder - would need actual calculation + + 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 if (numValue > totalAvailableBalance) { + setAmountError('Amount exceeds your available balance'); + } 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 insufficientBalance = inputAmount > totalAvailableBalance; + + return ( + + {/* Header with close button */} +
+ + +
+

+ Fund Proposal + + RSC + +

+

Support cutting-edge research.

+
+
+ + {/* Your Balance Section */} +
+
+

Your Balance

+

{totalAvailableBalance.toLocaleString()}

+

RSC

+
+
+ + {/* Proposal Details Section */} +
+
+ {/* Title */} +

+ Center for Cybernomics and Continuation of Moore's Law: Research on Next-Generation + Computing Architectures. +

+ + {/* Progress Bar */} +
+
+ Funding Progress + + {progressPercentage.toFixed(0)}% + +
+
+
+
+
+ + {/* Key Metrics */} +
+
+

{amountRaised.toLocaleString()}

+

RSC Raised

+
+
+

{goalAmount.toLocaleString()}

+

RSC Goal

+
+
+

{userImpact}

+

Your Impact

+
+
+ + {/* Amount to Fund Section */} +
+ +
+ + RSC +
+ } + /> +
+ + {/* Footer Text */} +
+ Funding amount + {inputAmount.toLocaleString()} RSC +
+
+
+
+ + {/* Action Buttons */} +
+ + + {insufficientBalance && ( +

+ Insufficient balance. You need {(inputAmount - totalAvailableBalance).toLocaleString()}{' '} + more RSC. +

+ )} +
+
+ ); +}; diff --git a/components/work/components/FundersSection.tsx b/components/work/components/FundersSection.tsx index 83f886625..af6bf0fc9 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,7 +131,7 @@ export const FundersSection: FC = ({ fundraise, fundraiseTi /> )} - setIsContributeModalOpen(false)} onContributeSuccess={handleContributeSuccess} From 8252ead2dc616aa1c62d82141f831cbc4f33d6dd Mon Sep 17 00:00:00 2001 From: Nick Tytarenko Date: Sat, 23 Aug 2025 09:11:10 +0300 Subject: [PATCH 2/7] small update --- .../modals/ContributeToFundraiseModalV2.tsx | 66 ++++++++++++++++++- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/components/modals/ContributeToFundraiseModalV2.tsx b/components/modals/ContributeToFundraiseModalV2.tsx index 16675ce14..9477d11e5 100644 --- a/components/modals/ContributeToFundraiseModalV2.tsx +++ b/components/modals/ContributeToFundraiseModalV2.tsx @@ -38,8 +38,14 @@ export const ContributeToFundraiseModalV2: FC = const amountRaised = fundraise?.amountRaised?.rsc || 50000; const progressPercentage = Math.min((amountRaised / goalAmount) * 100, 100); - // Calculate user's impact (this would need to be calculated based on actual user contributions) - const userImpact = '+0.2%'; // Placeholder - would need actual calculation + // 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, ''); @@ -142,12 +148,27 @@ export const ContributeToFundraiseModalV2: FC = {progressPercentage.toFixed(0)}%
-
+
+ {/* Preview of new progress after contribution */} + {inputAmount > 0 && ( +
+ )}
+ {inputAmount > 0 && ( +

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

+ )}
{/* Key Metrics */} @@ -190,6 +211,45 @@ export const ContributeToFundraiseModalV2: FC = {inputAmount.toLocaleString()} RSC
+ + {/* Impact Calculator Section */} +
+

+ Contribution Impact Calculator +

+ +
+ {/* New Progress After Contribution */} +
+ New Progress: + + {(((amountRaised + inputAmount) / goalAmount) * 100).toFixed(1)}% + +
+ + {/* New Amount Raised */} +
+ New Total Raised: + + {(amountRaised + inputAmount).toLocaleString()} RSC + +
+ + {/* Your Impact */} +
+ Your Impact: + {userImpact} +
+ + {/* Remaining to Goal */} +
+ Remaining to Goal: + + {Math.max(0, goalAmount - amountRaised - inputAmount).toLocaleString()} RSC + +
+
+
From ce8a745d60126051e4ca7031f6cec583a33af750 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Sat, 23 Aug 2025 22:24:06 +0300 Subject: [PATCH 3/7] draft 1st step --- .../modals/ContributeToFundraiseModalV2.tsx | 399 +++++++++++------- components/ui/BaseModal.tsx | 23 +- components/ui/form/CurrencyInput.tsx | 28 +- components/work/components/FundersSection.tsx | 1 + 4 files changed, 273 insertions(+), 178 deletions(-) diff --git a/components/modals/ContributeToFundraiseModalV2.tsx b/components/modals/ContributeToFundraiseModalV2.tsx index 9477d11e5..dfd65f564 100644 --- a/components/modals/ContributeToFundraiseModalV2.tsx +++ b/components/modals/ContributeToFundraiseModalV2.tsx @@ -3,17 +3,21 @@ import { FC, useState } from 'react'; import { BaseModal } from '@/components/ui/BaseModal'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/form/Input'; import { useUser } from '@/contexts/UserContext'; import { useCreateContribution } from '@/hooks/useFundraise'; -import { X, TrendingUp } from 'lucide-react'; +import { X, TrendingUp, ArrowLeft } from 'lucide-react'; import { toast } from 'react-hot-toast'; +import { Fundraise } from '@/types/funding'; +import { CurrencyInput } from '../ui/form/CurrencyInput'; + +const PROCESSING_FEE_PERCENTAGE = 0.025; // 2.5% interface ContributeToFundraiseModalProps { isOpen: boolean; onClose: () => void; onContributeSuccess?: () => void; - fundraise: any; // Using any for now as per your request + fundraise: Fundraise; + fundraiseTitle?: string; } export const ContributeToFundraiseModalV2: FC = ({ @@ -21,11 +25,13 @@ export const ContributeToFundraiseModalV2: FC = onClose, onContributeSuccess, fundraise, + fundraiseTitle, }) => { const { user } = useUser(); - const [inputAmount, setInputAmount] = useState(1000); + const [inputAmount, setInputAmount] = useState(100); const [isContributing, setIsContributing] = useState(false); const [amountError, setAmountError] = useState(undefined); + const [currentStep, setCurrentStep] = useState<'contribute' | 'purchase'>('contribute'); const [{ isLoading, error }, createContribution] = useCreateContribution(); @@ -34,8 +40,8 @@ export const ContributeToFundraiseModalV2: FC = const lockedBalance = user?.lockedBalance || 0; const totalAvailableBalance = userBalance + lockedBalance; - const goalAmount = fundraise?.goalAmount?.rsc || 500000; - const amountRaised = fundraise?.amountRaised?.rsc || 50000; + const goalAmount = fundraise.goalAmount.rsc; + const amountRaised = fundraise.amountRaised.rsc; const progressPercentage = Math.min((amountRaised / goalAmount) * 100, 100); // Calculate user's impact based on the contribution amount @@ -94,192 +100,263 @@ export const ContributeToFundraiseModalV2: FC = const insufficientBalance = inputAmount > totalAvailableBalance; - return ( - - {/* Header with close button */} -
- + + {/* Back button - only show on second step */} + {currentStep === 'purchase' && ( + - -
-

- Fund Proposal - - RSC - -

-

Support cutting-edge research.

+ + + )} + +
+
+

Fund Proposal

+ + RSC +
+

Support cutting-edge research.

{/* Your Balance Section */} -
-
+
+

Your Balance

-

{totalAvailableBalance.toLocaleString()}

-

RSC

+

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

+
+ ); - {/* Proposal Details Section */} -
-
- {/* Title */} -

- Center for Cybernomics and Continuation of Moore's Law: Research on Next-Generation - Computing Architectures. -

- - {/* 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

-
-
- - {/* Amount to Fund Section */} -
- -
- ( + <> + {renderHeader()} + {/* Scrollable content area */} +
+ {/* Proposal Details Section */} +
+
+ {/* Title */} +

+ {fundraiseTitle} +

+ + {/* Amount to Fund Section */} +
+ - RSC -
- } + currency="RSC" + label="Amount to Fund" + onCurrencyToggle={() => {}} + disableCurrencyToggle={true} + required={true} />
- {/* Footer Text */} -
- Funding amount - {inputAmount.toLocaleString()} RSC -
-
- - {/* Impact Calculator Section */} -
-

- Contribution Impact Calculator -

- -
- {/* New Progress After Contribution */} -
- New Progress: - - {(((amountRaised + inputAmount) / goalAmount) * 100).toFixed(1)}% + {/* Progress Bar */} +
+
+ Funding Progress + + {progressPercentage.toFixed(0)}%
- - {/* New Amount Raised */} -
- New Total Raised: - - {(amountRaised + inputAmount).toLocaleString()} RSC - +
+
+ {/* Preview of new progress after contribution */} + {inputAmount > 0 && ( +
+ )}
+ {inputAmount > 0 && ( +

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

+ )} +
- {/* Your Impact */} -
- Your Impact: - {userImpact} + {/* Key Metrics */} +
+
+

{amountRaised.toLocaleString()}

+

RSC Raised

+
+
+

{goalAmount.toLocaleString()}

+

RSC Goal

+
+

{userImpact}

+

Your Impact

+
+
- {/* Remaining to Goal */} -
- Remaining to Goal: - - {Math.max(0, goalAmount - amountRaised - inputAmount).toLocaleString()} RSC - + {/* Amounts Section */} +
+
+ {/* Funding Amount */} +
+ Funding amount + + {inputAmount.toLocaleString()} RSC + +
+ + {/* Payment Processing Fee */} +
+ Payment processing fee + + {(inputAmount * PROCESSING_FEE_PERCENTAGE).toFixed(0)} RSC + +
+ + {/* Total Amount */} +
+ Total amount + + {(inputAmount + inputAmount * PROCESSING_FEE_PERCENTAGE).toFixed(0)} RSC + +
-
- {/* Action Buttons */} -
- ) : ( -
- - Fund This Research -
+ )} - +
+
+ + ); - {insufficientBalance && ( -

- Insufficient balance. You need {(inputAmount - totalAvailableBalance).toLocaleString()}{' '} - more RSC. -

- )} + const renderPurchaseStep = () => ( + <> + {renderHeader()} + {/* Purchase Content Placeholder */} +
+
+
+
+
💰
+

Payment Form Coming Soon

+

This will be replaced with the actual payment form

+
+

+ Amount needed:{' '} + {(inputAmount - totalAvailableBalance).toLocaleString()} RSC +

+

+ Current balance: {totalAvailableBalance.toLocaleString()} RSC +

+
+
+
+
+ + ); + + return ( + + {currentStep === 'contribute' ? renderContributeStep() : renderPurchaseStep()} ); }; 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..83151218d 100644 --- a/components/ui/form/CurrencyInput.tsx +++ b/components/ui/form/CurrencyInput.tsx @@ -14,6 +14,8 @@ interface CurrencyInputProps { isExchangeRateLoading?: boolean; label?: string; className?: string; + disableCurrencyToggle?: boolean; + required?: boolean; } export const CurrencyInput = ({ @@ -27,6 +29,8 @@ export const CurrencyInput = ({ isExchangeRateLoading, label = 'I am offering', className = '', + disableCurrencyToggle = false, + required = false, }: CurrencyInputProps) => { const currentAmount = typeof value === 'string' ? parseFloat(value.replace(/,/g, '')) || 0 : value; @@ -47,21 +51,27 @@ 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} +
+ ) : ( + + ) } /> {error &&

{error}

} diff --git a/components/work/components/FundersSection.tsx b/components/work/components/FundersSection.tsx index af6bf0fc9..23bf0cc2d 100644 --- a/components/work/components/FundersSection.tsx +++ b/components/work/components/FundersSection.tsx @@ -136,6 +136,7 @@ export const FundersSection: FC = ({ fundraise, fundraiseTi onClose={() => setIsContributeModalOpen(false)} onContributeSuccess={handleContributeSuccess} fundraise={fundraise} + fundraiseTitle={fundraiseTitle} /> ); From a4e8a66ad7c2a2430af7b5c95e618970e31fb456 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Sun, 24 Aug 2025 16:16:14 +0300 Subject: [PATCH 4/7] add Stripe Payment Element --- components/StripeWrapper.tsx | 30 ++++ .../modals/ContributeToFundraiseModalV2.tsx | 143 ++++++++++++++++-- package-lock.json | 25 +++ package.json | 2 + 4 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 components/StripeWrapper.tsx diff --git a/components/StripeWrapper.tsx b/components/StripeWrapper.tsx new file mode 100644 index 000000000..eb5a979ec --- /dev/null +++ b/components/StripeWrapper.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { loadStripe, StripeElementsOptions } from '@stripe/stripe-js'; +import { Elements } from '@stripe/react-stripe-js'; +import { ReactNode } from 'react'; + +const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); + +interface StripeWrapperProps { + children: ReactNode; + options?: StripeElementsOptions; +} + +export const StripeWrapper: React.FC = ({ + children, + options = { + appearance: { + theme: 'stripe', + variables: { + colorPrimary: '#3971ff', + }, + }, + }, +}) => { + return ( + + {children} + + ); +}; diff --git a/components/modals/ContributeToFundraiseModalV2.tsx b/components/modals/ContributeToFundraiseModalV2.tsx index dfd65f564..ad1f4b045 100644 --- a/components/modals/ContributeToFundraiseModalV2.tsx +++ b/components/modals/ContributeToFundraiseModalV2.tsx @@ -9,6 +9,8 @@ import { X, TrendingUp, ArrowLeft } 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.025; // 2.5% @@ -320,27 +322,138 @@ export const ContributeToFundraiseModalV2: FC = ); + const PaymentForm: React.FC<{ amount: number }> = ({ amount }) => { + const stripe = useStripe(); + const elements = useElements(); + const [isProcessing, setIsProcessing] = useState(false); + const [message, setMessage] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!stripe || !elements) return; + + setIsProcessing(true); + + try { + // Create PaymentIntent first + const response = await fetch('/api/create-payment-intent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + amount: amount, + metadata: { + fundraiseId: fundraise.id, + userId: user?.id, + rscAmount: inputAmount - totalAvailableBalance, + }, + }), + }); + + const { clientSecret } = await response.json(); + + // Confirm the payment + const result = await stripe.confirmPayment({ + elements, + clientSecret, + confirmParams: { + return_url: `${window.location.origin}/payment-result`, + }, + redirect: 'if_required', // Only redirect for payment methods that require it + }); + + if (result.error) { + setMessage(result.error.message || 'Payment failed'); + } else if (result.paymentIntent && result.paymentIntent.status === 'succeeded') { + setMessage('Payment successful! RSC will be added to your account shortly.'); + // You could close the modal or show success state here + 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 {(inputAmount - totalAvailableBalance).toLocaleString()} RSC{' '} + to complete your contribution. +

+
+ +
+
+ Amount needed: + + {(inputAmount - totalAvailableBalance).toLocaleString()} RSC + +
+
+ Current balance: + + {totalAvailableBalance.toLocaleString()} RSC + +
+
+ +
+ +
+ + + + {message && ( +
+ {message} +
+ )} +
+ ); + }; + const renderPurchaseStep = () => ( <> {renderHeader()} - {/* Purchase Content Placeholder */}
-
-
💰
-

Payment Form Coming Soon

-

This will be replaced with the actual payment form

-
-

- Amount needed:{' '} - {(inputAmount - totalAvailableBalance).toLocaleString()} RSC -

-

- Current balance: {totalAvailableBalance.toLocaleString()} RSC -

-
-
+ + +
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", From 29c486b3845e9b265ffbd50f798d7ea0a4442980 Mon Sep 17 00:00:00 2001 From: Nick Tytarenko Date: Wed, 27 Aug 2025 23:45:06 +0300 Subject: [PATCH 5/7] WIP --- components/StripeWrapper.tsx | 117 +- .../modals/ContributeToFundraiseModalV2.tsx | 1021 +++++++++++------ hooks/usePayment.ts | 63 + services/payment.service.ts | 63 + types/payment.ts | 30 + 5 files changed, 921 insertions(+), 373 deletions(-) create mode 100644 hooks/usePayment.ts create mode 100644 services/payment.service.ts create mode 100644 types/payment.ts diff --git a/components/StripeWrapper.tsx b/components/StripeWrapper.tsx index eb5a979ec..bcb0ff191 100644 --- a/components/StripeWrapper.tsx +++ b/components/StripeWrapper.tsx @@ -1,8 +1,103 @@ 'use client'; -import { loadStripe, StripeElementsOptions } from '@stripe/stripe-js'; +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!); @@ -11,19 +106,15 @@ interface StripeWrapperProps { options?: StripeElementsOptions; } -export const StripeWrapper: React.FC = ({ - children, - options = { - appearance: { - theme: 'stripe', - variables: { - colorPrimary: '#3971ff', - }, - }, - }, -}) => { +export const StripeWrapper: React.FC = ({ children, options }) => { return ( - + {children} ); diff --git a/components/modals/ContributeToFundraiseModalV2.tsx b/components/modals/ContributeToFundraiseModalV2.tsx index ad1f4b045..84d950b49 100644 --- a/components/modals/ContributeToFundraiseModalV2.tsx +++ b/components/modals/ContributeToFundraiseModalV2.tsx @@ -1,18 +1,19 @@ 'use client'; -import { FC, useState } from 'react'; +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 { X, TrendingUp, ArrowLeft } from 'lucide-react'; +import { usePaymentIntent } from '@/hooks/usePayment'; +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.025; // 2.5% +const PROCESSING_FEE_PERCENTAGE = 0.07; // 7% interface ContributeToFundraiseModalProps { isOpen: boolean; @@ -22,6 +23,606 @@ interface ContributeToFundraiseModalProps { 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; +}> = ({ onClose, onBack, showBackButton, totalAvailableBalance }) => ( +
+ {/* Close button - always on the right */} + + + {/* Back button - only show on second step */} + {showBackButton && ( + + )} + +
+
+

Fund Proposal

+ + RSC + +
+

Support cutting-edge research.

+
+ + {/* Your Balance Section */} +
+
+

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); + + // 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: { + // Make sure to change this to your payment completion page + return_url: `${window.location.origin}/payment-result`, + }, + }); + + // 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 + +
+ + {/* Your Balance */} +
+ Your balance + + {totalAvailableBalance.toLocaleString()} RSC + +
+ + {/* RSC Needed to Buy */} +
+ RSC needed to buy + + {rscNeededToBuy.toLocaleString()} RSC + +
+
+
+ + + + {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 USD cents + const amountNeeded = Math.round(totalAmountWithFee - totalAvailableBalance); + + useEffect(() => { + const createIntent = async () => { + try { + await createPaymentIntent(amountNeeded, 'USD'); + } 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) { + // 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, +}) => ( + <> + + {/* Scrollable content area */} +
+ {/* Proposal Details Section */} +
+
+ {/* Title */} +

+ {fundraiseTitle} +

+ + {/* Amount to Fund Section */} +
+ {}} + disableCurrencyToggle={true} + required={true} + /> +
+ + {/* 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 + +
+
+
+
+
+ + {/* 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, @@ -35,17 +636,34 @@ export const ContributeToFundraiseModalV2: FC = 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 = userBalance + lockedBalance; + 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%'; @@ -65,10 +683,15 @@ export const ContributeToFundraiseModalV2: FC = // Validate minimum amount if (numValue < 10) { setAmountError('Minimum contribution amount is 10 RSC'); - } else if (numValue > totalAvailableBalance) { - setAmountError('Amount exceeds your available balance'); } else { - setAmountError(undefined); + 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); @@ -100,8 +723,6 @@ export const ContributeToFundraiseModalV2: FC = } }; - const insufficientBalance = inputAmount > totalAvailableBalance; - const handleBuyRSC = () => { setCurrentStep('purchase'); }; @@ -110,356 +731,6 @@ export const ContributeToFundraiseModalV2: FC = setCurrentStep('contribute'); }; - const renderHeader = () => ( -
- {/* Close button - always on the right */} - - - {/* Back button - only show on second step */} - {currentStep === 'purchase' && ( - - )} - -
-
-

Fund Proposal

- - RSC - -
-

Support cutting-edge research.

-
- - {/* Your Balance Section */} -
-
-

Your Balance

-

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

-
-
-
- ); - - const renderContributeStep = () => ( - <> - {renderHeader()} - {/* Scrollable content area */} -
- {/* Proposal Details Section */} -
-
- {/* Title */} -

- {fundraiseTitle} -

- - {/* Amount to Fund Section */} -
- {}} - disableCurrencyToggle={true} - required={true} - /> -
- - {/* 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 - - {(inputAmount * PROCESSING_FEE_PERCENTAGE).toFixed(0)} RSC - -
- - {/* Total Amount */} -
- Total amount - - {(inputAmount + inputAmount * PROCESSING_FEE_PERCENTAGE).toFixed(0)} RSC - -
-
-
-
-
- - {/* Action Buttons */} -
- {insufficientBalance && ( -
-
-
-
- ! -
-
-
-

Insufficient Balance

-
-

- You need{' '} - - {(inputAmount - totalAvailableBalance).toLocaleString()} more RSC - {' '} - to complete this contribution. -

-
-
-
-
- )} - - {insufficientBalance ? ( - - ) : ( - - )} -
-
- - ); - - const PaymentForm: React.FC<{ amount: number }> = ({ amount }) => { - const stripe = useStripe(); - const elements = useElements(); - const [isProcessing, setIsProcessing] = useState(false); - const [message, setMessage] = useState(null); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!stripe || !elements) return; - - setIsProcessing(true); - - try { - // Create PaymentIntent first - const response = await fetch('/api/create-payment-intent', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - amount: amount, - metadata: { - fundraiseId: fundraise.id, - userId: user?.id, - rscAmount: inputAmount - totalAvailableBalance, - }, - }), - }); - - const { clientSecret } = await response.json(); - - // Confirm the payment - const result = await stripe.confirmPayment({ - elements, - clientSecret, - confirmParams: { - return_url: `${window.location.origin}/payment-result`, - }, - redirect: 'if_required', // Only redirect for payment methods that require it - }); - - if (result.error) { - setMessage(result.error.message || 'Payment failed'); - } else if (result.paymentIntent && result.paymentIntent.status === 'succeeded') { - setMessage('Payment successful! RSC will be added to your account shortly.'); - // You could close the modal or show success state here - 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 {(inputAmount - totalAvailableBalance).toLocaleString()} RSC{' '} - to complete your contribution. -

-
- -
-
- Amount needed: - - {(inputAmount - totalAvailableBalance).toLocaleString()} RSC - -
-
- Current balance: - - {totalAvailableBalance.toLocaleString()} RSC - -
-
- -
- -
- - - - {message && ( -
- {message} -
- )} -
- ); - }; - - const renderPurchaseStep = () => ( - <> - {renderHeader()} -
-
-
- - - -
-
-
- - ); - return ( = padding="p-0" fixedWidth={true} > - {currentStep === 'contribute' ? renderContributeStep() : renderPurchaseStep()} + {currentStep === 'contribute' ? ( + + ) : ( + + )} ); }; 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/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); From a14eab9e8b6b0398c67e4fe92487b69e7c1e5456 Mon Sep 17 00:00:00 2001 From: Nick Tytarenko Date: Thu, 28 Aug 2025 00:11:18 +0300 Subject: [PATCH 6/7] add exhange rate functionality to the modal --- .../modals/ContributeToFundraiseModalV2.tsx | 404 ++++++++++-------- components/ui/form/CurrencyInput.tsx | 6 +- 2 files changed, 238 insertions(+), 172 deletions(-) diff --git a/components/modals/ContributeToFundraiseModalV2.tsx b/components/modals/ContributeToFundraiseModalV2.tsx index 84d950b49..9bece8268 100644 --- a/components/modals/ContributeToFundraiseModalV2.tsx +++ b/components/modals/ContributeToFundraiseModalV2.tsx @@ -6,6 +6,7 @@ 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'; @@ -73,7 +74,8 @@ const ModalHeader: FC<{ onBack?: () => void; showBackButton: boolean; totalAvailableBalance: number; -}> = ({ onClose, onBack, showBackButton, totalAvailableBalance }) => ( + currentStep: string; +}> = ({ onClose, onBack, showBackButton, totalAvailableBalance, currentStep }) => (
{/* Close button - always on the right */}
@@ -332,6 +360,7 @@ const PurchaseStep: FC<{ onBack={onBack} showBackButton={true} totalAvailableBalance={totalAvailableBalance} + currentStep="purchase" /> @@ -385,6 +414,7 @@ const PurchaseStep: FC<{ onBack={onBack} showBackButton={true} totalAvailableBalance={totalAvailableBalance} + currentStep="purchase" />
@@ -452,175 +482,201 @@ const ContributeStep: FC<{ processingFee, totalAmountWithFee, totalAvailableBalance, -}) => ( - <> - - {/* Scrollable content area */} -
- {/* Proposal Details Section */} -
-
- {/* Title */} -

- {fundraiseTitle} -

- - {/* Amount to Fund Section */} -
- {}} - disableCurrencyToggle={true} - required={true} - /> -
+}) => { + const { exchangeRate } = useExchangeRate(); - {/* Progress Bar */} -
-
- Funding Progress - - {progressPercentage.toFixed(0)}% - -
-
-
{ + 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 + } /> - {/* Preview of new progress after contribution */} - {inputAmount > 0 && ( +
+ + {/* 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 +

)}
- {inputAmount > 0 && ( -

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

- )} -
- {/* Key Metrics */} -
-
-

{amountRaised.toLocaleString()}

-

RSC Raised

-
-
-

{goalAmount.toLocaleString()}

-

RSC Goal

-
-
-

{userImpact}

-

Your Impact

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

{amountRaised.toLocaleString()}

+

RSC Raised

+
+
+

{goalAmount.toLocaleString()}

+

RSC Goal

+
+
+

{userImpact}

+

Your Impact

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

Insufficient Balance

-
-

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

+
+

Insufficient Balance

+
+

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

+
-
- )} + )} - {insufficientBalance ? ( - - ) : ( - - )} + + ) : ( + + )} +
-
- -); + + ); +}; // Main Modal Component export const ContributeToFundraiseModalV2: FC = ({ @@ -631,6 +687,7 @@ export const ContributeToFundraiseModalV2: FC = fundraiseTitle, }) => { const { user } = useUser(); + const { exchangeRate } = useExchangeRate(); const [inputAmount, setInputAmount] = useState(100); const [isContributing, setIsContributing] = useState(false); const [amountError, setAmountError] = useState(undefined); @@ -731,6 +788,13 @@ export const ContributeToFundraiseModalV2: FC = setCurrentStep('contribute'); }; + // Helper function to format USD amounts + const formatUSD = (rscAmount: number) => { + if (!exchangeRate) return ''; + const usdAmount = (rscAmount * exchangeRate).toFixed(2); + return `~$${usdAmount}`; + }; + return ( ) => void; currency: Currency; @@ -31,6 +31,7 @@ export const CurrencyInput = ({ className = '', disableCurrencyToggle = false, required = false, + ...props }: CurrencyInputProps) => { const currentAmount = typeof value === 'string' ? parseFloat(value.replace(/,/g, '')) || 0 : value; @@ -73,6 +74,7 @@ export const CurrencyInput = ({ ) } + {...props} /> {error &&

{error}

} {suggestedAmount && !error && ( From aa8ac5fff117f51870bd81240ceedc22d1c6101f Mon Sep 17 00:00:00 2001 From: Nick Tytarenko Date: Wed, 3 Sep 2025 20:21:44 +0300 Subject: [PATCH 7/7] minor fixes --- .../modals/ContributeToFundraiseModalV2.tsx | 96 ++++++++++--------- 1 file changed, 52 insertions(+), 44 deletions(-) diff --git a/components/modals/ContributeToFundraiseModalV2.tsx b/components/modals/ContributeToFundraiseModalV2.tsx index 9bece8268..1de1ab5b3 100644 --- a/components/modals/ContributeToFundraiseModalV2.tsx +++ b/components/modals/ContributeToFundraiseModalV2.tsx @@ -276,7 +276,14 @@ const PaymentForm: FC<{
- + { + setMessage( + 'Unable to load payment form. Please refresh the page and try again. If the problem persists, please contact support.' + ); + }} + /> {message && (
{ const createIntent = async () => { try { - await createPaymentIntent(amountNeeded, 'USD'); + await createPaymentIntent(amountNeeded, 'RSC'); } catch (error) { console.error('Failed to create payment intent:', error); toast.error('Failed to initialize payment. Please try again.'); @@ -369,42 +376,43 @@ const PurchaseStep: FC<{ // TODO: Show error if payment intent creation failed // Show error if payment intent creation failed - // if (intentError) { - // return ( - // <> - // - //
- //
- //
- //
- //
- // - //
- //
- //

- // Payment Initialization Failed - //

- //
- //

{intentError}

- //
- //
- //
- //
- //
- // - //
- //
- //
- // - // ); - // } + if (intentError || !paymentIntent) { + return ( + <> + +
+
+
+
+
+ +
+
+

+ Payment Initialization Failed +

+
+

{intentError}

+
+
+
+
+
+ +
+
+
+ + ); + } // Render Stripe Elements with payment intent return ( @@ -421,11 +429,11 @@ const PurchaseStep: FC<{