Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/app/(org)/dashboard/Contexts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function DashboardContexts({
initialTheme,
initialSidebarCollapsed,
referClicked,
shareableLinkUsage,
}: {
children: React.ReactNode;
organizationData: SharedContext["organizationData"];
Expand All @@ -46,6 +47,7 @@ export function DashboardContexts({
initialTheme: ITheme;
initialSidebarCollapsed: boolean;
referClicked: boolean;
shareableLinkUsage: SharedContext["shareableLinkUsage"];
}) {
const user = useCurrentUser();
if (!user) redirect("/login");
Expand Down Expand Up @@ -164,6 +166,7 @@ export function DashboardContexts({
isDeveloperSection,
developerApps,
setDeveloperApps,
shareableLinkUsage,
}}
>
{children}
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/(org)/dashboard/DashboardContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export function AnalyticsDashboard() {
</h1>
</div>
<p className="mt-1 text-lg text-center text-gray-11">
You can cancel anytime. Early adopter pricing locked in.
You can cancel anytime.
</p>

<div className="flex flex-col items-center mt-3 mb-4 w-full">
Expand Down
11 changes: 11 additions & 0 deletions apps/web/app/(org)/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -105,6 +115,7 @@ export default async function DashboardLayout({
anyNewNotifications={anyNewNotifications}
userPreferences={userPreferences}
referClicked={referClicked === "true"}
shareableLinkUsage={shareableLinkUsage}
>
<DashboardPasteImport />
<div className="bg-gray-2 dashboard-grid">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ const CustomDomainDialog = ({
handleClose();
}}
>
Upgrade To Cap Pro
Upgrade to Cap Pro
</Button>
))}
</DialogFooter>
Expand Down
36 changes: 12 additions & 24 deletions apps/web/app/api/settings/billing/usage/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 },
);
}
46 changes: 45 additions & 1 deletion apps/web/app/embed/[videoId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,23 @@ 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";
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";
Expand Down Expand Up @@ -225,6 +228,7 @@ async function EmbedContent({
});

let aiGenerationEnabled = false;
let ownerIsProUser = false;
const videoOwnerQuery = await db()
.select({
email: users.email,
Expand All @@ -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 (
Expand Down Expand Up @@ -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 (
<div className="flex flex-col gap-3 justify-center items-center px-6 min-h-screen text-center bg-black">
<Logo className="w-auto h-7" white />
<h1 className="text-lg font-semibold text-white">
This video is over its free limit
</h1>
<p className="max-w-sm text-sm leading-relaxed text-white/60">
{`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.`}
</p>
<a
href={`${buildEnv.NEXT_PUBLIC_WEB_URL}/s/${video.id}`}
target="_blank"
rel="noreferrer"
className="mt-2 rounded-full border border-gray-5 bg-gray-3 px-5 py-2 text-sm font-medium text-gray-12 transition-colors hover:bg-gray-6"
>
Open on Cap
</a>
</div>
);
}

const commentsQuery = await db()
.select({
id: comments.id,
Expand Down
9 changes: 7 additions & 2 deletions apps/web/app/s/[videoId]/Share.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -598,8 +598,12 @@ export const Share = ({
const [selectedView, setSelectedView] = useState<ShareView>(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 <video> can
Expand Down Expand Up @@ -988,6 +992,7 @@ export const Share = ({
isEditProcessing={isEditProcessing}
recordingStopped={recordingStopped}
defaultPlaybackSpeed={defaultPlaybackSpeed}
viewerIsOwner={viewerId === data.owner.id}
ref={playerRef}
/>
)}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/s/[videoId]/_components/ShareHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,7 @@ export const ShareHeader = ({
size="sm"
variant="blue"
>
Upgrade To Cap Pro
Upgrade to Cap Pro
</Button>
</div>
)}
Expand Down
19 changes: 18 additions & 1 deletion apps/web/app/s/[videoId]/_components/ShareVideo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
PreparingVideoOverlay,
RecordingInProgressOverlay,
} from "./RecordingInProgress";
import { ShareableLinkLimitOverlay } from "./ShareableLinkLimitOverlay";
import {
shouldDeferPlaybackSource,
shouldReloadPlaybackAfterUploadCompletes,
Expand Down Expand Up @@ -96,6 +97,7 @@ export const ShareVideo = forwardRef<
isEditProcessing: boolean;
recordingStopped?: boolean;
defaultPlaybackSpeed?: number;
viewerIsOwner?: boolean;
}
>(
(
Expand All @@ -115,6 +117,7 @@ export const ShareVideo = forwardRef<
isEditProcessing,
recordingStopped = false,
defaultPlaybackSpeed,
viewerIsOwner = false,
},
ref,
) => {
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -541,6 +545,19 @@ export const ShareVideo = forwardRef<
</div>
) : isProcessingInProgress ? (
<PreparingVideoOverlay className="h-full" />
) : 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.
<ShareableLinkLimitOverlay
isOwner={viewerIsOwner}
onUpgrade={openUpgradeModal}
onUpgradeHover={() => {
void importUpgradeModal();
}}
className="h-full"
/>
) : isMp4Source ? (
<CapVideoPlayer
videoId={data.id}
Expand Down Expand Up @@ -654,7 +671,7 @@ export const ShareVideo = forwardRef<
)}
</div>

{!data.owner.isPro && (
{!data.owner.isPro && !isOverShareLimit && (
<div className="absolute top-4 left-4 z-30">
<button
type="button"
Expand Down
Loading
Loading