Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
670 changes: 670 additions & 0 deletions apps/web/src/app/(shopper)/buyer/measurements/MeasurementsClient.tsx

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions apps/web/src/app/(shopper)/buyer/measurements/page.tsx
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 apps/web/src/app/(shopper)/buyer/profile/ProfileClient.tsx
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;
};
}, []);
Comment on lines +29 to +49
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Load profile state through React Query instead of effect-managed fetch.

This bypasses the app’s centralized query key/caching pattern for server state.

As per coding guidelines, "Use React Query (TanStack Query v5) for server state management with centralized queryKeys."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/app/`(shopper)/buyer/profile/ProfileClient.tsx around lines 29 -
49, The current useEffect-based loader (load/cancelled) in ProfileClient should
be replaced with a React Query useQuery that calls fetchOwnProfile so the app
uses centralized queryKeys and caching; remove the effect and instead call
useQuery with a descriptive key (e.g. ['ownProfile'] or similar), pass
fetchOwnProfile as the queryFn, and move the state updates into onSuccess (call
setProfile(fetched) and setState('ready')) and onError (setState('error'));
ensure you keep any necessary options (staleTime, refetchOnWindowFocus)
consistent with app conventions and reference the existing identifiers
fetchOwnProfile, setProfile, and setState when wiring the query.


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&rsquo;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>
);
}
11 changes: 11 additions & 0 deletions apps/web/src/app/(shopper)/buyer/profile/page.tsx
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 />;
}
50 changes: 50 additions & 0 deletions apps/web/src/app/(shopper)/buyer/saved/SavedClient.tsx
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&rsquo;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>
);
}
11 changes: 11 additions & 0 deletions apps/web/src/app/(shopper)/buyer/saved/page.tsx
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 />;
}
Loading
Loading