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:
Arjun Patel
2026-04-14 15:18:32 -07:00
committed by GitHub
parent aff18d82db
commit 67826b92c0
44 changed files with 2197 additions and 153 deletions
+29
View File
@@ -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),
});
}
+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) ---