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:
parent
ca96578f0b
commit
b7c9604cc7
49 changed files with 707 additions and 1132 deletions
|
@ -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)
|
||||
|
|
55
examples/with-stripe-typescript/app/actions/stripe.ts
Normal file
55
examples/with-stripe-typescript/app/actions/stripe.ts
Normal 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 }
|
||||
}
|
66
examples/with-stripe-typescript/app/api/webhooks/route.ts
Normal file
66
examples/with-stripe-typescript/app/api/webhooks/route.ts
Normal 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 })
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
188
examples/with-stripe-typescript/app/components/ElementsForm.tsx
Normal file
188
examples/with-stripe-typescript/app/components/ElementsForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
'use client'
|
||||
|
||||
export default function Error({ error }: { error: Error }) {
|
||||
return <h2>{error.message}</h2>
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
'use client'
|
||||
|
||||
export default function Error({ error }: { error: Error }) {
|
||||
return <h2>{error.message}</h2>
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
73
examples/with-stripe-typescript/app/layout.tsx
Normal file
73
examples/with-stripe-typescript/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
32
examples/with-stripe-typescript/app/page.tsx
Normal file
32
examples/with-stripe-typescript/app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
8
examples/with-stripe-typescript/lib/stripe.ts
Normal file
8
examples/with-stripe-typescript/lib/stripe.ts
Normal 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',
|
||||
})
|
5
examples/with-stripe-typescript/next.config.js
Normal file
5
examples/with-stripe-typescript/next.config.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
experimental: {
|
||||
serverActions: true,
|
||||
},
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
import { AppProps } from 'next/app'
|
||||
|
||||
import '../styles.css'
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />
|
||||
}
|
||||
|
||||
export default MyApp
|
|
@ -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 })
|
||||
}
|
||||
}
|
|
@ -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, we’re 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')
|
||||
}
|
||||
}
|
|
@ -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')
|
||||
}
|
||||
}
|
|
@ -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 })
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 |
|
@ -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 {
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue