Skip to content

feat(share): configurable call-to-action button on shared videos#1907

Open
alexis-morain wants to merge 2 commits into
CapSoftware:mainfrom
alexis-morain:feat/video-cta
Open

feat(share): configurable call-to-action button on shared videos#1907
alexis-morain wants to merge 2 commits into
CapSoftware:mainfrom
alexis-morain:feat/video-cta

Conversation

@alexis-morain

@alexis-morain alexis-morain commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

What

Adds an optional call-to-action button in the top-right of the video player on the public share page (/s/[videoId]), similar to Loom's CTA. The video owner sets a label and an https link; viewers see the button and clicking it opens the link in a new tab.

Typical use: a "Book a meeting" / "Talk to sales" button linking to Cal.com, Calendly, etc.

How it works

  • Storage: a cta object on the existing videos.metadata JSON column. No schema migration.

    cta?: { enabled: boolean; label: string; url: string }
  • Config UI: a "Call to action" button in ShareHeader (owner-only) opens a dialog to toggle, set the label, and set the URL.

  • Server action editCta: owner-only (ownerId check), trims the label (max 40 chars), and validates the URL with new URL() requiring https:. Disabling removes the key.

  • Display: a small CtaButton overlay anchor (target="_blank" rel="noopener noreferrer") rendered inside both CapVideoPlayer (MP4) and HLSVideoPlayer (HLS), so it stays visible in fullscreen. Hidden in the background-preview variant.

Notes

  • Purely additive (+272 lines, 8 files), no new routes, no migration.
  • No code comments, Biome-clean, tsc -b passes.
  • Per-video scope for this first version. An org-wide default could be a follow-up.

Test plan

  • Owner adds a CTA (label + https URL) → button appears top-right.
  • Anonymous viewer sees the button; click opens the URL in a new tab.
  • Non-https URL rejected (client disables save + server throws).
  • Toggle off / save → button removed.
  • Visible in fullscreen.

🤖 Generated with Claude Code

Greptile Summary

This PR adds a configurable call-to-action button to the public video share page, allowing owners to set a label and https URL that viewers see as an overlay in the top-right of the player.

  • Server action (edit-cta.ts): ownership check, https-only URL validation via new URL(), label trim/truncation, and revalidatePath on both the share and dashboard routes.
  • Display (CtaButton.tsx): rendered inside both CapVideoPlayer and HLSVideoPlayer (guarded by !isBackgroundPreview in the HLS path); uses rel="noopener noreferrer" and React's auto-escaping so no XSS surface.
  • Config UI (CtaDialog.tsx): owner-only entry point gated by isOwner in ShareHeader; state resets correctly on dialog open; two minor UX/maintenance issues flagged below.

Confidence Score: 4/5

Safe to merge; the two findings are non-blocking quality notes in the dialog component only.

The core security path (ownership gate, https enforcement, XSS handling) is solid. The two findings are confined to CtaDialog.tsx: the Save button accepting non-https URLs before the server rejects them, and the duplicated MAX_LABEL_LENGTH constant that could silently drift. Neither affects correctness of the stored data or security.

apps/web/app/s/[videoId]/_components/CtaDialog.tsx — client-side URL validation and duplicated constant.

Important Files Changed

Filename Overview
apps/web/actions/videos/edit-cta.ts New server action with correct ownership check, https-only URL validation via new URL(), label trimming, and cache revalidation. Logic is sound.
apps/web/app/s/[videoId]/_components/CtaDialog.tsx Dialog resets state on open correctly; Save button not disabled for non-empty non-https URLs (server catches it with a toast, but UX could be better). MAX_LABEL_LENGTH is duplicated with the server action.
apps/web/app/s/[videoId]/_components/CtaButton.tsx Simple anchor overlay with rel="noopener noreferrer" and React's auto-escaping. Handles null/disabled cta gracefully.
apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx CTA button correctly guarded by !isBackgroundPreview; prop threaded cleanly.
apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx CTA button added unconditionally; CapVideoPlayer has no background-preview mode so the guard present in HLSVideoPlayer is not needed here.
apps/web/app/s/[videoId]/_components/ShareHeader.tsx "Call to action" button correctly placed inside the isOwner branch. CtaDialog mounted unconditionally but open-state controlled; server re-validates ownership anyway.
apps/web/app/s/[videoId]/_components/ShareVideo.tsx Passes data.metadata?.cta to both video players; no issues.
packages/database/types/metadata.ts New VideoCta interface added alongside VideoMetadata.cta optional field. Purely additive, no migration required.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/web/app/s/[videoId]/_components/CtaDialog.tsx:127
**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.

### Issue 2 of 2
apps/web/app/s/[videoId]/_components/CtaDialog.tsx:22
**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.

Reviews (1): Last reviewed commit: "feat(share): add a configurable call-to-..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

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 <noreply@anthropic.com>
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()).

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.

@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

- disable Save when the URL is not https (immediate inline feedback matching
  the server-side constraint, instead of only erroring on submit)
- dedupe the label length limit: export MAX_CTA_LABEL_LENGTH from
  @cap/database/types and use it in both the dialog and the server action

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant