feat: implement freemium restrictions
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
ListInvitationsResponseSchema,
|
||||
ListNetworksResponseSchema,
|
||||
NetworkSchema,
|
||||
NetworkUsageSchema,
|
||||
PortalSessionResponseSchema,
|
||||
PrepareUploadResponseSchema,
|
||||
SignInResponseSchema,
|
||||
@@ -247,6 +248,14 @@ class ApiClient {
|
||||
`/networks/${networkId}/billing/portal-session`,
|
||||
);
|
||||
}
|
||||
|
||||
async getNetworkUsage(networkId: string) {
|
||||
return this.request(
|
||||
NetworkUsageSchema,
|
||||
"GET",
|
||||
`/networks/${networkId}/usage`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient({
|
||||
|
||||
@@ -304,3 +304,11 @@ 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>;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
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 {
|
||||
@@ -91,6 +93,70 @@ function CadenceOption({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -115,16 +181,6 @@ function FreeBilling({
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Plan"
|
||||
value={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Llink Free</span>
|
||||
<Badge variant="secondary">Free</Badge>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Separator className="mx-4" />
|
||||
<RadioGroup
|
||||
value={cadence}
|
||||
onValueChange={(v) => setCadence(v as BillingCadence)}
|
||||
@@ -201,21 +257,15 @@ function ProBilling({
|
||||
)}
|
||||
|
||||
<InfoRow
|
||||
label="Plan"
|
||||
label="Billing"
|
||||
value={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Llink Pro</span>
|
||||
<span>{`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}</span>
|
||||
<PlanStatusBadge status={billing.plan_status} />
|
||||
<Badge>Pro</Badge>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Separator className="mx-4" />
|
||||
<InfoRow
|
||||
label="Billing"
|
||||
value={`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}
|
||||
/>
|
||||
<Separator className="mx-4" />
|
||||
<InfoRow label="Seats" value={billing.seats} />
|
||||
{renewal && (
|
||||
<>
|
||||
@@ -243,7 +293,29 @@ function ProBilling({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
@@ -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";
|
||||
@@ -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 />
|
||||
|
||||
@@ -177,6 +177,12 @@ export default function NetworkSettingsPage() {
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<SettingsGroup title="Billing">
|
||||
<BillingSection networkId={networkId!} />
|
||||
</SettingsGroup>
|
||||
|
||||
<Separator className="mt-4" />
|
||||
|
||||
<SettingsGroup title="Members">
|
||||
{network?.humans.map((human, index) => (
|
||||
<div key={human.id}>
|
||||
@@ -220,12 +226,6 @@ export default function NetworkSettingsPage() {
|
||||
</p>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
<Separator className="mt-4" />
|
||||
|
||||
<SettingsGroup title="Billing">
|
||||
<BillingSection networkId={networkId!} />
|
||||
</SettingsGroup>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
Reference in New Issue
Block a user