example: Improve Stripe examples (#53255)

### What?
* Updates to the pre-existing [`with-stripe-typescript` example](https://github.com/vercel/next.js/tree/canary/examples/with-stripe-typescript).

### Why?
* Uses latest Stripe best practices.
* Updates to App Router.


Co-authored-by: Michael Novotny <446260+manovotny@users.noreply.github.com>
Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com>
This commit is contained in:
Jonathan Steele 2023-08-04 00:25:29 +01:00 committed by GitHub
parent ca96578f0b
commit b7c9604cc7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 707 additions and 1132 deletions

View file

@ -3,27 +3,22 @@
This is a full-stack TypeScript example using:
- Frontend:
- Next.js and [SWR](https://github.com/vercel/swr)
- Next.js
- [react-stripe-js](https://github.com/stripe/react-stripe-js) for [Checkout](https://stripe.com/checkout) and [Elements](https://stripe.com/elements)
- Backend
- Next.js [API routes](https://nextjs.org/docs/api-routes/introduction)
- Next.js [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions)
- [stripe-node with TypeScript](https://github.com/stripe/stripe-node#usage-with-typescript)
## Demo
- Live demo: https://nextjs-typescript-react-stripe-js.vercel.app/
- CodeSandbox: https://codesandbox.io/s/github/stripe-samples/nextjs-typescript-react-stripe-js
- Tutorial: https://dev.to/thorwebdev/type-safe-payments-with-next-js-typescript-and-stripe-4jo7
- [Live demo](https://nextjs-typescript-react-stripe-js.vercel.app)
- [Guide](https://vercel.com/guides/getting-started-with-nextjs-typescript-stripe)
The demo is running in test mode -- use `4242424242424242` as a test card number with any CVC + future expiration date.
Use the `4000000000003220` test card number to trigger a 3D Secure challenge flow.
Use the `4000002760003184` test card number to trigger a 3D Secure challenge flow.
Read more about testing on Stripe at https://stripe.com/docs/testing.
<details open><summary>Shopping Cart Checkout Demo</summary>
<img src="./public/shopping_cart_demo.gif" alt="A gif of the Shopping Cart Checkout payment page." align="center">
</details>
[Read more](https://stripe.com/docs/testing) about testing on Stripe.
<details><summary>Checkout Donations Demo</summary>
<img src="./public/checkout_demo.gif" alt="A gif of the Checkout payment page." align="center">
@ -41,25 +36,18 @@ Once you have access to [the environment variables you'll need](#required-config
## Included functionality
- [Global CSS styles](https://nextjs.org/blog/next-9-2#built-in-css-support-for-global-stylesheets)
- Implementation of a Layout component that loads and sets up Stripe.js and Elements for usage with SSR via `loadStripe` helper: [components/Layout.tsx](components/Layout.tsx).
- Stripe Checkout
- Custom Amount Donation with redirect to Stripe Checkout:
- Frontend: [pages/donate-with-checkout.tsx](pages/donate-with-checkout.tsx)
- Backend: [pages/api/checkout_sessions/](pages/api/checkout_sessions/)
- Checkout payment result page that uses [SWR](https://github.com/vercel/swr) hooks to fetch the CheckoutSession status from the API route: [pages/result.tsx](pages/result.tsx).
- Server Component: [app/donate-with-checkout/page.tsx](app/donate-with-checkout/page.tsx)
- Server Action: [app/actions/stripe.ts](app/actions/stripe.ts)
- Checkout Session 'success' page fetches the Checkout Session object from Stripe: [donate-with-checkout/result/page.tsx](app/donate-with-checkout/result/page.tsx)
- Stripe Elements
- Custom Amount Donation with Stripe Elements & PaymentIntents (no redirect):
- Frontend: [pages/donate-with-elements.tsx](pages/donate-with-elements.tsx)
- Backend: [pages/api/payment_intents/](pages/api/payment_intents/)
- Webhook handling for [post-payment events](https://stripe.com/docs/payments/accept-a-payment#web-fulfillment)
- By default Next.js API routes are same-origin only. To allow Stripe webhook event requests to reach our API route, we need to add `micro-cors` and [verify the webhook signature](https://stripe.com/docs/webhooks/signatures) of the event. All of this happens in [pages/api/webhooks/index.ts](pages/api/webhooks/index.ts).
- Helpers
- [utils/api-helpers.ts](utils/api-helpers.ts)
- helpers for GET and POST requests.
- [utils/stripe-helpers.ts](utils/stripe-helpers.ts)
- Format amount strings properly using `Intl.NumberFormat`.
- Format amount for usage with Stripe, including zero decimal currency detection.
- Custom Amount Donation with Stripe Payment Element & PaymentIntents:
- Server Component: [app/donate-with-elements/page.tsx](app/donate-with-elements/page.tsx)
- Server Action: [app/actions/stripe.ts](app/actions/stripe.ts)
- Payment Intent 'success' page (via `returl_url`) fetches the Payment Intent object from Stripe: [donate-with-elements/result/page.tsx](app/donate-with-elements/result/page.tsx)
- Webhook handling for [post-payment events](https://stripe.com/docs/payments/handling-payment-events)
- Route Handler: [app/api/webhooks/route.ts](app/api/webhooks/route.ts)
## How to use
@ -146,3 +134,4 @@ Alternatively, you can deploy using our template by clicking on the Deploy butto
- [@thorsten-stripe](https://twitter.com/thorwebdev)
- [@lfades](https://twitter.com/luis_fades)
- [@jsteele-stripe](https://twitter.com/ynnoj)

View file

@ -0,0 +1,55 @@
'use server'
import type { Stripe } from 'stripe'
import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import { CURRENCY } from '@/config'
import { formatAmountForStripe } from '@/utils/stripe-helpers'
import { stripe } from '@/lib/stripe'
export async function createCheckoutSession(data: FormData): Promise<void> {
const checkoutSession: Stripe.Checkout.Session =
await stripe.checkout.sessions.create({
mode: 'payment',
submit_type: 'donate',
line_items: [
{
quantity: 1,
price_data: {
currency: CURRENCY,
product_data: {
name: 'Custom amount donation',
},
unit_amount: formatAmountForStripe(
Number(data.get('customDonation') as string),
CURRENCY
),
},
},
],
success_url: `${headers().get(
'origin'
)}/donate-with-checkout/result?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${headers().get('origin')}/donate-with-checkout`,
})
redirect(checkoutSession.url as string)
}
export async function createPaymentIntent(
data: FormData
): Promise<{ client_secret: string }> {
const paymentIntent: Stripe.PaymentIntent =
await stripe.paymentIntents.create({
amount: formatAmountForStripe(
Number(data.get('customDonation') as string),
CURRENCY
),
automatic_payment_methods: { enabled: true },
currency: CURRENCY,
})
return { client_secret: paymentIntent.client_secret as string }
}

View file

@ -0,0 +1,66 @@
import type { Stripe } from 'stripe'
import { NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe'
export async function POST(req: Request) {
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
await (await req.blob()).text(),
req.headers.get('stripe-signature') as string,
process.env.STRIPE_WEBHOOK_SECRET as string
)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
// On error, log and return the error message.
if (err! instanceof Error) console.log(err)
console.log(`❌ Error message: ${errorMessage}`)
return NextResponse.json(
{ message: `Webhook Error: ${errorMessage}` },
{ status: 400 }
)
}
// Successfully constructed event.
console.log('✅ Success:', event.id)
const permittedEvents: string[] = [
'checkout.session.completed',
'payment_intent.succeeded',
'payment_intent.payment_failed',
]
if (permittedEvents.includes(event.type)) {
let data
try {
switch (event.type) {
case 'checkout.session.completed':
data = event.data.object as Stripe.Checkout.Session
console.log(`💰 CheckoutSession status: ${data.payment_status}`)
break
case 'payment_intent.failed':
data = event.data.object as Stripe.PaymentIntent
console.log(`❌ Payment failed: ${data.last_payment_error?.message}`)
break
case 'payment_intent.succeeded':
data = event.data.object as Stripe.PaymentIntent
console.log(`💰 PaymentIntent status: ${data.status}`)
break
default:
throw new Error(`Unhhandled event: ${event.type}`)
}
} catch (error) {
console.log(error)
return NextResponse.json(
{ message: 'Webhook handler failed' },
{ status: 500 }
)
}
}
// Return a response to acknowledge receipt of the event.
return NextResponse.json({ message: 'Received' }, { status: 200 })
}

View file

@ -0,0 +1,48 @@
'use client'
import React, { useState } from 'react'
import CustomDonationInput from '@/components/CustomDonationInput'
import StripeTestCards from '@/components/StripeTestCards'
import { formatAmountForDisplay } from '@/utils/stripe-helpers'
import * as config from '@/config'
import { createCheckoutSession } from '@/actions/stripe'
export default function CheckoutForm(): JSX.Element {
const [loading] = useState<boolean>(false)
const [input, setInput] = useState<{ customDonation: number }>({
customDonation: Math.round(config.MAX_AMOUNT / config.AMOUNT_STEP),
})
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (
e
): void =>
setInput({
...input,
[e.currentTarget.name]: e.currentTarget.value,
})
return (
<form action={createCheckoutSession}>
<CustomDonationInput
className="checkout-style"
name="customDonation"
min={config.MIN_AMOUNT}
max={config.MAX_AMOUNT}
step={config.AMOUNT_STEP}
currency={config.CURRENCY}
onChange={handleInputChange}
value={input.customDonation}
/>
<StripeTestCards />
<button
className="checkout-style-background"
type="submit"
disabled={loading}
>
Donate {formatAmountForDisplay(input.customDonation, config.CURRENCY)}
</button>
</form>
)
}

View file

@ -0,0 +1,38 @@
import { formatAmountForDisplay } from '@/utils/stripe-helpers'
export default function CustomDonationInput({
name,
min,
max,
currency,
step,
onChange,
value,
className,
}: {
name: string
min: number
max: number
currency: string
step: number
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
value: number
className?: string
}): JSX.Element {
return (
<label>
Custom donation amount ({formatAmountForDisplay(min, currency)}-
{formatAmountForDisplay(max, currency)}):
<input
type="range"
name={name}
min={min}
max={max}
step={step}
onChange={onChange}
value={value}
className={className}
></input>
</label>
)
}

View file

@ -0,0 +1,188 @@
'use client'
import type { StripeError } from '@stripe/stripe-js'
import * as React from 'react'
import {
useStripe,
useElements,
PaymentElement,
Elements,
} from '@stripe/react-stripe-js'
import CustomDonationInput from './CustomDonationInput'
import StripeTestCards from './StripeTestCards'
import { formatAmountForDisplay } from '@/utils/stripe-helpers'
import * as config from '@/config'
import getStripe from '@/utils/get-stripejs'
import { createPaymentIntent } from '@/actions/stripe'
function CheckoutForm(): JSX.Element {
const [input, setInput] = React.useState<{
customDonation: number
cardholderName: string
}>({
customDonation: Math.round(config.MAX_AMOUNT / config.AMOUNT_STEP),
cardholderName: '',
})
const [paymentType, setPaymentType] = React.useState<string>('')
const [payment, setPayment] = React.useState<{
status: 'initial' | 'processing' | 'error'
}>({ status: 'initial' })
const [errorMessage, setErrorMessage] = React.useState<string>('')
const stripe = useStripe()
const elements = useElements()
const PaymentStatus = ({ status }: { status: string }) => {
switch (status) {
case 'processing':
case 'requires_payment_method':
case 'requires_confirmation':
return <h2>Processing...</h2>
case 'requires_action':
return <h2>Authenticating...</h2>
case 'succeeded':
return <h2>Payment Succeeded 🥳</h2>
case 'error':
return (
<>
<h2>Error 😭</h2>
<p className="error-message">{errorMessage}</p>
</>
)
default:
return null
}
}
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) =>
setInput({
...input,
[e.currentTarget.name]: e.currentTarget.value,
})
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
try {
e.preventDefault()
// Abort if form isn't valid
if (!e.currentTarget.reportValidity()) return
if (!elements || !stripe) return
setPayment({ status: 'processing' })
const { error: submitError } = await elements.submit()
if (submitError) {
setPayment({ status: 'error' })
setErrorMessage(submitError.message ?? 'An unknown error occurred')
return
}
// Create a PaymentIntent with the specified amount.
const { client_secret: clientSecret } = await createPaymentIntent(
new FormData(e.target as HTMLFormElement)
)
// Use your card Element with other Stripe.js APIs
const { error: confirmError } = await stripe!.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: `${window.location.origin}/donate-with-elements/result`,
payment_method_data: {
billing_details: {
name: input.cardholderName,
},
},
},
})
if (confirmError) {
setPayment({ status: 'error' })
setErrorMessage(confirmError.message ?? 'An unknown error occurred')
}
} catch (err) {
const { message } = err as StripeError
setPayment({ status: 'error' })
setErrorMessage(message ?? 'An unknown error occurred')
}
}
return (
<>
<form onSubmit={handleSubmit}>
<CustomDonationInput
className="elements-style"
name="customDonation"
value={input.customDonation}
min={config.MIN_AMOUNT}
max={config.MAX_AMOUNT}
step={config.AMOUNT_STEP}
currency={config.CURRENCY}
onChange={handleInputChange}
/>
<StripeTestCards />
<fieldset className="elements-style">
<legend>Your payment details:</legend>
{paymentType === 'card' ? (
<input
placeholder="Cardholder name"
className="elements-style"
type="Text"
name="cardholderName"
onChange={handleInputChange}
required
/>
) : null}
<div className="FormRow elements-style">
<PaymentElement
onChange={(e) => {
setPaymentType(e.value.type)
}}
/>
</div>
</fieldset>
<button
className="elements-style-background"
type="submit"
disabled={
!['initial', 'succeeded', 'error'].includes(payment.status) ||
!stripe
}
>
Donate {formatAmountForDisplay(input.customDonation, config.CURRENCY)}
</button>
</form>
<PaymentStatus status={payment.status} />
</>
)
}
export default function ElementsForm(): JSX.Element {
return (
<Elements
stripe={getStripe()}
options={{
appearance: {
variables: {
colorIcon: '#6772e5',
fontFamily: 'Roboto, Open Sans, Segoe UI, sans-serif',
},
},
currency: config.CURRENCY,
mode: 'payment',
amount: Math.round(config.MAX_AMOUNT / config.AMOUNT_STEP),
}}
>
<CheckoutForm />
</Elements>
)
}

View file

@ -0,0 +1,10 @@
import type { Stripe } from 'stripe'
export default function PrintObject({
content,
}: {
content: Stripe.PaymentIntent | Stripe.Checkout.Session
}): JSX.Element {
const formattedContent: string = JSON.stringify(content, null, 2)
return <pre>{formattedContent}</pre>
}

View file

@ -1,4 +1,4 @@
const StripeTestCards = () => {
export default function StripeTestCards(): JSX.Element {
return (
<div className="test-card-notice">
Use any of the{' '}
@ -17,5 +17,3 @@ const StripeTestCards = () => {
</div>
)
}
export default StripeTestCards

View file

@ -0,0 +1,17 @@
import type { Metadata } from 'next'
import CheckoutForm from '@/components/CheckoutForm'
export const metadata: Metadata = {
title: 'Donate with Checkout | Next.js + TypeScript Example',
}
export default function DonatePage(): JSX.Element {
return (
<div className="page-container">
<h1>Donate with Checkout</h1>
<p>Donate to our project 💖</p>
<CheckoutForm />
</div>
)
}

View file

@ -0,0 +1,5 @@
'use client'
export default function Error({ error }: { error: Error }) {
return <h2>{error.message}</h2>
}

View file

@ -0,0 +1,18 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Checkout Session Result',
}
export default function ResultLayout({
children,
}: {
children: React.ReactNode
}): JSX.Element {
return (
<div className="page-container">
<h1>Checkout Session Result</h1>
{children}
</div>
)
}

View file

@ -0,0 +1,28 @@
import type { Stripe } from 'stripe'
import PrintObject from '@/components/PrintObject'
import { stripe } from '@/lib/stripe'
export default async function ResultPage({
searchParams,
}: {
searchParams: { session_id: string }
}): Promise<JSX.Element> {
if (!searchParams.session_id)
throw new Error('Please provide a valid session_id (`cs_test_...`)')
const checkoutSession: Stripe.Checkout.Session =
await stripe.checkout.sessions.retrieve(searchParams.session_id, {
expand: ['line_items', 'payment_intent'],
})
const paymentIntent = checkoutSession.payment_intent as Stripe.PaymentIntent
return (
<>
<h2>Status: {paymentIntent.status}</h2>
<h3>Checkout Session response:</h3>
<PrintObject content={checkoutSession} />
</>
)
}

View file

@ -0,0 +1,21 @@
import type { Metadata } from 'next'
import ElementsForm from '@/components/ElementsForm'
export const metadata: Metadata = {
title: 'Donate with Elements',
}
export default function PaymentElementPage({
searchParams,
}: {
searchParams?: { payment_intent_client_secret?: string }
}): JSX.Element {
return (
<div className="page-container">
<h1>Donate with Elements</h1>
<p>Donate to our project 💖</p>
<ElementsForm />
</div>
)
}

View file

@ -0,0 +1,5 @@
'use client'
export default function Error({ error }: { error: Error }) {
return <h2>{error.message}</h2>
}

View file

@ -0,0 +1,18 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Payment Intent Result',
}
export default function ResultLayout({
children,
}: {
children: React.ReactNode
}): JSX.Element {
return (
<div className="page-container">
<h1>Payment Intent Result</h1>
{children}
</div>
)
}

View file

@ -0,0 +1,24 @@
import type { Stripe } from 'stripe'
import PrintObject from '@/components/PrintObject'
import { stripe } from '@/lib/stripe'
export default async function ResultPage({
searchParams,
}: {
searchParams: { payment_intent: string }
}): Promise<JSX.Element> {
if (!searchParams.payment_intent)
throw new Error('Please provide a valid payment_intent (`pi_...`)')
const paymentIntent: Stripe.PaymentIntent =
await stripe.paymentIntents.retrieve(searchParams.payment_intent)
return (
<>
<h2>Status: {paymentIntent.status}</h2>
<h3>Payment Intent response:</h3>
<PrintObject content={paymentIntent} />
</>
)
}

View file

@ -0,0 +1,73 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import '../styles.css'
interface LayoutProps {
children: React.ReactNode
}
export const metadata: Metadata = {
title: {
default: 'TypeScript Next.js Stripe Example',
template: '%s | Next.js + TypeScript Example',
},
twitter: {
card: 'summary_large_image',
description:
'Full-stack TypeScript example using Next.js, react-stripe-js, and stripe-node.',
images: [
{
url: 'https://nextjs-typescript-react-stripe-js.vercel.app/social_card.png',
},
],
site: '@StripeDev',
title: 'TypeScript Next.js Stripe Example',
},
}
export default function RootLayout({ children }: LayoutProps) {
return (
<html lang="en">
<body>
<div className="container">
<header>
<div className="header-content">
<Link href="/" className="logo">
<img src="/logo.png" />
</Link>
<h1>
<span className="light">Stripe Sample</span>
<br />
Next.js, TypeScript, and Stripe 🔒💸
</h1>
</div>
</header>
{children}
</div>
<div className="banner">
<span>
This is a{' '}
<a
href="https://github.com/stripe-samples"
target="_blank"
rel="noopener noreferrer"
>
Stripe Sample
</a>
.{' View code on '}
<a
href="https://github.com/vercel/next.js/tree/canary/examples/with-stripe-typescript"
target="_blank"
rel="noopener noreferrer"
>
GitHub
</a>
.
</span>
</div>
</body>
</html>
)
}

View file

@ -0,0 +1,32 @@
import type { Metadata } from 'next'
import Link from 'next/link'
export const metadata: Metadata = {
title: 'Home | Next.js + TypeScript Example',
}
export default function IndexPage(): JSX.Element {
return (
<ul className="card-list">
<li>
<Link
href="/donate-with-checkout"
className="card checkout-style-background"
>
<h2 className="bottom">Donate with Checkout</h2>
<img src="/checkout-one-time-payments.svg" />
</Link>
</li>
<li>
<Link
href="/donate-with-elements"
className="card elements-style-background"
>
<h2 className="bottom">Donate with Elements</h2>
<img src="/elements-card-payment.svg" />
</Link>
</li>
</ul>
)
}

View file

@ -1,15 +0,0 @@
import React, { ReactNode } from 'react'
import { CartProvider } from 'use-shopping-cart'
import * as config from '../config'
const Cart = ({ children }: { children: ReactNode }) => (
<CartProvider
cartMode="checkout-session"
stripe={process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY as string}
currency={config.CURRENCY}
>
<>{children}</>
</CartProvider>
)
export default Cart

View file

@ -1,77 +0,0 @@
import React, { useEffect, useState } from 'react'
import StripeTestCards from '../components/StripeTestCards'
import { fetchPostJSON } from '../utils/api-helpers'
import { useShoppingCart } from 'use-shopping-cart'
const CartSummary = () => {
const [loading, setLoading] = useState(false)
const [cartEmpty, setCartEmpty] = useState(true)
const [errorMessage, setErrorMessage] = useState('')
const {
formattedTotalPrice,
cartCount,
clearCart,
cartDetails,
redirectToCheckout,
} = useShoppingCart()
useEffect(() => setCartEmpty(!cartCount), [cartCount])
const handleCheckout: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault()
setLoading(true)
setErrorMessage('')
const response = await fetchPostJSON(
'/api/checkout_sessions/cart',
cartDetails
)
if (response.statusCode > 399) {
console.error(response.message)
setErrorMessage(response.message)
setLoading(false)
return
}
redirectToCheckout(response.id)
}
return (
<form onSubmit={handleCheckout}>
<h2>Cart summary</h2>
{errorMessage ? (
<p style={{ color: 'red' }}>Error: {errorMessage}</p>
) : null}
{/* This is where we'll render our cart */}
<p suppressHydrationWarning>
<strong>Number of Items:</strong> {cartCount}
</p>
<p suppressHydrationWarning>
<strong>Total:</strong> {formattedTotalPrice}
</p>
{/* Redirects the user to Stripe */}
<StripeTestCards />
<button
className="cart-style-background"
type="submit"
disabled={cartEmpty || loading}
>
Checkout
</button>
<button
className="cart-style-background"
type="button"
onClick={clearCart}
>
Clear Cart
</button>
</form>
)
}
export default CartSummary

View file

@ -1,75 +0,0 @@
import React, { useState } from 'react'
import CustomDonationInput from '../components/CustomDonationInput'
import StripeTestCards from '../components/StripeTestCards'
import getStripe from '../utils/get-stripejs'
import { fetchPostJSON } from '../utils/api-helpers'
import { formatAmountForDisplay } from '../utils/stripe-helpers'
import * as config from '../config'
const CheckoutForm = () => {
const [loading, setLoading] = useState(false)
const [input, setInput] = useState({
customDonation: Math.round(config.MAX_AMOUNT / config.AMOUNT_STEP),
})
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) =>
setInput({
...input,
[e.currentTarget.name]: e.currentTarget.value,
})
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault()
setLoading(true)
// Create a Checkout Session.
const response = await fetchPostJSON('/api/checkout_sessions', {
amount: input.customDonation,
})
if (response.statusCode === 500) {
console.error(response.message)
return
}
// Redirect to Checkout.
const stripe = await getStripe()
const { error } = await stripe!.redirectToCheckout({
// Make the id field from the Checkout Session creation API response
// available to this file, so you can provide it as parameter here
// instead of the {{CHECKOUT_SESSION_ID}} placeholder.
sessionId: response.id,
})
// If `redirectToCheckout` fails due to a browser or network
// error, display the localized error message to your customer
// using `error.message`.
console.warn(error.message)
setLoading(false)
}
return (
<form onSubmit={handleSubmit}>
<CustomDonationInput
className="checkout-style"
name={'customDonation'}
value={input.customDonation}
min={config.MIN_AMOUNT}
max={config.MAX_AMOUNT}
step={config.AMOUNT_STEP}
currency={config.CURRENCY}
onChange={handleInputChange}
/>
<StripeTestCards />
<button
className="checkout-style-background"
type="submit"
disabled={loading}
>
Donate {formatAmountForDisplay(input.customDonation, config.CURRENCY)}
</button>
</form>
)
}
export default CheckoutForm

View file

@ -1,12 +0,0 @@
import { useEffect } from 'react'
import { useShoppingCart } from 'use-shopping-cart'
export default function ClearCart() {
const { clearCart } = useShoppingCart()
useEffect(() => {
clearCart()
}, [clearCart])
return <p>Cart cleared.</p>
}

View file

@ -1,50 +0,0 @@
import React from 'react'
import { formatAmountForDisplay } from '../utils/stripe-helpers'
type Props = {
name: string
value: number
min: number
max: number
currency: string
step: number
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
className?: string
}
const CustomDonationInput = ({
name,
value,
min,
max,
currency,
step,
onChange,
className,
}: Props) => (
<label>
Custom donation amount ({formatAmountForDisplay(min, currency)}-
{formatAmountForDisplay(max, currency)}):
<input
className={className}
type="number"
name={name}
value={value}
min={min}
max={max}
step={step}
onChange={onChange}
></input>
<input
type="range"
name={name}
value={value}
min={min}
max={max}
step={step}
onChange={onChange}
></input>
</label>
)
export default CustomDonationInput

View file

@ -1,157 +0,0 @@
import React, { useState, FC } from 'react'
import CustomDonationInput from '../components/CustomDonationInput'
import StripeTestCards from '../components/StripeTestCards'
import PrintObject from '../components/PrintObject'
import { fetchPostJSON } from '../utils/api-helpers'
import {
formatAmountForDisplay,
formatAmountFromStripe,
} from '../utils/stripe-helpers'
import * as config from '../config'
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'
import { PaymentIntent } from '@stripe/stripe-js'
const ElementsForm: FC<{
paymentIntent?: PaymentIntent | null
}> = ({ paymentIntent = null }) => {
const defaultAmout = paymentIntent
? formatAmountFromStripe(paymentIntent.amount, paymentIntent.currency)
: Math.round(config.MAX_AMOUNT / config.AMOUNT_STEP)
const [input, setInput] = useState({
customDonation: defaultAmout,
cardholderName: '',
})
const [paymentType, setPaymentType] = useState('')
const [payment, setPayment] = useState({ status: 'initial' })
const [errorMessage, setErrorMessage] = useState('')
const stripe = useStripe()
const elements = useElements()
const PaymentStatus = ({ status }: { status: string }) => {
switch (status) {
case 'processing':
case 'requires_payment_method':
case 'requires_confirmation':
return <h2>Processing...</h2>
case 'requires_action':
return <h2>Authenticating...</h2>
case 'succeeded':
return <h2>Payment Succeeded 🥳</h2>
case 'error':
return (
<>
<h2>Error 😭</h2>
<p className="error-message">{errorMessage}</p>
</>
)
default:
return null
}
}
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) =>
setInput({
...input,
[e.currentTarget.name]: e.currentTarget.value,
})
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault()
// Abort if form isn't valid
if (!e.currentTarget.reportValidity()) return
if (!elements) return
setPayment({ status: 'processing' })
// Create a PaymentIntent with the specified amount.
const response = await fetchPostJSON('/api/payment_intents', {
amount: input.customDonation,
payment_intent_id: paymentIntent?.id,
})
setPayment(response)
if (response.statusCode === 500) {
setPayment({ status: 'error' })
setErrorMessage(response.message)
return
}
// Use your card Element with other Stripe.js APIs
const { error } = await stripe!.confirmPayment({
elements,
confirmParams: {
return_url: 'http://localhost:3000/donate-with-elements',
payment_method_data: {
billing_details: {
name: input.cardholderName,
},
},
},
})
if (error) {
setPayment({ status: 'error' })
setErrorMessage(error.message ?? 'An unknown error occurred')
} else if (paymentIntent) {
setPayment(paymentIntent)
}
}
return (
<>
<form onSubmit={handleSubmit}>
<CustomDonationInput
className="elements-style"
name="customDonation"
value={input.customDonation}
min={config.MIN_AMOUNT}
max={config.MAX_AMOUNT}
step={config.AMOUNT_STEP}
currency={config.CURRENCY}
onChange={handleInputChange}
/>
<StripeTestCards />
<fieldset className="elements-style">
<legend>Your payment details:</legend>
{paymentType === 'card' ? (
<input
placeholder="Cardholder name"
className="elements-style"
type="Text"
name="cardholderName"
onChange={handleInputChange}
required
/>
) : null}
<div className="FormRow elements-style">
<PaymentElement
onChange={(e) => {
setPaymentType(e.value.type)
}}
/>
</div>
</fieldset>
<button
className="elements-style-background"
type="submit"
disabled={
!['initial', 'succeeded', 'error'].includes(payment.status) ||
!stripe
}
>
Donate {formatAmountForDisplay(input.customDonation, config.CURRENCY)}
</button>
</form>
<PaymentStatus status={payment.status} />
<PrintObject content={payment} />
</>
)
}
export default ElementsForm

View file

@ -1,70 +0,0 @@
import React, { ReactNode } from 'react'
import Head from 'next/head'
import Link from 'next/link'
type Props = {
children: ReactNode
title?: string
}
const Layout = ({
children,
title = 'TypeScript Next.js Stripe Example',
}: Props) => (
<>
<Head>
<title>{title}</title>
<meta charSet="utf-8" />
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@thorwebdev" />
<meta name="twitter:title" content="TypeScript Next.js Stripe Example" />
<meta
name="twitter:description"
content="Full-stack TypeScript example using Next.js, react-stripe-js, and stripe-node."
/>
<meta
name="twitter:image"
content="https://nextjs-typescript-react-stripe-js.vercel.app/social_card.png"
/>
</Head>
<div className="container">
<header>
<div className="header-content">
<Link href="/" className="logo">
<img src="/logo.png" />
</Link>
<h1>
<span className="light">Stripe Sample</span>
<br />
Next.js, TypeScript, and Stripe 🔒💸
</h1>
</div>
</header>
{children}
</div>
<div className="banner">
<span>
This is a{' '}
<a
href="https://github.com/stripe-samples"
target="_blank"
rel="noopener noreferrer"
>
Stripe Sample
</a>
.{' View code on '}
<a
href="https://github.com/vercel/next.js/tree/canary/examples/with-stripe-typescript"
target="_blank"
rel="noopener noreferrer"
>
GitHub
</a>
.
</span>
</div>
</>
)
export default Layout

View file

@ -1,12 +0,0 @@
import React from 'react'
type Props = {
content: object
}
const PrintObject = ({ content }: Props) => {
const formattedContent: string = JSON.stringify(content, null, 2)
return <pre>{formattedContent}</pre>
}
export default PrintObject

View file

@ -1,41 +0,0 @@
import products from '../data/products'
import { formatCurrencyString } from 'use-shopping-cart'
import { useShoppingCart } from 'use-shopping-cart'
const Products = () => {
const { addItem, removeItem } = useShoppingCart()
return (
<section className="products">
{products.map((product) => (
<div key={product.id} className="product">
<img src={product.image} alt={product.name} />
<h2>{product.name}</h2>
<p className="price">
{formatCurrencyString({
value: product.price,
currency: product.currency,
})}
</p>
<button
className="cart-style-background"
onClick={() => {
console.log(product)
addItem(product)
}}
>
Add to cart
</button>
<button
className="cart-style-background"
onClick={() => removeItem(product.id)}
>
Remove
</button>
</div>
))}
</section>
)
}
export default Products

View file

@ -1,22 +0,0 @@
const product = [
{
name: 'Bananas',
description: 'Yummy yellow fruit',
id: 'sku_GBJ2Ep8246qeeT',
price: 400,
image:
'https://images.unsplash.com/photo-1574226516831-e1dff420e562?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=225&q=80',
attribution: 'Photo by Priscilla Du Preez on Unsplash',
currency: 'USD',
},
{
name: 'Tangerines',
id: 'sku_GBJ2WWfMaGNC2Z',
price: 100,
image:
'https://images.unsplash.com/photo-1482012792084-a0c3725f289f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=225&q=80',
attribution: 'Photo by Jonathan Pielmayer on Unsplash',
currency: 'USD',
},
]
export default product

View file

@ -0,0 +1,8 @@
import 'server-only'
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: '2022-11-15',
})

View file

@ -0,0 +1,5 @@
module.exports = {
experimental: {
serverActions: true,
},
}

View file

@ -6,23 +6,17 @@
"start": "next start"
},
"dependencies": {
"@stripe/react-stripe-js": "1.13.0",
"@stripe/stripe-js": "1.42.0",
"micro": "^9.4.1",
"micro-cors": "^0.1.1",
"@stripe/react-stripe-js": "2.1.1",
"@stripe/stripe-js": "1.54.1",
"next": "latest",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"stripe": "10.14.0",
"swr": "^2.0.0",
"use-shopping-cart": "3.1.2",
"redux": "4.2.0"
"react": "18.2.0",
"react-dom": "18.2.0",
"server-only": "0.0.1",
"stripe": "12.14.0"
},
"devDependencies": {
"@types/micro": "^7.3.7",
"@types/micro-cors": "^0.1.2",
"@types/node": "^18.11.2",
"@types/react": "^18.0.21",
"typescript": "4.8.4"
"@types/node": "20.4.6",
"@types/react": "18.2.18",
"typescript": "5.1.6"
}
}

View file

@ -1,9 +0,0 @@
import { AppProps } from 'next/app'
import '../styles.css'
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
export default MyApp

View file

@ -1,30 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: '2022-08-01',
})
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const id: string = req.query.id as string
try {
if (!id.startsWith('cs_')) {
throw Error('Incorrect CheckoutSession ID.')
}
const checkout_session: Stripe.Checkout.Session =
await stripe.checkout.sessions.retrieve(id, {
expand: ['payment_intent'],
})
res.status(200).json(checkout_session)
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Internal server error'
res.status(500).json({ statusCode: 500, message: errorMessage })
}
}

