* 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]>
282 lines
7.9 KiB
TypeScript
282 lines
7.9 KiB
TypeScript
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>
|
||
);
|
||
}
|