Jan Pilik · Full-Stack Developer
3,641 commits.
18 months. Solo.
I designed and built Sanglogium — a production e-commerce platform covering every layer: design system, Stripe checkout, multi-carrier shipping, and Sanity CMS.
PROFESSIONAL CHECKOUT SYSTEM
screenshots from live product
video playing user journey, infinite loop
Basket — End User Experience
User reviews items and adjusts quantities. Real-time stock checks on each item. Quantities must be integers ≥ 1 and ≤ MAX_QUANTITY_PER_ITEM. Proceeds to address step.
Basket — Architecture
State: iron-session
Basket is stored in an encrypted, httpOnly iron-session cookie. Changing the basket invalidates all downstream checkout state (address, shippingCost, completedPaymentIntentId).
Cascade invalidation
basket changed
→ clear session.address
→ clear session.shippingCost
→ clear session.shippingCarrier
→ clear session.completedPaymentIntentId
This prevents stale shipping or payment data from reaching checkout after a basket change.
Basket — Code Quality
Funnel guard: basket validation
app/checkout/payment/page.tsx
if (!session.basket?.length) redirect("/basket")
if (session.basket.some(i => !Number.isInteger(i.quantity) || i.quantity < 1))
redirect("/basket?error=invalid_basket")
if (!session.address) redirect("/checkout/address")
if (session.shippingCost === undefined || session.shippingCost === null)
redirect("/checkout/shipping")
for (const item of session.basket) {
if (item.quantity > MAX_QUANTITY_PER_ITEM)
redirect(`/basket?error=excessive_quantity&id=${item.productId}`)
} Guards run server-side on the payment page. Empty basket, invalid quantities, missing address/shipping → immediate redirect. No Stripe call is made.
Basket — Diagrams
BASKET → CHECKOUT FLOW
───────────────────────
basket items (iron-session)
│
├─ basket changed?
│ → cascade: clear address,
│ shippingCost, paymentData
│
├─ validation (server):
│ ✓ items exist
│ ✓ qty: integer ≥ 1
│ ✓ qty ≤ MAX_QUANTITY_PER_ITEM
│ ✓ stock available (Sanity)
│
└─► /checkout/address
Address — End User Experience
User enters a shipping address. Server calls Google Maps Address Validation API. Three outcomes:
- ACCEPT — address is valid. Saved to session, redirects to shipping.
- FIX — address has errors. Inline validation messages shown per field. User corrects without leaving the page.
- CONFIRM — Google corrected the address. User sees the corrected version and can accept or re-enter.
Not a regex check — Google validates at PREMISE / SUB_PREMISE granularity level.
Address — Architecture
Google Maps Address Validation API
Called server-side on form submit. The API returns a verdict with inputGranularity, validationGranularity, geocodeGranularity, addressComplete, hasReplacedComponents, hasSpellCorrectedComponents.
3-outcome classification
ACCEPT: all components valid, granularity PREMISE/SUB_PREMISE, no spell corrections → save to iron-session.
FIX: invalid components or coarse granularity → return field-level errors to client.
CONFIRM: Google replaced/corrected components → return corrected address to client for user review.
Address — Code Quality
3-outcome classification logic
app/actions/address/address.ts
const ALLOWED_GRANULARITY = new Set(["PREMISE", "SUB_PREMISE"])
// Google verdict: inputGranularity, validationGranularity, geocodeGranularity
// addressComplete, hasReplacedComponents, hasSpellCorrectedComponents
// → ACCEPT: save to session, redirect to shipping
// → FIX: return validation errors to client
// → CONFIRM: show corrected address for user confirmation View on GitHub → Address — Diagrams
ADDRESS VALIDATION FLOW
────────────────────────
user submits address
│
Google Maps API (server)
│
┌─────┴──────────────────┐
│ │
FIX CONFIRM / ACCEPT
│ │
inline errors ACCEPT:
shown per field → save to iron-session
user corrects → /checkout/shipping
│
└─► re-submit CONFIRM:
→ show corrected address
→ user accepts → ACCEPT path
→ user rejects → re-enter
Shipping — End User Experience
Shipping rates are fetched live from the AlleKurier API based on the actual product dimensions (weight, length, width, height) stored in Sanity. The parcel calculator enforces per-parcel limits and auto-splits into multiple parcels when needed. User selects carrier. Selection is server-verified at save.
Shipping — Architecture
AlleKurier API integration
Real-time rates fetched server-side. Product dimensions (weight_g, length_cm, width_cm, height_cm) stored per product in Sanity. Parcel calculator aggregates basket to determine number of parcels needed.
Parcel limits (per courier)
- Max weight: 25,000g per parcel
- Max volume: 99,000 cm³ per parcel
- Auto-splits basket into multiple parcels if either limit is exceeded
Server re-verification
When the user saves their carrier selection, the server re-fetches the rate from AlleKurier. The shippingCost written to iron-session comes from a fresh API call, not from the client.
Shipping — Code Quality
Parcel calculator — enforces courier limits, auto-splits
lib/shipping/parcel-calculator.ts
const MAX_WEIGHT_G = 25000 // 25kg max per package
const MAX_VOLUME_CM3 = 99000 // 99,000 cm³ max per package
const parcelsByWeight = Math.ceil(totalWeight / MAX_WEIGHT_G)
const parcelsByVolume = Math.ceil(totalVolume / MAX_VOLUME_CM3)
const numParcels = Math.max(parcelsByWeight, parcelsByVolume, 1)
// auto-splits into multiple parcels when basket exceeds courier limits View on GitHub → Shipping — Diagrams
PARCEL CALCULATION
──────────────────
basket items (from Sanity):
weight_g, length_cm, width_cm, height_cm
totalWeight = sum(qty x weight_g)
totalVolume = sum(qty x l x w x h)
parcelsByWeight = ceil(totalWeight / 25000)
parcelsByVolume = ceil(totalVolume / 99000)
numParcels = max(byWeight, byVolume, 1)
│
└─► AlleKurier API (per-parcel rates)
DPD | DHL | InPost | others
│
user selects carrier
│
server re-verifies rate at save
│
stored in iron-session
Payment — End User Experience
Six payment methods: BLIK, P24, Klarna, Apple Pay, Google Pay, card. User selects and pays. If payment fails (requires_payment_method), a clear error is shown and the user can retry — session is preserved. Successful payment redirects to /success.
Payment — Architecture
5 funnel guards (server, before any Stripe call)
- Basket not empty
- All quantities are valid integers >= 1
- Address present in session
- shippingCost present in session
- No item quantity exceeds MAX_QUANTITY_PER_ITEM
Server-side price re-derivation
At payment time, the server fetches every product price fresh from Sanity and recomputes the grand total. The client's reported amount is ignored. The Stripe charge is based on the server-computed total only.
Inventory concurrency (ADR-002)
- Standard items — OCC: atomic Sanity check+decrement at payment. Collision gives clear message.
- Flagship ($3k+, qty=1) — Redis TTL soft reservation (10 min). Promotes on payment, auto-expires on abandon.
Payment — Code Quality
Server re-derives grand total — client total untrusted
app/api/checkout/payment-intent-session/route.ts
// Server re-derives grand total from live Sanity data — client total is untrusted
const products = await getBackendClient().fetch(
groq`*[_type == "product" && _id in $ids]{ _id, price_data { unit_amount } }`,
{ ids }
)
let subtotal = 0
for (const item of session.basket) {
const product = products.find(p => p._id === item.productId)
const unitPrice = product?.price_data?.unit_amount
if (!unitPrice || !Number.isFinite(unitPrice)) {
return NextResponse.json({ error: 'Invalid price' }, { status: 400 })
}
subtotal += unitPrice * item.quantity
}
const computedGrandTotal = Math.round(subtotal + session.shippingCost) View on GitHub Five funnel guards before any Stripe call
app/checkout/payment/page.tsx
if (!session.basket?.length) redirect("/basket")
if (session.basket.some(i => !Number.isInteger(i.quantity) || i.quantity < 1))
redirect("/basket?error=invalid_basket")
if (!session.address) redirect("/checkout/address")
if (session.shippingCost === undefined || session.shippingCost === null)
redirect("/checkout/shipping")
for (const item of session.basket) {
if (item.quantity > MAX_QUANTITY_PER_ITEM)
redirect(`/basket?error=excessive_quantity&id=${item.productId}`)
} Payment — Diagrams
PAYMENT SECURITY FLOW
----------------------
/checkout/payment (server):
5 FUNNEL GUARDS
1. basket not empty
2. valid integer quantities
3. address in session
4. shippingCost in session
5. qty <= MAX_QUANTITY_PER_ITEM
|
createPaymentIntent (Stripe API)
* amount = server-recomputed total
(fresh Sanity prices; client ignored)
|
Stripe Embedded Elements mount
(BLIK / P24 / Klarna / Apple Pay
Google Pay / card)
|
stripe.confirmPayment()
|
+----+-------------+
FAIL SUCCESS
| |
retry UI sync order create
session redirect /success
preserved + Stripe webhook (async)
Success Page — End User Experience
The success page renders a full order receipt: line items, subtotal, shipping carrier and estimated delivery date, VAT breakdown, total, and payment method hint.
Payment status branches (5 Stripe states)
- succeeded — show full receipt.
- requires_payment_method — payment failed, link to retry.
- canceled — order canceled.
- processing — show processing state, auto-refresh.
- unexpected — graceful error, never throws on a paid user.
Cross-device access
Dual verification: session.completedPaymentIntentId OR Sanity order fallback by userId. Receipt always accessible across devices.
Success Page — Architecture
Idempotent order creation
Order is created twice: synchronously before the redirect, and via Stripe webhook. Both use idempotency key = paymentIntentId. First write creates the Sanity document; second is a no-op. Handles webhook delays, mid-redirect crashes, duplicate deliveries.
Dual verification gate
Checks session.completedPaymentIntentId first. If absent (cross-device), falls back to Sanity query by userId + paymentIntentId. Receipt always accessible.
Security: no sensitive data in client
All order data fetched server-side. iron-session cookie is httpOnly and encrypted. No order details pass through the client bundle at any point.
Stripe webhook
Stripe-Signature header verified on every delivery. Replay-safe: idempotent creation means duplicates are no-ops.
Success Page — Code Quality
Inventory concurrency — ADR-002
docs/checkout/ADR-002-checkout-inventory-concurrency.md
Pattern 1 — Standard items: Pure OCC at payment
No inventory held during checkout. At payment: atomic Sanity transaction
checks stock > 0 and decrements in a single operation (OCC).
If check fails (race): "Another customer just purchased the last unit."
Rationale: ~81% cart abandonment in luxury/audio means pessimistic locking
starves real buyers. Collision probability negligible vs starvation risk.
Pattern 2 — Rare/flagship ($3k+, qty=1): Soft Reservation via Redis TTL
Redis key: soft-reserve:{productId}:{sessionId} TTL = 10 minutes
On completion: promote to hard Sanity decrement, delete Redis key.
On TTL expiry: auto-delete, item becomes available again.
Industry pattern: Fluent Commerce, Broadleaf, Microsoft Dynamics 365. Full ADR-002 on GitHub Server re-derives grand total (payment route)
app/api/checkout/payment-intent-session/route.ts
// Server re-derives grand total from live Sanity data — client total is untrusted
const products = await getBackendClient().fetch(
groq`*[_type == "product" && _id in $ids]{ _id, price_data { unit_amount } }`,
{ ids }
)
let subtotal = 0
for (const item of session.basket) {
const product = products.find(p => p._id === item.productId)
const unitPrice = product?.price_data?.unit_amount
if (!unitPrice || !Number.isFinite(unitPrice)) {
return NextResponse.json({ error: 'Invalid price' }, { status: 400 })
}
subtotal += unitPrice * item.quantity
}
const computedGrandTotal = Math.round(subtotal + session.shippingCost) View on GitHub Success Page — Diagrams
IDEMPOTENT ORDER CREATION
--------------------------
payment confirmed (Stripe)
|
+-----+-----------------+
| |
sync create Stripe webhook
(before redirect) (async delivery)
| |
+----------+------------+
| key = paymentIntentId
v
Sanity order doc
(first write wins;
second = no-op)
CROSS-DEVICE ACCESS
--------------------
/success page load
|
session.completedPaymentIntentId?
|
YES --> render full receipt
|
NO --> Sanity: query by
userId + paymentIntentId
|
found --> render receipt
|
not found --> pending state
CHECKOUT SYSTEM — SUMMARY
- Secure payments — server re-derives price, client total untrusted
- 5 funnel guards before any Stripe call
- Stripe-Signature verified on every webhook
- Idempotent order creation
- Sync + webhook, whichever arrives first wins
- Dual verification gate: session OR Sanity fallback
- Multi-carrier shipping from real product dimensions
- AlleKurier API: DPD, DHL, InPost
- Parcel calculator auto-splits at 25kg / 99,000cm³
- Inventory concurrency (ADR-002)
- OCC for standard items
- Redis TTL soft reservation for flagship ($3k+, qty=1)
- Address validation via Google Maps API
- 3-outcome: ACCEPT / FIX / CONFIRM
- PREMISE/SUB_PREMISE granularity required
Selected Work
01 Sang Logium — E-Commerce Platform
E-Commerce · Full StackA production e-commerce platform for audiophile equipment. Next.js 15, Stripe payments, Sanity CMS, full auth and order management.