Skip to content

Commit 59c3d71

Browse files
authored
feat(checkout): build payment section (#67)
* feat(checkout): integrate stripe * integrate stripe * use query params to persist cart and payment uuids * build thank you and failed payment pages * refactor payment form
1 parent 41a63d6 commit 59c3d71

26 files changed

Lines changed: 580 additions & 34 deletions

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ AUDIOPHILE_API_BASE_URL=http://0.0.0.0:3001
33
AUDIOPHILE_API_KEY=audiophile
44
AUDIOPHILE_API_VERSION=v1
55
MAPBOX_GL_TOKEN=token123
6+
STRIPE_PUBLIC_API_KEY=key123

app/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ export { default as PurchaseCartSummaryItem } from './purchase-cart-summary-item
3333
export { default as PurchaseCartSummaryFee } from './purchase-cart-summary-fee'
3434
export { default as LocationInfo } from './location-info'
3535
export { default as ProgressBar } from './progress-bar'
36+
export { default as PaymentForm } from './payment-form'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './payment-form'
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { useEffect, useState } from "react";
2+
import {
3+
PaymentElement,
4+
LinkAuthenticationElement,
5+
useStripe,
6+
useElements
7+
} from "@stripe/react-stripe-js";
8+
import invariant from 'tiny-invariant'
9+
10+
import type { FormEventHandler } from 'react'
11+
import type { PaymentIntentResult } from '@stripe/stripe-js'
12+
import type { PaymentFormProps } from "./types";
13+
14+
const PaymentForm = ({ redirectUrl }: PaymentFormProps) => {
15+
const stripe = useStripe();
16+
const elements = useElements();
17+
18+
const [message, setMessage] = useState<string | undefined>(undefined);
19+
const [isLoading, setIsLoading] = useState<boolean>(false);
20+
21+
useEffect(() => {
22+
if (!stripe) return
23+
24+
const clientSecret = new URLSearchParams(window.location.search).get("payment_intent_client_secret")
25+
if (!clientSecret) return
26+
27+
stripe
28+
.retrievePaymentIntent(clientSecret)
29+
.then(({ paymentIntent }: PaymentIntentResult) => {
30+
invariant(paymentIntent, 'payment intent must exist')
31+
32+
switch (paymentIntent.status) {
33+
case "succeeded":
34+
setMessage("Payment succeeded!");
35+
break;
36+
case "processing":
37+
setMessage("Your payment is processing.");
38+
break;
39+
case "requires_payment_method":
40+
setMessage("Your payment was not successful, please try again.");
41+
break;
42+
default:
43+
setMessage("Something went wrong.");
44+
break;
45+
}
46+
});
47+
}, [stripe]);
48+
49+
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
50+
e.preventDefault();
51+
if (!stripe || !elements) return
52+
53+
setIsLoading(true)
54+
55+
const { error } = await stripe.confirmPayment({
56+
elements,
57+
confirmParams: {
58+
return_url: redirectUrl
59+
},
60+
});
61+
62+
if (error.type === "card_error" || error.type === "validation_error") {
63+
setMessage(error.message);
64+
} else {
65+
setMessage("An unexpected error occurred.");
66+
}
67+
68+
setIsLoading(false);
69+
};
70+
71+
return (
72+
<form id="payment-form" onSubmit={handleSubmit}>
73+
<LinkAuthenticationElement
74+
id="link-authentication-element"
75+
className="!font-bold"
76+
/>
77+
<PaymentElement
78+
id="payment-element"
79+
options={{ layout: "tabs" }}
80+
/>
81+
<button
82+
disabled={isLoading || !stripe || !elements}
83+
id="submit"
84+
>
85+
<span id="button-text">
86+
{ isLoading ? <div className="spinner" id="spinner" /> : "Pay now" }
87+
</span>
88+
</button>
89+
{ message && <div id="payment-message">{ message }</div> }
90+
</form>
91+
);
92+
}
93+
94+
export default PaymentForm
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface PaymentFormProps {
2+
redirectUrl: string
3+
}

app/components/purchase-cart-list/purchase-cart-list.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import formatCurrency from '~/utils/format-currency'
55
import type { PurchaseCartListProps } from './types'
66

77
const PurchaseCartList = ({ cart, onClose, onCartRemoval }: PurchaseCartListProps) => {
8-
const { items } = cart
8+
const { items, uuid } = cart
99

1010
const subtotal = items.reduce((cumPrice, item) => item.price + cumPrice, 0)
1111

@@ -48,7 +48,7 @@ const PurchaseCartList = ({ cart, onClose, onCartRemoval }: PurchaseCartListProp
4848
</Text>
4949
</div>
5050

51-
<ButtonLink variant="primary" to="/checkout" className="text-center" onClick={onClose}>
51+
<ButtonLink variant="primary" to={`/checkout?cart_uuid=${uuid}`} className="text-center" onClick={onClose}>
5252
Checkout
5353
</ButtonLink>
5454
</div>

app/icons/check/check.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { CheckProps } from './types'
2+
3+
const Check = ({ className }: CheckProps) => {
4+
return (
5+
<svg viewBox="0 0 26 21" fill="none" className={className ? className : ''}>
6+
<path d="M1.75391 11.3328L8.50542 18.0843L24.3085 2.28125" strokeWidth="4"/>
7+
</svg>
8+
)
9+
}
10+
11+
export default Check

app/icons/check/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './check'

app/icons/check/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface CheckProps {
2+
className?: string
3+
}

app/icons/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export { default as BurgerMenu } from './burger-menu'
77
export { default as PurchaseCart } from './purchase-cart'
88
export { default as Cross } from './cross'
99
export { default as TrashCan } from './trash-can'
10+
export { default as Check } from './check'

0 commit comments

Comments
 (0)