From faca044e18cac9dc1e4c0debf9dd76d9c0f148a2 Mon Sep 17 00:00:00 2001 From: alexis-morain Date: Wed, 10 Jun 2026 12:59:28 +0200 Subject: [PATCH 1/2] feat(share): add a configurable call-to-action button on shared videos Adds an optional CTA button rendered in the top-right of the video player on the public share page, similar to Loom. The owner configures a label and an https link from a dialog in the share header; viewers see the button and it opens the link in a new tab. The config is stored per video in the existing videos.metadata JSON column (no schema migration). URLs are validated server-side and must be https. - packages/database/types/metadata.ts: VideoCta type + metadata.cta field - actions/videos/edit-cta.ts: owner-only server action with https validation - _components/CtaButton.tsx: overlay anchor shown over the player - _components/CtaDialog.tsx: owner config dialog - wire cta through CapVideoPlayer and HLSVideoPlayer, plus a trigger in ShareHeader Co-Authored-By: Claude Opus 4.8 --- apps/web/actions/videos/edit-cta.ts | 73 +++++++++ .../[videoId]/_components/CapVideoPlayer.tsx | 5 + .../app/s/[videoId]/_components/CtaButton.tsx | 21 +++ .../app/s/[videoId]/_components/CtaDialog.tsx | 138 ++++++++++++++++++ .../[videoId]/_components/HLSVideoPlayer.tsx | 5 + .../s/[videoId]/_components/ShareHeader.tsx | 21 +++ .../s/[videoId]/_components/ShareVideo.tsx | 2 + packages/database/types/metadata.ts | 7 + 8 files changed, 272 insertions(+) create mode 100644 apps/web/actions/videos/edit-cta.ts create mode 100644 apps/web/app/s/[videoId]/_components/CtaButton.tsx create mode 100644 apps/web/app/s/[videoId]/_components/CtaDialog.tsx diff --git a/apps/web/actions/videos/edit-cta.ts b/apps/web/actions/videos/edit-cta.ts new file mode 100644 index 00000000000..0bb7dd6445f --- /dev/null +++ b/apps/web/actions/videos/edit-cta.ts @@ -0,0 +1,73 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { videos } from "@cap/database/schema"; +import type { VideoCta, VideoMetadata } from "@cap/database/types"; +import type { Video } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +const MAX_LABEL_LENGTH = 40; + +export async function editCta(videoId: Video.VideoId, cta: VideoCta | null) { + const user = await getCurrentUser(); + + if (!user || !videoId) { + throw new Error("Missing required data for updating video CTA"); + } + + const userId = user.id; + const query = await db().select().from(videos).where(eq(videos.id, videoId)); + + const video = query[0]; + if (!video) { + throw new Error("Video not found"); + } + + if (video.ownerId !== userId) { + throw new Error("You don't have permission to update this video"); + } + + const currentMetadata = (video.metadata as VideoMetadata) || {}; + let nextCta: VideoCta | undefined; + + if (cta?.enabled) { + const label = cta.label.trim().slice(0, MAX_LABEL_LENGTH); + const url = cta.url.trim(); + + if (!label) { + throw new Error("CTA label is required"); + } + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error("CTA URL is invalid"); + } + + if (parsed.protocol !== "https:") { + throw new Error("CTA URL must start with https://"); + } + + nextCta = { enabled: true, label, url: parsed.toString() }; + } + + const updatedMetadata: VideoMetadata = { ...currentMetadata }; + if (nextCta) { + updatedMetadata.cta = nextCta; + } else { + delete updatedMetadata.cta; + } + + await db() + .update(videos) + .set({ metadata: updatedMetadata }) + .where(eq(videos.id, videoId)); + + revalidatePath(`/s/${videoId}`); + revalidatePath("/dashboard/caps"); + + return { success: true }; +} diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index bde2f11a064..6103b7c58b6 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -1,5 +1,6 @@ "use client"; +import type { VideoCta } from "@cap/database/types"; import { LogoSpinner } from "@cap/ui"; import { calculateStrokeDashoffset, getProgressCircleConfig } from "@cap/utils"; import type { Video } from "@cap/web-domain"; @@ -13,6 +14,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { retryVideoProcessing } from "@/actions/video/retry-processing"; import CommentStamp from "./CommentStamp"; +import { CtaButton } from "./CtaButton"; import { getActiveCaptionText } from "./caption-cues"; import { AVC_LEVEL_IOS_HARDWARE_CEILING, @@ -115,6 +117,7 @@ interface Props { showPlaybackStatusBadge?: boolean; showFloatingVolumeControl?: boolean; onUploadComplete?: () => void; + cta?: VideoCta | null; } export function CapVideoPlayer({ @@ -148,6 +151,7 @@ export function CapVideoPlayer({ showPlaybackStatusBadge = false, showFloatingVolumeControl = false, onUploadComplete, + cta, }: Props) { const [currentCue, setCurrentCue] = useState(""); const [controlsVisible, setControlsVisible] = useState(false); @@ -622,6 +626,7 @@ export function CapVideoPlayer({ )} autoHide > + {showUploadFailureOverlay && (
diff --git a/apps/web/app/s/[videoId]/_components/CtaButton.tsx b/apps/web/app/s/[videoId]/_components/CtaButton.tsx new file mode 100644 index 00000000000..296311c740f --- /dev/null +++ b/apps/web/app/s/[videoId]/_components/CtaButton.tsx @@ -0,0 +1,21 @@ +"use client"; + +import type { VideoCta } from "@cap/database/types"; +import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +export function CtaButton({ cta }: { cta?: VideoCta | null }) { + if (!cta?.enabled || !cta.url || !cta.label) return null; + + return ( + + {cta.label} + + + ); +} diff --git a/apps/web/app/s/[videoId]/_components/CtaDialog.tsx b/apps/web/app/s/[videoId]/_components/CtaDialog.tsx new file mode 100644 index 00000000000..e465bdfdfae --- /dev/null +++ b/apps/web/app/s/[videoId]/_components/CtaDialog.tsx @@ -0,0 +1,138 @@ +"use client"; + +import type { VideoCta } from "@cap/database/types"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Switch, +} from "@cap/ui"; +import type { Video } from "@cap/web-domain"; +import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useRouter } from "next/navigation"; +import { useEffect, useId, useState } from "react"; +import { toast } from "sonner"; +import { editCta } from "@/actions/videos/edit-cta"; + +const MAX_LABEL_LENGTH = 40; + +export const CtaDialog = ({ + isOpen, + onClose, + videoId, + cta, +}: { + isOpen: boolean; + onClose: () => void; + videoId: Video.VideoId; + cta?: VideoCta | null; +}) => { + const { refresh } = useRouter(); + const enabledId = useId(); + const labelId = useId(); + const urlId = useId(); + const [enabled, setEnabled] = useState(cta?.enabled ?? false); + const [label, setLabel] = useState(cta?.label ?? ""); + const [url, setUrl] = useState(cta?.url ?? ""); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (isOpen) { + setEnabled(cta?.enabled ?? false); + setLabel(cta?.label ?? ""); + setUrl(cta?.url ?? ""); + } + }, [isOpen, cta]); + + const handleSave = async () => { + setIsSaving(true); + try { + const next: VideoCta | null = enabled + ? { enabled: true, label: label.trim(), url: url.trim() } + : null; + await editCta(videoId, next); + toast.success( + enabled ? "Call to action saved" : "Call to action removed", + ); + refresh(); + onClose(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to save call to action", + ); + } finally { + setIsSaving(false); + } + }; + + return ( + + + } + description="Show a button in the top-right of your video that links anywhere you like." + > + Call to action + +
+
+ + +
+
+ + setLabel(e.target.value)} + /> +
+
+ + setUrl(e.target.value)} + /> +
+
+ + + + +
+
+ ); +}; diff --git a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx index 273b94d64cf..cca65b27e44 100644 --- a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx @@ -1,5 +1,6 @@ "use client"; +import type { VideoCta } from "@cap/database/types"; import { LogoSpinner } from "@cap/ui"; import { calculateStrokeDashoffset, getProgressCircleConfig } from "@cap/utils"; import type { Video } from "@cap/web-domain"; @@ -14,6 +15,7 @@ import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { retryVideoProcessing } from "@/actions/video/retry-processing"; +import { CtaButton } from "./CtaButton"; import { getActiveCaptionText } from "./caption-cues"; import { canRetryFailedProcessing, @@ -103,6 +105,7 @@ interface Props { duration?: number | null; defaultPlaybackSpeed?: number; previewMode?: "background"; + cta?: VideoCta | null; } export function HLSVideoPlayer({ @@ -128,6 +131,7 @@ export function HLSVideoPlayer({ duration: fallbackDuration, defaultPlaybackSpeed, previewMode, + cta, }: Props) { const hlsInstance = useRef(null); const [currentCue, setCurrentCue] = useState(""); @@ -620,6 +624,7 @@ export function HLSVideoPlayer({ )} autoHide > + {!isBackgroundPreview && } {hasFailedOrError && (
diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 0ebedd59208..882007aef06 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -7,6 +7,7 @@ import { faChartSimple, faChevronDown, faLock, + faUpRightFromSquare, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -31,6 +32,7 @@ import { UpgradeModal } from "@/components/UpgradeModal"; import { usePublicEnv } from "@/utils/public-env"; import { navigateWithTransition } from "@/utils/view-transition"; import type { SharePageBranding, VideoData } from "../types"; +import { CtaDialog } from "./CtaDialog"; export const ShareHeader = ({ data, @@ -80,6 +82,7 @@ export const ShareHeader = ({ const [isTitleRevealing, setIsTitleRevealing] = useState(false); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [isSharingDialogOpen, setIsSharingDialogOpen] = useState(false); + const [isCtaDialogOpen, setIsCtaDialogOpen] = useState(false); const [linkCopied, setLinkCopied] = useState(false); const [showCopyOptions, setShowCopyOptions] = useState(false); const [capturedTime, setCapturedTime] = useState(0); @@ -443,6 +446,12 @@ export const ShareHeader = ({
)} + setIsCtaDialogOpen(false)} + videoId={data.id} + cta={data.metadata?.cta} + /> setIsSharingDialogOpen(false)} @@ -611,6 +620,18 @@ export const ShareHeader = ({ /> View analytics + )} diff --git a/packages/database/types/metadata.ts b/packages/database/types/metadata.ts index 471ec923499..b03607d784b 100644 --- a/packages/database/types/metadata.ts +++ b/packages/database/types/metadata.ts @@ -44,6 +44,8 @@ export interface VideoCta { url: string; } +export const MAX_CTA_LABEL_LENGTH = 40; + export type VideoEditRange = { start: number; end: number;