-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat(share): configurable call-to-action button on shared videos #1907
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }; | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Save button is disabled only when Prompt To Fix With AIThis 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| variant="dark" | ||
| onClick={handleSave} | ||
| disabled={ | ||
| isSaving || | ||
| (enabled && (!label.trim() || !url.trim().startsWith("https://"))) | ||
| } | ||
| > | ||
| {isSaving ? "Saving..." : "Save"} | ||
| </Button> | ||
| </DialogFooter> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MAX_LABEL_LENGTHconstantThe same value (
40) is declared independently inCtaDialog.tsxand inapps/web/actions/videos/edit-cta.ts. If either is updated without touching the other, themaxLengthHTML attribute on the input and the server-sideslice(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
There was a problem hiding this comment.
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_LENGTHis now imported from@cap/database/typesinstead of being redeclared locally, so there is a single source of truth shared with the server action.