import { Outlet, useMatches, useNavigate } from "react-router-dom" import { ThemeSwitcher } from "../components/ThemeSwitcher" import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { User, Settings, LogOut, SquareArrowOutUpRight, TableOfContents, CircleQuestionMark, Gift, Zap, Download, Copy, Check, } from "lucide-react" import * as LucideIcons from "lucide-react" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" import { useAuth } from "@/contexts/AuthContext" import api from "@/utils/api.util" import { ClientProvider } from "@/contexts/provider/ClientProvider" import { useProfile } from "@/contexts/ProfileProvider" import { useClientTiers } from "@/contexts/ClientTiersProvider" import { resolveTierBadge } from "@/utils/tierBadge.util" import { useGroup } from "@/contexts/ClientGroupContext" import { useEffect, useRef, useState } from "react" import { AVATAR_COLORS } from "@/data/profile.data" import { Badge } from "@/components/ui/badge" import ClientNotificationBell from "@/components/generic/ClientNotificationBell" import StickyAnnouncementBar from "@/components/generic/StickyAnnouncementBar" import { QRCodeCanvas } from "qrcode.react" // ─── Refer / Invite dialog ──────────────────────────────────────────────────── function ReferDialog({ open, onOpenChange }) { const { groups, fetchGroups, loading } = useGroup() const [copiedLink, setCopiedLink] = useState(false) useEffect(() => { if (open) fetchGroups() }, [open]) const group = groups[0] ?? null const inviteUrl = group?.group_code ? `${window.location.origin}/signup?group_code=${group.group_code}` : null const handleCopyLink = async () => { if (!inviteUrl) return await navigator.clipboard.writeText(inviteUrl) setCopiedLink(true) setTimeout(() => setCopiedLink(false), 2000) } const handleDownload = () => { const canvas = document.querySelector("[data-qr='refer-invite']") if (!canvas) return const url = canvas.toDataURL("image/png") const link = document.createElement("a") link.href = url link.download = `QR_${group.group_code}.png` link.click() } return ( Invite link {loading ? (

Loading…

) : !inviteUrl ? (

No group found.

) : (
{/* QR code */}
{/* Download QR */}
or share the link
{/* Invite link */}

{inviteUrl}

)}
) } // ─── Helpers ────────────────────────────────────────────────────────────────── function getInitials(name = "") { return name.trim().split(/\s+/).map((n) => n[0]?.toUpperCase() ?? "").slice(0, 2).join("") } // ─── Inner nav — uses contexts available inside ClientProvider ──────────────── function ClientNav() { const navigate = useNavigate() const { user, logout } = useAuth() // Background fetches only — nav rendering never waits on these const { achievements, getAchievements } = useProfile() const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers() const [referOpen, setReferOpen] = useState(false) const [badgeImgUrl, setBadgeImgUrl] = useState(null) // Caches the resolved stream URL per asset_id — avoids re-hitting /media/token // for the same asset on re-renders. Token TTL is 4h so safe within a session. const badgeTokenCache = useRef({}) // Starts false on every mount (incl. full page reloads) so the tier badge // shows a gray skeleton instead of flashing "Free" before /tiers/me resolves — // myTier stays null for genuinely-free users too, so it can't be used as the signal. const [tierReady, setTierReady] = useState(false) useEffect(() => { if (!user) return; if (achievements.length === 0) getAchievements(); if (tierCategories.length === 0) getTierCategories(); if (!myTier) { getMyTier().finally(() => setTierReady(true)); } else { setTierReady(true); } }, [user]); // ── Derive directly from auth user — same pattern as admin UserMenu ────── // No extra fetch, no loading state, no flicker on reload. const given = user?.personal_info?.name?.given_name ?? "" const last = user?.personal_info?.name?.last_name ?? "" const fullName = given && last ? `${given} ${last}` : (user?.email ?? "") const avatarUrl = user?.personal_info?.avatar?.url ?? user?.personal_info?.avatar ?? "" const email = user?.email ?? "" const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground' const tierSlug = myTier?.status === 'active' ? (myTier.tier ?? 'free') : 'free' const tierBadge = resolveTierBadge(tierSlug, tierMap) const TierIcon = LucideIcons[tierMap[tierSlug]?.badge_icon] ?? null // Derive primitive deps — effect only fires when the actual asset changes, // not on every tierMap/tierSlug reference churn. const badgeAsset = tierMap[tierSlug]?.badgeAsset ?? null const badgeAssetId = badgeAsset?.asset_id ?? null const badgeProvider = badgeAsset?.storage_provider ?? null const badgeFileUrl = badgeAsset?.file_url ?? null useEffect(() => { if (!badgeAssetId) { setBadgeImgUrl(null); return } // Non-S3: use raw URL directly — no token needed if (badgeProvider !== 's3') { setBadgeImgUrl(badgeFileUrl); return } // Ref cache hit — reuse the URL without another POST if (badgeTokenCache.current[badgeAssetId]) { setBadgeImgUrl(badgeTokenCache.current[badgeAssetId]) return } let cancelled = false const base = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '') api.post('/client/media/token', { asset_id: badgeAssetId }) .then(({ data }) => { if (cancelled) return const url = `${base}/client/media/stream/${data?.data?.token}` badgeTokenCache.current[badgeAssetId] = url setBadgeImgUrl(url) }) .catch(() => { if (!cancelled) setBadgeImgUrl(null) }) return () => { cancelled = true } }, [badgeAssetId, badgeProvider, badgeFileUrl]) const initials = given && last ? (given[0] + last[0]).toUpperCase() : getInitials(fullName) const handleLogout = async () => { await logout() navigate("/login") } // console.log("USER OBJECT:", JSON.stringify(user, null, 2)) return ( <> ) } // ─── Layout ─────────────────────────────────────────────────────────────────── const ClientLayout = () => { const matches = useMatches() const currentHandle = matches.at(-1)?.handle ?? {} const showFooter = currentHandle.showFooter ?? true // Sticky announcement bar and nav stack inside one fixed header instead of // each being independently `fixed top-0` (which made them overlap). Height // is measured off this wrapper so --navbar-h always reflects the combined // space, whether or not an announcement is currently showing. const headerRef = useRef(null) useEffect(() => { if (!headerRef.current) return const update = () => { document.documentElement.style.setProperty('--navbar-h', `${headerRef.current.offsetHeight}px`) } update() const ro = new ResizeObserver(update) ro.observe(headerRef.current) return () => ro.disconnect() }, []) return (
{showFooter && ( )}
) } export default ClientLayout