mobile: billing & usage in network settings (parity phase 5)
Surface the network plan, daily usage, and Stripe management — all backed by client methods that already existed. New use-billing hooks and a BillingSection (mirroring desktop): every member sees the plan + usage summary; admins get cadence selection + "Upgrade to Pro" (checkout) and "Manage subscription" (portal), opening Stripe in the system browser. Added to NetworkSettingsScreen. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV
This commit is contained in:
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="mt-4 border-t border-border pt-2">
|
||||
<BillingSection networkId={networkId} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{isAdmin ? (
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user