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.

Demo

The owner sets a label + https link; viewers see the button in the top-right of the player (here linking to a booking page). Shown on the public share page:

Call-to-action button on the video share page

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

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>
@alexis-morain

Copy link
Copy Markdown
Contributor Author

Checking in on this one. It adds an optional Loom-style call-to-action button to the share page (owner sets a label + https link, stored on the existing videos.metadata JSON, no schema migration). The Greptile review feedback (https validation on Save, dedup of MAX_CTA_LABEL_LENGTH) has been addressed, and CI is green apart from the Vercel deploy check that needs maintainer authorization.

Before going further: is this a feature the team would want upstream? If so I'm happy to add a short demo GIF and adjust the button placement / styling to match your design conventions.

alexis-morain added a commit to alexis-morain/Cap that referenced this pull request Jun 13, 2026
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