buildmymcpserver/apps/web/components/hero-video.tsx
Marco Sadjadi 17056d0b30 feat(landing): honest high-end redesign — kill fake social proof, brand gradient identity, final CTA, mobile fixes
- removed fabricated fork counts/'verified' badges, 'our customers ship
  today' copy, pseudo client logo marks and stale v0.1 badge (zero-user
  product must not fake traction)
- new proof-by-specificity band: verifiable links to /docs/oauth, /status,
  /security instead of testimonial cosplay
- identity: indigo-to-cyan brand gradient, indigo-tinted elevated surfaces,
  mono section kickers, terminal-chrome hero rotator, gradient h-11 CTA
- how-it-works as connected pipeline; new final CTA band (page no longer
  ends on FAQ); footer upgraded to 4-column product/resources/legal
- lib/pricing.ts single tier source for pricing page + landing teaser;
  annual-billing FAQ claim softened to truth (no annual checkout exists)
- hero video preload=metadata + IntersectionObserver play (2.6MB off the
  critical path); 44px touch targets; mobile menu: Guides link, CTA,
  scroll-lock, escape/outside-tap close

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXUwmPVRTD8AKQtio6gCN5
2026-07-08 23:00:25 +02:00

341 lines
13 KiB
TypeScript

'use client';
import { ExternalLink, Pause, Play, Volume2, VolumeX } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Hero video player tuned for the stubborn-autoplay cases we hit in
* the field (Chrome with prefers-reduced-motion, data saver, etc.).
*
* Three failure modes we defend against:
*
* 1. Browser allows muted autoplay — the happy path. Element loads,
* starts playing, the `play` event flips `playing` to true and
* hides the overlay.
*
* 2. Browser blocks autoplay but allows playback on user gesture —
* the play overlay sits over the poster, the user clicks, we
* call `.load()` first to reset the resource-selection state and
* then `.play()` with the user gesture in scope.
*
* 3. Browser refuses to play even after the gesture — we catch the
* promise rejection and surface a small "open video in a new tab"
* link so the visitor isn't completely stuck.
*
* Controls: a deliberately subtle bottom bar (play/pause, elapsed time,
* a seek slider, mute) that stays out of the way — it only fades in on
* hover (desktop) or tap (touch) and auto-hides ~2.8s after the last
* interaction while playing; while paused it stays put. The seek slider
* is a real <input type=range> (keyboard + drag + touch, accessible)
* laid invisibly over a custom-drawn track so the look matches the rest
* of the page rather than the chrome of a native control bar.
*
* Source order: MP4 only. We previously offered WebM (VP9) first as a
* size win, but Chrome will pick WebM if listed first, and if that
* decode fails it does NOT fall back to MP4 — it just sits unloaded.
*/
const CONTROLS_HIDE_MS = 2800;
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
export function HeroVideo() {
const videoRef = useRef<HTMLVideoElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Set when the user explicitly pauses — the IntersectionObserver must not
// restart a video the user chose to stop.
const userPausedRef = useRef(false);
const [muted, setMuted] = useState(true);
const [playing, setPlaying] = useState(false);
const [playFailed, setPlayFailed] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [controlsVisible, setControlsVisible] = useState(false);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const onPlay = () => {
setPlaying(true);
setPlayFailed(false);
};
const onPause = () => setPlaying(false);
const onTime = () => setCurrentTime(v.currentTime);
const onMeta = () => setDuration(Number.isFinite(v.duration) ? v.duration : 0);
v.addEventListener('play', onPlay);
v.addEventListener('pause', onPause);
v.addEventListener('timeupdate', onTime);
v.addEventListener('loadedmetadata', onMeta);
v.addEventListener('durationchange', onMeta);
// Play only while scrolled into view: with preload="metadata" the 2.6 MB
// mp4 stays off the critical path until the section is actually visible,
// and playback pauses again off-screen (saves battery + decode). The
// .play() attempt is still best-effort — browsers that block autoplay
// leave the overlay as the user's escape hatch. `userPaused` tracks an
// explicit pause so the observer doesn't fight the user's choice.
const io = new IntersectionObserver(
([entry]) => {
if (!entry) return;
if (entry.isIntersecting) {
if (!userPausedRef.current && v.paused) v.play().catch(() => undefined);
} else if (!v.paused) {
v.pause();
}
},
{ threshold: 0.35 },
);
io.observe(v);
return () => {
io.disconnect();
v.removeEventListener('play', onPlay);
v.removeEventListener('pause', onPause);
v.removeEventListener('timeupdate', onTime);
v.removeEventListener('loadedmetadata', onMeta);
v.removeEventListener('durationchange', onMeta);
};
}, []);
// Clear any pending hide timer on unmount.
useEffect(
() => () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
},
[],
);
const scheduleHide = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => setControlsVisible(false), CONTROLS_HIDE_MS);
}, []);
const revealControls = useCallback(() => {
setControlsVisible(true);
scheduleHide();
}, [scheduleHide]);
const hideControlsNow = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current);
setControlsVisible(false);
}, []);
const togglePlay = useCallback(async () => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
setPlayFailed(false);
userPausedRef.current = false;
try {
// .load() resets the media element's resource selection — required
// when an earlier autoplay attempt was blocked before the source
// ever fetched. Without it, .play() on a stuck element no-ops.
v.load();
await v.play();
} catch {
setPlayFailed(true);
}
} else {
userPausedRef.current = true;
v.pause();
}
}, []);
// Click anywhere on the frame toggles playback (YouTube/Vimeo pattern) and
// reveals the controls — on touch, where there's no hover, this is how the
// bar surfaces.
const onVideoClick = useCallback(() => {
revealControls();
void togglePlay();
}, [revealControls, togglePlay]);
const toggleMute = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
const v = videoRef.current;
if (!v) return;
const next = !v.muted;
v.muted = next;
setMuted(next);
// Restart from frame 0 whenever the audio state toggles, so the narration
// and the silent loop both line up with the animation from the top.
v.currentTime = 0;
if (v.paused) v.play().catch(() => undefined);
}, []);
const onSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = videoRef.current;
if (!v) return;
const t = Number(e.currentTarget.value);
if (!Number.isFinite(t)) return;
v.currentTime = t;
setCurrentTime(t);
}, []);
const pct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
// Bar shows on hover/tap; while paused it stays up so the scrubber is reachable.
const barShown = controlsVisible || !playing;
return (
<div
className="absolute inset-0"
onPointerEnter={revealControls}
onPointerMove={revealControls}
onPointerLeave={() => {
if (playing) hideControlsNow();
}}
>
<video
ref={videoRef}
muted
loop
playsInline
preload="metadata"
poster="/videos/hero-poster.jpg"
onClick={onVideoClick}
className="size-full cursor-pointer object-cover"
aria-label="Animation: a prompt becomes a live MCP server, with secrets staying isolated from the AI pipeline"
>
{/* MP4 only — see file-header note on dropping the WebM source. */}
<source src="/videos/hero.mp4" type="video/mp4" />
</video>
{/* PLAY overlay — visible while paused. Full-frame so clicking
anywhere starts playback. */}
{!playing && (
<button
type="button"
onClick={onVideoClick}
aria-label="Play video"
className="group absolute inset-0 z-10 flex items-center justify-center"
style={{
backgroundColor: 'color-mix(in oklab, var(--color-bg) 30%, transparent)',
}}
>
<div
className="flex size-20 items-center justify-center rounded-full border backdrop-blur transition-transform duration-200 ease-out group-hover:scale-110"
style={{
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 80%, transparent)',
borderColor: 'var(--color-accent)',
boxShadow: '0 0 32px rgba(99, 102, 241, 0.45), 0 12px 40px rgba(0,0,0,0.55)',
color: 'var(--color-accent)',
}}
>
{/* translate-x-0.5 optically centres the triangle */}
<Play size={32} fill="currentColor" className="translate-x-0.5" />
</div>
</button>
)}
{/* Fallback escape hatch — surfaces only if .play() rejects even after a
user gesture (extension sandbox, hardware-decoder failures, etc.). */}
{playFailed && !playing && (
<a
href="/videos/hero.mp4"
target="_blank"
rel="noopener noreferrer"
className="absolute inset-x-0 bottom-20 z-20 mx-auto flex w-fit items-center gap-2 rounded-md border px-3 py-2 text-[12px] backdrop-blur hover:text-[--color-fg]"
style={{
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 85%, transparent)',
borderColor: 'var(--color-border-strong)',
color: 'var(--color-fg-muted)',
}}
>
your browser blocked playback. open the video directly
<ExternalLink size={12} />
</a>
)}
{/* Controls bar — subtle, reveals on hover/tap, auto-hides while playing.
pointer-events-none when hidden so it never swallows a frame click. */}
<div
className={`absolute inset-x-0 bottom-0 z-30 px-4 pb-3 pt-10 transition-[opacity,transform] duration-200 ease-out ${
barShown ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-1 opacity-0'
}`}
style={{
background:
'linear-gradient(to top, color-mix(in oklab, var(--color-bg) 72%, transparent), transparent)',
}}
>
<div className="flex items-center gap-3">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
revealControls();
void togglePlay();
}}
aria-label={playing ? 'Pause video' : 'Play video'}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full border backdrop-blur transition-colors duration-150 hover:text-[--color-fg] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--color-accent]"
style={{
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
borderColor: 'var(--color-border)',
color: 'var(--color-fg-muted)',
}}
>
{playing ? (
<Pause size={16} fill="currentColor" />
) : (
<Play size={16} fill="currentColor" className="translate-x-px" />
)}
</button>
<span
className="mono shrink-0 text-[11px] tabular-nums"
style={{ color: 'var(--color-fg-muted)' }}
>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
{/* Seek: custom-drawn rail + fill + thumb, with a transparent native
range on top carrying all the interaction (drag, touch, keyboard). */}
<div className="group/seek relative flex h-4 flex-1 items-center">
<div
className="h-1 w-full overflow-hidden rounded-full"
style={{ backgroundColor: 'color-mix(in oklab, var(--color-fg) 22%, transparent)' }}
>
<div
className="h-full rounded-full"
style={{ width: `${pct}%`, backgroundColor: 'var(--color-accent)' }}
/>
</div>
<div
aria-hidden
className="pointer-events-none absolute size-3 -translate-x-1/2 rounded-full opacity-0 shadow transition-opacity duration-150 group-hover/seek:opacity-100 group-focus-within/seek:opacity-100"
style={{ left: `${pct}%`, backgroundColor: 'var(--color-accent)' }}
/>
<input
type="range"
min={0}
max={duration || 0}
step="any"
value={Math.min(currentTime, duration || 0)}
onChange={onSeek}
onFocus={revealControls}
aria-label="Seek video"
className="absolute inset-0 size-full cursor-pointer appearance-none bg-transparent opacity-0 focus:outline-none"
/>
</div>
<button
type="button"
onClick={toggleMute}
aria-label={muted ? 'Unmute video' : 'Mute video'}
aria-pressed={!muted}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full border backdrop-blur transition-colors duration-150 hover:text-[--color-fg] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--color-accent]"
style={{
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
borderColor: 'var(--color-border)',
color: muted ? 'var(--color-fg-muted)' : 'var(--color-accent)',
}}
>
{muted ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
</div>
</div>
</div>
);
}