Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(stripe): integrate pay-by-card #45

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ PRIVATE_KEY=
WALLET_ADDRESS=
REDIRECT_URL=
INTERLEDGER_PAY_HOST=
SESSION_COOKIE_SECRET_KEY=
SESSION_COOKIE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
17 changes: 17 additions & 0 deletions app/lib/stripe.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function createPaymentIntent(amount: number) {
return await stripe.paymentIntents.create({
amount,
currency: 'eur',
automatic_payment_methods: {
enabled: true
}
})
}

export async function retrievePaymentIntent(id: string) {
return await stripe.paymentIntents.retrieve(id)
}
22 changes: 22 additions & 0 deletions app/routes/card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Header } from "~/components/header";
import { Link } from "@remix-run/react";
import { BackNav } from "~/components/icons";

export default function Card() {
return (
<>
<Header />
<Link to="/" className="flex gap-2 items-center justify-end">
<BackNav />
<span className="hover:text-green-1">Home</span>
</Link>
<div className="flex justify-center items-center flex-col h-full px-5">
<iframe
title="Card Payment"
src={"https://www.youtube.com"}
className="w-full h-full "
></iframe>
</div>
</>
);
}
96 changes: 96 additions & 0 deletions app/routes/checkout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import {
Elements,
PaymentElement,
useElements,
useStripe
} from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js'
import { useEffect, useState } from 'react'
import { createPaymentIntent } from '../lib/stripe.server'
import { getSession } from '../session'

const stripePromise = loadStripe('pk_test_B4Mlg9z1svOsuVjovpcLaK0d00lWym58fF')

export default function CheckoutPage() {
const [clientSecret, setClientSecret] = useState('')

const paymentIntent: any = useLoaderData()
const options = {
clientSecret: paymentIntent.client_secret
}

useEffect(() => {
if (paymentIntent && paymentIntent.client_secret) {
setClientSecret(paymentIntent.client_secret)
}
}, [paymentIntent])

return (
<Elements stripe={stripePromise} options={options}>
<CheckoutForm clientSecret={clientSecret} />
</Elements>
)
}

export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const amount = session.get('amount')
return await createPaymentIntent(amount)
}

type CheckoutFormProps = {
clientSecret: string
}

function CheckoutForm({ clientSecret }: CheckoutFormProps) {
console.log('clientSecret', clientSecret)
// 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)
/*
stripe.confirmCardPayment.(clientSecret, {
payment_method: {card: cardElement}
})`
*/
// Leaving it as frontend's choice

const stripe = useStripe()
const elements = useElements()

const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!stripe || !elements) return

const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: 'http://localhost:3000/success' // TODO Success Page
}
})
if (error) {
console.error(error.message)
}
}

return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button
type="submit"
style={{
marginTop: '20px',
background: '#5469d4',
color: '#ffffff',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
cursor: 'pointer',
padding: '10px 20px'
}}
disabled={!stripe || !elements}
>
Pay
</button>
</form>
)
}
38 changes: 38 additions & 0 deletions app/routes/pay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@ export default function Pay() {
Pay with Interledger
</Button>
</div>
<div className="flex justify-center">
<Button
aria-label="pay"
type="submit"
name="intent"
value="pay-by-card"
>
Pay by card
</Button>
</div>
</div>
</Form>
</div>
Expand All @@ -163,6 +173,34 @@ export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const intent = formData.get('intent')

if (intent === 'pay-by-card') {
const submission = await parse(formData, {
schema: schema.superRefine(async (data, context) => {
try {
receiver = await getValidWalletAddress(data.receiver)
session.set('receiver-wallet-address', receiver)
session.set('amount', data.amount)
} catch (error) {
context.addIssue({
path: ['receiver'],
code: z.ZodIssueCode.custom,
message: 'Receiver wallet address is not valid.'
})
}
}),
async: true
})

if (!submission.value || submission.intent !== 'submit') {
console.log('return json(submission)')
return json(submission)
}

return redirect(`/checkout`, {
headers: { 'Set-Cookie': await commitSession(session) }
})
}

if (intent === 'pay') {
const submission = await parse(formData, {
schema: schema.superRefine(async (data, context) => {
Expand Down
59 changes: 59 additions & 0 deletions app/routes/success.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { LoaderFunctionArgs } from '@remix-run/node'
import { json, useLoaderData } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { retrievePaymentIntent } from '../lib/stripe.server'

export async function loader({ request }: LoaderFunctionArgs) {
const params = new URL(request.url).searchParams
const paymentIntentId = params.get('payment_intent')
const clientSecret = params.get('payment_intent_client_secret')

const paymentIntent = await retrievePaymentIntent(paymentIntentId!)

return json({
id: paymentIntentId,
paymentIntent,
clientSecret
})
}

export default function SuccessPage() {
const [paymentIntent, setPaymentIntent] = useState(null)
const data = useLoaderData<typeof loader>() as any

useEffect(() => {
if (data.paymentIntent) {
setPaymentIntent(data.paymentIntent)
}
}, [data.paymentIntent])

if (!paymentIntent) {
return <div className="text-center text-gray-500">Loading...</div>
}

return (
<div className="max-w-lg mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
<h1 className="text-2xl font-bold text-green-600 mb-4">
Payment Successful
</h1>
<div className="text-left">
<p className="mb-2">
<strong>Payment Intent ID:</strong> {(paymentIntent as any).id}
</p>
<p className="mb-2">
<strong>Status:</strong> {(paymentIntent as any).status}
</p>
<p className="mb-2">
<strong>Amount:</strong> {(paymentIntent as any).amount}
</p>
<p className="mb-2">
<strong>Currency:</strong> {(paymentIntent as any).currency}
</p>
<p className="mb-2">
<strong>Payment Method:</strong>{' '}
{(paymentIntent as any).payment_method}
</p>
</div>
</div>
)
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
"@remix-run/node": "^2.8.1",
"@remix-run/react": "^2.8.1",
"@remix-run/serve": "^2.8.1",
"@stripe/react-stripe-js": "^3.0.0",
"@stripe/stripe-js": "^5.2.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"framer-motion": "^11.1.7",
Expand All @@ -35,6 +37,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remix": "^2.8.1",
"stripe": "^17.4.0",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"web-share-shim": "^1.0.4",
Expand Down
Loading