From 67007338daf34996c07a1767cdfc4e34ad818182 Mon Sep 17 00:00:00 2001 From: Nino K Date: Thu, 17 Oct 2024 10:29:17 +0300 Subject: [PATCH] stripe wip pre-alpha commit --- src/app/api/stripe/genPaymentIntent/route.ts | 45 +++++ src/app/billing/cart/page.tsx | 76 +++++++++ src/auth.ts | 2 +- src/components/blocks/checkout/Complete.tsx | 88 ++++++++++ src/components/blocks/checkout/Form.tsx | 163 +++++++++++++++++++ src/components/blocks/checkout/Steps.tsx | 126 ++++++++++++++ src/components/buttons/NavLogin.tsx | 4 +- 7 files changed, 501 insertions(+), 3 deletions(-) create mode 100644 src/app/api/stripe/genPaymentIntent/route.ts create mode 100644 src/app/billing/cart/page.tsx create mode 100644 src/components/blocks/checkout/Complete.tsx create mode 100644 src/components/blocks/checkout/Form.tsx create mode 100644 src/components/blocks/checkout/Steps.tsx diff --git a/src/app/api/stripe/genPaymentIntent/route.ts b/src/app/api/stripe/genPaymentIntent/route.ts new file mode 100644 index 0000000..e165a20 --- /dev/null +++ b/src/app/api/stripe/genPaymentIntent/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import Stripe from "stripe"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + +type Item = { + id: string; + quantity: number; + // this will change +}; + +const calculateOrderAmount = (items: Item[]): number => { + // wip, prisma soon + const amount = /* placeholder currently */ (10000.00 * 100); // it is in cents (14€) + + return amount; +}; + +export async function POST(req: Request) { + try { + const { items }: { items: Item[] } = await req.json(); + + const paymentIntent = await stripe.paymentIntents.create({ + amount: calculateOrderAmount(items), + currency: "eur", + automatic_payment_methods: { + enabled: false, + }, + payment_method_types: ["card", "alipay", "wechat_pay", "paypal", "mobilepay", "klarna"] + }); + + return NextResponse.json({ + clientSecret: paymentIntent.client_secret, + amount: paymentIntent.amount + }); + + } catch (error) { + console.error("Error creating a payment intent:", error); + return NextResponse.json({ + error: "An error occurred while creating the payment intent using Stripe, please try again soon. This is not your problem, but rather a problem with us or Stripe.", + }, { + status: 500, + }); + } +} diff --git a/src/app/billing/cart/page.tsx b/src/app/billing/cart/page.tsx new file mode 100644 index 0000000..53c34e4 --- /dev/null +++ b/src/app/billing/cart/page.tsx @@ -0,0 +1,76 @@ +"use client" + +import { useState, useEffect } from "react"; +import { loadStripe, StripeElementLocale } from "@stripe/stripe-js"; +import { Elements } from "@stripe/react-stripe-js"; +import { StripeElementsOptions, Appearance } from "@stripe/stripe-js"; + +import CheckoutForm from "@/components/blocks/checkout/Form"; +import CompletePage from "@/components/blocks/checkout/Complete"; +import { useLocale } from "next-intl"; + +export default function Cart() { + const locale = useLocale() as StripeElementLocale; + const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, { + locale: locale, + }); + const [clientSecret, setClientSecret] = useState(null); + const [amount, setAmount] = useState(null); + const [confirmed, setConfirmed] = useState(false); + + useEffect(() => { + const paymentIntentClientSecret = new URLSearchParams(window.location.search).get("payment_intent_client_secret"); + if (paymentIntentClientSecret) { + setConfirmed(true); + } + }, []); + + useEffect(() => { + fetch("/api/stripe/genPaymentIntent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: [{ id: "xl-tshirt" }] }), + }) + .then((res) => { + if (!res.ok) { + throw new Error("Failed to create payment intent"); + } + return res.json(); + }) + .then((data) => { + setClientSecret(data.clientSecret); + setAmount(data.amount) + }) + .catch((error) => { + console.error("Error fetching payment intent:", error); + }); + }, []); + + const appearance: Appearance = { + theme: 'flat', + variables: { + colorPrimary: '#393cb9', + colorText: '#ffffff', + colorBackground: '#475569', + borderRadius: '6px', + }, + }; + + // Options for the Elements component + const options: StripeElementsOptions | undefined = clientSecret ? { + clientSecret, + appearance, + } : undefined; + + return ( +
+ {clientSecret && ( + <> + + {confirmed ? : } + + + )} +
+ ); +} diff --git a/src/auth.ts b/src/auth.ts index e7d71d0..afc4994 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -7,7 +7,7 @@ import { PrismaClient } from "@prisma/client" const prisma = new PrismaClient() export const { handlers, signIn, signOut, auth } = NextAuth({ - adapter: PrismaAdapter(prisma), + //adapter: PrismaAdapter(prisma), pages: { signIn: '/login', signOut: '/api/auth/logout', diff --git a/src/components/blocks/checkout/Complete.tsx b/src/components/blocks/checkout/Complete.tsx new file mode 100644 index 0000000..87e1a55 --- /dev/null +++ b/src/components/blocks/checkout/Complete.tsx @@ -0,0 +1,88 @@ +import { useState, useEffect } from "react"; +import { useStripe } from "@stripe/react-stripe-js"; + +const STATUS_CONTENT_MAP: { + [key: string]: { + text: string; + iconColor: string; + }; +} = { + succeeded: { + text: "Payment succeeded", + iconColor: "#30B130", + }, + processing: { + text: "Your payment is processing.", + iconColor: "#6D6E78", + }, + requires_payment_method: { + text: "Your payment was not successful, please try again.", + iconColor: "#DF1B41", + }, + default: { + text: "Something went wrong, please try again.", + iconColor: "#DF1B41", + }, +}; + + +// WIP, COPIED FROM STRIPE DOCS +export default function CompletePage() { + const stripe = useStripe(); + const [status, setStatus] = useState("default"); + const [intentId, setIntentId] = useState(null); + + useEffect(() => { + if (!stripe) return; + + const clientSecret = new URLSearchParams(window.location.search).get( + "payment_intent_client_secret" + ); + if (!clientSecret) return; + + const retrievePaymentIntent = async () => { + const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret); + if (!paymentIntent) return; + + setStatus(paymentIntent.status || "default"); + setIntentId(paymentIntent.id || null); + }; + + retrievePaymentIntent(); + }, [stripe]); + + const currentStatus = STATUS_CONTENT_MAP[status] || STATUS_CONTENT_MAP["default"]; + + return ( +
+

{currentStatus.text}

+ {intentId && ( +
+ + + + + + + + + + + +
id{intentId}
status{status}
+
+ )} + {intentId && ( + + Stripe link + + )} + Test another +
+ ); +} diff --git a/src/components/blocks/checkout/Form.tsx b/src/components/blocks/checkout/Form.tsx new file mode 100644 index 0000000..8eac451 --- /dev/null +++ b/src/components/blocks/checkout/Form.tsx @@ -0,0 +1,163 @@ +import { FormEvent, useState, useEffect } from "react"; +import { + PaymentElement, + AddressElement, + useStripe, + useElements +} from "@stripe/react-stripe-js"; +import { StripePaymentElementOptions, StripeAddressElementOptions } from "@stripe/stripe-js"; +import DefaultButton from "@/components/buttons/Default"; +import { Session } from "@/components/buttons/NavLogin"; +import ProgressBar, { Step } from "./Steps"; + +export default function CheckoutForm({amount}: {amount: string | null}) { + const stripe = useStripe(); + const elements = useElements(); + + const [session, setSession] = useState(null); + const [message, setMessage] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [stepIndex, setStepIndex] = useState(0); + + const steps: Omit[] = [ + { label: 'Info', icon: 'document' }, + { label: 'Payment', icon: 'document' }, + { label: 'Review', icon: 'lock' }, + { label: 'Finish', icon: 'lock' }, + ]; + + const dynamicSteps: Step[] = steps.map((step, index) => ({ + ...step, + status: + index < stepIndex ? 'completed' : index === stepIndex ? 'active' : 'inactive', + })); + + useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + + try { + const res = await fetch('/api/auth/session'); + const json = await res.json(); + setSession(json); + } catch (error) { + console.error('Error fetching session:', error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, []); + + const handleSubmit = async (e: FormEvent) => { + console.log("test") + e.preventDefault(); + + if (!stripe || !elements) { + // Stripe.js hasn't yet loaded. + // Make sure to disable form submission until Stripe.js has loaded. + return; + } + + setIsLoading(true); + + const { error } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: "http://localhost:25572/billing/cart", + }, + }); + + if (error) { + if (error.type === "card_error" || error.type === "validation_error") { + setMessage(error.message); + } else { + setMessage("An unexpected error occurred."); + } + } else { + setMessage("Payment confirmation was successful."); + } + + setIsLoading(false); + }; + + const paymentElementOptions: StripePaymentElementOptions = { + layout: "tabs", + }; + const addressElementOptions: StripeAddressElementOptions = { + mode: "billing", + defaultValues: { + name: session?.user.name + } + } + + return ( + <> +
+
+
+
+ +
+ + + + +
+
+ tänne ne tuotteet +
+
+
+

Overview:

+
+
+ + { + stepIndex === 2 ? ( + + ) : ( + + ) + } +
+
+
+
+ {/* Show any error or success messages */} + {message &&
{message}
} + + ); +} diff --git a/src/components/blocks/checkout/Steps.tsx b/src/components/blocks/checkout/Steps.tsx new file mode 100644 index 0000000..455354b --- /dev/null +++ b/src/components/blocks/checkout/Steps.tsx @@ -0,0 +1,126 @@ +export interface Step { + label?: string; + status: "completed" | "active" | "inactive"; + icon?: "document" | "lock"; +} + +function StepIcon({ status, icon }: Step) { + if (status === "completed") { + return ( + + ); + } + + switch (icon) { + case "document": + return ( + + ); + case "lock": + return ( + + ); + default: + return null; + } +}; + +export default function ProgressBar({ steps }: { steps: Step[] }) { + const getStepStyles = (status: Step["status"]) => { + if (status === "completed") { + return { + bgColor: "bg-axsoterBlue", + borderColor: "after:border-axsoterBlue", + iconColor: "text-gray-100" + }; + } else if (status === "active") { + return { + bgColor: "bg-gray-100", + borderColor: "after:border-slate-600", + iconColor: "text-slate-600" + }; + } else { + return { + bgColor: "bg-slate-600", + borderColor: "after:border-slate-600", + iconColor: "text-gray-100" + }; + } + }; + + return ( + <> +
    + {steps.map((step, index) => { + const { bgColor, borderColor, iconColor } = getStepStyles(step.status); + return ( + <> +
  1. + + + +
  2. + + ); + })} +
+
    + {steps.map((step, index) => ( +
  1. + + {step.label} + +
  2. + ))} +
+ + ); +}; \ No newline at end of file diff --git a/src/components/buttons/NavLogin.tsx b/src/components/buttons/NavLogin.tsx index db621f2..82872a6 100644 --- a/src/components/buttons/NavLogin.tsx +++ b/src/components/buttons/NavLogin.tsx @@ -3,13 +3,13 @@ import Link from "next/link"; import { useTranslations } from 'next-intl'; import DefaultButton from "./Default"; -interface User { +export interface User { name: string; email: string; image: string; } -interface Session { +export interface Session { user: User; }