Files
llink/js/mobile/src/features/network-settings/BillingSection.tsx
T
3d80ac2993 feat: increase feature parity between mobile and desktop (#298)
* mobile: add invitations, network creation, and settings (parity phase 1)

Surface backend capabilities that already existed in the mobile API client
but had no UI:

- NetworkListScreen now lists pending invitations with an Accept action and
  a header "+" to create a network; empty state offers creation instead of
  pointing users to desktop.
- New CreateNetworkSheet and use-invitations hooks (accept invite, create
  network) following the existing react-query patterns.
- SettingsScreen replaces its placeholder with an email-notifications toggle
  (optimistic, mirrors desktop), app version, and sign out.
- Wire the previously-unreachable Settings row into the Drawer.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: network member management and avatars (parity phase 2)

- New NetworkSettingsScreen (reachable from the stream-list header) lists
  members with admin remove, an invite-by-email sheet, and pending
  invitations with revoke — backed by new use-member-management hooks.
- Avatars: add avatar_object_id to HumanSchema, uploadAvatar/deleteAvatar/
  getAvatarDownloadUrl client methods (raw PUT via expo-file-system), a
  use-avatar-url hook, and image rendering in the shared Avatar component.
  AccountScreen gains a tap-to-change profile picture via expo-image-picker.
- auth-store gains refreshUser to pick up avatar changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: task particles — view, edit, and compose (parity phase 3)

Bring the task particle to parity with desktop's richer model:

- Replace the thin `quest` schema with desktop's `task` model
  (ChecklistItem, TaskProperties: title/notes/checklist/assigned_to/done)
  in the discriminated union, the Firestore converter, and consumers
  (StreamCard, FallbackParticleView).
- New TaskParticleView renders an editable card (round done checkbox,
  title, notes, checklist with add/toggle/edit/remove, assignee chips)
  persisting each edit to Firestore; an 8s dwell auto-advances and
  field focus suspends playback. Wired into StreamView's render switch.
- Compose: a task button in the ComposeDock opens a TaskComposeSheet
  (createTaskParticle helper). Gated off in the new-stream flow, where a
  stream's first particle must be text or media.

Note: particles are written client-side to Firestore, matching desktop;
Orion's REST validator still only accepts `quest`, which is a pre-existing
inconsistency to reconcile backend-side separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: paper and file particle views (parity phase 4)

- Extract the shared markdown renderer/theme out of TextParticleView into
  a reusable MarkdownBody component (DRY).
- PaperParticleView renders desktop-authored documents (title + markdown)
  with a length-based dwell.
- FileParticleView shows name/size and a Download action that opens a
  signed URL via the OS.
- Both wired into StreamView's render switch; FallbackParticleView is now a
  true catch-all for unknown/folder types only.

Deferred (documented for a follow-up phase): composing papers/files from
mobile, particle attachments + lightbox, and link previews in text.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* 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

* mobile: let users dismiss the keyboard from a task card

Focusing a task field opened the keyboard with no way out — it covered the
card and the stream's tap-to-advance zones. Now:

- A "Done" pill appears at the card's top-right while editing (reusing the
  existing `editing` flag) and calls Keyboard.dismiss(); the title row
  reserves space so the pill never overlaps a long title.
- The card ScrollView gains keyboardDismissMode (interactive on iOS, on-drag
  on Android) so dragging the card also dismisses the keyboard.

Dismissing blurs the active field, which flips `editing` off and resumes the
dwell timer and tap navigation automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* decrease clutter in stream-view

* format

* format

* consolidate avatar

---------

Co-authored-by: Claude <[email protected]>
2026-06-21 10:38:16 -07:00

282 lines
7.9 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 { 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="Todays 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 ? `Couldnt 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>
);
}