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 ? ( + ) : isOverShareLimit ? ( + // Quota gate: the player is never mounted, so the video is not + // fetched or playable until the owner upgrades (server recomputes + // the flag on the next load). Recording/processing branches above + // keep priority so in-flight uploads always finalize. + { + void importUpgradeModal(); + }} + className="h-full" + /> ) : isMp4Source ? ( - {!data.owner.isPro && ( + {!data.owner.isPro && !isOverShareLimit && (
+ ) : ( + + )} +

+ {isOwner + ? "Videos recorded in Studio mode are saved to your device, free and unlimited." + : `Cap's free plan includes ${LIMIT} shareable links per month.`} +

+
+ + ); +} diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index f021913bbcb..6bf3f6982a3 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -57,6 +57,7 @@ import { runPromise } from "@/lib/server"; import { getSharePageBranding } from "@/lib/share-branding"; import { buildShareVideoMetadata } from "@/lib/share-video-metadata"; import { resolveShareWebUrl } from "@/lib/share-web-url"; +import { isVideoOverShareableLinkLimit } from "@/lib/shareable-link-quota"; import { isIframelyCrawlerUserAgent, isSocialCrawlerUserAgent, @@ -494,6 +495,24 @@ async function AuthorizedContent({ const sharedSpacesPromise = getSharedSpacesForVideo(videoId); + const ownerIsPro = userIsPro(video.owner); + + // Fail-open: a broken count must never take the share page down. + const overShareLimitPromise = ownerIsPro + ? Promise.resolve(false) + : isVideoOverShareableLinkLimit({ + id: videoId, + ownerId: video.owner.id, + createdAt: video.createdAt, + isScreenshot: video.isScreenshot, + }).catch((error) => { + console.error( + `[ShareVideoPage] Shareable link quota check failed for ${videoId}:`, + error, + ); + return false; + }); + const aiGenerationEnabledPromise = db() .select({ email: users.email, @@ -737,6 +756,7 @@ async function AuthorizedContent({ canManageSharePageBranding, canDownloadVideo, videoHasEdits, + ownerIsOverShareLimit, ] = await Promise.all([ spacesDataPromise, sharedSpacesPromise, @@ -749,6 +769,7 @@ async function AuthorizedContent({ canManageSharePageBrandingPromise, canDownloadVideoPromise, videoHasEditsPromise, + overShareLimitPromise, ]); const rules = resolveEffectiveVideoRules({ @@ -799,10 +820,11 @@ async function AuthorizedContent({ return { ...video, hasActiveUpload, + ownerIsOverShareLimit, owner: { id: video.owner.id, name: video.owner.name, - isPro: userIsPro(video.owner), + isPro: ownerIsPro, image: video.owner.image ? yield* imageUploads.resolveImageUrl(video.owner.image) : null, diff --git a/apps/web/app/s/[videoId]/types.ts b/apps/web/app/s/[videoId]/types.ts index e41583fb3dd..f013595ba84 100644 --- a/apps/web/app/s/[videoId]/types.ts +++ b/apps/web/app/s/[videoId]/types.ts @@ -19,6 +19,7 @@ export type VideoData = Omit & { shareableLinkIconUrl?: ImageUpload.ImageUrl | null; hasActiveUpload?: boolean; activeUploadRawFileKey?: string | null; + ownerIsOverShareLimit?: boolean; }; export type VideoOwner = { diff --git a/apps/web/components/UpgradeModal.tsx b/apps/web/components/UpgradeModal.tsx index 8fc24a248df..ee2bd980190 100644 --- a/apps/web/components/UpgradeModal.tsx +++ b/apps/web/components/UpgradeModal.tsx @@ -7,14 +7,17 @@ import { useMutation } from "@tanstack/react-query"; import { useCurrency } from "hooks/useCurrency"; import { BarChart3, + Clock, + Cloud, Database, Globe, Headphones, - Infinity, + Infinity as InfinityIcon, + Link2, Lock, + Mic, Minus, Plus, - Share2, Shield, ShieldCheck, Sparkles, @@ -22,7 +25,7 @@ import { } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useRouter } from "next/navigation"; -import { memo, useState } from "react"; +import { memo, useRef, useState } from "react"; import { toast } from "sonner"; import { useStripeContext } from "@/app/Layout/StripeContext"; import { PRICING } from "@/data/pricing"; @@ -63,6 +66,85 @@ const modalVariants = { }, }; +const ANNUAL_SAVINGS_PERCENT = Math.round( + (1 - PRICING.pro.annualPerMonth / PRICING.pro.monthly) * 100, +); + +const iconStyling = "text-blue-500 size-4"; + +const PRO_FEATURES = [ + { + icon: , + title: "Unlimited shareable links", + description: "No monthly limit, every video is instantly shareable", + }, + { + icon: , + title: "Unlimited recording length", + description: "The 5 minute free recording cap is removed", + }, + { + icon: , + title: "Unlimited cloud storage", + description: "Keep every recording, forever", + }, + { + icon: , + title: "Cap AI", + description: "Automatic titles, summaries, chapters & more", + }, + { + icon: , + title: "Custom domain", + description: "Share videos from your own domain", + }, + { + icon: , + title: "Password protected videos", + description: "Control exactly who can watch", + }, + { + icon: , + title: "Analytics", + description: "Views, engagement and viewer insights", + }, + { + icon: