Persönliche, unbegrenzt nutzbare Empfehlungscodes (z. B. BUBI-7K3M), die erst nach der Registrierung im Onboarding eingelöst werden — keine Kopplung an den Clerk-Sign-up-Ablauf mehr. Registrierung ohne Code bleibt möglich. - Schema (rein additiv): Owner-Felder für Code, Einladungsstatus, Zähler und Rang-Cache, ReferralRank / ReferralRankAchievement / ReferralSettings, AcquisitionSource, NotificationType REFERRAL_JOINED / REFERRAL_RANK_UP - Kernlogik src/lib/referrals.ts: Einlösen (7-Tage-Fenster, kein eigener Code, keine Kreis-Einladungen, gesperrte Einladende ungültig), gegenseitiges Folgen, Qualifizierung erst nach Tier + erstem Beitrag (Foto oder fertiges Video), Tageslimit mit Prüf-Markierung, Ränge mit Bestandsschutz - Web: /einladen, öffentliche Seite /r/[code], Onboarding-Schritt "Wie hast du von PawFeed erfahren?", Rang-Abzeichen, Profilring, "Eingeladen von …", neue Benachrichtigungen - Admin /p/[secret]/referrals: Ränge anlegen/bearbeiten/deaktivieren mit Live-Vorschau, Einstellungen, Übersicht mit Prüfung und Aberkennung; bevorzugte Verifizierung als Vorteil - prisma/scripts/referral-setup.ts: Start-Ränge, Settings, Code-Backfill, optional --credit-existing für Alt-Einladungen Noch offen: Mobile-App, Abbau des alten InviteCode-Ablaufs, Rechtstexte, db push + Deploy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
9.2 KiB
TypeScript
245 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { formatPetName } from "@/lib/pet-display-name";
|
|
import Link from "next/link";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import {
|
|
Home,
|
|
Compass,
|
|
Plus,
|
|
User,
|
|
ChevronDown,
|
|
Mail,
|
|
Moon,
|
|
Sun,
|
|
Sparkles,
|
|
Gift,
|
|
} from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
import { NotificationBell } from "@/components/notifications/NotificationBell";
|
|
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { PetAvatar } from "@/components/ui/pet-avatar";
|
|
import { PetIdentityBadges } from "@/components/ui/pet-identity-badges";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { useActivePet } from "@/context/ActivePetContext";
|
|
import { useThemeToggle } from "@/hooks/useThemeToggle";
|
|
|
|
type Pet = {
|
|
id: string;
|
|
name: string;
|
|
avatarKey: string | null;
|
|
isVerified: boolean;
|
|
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 t = useTranslations("Nav");
|
|
const tTheme = useTranslations("Theme");
|
|
const { isDark, setDark } = useThemeToggle();
|
|
const activePet = pets.find((p) => p.id === activePetId) ?? pets[0];
|
|
const [postTypeSheetOpen, setPostTypeSheetOpen] = useState(false);
|
|
|
|
// First-load fallback is handled once, centrally, by ActivePetInitializer
|
|
// (mounted alongside Sidebar/MobileNav in the app layout) — don't duplicate it here.
|
|
|
|
// D-05: Home → /feed; also highlight when on / (which redirects to /feed)
|
|
const isHomePage = pathname === "/feed" || pathname === "/";
|
|
const isPetsPage = pathname.startsWith("/pets");
|
|
const isExplorePage = pathname.startsWith("/explore");
|
|
const isNotificationsPage = pathname.startsWith("/notifications");
|
|
const isMessagesPage = pathname.startsWith("/messages");
|
|
const isSponsorPage = pathname.startsWith("/unterstuetzen");
|
|
const isInvitePage = pathname.startsWith("/einladen");
|
|
|
|
return (
|
|
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50">
|
|
{/* Fixed Messages shortcut — top-right corner, always reachable */}
|
|
<Link
|
|
href="/messages"
|
|
aria-label={t("messages")}
|
|
className={`fixed top-1.5 right-3 z-[51] flex items-center justify-center w-10 h-10 rounded-full bg-background/90 backdrop-blur-sm border border-border shadow-md ${
|
|
isMessagesPage ? "text-orange-500" : "text-foreground/75"
|
|
}`}
|
|
>
|
|
<Mail className="h-5 w-5" aria-hidden="true" />
|
|
</Link>
|
|
|
|
{/* 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"
|
|
>
|
|
<PetAvatar
|
|
name={activePet.name}
|
|
avatarKey={activePet.avatarKey}
|
|
className="h-6 w-6 shrink-0"
|
|
/>
|
|
<span className="font-medium text-foreground truncate max-w-[120px] flex items-center gap-1">
|
|
{formatPetName(activePet)}
|
|
<PetIdentityBadges isVerified={activePet.isVerified} size="sm" />
|
|
</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"
|
|
>
|
|
<PetAvatar
|
|
name={pet.name}
|
|
avatarKey={pet.avatarKey}
|
|
className="h-6 w-6 shrink-0"
|
|
/>
|
|
<span className="flex-1 text-sm truncate flex items-center gap-1">
|
|
{formatPetName(pet)}
|
|
<PetIdentityBadges isVerified={pet.isVerified} size="sm" />
|
|
</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>{t("addPet")}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => router.push("/einladen")}
|
|
className={`flex items-center gap-2 cursor-pointer ${isInvitePage ? "text-orange-500" : ""}`}
|
|
>
|
|
<Gift className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
<span>{t("invite")}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => router.push("/unterstuetzen")}
|
|
className={`flex items-center gap-2 cursor-pointer ${isSponsorPage ? "text-orange-500" : ""}`}
|
|
>
|
|
<Sparkles className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
<span>{t("sponsor")}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => setDark(!isDark)}
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
>
|
|
{isDark ? (
|
|
<Moon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
) : (
|
|
<Sun className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
)}
|
|
<span>{tTheme("toggleLabel")}</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bottom tab bar — pt-2 + pb handles iOS safe-area (home indicator) */}
|
|
<nav
|
|
className="bg-card border-t flex items-center justify-around px-2 pt-2 pb-[max(8px,env(safe-area-inset-bottom))]"
|
|
style={{ minHeight: "4rem" }}
|
|
aria-label="Mobile navigation"
|
|
>
|
|
{/* Home — D-05: href updated to /feed; label stays "Home" */}
|
|
<Link
|
|
href="/feed"
|
|
className={`flex flex-col items-center gap-0.5 px-3 py-1.5 text-xs font-medium ${
|
|
isHomePage ? "text-orange-500" : "text-foreground/60"
|
|
}`}
|
|
>
|
|
<Home className="h-6 w-6" aria-hidden="true" />
|
|
<span>{t("home")}</span>
|
|
</Link>
|
|
|
|
{/* Explore */}
|
|
<Link
|
|
href="/explore"
|
|
className={`flex flex-col items-center gap-0.5 px-3 py-1.5 text-xs font-medium ${
|
|
isExplorePage ? "text-orange-500" : "text-foreground/60"
|
|
}`}
|
|
>
|
|
<Compass className="h-6 w-6" aria-hidden="true" />
|
|
<span>{t("explore")}</span>
|
|
</Link>
|
|
|
|
{/* Post — center, accent-filled. D-01: opens PostTypeSheet */}
|
|
<button
|
|
type="button"
|
|
aria-label="Create post"
|
|
onClick={() => setPostTypeSheetOpen(true)}
|
|
className="flex flex-col items-center"
|
|
>
|
|
<span className="bg-orange-500 text-white rounded-full p-3 flex items-center justify-center shadow-sm">
|
|
<Plus className="h-5 w-5" aria-hidden="true" />
|
|
</span>
|
|
</button>
|
|
|
|
{/* Notifications — with live unread badge */}
|
|
<NotificationBell variant="mobile" isActive={isNotificationsPage} />
|
|
|
|
{/* Profile (My Pets) */}
|
|
<Link
|
|
href="/pets"
|
|
className={`flex flex-col items-center gap-0.5 px-3 py-1.5 text-xs font-medium ${
|
|
isPetsPage ? "text-orange-500" : "text-foreground/60"
|
|
}`}
|
|
>
|
|
<User className="h-6 w-6" aria-hidden="true" />
|
|
<span>{t("profile")}</span>
|
|
</Link>
|
|
</nav>
|
|
|
|
{/* PostTypeSheet — D-01: bottom Sheet with Photo/Story/Milestone choices */}
|
|
<PostTypeSheet
|
|
open={postTypeSheetOpen}
|
|
onOpenChange={setPostTypeSheetOpen}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|