feat(web): add shopper profile settings and saved pages#326
Conversation
Implements W-12. Closes the buyer-side account loop with five new
authenticated routes; the existing ProfileMenuDrawer's links now resolve
end-to-end. No backend changes.
Routes
- /buyer/profile — own profile header + 4 tabs (Gists / Replies /
Photos / Twizz). Tabs render polished empty
states; feed-list-by-user endpoints are
documented as backend follow-ups.
- /buyer/settings — Account (displayName / username / bio / avatar),
Contact (email + phone, read-only with
verification chips), Security (link to existing
/forgot-password), Preferences (Notifications
marked Soon).
- /buyer/measurements — body (bust / waist / hips / height) + shoe
(EU size / foot length) measurements; delivery
addresses with add / edit / set-default /
remove. Reuses fetchSavedAddresses /
saveAddresses already wired in lib/checkout.ts.
- /buyer/saved — placeholder empty state; backend has the
SavedPost model and individual save/unsave
routes but no list endpoint. Documented as a
backend follow-up.
- /buyer/wishlist — grid of starred products with optimistic
remove via POST /wishlist/toggle/:productId.
Backend endpoints used (all verified, no changes)
- GET /users/me
- PUT /users/me (handles USERNAME_LOCK_ACTIVE +
USERNAME_TAKEN with friendly inline
copy)
- PUT /users/me/measurements (partial saves; empty fields omitted)
- GET /users/me/addresses
- PUT /users/me/addresses
- GET /wishlist
- POST /wishlist/toggle/:productId
- POST /upload/image (multipart, uploadType=PROFILE_PHOTO;
rejects moderationStatus=BLOCKED)
Avatar upload
- New uploadAvatar() helper in lib/user.ts mirrors the existing
uploadProductImage pattern in lib/product-listing.ts: raw fetch (so
the browser sets the multipart boundary), credentials: include, unwraps
the { success, data } envelope, throws a typed error on
MODERATION_BLOCKED, and surfaces a friendly inline copy on Settings.
Dropship safety
- The wishlist response carries the full Product record (the backend
uses `include: { product: {} }` without a select). lib/wishlist.ts
never spreads — the normalizer reads only the safe whitelist
(productId, productCode, name, imageUrl, retailPriceKobo,
compareAtPriceKobo, savedAt, inStock, store.name,
store.verificationTier). Extra fields the backend includes
(dropshipperPriceKobo, wholesalePriceKobo, sourcedProductId, etc.)
are silently ignored; they never reach the DOM. A row is dropped
only when its essential whitelist fields can't be safely extracted
(no logging — fails closed).
Library additions
- lib/user.ts: fetchOwnProfile, updateMe, updateMeasurements,
uploadAvatar, OwnProfile / Measurements / UpdateProfileError types.
Re-exports fetchMe / fetchSavedAddresses / saveAddresses /
formatAddressLine / DeliveryAddress / CheckoutUser from lib/checkout.ts
so W-12 components import from one canonical path. Existing W-09a /
W-11 imports from lib/checkout.ts keep working — no refactor of merged
code.
- lib/wishlist.ts: fetchWishlist, toggleWishlist, WishlistItem type +
the strict-whitelist normalizer described above.
Auth / store mode
- Auth is already handled by middleware (/buyer/* redirects to /login).
- Profile / settings / measurements / saved / wishlist are personal
account pages — accessible in both shopping and store modes; no
useMode gate. Buy actions stay out of these surfaces.
Out of scope (documented as follow-ups)
- GET endpoint to list a user's saved posts (post.controller.ts has the
individual POST/DELETE save routes and the Prisma model, but no list).
- GET endpoint to list a user's own posts (Gists / Replies / Photos).
- Wishlist response selecting only safe Product fields server-side
(defense-in-depth — frontend whitelist already handles it for W-12).
- Email / phone change flows, notifications preferences, Twizz tab
content, follow lists — all stay out of MVP per spec.
UI
- Lucide icons only, Tailwind only, design-token colors, mobile-first
with ≥44px touch targets, skeletons not spinners, no emojis.
Checks
- pnpm run lint ✓
- npx tsc --noEmit ✓
- pnpm run build ✓
/buyer/{profile,settings,measurements,saved,wishlist} all registered.
Review or Edit in CodeSandboxOpen the branch in Web Editor • VS Code • Insiders |
|
Warning Review limit reached
Your plan currently allows 1 review/hour. Refill in 39 minutes and 25 seconds. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more review capacity refills, 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 trial, open-source, and free plans. In all cases, review capacity refills continuously over time. 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 (2)
📝 WalkthroughWalkthroughAdds buyer account features including profile editing with avatar upload, body measurements and delivery address management, public profile viewing, and wishlist/saved posts interfaces. Establishes typed API helpers for profile/measurement/avatar/wishlist operations with defensive field normalization and error handling. ChangesBuyer Account Management
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested reviewers
🚥 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: 8
🧹 Nitpick comments (5)
apps/web/src/app/(shopper)/buyer/saved/SavedClient.tsx (1)
18-20: ⚡ Quick winUse Syne font class for headings instead of Cabinet.
Both heading elements are styled with
font-cabinet; this should use the Syne heading token per frontend typography rules.Proposed diff
- <h1 className="font-cabinet text-2xl font-bold text-[var(--color-espresso)]"> + <h1 className="font-syne text-2xl font-bold text-[var(--color-espresso)]"> Saved posts </h1> @@ - <h2 className="font-cabinet text-lg font-semibold text-[var(--color-espresso)]"> + <h2 className="font-syne text-lg font-semibold text-[var(--color-espresso)]"> Saved posts coming soon </h2>As per coding guidelines, "Use Syne font (weight 700) for all headings via next/font/google".
Also applies to: 33-35
🤖 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/saved/SavedClient.tsx around lines 18 - 20, The heading elements in the SavedClient component are using the wrong font token; replace the "font-cabinet" class with the Syne heading token class (e.g., "font-syne" or whatever the established Syne class name is in your project) on the <h1> instances in SavedClient.tsx (both the main title and the secondary heading around lines noted) so headings use Syne weight 700 per typography rules; update the className strings in the SavedClient component accordingly.apps/web/src/app/(shopper)/buyer/profile/ProfileClient.tsx (1)
80-82: ⚡ Quick winUse Syne typography for profile heading.
The main heading is styled with
font-cabinet, but heading typography is expected to use Syne.As per coding guidelines, "Use Syne font (weight 700) for all headings via next/font/google."
🤖 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/profile/ProfileClient.tsx around lines 80 - 82, The profile heading currently uses the "font-cabinet" class; update the H1 in ProfileClient (the element rendering {displayName}) to use the Syne heading font (weight 700) per the typography guideline—replace the font-cabinet class with the Syne font class provided by your next/font/google setup (or import/apply the Syne 700 class used for other headings) so all headings consistently use Syne 700.apps/web/src/app/(shopper)/buyer/measurements/MeasurementsClient.tsx (1)
326-328: ⚡ Quick winSwitch heading styles to Syne for heading elements.
Heading nodes are rendered with
font-cabinetinstead of the required Syne heading style.As per coding guidelines, "Use Syne font (weight 700) for all headings via next/font/google."
Also applies to: 341-343, 377-379
🤖 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/measurements/MeasurementsClient.tsx around lines 326 - 328, The heading elements in MeasurementsClient (the <h1> at the top and the other heading nodes around the given ranges) are using the wrong utility class `font-cabinet`; update those heading elements to use the Syne heading style by replacing `font-cabinet` with the Syne font class (e.g., `font-syne`) and ensure the 700 weight is applied (add `font-bold` or the equivalent weight utility if your design system requires it) so headings use Syne weight 700; locate these changes inside the MeasurementsClient component where the heading nodes are rendered and adjust all instances (including the other headings at the noted ranges).apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx (1)
199-201: ⚡ Quick winApply Syne heading typography for heading elements.
Top-level/section headings are using
font-cabinetinstead of the required Syne heading style.As per coding guidelines, "Use Syne font (weight 700) for all headings via next/font/google."
Also applies to: 376-378
🤖 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/settings/SettingsClient.tsx around lines 199 - 201, The Settings page headings are using the wrong font ("font-cabinet"); update the h1 (and the other heading instances noted around lines 376-378) to use the project's Syne heading style at weight 700: import the Syne font from next/font/google in SettingsClient.tsx (or use the shared syne class export if your project provides one) and replace the "font-cabinet" class on the heading elements with the Syne class (e.g., use the syne.className or project "font-syne" utility) so all top-level/section headings render with Syne 700.apps/web/src/app/(shopper)/buyer/wishlist/WishlistClient.tsx (1)
25-51: 💤 Low valueDuplicate fetch logic between
loadcallback anduseEffect.The
loadfunction (lines 25-33) and the inlinerunfunction inuseEffect(lines 37-45) contain nearly identical fetch logic. Theloadcallback is only used for the retry button, whileuseEffecthas its own implementation.Consider reusing
loadin the effect to reduce duplication:♻️ Suggested refactor
const load = useCallback(async () => { + setState("loading"); try { const rows = await fetchWishlist(); setItems(rows); setState("ready"); } catch { setState("error"); } }, []); useEffect(() => { - let cancelled = false; - async function run() { - try { - const rows = await fetchWishlist(); - if (cancelled) return; - setItems(rows); - setState("ready"); - } catch { - if (!cancelled) setState("error"); - } - } - run(); - return () => { - cancelled = true; - }; + load(); }, [load]);Note: This removes the cancellation guard, which is fine here since React 18's automatic batching and the component being unmounted would discard the state updates anyway. If strict cancellation is needed, consider using
AbortControllerpassed to the fetch.🤖 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/wishlist/WishlistClient.tsx around lines 25 - 51, Refactor to remove the duplicated fetch logic by calling the existing load callback from the useEffect instead of defining run: replace the inline async run() that calls fetchWishlist, setItems and setState with a call to load(), and remove the cancelled flag and run/return cleanup (or if you need cancellation keep an AbortController and pass it into fetchWishlist). Ensure load remains the single place handling try/catch and setState so fetchWishlist, setItems and setState are only used in load.
🤖 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/web/src/app/`(shopper)/buyer/measurements/MeasurementsClient.tsx:
- Around line 148-171: Replace the manual useEffect loading in
MeasurementsClient.tsx with React Query: create centralized query keys (e.g.,
["profile"] and ["addresses"]), use useQuery (or useQueries) to fetch
fetchOwnProfile and fetchSavedAddresses, and derive local state by mapping
query.data to setSavedMeasurements/setMeasurementsForm (via measurementsToForm)
and setAddresses; handle loading/error by reading query.statuss instead of
setState, and switch any server writes to useMutation with proper invalidation
of the ["profile"] / ["addresses"] keys so cached data updates instead of manual
Promise.all and cancelled flags.
- Around line 61-73: The current manual parsing/validation in
formToMeasurementsPayload (and similar logic around the measurement/address
form) must be replaced by a Zod schema and React Hook Form integration: define a
Zod schema for MeasurementForm/Measurements that uses z.preprocess or
z.string().transform to parse/validate numeric fields (ensuring finite,
non-negative numbers) and address fields with the required rules; wire that
schema into useForm via zodResolver so formState.isValid controls submit; remove
manual Number.parseFloat/Number.isFinite checks and instead call schema.parse or
rely on RHF's handleSubmit to get a typed, validated payload (you can replace
formToMeasurementsPayload with schema.parse or a simple cast from the parsed
output) and update the submit handler to use the validated, numeric Measurements
object returned by RHF rather than hand-rolled validation.
In `@apps/web/src/app/`(shopper)/buyer/profile/ProfileClient.tsx:
- Around line 29-49: The current useEffect-based loader (load/cancelled) in
ProfileClient should be replaced with a React Query useQuery that calls
fetchOwnProfile so the app uses centralized queryKeys and caching; remove the
effect and instead call useQuery with a descriptive key (e.g. ['ownProfile'] or
similar), pass fetchOwnProfile as the queryFn, and move the state updates into
onSuccess (call setProfile(fetched) and setState('ready')) and onError
(setState('error')); ensure you keep any necessary options (staleTime,
refetchOnWindowFocus) consistent with app conventions and reference the existing
identifiers fetchOwnProfile, setProfile, and setState when wiring the query.
In `@apps/web/src/app/`(shopper)/buyer/settings/SettingsClient.tsx:
- Around line 76-97: Replace the manual useEffect loading flow with TanStack
Query: create a useQuery keyed like ["profile","own"] that calls fetchOwnProfile
and onSuccess calls setProfile and setForm(profileToForm(...)) and sets the
local state (replace setState("ready") / setState("error") with query status
handling), and use a useMutation for profile updates that invalidates/refetches
the ["profile","own"] query; ensure you remove the cancelled flag and load()
function, reference fetchOwnProfile, profileToForm, setProfile, setForm, and the
current local state setter (setState) when wiring query/mutation success/error
handlers so UI reflects loading/error/ready from React Query.
- Around line 107-113: The avatar upload flow lacks a client-side 5MB size
check: in handleAvatarPick (and the other avatar handler around the later block)
validate file.size <= 5 * 1024 * 1024 before setting setUploadingAvatar(true) or
calling uploadAvatar, and if it exceeds the limit call setAvatarError with a
clear message and return early; ensure the same check is applied wherever
uploadAvatar(file) is invoked to prevent oversized uploads from being attempted.
- Around line 99-191: The form currently uses manual state and validation
(functions update, handleUsernameBlur, buildPayload, handleSave, and FormState)
— migrate this to React Hook Form + a Zod schema: define a Zod schema matching
FormState/OwnProfile, initialize useForm with zodResolver and defaultValues from
profileToForm(profile), replace update/handleUsernameBlur by useForm's setValue
(ensure username is trimmed and lowercased on blur and triggers validation),
wire inputs via register, move buildPayload logic into the handleSubmit onSubmit
(or a small helper) to produce payload, call updateMe(payload) inside onSubmit
and use setError or formState.errors for validation errors (e.g.,
USERNAME_TAKEN/USERNAME_LOCK_ACTIVE map to setError on username), and keep
handleAvatarPick but call form.setValue("profilePhotoUrl", result.url) after
upload and clear file input; ensure saving state and toasts remain unchanged.
In `@apps/web/src/lib/user.ts`:
- Around line 190-197: The current loop over payload drops null values so
callers cannot explicitly clear saved measurements; change the filtered record
to allow null (e.g., Record<string, number | null>) and update the loop that
builds filtered from payload to include entries when value is either a finite
number (typeof value === "number" && Number.isFinite(value)) or value === null,
while still skipping undefined and non-numeric values so explicit nulls are sent
to the backend to clear fields.
- Around line 37-55: The exported OwnProfile interface uses non-conforming field
names; rename the public properties to frontend-safe names (change id -> userId,
dateOfBirth -> dateOfBirthAt, memberSince -> memberSinceAt) and keep other
fields (e.g., lastUsernameChangedAt) unchanged, then update the
normalization/mapping layer that transforms wire data into OwnProfile (the
function/module that constructs/mapping to OwnProfile) to read the incoming wire
fields (id, dateOfBirth, memberSince) and assign them to the new userId,
dateOfBirthAt, memberSinceAt properties so all ID fields end with "Id" and all
date/time fields end with "At".
---
Nitpick comments:
In `@apps/web/src/app/`(shopper)/buyer/measurements/MeasurementsClient.tsx:
- Around line 326-328: The heading elements in MeasurementsClient (the <h1> at
the top and the other heading nodes around the given ranges) are using the wrong
utility class `font-cabinet`; update those heading elements to use the Syne
heading style by replacing `font-cabinet` with the Syne font class (e.g.,
`font-syne`) and ensure the 700 weight is applied (add `font-bold` or the
equivalent weight utility if your design system requires it) so headings use
Syne weight 700; locate these changes inside the MeasurementsClient component
where the heading nodes are rendered and adjust all instances (including the
other headings at the noted ranges).
In `@apps/web/src/app/`(shopper)/buyer/profile/ProfileClient.tsx:
- Around line 80-82: The profile heading currently uses the "font-cabinet"
class; update the H1 in ProfileClient (the element rendering {displayName}) to
use the Syne heading font (weight 700) per the typography guideline—replace the
font-cabinet class with the Syne font class provided by your next/font/google
setup (or import/apply the Syne 700 class used for other headings) so all
headings consistently use Syne 700.
In `@apps/web/src/app/`(shopper)/buyer/saved/SavedClient.tsx:
- Around line 18-20: The heading elements in the SavedClient component are using
the wrong font token; replace the "font-cabinet" class with the Syne heading
token class (e.g., "font-syne" or whatever the established Syne class name is in
your project) on the <h1> instances in SavedClient.tsx (both the main title and
the secondary heading around lines noted) so headings use Syne weight 700 per
typography rules; update the className strings in the SavedClient component
accordingly.
In `@apps/web/src/app/`(shopper)/buyer/settings/SettingsClient.tsx:
- Around line 199-201: The Settings page headings are using the wrong font
("font-cabinet"); update the h1 (and the other heading instances noted around
lines 376-378) to use the project's Syne heading style at weight 700: import the
Syne font from next/font/google in SettingsClient.tsx (or use the shared syne
class export if your project provides one) and replace the "font-cabinet" class
on the heading elements with the Syne class (e.g., use the syne.className or
project "font-syne" utility) so all top-level/section headings render with Syne
700.
In `@apps/web/src/app/`(shopper)/buyer/wishlist/WishlistClient.tsx:
- Around line 25-51: Refactor to remove the duplicated fetch logic by calling
the existing load callback from the useEffect instead of defining run: replace
the inline async run() that calls fetchWishlist, setItems and setState with a
call to load(), and remove the cancelled flag and run/return cleanup (or if you
need cancellation keep an AbortController and pass it into fetchWishlist).
Ensure load remains the single place handling try/catch and setState so
fetchWishlist, setItems and setState are only used in load.
🪄 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: ab56ab6f-4376-4aa1-97ac-92cec1dc6e33
📒 Files selected for processing (12)
apps/web/src/app/(shopper)/buyer/measurements/MeasurementsClient.tsxapps/web/src/app/(shopper)/buyer/measurements/page.tsxapps/web/src/app/(shopper)/buyer/profile/ProfileClient.tsxapps/web/src/app/(shopper)/buyer/profile/page.tsxapps/web/src/app/(shopper)/buyer/saved/SavedClient.tsxapps/web/src/app/(shopper)/buyer/saved/page.tsxapps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsxapps/web/src/app/(shopper)/buyer/settings/page.tsxapps/web/src/app/(shopper)/buyer/wishlist/WishlistClient.tsxapps/web/src/app/(shopper)/buyer/wishlist/page.tsxapps/web/src/lib/user.tsapps/web/src/lib/wishlist.ts
| function formToMeasurementsPayload(form: MeasurementForm) { | ||
| const payload: Partial<Measurements> = {}; | ||
| for (const [key, raw] of Object.entries(form) as [ | ||
| keyof MeasurementForm, | ||
| string, | ||
| ][]) { | ||
| if (raw.trim() === "") continue; | ||
| const parsed = Number.parseFloat(raw); | ||
| if (!Number.isFinite(parsed) || parsed < 0) continue; | ||
| payload[key] = parsed; | ||
| } | ||
| return payload; | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Move measurements/address validation to RHF + Zod schemas.
Validation/parsing and submit control are currently hand-rolled; this route should use schema-based validation and RHF form state.
As per coding guidelines, "Use React Hook Form with Zod for form validation" and "All frontend form validation must use Zod schemas."
Also applies to: 122-129, 265-297
🤖 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/measurements/MeasurementsClient.tsx around
lines 61 - 73, The current manual parsing/validation in
formToMeasurementsPayload (and similar logic around the measurement/address
form) must be replaced by a Zod schema and React Hook Form integration: define a
Zod schema for MeasurementForm/Measurements that uses z.preprocess or
z.string().transform to parse/validate numeric fields (ensuring finite,
non-negative numbers) and address fields with the required rules; wire that
schema into useForm via zodResolver so formState.isValid controls submit; remove
manual Number.parseFloat/Number.isFinite checks and instead call schema.parse or
rely on RHF's handleSubmit to get a typed, validated payload (you can replace
formToMeasurementsPayload with schema.parse or a simple cast from the parsed
output) and update the submit handler to use the validated, numeric Measurements
object returned by RHF rather than hand-rolled validation.
| useEffect(() => { | ||
| let cancelled = false; | ||
| async function load() { | ||
| try { | ||
| const [profile, addressList] = await Promise.all([ | ||
| fetchOwnProfile(), | ||
| fetchSavedAddresses(), | ||
| ]); | ||
| if (cancelled) return; | ||
| setSavedMeasurements(profile?.bodyMeasurements ?? null); | ||
| setMeasurementsForm( | ||
| measurementsToForm(profile?.bodyMeasurements ?? null), | ||
| ); | ||
| setAddresses(addressList); | ||
| setState("ready"); | ||
| } catch { | ||
| if (!cancelled) setState("error"); | ||
| } | ||
| } | ||
| load(); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Use React Query for profile/address loading and write flows.
This component manually manages async server state in useEffect, missing centralized query keys/caching and mutation handling.
As per coding guidelines, "Use React Query (TanStack Query v5) for server state management with centralized queryKeys."
🤖 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/measurements/MeasurementsClient.tsx around
lines 148 - 171, Replace the manual useEffect loading in MeasurementsClient.tsx
with React Query: create centralized query keys (e.g., ["profile"] and
["addresses"]), use useQuery (or useQueries) to fetch fetchOwnProfile and
fetchSavedAddresses, and derive local state by mapping query.data to
setSavedMeasurements/setMeasurementsForm (via measurementsToForm) and
setAddresses; handle loading/error by reading query.statuss instead of setState,
and switch any server writes to useMutation with proper invalidation of the
["profile"] / ["addresses"] keys so cached data updates instead of manual
Promise.all and cancelled flags.
| useEffect(() => { | ||
| let cancelled = false; | ||
| async function load() { | ||
| try { | ||
| const fetched = await fetchOwnProfile(); | ||
| if (cancelled) return; | ||
| if (!fetched) { | ||
| setState("error"); | ||
| return; | ||
| } | ||
| setProfile(fetched); | ||
| setState("ready"); | ||
| } catch { | ||
| if (!cancelled) setState("error"); | ||
| } | ||
| } | ||
| load(); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Load profile state through React Query instead of effect-managed fetch.
This bypasses the app’s centralized query key/caching pattern for server state.
As per coding guidelines, "Use React Query (TanStack Query v5) for server state management with centralized queryKeys."
🤖 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/profile/ProfileClient.tsx around lines 29 -
49, The current useEffect-based loader (load/cancelled) in ProfileClient should
be replaced with a React Query useQuery that calls fetchOwnProfile so the app
uses centralized queryKeys and caching; remove the effect and instead call
useQuery with a descriptive key (e.g. ['ownProfile'] or similar), pass
fetchOwnProfile as the queryFn, and move the state updates into onSuccess (call
setProfile(fetched) and setState('ready')) and onError (setState('error'));
ensure you keep any necessary options (staleTime, refetchOnWindowFocus)
consistent with app conventions and reference the existing identifiers
fetchOwnProfile, setProfile, and setState when wiring the query.
| useEffect(() => { | ||
| let cancelled = false; | ||
| async function load() { | ||
| try { | ||
| const fetched = await fetchOwnProfile(); | ||
| if (cancelled) return; | ||
| if (!fetched) { | ||
| setState("error"); | ||
| return; | ||
| } | ||
| setProfile(fetched); | ||
| setForm(profileToForm(fetched)); | ||
| setState("ready"); | ||
| } catch { | ||
| if (!cancelled) setState("error"); | ||
| } | ||
| } | ||
| load(); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Use TanStack Query for profile loading/mutation state.
This route fetches state via useEffect instead of centralized React Query keys/caching, which diverges from the app’s server-state contract.
As per coding guidelines, "Use React Query (TanStack Query v5) for server state management with centralized queryKeys."
🤖 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/settings/SettingsClient.tsx around lines 76
- 97, Replace the manual useEffect loading flow with TanStack Query: create a
useQuery keyed like ["profile","own"] that calls fetchOwnProfile and onSuccess
calls setProfile and setForm(profileToForm(...)) and sets the local state
(replace setState("ready") / setState("error") with query status handling), and
use a useMutation for profile updates that invalidates/refetches the
["profile","own"] query; ensure you remove the cancelled flag and load()
function, reference fetchOwnProfile, profileToForm, setProfile, setForm, and the
current local state setter (setState) when wiring query/mutation success/error
handlers so UI reflects loading/error/ready from React Query.
| function update<K extends keyof FormState>(key: K, value: FormState[K]) { | ||
| setForm((prev) => (prev ? { ...prev, [key]: value } : prev)); | ||
| } | ||
|
|
||
| function handleUsernameBlur(value: string) { | ||
| update("username", value.trim().toLowerCase()); | ||
| } | ||
|
|
||
| async function handleAvatarPick(e: ChangeEvent<HTMLInputElement>) { | ||
| const file = e.target.files?.[0]; | ||
| if (!file) return; | ||
| setAvatarError(null); | ||
| setUploadingAvatar(true); | ||
| try { | ||
| const result = await uploadAvatar(file); | ||
| update("profilePhotoUrl", result.url); | ||
| toast("Photo uploaded — save to apply.", { variant: "success" }); | ||
| } catch (err) { | ||
| const message = | ||
| isUpdateProfileError(err) && err.code === "MODERATION_BLOCKED" | ||
| ? "This image didn't pass our content checks. Please pick another." | ||
| : "We couldn't upload that photo. Please try again."; | ||
| setAvatarError(message); | ||
| } finally { | ||
| setUploadingAvatar(false); | ||
| // Reset the file input so picking the same file again still fires onChange. | ||
| if (fileInputRef.current) fileInputRef.current.value = ""; | ||
| } | ||
| } | ||
|
|
||
| function buildPayload(current: OwnProfile, draft: FormState) { | ||
| const payload: Record<string, string> = {}; | ||
| if (draft.displayName.trim() !== (current.displayName ?? "")) { | ||
| payload.displayName = draft.displayName.trim(); | ||
| } | ||
| const trimmedUsername = draft.username.trim().toLowerCase(); | ||
| if (trimmedUsername !== (current.username ?? "")) { | ||
| payload.username = trimmedUsername; | ||
| } | ||
| if (draft.bio !== (current.bio ?? "")) { | ||
| payload.bio = draft.bio; | ||
| } | ||
| if (draft.profilePhotoUrl !== (current.profilePhotoUrl ?? "")) { | ||
| payload.profilePhotoUrl = draft.profilePhotoUrl; | ||
| } | ||
| return payload; | ||
| } | ||
|
|
||
| async function handleSave() { | ||
| if (!profile || !form) return; | ||
| const payload = buildPayload(profile, form); | ||
| if (Object.keys(payload).length === 0) { | ||
| toast("No changes to save.", { variant: "default" }); | ||
| return; | ||
| } | ||
|
|
||
| // Username change confirmation | ||
| if ( | ||
| typeof window !== "undefined" && | ||
| payload.username && | ||
| !window.confirm("Changing your username locks it for 7 days. Continue?") | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| setSaving(true); | ||
| setUsernameError(null); | ||
| try { | ||
| const updated = await updateMe(payload); | ||
| if (updated) { | ||
| setProfile(updated); | ||
| setForm(profileToForm(updated)); | ||
| toast("Saved.", { variant: "success" }); | ||
| } | ||
| } catch (err) { | ||
| if (isUpdateProfileError(err) && err.code === "USERNAME_LOCK_ACTIVE") { | ||
| const formatted = formatLockDate(err.unlocksAt); | ||
| setUsernameError( | ||
| formatted | ||
| ? `Username can only be changed once every 7 days. Available again on ${formatted}.` | ||
| : "Username can only be changed once every 7 days.", | ||
| ); | ||
| } else if (isUpdateProfileError(err) && err.code === "USERNAME_TAKEN") { | ||
| setUsernameError("That username is already taken."); | ||
| } else { | ||
| toast("We couldn't save your changes. Please try again.", { | ||
| variant: "error", | ||
| }); | ||
| } | ||
| } finally { | ||
| setSaving(false); | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Migrate settings form handling to React Hook Form + Zod.
Validation and submit flow are manual; this route should use RHF + Zod schema-based validation.
As per coding guidelines, "Use React Hook Form with Zod for form validation" and "All frontend form validation must use Zod schemas."
Also applies to: 261-287
🤖 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/settings/SettingsClient.tsx around lines 99
- 191, The form currently uses manual state and validation (functions update,
handleUsernameBlur, buildPayload, handleSave, and FormState) — migrate this to
React Hook Form + a Zod schema: define a Zod schema matching
FormState/OwnProfile, initialize useForm with zodResolver and defaultValues from
profileToForm(profile), replace update/handleUsernameBlur by useForm's setValue
(ensure username is trimmed and lowercased on blur and triggers validation),
wire inputs via register, move buildPayload logic into the handleSubmit onSubmit
(or a small helper) to produce payload, call updateMe(payload) inside onSubmit
and use setError or formState.errors for validation errors (e.g.,
USERNAME_TAKEN/USERNAME_LOCK_ACTIVE map to setError on username), and keep
handleAvatarPick but call form.setValue("profilePhotoUrl", result.url) after
upload and clear file input; ensure saving state and toasts remain unchanged.
| // Strip nulls/undefineds — the backend DTO treats every field as optional | ||
| // and we want partial saves to work cleanly. | ||
| const filtered: Record<string, number> = {}; | ||
| for (const [key, value] of Object.entries(payload)) { | ||
| if (typeof value === "number" && Number.isFinite(value)) { | ||
| filtered[key] = value; | ||
| } | ||
| } |
There was a problem hiding this comment.
Allow explicit measurement clearing in update payloads.
This filter drops null, so callers cannot clear an already-saved measurement field (it silently remains unchanged on backend).
Suggested fix
-export type MeasurementsPayload = Partial<Measurements>;
+export type MeasurementsPayload = Partial<Measurements>;
export async function updateMeasurements(
payload: MeasurementsPayload,
): Promise<Measurements | null> {
- // Strip nulls/undefineds — the backend DTO treats every field as optional
- // and we want partial saves to work cleanly.
- const filtered: Record<string, number> = {};
+ // Keep finite numbers and explicit nulls.
+ const filtered: Record<string, number | null> = {};
for (const [key, value] of Object.entries(payload)) {
if (typeof value === "number" && Number.isFinite(value)) {
filtered[key] = value;
+ } else if (value === null) {
+ filtered[key] = null;
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Strip nulls/undefineds — the backend DTO treats every field as optional | |
| // and we want partial saves to work cleanly. | |
| const filtered: Record<string, number> = {}; | |
| for (const [key, value] of Object.entries(payload)) { | |
| if (typeof value === "number" && Number.isFinite(value)) { | |
| filtered[key] = value; | |
| } | |
| } | |
| // Keep finite numbers and explicit nulls. | |
| const filtered: Record<string, number | null> = {}; | |
| for (const [key, value] of Object.entries(payload)) { | |
| if (typeof value === "number" && Number.isFinite(value)) { | |
| filtered[key] = value; | |
| } else if (value === null) { | |
| filtered[key] = null; | |
| } | |
| } |
🤖 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/lib/user.ts` around lines 190 - 197, The current loop over
payload drops null values so callers cannot explicitly clear saved measurements;
change the filtered record to allow null (e.g., Record<string, number | null>)
and update the loop that builds filtered from payload to include entries when
value is either a finite number (typeof value === "number" &&
Number.isFinite(value)) or value === null, while still skipping undefined and
non-numeric values so explicit nulls are sent to the backend to clear fields.
There was a problem hiding this comment.
Addressed the small actionable W-12 review items.
Fixed:
- Added a 5MB client-side guard before avatar upload.
- Renamed normalized
OwnProfilefields to follow repo conventions:userId,dateOfBirthAt, andmemberSinceAt. - Verified the measurements DTO before changing measurement clearing behavior.
Deferred intentionally:
- React Query/queryKeys refactor for profile/settings/measurements loading. The app does not yet have a centralized query key pattern, and recent W-09b/W-10/W-11 pages use the same scoped
useEffectloading pattern. This should be handled as a separate frontend architecture task. - RHF/Zod migration for settings and measurements. That is a larger form refactor beyond the W-12 feature scope.
Validation:
apps/web pnpm run lintpassedapps/web npx tsc --noEmitpassedapps/web pnpm run buildpassed
There was a problem hiding this comment.
@SAHEED2010, thanks for the thorough update and for verifying before changing behaviour.
On the measurement clearing concern — good call checking the backend DTO first. If the update-measurements.dto.ts marks every field as @IsOptional() but not @IsNullable() (or uses @IsNumber() without a null union), then sending null would be rejected or ignored server-side anyway, making the client-side null-passthrough pointless. Your verification is the right due-diligence; the suggestion is moot against that backend contract.
On the other fixes:
- The 5 MB pre-flight guard on avatar upload is a solid UX/safety improvement.
- The field renames (
userId,dateOfBirthAt,memberSinceAt) forOwnProfilealigning with repo conventions keeps the codebase consistent — good catch.
On the deferrals — the reasoning is sound. Introducing a React Query key registry or RHF/Zod forms as part of a single feature PR would be disproportionate scope-creep; centralising those patterns as a cross-cutting architecture task is the right call given that W-09b/W-10/W-11 follow the same useEffect loading pattern.
Everything looks well-considered. ✅
✏️ Learnings added
Learnt from: SAHEED2010
Repo: coded-devs/twizrr PR: 326
File: apps/web/src/lib/user.ts:190-197
Timestamp: 2026-05-23T08:16:47.692Z
Learning: In the twizrr monorepo, the backend `update-measurements.dto.ts` (at `apps/backend/src/domains/users/user/dto/update-measurements.dto.ts`) does not mark measurement fields as nullable (no `IsNullable()`), so sending `null` values to `PUT /users/me/measurements` is rejected or ignored server-side. The `updateMeasurements()` helper in `apps/web/src/lib/user.ts` intentionally strips `null` values from the payload before sending; this is correct behaviour given the backend DTO contract and should not be changed to pass nulls through.
Learnt from: SAHEED2010
Repo: coded-devs/twizrr PR: 326
File: apps/web/src/lib/user.ts:190-197
Timestamp: 2026-05-23T08:16:47.692Z
Learning: In the twizrr monorepo (apps/web), the `OwnProfile` interface in `apps/web/src/lib/user.ts` uses repo-convention field names: `userId` (not `id`), `dateOfBirthAt` (not `dateOfBirth`), and `memberSinceAt` (not `memberSince`). These names were deliberately chosen to match the rest of the repo's naming conventions as part of PR `#326` (W-12).
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
Three targeted fixes from CodeRabbit's W-12 review (PR #326). Heavy-lift refactors (TanStack Query migration, RHF + Zod) intentionally deferred — they're cross-cutting architecture changes that need their own task. Fixes - Settings avatar upload now enforces the advertised 5MB cap client-side in handleAvatarPick before calling uploadAvatar(). Oversized files set an inline error ("Profile photo must be 5MB or smaller.") and the file input is reset so the same path can be retried. No round trip to the backend for files we already know are too large. - lib/user.ts OwnProfile renamed to follow the repo's *Id / *At conventions: id → userId, dateOfBirth → dateOfBirthAt, memberSince → memberSinceAt. Wire fields stay the same; the re-keying happens at the normalizeOwnProfile boundary. No W-12 components consume those three fields today, so the rename is fully contained in the lib. Skipped (with reasons) - "Allow null measurements to clear saved fields" — verified the backend contract before changing. UpdateMeasurementsDto uses @IsOptional() + @IsNumber() (no null union) and user.service.toMeasurementJson only writes typeof value === "number" entries, then REPLACES the full bodyMeasurements JSON column. Net effect: null is dropped server-side AND the omit-empty-fields path the frontend already uses correctly clears fields (the backend rebuilds the JSON column from non-null values on every PUT). Sending null would change zero behaviour. - TanStack Query migration for Profile / Settings / Measurements loaders — cross-cutting refactor. W-09b / W-10 / W-11 all use the same useEffect + cancelled-flag pattern. There's no centralized queryKeys module in apps/web yet. This belongs in a frontend architecture task, not a feature PR. - RHF + Zod migration for Settings and Measurements forms — heavy form refactor; @hookform/resolvers isn't installed. Should be a separate form-standardization task that wires the dependency and refactors every form at once.
What does this PR do?
Implements W-12 — User Profile + Settings + Measurements. This adds the buyer-side profile, account settings, measurements/delivery info, saved posts placeholder, and wishlist pages.
Task
W-12 — User Profile + Settings + Measurements
What changed
/buyer/profile/buyer/settings/buyer/measurements/buyer/saved/buyer/wishlistFiles changed
apps/web/src/lib/user.tsapps/web/src/lib/wishlist.tsapps/web/src/app/(shopper)/buyer/profile/page.tsxapps/web/src/app/(shopper)/buyer/profile/ProfileClient.tsxapps/web/src/app/(shopper)/buyer/settings/page.tsxapps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsxapps/web/src/app/(shopper)/buyer/measurements/page.tsxapps/web/src/app/(shopper)/buyer/measurements/MeasurementsClient.tsxapps/web/src/app/(shopper)/buyer/saved/page.tsxapps/web/src/app/(shopper)/buyer/saved/SavedClient.tsxapps/web/src/app/(shopper)/buyer/wishlist/page.tsxapps/web/src/app/(shopper)/buyer/wishlist/WishlistClient.tsxBackend endpoints used
GET /users/mePUT /users/mePUT /users/me/measurementsGET /users/me/addressesPUT /users/me/addressesGET /wishlistPOST /wishlist/toggle/:productIdPOST /upload/imageNo backend changes were made.
Behavior implemented
Profile
Settings
POST /upload/imagefor avatar uploadBLOCKEDmoderation resultPUT /users/meUSERNAME_LOCK_ACTIVEUSERNAME_TAKENMeasurements
PUT /users/me/measurementsSaved
/buyer/saved/exploreWishlist
/buyer/wishlistGET /wishlist/p/[productCode]when product code is availableDropship/internal safety
Wishlist responses are normalized through a strict whitelist.
The frontend never renders:
storeTypephysicalStoreIdphysicalProductIdlinkedOrderIdsourcedProductIddropshipperCostKobodigitalMarginKobodropshipperPriceKobowholesalePriceKoboThe backend currently returns broader product records in
GET /wishlist; this PR ignores unsafe extra fields and renders only the approved whitelist.Deferred / follow-ups
/buyer/savedships as placeholderVerification
pnpm run lint— PASSnpx tsc --noEmit— PASSpnpm run build— PASSBuild confirms these routes are registered:
/buyer/profile/buyer/settings/buyer/measurements/buyer/saved/buyer/wishlistType of change
Area affected
How to test this
/buyer/profile./buyer/settings./buyer/measurements./buyer/savedand confirm placeholder state renders./buyer/wishlistwith seeded wishlist products.Expected result:
Pre-commit checklist
console.logleft in production code.envfiles committedanytypes addedScreenshots
Required for UI changes. Add mobile and desktop screenshots of:
/buyer/profile/buyer/settings/buyer/measurements/buyer/saved/buyer/wishlistNotes for reviewer
This PR intentionally keeps missing backend list surfaces as polished placeholders instead of inventing frontend contracts. Backend follow-ups are needed for saved-post list, user post tabs, and tighter wishlist product select.
Summary by CodeRabbit