diff --git a/.env b/.env index 6c072e865..2379151c3 100644 --- a/.env +++ b/.env @@ -3,7 +3,11 @@ MEETING_SCHEDULER_URL= NEXT_PUBLIC_GOOGLE_ANALYTICS_ID= NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_HOST= -NEXT_PUBLIC_SNIPCART_KEY= SENDGRID_API_KEY= MAILGUN_API_KEY= MAILGUN_DOMAIN= +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +# Optional: Stripe Dashboard shipping rate ID (shr_...) applied at checkout. +STRIPE_SHIPPING_RATE_ID= +ORDER_NOTIFICATION_EMAIL= diff --git a/docs-internal/stripe-checkout-design.md b/docs-internal/stripe-checkout-design.md new file mode 100644 index 000000000..194bd38e9 --- /dev/null +++ b/docs-internal/stripe-checkout-design.md @@ -0,0 +1,397 @@ +# πŸ“„ Design Doc: Replace Snipcart with Stripe Checkout + custom cart + +**Status:** Draft Β· **Author:** Kevin Β· **Scope:** Photography print store + +Goal: drop Snipcart (and its per-order fees / hosted cart) and replace it with a +minimal, self-owned cart + Stripe Checkout. Keep it as simple as possible; +inventory and order fulfillment stay manual via the Stripe Dashboard. + +--- + +## 0) Grounding: what already exists in this repo + +This is written against the actual codebase, not a greenfield template. The +migration is smaller than the generic draft suggests because a lot of +infrastructure is already here. + +| Concern | Already exists | File | +| ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------- | +| Deploy target | **Vercel** (server runtime β€” API routes & webhooks work) | `README.md`, Vercel project | +| Router | App Router, Next 14.2.3, pnpm 8.6.7 | `src/app/` | +| Price catalog | 6 size/material variants, server-side, `$` values | `src/constants/photoPricing.ts` | +| Product assembly | `getSnipcartProduct(photoID, variant)` β†’ id/name/price/image/desc | `src/utils/snipcart.ts` | +| Email sending | `EmailServiceFactory.create().sendEmail({to,from,subject,content})` | `src/services/email/*` | +| Email deps | `mailgun.js`, `form-data`, `@sendgrid/mail` installed | `package.json` | +| Analytics | PostHog `add_to_cart` event on the button | `ProductDetails.tsx:98` | +| `.env` policy | `.env` committed with **empty** values (de-facto example); `.gitignore` ignores `.env*.local` | `.env`, `.gitignore` | + +**What does NOT exist yet:** the `stripe` package, any cart state, a checkout +API route, a webhook, and success/cancel pages. + +### Snipcart touch points to remove + +- `src/components/organisms/Snipcart/Snipcart.tsx` β€” loads Snipcart JS/CSS. +- `` mounted globally in `src/app/layout.tsx:98`. +- `src/components/organisms/Snipcart/CartButton.tsx` + `` in `src/app/photography/layout.tsx:17`. +- `data-item-*` attributes + `snipcart-add-item` class in `ProductDetails.tsx:89-107`. +- `src/app/api/product/[photo_id]/pricing.json/route.ts` β€” Snipcart order-validation crawler (no longer needed; server validates directly). +- `getSnipcartProduct` / `ISnipcartProduct` in `src/utils/snipcart.ts` β€” rename/repurpose (see Β§4). +- `NEXT_PUBLIC_SNIPCART_KEY` in `.env`, `src/utils/getSnipcartPublicKey.ts`. + +--- + +## 1) Architecture + +```mermaid +flowchart LR + A[ProductDetails: Add to cart] --> B[Cart cookie] + B --> C[/photography/cart page/] + C --> D[POST /api/checkout] + D --> E[Server re-derives prices from photoPricing] + E --> F[Stripe Checkout Session] + F --> G[Redirect to Stripe hosted page] + G --> H[Payment complete] + H --> I[Stripe webhook: checkout.session.completed] + I --> J[Verify signature] + J --> K[EmailServiceFactory β†’ order email to Kevin] + K --> L[Manual fulfillment via Stripe Dashboard] +``` + +Key principle: **the client never sends prices.** The cart cookie stores only +`{ photoID, variantId, qty }`. The server looks up the real price in +`photoPricing` at checkout time. This makes cart tampering a non-issue and keeps +"inventory" in one place. + +### What "manual inventory management" means here + +Prints are per-photo Γ— 6 variants β€” far too many combinations to pre-create as +Stripe Products. So: + +- **Variants/prices** live in `photoPricing.ts` (edit code to change a size/price, or flip `inStock`). +- **Per-photo availability** uses the existing `PhotoTags.NotForSale` tag. +- **Orders & fulfillment** are managed entirely in the **Stripe Dashboard** (view paid sessions, customer + shipping details, mark as fulfilled). Line items carry the photo name + variant so each order is self-describing. + +Checkout uses inline `price_data` (not pre-created Stripe Prices) precisely so +you never have to sync a product catalog into Stripe. + +--- + +## 2) Dependencies + +```bash +pnpm add stripe +``` + +That's it. Email libs are already installed; no `axios`/`form-data` additions +needed β€” reuse `EmailServiceFactory`. + +Also add `@stripe/stripe-js` **only if** you want client-side redirect via +`stripe.redirectToCheckout`. Not required β€” the server returns `session.url` and +we can `window.location = url` directly. Recommend skipping it. + +--- + +## 3) Environment variables + +Follow the repo's existing convention: keep committing `.env` with **empty** +values (it already serves as the checked-in example), and put real values in +Vercel + a local `.env.local` (which is gitignored via `.env*.local`). + +Add to `.env` (empty, committed): + +```bash +# Stripe +STRIPE_SECRET_KEY= +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= # only needed if using @stripe/stripe-js +STRIPE_WEBHOOK_SECRET= + +# Order notifications (MAILGUN_* / SENDGRID_* already present) +ORDER_NOTIFICATION_EMAIL= +``` + +Real values go in: + +- **Vercel** project env (Production + Preview). +- **`.env.local`** for local dev (gitignored). + +Server-only (never `NEXT_PUBLIC_`): `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, +`MAILGUN_API_KEY`, `SENDGRID_API_KEY`. + +> ⚠️ Cleanup task: remove `NEXT_PUBLIC_SNIPCART_KEY` from `.env` and delete +> `getSnipcartPublicKey.ts` when Snipcart is torn out. + +--- + +## 4) Data model & product assembly (reuse, lightly renamed) + +`photoPricing.ts` stays exactly as-is (the source of truth). Repurpose +`getSnipcartProduct` β†’ a provider-neutral `getPrintProduct` returning the same +shape minus the Snipcart-specific `url`: + +```ts +// src/utils/printProduct.ts (was snipcart.ts) +export interface PrintProduct { + id: string; // `${photoID}_${variant.id}` + name: string; // `${photoName} (${variant.name})` + description: string; + image: string; // absolute CDN URL for Stripe line item image + price: number; // dollars, from photoPricing +} +``` + +Cart cookie item shape (all the client needs to store): + +```ts +type CartItem = { photoID: string; variantId: string; qty: number }; +``` + +--- + +## 5) Cart (cookie-backed, client-side) + +Keep it minimal. A tiny client cart context + a cookie. No global state library. + +- **Storage:** one cookie, e.g. `cart`, JSON of `CartItem[]`. Use a small helper + (`document.cookie` or the `cookies-next`-style pattern already used elsewhere, + or just `js-cookie` if you prefer β€” check what's installed first). +- **Add to cart:** in `ProductDetails.tsx`, replace the Snipcart `data-item-*` + button with an `onClick` that upserts `{ photoID, variantId: selectedSize.id, qty }` + into the cookie. **Keep the existing `posthog.capture("add_to_cart", …)` call.** +- **Cart button:** replace `CartButton.tsx` with a floating button that shows the + item count (read from cookie) and links to `/photography/cart`. +- **Cart page:** new route `src/app/photography/cart/page.tsx`. Reads the cookie, + renders line items (photo name via `getPhotoName`, price via `photoPricing` + lookup, thumbnail via CDN), supports qty change / remove, shows subtotal, and a + "Checkout" button that POSTs to `/api/checkout`. + +Add to `PAGES.PHOTOGRAPHY` in `src/utils/pages.ts`: + +```ts +CART: "/photography/cart", +CHECKOUT_SUCCESS: "/photography/cart/success", +``` + +> Note: cookie has a ~4KB limit. Storing only ids/qty (not full product data) +> keeps carts tiny even with many items. If it ever matters, fall back to +> `localStorage`; cookies are fine to start. + +--- + +## 6) Checkout API β€” `src/app/api/checkout/route.ts` + +```ts +import Stripe from "stripe"; +import { NextRequest } from "next/server"; +import { photoPricing } from "@/constants/photoPricing"; +import { getPrintProduct } from "@/utils/printProduct"; +import { PAGES } from "@/utils/pages"; +import { siteMetadata } from "@/constants/siteMetadata"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + +export async function POST(req: NextRequest) { + const { + items, + }: { items: { photoID: string; variantId: string; qty: number }[] } = + await req.json(); + + const line_items = items.map((item) => { + const variant = photoPricing.find((v) => v.id === item.variantId); + if (!variant || !variant.inStock) { + throw new Error(`Unavailable variant: ${item.variantId}`); + } + const product = getPrintProduct(item.photoID, variant); // server-side price + + return { + quantity: Math.max(1, Math.min(item.qty, 20)), // clamp + price_data: { + currency: "usd", + unit_amount: Math.round(variant.price * 100), // dollars β†’ cents + product_data: { + name: product.name, + description: product.description, + images: [product.image], + metadata: { photoID: item.photoID, variantId: item.variantId }, + }, + }, + }; + }); + + const base = siteMetadata.siteUrl; + const session = await stripe.checkout.sessions.create({ + mode: "payment", + line_items, + success_url: `${base}${PAGES.PHOTOGRAPHY.CHECKOUT_SUCCESS}?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${base}${PAGES.PHOTOGRAPHY.CART}`, + allow_promotion_codes: true, + shipping_address_collection: { allowed_countries: ["US"] }, // physical prints + // Optional: add flat-rate shipping via `shipping_options` (create rate in Stripe dashboard) + }); + + return Response.json({ url: session.url }); +} +``` + +Client "Checkout" handler: `POST` the cookie's items, then +`window.location.href = data.url`. + +**Shipping (decided):** collect a US shipping address +(`shipping_address_collection`). Shipping cost is controlled from the **Stripe +Dashboard**: create a shipping rate there and set its ID in the optional +`STRIPE_SHIPPING_RATE_ID` env var β€” the checkout route passes it as +`shipping_options` when present. Leave it unset for no shipping charge. Rates +can be added/changed later without code changes. + +--- + +## 7) Webhook β€” `src/app/api/stripe/webhook/route.ts` + +Reuse the existing email service; do **not** hand-roll Mailgun. + +```ts +import Stripe from "stripe"; +import { NextRequest } from "next/server"; +import { EmailServiceFactory } from "@/services/email/EmailServiceFactory"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + +export async function POST(req: NextRequest) { + const body = await req.text(); // raw body required for signature check + const sig = req.headers.get("stripe-signature")!; + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent( + body, + sig, + process.env.STRIPE_WEBHOOK_SECRET!, + ); + } catch (err) { + return new Response(`Webhook signature verification failed`, { + status: 400, + }); + } + + if (event.type === "checkout.session.completed") { + const session = event.data.object as Stripe.Checkout.Session; + + // Line items aren't on the base session object β€” fetch them for the email body. + const lineItems = await stripe.checkout.sessions.listLineItems(session.id, { + limit: 100, + }); + const summary = lineItems.data + .map((li) => `${li.quantity}Γ— ${li.description}`) + .join("\n"); + + const email = EmailServiceFactory.create(); + await email.sendEmail({ + to: { name: "Kevin", email: process.env.ORDER_NOTIFICATION_EMAIL! }, + from: { + name: "Print Orders", + email: process.env.ORDER_NOTIFICATION_EMAIL!, + }, + subject: `πŸ“Έ New print order β€” $${(session.amount_total ?? 0) / 100}`, + content: + `New order received\n\n` + + `Customer: ${session.customer_details?.email}\n` + + `Ship to: ${JSON.stringify(session.shipping_details?.address)}\n` + + `Total: $${(session.amount_total ?? 0) / 100}\n\n` + + `Items:\n${summary}\n\n` + + `Stripe session: ${session.id}`, + }); + } + + return new Response("ok"); +} +``` + +**App Router note:** App Router route handlers already receive the raw body via +`req.text()`, so there's no `bodyParser` config to disable (that was a Pages +Router concern). Just don't call `req.json()` before verifying. + +**Implementation notes (the shipped route goes beyond this sketch):** + +- **Don't trust the event payload's shape.** Event payloads follow the webhook + _endpoint's_ configured API version, not the SDK's β€” on older versions the + shipping address lives at `session.shipping_details` instead of + `session.collected_information.shipping_details`. The route re-retrieves the + session via the SDK so the shape always matches the SDK's pinned version. +- **Gate on `payment_status === "paid"`.** Delayed-notification payment methods + (eg. ACH) fire `checkout.session.completed` while the session is still + `unpaid`; the order email is sent only once paid. The route also handles + `checkout.session.async_payment_succeeded` (email) and logs + `checkout.session.async_payment_failed`. +- **Expand `data.price.product` on line items** so the exact `photoID` + (product metadata) appears in the order email β€” display names alone can be + ambiguous for fulfillment. + +--- + +## 8) Testing + +- Use Stripe **test** keys (`sk_test_…`) locally. +- Test card `4242 4242 4242 4242`, any future date / CVC / ZIP. +- Stripe CLI to exercise the webhook locally: + ```bash + stripe login + stripe listen --forward-to localhost:3000/api/stripe/webhook # copy the whsec_… into .env.local + stripe trigger checkout.session.completed + ``` +- Verify the order email actually sends (Mailgun sandbox or your real domain). + +--- + +## 9) Deployment (Vercel) + +- Add all Stripe + email env vars in the Vercel dashboard (Production + Preview). +- Register the production webhook in Stripe: + `https:///api/stripe/webhook`, events + `checkout.session.completed`, `checkout.session.async_payment_succeeded`, + and `checkout.session.async_payment_failed`. Copy that endpoint's signing + secret into `STRIPE_WEBHOOK_SECRET` on Vercel. +- When creating the endpoint, **pin its API version** to the version the + installed `stripe` SDK targets (see `node_modules/stripe/cjs/apiVersion.js`, + currently `2026-06-24.dahlia`) rather than the account default. The route + re-retrieves sessions so it tolerates a mismatch, but pinning keeps event + payloads and SDK types consistent. +- No static-export concern β€” the site already runs a Node runtime on Vercel. + +--- + +## 10) Security checklist + +| Risk | Mitigation | +| ---------------------------- | ----------------------------------------------------------------- | +| Leaking API keys | `.env` committed empty; real values only in Vercel + `.env.local` | +| Client tampers with price | Server ignores client price; re-derives from `photoPricing` | +| Fake webhook calls | `stripe.webhooks.constructEvent` signature verification | +| Payment fraud | Handled by Stripe | +| Selling out-of-stock variant | `inStock` check in checkout route | +| Stale Snipcart key public | Remove `NEXT_PUBLIC_SNIPCART_KEY` + `getSnipcartPublicKey.ts` | + +--- + +## 11) Migration checklist (build order) + +1. `pnpm add stripe`; add env vars (empty in `.env`, real in `.env.local` + Vercel). +2. Rename `src/utils/snipcart.ts` β†’ `printProduct.ts`; `getSnipcartProduct` β†’ `getPrintProduct` (drop `url`). +3. Add cart cookie helpers + a small cart context; add `CART`/`CHECKOUT_SUCCESS` to `PAGES`. +4. Rewrite the "Add to cart" button in `ProductDetails.tsx` (keep the PostHog event). +5. Replace `CartButton.tsx` with a count badge linking to `/photography/cart`. +6. Build `src/app/photography/cart/page.tsx` (+ success page). +7. Add `POST /api/checkout` (Β§6). +8. Add `POST /api/stripe/webhook` (Β§7). +9. Test end-to-end with Stripe CLI + test card. +10. Delete Snipcart: `Snipcart.tsx`, `` in root layout, old `pricing.json` route, `getSnipcartPublicKey.ts`, `NEXT_PUBLIC_SNIPCART_KEY`, Snipcart Script/CSS. +11. Ship to Vercel; register production webhook; do one real low-value live test. + +--- + +## 12) Decisions + +1. **Shipping** β€” Dashboard-configurable via optional `STRIPE_SHIPPING_RATE_ID` + (see Β§6). US-only address collection to start; shipping charges may be added + later if need be, with no code changes. +2. **Order storage** β€” email-only. The Stripe Dashboard is the order history; + no DB records. +3. **Cart persistence** β€” cookie only, single device. diff --git a/package.json b/package.json index 3c6640e10..78544dd85 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "sonner": "^1.3.1", "sqlite": "^5.1.1", "sqlite3": "^5.1.6", + "stripe": "^22.3.0", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", "zod": "^3.22.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57d4024fa..acc930e00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ dependencies: sqlite3: specifier: ^5.1.6 version: 5.1.6 + stripe: + specifier: ^22.3.0 + version: 22.3.0(@types/node@20.9.2) tailwind-merge: specifier: ^2.1.0 version: 2.1.0 @@ -8533,6 +8536,18 @@ packages: engines: {node: '>=8'} dev: true + /stripe@22.3.0(@types/node@20.9.2): + resolution: {integrity: sha512-ypO6xjVrMWs9SmIMeHr8naCx3dAQ0clxMdUTxn7Ejd7hmY9meBGfE+N4pVHkf9sUNebAHp6uJo6mV3GxDIc2cA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + dependencies: + '@types/node': 20.9.2 + dev: false + /style-to-object@0.4.4: resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} dependencies: diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts new file mode 100644 index 000000000..9d9e5fe68 --- /dev/null +++ b/src/app/api/checkout/route.ts @@ -0,0 +1,111 @@ +import { NextRequest } from "next/server"; +import Stripe from "stripe"; +import { photoPricing } from "@/constants/photoPricing"; +import { CartItem, MAX_ITEM_QTY } from "@/utils/cart/cartCookie"; +import { castPhotoID } from "@/utils/cdn/cdnAssets"; +import { PAGES } from "@/utils/pages"; +import { getPrintProduct } from "@/utils/printProduct"; +import { siteMetadata } from "@/constants/siteMetadata"; + +export type CheckoutRequest = { + items: CartItem[]; +}; + +/** + * Stripe Checkout's own cap on line items per session. + */ +const MAX_LINE_ITEMS = 100; + +/** + * Creates a Stripe Checkout Session for the given cart. The client only sends + * `{photoID, variantId, qty}` β€” prices are always re-derived server-side from + * `photoPricing`, so cart tampering is not possible. + */ +export async function POST(request: NextRequest) { + try { + if (!process.env.STRIPE_SECRET_KEY) { + throw new Error("No Stripe secret key found"); + } + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + + const body: CheckoutRequest = await request.json(); + if (!Array.isArray(body.items) || body.items.length === 0) { + return new Response("Cart is empty", { status: 400 }); + } + if (body.items.length > MAX_LINE_ITEMS) { + return new Response("Too many items in cart", { status: 400 }); + } + + const line_items: Stripe.Checkout.SessionCreateParams.LineItem[] = + body.items.map((item) => { + const photoID = castPhotoID(item.photoID); + if (!photoID) { + throw new CheckoutValidationError(`Unknown photo: ${item.photoID}`); + } + + const variant = photoPricing.find((v) => v.id === item.variantId); + if (!variant || !variant.inStock) { + throw new CheckoutValidationError( + `Unavailable print variant: ${item.variantId}`, + ); + } + + if (!Number.isInteger(item.qty) || item.qty < 1) { + throw new CheckoutValidationError( + `Invalid quantity for ${item.variantId}`, + ); + } + + const product = getPrintProduct(photoID, variant); + return { + quantity: Math.min(item.qty, MAX_ITEM_QTY), + price_data: { + currency: "usd", + unit_amount: Math.round(variant.price * 100), + product_data: { + name: product.name, + description: product.description, + images: [product.image], + metadata: { + photoID: item.photoID, + variantId: item.variantId, + }, + }, + }, + }; + }); + + // In dev, redirect back to the origin the checkout started on (eg. + // localhost) so local test payments don't land on the production site. + const baseUrl = + process.env.NODE_ENV === "development" + ? request.nextUrl.origin + : siteMetadata.siteUrl; + const session = await stripe.checkout.sessions.create({ + mode: "payment", + line_items, + success_url: `${baseUrl}${PAGES.PHOTOGRAPHY.CHECKOUT_SUCCESS}?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${baseUrl}${PAGES.PHOTOGRAPHY.CART}`, + allow_promotion_codes: true, + shipping_address_collection: { allowed_countries: ["US"] }, + // Shipping cost is configured in the Stripe Dashboard (optional). + ...(process.env.STRIPE_SHIPPING_RATE_ID + ? { + shipping_options: [ + { shipping_rate: process.env.STRIPE_SHIPPING_RATE_ID }, + ], + } + : {}), + }); + + return Response.json({ url: session.url }); + } catch (error) { + console.error("Error creating checkout session:", error); + if (error instanceof CheckoutValidationError) { + return new Response(error.message, { status: 400 }); + } + return new Response("Failed to create checkout session", { status: 500 }); + } +} + +class CheckoutValidationError extends Error {} diff --git a/src/app/api/product/[photo_id]/pricing.json/route.ts b/src/app/api/product/[photo_id]/pricing.json/route.ts deleted file mode 100644 index dab53d281..000000000 --- a/src/app/api/product/[photo_id]/pricing.json/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { NextRequest } from "next/server"; -import { getPhotoIDFromURLComponent } from "@/utils/cdn/cdnAssets"; -import { photoPricing } from "@/constants/photoPricing"; -import { getSnipcartProduct } from "@/utils/snipcart"; - -export interface ISnipcartValidationResponse { - /** - * The ID of the product - */ - id: string; - - /** - * The price of the product - */ - price: number; - - /** - * The custom fields of the product - */ - customFields: []; - - /** - * The URL of the product JSON. - */ - url: string; -} - -interface RouteParams { - params: { photo_id: string }; -} - -export async function GET( - _request: NextRequest, - { params: { photo_id: photoIdURLComponent } }: RouteParams, -) { - const photoID = getPhotoIDFromURLComponent(photoIdURLComponent); - if (!photoID) { - console.log(`Photo ID "${photoID}" not found`); - return new Response( - JSON.stringify( - { - error: `Photo ID "${photoID}" not found`, - }, - null, - 2, - ), - { status: 404 }, - ); - } - - const productVariants = photoPricing.map((p) => { - const product = getSnipcartProduct(photoID, p); - - const productVariant: ISnipcartValidationResponse = { - id: product.id, - price: product.price, - customFields: [], - url: product.url, - }; - - return productVariant; - }); - - // Return JSON array of product variants - return Response.json(productVariants); -} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts new file mode 100644 index 000000000..995efd9e2 --- /dev/null +++ b/src/app/api/stripe/webhook/route.ts @@ -0,0 +1,140 @@ +import { NextRequest } from "next/server"; +import Stripe from "stripe"; +import { EmailServiceFactory } from "@/services/email/EmailServiceFactory"; + +/** + * Stripe webhook. On a paid Checkout Session, sends an order notification + * email so fulfillment can be handled manually (order history lives in the + * Stripe Dashboard β€” no database). + */ +export async function POST(request: NextRequest) { + if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET) { + console.error("Stripe webhook env vars missing"); + return new Response("Stripe is not configured", { status: 500 }); + } + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + + // The raw body is required for signature verification. + const body = await request.text(); + const signature = request.headers.get("stripe-signature"); + if (!signature) { + return new Response("Missing stripe-signature header", { status: 400 }); + } + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET, + ); + } catch (error) { + console.error("Webhook signature verification failed:", error); + return new Response("Invalid signature", { status: 400 }); + } + + if ( + event.type === "checkout.session.completed" || + event.type === "checkout.session.async_payment_succeeded" + ) { + try { + // Event payloads follow the webhook endpoint's configured API version, + // not the SDK's β€” re-retrieve the session so the shape (eg. + // `collected_information`) always matches the SDK's pinned version. + const session = await stripe.checkout.sessions.retrieve( + event.data.object.id, + ); + + // Delayed-notification payment methods (eg. ACH) fire + // `checkout.session.completed` while `payment_status` is still + // "unpaid"; the `async_payment_succeeded` event arrives once the + // payment actually clears and triggers the email instead. + if (session.payment_status !== "paid") { + return new Response("ok (payment not yet complete)"); + } + + await sendOrderNotification(stripe, session); + } catch (error) { + // Return 500 so Stripe retries the webhook until the email succeeds. + console.error("Failed to send order notification:", error); + return new Response("Failed to send order notification", { + status: 500, + }); + } + } + + if (event.type === "checkout.session.async_payment_failed") { + // No email β€” the order never happened. Logged for visibility only. + console.warn("Async payment failed for session:", event.data.object.id); + } + + return new Response("ok"); +} + +const sendOrderNotification = async ( + stripe: Stripe, + session: Stripe.Checkout.Session, +) => { + if (!process.env.ORDER_NOTIFICATION_EMAIL) { + throw new Error("No order notification email found"); + } + + // Line items are not included on the session; fetch them (with the inline + // product expanded so the exact photoID is available) so each order email + // is self-describing for fulfillment. + const lineItems = await stripe.checkout.sessions.listLineItems(session.id, { + limit: 100, + expand: ["data.price.product"], + }); + const itemSummary = lineItems.data + .map((item) => { + const product = item.price?.product; + const photoID = + product && typeof product !== "string" && !product.deleted + ? product.metadata.photoID + : undefined; + return `- ${item.quantity}Γ— ${item.description}${ + photoID ? ` (photoID: ${photoID})` : "" + }`; + }) + .join("\n"); + + const shipping = session.collected_information?.shipping_details; + const address = shipping?.address; + const shippingSummary = shipping + ? `${shipping.name} +${address?.line1 ?? ""}${address?.line2 ? `\n${address.line2}` : ""} +${address?.city ?? ""}, ${address?.state ?? ""} ${address?.postal_code ?? ""} +${address?.country ?? ""}` + : "(no shipping address collected)"; + + const total = ((session.amount_total ?? 0) / 100).toFixed(2); + + const emailService = EmailServiceFactory.create(); + await emailService.sendEmail({ + to: { + name: "Print Orders", + email: process.env.ORDER_NOTIFICATION_EMAIL, + }, + from: { + name: "Print Orders", + email: process.env.ORDER_NOTIFICATION_EMAIL, + }, + subject: `πŸ“Έ New print order β€” $${total}`, + content: `New order received! + +Customer: ${session.customer_details?.name ?? "?"} <${ + session.customer_details?.email ?? "?" + }> +Total: $${total} + +Items: +${itemSummary} + +Ship to: +${shippingSummary} + +Stripe session: ${session.id} +Manage this order in the Stripe Dashboard.`, + }); +}; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 747658794..dca315d5c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,6 @@ import { HydrationOverlay } from "@builder.io/react-hydration-overlay"; import { Cutive_Mono, Montserrat, Mulish } from "next/font/google"; import "./globals.css"; import dynamic from "next/dynamic"; -import { Snipcart } from "../components/organisms/Snipcart/Snipcart"; import { PHProvider } from "./providers"; import { siteMetadata } from "@/constants/siteMetadata"; import { NavBar } from "@/components/organisms/NavBar/NavBar"; @@ -95,7 +94,6 @@ export default function RootLayout({ )} > - {isDev ? ( diff --git a/src/app/photography/cart/page.tsx b/src/app/photography/cart/page.tsx new file mode 100644 index 000000000..735ab082a --- /dev/null +++ b/src/app/photography/cart/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { usePostHog } from "posthog-js/react"; +import { toast } from "sonner"; +import { CustomLink } from "@/components/atoms/CustomLink/CustomLink"; +import { Button } from "@/components/ui/button"; +import { useCart } from "@/components/organisms/Cart/CartProvider"; +import { photoPricing } from "@/constants/photoPricing"; +import { CartItem } from "@/utils/cart/cartCookie"; +import { + castPhotoID, + getCdnAsset, + getPhotoName, + getPhotoThumbnail, +} from "@/utils/cdn/cdnAssets"; +import { PAGES } from "@/utils/pages"; + +const CartLineItem = ({ item }: { item: CartItem }) => { + const { setQty, removeItem } = useCart(); + + const photoID = castPhotoID(item.photoID); + const variant = photoPricing.find((v) => v.id === item.variantId); + if (!photoID || !variant) return null; + + const thumbnail = getPhotoThumbnail(photoID); + const image = getCdnAsset(thumbnail ?? photoID); + + return ( +
+ + {getPhotoName(photoID)} + + +
+ + {getPhotoName(photoID)} + +

