implement paywall (#161)
* implement core foundation * inject deps * fix incorrect migration * tail migration * use transaction for migration * fix: inject deps for tests * cleanup billing management for admin * upgrade stripe sdk to v85 * set price env variables * cleanup billing management * allow multiple dev windows * fix: settings scroll * feat: show nice video thumbnail in listview * feat: implement freemium restrictions * remove unnecessary comments * refactor * docs * format * tweak network settings better hierarchy
This commit was merged in pull request #161.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
||||
import { QuotaExceededError, useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
||||
import { isUsageExhausted, useInvalidateNetworkUsage, useNetworkUsage } from "@/hooks/use-network-usage";
|
||||
import { useRecorder } from "@/features/compose/use-recorder";
|
||||
import { useScreenRecorder } from "@/features/compose/use-screen-recorder";
|
||||
import { particlePath, parseParticlePath } from "@/lib/particle-path";
|
||||
@@ -70,12 +71,17 @@ export function ComposeOverlay({
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const createParticle = useCreateParticle();
|
||||
const createStream = useCreateStreamParticle();
|
||||
const { data: usage } = useNetworkUsage(networkId);
|
||||
const invalidateUsage = useInvalidateNetworkUsage();
|
||||
const quotaExhausted = isUsageExhausted(usage);
|
||||
|
||||
// Refs for synchronous reads in keyboard handlers
|
||||
const stepRef = useRef(step);
|
||||
const recordStartRef = useRef(0);
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
const quotaExhaustedRef = useRef(quotaExhausted);
|
||||
quotaExhaustedRef.current = quotaExhausted;
|
||||
const recordingSourceRef = useRef(recordingSource);
|
||||
recordingSourceRef.current = recordingSource;
|
||||
|
||||
@@ -88,7 +94,12 @@ export function ComposeOverlay({
|
||||
useEffect(() => {
|
||||
onActiveChange?.(step !== "idle");
|
||||
onStepChange?.(step);
|
||||
}, [step, onActiveChange, onStepChange]);
|
||||
// Refresh quota when the overlay activates — user is about to send, so
|
||||
// we want the most accurate count before the client-side gate kicks in.
|
||||
if (step !== "idle") {
|
||||
void invalidateUsage(networkId);
|
||||
}
|
||||
}, [step, onActiveChange, onStepChange, invalidateUsage, networkId]);
|
||||
|
||||
const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => {
|
||||
for (const a of items) {
|
||||
@@ -334,12 +345,25 @@ export function ComposeOverlay({
|
||||
],
|
||||
);
|
||||
|
||||
const handleQuotaError = useCallback((err: unknown): boolean => {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
toast.error("Daily message limit reached. Upgrade to Pro to keep sending.");
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [cancel]);
|
||||
|
||||
// Reply mode: create particle directly under targetPath
|
||||
const onSubmitReply = useEffectEvent(async () => {
|
||||
if (!targetPath || !userId || stepRef.current === "submitting") return;
|
||||
setStepSync("submitting");
|
||||
await createChildParticle(targetPath);
|
||||
cancel();
|
||||
try {
|
||||
await createChildParticle(targetPath);
|
||||
cancel();
|
||||
} catch (err) {
|
||||
if (!handleQuotaError(err)) throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// New stream mode: create stream + first child
|
||||
@@ -348,21 +372,25 @@ export function ComposeOverlay({
|
||||
if (!userId || stepRef.current === "submitting") return;
|
||||
setStepSync("submitting");
|
||||
|
||||
const streamId = await createStream.mutateAsync({
|
||||
networkId,
|
||||
properties: {
|
||||
name: streamName,
|
||||
},
|
||||
createdByHumanId: userId,
|
||||
visibleTo,
|
||||
});
|
||||
try {
|
||||
const streamId = await createStream.mutateAsync({
|
||||
networkId,
|
||||
properties: {
|
||||
name: streamName,
|
||||
},
|
||||
createdByHumanId: userId,
|
||||
visibleTo,
|
||||
});
|
||||
|
||||
const streamChildrenPath = particlePath(networkId, [streamId]);
|
||||
await createChildParticle(streamChildrenPath);
|
||||
const streamChildrenPath = particlePath(networkId, [streamId]);
|
||||
await createChildParticle(streamChildrenPath);
|
||||
|
||||
cancel();
|
||||
cancel();
|
||||
} catch (err) {
|
||||
if (!handleQuotaError(err)) throw err;
|
||||
}
|
||||
},
|
||||
[networkId, userId, createParticle, createChildParticle, cancel],
|
||||
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError],
|
||||
);
|
||||
|
||||
// --- Keyboard handling ---
|
||||
@@ -397,6 +425,13 @@ export function ComposeOverlay({
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (quotaExhaustedRef.current) {
|
||||
if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T" || e.key === "s" || e.key === "S") {
|
||||
e.preventDefault();
|
||||
toast.info("Daily message limit reached. Upgrade to Pro to keep sending.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (e.key === "`" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
recordStartRef.current = Date.now();
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNetworkUsage } from "@/hooks/use-network-usage";
|
||||
import { useIsNetworkAdmin, useNetwork } from "@/hooks/use-networks";
|
||||
|
||||
interface ComposeQuotaIndicatorProps {
|
||||
networkId: string;
|
||||
}
|
||||
|
||||
const SHOW_PROGRESS_AT_FRACTION = 0.7;
|
||||
|
||||
/**
|
||||
* Surfaces freemium quota state near compose:
|
||||
* - Nothing below 70% used (avoid nagging).
|
||||
* - A subtle progress pill between 70% and the limit.
|
||||
* - A locked banner with an upgrade CTA once the limit is hit.
|
||||
*
|
||||
* Pro networks and any network still loading usage render nothing.
|
||||
*/
|
||||
export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data: usage } = useNetworkUsage(networkId);
|
||||
const isAdmin = useIsNetworkAdmin(networkId);
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
if (!usage || usage.limit == null) return null;
|
||||
|
||||
const fraction = usage.used / usage.limit;
|
||||
const exhausted = usage.used >= usage.limit;
|
||||
|
||||
if (exhausted) {
|
||||
return (
|
||||
<div className="pointer-events-auto flex max-w-md flex-col items-center gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 text-center shadow-lg backdrop-blur">
|
||||
<div className="text-sm font-medium">
|
||||
{isAdmin
|
||||
? `You've reached today's ${usage.limit}-message limit`
|
||||
: `This network reached today's ${usage.limit}-message limit`}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Resets {formatResetRelative(usage.reset_at)} ({formatResetAbsolute(usage.reset_at)})
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => navigate(`/${networkId}/settings?section=billing`)}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Ask{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{network?.admin_human.email_prefix ?? "your admin"}
|
||||
</span>{" "}
|
||||
to upgrade to Pro
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fraction < SHOW_PROGRESS_AT_FRACTION) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-3 rounded-full border border-border bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur"
|
||||
title={`Resets ${formatResetRelative(usage.reset_at)} at ${formatResetAbsolute(usage.reset_at)}`}
|
||||
>
|
||||
<span className="tabular-nums">
|
||||
{usage.used}/{usage.limit} today
|
||||
</span>
|
||||
<Progress value={fraction * 100} className="h-1 w-24" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatResetRelative(resetAt: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = resetAt.getTime() - now.getTime();
|
||||
const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000)));
|
||||
if (hours < 1) return "soon";
|
||||
if (hours === 1) return "in 1 hour";
|
||||
return `in ${hours} hours`;
|
||||
}
|
||||
|
||||
function formatResetAbsolute(resetAt: Date): string {
|
||||
// Shows the user their local wall-clock time for the UTC-midnight reset,
|
||||
// so a user in UTC-8 sees "4:00 PM" instead of a relative hint alone.
|
||||
return resetAt.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Muted } from "@/components/ui/typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useCreateCheckoutSession,
|
||||
useCreatePortalSession,
|
||||
useNetworkBilling,
|
||||
} from "@/hooks/use-billing";
|
||||
import { useNetworkUsage } from "@/hooks/use-network-usage";
|
||||
import { useIsNetworkAdmin } from "@/hooks/use-networks";
|
||||
import type { BillingCadence, BillingStatus } from "@/api/types";
|
||||
|
||||
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 PlanStatusBadge({ status }: { status: BillingStatus["plan_status"] }) {
|
||||
if (status === "past_due")
|
||||
return <Badge variant="destructive">Past due</Badge>;
|
||||
if (status === "canceled") return <Badge variant="secondary">Canceled</Badge>;
|
||||
if (status === "trialing") return <Badge variant="secondary">Trialing</Badge>;
|
||||
return null;
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||
<Muted className="text-sm">{label}</Muted>
|
||||
<div className="flex-1" />
|
||||
<div className="text-sm">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CadenceOption({
|
||||
value,
|
||||
label,
|
||||
perSeatCents,
|
||||
billedNote,
|
||||
saveBadge,
|
||||
selected,
|
||||
}: {
|
||||
value: BillingCadence;
|
||||
label: string;
|
||||
perSeatCents: number;
|
||||
billedNote: string;
|
||||
saveBadge?: string;
|
||||
selected: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Label
|
||||
htmlFor={`cadence-${value}`}
|
||||
className={cn(
|
||||
"hover:bg-accent flex w-full cursor-pointer items-center gap-3 px-4 py-3 font-normal transition-colors",
|
||||
selected && "bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem id={`cadence-${value}`} value={value} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{saveBadge && <Badge>{saveBadge}</Badge>}
|
||||
</div>
|
||||
<Muted className="text-xs">{billedNote}</Muted>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-sm font-medium">{formatCents(perSeatCents)}</p>
|
||||
<Muted className="text-xs">per seat / mo</Muted>
|
||||
</div>
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
function formatResetLocal(resetAt: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = resetAt.getTime() - now.getTime();
|
||||
const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000)));
|
||||
const absolute = resetAt.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
if (hours < 1) return `soon (${absolute})`;
|
||||
if (hours === 1) return `in 1 hour (${absolute})`;
|
||||
return `in ${hours} hours (${absolute})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only plan + quota summary, sourced from `/usage` (member-accessible).
|
||||
* The `/billing` endpoint is admin-gated, so we can't use it for the
|
||||
* everyone-visible summary.
|
||||
*/
|
||||
function PlanSummary({ networkId }: { networkId: string }) {
|
||||
const { data: usage } = useNetworkUsage(networkId);
|
||||
|
||||
if (!usage) return null;
|
||||
|
||||
const isPro = usage.plan === "pro";
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Plan"
|
||||
value={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{isPro ? "Llink Pro" : "Llink Free"}</span>
|
||||
<Badge variant={isPro ? "default" : "secondary"}>
|
||||
{isPro ? "Pro" : "Free"}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{!isPro && (
|
||||
<>
|
||||
<Separator className="mx-4" />
|
||||
<InfoRow
|
||||
label="Today's messages"
|
||||
value={
|
||||
usage && usage.limit != null ? (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="tabular-nums">
|
||||
{usage.used} / {usage.limit}
|
||||
</span>
|
||||
<Muted className="text-xs">
|
||||
Resets {formatResetLocal(usage.reset_at)}
|
||||
</Muted>
|
||||
</div>
|
||||
) : (
|
||||
<Muted className="text-sm">—</Muted>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FreeBilling({
|
||||
networkId,
|
||||
billing,
|
||||
}: {
|
||||
networkId: string;
|
||||
billing: BillingStatus;
|
||||
}) {
|
||||
const createCheckout = useCreateCheckoutSession(networkId);
|
||||
const [cadence, setCadence] = useState<BillingCadence>("annual");
|
||||
|
||||
const handleUpgrade = () => {
|
||||
createCheckout.mutate(cadence, {
|
||||
onSuccess: ({ url }) => window.electronLink.openExternal(url),
|
||||
onError: (err) => toast.error(err.message || "Failed to start checkout"),
|
||||
});
|
||||
};
|
||||
|
||||
const annualPerSeatMonthlyCents = Math.round(billing.price_annual_cents / 12);
|
||||
const savingsPct = Math.round(
|
||||
(1 - annualPerSeatMonthlyCents / billing.price_monthly_cents) * 100,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RadioGroup
|
||||
value={cadence}
|
||||
onValueChange={(v) => setCadence(v as BillingCadence)}
|
||||
className="gap-0"
|
||||
>
|
||||
<CadenceOption
|
||||
value="annual"
|
||||
label="Annual"
|
||||
perSeatCents={annualPerSeatMonthlyCents}
|
||||
billedNote="Billed annually"
|
||||
saveBadge={savingsPct > 0 ? `Save ${savingsPct}%` : undefined}
|
||||
selected={cadence === "annual"}
|
||||
/>
|
||||
<Separator className="mx-4" />
|
||||
<CadenceOption
|
||||
value="monthly"
|
||||
label="Monthly"
|
||||
perSeatCents={billing.price_monthly_cents}
|
||||
billedNote="Billed monthly · cancel anytime"
|
||||
selected={cadence === "monthly"}
|
||||
/>
|
||||
</RadioGroup>
|
||||
<div className="px-4 py-3">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleUpgrade}
|
||||
disabled={createCheckout.isPending}
|
||||
>
|
||||
{createCheckout.isPending ? "Opening Stripe..." : "Upgrade to Pro"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProBilling({
|
||||
networkId,
|
||||
billing,
|
||||
}: {
|
||||
networkId: string;
|
||||
billing: BillingStatus;
|
||||
}) {
|
||||
const createPortal = useCreatePortalSession(networkId);
|
||||
|
||||
const handleManage = () => {
|
||||
createPortal.mutate(undefined, {
|
||||
onSuccess: ({ url }) => window.electronLink.openExternal(url),
|
||||
onError: (err) =>
|
||||
toast.error(err.message || "Failed to open billing portal"),
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<>
|
||||
{billing.cancel_at_period_end && renewal && (
|
||||
<div className="border-destructive/30 bg-destructive/10 text-destructive mx-4 my-2 rounded-md border px-3 py-2 text-sm">
|
||||
Your subscription is set to downgrade to Free on {renewal}.
|
||||
</div>
|
||||
)}
|
||||
{billing.plan_status === "past_due" && (
|
||||
<div className="border-destructive/30 bg-destructive/10 text-destructive mx-4 my-2 rounded-md border px-3 py-2 text-sm">
|
||||
Your last payment failed. Update your payment method to keep Pro
|
||||
active.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfoRow
|
||||
label="Billing"
|
||||
value={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}</span>
|
||||
<PlanStatusBadge status={billing.plan_status} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Separator className="mx-4" />
|
||||
<InfoRow label="Seats" value={billing.seats} />
|
||||
{renewal && (
|
||||
<>
|
||||
<Separator className="mx-4" />
|
||||
<InfoRow
|
||||
label={billing.cancel_at_period_end ? "Ends" : "Renews"}
|
||||
value={renewal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="px-4 py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleManage}
|
||||
disabled={createPortal.isPending}
|
||||
>
|
||||
<ExternalLink className="mr-2 size-3.5" />
|
||||
{createPortal.isPending
|
||||
? "Opening Stripe..."
|
||||
: "Manage subscription"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified billing section. Shows the plan + usage summary to every member,
|
||||
* and the admin-only management controls (upgrade / portal) below.
|
||||
*
|
||||
* `/billing` is admin-gated, so the management controls are the only part
|
||||
* that depends on it — members rely on `/usage` for the summary.
|
||||
*/
|
||||
export function BillingSection({ networkId }: { networkId: string }) {
|
||||
const isAdmin = useIsNetworkAdmin(networkId);
|
||||
return (
|
||||
<>
|
||||
<PlanSummary networkId={networkId} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator className="mx-4" />
|
||||
<AdminBillingControls networkId={networkId} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminBillingControls({ networkId }: { networkId: string }) {
|
||||
const { data: billing, isLoading, error } = useNetworkBilling(networkId);
|
||||
|
||||
if (isLoading || !billing) {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<Muted className="text-sm">Loading billing...</Muted>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<Muted className="text-sm">Failed to load billing.</Muted>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (billing.plan === "pro") {
|
||||
return <ProBilling networkId={networkId} billing={billing} />;
|
||||
}
|
||||
return <FreeBilling networkId={networkId} billing={billing} />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { particlePath } from "@/lib/particle-path";
|
||||
import { ParticleListView } from "@/features/particles/particle-list-view";
|
||||
import { VideoAudioToggle } from "@/components/video-audio-toggle";
|
||||
import { ComposeOverlay } from "./compose/compose-overlay";
|
||||
import { ComposeQuotaIndicator } from "./compose/compose-quota-indicator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
||||
import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
|
||||
@@ -50,7 +51,7 @@ export default function NetworkRoot() {
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{/* Top bar — stays in place */}
|
||||
<div className="flex shrink-0 items-center justify-between p-1 border-b">
|
||||
<div className="flex shrink-0 items-center p-1 border-b">
|
||||
<Tabs
|
||||
value={statusTab}
|
||||
onValueChange={(v) => setStatusTab(v === "closed" ? "closed" : "open")}
|
||||
@@ -75,6 +76,11 @@ export default function NetworkRoot() {
|
||||
</div>
|
||||
|
||||
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
||||
{!composeActive && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
|
||||
<ComposeQuotaIndicator networkId={networkId!} />
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
||||
<div className="pointer-events-auto">
|
||||
<NetworkRootControls />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Mail, Shield, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -17,15 +17,10 @@ import {
|
||||
useRevokeInvitation,
|
||||
} from "@/hooks/use-invitations";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { BillingSection } from "@/features/network-billing";
|
||||
import type { Human } from "@/api/types";
|
||||
|
||||
function MemberRow({
|
||||
human,
|
||||
isAdmin,
|
||||
}: {
|
||||
human: Human;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
function MemberRow({ human, isAdmin }: { human: Human; isAdmin: boolean }) {
|
||||
const initials = human.email_prefix.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
@@ -73,7 +68,7 @@ function InviteForm({ networkId }: { networkId: string }) {
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2 px-4 py-3">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
placeholder="[email protected]"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="flex-1"
|
||||
@@ -111,7 +106,7 @@ function PendingInvitationRow({
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||
<span className="text-muted-foreground flex size-10 items-center justify-center">
|
||||
<span className="text-muted-foreground flex size-8 items-center justify-center">
|
||||
<Mail className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -131,33 +126,63 @@ function PendingInvitationRow({
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsGroup({
|
||||
function SectionHeader({
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
description,
|
||||
trailing,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
description?: string;
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-muted-foreground px-4 pb-1 pt-4 text-xs font-medium uppercase tracking-wider">
|
||||
{title}
|
||||
</p>
|
||||
<div>{children}</div>
|
||||
<div className="flex items-start gap-3 px-4 pb-2 pt-6">
|
||||
<span className="text-muted-foreground mt-0.5 flex size-4 items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
|
||||
{trailing}
|
||||
</div>
|
||||
{description && <Muted className="text-xs">{description}</Muted>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="bg-card/40 mx-4 mb-2 overflow-hidden rounded-lg border">
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkSettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { networkId } = useParams<{ networkId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: networks } = useNetworks();
|
||||
const network = networks?.find((n) => n.id === networkId);
|
||||
const { data: invitations } = useNetworkInvitations(networkId!);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const isAdmin = currentUser?.id === network?.admin_human.id;
|
||||
|
||||
const billingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("section") === "billing") {
|
||||
billingRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const networkName = network?.name ?? "Network";
|
||||
const memberCount = network?.humans.length ?? 0;
|
||||
const pendingCount = invitations?.length ?? 0;
|
||||
const networkInitials = networkName.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
@@ -171,12 +196,38 @@ export default function NetworkSettingsPage() {
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium">{networkName}</span>
|
||||
<span className="text-sm font-medium">Settings</span>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<SettingsGroup title="Members">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex items-center gap-3 px-4 pb-4 pt-6">
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{networkInitials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-base font-semibold">{networkName}</p>
|
||||
<Muted className="text-xs">
|
||||
{memberCount} {memberCount === 1 ? "member" : "members"}
|
||||
{isAdmin ? " · You're an admin" : ""}
|
||||
</Muted>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Section>
|
||||
<SectionHeader
|
||||
icon={<Users className="size-4" />}
|
||||
title="Members"
|
||||
description="People with access to this network."
|
||||
trailing={
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{memberCount}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
{network?.humans.map((human, index) => (
|
||||
<div key={human.id}>
|
||||
<MemberRow
|
||||
@@ -188,39 +239,65 @@ export default function NetworkSettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
|
||||
<Separator className="mt-4" />
|
||||
</Section>
|
||||
|
||||
{isAdmin && network && (
|
||||
<>
|
||||
<SettingsGroup title="Invite">
|
||||
<InviteForm networkId={networkId!} />
|
||||
</SettingsGroup>
|
||||
|
||||
<Separator className="mt-4" />
|
||||
|
||||
<SettingsGroup title="Pending Invitations">
|
||||
{invitations && invitations.length > 0 ? (
|
||||
invitations.map((inv, index) => (
|
||||
<Section>
|
||||
<SectionHeader
|
||||
icon={<Mail className="size-4" />}
|
||||
title="Invitations"
|
||||
description="Invite teammates by email. They'll get a link to join."
|
||||
trailing={
|
||||
pendingCount > 0 ? (
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{pendingCount} pending
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
<InviteForm networkId={networkId!} />
|
||||
{pendingCount > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="px-4 pb-1 pt-3">
|
||||
<Muted className="text-xs font-medium uppercase tracking-wider">
|
||||
Pending
|
||||
</Muted>
|
||||
</div>
|
||||
{invitations!.map((inv, index) => (
|
||||
<div key={inv.email}>
|
||||
<PendingInvitationRow
|
||||
email={inv.email}
|
||||
networkId={networkId!}
|
||||
/>
|
||||
{index < invitations.length - 1 && (
|
||||
{index < invitations!.length - 1 && (
|
||||
<Separator className="mx-4" />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-muted-foreground px-4 py-3 text-sm">
|
||||
No pending invitations
|
||||
</p>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<div ref={billingRef}>
|
||||
<Section>
|
||||
<SectionHeader
|
||||
icon={<CreditCard className="size-4" />}
|
||||
title="Billing"
|
||||
description={
|
||||
isAdmin
|
||||
? "Manage your plan, seats, and payment."
|
||||
: "Your network's current plan and usage."
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
<BillingSection networkId={networkId!} />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,8 +28,38 @@ import { isParticleDeleted, type Particle, type StreamProperties } from "@/api/t
|
||||
import type { StreamParticle } from "@/hooks/use-stream-particles";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
|
||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||
import { StreamContextMenu } from "@/features/particles/stream-context-menu";
|
||||
|
||||
function VideoThumbnail({
|
||||
objectId,
|
||||
isUnseen,
|
||||
}: {
|
||||
objectId: string;
|
||||
isUnseen: boolean;
|
||||
}) {
|
||||
const { data: url } = useDownloadUrl(objectId);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"size-8 shrink-0 overflow-hidden rounded-md bg-muted",
|
||||
isUnseen && "ring-2 ring-primary",
|
||||
)}
|
||||
>
|
||||
{url && (
|
||||
<video
|
||||
// Seek ~15 frames in so we skip any initial black/fade-in frames
|
||||
src={`${url}#t=0.5`}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getParticleTypeIcon(particle: Particle): LucideIcon {
|
||||
if (isParticleDeleted(particle)) return Trash2;
|
||||
switch (particle.type) {
|
||||
@@ -156,6 +186,14 @@ const StreamRow = memo(function StreamRow({
|
||||
|
||||
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
|
||||
|
||||
const videoThumbObjectId =
|
||||
latestChild &&
|
||||
!isParticleDeleted(latestChild) &&
|
||||
latestChild.type === "media" &&
|
||||
latestChild.properties.mime_type.startsWith("video/")
|
||||
? latestChild.properties.object_id
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
@@ -173,13 +211,15 @@ const StreamRow = memo(function StreamRow({
|
||||
{shortcutKey}
|
||||
</kbd>
|
||||
)}
|
||||
<Avatar
|
||||
className={cn(isUnseen && "ring-2 ring-primary")}
|
||||
>
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{videoThumbObjectId ? (
|
||||
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
|
||||
) : (
|
||||
<Avatar className={cn(isUnseen && "ring-2 ring-primary")}>
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p
|
||||
|
||||
@@ -237,7 +237,7 @@ export default function AudioVideoSettingsPage() {
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-5 px-5 py-5">
|
||||
{!permissionGranted && (
|
||||
<div className="bg-muted/40 flex items-start justify-between gap-3 rounded-md border px-3 py-2.5">
|
||||
|
||||
Reference in New Issue
Block a user