mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-24 00:00:36 +00:00
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:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user