-
-
-
+
+
+
+
+
+
+ {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",
+ };
+}