+ {variant.name} Β· {variant.material} +

+

${variant.price}

+
+ +
+ + {item.qty} + +
+ + +
+ ); +}; + +export default function CartPage() { + const { items } = useCart(); + const posthog = usePostHog(); + const [isCheckingOut, setIsCheckingOut] = useState(false); + + // Safari/Firefox restore this page from the bfcache when the user hits + // Back from Stripe β€” reset the button so it isn't stuck on "Redirecting…". + useEffect(() => { + const onPageShow = (event: PageTransitionEvent) => { + if (event.persisted) setIsCheckingOut(false); + }; + window.addEventListener("pageshow", onPageShow); + return () => window.removeEventListener("pageshow", onPageShow); + }, []); + + const subtotal = items.reduce((total, item) => { + const variant = photoPricing.find((v) => v.id === item.variantId); + return total + (variant?.price ?? 0) * item.qty; + }, 0); + + const handleCheckout = async () => { + setIsCheckingOut(true); + posthog.capture("begin_checkout", { + item_count: items.length, + subtotal, + }); + + try { + const response = await fetch(PAGES.PHOTOGRAPHY.CHECKOUT_API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items }), + }); + if (!response.ok) { + const message = await response.text(); + // 400s carry a human-readable reason (eg. an out-of-stock variant). + toast.error( + response.status === 400 && message + ? message + : "Something went wrong starting checkout. Please try again.", + ); + setIsCheckingOut(false); + return; + } + const { url } = await response.json(); + window.location.href = url; + } catch (error) { + console.error("Checkout failed:", error); + toast.error("Something went wrong starting checkout. Please try again."); + setIsCheckingOut(false); + } + }; + + return ( +
+

Your Cart

+ + {items.length === 0 ? ( +
+

Your cart is empty.

+ + Browse photography + +
+ ) : ( + <> +
+ {items.map((item) => ( + + ))} +
+ +
+ Subtotal + ${subtotal.toFixed(2)} +
+

+ Shipping and taxes are calculated at checkout. +

+ + + + )} +
+ ); +} diff --git a/src/app/photography/cart/success/page.tsx b/src/app/photography/cart/success/page.tsx new file mode 100644 index 000000000..b922d79c9 --- /dev/null +++ b/src/app/photography/cart/success/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; +import { CustomLink } from "@/components/atoms/CustomLink/CustomLink"; +import { useCart } from "@/components/organisms/Cart/CartProvider"; +import { PAGES } from "@/utils/pages"; + +/** + * Post-checkout landing page. Stripe redirects here after a successful + * payment; the cart is cleared on arrival. + */ +export default function CheckoutSuccessPage() { + const { clearCart } = useCart(); + + useEffect(() => { + clearCart(); + }, [clearCart]); + + return ( +
+

Thank you for your order! πŸ“Έ

+

+ Your payment was successful. You'll receive a receipt from Stripe + at the email you provided, and I'll be in touch about your prints + shortly. +

+

+ Questions about your order?{" "} + Get in touch. +

+ + Continue browsing photography + +
+ ); +} diff --git a/src/app/photography/layout.tsx b/src/app/photography/layout.tsx index e22d4eefe..28f3eb834 100644 --- a/src/app/photography/layout.tsx +++ b/src/app/photography/layout.tsx @@ -1,5 +1,7 @@ import { Metadata } from "next"; -import { CartButton } from "@/components/organisms/Snipcart/CartButton"; +import { CartButton } from "@/components/organisms/Cart/CartButton"; +import { CartProvider } from "@/components/organisms/Cart/CartProvider"; +import { Toaster } from "@/components/ui/sonner"; import { siteMetadata } from "@/constants/siteMetadata"; export const metadata: Metadata = { @@ -13,9 +15,10 @@ export default function PhotographyLayout({ children: React.ReactNode; }) { return ( - <> + {children} - + + ); } diff --git a/src/app/photography/photo/[photo_id]/ProductDetails.tsx b/src/app/photography/photo/[photo_id]/ProductDetails.tsx index d7776fb69..351b2e6c4 100644 --- a/src/app/photography/photo/[photo_id]/ProductDetails.tsx +++ b/src/app/photography/photo/[photo_id]/ProductDetails.tsx @@ -1,9 +1,11 @@ "use client"; import { RadioGroup } from "@headlessui/react"; +import { useRouter } from "next/navigation"; import { useState } from "react"; import { groupBy } from "lodash"; import { usePostHog } from "posthog-js/react"; +import { toast } from "sonner"; import { VariantCategory } from "./VariantCategory"; import { CustomLink } from "@/components/atoms/CustomLink/CustomLink"; import { PhotoTagBadge } from "@/components/atoms/PhotoTagBadge/PhotoTagBadge"; @@ -19,8 +21,9 @@ import { PhotoTags } from "@/constants/photoTags/photoTags"; import { siteMetadata } from "@/constants/siteMetadata"; import { PhotoIdType, getPhotoName } from "@/utils/cdn/cdnAssets"; import { PAGES } from "@/utils/pages"; -import { getSnipcartProduct } from "@/utils/snipcart"; +import { getPrintProduct } from "@/utils/printProduct"; import { PhotoSize } from "@/utils/photos/getPhotoSize"; +import { useCart } from "@/components/organisms/Cart/CartProvider"; type ProductDetailsProps = { photoID: PhotoIdType; @@ -36,6 +39,8 @@ export const ProductDetails: React.FC = ({ photoSize, }) => { const posthog = usePostHog(); + const router = useRouter(); + const { addItem } = useCart(); const [selectedSizeID, setSelectedSizeID] = useState(defaultPhotoSize.id); const selectedSize = @@ -51,7 +56,7 @@ export const ProductDetails: React.FC = ({ ? "horizontal" : "vertical"; - const snipcartProduct = getSnipcartProduct(photoID, selectedSize); + const printProduct = getPrintProduct(photoID, selectedSize); return (
@@ -59,7 +64,7 @@ export const ProductDetails: React.FC = ({

${selectedSize.price}

-

{snipcartProduct.description}

+

{printProduct.description}

{tags.map((tag) => ( @@ -88,18 +93,19 @@ export const ProductDetails: React.FC = ({
- - {/* Hidden Metadata (could remove, no real use) */} - - -
- ); -}; diff --git a/src/components/organisms/Snipcart/Snipcart.tsx b/src/components/organisms/Snipcart/Snipcart.tsx deleted file mode 100644 index 93765e4d8..000000000 --- a/src/components/organisms/Snipcart/Snipcart.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable @next/next/no-css-tags */ -import Script from "next/script"; - -import "./snipcart.css"; -import { getSnipcartPublicKey } from "@/utils/getSnipcartPublicKey"; - -/** - * Contains logic neccessary for instantiating Snipcart cart management. Does not render anything - * visible (see `CartButton` for that). There was an issue with mounting and unmounting this - * component in the nested PhotographyLayout. Consequently, it is now included on all pages. - */ -export const Snipcart = () => { - const snipcartKey = getSnipcartPublicKey(); - return ( -
- - - -