Skip to content

Commit 2f2c47f

Browse files
feat(stripe): integrate pay-by-card through stripe
1 parent 6c74ff4 commit 2f2c47f

File tree

6 files changed

+216
-1
lines changed

6 files changed

+216
-1
lines changed

.env.example

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ PRIVATE_KEY=
33
WALLET_ADDRESS=
44
REDIRECT_URL=
55
INTERLEDGER_PAY_HOST=
6-
SESSION_COOKIE_SECRET_KEY=
6+
SESSION_COOKIE_SECRET_KEY=
7+
STRIPE_PUBLISHABLE_KEY=
8+
STRIPE_SECRET_KEY=

app/lib/stripe.server.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Stripe from 'stripe'
2+
3+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
4+
5+
export async function createPaymentIntent(amount: number) {
6+
return await stripe.paymentIntents.create({
7+
amount,
8+
currency: 'eur',
9+
automatic_payment_methods: {
10+
enabled: true
11+
}
12+
})
13+
}
14+
15+
export async function retrievePaymentIntent(id: string) {
16+
return await stripe.paymentIntents.retrieve(id)
17+
}

app/routes/checkout.tsx

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { LoaderFunctionArgs } from '@remix-run/node'
2+
import { useLoaderData } from '@remix-run/react'
3+
import {
4+
Elements,
5+
PaymentElement,
6+
useElements,
7+
useStripe
8+
} from '@stripe/react-stripe-js'
9+
import { loadStripe } from '@stripe/stripe-js'
10+
import { useEffect, useState } from 'react'
11+
import { createPaymentIntent } from '../lib/stripe.server'
12+
import { getSession } from '../session'
13+
14+
const stripePromise = loadStripe('pk_test_B4Mlg9z1svOsuVjovpcLaK0d00lWym58fF')
15+
16+
export default function CheckoutPage() {
17+
const [clientSecret, setClientSecret] = useState('')
18+
19+
const paymentIntent: any = useLoaderData()
20+
const options = {
21+
clientSecret: paymentIntent.client_secret
22+
}
23+
24+
useEffect(() => {
25+
if (paymentIntent && paymentIntent.client_secret) {
26+
setClientSecret(paymentIntent.client_secret)
27+
}
28+
}, [paymentIntent])
29+
30+
return (
31+
<Elements stripe={stripePromise} options={options}>
32+
<CheckoutForm clientSecret={clientSecret} />
33+
</Elements>
34+
)
35+
}
36+
37+
export async function loader({ request }: LoaderFunctionArgs) {
38+
const session = await getSession(request.headers.get('Cookie'))
39+
const amount = session.get('amount')
40+
return await createPaymentIntent(amount)
41+
}
42+
43+
type CheckoutFormProps = {
44+
clientSecret: string
45+
}
46+
47+
function CheckoutForm({ clientSecret }: CheckoutFormProps) {
48+
console.log('clientSecret', clientSecret)
49+
// Client secret might still need to be passed down as props in case of using the CardElement instead of PaymentElement (which allows for full customization)
50+
/*
51+
stripe.confirmCardPayment.(clientSecret, {
52+
payment_method: {card: cardElement}
53+
})`
54+
*/
55+
// Leaving it as frontend's choice
56+
57+
const stripe = useStripe()
58+
const elements = useElements()
59+
60+
const handleSubmit = async (event: React.FormEvent) => {
61+
event.preventDefault()
62+
if (!stripe || !elements) return
63+
64+
const { error } = await stripe.confirmPayment({
65+
elements,
66+
confirmParams: {
67+
return_url: 'http://localhost:3000/success' // TODO Success Page
68+
}
69+
})
70+
if (error) {
71+
console.error(error.message)
72+
}
73+
}
74+
75+
return (
76+
<form onSubmit={handleSubmit}>
77+
<PaymentElement />
78+
<button
79+
type="submit"
80+
style={{
81+
marginTop: '20px',
82+
background: '#5469d4',
83+
color: '#ffffff',
84+
border: 'none',
85+
borderRadius: '4px',
86+
fontSize: '16px',
87+
cursor: 'pointer',
88+
padding: '10px 20px'
89+
}}
90+
disabled={!stripe || !elements}
91+
>
92+
Pay
93+
</button>
94+
</form>
95+
)
96+
}

app/routes/pay.tsx

