Skip to content

feat(web): add shopper profile settings and saved pages#326

Merged
onerandomdevv merged 4 commits into
devfrom
feat/web-user-profile
May 23, 2026
Merged

feat(web): add shopper profile settings and saved pages#326
onerandomdevv merged 4 commits into
devfrom
feat/web-user-profile

Conversation

@SAHEED2010
Copy link
Copy Markdown
Collaborator

@SAHEED2010 SAHEED2010 commented May 23, 2026

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

  • Added /buyer/profile
  • Added /buyer/settings
  • Added /buyer/measurements
  • Added /buyer/saved
  • Added /buyer/wishlist
  • Added user/profile API helpers
  • Added wishlist API helpers
  • Added profile editing UI
  • Added avatar upload flow using existing moderated upload endpoint
  • Added measurements form
  • Added delivery address management UI
  • Added saved-posts placeholder page
  • Added wishlist grid with safe whitelist normalization

Files changed

  • apps/web/src/lib/user.ts
  • apps/web/src/lib/wishlist.ts
  • apps/web/src/app/(shopper)/buyer/profile/page.tsx
  • apps/web/src/app/(shopper)/buyer/profile/ProfileClient.tsx
  • apps/web/src/app/(shopper)/buyer/settings/page.tsx
  • apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx
  • apps/web/src/app/(shopper)/buyer/measurements/page.tsx
  • apps/web/src/app/(shopper)/buyer/measurements/MeasurementsClient.tsx
  • apps/web/src/app/(shopper)/buyer/saved/page.tsx
  • apps/web/src/app/(shopper)/buyer/saved/SavedClient.tsx
  • apps/web/src/app/(shopper)/buyer/wishlist/page.tsx
  • apps/web/src/app/(shopper)/buyer/wishlist/WishlistClient.tsx

Backend endpoints used

  • GET /users/me
  • PUT /users/me
  • PUT /users/me/measurements
  • GET /users/me/addresses
  • PUT /users/me/addresses
  • GET /wishlist
  • POST /wishlist/toggle/:productId
  • POST /upload/image

No backend changes were made.

Behavior implemented

Profile

  • Displays avatar, display name, username, bio, and counts if available
  • Provides tabs for:
    • Gists
    • Replies
    • Photos
    • Twizz
  • Uses empty states for post tabs because list-by-user post endpoints are not available yet
  • Twizz is shown as deferred/coming soon

Settings

  • Allows editing:
    • display name
    • username
    • bio
    • profile photo
  • Uses existing POST /upload/image for avatar upload
  • Rejects BLOCKED moderation result
  • Saves profile changes through PUT /users/me
  • Handles USERNAME_LOCK_ACTIVE
  • Handles USERNAME_TAKEN
  • Shows email/phone verification status as read-only account information

Measurements

  • Supports:
    • bust
    • waist
    • hips
    • height
    • EU shoe size
    • foot length
  • Saves through PUT /users/me/measurements
  • Supports delivery address list management through existing address endpoints
  • Allows adding, editing, removing, and setting default delivery address

Saved

  • Adds /buyer/saved
  • Renders a polished placeholder because the backend has save/unsave endpoints but no saved-post list endpoint yet
  • Links back to /explore

Wishlist

  • Adds /buyer/wishlist
  • Fetches wishlist through GET /wishlist
  • Allows removing/toggling wishlist products
  • Shows safe product card data only
  • Links products to /p/[productCode] when product code is available

Dropship/internal safety

Wishlist responses are normalized through a strict whitelist.

The frontend never renders:

  • storeType
  • physicalStoreId
  • physicalProductId
  • linkedOrderId
  • sourcedProductId
  • dropshipperCostKobo
  • digitalMarginKobo
  • dropshipperPriceKobo
  • wholesalePriceKobo
  • fulfillment data
  • bank fields
  • Paystack recipient fields
  • private store addresses

The backend currently returns broader product records in GET /wishlist; this PR ignores unsafe extra fields and renders only the approved whitelist.

Deferred / follow-ups

  • Saved-posts list endpoint is missing; /buyer/saved ships as placeholder
  • User post list endpoints for Gists/Replies/Photos are missing; profile tabs use empty states
  • Twizz remains deferred by MVP scope
  • Backend wishlist select should be tightened to avoid returning full product records