View file

@ -1,61 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next'
import Stripe from 'stripe'
// @ts-ignore
import { validateCartItems } from 'use-shopping-cart/utilities'
/*
* Product data can be loaded from anywhere. In this case, were loading it from
* a local JSON file, but this could also come from an async call to your
* inventory management service, a database query, or some other API call.
*
* The important thing is that the product info is loaded from somewhere trusted
* so you know the pricing information is accurate.
*/
import inventory from '../../../data/products'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: '2022-08-01',
})
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'POST') {
try {
// Validate the cart details that were sent from the client.
const line_items = validateCartItems(inventory as any, req.body)
const hasSubscription = line_items.find((item: any) => {
return !!item.price_data.recurring
})
// Create Checkout Sessions from body params.
const params: Stripe.Checkout.SessionCreateParams = {
submit_type: 'pay',
payment_method_types: ['card'],
billing_address_collection: 'auto',
shipping_address_collection: {
allowed_countries: ['US', 'CA'],
},
line_items,
success_url: `${req.headers.origin}/result?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.origin}/use-shopping-cart`,
mode: hasSubscription ? 'subscription' : 'payment',
}
const checkoutSession: Stripe.Checkout.Session =
await stripe.checkout.sessions.create(params)
res.status(200).json(checkoutSession)
} catch (err) {
console.log(err)
const errorMessage =
err instanceof Error ? err.message : 'Internal server error'
res.status(500).json({ statusCode: 500, message: errorMessage })
}
} else {
res.setHeader('Allow', 'POST')
res.status(405).end('Method Not Allowed')
}
}