+38
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,16 @@ export default function Pay() {
142142
Pay with Interledger
143143
</Button>
144144
</div>
145+
<div className="flex justify-center">
146+
<Button
147+
aria-label="pay"
148+
type="submit"
149+
name="intent"
150+
value="pay-by-card"
151+
>
152+
Pay by card
153+
</Button>
154+
</div>
145155
</div>
146156
</Form>
147157
</div>
@@ -163,6 +173,34 @@ export async function action({ request }: ActionFunctionArgs) {
163173
const formData = await request.formData()
164174
const intent = formData.get('intent')
165175

176+
if (intent === 'pay-by-card') {
177+
const submission = await parse(formData, {
178+
schema: schema.superRefine(async (data, context) => {
179+
try {
180+
receiver = await getValidWalletAddress(data.receiver)
181+
session.set('receiver-wallet-address', receiver)
182+
session.set('amount', data.amount)
183+
} catch (error) {
184+
context.addIssue({
185+
path: ['receiver'],
186+
code: z.ZodIssueCode.custom,
187+
message: 'Receiver wallet address is not valid.'
188+
})
189+
}
190+
}),
191+
async: true
192+
})
193+
194+
if (!submission.value || submission.intent !== 'submit') {
195+
console.log('return json(submission)')
196+
return json(submission)
197+
}
198+
199+
return redirect(`/checkout`, {
200+
headers: { 'Set-Cookie': await commitSession(session) }
201+
})
202+
}
203+
166204
if (intent === 'pay') {
167205
const submission = await parse(formData, {
168206
schema: schema.superRefine(async (data, context) => {

app/routes/success.tsx

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { LoaderFunctionArgs } from '@remix-run/node'
2+
import { json, useLoaderData } from '@remix-run/react'
3+
import { useEffect, useState } from 'react'
4+
import { retrievePaymentIntent } from '../lib/stripe.server'
5+
6+
export async function loader({ request }: LoaderFunctionArgs) {
7+
const params = new URL(request.url).searchParams
8+
const paymentIntentId = params.get('payment_intent')
9+
const clientSecret = params.get('payment_intent_client_secret')
10+
11+
const paymentIntent = await retrievePaymentIntent(paymentIntentId!)
12+
13+
return json({
14+
id: paymentIntentId,
15+
paymentIntent,
16+
clientSecret
17+
})
18+
}
19+
20+
export default function SuccessPage() {
21+
const [paymentIntent, setPaymentIntent] = useState(null)
22+
const data = useLoaderData<typeof loader>() as any
23+
24+
useEffect(() => {
25+
if (data.paymentIntent) {
26+
setPaymentIntent(data.paymentIntent)
27+
}
28+
}, [data.paymentIntent])
29+
30+
if (!paymentIntent) {
31+
return <div className="text-center text-gray-500">Loading...</div>
32+
}
33+
34+
return (
35+
<div className="max-w-lg mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
36+
<h1 className="text-2xl font-bold text-green-600 mb-4">
37+
Payment Successful
38+
</h1>
39+
<div className="text-left">
40+
<p className="mb-2">
41+
<strong>Payment Intent ID:</strong> {(paymentIntent as any).id}
42+
</p>
43+
<p className="mb-2">
44+
<strong>Status:</strong> {(paymentIntent as any).status}
45+
</p>
46+
<p className="mb-2">
47+
<strong>Amount:</strong> {(paymentIntent as any).amount}
48+
</p>
49+
<p className="mb-2">
50+
<strong>Currency:</strong> {(paymentIntent as any).currency}
51+
</p>
52+
<p className="mb-2">
53+
<strong>Payment Method:</strong>{' '}
54+
{(paymentIntent as any).payment_method}
55+
</p>
56+
</div>
57+
</div>
58+
)
59+
}

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
"@remix-run/node": "^2.8.1",
2828
"@remix-run/react": "^2.8.1",
2929
"@remix-run/serve": "^2.8.1",
30+
"@stripe/react-stripe-js": "^3.0.0",
31+
"@stripe/stripe-js": "^5.2.0",
3032
"class-variance-authority": "^0.7.0",
3133
"clsx": "^2.1.0",
3234
"framer-motion": "^11.1.7",
@@ -35,6 +37,7 @@
3537
"react": "^18.2.0",
3638
"react-dom": "^18.2.0",
3739
"remix": "^2.8.1",
40+
"stripe": "^17.4.0",
3841
"tailwind-merge": "^2.2.1",
3942
"tailwindcss-animate": "^1.0.7",
4043
"web-share-shim": "^1.0.4",

0 commit comments

Comments
 (0)