All files / src/components/layout more-sheet.tsx

91.66% Statements 22/24
75% Branches 15/20
83.33% Functions 5/6
95.65% Lines 22/23

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196                                                          6x           6x           6x           6x                     6x     15x 15x 15x 15x 15x 15x 15x             15x   15x       15x                               90x 90x                                                                                                                               1x 1x                 1x                 1x 1x                            
import React from "react";
import { Drawer } from "vaul";
import { Link, useRouterState } from "@tanstack/react-router";
import {
	BarChart3,
	User,
	Users,
	Globe,
	FileText,
	Settings,
	MessageSquare,
	LogOut,
	Sun,
	Moon,
	Monitor,
	Clapperboard,
	Store,
} from "lucide-react";
import { useAuth } from "../../lib/auth-context";
import { useTheme, type Theme } from "../../lib/theme-context";
import { usePro } from "../../lib/pro-context";
import { useSocialDrafts } from "../../hooks/queries/useSocialDraftQueries";
import { MARKETPLACE_URL } from "../../lib/env";
 
type MoreSheetProps = {
	open: boolean;
	onClose: () => void;
};
 
const themeIcons: Record<Theme, typeof Sun> = {
	light: Sun,
	dark: Moon,
	system: Monitor,
};
 
const themeLabels: Record<Theme, string> = {
	light: "Light",
	dark: "Dark",
	system: "System",
};
 
const themeCycle: Record<Theme, Theme> = {
	light: "dark",
	dark: "system",
	system: "light",
};
 
const MORE_ITEMS = [
	{ to: "/analytics", icon: BarChart3, label: "Analytics" },
	{ to: "/profile", icon: User, label: "Profile" },
	{ to: "/team", icon: Users, label: "Team" },
	{ to: "/website", icon: Globe, label: "Website" },
	{ to: "/blogs", icon: FileText, label: "Blog" },
	{ to: "/settings", icon: Settings, label: "Settings" },
] as const;
 
// vaul 1.x PortalProps derives from ComponentPropsWithoutRef which drops
// children in React 19 — cast once here rather than suppressing at every site.
const DrawerPortal = Drawer.Portal as React.ComponentType<React.PropsWithChildren>;
 
export function MoreSheet({ open, onClose }: MoreSheetProps) {
	const routerState = useRouterState();
	const { signOut, hasProAccess } = useAuth();
	const { theme, setTheme } = useTheme();
	const ThemeIcon = themeIcons[theme];
	const { pro, proId, isLoading: isProLoading } = usePro();
	const { data: socialDraftsData } = useSocialDrafts(proId ?? null);
	const socialPendingCount = socialDraftsData?.pendingCount ?? 0;
 
	// #372 + #425: mobile Pros need a back-link to their own public profile,
	// not just the marketplace homepage. Same gate idiom as sidebar.tsx — only
	// rendered after the pro is published AND has a slug. Same-tab navigation
	// (rather than new-tab as on desktop) matches mobile expectations: tab
	// switching is awkward on phones and the portal is reachable via Back.
	const isProPublished = pro?.status === "published";
	const marketplaceProfileUrl =
		!isProLoading && isProPublished && pro?.slug
			? `${MARKETPLACE_URL}/pros/${pro.slug}`
			: null;
 
	return (
		<Drawer.Root
			open={open}
			onOpenChange={(o) => {
				if (!o) onClose();
			}}
		>
			<DrawerPortal>
				<Drawer.Overlay className="fixed inset-0 z-[55] bg-black/50" />
				<Drawer.Content className="fixed inset-x-0 bottom-0 z-[55] rounded-t-2xl bg-background-elevated pb-safe">
					<div className="mx-auto mt-3 mb-2 h-1 w-10 flex-shrink-0 rounded-full bg-border-default" />
					<Drawer.Title className="sr-only">More options</Drawer.Title>
					<nav className="px-2 pb-4">
						<ul className="grid grid-cols-3 gap-1">
							{MORE_ITEMS.map(({ to, icon: Icon, label }) => {
								const isActive =
									routerState.location.pathname.startsWith(to);
								return (
									<li key={to}>
										<Link
											to={to}
											onClick={onClose}
											className={`flex flex-col items-center gap-1.5 rounded-xl px-3 py-3 text-xs transition-colors ${
												isActive
													? "bg-primary-100 text-primary-700"
													: "text-foreground-muted hover:bg-background-muted"
											}`}
										>
											<Icon className="h-5 w-5" />
											{label}
										</Link>
									</li>
								);
							})}
							{/* Social Studio — fixed slot keeps the 3-col grid balanced
							   (6 base items + Social Studio = 7 → row 3 has 1 tile;
							   pair with Marketplace below when published to fill row 3). */}
							<li>
								<Link
									to="/social-studio"
									onClick={onClose}
									className={`relative flex flex-col items-center gap-1.5 rounded-xl px-3 py-3 text-xs transition-colors ${
										routerState.location.pathname.startsWith("/social-studio")
											? "bg-primary-100 text-primary-700"
											: "text-foreground-muted hover:bg-background-muted"
									}`}
								>
									<div className="relative">
										<Clapperboard className="h-5 w-5" />
										{socialPendingCount > 0 && (
											<span className="absolute -top-1.5 -right-2 bg-primary-600 text-white text-[8px] font-bold px-1 py-0.5 rounded-full min-w-[14px] text-center leading-tight">
												{socialPendingCount}
											</span>
										)}
									</div>
									Social Studio
								</Link>
							</li>
 
							{/* Marketplace — deep-link to the pro's public profile.
							   External anchor, not a TanStack <Link>, because the
							   marketplace is a separate Astro app. Same gate idiom
							   as desktop sidebar (hasProAccess + published + slug)
							   so the link surface is identical across platforms. */}
							{hasProAccess && marketplaceProfileUrl && (
								<li>
									<a
										href={marketplaceProfileUrl}
										onClick={onClose}
										className="flex flex-col items-center gap-1.5 rounded-xl px-3 py-3 text-xs text-foreground-muted hover:bg-background-muted transition-colors"
									>
										<Store className="h-5 w-5" />
										Marketplace
									</a>
								</li>
							)}
						</ul>
						<div className="mt-3 border-t border-border-default pt-3 px-1 flex gap-1">
							<button
								type="button"
								onClick={() => {
									onClose();
									window.dispatchEvent(new CustomEvent("open-feedback"));
								}}
								className="flex flex-1 flex-col items-center gap-1.5 rounded-xl px-2 py-3 text-xs text-foreground-muted hover:bg-background-muted transition-colors"
							>
								<MessageSquare className="h-5 w-5" />
								Feedback
							</button>
							<button
								type="button"
								onClick={() => setTheme(themeCycle[theme])}
								className="flex flex-1 flex-col items-center gap-1.5 rounded-xl px-2 py-3 text-xs text-foreground-muted hover:bg-background-muted transition-colors"
							>
								<ThemeIcon className="h-5 w-5" />
								{themeLabels[theme]}
							</button>
							<button
								type="button"
								onClick={() => {
									onClose();
									signOut();
								}}
								className="flex flex-1 flex-col items-center gap-1.5 rounded-xl px-2 py-3 text-xs text-error hover:bg-error/10 transition-colors"
							>
								<LogOut className="h-5 w-5" />
								Logout
							</button>
						</div>
					</nav>
				</Drawer.Content>
			</DrawerPortal>
		</Drawer.Root>
	);
}