feat(web): subscription gates + owner-only billing + split-pane settings

LockedSurface + feature gating:
- New useFeatureAccess() hook: single source of truth for "can this
  org do X". Reads subscription + role, returns hasInbox, hasAdvanced,
  hasBulkOps, hasTeam, hasWebhooks, isOwner, plus the current plan
  name and status. Pages consult this instead of querying the
  subscription directly.
- New LockedSurface component: renders the real page contents behind
  a frosted overlay at 40% opacity, with a centered upgrade card on
  top. Card has the feature name, a blurb, optional bullets, and a
  slate-900 "Upgrade to <Plan>" button (or a "ask your owner" line
  if the viewer isn't the owner). Users see what they'd unlock
  rather than a blank page.
- /app/unibox is now wrapped in LockedSurface. Non-paid orgs get
  the lock with bullets for the actual inbox features.

Billing access:
- /app/billing checks access.isOwner before rendering. Non-owners
  see an EmptyBlock explaining that billing is owner-scoped. The
  UserNav menu also hides the Billing item entirely from non-owners
  so the route isn't even discoverable.

Settings — two-pane sheet:
- Left nav rail (200px, hairline divider) with 6 sections: Profile,
  Notifications, Security, Members, Workspace (owner-only),
  Danger zone. Each row is the same NavRow visual as the main
  sidebar — small icon, h-7, slate-200/70 active state.
- Right panel renders the active section, paginated via URL hash
  (#profile, #members, …) so deep links work.
- Profile: first/last name + disabled email + Save.
- Notifications: 5 toggle rows using a slate-900 switch.
- Security: sessions / 2FA / change-password rows.
- Members: inline invite form (email + role pill toggle + Invite
  button) at the top, members list, pending invitations list,
  link out to the full /app/team page.
- Workspace (owner-only): workspace name + default sender domain.
- Danger zone: red-bordered cards for Delete account and Leave
  workspace, each with their own destructive button.
This commit is contained in:
Matthew Meszaros
2026-05-23 11:54:09 +00:00
parent a173850977
commit c696a1d251
6 changed files with 655 additions and 120 deletions
+34 -5
View File
@@ -1,12 +1,13 @@
// Billing — plan + usage + invoices preview.
// Billing — plan + usage + invoices.
//
// Distinct from Settings (form-heavy) and the empty-state pages —
// dominated by a plan card on the left + a usage strip on the right
// + a fake-invoices preview. Reads as "money", not "config".
// Owner-only page. Non-owners (admin/member) see an explicit
// permissions message rather than the contents — the data here is
// payment-sensitive and roles matter.
import { ArrowUpRightIcon, FileTextIcon, SparklesIcon } from "lucide-react";
import { ArrowUpRightIcon, FileTextIcon, ShieldAlertIcon, SparklesIcon } from "lucide-react";
import { Link } from "react-router-dom";
import {
EmptyBlock,
Page,
PageBody,
PageTopbar,
@@ -16,12 +17,40 @@ import {
TopbarAction,
} from "@/components/layout/Page";
import { comingSoon } from "@/lib/helper/comingSoon";
import useFeatureAccess from "@/hooks/useFeatureAccess";
const SAMPLE_INVOICES = [
{ number: "INV-2026-001", amount: "$0.00", status: "Trial", date: "May 22" },
];
export default function BillingPage() {
const access = useFeatureAccess();
// Gate the page contents for non-owners — admins and members
// shouldn't see invoices, payment methods, or plan controls.
if (!access.loading && !access.isOwner) {
return (
<Page>
<PageTopbar eyebrow="Billing" subtitle="Owner only" />
<PageBody>
<EmptyBlock
title="Only the workspace owner can view billing"
body="Plan changes, invoices and payment methods are scoped to the owner role. Ask your owner to share an update if you need one."
cta={
<Link
to="/app/emails"
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<ShieldAlertIcon className="w-3 h-3" />
Back to dashboard
</Link>
}
/>
</PageBody>
</Page>
);
}
return (
<Page>
<PageTopbar eyebrow="Billing" subtitle="Plan · usage · invoices" />
+385 -82
View File
@@ -1,27 +1,157 @@
// Settings — sectioned account/notification/security/danger page.
// Settings — two-pane sheet.
//
// Settings used to be a single Profile form. Different from every
// other tab because each row is a labelled form field grouped by
// SectionBar, and the page reads like an actual settings sheet —
// inline edits, hairline rows, slate-900 save buttons.
// ┌────────────────┬─────────────────────────────────────────┐
// │ left nav rail │ active section panel │
// │ Profile │ │
// │ Notifications │ │
// │ Security │ │
// │ Members │ │
// │ Workspace │ │
// │ Danger zone │ │
// └────────────────┴─────────────────────────────────────────┘
//
// Each section is a small component below; the rail just switches
// which one renders on the right. Section is held in URL hash so
// links like /app/settings#members work directly.
import React from "react";
import { Page, PageBody, PageTopbar, SectionBar, TopbarAction } from "@/components/layout/Page";
import { Link, useLocation, useNavigate } from "react-router-dom";
import {
AlertOctagonIcon,
BellIcon,
BriefcaseIcon,
Loader2Icon,
ShieldIcon,
Trash2Icon,
UserIcon,
UsersIcon,
} from "lucide-react";
import toast from "react-hot-toast";
import { Page, PageTopbar, TopbarAction } from "@/components/layout/Page";
import { Label, TextInput } from "@/components/ui/field";
import { useUserProfile } from "@/hooks/context/user";
import { useAppStore } from "@/stores";
import { comingSoon } from "@/lib/helper/comingSoon";
import useMembers from "@/lib/api/hooks/app/organizations/useMembers";
import usePendingInvitations from "@/lib/api/hooks/app/organizations/usePendingInvitations";
import useInviteMember from "@/lib/api/hooks/app/organizations/useInviteMember";
import useFeatureAccess from "@/hooks/useFeatureAccess";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
type SectionId =
| "profile"
| "notifications"
| "security"
| "members"
| "workspace"
| "danger";
interface SectionDef {
id: SectionId;
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
/** Owner-only sections are hidden from non-owners. */
ownerOnly?: boolean;
}
const SECTIONS: SectionDef[] = [
{ id: "profile", label: "Profile", icon: UserIcon, description: "Personal information and avatar." },
{ id: "notifications", label: "Notifications", icon: BellIcon, description: "What you get notified about." },
{ id: "security", label: "Security", icon: ShieldIcon, description: "Password, 2FA, active sessions." },
{ id: "members", label: "Members", icon: UsersIcon, description: "Team and invitations." },
{ id: "workspace", label: "Workspace", icon: BriefcaseIcon, description: "Org-wide settings.", ownerOnly: true },
{ id: "danger", label: "Danger zone", icon: AlertOctagonIcon, description: "Irreversible actions." },
];
export default function SettingsPage() {
const navigate = useNavigate();
const location = useLocation();
const access = useFeatureAccess();
const sectionFromHash = (location.hash.replace("#", "") || "profile") as SectionId;
const activeSection: SectionId =
SECTIONS.some((s) => s.id === sectionFromHash) ? sectionFromHash : "profile";
const visibleSections = SECTIONS.filter((s) => !s.ownerOnly || access.isOwner);
const current = visibleSections.find((s) => s.id === activeSection) ?? visibleSections[0];
function go(id: SectionId) {
navigate(`#${id}`, { replace: true });
}
return (
<Page>
<PageTopbar
eyebrow="Settings"
subtitle={current?.description ?? "Account and workspace"}
/>
<div className="flex-1 min-h-0 flex">
<nav className="w-[200px] shrink-0 border-r border-slate-200/70 py-2 overflow-y-auto">
{visibleSections.map((s) => {
const active = current?.id === s.id;
return (
<button
key={s.id}
type="button"
onClick={() => go(s.id)}
className={`group block w-[calc(100%-0.75rem)] mx-1.5 my-px flex items-center gap-2 px-2 h-7 rounded text-[12.5px] text-left transition-colors ${
active
? "bg-slate-200/70 text-slate-900 font-medium"
: "text-slate-600 hover:text-slate-900 hover:bg-slate-200/40"
}`}
>
<s.icon
className={`w-[14px] h-[14px] shrink-0 ${
active ? "text-slate-700" : "text-slate-400 group-hover:text-slate-600"
}`}
/>
<span className="truncate">{s.label}</span>
</button>
);
})}
</nav>
<div className="flex-1 min-w-0 overflow-y-auto">
{current?.id === "profile" && <ProfileSection />}
{current?.id === "notifications" && <NotificationsSection />}
{current?.id === "security" && <SecuritySection />}
{current?.id === "members" && <MembersSection />}
{current?.id === "workspace" && <WorkspaceSection />}
{current?.id === "danger" && <DangerSection />}
</div>
</div>
</Page>
);
}
function SectionHeading({ title, description }: { title: string; description?: string }) {
return (
<div className="mb-4">
<h2 className="text-[14px] font-semibold text-slate-900">{title}</h2>
{description && (
<p className="text-[12px] text-slate-500 mt-0.5 leading-relaxed max-w-xl">
{description}
</p>
)}
</div>
);
}
function ProfileSection() {
const { user } = useUserProfile();
const [firstName, setFirstName] = React.useState(user.first_name ?? "");
const [lastName, setLastName] = React.useState(user.last_name ?? "");
return (
<Page>
<PageTopbar eyebrow="Settings" subtitle="Account · notifications · security" />
<SectionBar label="Profile" />
<div className="px-5 py-4 border-b border-slate-200/60 space-y-3 max-w-xl">
<div className="px-6 py-5 max-w-xl">
<SectionHeading
title="Profile"
description="Used across invitations, emails sent on your behalf, and your sidebar avatar."
/>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<Label>First name</Label>
@@ -40,80 +170,232 @@ export default function SettingsPage() {
disabled
className="w-full h-7 px-2.5 rounded-md border border-slate-200 bg-slate-50 text-[12.5px] text-slate-500"
/>
<p className="text-[11px] text-slate-400 mt-1">
Email changes are coming soon contact support to update for now.
</p>
<p className="text-[11px] text-slate-400 mt-1">Email changes go through support for now.</p>
</div>
<div className="pt-1">
<TopbarAction onClick={() => comingSoon("Profile editing")}>
Save profile
</TopbarAction>
<TopbarAction onClick={() => comingSoon("Profile editing")}>Save profile</TopbarAction>
</div>
</div>
</div>
);
}
<SectionBar label="Notifications" />
<div className="px-5 py-4 border-b border-slate-200/60 space-y-1 max-w-xl">
<ToggleRow
label="Reply received"
description="Email + push when a recipient replies to a campaign"
/>
<ToggleRow
label="Bounce detected"
description="Notify when a mailbox starts bouncing hard"
/>
<ToggleRow
label="Spam complaint"
description="Immediate alert on any complaint event"
defaultOn
/>
<ToggleRow
label="Weekly digest"
description="Monday summary of last week's send volume and replies"
defaultOn
/>
function NotificationsSection() {
return (
<div className="px-6 py-5 max-w-xl">
<SectionHeading title="Notifications" description="Email + in-app alerts. Change any time." />
<div className="space-y-1">
<ToggleRow label="Reply received" description="When a recipient replies to a campaign." />
<ToggleRow label="Bounce detected" description="When a mailbox starts bouncing hard." />
<ToggleRow label="Spam complaint" description="Immediate alert on any complaint event." defaultOn />
<ToggleRow label="Weekly digest" description="Monday summary of last week's volume and replies." defaultOn />
<ToggleRow label="Worker downtime" description="If one of your sender workers stops responding." defaultOn />
</div>
</div>
);
}
<SectionBar label="Security" />
<div className="px-5 py-4 border-b border-slate-200/60 space-y-2 max-w-xl">
<RowLink
title="Active sessions"
description="Devices currently signed in to your account"
cta="View sessions"
/>
<RowLink
title="Two-factor authentication"
description="Add a one-time code to every sign-in"
cta="Enable 2FA"
/>
<RowLink
title="Change password"
description="Use 12+ characters with mixed case and a number"
cta="Change"
/>
function SecuritySection() {
return (
<div className="px-6 py-5 max-w-xl">
<SectionHeading title="Security" description="Sign-in protection for your account." />
<div className="space-y-2">
<RowLink title="Active sessions" description="Devices currently signed in to your account." cta="View sessions" />
<RowLink title="Two-factor authentication" description="Add a one-time code to every sign-in." cta="Enable 2FA" />
<RowLink title="Change password" description="Use 12+ characters with mixed case and a number." cta="Change" />
</div>
</div>
);
}
<SectionBar label="Danger zone" />
<PageBody>
<div className="px-5 py-4 max-w-xl space-y-2">
<div className="rounded-md border border-red-200 bg-red-50/40 p-3">
<div className="text-[12.5px] font-semibold text-red-700 mb-0.5">
Delete account
function MembersSection() {
const members = useMembers();
const invites = usePendingInvitations();
const invite = useInviteMember();
const access = useFeatureAccess();
const memberList = members.data ?? [];
const inviteList = invites.data ?? [];
const [email, setEmail] = React.useState("");
const [role, setRole] = React.useState<"admin" | "member">("member");
const canInvite = access.isOwner;
async function submit() {
const e = email.trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) {
toast.error("Enter a valid email");
return;
}
try {
await toast.promise(invite.mutateAsync({ email: e, role }), {
loading: "Sending invite…",
success: "Invitation sent",
error: (err: AppError) => buildError(err),
});
setEmail("");
} catch {
/* surfaced */
}
}
return (
<div className="px-6 py-5 max-w-2xl">
<SectionHeading
title="Members"
description="Everyone with access to this workspace. Owners can change roles and invite."
/>
{canInvite && (
<div className="mb-5 rounded-md border border-slate-200 bg-white">
<div className="h-9 px-3 border-b border-slate-200 flex items-center gap-1.5">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Invite by email
</span>
</div>
<div className="px-3 py-3 flex items-center gap-2">
<TextInput
value={email}
onChange={setEmail}
placeholder="teammate@company.com"
type="email"
className="flex-1"
/>
<div className="inline-flex items-center rounded-md border border-slate-200 bg-white p-0.5 shrink-0">
{(["admin", "member"] as const).map((r) => (
<button
key={r}
type="button"
onClick={() => setRole(r)}
className={`h-6 px-2 rounded text-[11.5px] font-medium transition-colors capitalize ${
role === r ? "bg-slate-900 text-white" : "text-slate-500 hover:text-slate-900"
}`}
>
{r}
</button>
))}
</div>
<p className="text-[11.5px] text-red-700/80 mb-2 leading-relaxed">
Permanently delete your account and every workspace you own.
This can't be undone.
</p>
<button
type="button"
onClick={() => comingSoon("Account deletion")}
className="h-7 px-2.5 rounded-md border border-red-300 hover:border-red-400 text-red-700 hover:text-red-800 hover:bg-red-100/60 text-[12px] font-medium transition-colors"
onClick={submit}
disabled={invite.isPending}
className="h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60 shrink-0"
>
Delete account
{invite.isPending && <Loader2Icon className="w-3 h-3 animate-spin" />}
Invite
</button>
</div>
</div>
</PageBody>
</Page>
)}
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium mb-1.5 flex items-center gap-1.5">
Members <span className="font-mono tabular-nums">{memberList.length}</span>
</div>
<div className="rounded-md border border-slate-200 bg-white overflow-hidden divide-y divide-slate-200/60 mb-5">
{members.isPending ? (
<div className="px-3 py-3 text-[11.5px] text-slate-400">Loading</div>
) : memberList.length === 0 ? (
<div className="px-3 py-3 text-[11.5px] text-slate-400">No members yet.</div>
) : (
memberList.map((m) => (
<div key={m.user_id} className="px-3 h-10 flex items-center gap-2.5">
<div className="size-6 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
<span className="text-[9.5px] font-semibold text-slate-600">
{m.email.slice(0, 2).toUpperCase()}
</span>
</div>
<span className="text-[12px] text-slate-900 truncate flex-1">{m.email}</span>
<span className="text-[10.5px] uppercase tracking-[0.08em] font-medium text-slate-500">
{m.role}
</span>
</div>
))
)}
</div>
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium mb-1.5 flex items-center gap-1.5">
Pending invitations <span className="font-mono tabular-nums">{inviteList.length}</span>
</div>
<div className="rounded-md border border-slate-200 bg-white overflow-hidden divide-y divide-slate-200/60">
{invites.isPending ? (
<div className="px-3 py-3 text-[11.5px] text-slate-400">Loading</div>
) : inviteList.length === 0 ? (
<div className="px-3 py-3 text-[11.5px] text-slate-400">No pending invitations.</div>
) : (
inviteList.map((inv) => (
<div key={inv.id} className="px-3 h-10 flex items-center gap-2.5">
<span className="text-[12px] text-slate-900 truncate flex-1">{inv.email}</span>
<span className="text-[10.5px] uppercase tracking-[0.08em] font-medium text-slate-500">
{inv.role}
</span>
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums">
expires {new Date(inv.expires_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</span>
</div>
))
)}
</div>
<p className="text-[11px] text-slate-400 mt-3">
Need finer controls?{" "}
<Link to="/app/team" className="text-slate-700 underline-offset-2 hover:underline">
Open the full team page
</Link>
.
</p>
</div>
);
}
function WorkspaceSection() {
const currentOrg = useAppStore((s) => s.currentOrganization);
const [name, setName] = React.useState(currentOrg?.name ?? "");
const [domain, setDomain] = React.useState("");
return (
<div className="px-6 py-5 max-w-xl">
<SectionHeading title="Workspace" description="Org-wide settings. Visible only to the owner." />
<div className="space-y-3">
<div>
<Label>Workspace name</Label>
<TextInput value={name} onChange={setName} className="w-full" />
</div>
<div>
<Label>Default sender domain</Label>
<TextInput value={domain} onChange={setDomain} placeholder="company.com" className="w-full" />
<p className="text-[11px] text-slate-400 mt-1">
Used when a campaign doesn't explicitly pick a from-domain.
</p>
</div>
<div className="pt-1">
<TopbarAction onClick={() => comingSoon("Workspace settings")}>
Save workspace
</TopbarAction>
</div>
</div>
</div>
);
}
function DangerSection() {
return (
<div className="px-6 py-5 max-w-xl">
<SectionHeading title="Danger zone" description="Irreversible actions. Read carefully." />
<div className="space-y-3">
<DangerCard
title="Delete account"
body="Permanently delete your account and every workspace you own. This can't be undone."
cta="Delete account"
onClick={() => comingSoon("Account deletion")}
/>
<DangerCard
title="Leave workspace"
body="Remove yourself from this workspace. You'll lose access to its data immediately."
cta="Leave workspace"
onClick={() => comingSoon("Leave workspace")}
/>
</div>
</div>
);
}
@@ -128,14 +410,10 @@ function ToggleRow({
}) {
const [on, setOn] = React.useState(!!defaultOn);
return (
<div className="flex items-center gap-3 py-2 group">
<div className="flex items-center gap-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="text-[12.5px] text-slate-900 font-medium leading-tight">
{label}
</div>
<div className="text-[11.5px] text-slate-500 leading-tight mt-0.5">
{description}
</div>
<div className="text-[12.5px] text-slate-900 font-medium leading-tight">{label}</div>
<div className="text-[11.5px] text-slate-500 leading-tight mt-0.5">{description}</div>
</div>
<button
type="button"
@@ -168,12 +446,8 @@ function RowLink({
return (
<div className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<div className="text-[12.5px] text-slate-900 font-medium leading-tight">
{title}
</div>
<div className="text-[11.5px] text-slate-500 leading-tight mt-0.5">
{description}
</div>
<div className="text-[12.5px] text-slate-900 font-medium leading-tight">{title}</div>
<div className="text-[11.5px] text-slate-500 leading-tight mt-0.5">{description}</div>
</div>
<button
type="button"
@@ -185,3 +459,32 @@ function RowLink({
</div>
);
}
function DangerCard({
title,
body,
cta,
onClick,
}: {
title: string;
body: string;
cta: string;
onClick: () => void;
}) {
return (
<div className="rounded-md border border-red-200 bg-red-50/40 p-3">
<div className="text-[12.5px] font-semibold text-red-700 mb-0.5 flex items-center gap-1.5">
<Trash2Icon className="w-3 h-3" />
{title}
</div>
<p className="text-[11.5px] text-red-700/80 mb-2 leading-relaxed">{body}</p>
<button
type="button"
onClick={onClick}
className="h-7 px-2.5 rounded-md border border-red-300 hover:border-red-400 text-red-700 hover:text-red-800 hover:bg-red-100/60 text-[12px] font-medium transition-colors"
>
{cta}
</button>
</div>
);
}
+41 -27
View File
@@ -1,42 +1,56 @@
// Unibox page — three-pane mail browser.
//
// ┌────────────────┬──────────────────────────────────────┐
// │ ConversationList │ ThreadView │
// │ (340px) │ (fills remainder) │
// └────────────────┴──────────────────────────────────────┘
// Unibox page — three-pane mail browser, locked behind a Pro
// subscription. When the org isn't on a paid tier we render the
// real layout behind a frosted "Upgrade to Pro" overlay so the
// user sees what they'd unlock.
import { ConversationList } from "@/components/app/unibox/ConversationList";
import { ThreadView } from "@/components/app/unibox/ThreadView";
import { useAppStore } from "@/stores";
import { InboxIcon } from "lucide-react";
import useFeatureAccess from "@/hooks/useFeatureAccess";
import { LockedSurface } from "@/components/layout/LockedSurface";
export default function UniboxPage() {
const selectedThreadId = useAppStore((s) => s.selectedThreadId);
const access = useFeatureAccess();
return (
<div className="flex h-full bg-white">
<div className="w-[340px] shrink-0 border-r border-slate-200 overflow-hidden flex flex-col">
<ConversationList />
</div>
<div className="flex-1 min-w-0 overflow-hidden flex flex-col">
{selectedThreadId ? (
<ThreadView threadId={selectedThreadId} />
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center px-5">
<div className="w-8 h-8 rounded-md bg-slate-100 flex items-center justify-center mx-auto mb-3 text-slate-400">
<InboxIcon className="w-4 h-4" />
<LockedSurface
locked={!access.loading && !access.hasInbox}
feature="Unified inbox"
blurb="Read and reply to every inbound message across every connected mailbox from one place — searchable, filterable, with realtime updates."
plan="Pro"
bullets={[
"Search by sender, subject, account, date range, and tag",
"Three-pane reader with keyboard shortcuts",
"Tag-based filtering for groups of mailboxes",
"Realtime new-message updates over WebSocket",
]}
>
<div className="flex h-full bg-white">
<div className="w-[340px] shrink-0 border-r border-slate-200 overflow-hidden flex flex-col">
<ConversationList />
</div>
<div className="flex-1 min-w-0 overflow-hidden flex flex-col">
{selectedThreadId ? (
<ThreadView threadId={selectedThreadId} />
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center px-5">
<div className="w-8 h-8 rounded-md bg-slate-100 flex items-center justify-center mx-auto mb-3 text-slate-400">
<InboxIcon className="w-4 h-4" />
</div>
<p className="text-[12.5px] font-medium text-slate-700">
Select a conversation
</p>
<p className="text-[11.5px] text-slate-400 mt-1 max-w-[34ch] leading-relaxed">
Pick a thread from the list to read and reply.
</p>
</div>
<p className="text-[12.5px] font-medium text-slate-700">
Select a conversation
</p>
<p className="text-[11.5px] text-slate-400 mt-1 max-w-[34ch] leading-relaxed">
Pick a thread from the list to read and reply.
</p>
</div>
</div>
)}
)}
</div>
</div>
</div>
</LockedSurface>
);
}
+123
View File
@@ -0,0 +1,123 @@
// LockedSurface — render a feature page through a frosted-glass
// overlay when the org doesn't have the subscription tier.
//
// The page contents are still rendered behind the lock at reduced
// opacity so the user gets a preview of what they'd unlock. The
// overlay sits absolute with a backdrop-blur, centered upgrade card
// with feature name, blurb, and a slate-900 CTA.
//
// Use it like:
//
// const access = useFeatureAccess();
// <LockedSurface
// locked={!access.hasInbox}
// feature="Unified inbox"
// blurb="See every reply across every connected mailbox in one place."
// plan="Pro"
// >
// <RealInbox />
// </LockedSurface>
import React from "react";
import { Link } from "react-router-dom";
import { LockIcon, SparklesIcon } from "lucide-react";
import { useAppStore } from "@/stores";
interface Props {
locked: boolean;
feature: string;
blurb: string;
plan?: string;
children: React.ReactNode;
/** Where the upgrade button routes — defaults to /app/billing. */
upgradeTo?: string;
/** Bullets shown under the blurb. Each short, one line max. */
bullets?: string[];
}
export function LockedSurface({
locked,
feature,
blurb,
plan = "Pro",
children,
upgradeTo = "/app/billing",
bullets,
}: Props) {
const isOwner = useAppStore((s) => s.currentOrganization?.role === "owner");
if (!locked) return <>{children}</>;
return (
<div className="relative h-full">
{/* Preview layer — the real page rendered as a teaser. */}
<div
aria-hidden
className="absolute inset-0 overflow-hidden pointer-events-none select-none opacity-40"
>
{children}
</div>
{/* Frosted overlay + centered upgrade card. */}
<div className="absolute inset-0 bg-gradient-to-b from-white/50 via-white/70 to-white/90 backdrop-blur-[6px] flex items-center justify-center px-4">
<div className="w-full max-w-[440px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.12),0_8px_16px_-8px_rgba(15,23,42,0.06)] overflow-hidden">
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-2.5">
<div className="size-5 rounded bg-amber-50 text-amber-600 flex items-center justify-center">
<LockIcon className="w-3 h-3" />
</div>
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Locked
</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-900 font-medium truncate">
{feature}
</span>
<span className="ml-auto text-[10px] uppercase tracking-[0.1em] font-semibold text-sky-700 bg-sky-50 border border-sky-100 rounded px-1.5 py-0.5">
{plan}
</span>
</div>
<div className="px-4 py-4">
<p className="text-[13px] text-slate-700 leading-relaxed mb-3">
{blurb}
</p>
{bullets && bullets.length > 0 && (
<ul className="space-y-1.5 mb-4">
{bullets.map((b) => (
<li
key={b}
className="flex items-start gap-2 text-[12px] text-slate-700 leading-snug"
>
<span className="size-1 rounded-full bg-slate-400 mt-1.5 shrink-0" />
<span>{b}</span>
</li>
))}
</ul>
)}
</div>
<div className="px-3 h-12 border-t border-slate-200 flex items-center gap-1.5">
{isOwner ? (
<>
<span className="text-[11px] text-slate-400">
Upgrade unlocks it instantly
</span>
<Link
to={upgradeTo}
className="ml-auto h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<SparklesIcon className="w-3 h-3" />
Upgrade to {plan}
</Link>
</>
) : (
<span className="text-[11.5px] text-slate-500">
Ask your workspace owner to upgrade to <span className="font-medium text-slate-900">{plan}</span>.
</span>
)}
</div>
</div>
</div>
</div>
);
}
+10 -6
View File
@@ -15,6 +15,7 @@ import {
UsersIcon,
} from "lucide-react";
import { useAppStore } from "@/stores";
import useFeatureAccess from "@/hooks/useFeatureAccess";
import {
PopoverMenu,
PopoverMenuContent,
@@ -27,6 +28,7 @@ export function UserNav() {
const navigate = useNavigate();
const user = useAppStore((s) => s.user);
const logout = useAppStore((s) => s.logout);
const access = useFeatureAccess();
if (!user) return null;
@@ -81,12 +83,14 @@ export function UserNav() {
>
Settings
</PopoverMenuItem>
<PopoverMenuItem
onSelect={() => navigate("/app/billing")}
icon={<CreditCardIcon className="w-3 h-3" />}
>
Billing
</PopoverMenuItem>
{access.isOwner && (
<PopoverMenuItem
onSelect={() => navigate("/app/billing")}
icon={<CreditCardIcon className="w-3 h-3" />}
>
Billing
</PopoverMenuItem>
)}
<PopoverMenuItem
onSelect={() => navigate("/app/team")}
icon={<UsersIcon className="w-3 h-3" />}
+62
View File
@@ -0,0 +1,62 @@
// useFeatureAccess — single source of truth for "can this org do X".
//
// Reads from the subscription hook and returns boolean gates that
// the page surfaces consult. Centralizing this means a future plan
// change only touches this file; pages stay declarative.
//
// const access = useFeatureAccess();
// if (!access.hasInbox) return <LockedSurface feature="Inbox" ... />;
import useSubscription from "@/lib/api/hooks/app/subscription/useSubscription";
import { useAppStore } from "@/stores";
export type Plan = "free" | "pro" | "premium" | "enterprise" | string;
export interface FeatureAccess {
loading: boolean;
/** Underlying subscription status — undefined while loading. */
status?: "active" | "canceled" | "past_due" | "trialing" | "incomplete";
plan: Plan;
/** True when the user can be expected to have paid features. */
paid: boolean;
/** Inbox / unified mailbox — paid-only per product policy. */
hasInbox: boolean;
/** Advanced outreach (AB tests, advanced sequences). */
hasAdvanced: boolean;
/** Realtime websocket events. */
hasRealtime: boolean;
/** Bulk operations on contacts, campaigns. */
hasBulkOps: boolean;
/** Team invitations + multiple seats. */
hasTeam: boolean;
/** Custom webhooks. */
hasWebhooks: boolean;
/** Convenience: whether the viewer is the current org's owner. */
isOwner: boolean;
}
export default function useFeatureAccess(): FeatureAccess {
const sub = useSubscription();
const currentOrg = useAppStore((s) => s.currentOrganization);
const plan: Plan = (sub.data?.plan_name ?? currentOrg?.plan ?? "free").toLowerCase();
const status = sub.data?.status;
const isPaid = status === "active" || status === "trialing";
const isPro = isPaid && plan !== "free";
const isPremium = isPaid && (plan === "premium" || plan === "enterprise");
const isEnterprise = isPaid && plan === "enterprise";
return {
loading: sub.isPending,
status,
plan,
paid: isPaid,
hasInbox: isPro,
hasAdvanced: isPremium,
hasRealtime: true, // baseline feature
hasBulkOps: isPro,
hasTeam: isPro,
hasWebhooks: isEnterprise,
isOwner: currentOrg?.role === "owner",
};
}