View file

@ -1,55 +0,0 @@
import { CURRENCY, MAX_AMOUNT, MIN_AMOUNT } from '../../../config'
import { NextApiRequest, NextApiResponse } from 'next'
import Stripe from 'stripe'
import { formatAmountForStripe } from '../../../utils/stripe-helpers'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: '2022-08-01',
})
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'POST') {
const amount: number = req.body.amount
try {
// Validate the amount that was passed from the client.
if (!(amount >= MIN_AMOUNT && amount <= MAX_AMOUNT)) {
throw new Error('Invalid amount.')
}
// Create Checkout Sessions from body params.
const params: Stripe.Checkout.SessionCreateParams = {
submit_type: 'donate',
payment_method_types: ['card'],
line_items: [
{
amount: formatAmountForStripe(amount, CURRENCY),
quantity: 1,
price_data: {
currency: CURRENCY,
product_data: {
name: 'Custom amount donation',
},
},
},
],
success_url: `${req.headers.origin}/result?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.origin}/donate-with-checkout`,
}
const checkoutSession: Stripe.Checkout.Session =
await stripe.checkout.sessions.create(params)
res.status(200).json(checkoutSession)
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Internal server error'
res.status(500).json({ statusCode: 500, message: errorMessage })
}
} else {
res.setHeader('Allow', 'POST')
res.status(405).end('Method Not Allowed')
}
}

