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:
@@ -2,18 +2,23 @@ import { appConfig } from "@/config/env";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
BillingStatusSchema,
|
||||
CheckoutSessionResponseSchema,
|
||||
DepotObjectSchema,
|
||||
GetLivekitTokenResponseSchema,
|
||||
HumanSchema,
|
||||
ListInvitationsResponseSchema,
|
||||
ListNetworksResponseSchema,
|
||||
NetworkSchema,
|
||||
NetworkUsageSchema,
|
||||
PortalSessionResponseSchema,
|
||||
PrepareUploadResponseSchema,
|
||||
SignInResponseSchema,
|
||||
} from "./types";
|
||||
import type {
|
||||
AcceptInvitationRequest,
|
||||
AddMembersRequest,
|
||||
BillingCadence,
|
||||
CreateNetworkRequest,
|
||||
PrepareUploadRequest,
|
||||
RequestCodeRequest,
|
||||
@@ -216,6 +221,41 @@ class ApiClient {
|
||||
async getLivekitToken(networkId: string, streamId: string) {
|
||||
return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId });
|
||||
}
|
||||
|
||||
// --- Billing (network admin only) ---
|
||||
|
||||
async getNetworkBilling(networkId: string) {
|
||||
return this.request(
|
||||
BillingStatusSchema,
|
||||
"GET",
|
||||
`/networks/${networkId}/billing`,
|
||||
);
|
||||
}
|
||||
|
||||
async createCheckoutSession(networkId: string, cadence: BillingCadence) {
|
||||
return this.request(
|
||||
CheckoutSessionResponseSchema,
|
||||
"POST",
|
||||
`/networks/${networkId}/billing/checkout-session`,
|
||||
{ cadence },
|
||||
);
|
||||
}
|
||||
|
||||
async createPortalSession(networkId: string) {
|
||||
return this.request(
|
||||
PortalSessionResponseSchema,
|
||||
"POST",
|
||||
`/networks/${networkId}/billing/portal-session`,
|
||||
);
|
||||
}
|
||||
|
||||
async getNetworkUsage(networkId: string) {
|
||||
return this.request(
|
||||
NetworkUsageSchema,
|
||||
"GET",
|
||||
`/networks/${networkId}/usage`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient({
|
||||
|
||||
@@ -262,3 +262,53 @@ export const SignInResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
|
||||
|
||||
// --- Billing types ---
|
||||
|
||||
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
|
||||
export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
|
||||
|
||||
export const NetworkPlanSchema = z.enum(["free", "pro"]);
|
||||
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
|
||||
|
||||
// Mirrors Stripe subscription.status plus "active" as the default free-tier value.
|
||||
export const BillingPlanStatusSchema = z.enum([
|
||||
"active",
|
||||
"trialing",
|
||||
"past_due",
|
||||
"canceled",
|
||||
"incomplete",
|
||||
"incomplete_expired",
|
||||
"unpaid",
|
||||
]);
|
||||
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
|
||||
|
||||
export const BillingStatusSchema = z.object({
|
||||
plan: NetworkPlanSchema,
|
||||
plan_status: BillingPlanStatusSchema,
|
||||
cadence: BillingCadenceSchema.nullable(),
|
||||
seats: z.number().int(),
|
||||
current_period_end: z.coerce.date().nullable(),
|
||||
cancel_at_period_end: z.boolean(),
|
||||
price_monthly_cents: z.number().int(),
|
||||
price_annual_cents: z.number().int(),
|
||||
});
|
||||
export type BillingStatus = z.infer<typeof BillingStatusSchema>;
|
||||
|
||||
export const CheckoutSessionResponseSchema = z.object({
|
||||
url: z.string().url(),
|
||||
});
|
||||
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>;
|
||||
|
||||
export const PortalSessionResponseSchema = z.object({
|
||||
url: z.string().url(),
|
||||
});
|
||||
export type PortalSessionResponse = z.infer<typeof PortalSessionResponseSchema>;
|
||||
|
||||
export const NetworkUsageSchema = z.object({
|
||||
plan: NetworkPlanSchema,
|
||||
used: z.number().int().nonnegative(),
|
||||
limit: z.number().int().nonnegative().nullable(),
|
||||
reset_at: z.coerce.date(),
|
||||
});
|
||||
export type NetworkUsage = z.infer<typeof NetworkUsageSchema>;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid w-full gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="flex size-4 items-center justify-center"
|
||||
>
|
||||
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/api/client";
|
||||
import type { BillingCadence } from "@/api/types";
|
||||
|
||||
export function useNetworkBilling(networkId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["network-billing", networkId],
|
||||
queryFn: () => apiClient.getNetworkBilling(networkId!),
|
||||
enabled: !!networkId,
|
||||
// Refetch on window focus so the UI catches up after the user returns
|
||||
// from Stripe Checkout (webhook may land a second or two later).
|
||||
// FIX: doesn't work with electron
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 10000
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCheckoutSession(networkId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (cadence: BillingCadence) =>
|
||||
apiClient.createCheckoutSession(networkId, cadence),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePortalSession(networkId: string) {
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.createPortalSession(networkId),
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,26 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createParticle, createStreamParticle } from "@/lib/firestore-particles";
|
||||
import type { ParticleType, ParticlePropertiesMap } from "@/api/types";
|
||||
import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types";
|
||||
import { parseParticlePath, particlePath, ParticlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
|
||||
import {
|
||||
isUsageExhausted,
|
||||
networkUsageQueryKey,
|
||||
useBumpNetworkUsage,
|
||||
useInvalidateNetworkUsage,
|
||||
} from "./use-network-usage";
|
||||
|
||||
/**
|
||||
* Thrown when a free-plan network attempts to create a non-container particle
|
||||
* after hitting its daily message limit. Callers should surface an upgrade
|
||||
* prompt; compose UI should also disable triggers proactively via
|
||||
* `useNetworkUsage` rather than relying on this throw.
|
||||
*/
|
||||
export class QuotaExceededError extends Error {
|
||||
constructor(public readonly networkId: string) {
|
||||
super("Daily message limit reached");
|
||||
this.name = "QuotaExceededError";
|
||||
}
|
||||
}
|
||||
|
||||
interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
||||
// Path to which the new particle will be added as a child
|
||||
@@ -12,16 +31,37 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
||||
}
|
||||
|
||||
export function useCreateParticle() {
|
||||
const qc = useQueryClient();
|
||||
const bumpUsage = useBumpNetworkUsage();
|
||||
const invalidateUsage = useInvalidateNetworkUsage();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (params: CreateParticleParams) => {
|
||||
const { networkId } = parseParticlePath(params.path);
|
||||
|
||||
// Containers aren't counted server-side, so we don't block them.
|
||||
if (!CONTAINER_TYPES.has(params.type)) {
|
||||
const cached = qc.getQueryData<NetworkUsage>(networkUsageQueryKey(networkId));
|
||||
if (isUsageExhausted(cached)) {
|
||||
throw new QuotaExceededError(networkId);
|
||||
}
|
||||
}
|
||||
|
||||
const collectionPath = toFirestoreChildrenPath(params.path);
|
||||
return await createParticle(
|
||||
const result = await createParticle(
|
||||
collectionPath,
|
||||
params.type,
|
||||
params.properties,
|
||||
params.createdByHumanId,
|
||||
);
|
||||
}
|
||||
|
||||
if (!CONTAINER_TYPES.has(params.type)) {
|
||||
bumpUsage(networkId);
|
||||
void invalidateUsage(networkId);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { apiClient } from "@/api/client";
|
||||
import type { NetworkUsage } from "@/api/types";
|
||||
|
||||
export const networkUsageQueryKey = (networkId: string | undefined) =>
|
||||
["network-usage", networkId] as const;
|
||||
|
||||
export function useNetworkUsage(networkId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: networkUsageQueryKey(networkId),
|
||||
queryFn: () => apiClient.getNetworkUsage(networkId!),
|
||||
enabled: !!networkId,
|
||||
// Refetch whenever a consumer mounts (billing settings, compose indicator)
|
||||
// so users land on fresh quota state without listener wiring.
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a callback that invalidates the usage query for a network.
|
||||
* Callers: own-send success path, inbound-particle listener.
|
||||
*/
|
||||
export function useInvalidateNetworkUsage() {
|
||||
const qc = useQueryClient();
|
||||
return useCallback(
|
||||
(networkId: string) =>
|
||||
qc.invalidateQueries({ queryKey: networkUsageQueryKey(networkId) }),
|
||||
[qc],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic bump of the cached `used` count. The worker-written truth is
|
||||
* reconciled on the next invalidation/refetch.
|
||||
*/
|
||||
export function useBumpNetworkUsage() {
|
||||
const qc = useQueryClient();
|
||||
return useCallback(
|
||||
(networkId: string) => {
|
||||
qc.setQueryData<NetworkUsage>(networkUsageQueryKey(networkId), (prev) =>
|
||||
prev ? { ...prev, used: prev.used + 1 } : prev,
|
||||
);
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the network is on the free plan and has exhausted today's quota.
|
||||
*/
|
||||
export function isUsageExhausted(usage: NetworkUsage | undefined): boolean {
|
||||
if (!usage) return false;
|
||||
if (usage.limit == null) return false;
|
||||
return usage.used >= usage.limit;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
export function useNetworks() {
|
||||
return useQuery({
|
||||
@@ -12,3 +13,10 @@ export function useNetwork(networkId: string) {
|
||||
const { data: networks } = useNetworks();
|
||||
return networks?.find((n) => n.id === networkId) || null;
|
||||
}
|
||||
|
||||
export function useIsNetworkAdmin(networkId: string): boolean {
|
||||
const network = useNetwork(networkId);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
if (!network || !userId) return false;
|
||||
return network.admin_human.id === userId;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, us
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||
import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
|
||||
|
||||
// --- Playback reducer (ID-based) ---
|
||||
|
||||
+11
-1
@@ -25,11 +25,21 @@ if (process.platform === 'darwin' && !app.isPackaged) {
|
||||
app.dock?.setIcon(path.join(__dirname, '../../assets/icon.png'));
|
||||
}
|
||||
|
||||
// In dev, `LLINK_PROFILE=foo yarn start` spins up a second instance with an
|
||||
// isolated userData dir so it can coexist with the default one (separate auth,
|
||||
// cookies, leveldb locks).
|
||||
const devProfile = !app.isPackaged ? process.env.LLINK_PROFILE : undefined;
|
||||
if (devProfile) {
|
||||
app.setPath('userData', `${app.getPath('userData')}-${devProfile}`);
|
||||
}
|
||||
|
||||
// Single-instance lock: on Windows/Linux, clicking a llink:// URL launches a new
|
||||
// process. The lock makes the losing instance quit and fires `second-instance` on
|
||||
// the primary, so we focus the existing window instead of spawning a duplicate.
|
||||
// macOS uses `open-url` instead and doesn't need this, but the lock is harmless.
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
// Skip the lock when running a named dev profile — those instances are meant to
|
||||
// run alongside the default one.
|
||||
if (!devProfile && !app.requestSingleInstanceLock()) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user