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
+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) &&