From 08205765ce28d5d3b12ab97a7b7e82af1b214171 Mon Sep 17 00:00:00 2001 From: SAHEED2010 Date: Sat, 23 May 2026 20:59:15 +1300 Subject: [PATCH 1/2] feat(web): add shopper profile settings and saved pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../buyer/measurements/MeasurementsClient.tsx | 670 ++++++++++++++++++ .../app/(shopper)/buyer/measurements/page.tsx | 11 + .../(shopper)/buyer/profile/ProfileClient.tsx | 234 ++++++ .../src/app/(shopper)/buyer/profile/page.tsx | 11 + .../app/(shopper)/buyer/saved/SavedClient.tsx | 50 ++ .../src/app/(shopper)/buyer/saved/page.tsx | 11 + .../buyer/settings/SettingsClient.tsx | 440 ++++++++++++ .../src/app/(shopper)/buyer/settings/page.tsx | 11 + .../buyer/wishlist/WishlistClient.tsx | 260 +++++++ .../src/app/(shopper)/buyer/wishlist/page.tsx | 11 + apps/web/src/lib/user.ts | 301 ++++++++ apps/web/src/lib/wishlist.ts | 192 +++++ 12 files changed, 2202 insertions(+) create mode 100644 apps/web/src/app/(shopper)/buyer/measurements/MeasurementsClient.tsx create mode 100644 apps/web/src/app/(shopper)/buyer/measurements/page.tsx create mode 100644 apps/web/src/app/(shopper)/buyer/profile/ProfileClient.tsx create mode 100644 apps/web/src/app/(shopper)/buyer/profile/page.tsx create mode 100644 apps/web/src/app/(shopper)/buyer/saved/SavedClient.tsx create mode 100644 apps/web/src/app/(shopper)/buyer/saved/page.tsx create mode 100644 apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx create mode 100644 apps/web/src/app/(shopper)/buyer/settings/page.tsx create mode 100644 apps/web/src/app/(shopper)/buyer/wishlist/WishlistClient.tsx create mode 100644 apps/web/src/app/(shopper)/buyer/wishlist/page.tsx create mode 100644 apps/web/src/lib/user.ts create mode 100644 apps/web/src/lib/wishlist.ts diff --git a/apps/web/src/app/(shopper)/buyer/measurements/MeasurementsClient.tsx b/apps/web/src/app/(shopper)/buyer/measurements/MeasurementsClient.tsx new file mode 100644 index 00000000..30ba9ebb --- /dev/null +++ b/apps/web/src/app/(shopper)/buyer/measurements/MeasurementsClient.tsx @@ -0,0 +1,670 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + AlertCircle, + CheckCircle2, + Footprints, + MapPin, + Plus, + Ruler, + Trash2, +} from "lucide-react"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { useToast } from "@/components/ui/Toast"; +import { cn } from "@/lib/utils"; +import { + fetchOwnProfile, + fetchSavedAddresses, + formatAddressLine, + saveAddresses, + updateMeasurements, + type DeliveryAddress, + type Measurements, +} from "@/lib/user"; + +type LoadState = "loading" | "ready" | "error"; + +interface MeasurementForm { + bustCm: string; + waistCm: string; + hipsCm: string; + heightCm: string; + shoeSizeEU: string; + footLengthCm: string; +} + +const EMPTY_MEASUREMENTS: MeasurementForm = { + bustCm: "", + waistCm: "", + hipsCm: "", + heightCm: "", + shoeSizeEU: "", + footLengthCm: "", +}; + +function measurementsToForm(m: Measurements | null): MeasurementForm { + if (!m) return EMPTY_MEASUREMENTS; + return { + bustCm: m.bustCm !== null ? String(m.bustCm) : "", + waistCm: m.waistCm !== null ? String(m.waistCm) : "", + hipsCm: m.hipsCm !== null ? String(m.hipsCm) : "", + heightCm: m.heightCm !== null ? String(m.heightCm) : "", + shoeSizeEU: m.shoeSizeEU !== null ? String(m.shoeSizeEU) : "", + footLengthCm: m.footLengthCm !== null ? String(m.footLengthCm) : "", + }; +} + +function formToMeasurementsPayload(form: MeasurementForm) { + const payload: Partial = {}; + 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; +} + +interface AddressDraft { + id?: string; + label: string; + street: string; + line2: string; + city: string; + state: string; + postalCode: string; + isDefault: boolean; +} + +const EMPTY_DRAFT: AddressDraft = { + label: "Home", + street: "", + line2: "", + city: "", + state: "", + postalCode: "", + isDefault: false, +}; + +function addressToDraft(a: DeliveryAddress): AddressDraft { + return { + id: a.id, + label: a.label, + street: a.street, + line2: a.line2 ?? "", + city: a.city, + state: a.state, + postalCode: a.postalCode ?? "", + isDefault: a.isDefault === true, + }; +} + +function draftToAddress(d: AddressDraft): DeliveryAddress { + return { + ...(d.id ? { id: d.id } : {}), + label: d.label.trim(), + street: d.street.trim(), + ...(d.line2.trim() ? { line2: d.line2.trim() } : {}), + city: d.city.trim(), + state: d.state.trim(), + ...(d.postalCode.trim() ? { postalCode: d.postalCode.trim() } : {}), + isDefault: d.isDefault, + }; +} + +function isDraftValid(d: AddressDraft): boolean { + return ( + d.label.trim().length > 0 && + d.street.trim().length > 0 && + d.city.trim().length > 0 && + d.state.trim().length > 0 + ); +} + +export function MeasurementsClient() { + const { toast } = useToast(); + + const [state, setState] = useState("loading"); + const [measurementsForm, setMeasurementsForm] = + useState(EMPTY_MEASUREMENTS); + const [savedMeasurements, setSavedMeasurements] = + useState(null); + const [addresses, setAddresses] = useState([]); + + const [savingMeasurements, setSavingMeasurements] = useState(false); + const [savingAddresses, setSavingAddresses] = useState(false); + + const [draftOpen, setDraftOpen] = useState(false); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [draftError, setDraftError] = useState(null); + + 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; + }; + }, []); + + const measurementsChanged = useMemo(() => { + if (!savedMeasurements) { + return Object.values(measurementsForm).some((v) => v.trim() !== ""); + } + return ( + JSON.stringify(formToMeasurementsPayload(measurementsForm)) !== + JSON.stringify({ + ...(savedMeasurements.bustCm !== null + ? { bustCm: savedMeasurements.bustCm } + : {}), + ...(savedMeasurements.waistCm !== null + ? { waistCm: savedMeasurements.waistCm } + : {}), + ...(savedMeasurements.hipsCm !== null + ? { hipsCm: savedMeasurements.hipsCm } + : {}), + ...(savedMeasurements.heightCm !== null + ? { heightCm: savedMeasurements.heightCm } + : {}), + ...(savedMeasurements.shoeSizeEU !== null + ? { shoeSizeEU: savedMeasurements.shoeSizeEU } + : {}), + ...(savedMeasurements.footLengthCm !== null + ? { footLengthCm: savedMeasurements.footLengthCm } + : {}), + }) + ); + }, [measurementsForm, savedMeasurements]); + + function updateMeasurementsField(key: keyof MeasurementForm, value: string) { + setMeasurementsForm((prev) => ({ ...prev, [key]: value })); + } + + async function handleSaveMeasurements() { + setSavingMeasurements(true); + try { + const payload = formToMeasurementsPayload(measurementsForm); + const saved = await updateMeasurements(payload); + setSavedMeasurements(saved); + setMeasurementsForm(measurementsToForm(saved)); + toast("Measurements saved.", { variant: "success" }); + } catch { + toast("We couldn't save your measurements. Please try again.", { + variant: "error", + }); + } finally { + setSavingMeasurements(false); + } + } + + // ── Address actions ──────────────────────────────────────────────────────── + + function openAddDraft() { + setDraft(EMPTY_DRAFT); + setDraftError(null); + setDraftOpen(true); + } + + function openEditDraft(address: DeliveryAddress) { + setDraft(addressToDraft(address)); + setDraftError(null); + setDraftOpen(true); + } + + function cancelDraft() { + setDraftOpen(false); + setDraft(EMPTY_DRAFT); + setDraftError(null); + } + + async function commit(next: DeliveryAddress[]) { + setSavingAddresses(true); + try { + const saved = await saveAddresses(next); + setAddresses(saved); + toast("Addresses updated.", { variant: "success" }); + } catch { + toast("We couldn't save your addresses. Please try again.", { + variant: "error", + }); + // Reload from backend on failure so UI doesn't drift. + try { + const fresh = await fetchSavedAddresses(); + setAddresses(fresh); + } catch { + /* ignore */ + } + } finally { + setSavingAddresses(false); + } + } + + async function handleSaveDraft() { + if (!isDraftValid(draft)) { + setDraftError("Label, street, city and state are required."); + return; + } + setDraftError(null); + + const newAddress = draftToAddress(draft); + let next: DeliveryAddress[]; + if (draft.id) { + // Edit existing + next = addresses.map((a) => + a.id === draft.id ? { ...newAddress, id: draft.id } : a, + ); + } else { + // Add new — if marked default, clear default on the rest + const cleared = newAddress.isDefault + ? addresses.map((a) => ({ ...a, isDefault: false })) + : addresses; + next = [...cleared, newAddress]; + } + + if (newAddress.isDefault) { + next = next.map((a, idx) => ({ + ...a, + isDefault: draft.id ? a.id === draft.id : idx === next.length - 1, + })); + } + + setDraftOpen(false); + setDraft(EMPTY_DRAFT); + await commit(next); + } + + async function handleSetDefault(target: DeliveryAddress) { + if (!target.id) return; + const next = addresses.map((a) => ({ + ...a, + isDefault: a.id === target.id, + })); + await commit(next); + } + + async function handleRemove(target: DeliveryAddress) { + if (!target.id) return; + if ( + typeof window !== "undefined" && + !window.confirm("Remove this address?") + ) { + return; + } + const next = addresses.filter((a) => a.id !== target.id); + await commit(next); + } + + if (state === "loading") return ; + if (state === "error") return ; + + return ( +
+
+

