feat(web): add buy-now checkout flow#317
Conversation
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.
Review or Edit in CodeSandboxOpen the branch in Web Editor • VS Code • Insiders |
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (7)
📝 WalkthroughWalkthroughThis PR implements a complete "Buy Now" to checkout flow: the backend exposes product IDs in the public API contract, the frontend adds navigation from product detail to a new checkout page, and the checkout page orchestrates address selection, order creation, and Paystack payment initialization and confirmation. ChangesBuy Now Checkout Implementation
Sequence Diagram(Included in the review stack artifact above) Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/backend/src/domains/commerce/product/product.service.ts`:
- Line 120: The public product contract exposes an `id` field but must use the
`*Id` naming convention: change the exposed property from `id` to `productId`
everywhere in this file (replace usages where objects are constructed with `id`
in public DTOs) and update the selection/mapping layers so DB `id` is still
selected (keep `id: true` in Prisma selects) but is mapped into the public shape
as `productId`; specifically update the projection/mapping helpers (e.g., any
function named like mapProductToPublicDto, getPublicProductFields or similar),
all places that build the public product object, and the call sites that expect
the public field name so types/tests remain consistent. Ensure only the public
API field name changes while the DB field `id` is preserved and mapped.
In `@apps/web/src/app/`(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx:
- Around line 332-338: The fallback logic in CheckoutClient for Paystack (the
block checking paystack.accessCode and paystack.reference) ignores an
authorizationUrl returned from initializePayment; update the code to first check
for and redirect to the authorizationUrl provided by the initializePayment
response (e.g., any authorizationUrl on the paystack/init result) before falling
back to order.authorizationUrl, using window.location.href to navigate and
returning immediately if found; keep the existing final throw if neither URL
exists.
- Around line 242-286: Replace the ad-hoc trimming/required checks in
handleSaveDraft with a Zod schema + react-hook-form: define a Zod schema for
NewAddressDraft (use z.preprocess or .transform to trim strings and coerce empty
line2/postalCode to undefined, and mark label, street, city, state as required),
wire useForm({ resolver: zodResolver(schema), defaultValues: EMPTY_DRAFT }) in
the component, replace draft state usage with form register/controlled inputs
and show formState.errors instead of draftError, and change handleSaveDraft to
be the form submit handler that builds the next addresses array, calls
saveAddresses, then uses form.reset(EMPTY_DRAFT) and
setShowNewAddressForm(false)/setSelectedAddressId(...) on success and sets
form-level error via setError or setFormError on failure while still toggling
setSavingAddress accordingly.
- Around line 108-183: Replace the three manual useEffect loaders inside
CheckoutClient that call fetchCheckoutProductByCode, fetchMe, and
fetchSavedAddresses with React Query useQuery hooks keyed by the project's
centralized queryKeys; specifically, remove the useEffect blocks that manage
cancelled flags and local loading/error state (the ones that call
fetchCheckoutProductByCode, fetchMe, and fetchSavedAddresses) and instead create
useQuery entries (e.g., useQuery(queryKeys.product(productCode), () =>
fetchCheckoutProductByCode(productCode), { enabled: !!productCode }) etc.) to
drive product, me, and addresses state; map query results/errors/status to the
existing setters (setProduct, setProductLoading/err state replaced by
query.status/error, setMe, setAddresses) and on successful addresses query
setSelectedAddressId to preferred?.id and setShowNewAddressForm when list is
empty; wire these useQuery keys into the centralized queryKeys module (create or
import queryKeys with functions like product(code), me(), addresses()) so other
components use the same keys.
In `@apps/web/src/lib/checkout.ts`:
- Around line 98-101: In saveAddresses (in apps/web/src/lib/checkout.ts) the
call uses api.post to "/users/me/addresses" but the backend controller defines
`@Put`("me/addresses"); change the HTTP verb from POST to PUT by replacing the
api.post invocation with api.put so the request matches the controller route and
payload shape (ensure the call still expects the same response type of {
deliveryAddresses: RawAddressEntry[] }).
In `@apps/web/src/lib/paystack.ts`:
- Around line 47-75: The loaderPromise stays rejected after any failure so
future calls never retry; update all rejection paths in the loader creation (the
Promise returned by loaderPromise in this module) to clear the cached
loaderPromise before rejecting—i.e. in the existing script error listener, in
script.onerror, and in the branches where PaystackPop/ctor is missing (both
existing load handler and script.onload) set loaderPromise = undefined (or null)
immediately before calling reject(new Error(...)); ensure you reference the
loaderPromise variable and the PAYSTACK_INLINE_SRC script handlers / PaystackPop
attach checks so subsequent calls can re-create the script and retry.
- Around line 94-99: The code currently coerces args.amountKobo to 0 on invalid
input which causes Paystack errors; update openPaystackInline to validate
args.amountKobo before calling popup.newTransaction: parse amount =
Number(args.amountKobo) and require Number.isFinite(amount) &&
Number.isInteger(amount) && amount > 0, and if the check fails throw or return a
rejected error (do not pass 0 to popup.newTransaction); reference the amount
variable, args.amountKobo, and the popup.newTransaction invocation when making
the change so invalid inputs are surfaced upstream instead of silently converted
to zero.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 050b08dc-0961-47a3-a721-eb5b23a3410b
📒 Files selected for processing (8)
apps/backend/src/domains/commerce/product/product.service.tsapps/web/src/app/(public)/p/[code]/ProductDetailClient.tsxapps/web/src/app/(public)/p/[code]/page.tsxapps/web/src/app/(shopper)/buyer/_components/ShopperShell.tsxapps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsxapps/web/src/app/(shopper)/buyer/checkout/[productId]/page.tsxapps/web/src/lib/checkout.tsapps/web/src/lib/paystack.ts
| 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; | ||
| }; | ||
| }, []); | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx"
if [ -f "$FILE" ]; then
echo "== File exists: $FILE =="
wc -l "$FILE"
echo
sed -n '1,220p' "$FILE" | nl -ba | sed -n '1,220p'
else
echo "File not found: $FILE"
exit 1
fiRepository: coded-devs/twizrr
Length of output: 293
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find React Query usage in the same directory
rg -n "use(Query|InfiniteQuery|Mutation)|useQuery|QueryClient|queryKeys|queryKey" apps/web/src/app/(shopper)/buyer/checkout/[productId] || trueRepository: coded-devs/twizrr
Length of output: 123
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate centralized queryKeys (likely in some queryKeys file)
rg -n "queryKeys" apps/web/src | head -n 50Repository: coded-devs/twizrr
Length of output: 43
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Identify the fetch functions used in the snippet
rg -n "fetchCheckoutProductByCode|fetchMe|fetchSavedAddresses" apps/web/src | head -n 50Repository: coded-devs/twizrr
Length of output: 1029
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check if there is an existing query hook for checkout product/me/addresses
rg -n "Checkout.*Product|fetchCheckoutProductByCode|get.*Checkout|SavedAddresses|fetchSavedAddresses|fetchMe" apps/web/src | head -n 80Repository: coded-devs/twizrr
Length of output: 2970
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check TanStack Query version / setup usage
rg -n "`@tanstack/react-query`|tanstack/react-query" apps/web/package.json apps/web/src -S || trueRepository: coded-devs/twizrr
Length of output: 257
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx"
echo "== useQuery/useMutation/queryKeys usage in CheckoutClient =="
rg -n "use(Query|Mutation|InfiniteQuery)|queryKeys|queryKey" "$FILE" || true
echo
echo "== Top imports / React Query references in CheckoutClient (lines 1-80) =="
sed -n '1,120p' "$FILE" | nl -ba | head -n 120Repository: coded-devs/twizrr
Length of output: 259
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== React Query provider =="
sed -n '1,200p' apps/web/src/components/providers/ReactQueryProvider.tsx | nl -ba | head -n 200Repository: coded-devs/twizrr
Length of output: 149
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Search for centralized queryKeys definitions in apps/web/src =="
rg -n "queryKeys|queryKey" apps/web/src | head -n 80Repository: coded-devs/twizrr
Length of output: 128
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Search for checkout-specific query hooks =="
rg -n "use(Checkout|SavedAddresses|Me)|checkout.*useQuery|useQuery.*checkout|savedAddresses.*useQuery|fetchSavedAddresses.*useQuery" apps/web/src | head -n 120Repository: coded-devs/twizrr
Length of output: 2727
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Identify whether queryKeys module exists =="
fd -t f "queryKeys*" apps/web/src || trueRepository: coded-devs/twizrr
Length of output: 108
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx"
echo "== First 120 lines (no nl) =="
sed -n '1,120p' "$FILE" | nl -baRepository: coded-devs/twizrr
Length of output: 152
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# nl missing in this environment; print with line numbers using awk instead
FILE="apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx"
echo "== First 120 lines (awk line numbers) =="
awk '{printf "%5d\t%s\n", NR, $0}' "$FILE" | sed -n '1,120p'Repository: coded-devs/twizrr
Length of output: 4184
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find whether CheckoutClient uses React Query at all
FILE="apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx"
rg -n "useQuery|useMutation|queryKeys|queryKey|QueryClient|QueryClientProvider" "$FILE" || trueRepository: coded-devs/twizrr
Length of output: 43
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show React Query provider implementation (for how queryKeys are structured)
FILE="apps/web/src/components/providers/ReactQueryProvider.tsx"
echo "== ReactQueryProvider.tsx =="
sed -n '1,200p' "$FILE" | catRepository: coded-devs/twizrr
Length of output: 589
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find how queryKeys are used elsewhere; search broadly but limit output
rg -n -i "querykeys|querykey" apps/web/src | head -n 80Repository: coded-devs/twizrr
Length of output: 43
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find any useQuery usage patterns and inspect nearby lines
rg -n "useQuery\\(" apps/web/src | head -n 80Repository: coded-devs/twizrr
Length of output: 43
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Search for TanStack Query usage patterns even if it's wrapped
rg -n "`@tanstack/react-query`|QueryClientProvider|useMutation\\(|useQuery\\(" apps/web/src | head -n 120Repository: coded-devs/twizrr
Length of output: 337
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== TanStack Query usage in apps/web/src =="
rg -n "`@tanstack/react-query`|useQuery\\(|useMutation\\(|QueryClient|queryClient|QueryClientProvider" apps/web/src | head -n 200Repository: coded-devs/twizrr
Length of output: 556
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== queryKeys/queryKey definitions in apps/web/src =="
rg -n -i "querykeys|querykey" apps/web/src | head -n 200Repository: coded-devs/twizrr
Length of output: 114
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Files with query/key in name (case-insensitive) =="
fd -t f -i "*query*key*" apps/web/src || trueRepository: coded-devs/twizrr
Length of output: 424
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== CheckoutClient has any React Query hooks? =="
rg -n "useQuery\\(|useMutation\\(|queryKeys|queryKey|QueryClient|QueryClientProvider" apps/web/src/app/'(shopper)'/buyer/checkout/'[productId]'/CheckoutClient.tsx || trueRepository: coded-devs/twizrr
Length of output: 109
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== How fetch* functions are used (checkout/me/addresses) =="
rg -n "fetchCheckoutProductByCode\\(|fetchMe\\(|fetchSavedAddresses\\(" apps/web/src/app/'(shopper)'/buyer/checkout/'[productId]'/CheckoutClient.tsxRepository: coded-devs/twizrr
Length of output: 292
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Any useQuery usage across apps/web/src ? =="
rg -n "useQuery\\(" apps/web/src | head -n 50 || true
echo
echo "== Any useMutation usage across apps/web/src ? =="
rg -n "useMutation\\(" apps/web/src | head -n 50 || true
echo
echo "== Any tanstack/react-query imports besides provider? =="
rg -n "`@tanstack/react-query`" apps/web/src | head -n 50Repository: coded-devs/twizrr
Length of output: 349
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check for any queryKeys-like pattern in repository at all (not just apps/web/src)
rg -n -i "querykeys|querykey" apps/web | head -n 50 || trueRepository: coded-devs/twizrr
Length of output: 748
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Find actual queryKeys.ts files =="
fd -t f -i "queryKeys.ts" apps/web || true
fd -t f -i "*queryKeys*.ts" apps/web || true
fd -t f -i "*queryKey*.ts" apps/web || trueRepository: coded-devs/twizrr
Length of output: 703
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Search for queryKeys usage in TS/TSX (exclude AGENTS.md) =="
rg -n -i "querykeys|querykey" apps/web --glob "*.{ts,tsx}" || trueRepository: coded-devs/twizrr
Length of output: 124
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Search for TanStack Query hooks used anywhere (exclude provider only) =="
rg -n "use(Query|Mutation|InfiniteQuery|SuspenseQuery)\\(" apps/web --glob "*.{ts,tsx}" || trueRepository: coded-devs/twizrr
Length of output: 137
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Search for useQueryClient / invalidateQueries / setQueryData usage =="
rg -n "`@tanstack/react-query`|useQueryClient|invalidateQueries\\(|cancelQueries\\(|setQueryData\\(" apps/web --glob "*.{ts,tsx}" || trueRepository: coded-devs/twizrr
Length of output: 266
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Inspect queryKeys.ts if it exists =="
for f in $(fd -t f -i "queryKeys.ts" apps/web 2>/dev/null || true); do
echo "--- $f ---"
sed -n '1,220p' "$f"
doneRepository: coded-devs/twizrr
Length of output: 101
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Search for queryKeys.ts with glob (fd) =="
fd -t f --glob '*queryKeys*.ts' apps/web || true
fd -t f --glob '*queryKey*.ts' apps/web || true
echo
echo "== Search for 'queryKeys' string in TS/TSX under apps/web/src (exclude AGENTS.md) =="
rg -n "queryKeys" apps/web/src --glob "*.{ts,tsx}" || true
echo
echo "== Search for 'export const queryKeys' across apps/web/src =="
rg -n "export const queryKeys" apps/web/src || true
echo
echo "== Inspect AGENTS.md references around the mentioned lines =="
sed -n '600,700p' apps/web/AGENTS.md | catRepository: coded-devs/twizrr
Length of output: 3555
Refactor CheckoutClient data loading to React Query + centralized queryKeys.
- In
apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx(lines 108–183), product (fetchCheckoutProductByCode), user (fetchMe), and addresses (fetchSavedAddresses) are fetched viauseEffect+ localuseStateloading/error handling instead ofuseQuery. - Migrate these to
useQueryentries driven by query status, using the project’s centralizedqueryKeys(noqueryKeysimplementation was found underapps/web/src; wire into the existing module or introduce it).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/app/`(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx
around lines 108 - 183, Replace the three manual useEffect loaders inside
CheckoutClient that call fetchCheckoutProductByCode, fetchMe, and
fetchSavedAddresses with React Query useQuery hooks keyed by the project's
centralized queryKeys; specifically, remove the useEffect blocks that manage
cancelled flags and local loading/error state (the ones that call
fetchCheckoutProductByCode, fetchMe, and fetchSavedAddresses) and instead create
useQuery entries (e.g., useQuery(queryKeys.product(productCode), () =>
fetchCheckoutProductByCode(productCode), { enabled: !!productCode }) etc.) to
drive product, me, and addresses state; map query results/errors/status to the
existing setters (setProduct, setProductLoading/err state replaced by
query.status/error, setMe, setAddresses) and on successful addresses query
setSelectedAddressId to preferred?.id and setShowNewAddressForm when list is
empty; wire these useQuery keys into the centralized queryKeys module (create or
import queryKeys with functions like product(code), me(), addresses()) so other
components use the same keys.
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx"
echo "== Basic info =="
wc -l "$FILE"
sed -n '200,330p' "$FILE"
echo
echo "== Does file use react-hook-form / zod? =="
rg -n "react-hook-form|useForm\\(|zod|z\\.object|zodResolver" "$FILE" || true
echo
echo "== Find address draft schema usage in file (if any) =="
rg -n "draft|DeliveryAddress|saveAddresses|EMPTY_DRAFT|handleSaveDraft|setDraftError" "$FILE"
echo
echo "== Search for Zod schema definitions in the file =="
rg -n "const .*Schema|schema *=|zodResolver\\(|z\\.object" "$FILE" || true
echo
echo "== Check if there is another handle/submit using RHF in this component =="
rg -n "handleSubmit\\(|<form|FormProvider|Controller" "$FILE" || trueRepository: coded-devs/twizrr
Length of output: 6261
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx"
echo "== Search for Zod schema imports/usages (broader) =="
rg -n "from ['\"]zod['\"]|zodResolver|\\bZod\\b|\\bSchema\\b" "$FILE" || true
echo
echo "== Search for React Hook Form usage (broader) =="
rg -n "react-hook-form|useForm\\b|FormProvider|Controller|useController|watch\\b|handleSubmit\\b" "$FILE" || true
echo
echo "== Inspect address form JSX section =="
sed -n '430,620p' "$FILE"Repository: coded-devs/twizrr
Length of output: 7739
Replace manual address validation with React Hook Form + Zod schema (apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx, lines 242-286)
The “Add a new address” flow validates by trimming and required-checking fields inside handleSaveDraft (setting draftError), but this component doesn’t use React Hook Form or any Zod schema for validation. Move the trimming/required rules into a Zod schema and wire the inputs through React Hook Form so validation/errors come from the schema rather than ad-hoc state checks.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/app/`(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx
around lines 242 - 286, Replace the ad-hoc trimming/required checks in
handleSaveDraft with a Zod schema + react-hook-form: define a Zod schema for
NewAddressDraft (use z.preprocess or .transform to trim strings and coerce empty
line2/postalCode to undefined, and mark label, street, city, state as required),
wire useForm({ resolver: zodResolver(schema), defaultValues: EMPTY_DRAFT }) in
the component, replace draft state usage with form register/controlled inputs
and show formState.errors instead of draftError, and change handleSaveDraft to
be the form submit handler that builds the next addresses array, calls
saveAddresses, then uses form.reset(EMPTY_DRAFT) and
setShowNewAddressForm(false)/setSelectedAddressId(...) on success and sets
form-level error via setError or setFormError on failure while still toggling
setSavingAddress accordingly.
- 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.
- DeliveryCodeInput: stop collapsing positional slots when the user fills a non-final box first. The wire-format string is now always 6 chars with " " for empty slots; positions are preserved. OrderDetailClient extracts the digit-only payload (cleanOtp via /\D/g) for validation and submission, so a 6-char value with placeholder spaces no longer passes the length check. - OrderTimeline: terminal-state (CANCELLED / DISPUTE / REFUND_PENDING) freeze step is now derived from the order's event history rather than hardcoded to step 1. A cancel-at-PENDING_PAYMENT freezes at "Order Placed"; a dispute raised after dispatch freezes at "Dispatched"; etc. - OrderDetailClient: confirm-delivery and cancel error paths no longer forward backend err.message to the UI. Both surface generic copy and the OTP helper text above the input already gives the buyer context for failed attempts. - OrderDetailClient: tracking effect now has an explicit try/catch as defense in depth. fetchOrderTracking already swallows errors and returns [], but this guards against future regressions. Intentionally NOT actioned: - Migrating order/tracking data loading to TanStack Query (PR #322 comments 1, 6) and migrating OTP validation to Zod + RHF (comment 3): the apps/web codebase has no centralized queryKeys module and no @hookform/resolvers dependency. Same posture as PR #317 — these are cross-cutting refactors that need their own task to establish the patterns. - Switching `params: Promise<{ id: string }>` to a synchronous shape (comment 5): W-09a and W-09b already use the async-params pattern successfully on Next 14.2.x; reverting would diverge from established routes. The Promise form is also forward-compatible with Next 15 where async params become required.
- DeliveryCodeInput: stop collapsing positional slots when the user fills a non-final box first. The wire-format string is now always 6 chars with " " for empty slots; positions are preserved. OrderDetailClient extracts the digit-only payload (cleanOtp via /\D/g) for validation and submission, so a 6-char value with placeholder spaces no longer passes the length check. - OrderTimeline: terminal-state (CANCELLED / DISPUTE / REFUND_PENDING) freeze step is now derived from the order's event history rather than hardcoded to step 1. A cancel-at-PENDING_PAYMENT freezes at "Order Placed"; a dispute raised after dispatch freezes at "Dispatched"; etc. - OrderDetailClient: confirm-delivery and cancel error paths no longer forward backend err.message to the UI. Both surface generic copy and the OTP helper text above the input already gives the buyer context for failed attempts. - OrderDetailClient: tracking effect now has an explicit try/catch as defense in depth. fetchOrderTracking already swallows errors and returns [], but this guards against future regressions. Intentionally NOT actioned: - Migrating order/tracking data loading to TanStack Query (PR #322 comments 1, 6) and migrating OTP validation to Zod + RHF (comment 3): the apps/web codebase has no centralized queryKeys module and no @hookform/resolvers dependency. Same posture as PR #317 — these are cross-cutting refactors that need their own task to establish the patterns. - Switching `params: Promise<{ id: string }>` to a synchronous shape (comment 5): W-09a and W-09b already use the async-params pattern successfully on Next 14.2.x; reverting would diverge from established routes. The Promise form is also forward-compatible with Next 15 where async params become required.
What does this PR do?
Implements W-09a — Checkout Flow: Product → Buy Now. This adds a focused standalone checkout flow from product detail, including delivery address selection, payment method selection, escrow-protected order summary, direct order creation, and Paystack inline checkout.
Task
W-09a — Checkout Flow: Product → Buy Now
What changed
/buyer/checkout/[productId]checkout routeGET /users/me/addressesPUT /users/me/addresses?sourcedProductId=idfor direct checkoutBackend endpoints verified
POST /orders/directPOST /ordersPOST /orders/sourced/:sourcedProductIdPOST /payments/initializeGET /payments/verify/:referenceGET /users/meGET /users/me/addressesPUT /users/me/addressesGET /products/:codeFiles changed
apps/backend/src/domains/commerce/product/product.service.tsapps/web/src/app/(public)/p/[code]/page.tsxapps/web/src/app/(public)/p/[code]/ProductDetailClient.tsxapps/web/src/app/(shopper)/buyer/_components/ShopperShell.tsxapps/web/src/app/(shopper)/buyer/checkout/[productId]/page.tsxapps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsxapps/web/src/lib/checkout.tsapps/web/src/lib/paystack.tsBackend route-contract note
GET /products/:codedid not previously expose the product UUID, butCreateDirectOrderDto.productIdrequires it.To avoid inventing a frontend contract, this PR adds
idto the existing public product response in the smallest possible way:PRODUCT_PUBLIC_SELECTPublicProductResponsetoPublicResponseNo new backend endpoint was added and no DTO contract was changed.
Sourced product behavior
?sourcedProductId=POST /orders/sourced/:sourcedProductIdPayment behavior
POST /payments/initialize/buyer/orders/[orderId]as a temporary handoff until W-09b landsDeferred / follow-ups
?sourcedProductId=Verification
Web:
pnpm run lint— PASSnpx tsc --noEmit— PASSpnpm run build— PASSBackend:
npx tsc --noEmit— PASSpnpm run lint— PASSType of change
Area affected
How to test this
/buyer/checkout/[productId].Expected result:
sourcedProductIdis present.Pre-commit checklist
console.logleft in production code.envfiles committedanytypes addedScreenshots
Required for UI changes. Add checkout desktop/mobile screenshots before requesting final review.
Notes for reviewer
This PR intentionally does not implement W-09b. After payment success, it routes to
/buyer/orders/[orderId]as the temporary handoff path. DEV C will replace/extend this with the Order Confirmed Screen after W-09a merges.Summary by CodeRabbit