Verification

  • pnpm run lint — PASS
  • npx tsc --noEmit — PASS
  • pnpm run build — PASS

Build confirms these routes are registered:

  • /buyer/profile
  • /buyer/settings
  • /buyer/measurements
  • /buyer/saved
  • /buyer/wishlist

Type of change

  • New feature
  • Bug fix
  • Refactor / cleanup
  • Database migration included
  • Chore / maintenance
  • Documentation

Area affected

  • Web
  • Backend
  • WhatsApp
  • Shared package
  • Database / Prisma
  • GitHub / CI / infrastructure

How to test this

  1. Log in as a shopper.
  2. Open /buyer/profile.
  3. Confirm profile header and tabs render.
  4. Open /buyer/settings.
  5. Edit profile fields and save.
  6. Upload a profile photo and confirm it saves when moderation allows it.
  7. Open /buyer/measurements.
  8. Save measurements.
  9. Add/edit/remove delivery addresses.
  10. Open /buyer/saved and confirm placeholder state renders.
  11. Open /buyer/wishlist with seeded wishlist products.
  12. Remove a wishlist item and refresh.

Expected result:

  • All five buyer account routes render.
  • Profile/settings/measurements save through verified backend endpoints.
  • Saved posts page does not 404.
  • Wishlist renders safe product cards only.
  • No internal/dropship fields appear in the UI.

Pre-commit checklist

  • Web lint/type/build pass when web is affected
  • Backend lint/type/build pass when backend is affected
  • Shared package build passes when shared is affected
  • No console.log left in production code
  • No secrets or .env files committed
  • No new any types added
  • No emojis added
  • Tailwind/design tokens used
  • No backend changes
  • No Prisma migration added

Screenshots

Required for UI changes. Add mobile and desktop screenshots of:

  • /buyer/profile
  • /buyer/settings
  • /buyer/measurements
  • /buyer/saved
  • /buyer/wishlist

Notes 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

  • New Features
    • Added profile page displaying user information and stats
    • Added settings page to update display name, username, bio, and profile photo
    • Added body measurements management with delivery address organization
    • Added wishlist feature to save and manage favorite items
    • Added saved posts section (coming soon)

Review Change Stack

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.
@SAHEED2010 SAHEED2010 requested a review from onerandomdevv as a code owner May 23, 2026 08:03
@codesandbox
Copy link
Copy Markdown

codesandbox Bot commented May 23, 2026

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 23, 2026

Warning

Review limit reached

@SAHEED2010, we couldn't start this review because you've used your available PR reviews for now.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d0bcb3db-d134-455b-bda1-4077d63704a6

📥 Commits

Reviewing files that changed from the base of the PR and between b67e707 and 355e204.

📒 Files selected for processing (2)
  • apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx
  • apps/web/src/lib/user.ts
📝 Walkthrough

Walkthrough

Adds 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.

Changes

Buyer Account Management

Layer / File(s) Summary
User profile and wishlist API helpers
apps/web/src/lib/user.ts, apps/web/src/lib/wishlist.ts
Defines strict TypeScript types (OwnProfile, Measurements, WishlistItem) with defensive normalization. Implements fetchOwnProfile, updateMe, updateMeasurements, uploadAvatar, fetchWishlist, and toggleWishlist as typed API wrappers with coercion helpers, error detection, and moderation-status handling.
Settings form and avatar upload
apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx, apps/web/src/app/(shopper)/buyer/settings/page.tsx
Loads user profile on mount, converts to editable form state for display name/username/bio/avatar. Handles avatar file upload with moderation errors, username normalization, minimal diff payload generation, and targeted error UI for username conflicts. Page exports metadata and renders component.
Measurements and delivery address management
apps/web/src/app/(shopper)/buyer/measurements/MeasurementsClient.tsx, apps/web/src/app/(shopper)/buyer/measurements/page.tsx
Loads profile and addresses concurrently, editable measurement form with float parsing/validation, address draft UI with add/edit modes. Default-address logic ensures exactly one default; removal prompts and rolls back on failure. Commit-based persistence via saveAddresses and updateMeasurements. Page exports metadata and renders component.
Public profile view with tabs and stats
apps/web/src/app/(shopper)/buyer/profile/ProfileClient.tsx, apps/web/src/app/(shopper)/buyer/profile/page.tsx
Loads current user profile on mount with loading/error/ready states. Displays profile header (avatar/name/bio), stat counters (posts/followers/following), and tabbed content with placeholder messaging. Page exports metadata and renders component.
Wishlist display and item removal
apps/web/src/app/(shopper)/buyer/wishlist/WishlistClient.tsx, apps/web/src/app/(shopper)/buyer/wishlist/page.tsx
Loads wishlist items on mount, renders skeleton/empty-state/error-with-retry UI based on state. Supports optimistic removal with rollback on error and pending-state blocking. Card shows product media, price, stock status, and conditional product link. Page exports metadata and renders component.
Saved posts empty state
apps/web/src/app/(shopper)/buyer/saved/SavedClient.tsx, apps/web/src/app/(shopper)/buyer/saved/page.tsx
Renders static empty-state UI with heading, description, icon, and /explore link while backend list functionality remains pending. Page exports metadata and renders component.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • onerandomdevv

