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
219 lines
7.2 KiB
TypeScript
219 lines
7.2 KiB
TypeScript
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">
|
||
Couldn’t 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>
|
||
);
|
||
}
|