feat: implement freemium restrictions

This commit is contained in:
talksik
2026-04-14 14:42:53 -07:00
parent 1cd22cd4e2
commit 185d83401f
20 changed files with 636 additions and 46 deletions
+9
View File
@@ -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({
+8
View File
@@ -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>;
+51 -16
View File
@@ -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",
});
}
+90 -18
View File
@@ -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) {
+6
View File
@@ -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 />
+6 -6
View File
@@ -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>
+45 -5
View File
@@ -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;
},
});
}
+57
View File
@@ -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;
}
+8
View File
@@ -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;
}
+1 -1
View File
@@ -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) ---