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 {
|
header {
|
||||||
background-color: #181818;
|
background-color: #181818;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -25,15 +41,17 @@ header .info {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header .info p {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background-color: #181818;
|
background-color: #181818;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
width: 100%;
|
|
||||||
max-width: 31.25rem;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
display: flex;
|
|
||||||
box-shadow: 0 4px 8px #0000001a;
|
box-shadow: 0 4px 8px #0000001a;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +65,17 @@ form label {
|
|||||||
font-weight: 700;
|
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 {
|
form input {
|
||||||
color: #e4e4e4;
|
color: #e4e4e4;
|
||||||
background-color: #282828;
|
background-color: #282828;
|
||||||
@@ -69,6 +98,15 @@ form input[type='number'] {
|
|||||||
appearance: textfield;
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form textarea {
|
||||||
|
color: #e4e4e4;
|
||||||
|
background-color: #282828;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -83,3 +121,152 @@ form button {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
transition: background-color 0.3s;
|
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 '@/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() {
|
function App() {
|
||||||
const [amount, setAmount] = useState<number | ''>('');
|
const queryParams = new URLSearchParams(window.location.search);
|
||||||
const [email, setEmail] = useState<string>('');
|
const clientSecretFromUrl = queryParams.get('payment_intent_client_secret');
|
||||||
const [errors, setErrors] = useState<{ amount?: string; email?: string }>({});
|
const [secret, setSecret] = useState<string | undefined>(clientSecretFromUrl ?? undefined);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
|
||||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
async function handleDonationFormSubmit(formData: {
|
||||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
message?: string;
|
||||||
|
email?: string;
|
||||||
|
}) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
function handleAmountInput(event: React.ChangeEvent<HTMLInputElement>) {
|
try {
|
||||||
setErrors((prevErrors) => ({ ...prevErrors, amount: undefined }));
|
const url = new URL('/payments/create-intent', import.meta.url);
|
||||||
const value = parseInt(event.target.value);
|
const res = await fetch(url, {
|
||||||
setAmount(isNaN(value) ? '' : value);
|
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>) {
|
if (!res.ok) {
|
||||||
setErrors((prevErrors) => ({ ...prevErrors, email: undefined }));
|
console.error('Failed to create payment intent');
|
||||||
setEmail(event.target.value);
|
alert('An error occurred while creating the payment. Please try again.');
|
||||||
}
|
return;
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const isValid = validateForm();
|
|
||||||
|
|
||||||
if (isValid === false) {
|
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,49 +65,58 @@ function App() {
|
|||||||
<h1>Stevan Freeborn</h1>
|
<h1>Stevan Freeborn</h1>
|
||||||
<p>
|
<p>
|
||||||
I'm a dad of 2 who enjoys drinking coffee, lifting weights, and solving problems with
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<form
|
<section className='payment'>
|
||||||
onSubmit={handleSubmit}
|
{secret === undefined ? (
|
||||||
noValidate
|
<PaymentForm
|
||||||
>
|
onValidSubmit={handleDonationFormSubmit}
|
||||||
<div className='group'>
|
isSubmitting={isSubmitting}
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
<span className='error-message'>{errors.amount}</span>
|
) : (
|
||||||
</div>
|
<Elements
|
||||||
<div className='group'>
|
options={{
|
||||||
<label htmlFor='email'>Email</label>
|
clientSecret: secret,
|
||||||
<input
|
loader: 'auto',
|
||||||
ref={emailInputRef}
|
appearance: {
|
||||||
id='email'
|
theme: 'stripe',
|
||||||
type='email'
|
disableAnimations: true,
|
||||||
aria-describedby='emailErrorMessage'
|
variables: {
|
||||||
aria-invalid={errors.email ? 'true' : 'false'}
|
colorBackground: '#181818',
|
||||||
value={email}
|
colorPrimary: '#E4E4E4',
|
||||||
onInput={handleEmailInput}
|
colorText: '#E4E4E4',
|
||||||
/>
|
fontFamily: 'CaskaydiaCove NFM, monospace',
|
||||||
<span
|
fontSizeBase: '16px',
|
||||||
id='emailErrorMessage'
|
borderRadius: '0.25rem',
|
||||||
className='error-message'
|
colorDanger: '#ff6b6b',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'.Label': {
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
'.Input': {
|
||||||
|
backgroundColor: '#282828',
|
||||||
|
border: '1px solid #444',
|
||||||
|
padding: '0.5rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
stripe={stripe}
|
||||||
>
|
>
|
||||||
{errors.email}
|
{clientSecretFromUrl ? (
|
||||||
</span>
|
<PaymentConfirmationCard clientSecret={clientSecretFromUrl} />
|
||||||
</div>
|
) : (
|
||||||
<button type='submit'>Buy</button>
|
<CheckoutForm />
|
||||||
</form>
|
)}
|
||||||
|
</Elements>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{clientSecretFromUrl === null ? <PreviousPaymentsList /> : null}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,12 +69,3 @@ body {
|
|||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
color: var(--text-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 App from '@/App.tsx';
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
@@ -25,8 +25,36 @@ describe('profile info', () => {
|
|||||||
describe('form', () => {
|
describe('form', () => {
|
||||||
let user: UserEvent;
|
let user: UserEvent;
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
user = userEvent.setup();
|
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', () => {
|
test('it should have an amount input', () => {
|
||||||
@@ -83,8 +111,6 @@ describe('form', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('it should allow form to be submitted without email', async () => {
|
test('it should allow form to be submitted without email', async () => {
|
||||||
const windowSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
|
||||||
|
|
||||||
const screen = render(<App />);
|
const screen = render(<App />);
|
||||||
|
|
||||||
const amountInput = screen.getByLabelText('Amount');
|
const amountInput = screen.getByLabelText('Amount');
|
||||||
@@ -93,7 +119,6 @@ describe('form', () => {
|
|||||||
await user.type(amountInput, '10');
|
await user.type(amountInput, '10');
|
||||||
await user.click(submitButton);
|
await user.click(submitButton);
|
||||||
|
|
||||||
expect(windowSpy).toHaveBeenCalledWith('Form is valid! Amount: 10, Email: ');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it should validate email if provided', async () => {
|
test('it should validate email if provided', async () => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public sealed class PaymentTests(TestApi api) : IClassFixture<TestApi>
|
|||||||
email = string.Empty,
|
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);
|
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ public sealed class PaymentTests(TestApi api) : IClassFixture<TestApi>
|
|||||||
email = "invalid-email",
|
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);
|
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
}
|
}
|
||||||
@@ -48,11 +48,12 @@ public sealed class PaymentTests(TestApi api) : IClassFixture<TestApi>
|
|||||||
|
|
||||||
var request = new
|
var request = new
|
||||||
{
|
{
|
||||||
|
name = "Test User",
|
||||||
amount = 5000,
|
amount = 5000,
|
||||||
email = "test@test.com",
|
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);
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user