View file

@ -1,74 +0,0 @@
import { CURRENCY, MAX_AMOUNT, MIN_AMOUNT } from '../../../config'
import { NextApiRequest, NextApiResponse } from 'next'
import Stripe from 'stripe'
import { formatAmountForStripe } from '../../../utils/stripe-helpers'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: '2022-08-01',
})
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST')
res.status(405).end('Method Not Allowed')
return
}
const {
amount,
payment_intent_id,
}: { amount: number; payment_intent_id?: string } = req.body
// Validate the amount that was passed from the client.
if (!(amount >= MIN_AMOUNT && amount <= MAX_AMOUNT)) {
res.status(500).json({ statusCode: 400, message: 'Invalid amount.' })
return
}
if (payment_intent_id) {
try {
const current_intent = await stripe.paymentIntents.retrieve(
payment_intent_id
)
// If PaymentIntent has been created, just update the amount.
if (current_intent) {
const updated_intent = await stripe.paymentIntents.update(
payment_intent_id,
{
amount: formatAmountForStripe(amount, CURRENCY),
}
)
res.status(200).json(updated_intent)
return
}
} catch (e) {
if ((e as any).code !== 'resource_missing') {
const errorMessage =
e instanceof Error ? e.message : 'Internal server error'
res.status(500).json({ statusCode: 500, message: errorMessage })
return
}
}
}
try {
// Create PaymentIntent from body params.
const params: Stripe.PaymentIntentCreateParams = {
amount: formatAmountForStripe(amount, CURRENCY),
currency: CURRENCY,
description: process.env.STRIPE_PAYMENT_DESCRIPTION ?? '',
automatic_payment_methods: {
enabled: true,
},
}
const payment_intent: Stripe.PaymentIntent =
await stripe.paymentIntents.create(params)
res.status(200).json(payment_intent)
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Internal server error'
res.status(500).json({ statusCode: 500, message: errorMessage })
}
}

