feat(private-zone): add paid video moments
Docker Image / Build and Push Docker Image (push) Successful in 2m5s

This commit is contained in:
Codex
2026-07-24 20:17:28 +08:00
parent 30ab2c2c97
commit 0d5b5c17fa
25 changed files with 1578 additions and 43 deletions
@@ -0,0 +1,67 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PrivateZoneVideoPostCard } from "../private-zone-video-post-card";
const basePost = {
postId: "11111111-1111-1111-1111-111111111111",
characterId: "maya-tan",
caption: "Only for you.",
posterUrl: "https://storage.example/poster.jpg?token=short",
mediaType: "video" as const,
videoUrl: null,
videoMimeType: "video/mp4",
videoSizeBytes: 1024,
durationSeconds: 18,
unlockCostCredits: 100,
currency: "credits" as const,
locked: true,
unlocked: false,
availableForNewUnlock: true,
publishedAt: "2026-07-24T10:00:00+00:00",
};
describe("PrivateZoneVideoPostCard", () => {
it("shows caption and poster but no video source while locked", () => {
const html = renderToStaticMarkup(
<PrivateZoneVideoPostCard
post={basePost}
displayName="Maya"
avatarUrl="/images/avatar.png"
index={0}
isUnlocking={false}
onUnlock={() => undefined}
onPlaybackError={() => undefined}
/>,
);
expect(html).toContain("Only for you.");
expect(html).toContain("poster.jpg");
expect(html).toContain("Unlock for 100 credits");
expect(html).not.toContain("<video");
});
it("renders an inline controlled video only after unlock", () => {
const html = renderToStaticMarkup(
<PrivateZoneVideoPostCard
post={{
...basePost,
locked: false,
unlocked: true,
videoUrl: "https://storage.example/video.mp4?token=short",
}}
displayName="Maya"
avatarUrl="/images/avatar.png"
index={0}
isUnlocking={false}
onUnlock={() => undefined}
onPlaybackError={() => undefined}
/>,
);
expect(html).toContain("<video");
expect(html).toContain("controls");
expect(html).toContain("playsInline");
expect(html).not.toContain("autoplay");
});
});
+2
View File
@@ -1,4 +1,6 @@
export * from "./private-album-card";
export * from "./private-album-gallery";
export * from "./private-zone-video-post-card";
export * from "./status-card";
export * from "./unlock-confirm-dialog";
export * from "./video-post-unlock-dialog";
@@ -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" });
}
@@ -0,0 +1,54 @@
import type { PrivateZoneVideoPost } from "@/data/schemas/private-zone";
import styles from "../private-zone-screen.module.css";
export interface VideoPostUnlockDialogProps {
post: PrivateZoneVideoPost;
isUnlocking: boolean;
errorMessage: string | null;
onCancel: () => void;
onConfirm: () => void;
}
export function VideoPostUnlockDialog({
post,
isUnlocking,
errorMessage,
onCancel,
onConfirm,
}: VideoPostUnlockDialogProps) {
return (
<div className={styles.dialogOverlay} role="alertdialog" aria-modal="true">
<div className={styles.dialog}>
<h2 className={styles.dialogTitle}>Unlock this private video?</h2>
<p className={styles.dialogCopy}>
You&apos;ll use {post.unlockCostCredits} credits. The video stays in your
Private Zone after unlocking.
</p>
{errorMessage ? (
<p className={styles.dialogError}>{errorMessage}</p>
) : null}
<div className={styles.dialogActions}>
<button
type="button"
className={styles.dialogSecondary}
disabled={isUnlocking}
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
data-analytics-key="private_zone_video.unlock_confirm"
data-analytics-label="Confirm Private Zone video unlock"
className={styles.dialogPrimary}
disabled={isUnlocking}
onClick={onConfirm}
>
{isUnlocking ? "Unlocking..." : "Unlock"}
</button>
</div>
</div>
</div>
);
}
@@ -215,6 +215,38 @@
box-shadow: 0 8px 18px rgba(131, 72, 85, 0.08);
}
.contentTabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-bottom: 18px;
padding: 5px;
border: 1px solid rgba(255, 116, 159, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 10px 24px rgba(131, 72, 85, 0.07);
}
.contentTab {
min-height: 42px;
padding: 0 16px;
border: 0;
border-radius: 999px;
background: transparent;
color: #8a7078;
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 850;
transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.contentTabActive {
background: linear-gradient(135deg, #ff7aa9, #ffad79);
color: #ffffff;
box-shadow: 0 8px 18px rgba(248, 77, 150, 0.2);
}
.timeline {
display: flex;
flex-direction: column;
@@ -263,6 +295,118 @@
margin: 0;
}
.momentVideo,
.momentLockedMedia {
width: 100%;
margin-top: 14px;
border-radius: clamp(18px, 4.815vw, 26px);
background: #161316;
box-shadow: 0 16px 34px rgba(34, 25, 29, 0.16);
}
.momentVideo {
display: block;
max-height: 68vh;
aspect-ratio: 9 / 12;
object-fit: contain;
}
.momentLockedMedia {
position: relative;
aspect-ratio: 9 / 12;
overflow: hidden;
}
.momentPoster,
.momentPosterFallback {
display: block;
width: 100%;
height: 100%;
}
.momentPoster {
object-fit: cover;
}
.momentPosterFallback {
display: grid;
place-items: center;
background:
radial-gradient(circle at 70% 15%, rgba(255, 179, 109, 0.34), transparent 42%),
linear-gradient(145deg, #4f4249, #211b20);
color: rgba(255, 255, 255, 0.78);
}
.momentLockPanel {
position: absolute;
right: 14px;
bottom: 14px;
left: 14px;
display: flex;
min-height: 164px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 7px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 22px;
background: rgba(23, 19, 23, 0.7);
color: #ffffff;
text-align: center;
backdrop-filter: blur(14px);
}
.momentLockIcon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
}
.momentLockPanel strong {
font-size: 20px;
font-weight: 900;
}
.momentLockPanel span:not(.momentLockIcon) {
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-weight: 650;
}
.momentLockPanel button,
.loadMoreButton {
min-height: 44px;
padding: 0 22px;
border: 0;
border-radius: 999px;
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 850;
}
.momentLockPanel button {
margin-top: 9px;
background: #ffffff;
color: #2c2227;
}
.momentLockPanel button:disabled {
cursor: wait;
opacity: 0.7;
}
.loadMoreButton {
display: block;
margin: 16px auto 0;
background: rgba(255, 93, 149, 0.12);
color: #a94c64;
}
.authorName {
color: #26191d;
font-size: clamp(14px, 3.333vw, 17px);
@@ -776,6 +920,9 @@
}
.primaryCta:focus-visible,
.contentTab:focus-visible,
.momentLockPanel button:focus-visible,
.loadMoreButton:focus-visible,
.lockedPreviewCta:focus-visible,
.mediaGridItem:focus-visible,
.galleryClose:focus-visible,
+199 -37
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
@@ -18,17 +18,24 @@ import {
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { isPrivateAlbumLocked } from "@/lib/private-zone/private_album";
import { behaviorAnalytics } from "@/lib/analytics";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import {
usePrivateZoneDispatch,
usePrivateZoneState,
} from "@/stores/private-zone";
import {
usePrivateZonePostDispatch,
usePrivateZonePostState,
} from "@/stores/private-zone-posts";
import {
PrivateAlbumCard,
PrivateAlbumGallery,
PrivateZoneVideoPostCard,
StatusCard,
UnlockConfirmDialog,
VideoPostUnlockDialog,
} from "./components";
import styles from "./private-zone-screen.module.css";
import {
@@ -54,11 +61,16 @@ export function PrivateZoneScreen() {
const authDispatch = useAuthDispatch();
const state = usePrivateZoneState();
const dispatch = usePrivateZoneDispatch();
const postState = usePrivateZonePostState();
const postDispatch = usePrivateZonePostDispatch();
const [activeTab, setActiveTab] = useState<"moments" | "albums">("moments");
const openedGalleryInPageRef = useRef(false);
const previousPostLoginStatusRef = useRef(authState.loginStatus);
const galleryState = useMemo(
() => getPrivateAlbumGalleryState(searchParams),
[searchParams],
);
const visibleTab = galleryState ? "albums" : activeTab;
usePrivateZoneBootstrapFlow({
authDispatch,
@@ -74,6 +86,56 @@ export function PrivateZoneScreen() {
unlockPaywallRequest: state.unlockPaywallRequest,
});
usePrivateZoneUnlockSuccessRefresh(state.unlockSuccessNonce);
usePrivateZoneUnlockSuccessRefresh(postState.unlockSuccessNonce);
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
if (authState.loginStatus === "notLoggedIn") return;
const previous = previousPostLoginStatusRef.current;
previousPostLoginStatusRef.current = authState.loginStatus;
if (postState.status === "idle") {
postDispatch({ type: "PrivateZonePostInit" });
return;
}
if (previous !== authState.loginStatus && postState.status !== "loading") {
postDispatch({ type: "PrivateZonePostRefresh" });
}
}, [
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
postDispatch,
postState.status,
]);
useEffect(() => {
const request = postState.unlockPaywallRequest;
if (!request) return;
if (isPrivateZoneAuthRequired(authState.loginStatus)) {
navigator.openAuth(characterRoutes.privateZone);
} else {
behaviorAnalytics.paywallShown({
entryPoint: "private_zone",
triggerReason: "insufficient_credits",
});
navigator.openSubscription({
type: "topup",
returnTo: "private-zone",
analytics: {
entryPoint: "private_zone",
triggerReason: "insufficient_credits",
},
});
}
postDispatch({ type: "PrivateZonePostUnlockPaywallConsumed" });
}, [
authState.loginStatus,
characterRoutes.privateZone,
navigator,
postDispatch,
postState.unlockPaywallRequest,
]);
const displayName = character.displayName;
const avatarUrl = character.assets.avatar;
@@ -86,6 +148,13 @@ export function PrivateZoneScreen() {
) ?? null,
[state.items, state.pendingConfirmAlbumId],
);
const pendingPost = useMemo(
() =>
postState.items.find(
(post) => post.postId === postState.pendingConfirmPostId,
) ?? null,
[postState.items, postState.pendingConfirmPostId],
);
const galleryAlbum = useMemo(
() =>
galleryState
@@ -157,6 +226,14 @@ export function PrivateZoneScreen() {
);
};
const handlePostUnlock = (postId: string) => {
if (isPrivateZoneAuthRequired(authState.loginStatus)) {
navigator.openAuth(characterRoutes.privateZone);
return;
}
postDispatch({ type: "PrivateZonePostUnlockRequested", postId });
};
const handleCloseGallery = () => {
if (openedGalleryInPageRef.current) {
openedGalleryInPageRef.current = false;
@@ -235,49 +312,116 @@ export function PrivateZoneScreen() {
</section>
<section className={styles.postsSection} aria-labelledby="posts-title">
<div className={styles.contentTabs} role="tablist" aria-label="Private Zone content">
<button
type="button"
role="tab"
aria-selected={visibleTab === "moments"}
className={`${styles.contentTab} ${visibleTab === "moments" ? styles.contentTabActive : ""}`}
onClick={() => setActiveTab("moments")}
>
Moments
</button>
<button
type="button"
role="tab"
aria-selected={visibleTab === "albums"}
className={`${styles.contentTab} ${visibleTab === "albums" ? styles.contentTabActive : ""}`}
onClick={() => setActiveTab("albums")}
>
Albums
</button>
</div>
<div className={styles.sectionHeader}>
<div>
<p className={styles.kicker}>Albums</p>
<p className={styles.kicker}>{visibleTab === "moments" ? "Moments" : "Albums"}</p>
<h2 id="posts-title" className={styles.sectionTitle}>
Private albums
{visibleTab === "moments" ? "Private moments" : "Private albums"}
</h2>
</div>
<span className={styles.postCount}>{state.items.length}</span>
<span className={styles.postCount}>
{visibleTab === "moments" ? postState.items.length : state.items.length}
</span>
</div>
{state.errorMessage ? (
<StatusCard
message={state.errorMessage}
actionLabel="Retry"
onAction={() => dispatch({ type: "PrivateZoneRefresh" })}
/>
) : null}
{state.isLoading && state.items.length === 0 ? (
<StatusCard message="Loading private albums..." />
) : null}
<div className={styles.timeline}>
{state.items.map((album, index) => (
<PrivateAlbumCard
key={album.albumId}
album={album}
displayName={displayName}
avatarUrl={avatarUrl}
index={index}
isUnlocking={state.unlockingAlbumId === album.albumId}
onOpenGallery={(imageIndex) =>
handleOpenGallery(album.albumId, imageIndex)
}
onUnlock={() =>
dispatch({
type: "PrivateZoneUnlockRequested",
albumId: album.albumId,
})
}
/>
))}
</div>
{visibleTab === "moments" ? (
<>
{postState.errorMessage ? (
<StatusCard
message={postState.errorMessage}
actionLabel="Retry"
onAction={() => postDispatch({ type: "PrivateZonePostRefresh" })}
/>
) : null}
{postState.isLoading && postState.items.length === 0 ? (
<StatusCard message="Loading private moments..." />
) : null}
{!postState.isLoading && !postState.errorMessage && postState.items.length === 0 ? (
<StatusCard message="No private moments yet." />
) : null}
<div className={styles.timeline}>
{postState.items.map((post, index) => (
<PrivateZoneVideoPostCard
key={post.postId}
post={post}
displayName={displayName}
avatarUrl={avatarUrl}
index={index}
isUnlocking={postState.unlockingPostId === post.postId}
onUnlock={() => handlePostUnlock(post.postId)}
onPlaybackError={() =>
postDispatch({ type: "PrivateZonePostRefresh" })
}
/>
))}
</div>
{postState.hasMore ? (
<button
type="button"
className={styles.loadMoreButton}
disabled={postState.isLoadingMore}
onClick={() => postDispatch({ type: "PrivateZonePostLoadMore" })}
>
{postState.isLoadingMore ? "Loading..." : "Load more"}
</button>
) : null}
</>
) : (
<>
{state.errorMessage ? (
<StatusCard
message={state.errorMessage}
actionLabel="Retry"
onAction={() => dispatch({ type: "PrivateZoneRefresh" })}
/>
) : null}
{state.isLoading && state.items.length === 0 ? (
<StatusCard message="Loading private albums..." />
) : null}
<div className={styles.timeline}>
{state.items.map((album, index) => (
<PrivateAlbumCard
key={album.albumId}
album={album}
displayName={displayName}
avatarUrl={avatarUrl}
index={index}
isUnlocking={state.unlockingAlbumId === album.albumId}
onOpenGallery={(imageIndex) =>
handleOpenGallery(album.albumId, imageIndex)
}
onUnlock={() =>
dispatch({
type: "PrivateZoneUnlockRequested",
albumId: album.albumId,
})
}
/>
))}
</div>
</>
)}
</section>
<AppBottomNav
@@ -309,6 +453,24 @@ export function PrivateZoneScreen() {
</div>
) : null}
{pendingPost ? (
<VideoPostUnlockDialog
post={pendingPost}
isUnlocking={postState.isUnlocking}
errorMessage={postState.unlockErrorMessage}
onCancel={() =>
postDispatch({ type: "PrivateZonePostUnlockCancelled" })
}
onConfirm={() =>
postDispatch({ type: "PrivateZonePostUnlockConfirmed" })
}
/>
) : postState.unlockErrorMessage ? (
<div className={styles.toast} role="status">
{postState.unlockErrorMessage}
</div>
) : null}
{galleryAlbum &&
galleryState &&
!isPrivateAlbumLocked(galleryAlbum) &&