From 7546609ed10e290d5c85465fb46dde6a36e6cd7d Mon Sep 17 00:00:00 2001 From: "Aatman Jain (AJ)" <257399449+AatmanAJ@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:14:03 +0000 Subject: [PATCH 1/2] feat(web): default call to action for every video --- .../unit/share-call-to-action.test.ts | 70 ++++++++ .../__tests__/unit/share-page-loading.test.ts | 55 +++++- .../unit/video-call-to-action-action.test.ts | 102 ++++++++++- apps/web/actions/videos/call-to-action.ts | 82 ++++++++- .../components/DefaultCallToAction.tsx | 94 +++++++++++ .../(org)/dashboard/settings/account/page.tsx | 4 + apps/web/app/embed/[videoId]/page.tsx | 8 +- .../s/[videoId]/_components/ShareHeader.tsx | 6 +- .../call-to-action/CallToActionDialog.tsx | 159 +++++++++++++++--- apps/web/app/s/[videoId]/page.tsx | 18 +- apps/web/app/s/[videoId]/types.ts | 7 +- apps/web/lib/share-call-to-action.ts | 39 ++++- packages/database/schema.ts | 3 +- 13 files changed, 599 insertions(+), 48 deletions(-) create mode 100644 apps/web/app/(org)/dashboard/settings/account/components/DefaultCallToAction.tsx diff --git a/apps/web/__tests__/unit/share-call-to-action.test.ts b/apps/web/__tests__/unit/share-call-to-action.test.ts index 7570eec0c85..c76bc567e67 100644 --- a/apps/web/__tests__/unit/share-call-to-action.test.ts +++ b/apps/web/__tests__/unit/share-call-to-action.test.ts @@ -5,8 +5,10 @@ import { DEFAULT_CTA_COLOR, normalizeCallToActionUrl, normalizeHexColor, + parseDefaultCallToAction, parseShareCallToAction, readableTextColor, + resolveShareCallToAction, type ShareCallToAction, shouldShowCornerCard, toStoredCallToAction, @@ -281,3 +283,71 @@ describe("shouldShowCornerCard", () => { ).toBe(false); }); }); + +describe("resolveShareCallToAction", () => { + const own = { label: "Watch the demo", url: "https://example.com/demo" }; + const fallback = { + label: "Book a call", + url: "https://cal.com/me", + color: "#12A150", + }; + + it("prefers the video's own call to action over the owner's default", () => { + const resolved = resolveShareCallToAction( + { callToAction: own }, + { defaultCallToAction: fallback }, + ); + expect(resolved.source).toBe("video"); + expect(resolved.callToAction?.label).toBe("Watch the demo"); + }); + + it("falls back to the owner's default when the video has none", () => { + for (const settings of [null, {}, { callToAction: null }]) { + const resolved = resolveShareCallToAction(settings, { + defaultCallToAction: fallback, + }); + expect(resolved.source).toBe("default"); + expect(resolved.callToAction).toMatchObject({ + label: "Book a call", + url: "https://cal.com/me", + color: "#12A150", + showWhilePlaying: true, + }); + } + }); + + it("keeps a video that was turned off free of the default", () => { + expect( + resolveShareCallToAction( + { callToAction: false }, + { defaultCallToAction: fallback }, + ), + ).toEqual({ callToAction: null, source: null }); + }); + + it("ignores an invalid stored default", () => { + expect( + resolveShareCallToAction( + {}, + { defaultCallToAction: { label: "Go", url: "javascript:alert(1)" } }, + ), + ).toEqual({ callToAction: null, source: null }); + expect(resolveShareCallToAction({}, null)).toEqual({ + callToAction: null, + source: null, + }); + }); +}); + +describe("parseDefaultCallToAction", () => { + it("reads the default from user preferences", () => { + expect( + parseDefaultCallToAction({ + notifications: {}, + defaultCallToAction: { label: "Reply to me", url: "mailto:a@b.co" }, + }), + ).toMatchObject({ label: "Reply to me", url: "mailto:a@b.co" }); + expect(parseDefaultCallToAction({ notifications: {} })).toBeNull(); + expect(parseDefaultCallToAction(null)).toBeNull(); + }); +}); diff --git a/apps/web/__tests__/unit/share-page-loading.test.ts b/apps/web/__tests__/unit/share-page-loading.test.ts index da8ee4386e2..63aed50ee7b 100644 --- a/apps/web/__tests__/unit/share-page-loading.test.ts +++ b/apps/web/__tests__/unit/share-page-loading.test.ts @@ -163,7 +163,10 @@ function arrangeRows(rows: ReturnType[]) { from: () => query, leftJoin: () => query, innerJoin: () => query, - where: () => Promise.resolve(result), + where: () => + Object.assign(Promise.resolve(result), { + limit: () => Promise.resolve(result), + }), }; return query; }); @@ -192,6 +195,8 @@ async function renderAuthorizedContent() { sharedOrganizations: unknown[]; ownerIsOverShareLimit: boolean; callToAction: { label: string; url: string } | null; + callToActionSource: "video" | "default" | null; + defaultCallToAction: { label: string; url: string } | null; }; }>; }>; @@ -264,6 +269,54 @@ describe("share page loading", () => { }, ); + const ownerDefault = { label: "Book a call", url: "https://cal.com/owner" }; + + const withDefault = (videoSettings: unknown) => + ({ + ...createVideo(), + videoSettings, + owner: { + id: "owner", + image: null, + preferences: { defaultCallToAction: ownerDefault }, + }, + }) as unknown as ReturnType; + + it("shows the owner's default on a video without its own call to action", async () => { + mocks.ownerIsPro = true; + arrangeRows([withDefault({})]); + const data = (await renderAuthorizedContent()).props.children.props.data; + expect(data.callToAction).toMatchObject(ownerDefault); + expect(data.callToActionSource).toBe("default"); + }); + + it("keeps a video that was turned off free of the owner's default", async () => { + mocks.ownerIsPro = true; + arrangeRows([withDefault({ callToAction: false })]); + const data = (await renderAuthorizedContent()).props.children.props.data; + expect(data.callToAction).toBeNull(); + expect(data.callToActionSource).toBeNull(); + }); + + it("doesn't apply the default for an owner without Pro", async () => { + arrangeRows([withDefault({})]); + const data = (await renderAuthorizedContent()).props.children.props.data; + expect(data.callToAction).toBeNull(); + }); + + it("only sends the default itself to the owner", async () => { + mocks.ownerIsPro = true; + arrangeRows([withDefault({})]); + const viewerData = (await renderAuthorizedContent()).props.children.props + .data; + expect(viewerData.defaultCallToAction).toBeNull(); + + mocks.user.mockResolvedValue({ id: "owner" }); + const ownerData = (await renderAuthorizedContent()).props.children.props + .data; + expect(ownerData.defaultCallToAction).toMatchObject(ownerDefault); + }); + it("reuses the organization sharing query for the header and video data", async () => { mocks.sharedOrganizations = [ { id: "shared", organizationId: "shared", name: "Shared team" }, diff --git a/apps/web/__tests__/unit/video-call-to-action-action.test.ts b/apps/web/__tests__/unit/video-call-to-action-action.test.ts index eaa2fb05cbc..bea0c30b45d 100644 --- a/apps/web/__tests__/unit/video-call-to-action-action.test.ts +++ b/apps/web/__tests__/unit/video-call-to-action-action.test.ts @@ -36,15 +36,21 @@ vi.mock("@cap/database", () => ({ })); import type { Video } from "@cap/web-domain"; -import { updateVideoCallToAction } from "@/actions/videos/call-to-action"; +import { + updateDefaultCallToAction, + updateVideoCallToAction, +} from "@/actions/videos/call-to-action"; const videoId = "video123" as Video.VideoId; const sqlText = (value: unknown): string => { const chunks = (value as { queryChunks?: unknown[] }).queryChunks ?? []; return chunks - .map((chunk) => { + .map((chunk): string => { if (typeof chunk === "string") return chunk; + if (chunk && typeof chunk === "object" && "queryChunks" in chunk) { + return sqlText(chunk); + } if (chunk && typeof chunk === "object" && "value" in chunk) { const inner = (chunk as { value: unknown }).value; return Array.isArray(inner) ? inner.join("") : String(inner); @@ -135,24 +141,104 @@ describe("updateVideoCallToAction", () => { color: "#12A150", showWhilePlaying: false, }, + source: "video", }); expect(mocks.updates).toHaveLength(1); const settings = sqlText( (mocks.updates[0] as { settings: unknown }).settings, ); - expect(settings).toContain("JSON_MERGE_PATCH("); - expect(settings).toContain('"callToAction":null'); + expect(settings).toContain("JSON_SET("); + expect(settings).toContain("'$.callToAction'"); expect(settings).toContain('"url":"https://cal.com/demo"'); }); - it("removes only the call to action key", async () => { + it("turns calls to action off for the video so a default can't apply", async () => { const result = await updateVideoCallToAction(videoId, null); - expect(result).toEqual({ success: true, callToAction: null }); + expect(result).toEqual({ success: true, callToAction: null, source: null }); + expect(mocks.updates).toHaveLength(1); const settings = sqlText( (mocks.updates[0] as { settings: unknown }).settings, ); - expect(settings).toContain("JSON_MERGE_PATCH("); - expect(settings).toContain('{"callToAction":null}'); + expect(settings).toContain("'$.callToAction'"); + expect(settings).toContain("CAST('false' AS JSON)"); expect(settings).not.toContain('"url"'); }); + + it("saves as the owner's default and lets the video follow it", async () => { + const result = await updateVideoCallToAction( + videoId, + { label: "Book a call", url: "cal.com/demo" }, + { asDefault: true }, + ); + expect(result).toMatchObject({ success: true, source: "default" }); + expect(mocks.updates).toHaveLength(2); + const preferences = sqlText( + (mocks.updates[0] as { preferences: unknown }).preferences, + ); + expect(preferences).toContain("'$.defaultCallToAction'"); + expect(preferences).toContain('"url":"https://cal.com/demo"'); + const settings = sqlText( + (mocks.updates[1] as { settings: unknown }).settings, + ); + expect(settings).toContain("JSON_REMOVE("); + expect(settings).toContain("'$.callToAction'"); + }); +}); + +describe("updateDefaultCallToAction", () => { + beforeEach(() => { + mocks.user = { id: "owner", stripeSubscriptionStatus: "active" }; + mocks.updates = []; + }); + + it("requires a signed-in user", async () => { + mocks.user = null; + await expect(updateDefaultCallToAction(null)).rejects.toThrow( + "Unauthorized", + ); + expect(mocks.updates).toHaveLength(0); + }); + + it("stores the normalized default in the user's preferences", async () => { + const result = await updateDefaultCallToAction({ + label: "Try it free", + url: "cap.so", + }); + expect(result).toMatchObject({ + success: true, + callToAction: { label: "Try it free", url: "https://cap.so/" }, + }); + const preferences = sqlText( + (mocks.updates[0] as { preferences: unknown }).preferences, + ); + expect(preferences).toContain("JSON_SET("); + expect(preferences).toContain("'$.defaultCallToAction'"); + // New users' preferences are a JSON null, which COALESCE wouldn't replace. + expect(preferences).toContain("JSON_TYPE("); + expect(preferences).not.toContain("COALESCE("); + }); + + it("needs Pro to set a default but not to remove one", async () => { + mocks.user = { id: "owner", stripeSubscriptionStatus: "canceled" }; + await expect( + updateDefaultCallToAction({ label: "Go", url: "example.com" }), + ).resolves.toEqual({ success: false, errors: {}, upgradeRequired: true }); + expect(mocks.updates).toHaveLength(0); + + await expect(updateDefaultCallToAction(null)).resolves.toEqual({ + success: true, + callToAction: null, + }); + const preferences = sqlText( + (mocks.updates[0] as { preferences: unknown }).preferences, + ); + expect(preferences).toContain("JSON_REMOVE("); + expect(preferences).toContain("'$.defaultCallToAction'"); + }); + + it("returns field errors without writing invalid input", async () => { + const result = await updateDefaultCallToAction({ label: "", url: "" }); + expect(result.success).toBe(false); + expect(mocks.updates).toHaveLength(0); + }); }); diff --git a/apps/web/actions/videos/call-to-action.ts b/apps/web/actions/videos/call-to-action.ts index e24c4b5ecee..006b89b81f9 100644 --- a/apps/web/actions/videos/call-to-action.ts +++ b/apps/web/actions/videos/call-to-action.ts @@ -2,25 +2,51 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { videos } from "@cap/database/schema"; +import { users, videos } from "@cap/database/schema"; import { userIsPro } from "@cap/utils"; -import type { Video } from "@cap/web-domain"; -import { and, eq, sql } from "drizzle-orm"; +import type { User, Video } from "@cap/web-domain"; +import { type AnyColumn, and, eq, type SQL, sql } from "drizzle-orm"; import { type CallToActionErrors, type CallToActionInput, + type CallToActionSource, type ShareCallToAction, toStoredCallToAction, validateCallToAction, } from "@/lib/share-call-to-action"; export type UpdateVideoCallToActionResult = + | { + success: true; + callToAction: ShareCallToAction | null; + source: CallToActionSource | null; + } + | { success: false; errors: CallToActionErrors; upgradeRequired?: true }; + +export type UpdateDefaultCallToActionResult = | { success: true; callToAction: ShareCallToAction | null } | { success: false; errors: CallToActionErrors; upgradeRequired?: true }; +// New users' preferences hold a JSON `null`, which COALESCE keeps and JSON_SET +// can't add a key to, so anything that isn't an object starts empty. +const jsonObject = (column: AnyColumn): SQL => + sql`CASE WHEN JSON_TYPE(${column}) = 'OBJECT' THEN ${column} ELSE JSON_OBJECT() END`; + +const storeDefaultCallToAction = ( + userId: User.UserId, + value: ShareCallToAction, +) => + db() + .update(users) + .set({ + preferences: sql`JSON_SET(${jsonObject(users.preferences)}, '$.defaultCallToAction', CAST(${JSON.stringify(toStoredCallToAction(value))} AS JSON))`, + }) + .where(eq(users.id, userId)); + export async function updateVideoCallToAction( videoId: Video.VideoId, input: CallToActionInput | null, + options: { asDefault?: boolean } = {}, ): Promise { const user = await getCurrentUser(); if (!user) throw new Error("Unauthorized"); @@ -39,25 +65,65 @@ export async function updateVideoCallToAction( return { success: false, errors: {}, upgradeRequired: true }; } + const ownedVideo = and(eq(videos.id, videoId), eq(videos.ownerId, user.id)); + if (input === null) { await db() .update(videos) .set({ - settings: sql`JSON_MERGE_PATCH(COALESCE(${videos.settings}, JSON_OBJECT()), CAST('{"callToAction":null}' AS JSON))`, + settings: sql`JSON_SET(${jsonObject(videos.settings)}, '$.callToAction', CAST('false' AS JSON))`, }) - .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))); - return { success: true, callToAction: null }; + .where(ownedVideo); + return { success: true, callToAction: null, source: null }; } const result = validateCallToAction(input); if (!result.ok) return { success: false, errors: result.errors }; + if (options.asDefault) { + await storeDefaultCallToAction(user.id, result.value); + await db() + .update(videos) + .set({ + settings: sql`JSON_REMOVE(${jsonObject(videos.settings)}, '$.callToAction')`, + }) + .where(ownedVideo); + return { success: true, callToAction: result.value, source: "default" }; + } + await db() .update(videos) .set({ - settings: sql`JSON_MERGE_PATCH(JSON_MERGE_PATCH(COALESCE(${videos.settings}, JSON_OBJECT()), CAST('{"callToAction":null}' AS JSON)), CAST(${JSON.stringify({ callToAction: toStoredCallToAction(result.value) })} AS JSON))`, + settings: sql`JSON_SET(${jsonObject(videos.settings)}, '$.callToAction', CAST(${JSON.stringify(toStoredCallToAction(result.value))} AS JSON))`, }) - .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))); + .where(ownedVideo); + + return { success: true, callToAction: result.value, source: "video" }; +} + +export async function updateDefaultCallToAction( + input: CallToActionInput | null, +): Promise { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + if (input === null) { + await db() + .update(users) + .set({ + preferences: sql`JSON_REMOVE(${jsonObject(users.preferences)}, '$.defaultCallToAction')`, + }) + .where(eq(users.id, user.id)); + return { success: true, callToAction: null }; + } + + if (!userIsPro(user)) { + return { success: false, errors: {}, upgradeRequired: true }; + } + + const result = validateCallToAction(input); + if (!result.ok) return { success: false, errors: result.errors }; + await storeDefaultCallToAction(user.id, result.value); return { success: true, callToAction: result.value }; } diff --git a/apps/web/app/(org)/dashboard/settings/account/components/DefaultCallToAction.tsx b/apps/web/app/(org)/dashboard/settings/account/components/DefaultCallToAction.tsx new file mode 100644 index 00000000000..a25b4012b39 --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/account/components/DefaultCallToAction.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Button, Card, CardDescription, CardTitle } from "@cap/ui"; +import { ArrowUpRight } from "lucide-react"; +import dynamic from "next/dynamic"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { + callToActionDestinationLabel, + parseDefaultCallToAction, + readableTextColor, +} from "@/lib/share-call-to-action"; +import { useDashboardContext } from "../../../Contexts"; + +const CallToActionDialog = dynamic( + () => + import( + "@/app/s/[videoId]/_components/call-to-action/CallToActionDialog" + ).then((m) => m.CallToActionDialog), + { ssr: false }, +); + +export function DefaultCallToAction() { + const router = useRouter(); + const { user, userPreferences, setUpgradeModalOpen } = useDashboardContext(); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogMounted, setDialogMounted] = useState(false); + const defaultCallToAction = parseDefaultCallToAction(userPreferences); + + const openDialog = () => { + if (!user.isPro) { + setUpgradeModalOpen(true); + return; + } + setDialogMounted(true); + setDialogOpen(true); + }; + + return ( + +
+
+ Default call to action + + Shown on every video of yours that doesn't have its own call to + action, including ones you've already shared. You can turn it off + for a single video from that video's Call to action menu. + +
+ +
+ + {defaultCallToAction && ( +
+ + {defaultCallToAction.label} + + + + {defaultCallToAction.headline + ? `${defaultCallToAction.headline} · ` + : ""} + {callToActionDestinationLabel(defaultCallToAction.url)} + +
+ )} + + {dialogMounted && ( + router.refresh()} + onUpgradeRequest={() => setUpgradeModalOpen(true)} + /> + )} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/settings/account/page.tsx b/apps/web/app/(org)/dashboard/settings/account/page.tsx index e674a60496a..8b62bd07f0c 100644 --- a/apps/web/app/(org)/dashboard/settings/account/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { CliApiKeys } from "./components/CliApiKeys"; +import { DefaultCallToAction } from "./components/DefaultCallToAction"; import { Settings } from "./Settings"; import { listCliApiKeys } from "./server"; @@ -14,6 +15,9 @@ export default async function SettingsPage() { return ( <> +
+ +
diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index c9b5cb78602..ff6eccdd411 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -920,6 +920,8 @@ export const ShareHeader = ({ onOpenChange={setIsCtaDialogOpen} videoId={data.id} callToAction={data.callToAction ?? null} + source={data.callToActionSource ?? null} + defaultCallToAction={data.defaultCallToAction ?? null} onSaved={refresh} onUpgradeRequest={() => setUpgradeModalOpen(true)} /> @@ -1241,7 +1243,9 @@ export const ShareHeader = ({ ) : data.callToAction ? ( - On + {data.callToActionSource === "default" + ? "Default" + : "On"} ) : null} diff --git a/apps/web/app/s/[videoId]/_components/call-to-action/CallToActionDialog.tsx b/apps/web/app/s/[videoId]/_components/call-to-action/CallToActionDialog.tsx index fc891fdfb7f..b61e1335f65 100644 --- a/apps/web/app/s/[videoId]/_components/call-to-action/CallToActionDialog.tsx +++ b/apps/web/app/s/[videoId]/_components/call-to-action/CallToActionDialog.tsx @@ -14,9 +14,13 @@ import { useState, } from "react"; import { toast } from "sonner"; -import { updateVideoCallToAction } from "@/actions/videos/call-to-action"; +import { + updateDefaultCallToAction, + updateVideoCallToAction, +} from "@/actions/videos/call-to-action"; import { type CallToActionErrors, + type CallToActionSource, CTA_COLOR_PRESETS, CTA_HEADLINE_MAX_LENGTH, CTA_LABEL_MAX_LENGTH, @@ -63,11 +67,33 @@ function formFromCallToAction(cta: ShareCallToAction | null): FormState { }; } -function useVideoThumbnail(videoId: Video.VideoId, enabled: boolean) { +function initialDialogState({ + editingDefault, + callToAction, + source, + defaultCallToAction, +}: { + editingDefault: boolean; + callToAction: ShareCallToAction | null; + source: CallToActionSource | null; + defaultCallToAction: ShareCallToAction | null; +}) { + return { + form: formFromCallToAction( + callToAction ?? (editingDefault ? null : defaultCallToAction), + ), + useAsDefault: + !editingDefault && + (source === "default" || + (callToAction === null && defaultCallToAction !== null)), + }; +} + +function useVideoThumbnail(videoId: Video.VideoId | null, enabled: boolean) { const [thumbnail, setThumbnail] = useState(null); useEffect(() => { - if (!enabled) return; + if (!enabled || !videoId) return; const controller = new AbortController(); fetch(`/api/thumbnail?videoId=${encodeURIComponent(videoId)}`, { signal: controller.signal, @@ -90,23 +116,47 @@ function useVideoThumbnail(videoId: Video.VideoId, enabled: boolean) { return thumbnail; } +/** + * Without a `videoId` the dialog edits the owner's default call to action, + * which every video without its own shows. + */ export function CallToActionDialog({ open, onOpenChange, - videoId, + videoId = null, callToAction, + source = null, + defaultCallToAction = null, onSaved, onUpgradeRequest, }: { open: boolean; onOpenChange: (open: boolean) => void; - videoId: Video.VideoId; + videoId?: Video.VideoId | null; callToAction: ShareCallToAction | null; + source?: CallToActionSource | null; + defaultCallToAction?: ShareCallToAction | null; onSaved?: (callToAction: ShareCallToAction | null) => void; onUpgradeRequest: () => void; }) { - const [form, setForm] = useState(() => - formFromCallToAction(callToAction), + const editingDefault = videoId === null; + const [form, setForm] = useState( + () => + initialDialogState({ + editingDefault, + callToAction, + source, + defaultCallToAction, + }).form, + ); + const [useAsDefault, setUseAsDefault] = useState( + () => + initialDialogState({ + editingDefault, + callToAction, + source, + defaultCallToAction, + }).useAsDefault, ); const [errors, setErrors] = useState({}); const [touched, setTouched] = useState>({}); @@ -119,6 +169,7 @@ export function CallToActionDialog({ url: useId(), headline: useId(), playing: useId(), + allVideos: useId(), form: useId(), }; @@ -127,12 +178,19 @@ export function CallToActionDialog({ const opening = open && !wasOpenRef.current; wasOpenRef.current = open; if (!opening) return; - setForm(formFromCallToAction(callToAction)); + const initial = initialDialogState({ + editingDefault, + callToAction, + source, + defaultCallToAction, + }); + setForm(initial.form); + setUseAsDefault(initial.useAsDefault); setErrors({}); setTouched({}); setPreviewMode("end"); setPending(null); - }, [open, callToAction]); + }, [open, editingDefault, callToAction, source, defaultCallToAction]); const liveErrors = useMemo(() => { const result = validateCallToAction(form); @@ -161,8 +219,30 @@ export function CallToActionDialog({ setTouched((previous) => ({ ...previous, [field]: true })); const isEditing = callToAction !== null; + const usesDefault = !editingDefault && source === "default"; + const savesDefault = editingDefault || useAsDefault; + const replacesOtherDefault = + useAsDefault && !usesDefault && defaultCallToAction !== null; const busy = pending !== null; + const saveLabel = editingDefault + ? isEditing + ? "Save default" + : "Set default" + : useAsDefault + ? usesDefault + ? "Save changes" + : "Use on all videos" + : isEditing && source === "video" + ? "Save changes" + : "Add to video"; + + const removeLabel = editingDefault + ? "Remove default" + : usesDefault + ? "Turn off for this video" + : "Remove"; + const handleSave = async () => { const result = validateCallToAction(form); if (!result.ok) { @@ -172,7 +252,11 @@ export function CallToActionDialog({ } setPending("save"); try { - const response = await updateVideoCallToAction(videoId, form); + const response = videoId + ? await updateVideoCallToAction(videoId, form, { + asDefault: useAsDefault, + }) + : await updateDefaultCallToAction(form); if (!response.success) { if (response.upgradeRequired) { onOpenChange(false); @@ -183,7 +267,11 @@ export function CallToActionDialog({ return; } toast.success( - isEditing ? "Call to action updated" : "Call to action added", + savesDefault + ? "Default call to action saved" + : isEditing && source === "video" + ? "Call to action updated" + : "Call to action added", ); onSaved?.(response.callToAction); onOpenChange(false); @@ -197,7 +285,9 @@ export function CallToActionDialog({ const handleRemove = async () => { setPending("remove"); try { - const response = await updateVideoCallToAction(videoId, null); + const response = videoId + ? await updateVideoCallToAction(videoId, null) + : await updateDefaultCallToAction(null); if (!response.success) { if (response.upgradeRequired) { onOpenChange(false); @@ -207,7 +297,13 @@ export function CallToActionDialog({ } return; } - toast.success("Call to action removed"); + toast.success( + editingDefault + ? "Default call to action removed" + : usesDefault + ? "Call to action turned off for this video" + : "Call to action removed", + ); onSaved?.(null); onOpenChange(false); } catch { @@ -297,10 +393,12 @@ export function CallToActionDialog({
- Call to action + {editingDefault ? "Default call to action" : "Call to action"}

- Give viewers a next step, right on your video. + {editingDefault + ? "Shown on every video of yours that doesn't have its own." + : "Give viewers a next step, right on your video."}

)}
@@ -464,11 +585,7 @@ export function CallToActionDialog({ disabled={busy} className="inline-flex h-9 items-center gap-1.5 rounded-full bg-gray-12 px-4 text-[13px] font-semibold text-gray-1 transition-[opacity,transform] hover:opacity-90 active:scale-[0.98] disabled:opacity-60" > - {pending === "save" - ? "Saving…" - : isEditing - ? "Save changes" - : "Add to video"} + {pending === "save" ? "Saving…" : saveLabel}
diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index cdc36cbc1d9..e0a0da2b0f1 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -61,7 +61,10 @@ import { getPublicShareVideo } from "@/lib/public-share-video"; import * as EffectRuntime from "@/lib/server"; import { runPromise } from "@/lib/server"; import { getSharePageBranding } from "@/lib/share-branding"; -import { parseShareCallToAction } from "@/lib/share-call-to-action"; +import { + parseDefaultCallToAction, + resolveShareCallToAction, +} from "@/lib/share-call-to-action"; import { getShareDashboardDestination } from "@/lib/share-dashboard-destination"; import { canStartPlaybackInHtml, @@ -956,6 +959,10 @@ async function AuthorizedContent({ aiGenerationStatus, }; + const resolvedCallToAction = ownerIsPro + ? resolveShareCallToAction(video.videoSettings, video.owner.preferences) + : { callToAction: null, source: null }; + const videoWithOrganizationInfo = { ...video, hasActiveUpload, @@ -981,9 +988,12 @@ async function AuthorizedContent({ hasInheritedPassword: rules.hasInheritedPassword, inheritedPasswordSources: rules.inheritedPasswordSources, inheritedSpaceSettings: rules.inheritedSettings, - callToAction: ownerIsPro - ? parseShareCallToAction(video.videoSettings) - : null, + callToAction: resolvedCallToAction.callToAction, + callToActionSource: resolvedCallToAction.source, + defaultCallToAction: + user?.id === video.owner.id + ? parseDefaultCallToAction(video.owner.preferences) + : null, }; const isEditProcessing = isEditSourceKey({ diff --git a/apps/web/app/s/[videoId]/types.ts b/apps/web/app/s/[videoId]/types.ts index 76ff1323a8d..633a71d9aa0 100644 --- a/apps/web/app/s/[videoId]/types.ts +++ b/apps/web/app/s/[videoId]/types.ts @@ -2,7 +2,10 @@ import type { videos } from "@cap/database/schema"; import type { SpaceRuleSource, ViewerSettingKey } from "@cap/web-backend"; import type { ImageUpload, Organisation, User } from "@cap/web-domain"; import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; -import type { ShareCallToAction } from "@/lib/share-call-to-action"; +import type { + CallToActionSource, + ShareCallToAction, +} from "@/lib/share-call-to-action"; export type VideoData = Omit & { owner: VideoOwner; @@ -22,6 +25,8 @@ export type VideoData = Omit & { activeUploadRawFileKey?: string | null; ownerIsOverShareLimit?: boolean; callToAction?: ShareCallToAction | null; + callToActionSource?: CallToActionSource | null; + defaultCallToAction?: ShareCallToAction | null; }; export type VideoOwner = { diff --git a/apps/web/lib/share-call-to-action.ts b/apps/web/lib/share-call-to-action.ts index 1109eccc209..d0cf0764fa1 100644 --- a/apps/web/lib/share-call-to-action.ts +++ b/apps/web/lib/share-call-to-action.ts @@ -163,7 +163,44 @@ export function parseShareCallToAction( settings: unknown, ): ShareCallToAction | null { if (!isRecord(settings)) return null; - const stored = settings.callToAction; + return parseStoredCallToAction(settings.callToAction); +} + +export function parseDefaultCallToAction( + preferences: unknown, +): ShareCallToAction | null { + if (!isRecord(preferences)) return null; + return parseStoredCallToAction(preferences.defaultCallToAction); +} + +export type CallToActionSource = "video" | "default"; + +export type ResolvedCallToAction = { + callToAction: ShareCallToAction | null; + source: CallToActionSource | null; +}; + +/** + * A video's own call to action wins. `false` means the owner turned calls to + * action off for this video, so their default doesn't apply to it either. + */ +export function resolveShareCallToAction( + videoSettings: unknown, + ownerPreferences: unknown, +): ResolvedCallToAction { + const stored = isRecord(videoSettings) ? videoSettings.callToAction : null; + if (stored === false) return { callToAction: null, source: null }; + + const own = parseStoredCallToAction(stored); + if (own) return { callToAction: own, source: "video" }; + + const fallback = parseDefaultCallToAction(ownerPreferences); + return fallback + ? { callToAction: fallback, source: "default" } + : { callToAction: null, source: null }; +} + +function parseStoredCallToAction(stored: unknown): ShareCallToAction | null { if (!isRecord(stored)) return null; if (typeof stored.label !== "string" || typeof stored.url !== "string") { return null; diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 3752d13aa24..3b4e6b5e742 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -113,6 +113,7 @@ export const users = mysqlTable( trackedEvents?: { user_signed_up?: boolean; }; + defaultCallToAction?: VideoCallToAction; } | null>() .default(null), activeOrganizationId: nanoId( @@ -446,7 +447,7 @@ export const videos = mysqlTable( disableTranscript?: boolean; disableComments?: boolean; defaultPlaybackSpeed?: number; - callToAction?: VideoCallToAction; + callToAction?: VideoCallToAction | false; }>(), transcriptionStatus: varchar("transcriptionStatus", { length: 255 }).$type< "PROCESSING" | "COMPLETE" | "ERROR" | "SKIPPED" | "NO_AUDIO" From 43ada33321496d3dbeeb85766f56cfb98a290f68 Mon Sep 17 00:00:00 2001 From: "Aatman Jain (AJ)" <257399449+AatmanAJ@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:32:00 +0000 Subject: [PATCH 2/2] fix(web): save a default call to action in one transaction and let downgraded owners remove it --- .../unit/video-call-to-action-action.test.ts | 32 +++++++++++++++++++ apps/web/actions/videos/call-to-action.ts | 24 ++++++++------ .../components/DefaultCallToAction.tsx | 6 ++-- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/apps/web/__tests__/unit/video-call-to-action-action.test.ts b/apps/web/__tests__/unit/video-call-to-action-action.test.ts index bea0c30b45d..b3be98a6a67 100644 --- a/apps/web/__tests__/unit/video-call-to-action-action.test.ts +++ b/apps/web/__tests__/unit/video-call-to-action-action.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ } | null, video: null as { ownerId: string } | null, updates: [] as unknown[], + failTransactionWrite: null as number | null, })); vi.mock("@cap/env", () => ({ @@ -32,6 +33,24 @@ vi.mock("@cap/database", () => ({ }, }), }), + transaction: async ( + run: (tx: { update: () => unknown }) => Promise, + ) => { + const staged: unknown[] = []; + await run({ + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + if (mocks.failTransactionWrite === staged.length) { + throw new Error("write failed"); + } + staged.push(values); + }, + }), + }), + }); + mocks.updates.push(...staged); + }, }), })); @@ -65,6 +84,7 @@ describe("updateVideoCallToAction", () => { mocks.user = { id: "owner", stripeSubscriptionStatus: "active" }; mocks.video = { ownerId: "owner" }; mocks.updates = []; + mocks.failTransactionWrite = null; }); it("requires a signed-in user", async () => { @@ -183,6 +203,18 @@ describe("updateVideoCallToAction", () => { expect(settings).toContain("JSON_REMOVE("); expect(settings).toContain("'$.callToAction'"); }); + + it("leaves the default unchanged when clearing the video's own copy fails", async () => { + mocks.failTransactionWrite = 1; + await expect( + updateVideoCallToAction( + videoId, + { label: "Book a call", url: "cal.com/demo" }, + { asDefault: true }, + ), + ).rejects.toThrow("write failed"); + expect(mocks.updates).toHaveLength(0); + }); }); describe("updateDefaultCallToAction", () => { diff --git a/apps/web/actions/videos/call-to-action.ts b/apps/web/actions/videos/call-to-action.ts index 006b89b81f9..c8bf4e3111f 100644 --- a/apps/web/actions/videos/call-to-action.ts +++ b/apps/web/actions/videos/call-to-action.ts @@ -33,10 +33,11 @@ const jsonObject = (column: AnyColumn): SQL => sql`CASE WHEN JSON_TYPE(${column}) = 'OBJECT' THEN ${column} ELSE JSON_OBJECT() END`; const storeDefaultCallToAction = ( + database: Pick, "update">, userId: User.UserId, value: ShareCallToAction, ) => - db() + database .update(users) .set({ preferences: sql`JSON_SET(${jsonObject(users.preferences)}, '$.defaultCallToAction', CAST(${JSON.stringify(toStoredCallToAction(value))} AS JSON))`, @@ -81,14 +82,17 @@ export async function updateVideoCallToAction( if (!result.ok) return { success: false, errors: result.errors }; if (options.asDefault) { - await storeDefaultCallToAction(user.id, result.value); - await db() - .update(videos) - .set({ - settings: sql`JSON_REMOVE(${jsonObject(videos.settings)}, '$.callToAction')`, - }) - .where(ownedVideo); - return { success: true, callToAction: result.value, source: "default" }; + const value = result.value; + await db().transaction(async (tx) => { + await storeDefaultCallToAction(tx, user.id, value); + await tx + .update(videos) + .set({ + settings: sql`JSON_REMOVE(${jsonObject(videos.settings)}, '$.callToAction')`, + }) + .where(ownedVideo); + }); + return { success: true, callToAction: value, source: "default" }; } await db() @@ -124,6 +128,6 @@ export async function updateDefaultCallToAction( const result = validateCallToAction(input); if (!result.ok) return { success: false, errors: result.errors }; - await storeDefaultCallToAction(user.id, result.value); + await storeDefaultCallToAction(db(), user.id, result.value); return { success: true, callToAction: result.value }; } diff --git a/apps/web/app/(org)/dashboard/settings/account/components/DefaultCallToAction.tsx b/apps/web/app/(org)/dashboard/settings/account/components/DefaultCallToAction.tsx index a25b4012b39..94d6c78f293 100644 --- a/apps/web/app/(org)/dashboard/settings/account/components/DefaultCallToAction.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/components/DefaultCallToAction.tsx @@ -28,7 +28,7 @@ export function DefaultCallToAction() { const defaultCallToAction = parseDefaultCallToAction(userPreferences); const openDialog = () => { - if (!user.isPro) { + if (!user.isPro && !defaultCallToAction) { setUpgradeModalOpen(true); return; } @@ -55,7 +55,9 @@ export function DefaultCallToAction() { onClick={openDialog} > {defaultCallToAction ? "Edit" : "Set default"} - {!user.isPro && Pro} + {!user.isPro && !defaultCallToAction && ( + Pro + )}