View file

@ -1,70 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next'
import Cors from 'micro-cors'
import Stripe from 'stripe'
import { buffer } from 'micro'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: '2022-08-01',
})
const webhookSecret: string = process.env.STRIPE_WEBHOOK_SECRET!
// Stripe requires the raw body to construct the event.
export const config = {
api: {
bodyParser: false,
},
}
const cors = Cors({
allowMethods: ['POST', 'HEAD'],
})
const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const buf = await buffer(req)
const sig = req.headers['stripe-signature']!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(buf.toString(), sig, webhookSecret)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
// On error, log and return the error message.
if (err! instanceof Error) console.log(err)
console.log(`❌ Error message: ${errorMessage}`)
res.status(400).send(`Webhook Error: ${errorMessage}`)
return
}
// Successfully constructed event.
console.log('✅ Success:', event.id)
// Cast event data to Stripe object.
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object as Stripe.PaymentIntent
console.log(`💰 PaymentIntent status: ${paymentIntent.status}`)
} else if (event.type === 'payment_intent.payment_failed') {
const paymentIntent = event.data.object as Stripe.PaymentIntent
console.log(
`❌ Payment failed: ${paymentIntent.last_payment_error?.message}`
)
} else if (event.type === 'charge.succeeded') {
const charge = event.data.object as Stripe.Charge
console.log(`💵 Charge id: ${charge.id}`)
} else {
console.warn(`🤷‍♀️ Unhandled event type: ${event.type}`)
}
// Return a response to acknowledge receipt of the event.
res.json({ received: true })
} else {
res.setHeader('Allow', 'POST')
res.status(405).end('Method Not Allowed')
}
}
export default cors(webhookHandler as any)

