Skip to content

Commit 8445d51

Browse files
committed
gatelock
1 parent 47fbf34 commit 8445d51

File tree

13 files changed

+729
-139
lines changed

13 files changed

+729
-139
lines changed

apps/dashboard/app/(main)/websites/[id]/flags/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { useQuery } from "@tanstack/react-query";
66
import { useAtom } from "jotai";
77
import { useParams } from "next/navigation";
88
import { Suspense, useCallback, useState } from "react";
9+
import { FeatureGate } from "@/components/feature-gate";
10+
import { GATED_FEATURES } from "@/components/providers/billing-provider";
911
import { Badge } from "@/components/ui/badge";
1012
import { Card, CardContent } from "@/components/ui/card";
1113
import {
@@ -124,7 +126,7 @@ export default function FlagsPage() {
124126
}
125127

126128
return (
127-
<>
129+
<FeatureGate feature={GATED_FEATURES.FEATURE_FLAGS}>
128130
<WebsitePageHeader
129131
createActionLabel="Create Flag"
130132
description="Control feature rollouts and A/B testing"
@@ -204,6 +206,6 @@ export default function FlagsPage() {
204206
</Suspense>
205207
)}
206208
</div>
207-
</>
209+
</FeatureGate>
208210
);
209211
}

apps/dashboard/app/(main)/websites/[id]/funnels/page.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { useAtom } from "jotai";
55
import dynamic from "next/dynamic";
66
import { useParams } from "next/navigation";
77
import { useCallback, useMemo, useState } from "react";
8+
import { FeatureGate } from "@/components/feature-gate";
9+
import { GATED_FEATURES } from "@/components/providers/billing-provider";
810
import { Card, CardContent } from "@/components/ui/card";
911
import { DeleteDialog } from "@/components/ui/delete-dialog";
1012
import { useDateFilters } from "@/hooks/use-date-filters";
@@ -240,7 +242,8 @@ export default function FunnelsPage() {
240242
}
241243

242244
return (
243-
<div className="relative flex h-full flex-col">
245+
<FeatureGate feature={GATED_FEATURES.FUNNELS}>
246+
<div className="relative flex h-full flex-col">
244247
<WebsitePageHeader
245248
createActionLabel="Create Funnel"
246249
description="Track user journeys and optimize conversion drop-off points"
@@ -350,6 +353,7 @@ export default function FunnelsPage() {
350353
title="Delete Funnel"
351354
/>
352355
)}
353-
</div>
356+
</div>
357+
</FeatureGate>
354358
);
355359
}

apps/dashboard/app/(main)/websites/[id]/goals/page.tsx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { TargetIcon, TrendDownIcon } from "@phosphor-icons/react";
44
import { useAtom } from "jotai";
55
import { useParams } from "next/navigation";
66
import { useCallback, useMemo, useState } from "react";
7+
import { FeatureGate } from "@/components/feature-gate";
8+
import { GATED_FEATURES } from "@/components/providers/billing-provider";
79
import { Card, CardContent } from "@/components/ui/card";
810
import { DeleteDialog } from "@/components/ui/delete-dialog";
911
import { useDateFilters } from "@/hooks/use-date-filters";
@@ -153,9 +155,10 @@ export default function GoalsPage() {
153155
}
154156

155157
return (
156-
<div className="relative flex h-full flex-col">
157-
<WebsitePageHeader
158-
createActionLabel="Create Goal"
158+
<FeatureGate feature={GATED_FEATURES.GOALS}>
159+
<div className="relative flex h-full flex-col">
160+
<WebsitePageHeader
161+
createActionLabel="Create Goal"
159162
description="Track key conversions and measure success"
160163
hasError={!!goalsError}
161164
icon={
@@ -215,15 +218,16 @@ export default function GoalsPage() {
215218
)}
216219

217220
{deletingGoalId && (
218-
<DeleteDialog
219-
confirmLabel="Delete Goal"
220-
description="Are you sure you want to delete this goal? This action cannot be undone and will permanently remove all associated analytics data."
221-
isOpen={!!deletingGoalId}
222-
onClose={() => setDeletingGoalId(null)}
223-
onConfirm={() => deletingGoalId && handleDeleteGoal(deletingGoalId)}
224-
title="Delete Goal"
225-
/>
226-
)}
227-
</div>
221+
<DeleteDialog
222+
confirmLabel="Delete Goal"
223+
description="Are you sure you want to delete this goal? This action cannot be undone and will permanently remove all associated analytics data."
224+
isOpen={!!deletingGoalId}
225+
onClose={() => setDeletingGoalId(null)}
226+
onConfirm={() => deletingGoalId && handleDeleteGoal(deletingGoalId)}
227+
title="Delete Goal"
228+
/>
229+
)}
230+
</div>
231+
</FeatureGate>
228232
);
229233
}

