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
+35 -3
View File
@@ -13,6 +13,7 @@ const characters = [
displayName: "Elio Silvestri", displayName: "Elio Silvestri",
shortName: "Elio", shortName: "Elio",
cover: "elio.png", cover: "elio.png",
splashCover: "elio.png",
}, },
{ {
id: "maya-tan", id: "maya-tan",
@@ -20,6 +21,7 @@ const characters = [
displayName: "Maya Tan", displayName: "Maya Tan",
shortName: "Maya", shortName: "Maya",
cover: "maya.webp", cover: "maya.webp",
splashCover: "maya-home.webp",
}, },
{ {
id: "nayeli-cervantes", id: "nayeli-cervantes",
@@ -27,6 +29,7 @@ const characters = [
displayName: "Nayeli Cervantes", displayName: "Nayeli Cervantes",
shortName: "Nayeli", shortName: "Nayeli",
cover: "nayeli.webp", cover: "nayeli.webp",
splashCover: "nayeli.webp",
}, },
] as const; ] as const;
@@ -56,9 +59,11 @@ for (const character of characters) {
await page.goto(`/characters/${character.slug}/splash`); await page.goto(`/characters/${character.slug}/splash`);
const cover = page.locator(`img[src*="${character.cover}"]`); const cover = page.locator(`img[src*="${character.splashCover}"]`);
await expect(cover).toBeVisible(); await expect(cover).toBeVisible();
await expect(page.getByText(character.displayName).last()).toBeVisible(); await expect(
page.getByRole("navigation", { name: "Primary navigation" }),
).toContainText("Private Zone");
await expect await expect
.poll(() => .poll(() =>
cover.evaluate((image: HTMLImageElement) => cover.evaluate((image: HTMLImageElement) =>
@@ -84,6 +89,13 @@ for (const character of characters) {
test(`${character.displayName} can open a character-scoped Private Zone`, async ({ test(`${character.displayName} can open a character-scoped Private Zone`, async ({
page, page,
}) => { }) => {
const momentsRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return (
url.pathname === "/api/private-zone/posts" &&
url.searchParams.get("characterId") === character.id
);
});
const albumRequest = page.waitForRequest((request) => { const albumRequest = page.waitForRequest((request) => {
const url = new URL(request.url()); const url = new URL(request.url());
return ( return (
@@ -94,7 +106,11 @@ for (const character of characters) {
await page.goto(`/characters/${character.slug}/private-zone`); await page.goto(`/characters/${character.slug}/private-zone`);
await albumRequest; await Promise.all([momentsRequest, albumRequest]);
await expect(
page.getByRole("heading", { name: "Private moments" }),
).toBeVisible();
await page.getByRole("tab", { name: "Albums" }).click();
await expect( await expect(
page.getByRole("heading", { name: "Private albums" }), page.getByRole("heading", { name: "Private albums" }),
).toBeVisible(); ).toBeVisible();
@@ -147,11 +163,27 @@ test("a real Private Zone request failure still offers Retry", async ({
await page.goto("/characters/maya/private-zone"); await page.goto("/characters/maya/private-zone");
await page.getByRole("tab", { name: "Albums" }).click();
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible(); await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0);
}); });
async function registerMultiRoleCommercialMocks(page: Page): Promise<void> { async function registerMultiRoleCommercialMocks(page: Page): Promise<void> {
await page.route("**/api/private-zone/posts**", async (route) => {
const url = new URL(route.request().url());
const characterId = url.searchParams.get("characterId") ?? "elio";
await route.fulfill({
json: apiEnvelope({
characterId,
items: [],
nextCursor: null,
hasMore: false,
creditBalance: 1000,
currency: "credits",
}),
});
});
await page.route("**/api/private-zone/albums**", async (route) => { await page.route("**/api/private-zone/albums**", async (route) => {
const url = new URL(route.request().url()); const url = new URL(route.request().url());
const characterId = url.searchParams.get("characterId") ?? "elio"; const characterId = url.searchParams.get("characterId") ?? "elio";
+146
View File
@@ -0,0 +1,146 @@
import { expect, test, type Page } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { apiEnvelope } from "@e2e/fixtures/data/common";
import {
clearBrowserState,
seedEmailSession,
} from "@e2e/fixtures/test-helpers";
const postId = "11111111-1111-1111-1111-111111111111";
let unlocked = false;
test.beforeEach(async ({ baseURL, context, page }) => {
unlocked = false;
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
await registerPrivateZoneVideoMocks(page);
});
test("Private Zone defaults to Moments and reveals video only after one credit unlock", async ({
page,
}) => {
await seedEmailSession(page);
const listRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return (
url.pathname === "/api/private-zone/posts" &&
url.searchParams.get("characterId") === "maya-tan"
);
});
await page.goto("/characters/maya/private-zone");
await listRequest;
await expect(
page.getByRole("heading", { name: "Private moments" }),
).toBeVisible();
await expect(page.getByText("Only for you.")).toBeVisible();
await expect(page.locator("video")).toHaveCount(0);
await page.getByRole("button", { name: "Unlock for 100 credits" }).click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toContainText("100 credits");
const unlockRequest = page.waitForRequest(
`**/api/private-zone/posts/${postId}/unlock`,
);
await dialog.getByRole("button", { name: "Unlock", exact: true }).click();
expect((await unlockRequest).postDataJSON()).toEqual({ expectedCost: 100 });
const video = page.locator("video");
await expect(video).toHaveCount(1);
await expect(video.locator("source")).toHaveAttribute("src", /video\.mp4/);
await expect(page.getByRole("alertdialog")).toHaveCount(0);
});
test("Albums remain available behind the second tab", async ({ page }) => {
await seedEmailSession(page);
await page.goto("/characters/maya/private-zone");
await page.getByRole("tab", { name: "Albums" }).click();
await expect(
page.getByRole("heading", { name: "Private albums" }),
).toBeVisible();
await expect(page.getByText("Maya archive")).toBeVisible();
});
async function registerPrivateZoneVideoMocks(page: Page): Promise<void> {
await page.route("**/api/private-zone/posts**", async (route) => {
const url = new URL(route.request().url());
await route.fulfill({
json: apiEnvelope({
characterId: url.searchParams.get("characterId") ?? "maya-tan",
items: [videoPost(unlocked)],
nextCursor: null,
hasMore: false,
creditBalance: unlocked ? 150 : 250,
currency: "credits",
}),
});
});
// Playwright evaluates page routes in reverse registration order. Register
// the specific mutation last so the broader list route cannot intercept it.
await page.route("**/api/private-zone/posts/*/unlock", async (route) => {
unlocked = true;
await route.fulfill({
json: apiEnvelope({
postId,
reason: "ok",
unlocked: true,
creditsCharged: 100,
requiredCredits: 100,
currentCredits: 150,
shortfallCredits: 0,
creditBalance: 150,
post: videoPost(true),
}),
});
});
await page.route("**/api/private-zone/albums**", async (route) => {
await route.fulfill({
json: apiEnvelope({
items: [
{
albumId: "album-maya",
title: "Maya archive",
content: null,
previewText: "Private photos",
imageCount: 1,
images: [{ url: "/images/private-zone/banner/maya.png", locked: true, index: 0 }],
locked: true,
unlocked: false,
unlockCost: 80,
publishedAt: "2026-07-24T10:00:00+00:00",
lockDetail: { locked: true },
},
],
creditBalance: 250,
pendingImageCount: 0,
hasIncompleteContent: false,
}),
});
});
}
function videoPost(isUnlocked: boolean) {
return {
postId,
characterId: "maya-tan",
caption: "Only for you.",
posterUrl: "/images/private-zone/banner/maya.png",
mediaType: "video",
videoUrl: isUnlocked ? "/test-video.mp4?token=short" : null,
videoMimeType: "video/mp4",
videoSizeBytes: 1024,
durationSeconds: 18,
unlockCostCredits: 100,
currency: "credits",
locked: !isUnlocked,
unlocked: isUnlocked,
availableForNewUnlock: true,
publishedAt: "2026-07-24T10:00:00+00:00",
};
}
@@ -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-card";
export * from "./private-album-gallery"; export * from "./private-album-gallery";
export * from "./private-zone-video-post-card";
export * from "./status-card"; export * from "./status-card";
export * from "./unlock-confirm-dialog"; 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); 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 { .timeline {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -263,6 +295,118 @@
margin: 0; 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 { .authorName {
color: #26191d; color: #26191d;
font-size: clamp(14px, 3.333vw, 17px); font-size: clamp(14px, 3.333vw, 17px);
@@ -776,6 +920,9 @@
} }
.primaryCta:focus-visible, .primaryCta:focus-visible,
.contentTab:focus-visible,
.momentLockPanel button:focus-visible,
.loadMoreButton:focus-visible,
.lockedPreviewCta:focus-visible, .lockedPreviewCta:focus-visible,
.mediaGridItem:focus-visible, .mediaGridItem:focus-visible,
.galleryClose:focus-visible, .galleryClose:focus-visible,
+173 -11
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
@@ -18,17 +18,24 @@ import {
useActiveCharacterRoutes, useActiveCharacterRoutes,
} from "@/providers/character-provider"; } from "@/providers/character-provider";
import { isPrivateAlbumLocked } from "@/lib/private-zone/private_album"; import { isPrivateAlbumLocked } from "@/lib/private-zone/private_album";
import { behaviorAnalytics } from "@/lib/analytics";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { import {
usePrivateZoneDispatch, usePrivateZoneDispatch,
usePrivateZoneState, usePrivateZoneState,
} from "@/stores/private-zone"; } from "@/stores/private-zone";
import {
usePrivateZonePostDispatch,
usePrivateZonePostState,
} from "@/stores/private-zone-posts";
import { import {
PrivateAlbumCard, PrivateAlbumCard,
PrivateAlbumGallery, PrivateAlbumGallery,
PrivateZoneVideoPostCard,
StatusCard, StatusCard,
UnlockConfirmDialog, UnlockConfirmDialog,
VideoPostUnlockDialog,
} from "./components"; } from "./components";
import styles from "./private-zone-screen.module.css"; import styles from "./private-zone-screen.module.css";
import { import {
@@ -54,11 +61,16 @@ export function PrivateZoneScreen() {
const authDispatch = useAuthDispatch(); const authDispatch = useAuthDispatch();
const state = usePrivateZoneState(); const state = usePrivateZoneState();
const dispatch = usePrivateZoneDispatch(); const dispatch = usePrivateZoneDispatch();
const postState = usePrivateZonePostState();
const postDispatch = usePrivateZonePostDispatch();
const [activeTab, setActiveTab] = useState<"moments" | "albums">("moments");
const openedGalleryInPageRef = useRef(false); const openedGalleryInPageRef = useRef(false);
const previousPostLoginStatusRef = useRef(authState.loginStatus);
const galleryState = useMemo( const galleryState = useMemo(
() => getPrivateAlbumGalleryState(searchParams), () => getPrivateAlbumGalleryState(searchParams),
[searchParams], [searchParams],
); );
const visibleTab = galleryState ? "albums" : activeTab;
usePrivateZoneBootstrapFlow({ usePrivateZoneBootstrapFlow({
authDispatch, authDispatch,
@@ -74,6 +86,56 @@ export function PrivateZoneScreen() {
unlockPaywallRequest: state.unlockPaywallRequest, unlockPaywallRequest: state.unlockPaywallRequest,
}); });
usePrivateZoneUnlockSuccessRefresh(state.unlockSuccessNonce); 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 displayName = character.displayName;
const avatarUrl = character.assets.avatar; const avatarUrl = character.assets.avatar;
@@ -86,6 +148,13 @@ export function PrivateZoneScreen() {
) ?? null, ) ?? null,
[state.items, state.pendingConfirmAlbumId], [state.items, state.pendingConfirmAlbumId],
); );
const pendingPost = useMemo(
() =>
postState.items.find(
(post) => post.postId === postState.pendingConfirmPostId,
) ?? null,
[postState.items, postState.pendingConfirmPostId],
);
const galleryAlbum = useMemo( const galleryAlbum = useMemo(
() => () =>
galleryState 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 = () => { const handleCloseGallery = () => {
if (openedGalleryInPageRef.current) { if (openedGalleryInPageRef.current) {
openedGalleryInPageRef.current = false; openedGalleryInPageRef.current = false;
@@ -235,16 +312,83 @@ export function PrivateZoneScreen() {
</section> </section>
<section className={styles.postsSection} aria-labelledby="posts-title"> <section className={styles.postsSection} aria-labelledby="posts-title">
<div className={styles.sectionHeader}> <div className={styles.contentTabs} role="tablist" aria-label="Private Zone content">
<div> <button
<p className={styles.kicker}>Albums</p> type="button"
<h2 id="posts-title" className={styles.sectionTitle}> role="tab"
Private albums aria-selected={visibleTab === "moments"}
</h2> className={`${styles.contentTab} ${visibleTab === "moments" ? styles.contentTabActive : ""}`}
</div> onClick={() => setActiveTab("moments")}
<span className={styles.postCount}>{state.items.length}</span> >
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>
<div className={styles.sectionHeader}>
<div>
<p className={styles.kicker}>{visibleTab === "moments" ? "Moments" : "Albums"}</p>
<h2 id="posts-title" className={styles.sectionTitle}>
{visibleTab === "moments" ? "Private moments" : "Private albums"}
</h2>
</div>
<span className={styles.postCount}>
{visibleTab === "moments" ? postState.items.length : state.items.length}
</span>
</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 ? ( {state.errorMessage ? (
<StatusCard <StatusCard
message={state.errorMessage} message={state.errorMessage}
@@ -252,11 +396,9 @@ export function PrivateZoneScreen() {
onAction={() => dispatch({ type: "PrivateZoneRefresh" })} onAction={() => dispatch({ type: "PrivateZoneRefresh" })}
/> />
) : null} ) : null}
{state.isLoading && state.items.length === 0 ? ( {state.isLoading && state.items.length === 0 ? (
<StatusCard message="Loading private albums..." /> <StatusCard message="Loading private albums..." />
) : null} ) : null}
<div className={styles.timeline}> <div className={styles.timeline}>
{state.items.map((album, index) => ( {state.items.map((album, index) => (
<PrivateAlbumCard <PrivateAlbumCard
@@ -278,6 +420,8 @@ export function PrivateZoneScreen() {
/> />
))} ))}
</div> </div>
</>
)}
</section> </section>
<AppBottomNav <AppBottomNav
@@ -309,6 +453,24 @@ export function PrivateZoneScreen() {
</div> </div>
) : null} ) : 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 && {galleryAlbum &&
galleryState && galleryState &&
!isPrivateAlbumLocked(galleryAlbum) && !isPrivateAlbumLocked(galleryAlbum) &&
@@ -9,4 +9,5 @@ export * from "./ifeedback_repository";
export * from "./imetrics_repository"; export * from "./imetrics_repository";
export * from "./ipayment_repository"; export * from "./ipayment_repository";
export * from "./iprivate_zone_repository"; export * from "./iprivate_zone_repository";
export * from "./iprivate_zone_post_repository";
export * from "./iuser_repository"; export * from "./iuser_repository";
@@ -0,0 +1,21 @@
import type {
PrivateZonePostsResponse,
PrivateZonePostUnlockResponse,
} from "@/data/schemas/private-zone";
import type { Result } from "@/utils/result";
export interface GetPrivateZonePostsInput {
characterId: string;
limit?: number;
cursor?: string | null;
}
export interface IPrivateZonePostRepository {
getPosts(
input: GetPrivateZonePostsInput,
): Promise<Result<PrivateZonePostsResponse>>;
unlockPost(
postId: string,
expectedCost: number,
): Promise<Result<PrivateZonePostUnlockResponse>>;
}
@@ -0,0 +1,43 @@
import type {
GetPrivateZonePostsInput,
IPrivateZonePostRepository,
} from "@/data/repositories/interfaces";
import {
UnlockPrivateZonePostRequestSchema,
type PrivateZonePostsResponse,
type PrivateZonePostUnlockResponse,
} from "@/data/schemas/private-zone";
import {
PrivateZonePostApi,
privateZonePostApi,
} from "@/data/services/api/private_zone_post_api";
import { Result } from "@/utils/result";
import { createLazySingleton } from "./lazy_singleton";
export class PrivateZonePostRepository implements IPrivateZonePostRepository {
constructor(private readonly api: PrivateZonePostApi) {}
getPosts(
input: GetPrivateZonePostsInput,
): Promise<Result<PrivateZonePostsResponse>> {
return Result.wrap(() => this.api.getPosts(input));
}
unlockPost(
postId: string,
expectedCost: number,
): Promise<Result<PrivateZonePostUnlockResponse>> {
return Result.wrap(() =>
this.api.unlockPost(
postId,
UnlockPrivateZonePostRequestSchema.parse({ expectedCost }),
),
);
}
}
export const getPrivateZonePostRepository =
createLazySingleton<IPrivateZonePostRepository>(
() => new PrivateZonePostRepository(privateZonePostApi),
);
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
PrivateZonePostUnlockResponseSchema,
PrivateZonePostsResponseSchema,
} from "@/data/schemas/private-zone";
const post = {
postId: "11111111-1111-1111-1111-111111111111",
characterId: "maya-tan",
caption: "Only for you.",
posterUrl: "https://storage.example/poster.jpg?token=short",
mediaType: "video",
videoUrl: null,
videoMimeType: "video/mp4",
videoSizeBytes: 1024,
durationSeconds: 18,
unlockCostCredits: 100,
currency: "credits",
locked: true,
unlocked: false,
availableForNewUnlock: true,
publishedAt: "2026-07-24T10:00:00+00:00",
};
describe("Private Zone video post schema", () => {
it("parses a locked post without inventing a video URL", () => {
const response = PrivateZonePostsResponseSchema.parse({
characterId: "maya-tan",
items: [post],
nextCursor: null,
hasMore: false,
creditBalance: 250,
currency: "credits",
});
expect(response.items[0].videoUrl).toBeNull();
expect(response.items[0].posterUrl).toContain("poster.jpg");
expect(response.items[0].unlockCostCredits).toBe(100);
});
it("parses cost changes and insufficient-credit details", () => {
const changed = PrivateZonePostUnlockResponseSchema.parse({
postId: post.postId,
reason: "cost_changed",
unlocked: false,
creditsCharged: 0,
requiredCredits: 150,
creditBalance: 250,
post: { ...post, unlockCostCredits: 150 },
});
const insufficient = PrivateZonePostUnlockResponseSchema.parse({
postId: post.postId,
reason: "insufficient_credits",
unlocked: false,
creditsCharged: 0,
requiredCredits: 100,
currentCredits: 40,
shortfallCredits: 60,
creditBalance: 40,
post,
});
expect(changed.post?.unlockCostCredits).toBe(150);
expect(insufficient.shortfallCredits).toBe(60);
});
});
+1
View File
@@ -3,6 +3,7 @@
*/ */
export * from "./private_album"; export * from "./private_album";
export * from "./private_zone_post";
export * from "./request/unlock_private_album_request"; export * from "./request/unlock_private_album_request";
export * from "./response/private_album_unlock_response"; export * from "./response/private_album_unlock_response";
export * from "./response/private_albums_response"; export * from "./response/private_albums_response";
@@ -0,0 +1,82 @@
import { z } from "zod";
import {
arrayOrEmpty,
booleanOrFalse,
booleanOrTrue,
numberOrNull,
numberOrZero,
schemaOrNull,
stringOrEmpty,
stringOrNull,
} from "../nullable-defaults";
export const PrivateZoneVideoPostSchema = z
.object({
postId: stringOrEmpty,
characterId: stringOrEmpty,
caption: stringOrEmpty,
posterUrl: stringOrNull,
mediaType: z.literal("video").default("video"),
videoUrl: stringOrNull,
videoMimeType: stringOrEmpty,
videoSizeBytes: numberOrZero,
durationSeconds: numberOrNull,
unlockCostCredits: numberOrZero,
currency: z.literal("credits").default("credits"),
locked: booleanOrFalse,
unlocked: booleanOrFalse,
availableForNewUnlock: booleanOrTrue,
publishedAt: stringOrEmpty,
})
.readonly();
export const PrivateZonePostsResponseSchema = z
.object({
characterId: stringOrEmpty,
items: arrayOrEmpty(PrivateZoneVideoPostSchema),
nextCursor: stringOrNull,
hasMore: booleanOrFalse,
creditBalance: numberOrZero,
currency: z.literal("credits").default("credits"),
})
.readonly();
export const PrivateZonePostUnlockReasonSchema = z.enum([
"ok",
"already_unlocked",
"insufficient_credits",
"cost_changed",
"unavailable",
"deduct_failed",
"not_found",
]);
export const PrivateZonePostUnlockResponseSchema = z
.object({
postId: stringOrEmpty,
reason: PrivateZonePostUnlockReasonSchema.or(stringOrEmpty),
unlocked: booleanOrFalse,
creditsCharged: numberOrZero,
requiredCredits: numberOrZero,
currentCredits: numberOrZero,
shortfallCredits: numberOrZero,
creditBalance: numberOrZero,
post: schemaOrNull(PrivateZoneVideoPostSchema),
})
.readonly();
export const UnlockPrivateZonePostRequestSchema = z
.object({
expectedCost: z.number().int().min(1),
})
.readonly();
export type PrivateZoneVideoPost = z.output<typeof PrivateZoneVideoPostSchema>;
export type PrivateZonePostsResponse = z.output<typeof PrivateZonePostsResponseSchema>;
export type PrivateZonePostUnlockResponse = z.output<
typeof PrivateZonePostUnlockResponseSchema
>;
export type UnlockPrivateZonePostRequest = z.output<
typeof UnlockPrivateZonePostRequestSchema
>;
+2
View File
@@ -26,6 +26,8 @@
"chatUnlockHistory": { "method": "post", "path": "/api/chat/unlock-history" }, "chatUnlockHistory": { "method": "post", "path": "/api/chat/unlock-history" },
"privateZoneAlbums": { "method": "get", "path": "/api/private-zone/albums" }, "privateZoneAlbums": { "method": "get", "path": "/api/private-zone/albums" },
"privateZoneAlbumUnlock": { "method": "post", "path": "/api/private-zone/albums/{albumId}/unlock" }, "privateZoneAlbumUnlock": { "method": "post", "path": "/api/private-zone/albums/{albumId}/unlock" },
"privateZonePosts": { "method": "get", "path": "/api/private-zone/posts" },
"privateZonePostUnlock": { "method": "post", "path": "/api/private-zone/posts/{postId}/unlock" },
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" }, "metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" }, "reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
"feedback": { "method": "post", "path": "/api/feedback" }, "feedback": { "method": "post", "path": "/api/feedback" },
+11
View File
@@ -105,6 +105,17 @@ export class ApiPath {
); );
} }
/** 获取 Private Zone 付费视频朋友圈 */
static readonly privateZonePosts = apiContract.privateZonePosts.path;
/** 解锁单条 Private Zone 付费视频朋友圈 */
static privateZonePostUnlock(postId: string): string {
return apiContract.privateZonePostUnlock.path.replace(
"{postId}",
encodeURIComponent(postId),
);
}
// ============ 数据看板相关 ============ // ============ 数据看板相关 ============
/** 上报 PWA 事件 */ /** 上报 PWA 事件 */
static readonly metricsPwaEvent = apiContract.metricsPwaEvent.path; static readonly metricsPwaEvent = apiContract.metricsPwaEvent.path;
@@ -0,0 +1,49 @@
import {
PrivateZonePostsResponseSchema,
PrivateZonePostUnlockResponseSchema,
type PrivateZonePostsResponse,
type PrivateZonePostUnlockResponse,
type UnlockPrivateZonePostRequest,
} from "@/data/schemas/private-zone";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { type ApiEnvelope, unwrap } from "./response_helper";
export interface GetPrivateZonePostsInput {
characterId: string;
limit?: number;
cursor?: string | null;
}
export class PrivateZonePostApi {
async getPosts(
input: GetPrivateZonePostsInput,
): Promise<PrivateZonePostsResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.privateZonePosts,
{
query: {
characterId: input.characterId,
limit: input.limit ?? 20,
cursor: input.cursor || undefined,
},
},
);
return PrivateZonePostsResponseSchema.parse(unwrap(env));
}
async unlockPost(
postId: string,
body: UnlockPrivateZonePostRequest,
): Promise<PrivateZonePostUnlockResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.privateZonePostUnlock(postId),
{ method: "POST", body },
);
const parsed = PrivateZonePostUnlockResponseSchema.parse(unwrap(env));
return parsed.postId ? parsed : { ...parsed, postId };
}
}
export const privateZonePostApi = new PrivateZonePostApi();
@@ -3,6 +3,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { PrivateZoneProvider } from "@/stores/private-zone"; import { PrivateZoneProvider } from "@/stores/private-zone";
import { PrivateZonePostProvider } from "@/stores/private-zone-posts";
export interface PrivateZoneRouteProviderProps { export interface PrivateZoneRouteProviderProps {
children: ReactNode; children: ReactNode;
@@ -14,8 +15,10 @@ export function PrivateZoneRouteProvider({
characterId, characterId,
}: PrivateZoneRouteProviderProps) { }: PrivateZoneRouteProviderProps) {
return ( return (
<PrivateZoneProvider key={characterId} characterId={characterId}> <PrivateZonePostProvider key={characterId} characterId={characterId}>
<PrivateZoneProvider characterId={characterId}>
{children} {children}
</PrivateZoneProvider> </PrivateZoneProvider>
</PrivateZonePostProvider>
); );
} }
@@ -0,0 +1,126 @@
import { createActor, fromPromise, waitFor } from "xstate";
import { describe, expect, it } from "vitest";
import type {
PrivateZonePostUnlockResponse,
PrivateZonePostsResponse,
} from "@/data/schemas/private-zone";
import { privateZonePostMachine } from "../private-zone-post-machine";
const POST_ID = "11111111-1111-1111-1111-111111111111";
const lockedPost = {
postId: POST_ID,
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",
};
const listResponse: PrivateZonePostsResponse = {
characterId: "maya-tan",
items: [lockedPost],
nextCursor: null,
hasMore: false,
creditBalance: 150,
currency: "credits",
};
function actorWithUnlock(response: PrivateZonePostUnlockResponse) {
const machine = privateZonePostMachine.provide({
actors: {
loadPosts: fromPromise(async () => listResponse),
unlockPost: fromPromise(async () => response),
},
});
return createActor(machine, { input: { characterId: "maya-tan" } }).start();
}
describe("Private Zone video post machine", () => {
it("loads moments independently from album state", async () => {
const actor = actorWithUnlock({
postId: POST_ID,
reason: "deduct_failed",
unlocked: false,
creditsCharged: 0,
requiredCredits: 100,
currentCredits: 150,
shortfallCredits: 0,
creditBalance: 150,
post: lockedPost,
});
actor.send({ type: "PrivateZonePostInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context.items).toHaveLength(1);
expect(actor.getSnapshot().context.creditBalance).toBe(150);
});
it("patches the signed video after a successful unlock", async () => {
const actor = actorWithUnlock({
postId: POST_ID,
reason: "ok",
unlocked: true,
creditsCharged: 100,
requiredCredits: 100,
currentCredits: 50,
shortfallCredits: 0,
creditBalance: 50,
post: {
...lockedPost,
locked: false,
unlocked: true,
videoUrl: "https://storage.example/video.mp4?token=short",
},
});
actor.send({ type: "PrivateZonePostInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PrivateZonePostUnlockRequested", postId: POST_ID });
actor.send({ type: "PrivateZonePostUnlockConfirmed" });
await waitFor(
actor,
(snapshot) => snapshot.matches("ready") && snapshot.context.unlockSuccessNonce === 1,
);
expect(actor.getSnapshot().context.items[0].videoUrl).toContain("video.mp4");
expect(actor.getSnapshot().context.creditBalance).toBe(50);
});
it("creates a top-up request without unlocking when credits are insufficient", async () => {
const actor = actorWithUnlock({
postId: POST_ID,
reason: "insufficient_credits",
unlocked: false,
creditsCharged: 0,
requiredCredits: 100,
currentCredits: 25,
shortfallCredits: 75,
creditBalance: 25,
post: lockedPost,
});
actor.send({ type: "PrivateZonePostInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PrivateZonePostUnlockRequested", postId: POST_ID });
actor.send({ type: "PrivateZonePostUnlockConfirmed" });
await waitFor(
actor,
(snapshot) => snapshot.matches("ready") && !!snapshot.context.unlockPaywallRequest,
);
expect(actor.getSnapshot().context.unlockPaywallRequest?.shortfallCredits).toBe(75);
expect(actor.getSnapshot().context.items[0].videoUrl).toBeNull();
});
});
+4
View File
@@ -0,0 +1,4 @@
export * from "./private-zone-post-context";
export * from "./private-zone-post-events";
export * from "./private-zone-post-machine";
export * from "./private-zone-post-state";
@@ -0,0 +1,37 @@
import { fromPromise } from "xstate";
import type {
PrivateZonePostsResponse,
PrivateZonePostUnlockResponse,
} from "@/data/schemas/private-zone";
import { getPrivateZonePostRepository } from "@/data/repositories/private_zone_post_repository";
import { Result } from "@/utils/result";
export const PRIVATE_ZONE_POST_PAGE_SIZE = 20;
export const loadPrivateZonePostsActor = fromPromise<
PrivateZonePostsResponse,
{ characterId: string; cursor?: string | null }
>(async ({ input }) => {
const result = await getPrivateZonePostRepository().getPosts({
characterId: input.characterId,
cursor: input.cursor,
limit: PRIVATE_ZONE_POST_PAGE_SIZE,
});
if (Result.isErr(result)) throw result.error;
return result.data;
});
export const unlockPrivateZonePostActor = fromPromise<
PrivateZonePostUnlockResponse,
{ postId: string; expectedCost: number }
>(async ({ input }) => {
const result = await getPrivateZonePostRepository().unlockPost(
input.postId,
input.expectedCost,
);
if (Result.isErr(result)) throw result.error;
return result.data.postId
? result.data
: { ...result.data, postId: input.postId };
});
@@ -0,0 +1,45 @@
"use client";
import type { Dispatch, ReactNode } from "react";
import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate";
import type { PrivateZonePostEvent } from "./private-zone-post-events";
import { privateZonePostMachine } from "./private-zone-post-machine";
type Snapshot = SnapshotFrom<typeof privateZonePostMachine>;
const Context = createActorContext(privateZonePostMachine);
export interface PrivateZonePostProviderProps {
children: ReactNode;
characterId: string;
}
export function PrivateZonePostProvider({
children,
characterId,
}: PrivateZonePostProviderProps) {
return (
<Context.Provider options={{ input: { characterId } }}>
{children}
</Context.Provider>
);
}
function selectState(snapshot: Snapshot) {
return {
status: String(snapshot.value),
...snapshot.context,
isLoading: snapshot.matches("loading"),
isLoadingMore: snapshot.matches("loadingMore"),
isUnlocking: snapshot.matches("unlocking"),
};
}
export function usePrivateZonePostState() {
return Context.useSelector(selectState, shallowEqual);
}
export function usePrivateZonePostDispatch(): Dispatch<PrivateZonePostEvent> {
return Context.useActorRef().send;
}
@@ -0,0 +1,8 @@
export type PrivateZonePostEvent =
| { type: "PrivateZonePostInit" }
| { type: "PrivateZonePostRefresh" }
| { type: "PrivateZonePostLoadMore" }
| { type: "PrivateZonePostUnlockRequested"; postId: string }
| { type: "PrivateZonePostUnlockConfirmed" }
| { type: "PrivateZonePostUnlockCancelled" }
| { type: "PrivateZonePostUnlockPaywallConsumed" };
@@ -0,0 +1,259 @@
import { assign, setup } from "xstate";
import type {
PrivateZonePostsResponse,
PrivateZonePostUnlockResponse,
PrivateZoneVideoPost,
} from "@/data/schemas/private-zone";
import {
loadPrivateZonePostsActor,
unlockPrivateZonePostActor,
} from "./private-zone-post-actors";
import type { PrivateZonePostEvent } from "./private-zone-post-events";
import {
createInitialPrivateZonePostState,
type PrivateZonePostState,
} from "./private-zone-post-state";
function errorMessage(error: unknown): string {
return error instanceof Error && error.message
? error.message
: "Moments are temporarily unavailable. Please try again.";
}
function pendingPost(context: PrivateZonePostState): PrivateZoneVideoPost | null {
if (!context.pendingConfirmPostId) return null;
return context.items.find(
(post) => post.postId === context.pendingConfirmPostId,
) ?? null;
}
function patchPost(
items: readonly PrivateZoneVideoPost[],
response: PrivateZonePostUnlockResponse,
): readonly PrivateZoneVideoPost[] {
if (!response.post) return items;
return items.map((post) =>
post.postId === response.postId ? response.post! : post,
);
}
function appendPosts(
current: readonly PrivateZoneVideoPost[],
response: PrivateZonePostsResponse,
): readonly PrivateZoneVideoPost[] {
const byId = new Map(current.map((post) => [post.postId, post]));
response.items.forEach((post) => byId.set(post.postId, post));
return [...byId.values()];
}
const machineSetup = setup({
types: {
context: {} as PrivateZonePostState,
events: {} as PrivateZonePostEvent,
input: {} as { characterId: string },
},
actors: {
loadPosts: loadPrivateZonePostsActor,
unlockPost: unlockPrivateZonePostActor,
},
guards: {
hasMore: ({ context }) => context.hasMore && !!context.nextCursor,
hasPendingPost: ({ context }) => pendingPost(context) !== null,
},
});
export const privateZonePostMachine = machineSetup.createMachine({
id: "privateZonePosts",
context: ({ input }) => createInitialPrivateZonePostState(input.characterId),
initial: "idle",
states: {
idle: {
on: { PrivateZonePostInit: "loading" },
},
loading: {
entry: assign({
errorMessage: null,
unlockPaywallRequest: null,
}),
invoke: {
src: "loadPosts",
input: ({ context }) => ({ characterId: context.characterId }),
onDone: {
target: "ready",
actions: assign(({ event }) => ({
items: event.output.items,
creditBalance: event.output.creditBalance,
nextCursor: event.output.nextCursor,
hasMore: event.output.hasMore,
errorMessage: null,
})),
},
onError: {
target: "failed",
actions: assign(({ event }) => ({
errorMessage: errorMessage(event.error),
})),
},
},
},
failed: {
on: {
PrivateZonePostInit: "loading",
PrivateZonePostRefresh: "loading",
},
},
ready: {
on: {
PrivateZonePostRefresh: "loading",
PrivateZonePostLoadMore: {
guard: "hasMore",
target: "loadingMore",
},
PrivateZonePostUnlockRequested: {
actions: assign(({ event }) => ({
pendingConfirmPostId: event.postId,
unlockErrorMessage: null,
unlockPaywallRequest: null,
})),
},
PrivateZonePostUnlockCancelled: {
actions: assign({ pendingConfirmPostId: null }),
},
PrivateZonePostUnlockConfirmed: {
guard: "hasPendingPost",
target: "unlocking",
},
PrivateZonePostUnlockPaywallConsumed: {
actions: assign({ unlockPaywallRequest: null }),
},
},
},
loadingMore: {
invoke: {
src: "loadPosts",
input: ({ context }) => ({
characterId: context.characterId,
cursor: context.nextCursor,
}),
onDone: {
target: "ready",
actions: assign(({ context, event }) => ({
items: appendPosts(context.items, event.output),
creditBalance: event.output.creditBalance,
nextCursor: event.output.nextCursor,
hasMore: event.output.hasMore,
errorMessage: null,
})),
},
onError: {
target: "ready",
actions: assign(({ event }) => ({
errorMessage: errorMessage(event.error),
})),
},
},
},
unlocking: {
entry: assign(({ context }) => ({
unlockingPostId: context.pendingConfirmPostId,
unlockErrorMessage: null,
unlockPaywallRequest: null,
})),
invoke: {
src: "unlockPost",
input: ({ context }) => {
const post = pendingPost(context);
return {
postId: post?.postId ?? "",
expectedCost: post?.unlockCostCredits ?? 0,
};
},
onDone: [
{
guard: ({ event }) => event.output.unlocked,
target: "ready",
actions: assign(({ context, event }) => ({
items: patchPost(context.items, event.output),
creditBalance: event.output.creditBalance,
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage: null,
unlockSuccessNonce: context.unlockSuccessNonce + 1,
})),
},
{
guard: ({ event }) =>
event.output.reason === "insufficient_credits",
target: "ready",
actions: assign(({ context, event }) => ({
items: patchPost(context.items, event.output),
creditBalance: event.output.creditBalance,
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage: null,
unlockPaywallRequest: {
postId: event.output.postId,
reason: event.output.reason,
requiredCredits:
event.output.requiredCredits ||
event.output.post?.unlockCostCredits ||
0,
currentCredits:
event.output.currentCredits || event.output.creditBalance,
shortfallCredits: event.output.shortfallCredits,
},
})),
},
{
guard: ({ event }) => event.output.reason === "cost_changed",
target: "ready",
actions: assign(({ context, event }) => ({
items: patchPost(context.items, event.output),
creditBalance: event.output.creditBalance,
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage:
"Unlock price changed. Please review the new price and confirm again.",
})),
},
{
guard: ({ event }) =>
event.output.reason === "not_found" ||
event.output.reason === "unavailable",
target: "ready",
actions: assign(({ context, event }) => ({
items: context.items.filter(
(post) => post.postId !== event.output.postId,
),
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage: "This moment is no longer available.",
})),
},
{
target: "ready",
actions: assign(({ context, event }) => ({
items: patchPost(context.items, event.output),
creditBalance: event.output.creditBalance,
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage: "Unlock failed. Please try again.",
})),
},
],
onError: {
target: "ready",
actions: assign(({ event }) => ({
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage: errorMessage(event.error),
})),
},
},
},
},
});
export type PrivateZonePostMachine = typeof privateZonePostMachine;
@@ -0,0 +1,41 @@
import type { PrivateZoneVideoPost } from "@/data/schemas/private-zone";
export interface PrivateZonePostPaywallRequest {
postId: string;
reason: string;
requiredCredits: number;
currentCredits: number;
shortfallCredits: number;
}
export interface PrivateZonePostState {
characterId: string;
items: readonly PrivateZoneVideoPost[];
creditBalance: number;
nextCursor: string | null;
hasMore: boolean;
errorMessage: string | null;
pendingConfirmPostId: string | null;
unlockingPostId: string | null;
unlockErrorMessage: string | null;
unlockPaywallRequest: PrivateZonePostPaywallRequest | null;
unlockSuccessNonce: number;
}
export function createInitialPrivateZonePostState(
characterId: string,
): PrivateZonePostState {
return {
characterId,
items: [],
creditBalance: 0,
nextCursor: null,
hasMore: false,
errorMessage: null,
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage: null,
unlockPaywallRequest: null,
unlockSuccessNonce: 0,
};
}