View file

@ -1,18 +0,0 @@
import { NextPage } from 'next'
import Layout from '../components/Layout'
import CheckoutForm from '../components/CheckoutForm'
const DonatePage: NextPage = () => {
return (
<Layout title="Donate with Checkout | Next.js + TypeScript Example">
<div className="page-container">
<h1>Donate with Checkout</h1>
<p>Donate to our project 💖</p>
<CheckoutForm />
</div>
</Layout>
)
}
export default DonatePage

View file

@ -1,48 +0,0 @@
import { NextPage } from 'next'
import { useState, useEffect } from 'react'
import { Elements } from '@stripe/react-stripe-js'
import { PaymentIntent } from '@stripe/stripe-js'
import getStripe from '../utils/get-stripejs'
import { fetchPostJSON } from '../utils/api-helpers'
import Layout from '../components/Layout'
import * as config from '../config'
import ElementsForm from '../components/ElementsForm'
const DonatePage: NextPage = () => {
const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null)
useEffect(() => {
fetchPostJSON('/api/payment_intents', {
amount: Math.round(config.MAX_AMOUNT / config.AMOUNT_STEP),
}).then((data) => {
setPaymentIntent(data)
})
}, [setPaymentIntent])
return (
<Layout title="Donate with Elements | Next.js + TypeScript Example">
<div className="page-container">
<h1>Donate with Elements</h1>
<p>Donate to our project 💖</p>
{paymentIntent && paymentIntent.client_secret ? (
<Elements
stripe={getStripe()}
options={{
appearance: {
variables: {
colorIcon: '#6772e5',
fontFamily: 'Roboto, Open Sans, Segoe UI, sans-serif',
},
},
clientSecret: paymentIntent.client_secret,
}}
>
<ElementsForm paymentIntent={paymentIntent} />
</Elements>
) : (
<p>Loading...</p>
)}
</div>
</Layout>
)
}
export default DonatePage

