feat: user menu + profile page + in-app subscription management
All checks were successful
Deploy to Production / deploy (push) Successful in 52s

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>
This commit is contained in:
Marco Sadjadi 2026-05-25 17:46:36 +02:00
parent 1b8f61df5f
commit 1c58977596
6 changed files with 856 additions and 88 deletions

View File

@ -12,12 +12,57 @@ import {
users, users,
} from '@bmm/db'; } from '@bmm/db';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { audit } from '../lib/audit.js'; import { audit } from '../lib/audit.js';
import { requireAuth } from '../plugins/session.js'; import { requireAuth } from '../plugins/session.js';
const db = createDb(); const db = createDb();
export async function accountRoutes(app: FastifyInstance): Promise<void> { export async function accountRoutes(app: FastifyInstance): Promise<void> {
// ─── Profile: read + update ───────────────────────────────────────────
app.get('/v1/account/profile', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const [row] = await db
.select({
id: users.id,
email: users.email,
name: users.name,
phone: users.phone,
isAdmin: users.isAdmin,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.id, user.userId))
.limit(1);
if (!row) return reply.code(404).send({ error: 'user_not_found' });
return reply.send({ profile: row });
});
app.patch('/v1/account/profile', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Body = z.object({
name: z.string().min(1).max(128).optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
if (!parsed.data.name) return reply.send({ ok: true, changed: false });
await db
.update(users)
.set({ name: parsed.data.name })
.where(eq(users.id, user.userId));
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'account.profile_updated',
resourceType: 'user',
ipAddress: req.ip,
});
return reply.send({ ok: true, changed: true });
});
/** /**
* GDPR Art. 15 / Swiss DSG Art. 25 right of access. Returns every record * GDPR Art. 15 / Swiss DSG Art. 25 right of access. Returns every record
* we hold that belongs to the calling user. Excludes hashed passwords, * we hold that belongs to the calling user. Excludes hashed passwords,

View File

@ -108,6 +108,9 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
}); });
// ─── Billing status — drives the /settings/billing UI ──────────────────── // ─── Billing status — drives the /settings/billing UI ────────────────────
// Combines our DB state (plan, suspension) with a live Stripe lookup of the
// subscription + recent invoices, so the page can render cancel buttons +
// invoice links inline without a second round-trip.
app.get('/v1/billing/status', { preHandler: requireAuth }, async (req, reply) => { app.get('/v1/billing/status', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!; const user = req.user!;
const [org] = await db const [org] = await db
@ -122,13 +125,167 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
.where(eq(organizations.id, user.orgId)) .where(eq(organizations.id, user.orgId))
.limit(1); .limit(1);
if (!org) return reply.code(404).send({ error: 'org_not_found' }); if (!org) return reply.code(404).send({ error: 'org_not_found' });
return reply.send({
const base = {
plan: org.plan, plan: org.plan,
hasCustomer: Boolean(org.stripeCustomerId), hasCustomer: Boolean(org.stripeCustomerId),
hasSubscription: Boolean(org.stripeSubscriptionId), hasSubscription: Boolean(org.stripeSubscriptionId),
suspended: org.suspended, suspended: org.suspended,
suspendedReason: org.suspendedReason, suspendedReason: org.suspendedReason,
}); };
if (!stripe || !org.stripeSubscriptionId || !org.stripeCustomerId) {
return reply.send(base);
}
try {
const [sub, invoices] = await Promise.all([
stripe.subscriptions.retrieve(org.stripeSubscriptionId),
stripe.invoices.list({ customer: org.stripeCustomerId, limit: 5 }),
]);
const item = sub.items.data[0];
const price = item?.price;
// Stripe v2024-09 moved period boundaries onto subscription items;
// for our single-item subs they're equivalent to the old sub-level field.
const currentPeriodEnd = item?.current_period_end ?? 0;
return reply.send({
...base,
subscription: {
id: sub.id,
status: sub.status,
currentPeriodEnd,
cancelAtPeriodEnd: sub.cancel_at_period_end,
priceId: price?.id ?? null,
amount: price?.unit_amount ?? null,
currency: price?.currency ?? null,
interval: price?.recurring?.interval ?? null,
},
invoices: invoices.data.map((inv) => ({
id: inv.id,
number: inv.number,
status: inv.status,
amountPaid: inv.amount_paid,
currency: inv.currency,
created: inv.created,
pdfUrl: inv.invoice_pdf,
hostedUrl: inv.hosted_invoice_url,
})),
});
} catch (err) {
app.log.warn({ err }, 'stripe status fetch failed — returning db-only');
return reply.send({ ...base, _stripeError: true });
}
});
// ─── In-app cancellation (no portal redirect) ────────────────────────────
// Schedules cancellation at period end — user keeps paid features until the
// billing date already paid for, and Stripe automatically deletes the sub
// after that. The webhook handler converts that to plan='hobby'.
app.post('/v1/billing/cancel', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const [org] = await db
.select({ stripeSubscriptionId: organizations.stripeSubscriptionId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org?.stripeSubscriptionId) {
return reply.code(409).send({ error: 'no_active_subscription' });
}
try {
const sub = await stripe.subscriptions.update(org.stripeSubscriptionId, {
cancel_at_period_end: true,
});
const cancelAt = sub.items.data[0]?.current_period_end ?? null;
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'billing.cancel_scheduled',
resourceType: 'subscription',
resourceId: org.stripeSubscriptionId,
metadata: { cancelAt },
ipAddress: req.ip,
});
return reply.send({ ok: true, cancelAt });
} catch (err) {
app.log.error({ err }, 'cancel failed');
return reply.code(502).send({ error: 'cancel_failed' });
}
});
// ─── Reactivate (undo scheduled cancellation) ────────────────────────────
app.post('/v1/billing/reactivate', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const [org] = await db
.select({ stripeSubscriptionId: organizations.stripeSubscriptionId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org?.stripeSubscriptionId) {
return reply.code(409).send({ error: 'no_active_subscription' });
}
try {
await stripe.subscriptions.update(org.stripeSubscriptionId, {
cancel_at_period_end: false,
});
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'billing.reactivated',
resourceType: 'subscription',
resourceId: org.stripeSubscriptionId,
ipAddress: req.ip,
});
return reply.send({ ok: true });
} catch (err) {
app.log.error({ err }, 'reactivate failed');
return reply.code(502).send({ error: 'reactivate_failed' });
}
});
// ─── In-app plan change (upgrade/downgrade between Pro/Team monthly/yearly)
app.post('/v1/billing/change-plan', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const parsed = TierBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const newPriceId = priceIdForTier(parsed.data.tier as PriceTier);
if (!newPriceId) {
return reply.code(503).send({ error: 'price_not_configured', tier: parsed.data.tier });
}
const [org] = await db
.select({ stripeSubscriptionId: organizations.stripeSubscriptionId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org?.stripeSubscriptionId) {
return reply.code(409).send({ error: 'no_active_subscription' });
}
try {
const current = await stripe.subscriptions.retrieve(org.stripeSubscriptionId);
const itemId = current.items.data[0]?.id;
if (!itemId) return reply.code(500).send({ error: 'subscription_item_missing' });
await stripe.subscriptions.update(org.stripeSubscriptionId, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'create_prorations',
});
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'billing.plan_changed',
resourceType: 'subscription',
resourceId: org.stripeSubscriptionId,
metadata: { tier: parsed.data.tier },
ipAddress: req.ip,
});
return reply.send({ ok: true });
} catch (err) {
app.log.error({ err }, 'plan change failed');
return reply.code(502).send({ error: 'plan_change_failed' });
}
}); });
// ─── Webhook ───────────────────────────────────────────────────────────── // ─── Webhook ─────────────────────────────────────────────────────────────

View File

@ -1,6 +1,7 @@
import { CookieBanner } from '@/components/cookie-banner'; import { CookieBanner } from '@/components/cookie-banner';
import { Logo } from '@/components/logo'; import { Logo } from '@/components/logo';
import { MobileActionBar } from '@/components/mobile-action-bar'; import { MobileActionBar } from '@/components/mobile-action-bar';
import { UserMenu } from '@/components/user-menu';
import { FileClock, LayoutGrid, Package, Server, Settings } from 'lucide-react'; import { FileClock, LayoutGrid, Package, Server, Settings } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
@ -29,12 +30,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</NavLink> </NavLink>
</nav> </nav>
</div> </div>
<Link <div className="flex items-center gap-1 sm:gap-2">
href="/servers/new" <Link
className="hidden h-7 items-center gap-1.5 rounded-md bg-[--color-accent] px-2.5 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:inline-flex" href="/servers/new"
> className="hidden h-7 items-center gap-1.5 rounded-md bg-[--color-accent] px-2.5 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:inline-flex"
+ New server >
</Link> + New server
</Link>
<UserMenu />
</div>
</div> </div>
</header> </header>
<main className="flex-1 bg-[--color-bg] pb-20 sm:pb-0">{children}</main> <main className="flex-1 bg-[--color-bg] pb-20 sm:pb-0">{children}</main>

View File

@ -10,12 +10,37 @@ import { Suspense, useCallback, useEffect, useState } from 'react';
type Plan = 'hobby' | 'pro' | 'team' | 'enterprise'; type Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly'; type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly';
interface SubscriptionInfo {
id: string;
status: string;
currentPeriodEnd: number;
cancelAtPeriodEnd: boolean;
priceId: string | null;
amount: number | null;
currency: string | null;
interval: string | null;
}
interface Invoice {
id: string;
number: string | null;
status: string | null;
amountPaid: number;
currency: string;
created: number;
pdfUrl: string | null;
hostedUrl: string | null;
}
interface BillingStatus { interface BillingStatus {
plan: Plan; plan: Plan;
hasCustomer: boolean; hasCustomer: boolean;
hasSubscription: boolean; hasSubscription: boolean;
suspended: boolean; suspended: boolean;
suspendedReason: string | null; suspendedReason: string | null;
subscription?: SubscriptionInfo;
invoices?: Invoice[];
_stripeError?: boolean;
} }
const PLAN_LABEL: Record<Plan, string> = { const PLAN_LABEL: Record<Plan, string> = {
@ -25,6 +50,14 @@ const PLAN_LABEL: Record<Plan, string> = {
enterprise: 'Enterprise', enterprise: 'Enterprise',
}; };
function formatMoney(amount: number | null, currency: string | null): string {
if (amount === null || currency === null) return '—';
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount / 100);
}
function BillingInner() { function BillingInner() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -50,8 +83,6 @@ function BillingInner() {
loadStatus(); loadStatus();
}, [loadStatus]); }, [loadStatus]);
// Came back from Stripe Checkout? Poll briefly — the webhook usually arrives
// within a couple seconds and flips the plan.
useEffect(() => { useEffect(() => {
if (!justSubscribed) return; if (!justSubscribed) return;
let tries = 0; let tries = 0;
@ -63,39 +94,13 @@ function BillingInner() {
return () => clearInterval(id); return () => clearInterval(id);
}, [justSubscribed, loadStatus]); }, [justSubscribed, loadStatus]);
const startCheckout = useCallback( const startCheckout = useCallback(async (tier: Tier) => {
async (tier: Tier) => { setBusy(tier);
setBusy(tier);
setError(null);
try {
const res = await apiFetch<{ url: string }>('/v1/billing/checkout-session', {
method: 'POST',
body: JSON.stringify({ tier }),
});
window.location.href = res.url;
} catch (e) {
setBusy(null);
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
}
},
[],
);
// Auto-fire checkout when the user lands here from the pricing page CTA.
useEffect(() => {
if (!autoUpgradeTier || !status) return;
if (status.hasSubscription) return; // already paying — don't redirect
void startCheckout(autoUpgradeTier);
}, [autoUpgradeTier, status, startCheckout]);
async function openPortal() {
setBusy('portal');
setError(null); setError(null);
try { try {
const res = await apiFetch<{ url: string }>('/v1/billing/portal', { const res = await apiFetch<{ url: string }>('/v1/billing/checkout-session', {
method: 'POST', method: 'POST',
body: '{}', body: JSON.stringify({ tier }),
}); });
window.location.href = res.url; window.location.href = res.url;
} catch (e) { } catch (e) {
@ -103,75 +108,171 @@ function BillingInner() {
const detail = (e as { detail?: { detail?: string; error?: string } }).detail; const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message); setError(detail?.detail ?? detail?.error ?? (e as Error).message);
} }
}, []);
useEffect(() => {
if (!autoUpgradeTier || !status) return;
if (status.hasSubscription) return;
void startCheckout(autoUpgradeTier);
}, [autoUpgradeTier, status, startCheckout]);
async function changePlan(tier: Tier) {
if (!confirm(`Switch to ${tier.replace('_', ' ')}? Prorated charges apply immediately.`)) return;
setBusy(`change-${tier}`);
setError(null);
try {
await apiFetch('/v1/billing/change-plan', {
method: 'POST',
body: JSON.stringify({ tier }),
});
// Webhook will update plan asynchronously; poll briefly.
let tries = 0;
const id = setInterval(() => {
tries += 1;
loadStatus();
if (tries >= 6) clearInterval(id);
}, 1500);
} catch (e) {
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
} finally {
setBusy(null);
}
}
async function cancelSubscription() {
if (!confirm('Cancel subscription at the end of the current billing period?')) return;
setBusy('cancel');
setError(null);
try {
await apiFetch('/v1/billing/cancel', { method: 'POST', body: '{}' });
loadStatus();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(null);
}
}
async function reactivateSubscription() {
setBusy('reactivate');
setError(null);
try {
await apiFetch('/v1/billing/reactivate', { method: 'POST', body: '{}' });
loadStatus();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(null);
}
} }
if (!status && !error) { if (!status && !error) {
return ( return (
<div className="mx-auto max-w-3xl px-6 py-12 text-center"> <div className="mx-auto max-w-3xl px-6 py-12 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} /> <Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
<p className="mt-3 text-[13px] text-[--color-fg-muted]">Loading billing</p>
</div> </div>
); );
} }
const sub = status?.subscription;
const hasSub = Boolean(status?.hasSubscription && sub);
const planValue = status?.plan ?? 'hobby';
const currentPlanIsPro = planValue === 'pro';
const currentPlanIsTeam = planValue === 'team';
return ( return (
<div className="mx-auto max-w-3xl px-6 py-10"> <div className="mx-auto max-w-3xl px-6 py-10">
<div> <div>
<h1 className="text-[22px] font-semibold tracking-tight">Billing</h1> <h1 className="text-[22px] font-semibold tracking-tight">Billing</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]"> <p className="mt-1 text-[13px] text-[--color-fg-muted]">
Manage your subscription, payment method, and invoices. Plan, renewal, invoices and cancellation everything in-app.
</p> </p>
</div> </div>
{cancelledCheckout && ( {cancelledCheckout && (
<div className="mt-4 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-3.5 py-2.5 text-[12.5px]"> <Alert tone="muted">Checkout cancelled. No charge made.</Alert>
Checkout cancelled. No charge made.
</div>
)} )}
{justSubscribed && ( {justSubscribed && (
<div className="mt-4 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3.5 py-2.5 text-[12.5px]"> <Alert tone="success">
Subscription active. Plan will update within a few seconds refreshing Subscription active. Plan updates within a few seconds refreshing
</div> </Alert>
)} )}
{status?.suspended && ( {status?.suspended && (
<div className="mt-4 rounded-md border border-amber-500/40 bg-amber-500/10 px-3.5 py-2.5 text-[12.5px]"> <Alert tone="warn">
<strong>Subscription paused</strong> {status.suspendedReason ?? 'payment issue'}. <strong>Subscription paused</strong> {status.suspendedReason ?? 'payment issue'}. New
Update your payment method below to restore access. Existing servers keep running. servers + previews are blocked until payment succeeds; existing servers keep running.
</div> </Alert>
)} )}
{error && ( {status?._stripeError && (
<div className="mt-4 rounded-md border border-[--color-danger]/40 bg-[--color-danger]/10 px-3.5 py-2.5 text-[12.5px] text-[--color-fg]"> <Alert tone="warn">
{error} Live billing data temporarily unavailable showing local state only. Try again in a
</div> minute.
</Alert>
)} )}
{error && <Alert tone="error">{error}</Alert>}
{status && ( {status && (
<div className="panel mt-6 p-5"> <div className="panel mt-6 p-5">
<div className="flex items-baseline justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]"> <div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
Current plan Current plan
</div> </div>
<div className="mt-1 text-[20px] font-semibold tracking-tight"> <div className="mt-1 flex items-baseline gap-2">
{PLAN_LABEL[status.plan]} <span className="text-[22px] font-semibold tracking-tight">
{PLAN_LABEL[status.plan]}
</span>
{sub?.amount !== null && sub?.amount !== undefined && (
<span className="text-[13px] text-[--color-fg-muted]">
{formatMoney(sub.amount, sub.currency)} / {sub.interval}
</span>
)}
</div> </div>
</div> </div>
{status.hasSubscription && ( {sub && (
<Button <div className="text-right text-[12px]">
variant="secondary" <div className="text-[--color-fg-subtle]">
size="md" {sub.cancelAtPeriodEnd ? 'Cancels' : 'Renews'}
onClick={openPortal} </div>
disabled={busy === 'portal'} <div className="mt-0.5 mono text-[--color-fg]">
> {new Date(sub.currentPeriodEnd * 1000).toLocaleDateString()}
{busy === 'portal' ? 'Opening…' : 'Manage billing'} </div>
</Button> </div>
)} )}
</div> </div>
{!status.hasSubscription && (
<p className="mt-3 text-[12.5px] text-[--color-fg-muted]"> {hasSub && sub && (
You&apos;re on the free tier. Upgrade below to unlock more servers, faster Claude <div className="mt-5 flex flex-wrap gap-2 border-t border-[--color-border] pt-4">
analysis and higher daily limits. {sub.cancelAtPeriodEnd ? (
</p> <>
<p className="w-full text-[12.5px] text-[--color-fg-muted]">
Scheduled to cancel on{' '}
<span className="mono text-[--color-fg]">
{new Date(sub.currentPeriodEnd * 1000).toLocaleDateString()}
</span>
. You keep paid features until then.
</p>
<Button
variant="primary"
size="md"
onClick={reactivateSubscription}
disabled={busy === 'reactivate'}
>
{busy === 'reactivate' ? 'Reactivating…' : 'Keep subscription'}
</Button>
</>
) : (
<Button
variant="ghost"
size="md"
onClick={cancelSubscription}
disabled={busy === 'cancel'}
>
{busy === 'cancel' ? 'Cancelling…' : 'Cancel subscription'}
</Button>
)}
</div>
)} )}
</div> </div>
)} )}
@ -208,27 +309,136 @@ function BillingInner() {
/> />
</div> </div>
<p className="mt-4 text-[12px] text-[--color-fg-subtle]"> <p className="mt-4 text-[12px] text-[--color-fg-subtle]">
Annual saves 2 months. VAT calculated automatically based on your billing address. Cancel Annual saves 2 months. VAT calculated automatically based on your billing address.
any time from the billing portal service continues until end of period. Cancel anytime from this page service continues until end of period.
</p>
<p className="mt-2 text-[12px] text-[--color-fg-subtle]">
Need Enterprise (BYOC, SSO, EU-data-residency)?{' '}
<a
className="text-[--color-accent] hover:underline"
href="mailto:sales@buildmymcpserver.com"
>
Contact sales
</a>
.
</p> </p>
</> </>
)} )}
<div className="mt-10 text-[12px] text-[--color-fg-subtle]"> {status && status.hasSubscription && (
<>
<h2 className="mt-10 text-[15px] font-semibold tracking-tight">Switch plan</h2>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Prorated immediately. The new amount is added to your next invoice.
</p>
<div className="mt-3 grid gap-2 md:grid-cols-2">
{!currentPlanIsPro && (
<PlanSwitch
label="Pro — €49 / month"
onClick={() => changePlan('pro_monthly')}
busy={busy === 'change-pro_monthly'}
/>
)}
<PlanSwitch
label={currentPlanIsPro ? 'Pro — €490 / year (2 months free)' : 'Pro — yearly'}
onClick={() => changePlan('pro_yearly')}
busy={busy === 'change-pro_yearly'}
/>
{!currentPlanIsTeam && (
<PlanSwitch
label="Team — €199 / month"
onClick={() => changePlan('team_monthly')}
busy={busy === 'change-team_monthly'}
/>
)}
<PlanSwitch
label={currentPlanIsTeam ? 'Team — €1990 / year (2 months free)' : 'Team — yearly'}
onClick={() => changePlan('team_yearly')}
busy={busy === 'change-team_yearly'}
/>
</div>
</>
)}
{status?.invoices && status.invoices.length > 0 && (
<>
<h2 className="mt-10 text-[15px] font-semibold tracking-tight">Invoices</h2>
<div className="panel mt-3 divide-y divide-[--color-border]">
{status.invoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between px-4 py-3 text-[12.5px]">
<div>
<div className="mono text-[--color-fg]">{inv.number ?? inv.id}</div>
<div className="text-[11px] text-[--color-fg-subtle]">
{new Date(inv.created * 1000).toLocaleDateString()} · {inv.status ?? 'unknown'}
</div>
</div>
<div className="flex items-center gap-3">
<span className="mono text-[--color-fg]">
{formatMoney(inv.amountPaid, inv.currency)}
</span>
{inv.pdfUrl && (
<a
href={inv.pdfUrl}
target="_blank"
rel="noreferrer"
className="text-[11.5px] text-[--color-accent] hover:underline"
>
PDF
</a>
)}
{inv.hostedUrl && (
<a
href={inv.hostedUrl}
target="_blank"
rel="noreferrer"
className="text-[11.5px] text-[--color-fg-muted] hover:text-[--color-fg]"
>
View
</a>
)}
</div>
</div>
))}
</div>
</>
)}
<p className="mt-10 text-[12px] text-[--color-fg-subtle]">
Payment-method updates and other rare actions:{' '}
<button
type="button"
onClick={async () => {
try {
const r = await apiFetch<{ url: string }>('/v1/billing/portal', {
method: 'POST',
body: '{}',
});
window.location.href = r.url;
} catch {
setError('Portal could not open');
}
}}
className="text-[--color-accent] hover:underline"
>
open Stripe billing portal
</button>{' '}
·{' '}
<Link href="/pricing" className="hover:text-[--color-fg]"> <Link href="/pricing" className="hover:text-[--color-fg]">
Compare all plans compare plans
</Link> </Link>
</div> </p>
</div>
);
}
function Alert({
tone,
children,
}: {
tone: 'muted' | 'success' | 'warn' | 'error';
children: React.ReactNode;
}) {
const cls =
tone === 'success'
? 'border-emerald-500/30 bg-emerald-500/10'
: tone === 'warn'
? 'border-amber-500/40 bg-amber-500/10'
: tone === 'error'
? 'border-[--color-danger]/40 bg-[--color-danger]/10'
: 'border-[--color-border] bg-[--color-bg-subtle]';
return (
<div className={`mt-4 rounded-md border px-3.5 py-2.5 text-[12.5px] ${cls}`}>
{children}
</div> </div>
); );
} }
@ -290,6 +500,28 @@ function TierCard({
); );
} }
function PlanSwitch({
label,
onClick,
busy,
}: {
label: string;
onClick: () => void;
busy: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={busy}
className="panel flex items-center justify-between p-3 text-left text-[12.5px] transition-colors hover:bg-[--color-bg-subtle] disabled:opacity-60"
>
<span className="text-[--color-fg]">{label}</span>
<span className="text-[--color-fg-muted]">{busy ? '…' : '→'}</span>
</button>
);
}
export default function BillingPage() { export default function BillingPage() {
return ( return (
<Suspense <Suspense

View File

@ -0,0 +1,140 @@
'use client';
import { Input, Label } from '@/components/input';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
interface Profile {
id: string;
email: string;
name: string | null;
phone: string | null;
isAdmin: boolean;
createdAt: string;
}
export default function ProfilePage() {
const [profile, setProfile] = useState<Profile | null>(null);
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
function load() {
apiFetch<{ profile: Profile }>('/v1/account/profile')
.then((r) => {
setProfile(r.profile);
setName(r.profile.name ?? '');
})
.catch((e) => setError((e as Error).message));
}
useEffect(load, []);
async function save(e: React.FormEvent) {
e.preventDefault();
if (!profile) return;
setBusy(true);
setError(null);
setSaved(false);
try {
await apiFetch('/v1/account/profile', {
method: 'PATCH',
body: JSON.stringify({ name: name.trim() }),
});
setSaved(true);
load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
if (!profile && !error) {
return (
<div className="mx-auto max-w-2xl px-6 py-12 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
</div>
);
}
if (!profile) {
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<p className="text-[13px] text-[--color-danger]">{error}</p>
</div>
);
}
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<h1 className="text-[22px] font-semibold tracking-tight">Profile</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Personal details. Email and phone can&apos;t be changed self-service yet open a{' '}
<Link href="/settings/support" className="text-[--color-accent] hover:underline">
support ticket
</Link>
{' '}
if you need it changed.
</p>
<form onSubmit={save} className="panel mt-6 space-y-4 p-5">
<div className="space-y-1.5">
<Label htmlFor="name">Display name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={128}
placeholder="How should we address you?"
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReadField label="Email" value={profile.email} mono />
<ReadField label="Phone" value={profile.phone ?? '—'} mono />
<ReadField label="Account created" value={new Date(profile.createdAt).toLocaleString()} />
<ReadField label="Role" value={profile.isAdmin ? 'Admin' : 'Member'} />
</div>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
{saved && <p className="text-[12.5px] text-emerald-300">Saved.</p>}
<div className="flex justify-end">
<Button
variant="primary"
size="md"
type="submit"
disabled={busy || name.trim() === (profile.name ?? '')}
>
{busy ? 'Saving…' : 'Save changes'}
</Button>
</div>
</form>
<div className="mt-8 grid gap-3 sm:grid-cols-3 text-[12px]">
<Link href="/settings/billing" className="panel p-3 text-[--color-fg-muted] transition-colors hover:text-[--color-fg]">
Billing
</Link>
<Link href="/settings/support" className="panel p-3 text-[--color-fg-muted] transition-colors hover:text-[--color-fg]">
Support
</Link>
<Link href="/settings/account" className="panel p-3 text-[--color-fg-muted] transition-colors hover:text-[--color-fg]">
Your data
</Link>
</div>
</div>
);
}
function ReadField({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{label}</div>
<div className={`mt-1 text-[13px] text-[--color-fg] ${mono ? 'mono' : ''}`}>{value}</div>
</div>
);
}

View File

@ -0,0 +1,190 @@
'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;
email: string;
name: string | null;
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;
const label = user.name?.trim() || user.email;
const initial = (user.name?.trim() || user.email).charAt(0).toUpperCase();
const shade = avatarShade(user.email);
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"
className="absolute right-0 top-9 z-50 w-64 overflow-hidden rounded-md border border-[--color-border] bg-[--color-bg-elevated] shadow-lg shadow-black/40"
>
<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}
</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>
);
}