From e37888821ee4cc60849da3218c282ca014755247 Mon Sep 17 00:00:00 2001 From: SAHEED2010 Date: Sat, 23 May 2026 10:53:06 +1300 Subject: [PATCH 1/2] feat(web): add buy-now checkout flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements W-09a — Buy Now checkout from product detail with escrow trust banner, saved-address selection plus inline add-new flow, Paystack inline payment, and a single-product order summary. Routes /buyer/checkout/[productId] renders standalone (no nav chrome) via ShopperShell, blocks store-mode identities, and reuses POST /orders/direct + POST /payments/initialize. Sourced-product flow is wired behind ?sourcedProductId= for future Quick Buy entry points. Public product response now exposes id so the checkout page can call createDirectOrder. --- .../commerce/product/product.service.ts | 3 + .../(public)/p/[code]/ProductDetailClient.tsx | 9 +- apps/web/src/app/(public)/p/[code]/page.tsx | 1 + .../buyer/_components/ShopperShell.tsx | 19 + .../checkout/[productId]/CheckoutClient.tsx | 953 ++++++++++++++++++ .../buyer/checkout/[productId]/page.tsx | 33 + apps/web/src/lib/checkout.ts | 409 ++++++++ apps/web/src/lib/paystack.ts | 105 ++ 8 files changed, 1530 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx create mode 100644 apps/web/src/app/(shopper)/buyer/checkout/[productId]/page.tsx create mode 100644 apps/web/src/lib/checkout.ts create mode 100644 apps/web/src/lib/paystack.ts diff --git a/apps/backend/src/domains/commerce/product/product.service.ts b/apps/backend/src/domains/commerce/product/product.service.ts index 0a1dac9..878cc92 100644 --- a/apps/backend/src/domains/commerce/product/product.service.ts +++ b/apps/backend/src/domains/commerce/product/product.service.ts @@ -117,6 +117,7 @@ const PRODUCT_OWNER_SELECT = { } satisfies Prisma.ProductSelect; const PRODUCT_PUBLIC_SELECT = { + id: true, productCode: true, name: true, title: true, @@ -277,6 +278,7 @@ export interface OwnerProductResponse { } export interface PublicProductResponse { + id: string; productCode: string | null; name: string; title: string | null; @@ -1355,6 +1357,7 @@ export class ProductService { product: PublicProductRecord, ): PublicProductResponse { return { + id: product.id, productCode: product.productCode, name: product.name, title: product.title, diff --git a/apps/web/src/app/(public)/p/[code]/ProductDetailClient.tsx b/apps/web/src/app/(public)/p/[code]/ProductDetailClient.tsx index ba7c9c8..26cd185 100644 --- a/apps/web/src/app/(public)/p/[code]/ProductDetailClient.tsx +++ b/apps/web/src/app/(public)/p/[code]/ProductDetailClient.tsx @@ -3,6 +3,7 @@ import { useState, useMemo } from "react"; import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { ChevronLeft, ChevronRight, @@ -208,6 +209,7 @@ interface ProductDetailClientProps { export function ProductDetailClient({ product }: ProductDetailClientProps) { const { toast } = useToast(); + const router = useRouter(); // ── Variant state ────────────────────────────────────────────────────────── const uniqueColors = useMemo( @@ -316,8 +318,11 @@ export function ProductDetailClient({ product }: ProductDetailClientProps) { function handleBuyNow() { if (!validateVariantAndProceed()) return; - // Checkout is not available in W-06 — coming in W-09a - toast("Checkout coming soon", { variant: "default" }); + const params = new URLSearchParams(); + if (product.productCode) params.set("code", product.productCode); + if (selectedVariant?.id) params.set("variantId", selectedVariant.id); + const qs = params.toString(); + router.push(`/buyer/checkout/${product.id}${qs ? `?${qs}` : ""}`); } // ── Render ───────────────────────────────────────────────────────────────── diff --git a/apps/web/src/app/(public)/p/[code]/page.tsx b/apps/web/src/app/(public)/p/[code]/page.tsx index d2e88ce..66fd409 100644 --- a/apps/web/src/app/(public)/p/[code]/page.tsx +++ b/apps/web/src/app/(public)/p/[code]/page.tsx @@ -46,6 +46,7 @@ export interface ProductDetail { } export interface PublicProduct { + id: string; productCode: string | null; name: string; title: string | null; diff --git a/apps/web/src/app/(shopper)/buyer/_components/ShopperShell.tsx b/apps/web/src/app/(shopper)/buyer/_components/ShopperShell.tsx index d97f94a..ba3bd7c 100644 --- a/apps/web/src/app/(shopper)/buyer/_components/ShopperShell.tsx +++ b/apps/web/src/app/(shopper)/buyer/_components/ShopperShell.tsx @@ -8,9 +8,28 @@ interface ShopperShellProps { children: ReactNode; } +// Routes inside /buyer/* that should render without the 3-column social shell. +// Checkout is the only focus-mode route in MVP — see TWIZRR_WEB_TASKS W-09a +// and apps/web/AGENTS.md ("checkout/[productId]/page.tsx ← focus mode"). +const STANDALONE_ROUTE_PREFIXES = ["/buyer/checkout"]; + +function isStandaloneRoute(pathname: string): boolean { + return STANDALONE_ROUTE_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(prefix + "/"), + ); +} + export function ShopperShell({ children }: ShopperShellProps) { const pathname = usePathname(); + if (isStandaloneRoute(pathname)) { + return ( +
+ {children} +
+ ); + } + return ( (null); + const [productError, setProductError] = useState(null); + const [productLoading, setProductLoading] = useState(true); + + const [me, setMe] = useState(null); + + const [addresses, setAddresses] = useState([]); + const [addressesLoading, setAddressesLoading] = useState(true); + const [selectedAddressId, setSelectedAddressId] = useState( + null, + ); + const [showNewAddressForm, setShowNewAddressForm] = useState(false); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [savingAddress, setSavingAddress] = useState(false); + const [draftError, setDraftError] = useState(null); + + const [paymentMethod, setPaymentMethod] = + useState("PAYSTACK_INLINE"); + + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + useEffect(() => { + let cancelled = false; + async function load() { + if (!productCode) { + setProductError( + "Missing product reference. Please return to the product page and try again.", + ); + setProductLoading(false); + return; + } + try { + const fetched = await fetchCheckoutProductByCode(productCode); + if (cancelled) return; + if (!fetched) { + setProductError("Product could not be loaded."); + } else if (fetched.id !== productId) { + // Code/UUID mismatch — refuse to proceed rather than trust either. + setProductError( + "Product details do not match the checkout link. Please retry from the product page.", + ); + } else { + setProduct(fetched); + } + } catch { + if (!cancelled) setProductError("Product could not be loaded."); + } finally { + if (!cancelled) setProductLoading(false); + } + } + load(); + return () => { + cancelled = true; + }; + }, [productCode, productId]); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const user = await fetchMe(); + if (!cancelled) setMe(user); + } catch { + if (!cancelled) setMe(null); + } + } + load(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const list = await fetchSavedAddresses(); + if (cancelled) return; + setAddresses(list); + const preferred = list.find((a) => a.isDefault) ?? list[0] ?? null; + if (preferred?.id) setSelectedAddressId(preferred.id); + if (list.length === 0) setShowNewAddressForm(true); + } catch { + if (!cancelled) { + setAddresses([]); + setShowNewAddressForm(true); + } + } finally { + if (!cancelled) setAddressesLoading(false); + } + } + load(); + return () => { + cancelled = true; + }; + }, []); + + // ── Derived display ────────────────────────────────────────────────────────── + const selectedVariant: CheckoutProductVariant | null = useMemo(() => { + if (!product || !preselectedVariantId) return null; + return product.variants.find((v) => v.id === preselectedVariantId) ?? null; + }, [product, preselectedVariantId]); + + const variantSummary = useMemo(() => { + if (!selectedVariant) return null; + const parts = [selectedVariant.colorName, selectedVariant.sizeName].filter( + (part): part is string => + typeof part === "string" && part.trim().length > 0, + ); + return parts.length > 0 ? parts.join(" · ") : selectedVariant.label; + }, [selectedVariant]); + + const unitPriceKobo: string | null = useMemo(() => { + if (selectedVariant?.priceOverrideKobo) { + return selectedVariant.priceOverrideKobo; + } + return product?.retailPriceKobo ?? null; + }, [product, selectedVariant]); + + const subtotalKobo = useMemo( + () => multiplyKobo(unitPriceKobo, QUANTITY), + [unitPriceKobo], + ); + + const thumbnailUrl = useMemo(() => { + if (!product) return null; + if (product.images.length > 0) { + const defaultImage = product.images.find((img) => img.isDefault); + return (defaultImage ?? product.images[0]).url; + } + return product.imageUrl; + }, [product]); + + const selectedAddress = useMemo(() => { + if (!selectedAddressId) return null; + return addresses.find((a) => a.id === selectedAddressId) ?? null; + }, [addresses, selectedAddressId]); + + const canConfirm = + !!product && + !!me && + !!selectedAddress && + paymentMethod === "PAYSTACK_INLINE" && + !submitting && + !isStoreMode && + unitPriceKobo !== null; + + // ── Address form ───────────────────────────────────────────────────────────── + function updateDraft( + key: K, + value: NewAddressDraft[K], + ) { + setDraft((prev) => ({ ...prev, [key]: value })); + } + + async function handleSaveDraft() { + const trimmed: NewAddressDraft = { + label: draft.label.trim(), + street: draft.street.trim(), + line2: draft.line2.trim(), + city: draft.city.trim(), + state: draft.state.trim(), + postalCode: draft.postalCode.trim(), + }; + if (!trimmed.label || !trimmed.street || !trimmed.city || !trimmed.state) { + setDraftError("Label, street, city and state are required."); + return; + } + setDraftError(null); + setSavingAddress(true); + try { + const next: DeliveryAddress[] = [ + ...addresses.map((address) => ({ ...address, isDefault: false })), + { + label: trimmed.label, + street: trimmed.street, + line2: trimmed.line2 || undefined, + city: trimmed.city, + state: trimmed.state, + postalCode: trimmed.postalCode || undefined, + isDefault: true, + }, + ]; + const saved = await saveAddresses(next); + setAddresses(saved); + const newest = saved.find((a) => a.isDefault) ?? saved[saved.length - 1]; + if (newest?.id) setSelectedAddressId(newest.id); + setShowNewAddressForm(false); + setDraft(EMPTY_DRAFT); + toast("Address saved", { variant: "success" }); + } catch (err) { + const message = + typeof (err as { message?: string })?.message === "string" + ? (err as { message: string }).message + : "Could not save address. Please try again."; + setDraftError(message); + } finally { + setSavingAddress(false); + } + } + + // ── Confirm + Paystack popup ───────────────────────────────────────────────── + const handleConfirm = useCallback(async () => { + if (!product || !me || !selectedAddress || !unitPriceKobo) return; + if (!PAYSTACK_PUBLIC_KEY) { + setSubmitError( + "Payment is not configured. Please contact support and try again later.", + ); + return; + } + setSubmitError(null); + setSubmitting(true); + try { + const deliveryAddressLine = formatAddressLine(selectedAddress); + const deliveryDetails: Record = { + label: selectedAddress.label, + street: selectedAddress.street, + city: selectedAddress.city, + state: selectedAddress.state, + ...(selectedAddress.line2 ? { line2: selectedAddress.line2 } : {}), + ...(selectedAddress.postalCode + ? { postalCode: selectedAddress.postalCode } + : {}), + }; + + const order = + isSourced && sourcedProductId + ? await createSourcedOrder(sourcedProductId, { + quantity: QUANTITY, + deliveryAddress: deliveryAddressLine, + deliveryDetails, + }) + : await createDirectOrder({ + productId: product.id, + quantity: QUANTITY, + deliveryAddress: deliveryAddressLine, + deliveryDetails, + }); + + // Re-initialize the payment to retrieve access_code + reference for the + // inline popup (the order creation only returns authorizationUrl). + // POST /payments/initialize is idempotent by orderId — it re-uses the + // same Paystack reference instead of starting a fresh charge. + const paystack = await initializePayment(order.orderId); + + if (!paystack.accessCode && !paystack.reference) { + // Last-resort fallback: backend gave us only a redirect URL. + if (order.authorizationUrl) { + window.location.href = order.authorizationUrl; + return; + } + throw new Error("Could not start payment. Please try again."); + } + + await openPaystackInline({ + publicKey: PAYSTACK_PUBLIC_KEY, + accessCode: paystack.accessCode || undefined, + reference: paystack.reference || undefined, + email: me.email, + amountKobo: order.totalAmountKobo, + onSuccess: () => { + // After Paystack succeeds, the webhook transitions PAID server-side. + // W-09b will own the post-checkout confirmation screen. Until that + // route ships, send buyers to the order tracking destination. + // Handoff URL is structured so DEV C can swap it without touching + // checkout: /buyer/orders/${orderId}. + toast("Payment received. Confirming your order…", { + variant: "success", + }); + router.push(`/buyer/orders/${order.orderId}`); + }, + onCancel: () => { + setSubmitting(false); + toast("Payment cancelled. You can retry when you're ready.", { + variant: "warning", + }); + }, + onError: (message) => { + setSubmitting(false); + setSubmitError(message); + }, + }); + } catch (err) { + const message = + typeof (err as { message?: string })?.message === "string" + ? (err as { message: string }).message + : "Something went wrong. Please try again."; + setSubmitError(message); + setSubmitting(false); + } + }, [ + isSourced, + me, + product, + router, + selectedAddress, + sourcedProductId, + toast, + unitPriceKobo, + ]); + + // ── Render ─────────────────────────────────────────────────────────────────── + return ( +
+ {/* Focused header */} +
+
+ +

+ Checkout +

+ + +
+
+ +
+ {/* Escrow trust banner — visible throughout checkout */} +
+
+ + {/* Store-mode block: never let a store identity buy */} + {isStoreMode && ( +
+

+ Checkout is only available in shopping mode +

+

+ Switch back to shopping mode to complete this purchase. +

+
+ +
+
+ )} + + {/* Step progress */} + + + {/* Step 1 — Delivery address */} + + + {/* Step 2 — Payment method */} + + + {/* Step 3 — Order summary */} + + + {submitError && ( +

+ {submitError} +

+ )} +
+ + {/* Sticky confirm bar */} +
+
+ +

+ By confirming, you agree that funds remain in escrow until you + confirm delivery. +

+
+
+
+ ); +} + +// ─── Subcomponents ────────────────────────────────────────────────────────────── + +function StepProgress({ + steps, + current, +}: { + steps: string[]; + current: number; +}) { + return ( +
    + {steps.map((label, index) => { + const step = index + 1; + const active = step === current; + const done = step < current; + return ( +
  1. + + {done ? ( + + + {label} + + {step !== steps.length && ( +
  2. + ); + })} +
+ ); +} + +function Section({ + icon, + title, + description, + children, +}: { + icon: React.ReactNode; + title: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+ {icon} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {children} +
+ ); +} + +function AddressCard({ + address, + selected, + onSelect, +}: { + address: DeliveryAddress; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function PaymentOption({ + selected, + onSelect, + title, + subtitle, + icon, +}: { + selected: boolean; + onSelect: () => void; + title: string; + subtitle: string; + icon: React.ReactNode; +}) { + return ( + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + + {value} + +
+ ); +} diff --git a/apps/web/src/app/(shopper)/buyer/checkout/[productId]/page.tsx b/apps/web/src/app/(shopper)/buyer/checkout/[productId]/page.tsx new file mode 100644 index 0000000..a22e5c0 --- /dev/null +++ b/apps/web/src/app/(shopper)/buyer/checkout/[productId]/page.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { CheckoutClient } from "./CheckoutClient"; + +export const metadata: Metadata = { + title: "Checkout — twizrr", + robots: { index: false, follow: false }, +}; + +interface CheckoutPageProps { + params: Promise<{ productId: string }>; + searchParams: Promise<{ + code?: string; + variantId?: string; + sourcedProductId?: string; + }>; +} + +export default async function CheckoutPage({ + params, + searchParams, +}: CheckoutPageProps) { + const { productId } = await params; + const { code, variantId, sourcedProductId } = await searchParams; + + return ( + + ); +} diff --git a/apps/web/src/lib/checkout.ts b/apps/web/src/lib/checkout.ts new file mode 100644 index 0000000..845aab4 --- /dev/null +++ b/apps/web/src/lib/checkout.ts @@ -0,0 +1,409 @@ +/** + * Checkout helpers for W-09a — Buy Now flow. + * + * Endpoint contracts verified from: + * apps/backend/src/modules/order/order.controller.ts + * apps/backend/src/modules/order/dto/create-direct-order.dto.ts + * apps/backend/src/modules/order/dto/create-sourced-order.dto.ts + * apps/backend/src/domains/money/payment/payment.controller.ts + * apps/backend/src/domains/money/payment/dto/initialize-payment.dto.ts + * apps/backend/src/domains/users/user/user.controller.ts + * + * Money rule: all amounts crossing the wire are BigInt-serialised digit + * strings in kobo. Conversion to Naira happens only at the display layer + * via formatKobo() — never in business logic on the client. + */ + +import { api } from "@/lib/api"; + +// ─── Delivery addresses ──────────────────────────────────────────────────────── + +export interface DeliveryAddress { + id?: string; + label: string; + street: string; + line2?: string; + city: string; + state: string; + postalCode?: string; + isDefault?: boolean; +} + +interface RawAddressEntry { + id?: unknown; + label?: unknown; + street?: unknown; + line2?: unknown; + city?: unknown; + state?: unknown; + postalCode?: unknown; + isDefault?: unknown; +} + +interface AddressesResponse { + deliveryAddresses: RawAddressEntry[] | null; +} + +function toStringOrUndefined(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function toStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function normalizeAddress(raw: RawAddressEntry): DeliveryAddress | null { + const label = toStringOrEmpty(raw.label).trim(); + const street = toStringOrEmpty(raw.street).trim(); + const city = toStringOrEmpty(raw.city).trim(); + const state = toStringOrEmpty(raw.state).trim(); + if (!label || !street || !city || !state) return null; + return { + id: toStringOrUndefined(raw.id), + label, + street, + city, + state, + line2: toStringOrUndefined(raw.line2), + postalCode: toStringOrUndefined(raw.postalCode), + isDefault: raw.isDefault === true, + }; +} + +export async function fetchSavedAddresses(): Promise { + const res = await api.get("/users/me/addresses"); + const raw = Array.isArray(res?.deliveryAddresses) + ? res.deliveryAddresses + : []; + return raw + .map(normalizeAddress) + .filter((address): address is DeliveryAddress => address !== null); +} + +export async function saveAddresses( + addresses: DeliveryAddress[], +): Promise { + const payload = { + addresses: addresses.map((address) => ({ + ...(address.id ? { id: address.id } : {}), + label: address.label, + street: address.street, + ...(address.line2 ? { line2: address.line2 } : {}), + city: address.city, + state: address.state, + ...(address.postalCode ? { postalCode: address.postalCode } : {}), + isDefault: address.isDefault === true, + })), + }; + const res = await api.post<{ deliveryAddresses: RawAddressEntry[] }>( + "/users/me/addresses", + payload, + ); + return (res?.deliveryAddresses ?? []) + .map(normalizeAddress) + .filter((address): address is DeliveryAddress => address !== null); +} + +export function formatAddressLine(address: DeliveryAddress): string { + const parts = [address.street, address.line2, address.city, address.state] + .map((segment) => (segment ?? "").trim()) + .filter((segment) => segment.length > 0); + return parts.join(", "); +} + +// ─── Auth: current user (for email used by Paystack) ─────────────────────────── + +export interface CheckoutUser { + id: string; + email: string; + displayName: string | null; +} + +interface RawMeResponse { + id?: unknown; + email?: unknown; + displayName?: unknown; +} + +export async function fetchMe(): Promise { + try { + const res = await api.get("/users/me"); + if (typeof res?.id !== "string" || typeof res?.email !== "string") { + return null; + } + return { + id: res.id, + email: res.email, + displayName: typeof res.displayName === "string" ? res.displayName : null, + }; + } catch { + return null; + } +} + +// ─── Order creation ──────────────────────────────────────────────────────────── + +export interface CreateOrderResult { + orderId: string; + authorizationUrl: string | null; + totalAmountKobo: string; +} + +interface RawCreateOrderResponse { + orderId?: unknown; + authorizationUrl?: unknown; + totalAmountKobo?: unknown; +} + +function normalizeCreateOrder( + res: RawCreateOrderResponse, +): CreateOrderResult | null { + if (typeof res?.orderId !== "string") return null; + const totalAmountKobo = + typeof res.totalAmountKobo === "string" + ? res.totalAmountKobo + : typeof res.totalAmountKobo === "number" + ? String(res.totalAmountKobo) + : "0"; + return { + orderId: res.orderId, + authorizationUrl: + typeof res.authorizationUrl === "string" ? res.authorizationUrl : null, + totalAmountKobo, + }; +} + +export interface DirectOrderPayload { + productId: string; + quantity: number; + deliveryAddress: string; + deliveryDetails?: Record; + deliveryMethod?: "STORE_DELIVERY" | "PLATFORM_LOGISTICS"; + idempotencyKey?: string; +} + +export async function createDirectOrder( + payload: DirectOrderPayload, +): Promise { + const res = await api.post("/orders/direct", payload); + const result = normalizeCreateOrder(res); + if (!result) throw new Error("Order creation returned an unexpected shape"); + return result; +} + +export interface SourcedOrderPayload { + quantity: number; + deliveryAddress: string; + deliveryDetails?: Record; + deliveryMethod?: "STORE_DELIVERY" | "PLATFORM_LOGISTICS"; + idempotencyKey?: string; +} + +export async function createSourcedOrder( + sourcedProductId: string, + payload: SourcedOrderPayload, +): Promise { + const res = await api.post( + `/orders/sourced/${encodeURIComponent(sourcedProductId)}`, + payload, + ); + const result = normalizeCreateOrder(res); + if (!result) throw new Error("Order creation returned an unexpected shape"); + return result; +} + +// ─── Payment initialization ──────────────────────────────────────────────────── + +export interface PaystackInitResult { + authorizationUrl: string; + accessCode: string; + reference: string; +} + +interface RawPaystackInitResponse { + authorization_url?: unknown; + access_code?: unknown; + reference?: unknown; + paymentReference?: unknown; +} + +export async function initializePayment( + orderId: string, +): Promise { + const res = await api.post("/payments/initialize", { + orderId, + }); + const authorizationUrl = + typeof res?.authorization_url === "string" ? res.authorization_url : ""; + const accessCode = + typeof res?.access_code === "string" ? res.access_code : ""; + const reference = + typeof res?.reference === "string" + ? res.reference + : typeof res?.paymentReference === "string" + ? res.paymentReference + : ""; + if (!authorizationUrl && !accessCode && !reference) { + throw new Error("Payment initialization returned an unexpected shape"); + } + return { authorizationUrl, accessCode, reference }; +} + +// ─── Product fetch (for checkout summary) ────────────────────────────────────── + +export interface CheckoutProductVariant { + id: string; + label: string; + colorName: string | null; + sizeName: string | null; + priceOverrideKobo: string | null; +} + +export interface CheckoutProductImage { + id: string; + url: string; + altText: string | null; + isDefault: boolean; +} + +export interface CheckoutProduct { + id: string; + productCode: string | null; + name: string; + status: string; + retailPriceKobo: string | null; + imageUrl: string | null; + hasVariants: boolean; + variants: CheckoutProductVariant[]; + images: CheckoutProductImage[]; + store: { + handle: string | null; + name: string | null; + isOpen: boolean; + }; +} + +interface RawCheckoutVariant { + id?: unknown; + variantLabel?: unknown; + colorName?: unknown; + sizeName?: unknown; + priceOverrideKobo?: unknown; + isActive?: unknown; +} + +interface RawCheckoutImage { + id?: unknown; + url?: unknown; + altText?: unknown; + isDefault?: unknown; +} + +interface RawCheckoutProduct { + id?: unknown; + productCode?: unknown; + name?: unknown; + status?: unknown; + retailPriceKobo?: unknown; + imageUrl?: unknown; + hasVariants?: unknown; + variants?: unknown; + images?: unknown; + store?: { + handle?: unknown; + name?: unknown; + isOpen?: unknown; + }; +} + +function normalizeCheckoutProduct( + raw: RawCheckoutProduct, +): CheckoutProduct | null { + if (typeof raw?.id !== "string" || typeof raw?.name !== "string") return null; + + const variants: CheckoutProductVariant[] = Array.isArray(raw.variants) + ? raw.variants + .map((entry): CheckoutProductVariant | null => { + const v = entry as RawCheckoutVariant; + if (typeof v?.id !== "string") return null; + return { + id: v.id, + label: + typeof v.variantLabel === "string" ? v.variantLabel : "Variant", + colorName: typeof v.colorName === "string" ? v.colorName : null, + sizeName: typeof v.sizeName === "string" ? v.sizeName : null, + priceOverrideKobo: + typeof v.priceOverrideKobo === "string" + ? v.priceOverrideKobo + : null, + }; + }) + .filter( + (variant): variant is CheckoutProductVariant => variant !== null, + ) + : []; + + const images: CheckoutProductImage[] = Array.isArray(raw.images) + ? raw.images + .map((entry): CheckoutProductImage | null => { + const i = entry as RawCheckoutImage; + if (typeof i?.id !== "string" || typeof i?.url !== "string") + return null; + return { + id: i.id, + url: i.url, + altText: typeof i.altText === "string" ? i.altText : null, + isDefault: i.isDefault === true, + }; + }) + .filter((image): image is CheckoutProductImage => image !== null) + : []; + + return { + id: raw.id, + productCode: typeof raw.productCode === "string" ? raw.productCode : null, + name: raw.name, + status: typeof raw.status === "string" ? raw.status : "UNKNOWN", + retailPriceKobo: + typeof raw.retailPriceKobo === "string" ? raw.retailPriceKobo : null, + imageUrl: typeof raw.imageUrl === "string" ? raw.imageUrl : null, + hasVariants: raw.hasVariants === true, + variants, + images, + store: { + handle: typeof raw.store?.handle === "string" ? raw.store.handle : null, + name: typeof raw.store?.name === "string" ? raw.store.name : null, + isOpen: raw.store?.isOpen === true, + }, + }; +} + +/** + * Fetch a product for the checkout summary. The public lookup + * (domains/commerce/product/product.controller.ts) is keyed by productCode + * (TWZ-XXXXXX), not by UUID — the checkout page receives the code from a + * `?code=` query param and uses it here. + */ +export async function fetchCheckoutProductByCode( + productCode: string, +): Promise { + try { + const res = await api.get( + `/products/${encodeURIComponent(productCode)}`, + ); + return normalizeCheckoutProduct(res); + } catch { + return null; + } +} + +// ─── Pure math helpers (kobo as BigInt — never Float for totals) ─────────────── + +export function multiplyKobo(kobo: string | null, quantity: number): bigint { + if (kobo === null) return 0n; + try { + return BigInt(kobo) * BigInt(Math.max(1, Math.trunc(quantity))); + } catch { + return 0n; + } +} diff --git a/apps/web/src/lib/paystack.ts b/apps/web/src/lib/paystack.ts new file mode 100644 index 0000000..2773c69 --- /dev/null +++ b/apps/web/src/lib/paystack.ts @@ -0,0 +1,105 @@ +/** + * Paystack inline popup loader. + * + * Loads https://js.paystack.co/v2/inline.js once and exposes a typed handle + * to PaystackPop.newTransaction({ accessCode, onSuccess, onCancel, onError }). + * + * Public key is read from NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY (configured in + * apps/web/.env.example) — only the publishable key is exposed to the + * browser, never the secret. + */ + +const PAYSTACK_INLINE_SRC = "https://js.paystack.co/v2/inline.js"; + +interface PaystackTransactionRequest { + key?: string; + accessCode?: string; + email?: string; + amount?: number; + reference?: string; + onSuccess?: (transaction: { reference: string }) => void; + onCancel?: () => void; + onError?: (error: { message?: string }) => void; +} + +interface PaystackHandle { + newTransaction(request: PaystackTransactionRequest): unknown; +} + +interface PaystackPopConstructor { + new (): PaystackHandle; +} + +interface PaystackGlobals { + PaystackPop?: PaystackPopConstructor; +} + +let loaderPromise: Promise | null = null; + +export async function loadPaystackInline(): Promise { + if (typeof window === "undefined") { + throw new Error("Paystack inline can only load in the browser"); + } + const globals = window as unknown as PaystackGlobals; + if (globals.PaystackPop) return globals.PaystackPop; + if (loaderPromise) return loaderPromise; + + loaderPromise = new Promise((resolve, reject) => { + const existing = document.querySelector( + `script[src="${PAYSTACK_INLINE_SRC}"]`, + ); + if (existing) { + existing.addEventListener("load", () => { + const ctor = (window as unknown as PaystackGlobals).PaystackPop; + if (ctor) resolve(ctor); + else reject(new Error("Paystack inline failed to attach")); + }); + existing.addEventListener("error", () => + reject(new Error("Paystack inline script failed to load")), + ); + return; + } + const script = document.createElement("script"); + script.src = PAYSTACK_INLINE_SRC; + script.async = true; + script.onload = () => { + const ctor = (window as unknown as PaystackGlobals).PaystackPop; + if (ctor) resolve(ctor); + else reject(new Error("Paystack inline failed to attach")); + }; + script.onerror = () => + reject(new Error("Paystack inline script failed to load")); + document.head.appendChild(script); + }); + + return loaderPromise; +} + +export interface OpenPaystackArgs { + publicKey: string; + accessCode?: string; + email: string; + amountKobo: bigint | string | number; + reference?: string; + onSuccess: (reference: string) => void; + onCancel?: () => void; + onError?: (message: string) => void; +} + +export async function openPaystackInline( + args: OpenPaystackArgs, +): Promise { + const Pop = await loadPaystackInline(); + const popup = new Pop(); + const amount = Number(args.amountKobo); + popup.newTransaction({ + key: args.publicKey, + email: args.email, + amount: Number.isFinite(amount) ? amount : 0, + accessCode: args.accessCode, + reference: args.reference, + onSuccess: (tx) => args.onSuccess(tx.reference), + onCancel: args.onCancel, + onError: (err) => args.onError?.(err.message ?? "Payment failed"), + }); +} From 1ae84c8535c0b25856f80f9d4eef304603c115fd Mon Sep 17 00:00:00 2001 From: SAHEED2010 Date: Sat, 23 May 2026 11:18:14 +1300 Subject: [PATCH 2/2] fix(web): address CodeRabbit review on PR #317 - Rename public product `id` -> `productId` to follow project naming convention; DB column stays `id` and is mapped in toPublicResponse. - Switch saveAddresses to PUT (matches @Put("me/addresses") backend contract) and add api.put helper to lib/api.ts. - In checkout confirm, prefer authorizationUrl from /payments/initialize over the order-creation response when falling back to a redirect. - Reset cached Paystack loader promise on failure so transient script load errors can be retried instead of poisoning subsequent attempts. - Validate amountKobo as a positive integer in openPaystackInline; surface invalid input as an error rather than silently coercing to 0. CodeRabbit comments intentionally not actioned: - Migrating CheckoutClient loaders to React Query: no centralized queryKeys module exists in apps/web yet; introducing one is a cross-cutting follow-up, not part of W-09a. - React Hook Form + Zod for the inline address draft: @hookform/resolvers is not installed; the form is a five-field inline draft already trimmed and required-checked. Will revisit when the project adopts RHF+zodResolver more broadly. --- .../commerce/product/product.service.ts | 4 +-- .../(public)/p/[code]/ProductDetailClient.tsx | 2 +- apps/web/src/app/(public)/p/[code]/page.tsx | 2 +- .../checkout/[productId]/CheckoutClient.tsx | 13 +++++---- apps/web/src/lib/api.ts | 2 ++ apps/web/src/lib/checkout.ts | 11 ++++---- apps/web/src/lib/paystack.ts | 27 ++++++++++++++++--- 7 files changed, 44 insertions(+), 17 deletions(-) diff --git a/apps/backend/src/domains/commerce/product/product.service.ts b/apps/backend/src/domains/commerce/product/product.service.ts index 878cc92..1e46934 100644 --- a/apps/backend/src/domains/commerce/product/product.service.ts +++ b/apps/backend/src/domains/commerce/product/product.service.ts @@ -278,7 +278,7 @@ export interface OwnerProductResponse { } export interface PublicProductResponse { - id: string; + productId: string; productCode: string | null; name: string; title: string | null; @@ -1357,7 +1357,7 @@ export class ProductService { product: PublicProductRecord, ): PublicProductResponse { return { - id: product.id, + productId: product.id, productCode: product.productCode, name: product.name, title: product.title, diff --git a/apps/web/src/app/(public)/p/[code]/ProductDetailClient.tsx b/apps/web/src/app/(public)/p/[code]/ProductDetailClient.tsx index 26cd185..f34ad6b 100644 --- a/apps/web/src/app/(public)/p/[code]/ProductDetailClient.tsx +++ b/apps/web/src/app/(public)/p/[code]/ProductDetailClient.tsx @@ -322,7 +322,7 @@ export function ProductDetailClient({ product }: ProductDetailClientProps) { if (product.productCode) params.set("code", product.productCode); if (selectedVariant?.id) params.set("variantId", selectedVariant.id); const qs = params.toString(); - router.push(`/buyer/checkout/${product.id}${qs ? `?${qs}` : ""}`); + router.push(`/buyer/checkout/${product.productId}${qs ? `?${qs}` : ""}`); } // ── Render ───────────────────────────────────────────────────────────────── diff --git a/apps/web/src/app/(public)/p/[code]/page.tsx b/apps/web/src/app/(public)/p/[code]/page.tsx index 66fd409..5f76e92 100644 --- a/apps/web/src/app/(public)/p/[code]/page.tsx +++ b/apps/web/src/app/(public)/p/[code]/page.tsx @@ -46,7 +46,7 @@ export interface ProductDetail { } export interface PublicProduct { - id: string; + productId: string; productCode: string | null; name: string; title: string | null; diff --git a/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx b/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx index 904194c..9cba6c8 100644 --- a/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx +++ b/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx @@ -120,7 +120,7 @@ export function CheckoutClient({ if (cancelled) return; if (!fetched) { setProductError("Product could not be loaded."); - } else if (fetched.id !== productId) { + } else if (fetched.productId !== productId) { // Code/UUID mismatch — refuse to proceed rather than trust either. setProductError( "Product details do not match the checkout link. Please retry from the product page.", @@ -317,7 +317,7 @@ export function CheckoutClient({ deliveryDetails, }) : await createDirectOrder({ - productId: product.id, + productId: product.productId, quantity: QUANTITY, deliveryAddress: deliveryAddressLine, deliveryDetails, @@ -330,9 +330,12 @@ export function CheckoutClient({ const paystack = await initializePayment(order.orderId); if (!paystack.accessCode && !paystack.reference) { - // Last-resort fallback: backend gave us only a redirect URL. - if (order.authorizationUrl) { - window.location.href = order.authorizationUrl; + // Last-resort fallback: redirect to a hosted Paystack page. + // Prefer the URL returned by /payments/initialize (more current); + // fall back to the order-creation response. + const redirectUrl = paystack.authorizationUrl || order.authorizationUrl; + if (redirectUrl) { + window.location.href = redirectUrl; return; } throw new Error("Could not start payment. Please try again."); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index a88598a..354d1de 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -31,5 +31,7 @@ async function request(path: string, init: RequestInit = {}): Promise { export const api = { post: (path: string, body: unknown): Promise => request(path, { method: "POST", body: JSON.stringify(body) }), + put: (path: string, body: unknown): Promise => + request(path, { method: "PUT", body: JSON.stringify(body) }), get: (path: string): Promise => request(path, { method: "GET" }), }; diff --git a/apps/web/src/lib/checkout.ts b/apps/web/src/lib/checkout.ts index 845aab4..bfec417 100644 --- a/apps/web/src/lib/checkout.ts +++ b/apps/web/src/lib/checkout.ts @@ -95,7 +95,7 @@ export async function saveAddresses( isDefault: address.isDefault === true, })), }; - const res = await api.post<{ deliveryAddresses: RawAddressEntry[] }>( + const res = await api.put<{ deliveryAddresses: RawAddressEntry[] }>( "/users/me/addresses", payload, ); @@ -267,7 +267,7 @@ export interface CheckoutProductImage { } export interface CheckoutProduct { - id: string; + productId: string; productCode: string | null; name: string; status: string; @@ -300,7 +300,7 @@ interface RawCheckoutImage { } interface RawCheckoutProduct { - id?: unknown; + productId?: unknown; productCode?: unknown; name?: unknown; status?: unknown; @@ -319,7 +319,8 @@ interface RawCheckoutProduct { function normalizeCheckoutProduct( raw: RawCheckoutProduct, ): CheckoutProduct | null { - if (typeof raw?.id !== "string" || typeof raw?.name !== "string") return null; + if (typeof raw?.productId !== "string" || typeof raw?.name !== "string") + return null; const variants: CheckoutProductVariant[] = Array.isArray(raw.variants) ? raw.variants @@ -360,7 +361,7 @@ function normalizeCheckoutProduct( : []; return { - id: raw.id, + productId: raw.productId, productCode: typeof raw.productCode === "string" ? raw.productCode : null, name: raw.name, status: typeof raw.status === "string" ? raw.status : "UNKNOWN", diff --git a/apps/web/src/lib/paystack.ts b/apps/web/src/lib/paystack.ts index 2773c69..426c43c 100644 --- a/apps/web/src/lib/paystack.ts +++ b/apps/web/src/lib/paystack.ts @@ -72,7 +72,14 @@ export async function loadPaystackInline(): Promise { document.head.appendChild(script); }); - return loaderPromise; + // Clear the cached promise on failure so a subsequent attempt can retry + // (e.g. a transient network failure on first load — buyer hits Confirm again). + try { + return await loaderPromise; + } catch (error) { + loaderPromise = null; + throw error; + } } export interface OpenPaystackArgs { @@ -89,13 +96,27 @@ export interface OpenPaystackArgs { export async function openPaystackInline( args: OpenPaystackArgs, ): Promise { + // Paystack v2 Inline requires amount to be a positive integer (kobo). + // Surface invalid input as an error instead of silently sending 0 — a + // zero amount turns into an opaque "valid amount" rejection from Paystack + // that hides upstream payment-initialization bugs. + let parsed: bigint; + try { + parsed = BigInt(args.amountKobo); + } catch { + throw new Error("Invalid payment amount"); + } + if (parsed <= 0n || parsed > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error("Invalid payment amount"); + } + const amount = Number(parsed); + const Pop = await loadPaystackInline(); const popup = new Pop(); - const amount = Number(args.amountKobo); popup.newTransaction({ key: args.publicKey, email: args.email, - amount: Number.isFinite(amount) ? amount : 0, + amount, accessCode: args.accessCode, reference: args.reference, onSuccess: (tx) => args.onSuccess(tx.reference),