Files
llink/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx
T
Claude c161d96534 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
2026-06-21 02:05:12 +00:00

219 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { Alert, Pressable, ScrollView, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Mail, Shield, UserPlus, X } from 'lucide-react-native';
import { toast } from 'sonner-native';
import type { Human } from '@/api/types';
import { useNetwork } from '@/hooks/use-networks';
import {
useNetworkInvitations,
useRemoveMember,
useRevokeInvitation,
} from '@/hooks/use-member-management';
import { useAuthStore } from '@/stores/auth-store';
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,
navigation,
}: RootStackScreenProps<'NetworkSettings'>) {
const { networkId } = route.params;
const network = useNetwork(networkId);
const { data: invitations, error: invitationsError } =
useNetworkInvitations(networkId);
const currentUserId = useAuthStore((s) => s.user?.id);
const isAdmin = !!currentUserId && network?.admin_human.id === currentUserId;
const [addOpen, setAddOpen] = useState(false);
const removeMember = useRemoveMember(networkId);
const members = network?.humans ?? [];
const pending = invitations ?? [];
const handleRemove = (human: Human) => {
Alert.alert(
`Remove ${human.email_prefix}?`,
"They'll lose access to this network's streams and files. Content they posted stays in the network.",
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
try {
await removeMember.mutateAsync(human.id);
toast.success(`Removed ${human.email}`);
} catch (err) {
toast.error(toUserMessage(err));
}
},
},
],
);
};
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
<Text className="text-foreground text-2xl"></Text>
</Pressable>
<Text className="flex-1 text-center text-foreground text-base font-semibold">
{network?.name ?? 'Network'}
</Text>
<View className="w-8" />
</View>
<ScrollView className="flex-1">
<View className="flex-row items-center justify-between px-4 pt-5 pb-2">
<View>
<Text className="text-foreground text-base font-semibold">
Members
</Text>
<Text className="text-muted-foreground text-xs">
{members.length} {members.length === 1 ? 'member' : 'members'}
</Text>
</View>
{isAdmin ? (
<Pressable
onPress={() => setAddOpen(true)}
className="flex-row items-center bg-primary rounded-full px-3 py-2"
>
<UserPlus size={14} color="#000000" />
<Text className="text-primary-foreground text-sm font-semibold ml-1">
Add
</Text>
</Pressable>
) : null}
</View>
<View className="px-2">
{members.map((human) => {
const isRowAdmin = human.id === network?.admin_human.id;
const canRemove =
isAdmin && !isRowAdmin && human.id !== currentUserId;
return (
<View
key={human.id}
className="flex-row items-center gap-3 px-2 py-3"
>
<Avatar humanId={human.id} humans={members} size="md" />
<View className="flex-1">
<Text className="text-foreground text-sm font-medium">
{human.email_prefix}
</Text>
<Text className="text-muted-foreground text-xs">
{human.email}
</Text>
</View>
{isRowAdmin ? (
<View className="flex-row items-center bg-muted rounded-full px-2 py-1">
<Shield size={12} color="#a6a6a6" />
<Text className="text-muted-foreground text-xs ml-1">
Admin
</Text>
</View>
) : null}
{canRemove ? (
<Pressable
onPress={() => handleRemove(human)}
hitSlop={8}
className="p-1"
accessibilityLabel={`Remove ${human.email}`}
>
<X size={16} color="#a6a6a6" />
</Pressable>
) : null}
</View>
);
})}
</View>
{isAdmin ? (
<View className="mt-4">
<Text className="text-foreground text-base font-semibold px-4 pt-2 pb-2">
Pending invitations
</Text>
{invitationsError ? (
<Text className="text-muted-foreground text-xs px-4 py-2">
Couldnt load pending invitations.
</Text>
) : pending.length === 0 ? (
<Text className="text-muted-foreground text-xs px-4 py-2">
No pending invitations.
</Text>
) : (
<View className="px-2">
{pending.map((inv) => (
<PendingInvitationRow
key={inv.email}
email={inv.email}
networkId={networkId}
/>
))}
</View>
)}
</View>
) : null}
<View className="mt-4 border-t border-border pt-2">
<BillingSection networkId={networkId} />
</View>
</ScrollView>
{isAdmin ? (
<AddMembersSheet
open={addOpen}
onClose={() => setAddOpen(false)}
networkId={networkId}
/>
) : null}
</SafeAreaView>
);
}
function PendingInvitationRow({
email,
networkId,
}: {
email: string;
networkId: string;
}) {
const revokeInvitation = useRevokeInvitation(networkId);
const handleRevoke = async () => {
try {
await revokeInvitation.mutateAsync(email);
toast.success(`Invitation to ${email} revoked`);
} catch (err) {
toast.error(toUserMessage(err));
}
};
return (
<View className="flex-row items-center gap-3 px-2 py-3">
<View className="h-10 w-10 items-center justify-center rounded-full bg-muted">
<Mail size={16} color="#a6a6a6" />
</View>
<View className="flex-1">
<Text className="text-foreground text-sm" numberOfLines={1}>
{email}
</Text>
<Text className="text-muted-foreground text-xs">Pending</Text>
</View>
<Pressable
onPress={handleRevoke}
disabled={revokeInvitation.isPending}
hitSlop={8}
className="p-1"
accessibilityLabel={`Revoke invitation to ${email}`}
>
<X size={16} color="#a6a6a6" />
</Pressable>
</View>
);
}