landing-user-menu.tsx264 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useEffect, useRef, useState } from 'react';
5
6import { BookOpenIcon, type BookOpenIconHandle } from './ui/book-open';
7import { LayoutGridIcon, type LayoutGridIconHandle } from './ui/layout-grid';
8import { LogOutIcon, type LogOutIconHandle } from './ui/log-out';
9
10interface UserInfo {
11 name: string | null;
12 email: string;
13 image: string | null;
14 legalName: string | null;
15}
16
17/**
18 * Landing-page avatar menu, shown in place of the "sign in" link when the
19 * visitor already has a session. Mirrors the sidebar UserMenuButton's
20 * dropdown contents but opens downward (the button lives at the top of
21 * the page, not the bottom of a sidebar).
22 *
23 * Each row drives its own icon animation from hover on the whole row so
24 * the motion kicks in the moment the cursor crosses the button's padding,
25 * not only the icon's 16px surface.
26 */
27export function LandingUserMenu({ user }: { user: UserInfo }) {
28 const router = useRouter();
29 const [open, setOpen] = useState(false);
30 const [signingOut, setSigningOut] = useState(false);
31 const containerRef = useRef<HTMLDivElement>(null);
32
33 const displayName = user.legalName ?? user.name ?? user.email.split('@')[0]!;
34
35 useEffect(() => {
36 if (!open) return;
37 function onDown(e: MouseEvent) {
38 if (!containerRef.current?.contains(e.target as Node)) setOpen(false);
39 }
40 function onKey(e: KeyboardEvent) {
41 if (e.key === 'Escape') setOpen(false);
42 }
43 document.addEventListener('mousedown', onDown);
44 document.addEventListener('keydown', onKey);
45 return () => {
46 document.removeEventListener('mousedown', onDown);
47 document.removeEventListener('keydown', onKey);
48 };
49 }, [open]);
50
51 async function signOut() {
52 setSigningOut(true);
53 try {
54 await fetch('/api/v1/auth/sign-out', {
55 method: 'POST',
56 credentials: 'include',
57 });
58 } finally {
59 window.location.href = '/';
60 }
61 }
62
63 return (
64 <div ref={containerRef} className="relative">
65 <button
66 type="button"
67 onClick={() => setOpen((v) => !v)}
68 aria-haspopup="menu"
69 aria-expanded={open}
70 aria-label={`account menu for ${displayName}`}
71 title={displayName}
72 className="flex size-9 items-center justify-center rounded-full border border-[var(--color-border-subtle)] transition-colors hover:border-[var(--color-border)]"
73 >
74 <Avatar user={user} size={28} />
75 </button>
76
77 {open && (
78 <div
79 role="menu"
80 className="absolute right-0 top-full z-50 mt-2 w-60 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] shadow-[var(--shadow-lg)]"
81 >
82 <div className="flex items-center gap-3 border-b border-[var(--color-border-subtle)] px-3 py-3">
83 <Avatar user={user} size={32} />
84 <div className="flex min-w-0 flex-1 flex-col leading-tight">
85 <span className="truncate text-sm font-medium text-[var(--color-text)]">
86 {displayName}
87 </span>
88 <span className="truncate font-mono text-[11px] text-[var(--color-text-subtle)]">
89 {user.email}
90 </span>
91 </div>
92 </div>
93 <ul className="p-1">
94 <li>
95 <AnimatedMenuButton
96 icon={LayoutGridIcon}
97 label="dashboard"
98 onSelect={() => {
99 setOpen(false);
100 router.push('/dashboard');
101 }}
102 />
103 </li>
104 <li>
105 <AnimatedMenuAnchor
106 href="https://docs.briven.tech"
107 icon={BookOpenIcon}
108 label="docs"
109 target="_blank"
110 rel="noreferrer"
111 onSelect={() => setOpen(false)}
112 />
113 </li>
114 <li>
115 <AnimatedMenuButton
116 icon={LogOutIcon}
117 label={signingOut ? 'signing out…' : 'log out'}
118 disabled={signingOut}
119 destructive
120 onSelect={signOut}
121 />
122 </li>
123 </ul>
124 </div>
125 )}
126 </div>
127 );
128}
129
130type IconHandle = LayoutGridIconHandle | BookOpenIconHandle | LogOutIconHandle;
131type IconComponent = typeof LayoutGridIcon | typeof BookOpenIcon | typeof LogOutIcon;
132
133function useRowHover() {
134 const ref = useRef<IconHandle>(null);
135 const [hover, setHover] = useState(false);
136 useEffect(() => {
137 if (!ref.current) return;
138 if (hover) ref.current.startAnimation();
139 else ref.current.stopAnimation();
140 }, [hover]);
141 return {
142 ref,
143 handlers: {
144 onMouseEnter: () => setHover(true),
145 onMouseLeave: () => setHover(false),
146 onFocus: () => setHover(true),
147 onBlur: () => setHover(false),
148 },
149 };
150}
151
152function rowClasses(destructive?: boolean) {
153 return `flex w-full cursor-pointer items-center gap-3 rounded px-3 py-2 font-mono text-sm transition-colors ${
154 destructive
155 ? 'text-[var(--color-error)] hover:bg-[var(--color-surface-overlay)]'
156 : 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-overlay)] hover:text-[var(--color-text)]'
157 } disabled:cursor-not-allowed disabled:opacity-50`;
158}
159
160function iconNode(Icon: IconComponent, ref: React.Ref<IconHandle>) {
161 return (
162 <span className="pointer-events-none">
163 <Icon ref={ref as never} size={16} />
164 </span>
165 );
166}
167
168function AnimatedMenuAnchor({
169 href,
170 icon: Icon,
171 label,
172 onSelect,
173 target,
174 rel,
175}: {
176 href: string;
177 icon: IconComponent;
178 label: string;
179 onSelect?: () => void;
180 target?: string;
181 rel?: string;
182}) {
183 const { ref, handlers } = useRowHover();
184 return (
185 <a
186 href={href}
187 target={target}
188 rel={rel}
189 role="menuitem"
190 onClick={onSelect}
191 className={rowClasses()}
192 {...handlers}
193 >
194 {iconNode(Icon, ref)}
195 {label}
196 </a>
197 );
198}
199
200function AnimatedMenuButton({
201 icon: Icon,
202 label,
203 onSelect,
204 disabled,
205 destructive,
206}: {
207 icon: IconComponent;
208 label: string;
209 onSelect?: () => void;
210 disabled?: boolean;
211 destructive?: boolean;
212}) {
213 const { ref, handlers } = useRowHover();
214 return (
215 <button
216 type="button"
217 role="menuitem"
218 onClick={onSelect}
219 disabled={disabled}
220 className={rowClasses(destructive)}
221 {...handlers}
222 >
223 {iconNode(Icon, ref)}
224 {label}
225 </button>
226 );
227}
228
229function Avatar({ user, size }: { user: UserInfo; size: number }) {
230 const initials = getInitials(user.legalName ?? user.name ?? user.email);
231 if (user.image) {
232 return (
233 <img
234 src={user.image}
235 alt=""
236 width={size}
237 height={size}
238 className="shrink-0 rounded-full object-cover"
239 style={{ width: size, height: size }}
240 />
241 );
242 }
243 return (
244 <span
245 aria-hidden
246 className="flex shrink-0 items-center justify-center rounded-full bg-[var(--color-primary-subtle)] font-mono text-[var(--color-primary)]"
247 style={{ width: size, height: size, fontSize: Math.round(size * 0.42) }}
248 >
249 {initials}
250 </span>
251 );
252}
253
254function getInitials(source: string): string {
255 const cleaned = source.trim();
256 if (!cleaned) return '·';
257 const parts = cleaned.includes('@') ? [cleaned.split('@')[0]!] : cleaned.split(/\s+/);
258 const letters = parts
259 .slice(0, 2)
260 .map((p) => p[0])
261 .filter(Boolean)
262 .join('');
263 return (letters || cleaned[0] || '·').toUpperCase();
264}