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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicated MAX_LABEL_LENGTH constant

The same value (40) is declared independently in CtaDialog.tsx and in apps/web/actions/videos/edit-cta.ts. If either is updated without touching the other, the maxLength HTML attribute on the input and the server-side slice(0, MAX_LABEL_LENGTH) will silently diverge, potentially allowing the client to accept labels that the server then truncates. Exporting the constant from one canonical location (e.g. the types package or the server action) and importing it in the dialog would keep them in sync.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/s/[videoId]/_components/CtaDialog.tsx
Line: 22

Comment:
**Duplicated `MAX_LABEL_LENGTH` constant**

The same value (`40`) is declared independently in `CtaDialog.tsx` and in `apps/web/actions/videos/edit-cta.ts`. If either is updated without touching the other, the `maxLength` HTML attribute on the input and the server-side `slice(0, MAX_LABEL_LENGTH)` will silently diverge, potentially allowing the client to accept labels that the server then truncates. Exporting the constant from one canonical location (e.g. the types package or the server action) and importing it in the dialog would keep them in sync.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6a6b5dc: MAX_CTA_LABEL_LENGTH is now imported from @cap/database/types instead of being redeclared locally, so there is a single source of truth shared with the server action.

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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Save enabled for non-https URLs

The Save button is disabled only when url is empty, but not when it contains a valid-looking non-https value (e.g. http://example.com). In that case the button is enabled, the server action runs, throws "CTA URL must start with https://", and the user sees a toast error. Adding !url.trim().startsWith("https://") to the disabled condition provides immediate inline feedback that matches the server-side constraint.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/s/[videoId]/_components/CtaDialog.tsx
Line: 127

Comment:
**Save enabled for non-https URLs**

The Save button is disabled only when `url` is empty, but not when it contains a valid-looking non-https value (e.g. `http://example.com`). In that case the button is enabled, the server action runs, throws "CTA URL must start with https://", and the user sees a toast error. Adding `!url.trim().startsWith("https://")` to the disabled condition provides immediate inline feedback that matches the server-side constraint.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6a6b5dc: the Save button is now disabled when the URL does not start with https:// (the same validation editCta enforces server-side via new URL()).

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