From e7d300f9c2022a0e290782d989394efae1f80fc5 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 6 Jun 2026 08:57:38 +0200 Subject: [PATCH] feat(01-02): protected app shell with sidebar, mobile nav, onboarding redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (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 --- src/app/(app)/layout.tsx | 54 +++++++- src/app/(app)/onboarding/pet/page.tsx | 86 ++++--------- src/components/layout/MobileNav.tsx | 174 +++++++++++++++++++++++++ src/components/layout/Sidebar.tsx | 176 ++++++++++++++++++++++++++ 4 files changed, 420 insertions(+), 70 deletions(-) create mode 100644 src/components/layout/MobileNav.tsx create mode 100644 src/components/layout/Sidebar.tsx diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 5275a2d..1ef875d 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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
{children}
; +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 ( +
+ {/* Desktop sidebar — hidden on mobile */} + + + {/* Main content area */} +
{children}
+ + {/* Mobile bottom nav — hidden on desktop */} + +
+ ); } diff --git a/src/app/(app)/onboarding/pet/page.tsx b/src/app/(app)/onboarding/pet/page.tsx index 3b51763..8acf0d0 100644 --- a/src/app/(app)/onboarding/pet/page.tsx +++ b/src/app/(app)/onboarding/pet/page.tsx @@ -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 ( -
-

Add your first pet

-

- Your pet will be their own star on OnlyPets. -

+
+
+

Add your first pet

+

+ Your pet will be their own star on OnlyPets. +

-
-
- - setName(e.target.value)} - maxLength={30} - required - /> + {/* PetForm goes here — Plan 03 */} +
+ Pet profile form — coming in Plan 03
- - - +
); } diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx new file mode 100644 index 0000000..b710f5b --- /dev/null +++ b/src/components/layout/MobileNav.tsx @@ -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 ( +
+ {/* Active pet switcher bar — shown above tab bar on mobile */} + {activePet && ( +
+ + + + + {activePet.name.slice(0, 2).toUpperCase()} + + + + {activePet.name} + + + + + {pets.map((pet) => ( + setActivePet(pet.id)} + className="flex items-center gap-2 cursor-pointer" + > + + + {pet.name.slice(0, 2).toUpperCase()} + + + {pet.name} + + {pet.species.name} + + {pet.id === activePetId && ( + + )} + + ))} + + router.push("/onboarding/pet")} + className="flex items-center gap-2 cursor-pointer" + > + + + Add Pet + + + +
+ )} + + {/* Bottom tab bar */} + +
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..10af7e4 --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -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 ( + + ); +}