diff --git a/apps/web/app/(org)/dashboard/Contexts.tsx b/apps/web/app/(org)/dashboard/Contexts.tsx
index 40f5ac3b06a..fe9cfc25f47 100644
--- a/apps/web/app/(org)/dashboard/Contexts.tsx
+++ b/apps/web/app/(org)/dashboard/Contexts.tsx
@@ -34,6 +34,7 @@ export function DashboardContexts({
initialTheme,
initialSidebarCollapsed,
referClicked,
+ shareableLinkUsage,
}: {
children: React.ReactNode;
organizationData: SharedContext["organizationData"];
@@ -46,6 +47,7 @@ export function DashboardContexts({
initialTheme: ITheme;
initialSidebarCollapsed: boolean;
referClicked: boolean;
+ shareableLinkUsage: SharedContext["shareableLinkUsage"];
}) {
const user = useCurrentUser();
if (!user) redirect("/login");
@@ -164,6 +166,7 @@ export function DashboardContexts({
isDeveloperSection,
developerApps,
setDeveloperApps,
+ shareableLinkUsage,
}}
>
{children}
diff --git a/apps/web/app/(org)/dashboard/DashboardContext.ts b/apps/web/app/(org)/dashboard/DashboardContext.ts
index b4c0e6e7a33..489f5938c0b 100644
--- a/apps/web/app/(org)/dashboard/DashboardContext.ts
+++ b/apps/web/app/(org)/dashboard/DashboardContext.ts
@@ -40,6 +40,7 @@ export type SharedContext = {
isDeveloperSection: boolean;
developerApps: DeveloperApp[] | null;
setDeveloperApps: (apps: DeveloperApp[] | null) => void;
+ shareableLinkUsage: { used: number; limit: number } | null;
};
export type ITheme = "light" | "dark";
diff --git a/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx b/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx
index 5da3eb1e288..e702bb341a0 100644
--- a/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx
+++ b/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx
@@ -240,7 +240,7 @@ export function AnalyticsDashboard() {
- You can cancel anytime. Early adopter pricing locked in.
+ You can cancel anytime.
diff --git a/apps/web/app/(org)/dashboard/layout.tsx b/apps/web/app/(org)/dashboard/layout.tsx
index dde0a568be2..bd9ceb66594 100644
--- a/apps/web/app/(org)/dashboard/layout.tsx
+++ b/apps/web/app/(org)/dashboard/layout.tsx
@@ -1,12 +1,14 @@
import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { organizationInvites } from "@cap/database/schema";
+import { userIsPro } from "@cap/utils";
import { and, eq } from "drizzle-orm";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { AuthContextProvider } from "@/app/Layout/AuthContext";
import { resolveCurrentUser } from "@/app/Layout/current-user";
import { runPromise } from "@/lib/server";
+import { getShareableLinkUsage } from "@/lib/shareable-link-quota";
import DashboardInner from "./_components/DashboardInner";
import { DashboardPasteImport } from "./_components/DashboardPasteImport";
import MobileTab from "./_components/MobileTab";
@@ -78,6 +80,14 @@ export default async function DashboardLayout({
userPreferences = null;
}
+ // Fail-open: the meter is informational and must never take the shell down.
+ const shareableLinkUsage = userIsPro(user)
+ ? null
+ : await getShareableLinkUsage(user.id).catch((error) => {
+ console.error("Failed to load shareable link usage", error);
+ return null;
+ });
+
let activeOrganization = organizationSelect.find(
(organization) =>
organization.organization.id === user.activeOrganizationId,
@@ -105,6 +115,7 @@ export default async function DashboardLayout({
anyNewNotifications={anyNewNotifications}
userPreferences={userPreferences}
referClicked={referClicked === "true"}
+ shareableLinkUsage={shareableLinkUsage}
>
diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/CustomDomainDialog.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/CustomDomainDialog.tsx
index 0bd00bb3377..cc29a590e74 100644
--- a/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/CustomDomainDialog.tsx
+++ b/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/CustomDomainDialog.tsx
@@ -466,7 +466,7 @@ const CustomDomainDialog = ({
handleClose();
}}
>
- Upgrade To Cap Pro
+ Upgrade to Cap Pro
))}
diff --git a/apps/web/app/api/settings/billing/usage/route.ts b/apps/web/app/api/settings/billing/usage/route.ts
index b402b4f032d..3866373eb99 100644
--- a/apps/web/app/api/settings/billing/usage/route.ts
+++ b/apps/web/app/api/settings/billing/usage/route.ts
@@ -1,8 +1,6 @@
-import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
-import { videos } from "@cap/database/schema";
import { userIsPro } from "@cap/utils";
-import { count, eq } from "drizzle-orm";
+import { getShareableLinkUsage } from "@/lib/shareable-link-quota";
export const dynamic = "force-dynamic";
@@ -13,35 +11,25 @@ export async function GET() {
return Response.json({ auth: false }, { status: 401 });
}
- const numberOfVideos = await db()
- .select({ count: count() })
- .from(videos)
- .where(eq(videos.ownerId, user.id));
-
- if (!numberOfVideos[0]) {
- return Response.json(
- { error: "Could not fetch video count" },
- { status: 500 },
- );
- }
+ const usage = await getShareableLinkUsage(user.id);
if (userIsPro(user)) {
return Response.json(
{
subscription: true,
videoLimit: 0,
- videoCount: numberOfVideos[0].count,
- },
- { status: 200 },
- );
- } else {
- return Response.json(
- {
- subscription: false,
- videoLimit: 25,
- videoCount: numberOfVideos[0].count,
+ videoCount: usage.used,
},
{ status: 200 },
);
}
+
+ return Response.json(
+ {
+ subscription: false,
+ videoLimit: usage.limit,
+ videoCount: usage.used,
+ },
+ { status: 200 },
+ );
}
diff --git a/apps/web/app/embed/[videoId]/page.tsx b/apps/web/app/embed/[videoId]/page.tsx
index 28eb5817209..39370252f8a 100644
--- a/apps/web/app/embed/[videoId]/page.tsx
+++ b/apps/web/app/embed/[videoId]/page.tsx
@@ -12,13 +12,15 @@ import {
} from "@cap/database/schema";
import type { VideoMetadata } from "@cap/database/types";
import { buildEnv } from "@cap/env";
+import { Logo } from "@cap/ui";
+import { userIsPro } from "@cap/utils";
import {
provideOptionalAuth,
resolveEffectiveVideoRules,
Videos,
VideosPolicy,
} from "@cap/web-backend";
-import { type Organisation, Policy, type Video } from "@cap/web-domain";
+import { type Organisation, Policy, Video } from "@cap/web-domain";
import { and, eq, isNull, sql } from "drizzle-orm";
import { Effect, Option } from "effect";
import type { Metadata } from "next";
@@ -26,6 +28,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import * as EffectRuntime from "@/lib/server";
import { buildShareVideoMetadata } from "@/lib/share-video-metadata";
+import { isVideoOverShareableLinkLimit } from "@/lib/shareable-link-quota";
import { transcribeVideo } from "@/lib/transcribe";
import { isAiGenerationEnabled } from "@/utils/flags";
import { EmbedVideo } from "./_components/EmbedVideo";
@@ -225,6 +228,7 @@ async function EmbedContent({
});
let aiGenerationEnabled = false;
+ let ownerIsProUser = false;
const videoOwnerQuery = await db()
.select({
email: users.email,
@@ -238,6 +242,7 @@ async function EmbedContent({
if (videoOwnerQuery.length > 0 && videoOwnerQuery[0]) {
const videoOwner = videoOwnerQuery[0];
aiGenerationEnabled = await isAiGenerationEnabled(videoOwner);
+ ownerIsProUser = userIsPro(videoOwner);
}
if (
@@ -275,6 +280,45 @@ async function EmbedContent({
);
}
+ // Same quota gate as the share page, so embeds are not a loophole around
+ // it. Fail-open: a broken count must never take the embed down.
+ const overShareLimit =
+ !ownerIsProUser &&
+ (await isVideoOverShareableLinkLimit({
+ id: video.id,
+ ownerId: video.ownerId,
+ createdAt: video.createdAt,
+ isScreenshot: video.isScreenshot,
+ }).catch((error) => {
+ console.error(
+ `[EmbedVideoPage] Shareable link quota check failed for ${video.id}:`,
+ error,
+ );
+ return false;
+ }));
+
+ if (overShareLimit) {
+ return (
+
+
+
+ This video is over its free limit
+
+
+ {`The owner of this video has used all ${Video.FREE_PLAN_SHAREABLE_LINKS_PER_MONTH} shareable links included with Cap's free plan this month. As soon as they upgrade to Cap Pro, this video will be instantly viewable.`}
+
+
+ Open on Cap
+
+
+ );
+ }
+
const commentsQuery = await db()
.select({
id: comments.id,
diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx
index 639eb159b61..eeca8be43ef 100644
--- a/apps/web/app/s/[videoId]/Share.tsx
+++ b/apps/web/app/s/[videoId]/Share.tsx
@@ -598,8 +598,12 @@ export const Share = ({
const [selectedView, setSelectedView] = useState
(initialView);
// Screenshots have no timeline to show, and a comments-disabled video has
// nothing to branch — both stay on the classic layout even if someone
- // hand-writes `?view=timeline`.
- const timelineAvailable = !isScreenshot && !areCommentStampsDisabled;
+ // hand-writes `?view=timeline`. Over-quota videos stay classic too: the
+ // timeline deck's filmstrip would leak frames below the upgrade gate.
+ const timelineAvailable =
+ !isScreenshot &&
+ !areCommentStampsDisabled &&
+ data.ownerIsOverShareLimit !== true;
// Where the timeline's filmstrip frames come from. Mirrors the source split
// in `ShareVideo`: instant recordings have a result.mp4 a bare
)}
diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx
index cc20e226447..745ce641eb0 100644
--- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx
+++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx
@@ -28,6 +28,7 @@ import {
PreparingVideoOverlay,
RecordingInProgressOverlay,
} from "./RecordingInProgress";
+import { ShareableLinkLimitOverlay } from "./ShareableLinkLimitOverlay";
import {
shouldDeferPlaybackSource,
shouldReloadPlaybackAfterUploadCompletes,
@@ -96,6 +97,7 @@ export const ShareVideo = forwardRef<
isEditProcessing: boolean;
recordingStopped?: boolean;
defaultPlaybackSpeed?: number;
+ viewerIsOwner?: boolean;
}
>(
(
@@ -115,6 +117,7 @@ export const ShareVideo = forwardRef<
isEditProcessing,
recordingStopped = false,
defaultPlaybackSpeed,
+ viewerIsOwner = false,
},
ref,
) => {
@@ -324,6 +327,7 @@ export const ShareVideo = forwardRef<
const isMp4Source =
data.source.type === "desktopMP4" || data.source.type === "webMP4";
const isSegmentsSource = data.source.type === "desktopSegments";
+ const isOverShareLimit = data.ownerIsOverShareLimit === true;
const previousSegmentUploadProgressRef = useRef(segmentUploadProgress);
const isActivelyRecording =
isSegmentsSource &&
@@ -541,6 +545,19 @@ export const ShareVideo = forwardRef<
) : isProcessingInProgress ? (