mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
396 lines
18 KiB
React
396 lines
18 KiB
React
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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[420px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Invite link</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{loading ? (
|
|
<p className="py-8 text-center text-sm text-muted-foreground">Loading…</p>
|
|
) : !inviteUrl ? (
|
|
<p className="py-8 text-center text-sm text-muted-foreground">No group found.</p>
|
|
) : (
|
|
<div className="flex flex-col items-center gap-5 py-2">
|
|
|
|
{/* QR code */}
|
|
<div className="rounded-xl border bg-white p-4 shadow-sm">
|
|
<QRCodeCanvas
|
|
data-qr="refer-invite"
|
|
value={inviteUrl}
|
|
size={180}
|
|
includeMargin={false}
|
|
/>
|
|
</div>
|
|
|
|
{/* Download QR */}
|
|
<Button
|
|
variant="outline"
|
|
className="w-full gap-2"
|
|
onClick={handleDownload}
|
|
>
|
|
<Download className="size-4" />
|
|
Download QR code
|
|
</Button>
|
|
|
|
<div className="w-full flex items-center gap-2 text-xs text-muted-foreground">
|
|
<div className="flex-1 h-px bg-border" />
|
|
or share the link
|
|
<div className="flex-1 h-px bg-border" />
|
|
</div>
|
|
|
|
{/* Invite link */}
|
|
<div className="w-full flex flex-col gap-2">
|
|
<div className="flex items-center gap-2 rounded-lg border bg-muted px-3 py-2 min-w-0">
|
|
<p className="font-mono text-xs text-muted-foreground truncate w-64">
|
|
{inviteUrl}
|
|
</p>
|
|
</div>
|
|
<Button className="w-full gap-2" onClick={handleCopyLink}>
|
|
{copiedLink
|
|
? <><Check className="size-4" /> Copied!</>
|
|
: <><Copy className="size-4" /> Copy invite link</>
|
|
}
|
|
</Button>
|
|
</div>
|
|
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ─── 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 (
|
|
<>
|
|
<nav className="bg-card w-full border-b border-default">
|
|
<div className="flex flex-wrap items-center justify-between mx-auto py-3 px-6">
|
|
|
|
{/* Logo */}
|
|
<div className="flex gap-4 items-center">
|
|
<div className="w-40 cursor-pointer" onClick={() => navigate("/")}>
|
|
<img src="/philpro-white.png" alt="Philproperties" className="object-cover dark:hidden" />
|
|
<img src="/philpro-dark.png" alt="Philproperties" className="object-cover hidden dark:block" />
|
|
</div>
|
|
<div>
|
|
<svg
|
|
data-testid="geist-icon"
|
|
height="16"
|
|
width="16"
|
|
viewBox="0 0 16 16"
|
|
strokeLinejoin="round"
|
|
className="xs:hidden sm:block fill-slate-300"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
clipRule="evenodd"
|
|
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2">
|
|
{!tierReady ? (
|
|
<div className="xs:hidden md:inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-muted animate-pulse">
|
|
<div className="size-3 rounded-full bg-muted-foreground/30" />
|
|
<span className="text-transparent select-none">Free</span>
|
|
</div>
|
|
) : tierBadge && (
|
|
<div className={`xs:hidden md:inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${tierBadge.cls}`}>
|
|
{badgeImgUrl
|
|
? <img src={badgeImgUrl} className="size-3.5 rounded-full object-cover" />
|
|
: TierIcon && <TierIcon className="size-3" />
|
|
}
|
|
{tierBadge.label}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right — bell + user menu */}
|
|
<div className="flex items-center gap-3">
|
|
<ClientNotificationBell />
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Avatar className="cursor-pointer">
|
|
<AvatarImage src={avatarUrl} />
|
|
<AvatarFallback className={`text-sm font-semibold ${avatarColor}`}>
|
|
{initials || "PH"}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</DropdownMenuTrigger>
|
|
|
|
<DropdownMenuContent align="end" className="w-64">
|
|
|
|
{/* User info */}
|
|
<DropdownMenuGroup className="p-1.5">
|
|
<div className="w-full truncate text-start text-sm font-medium">
|
|
{fullName || "—"}
|
|
</div>
|
|
<div className="w-full truncate text-start text-[13px] font-medium text-muted-foreground">
|
|
{email}
|
|
</div>
|
|
</DropdownMenuGroup>
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem className="bg-gradient-to-r from-[#0e7490] via-[#3b82f6] to-[#4f46e5] text-white" onClick={() => navigate("/plans")}>
|
|
<Zap /> Plans
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => navigate("/profile")}>
|
|
<User /> Profile
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => navigate("/settings")}>
|
|
<Settings /> Account Settings
|
|
</DropdownMenuItem>
|
|
{/* <DropdownMenuItem>
|
|
<TableOfContents /> Documentation
|
|
<DropdownMenuShortcut>
|
|
<SquareArrowOutUpRight />
|
|
</DropdownMenuShortcut>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem>
|
|
<CircleQuestionMark /> Feedback
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => setReferOpen(true)}>
|
|
<Gift /> Refer
|
|
</DropdownMenuItem> */}
|
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
|
Theme
|
|
<DropdownMenuShortcut>
|
|
<ThemeSwitcher />
|
|
</DropdownMenuShortcut>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
|
|
<LogOut /> Log out
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<ReferDialog open={referOpen} onOpenChange={setReferOpen} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ─── 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 (
|
|
<ClientProvider>
|
|
<div className="min-h-screen flex flex-col">
|
|
<header ref={headerRef} className="fixed top-0 left-0 right-0 z-50 flex flex-col">
|
|
<StickyAnnouncementBar />
|
|
<ClientNav />
|
|
</header>
|
|
<div className="flex-1 flex flex-col">
|
|
<Outlet />
|
|
</div>
|
|
<Toaster position="bottom-right" richColors />
|
|
{showFooter && (
|
|
<footer className="bg-muted border-t w-full py-4 px-5 text-right text-sm text-muted-foreground">
|
|
© Philproperties, 2026
|
|
</footer>
|
|
)}
|
|
</div>
|
|
</ClientProvider>
|
|
)
|
|
}
|
|
|
|
export default ClientLayout |