Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'],
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
86 changes: 46 additions & 40 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
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
6 changes: 6 additions & 0 deletions apps/frontend/src/containers/donations/steps/Step1Amount.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import React from 'react';
import { Label } from '@components/ui/label';
import { Button } from '@components/ui/button';
import { Input } from '@components/ui/input';
import { Textarea } from '@components/ui/textarea';
import { Checkbox } from '@components/ui/checkbox';
import { ToggleSwitch } from '@components/ToggleSwitch';
import { DonationRecurrence } from './DonationRecurrence';
import { DonationAmount } from './DonationAmount';
Expand Down
Loading
Loading