-
Notifications
You must be signed in to change notification settings - Fork 2
feat(web): add shopper profile settings and saved pages #326
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
0820576
feat(web): add shopper profile settings and saved pages
SAHEED2010 b67e707
Merge branch 'dev' into feat/web-user-profile
onerandomdevv 12f47eb
Merge branch 'dev' into feat/web-user-profile
SAHEED2010 355e204
fix(web): address W-12 review findings
SAHEED2010 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
670 changes: 670 additions & 0 deletions
670
apps/web/src/app/(shopper)/buyer/measurements/MeasurementsClient.tsx
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <MeasurementsClient />; | ||
| } |
234 changes: 234 additions & 0 deletions
234
apps/web/src/app/(shopper)/buyer/profile/ProfileClient.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<LoadState>("loading"); | ||
| const [profile, setProfile] = useState<OwnProfile | null>(null); | ||
| const [activeTab, setActiveTab] = useState<TabKey>("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 <ProfileSkeleton />; | ||
| if (state === "error" || !profile) return <ProfileError />; | ||
|
|
||
| const handle = profile.username ? `@${profile.username}` : null; | ||
| const displayName = profile.displayName ?? handle ?? "Your profile"; | ||
|
|
||
| return ( | ||
| <div className="mx-auto w-full max-w-2xl px-4 py-5 pb-12 sm:py-6"> | ||
| {/* Header */} | ||
| <header className="flex flex-col gap-4 sm:flex-row sm:items-center"> | ||
| <div className="relative h-20 w-20 shrink-0 overflow-hidden rounded-full bg-[var(--surface-muted)]"> | ||
| {profile.profilePhotoUrl ? ( | ||
| <Image | ||
| src={profile.profilePhotoUrl} | ||
| alt={displayName} | ||
| fill | ||
| className="object-cover" | ||
| sizes="80px" | ||
| /> | ||
| ) : ( | ||
| <div className="flex h-full w-full items-center justify-center"> | ||
| <User | ||
| className="h-8 w-8 text-[var(--muted-foreground)]" | ||
| aria-hidden="true" | ||
| /> | ||
| </div> | ||
| )} | ||
| </div> | ||
| <div className="min-w-0 flex-1"> | ||
| <h1 className="font-cabinet text-xl font-bold text-[var(--color-espresso)]"> | ||
| {displayName} | ||
| </h1> | ||
| {handle && ( | ||
| <p className="font-cabinet text-sm text-[var(--muted-foreground)]"> | ||
| {handle} | ||
| </p> | ||
| )} | ||
| {profile.bio && ( | ||
| <p className="mt-2 font-cabinet text-sm text-[var(--color-espresso)]"> | ||
| {profile.bio} | ||
| </p> | ||
| )} | ||
| </div> | ||
| <div className="shrink-0"> | ||
| <Link href="/buyer/settings"> | ||
| <Button | ||
| variant="secondary" | ||
| size="md" | ||
| leftIcon={<Pencil className="h-4 w-4" aria-hidden="true" />} | ||
| > | ||
| Edit profile | ||
| </Button> | ||
| </Link> | ||
| </div> | ||
| </header> | ||
|
|
||
| {/* Stats */} | ||
| <section className="mt-5 flex items-center gap-6 border-y border-[var(--border)] py-4"> | ||
| <Stat label="Posts" value={profile.postCount} /> | ||
| <Stat label="Followers" value={profile.followerCount} /> | ||
| <Stat label="Following" value={profile.followingCount} /> | ||
| </section> | ||
|
|
||
| {/* Tabs */} | ||
| <nav | ||
| aria-label="Profile content" | ||
| className="-mx-4 mt-2 flex items-center gap-1 overflow-x-auto border-b border-[var(--border)] px-4" | ||
| > | ||
| {TABS.map((tab) => { | ||
| const isActive = tab.key === activeTab; | ||
| return ( | ||
| <button | ||
| key={tab.key} | ||
| type="button" | ||
| onClick={() => setActiveTab(tab.key)} | ||
| aria-pressed={isActive} | ||
| className={cn( | ||
| "flex shrink-0 items-center gap-1.5 border-b-2 px-3 py-2 font-cabinet text-sm font-medium transition-colors min-h-[44px]", | ||
| isActive | ||
| ? "border-[var(--color-saffron)] text-[var(--color-espresso)]" | ||
| : "border-transparent text-[var(--muted-foreground)] hover:text-[var(--color-espresso)]", | ||
| )} | ||
| > | ||
| {tab.label} | ||
| {tab.soon && ( | ||
| <Badge variant="default" className="opacity-70"> | ||
| Soon | ||
| </Badge> | ||
| )} | ||
| </button> | ||
| ); | ||
| })} | ||
| </nav> | ||
|
|
||
| {/* Tab content — every tab is a polished empty state for now */} | ||
| <section className="mt-6"> | ||
| <TabPlaceholder tab={activeTab} /> | ||
| </section> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function Stat({ label, value }: { label: string; value: number }) { | ||
| return ( | ||
| <div> | ||
| <p className="font-mono text-base font-bold text-[var(--color-espresso)]"> | ||
| {value} | ||
| </p> | ||
| <p className="font-cabinet text-xs text-[var(--muted-foreground)]"> | ||
| {label} | ||
| </p> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function TabPlaceholder({ tab }: { tab: TabKey }) { | ||
| const labels: Record<TabKey, { title: string; body: string }> = { | ||
| 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 ( | ||
| <div className="flex flex-col items-center rounded-2xl border border-dashed border-[var(--border)] bg-[var(--card)] py-10 text-center"> | ||
| <p className="font-cabinet text-sm font-semibold text-[var(--color-espresso)]"> | ||
| {title} | ||
| </p> | ||
| <p className="mt-1 max-w-xs font-cabinet text-xs text-[var(--muted-foreground)]"> | ||
| {body} | ||
| </p> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function ProfileSkeleton() { | ||
| return ( | ||
| <div className="mx-auto w-full max-w-2xl px-4 py-5 sm:py-6"> | ||
| <div className="flex flex-col gap-4 sm:flex-row sm:items-center"> | ||
| <Skeleton className="h-20 w-20 rounded-full" /> | ||
| <div className="flex-1 space-y-2"> | ||
| <Skeleton className="h-5 w-44" /> | ||
| <Skeleton className="h-4 w-28" /> | ||
| <Skeleton className="h-3 w-3/4" /> | ||
| </div> | ||
| <Skeleton className="h-11 w-28 rounded-full" /> | ||
| </div> | ||
| <Skeleton className="mt-5 h-16 w-full" /> | ||
| <Skeleton className="mt-4 h-10 w-full" /> | ||
| <Skeleton className="mt-6 h-40 w-full rounded-2xl" /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function ProfileError() { | ||
| return ( | ||
| <div className="mx-auto flex w-full max-w-md flex-col items-center px-4 py-16 text-center"> | ||
| <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--surface-muted)]"> | ||
| <AlertCircle | ||
| className="h-7 w-7 text-[var(--muted-foreground)]" | ||
| aria-hidden="true" | ||
| /> | ||
| </div> | ||
| <h1 className="font-cabinet text-xl font-bold text-[var(--color-espresso)]"> | ||
| We couldn’t load your profile | ||
| </h1> | ||
| <p className="mt-2 font-cabinet text-sm text-[var(--muted-foreground)]"> | ||
| Try refreshing — your account is safe. | ||
| </p> | ||
| </div> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <ProfileClient />; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div className="mx-auto w-full max-w-2xl px-4 py-5 pb-12 sm:py-6"> | ||
| <header className="mb-6"> | ||
| <h1 className="font-cabinet text-2xl font-bold text-[var(--color-espresso)]"> | ||
| Saved posts | ||
| </h1> | ||
| <p className="mt-1 font-cabinet text-sm text-[var(--muted-foreground)]"> | ||
| Posts you bookmark from the feed will live here. | ||
| </p> | ||
| </header> | ||
|
|
||
| <div className="flex flex-col items-center rounded-2xl border border-dashed border-[var(--border)] bg-[var(--card)] px-6 py-12 text-center"> | ||
| <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--surface-muted)]"> | ||
| <Bookmark | ||
| className="h-7 w-7 text-[var(--color-saffron)]" | ||
| aria-hidden="true" | ||
| /> | ||
| </div> | ||
| <h2 className="font-cabinet text-lg font-semibold text-[var(--color-espresso)]"> | ||
| Saved posts coming soon | ||
| </h2> | ||
| <p className="mt-2 max-w-sm font-cabinet text-sm text-[var(--muted-foreground)]"> | ||
| We’re wiring up the list view. Posts you save will appear here | ||
| as soon as it ships. | ||
| </p> | ||
| <Link | ||
| href="/explore" | ||
| className="mt-6 inline-flex min-h-[44px] items-center gap-1 rounded-full bg-[var(--color-saffron)] px-5 py-2.5 font-cabinet text-sm font-medium text-[var(--color-espresso)] transition-colors hover:bg-[var(--color-saffron-dark)]" | ||
| > | ||
| Browse posts | ||
| <ArrowRight className="h-4 w-4" aria-hidden="true" /> | ||
| </Link> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <SavedClient />; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Load profile state through React Query instead of effect-managed fetch.
This bypasses the app’s centralized query key/caching pattern for server state.
As per coding guidelines, "Use React Query (TanStack Query v5) for server state management with centralized queryKeys."
🤖 Prompt for AI Agents