diff --git a/js/mobile/src/features/network-settings/BillingSection.tsx b/js/mobile/src/features/network-settings/BillingSection.tsx new file mode 100644 index 0000000..8d8dbb1 --- /dev/null +++ b/js/mobile/src/features/network-settings/BillingSection.tsx @@ -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 ( + + {label} + + {value} + + ); +} + +/** + * 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 ( + + + Plan & billing + + + + {isPro ? 'Llink Pro' : 'Llink Free'} + + } + /> + {!isPro && usage?.limit != null ? ( + + {usage.used} / {usage.limit} + + } + /> + ) : null} + + {isAdmin ? : null} + + ); +} + +function AdminBillingControls({ networkId }: { networkId: string }) { + const { + data: billing, + isLoading, + error, + } = useNetworkBilling(networkId, true); + + if (isLoading || !billing) { + return ( + + {error ? `Couldn’t load billing: ${toUserMessage(error)}` : 'Loading…'} + + ); + } + + return billing.plan === 'pro' ? ( + + ) : ( + + ); +} + +function FreeBilling({ + networkId, + billing, +}: { + networkId: string; + billing: BillingStatus; +}) { + const createCheckout = useCreateCheckoutSession(networkId); + const [cadence, setCadence] = useState('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 ( + + 0 ? `Save ${savingsPct}%` : undefined} + selected={cadence === 'annual'} + onPress={() => setCadence('annual')} + /> + setCadence('monthly')} + /> + + + {createCheckout.isPending ? 'Opening Stripe…' : 'Upgrade to Pro'} + + + + ); +} + +function CadenceOption({ + label, + note, + perSeatCents, + badge, + selected, + onPress, +}: { + label: string; + note: string; + perSeatCents: number; + badge?: string; + selected: boolean; + onPress: () => void; +}) { + return ( + + + + + {label} + {badge ? ( + + + {badge} + + + ) : null} + + {note} + + + + {formatCents(perSeatCents)} + + per seat / mo + + + ); +} + +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 ( + + {billing.cancel_at_period_end && renewal ? ( + + Your subscription downgrades to Free on {renewal}. + + ) : null} + {billing.plan_status === 'past_due' ? ( + + Your last payment failed. Update your payment method to keep Pro + active. + + ) : null} + + + {`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`} + + } + /> + {billing.seats}} + /> + {renewal ? ( + {renewal}} + /> + ) : null} + + + + + {createPortal.isPending ? 'Opening Stripe…' : 'Manage subscription'} + + + + ); +} diff --git a/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx b/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx index 0331a67..9ae3632 100644 --- a/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx +++ b/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx @@ -15,6 +15,7 @@ import { Avatar } from '@/components/Avatar'; import { toUserMessage } from '@/lib/errors'; import type { RootStackScreenProps } from '@/navigation/types'; import { AddMembersSheet } from './AddMembersSheet'; +import { BillingSection } from './BillingSection'; export function NetworkSettingsScreen({ route, @@ -157,6 +158,10 @@ export function NetworkSettingsScreen({ )} ) : null} + + + + {isAdmin ? ( diff --git a/js/mobile/src/hooks/use-billing.ts b/js/mobile/src/hooks/use-billing.ts new file mode 100644 index 0000000..2163efa --- /dev/null +++ b/js/mobile/src/hooks/use-billing.ts @@ -0,0 +1,33 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { BillingCadence } from '@/api/types'; + +/** Plan + quota summary. Member-accessible (sourced from `/usage`). */ +export function useNetworkUsage(networkId: string) { + return useQuery({ + queryKey: ['network-usage', networkId], + queryFn: () => apiClient.getNetworkUsage(networkId), + }); +} + +/** Full billing status. Admin-gated (`/billing`). */ +export function useNetworkBilling(networkId: string, enabled: boolean) { + return useQuery({ + queryKey: ['network-billing', networkId], + queryFn: () => apiClient.getNetworkBilling(networkId), + enabled, + }); +} + +export function useCreateCheckoutSession(networkId: string) { + return useMutation({ + mutationFn: (cadence: BillingCadence) => + apiClient.createCheckoutSession(networkId, cadence), + }); +} + +export function useCreatePortalSession(networkId: string) { + return useMutation({ + mutationFn: () => apiClient.createPortalSession(networkId), + }); +}