11"use client" ;
22
3- import { LockIcon , RocketLaunchIcon } from "@phosphor-icons/react" ;
3+ import {
4+ ArrowRightIcon ,
5+ CrownIcon ,
6+ LockSimpleIcon ,
7+ RocketLaunchIcon ,
8+ SparkleIcon ,
9+ StarIcon ,
10+ } from "@phosphor-icons/react" ;
411import Link from "next/link" ;
512import type { ReactNode } from "react" ;
613import {
714 useBillingContext ,
815 type GatedFeatureId ,
916} from "@/components/providers/billing-provider" ;
1017import { Button } from "@/components/ui/button" ;
18+ import { Card , CardContent , CardHeader } from "@/components/ui/card" ;
19+ import { cn } from "@/lib/utils" ;
1120import { FEATURE_METADATA , PLAN_IDS } from "@/types/features" ;
1221
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" ,
22+ const PLAN_CONFIG : Record <
23+ string ,
24+ { name : string ; icon : typeof StarIcon ; color : string }
25+ > = {
26+ [ PLAN_IDS . FREE ] : { name : "Free" , icon : SparkleIcon , color : "text-muted-foreground" } ,
27+ [ PLAN_IDS . HOBBY ] : { name : "Hobby" , icon : RocketLaunchIcon , color : "text-success" } ,
28+ [ PLAN_IDS . PRO ] : { name : "Pro" , icon : StarIcon , color : "text-primary" } ,
29+ [ PLAN_IDS . SCALE ] : { name : "Scale" , icon : CrownIcon , color : "text-amber-500" } ,
1830} ;
1931
2032interface FeatureGateProps {
2133 feature : GatedFeatureId ;
2234 children : ReactNode ;
2335 title ?: string ;
2436 description ?: string ;
25- /** Block rendering while checking access (default: false, shows content optimistically) */
2637 blockWhileLoading ?: boolean ;
2738}
2839
29- /**
30- * Wraps content requiring a specific feature.
31- * Shows upgrade prompt when feature is unavailable.
32- */
3340export function FeatureGate ( {
3441 feature,
3542 children,
3643 title,
3744 description,
3845 blockWhileLoading = false ,
3946} : FeatureGateProps ) {
40- const { isFeatureEnabled, currentPlanId, isFree, isLoading } =
41- useBillingContext ( ) ;
47+ const { isFeatureEnabled, currentPlanId, isLoading } = useBillingContext ( ) ;
4248
43- // Optimistic: show content while loading
4449 if ( isLoading && ! blockWhileLoading ) {
4550 return < > { children } </ > ;
4651 }
@@ -50,67 +55,81 @@ export function FeatureGate({
5055 }
5156
5257 const metadata = FEATURE_METADATA [ feature ] ;
53- const planName = metadata ?. minPlan ? PLAN_NAMES [ metadata . minPlan ] : "a paid" ;
58+ const requiredPlan = metadata ?. minPlan ?? PLAN_IDS . HOBBY ;
59+ const planConfig = PLAN_CONFIG [ requiredPlan ] ?? PLAN_CONFIG [ PLAN_IDS . HOBBY ] ;
60+ const currentConfig = PLAN_CONFIG [ currentPlanId ?? PLAN_IDS . FREE ] ?? PLAN_CONFIG [ PLAN_IDS . FREE ] ;
61+ const PlanIcon = planConfig . icon ;
62+ const CurrentIcon = currentConfig . icon ;
5463
5564 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 >
65+ < div className = "flex h-full min-h-[400px] items-center justify-center p-4" >
66+ < Card className = "w-full max-w-md overflow-hidden pt-0" >
67+ < CardHeader className = "dotted-bg flex flex-col items-center gap-4 border-b bg-accent py-8" >
68+ < div className = "flex size-14 items-center justify-center rounded border bg-card" >
69+ < LockSimpleIcon
70+ className = "size-7 text-muted-foreground"
71+ weight = "duotone"
72+ />
73+ </ div >
74+ < div className = "text-center" >
75+ < h2 className = "font-semibold text-lg tracking-tight" >
76+ { title ?? `Unlock ${ metadata ?. name ?? "this feature" } ` }
77+ </ h2 >
78+ < p className = "mt-1 text-muted-foreground text-sm" >
79+ { description ?? metadata ?. description ?? "Upgrade to access this feature." }
80+ </ p >
81+ </ div >
82+ </ CardHeader >
6483
65- < h2 className = "mb-2 font-semibold text-xl" >
66- { title ??
67- `${ metadata ?. name ?? "This feature" } requires ${ planName } plan` }
68- </ h2 >
84+ < CardContent className = "space-y-4 p-4" >
85+ { /* Required plan */ }
86+ < div className = "flex items-center justify-between rounded border bg-accent/50 px-3 py-2.5" >
87+ < span className = "text-muted-foreground text-sm" > Required plan</ span >
88+ < div className = "flex items-center gap-1.5" >
89+ < PlanIcon className = { cn ( "size-4" , planConfig . color ) } weight = "duotone" />
90+ < span className = { cn ( "font-semibold text-sm" , planConfig . color ) } >
91+ { planConfig . name }
92+ </ span >
93+ </ div >
94+ </ div >
6995
70- < p className = "mb-6 text-muted-foreground" >
71- { description ??
72- metadata ?. description ??
73- "Upgrade your plan to access this feature." }
74- </ p >
96+ { /* Current plan */ }
97+ < div className = "flex items-center justify-between rounded border px-3 py-2.5" >
98+ < span className = "text-muted-foreground text-sm" > Your plan</ span >
99+ < div className = "flex items-center gap-1.5" >
100+ < CurrentIcon className = { cn ( "size-4" , currentConfig . color ) } weight = "duotone" />
101+ < span className = "font-medium text-foreground text-sm" >
102+ { currentConfig . name }
103+ </ span >
104+ </ div >
105+ </ div >
75106
76- < div className = "flex flex-col gap-3 sm:flex-row" >
77- < Button asChild >
107+ { /* CTA */ }
108+ < Button asChild className = "group w-full gap-2" size = "lg" >
78109 < Link href = "/billing/plans" >
79- < RocketLaunchIcon className = "mr-2 size-4" weight = "duotone" />
80- Upgrade to { planName }
110+ < RocketLaunchIcon className = "size-5" weight = "duotone" />
111+ Upgrade to { planConfig . name }
112+ < ArrowRightIcon className = "size-4 transition-transform group-hover:translate-x-0.5" />
81113 </ Link >
82114 </ 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're on the{ " " }
91- < span className = "font-medium" >
92- { PLAN_NAMES [ currentPlanId ?? PLAN_IDS . FREE ] }
93- </ span > { " " }
94- plan
95- </ p >
96- ) }
97- </ div >
115+ </ CardContent >
116+ </ Card >
98117 </ div >
99118 ) ;
100119}
101120
102121export function useFeatureGate ( feature : GatedFeatureId ) {
103- const { isFeatureEnabled, getGatedFeatureAccess, isLoading } =
104- useBillingContext ( ) ;
122+ const { isFeatureEnabled, getGatedFeatureAccess, isLoading } = useBillingContext ( ) ;
105123
106124 const access = getGatedFeatureAccess ( feature ) ;
107125 const metadata = FEATURE_METADATA [ feature ] ;
126+ const planConfig = metadata ?. minPlan ? PLAN_CONFIG [ metadata . minPlan ] : null ;
108127
109128 return {
110129 isEnabled : isFeatureEnabled ( feature ) ,
111130 isLoading,
112131 ...access ,
113- planName : metadata ?. minPlan ? PLAN_NAMES [ metadata . minPlan ] : null ,
132+ planName : planConfig ?. name ?? null ,
114133 featureName : metadata ?. name ?? feature ,
115134 } ;
116135}
0 commit comments