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;