feat(01-02): protected app shell with sidebar, mobile nav, onboarding redirect
- (app)/layout.tsx: server component with auth() check + pets.list + redirect to /onboarding/pet if no pets - Sidebar.tsx: 240px desktop nav (lg:), pet switcher with active state, nav links with orange-500 active indicator - MobileNav.tsx: fixed bottom bar (h-16, lg:hidden), pet switcher above tabs, orange-500 center post button - onboarding/pet/page.tsx: stub with Plan 03 placeholder (replaces walking skeleton client form) - Deviation: base-nova shadcn uses @base-ui/react/menu (no asChild support) — used onClick+useRouter - Deviation: Clerk 7 UserButton no longer accepts afterSignOutUrl
This commit is contained in:
1 parent
325eaa9a25
commit
e7d300f9c2
4 files changed
+420
-70
No files matched your search
@@ -1,10 +1,52 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { createTRPCCaller } from "@/trpc/server";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { MobileNav } from "@/components/layout/MobileNav";
|
||||
|
||||
/**
|
||||
* App shell layout — applied to all routes under /(app)/
|
||||
* Clerk's proxy.ts enforces authentication before this layout renders.
|
||||
* Protected app shell layout — applied to all routes under /(app)/.
|
||||
*
|
||||
* This is a minimal skeleton layout for Phase 1.
|
||||
* Full navigation (sidebar, pet switcher) is implemented in Plan 02.
|
||||
* Auth enforcement:
|
||||
* 1. Belt-and-suspenders: auth() check server-side — proxy.ts already gates the route,
|
||||
* but the layout must not render pet data for unauthenticated requests.
|
||||
* 2. If owner has no pets: redirect to /onboarding/pet (PET-01 prerequisite).
|
||||
*
|
||||
* Security (T-2-02): auth() called server-side — never trust the client to self-report auth.
|
||||
* Security (T-2-03): pets.list scoped by ctx.userId — no cross-owner pet leakage.
|
||||
*
|
||||
* ActivePetProvider is already mounted in root layout.tsx so we do NOT re-wrap here.
|
||||
*/
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-screen bg-background">{children}</div>;
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Belt-and-suspenders auth check (proxy.ts handles the primary gate)
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
redirect("/log-in");
|
||||
}
|
||||
|
||||
// Fetch the owner's pets via tRPC server caller (scoped by userId)
|
||||
const caller = await createTRPCCaller();
|
||||
const pets = await caller.pets.list();
|
||||
|
||||
// No pets → send to onboarding to create their first pet
|
||||
if (pets.length === 0) {
|
||||
redirect("/onboarding/pet");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Desktop sidebar — hidden on mobile */}
|
||||
<Sidebar pets={pets} />
|
||||
|
||||
{/* Main content area */}
|
||||
<main className="flex-1 overflow-auto pb-32 lg:pb-0">{children}</main>
|
||||
|
||||
{/* Mobile bottom nav — hidden on desktop */}
|
||||
<MobileNav pets={pets} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Pet onboarding page — /onboarding/pet
|
||||
* Minimal form to create the first pet profile (walking skeleton).
|
||||
* Onboarding pet page — /onboarding/pet
|
||||
*
|
||||
* Full form (breed selector, avatar upload, bio, adoption story) is
|
||||
* implemented in Plan 02/03. This page proves the end-to-end tRPC flow.
|
||||
* Landing page shown when an authenticated owner has no pets yet.
|
||||
* The app shell layout.tsx redirects here when pets.length === 0.
|
||||
*
|
||||
* UI-SPEC: Pet Profile Screens — Create First Pet (onboarding)
|
||||
* - Heading: "Add your first pet" (text-xl font-semibold)
|
||||
* - Subheading: "Your pet will be their own star on OnlyPets." (text-sm text-muted-foreground)
|
||||
* - PetForm placeholder (wired in Plan 03)
|
||||
*
|
||||
* This page is intentionally outside the (app) layout's pet-list check —
|
||||
* owners are redirected HERE when they have no pets, so the shell must
|
||||
* render without requiring an existing pet.
|
||||
*/
|
||||
export default function OnboardingPetPage() {
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const [name, setName] = useState("");
|
||||
|
||||
// pets.create mutation via tRPC
|
||||
const createPet = useMutation(
|
||||
trpc.pets.create.mutationOptions({
|
||||
onSuccess: (pet) => {
|
||||
toast.success(`Pet profile created for ${pet.name}!`);
|
||||
router.push("/pets");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message || "Failed to create pet profile.");
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
// Using a placeholder speciesId — real species selector in Plan 02
|
||||
createPet.mutate({ name: name.trim(), speciesId: "placeholder" });
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="container max-w-md mx-auto px-6 py-12">
|
||||
<h1 className="text-xl font-semibold mb-2">Add your first pet</h1>
|
||||
<p className="text-sm text-muted-foreground mb-8">
|
||||
Your pet will be their own star on OnlyPets.
|
||||
</p>
|
||||
<main className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-[512px]">
|
||||
<h1 className="text-xl font-semibold mb-2">Add your first pet</h1>
|
||||
<p className="text-sm text-muted-foreground mb-8">
|
||||
Your pet will be their own star on OnlyPets.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pet-name">Pet name</Label>
|
||||
<Input
|
||||
id="pet-name"
|
||||
placeholder="e.g. Buddy, Luna, Max"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={30}
|
||||
required
|
||||
/>
|
||||
{/* PetForm goes here — Plan 03 */}
|
||||
<div className="border-2 border-dashed border-muted rounded-lg p-12 flex items-center justify-center text-sm text-muted-foreground">
|
||||
Pet profile form — coming in Plan 03
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={createPet.isPending || !name.trim()}
|
||||
>
|
||||
{createPet.isPending ? "Adding pet…" : "Add Pet"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Home,
|
||||
Compass,
|
||||
Plus,
|
||||
Bell,
|
||||
User,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
|
||||
type Pet = {
|
||||
id: string;
|
||||
name: string;
|
||||
species: { name: string };
|
||||
};
|
||||
|
||||
type MobileNavProps = {
|
||||
pets: Pet[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Mobile bottom tab bar — visible below lg: breakpoint.
|
||||
*
|
||||
* UI-SPEC: Navigation (App Shell) — Mobile
|
||||
* - Fixed bottom bar (h-16) with bg-card border-top
|
||||
* - 5 tabs: Home, Explore (placeholder), Post (center, orange-500), Notifications (placeholder), Profile
|
||||
* - Top active pet switcher dropdown above the tab bar
|
||||
*
|
||||
* Explore and Notifications are placeholder tabs (aria-disabled) in Phase 1.
|
||||
* Post tab (center) links to /pets in Phase 1 — full post creation in Phase 2.
|
||||
*
|
||||
* Deviation notes:
|
||||
* - shadcn base-nova uses @base-ui/react/menu which does not support asChild on
|
||||
* DropdownMenuTrigger or DropdownMenuItem. Using onClick+useRouter for navigation items.
|
||||
*/
|
||||
export function MobileNav({ pets }: MobileNavProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { activePetId, setActivePet } = useActivePet();
|
||||
|
||||
const activePet = pets.find((p) => p.id === activePetId) ?? pets[0];
|
||||
|
||||
const isHomePage = pathname === "/";
|
||||
const isPetsPage = pathname.startsWith("/pets");
|
||||
|
||||
return (
|
||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50">
|
||||
{/* Active pet switcher bar — shown above tab bar on mobile */}
|
||||
{activePet && (
|
||||
<div className="bg-background border-t border px-4 py-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="flex items-center gap-2 text-sm cursor-pointer"
|
||||
aria-label="Switch active pet"
|
||||
>
|
||||
<Avatar className="h-6 w-6 shrink-0">
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{activePet.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium text-foreground truncate max-w-[120px]">
|
||||
{activePet.name}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[200px]">
|
||||
{pets.map((pet) => (
|
||||
<DropdownMenuItem
|
||||
key={pet.id}
|
||||
onClick={() => setActivePet(pet.id)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Avatar className="h-6 w-6 shrink-0">
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{pet.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 text-sm truncate">{pet.name}</span>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{pet.species.name}
|
||||
</Badge>
|
||||
{pet.id === activePetId && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-orange-500 shrink-0"
|
||||
aria-label="Active pet"
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push("/onboarding/pet")}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<span className="text-orange-500">+</span>
|
||||
<span>Add Pet</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom tab bar */}
|
||||
<nav
|
||||
className="h-16 bg-card border-t border flex items-center justify-around px-2"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
{/* Home */}
|
||||
<Link
|
||||
href="/"
|
||||
className={`flex flex-col items-center gap-0.5 px-3 py-2 text-xs ${
|
||||
isHomePage ? "text-orange-500" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Home className="h-5 w-5" aria-hidden="true" />
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
|
||||
{/* Explore — placeholder, Phase 1 */}
|
||||
<span
|
||||
aria-disabled="true"
|
||||
className="flex flex-col items-center gap-0.5 px-3 py-2 text-xs text-muted-foreground/50 cursor-not-allowed"
|
||||
>
|
||||
<Compass className="h-5 w-5" aria-hidden="true" />
|
||||
<span>Explore</span>
|
||||
</span>
|
||||
|
||||
{/* Post — center, accent-filled. Phase 1: navigates to /pets as placeholder */}
|
||||
<Link
|
||||
href="/pets"
|
||||
aria-label="My Pets"
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<span className="bg-orange-500 text-white rounded-full p-3 flex items-center justify-center">
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Notifications — placeholder, Phase 1 */}
|
||||
<span
|
||||
aria-disabled="true"
|
||||
className="flex flex-col items-center gap-0.5 px-3 py-2 text-xs text-muted-foreground/50 cursor-not-allowed"
|
||||
>
|
||||
<Bell className="h-5 w-5" aria-hidden="true" />
|
||||
<span>Alerts</span>
|
||||
</span>
|
||||
|
||||
{/* Profile (My Pets) */}
|
||||
<Link
|
||||
href="/pets"
|
||||
className={`flex flex-col items-center gap-0.5 px-3 py-2 text-xs ${
|
||||
isPetsPage ? "text-orange-500" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<User className="h-5 w-5" aria-hidden="true" />
|
||||
<span>Profile</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Home,
|
||||
Heart,
|
||||
Compass,
|
||||
Bell,
|
||||
MessageCircle,
|
||||
PawPrint,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { UserButton } from "@clerk/nextjs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
|
||||
type Pet = {
|
||||
id: string;
|
||||
name: string;
|
||||
species: { name: string };
|
||||
};
|
||||
|
||||
type SidebarProps = {
|
||||
pets: Pet[];
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/", label: "Home", icon: Home, active: true },
|
||||
{ href: "/pets", label: "My Pets", icon: Heart, active: true },
|
||||
{ href: "/explore", label: "Explore", icon: Compass, active: false },
|
||||
{ href: "/notifications", label: "Notifications", icon: Bell, active: false },
|
||||
{ href: "/messages", label: "Messages", icon: MessageCircle, active: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Desktop sidebar — 240px fixed left nav visible at lg: breakpoint.
|
||||
*
|
||||
* UI-SPEC: Navigation (App Shell) — Desktop sidebar
|
||||
* - OnlyPets wordmark + paw icon (orange-500)
|
||||
* - Active pet switcher using useActivePet() context
|
||||
* - Nav links: Home, My Pets (active in Phase 1); Explore/Notifications/Messages (placeholders)
|
||||
* - Bottom: Clerk UserButton
|
||||
*
|
||||
* Security (T-2-03): receives pets already scoped by pets.list (ownership-scoped) — no
|
||||
* cross-owner data is possible because the server layout enforces ownership via ctx.userId.
|
||||
*
|
||||
* Deviation notes:
|
||||
* - shadcn base-nova uses @base-ui/react/menu which does not support asChild on
|
||||
* DropdownMenuTrigger or DropdownMenuItem. Using onClick+useRouter for navigation.
|
||||
* - Clerk 7 UserButton no longer accepts afterSignOutUrl (removed from UserButtonProps).
|
||||
*/
|
||||
export function Sidebar({ pets }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { activePetId, setActivePet } = useActivePet();
|
||||
|
||||
const activePet = pets.find((p) => p.id === activePetId) ?? pets[0];
|
||||
|
||||
return (
|
||||
<nav className="hidden lg:flex flex-col w-[240px] min-h-screen bg-card border-r border shrink-0">
|
||||
{/* Wordmark */}
|
||||
<div className="flex items-center gap-2 px-4 py-5">
|
||||
<PawPrint className="h-5 w-5 text-orange-500" aria-hidden="true" />
|
||||
<span className="text-xl font-semibold text-foreground">OnlyPets</span>
|
||||
</div>
|
||||
|
||||
{/* Pet switcher */}
|
||||
{activePet && (
|
||||
<div className="px-3 pb-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="flex items-center gap-2 w-full px-2 py-2 rounded-md hover:bg-muted transition-colors text-left cursor-pointer"
|
||||
aria-label="Switch active pet"
|
||||
>
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{activePet.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium text-foreground flex-1 truncate">
|
||||
{activePet.name}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[200px]">
|
||||
{pets.map((pet) => (
|
||||
<DropdownMenuItem
|
||||
key={pet.id}
|
||||
onClick={() => setActivePet(pet.id)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Avatar className="h-6 w-6 shrink-0">
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{pet.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 text-sm truncate">{pet.name}</span>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{pet.species.name}
|
||||
</Badge>
|
||||
{pet.id === activePetId && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-orange-500 shrink-0"
|
||||
aria-label="Active pet"
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push("/onboarding/pet")}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<span className="text-orange-500">+</span>
|
||||
<span>Add Pet</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nav links */}
|
||||
<div className="flex-1 px-3 py-2 space-y-1">
|
||||
{navLinks.map(({ href, label, icon: Icon, active }) => {
|
||||
const isCurrentPage =
|
||||
href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
const isActive = isCurrentPage;
|
||||
|
||||
if (!active) {
|
||||
// Placeholder links — visually present but non-navigable in Phase 1
|
||||
return (
|
||||
<span
|
||||
key={href}
|
||||
aria-disabled="true"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm text-muted-foreground/50 cursor-not-allowed select-none"
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
? "border-l-2 border-orange-500 text-foreground font-medium bg-muted pl-[10px]"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom: owner identity */}
|
||||
<div className="px-4 py-4 border-t border flex items-center gap-2">
|
||||
<UserButton />
|
||||
<span className="text-xs text-muted-foreground">Account</span>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user