+ Measurements & delivery +

+

+ Help sellers match the right size and ship to the right place. +

+
+ + {/* Measurements */} +
+
+
+
+ updateMeasurementsField("bustCm", v)} + max={300} + /> + updateMeasurementsField("waistCm", v)} + max={300} + /> + updateMeasurementsField("hipsCm", v)} + max={300} + /> + updateMeasurementsField("heightCm", v)} + max={250} + /> +
+ +
+
+
+ updateMeasurementsField("shoeSizeEU", v)} + max={60} + /> + updateMeasurementsField("footLengthCm", v)} + max={40} + /> +
+ +
+ +
+
+ + {/* Addresses */} +
+
+
+
+ {!draftOpen && ( + + )} +
+ + {addresses.length === 0 && !draftOpen && ( +

+ No addresses saved yet. +

+ )} + +
    + {addresses.map((address) => ( +
  • +
  • + ))} +
+ + {/* Draft form */} + {draftOpen && ( +
+

+ {draft.id ? "Edit address" : "Add a new address"} +

+
+ + setDraft((prev) => ({ ...prev, label: e.target.value })) + } + maxLength={40} + /> + + setDraft((prev) => ({ ...prev, street: e.target.value })) + } + maxLength={160} + /> + + setDraft((prev) => ({ ...prev, line2: e.target.value })) + } + maxLength={160} + /> +
+ + setDraft((prev) => ({ ...prev, city: e.target.value })) + } + maxLength={80} + /> + + setDraft((prev) => ({ ...prev, state: e.target.value })) + } + maxLength={80} + /> +
+ + setDraft((prev) => ({ + ...prev, + postalCode: e.target.value, + })) + } + maxLength={20} + /> + + {draftError && ( +

+ {draftError} +

+ )} +
+ + +
+
+
+ )} +
+
+ ); +} + +function NumberField({ + label, + value, + onChange, + max, +}: { + label: string; + value: string; + onChange: (next: string) => void; + max: number; +}) { + return ( + onChange(e.target.value)} + /> + ); +} + +function MeasurementsSkeleton() { + return ( +
+ + + + +
+ ); +} + +function MeasurementsError() { + return ( +
+
+
+

+ We couldn’t load your measurements +

+

+ Try refreshing — your saved info is safe. +

+
+ ); +} diff --git a/apps/web/src/app/(shopper)/buyer/measurements/page.tsx b/apps/web/src/app/(shopper)/buyer/measurements/page.tsx new file mode 100644 index 00000000..155dd957 --- /dev/null +++ b/apps/web/src/app/(shopper)/buyer/measurements/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { MeasurementsClient } from "./MeasurementsClient"; + +export const metadata: Metadata = { + title: "Measurements & delivery — twizrr", + robots: { index: false, follow: false }, +}; + +export default function MeasurementsPage() { + return ; +} diff --git a/apps/web/src/app/(shopper)/buyer/profile/ProfileClient.tsx b/apps/web/src/app/(shopper)/buyer/profile/ProfileClient.tsx new file mode 100644 index 00000000..90ce8f67 --- /dev/null +++ b/apps/web/src/app/(shopper)/buyer/profile/ProfileClient.tsx @@ -0,0 +1,234 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { AlertCircle, Pencil, User } from "lucide-react"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { cn } from "@/lib/utils"; +import { fetchOwnProfile, type OwnProfile } from "@/lib/user"; + +type LoadState = "loading" | "ready" | "error"; + +type TabKey = "gists" | "replies" | "photos" | "twizz"; + +const TABS: { key: TabKey; label: string; soon?: boolean }[] = [ + { key: "gists", label: "Gists" }, + { key: "replies", label: "Replies" }, + { key: "photos", label: "Photos" }, + { key: "twizz", label: "Twizz", soon: true }, +]; + +export function ProfileClient() { + const [state, setState] = useState("loading"); + const [profile, setProfile] = useState(null); + const [activeTab, setActiveTab] = useState("gists"); + + 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; + }; + }, []); + + if (state === "loading") return ; + if (state === "error" || !profile) return ; + + const handle = profile.username ? `@${profile.username}` : null; + const displayName = profile.displayName ?? handle ?? "Your profile"; + + return ( +
+ {/* Header */} +
+
+ {profile.profilePhotoUrl ? ( + {displayName} + ) : ( +
+
+ )} +
+
+

+ {displayName} +

+ {handle && ( +

+ {handle} +

+ )} + {profile.bio && ( +

+ {profile.bio} +

+ )} +
+
+ + + +
+
+ + {/* Stats */} +
+ + + +
+ + {/* Tabs */} + + + {/* Tab content — every tab is a polished empty state for now */} +
+ +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+

+ {value} +

+

+ {label} +

+
+ ); +} + +function TabPlaceholder({ tab }: { tab: TabKey }) { + const labels: Record = { + gists: { + title: "No gists yet", + body: "Your gists will show up here once feed view ships.", + }, + replies: { + title: "No replies yet", + body: "Your replies will show up here once feed view ships.", + }, + photos: { + title: "No photos yet", + body: "Your photo posts will show up here once feed view ships.", + }, + twizz: { + title: "Twizz is coming soon", + body: "Short videos will land in a later release.", + }, + }; + const { title, body } = labels[tab]; + return ( +
+

+ {title} +

+

+ {body} +

+
+ ); +} + +function ProfileSkeleton() { + return ( +
+
+ +
+ + + +
+ +
+ + + +
+ ); +} + +function ProfileError() { + return ( +
+
+
+

+ We couldn’t load your profile +

+

+ Try refreshing — your account is safe. +

+
+ ); +} diff --git a/apps/web/src/app/(shopper)/buyer/profile/page.tsx b/apps/web/src/app/(shopper)/buyer/profile/page.tsx new file mode 100644 index 00000000..82402770 --- /dev/null +++ b/apps/web/src/app/(shopper)/buyer/profile/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { ProfileClient } from "./ProfileClient"; + +export const metadata: Metadata = { + title: "Your profile — twizrr", + robots: { index: false, follow: false }, +}; + +export default function ProfilePage() { + return ; +} diff --git a/apps/web/src/app/(shopper)/buyer/saved/SavedClient.tsx b/apps/web/src/app/(shopper)/buyer/saved/SavedClient.tsx new file mode 100644 index 00000000..9d0e0f4e --- /dev/null +++ b/apps/web/src/app/(shopper)/buyer/saved/SavedClient.tsx @@ -0,0 +1,50 @@ +import Link from "next/link"; +import { ArrowRight, Bookmark } from "lucide-react"; + +/** + * Saved posts route — backend-pending placeholder. + * + * Backend status (verified): `apps/backend/src/modules/post/post.controller.ts` + * exposes POST and DELETE for /posts/:id/save, and the Prisma schema has a + * SavedPost model, but there is no list endpoint to fetch the authenticated + * user's saved posts. This route ships with a polished empty state so the + * existing ProfileMenuDrawer's link doesn't 404. Listing UI lands once the + * backend GET endpoint ships — see PR description. + */ +export function SavedClient() { + return ( +
+
+

+ Saved posts +

+

+ Posts you bookmark from the feed will live here. +

+
+ +
+
+
+

+ Saved posts coming soon +

+

+ We’re wiring up the list view. Posts you save will appear here + as soon as it ships. +

+ + Browse posts +
+
+ ); +} diff --git a/apps/web/src/app/(shopper)/buyer/saved/page.tsx b/apps/web/src/app/(shopper)/buyer/saved/page.tsx new file mode 100644 index 00000000..3cc159d6 --- /dev/null +++ b/apps/web/src/app/(shopper)/buyer/saved/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { SavedClient } from "./SavedClient"; + +export const metadata: Metadata = { + title: "Saved posts — twizrr", + robots: { index: false, follow: false }, +}; + +export default function SavedPage() { + return ; +} diff --git a/apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx b/apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx new file mode 100644 index 00000000..1afc9e4c --- /dev/null +++ b/apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx @@ -0,0 +1,440 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useEffect, useRef, useState, type ChangeEvent } from "react"; +import { + AlertCircle, + Bell, + Camera, + CheckCircle2, + Lock, + User, +} from "lucide-react"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { useToast } from "@/components/ui/Toast"; +import { cn } from "@/lib/utils"; +import { + fetchOwnProfile, + updateMe, + uploadAvatar, + type OwnProfile, + type UpdateProfileError, +} from "@/lib/user"; + +type LoadState = "loading" | "ready" | "error"; + +interface FormState { + displayName: string; + username: string; + bio: string; + profilePhotoUrl: string; +} + +function profileToForm(profile: OwnProfile): FormState { + return { + displayName: profile.displayName ?? "", + username: profile.username ?? "", + bio: profile.bio ?? "", + profilePhotoUrl: profile.profilePhotoUrl ?? "", + }; +} + +function isUpdateProfileError(value: unknown): value is UpdateProfileError { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.message === "string" || typeof v.code === "string"; +} + +function formatLockDate(iso: string | undefined): string | null { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + return new Intl.DateTimeFormat("en-NG", { + day: "numeric", + month: "short", + year: "numeric", + }).format(date); +} + +export function SettingsClient() { + const { toast } = useToast(); + const fileInputRef = useRef(null); + + const [state, setState] = useState("loading"); + const [profile, setProfile] = useState(null); + const [form, setForm] = useState(null); + + const [saving, setSaving] = useState(false); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const [usernameError, setUsernameError] = useState(null); + const [avatarError, setAvatarError] = useState(null); + + 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; + }; + }, []); + + function update(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) { + 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 = {}; + 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); + } + } + + if (state === "loading") return ; + if (state === "error" || !profile || !form) return ; + + return ( +
+
+

+ Settings +

+

+ Manage how you appear on twizrr and how we reach you. +

+
+ + {/* Account */} +
+ {/* Avatar */} +
+
+ {form.profilePhotoUrl ? ( + {profile.displayName + ) : ( +
+
+ )} +
+
+ + +

+ JPG, PNG, or WEBP up to 5MB. +

+ {avatarError && ( +

+ {avatarError} +

+ )} +
+
+ +
+ update("displayName", e.target.value)} + maxLength={80} + showCharacterCount + /> + update("username", e.target.value)} + onBlur={(e) => handleUsernameBlur(e.target.value)} + maxLength={20} + error={usernameError ?? undefined} + helperText={ + usernameError + ? undefined + : "Lowercase letters, numbers, and underscores only." + } + /> + update("bio", e.target.value)} + maxLength={180} + showCharacterCount + /> +
+
+ + {/* Contact */} +
+ + +
+ + {/* Security */} +
+ +
+ + {/* Preferences */} +
+
+
+
+ + {/* Save bar */} +
+ +
+
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function ReadOnlyRow({ + label, + value, + verified, +}: { + label: string; + value: string; + verified: boolean; +}) { + return ( +
+
+

+ {label} +

+

+ {value} +

+
+ + {verified ? "Verified" : "Unverified"} + +
+ ); +} + +function SettingsSkeleton() { + return ( +
+ + + + + + +
+ ); +} + +function SettingsError() { + return ( +
+
+
+

+ We couldn’t load your settings +

+

+ Try refreshing — your account is safe. +

+
+ ); +} diff --git a/apps/web/src/app/(shopper)/buyer/settings/page.tsx b/apps/web/src/app/(shopper)/buyer/settings/page.tsx new file mode 100644 index 00000000..a1a38624 --- /dev/null +++ b/apps/web/src/app/(shopper)/buyer/settings/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { SettingsClient } from "./SettingsClient"; + +export const metadata: Metadata = { + title: "Settings — twizrr", + robots: { index: false, follow: false }, +}; + +export default function SettingsPage() { + return ; +} diff --git a/apps/web/src/app/(shopper)/buyer/wishlist/WishlistClient.tsx b/apps/web/src/app/(shopper)/buyer/wishlist/WishlistClient.tsx new file mode 100644 index 00000000..8c5d7391 --- /dev/null +++ b/apps/web/src/app/(shopper)/buyer/wishlist/WishlistClient.tsx @@ -0,0 +1,260 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { AlertCircle, ArrowRight, Heart, Store } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { useToast } from "@/components/ui/Toast"; +import { cn, formatKobo } from "@/lib/utils"; +import { + fetchWishlist, + toggleWishlist, + type WishlistItem, +} from "@/lib/wishlist"; + +type LoadState = "loading" | "ready" | "error"; + +export function WishlistClient() { + const { toast } = useToast(); + const [state, setState] = useState("loading"); + const [items, setItems] = useState([]); + const [pendingId, setPendingId] = useState(null); + + const load = useCallback(async () => { + 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; + }; + }, []); + + async function handleRemove(item: WishlistItem) { + if (pendingId) return; + setPendingId(item.productId); + // Optimistic: drop the row immediately, restore on failure. + const previous = items; + setItems((rows) => rows.filter((r) => r.productId !== item.productId)); + try { + await toggleWishlist(item.productId); + } catch { + setItems(previous); + toast("We couldn't remove that item. Please try again.", { + variant: "error", + }); + } finally { + setPendingId(null); + } + } + + return ( +
+
+

+ Your wishlist +

+

+ Products you starred from the feed or product pages. +

+
+ + {state === "loading" && } + {state === "error" && } + {state === "ready" && + (items.length === 0 ? ( + + ) : ( +
    + {items.map((item) => ( +
  • + handleRemove(item)} + /> +
  • + ))} +
+ ))} +
+ ); +} + +interface WishlistCardProps { + item: WishlistItem; + pending: boolean; + onRemove: () => void; +} + +function WishlistCard({ item, pending, onRemove }: WishlistCardProps) { + const cardClass = cn( + "group relative flex h-full flex-col overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--card)]", + "transition-colors hover:border-[var(--color-saffron)]", + ); + const inner = ( + <> +
+ {item.imageUrl ? ( + {item.name} + ) : ( +
+
+ )} + +
+
+

+ {item.name} +

+ {item.store?.name && ( +

+ {item.store.name} +

+ )} +
+ {item.retailPriceKobo ? ( +

+ {formatKobo(item.retailPriceKobo)} +

+ ) : ( +

+ Price unavailable +

+ )} + {!item.inStock && ( +

+ Out of stock +

+ )} +
+
+ + ); + + if (item.productCode) { + return ( + + {inner} + + ); + } + return
{inner}
; +} + +function WishlistSkeleton() { + return ( + + ); +} + +function WishlistEmpty() { + return ( +
+
+
+

+ Your wishlist is empty +

+

+ Tap the heart on a product to save it for later. +

+ + Browse products +
+ ); +} + +function WishlistError({ onRetry }: { onRetry: () => void }) { + return ( +
+
+
+

+ We couldn’t load your wishlist +

+

+ Try refreshing in a moment. +

+
+ +
+
+ ); +} diff --git a/apps/web/src/app/(shopper)/buyer/wishlist/page.tsx b/apps/web/src/app/(shopper)/buyer/wishlist/page.tsx new file mode 100644 index 00000000..a5cd8f51 --- /dev/null +++ b/apps/web/src/app/(shopper)/buyer/wishlist/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { WishlistClient } from "./WishlistClient"; + +export const metadata: Metadata = { + title: "Your wishlist — twizrr", + robots: { index: false, follow: false }, +}; + +export default function WishlistPage() { + return ; +} diff --git a/apps/web/src/lib/user.ts b/apps/web/src/lib/user.ts new file mode 100644 index 00000000..9a590403 --- /dev/null +++ b/apps/web/src/lib/user.ts @@ -0,0 +1,301 @@ +/** + * User / profile helpers — read- and action-side typed API for the + * shopper profile, settings, and measurements screens (W-12). + * + * Endpoint contracts verified from: + * apps/backend/src/domains/users/user/user.controller.ts + * apps/backend/src/domains/users/user/user.service.ts (getMe / toOwnProfile) + * apps/backend/src/domains/users/user/dto/{update-user,update-measurements,update-addresses}.dto.ts + * apps/backend/src/domains/platform/upload/upload.service.ts + * + * The W-09a checkout flow already wired fetchMe / fetchSavedAddresses / + * saveAddresses against the same backend. Those helpers stay in + * lib/checkout.ts (so existing W-09a / W-11 imports keep working) and + * this module re-exports them so W-12 has one canonical import path. + */ + +import { api } from "@/lib/api"; +export { + fetchMe, + fetchSavedAddresses, + saveAddresses, + formatAddressLine, +} from "@/lib/checkout"; +export type { CheckoutUser, DeliveryAddress } from "@/lib/checkout"; + +// ─── Own-profile shape (strict whitelist from /users/me) ────────────────────── + +export interface Measurements { + bustCm: number | null; + waistCm: number | null; + hipsCm: number | null; + heightCm: number | null; + shoeSizeEU: number | null; + footLengthCm: number | null; +} + +export interface OwnProfile { + id: string; + username: string | null; + displayName: string | null; + bio: string | null; + profilePhotoUrl: string | null; + email: string; + phone: string; + dateOfBirth: string | null; + bodyMeasurements: Measurements | null; + isEmailVerified: boolean; + isPhoneVerified: boolean; + isMinor: boolean; + lastUsernameChangedAt: string | null; + memberSince: string; + followerCount: number; + followingCount: number; + postCount: number; +} + +interface RawOwnProfile { + id?: unknown; + username?: unknown; + displayName?: unknown; + bio?: unknown; + profilePhotoUrl?: unknown; + email?: unknown; + phone?: unknown; + dateOfBirth?: unknown; + bodyMeasurements?: unknown; + isEmailVerified?: unknown; + isPhoneVerified?: unknown; + isMinor?: unknown; + lastUsernameChangedAt?: unknown; + memberSince?: unknown; + followerCount?: unknown; + followingCount?: unknown; + postCount?: unknown; +} + +function toStringOrNull(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function toBool(value: unknown): boolean { + return value === true; +} + +function toInt(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.trunc(value); + } + return 0; +} + +function toOptionalNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +function normalizeMeasurements(raw: unknown): Measurements | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record; + return { + bustCm: toOptionalNumber(r.bustCm), + waistCm: toOptionalNumber(r.waistCm), + hipsCm: toOptionalNumber(r.hipsCm), + heightCm: toOptionalNumber(r.heightCm), + shoeSizeEU: toOptionalNumber(r.shoeSizeEU), + footLengthCm: toOptionalNumber(r.footLengthCm), + }; +} + +function normalizeOwnProfile(raw: RawOwnProfile): OwnProfile | null { + if ( + typeof raw?.id !== "string" || + typeof raw?.email !== "string" || + typeof raw?.phone !== "string" + ) { + return null; + } + return { + id: raw.id, + username: toStringOrNull(raw.username), + displayName: toStringOrNull(raw.displayName), + bio: toStringOrNull(raw.bio), + profilePhotoUrl: toStringOrNull(raw.profilePhotoUrl), + email: raw.email, + phone: raw.phone, + dateOfBirth: toStringOrNull(raw.dateOfBirth), + bodyMeasurements: normalizeMeasurements(raw.bodyMeasurements), + isEmailVerified: toBool(raw.isEmailVerified), + isPhoneVerified: toBool(raw.isPhoneVerified), + isMinor: toBool(raw.isMinor), + lastUsernameChangedAt: toStringOrNull(raw.lastUsernameChangedAt), + memberSince: toStringOrNull(raw.memberSince) ?? "", + followerCount: toInt(raw.followerCount), + followingCount: toInt(raw.followingCount), + postCount: toInt(raw.postCount), + }; +} + +export async function fetchOwnProfile(): Promise { + const res = await api.get("/users/me"); + return normalizeOwnProfile(res); +} + +// ─── Update profile (PUT /users/me) ─────────────────────────────────────────── + +export interface UpdateProfilePayload { + displayName?: string; + username?: string; + bio?: string; + profilePhotoUrl?: string; +} + +export interface UpdateProfileError { + message: string; + code?: string; + unlocksAt?: string; +} + +function isApiErrorLike(value: unknown): value is UpdateProfileError { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return typeof v.message === "string" || typeof v.code === "string"; +} + +export async function updateMe( + payload: UpdateProfilePayload, +): Promise { + try { + const res = await api.put("/users/me", payload); + return normalizeOwnProfile(res); + } catch (err) { + if (isApiErrorLike(err)) { + throw err; + } + throw { message: "Could not save your changes. Please try again." }; + } +} + +// ─── Update measurements (PUT /users/me/measurements) ───────────────────────── + +export type MeasurementsPayload = Partial; + +export async function updateMeasurements( + payload: MeasurementsPayload, +): Promise { + // Strip nulls/undefineds — the backend DTO treats every field as optional + // and we want partial saves to work cleanly. + const filtered: Record = {}; + for (const [key, value] of Object.entries(payload)) { + if (typeof value === "number" && Number.isFinite(value)) { + filtered[key] = value; + } + } + const res = await api.put<{ bodyMeasurements?: unknown }>( + "/users/me/measurements", + filtered, + ); + return normalizeMeasurements(res?.bodyMeasurements); +} + +// ─── Upload avatar via POST /upload/image ───────────────────────────────────── +// +// Uses raw fetch (not the api helper) because multipart/form-data needs the +// browser to set the Content-Type boundary automatically — same pattern as +// lib/product-listing.ts's uploadProductImage. + +const API_BASE = (process.env.NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); + +export type AvatarModerationStatus = "SAFE" | "SENSITIVE" | "BLOCKED"; + +export interface UploadAvatarResult { + url: string; + moderationStatus: AvatarModerationStatus; + cloudinaryPublicId: string; +} + +export interface UploadAvatarError { + message: string; + code?: string; +} + +interface RawUploadResponse { + url?: unknown; + moderationStatus?: unknown; + cloudinaryPublicId?: unknown; +} + +function isUploadResult(value: unknown): value is UploadAvatarResult { + if (!value || typeof value !== "object") return false; + const v = value as RawUploadResponse; + return ( + typeof v.url === "string" && + (v.moderationStatus === "SAFE" || + v.moderationStatus === "SENSITIVE" || + v.moderationStatus === "BLOCKED") && + typeof v.cloudinaryPublicId === "string" + ); +} + +export async function uploadAvatar(file: File): Promise { + if (!API_BASE) { + throw { + message: "API URL is not configured.", + code: "API_NOT_CONFIGURED", + } satisfies UploadAvatarError; + } + + const form = new FormData(); + form.append("file", file); + form.append("uploadType", "PROFILE_PHOTO"); + form.append("contentType", "PROFILE_PHOTO"); + + const res = await fetch(`${API_BASE}/upload/image`, { + method: "POST", + credentials: "include", + body: form, + }); + + let json: unknown; + try { + json = await res.json(); + } catch { + throw { + message: `Upload failed with status ${res.status}.`, + code: "INVALID_RESPONSE", + } satisfies UploadAvatarError; + } + + if (!res.ok) { + throw json as UploadAvatarError; + } + + // Unwrap { success: true, data: {...} } envelope + const data = + json !== null && + typeof json === "object" && + "data" in (json as Record) + ? (json as { data: unknown }).data + : json; + + if (!isUploadResult(data)) { + throw { + message: "Upload response was unexpected.", + code: "INVALID_RESPONSE", + } satisfies UploadAvatarError; + } + + if (data.moderationStatus === "BLOCKED") { + throw { + message: + "This image didn't pass our content checks. Please pick another.", + code: "MODERATION_BLOCKED", + } satisfies UploadAvatarError; + } + + return data; +} diff --git a/apps/web/src/lib/wishlist.ts b/apps/web/src/lib/wishlist.ts new file mode 100644 index 00000000..151edf94 --- /dev/null +++ b/apps/web/src/lib/wishlist.ts @@ -0,0 +1,192 @@ +/** + * Wishlist helpers — read- and action-side typed API for /buyer/wishlist. + * + * Endpoint contracts verified from: + * apps/backend/src/modules/wishlist/wishlist.controller.ts + * apps/backend/src/modules/wishlist/wishlist.service.ts (findAll) + * + * Field-leak posture: + * The backend's `findAll()` uses `include: { product: {} }` without a + * `select`, so the response carries the full Product record. That + * payload includes internal pricing fields (dropshipperPriceKobo, + * wholesalePriceKobo) and may also carry sourcedProductId / other + * internals depending on schema state. This module never spreads the + * raw record — it reads only the safe whitelist below. Extra fields + * on the wire are silently ignored; they never reach the DOM because + * nothing reads them. A row is dropped only when its essential + * whitelist fields can't be safely extracted. + * + * The wide-open backend select is flagged for follow-up in the PR. + */ + +import { api } from "@/lib/api"; + +// ─── Public wishlist shape (strict whitelist) ───────────────────────────────── + +export interface WishlistItemStore { + name: string | null; + verificationTier: string | null; +} + +export interface WishlistItem { + productId: string; + productCode: string | null; + name: string; + imageUrl: string | null; + retailPriceKobo: string | null; + compareAtPriceKobo: string | null; + savedAt: string; + inStock: boolean; + store: WishlistItemStore | null; +} + +// ─── Raw response typing (narrow — never spread) ────────────────────────────── + +interface RawWishlistProduct { + id?: unknown; + productCode?: unknown; + name?: unknown; + title?: unknown; + imageUrl?: unknown; + retailPriceKobo?: unknown; + compareAtPriceKobo?: unknown; +} + +interface RawWishlistStore { + businessName?: unknown; + storeName?: unknown; + verificationTier?: unknown; +} + +interface RawWishlistStockCache { + stock?: unknown; +} + +interface RawWishlistRow { + // The backend spreads the full Product into the row, so productId-equivalent + // fields land at the top level OR under `product`. We tolerate both shapes + // to be liberal about what we accept; we are strict about what we expose. + id?: unknown; + productId?: unknown; + productCode?: unknown; + name?: unknown; + title?: unknown; + imageUrl?: unknown; + retailPriceKobo?: unknown; + compareAtPriceKobo?: unknown; + savedAt?: unknown; + stockCache?: RawWishlistStockCache | null; + storeProfile?: RawWishlistStore | null; + product?: RawWishlistProduct | null; +} + +// ─── Coercion helpers ───────────────────────────────────────────────────────── + +function toOptionalString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function toOptionalKoboString(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value === "string") return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + if (typeof value === "bigint") return value.toString(); + return null; +} + +// ─── Joined-relation normalizers (explicit reads only — never spread) ──────── + +function normalizeStore( + raw: RawWishlistStore | null | undefined, +): WishlistItemStore | null { + if (!raw || typeof raw !== "object") return null; + // Prefer the public storeName; fall back to businessName. + const name = + typeof raw.storeName === "string" && raw.storeName.trim().length > 0 + ? raw.storeName + : typeof raw.businessName === "string" && + raw.businessName.trim().length > 0 + ? raw.businessName + : null; + return { + name, + verificationTier: + typeof raw.verificationTier === "string" ? raw.verificationTier : null, + }; +} + +function normalizeRow(raw: RawWishlistRow): WishlistItem | null { + // The wishlist service spreads ...s.product into the row, so the product + // id sits at row.id and other product fields at row.{name,imageUrl,...}. + // Some shapes may also carry a `product` sub-object — read either path, + // never spread either. + const product = raw.product ?? null; + + const productId = + typeof raw.productId === "string" + ? raw.productId + : typeof raw.id === "string" + ? raw.id + : typeof product?.id === "string" + ? product.id + : null; + if (!productId) return null; + + const name = + typeof raw.name === "string" && raw.name.trim().length > 0 + ? raw.name + : typeof raw.title === "string" && raw.title.trim().length > 0 + ? raw.title + : typeof product?.name === "string" && product.name.trim().length > 0 + ? product.name + : null; + if (!name) return null; + + const productCode = + toOptionalString(raw.productCode) ?? toOptionalString(product?.productCode); + const imageUrl = + toOptionalString(raw.imageUrl) ?? toOptionalString(product?.imageUrl); + + const retailPriceKobo = + toOptionalKoboString(raw.retailPriceKobo) ?? + toOptionalKoboString(product?.retailPriceKobo); + const compareAtPriceKobo = + toOptionalKoboString(raw.compareAtPriceKobo) ?? + toOptionalKoboString(product?.compareAtPriceKobo); + + const stock = + typeof raw.stockCache?.stock === "number" ? raw.stockCache.stock : 0; + const inStock = stock > 0; + + return { + productId, + productCode, + name, + imageUrl, + retailPriceKobo, + compareAtPriceKobo, + savedAt: typeof raw.savedAt === "string" ? raw.savedAt : "", + inStock, + store: normalizeStore(raw.storeProfile), + }; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export async function fetchWishlist(): Promise { + const res = await api.get("/wishlist"); + const rows: unknown[] = Array.isArray(res) + ? res + : Array.isArray((res as { data?: unknown[] })?.data) + ? ((res as { data: unknown[] }).data ?? []) + : []; + return rows + .map((row) => normalizeRow(row as RawWishlistRow)) + .filter((row): row is WishlistItem => row !== null); +} + +export async function toggleWishlist(productId: string): Promise { + await api.post(`/wishlist/toggle/${encodeURIComponent(productId)}`, {}); +} From 355e20408926fdd522515080574a49bc4e43356b Mon Sep 17 00:00:00 2001 From: SAHEED2010 Date: Sat, 23 May 2026 21:19:56 +1300 Subject: [PATCH 2/2] fix(web): address W-12 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../(shopper)/buyer/settings/SettingsClient.tsx | 10 ++++++++++ apps/web/src/lib/user.ts | 17 +++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx b/apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx index 1afc9e4c..65e6f44d 100644 --- a/apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx +++ b/apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx @@ -27,6 +27,8 @@ import { type LoadState = "loading" | "ready" | "error"; +const MAX_AVATAR_BYTES = 5 * 1024 * 1024; + interface FormState { displayName: string; username: string; @@ -108,6 +110,14 @@ export function SettingsClient() { const file = e.target.files?.[0]; if (!file) return; setAvatarError(null); + // Client-side enforcement of the 5MB cap advertised under the field. + // Without this guard, oversized files only fail after the upload round + // trip, which costs the buyer time and bandwidth. + if (file.size > MAX_AVATAR_BYTES) { + setAvatarError("Profile photo must be 5MB or smaller."); + if (fileInputRef.current) fileInputRef.current.value = ""; + return; + } setUploadingAvatar(true); try { const result = await uploadAvatar(file); diff --git a/apps/web/src/lib/user.ts b/apps/web/src/lib/user.ts index 9a590403..32e9eede 100644 --- a/apps/web/src/lib/user.ts +++ b/apps/web/src/lib/user.ts @@ -35,20 +35,23 @@ export interface Measurements { } export interface OwnProfile { - id: string; + // Repo convention: IDs suffix with `Id`, date/time fields suffix with `At`. + // The wire fields are `id`, `dateOfBirth`, `memberSince`; the normalizer + // re-keys them at this boundary. + userId: string; username: string | null; displayName: string | null; bio: string | null; profilePhotoUrl: string | null; email: string; phone: string; - dateOfBirth: string | null; + dateOfBirthAt: string | null; bodyMeasurements: Measurements | null; isEmailVerified: boolean; isPhoneVerified: boolean; isMinor: boolean; lastUsernameChangedAt: string | null; - memberSince: string; + memberSinceAt: string; followerCount: number; followingCount: number; postCount: number; @@ -119,21 +122,23 @@ function normalizeOwnProfile(raw: RawOwnProfile): OwnProfile | null { ) { return null; } + // Re-key wire fields at the normalization boundary so the rest of the app + // uses repo-convention names (`*Id` for ids, `*At` for date/time fields). return { - id: raw.id, + userId: raw.id, username: toStringOrNull(raw.username), displayName: toStringOrNull(raw.displayName), bio: toStringOrNull(raw.bio), profilePhotoUrl: toStringOrNull(raw.profilePhotoUrl), email: raw.email, phone: raw.phone, - dateOfBirth: toStringOrNull(raw.dateOfBirth), + dateOfBirthAt: toStringOrNull(raw.dateOfBirth), bodyMeasurements: normalizeMeasurements(raw.bodyMeasurements), isEmailVerified: toBool(raw.isEmailVerified), isPhoneVerified: toBool(raw.isPhoneVerified), isMinor: toBool(raw.isMinor), lastUsernameChangedAt: toStringOrNull(raw.lastUsernameChangedAt), - memberSince: toStringOrNull(raw.memberSince) ?? "", + memberSinceAt: toStringOrNull(raw.memberSince) ?? "", followerCount: toInt(raw.followerCount), followingCount: toInt(raw.followingCount), postCount: toInt(raw.postCount),