View file

@ -1,41 +0,0 @@
import { NextPage } from 'next'
import Link from 'next/link'
import Layout from '../components/Layout'
const IndexPage: NextPage = () => {
return (
<Layout title="Home | Next.js + TypeScript Example">
<ul className="card-list">
<li>
<Link
href="/donate-with-checkout"
className="card checkout-style-background"
>
<h2 className="bottom">Donate with Checkout</h2>
<img src="/checkout-one-time-payments.svg" />
</Link>
</li>
<li>
<Link
href="/donate-with-elements"
className="card elements-style-background"
>
<h2 className="bottom">Donate with Elements</h2>
<img src="/elements-card-payment.svg" />
</Link>
</li>
<li>
<Link
href="/use-shopping-cart"
className="card cart-style-background"
>
<h2 className="bottom">Use Shopping Cart</h2>
<img src="/use-shopping-cart.png" />
</Link>
</li>
</ul>
</Layout>
)
}
export default IndexPage

View file

@ -1,41 +0,0 @@
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import Layout from '../components/Layout'
import PrintObject from '../components/PrintObject'
import Cart from '../components/Cart'
import ClearCart from '../components/ClearCart'
import { fetchGetJSON } from '../utils/api-helpers'
import useSWR from 'swr'
const ResultPage: NextPage = () => {
const router = useRouter()
// Fetch CheckoutSession from static page via
// https://nextjs.org/docs/basic-features/data-fetching#static-generation
const { data, error } = useSWR(
router.query.session_id
? `/api/checkout_sessions/${router.query.session_id}`
: null,
fetchGetJSON
)
if (error) return <div>failed to load</div>
return (
<Layout title="Checkout Payment Result | Next.js + TypeScript Example">
<div className="page-container">
<h1>Checkout Payment Result</h1>
<h2>Status: {data?.payment_intent?.status ?? 'loading...'}</h2>
<h3>CheckoutSession response:</h3>
<PrintObject content={data ?? 'loading...'} />
<Cart>
<ClearCart />
</Cart>
</div>
</Layout>
)
}
export default ResultPage

