feat(private-zone): add paid video moments

This commit is contained in:
Codex
2026-07-24 20:17:28 +08:00
parent 76bffc1a0d
commit 38a4645b9c
25 changed files with 1571 additions and 41 deletions
@@ -0,0 +1,125 @@
import type { CSSProperties } from "react";
import { LockKeyhole, Play } from "lucide-react";
import { CharacterAvatar } from "@/app/_components";
import type { PrivateZoneVideoPost } from "@/data/schemas/private-zone";
import styles from "../private-zone-screen.module.css";
export interface PrivateZoneVideoPostCardProps {
post: PrivateZoneVideoPost;
displayName: string;
avatarUrl: string;
index: number;
isUnlocking: boolean;
onUnlock: () => void;
onPlaybackError: () => void;
}
export function PrivateZoneVideoPostCard({
post,
displayName,
avatarUrl,
index,
isUnlocking,
onUnlock,
onPlaybackError,
}: PrivateZoneVideoPostCardProps) {
return (
<article
className={styles.postCard}
style={{ "--reveal-index": index } as CSSProperties}
>
<header className={styles.postHeader}>
<div className={styles.postAuthor}>
<CharacterAvatar
src={avatarUrl}
alt=""
size={38}
imageSize={38}
className={styles.postAvatar}
/>
<div>
<p className={styles.authorName}>{displayName}</p>
<p className={styles.authorSubline}>Private moment</p>
</div>
</div>
<time className={styles.postTime}>{formatMomentTime(post.publishedAt)}</time>
</header>
{post.caption ? <p className={styles.postText}>{post.caption}</p> : null}
{post.unlocked && post.videoUrl ? (
<video
className={styles.momentVideo}
controls
playsInline
preload="metadata"
poster={post.posterUrl ?? undefined}
onError={onPlaybackError}
>
<source src={post.videoUrl} type={post.videoMimeType || "video/mp4"} />
Your browser cannot play this video.
</video>
) : (
<div className={styles.momentLockedMedia}>
{post.posterUrl ? (
// Signed private media URLs intentionally bypass Next image optimization.
// eslint-disable-next-line @next/next/no-img-element
<img
className={styles.momentPoster}
src={post.posterUrl}
alt="Private video cover"
/>
) : (
<div className={styles.momentPosterFallback}>
<Play size={34} aria-hidden="true" />
</div>
)}
<div className={styles.momentLockPanel}>
<span className={styles.momentLockIcon} aria-hidden="true">
<LockKeyhole size={18} />
</span>
<strong>Private video</strong>
<span>
{post.durationSeconds == null
? "Unlock to watch"
: `${formatDuration(post.durationSeconds)} · Unlock to watch`}
</span>
<button
type="button"
data-analytics-key="private_zone_video.unlock"
data-analytics-label="Unlock Private Zone video moment"
disabled={isUnlocking || !post.availableForNewUnlock}
onClick={onUnlock}
>
{isUnlocking
? "Unlocking..."
: post.availableForNewUnlock
? `Unlock for ${post.unlockCostCredits} credits`
: "No longer available"}
</button>
</div>
</div>
)}
</article>
);
}
function formatDuration(seconds: number): string {
const safe = Math.max(0, Math.round(seconds));
const minutes = Math.floor(safe / 60);
const remainder = String(safe % 60).padStart(2, "0");
return `${minutes}:${remainder}`;
}
function formatMomentTime(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "Just now";
const seconds = Math.max(0, Math.floor((Date.now() - parsed.getTime()) / 1000));
if (seconds < 60) return "Just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d`;
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}