buildmymcpserver/apps/web/components/user-menu.tsx

214 lines
7.4 KiB
TypeScript
Raw Normal View History

feat: user menu + profile page + in-app subscription management User-facing identity: - UserMenu component in dashboard header: avatar (deterministic colour from email hash), email + name, current plan badge, dropdown to Profile / Billing / Support / Your data / (Admin panel if isAdmin) / Sign out - /settings/profile: editable display name; email + phone shown read-only (changing them requires support ticket — magic-link flow assumed) - GET + PATCH /v1/account/profile In-app subscription management (no more Stripe Portal redirect for the common flows — cancellation, plan switch, invoice viewing all in-app): - Billing status now combines DB state with a live Stripe lookup of the subscription details + last 5 invoices. Single roundtrip. - POST /v1/billing/cancel → schedules cancel_at_period_end - POST /v1/billing/reactivate → undo scheduled cancel - POST /v1/billing/change-plan → prorated swap between any tier+cycle - /settings/billing rewritten: current plan card with renew/cancel date, big cancel button + reactivate flow, plan-switcher grid, invoice list with PDF + hosted-invoice links - Stripe portal still linked at the bottom as the escape hatch for rare actions (payment-method update, address change). New-subscription Checkout still uses Stripe-hosted Checkout (industry standard for PCI). Stripe SDK v22 / API 2024-09 fix: current_period_end moved to subscription items; updated read paths accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:46:36 +02:00
'use client';
import { apiFetch } from '@/lib/api';
import {
ChevronDown,
CreditCard,
Download,
LifeBuoy,
LogOut,
ShieldAlert,
User as UserIcon,
} from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
interface MeUser {
userId: string;
// All three can be null on a fresh phone-only signup: name not collected,
// email not entered, phone is the only stable identifier. Anything that
// dereferences these MUST handle the null case.
email: string | null;
feat: user menu + profile page + in-app subscription management User-facing identity: - UserMenu component in dashboard header: avatar (deterministic colour from email hash), email + name, current plan badge, dropdown to Profile / Billing / Support / Your data / (Admin panel if isAdmin) / Sign out - /settings/profile: editable display name; email + phone shown read-only (changing them requires support ticket — magic-link flow assumed) - GET + PATCH /v1/account/profile In-app subscription management (no more Stripe Portal redirect for the common flows — cancellation, plan switch, invoice viewing all in-app): - Billing status now combines DB state with a live Stripe lookup of the subscription details + last 5 invoices. Single roundtrip. - POST /v1/billing/cancel → schedules cancel_at_period_end - POST /v1/billing/reactivate → undo scheduled cancel - POST /v1/billing/change-plan → prorated swap between any tier+cycle - /settings/billing rewritten: current plan card with renew/cancel date, big cancel button + reactivate flow, plan-switcher grid, invoice list with PDF + hosted-invoice links - Stripe portal still linked at the bottom as the escape hatch for rare actions (payment-method update, address change). New-subscription Checkout still uses Stripe-hosted Checkout (industry standard for PCI). Stripe SDK v22 / API 2024-09 fix: current_period_end moved to subscription items; updated read paths accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:46:36 +02:00
name: string | null;
phone: string | null;
feat: user menu + profile page + in-app subscription management User-facing identity: - UserMenu component in dashboard header: avatar (deterministic colour from email hash), email + name, current plan badge, dropdown to Profile / Billing / Support / Your data / (Admin panel if isAdmin) / Sign out - /settings/profile: editable display name; email + phone shown read-only (changing them requires support ticket — magic-link flow assumed) - GET + PATCH /v1/account/profile In-app subscription management (no more Stripe Portal redirect for the common flows — cancellation, plan switch, invoice viewing all in-app): - Billing status now combines DB state with a live Stripe lookup of the subscription details + last 5 invoices. Single roundtrip. - POST /v1/billing/cancel → schedules cancel_at_period_end - POST /v1/billing/reactivate → undo scheduled cancel - POST /v1/billing/change-plan → prorated swap between any tier+cycle - /settings/billing rewritten: current plan card with renew/cancel date, big cancel button + reactivate flow, plan-switcher grid, invoice list with PDF + hosted-invoice links - Stripe portal still linked at the bottom as the escape hatch for rare actions (payment-method update, address change). New-subscription Checkout still uses Stripe-hosted Checkout (industry standard for PCI). Stripe SDK v22 / API 2024-09 fix: current_period_end moved to subscription items; updated read paths accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:46:36 +02:00
plan?: 'hobby' | 'pro' | 'team' | 'enterprise';
isAdmin?: boolean;
}
const PLAN_LABEL: Record<NonNullable<MeUser['plan']>, string> = {
hobby: 'Hobby',
pro: 'Pro',
team: 'Team',
enterprise: 'Enterprise',
};
/** Pick a deterministic accent for the avatar based on the email. Keeps
* the avatar stable across sessions without needing an uploaded image. */
function avatarShade(seed: string): string {
let hash = 0;
for (let i = 0; i < seed.length; i += 1) hash = (hash * 31 + seed.charCodeAt(i)) | 0;
const palette = [
'bg-indigo-500/30 text-indigo-200',
'bg-emerald-500/30 text-emerald-200',
'bg-rose-500/30 text-rose-200',
'bg-amber-500/30 text-amber-200',
'bg-sky-500/30 text-sky-200',
'bg-fuchsia-500/30 text-fuchsia-200',
];
return palette[Math.abs(hash) % palette.length] ?? palette[0]!;
}
export function UserMenu() {
const router = useRouter();
const [user, setUser] = useState<MeUser | null>(null);
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
apiFetch<{ user: MeUser }>('/v1/auth/me')
.then((r) => setUser(r.user))
.catch(() => setUser(null));
}, []);
// Click outside / Escape to close
useEffect(() => {
if (!open) return;
function onDocClick(e: MouseEvent) {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onKey);
};
}, [open]);
async function logout() {
await apiFetch('/v1/auth/logout', { method: 'POST' }).catch(() => undefined);
router.push('/');
}
if (!user) return null;
// Pick the best identifier we have. Phone-only signups have null email
// AND null name — used to crash with .charAt(null) here. Fallback chain:
// name → email → phone → "Account".
const identifier =
user.name?.trim() ||
user.email ||
user.phone ||
'Account';
const label = identifier;
const initial = identifier.charAt(0).toUpperCase() || '?';
// Avatar colour is derived from a stable identifier so it doesn't shift
// across sessions. Fall back to userId so we always have something.
const shade = avatarShade(user.email || user.phone || user.userId);
feat: user menu + profile page + in-app subscription management User-facing identity: - UserMenu component in dashboard header: avatar (deterministic colour from email hash), email + name, current plan badge, dropdown to Profile / Billing / Support / Your data / (Admin panel if isAdmin) / Sign out - /settings/profile: editable display name; email + phone shown read-only (changing them requires support ticket — magic-link flow assumed) - GET + PATCH /v1/account/profile In-app subscription management (no more Stripe Portal redirect for the common flows — cancellation, plan switch, invoice viewing all in-app): - Billing status now combines DB state with a live Stripe lookup of the subscription details + last 5 invoices. Single roundtrip. - POST /v1/billing/cancel → schedules cancel_at_period_end - POST /v1/billing/reactivate → undo scheduled cancel - POST /v1/billing/change-plan → prorated swap between any tier+cycle - /settings/billing rewritten: current plan card with renew/cancel date, big cancel button + reactivate flow, plan-switcher grid, invoice list with PDF + hosted-invoice links - Stripe portal still linked at the bottom as the escape hatch for rare actions (payment-method update, address change). New-subscription Checkout still uses Stripe-hosted Checkout (industry standard for PCI). Stripe SDK v22 / API 2024-09 fix: current_period_end moved to subscription items; updated read paths accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:46:36 +02:00
return (
<div ref={wrapRef} className="relative">
<button
type="button"
aria-label="Account menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex h-7 items-center gap-1.5 rounded-md pl-1 pr-1.5 text-[12.5px] text-[--color-fg-muted] transition-colors hover:bg-[--color-bg-subtle] hover:text-[--color-fg]"
>
<span
aria-hidden
className={`mono inline-flex size-5 items-center justify-center rounded-full text-[11px] font-semibold ${shade}`}
>
{initial}
</span>
<ChevronDown size={13} className="opacity-60" />
</button>
{open && (
<div
role="menu"
// Background + border via inline style: Tailwind v4's bracket-arbitrary
// syntax `bg-[--color-X]` emits invalid CSS (forgets var() wrap), so the
// dropdown rendered fully transparent. Inline color-mix with explicit
// var() gives the frosted look — 88% elevated panel + backdrop-blur on
// top — same pattern as the marketing burger menu.
className="absolute right-0 top-9 z-50 w-64 overflow-hidden rounded-md border shadow-lg shadow-black/40 backdrop-blur-md"
style={{
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 88%, transparent)',
borderColor: 'var(--color-border)',
}}
feat: user menu + profile page + in-app subscription management User-facing identity: - UserMenu component in dashboard header: avatar (deterministic colour from email hash), email + name, current plan badge, dropdown to Profile / Billing / Support / Your data / (Admin panel if isAdmin) / Sign out - /settings/profile: editable display name; email + phone shown read-only (changing them requires support ticket — magic-link flow assumed) - GET + PATCH /v1/account/profile In-app subscription management (no more Stripe Portal redirect for the common flows — cancellation, plan switch, invoice viewing all in-app): - Billing status now combines DB state with a live Stripe lookup of the subscription details + last 5 invoices. Single roundtrip. - POST /v1/billing/cancel → schedules cancel_at_period_end - POST /v1/billing/reactivate → undo scheduled cancel - POST /v1/billing/change-plan → prorated swap between any tier+cycle - /settings/billing rewritten: current plan card with renew/cancel date, big cancel button + reactivate flow, plan-switcher grid, invoice list with PDF + hosted-invoice links - Stripe portal still linked at the bottom as the escape hatch for rare actions (payment-method update, address change). New-subscription Checkout still uses Stripe-hosted Checkout (industry standard for PCI). Stripe SDK v22 / API 2024-09 fix: current_period_end moved to subscription items; updated read paths accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:46:36 +02:00
>
<div className="border-b border-[--color-border] px-3.5 py-3">
<div className="flex items-center gap-2.5">
<span
aria-hidden
className={`mono inline-flex size-8 items-center justify-center rounded-full text-[13px] font-semibold ${shade}`}
>
{initial}
</span>
<div className="min-w-0">
<div className="truncate text-[12.5px] font-medium text-[--color-fg]">
{label}
</div>
<div className="mono truncate text-[10.5px] text-[--color-fg-subtle]">
{user.email || user.phone || user.userId.slice(0, 8)}
feat: user menu + profile page + in-app subscription management User-facing identity: - UserMenu component in dashboard header: avatar (deterministic colour from email hash), email + name, current plan badge, dropdown to Profile / Billing / Support / Your data / (Admin panel if isAdmin) / Sign out - /settings/profile: editable display name; email + phone shown read-only (changing them requires support ticket — magic-link flow assumed) - GET + PATCH /v1/account/profile In-app subscription management (no more Stripe Portal redirect for the common flows — cancellation, plan switch, invoice viewing all in-app): - Billing status now combines DB state with a live Stripe lookup of the subscription details + last 5 invoices. Single roundtrip. - POST /v1/billing/cancel → schedules cancel_at_period_end - POST /v1/billing/reactivate → undo scheduled cancel - POST /v1/billing/change-plan → prorated swap between any tier+cycle - /settings/billing rewritten: current plan card with renew/cancel date, big cancel button + reactivate flow, plan-switcher grid, invoice list with PDF + hosted-invoice links - Stripe portal still linked at the bottom as the escape hatch for rare actions (payment-method update, address change). New-subscription Checkout still uses Stripe-hosted Checkout (industry standard for PCI). Stripe SDK v22 / API 2024-09 fix: current_period_end moved to subscription items; updated read paths accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:46:36 +02:00
</div>
</div>
</div>
{user.plan && (
<div className="mt-2.5 flex items-center justify-between text-[11px]">
<span className="text-[--color-fg-subtle]">Plan</span>
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2 py-0.5 text-[10.5px] text-[--color-fg]">
{PLAN_LABEL[user.plan]}
</span>
</div>
)}
</div>
<nav className="py-1">
<MenuLink href="/settings/profile" icon={UserIcon} label="Profile" onClick={() => setOpen(false)} />
<MenuLink href="/settings/billing" icon={CreditCard} label="Billing" onClick={() => setOpen(false)} />
<MenuLink href="/settings/support" icon={LifeBuoy} label="Support" onClick={() => setOpen(false)} />
<MenuLink href="/settings/account" icon={Download} label="Your data" onClick={() => setOpen(false)} />
{user.isAdmin && (
<MenuLink href="/admin" icon={ShieldAlert} label="Admin panel" onClick={() => setOpen(false)} />
)}
</nav>
<div className="border-t border-[--color-border] py-1">
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
void logout();
}}
className="flex w-full items-center gap-2 px-3.5 py-1.5 text-left text-[12.5px] text-[--color-fg-muted] transition-colors hover:bg-[--color-bg-subtle] hover:text-[--color-danger]"
>
<LogOut size={13} />
Sign out
</button>
</div>
</div>
)}
</div>
);
}
function MenuLink({
href,
icon: Icon,
label,
onClick,
}: {
href: string;
icon: React.ComponentType<{ size?: number }>;
label: string;
onClick: () => void;
}) {
return (
<Link
role="menuitem"
href={href}
onClick={onClick}
className="flex items-center gap-2 px-3.5 py-1.5 text-[12.5px] text-[--color-fg-muted] transition-colors hover:bg-[--color-bg-subtle] hover:text-[--color-fg]"
>
<Icon size={13} />
{label}
</Link>
);
}