apps/dashboard/app/(main)/websites/[id]/retention/page.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import { SpinnerIcon } from "@phosphor-icons/react";
44
import dynamic from "next/dynamic";
55
import { useParams } from "next/navigation";
6+
import { FeatureGate } from "@/components/feature-gate";
7+
import { GATED_FEATURES } from "@/components/providers/billing-provider";
68

79
const RetentionContentDynamic = dynamic(
810
() =>
@@ -23,8 +25,10 @@ export default function RetentionPage() {
2325
const { id: websiteId } = useParams();
2426

2527
return (
26-
<div className="flex h-[calc(100vh-6rem)] flex-col overflow-hidden p-4">
27-
<RetentionContentDynamic websiteId={websiteId as string} />
28-
</div>
28+
<FeatureGate feature={GATED_FEATURES.RETENTION}>
29+
<div className="flex h-[calc(100vh-6rem)] flex-col overflow-hidden p-4">
30+
<RetentionContentDynamic websiteId={websiteId as string} />
31+
</div>
32+
</FeatureGate>
2933
);
3034
}

apps/dashboard/app/providers.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { AutumnProvider } from "autumn-js/react";
1111
import { ThemeProvider } from "next-themes";
1212
import { NuqsAdapter } from "nuqs/adapters/next/app";
1313
import { useState } from "react";
14+
import { BillingProvider } from "@/components/providers/billing-provider";
1415
import { OrganizationsProvider } from "@/components/providers/organizations-provider";
1516

1617
const defaultQueryClientOptions = {
@@ -56,9 +57,11 @@ export default function Providers({ children }: { children: React.ReactNode }) {
5657
process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"
5758
}
5859
>
59-
<OrganizationsProvider>
60-
<NuqsAdapter>{children}</NuqsAdapter>
61-
</OrganizationsProvider>
60+
<BillingProvider>
61+
<OrganizationsProvider>
62+
<NuqsAdapter>{children}</NuqsAdapter>
63+
</OrganizationsProvider>
64+
</BillingProvider>
6265
</AutumnProvider>
6366
</FlagsProviderWrapper>
6467
</QueryClientProvider>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"use client";
2+
3+
import { LockIcon, RocketLaunchIcon } from "@phosphor-icons/react";
4+
import Link from "next/link";
5+
import type { ReactNode } from "react";
6+
import {
7+
useBillingContext,
8+
type GatedFeatureId,
9+
} from "@/components/providers/billing-provider";
10+
import { Button } from "@/components/ui/button";
11+
import { FEATURE_METADATA, PLAN_IDS } from "@/types/features";
12+
13+
const PLAN_NAMES: Record<string, string> = {
14+
[PLAN_IDS.FREE]: "Free",
15+
[PLAN_IDS.HOBBY]: "Hobby",
16+
[PLAN_IDS.PRO]: "Pro",
17+
[PLAN_IDS.SCALE]: "Scale",
18+
};
19+
20+
interface FeatureGateProps {
21+
feature: GatedFeatureId;
22+
children: ReactNode;
23+
title?: string;
24+
description?: string;
25+
/** Block rendering while checking access (default: false, shows content optimistically) */
26+
blockWhileLoading?: boolean;
27+
}
28+
29+
/**
30+
* Wraps content requiring a specific feature.
31+
* Shows upgrade prompt when feature is unavailable.
32+
*/
33+
export function FeatureGate({
34+
feature,
35+
children,
36+
title,
37+
description,
38+
blockWhileLoading = false,
39+
}: FeatureGateProps) {
40+
const { isFeatureEnabled, currentPlanId, isFree, isLoading } =
41+
useBillingContext();
42+
43+
// Optimistic: show content while loading
44+
if (isLoading && !blockWhileLoading) {
45+
return <>{children}</>;
46+
}
47+
48+
if (isFeatureEnabled(feature)) {
49+
return <>{children}</>;
50+
}
51+
52+
const metadata = FEATURE_METADATA[feature];
53+
const planName = metadata?.minPlan ? PLAN_NAMES[metadata.minPlan] : "a paid";
54+
55+
return (
56+
<div className="flex h-full min-h-[400px] flex-col items-center justify-center p-8">
57+
<div className="flex max-w-md flex-col items-center text-center">
58+
<div className="mb-6 flex size-16 items-center justify-center rounded-full bg-secondary">
59+
<LockIcon
60+
className="size-8 text-muted-foreground"
61+
weight="duotone"
62+
/>
63+
</div>
64+
65+
<h2 className="mb-2 font-semibold text-xl">
66+
{title ??
67+
`${metadata?.name ?? "This feature"} requires ${planName} plan`}
68+
</h2>
69+
70+
<p className="mb-6 text-muted-foreground">
71+
{description ??
72+
metadata?.description ??
73+
"Upgrade your plan to access this feature."}
74+
</p>
75+
76+
<div className="flex flex-col gap-3 sm:flex-row">
77+
<Button asChild>
78+
<Link href="/billing/plans">
79+
<RocketLaunchIcon className="mr-2 size-4" weight="duotone" />
80+
Upgrade to {planName}
81+
</Link>
82+
</Button>
83+
<Button asChild variant="outline">
84+
<Link href="/billing">View Current Plan</Link>
85+
</Button>
86+
</div>
87+
88+
{isFree && (
89+
<p className="mt-6 text-muted-foreground text-sm">
90+
You&apos;re on the{" "}
91+
<span className="font-medium">
92+
{PLAN_NAMES[currentPlanId ?? PLAN_IDS.FREE]}
93+
</span>{" "}
94+
plan
95+
</p>
96+
)}
97+
</div>
98+
</div>
99+
);
100+
}
101+
102+
export function useFeatureGate(feature: GatedFeatureId) {
103+
const { isFeatureEnabled, getGatedFeatureAccess, isLoading } =
104+
useBillingContext();
105+
106+
const access = getGatedFeatureAccess(feature);
107+
const metadata = FEATURE_METADATA[feature];
108+
109+
return {
110+
isEnabled: isFeatureEnabled(feature),
111+
isLoading,
112+
...access,
113+
planName: metadata?.minPlan ? PLAN_NAMES[metadata.minPlan] : null,
114+
featureName: metadata?.name ?? feature,
115+
};
116+
}

apps/dashboard/components/layout/navigation/navigation-config.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
UsersThreeIcon,
3838
WarningIcon,
3939
} from "@phosphor-icons/react";
40+
import { GATED_FEATURES } from "@/types/features";
4041
import type { Category, NavigationSection } from "./types";
4142

4243
const createNavItem = (
@@ -232,25 +233,43 @@ export const websiteNavigation: NavigationSection[] = [
232233
createNavItem("Web Vitals", HeartbeatIcon, "/vitals", {
233234
rootLevel: false,
234235
alpha: true,
236+
gatedFeature: GATED_FEATURES.WEB_VITALS,
235237
}),
236238
createNavItem("Performance", ActivityIcon, "/performance", {
237239
rootLevel: false,
238240
tag: "deprecated",
239241
}),
240-
createNavItem("Geographic", MapPinIcon, "/map", { rootLevel: false }),
241-
createNavItem("Error Tracking", BugIcon, "/errors", { rootLevel: false }),
242+
createNavItem("Geographic", MapPinIcon, "/map", {
243+
rootLevel: false,
244+
gatedFeature: GATED_FEATURES.GEOGRAPHIC,
245+
}),
246+
createNavItem("Error Tracking", BugIcon, "/errors", {
247+
rootLevel: false,
248+
gatedFeature: GATED_FEATURES.ERROR_TRACKING,
249+
}),
242250
]),
243251
createNavSection("Product Analytics", TrendUpIcon, [
244-
createNavItem("Users", UsersThreeIcon, "/users", { rootLevel: false }),
245-
createNavItem("Funnels", FunnelIcon, "/funnels", { rootLevel: false }),
246-
createNavItem("Goals", TargetIcon, "/goals", { rootLevel: false }),
252+
createNavItem("Users", UsersThreeIcon, "/users", {
253+
rootLevel: false,
254+
gatedFeature: GATED_FEATURES.USERS,
255+
}),
256+
createNavItem("Funnels", FunnelIcon, "/funnels", {
257+
rootLevel: false,
258+
gatedFeature: GATED_FEATURES.FUNNELS,
259+
}),
260+
createNavItem("Goals", TargetIcon, "/goals", {
261+
rootLevel: false,
262+
gatedFeature: GATED_FEATURES.GOALS,
263+
}),
247264
createNavItem("Retention", RepeatIcon, "/retention", {
248265
rootLevel: false,
249266
alpha: true,
267+
gatedFeature: GATED_FEATURES.RETENTION,
250268
}),
251269
createNavItem("Feature Flags", FlagIcon, "/flags", {
252270
alpha: true,
253271
rootLevel: false,
272+
gatedFeature: GATED_FEATURES.FEATURE_FLAGS,
254273
}),
255274
// createNavItem("Databunny AI", RobotIcon, "/assistant", {
256275
// alpha: true,
@@ -276,6 +295,7 @@ export const websiteSettingsNavigation: NavigationSection[] = [
276295
),
277296
createNavItem("Data Export", FileArrowDownIcon, "/settings/export", {
278297
rootLevel: false,
298+
gatedFeature: GATED_FEATURES.DATA_EXPORT,
279299
}),
280300
]),
281301
];

0 commit comments

Comments
 (0)