Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
75 changes: 75 additions & 0 deletions apps/web/actions/videos/edit-cta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use server";

import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { videos } from "@cap/database/schema";
import {
MAX_CTA_LABEL_LENGTH,
type VideoCta,
type VideoMetadata,
} from "@cap/database/types";
import type { Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";

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_CTA_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 };
}
5 changes: 5 additions & 0 deletions apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,6 +14,7 @@
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,
Expand Down Expand Up @@ -115,6 +117,7 @@
showPlaybackStatusBadge?: boolean;
showFloatingVolumeControl?: boolean;
onUploadComplete?: () => void;
cta?: VideoCta | null;
}

export function CapVideoPlayer({
Expand Down Expand Up @@ -148,6 +151,7 @@
showPlaybackStatusBadge = false,
showFloatingVolumeControl = false,
onUploadComplete,
cta,
}: Props) {
const [currentCue, setCurrentCue] = useState<string>("");
const [controlsVisible, setControlsVisible] = useState(false);
Expand Down Expand Up @@ -622,6 +626,7 @@
)}
autoHide
>
<CtaButton cta={cta} />
{showUploadFailureOverlay && (
<div className="flex absolute inset-0 flex-col px-3 gap-3 z-[20] justify-center items-center bg-black transition-opacity duration-300">
<AlertTriangleIcon className="text-red-500 size-12" />
Expand Down Expand Up @@ -651,7 +656,7 @@
<div
className={clsx(
"flex absolute inset-0 z-10 rounded-xl justify-center items-center bg-black transition-opacity duration-300 overflow-visible",
videoLoaded || !!uploadProgress || !showPreparingOverlay

Check notice on line 659 in apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx

View workflow job for this annotation

GitHub Actions / Lint (Biome)

lint/complexity/noExtraBooleanCast

Avoid redundant double-negation.
? "opacity-0 pointer-events-none"
: "opacity-100",
)}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/app/s/[videoId]/_components/CtaButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<a
href={cta.url}
target="_blank"
rel="noopener noreferrer"
className="absolute top-3 right-3 z-30 inline-flex items-center gap-2 rounded-full bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-lg transition-colors hover:bg-blue-600"
>
<span className="truncate max-w-[200px]">{cta.label}</span>
<FontAwesomeIcon className="size-3" icon={faArrowUpRightFromSquare} />
</a>
);
}
139 changes: 139 additions & 0 deletions apps/web/app/s/[videoId]/_components/CtaDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"use client";

import { MAX_CTA_LABEL_LENGTH, 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";

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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="p-0 w-full max-w-md rounded-xl border bg-gray-2 border-gray-4">
<DialogHeader
icon={<FontAwesomeIcon icon={faUpRightFromSquare} />}
description="Show a button in the top-right of your video that links anywhere you like."
>
<DialogTitle>Call to action</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 p-5">
<div className="flex justify-between items-center">
<Label htmlFor={enabledId}>Show call to action</Label>
<Switch
id={enabledId}
checked={enabled}
onCheckedChange={setEnabled}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={labelId}>Button label</Label>
<Input
id={labelId}
placeholder="Book a meeting"
value={label}
maxLength={MAX_CTA_LABEL_LENGTH}
disabled={!enabled}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={urlId}>Link (https)</Label>
<Input
id={urlId}
type="url"
placeholder="https://cal.com/your-handle"
value={url}
disabled={!enabled}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
</div>
<DialogFooter className="p-5 border-t border-gray-4">
<Button
size="sm"
variant="gray"
onClick={onClose}
disabled={isSaving}
>
Cancel
</Button>
<Button
size="sm"
variant="dark"
onClick={handleSave}
disabled={
isSaving ||
(enabled && (!label.trim() || !url.trim().startsWith("https://")))
}
>
{isSaving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
5 changes: 5 additions & 0 deletions apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -103,6 +105,7 @@ interface Props {
duration?: number | null;
defaultPlaybackSpeed?: number;
previewMode?: "background";
cta?: VideoCta | null;
}

export function HLSVideoPlayer({
Expand All @@ -128,6 +131,7 @@ export function HLSVideoPlayer({
duration: fallbackDuration,
defaultPlaybackSpeed,
previewMode,
cta,
}: Props) {
const hlsInstance = useRef<Hls | null>(null);
const [currentCue, setCurrentCue] = useState<string>("");
Expand Down Expand Up @@ -620,6 +624,7 @@ export function HLSVideoPlayer({
)}
autoHide
>
{!isBackgroundPreview && <CtaButton cta={cta} />}
{hasFailedOrError && (
<div className="flex absolute inset-0 flex-col px-3 gap-3 z-[20] justify-center items-center bg-black transition-opacity duration-300">
<AlertTriangleIcon className="text-red-500 size-12" />
Expand Down
21 changes: 21 additions & 0 deletions apps/web/app/s/[videoId]/_components/ShareHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -443,6 +446,12 @@ export const ShareHeader = ({
</Button>
</div>
)}
<CtaDialog
isOpen={isCtaDialogOpen}
onClose={() => setIsCtaDialogOpen(false)}
videoId={data.id}
cta={data.metadata?.cta}
/>
<SharingDialog
isOpen={isSharingDialogOpen}
onClose={() => setIsSharingDialogOpen(false)}
Expand Down Expand Up @@ -611,6 +620,18 @@ export const ShareHeader = ({
/>
View analytics
</Button>
<Button
variant="gray"
size="xs"
className="h-8 gap-1.5 rounded-full px-2.5 text-xs"
onClick={() => setIsCtaDialogOpen(true)}
>
<FontAwesomeIcon
className="size-3.5 text-gray-12"
icon={faUpRightFromSquare}
/>
Call to action
</Button>
</>
)}
<Button
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/s/[videoId]/_components/ShareVideo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ export const ShareVideo = forwardRef<
isCaptionLoading={captionContext.isTranslating}
hasCaptions={data.transcriptionStatus === "COMPLETE"}
canRetryProcessing={canRetryProcessing}
cta={data.metadata?.cta}
/>
) : (
<HLSVideoPlayer
Expand All @@ -443,6 +444,7 @@ export const ShareVideo = forwardRef<
isCaptionLoading={captionContext.isTranslating}
hasCaptions={data.transcriptionStatus === "COMPLETE"}
canRetryProcessing={canRetryProcessing}
cta={data.metadata?.cta}
/>
)}
{showFinalizeRecordingControl && (
Expand Down
Loading
Loading