feat(client): add payment components
This commit is contained in:
@@ -1,3 +1,19 @@
|
||||
main {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem;
|
||||
padding-top: 2rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.payment,
|
||||
.previous-donations {
|
||||
--max-section-width: 31.25rem;
|
||||
flex: 1 1 var(--max-section-width);
|
||||
max-width: var(--max-section-width);
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: #181818;
|
||||
flex-direction: column;
|
||||
@@ -25,15 +41,17 @@ header .info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header .info p {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #181818;
|
||||
border-radius: 0.25rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 31.25rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
box-shadow: 0 4px 8px #0000001a;
|
||||
}
|
||||
|
||||
@@ -47,6 +65,17 @@ form label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
form label .detail {
|
||||
font-size: 0.875rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
form label:has(+ input:required)::after {
|
||||
content: ' [required]';
|
||||
font-size: 0.875rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
form input {
|
||||
color: #e4e4e4;
|
||||
background-color: #282828;
|
||||
@@ -69,6 +98,15 @@ form input[type='number'] {
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
form textarea {
|
||||
color: #e4e4e4;
|
||||
background-color: #282828;
|
||||
border: 1px solid #444;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff6b6b;
|
||||
font-size: 0.875rem;
|
||||
@@ -83,3 +121,152 @@ form button {
|
||||
font-weight: 700;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.payment-confirmation-loading {
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.payment-confirmation-loading .spinner {
|
||||
border: 4px solid #444;
|
||||
border-top: 4px solid #b39cd0;
|
||||
border-radius: 50%;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: 0 auto 1rem auto;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.payment-confirmation-card {
|
||||
background-color: #181818;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 31.25rem;
|
||||
box-shadow: 0 4px 8px #0000001a;
|
||||
}
|
||||
|
||||
.previous-donations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.previous-donations ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.previous-donations ul li {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.previous-donations .load-more-button {
|
||||
color: #fff;
|
||||
background-color: #b39cd0;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
font-weight: 700;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.payment-card {
|
||||
flex: 1;
|
||||
background-color: #282828;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.payment-card .header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.payment-card .left,
|
||||
.payment-card .right {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.payment-card .left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.payment-card .left img {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.payment-card .header .right {
|
||||
flex: 1;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.payment-card .header .info,
|
||||
.payment-card .header .details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.payment-card .header .right .info .date {
|
||||
font-size: 0.875rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.payment-card .header .right .details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.payment-card .header .right .details .amount {
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.payment-card .header .right .info,
|
||||
.payment-card .header .right .details .amount,
|
||||
.payment-card .message {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.payment-card .message {
|
||||
font-style: italic;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.return-home-link {
|
||||
color: #b39cd0;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -1,64 +1,57 @@
|
||||
import '@/App.css';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Elements } from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { useState } from 'react';
|
||||
import CheckoutForm from './CheckoutForm';
|
||||
import PaymentForm from './PaymentForm';
|
||||
import PaymentConfirmationCard from './PaymentConfirmationCard';
|
||||
import PreviousPaymentsList from './PreviousPaymentsList';
|
||||
|
||||
const stripe = loadStripe(import.meta.env.VITE_STRIPE_API_KEY);
|
||||
|
||||
function App() {
|
||||
const [amount, setAmount] = useState<number | ''>('');
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [errors, setErrors] = useState<{ amount?: string; email?: string }>({});
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const clientSecretFromUrl = queryParams.get('payment_intent_client_secret');
|
||||
const [secret, setSecret] = useState<string | undefined>(clientSecretFromUrl ?? undefined);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
async function handleDonationFormSubmit(formData: {
|
||||
name: string;
|
||||
amount: number;
|
||||
message?: string;
|
||||
email?: string;
|
||||
}) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
function handleAmountInput(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
setErrors((prevErrors) => ({ ...prevErrors, amount: undefined }));
|
||||
const value = parseInt(event.target.value);
|
||||
setAmount(isNaN(value) ? '' : value);
|
||||
}
|
||||
try {
|
||||
const url = new URL('/payments/create-intent', import.meta.url);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
amount: formData.amount,
|
||||
message: formData.message,
|
||||
email: formData.email,
|
||||
}),
|
||||
});
|
||||
|
||||
function handleEmailInput(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
setErrors((prevErrors) => ({ ...prevErrors, email: undefined }));
|
||||
setEmail(event.target.value);
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
const newErrors: { amount?: string; email?: string } = {};
|
||||
|
||||
if (amount === '' || amount <= 0) {
|
||||
newErrors.amount = 'Please enter a valid amount greater than 0.';
|
||||
}
|
||||
|
||||
if (email.trim() !== '' && emailInputRef.current?.validity.typeMismatch) {
|
||||
newErrors.email = 'Please enter a valid email address.';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
const isValid = Object.keys(newErrors).length === 0;
|
||||
|
||||
if (isValid === false) {
|
||||
if (newErrors.amount) {
|
||||
amountInputRef.current?.focus();
|
||||
} else if (newErrors.email) {
|
||||
emailInputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const isValid = validateForm();
|
||||
|
||||
if (isValid === false) {
|
||||
if (!res.ok) {
|
||||
console.error('Failed to create payment intent');
|
||||
alert('An error occurred while creating the payment. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: We will stop displaying our form
|
||||
// and we will initialize the stripe embedded
|
||||
// elements and pass along the amount and email
|
||||
alert(`Form is valid! Amount: ${amount}, Email: ${email}`);
|
||||
const data = await res.json();
|
||||
setSecret(data.clientSecret);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('An error occurred while creating the payment. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -72,49 +65,58 @@ function App() {
|
||||
<h1>Stevan Freeborn</h1>
|
||||
<p>
|
||||
I'm a dad of 2 who enjoys drinking coffee, lifting weights, and solving problems with
|
||||
code. You really don't need to buy me a coffee.
|
||||
code. If you have found my open helpful, consider supporting me with a donation which I
|
||||
will more than likely spend on more coffee!
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<div className='group'>
|
||||
<label htmlFor='amount'>Amount</label>
|
||||
<input
|
||||
ref={amountInputRef}
|
||||
id='amount'
|
||||
type='number'
|
||||
min={1}
|
||||
aria-describedby='amountErrorMessage'
|
||||
aria-invalid={errors.amount ? 'true' : 'false'}
|
||||
value={amount}
|
||||
onInput={handleAmountInput}
|
||||
<section className='payment'>
|
||||
{secret === undefined ? (
|
||||
<PaymentForm
|
||||
onValidSubmit={handleDonationFormSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
<span className='error-message'>{errors.amount}</span>
|
||||
</div>
|
||||
<div className='group'>
|
||||
<label htmlFor='email'>Email</label>
|
||||
<input
|
||||
ref={emailInputRef}
|
||||
id='email'
|
||||
type='email'
|
||||
aria-describedby='emailErrorMessage'
|
||||
aria-invalid={errors.email ? 'true' : 'false'}
|
||||
value={email}
|
||||
onInput={handleEmailInput}
|
||||
/>
|
||||
<span
|
||||
id='emailErrorMessage'
|
||||
className='error-message'
|
||||
) : (
|
||||
<Elements
|
||||
options={{
|
||||
clientSecret: secret,
|
||||
loader: 'auto',
|
||||
appearance: {
|
||||
theme: 'stripe',
|
||||
disableAnimations: true,
|
||||
variables: {
|
||||
colorBackground: '#181818',
|
||||
colorPrimary: '#E4E4E4',
|
||||
colorText: '#E4E4E4',
|
||||
fontFamily: 'CaskaydiaCove NFM, monospace',
|
||||
fontSizeBase: '16px',
|
||||
borderRadius: '0.25rem',
|
||||
colorDanger: '#ff6b6b',
|
||||
},
|
||||
rules: {
|
||||
'.Label': {
|
||||
fontWeight: '700',
|
||||
},
|
||||
'.Input': {
|
||||
backgroundColor: '#282828',
|
||||
border: '1px solid #444',
|
||||
padding: '0.5rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
stripe={stripe}
|
||||
>
|
||||
{errors.email}
|
||||
</span>
|
||||
</div>
|
||||
<button type='submit'>Buy</button>
|
||||
</form>
|
||||
{clientSecretFromUrl ? (
|
||||
<PaymentConfirmationCard clientSecret={clientSecretFromUrl} />
|
||||
) : (
|
||||
<CheckoutForm />
|
||||
)}
|
||||
</Elements>
|
||||
)}
|
||||
</section>
|
||||
{clientSecretFromUrl === null ? <PreviousPaymentsList /> : null}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -69,12 +69,3 @@ body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import App from '@/App.tsx';
|
||||
import { render } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -25,8 +25,36 @@ describe('profile info', () => {
|
||||
describe('form', () => {
|
||||
let user: UserEvent;
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
mockFetch.mockImplementation(async (url: URL) => {
|
||||
if (url.toString().endsWith('/payments/create-intent')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clientSecret: 'test_client_secret',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (url.toString().endsWith('/payments')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
payments: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
test('it should have an amount input', () => {
|
||||
@@ -83,8 +111,6 @@ describe('form', () => {
|
||||
});
|
||||
|
||||
test('it should allow form to be submitted without email', async () => {
|
||||
const windowSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
||||
const screen = render(<App />);
|
||||
|
||||
const amountInput = screen.getByLabelText('Amount');
|
||||
@@ -93,7 +119,6 @@ describe('form', () => {
|
||||
await user.type(amountInput, '10');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(windowSpy).toHaveBeenCalledWith('Form is valid! Amount: 10, Email: ');
|
||||
});
|
||||
|
||||
test('it should validate email if provided', async () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class PaymentTests(TestApi api) : IClassFixture<TestApi>
|
||||
email = string.Empty,
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/create-payment-intent", request, TestContext.Current.CancellationToken);
|
||||
var response = await client.PostAsJsonAsync("/payments/create-intent", request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public sealed class PaymentTests(TestApi api) : IClassFixture<TestApi>
|
||||
email = "invalid-email",
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/create-payment-intent", request, TestContext.Current.CancellationToken);
|
||||
var response = await client.PostAsJsonAsync("/payments/create-intent", request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
@@ -48,11 +48,12 @@ public sealed class PaymentTests(TestApi api) : IClassFixture<TestApi>
|
||||
|
||||
var request = new
|
||||
{
|
||||
name = "Test User",
|
||||
amount = 5000,
|
||||
email = "test@test.com",
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/create-payment-intent", request, TestContext.Current.CancellationToken);
|
||||
var response = await client.PostAsJsonAsync("/payments/create-intent", request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user