feat(private-zone): add paid video moments
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
>;
|
||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user