View file

@ -1,27 +0,0 @@
import { NextPage } from 'next'
import Layout from '../components/Layout'
import Cart from '../components/Cart'
import CartSummary from '../components/CartSummary'
import Products from '../components/Products'
const DonatePage: NextPage = () => {
return (
<Layout title="Shopping Cart | Next.js + TypeScript Example">
<div className="page-container">
<h1>Shopping Cart</h1>
<p>
Powered by the{' '}
<a href="https://useshoppingcart.com">use-shopping-cart</a> React
hooks library.
</p>
<Cart>
<CartSummary />
<Products />
</Cart>
</div>
</Layout>
)
}
export default DonatePage

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

View file

@ -193,25 +193,6 @@ button:disabled {
.card.checkout-style-background:hover {
box-shadow: 20px 20px 60px #614b91, -20px -20px 60px #bd91ff;
}
.cart-style-background {
background: teal;
transition: box-shadow var(--transition-duration);
}
.card.cart-style-background:hover {
box-shadow: 20px 20px 60px teal, -20px -20px 60px teal;
}
/* Products */
.products {
display: grid;
gap: 2rem;
grid-template-columns: repeat(2, 1fr);
margin-top: 3rem;
}
.product img {
max-width: 100%;
}
/* Test card number */
.test-card-notice {

View file

@ -14,8 +14,21 @@
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"incremental": true,
"jsx": "preserve"
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/actions/*": ["app/actions/*"],
"@/components/*": ["app/components/*"],
"@/config": ["config/"],
"@/lib/*": ["lib/*"],
"@/utils/*": ["utils/*"]
},
"plugins": [
{
"name": "next"
}
]
},
"exclude": ["node_modules"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
}

View file

@ -1,36 +0,0 @@
export async function fetchGetJSON(url: string) {
try {
const data = await fetch(url).then((res) => res.json())
return data
} catch (err) {
if (err instanceof Error) {
throw new Error(err.message)
}
throw err
}
}
export async function fetchPostJSON(url: string, data?: {}) {
try {
// Default options are marked with *
const response = await fetch(url, {
method: 'POST', // *GET, POST, PUT, DELETE, etc.
mode: 'cors', // no-cors, *cors, same-origin
cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
credentials: 'same-origin', // include, *same-origin, omit
headers: {
'Content-Type': 'application/json',
// 'Content-Type': 'application/x-www-form-urlencoded',
},
redirect: 'follow', // manual, *follow, error
referrerPolicy: 'no-referrer', // no-referrer, *client
body: JSON.stringify(data || {}), // body data type must match "Content-Type" header
})
return await response.json() // parses JSON response into native JavaScript objects
} catch (err) {
if (err instanceof Error) {
throw new Error(err.message)
}
throw err
}
}

View file

@ -4,11 +4,12 @@
import { Stripe, loadStripe } from '@stripe/stripe-js'
let stripePromise: Promise<Stripe | null>
const getStripe = () => {
if (!stripePromise) {
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
}
export default function getStripe(): Promise<Stripe | null> {
if (!stripePromise)
stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY as string
)
return stripePromise
}
export default getStripe

View file

@ -28,22 +28,3 @@ export function formatAmountForStripe(
}
return zeroDecimalCurrency ? amount : Math.round(amount * 100)
}
export function formatAmountFromStripe(
amount: number,
currency: string
): number {
let numberFormat = new Intl.NumberFormat(['en-US'], {
style: 'currency',
currency: currency,
currencyDisplay: 'symbol',
})
const parts = numberFormat.formatToParts(amount)
let zeroDecimalCurrency: boolean = true
for (let part of parts) {
if (part.type === 'decimal') {
zeroDecimalCurrency = false
}
}
return zeroDecimalCurrency ? amount : Math.round(amount / 100)
}