Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/backend/src/payments/payments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export class PaymentsService {
amount: request.amount,
currency: request.currency,
metadata: request.metadata,
payment_method_types: ['card', 'us_bank_accounts'],
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stripe recommends letting them manage payment methods dynamically by using automatic_payment_methods: { enabled: true }

payment_method_types: ['card'],
});

this.logger.debug(
Expand Down
43 changes: 40 additions & 3 deletions apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,46 @@ export type DonationCreateRequest = {
firstName: string;
lastName: string;
email: string;
amount: number; // parsed to number in the form
amount: number;
isAnonymous: boolean;
donationType: 'one_time' | 'recurring';
dedicationMessage: string; // allow '' from ui
dedicationMessage: string;
showDedicationPublicly: boolean;
recurringInterval?: 'weekly' | 'monthly' | 'yearly';
paymentIntentId?: string;
};

export type CreateDonationResponse = { id: string };

export type CreatePaymentIntentRequest = {
amount: number; // in cents
currency: string;
metadata?: Record<string, unknown>;
};

export type PaymentIntentResponse = {
id: string;
clientSecret: string;
amount: number;
currency: string;
status: string;
};

export type SignInRequest = { email: string; password: string };

export type SignUpRequest = {
firstName: string;
lastName: string;
email: string;
password: string;
};

export type AuthResponse = {
accessToken: string;
refreshToken: string;
idToken: string;
};

export type RefreshRequest = { refreshToken: string; userSub: string };

type ApiError = { error?: string; message?: string };
Expand All @@ -40,11 +58,30 @@ export class ApiClient {
}

public async getHello(): Promise<string> {
//return this.get('/api') as Promise<string>;
const res = await this.axiosInstance.get<string>('/api');
return res.data;
}

public async createPaymentIntent(
body: CreatePaymentIntentRequest,
): Promise<PaymentIntentResponse> {
try {
const res = await this.axiosInstance.post('/api/payments/intent', body);
return res.data as PaymentIntentResponse;
} catch (err: unknown) {
if (axios.isAxiosError<ApiError>(err)) {
const data = err.response?.data;
const msg =
data?.error ??
data?.message ??
err.message ??
'Failed to create payment intent';
throw new Error(msg);
}
throw new Error('Failed to create payment intent');
}
}

public setAuthToken(token: string | null) {
if (token) {
this.axiosInstance.defaults.headers.common['Authorization'] =
Expand Down
98 changes: 46 additions & 52 deletions apps/frontend/src/containers/donations/DonationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import apiClient, {
type CreateDonationResponse,
type CreateDonationRequest,
} from '../../api/apiClient';
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import { type Step2DetailsRef } from './steps/Step2Details';
import { useSearchParams } from 'react-router-dom';
import './donations.css';
import {
Expand All @@ -15,6 +16,7 @@ import { Step1Amount } from './steps/Step1Amount';
import { Step2Details } from './steps/Step2Details';
import { Step3Confirm } from './steps/Step3Confirm';
import { Step4Receipt } from './steps/Step4Receipt';
import { StripeProvider } from './StripeProvider';
import { Button } from '@components/ui/button';

export const DonationForm: React.FC<DonationFormProps> = ({
Expand All @@ -41,15 +43,14 @@ export const DonationForm: React.FC<DonationFormProps> = ({
recurringInterval: 'monthly',
isDedicated: false,
dedicationKind: null,
cardNumber: '',
cardExpiry: '',
cardCvc: '',
coverFees: false,
});

const [errors, setErrors] = useState<Partial<FormErrors>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [paymentMethodId, setPaymentMethodId] = useState<string | null>(null);
const step2Ref = useRef<Step2DetailsRef>(null);
const [receiptId, setReceiptId] = useState<string | null>(
searchParams.get('receiptId'),
);
Expand Down Expand Up @@ -164,10 +165,25 @@ export const DonationForm: React.FC<DonationFormProps> = ({
setSubmitError(null);
};

const handleNext = () => {
const handleNext = async () => {
if (!validateStep(currentStep)) {
return;
}
if (currentStep === 2) {
try {
const pmId = await step2Ref.current?.createPaymentMethod();
if (!pmId) {
setSubmitError('Could not process card. Please try again.');
return;
}
setPaymentMethodId(pmId);
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Could not process card.',
);
return;
}
}
setCurrentStep((prev) => clampStep(prev + 1));
};

Expand All @@ -186,9 +202,6 @@ export const DonationForm: React.FC<DonationFormProps> = ({
dedicationMessage: '',
showDedicationPublicly: false,
recurringInterval: 'monthly',
cardNumber: '',
cardExpiry: '',
cardCvc: '',
coverFees: false,
});
setErrors({});
Expand All @@ -197,27 +210,7 @@ export const DonationForm: React.FC<DonationFormProps> = ({
setCurrentStep(1);
};

const handleSubmit = async () => {
if (isSubmitting) {
return;
}

const step1Valid = validateStep(1);

if (!step1Valid) {
setCurrentStep(1);
return;
}

const step2Valid = validateStep(2);
if (!step2Valid) {
setCurrentStep(2);
return;
}

setIsSubmitting(true);
setSubmitError(null);

const handlePaymentSuccess = async (paymentIntentId: string) => {
try {
const payload: CreateDonationRequest = {
firstName: formData.firstName.trim(),
Expand All @@ -228,6 +221,7 @@ export const DonationForm: React.FC<DonationFormProps> = ({
donationType: formData.donationType,
dedicationMessage: formData.dedicationMessage,
showDedicationPublicly: formData.showDedicationPublicly,
paymentIntentId,
...(formData.donationType === 'recurring' && {
recurringInterval: formData.recurringInterval,
}),
Expand All @@ -241,10 +235,8 @@ export const DonationForm: React.FC<DonationFormProps> = ({
setCurrentStep(4);
} catch (error) {
const err = error as Error;
setSubmitError(err.message || 'Failed to submit donation');
setSubmitError(err.message || 'Failed to record donation');
onError(err);
} finally {
setIsSubmitting(false);
}
};

Expand All @@ -262,17 +254,31 @@ export const DonationForm: React.FC<DonationFormProps> = ({

case 2:
return (
<Step2Details
formData={formData}
errors={errors}
isSubmitting={isSubmitting}
onChange={handleInputChange}
/>
<StripeProvider>
<Step2Details
ref={step2Ref}
formData={formData}
errors={errors}
isSubmitting={isSubmitting}
onChange={handleInputChange}
/>
</StripeProvider>
);

case 3:
return <Step3Confirm formData={formData} />;

return (
<StripeProvider>
<Step3Confirm
formData={formData}
paymentMethodId={paymentMethodId}
onPaymentSuccess={handlePaymentSuccess}
onPaymentError={(error) => setSubmitError(error)}
isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting}
/>
</StripeProvider>
);
case 4:
default:
return <Step4Receipt receiptId={receiptId} />;
}
Expand Down Expand Up @@ -342,18 +348,6 @@ export const DonationForm: React.FC<DonationFormProps> = ({
</Button>
)}

{currentStep === 3 && (
<Button
variant="unstyled"
type="button"
className="flex-1 rounded-[4px] bg-[#007b64] hover:bg-[#006b54] text-white h-[2.5rem] px-3 text-base font-semibold disabled:bg-[#aaa]"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? 'Processing...' : 'Donate'}
</Button>
)}

{currentStep === 4 && (
<Button
variant="default"
Expand Down
38 changes: 38 additions & 0 deletions apps/frontend/src/containers/donations/StripeProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';

const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;

if (!publishableKey) {
throw new Error('Missing VITE_STRIPE_PUBLISHABLE_KEY environment variable');
}

const stripePromise = loadStripe(publishableKey);

interface StripeProviderProps {
children: React.ReactNode;
}

export const StripeProvider: React.FC<StripeProviderProps> = ({ children }) => {
const options = {
appearance: {
theme: 'stripe' as const,
variables: {
colorPrimary: '#0570de',
colorBackground: '#ffffff',
colorText: '#30313d',
colorDanger: '#df1b41',
fontFamily: 'Source Sans 3, system-ui, sans-serif',
spacingUnit: '4px',
borderRadius: '4px',
},
},
};

return (
<Elements stripe={stripePromise} options={options}>
{children}
</Elements>
);
};
3 changes: 0 additions & 3 deletions apps/frontend/src/containers/donations/donation-form.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ export interface DonationFormData {
isAnonymous: boolean;
dedicationMessage: string;
showDedicationPublicly: boolean;
cardNumber: string;
cardExpiry: string;
cardCvc: string;
coverFees: boolean;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from 'react';
import { ToggleSwitch } from '@components/ToggleSwitch';
import { DonationRecurrence } from './DonationRecurrence';
import { DonationAmount } from './DonationAmount';
Expand Down
Loading