Files
cozsweet-frontend-nextjs/src/stores/private-zone-posts/private-zone-post-machine.ts
T
Codex 8c0d1ad3ce
Docker Image / Build and Push Docker Image (push) Successful in 5m2s
feat(chat): render role-bound payment guidance
2026-07-27 18:21:13 +08:00

263 lines
8.2 KiB
TypeScript

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,
...(event.output.paymentGuidance
? { paymentGuidance: event.output.paymentGuidance }
: {}),
},
})),
},
{
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;