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..65e6f44d --- /dev/null +++ b/apps/web/src/app/(shopper)/buyer/settings/SettingsClient.tsx @@ -0,0 +1,450 @@ +"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"; + +const MAX_AVATAR_BYTES = 5 * 1024 * 1024; + +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); + // 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); + 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..32e9eede --- /dev/null +++ b/apps/web/src/lib/user.ts @@ -0,0 +1,306 @@ +/** + * 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 { + // 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; + dateOfBirthAt: string | null; + bodyMeasurements: Measurements | null; + isEmailVerified: boolean; + isPhoneVerified: boolean; + isMinor: boolean; + lastUsernameChangedAt: string | null; + memberSinceAt: 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; + } + // 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 { + 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, + dateOfBirthAt: toStringOrNull(raw.dateOfBirth), + bodyMeasurements: normalizeMeasurements(raw.bodyMeasurements), + isEmailVerified: toBool(raw.isEmailVerified), + isPhoneVerified: toBool(raw.isPhoneVerified), + isMinor: toBool(raw.isMinor), + lastUsernameChangedAt: toStringOrNull(raw.lastUsernameChangedAt), + memberSinceAt: 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)}`, {}); +}