feat(private-zone): add paid video moments
This commit is contained in:
@@ -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