feat(private-zone): add paid video moments

This commit is contained in:
Codex
2026-07-24 20:17:28 +08:00
parent ff2759cf0f
commit a44fc4860e
25 changed files with 1578 additions and 43 deletions
@@ -0,0 +1,126 @@
import { createActor, fromPromise, waitFor } from "xstate";
import { describe, expect, it } from "vitest";
import type {
PrivateZonePostUnlockResponse,
PrivateZonePostsResponse,
} from "@/data/schemas/private-zone";
import { privateZonePostMachine } from "../private-zone-post-machine";
const POST_ID = "11111111-1111-1111-1111-111111111111";
const lockedPost = {
postId: POST_ID,
characterId: "maya-tan",
caption: "Only for you.",
posterUrl: "https://storage.example/poster.jpg?token=short",
mediaType: "video" as const,
videoUrl: null,
videoMimeType: "video/mp4",
videoSizeBytes: 1024,
durationSeconds: 18,
unlockCostCredits: 100,
currency: "credits" as const,
locked: true,
unlocked: false,
availableForNewUnlock: true,
publishedAt: "2026-07-24T10:00:00+00:00",
};
const listResponse: PrivateZonePostsResponse = {
characterId: "maya-tan",
items: [lockedPost],
nextCursor: null,
hasMore: false,
creditBalance: 150,
currency: "credits",
};
function actorWithUnlock(response: PrivateZonePostUnlockResponse) {
const machine = privateZonePostMachine.provide({
actors: {
loadPosts: fromPromise(async () => listResponse),
unlockPost: fromPromise(async () => response),
},
});
return createActor(machine, { input: { characterId: "maya-tan" } }).start();
}
describe("Private Zone video post machine", () => {
it("loads moments independently from album state", async () => {
const actor = actorWithUnlock({
postId: POST_ID,
reason: "deduct_failed",
unlocked: false,
creditsCharged: 0,
requiredCredits: 100,
currentCredits: 150,
shortfallCredits: 0,
creditBalance: 150,
post: lockedPost,
});
actor.send({ type: "PrivateZonePostInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context.items).toHaveLength(1);
expect(actor.getSnapshot().context.creditBalance).toBe(150);
});
it("patches the signed video after a successful unlock", async () => {
const actor = actorWithUnlock({
postId: POST_ID,
reason: "ok",
unlocked: true,
creditsCharged: 100,
requiredCredits: 100,
currentCredits: 50,
shortfallCredits: 0,
creditBalance: 50,
post: {
...lockedPost,
locked: false,
unlocked: true,
videoUrl: "https://storage.example/video.mp4?token=short",
},
});
actor.send({ type: "PrivateZonePostInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PrivateZonePostUnlockRequested", postId: POST_ID });
actor.send({ type: "PrivateZonePostUnlockConfirmed" });
await waitFor(
actor,
(snapshot) => snapshot.matches("ready") && snapshot.context.unlockSuccessNonce === 1,
);
expect(actor.getSnapshot().context.items[0].videoUrl).toContain("video.mp4");
expect(actor.getSnapshot().context.creditBalance).toBe(50);
});
it("creates a top-up request without unlocking when credits are insufficient", async () => {
const actor = actorWithUnlock({
postId: POST_ID,
reason: "insufficient_credits",
unlocked: false,
creditsCharged: 0,
requiredCredits: 100,
currentCredits: 25,
shortfallCredits: 75,
creditBalance: 25,
post: lockedPost,
});
actor.send({ type: "PrivateZonePostInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PrivateZonePostUnlockRequested", postId: POST_ID });
actor.send({ type: "PrivateZonePostUnlockConfirmed" });
await waitFor(
actor,
(snapshot) => snapshot.matches("ready") && !!snapshot.context.unlockPaywallRequest,
);
expect(actor.getSnapshot().context.unlockPaywallRequest?.shortfallCredits).toBe(75);
expect(actor.getSnapshot().context.items[0].videoUrl).toBeNull();
});
});
+4
View File
@@ -0,0 +1,4 @@
export * from "./private-zone-post-context";
export * from "./private-zone-post-events";
export * from "./private-zone-post-machine";
export * from "./private-zone-post-state";
@@ -0,0 +1,37 @@
import { fromPromise } from "xstate";
import type {
PrivateZonePostsResponse,
PrivateZonePostUnlockResponse,
} from "@/data/schemas/private-zone";
import { getPrivateZonePostRepository } from "@/data/repositories/private_zone_post_repository";
import { Result } from "@/utils/result";
export const PRIVATE_ZONE_POST_PAGE_SIZE = 20;
export const loadPrivateZonePostsActor = fromPromise<
PrivateZonePostsResponse,
{ characterId: string; cursor?: string | null }
>(async ({ input }) => {
const result = await getPrivateZonePostRepository().getPosts({
characterId: input.characterId,
cursor: input.cursor,
limit: PRIVATE_ZONE_POST_PAGE_SIZE,
});
if (Result.isErr(result)) throw result.error;
return result.data;
});
export const unlockPrivateZonePostActor = fromPromise<
PrivateZonePostUnlockResponse,
{ postId: string; expectedCost: number }
>(async ({ input }) => {
const result = await getPrivateZonePostRepository().unlockPost(
input.postId,
input.expectedCost,
);
if (Result.isErr(result)) throw result.error;
return result.data.postId
? result.data
: { ...result.data, postId: input.postId };
});
@@ -0,0 +1,45 @@
"use client";
import type { Dispatch, ReactNode } from "react";
import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate";
import type { PrivateZonePostEvent } from "./private-zone-post-events";
import { privateZonePostMachine } from "./private-zone-post-machine";
type Snapshot = SnapshotFrom<typeof privateZonePostMachine>;
const Context = createActorContext(privateZonePostMachine);
export interface PrivateZonePostProviderProps {
children: ReactNode;
characterId: string;
}
export function PrivateZonePostProvider({
children,
characterId,
}: PrivateZonePostProviderProps) {
return (
<Context.Provider options={{ input: { characterId } }}>
{children}
</Context.Provider>
);
}
function selectState(snapshot: Snapshot) {
return {
status: String(snapshot.value),
...snapshot.context,
isLoading: snapshot.matches("loading"),
isLoadingMore: snapshot.matches("loadingMore"),
isUnlocking: snapshot.matches("unlocking"),
};
}
export function usePrivateZonePostState() {
return Context.useSelector(selectState, shallowEqual);
}
export function usePrivateZonePostDispatch(): Dispatch<PrivateZonePostEvent> {
return Context.useActorRef().send;
}
@@ -0,0 +1,8 @@
export type PrivateZonePostEvent =
| { type: "PrivateZonePostInit" }
| { type: "PrivateZonePostRefresh" }
| { type: "PrivateZonePostLoadMore" }
| { type: "PrivateZonePostUnlockRequested"; postId: string }
| { type: "PrivateZonePostUnlockConfirmed" }
| { type: "PrivateZonePostUnlockCancelled" }
| { type: "PrivateZonePostUnlockPaywallConsumed" };
@@ -0,0 +1,259 @@
import { assign, setup } from "xstate";
import type {
PrivateZonePostsResponse,
PrivateZonePostUnlockResponse,
PrivateZoneVideoPost,
} from "@/data/schemas/private-zone";
import {
loadPrivateZonePostsActor,
unlockPrivateZonePostActor,
} from "./private-zone-post-actors";
import type { PrivateZonePostEvent } from "./private-zone-post-events";
import {
createInitialPrivateZonePostState,
type PrivateZonePostState,
} from "./private-zone-post-state";
function errorMessage(error: unknown): string {
return error instanceof Error && error.message
? error.message
: "Moments are temporarily unavailable. Please try again.";
}
function pendingPost(context: PrivateZonePostState): PrivateZoneVideoPost | null {
if (!context.pendingConfirmPostId) return null;
return context.items.find(
(post) => post.postId === context.pendingConfirmPostId,
) ?? null;
}
function patchPost(
items: readonly PrivateZoneVideoPost[],
response: PrivateZonePostUnlockResponse,
): readonly PrivateZoneVideoPost[] {
if (!response.post) return items;
return items.map((post) =>
post.postId === response.postId ? response.post! : post,
);
}
function appendPosts(
current: readonly PrivateZoneVideoPost[],
response: PrivateZonePostsResponse,
): readonly PrivateZoneVideoPost[] {
const byId = new Map(current.map((post) => [post.postId, post]));
response.items.forEach((post) => byId.set(post.postId, post));
return [...byId.values()];
}
const machineSetup = setup({
types: {
context: {} as PrivateZonePostState,
events: {} as PrivateZonePostEvent,
input: {} as { characterId: string },
},
actors: {
loadPosts: loadPrivateZonePostsActor,
unlockPost: unlockPrivateZonePostActor,
},
guards: {
hasMore: ({ context }) => context.hasMore && !!context.nextCursor,
hasPendingPost: ({ context }) => pendingPost(context) !== null,
},
});
export const privateZonePostMachine = machineSetup.createMachine({
id: "privateZonePosts",
context: ({ input }) => createInitialPrivateZonePostState(input.characterId),
initial: "idle",
states: {
idle: {
on: { PrivateZonePostInit: "loading" },
},
loading: {
entry: assign({
errorMessage: null,
unlockPaywallRequest: null,
}),
invoke: {
src: "loadPosts",
input: ({ context }) => ({ characterId: context.characterId }),
onDone: {
target: "ready",
actions: assign(({ event }) => ({
items: event.output.items,
creditBalance: event.output.creditBalance,
nextCursor: event.output.nextCursor,
hasMore: event.output.hasMore,
errorMessage: null,
})),
},
onError: {
target: "failed",
actions: assign(({ event }) => ({
errorMessage: errorMessage(event.error),
})),
},
},
},
failed: {
on: {
PrivateZonePostInit: "loading",
PrivateZonePostRefresh: "loading",
},
},
ready: {
on: {
PrivateZonePostRefresh: "loading",
PrivateZonePostLoadMore: {
guard: "hasMore",
target: "loadingMore",
},
PrivateZonePostUnlockRequested: {
actions: assign(({ event }) => ({
pendingConfirmPostId: event.postId,
unlockErrorMessage: null,
unlockPaywallRequest: null,
})),
},
PrivateZonePostUnlockCancelled: {
actions: assign({ pendingConfirmPostId: null }),
},
PrivateZonePostUnlockConfirmed: {
guard: "hasPendingPost",
target: "unlocking",
},
PrivateZonePostUnlockPaywallConsumed: {
actions: assign({ unlockPaywallRequest: null }),
},
},
},
loadingMore: {
invoke: {
src: "loadPosts",
input: ({ context }) => ({
characterId: context.characterId,
cursor: context.nextCursor,
}),
onDone: {
target: "ready",
actions: assign(({ context, event }) => ({
items: appendPosts(context.items, event.output),
creditBalance: event.output.creditBalance,
nextCursor: event.output.nextCursor,
hasMore: event.output.hasMore,
errorMessage: null,
})),
},
onError: {
target: "ready",
actions: assign(({ event }) => ({
errorMessage: errorMessage(event.error),
})),
},
},
},
unlocking: {
entry: assign(({ context }) => ({
unlockingPostId: context.pendingConfirmPostId,
unlockErrorMessage: null,
unlockPaywallRequest: null,
})),
invoke: {
src: "unlockPost",
input: ({ context }) => {
const post = pendingPost(context);
return {
postId: post?.postId ?? "",
expectedCost: post?.unlockCostCredits ?? 0,
};
},
onDone: [
{
guard: ({ event }) => event.output.unlocked,
target: "ready",
actions: assign(({ context, event }) => ({
items: patchPost(context.items, event.output),
creditBalance: event.output.creditBalance,
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage: null,
unlockSuccessNonce: context.unlockSuccessNonce + 1,
})),
},
{
guard: ({ event }) =>
event.output.reason === "insufficient_credits",
target: "ready",
actions: assign(({ context, event }) => ({
items: patchPost(context.items, event.output),
creditBalance: event.output.creditBalance,
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage: null,
unlockPaywallRequest: {
postId: event.output.postId,
reason: event.output.reason,
requiredCredits:
event.output.requiredCredits ||
event.output.post?.unlockCostCredits ||
0,
currentCredits:
event.output.currentCredits || event.output.creditBalance,
shortfallCredits: event.output.shortfallCredits,
},
})),
},
{
guard: ({ event }) => event.output.reason === "cost_changed",
target: "ready",
actions: assign(({ context, event }) => ({
items: patchPost(context.items, event.output),
creditBalance: event.output.creditBalance,
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage:
"Unlock price changed. Please review the new price and confirm again.",
})),
},
{
guard: ({ event }) =>
event.output.reason === "not_found" ||
event.output.reason === "unavailable",
target: "ready",
actions: assign(({ context, event }) => ({
items: context.items.filter(
(post) => post.postId !== event.output.postId,
),
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage: "This moment is no longer available.",
})),
},
{
target: "ready",
actions: assign(({ context, event }) => ({
items: patchPost(context.items, event.output),
creditBalance: event.output.creditBalance,
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage: "Unlock failed. Please try again.",
})),
},
],
onError: {
target: "ready",
actions: assign(({ event }) => ({
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage: errorMessage(event.error),
})),
},
},
},
},
});
export type PrivateZonePostMachine = typeof privateZonePostMachine;
@@ -0,0 +1,41 @@
import type { PrivateZoneVideoPost } from "@/data/schemas/private-zone";
export interface PrivateZonePostPaywallRequest {
postId: string;
reason: string;
requiredCredits: number;
currentCredits: number;
shortfallCredits: number;
}
export interface PrivateZonePostState {
characterId: string;
items: readonly PrivateZoneVideoPost[];
creditBalance: number;
nextCursor: string | null;
hasMore: boolean;
errorMessage: string | null;
pendingConfirmPostId: string | null;
unlockingPostId: string | null;
unlockErrorMessage: string | null;
unlockPaywallRequest: PrivateZonePostPaywallRequest | null;
unlockSuccessNonce: number;
}
export function createInitialPrivateZonePostState(
characterId: string,
): PrivateZonePostState {
return {
characterId,
items: [],
creditBalance: 0,
nextCursor: null,
hasMore: false,
errorMessage: null,
pendingConfirmPostId: null,
unlockingPostId: null,
unlockErrorMessage: null,
unlockPaywallRequest: null,
unlockSuccessNonce: 0,
};
}