From 0d5b5c17fa8ea4dc795f1768651c2ae912c5b25a Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 20:17:28 +0800 Subject: [PATCH] feat(private-zone): add paid video moments --- e2e/specs/mock/multi-role-commercial.spec.ts | 38 ++- e2e/specs/mock/private-zone-video.spec.ts | 146 ++++++++++ .../private-zone-video-post-card.test.tsx | 67 +++++ src/app/private-zone/components/index.ts | 2 + .../private-zone-video-post-card.tsx | 125 +++++++++ .../components/video-post-unlock-dialog.tsx | 54 ++++ .../private-zone-screen.module.css | 147 ++++++++++ src/app/private-zone/private-zone-screen.tsx | 236 +++++++++++++--- src/data/repositories/interfaces/index.ts | 1 + .../iprivate_zone_post_repository.ts | 21 ++ .../private_zone_post_repository.ts | 43 +++ .../__tests__/private_zone_post.test.ts | 67 +++++ src/data/schemas/private-zone/index.ts | 1 + .../schemas/private-zone/private_zone_post.ts | 82 ++++++ src/data/services/api/api_contract.json | 2 + src/data/services/api/api_path.ts | 11 + .../services/api/private_zone_post_api.ts | 49 ++++ src/providers/private-zone-route-provider.tsx | 9 +- .../private-zone-post-machine.test.ts | 126 +++++++++ src/stores/private-zone-posts/index.ts | 4 + .../private-zone-post-actors.ts | 37 +++ .../private-zone-post-context.tsx | 45 +++ .../private-zone-post-events.ts | 8 + .../private-zone-post-machine.ts | 259 ++++++++++++++++++ .../private-zone-post-state.ts | 41 +++ 25 files changed, 1578 insertions(+), 43 deletions(-) create mode 100644 e2e/specs/mock/private-zone-video.spec.ts create mode 100644 src/app/private-zone/components/__tests__/private-zone-video-post-card.test.tsx create mode 100644 src/app/private-zone/components/private-zone-video-post-card.tsx create mode 100644 src/app/private-zone/components/video-post-unlock-dialog.tsx create mode 100644 src/data/repositories/interfaces/iprivate_zone_post_repository.ts create mode 100644 src/data/repositories/private_zone_post_repository.ts create mode 100644 src/data/schemas/private-zone/__tests__/private_zone_post.test.ts create mode 100644 src/data/schemas/private-zone/private_zone_post.ts create mode 100644 src/data/services/api/private_zone_post_api.ts create mode 100644 src/stores/private-zone-posts/__tests__/private-zone-post-machine.test.ts create mode 100644 src/stores/private-zone-posts/index.ts create mode 100644 src/stores/private-zone-posts/private-zone-post-actors.ts create mode 100644 src/stores/private-zone-posts/private-zone-post-context.tsx create mode 100644 src/stores/private-zone-posts/private-zone-post-events.ts create mode 100644 src/stores/private-zone-posts/private-zone-post-machine.ts create mode 100644 src/stores/private-zone-posts/private-zone-post-state.ts diff --git a/e2e/specs/mock/multi-role-commercial.spec.ts b/e2e/specs/mock/multi-role-commercial.spec.ts index 3ca68efe..bd808edb 100644 --- a/e2e/specs/mock/multi-role-commercial.spec.ts +++ b/e2e/specs/mock/multi-role-commercial.spec.ts @@ -13,6 +13,7 @@ const characters = [ displayName: "Elio Silvestri", shortName: "Elio", cover: "elio.png", + splashCover: "elio.png", }, { id: "maya-tan", @@ -20,6 +21,7 @@ const characters = [ displayName: "Maya Tan", shortName: "Maya", cover: "maya.webp", + splashCover: "maya-home.webp", }, { id: "nayeli-cervantes", @@ -27,6 +29,7 @@ const characters = [ displayName: "Nayeli Cervantes", shortName: "Nayeli", cover: "nayeli.webp", + splashCover: "nayeli.webp", }, ] as const; @@ -56,9 +59,11 @@ for (const character of characters) { 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(page.getByText(character.displayName).last()).toBeVisible(); + await expect( + page.getByRole("navigation", { name: "Primary navigation" }), + ).toContainText("Private Zone"); await expect .poll(() => cover.evaluate((image: HTMLImageElement) => @@ -84,6 +89,13 @@ for (const character of characters) { test(`${character.displayName} can open a character-scoped Private Zone`, async ({ 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 url = new URL(request.url()); return ( @@ -94,7 +106,11 @@ for (const character of characters) { 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( page.getByRole("heading", { name: "Private albums" }), ).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.getByRole("tab", { name: "Albums" }).click(); await expect(page.getByRole("button", { name: "Retry" })).toBeVisible(); await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0); }); async function registerMultiRoleCommercialMocks(page: Page): Promise { + 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) => { const url = new URL(route.request().url()); const characterId = url.searchParams.get("characterId") ?? "elio"; diff --git a/e2e/specs/mock/private-zone-video.spec.ts b/e2e/specs/mock/private-zone-video.spec.ts new file mode 100644 index 00000000..26ff7414 --- /dev/null +++ b/e2e/specs/mock/private-zone-video.spec.ts @@ -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 { + 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", + }; +} diff --git a/src/app/private-zone/components/__tests__/private-zone-video-post-card.test.tsx b/src/app/private-zone/components/__tests__/private-zone-video-post-card.test.tsx new file mode 100644 index 00000000..83879f70 --- /dev/null +++ b/src/app/private-zone/components/__tests__/private-zone-video-post-card.test.tsx @@ -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( + 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(" { + const html = renderToStaticMarkup( + undefined} + onPlaybackError={() => undefined} + />, + ); + + expect(html).toContain(" void; + onPlaybackError: () => void; +} + +export function PrivateZoneVideoPostCard({ + post, + displayName, + avatarUrl, + index, + isUnlocking, + onUnlock, + onPlaybackError, +}: PrivateZoneVideoPostCardProps) { + return ( +
+
+
+ +
+

{displayName}

+

Private moment

+
+
+ +
+ + {post.caption ?

{post.caption}

: null} + + {post.unlocked && post.videoUrl ? ( + + ) : ( +
+ {post.posterUrl ? ( + // Signed private media URLs intentionally bypass Next image optimization. + // eslint-disable-next-line @next/next/no-img-element + Private video cover + ) : ( +
+
+ )} +
+ + Private video + + {post.durationSeconds == null + ? "Unlock to watch" + : `${formatDuration(post.durationSeconds)} · Unlock to watch`} + + +
+
+ )} +
+ ); +} + +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" }); +} diff --git a/src/app/private-zone/components/video-post-unlock-dialog.tsx b/src/app/private-zone/components/video-post-unlock-dialog.tsx new file mode 100644 index 00000000..5f8bd66a --- /dev/null +++ b/src/app/private-zone/components/video-post-unlock-dialog.tsx @@ -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 ( +
+
+

Unlock this private video?

+

+ You'll use {post.unlockCostCredits} credits. The video stays in your + Private Zone after unlocking. +

+ {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+ + +
+
+
+ ); +} diff --git a/src/app/private-zone/private-zone-screen.module.css b/src/app/private-zone/private-zone-screen.module.css index acfa6470..c7c893ff 100644 --- a/src/app/private-zone/private-zone-screen.module.css +++ b/src/app/private-zone/private-zone-screen.module.css @@ -215,6 +215,38 @@ box-shadow: 0 8px 18px rgba(131, 72, 85, 0.08); } +.contentTabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 18px; + padding: 5px; + border: 1px solid rgba(255, 116, 159, 0.14); + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + box-shadow: 0 10px 24px rgba(131, 72, 85, 0.07); +} + +.contentTab { + min-height: 42px; + padding: 0 16px; + border: 0; + border-radius: 999px; + background: transparent; + color: #8a7078; + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 850; + transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; +} + +.contentTabActive { + background: linear-gradient(135deg, #ff7aa9, #ffad79); + color: #ffffff; + box-shadow: 0 8px 18px rgba(248, 77, 150, 0.2); +} + .timeline { display: flex; flex-direction: column; @@ -263,6 +295,118 @@ margin: 0; } +.momentVideo, +.momentLockedMedia { + width: 100%; + margin-top: 14px; + border-radius: clamp(18px, 4.815vw, 26px); + background: #161316; + box-shadow: 0 16px 34px rgba(34, 25, 29, 0.16); +} + +.momentVideo { + display: block; + max-height: 68vh; + aspect-ratio: 9 / 12; + object-fit: contain; +} + +.momentLockedMedia { + position: relative; + aspect-ratio: 9 / 12; + overflow: hidden; +} + +.momentPoster, +.momentPosterFallback { + display: block; + width: 100%; + height: 100%; +} + +.momentPoster { + object-fit: cover; +} + +.momentPosterFallback { + display: grid; + place-items: center; + background: + radial-gradient(circle at 70% 15%, rgba(255, 179, 109, 0.34), transparent 42%), + linear-gradient(145deg, #4f4249, #211b20); + color: rgba(255, 255, 255, 0.78); +} + +.momentLockPanel { + position: absolute; + right: 14px; + bottom: 14px; + left: 14px; + display: flex; + min-height: 164px; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 7px; + padding: 20px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 22px; + background: rgba(23, 19, 23, 0.7); + color: #ffffff; + text-align: center; + backdrop-filter: blur(14px); +} + +.momentLockIcon { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 999px; + background: rgba(255, 255, 255, 0.16); +} + +.momentLockPanel strong { + font-size: 20px; + font-weight: 900; +} + +.momentLockPanel span:not(.momentLockIcon) { + color: rgba(255, 255, 255, 0.8); + font-size: 13px; + font-weight: 650; +} + +.momentLockPanel button, +.loadMoreButton { + min-height: 44px; + padding: 0 22px; + border: 0; + border-radius: 999px; + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 850; +} + +.momentLockPanel button { + margin-top: 9px; + background: #ffffff; + color: #2c2227; +} + +.momentLockPanel button:disabled { + cursor: wait; + opacity: 0.7; +} + +.loadMoreButton { + display: block; + margin: 16px auto 0; + background: rgba(255, 93, 149, 0.12); + color: #a94c64; +} + .authorName { color: #26191d; font-size: clamp(14px, 3.333vw, 17px); @@ -776,6 +920,9 @@ } .primaryCta:focus-visible, +.contentTab:focus-visible, +.momentLockPanel button:focus-visible, +.loadMoreButton:focus-visible, .lockedPreviewCta:focus-visible, .mediaGridItem:focus-visible, .galleryClose:focus-visible, diff --git a/src/app/private-zone/private-zone-screen.tsx b/src/app/private-zone/private-zone-screen.tsx index 708297e7..187a70ba 100644 --- a/src/app/private-zone/private-zone-screen.tsx +++ b/src/app/private-zone/private-zone-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; @@ -18,17 +18,24 @@ import { useActiveCharacterRoutes, } from "@/providers/character-provider"; import { isPrivateAlbumLocked } from "@/lib/private-zone/private_album"; +import { behaviorAnalytics } from "@/lib/analytics"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { usePrivateZoneDispatch, usePrivateZoneState, } from "@/stores/private-zone"; +import { + usePrivateZonePostDispatch, + usePrivateZonePostState, +} from "@/stores/private-zone-posts"; import { PrivateAlbumCard, PrivateAlbumGallery, + PrivateZoneVideoPostCard, StatusCard, UnlockConfirmDialog, + VideoPostUnlockDialog, } from "./components"; import styles from "./private-zone-screen.module.css"; import { @@ -54,11 +61,16 @@ export function PrivateZoneScreen() { const authDispatch = useAuthDispatch(); const state = usePrivateZoneState(); const dispatch = usePrivateZoneDispatch(); + const postState = usePrivateZonePostState(); + const postDispatch = usePrivateZonePostDispatch(); + const [activeTab, setActiveTab] = useState<"moments" | "albums">("moments"); const openedGalleryInPageRef = useRef(false); + const previousPostLoginStatusRef = useRef(authState.loginStatus); const galleryState = useMemo( () => getPrivateAlbumGalleryState(searchParams), [searchParams], ); + const visibleTab = galleryState ? "albums" : activeTab; usePrivateZoneBootstrapFlow({ authDispatch, @@ -74,6 +86,56 @@ export function PrivateZoneScreen() { unlockPaywallRequest: state.unlockPaywallRequest, }); usePrivateZoneUnlockSuccessRefresh(state.unlockSuccessNonce); + usePrivateZoneUnlockSuccessRefresh(postState.unlockSuccessNonce); + + useEffect(() => { + if (!authState.hasInitialized || authState.isLoading) return; + if (authState.loginStatus === "notLoggedIn") return; + + const previous = previousPostLoginStatusRef.current; + previousPostLoginStatusRef.current = authState.loginStatus; + if (postState.status === "idle") { + postDispatch({ type: "PrivateZonePostInit" }); + return; + } + if (previous !== authState.loginStatus && postState.status !== "loading") { + postDispatch({ type: "PrivateZonePostRefresh" }); + } + }, [ + authState.hasInitialized, + authState.isLoading, + authState.loginStatus, + postDispatch, + postState.status, + ]); + + useEffect(() => { + const request = postState.unlockPaywallRequest; + if (!request) return; + if (isPrivateZoneAuthRequired(authState.loginStatus)) { + navigator.openAuth(characterRoutes.privateZone); + } else { + behaviorAnalytics.paywallShown({ + entryPoint: "private_zone", + triggerReason: "insufficient_credits", + }); + navigator.openSubscription({ + type: "topup", + returnTo: "private-zone", + analytics: { + entryPoint: "private_zone", + triggerReason: "insufficient_credits", + }, + }); + } + postDispatch({ type: "PrivateZonePostUnlockPaywallConsumed" }); + }, [ + authState.loginStatus, + characterRoutes.privateZone, + navigator, + postDispatch, + postState.unlockPaywallRequest, + ]); const displayName = character.displayName; const avatarUrl = character.assets.avatar; @@ -86,6 +148,13 @@ export function PrivateZoneScreen() { ) ?? null, [state.items, state.pendingConfirmAlbumId], ); + const pendingPost = useMemo( + () => + postState.items.find( + (post) => post.postId === postState.pendingConfirmPostId, + ) ?? null, + [postState.items, postState.pendingConfirmPostId], + ); const galleryAlbum = useMemo( () => galleryState @@ -157,6 +226,14 @@ export function PrivateZoneScreen() { ); }; + const handlePostUnlock = (postId: string) => { + if (isPrivateZoneAuthRequired(authState.loginStatus)) { + navigator.openAuth(characterRoutes.privateZone); + return; + } + postDispatch({ type: "PrivateZonePostUnlockRequested", postId }); + }; + const handleCloseGallery = () => { if (openedGalleryInPageRef.current) { openedGalleryInPageRef.current = false; @@ -235,49 +312,116 @@ export function PrivateZoneScreen() {
+
+ + +
+
-

Albums

+

{visibleTab === "moments" ? "Moments" : "Albums"}

- Private albums + {visibleTab === "moments" ? "Private moments" : "Private albums"}

- {state.items.length} + + {visibleTab === "moments" ? postState.items.length : state.items.length} +
- {state.errorMessage ? ( - dispatch({ type: "PrivateZoneRefresh" })} - /> - ) : null} - - {state.isLoading && state.items.length === 0 ? ( - - ) : null} - -
- {state.items.map((album, index) => ( - - handleOpenGallery(album.albumId, imageIndex) - } - onUnlock={() => - dispatch({ - type: "PrivateZoneUnlockRequested", - albumId: album.albumId, - }) - } - /> - ))} -
+ {visibleTab === "moments" ? ( + <> + {postState.errorMessage ? ( + postDispatch({ type: "PrivateZonePostRefresh" })} + /> + ) : null} + {postState.isLoading && postState.items.length === 0 ? ( + + ) : null} + {!postState.isLoading && !postState.errorMessage && postState.items.length === 0 ? ( + + ) : null} +
+ {postState.items.map((post, index) => ( + handlePostUnlock(post.postId)} + onPlaybackError={() => + postDispatch({ type: "PrivateZonePostRefresh" }) + } + /> + ))} +
+ {postState.hasMore ? ( + + ) : null} + + ) : ( + <> + {state.errorMessage ? ( + dispatch({ type: "PrivateZoneRefresh" })} + /> + ) : null} + {state.isLoading && state.items.length === 0 ? ( + + ) : null} +
+ {state.items.map((album, index) => ( + + handleOpenGallery(album.albumId, imageIndex) + } + onUnlock={() => + dispatch({ + type: "PrivateZoneUnlockRequested", + albumId: album.albumId, + }) + } + /> + ))} +
+ + )}
) : null} + {pendingPost ? ( + + postDispatch({ type: "PrivateZonePostUnlockCancelled" }) + } + onConfirm={() => + postDispatch({ type: "PrivateZonePostUnlockConfirmed" }) + } + /> + ) : postState.unlockErrorMessage ? ( +
+ {postState.unlockErrorMessage} +
+ ) : null} + {galleryAlbum && galleryState && !isPrivateAlbumLocked(galleryAlbum) && diff --git a/src/data/repositories/interfaces/index.ts b/src/data/repositories/interfaces/index.ts index 6173bdbc..f54686f4 100644 --- a/src/data/repositories/interfaces/index.ts +++ b/src/data/repositories/interfaces/index.ts @@ -9,4 +9,5 @@ export * from "./ifeedback_repository"; export * from "./imetrics_repository"; export * from "./ipayment_repository"; export * from "./iprivate_zone_repository"; +export * from "./iprivate_zone_post_repository"; export * from "./iuser_repository"; diff --git a/src/data/repositories/interfaces/iprivate_zone_post_repository.ts b/src/data/repositories/interfaces/iprivate_zone_post_repository.ts new file mode 100644 index 00000000..92cce7a1 --- /dev/null +++ b/src/data/repositories/interfaces/iprivate_zone_post_repository.ts @@ -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>; + unlockPost( + postId: string, + expectedCost: number, + ): Promise>; +} diff --git a/src/data/repositories/private_zone_post_repository.ts b/src/data/repositories/private_zone_post_repository.ts new file mode 100644 index 00000000..c1634df3 --- /dev/null +++ b/src/data/repositories/private_zone_post_repository.ts @@ -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> { + return Result.wrap(() => this.api.getPosts(input)); + } + + unlockPost( + postId: string, + expectedCost: number, + ): Promise> { + return Result.wrap(() => + this.api.unlockPost( + postId, + UnlockPrivateZonePostRequestSchema.parse({ expectedCost }), + ), + ); + } +} + +export const getPrivateZonePostRepository = + createLazySingleton( + () => new PrivateZonePostRepository(privateZonePostApi), + ); diff --git a/src/data/schemas/private-zone/__tests__/private_zone_post.test.ts b/src/data/schemas/private-zone/__tests__/private_zone_post.test.ts new file mode 100644 index 00000000..75545ba2 --- /dev/null +++ b/src/data/schemas/private-zone/__tests__/private_zone_post.test.ts @@ -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); + }); +}); diff --git a/src/data/schemas/private-zone/index.ts b/src/data/schemas/private-zone/index.ts index e52eed85..4b9a156d 100644 --- a/src/data/schemas/private-zone/index.ts +++ b/src/data/schemas/private-zone/index.ts @@ -3,6 +3,7 @@ */ export * from "./private_album"; +export * from "./private_zone_post"; export * from "./request/unlock_private_album_request"; export * from "./response/private_album_unlock_response"; export * from "./response/private_albums_response"; diff --git a/src/data/schemas/private-zone/private_zone_post.ts b/src/data/schemas/private-zone/private_zone_post.ts new file mode 100644 index 00000000..25bbfedd --- /dev/null +++ b/src/data/schemas/private-zone/private_zone_post.ts @@ -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; +export type PrivateZonePostsResponse = z.output; +export type PrivateZonePostUnlockResponse = z.output< + typeof PrivateZonePostUnlockResponseSchema +>; +export type UnlockPrivateZonePostRequest = z.output< + typeof UnlockPrivateZonePostRequestSchema +>; diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json index b372f686..ff1aa5d7 100644 --- a/src/data/services/api/api_contract.json +++ b/src/data/services/api/api_contract.json @@ -26,6 +26,8 @@ "chatUnlockHistory": { "method": "post", "path": "/api/chat/unlock-history" }, "privateZoneAlbums": { "method": "get", "path": "/api/private-zone/albums" }, "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" }, "reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" }, "feedback": { "method": "post", "path": "/api/feedback" }, diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index bd7c2e33..885777a2 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -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 事件 */ static readonly metricsPwaEvent = apiContract.metricsPwaEvent.path; diff --git a/src/data/services/api/private_zone_post_api.ts b/src/data/services/api/private_zone_post_api.ts new file mode 100644 index 00000000..c837153a --- /dev/null +++ b/src/data/services/api/private_zone_post_api.ts @@ -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 { + const env = await httpClient>( + 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 { + const env = await httpClient>( + ApiPath.privateZonePostUnlock(postId), + { method: "POST", body }, + ); + const parsed = PrivateZonePostUnlockResponseSchema.parse(unwrap(env)); + return parsed.postId ? parsed : { ...parsed, postId }; + } +} + +export const privateZonePostApi = new PrivateZonePostApi(); diff --git a/src/providers/private-zone-route-provider.tsx b/src/providers/private-zone-route-provider.tsx index ac112375..f29e69db 100644 --- a/src/providers/private-zone-route-provider.tsx +++ b/src/providers/private-zone-route-provider.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { PrivateZoneProvider } from "@/stores/private-zone"; +import { PrivateZonePostProvider } from "@/stores/private-zone-posts"; export interface PrivateZoneRouteProviderProps { children: ReactNode; @@ -14,8 +15,10 @@ export function PrivateZoneRouteProvider({ characterId, }: PrivateZoneRouteProviderProps) { return ( - - {children} - + + + {children} + + ); } diff --git a/src/stores/private-zone-posts/__tests__/private-zone-post-machine.test.ts b/src/stores/private-zone-posts/__tests__/private-zone-post-machine.test.ts new file mode 100644 index 00000000..e58773bc --- /dev/null +++ b/src/stores/private-zone-posts/__tests__/private-zone-post-machine.test.ts @@ -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(); + }); +}); diff --git a/src/stores/private-zone-posts/index.ts b/src/stores/private-zone-posts/index.ts new file mode 100644 index 00000000..8b74b95d --- /dev/null +++ b/src/stores/private-zone-posts/index.ts @@ -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"; diff --git a/src/stores/private-zone-posts/private-zone-post-actors.ts b/src/stores/private-zone-posts/private-zone-post-actors.ts new file mode 100644 index 00000000..f019093d --- /dev/null +++ b/src/stores/private-zone-posts/private-zone-post-actors.ts @@ -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 }; +}); diff --git a/src/stores/private-zone-posts/private-zone-post-context.tsx b/src/stores/private-zone-posts/private-zone-post-context.tsx new file mode 100644 index 00000000..6243068d --- /dev/null +++ b/src/stores/private-zone-posts/private-zone-post-context.tsx @@ -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; +const Context = createActorContext(privateZonePostMachine); + +export interface PrivateZonePostProviderProps { + children: ReactNode; + characterId: string; +} + +export function PrivateZonePostProvider({ + children, + characterId, +}: PrivateZonePostProviderProps) { + return ( + + {children} + + ); +} + +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 { + return Context.useActorRef().send; +} diff --git a/src/stores/private-zone-posts/private-zone-post-events.ts b/src/stores/private-zone-posts/private-zone-post-events.ts new file mode 100644 index 00000000..bd9b5c22 --- /dev/null +++ b/src/stores/private-zone-posts/private-zone-post-events.ts @@ -0,0 +1,8 @@ +export type PrivateZonePostEvent = + | { type: "PrivateZonePostInit" } + | { type: "PrivateZonePostRefresh" } + | { type: "PrivateZonePostLoadMore" } + | { type: "PrivateZonePostUnlockRequested"; postId: string } + | { type: "PrivateZonePostUnlockConfirmed" } + | { type: "PrivateZonePostUnlockCancelled" } + | { type: "PrivateZonePostUnlockPaywallConsumed" }; diff --git a/src/stores/private-zone-posts/private-zone-post-machine.ts b/src/stores/private-zone-posts/private-zone-post-machine.ts new file mode 100644 index 00000000..77703ffb --- /dev/null +++ b/src/stores/private-zone-posts/private-zone-post-machine.ts @@ -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; diff --git a/src/stores/private-zone-posts/private-zone-post-state.ts b/src/stores/private-zone-posts/private-zone-post-state.ts new file mode 100644 index 00000000..4b256917 --- /dev/null +++ b/src/stores/private-zone-posts/private-zone-post-state.ts @@ -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, + }; +}