+
{product.store.handle && (
- @{product.store.handle}
+ {product.store.handle}
)}
{product.store.handle && (
Visit
diff --git a/apps/web/src/app/(public)/u/[username]/page.tsx b/apps/web/src/app/(public)/u/[username]/page.tsx
new file mode 100644
index 00000000..ae1ffc6d
--- /dev/null
+++ b/apps/web/src/app/(public)/u/[username]/page.tsx
@@ -0,0 +1,169 @@
+import Image from "next/image";
+import { notFound } from "next/navigation";
+import { CalendarDays, UserRound } from "lucide-react";
+
+interface PublicUserProfile {
+ id: string;
+ username: string | null;
+ displayName: string | null;
+ bio: string | null;
+ profilePhotoUrl: string | null;
+ memberSince: string;
+ followerCount: number;
+ followingCount: number;
+ postCount: number;
+}
+
+interface PublicUserPageProps {
+ params: {
+ username: string;
+ };
+}
+
+const API_BASE = (process.env.NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "");
+
+export default async function PublicUserPage({ params }: PublicUserPageProps) {
+ const profile = await fetchPublicUser(params.username);
+
+ if (profile.status === "not-found") {
+ notFound();
+ }
+
+ if (profile.status === "error") {
+ return (
+
+
+
+
+
+ We could not load this profile
+
+
+ Please try again in a moment.
+
+
+ );
+ }
+
+ const user = profile.data;
+ const displayName = user.displayName || user.username || "twizrr user";
+ const handle = user.username ?? null;
+
+ return (
+
+
+
+
+
+ {user.profilePhotoUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {displayName}
+
+ {handle ? (
+
+ {handle}
+
+ ) : null}
+
+
+
+
+ {user.bio ? (
+
+ {user.bio}
+
+ ) : null}
+
+
+
+
+
+
+
+ {user.memberSince ? (
+
+
+ Joined {formatMonthYear(user.memberSince)}
+
+ ) : null}
+
+
+ );
+}
+
+function ProfileStat({ label, value }: { label: string; value: number }) {
+ return (
+
+
+ {label}
+
+
+ {value.toLocaleString("en-NG")}
+
+
+ );
+}
+
+function formatMonthYear(value: string): string {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "recently";
+ return new Intl.DateTimeFormat("en-NG", {
+ month: "long",
+ year: "numeric",
+ }).format(date);
+}
+
+async function fetchPublicUser(
+ username: string,
+): Promise<
+ | { status: "ok"; data: PublicUserProfile }
+ | { status: "not-found" }
+ | { status: "error" }
+> {
+ if (!API_BASE) {
+ return { status: "error" };
+ }
+
+ try {
+ const res = await fetch(
+ `${API_BASE}/users/${encodeURIComponent(username)}`,
+ {
+ cache: "no-store",
+ },
+ );
+
+ if (res.status === 404) {
+ return { status: "not-found" };
+ }
+
+ if (!res.ok) {
+ return { status: "error" };
+ }
+
+ const json = (await res.json()) as {
+ data?: PublicUserProfile;
+ };
+
+ if (!json.data || typeof json.data.id !== "string") {
+ return { status: "error" };
+ }
+
+ return { status: "ok", data: json.data };
+ } catch {
+ return { status: "error" };
+ }
+}
diff --git a/apps/web/src/app/(settings)/settings/account/page.tsx b/apps/web/src/app/(settings)/settings/account/page.tsx
new file mode 100644
index 00000000..e12f219c
--- /dev/null
+++ b/apps/web/src/app/(settings)/settings/account/page.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from "next";
+import { ShopperShell } from "@/app/(shopper)/buyer/_components/ShopperShell";
+import { AccountSettingsContent } from "@/components/settings/AccountSettingsContent";
+
+export const metadata: Metadata = {
+ title: "Account Settings - twizrr",
+ robots: { index: false, follow: false },
+};
+
+export default function AccountSettingsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(settings)/settings/page.tsx b/apps/web/src/app/(settings)/settings/page.tsx
new file mode 100644
index 00000000..973befbb
--- /dev/null
+++ b/apps/web/src/app/(settings)/settings/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function SettingsIndexPage() {
+ redirect("/settings/account");
+}
diff --git a/apps/web/src/app/(settings)/settings/profile/page.tsx b/apps/web/src/app/(settings)/settings/profile/page.tsx
new file mode 100644
index 00000000..6ea3583f
--- /dev/null
+++ b/apps/web/src/app/(settings)/settings/profile/page.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from "next";
+import { ShopperShell } from "@/app/(shopper)/buyer/_components/ShopperShell";
+import { SettingsClient } from "@/app/(shopper)/buyer/settings/SettingsClient";
+
+export const metadata: Metadata = {
+ title: "Profile Settings - twizrr",
+ robots: { index: false, follow: false },
+};
+
+export default function ProfileSettingsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(settings)/settings/store/account/page.tsx b/apps/web/src/app/(settings)/settings/store/account/page.tsx
new file mode 100644
index 00000000..d64a30fd
--- /dev/null
+++ b/apps/web/src/app/(settings)/settings/store/account/page.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from "next";
+import { StoreShell } from "@/app/(store)/store/_components/StoreShell";
+import { StoreSettingsClient } from "@/app/(store)/store/settings/StoreSettingsClient";
+
+export const metadata: Metadata = {
+ title: "Store Settings - twizrr",
+ robots: { index: false, follow: false },
+};
+
+export default function StoreAccountSettingsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(settings)/settings/store/page.tsx b/apps/web/src/app/(settings)/settings/store/page.tsx
new file mode 100644
index 00000000..3e33f5d1
--- /dev/null
+++ b/apps/web/src/app/(settings)/settings/store/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function StoreSettingsIndexPage() {
+ redirect("/settings/store/account");
+}
diff --git a/apps/web/src/app/(settings)/settings/store/profile/page.tsx b/apps/web/src/app/(settings)/settings/store/profile/page.tsx
new file mode 100644
index 00000000..f5784a93
--- /dev/null
+++ b/apps/web/src/app/(settings)/settings/store/profile/page.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from "next";
+import { StoreShell } from "@/app/(store)/store/_components/StoreShell";
+import { StoreSettingsClient } from "@/app/(store)/store/settings/StoreSettingsClient";
+
+export const metadata: Metadata = {
+ title: "Store Profile Settings - twizrr",
+ robots: { index: false, follow: false },
+};
+
+export default function StoreProfileSettingsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(shopper)/buyer/_components/ShopperShell.tsx b/apps/web/src/app/(shopper)/buyer/_components/ShopperShell.tsx
index 973ec27a..bea5939a 100644
--- a/apps/web/src/app/(shopper)/buyer/_components/ShopperShell.tsx
+++ b/apps/web/src/app/(shopper)/buyer/_components/ShopperShell.tsx
@@ -3,19 +3,18 @@
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import { AppShell } from "@/components/layout";
+import { useOwnProfile } from "@/hooks/useOwnProfile";
+import { useOwnerStore } from "@/hooks/useOwnerStore";
interface ShopperShellProps {
children: ReactNode;
}
-// Routes inside /buyer/* that should render without the 3-column social shell.
+// Routes that should render without the 3-column social shell.
// Checkout (W-09a) and the post-checkout confirmation (W-09b) are both
// focus-mode routes per apps/web/AGENTS.md ("Order confirmed — centered,
// minimal chrome").
-const STANDALONE_ROUTE_PREFIXES = [
- "/buyer/checkout",
- "/buyer/orders/confirmed",
-];
+const STANDALONE_ROUTE_PREFIXES = ["/checkout", "/orders/confirmed"];
function isStandaloneRoute(pathname: string): boolean {
return STANDALONE_ROUTE_PREFIXES.some(
@@ -25,6 +24,8 @@ function isStandaloneRoute(pathname: string): boolean {
export function ShopperShell({ children }: ShopperShellProps) {
const pathname = usePathname();
+ const { hasStore, isLoading } = useOwnerStore();
+ const { profile } = useOwnProfile();
if (isStandaloneRoute(pathname)) {
return (
@@ -39,7 +40,10 @@ export function ShopperShell({ children }: ShopperShellProps) {
mode="SHOPPING"
activeHref={pathname}
showRightPanel={true}
- hasStoreProfile={false}
+ hasStoreProfile={isLoading ? null : hasStore}
+ displayName={profile?.displayName ?? null}
+ profilePhotoUrl={profile?.profilePhotoUrl ?? null}
+ username={profile?.username ?? null}
>
{children}
diff --git a/apps/web/src/app/(shopper)/buyer/cart/CartCheckoutSheet.tsx b/apps/web/src/app/(shopper)/buyer/cart/CartCheckoutSheet.tsx
index 8acb724d..6ed53cd1 100644
--- a/apps/web/src/app/(shopper)/buyer/cart/CartCheckoutSheet.tsx
+++ b/apps/web/src/app/(shopper)/buyer/cart/CartCheckoutSheet.tsx
@@ -223,7 +223,7 @@ export function CartCheckoutSheet({
toast("Payment received. Confirming your order…", {
variant: "success",
});
- router.push(`/buyer/orders/confirmed/${order.orderId}`);
+ router.push(`/orders/confirmed/${order.orderId}`);
},
onCancel: () => {
setSubmitting(false);
diff --git a/apps/web/src/app/(shopper)/buyer/cart/CartClient.tsx b/apps/web/src/app/(shopper)/buyer/cart/CartClient.tsx
index f4a4a6ff..7dfd9e8d 100644
--- a/apps/web/src/app/(shopper)/buyer/cart/CartClient.tsx
+++ b/apps/web/src/app/(shopper)/buyer/cart/CartClient.tsx
@@ -17,6 +17,7 @@ import { Button } from "@/components/ui/Button";
import { Skeleton } from "@/components/ui/Skeleton";
import { useToast } from "@/components/ui/Toast";
import { useMode } from "@/hooks/useMode";
+import { getPublicStoreHref } from "@/lib/routes";
import { cn, formatKobo } from "@/lib/utils";
import {
clearCart,
@@ -287,7 +288,7 @@ function CartItemRow({
{item.store.handle ? (
{item.store.name}
diff --git a/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx b/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx
index 737bb0b6..a3beb2c4 100644
--- a/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx
+++ b/apps/web/src/app/(shopper)/buyer/checkout/[productId]/CheckoutClient.tsx
@@ -21,6 +21,7 @@ import { Skeleton } from "@/components/ui/Skeleton";
import { useToast } from "@/components/ui/Toast";
import { cn, formatKobo } from "@/lib/utils";
import { useMode } from "@/hooks/useMode";
+import { getPublicStoreHref } from "@/lib/routes";
import {
createDirectOrder,
createSourcedOrder,
@@ -362,7 +363,7 @@ export function CheckoutClient({
confirmParams.set("variantId", preselectedVariantId);
const qs = confirmParams.toString();
router.push(
- `/buyer/orders/confirmed/${order.orderId}${qs ? `?${qs}` : ""}`,
+ `/orders/confirmed/${order.orderId}${qs ? `?${qs}` : ""}`,
);
},
onCancel: () => {
@@ -672,7 +673,7 @@ export function CheckoutClient({
Sold by{" "}
{product.store.handle ? (
{product.store.name}
diff --git a/apps/web/src/app/(shopper)/buyer/feed/page.tsx b/apps/web/src/app/(shopper)/buyer/feed/page.tsx
deleted file mode 100644
index 1fbef3a5..00000000
--- a/apps/web/src/app/(shopper)/buyer/feed/page.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { Metadata } from "next";
-import { FeedClient } from "@/components/feed/FeedClient";
-
-export const metadata: Metadata = {
- title: "Feed | twizrr",
- description:
- "Your personalised feed of products and posts from stores you follow on twizrr.",
-};
-
-/**
- * /buyer/feed — authenticated shopper feed.
- *
- * This route is protected by middleware (cookie check). By the time this page
- * renders the user is guaranteed to have a session cookie, so we pass
- * isAuthenticated=true and default to the "for-you" tab.
- *
- * The ShopperShell layout (buyer/layout.tsx) wraps this page with AppShell
- * already, so we don't add AppShell here.
- */
-export default function BuyerFeedPage() {
- return (
- <>
-
-
-
- >
- );
-}
diff --git a/apps/web/src/app/(shopper)/buyer/measurements/page.tsx b/apps/web/src/app/(shopper)/buyer/measurements/page.tsx
deleted file mode 100644
index 155dd957..00000000
--- a/apps/web/src/app/(shopper)/buyer/measurements/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-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/orders/[id]/OrderDetailClient.tsx b/apps/web/src/app/(shopper)/buyer/orders/[id]/OrderDetailClient.tsx
index d8b3153f..e6d79a77 100644
--- a/apps/web/src/app/(shopper)/buyer/orders/[id]/OrderDetailClient.tsx
+++ b/apps/web/src/app/(shopper)/buyer/orders/[id]/OrderDetailClient.tsx
@@ -20,6 +20,7 @@ import { DeliveryCodeInput } from "@/components/order/DeliveryCodeInput";
import { EscrowBanner } from "@/components/order/EscrowBanner";
import { OrderTimeline } from "@/components/order/OrderTimeline";
import { RaiseDisputeDialog } from "@/components/order/RaiseDisputeDialog";
+import { getPublicStoreHref } from "@/lib/routes";
import { cn, formatKobo } from "@/lib/utils";
import {
cancelOrder,
@@ -217,7 +218,7 @@ function OrderDetailLoaded({
{/* Back link */}
@@ -309,7 +310,7 @@ function OrderDetailLoaded({
Sold by{" "}
{order.store?.handle ? (
{storeLabel}
@@ -603,7 +604,7 @@ function OrderDetailError() {
your list.
Back to your orders
diff --git a/apps/web/src/app/(shopper)/buyer/orders/confirmed/[orderId]/OrderConfirmedClient.tsx b/apps/web/src/app/(shopper)/buyer/orders/confirmed/[orderId]/OrderConfirmedClient.tsx
index d4117685..c5d9bd91 100644
--- a/apps/web/src/app/(shopper)/buyer/orders/confirmed/[orderId]/OrderConfirmedClient.tsx
+++ b/apps/web/src/app/(shopper)/buyer/orders/confirmed/[orderId]/OrderConfirmedClient.tsx
@@ -111,7 +111,7 @@ export function OrderConfirmedClient({
totalAmountKobo={order.totalAmountKobo}
deliveryAddress={order.deliveryAddress}
paymentMethodLabel="Card / Bank Transfer"
- trackOrderHref={`/buyer/orders/${order.id}`}
+ trackOrderHref={`/orders/${order.id}`}
/>
);
}
diff --git a/apps/web/src/app/(shopper)/buyer/profile/page.tsx b/apps/web/src/app/(shopper)/buyer/profile/page.tsx
deleted file mode 100644
index 82402770..00000000
--- a/apps/web/src/app/(shopper)/buyer/profile/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-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/settings/page.tsx b/apps/web/src/app/(shopper)/buyer/settings/page.tsx
deleted file mode 100644
index a1a38624..00000000
--- a/apps/web/src/app/(shopper)/buyer/settings/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-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/(store)/store/my-store/MyStoreClient.tsx b/apps/web/src/app/(store)/store/my-store/MyStoreClient.tsx
deleted file mode 100644
index 2190c339..00000000
--- a/apps/web/src/app/(store)/store/my-store/MyStoreClient.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import Link from "next/link";
-import {
- Copy,
- ExternalLink,
- Settings,
- ShieldCheck,
- Tag,
- Eye,
-} from "lucide-react";
-import { Button } from "@/components/ui/Button";
-import { Skeleton } from "@/components/ui/Skeleton";
-import { useToast } from "@/components/ui/Toast";
-import { StorePreviewCard } from "@/components/store-settings/StorePreviewCard";
-import { fetchOwnerStoreFull, type OwnerStoreFull } from "@/lib/store-settings";
-
-export function MyStoreClient() {
- const { toast } = useToast();
- const [store, setStore] = useState
(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(false);
-
- useEffect(() => {
- let cancelled = false;
- async function load() {
- try {
- const data = await fetchOwnerStoreFull();
- if (cancelled) return;
- setStore(data);
- setError(data === null);
- } catch {
- if (!cancelled) setError(true);
- } finally {
- if (!cancelled) setLoading(false);
- }
- }
- load();
- return () => {
- cancelled = true;
- };
- }, []);
-
- const handleCopyUrl = async () => {
- if (!store?.storeHandle) return;
- const url = `${window.location.origin}/@${store.storeHandle}`;
- try {
- await navigator.clipboard.writeText(url);
- toast("Store URL copied to clipboard", { variant: "success" });
- } catch {
- toast("Failed to copy URL", { variant: "error" });
- }
- };
-
- if (loading) {
- return (
-
-
-
- );
- }
-
- if (error || !store) {
- return (
-
-
- Could not load store details. Please try again later.
-
-
- );
- }
-
- const publicUrl = `/@${store.storeHandle}`;
-
- return (
-
-
-
- {/* Owner-only banner */}
-
-
-
- This is how shoppers see your store
-
-
-
-
-
-
-
- Quick Shortcuts
-
-
-
-
-
-
-
-
- Edit Profile
-
-
- Update details & bank
-
-
-
-
-
-
-
-
-
-
- Verification
-
-
- Manage your tier
-
-
-
-
-
-
-
-
-
-
- Products
-
-
- Add a new item
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/app/(store)/store/my-store/page.tsx b/apps/web/src/app/(store)/store/my-store/page.tsx
deleted file mode 100644
index 01371441..00000000
--- a/apps/web/src/app/(store)/store/my-store/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { Metadata } from "next";
-import { MyStoreClient } from "./MyStoreClient";
-
-export const metadata: Metadata = {
- title: "My Store — twizrr",
- robots: { index: false, follow: false },
-};
-
-export default function MyStorePage() {
- return ;
-}
diff --git a/apps/web/src/app/(store)/store/settings/page.tsx b/apps/web/src/app/(store)/store/settings/page.tsx
deleted file mode 100644
index b8ae5d4c..00000000
--- a/apps/web/src/app/(store)/store/settings/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { Metadata } from "next";
-import { StoreSettingsClient } from "./StoreSettingsClient";
-
-export const metadata: Metadata = {
- title: "Store Settings — twizrr",
- robots: { index: false, follow: false },
-};
-
-export default function StoreSettingsPage() {
- return ;
-}
diff --git a/apps/web/src/components/feed/FeedCard.tsx b/apps/web/src/components/feed/FeedCard.tsx
index e00c5ef7..06879031 100644
--- a/apps/web/src/components/feed/FeedCard.tsx
+++ b/apps/web/src/components/feed/FeedCard.tsx
@@ -6,6 +6,7 @@ import { Heart, MessageCircle, Bookmark, Share2, Zap } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { useMode } from "@/hooks/useMode";
+import { getPublicStoreHref } from "@/lib/routes";
import { formatKobo } from "@/lib/utils";
import type {
FeedItem,
@@ -81,6 +82,7 @@ function PostFeedCard({
const displayName = store?.storeName || author?.displayName || "Unknown";
const handle = store?.handle || author?.username || "";
const avatarUrl = store?.logoUrl || author?.profilePhotoUrl;
+ const avatarShape = store ? "rounded-xl" : "rounded-full";
const isVerified =
store?.tier === "TIER_1" ||
store?.tier === "TIER_2" ||
@@ -100,7 +102,9 @@ function PostFeedCard({
{/* Header */}
-
+
{avatarUrl && (
- {handle && @{handle}}
+ {handle && {handle}}
•
{timeAgo(item.createdAt)}
@@ -238,7 +242,7 @@ function ProductFeedCard({
{/* Header */}
-
+
{avatarUrl && (
{handle && (
- @{handle}
+ {handle}
)}
@@ -320,36 +324,55 @@ function ProductFeedCard({
function StoreFeedCard({ item }: { item: StoreCardFeedItem }) {
const store = item.store;
- return (
-
-
-
- {store.logoUrl && (
-
- )}
-
-
-
- {store.storeName}
-
+ const storeSummary = (
+ <>
+
+ {store.logoUrl && (
+
+ )}
+
+
+
+ {store.storeName}
+
+ {store.handle ? (
- @{store.handle}
-
-
- {store.followerCount} followers
+ {store.handle}
+ ) : null}
+
+ {store.followerCount} followers
+
+
+ >
+ );
+
+ const content = (
+
+ {store.handle ? (
+
+ {storeSummary}
+
+ ) : (
+
+ {storeSummary}
-
-
-
+ )}
+
+
);
+
+ return content;
}
function HashtagFeedCard({ item }: { item: HashtagFeedItem }) {
diff --git a/apps/web/src/components/feed/QuickBuySheet.tsx b/apps/web/src/components/feed/QuickBuySheet.tsx
index 1e088855..0026dd48 100644
--- a/apps/web/src/components/feed/QuickBuySheet.tsx
+++ b/apps/web/src/components/feed/QuickBuySheet.tsx
@@ -22,7 +22,10 @@ export function QuickBuySheet({
const handleProceedToCheckout = () => {
onOpenChange(false);
- router.push(`/buyer/checkout/${product.id}`);
+ const params = new URLSearchParams();
+ if (product.productCode) params.set("code", product.productCode);
+ const query = params.toString();
+ router.push(`/checkout/${product.id}${query ? `?${query}` : ""}`);
};
return (
diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx
index ea1cf09a..a740925d 100644
--- a/apps/web/src/components/layout/AppShell.tsx
+++ b/apps/web/src/components/layout/AppShell.tsx
@@ -1,21 +1,24 @@
-import type { ReactNode } from 'react'
-import { cn } from '@/lib/utils'
-import { BottomNav } from './BottomNav'
-import { LeftNav } from './LeftNav'
-import { MobileHeader } from './MobileHeader'
-import { RightPanel } from './RightPanel'
-import { WhatsAppFAB } from './WhatsAppFAB'
-import type { AppShellMode } from './navigation'
+import type { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+import { BottomNav } from "./BottomNav";
+import { LeftNav } from "./LeftNav";
+import { MobileHeader } from "./MobileHeader";
+import { RightPanel } from "./RightPanel";
+import { WhatsAppFAB } from "./WhatsAppFAB";
+import type { AppShellMode } from "./navigation";
interface AppShellProps {
- children: ReactNode
- mode: AppShellMode
- showRightPanel?: boolean
- centerMaxWidth?: boolean
- activeHref?: string
- hasStoreProfile?: boolean
- username?: string
- storeName?: string
+ children: ReactNode;
+ mode: AppShellMode;
+ showRightPanel?: boolean;
+ centerMaxWidth?: boolean;
+ activeHref?: string;
+ displayName?: string | null;
+ hasStoreProfile?: boolean | null;
+ profilePhotoUrl?: string | null;
+ username?: string | null;
+ storeName?: string;
+ storeHandle?: string | null;
}
export function AppShell({
@@ -24,41 +27,44 @@ export function AppShell({
showRightPanel = true,
centerMaxWidth = true,
activeHref,
- hasStoreProfile = true,
+ displayName,
+ hasStoreProfile = null,
+ profilePhotoUrl,
username,
storeName,
+ storeHandle,
}: AppShellProps) {
- const isStoreMode = mode === 'STORE'
+ const isStoreMode = mode === "STORE";
return (
-
+
{children}
@@ -72,8 +78,13 @@ export function AppShell({
-
+
- )
+ );
}
diff --git a/apps/web/src/components/layout/BottomNav.tsx b/apps/web/src/components/layout/BottomNav.tsx
index 52c82354..51e9c824 100644
--- a/apps/web/src/components/layout/BottomNav.tsx
+++ b/apps/web/src/components/layout/BottomNav.tsx
@@ -1,56 +1,72 @@
-import Link from 'next/link'
-import { cn } from '@/lib/utils'
-import { SHOPPING_NAV_ITEMS, STORE_NAV_ITEMS, type AppShellMode, type ShellNavItem } from './navigation'
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import {
+ getShoppingNavItems,
+ getStoreNavItems,
+ type AppShellMode,
+ type ShellNavItem,
+} from "./navigation";
interface BottomNavProps {
- mode: AppShellMode
- activeHref?: string
+ mode: AppShellMode;
+ activeHref?: string;
+ storeHandle?: string | null;
+ username?: string | null;
}
function isActiveItem(item: ShellNavItem, activeHref?: string): boolean {
if (!activeHref) {
- return false
+ return false;
}
- return activeHref === item.href || activeHref.startsWith(`${item.href}/`)
+ return activeHref === item.href || activeHref.startsWith(`${item.href}/`);
}
-export function BottomNav({ mode, activeHref }: BottomNavProps) {
- const isStoreMode = mode === 'STORE'
- const navItems = isStoreMode ? STORE_NAV_ITEMS : SHOPPING_NAV_ITEMS
+export function BottomNav({
+ mode,
+ activeHref,
+ storeHandle,
+ username,
+}: BottomNavProps) {
+ const isStoreMode = mode === "STORE";
+ const navItems = isStoreMode
+ ? getStoreNavItems(storeHandle, "bottom")
+ : getShoppingNavItems(username, "bottom");
return (
- )
+ );
}
diff --git a/apps/web/src/components/layout/LeftNav.tsx b/apps/web/src/components/layout/LeftNav.tsx
index 897565ec..6ee5f056 100644
--- a/apps/web/src/components/layout/LeftNav.tsx
+++ b/apps/web/src/components/layout/LeftNav.tsx
@@ -1,49 +1,61 @@
-import Image from 'next/image'
-import Link from 'next/link'
-import { ArrowLeft, LayoutDashboard, MoreHorizontal } from 'lucide-react'
-import { cn } from '@/lib/utils'
+import Image from "next/image";
+import Link from "next/link";
import {
- SHOPPING_NAV_ITEMS,
- STORE_NAV_ITEMS,
+ ArrowLeft,
+ LayoutDashboard,
+ MoreHorizontal,
+ PlusCircle,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import {
+ getShoppingNavItems,
+ getStoreNavItems,
type AppShellMode,
type ShellNavItem,
-} from './navigation'
+} from "./navigation";
interface LeftNavProps {
- mode: AppShellMode
- activeHref?: string
- hasStoreProfile?: boolean
- username?: string
+ mode: AppShellMode;
+ activeHref?: string;
+ hasStoreProfile?: boolean | null;
+ displayName?: string | null;
+ profilePhotoUrl?: string | null;
+ username?: string | null;
+ storeHandle?: string | null;
}
function isActiveItem(item: ShellNavItem, activeHref?: string): boolean {
if (!activeHref) {
- return false
+ return false;
}
- return activeHref === item.href || activeHref.startsWith(`${item.href}/`)
+ return activeHref === item.href || activeHref.startsWith(`${item.href}/`);
}
export function LeftNav({
mode,
activeHref,
- hasStoreProfile = true,
- username = 'username',
+ hasStoreProfile = null,
+ displayName,
+ profilePhotoUrl,
+ username,
+ storeHandle,
}: LeftNavProps) {
- const isStoreMode = mode === 'STORE'
- const navItems = isStoreMode ? STORE_NAV_ITEMS : SHOPPING_NAV_ITEMS
+ const isStoreMode = mode === "STORE";
+ const navItems = isStoreMode
+ ? getStoreNavItems(storeHandle, "left")
+ : getShoppingNavItems(username, "left");
+ const accountLabel = username ?? displayName ?? "Profile";
return (
+ );
+}
diff --git a/apps/web/src/hooks/useOwnProfile.ts b/apps/web/src/hooks/useOwnProfile.ts
new file mode 100644
index 00000000..d109e34a
--- /dev/null
+++ b/apps/web/src/hooks/useOwnProfile.ts
@@ -0,0 +1,38 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { fetchOwnProfile, type OwnProfile } from "@/lib/user";
+
+export function useOwnProfile() {
+ const [profile, setProfile] = useState
(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ let active = true;
+
+ async function loadProfile() {
+ try {
+ const nextProfile = await fetchOwnProfile();
+ if (active) {
+ setProfile(nextProfile);
+ }
+ } catch {
+ if (active) {
+ setProfile(null);
+ }
+ } finally {
+ if (active) {
+ setIsLoading(false);
+ }
+ }
+ }
+
+ void loadProfile();
+
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ return { profile, isLoading };
+}
diff --git a/apps/web/src/hooks/useOwnerStore.ts b/apps/web/src/hooks/useOwnerStore.ts
new file mode 100644
index 00000000..17185a94
--- /dev/null
+++ b/apps/web/src/hooks/useOwnerStore.ts
@@ -0,0 +1,107 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { fetchOwnerStoreFull, type OwnerStoreFull } from "@/lib/store-settings";
+
+export type OwnerStoreState = "loading" | "ready" | "none";
+
+export interface UseOwnerStoreResult {
+ store: OwnerStoreFull | null;
+ state: OwnerStoreState;
+ hasStore: boolean;
+ isLoading: boolean;
+ refresh: () => Promise;
+}
+
+const STORE_CHECK_TIMEOUT_MS = 8_000;
+
+let cachedStore: OwnerStoreFull | null = null;
+let cachedState: OwnerStoreState | null = null;
+
+function applyOwnerStoreResult(ownerStore: OwnerStoreFull | null) {
+ cachedStore = ownerStore;
+ cachedState = ownerStore ? "ready" : "none";
+}
+
+function applyOwnerStoreFailure() {
+ cachedStore = null;
+ cachedState = null;
+}
+
+async function fetchOwnerStoreWithTimeout(): Promise {
+ let timeoutId: ReturnType | null = null;
+
+ const timeout = new Promise((_, reject) => {
+ timeoutId = setTimeout(
+ () => reject(new Error("OwnerStoreTimeout")),
+ STORE_CHECK_TIMEOUT_MS,
+ );
+ });
+
+ try {
+ return await Promise.race([fetchOwnerStoreFull(), timeout]);
+ } finally {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ }
+}
+
+export function useOwnerStore(): UseOwnerStoreResult {
+ const [store, setStore] = useState(cachedStore);
+ const [state, setState] = useState(cachedState ?? "loading");
+
+ const refresh = useCallback(async () => {
+ if (cachedState === null) {
+ setState("loading");
+ }
+ try {
+ const ownerStore = await fetchOwnerStoreWithTimeout();
+ applyOwnerStoreResult(ownerStore);
+ setStore(ownerStore);
+ setState(cachedState ?? "none");
+ } catch {
+ applyOwnerStoreFailure();
+ setStore(null);
+ setState("none");
+ }
+ }, []);
+
+ useEffect(() => {
+ let active = true;
+
+ async function load() {
+ if (cachedState !== null) {
+ setStore(cachedStore);
+ setState(cachedState);
+ }
+
+ try {
+ const ownerStore = await fetchOwnerStoreWithTimeout();
+ if (!active) return;
+ applyOwnerStoreResult(ownerStore);
+ setStore(ownerStore);
+ setState(cachedState ?? "none");
+ } catch {
+ if (!active) return;
+ applyOwnerStoreFailure();
+ setStore(null);
+ setState("none");
+ }
+ }
+
+ void load();
+
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ return {
+ store,
+ state,
+ hasStore: state === "ready",
+ isLoading: state === "loading",
+ refresh,
+ };
+}
diff --git a/apps/web/src/lib/route-policy.ts b/apps/web/src/lib/route-policy.ts
new file mode 100644
index 00000000..4cba6e19
--- /dev/null
+++ b/apps/web/src/lib/route-policy.ts
@@ -0,0 +1,79 @@
+const ACCOUNT_REQUIRED_PREFIXES = [
+ "/home",
+ "/cart",
+ "/checkout",
+ "/orders",
+ "/wishlist",
+ "/saved",
+ "/messages",
+ "/notifications",
+ "/settings",
+];
+const ADMIN_REQUIRED_PREFIXES = ["/admin"];
+const AUTH_PAGE_PREFIXES = [
+ "/login",
+ "/register",
+ "/forgot-password",
+ "/verify-email",
+];
+
+function matchesPrefix(pathname: string, prefix: string): boolean {
+ return pathname === prefix || pathname.startsWith(prefix + "/");
+}
+
+function isRemovedStoreRoute(pathname: string): boolean {
+ return (
+ matchesPrefix(pathname, "/store/my-store") ||
+ matchesPrefix(pathname, "/store/settings")
+ );
+}
+
+export function isAuthPageRoute(pathname: string): boolean {
+ return AUTH_PAGE_PREFIXES.some((prefix) => matchesPrefix(pathname, prefix));
+}
+
+export function isStoreSetupRoute(pathname: string): boolean {
+ return pathname === "/store/setup";
+}
+
+export function isStoreWorkspaceRoute(pathname: string): boolean {
+ if (isRemovedStoreRoute(pathname)) {
+ return false;
+ }
+
+ if (
+ pathname === "/settings/store" ||
+ pathname.startsWith("/settings/store/")
+ ) {
+ return true;
+ }
+
+ return (
+ (pathname === "/store" || pathname.startsWith("/store/")) &&
+ !isStoreSetupRoute(pathname)
+ );
+}
+
+export function isProtectedRoute(pathname: string): boolean {
+ return (
+ isStoreSetupRoute(pathname) ||
+ isStoreWorkspaceRoute(pathname) ||
+ ACCOUNT_REQUIRED_PREFIXES.some((prefix) =>
+ matchesPrefix(pathname, prefix),
+ ) ||
+ ADMIN_REQUIRED_PREFIXES.some((prefix) => matchesPrefix(pathname, prefix))
+ );
+}
+
+export function getLoginRedirectPath(pathname: string, search = ""): string {
+ const destination = pathname + search;
+ return `/login?redirect=${encodeURIComponent(destination)}`;
+}
+
+export function getStoreSetupRedirectHref(
+ pathname: string,
+ search = "",
+): string {
+ const destination = pathname + search;
+ return `/store/setup?redirect=${encodeURIComponent(destination)}`;
+}
diff --git a/apps/web/src/lib/routes.ts b/apps/web/src/lib/routes.ts
new file mode 100644
index 00000000..332b93bf
--- /dev/null
+++ b/apps/web/src/lib/routes.ts
@@ -0,0 +1,23 @@
+export const STORE_PROFILE_FALLBACK_HREF = "/stores";
+
+export function normalizePublicHandle(handle: string | null | undefined) {
+ const cleanHandle = handle?.trim().replace(/^@+/, "");
+ return cleanHandle && /^[a-zA-Z0-9_-]+$/.test(cleanHandle)
+ ? cleanHandle
+ : null;
+}
+
+export function getPublicStoreHref(handle: string | null | undefined) {
+ const cleanHandle = normalizePublicHandle(handle);
+ return cleanHandle ? `/stores/${cleanHandle}` : STORE_PROFILE_FALLBACK_HREF;
+}
+
+export function getPublicUserHref(username: string | null | undefined) {
+ const cleanUsername = normalizePublicHandle(username);
+ return cleanUsername ? `/u/${cleanUsername}` : "/explore";
+}
+
+export function getOwnProfileHref(username: string | null | undefined) {
+ const cleanUsername = normalizePublicHandle(username);
+ return cleanUsername ? `/u/${cleanUsername}` : "/settings/profile";
+}
diff --git a/apps/web/src/lib/wishlist.ts b/apps/web/src/lib/wishlist.ts
index 151edf94..fdd1ab28 100644
--- a/apps/web/src/lib/wishlist.ts
+++ b/apps/web/src/lib/wishlist.ts
@@ -1,5 +1,5 @@
/**
- * Wishlist helpers — read- and action-side typed API for /buyer/wishlist.
+ * Wishlist helpers for the private wishlist route.
*
* Endpoint contracts verified from:
* apps/backend/src/modules/wishlist/wishlist.controller.ts
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index 9ddf8b9a..4fad38a3 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -1,5 +1,10 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
+import {
+ getLoginRedirectPath,
+ isAuthPageRoute,
+ isProtectedRoute,
+} from "@/lib/route-policy";
// Cookie set by the backend on successful login (HttpOnly, server-managed).
// Middleware cannot verify the JWT signature at the edge — it only checks
@@ -7,20 +12,6 @@ import type { NextRequest } from "next/server";
// by the backend on every authenticated API call.
const SESSION_COOKIE = "twizrr_access_token";
-// Stored without trailing slash so we can match both "/buyer" and "/buyer/*".
-// /admin is included here so unauthenticated visitors are redirected to /login
-// before the page mounts. Role enforcement (SUPER_ADMIN/OPERATOR/SUPPORT) is
-// not done at the edge — the admin layout probes GET /admin/dashboard server-
-// side and shows an Unauthorized state when the backend returns 401/403.
-const PROTECTED_PREFIXES = ["/buyer", "/store", "/admin"];
-
-const AUTH_PAGE_PREFIXES = [
- "/login",
- "/register",
- "/forgot-password",
- "/verify-email",
-];
-
function hasSession(req: NextRequest): boolean {
return req.cookies.has(SESSION_COOKIE);
}
@@ -28,39 +19,24 @@ function hasSession(req: NextRequest): boolean {
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
- // ── /@handle rewrite → /stores/:handle ─────────────────────────────────────
- const handleMatch = /^\/@([a-zA-Z0-9_-]+)\/?$/.exec(pathname);
- if (handleMatch) {
- const rewrite = request.nextUrl.clone();
- rewrite.pathname = `/stores/${handleMatch[1]}`;
- return NextResponse.rewrite(rewrite);
- }
-
const session = hasSession(request);
- // ── Protected routes — /buyer/* and /store/* ────────────────────────────────
- if (
- PROTECTED_PREFIXES.some(
- (p) => pathname === p || pathname.startsWith(p + "/"),
- )
- ) {
+ if (isProtectedRoute(pathname)) {
if (!session) {
- const loginUrl = request.nextUrl.clone();
- loginUrl.pathname = "/login";
- // Preserve the full destination (path + query string) for post-login redirect
- const destination = pathname + (request.nextUrl.search ?? "");
- loginUrl.search = `redirect=${encodeURIComponent(destination)}`;
+ const loginUrl = new URL(
+ getLoginRedirectPath(pathname, request.nextUrl.search),
+ request.url,
+ );
return NextResponse.redirect(loginUrl);
}
}
- // ── Auth pages — redirect away when already logged in ───────────────────────
- if (AUTH_PAGE_PREFIXES.some((p) => pathname.startsWith(p))) {
+ if (isAuthPageRoute(pathname)) {
if (session) {
- const exploreUrl = request.nextUrl.clone();
- exploreUrl.pathname = "/explore";
- exploreUrl.search = "";
- return NextResponse.redirect(exploreUrl);
+ const homeUrl = request.nextUrl.clone();
+ homeUrl.pathname = "/home";
+ homeUrl.search = "";
+ return NextResponse.redirect(homeUrl);
}
}