@@ -0,0 +1,281 @@
import { useState } from 'react' ;
import { Linking , Pressable , Text , View } from 'react-native' ;
import { ExternalLink } from 'lucide-react-native' ;
import { toast } from 'sonner-native' ;
import type { BillingCadence , BillingStatus } from '@/api/types' ;
import {
useCreateCheckoutSession ,
useCreatePortalSession ,
useNetworkBilling ,
useNetworkUsage ,
} from '@/hooks/use-billing' ;
import { useIsNetworkAdmin } from '@/hooks/use-networks' ;
import { toUserMessage } from '@/lib/errors' ;
import { cn } from '@/lib/utils' ;
function formatCents ( cents : number ) : string {
if ( cents % 100 === 0 ) return ` $ ${ cents / 100 } ` ;
return ` $ ${ ( cents / 100 ) . toFixed ( 2 ) } ` ;
}
function formatDate ( date : Date ) : string {
return date . toLocaleDateString ( undefined , {
month : 'long' ,
day : 'numeric' ,
year : 'numeric' ,
} ) ;
}
function InfoRow ( { label , value } : { label : string ; value : React.ReactNode } ) {
return (
< View className = "flex-row items-center gap-3 px-1 py-2" >
< Text className = "text-muted-foreground text-sm" > { label } < / Text >
< View className = "flex-1" / >
< View > { value } < / View >
< / View >
) ;
}
/**
* Plan + usage summary for every member, plus admin-only upgrade/manage
* controls. Mirrors desktop's BillingSection — `/usage` powers the
* everyone-visible summary; `/billing` (admin-gated) drives the controls.
* Stripe checkout/portal URLs are opened in the system browser.
*/
export function BillingSection ( { networkId } : { networkId : string } ) {
const isAdmin = useIsNetworkAdmin ( networkId ) ;
const { data : usage } = useNetworkUsage ( networkId ) ;
const isPro = usage ? . plan === 'pro' ;
return (
< View className = "px-3" >
< Text className = "text-foreground text-base font-semibold pt-2 pb-2" >
Plan & amp ; billing
< / Text >
< InfoRow
label = "Plan"
value = {
< Text className = "text-foreground text-sm font-medium" >
{ isPro ? 'Llink Pro' : 'Llink Free' }
< / Text >
}
/ >
{ ! isPro && usage ? . limit != null ? (
< InfoRow
label = "Today’ s messages"
value = {
< Text className = "text-foreground text-sm tabular-nums" >
{ usage . used } / { usage . limit }
< / Text >
}
/ >
) : null }
{ isAdmin ? < AdminBillingControls networkId = { networkId } / > : null }
< / View >
) ;
}
function AdminBillingControls ( { networkId } : { networkId : string } ) {
const {
data : billing ,
isLoading ,
error ,
} = useNetworkBilling ( networkId , true ) ;
if ( isLoading || ! billing ) {
return (
< Text className = "text-muted-foreground text-sm px-1 py-2" >
{ error ? ` Couldn’ t load billing: ${ toUserMessage ( error ) } ` : 'Loading…' }
< / Text >
) ;
}
return billing . plan === 'pro' ? (
< ProBilling networkId = { networkId } billing = { billing } / >
) : (
< FreeBilling networkId = { networkId } billing = { billing } / >
) ;
}
function FreeBilling ( {
networkId ,
billing ,
} : {
networkId : string ;
billing : BillingStatus ;
} ) {
const createCheckout = useCreateCheckoutSession ( networkId ) ;
const [ cadence , setCadence ] = useState < BillingCadence > ( 'annual' ) ;
const annualPerSeatMonthlyCents = Math . round ( billing . price_annual_cents / 12 ) ;
const savingsPct = Math . round (
( 1 - annualPerSeatMonthlyCents / billing . price_monthly_cents ) * 100 ,
) ;
const handleUpgrade = async ( ) = > {
try {
const { url } = await createCheckout . mutateAsync ( cadence ) ;
await Linking . openURL ( url ) ;
} catch ( err ) {
toast . error ( toUserMessage ( err ) ) ;
}
} ;
return (
< View className = "mt-2 gap-2" >
< CadenceOption
label = "Annual"
note = "Billed annually"
perSeatCents = { annualPerSeatMonthlyCents }
badge = { savingsPct > 0 ? ` Save ${ savingsPct } % ` : undefined }
selected = { cadence === 'annual' }
onPress = { ( ) = > setCadence ( 'annual' ) }
/ >
< CadenceOption
label = "Monthly"
note = "Billed monthly · cancel anytime"
perSeatCents = { billing . price_monthly_cents }
selected = { cadence === 'monthly' }
onPress = { ( ) = > setCadence ( 'monthly' ) }
/ >
< Pressable
onPress = { handleUpgrade }
disabled = { createCheckout . isPending }
className = "mt-2 rounded-xl bg-primary py-3 items-center"
>
< Text className = "text-primary-foreground text-base font-semibold" >
{ createCheckout . isPending ? 'Opening Stripe…' : 'Upgrade to Pro' }
< / Text >
< / Pressable >
< / View >
) ;
}
function CadenceOption ( {
label ,
note ,
perSeatCents ,
badge ,
selected ,
onPress ,
} : {
label : string ;
note : string ;
perSeatCents : number ;
badge? : string ;
selected : boolean ;
onPress : ( ) = > void ;
} ) {
return (
< Pressable
onPress = { onPress }
className = { cn (
'flex-row items-center gap-3 rounded-xl border px-4 py-3' ,
selected ? 'border-primary bg-accent' : 'border-border' ,
) }
>
< View
className = { cn (
'h-5 w-5 rounded-full border-2' ,
selected ? 'border-primary bg-primary' : 'border-muted-foreground' ,
) }
/ >
< View className = "flex-1" >
< View className = "flex-row items-center gap-2" >
< Text className = "text-foreground text-sm font-medium" > { label } < / Text >
{ badge ? (
< View className = "bg-primary rounded-full px-2 py-0.5" >
< Text className = "text-primary-foreground text-[10px] font-semibold" >
{ badge }
< / Text >
< / View >
) : null }
< / View >
< Text className = "text-muted-foreground text-xs" > { note } < / Text >
< / View >
< View className = "items-end" >
< Text className = "text-foreground text-sm font-medium" >
{ formatCents ( perSeatCents ) }
< / Text >
< Text className = "text-muted-foreground text-xs" > per seat / mo < / Text >
< / View >
< / Pressable >
) ;
}
function ProBilling ( {
networkId ,
billing ,
} : {
networkId : string ;
billing : BillingStatus ;
} ) {
const createPortal = useCreatePortalSession ( networkId ) ;
const cadenceLabel = billing . cadence === 'annual' ? 'Annual' : 'Monthly' ;
const perSeatCents =
billing . cadence === 'annual'
? Math . round ( billing . price_annual_cents / 12 )
: billing . price_monthly_cents ;
const renewal = billing . current_period_end
? formatDate ( billing . current_period_end )
: null ;
const handleManage = async ( ) = > {
try {
const { url } = await createPortal . mutateAsync ( ) ;
await Linking . openURL ( url ) ;
} catch ( err ) {
toast . error ( toUserMessage ( err ) ) ;
}
} ;
return (
< View className = "mt-2" >
{ billing . cancel_at_period_end && renewal ? (
< Text className = "text-destructive text-sm py-2" >
Your subscription downgrades to Free on { renewal } .
< / Text >
) : null }
{ billing . plan_status === 'past_due' ? (
< Text className = "text-destructive text-sm py-2" >
Your last payment failed . Update your payment method to keep Pro
active .
< / Text >
) : null }
< InfoRow
label = "Billing"
value = {
< Text className = "text-foreground text-sm" >
{ ` ${ cadenceLabel } · ${ formatCents ( perSeatCents ) } / seat / mo ` }
< / Text >
}
/ >
< InfoRow
label = "Seats"
value = { < Text className = "text-foreground text-sm" > { billing . seats } < / Text > }
/ >
{ renewal ? (
< InfoRow
label = { billing . cancel_at_period_end ? 'Ends' : 'Renews' }
value = { < Text className = "text-foreground text-sm" > { renewal } < / Text > }
/ >
) : null }
< Pressable
onPress = { handleManage }
disabled = { createPortal . isPending }
className = "mt-2 flex-row items-center justify-center gap-2 rounded-xl border border-border py-3"
>
< ExternalLink color = "#fafafa" size = { 15 } / >
< Text className = "text-foreground text-base font-medium" >
{ createPortal . isPending ? 'Opening Stripe…' : 'Manage subscription' }
< / Text >
< / Pressable >
< / View >
) ;
}