🐇 A burrow full of buyer bliss now blooms,
From profiles twizzed to wishlists in their rooms,
With measurements measured and addresses set,
Settings saved smooth—the finest feature yet!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the main change: adding shopper profile, settings, and saved pages to the web application.
Description check ✅ Passed The PR description is comprehensive and follows the template with all critical sections completed: what changed, type of change, area affected, testing instructions, pre-commit checklist, and notes for reviewer.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/web-user-profile

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

onerandomdevv
onerandomdevv previously approved these changes May 23, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (5)
apps/web/src/app/(shopper)/buyer/saved/SavedClient.tsx (1)

18-20: ⚡ Quick win

Use 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 win

Use 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 win

Switch heading styles to Syne for heading elements.

Heading nodes are rendered with font-cabinet instead 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 win

Apply Syne heading typography for heading elements.

Top-level/section headings are using font-cabinet instead 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 value

Duplicate fetch logic between load callback and useEffect.

The load function (lines 25-33) and the inline run function in useEffect (lines 37-45) contain nearly identical fetch logic. The load callback is only used for the retry button, while useEffect has its own implementation.

Consider reusing load in 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 AbortController passed 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5f1e14e and b67e707.

📒 Files selected for processing (12)
  • apps/web/src/app/(shopper)/buyer/measurements/MeasurementsClient.tsx
  • apps/web/src/app/(shopper)/buyer/measurements/page.tsx
  • apps/web/src/app/(shopper)/buyer/profile/ProfileClient.tsx
  • apps/web/src/app/(shopper)/buyer/profile/page.tsx
  • apps/web/src/app/(shopper)/buyer/saved/SavedClient.tsx
  • apps/web/src/app/(shopper)/buyer/saved/page.tsx
  • apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx
  • apps/web/src/app/(shopper)/buyer/settings/page.tsx
  • apps/web/src/app/(shopper)/buyer/wishlist/WishlistClient.tsx
  • apps/web/src/app/(shopper)/buyer/wishlist/page.tsx
  • apps/web/src/lib/user.ts
  • apps/web/src/lib/wishlist.ts

Comment on lines +61 to +73
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Comment on lines +148 to +171
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;
};
}, []);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Comment on lines +29 to +49
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;
};
}, []);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Comment on lines +76 to +97
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;
};
}, []);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Comment on lines +99 to +191
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);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Comment thread apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx
Comment thread apps/web/src/lib/user.ts
Comment thread apps/web/src/lib/user.ts
Comment on lines +190 to +197
// 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;
}
}
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
// 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the small actionable W-12 review items.

Fixed:

  • Added a 5MB client-side guard before avatar upload.
  • Renamed normalized OwnProfile fields to follow repo conventions: userId, dateOfBirthAt, and memberSinceAt.
  • 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 useEffect loading 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 lint passed
  • apps/web npx tsc --noEmit passed
  • apps/web pnpm run build passed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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) for OwnProfile aligning 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.
@onerandomdevv onerandomdevv merged commit 2fcacbf into dev May 23, 2026
8 checks passed
@SAHEED2010 SAHEED2010 deleted the feat/web-user-profile branch May 23, 2026 08:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants