Files
starr-philproperties/src/modules/client/layout/ClientLayout.jsx
T
kennethobsequio fbef7cb6e6 ready to test
Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-22 10:07:20 +08:00

307 lines
13 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Toaster } from "sonner"
import { useAuth } from "@/contexts/AuthContext"
import { ClientProvider } from "@/contexts/provider/ClientProvider"
import { useProfile } from "@/contexts/ProfileProvider"
import { useClientTiers } from "@/contexts/ClientTiersProvider"
import { useGroup } from "@/contexts/ClientGroupContext"
import { useEffect, useState } from "react"
import { ROLE_CONFIG, AVATAR_COLORS } from "@/data/profile.data"
import { Badge } from "@/components/ui/badge"
import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
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 flex-1">
{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, getMyTier } = useClientTiers()
const [referOpen, setReferOpen] = useState(false)
useEffect(() => {
if (!user) return;
if (achievements.length === 0) getAchievements();
if (!myTier) getMyTier();
}, [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 role = ROLE_CONFIG[user?.acc_type] ?? { label: user?.acc_type, variant: 'outline' }
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
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 fixed w-full z-50 top-0 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">
<Badge variant={role.variant} className="xs:hidden md:block capitalize">
{role.label}
</Badge>
</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
return (
<ClientProvider>
<ClientNav />
<Outlet />
<Toaster position="bottom-right" richColors />
{showFooter && (
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground">
© Philproperties, 2026
</footer>
)}
</ClientProvider>
)
}
export default ClientLayout