feat(private-room): connect moments feed
Docker Image / Build and Push Docker Image (push) Successful in 9m29s
Docker Image / Build and Push Docker Image (push) Successful in 9m29s
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createActor, fromPromise, waitFor } from "xstate";
|
||||
|
||||
import {
|
||||
PrivateRoomMomentsResponse,
|
||||
PrivateRoomUnlockResponse,
|
||||
} from "@/data/dto/private-room";
|
||||
import { privateRoomMachine } from "@/stores/private-room/private-room-machine";
|
||||
|
||||
const baseMoment = {
|
||||
momentId: "schedule:91",
|
||||
source: "elio_schedules",
|
||||
sourceId: "91",
|
||||
characterId: "elio",
|
||||
author: {
|
||||
id: "elio",
|
||||
name: "Elio Silvestri",
|
||||
avatarUrl: null,
|
||||
},
|
||||
createdAt: "2026-07-01T08:11:35+00:00",
|
||||
publishedAt: "2026-07-01T08:11:35+00:00",
|
||||
timeText: "7 days ago",
|
||||
title: "Paris morning",
|
||||
content: null,
|
||||
text: null,
|
||||
textPreview: "Unlock to view private room photos",
|
||||
mediaCount: 1,
|
||||
images: [
|
||||
{
|
||||
url: null,
|
||||
type: "image",
|
||||
locked: true,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
locked: true,
|
||||
unlocked: false,
|
||||
unlockCost: 40,
|
||||
unlockCostPerImage: 40,
|
||||
requiredCredits: 40,
|
||||
currency: "credits",
|
||||
lockDetail: {
|
||||
locked: true,
|
||||
showContent: false,
|
||||
showUpgrade: true,
|
||||
reason: "private_room_moment",
|
||||
hint: "40 credits · Unlock photo",
|
||||
actionLabel: "Unlock",
|
||||
type: "private_room_moment",
|
||||
requiredCredits: 40,
|
||||
currentCredits: 100,
|
||||
shortfallCredits: 0,
|
||||
mediaCount: 1,
|
||||
unlockCostPerImage: 40,
|
||||
},
|
||||
};
|
||||
|
||||
function makeMomentsResponse(
|
||||
overrides: Partial<Parameters<typeof PrivateRoomMomentsResponse.from>[0]> = {},
|
||||
): PrivateRoomMomentsResponse {
|
||||
return PrivateRoomMomentsResponse.from({
|
||||
profile: {
|
||||
characterId: "elio",
|
||||
displayName: "Elio Silvestri",
|
||||
authorName: "Elio Silvestri",
|
||||
avatarUrl: null,
|
||||
coverUrl: null,
|
||||
title: "Elio Private room",
|
||||
subtitle: "Join me, unlock my private room",
|
||||
},
|
||||
items: [baseMoment],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
creditBalance: 100,
|
||||
unlockCostDefault: 40,
|
||||
unlockCostPerImage: 40,
|
||||
currency: "credits",
|
||||
source: "elio_schedules",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function makeUnlockResponse(
|
||||
overrides: Partial<Parameters<typeof PrivateRoomUnlockResponse.from>[0]> = {},
|
||||
): PrivateRoomUnlockResponse {
|
||||
return PrivateRoomUnlockResponse.from({
|
||||
...baseMoment,
|
||||
locked: false,
|
||||
unlocked: true,
|
||||
images: [
|
||||
{
|
||||
url: "https://example.supabase.co/storage/v1/object/public/elio-schedules/photo.jpg",
|
||||
type: "image",
|
||||
locked: false,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
reason: "ok",
|
||||
creditsCharged: 40,
|
||||
creditBalance: 60,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createTestMachine(
|
||||
options: {
|
||||
firstPage?: PrivateRoomMomentsResponse;
|
||||
morePage?: PrivateRoomMomentsResponse;
|
||||
unlockResponse?: PrivateRoomUnlockResponse;
|
||||
} = {},
|
||||
) {
|
||||
return privateRoomMachine.provide({
|
||||
actors: {
|
||||
loadMoments: fromPromise(async () => options.firstPage ?? makeMomentsResponse()),
|
||||
loadMoreMoments: fromPromise(async () =>
|
||||
options.morePage ??
|
||||
makeMomentsResponse({
|
||||
items: [
|
||||
{
|
||||
...baseMoment,
|
||||
momentId: "schedule:92",
|
||||
sourceId: "92",
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
}),
|
||||
),
|
||||
unlockMoment: fromPromise(async () =>
|
||||
options.unlockResponse ?? makeUnlockResponse(),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("privateRoomMachine", () => {
|
||||
it("loads the first page", async () => {
|
||||
const actor = createActor(createTestMachine()).start();
|
||||
|
||||
actor.send({ type: "PrivateRoomInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.profile?.displayName).toBe("Elio Silvestri");
|
||||
expect(context.items).toHaveLength(1);
|
||||
expect(context.creditBalance).toBe(100);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("appends more moments without overwriting existing items", async () => {
|
||||
const actor = createActor(
|
||||
createTestMachine({
|
||||
firstPage: makeMomentsResponse({
|
||||
hasMore: true,
|
||||
nextCursor: "cursor-1",
|
||||
}),
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "PrivateRoomInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
actor.send({ type: "PrivateRoomLoadMore" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
expect(actor.getSnapshot().context.items.map((item) => item.momentId)).toEqual([
|
||||
"schedule:91",
|
||||
"schedule:92",
|
||||
]);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("replaces a locked moment after unlock success", async () => {
|
||||
const actor = createActor(createTestMachine()).start();
|
||||
|
||||
actor.send({ type: "PrivateRoomInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
actor.send({
|
||||
type: "PrivateRoomUnlockRequested",
|
||||
momentId: "schedule:91",
|
||||
});
|
||||
actor.send({ type: "PrivateRoomUnlockConfirmed" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.items[0]?.unlocked).toBe(true);
|
||||
expect(context.items[0]?.images[0]?.url).toContain("https://");
|
||||
expect(context.unlockSuccessNonce).toBe(1);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("exposes a paywall request for insufficient credits", async () => {
|
||||
const actor = createActor(
|
||||
createTestMachine({
|
||||
unlockResponse: makeUnlockResponse({
|
||||
...baseMoment,
|
||||
reason: "insufficient_credits",
|
||||
locked: true,
|
||||
unlocked: false,
|
||||
creditBalance: 10,
|
||||
currentCredits: 10,
|
||||
shortfallCredits: 30,
|
||||
}),
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "PrivateRoomInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
actor.send({
|
||||
type: "PrivateRoomUnlockRequested",
|
||||
momentId: "schedule:91",
|
||||
});
|
||||
actor.send({ type: "PrivateRoomUnlockConfirmed" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.items[0]?.unlocked).toBe(false);
|
||||
expect(context.unlockPaywallRequest).toMatchObject({
|
||||
momentId: "schedule:91",
|
||||
reason: "insufficient_credits",
|
||||
shortfallCredits: 30,
|
||||
});
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("refreshes and keeps an unlock error when the cost changes", async () => {
|
||||
const actor = createActor(
|
||||
createTestMachine({
|
||||
unlockResponse: makeUnlockResponse({
|
||||
...baseMoment,
|
||||
reason: "cost_changed",
|
||||
locked: true,
|
||||
unlocked: false,
|
||||
}),
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "PrivateRoomInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
actor.send({
|
||||
type: "PrivateRoomUnlockRequested",
|
||||
momentId: "schedule:91",
|
||||
});
|
||||
actor.send({ type: "PrivateRoomUnlockConfirmed" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
expect(actor.getSnapshot().context.unlockErrorMessage).toBe(
|
||||
"Unlock price changed. Please confirm again.",
|
||||
);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./private-room-context";
|
||||
export * from "./private-room-events";
|
||||
export * from "./private-room-machine";
|
||||
export * from "./private-room-state";
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useMachine } from "@xstate/react";
|
||||
|
||||
import { privateRoomMachine } from "./private-room-machine";
|
||||
import type {
|
||||
PrivateRoomEvent,
|
||||
PrivateRoomState as MachineContext,
|
||||
} from "./private-room-machine";
|
||||
|
||||
export interface PrivateRoomContextState {
|
||||
status: string;
|
||||
profile: MachineContext["profile"];
|
||||
items: MachineContext["items"];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
creditBalance: number;
|
||||
errorMessage: string | null;
|
||||
unlockingMomentId: string | null;
|
||||
unlockErrorMessage: string | null;
|
||||
pendingConfirmMomentId: string | null;
|
||||
unlockPaywallRequest: MachineContext["unlockPaywallRequest"];
|
||||
unlockSuccessNonce: number;
|
||||
isLoading: boolean;
|
||||
isLoadingMore: boolean;
|
||||
isUnlocking: boolean;
|
||||
}
|
||||
|
||||
const PrivateRoomStateCtx = createContext<PrivateRoomContextState | null>(null);
|
||||
const PrivateRoomDispatchCtx = createContext<Dispatch<PrivateRoomEvent> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export interface PrivateRoomProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PrivateRoomProvider({ children }: PrivateRoomProviderProps) {
|
||||
const [state, send] = useMachine(privateRoomMachine);
|
||||
|
||||
const privateRoomState = useMemo<PrivateRoomContextState>(
|
||||
() => ({
|
||||
status: String(state.value),
|
||||
profile: state.context.profile,
|
||||
items: state.context.items,
|
||||
nextCursor: state.context.nextCursor,
|
||||
hasMore: state.context.hasMore,
|
||||
creditBalance: state.context.creditBalance,
|
||||
errorMessage: state.context.errorMessage,
|
||||
unlockingMomentId: state.context.unlockingMomentId,
|
||||
unlockErrorMessage: state.context.unlockErrorMessage,
|
||||
pendingConfirmMomentId: state.context.pendingConfirmMomentId,
|
||||
unlockPaywallRequest: state.context.unlockPaywallRequest,
|
||||
unlockSuccessNonce: state.context.unlockSuccessNonce,
|
||||
isLoading: state.matches("loading"),
|
||||
isLoadingMore: state.matches("loadingMore"),
|
||||
isUnlocking: state.matches("unlocking"),
|
||||
}),
|
||||
[state],
|
||||
);
|
||||
|
||||
return (
|
||||
<PrivateRoomStateCtx.Provider value={privateRoomState}>
|
||||
<PrivateRoomDispatchCtx.Provider value={send}>
|
||||
{children}
|
||||
</PrivateRoomDispatchCtx.Provider>
|
||||
</PrivateRoomStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePrivateRoomState(): PrivateRoomContextState {
|
||||
const ctx = useContext(PrivateRoomStateCtx);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"usePrivateRoomState must be used inside <PrivateRoomProvider>",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function usePrivateRoomDispatch(): Dispatch<PrivateRoomEvent> {
|
||||
const ctx = useContext(PrivateRoomDispatchCtx);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"usePrivateRoomDispatch must be used inside <PrivateRoomProvider>",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type PrivateRoomEvent =
|
||||
| { type: "PrivateRoomInit" }
|
||||
| { type: "PrivateRoomRefresh" }
|
||||
| { type: "PrivateRoomLoadMore" }
|
||||
| { type: "PrivateRoomUnlockRequested"; momentId: string }
|
||||
| { type: "PrivateRoomUnlockConfirmed" }
|
||||
| { type: "PrivateRoomUnlockCancelled" }
|
||||
| { type: "PrivateRoomUnlockPaywallConsumed" };
|
||||
@@ -0,0 +1,51 @@
|
||||
import { fromPromise } from "xstate";
|
||||
|
||||
import type {
|
||||
PrivateRoomMomentsResponse,
|
||||
PrivateRoomUnlockResponse,
|
||||
} from "@/data/dto/private-room";
|
||||
import { getPrivateRoomRepository } from "@/data/repositories/private_room_repository";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
import {
|
||||
PRIVATE_ROOM_CHARACTER,
|
||||
PRIVATE_ROOM_PAGE_SIZE,
|
||||
} from "./private-room-machine.helpers";
|
||||
|
||||
export const loadPrivateRoomMomentsActor =
|
||||
fromPromise<PrivateRoomMomentsResponse>(async () => {
|
||||
const repo = getPrivateRoomRepository();
|
||||
const result = await repo.getMoments({
|
||||
character: PRIVATE_ROOM_CHARACTER,
|
||||
limit: PRIVATE_ROOM_PAGE_SIZE,
|
||||
});
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data;
|
||||
});
|
||||
|
||||
export const loadMorePrivateRoomMomentsActor = fromPromise<
|
||||
PrivateRoomMomentsResponse,
|
||||
{ cursor: string | null }
|
||||
>(async ({ input }) => {
|
||||
const repo = getPrivateRoomRepository();
|
||||
const result = await repo.getMoments({
|
||||
character: PRIVATE_ROOM_CHARACTER,
|
||||
limit: PRIVATE_ROOM_PAGE_SIZE,
|
||||
cursor: input.cursor,
|
||||
});
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data;
|
||||
});
|
||||
|
||||
export const unlockPrivateRoomMomentActor = fromPromise<
|
||||
PrivateRoomUnlockResponse,
|
||||
{ momentId: string; expectedCost: number }
|
||||
>(async ({ input }) => {
|
||||
const repo = getPrivateRoomRepository();
|
||||
const result = await repo.unlockMoment(
|
||||
input.momentId,
|
||||
input.expectedCost,
|
||||
);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data;
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
PrivateRoomMoment,
|
||||
PrivateRoomMomentsResponse,
|
||||
PrivateRoomUnlockResponse,
|
||||
} from "@/data/dto/private-room";
|
||||
|
||||
import type {
|
||||
PrivateRoomState,
|
||||
PrivateRoomUnlockPaywallRequest,
|
||||
} from "./private-room-state";
|
||||
|
||||
export const PRIVATE_ROOM_CHARACTER = "elio";
|
||||
export const PRIVATE_ROOM_PAGE_SIZE = 20;
|
||||
|
||||
export function applyMomentsResponse(
|
||||
response: PrivateRoomMomentsResponse,
|
||||
): Partial<PrivateRoomState> {
|
||||
return {
|
||||
profile: response.profile,
|
||||
items: response.items,
|
||||
nextCursor: response.nextCursor,
|
||||
hasMore: response.hasMore,
|
||||
creditBalance: response.creditBalance,
|
||||
errorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendMomentsResponse(
|
||||
context: PrivateRoomState,
|
||||
response: PrivateRoomMomentsResponse,
|
||||
): Partial<PrivateRoomState> {
|
||||
const knownIds = new Set(context.items.map((item) => item.momentId));
|
||||
const nextItems = [
|
||||
...context.items,
|
||||
...response.items.filter((item) => !knownIds.has(item.momentId)),
|
||||
];
|
||||
return {
|
||||
profile: response.profile,
|
||||
items: nextItems,
|
||||
nextCursor: response.nextCursor,
|
||||
hasMore: response.hasMore,
|
||||
creditBalance: response.creditBalance,
|
||||
errorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function replaceMoment(
|
||||
items: readonly PrivateRoomMoment[],
|
||||
nextMoment: PrivateRoomMoment,
|
||||
): PrivateRoomMoment[] {
|
||||
return items.map((item) =>
|
||||
item.momentId === nextMoment.momentId ? nextMoment : item,
|
||||
);
|
||||
}
|
||||
|
||||
export function toPrivateRoomErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return "Private room is temporarily unavailable. Please try again.";
|
||||
}
|
||||
|
||||
export function toUnlockErrorMessage(
|
||||
response: PrivateRoomUnlockResponse,
|
||||
): string | null {
|
||||
switch (response.reason) {
|
||||
case "cost_changed":
|
||||
return "Unlock price changed. Please confirm again.";
|
||||
case "not_found":
|
||||
return "This private room post is no longer available.";
|
||||
case "no_media":
|
||||
return "This private room post has no photos to unlock.";
|
||||
case "deduct_failed":
|
||||
return "Credit deduction failed. Please try again.";
|
||||
default:
|
||||
return response.unlocked ? null : "Unlock failed. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
export function toPaywallRequest(
|
||||
response: PrivateRoomUnlockResponse,
|
||||
): PrivateRoomUnlockPaywallRequest {
|
||||
return {
|
||||
momentId: response.momentId,
|
||||
reason: response.reason,
|
||||
requiredCredits: response.requiredCredits,
|
||||
currentCredits: response.currentCredits || response.creditBalance,
|
||||
shortfallCredits: response.shortfallCredits,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { assign, setup } from "xstate";
|
||||
|
||||
import type { PrivateRoomMoment } from "@/data/dto/private-room";
|
||||
|
||||
import type { PrivateRoomEvent } from "./private-room-events";
|
||||
import {
|
||||
appendMomentsResponse,
|
||||
applyMomentsResponse,
|
||||
replaceMoment,
|
||||
toPaywallRequest,
|
||||
toPrivateRoomErrorMessage,
|
||||
toUnlockErrorMessage,
|
||||
} from "./private-room-machine.helpers";
|
||||
import {
|
||||
loadMorePrivateRoomMomentsActor,
|
||||
loadPrivateRoomMomentsActor,
|
||||
unlockPrivateRoomMomentActor,
|
||||
} from "./private-room-machine.actors";
|
||||
import { initialState, type PrivateRoomState } from "./private-room-state";
|
||||
|
||||
export type { PrivateRoomEvent } from "./private-room-events";
|
||||
export type { PrivateRoomState } from "./private-room-state";
|
||||
export { initialState } from "./private-room-state";
|
||||
|
||||
function getPendingMoment(context: PrivateRoomState): PrivateRoomMoment | null {
|
||||
if (!context.pendingConfirmMomentId) return null;
|
||||
return (
|
||||
context.items.find(
|
||||
(item) => item.momentId === context.pendingConfirmMomentId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export const privateRoomMachine = setup({
|
||||
types: {
|
||||
context: {} as PrivateRoomState,
|
||||
events: {} as PrivateRoomEvent,
|
||||
},
|
||||
actors: {
|
||||
loadMoments: loadPrivateRoomMomentsActor,
|
||||
loadMoreMoments: loadMorePrivateRoomMomentsActor,
|
||||
unlockMoment: unlockPrivateRoomMomentActor,
|
||||
},
|
||||
}).createMachine({
|
||||
id: "privateRoom",
|
||||
initial: "idle",
|
||||
context: initialState,
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
PrivateRoomInit: "loading",
|
||||
},
|
||||
},
|
||||
|
||||
loading: {
|
||||
entry: assign({
|
||||
errorMessage: null,
|
||||
unlockPaywallRequest: null,
|
||||
}),
|
||||
invoke: {
|
||||
src: "loadMoments",
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => applyMomentsResponse(event.output)),
|
||||
},
|
||||
onError: {
|
||||
target: "failed",
|
||||
actions: assign(({ event }) => ({
|
||||
errorMessage: toPrivateRoomErrorMessage(event.error),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
failed: {
|
||||
on: {
|
||||
PrivateRoomInit: "loading",
|
||||
PrivateRoomRefresh: "loading",
|
||||
},
|
||||
},
|
||||
|
||||
ready: {
|
||||
on: {
|
||||
PrivateRoomInit: {
|
||||
target: "loading",
|
||||
},
|
||||
PrivateRoomRefresh: {
|
||||
target: "loading",
|
||||
},
|
||||
PrivateRoomLoadMore: [
|
||||
{
|
||||
guard: ({ context }) =>
|
||||
context.hasMore && context.nextCursor !== null,
|
||||
target: "loadingMore",
|
||||
},
|
||||
],
|
||||
PrivateRoomUnlockRequested: {
|
||||
actions: assign(({ event }) => ({
|
||||
pendingConfirmMomentId: event.momentId,
|
||||
unlockErrorMessage: null,
|
||||
unlockPaywallRequest: null,
|
||||
})),
|
||||
},
|
||||
PrivateRoomUnlockCancelled: {
|
||||
actions: assign({
|
||||
pendingConfirmMomentId: null,
|
||||
}),
|
||||
},
|
||||
PrivateRoomUnlockConfirmed: [
|
||||
{
|
||||
guard: ({ context }) => getPendingMoment(context) !== null,
|
||||
target: "unlocking",
|
||||
},
|
||||
],
|
||||
PrivateRoomUnlockPaywallConsumed: {
|
||||
actions: assign({
|
||||
unlockPaywallRequest: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
loadingMore: {
|
||||
invoke: {
|
||||
src: "loadMoreMoments",
|
||||
input: ({ context }) => ({
|
||||
cursor: context.nextCursor,
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) =>
|
||||
appendMomentsResponse(context, event.output),
|
||||
),
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => ({
|
||||
errorMessage: toPrivateRoomErrorMessage(event.error),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
unlocking: {
|
||||
entry: assign(({ context }) => ({
|
||||
unlockingMomentId: context.pendingConfirmMomentId,
|
||||
unlockErrorMessage: null,
|
||||
unlockPaywallRequest: null,
|
||||
})),
|
||||
invoke: {
|
||||
src: "unlockMoment",
|
||||
input: ({ context }) => {
|
||||
const pendingMoment = getPendingMoment(context);
|
||||
return {
|
||||
momentId: pendingMoment?.momentId ?? "",
|
||||
expectedCost: pendingMoment?.unlockCost ?? 0,
|
||||
};
|
||||
},
|
||||
onDone: [
|
||||
{
|
||||
guard: ({ event }) => event.output.unlocked,
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => ({
|
||||
items: replaceMoment(context.items, event.output),
|
||||
creditBalance: event.output.creditBalance || context.creditBalance,
|
||||
pendingConfirmMomentId: null,
|
||||
unlockingMomentId: null,
|
||||
unlockErrorMessage: null,
|
||||
unlockSuccessNonce: context.unlockSuccessNonce + 1,
|
||||
})),
|
||||
},
|
||||
{
|
||||
guard: ({ event }) =>
|
||||
event.output.reason === "insufficient_credits",
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => ({
|
||||
items: replaceMoment(context.items, event.output),
|
||||
creditBalance: event.output.creditBalance || context.creditBalance,
|
||||
pendingConfirmMomentId: null,
|
||||
unlockingMomentId: null,
|
||||
unlockErrorMessage: null,
|
||||
unlockPaywallRequest: toPaywallRequest(event.output),
|
||||
})),
|
||||
},
|
||||
{
|
||||
guard: ({ event }) => event.output.reason === "cost_changed",
|
||||
target: "loading",
|
||||
actions: assign(({ event }) => ({
|
||||
pendingConfirmMomentId: null,
|
||||
unlockingMomentId: null,
|
||||
unlockErrorMessage: toUnlockErrorMessage(event.output),
|
||||
})),
|
||||
},
|
||||
{
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => ({
|
||||
items: replaceMoment(context.items, event.output),
|
||||
pendingConfirmMomentId: null,
|
||||
unlockingMomentId: null,
|
||||
unlockErrorMessage: toUnlockErrorMessage(event.output),
|
||||
})),
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => ({
|
||||
pendingConfirmMomentId: null,
|
||||
unlockingMomentId: null,
|
||||
unlockErrorMessage: toPrivateRoomErrorMessage(event.error),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type PrivateRoomMachine = typeof privateRoomMachine;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
PrivateRoomMoment,
|
||||
PrivateRoomProfile,
|
||||
} from "@/data/dto/private-room";
|
||||
|
||||
export interface PrivateRoomUnlockPaywallRequest {
|
||||
momentId: string;
|
||||
reason: string;
|
||||
requiredCredits: number;
|
||||
currentCredits: number;
|
||||
shortfallCredits: number;
|
||||
}
|
||||
|
||||
export interface PrivateRoomState {
|
||||
profile: PrivateRoomProfile | null;
|
||||
items: PrivateRoomMoment[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
creditBalance: number;
|
||||
errorMessage: string | null;
|
||||
unlockingMomentId: string | null;
|
||||
unlockErrorMessage: string | null;
|
||||
pendingConfirmMomentId: string | null;
|
||||
unlockPaywallRequest: PrivateRoomUnlockPaywallRequest | null;
|
||||
unlockSuccessNonce: number;
|
||||
}
|
||||
|
||||
export const initialState: PrivateRoomState = {
|
||||
profile: null,
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
creditBalance: 0,
|
||||
errorMessage: null,
|
||||
unlockingMomentId: null,
|
||||
unlockErrorMessage: null,
|
||||
pendingConfirmMomentId: null,
|
||||
unlockPaywallRequest: null,
|
||||
unlockSuccessNonce: 0,
|
||||
};
|
||||
Reference in New Issue
Block a user