feat: replace Snipcart with Stripe Checkout + custom cookie cart - #84
Draft
khou22 wants to merge 7 commits into
Draft
feat: replace Snipcart with Stripe Checkout + custom cookie cart#84khou22 wants to merge 7 commits into
khou22 wants to merge 7 commits into
Conversation
- Add cookie-backed cart (CartProvider/useCart) storing only
{photoID, variantId, qty}; prices always re-derived server-side
- New cart page + checkout success page under /photography/cart
- POST /api/checkout validates items against photoPricing and creates
a Stripe Checkout Session (inline price_data, US shipping address,
optional Dashboard-configured STRIPE_SHIPPING_RATE_ID)
- POST /api/stripe/webhook verifies signature and emails order details
via existing EmailServiceFactory (email-only order tracking)
- Replace Snipcart add-to-cart in ProductDetails with cart hook +
sonner toast (PostHog add_to_cart event preserved)
- Remove Snipcart script/CSS, CartButton, pricing.json validation
route, getSnipcartPublicKey, and NEXT_PUBLIC_SNIPCART_KEY
- Add design doc at docs-internal/stripe-checkout-design.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
khou22
marked this pull request as draft
July 7, 2026 05:00
…oad shape - Re-retrieve the Checkout Session via the SDK instead of trusting the event payload: event shapes follow the webhook endpoint's configured API version, so collected_information (shipping) could silently be missing if the endpoint was created on an older version - Only send the order email when payment_status === "paid" — delayed payment methods fire checkout.session.completed while still unpaid - Handle checkout.session.async_payment_succeeded so async payments still trigger the fulfillment email; log async_payment_failed - Expand line item products so the exact photoID appears in the order email (display names alone can be ambiguous for fulfillment) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
success_url/cancel_url were always built from the production siteUrl, so local test checkouts redirected to khou22.com — the localhost cart cookie was never cleared and the success page couldn't be tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A malformed qty (string, NaN, missing) previously flowed through Math.round into a NaN quantity, which Stripe rejected — surfacing as a 500 instead of a 400. Reject non-integer or < 1 quantities and carts over Stripe's 100-line-item cap with proper 400s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The variant's id and dimensions are both 12x18 but the customer-facing name said "16x20" — which would have printed on Stripe line items and receipts for the wrong size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Reset the checkout button when the page is restored from the bfcache (Back from Stripe left it stuck on "Redirecting…" in Safari/Firefox) - Surface server 400 validation messages (eg. out-of-stock variant) in the error toast instead of a generic failure message - Set the Secure attribute on the cart cookie over https Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Removes Snipcart entirely and replaces it with a self-owned cart (cookie-backed) and Stripe Checkout. No monthly/per-order platform fees, no vendor-hosted cart UI, and no secrets in client code. Inventory and order fulfillment stay manual via the Stripe Dashboard, which suits the low volume of the print store.
Motivation
Architecture
Key principle: the client never sends prices. The cart cookie stores only
{ photoID, variantId, qty }; the server re-derives the real price fromphotoPricingat checkout time, so cart tampering is a non-issue.What changed
Added
CartProvider/useCart(src/components/organisms/Cart/) storing only{ photoID, variantId, qty }in a 30-day cookie, with validation + quantity clamping on read./photography/cart) — line items with thumbnails, quantity controls, remove, subtotal, and a Checkout button./photography/cart/success) — clears the cart on arrival.POST /api/checkout— validates items againstphotoPricing(rejects unknown photos / out-of-stock variants with400), builds Stripe line items with inlineprice_data, collects a US shipping address, and applies an optional Dashboard-configured shipping rate viaSTRIPE_SHIPPING_RATE_ID.POST /api/stripe/webhook— verifies the Stripe signature, fetches line items, and emails the full order (items, customer, ship-to) via the existingEmailServiceFactory. Returns5xxon email failure so Stripe automatically retries.getPrintProduct(src/utils/printProduct.ts, renamed fromsnipcart.ts) — provider-neutral product metadata.docs-internal/stripe-checkout-design.md.Removed
CartButton, thepricing.jsonorder-validation route,getSnipcartPublicKey, and theNEXT_PUBLIC_SNIPCART_KEYenv var.Changed
ProductDetails"Add to cart" now uses the cart hook + a sonner toast. The existing PostHogadd_to_cartevent is preserved, and a newbegin_checkoutevent was added on the cart page.Dependencies
stripe. Email libraries (mailgun.js,form-data) were already installed and are reused viaEmailServiceFactory— no new email deps.Security
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET, mail keys) are server-only; neverNEXT_PUBLIC_..envis committed with empty placeholder values (the checked-in example); real values live only in.env.localand Vercel.Vercel compatibility
Each
src/app/api/*route deploys as a serverless function — the webhook is just an inbound HTTPS POST, exactly like the existing/api/contactendpoint. The handler reads the raw body viarequest.text()before parsing, so signature verification works. If the function ever errors, Stripe retries for up to 3 days. Note: production Deployment Protection must stay off, or it will block Stripe's POSTs.Testing
pnpm lint— cleanpnpm test— 19 passedpnpm build— succeeds; new routes registered (/api/checkoutƒ,/api/stripe/webhookƒ,/photography/cart,/photography/cart/success)🚀 Pre-launch checklist
1. Stripe Dashboard (test mode first)
sk_test_…) for local testingshr_…ID2. Local test run
.env.local(gitignored):STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET= # filled in by the next step STRIPE_SHIPPING_RATE_ID= # optional, shr_... (test-mode rate) ORDER_NOTIFICATION_EMAIL=you@example.com MAILGUN_API_KEY=... # already have MAILGUN_DOMAIN=... # already havebrew install stripe/stripe-cli/stripe && stripe loginstripe listen --forward-to localhost:3000/api/stripe/webhook→ copy the printed
whsec_…intoSTRIPE_WEBHOOK_SECRETin.env.localpnpm dev, then run the end-to-end flow:4242 4242 4242 4242(any date/CVC/ZIP)3. Vercel (production)
STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET= # filled in by the next section STRIPE_SHIPPING_RATE_ID= # optional, must be a LIVE-mode rate ORDER_NOTIFICATION_EMAIL=you@example.comMAILGUN_API_KEY/MAILGUN_DOMAINare already set in Vercel4. Stripe Dashboard (live mode)
https://<your-domain>/api/stripe/webhook→ listen for events:
checkout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed→ pin the endpoint's API version to the SDK's pinned version (
2026-06-24.dahlia) instead of the account defaultwhsec_…) intoSTRIPE_WEBHOOK_SECRETon Vercel → redeploySTRIPE_SHIPPING_RATE_ID5. Live smoke test
6. Cleanup
🔍 Post-review hardening (follow-up commits)
A rigorous audit of the initial commit surfaced six improvements, each in its own commit:
payment_status === "paid", handlescheckout.session.async_payment_succeeded(delayed methods like ACH firecompletedwhile still unpaid), and expands line-item products so the exactphotoIDappears in the order email.success_url/cancel_urluse the request origin in dev so local test checkouts return to localhost instead of production./api/checkoutrejects non-integer/< 1quantities and carts over Stripe's 100-line-item cap with proper 400s (previously a NaN quantity surfaced as a 500).12x18-photo-papervariant's display name said "16x20"; corrected to "12x18" (id + dimensions both agree).Secureover https.