diff --git a/web/src/app/app/billing/page.tsx b/web/src/app/app/billing/page.tsx index cd6b6cf1..ff388343 100644 --- a/web/src/app/app/billing/page.tsx +++ b/web/src/app/app/billing/page.tsx @@ -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 ( + + + + + + Back to dashboard + + } + /> + + + ); + } + return ( diff --git a/web/src/app/app/settings/page.tsx b/web/src/app/app/settings/page.tsx index 38d149fa..0818ebe3 100644 --- a/web/src/app/app/settings/page.tsx +++ b/web/src/app/app/settings/page.tsx @@ -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 ( + + + +
+ + +
+ {current?.id === "profile" && } + {current?.id === "notifications" && } + {current?.id === "security" && } + {current?.id === "members" && } + {current?.id === "workspace" && } + {current?.id === "danger" && } +
+
+
+ ); +} + +function SectionHeading({ title, description }: { title: string; description?: string }) { + return ( +
+

{title}

+ {description && ( +

+ {description} +

+ )} +
+ ); +} + +function ProfileSection() { const { user } = useUserProfile(); const [firstName, setFirstName] = React.useState(user.first_name ?? ""); const [lastName, setLastName] = React.useState(user.last_name ?? ""); return ( - - - - -
+
+ +
@@ -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" /> -

- Email changes are coming soon — contact support to update for now. -

+

Email changes go through support for now.

- comingSoon("Profile editing")}> - Save profile - + comingSoon("Profile editing")}>Save profile
+
+ ); +} - -
- - - - +function NotificationsSection() { + return ( +
+ +
+ + + + +
+
+ ); +} - -
- - - +function SecuritySection() { + return ( +
+ +
+ + +
+
+ ); +} - - -
-
-
- 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 ( +
+ + + {canInvite && ( +
+
+ + Invite by email + +
+
+ +
+ {(["admin", "member"] as const).map((r) => ( + + ))}
-

- Permanently delete your account and every workspace you own. - This can't be undone. -

- - + )} + +
+ Members {memberList.length} +
+
+ {members.isPending ? ( +
Loading…
+ ) : memberList.length === 0 ? ( +
No members yet.
+ ) : ( + memberList.map((m) => ( +
+
+ + {m.email.slice(0, 2).toUpperCase()} + +
+ {m.email} + + {m.role} + +
+ )) + )} +
+ +
+ Pending invitations {inviteList.length} +
+
+ {invites.isPending ? ( +
Loading…
+ ) : inviteList.length === 0 ? ( +
No pending invitations.
+ ) : ( + inviteList.map((inv) => ( +
+ {inv.email} + + {inv.role} + + + expires {new Date(inv.expires_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })} + +
+ )) + )} +
+ +

+ Need finer controls?{" "} + + Open the full team page + + . +

+
+ ); +} + +function WorkspaceSection() { + const currentOrg = useAppStore((s) => s.currentOrganization); + const [name, setName] = React.useState(currentOrg?.name ?? ""); + const [domain, setDomain] = React.useState(""); + + return ( +
+ +
+
+ + +
+
+ + +

+ Used when a campaign doesn't explicitly pick a from-domain. +

+
+
+ comingSoon("Workspace settings")}> + Save workspace + +
+
+
+ ); +} + +function DangerSection() { + return ( +
+ +
+ comingSoon("Account deletion")} + /> + comingSoon("Leave workspace")} + /> +
+
); } @@ -128,14 +410,10 @@ function ToggleRow({ }) { const [on, setOn] = React.useState(!!defaultOn); return ( -
+
-
- {label} -
-
- {description} -
+
{label}
+
{description}
+
+ ); +} diff --git a/web/src/app/app/unibox/page.tsx b/web/src/app/app/unibox/page.tsx index d93a31d3..cee06f66 100644 --- a/web/src/app/app/unibox/page.tsx +++ b/web/src/app/app/unibox/page.tsx @@ -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 ( -
-
- -
-
- {selectedThreadId ? ( - - ) : ( -
-
-
- + +
+
+ +
+
+ {selectedThreadId ? ( + + ) : ( +
+
+
+ +
+

+ Select a conversation +

+

+ Pick a thread from the list to read and reply. +

-

- Select a conversation -

-

- Pick a thread from the list to read and reply. -

-
- )} + )} +
-
+ ); } diff --git a/web/src/components/layout/LockedSurface.tsx b/web/src/components/layout/LockedSurface.tsx new file mode 100644 index 00000000..2c5e8f04 --- /dev/null +++ b/web/src/components/layout/LockedSurface.tsx @@ -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(); +// +// +// + +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 ( +
+ {/* Preview layer — the real page rendered as a teaser. */} +
+ {children} +
+ + {/* Frosted overlay + centered upgrade card. */} +
+
+
+
+ +
+ + Locked + +
+ + {feature} + + + {plan} + +
+ +
+

+ {blurb} +

+ {bullets && bullets.length > 0 && ( +
    + {bullets.map((b) => ( +
  • + + {b} +
  • + ))} +
+ )} +
+ +
+ {isOwner ? ( + <> + + Upgrade unlocks it instantly + + + + Upgrade to {plan} + + + ) : ( + + Ask your workspace owner to upgrade to {plan}. + + )} +
+
+
+
+ ); +} diff --git a/web/src/components/layout/UserNav.tsx b/web/src/components/layout/UserNav.tsx index 70ff7353..2a7cd671 100644 --- a/web/src/components/layout/UserNav.tsx +++ b/web/src/components/layout/UserNav.tsx @@ -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 - navigate("/app/billing")} - icon={} - > - Billing - + {access.isOwner && ( + navigate("/app/billing")} + icon={} + > + Billing + + )} navigate("/app/team")} icon={} diff --git a/web/src/hooks/useFeatureAccess.ts b/web/src/hooks/useFeatureAccess.ts new file mode 100644 index 00000000..109d10c0 --- /dev/null +++ b/web/src/hooks/useFeatureAccess.ts @@ -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 ; + +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", + }; +}