refactor(app): split page orchestration flows
This commit is contained in:
@@ -9,6 +9,7 @@ export * from "./dialog";
|
|||||||
export * from "./fixed-bottom-area";
|
export * from "./fixed-bottom-area";
|
||||||
export * from "./loading-indicator";
|
export * from "./loading-indicator";
|
||||||
export * from "./mobile-shell";
|
export * from "./mobile-shell";
|
||||||
|
export * from "./page-loading-fallback";
|
||||||
export * from "./page-scaffold";
|
export * from "./page-scaffold";
|
||||||
export * from "./responsive-mobile-shell";
|
export * from "./responsive-mobile-shell";
|
||||||
export * from "./scrollable-page";
|
export * from "./scrollable-page";
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
min-height: 60vh;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neutral {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warm {
|
||||||
|
color: #8b6265;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { MobileShell } from "./mobile-shell";
|
||||||
|
import styles from "./page-loading-fallback.module.css";
|
||||||
|
|
||||||
|
export interface PageLoadingFallbackProps {
|
||||||
|
children: string;
|
||||||
|
background?: string;
|
||||||
|
tone?: "neutral" | "dark" | "warm";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageLoadingFallback({
|
||||||
|
children,
|
||||||
|
background,
|
||||||
|
tone = "neutral",
|
||||||
|
}: PageLoadingFallbackProps) {
|
||||||
|
return (
|
||||||
|
<MobileShell background={background}>
|
||||||
|
<div className={`${styles.content} ${styles[tone]}`}>{children}</div>
|
||||||
|
</MobileShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { shouldInspectPendingPaymentOrder } from "../use-payment-order-lifecycle";
|
||||||
|
|
||||||
|
describe("shouldInspectPendingPaymentOrder", () => {
|
||||||
|
it("allows pending order inspection when payment plans are ready", () => {
|
||||||
|
expect(
|
||||||
|
shouldInspectPendingPaymentOrder({
|
||||||
|
currentOrderId: null,
|
||||||
|
isPaid: false,
|
||||||
|
isPollingOrder: false,
|
||||||
|
shouldResumePendingOrder: true,
|
||||||
|
status: "ready",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows cleanup inspection for active stale payment state", () => {
|
||||||
|
expect(
|
||||||
|
shouldInspectPendingPaymentOrder({
|
||||||
|
currentOrderId: "order-1",
|
||||||
|
isPaid: false,
|
||||||
|
isPollingOrder: true,
|
||||||
|
shouldResumePendingOrder: false,
|
||||||
|
status: "pollingOrder",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits when resume is requested but payment plans are not ready", () => {
|
||||||
|
expect(
|
||||||
|
shouldInspectPendingPaymentOrder({
|
||||||
|
currentOrderId: "order-1",
|
||||||
|
isPaid: false,
|
||||||
|
isPollingOrder: true,
|
||||||
|
shouldResumePendingOrder: true,
|
||||||
|
status: "pollingOrder",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type Dispatch, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearPendingPaymentOrder,
|
||||||
|
getPendingPaymentOrderForType,
|
||||||
|
type PendingPaymentSubscriptionType,
|
||||||
|
} from "@/lib/payment/pending_payment_order";
|
||||||
|
import type {
|
||||||
|
PaymentContextState,
|
||||||
|
} from "@/stores/payment/payment-context";
|
||||||
|
import type { PaymentEvent } from "@/stores/payment/payment-events";
|
||||||
|
import { useUserDispatch } from "@/stores/user/user-context";
|
||||||
|
|
||||||
|
export interface PaymentOrderLifecycleState {
|
||||||
|
currentOrderId: string | null;
|
||||||
|
isPaid: boolean;
|
||||||
|
isPollingOrder: boolean;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShouldInspectPendingPaymentOrderInput
|
||||||
|
extends PaymentOrderLifecycleState {
|
||||||
|
shouldResumePendingOrder: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePaymentOrderLifecycleInput {
|
||||||
|
payment: PaymentContextState;
|
||||||
|
paymentDispatch: Dispatch<PaymentEvent>;
|
||||||
|
paymentType: PendingPaymentSubscriptionType;
|
||||||
|
shouldResumePendingOrder: boolean;
|
||||||
|
refreshUserOnPaid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldInspectPendingPaymentOrder({
|
||||||
|
isPaid,
|
||||||
|
isPollingOrder,
|
||||||
|
shouldResumePendingOrder,
|
||||||
|
status,
|
||||||
|
}: ShouldInspectPendingPaymentOrderInput): boolean {
|
||||||
|
return (
|
||||||
|
status === "ready" ||
|
||||||
|
(!shouldResumePendingOrder &&
|
||||||
|
(isPollingOrder || isPaid || status === "failed"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePaymentOrderLifecycle({
|
||||||
|
payment,
|
||||||
|
paymentDispatch,
|
||||||
|
paymentType,
|
||||||
|
refreshUserOnPaid = true,
|
||||||
|
shouldResumePendingOrder,
|
||||||
|
}: UsePaymentOrderLifecycleInput): void {
|
||||||
|
const userDispatch = useUserDispatch();
|
||||||
|
const refreshedPaidOrderRef = useRef<string | null>(null);
|
||||||
|
const resumedPendingOrderRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!refreshUserOnPaid) return;
|
||||||
|
if (!payment.isPaid || !payment.currentOrderId) return;
|
||||||
|
if (refreshedPaidOrderRef.current === payment.currentOrderId) return;
|
||||||
|
refreshedPaidOrderRef.current = payment.currentOrderId;
|
||||||
|
userDispatch({ type: "UserFetch" });
|
||||||
|
}, [
|
||||||
|
payment.currentOrderId,
|
||||||
|
payment.isPaid,
|
||||||
|
refreshUserOnPaid,
|
||||||
|
userDispatch,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!shouldInspectPendingPaymentOrder({
|
||||||
|
currentOrderId: payment.currentOrderId,
|
||||||
|
isPaid: payment.isPaid,
|
||||||
|
isPollingOrder: payment.isPollingOrder,
|
||||||
|
shouldResumePendingOrder,
|
||||||
|
status: payment.status,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const handlePendingOrder = async () => {
|
||||||
|
const result = await getPendingPaymentOrderForType(paymentType);
|
||||||
|
if (cancelled || !result.success || result.data === null) return;
|
||||||
|
|
||||||
|
if (!shouldResumePendingOrder) {
|
||||||
|
await clearPendingPaymentOrder();
|
||||||
|
if (
|
||||||
|
payment.currentOrderId === result.data.orderId &&
|
||||||
|
(payment.isPollingOrder ||
|
||||||
|
payment.isPaid ||
|
||||||
|
payment.status === "failed")
|
||||||
|
) {
|
||||||
|
paymentDispatch({ type: "PaymentReset" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.currentOrderId === result.data.orderId) return;
|
||||||
|
if (resumedPendingOrderRef.current === result.data.orderId) return;
|
||||||
|
|
||||||
|
resumedPendingOrderRef.current = result.data.orderId;
|
||||||
|
paymentDispatch({
|
||||||
|
type: "PaymentReturned",
|
||||||
|
orderId: result.data.orderId,
|
||||||
|
createdAt: result.data.createdAt,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void handlePendingOrder();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
payment.currentOrderId,
|
||||||
|
payment.isPaid,
|
||||||
|
payment.isPollingOrder,
|
||||||
|
payment.status,
|
||||||
|
paymentDispatch,
|
||||||
|
paymentType,
|
||||||
|
shouldResumePendingOrder,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!payment.currentOrderId) return;
|
||||||
|
if (!payment.isPaid && payment.status !== "failed") return;
|
||||||
|
|
||||||
|
void clearPendingPaymentOrder();
|
||||||
|
}, [payment.currentOrderId, payment.isPaid, payment.status]);
|
||||||
|
}
|
||||||
+4
-15
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { PageLoadingFallback } from "@/app/_components/core";
|
||||||
import { ChatScreen } from "@/app/chat/chat-screen";
|
import { ChatScreen } from "@/app/chat/chat-screen";
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
@@ -15,17 +13,8 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
function ChatFallback() {
|
function ChatFallback() {
|
||||||
return (
|
return (
|
||||||
<MobileShell background="#111111">
|
<PageLoadingFallback background="#111111" tone="dark">
|
||||||
<div
|
Loading chat...
|
||||||
style={{
|
</PageLoadingFallback>
|
||||||
minHeight: "60vh",
|
|
||||||
display: "grid",
|
|
||||||
placeItems: "center",
|
|
||||||
color: "rgba(255, 255, 255, 0.72)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Loading chat...
|
|
||||||
</div>
|
|
||||||
</MobileShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isPrivateRoomAuthRequired } from "../use-private-room-flow";
|
||||||
|
|
||||||
|
describe("isPrivateRoomAuthRequired", () => {
|
||||||
|
it.each(["guest", "notLoggedIn"] as const)(
|
||||||
|
"requires auth for %s users",
|
||||||
|
(loginStatus) => {
|
||||||
|
expect(isPrivateRoomAuthRequired(loginStatus)).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["email", "facebook", "google", "apple"] as const)(
|
||||||
|
"allows %s users to top up directly",
|
||||||
|
(loginStatus) => {
|
||||||
|
expect(isPrivateRoomAuthRequired(loginStatus)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useMemo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
|
|
||||||
import type { PrivateRoomMoment } from "@/data/dto/private-room";
|
import type { PrivateRoomMoment } from "@/data/dto/private-room";
|
||||||
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
||||||
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
|
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||||
@@ -20,9 +19,14 @@ import {
|
|||||||
usePrivateRoomDispatch,
|
usePrivateRoomDispatch,
|
||||||
usePrivateRoomState,
|
usePrivateRoomState,
|
||||||
} from "@/stores/private-room";
|
} from "@/stores/private-room";
|
||||||
import { useUserDispatch } from "@/stores/user/user-context";
|
|
||||||
|
|
||||||
import styles from "./private-room-screen.module.css";
|
import styles from "./private-room-screen.module.css";
|
||||||
|
import {
|
||||||
|
isPrivateRoomAuthRequired,
|
||||||
|
usePrivateRoomBootstrapFlow,
|
||||||
|
usePrivateRoomUnlockPaywallNavigation,
|
||||||
|
usePrivateRoomUnlockSuccessRefresh,
|
||||||
|
} from "./use-private-room-flow";
|
||||||
|
|
||||||
const FALLBACK_PROFILE = {
|
const FALLBACK_PROFILE = {
|
||||||
name: "Elio Silvestri",
|
name: "Elio Silvestri",
|
||||||
@@ -37,70 +41,23 @@ export function PrivateRoomScreen() {
|
|||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
const userDispatch = useUserDispatch();
|
|
||||||
const state = usePrivateRoomState();
|
const state = usePrivateRoomState();
|
||||||
const dispatch = usePrivateRoomDispatch();
|
const dispatch = usePrivateRoomDispatch();
|
||||||
const previousLoginStatusRef = useRef(authState.loginStatus);
|
|
||||||
const lastUnlockSuccessNonceRef = useRef(0);
|
|
||||||
|
|
||||||
useGuestLoginBootstrap({
|
usePrivateRoomBootstrapFlow({
|
||||||
dispatch: authDispatch,
|
authDispatch,
|
||||||
hasInitialized: authState.hasInitialized,
|
hasInitialized: authState.hasInitialized,
|
||||||
isLoading: authState.isLoading,
|
isAuthLoading: authState.isLoading,
|
||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
|
roomDispatch: dispatch,
|
||||||
|
roomStatus: state.status,
|
||||||
});
|
});
|
||||||
|
usePrivateRoomUnlockPaywallNavigation({
|
||||||
useEffect(() => {
|
loginStatus: authState.loginStatus,
|
||||||
if (!authState.hasInitialized || authState.isLoading) return;
|
roomDispatch: dispatch,
|
||||||
if (authState.loginStatus === "notLoggedIn") return;
|
unlockPaywallRequest: state.unlockPaywallRequest,
|
||||||
|
});
|
||||||
const previousLoginStatus = previousLoginStatusRef.current;
|
usePrivateRoomUnlockSuccessRefresh(state.unlockSuccessNonce);
|
||||||
previousLoginStatusRef.current = authState.loginStatus;
|
|
||||||
|
|
||||||
if (state.status === "idle") {
|
|
||||||
dispatch({ type: "PrivateRoomInit" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
previousLoginStatus !== authState.loginStatus &&
|
|
||||||
state.status !== "loading"
|
|
||||||
) {
|
|
||||||
dispatch({ type: "PrivateRoomRefresh" });
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
authState.hasInitialized,
|
|
||||||
authState.isLoading,
|
|
||||||
authState.loginStatus,
|
|
||||||
dispatch,
|
|
||||||
state.status,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const request = state.unlockPaywallRequest;
|
|
||||||
if (!request) return;
|
|
||||||
|
|
||||||
if (
|
|
||||||
authState.loginStatus === "guest" ||
|
|
||||||
authState.loginStatus === "notLoggedIn"
|
|
||||||
) {
|
|
||||||
navigator.openAuth(ROUTES.privateRoom);
|
|
||||||
} else {
|
|
||||||
navigator.openSubscription({ type: "topup" });
|
|
||||||
}
|
|
||||||
dispatch({ type: "PrivateRoomUnlockPaywallConsumed" });
|
|
||||||
}, [
|
|
||||||
authState.loginStatus,
|
|
||||||
dispatch,
|
|
||||||
navigator,
|
|
||||||
state.unlockPaywallRequest,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.unlockSuccessNonce <= lastUnlockSuccessNonceRef.current) return;
|
|
||||||
lastUnlockSuccessNonceRef.current = state.unlockSuccessNonce;
|
|
||||||
userDispatch({ type: "UserFetch" });
|
|
||||||
}, [state.unlockSuccessNonce, userDispatch]);
|
|
||||||
|
|
||||||
const profile = state.profile;
|
const profile = state.profile;
|
||||||
const displayName =
|
const displayName =
|
||||||
@@ -117,10 +74,7 @@ export function PrivateRoomScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleTopUpClick = () => {
|
const handleTopUpClick = () => {
|
||||||
if (
|
if (isPrivateRoomAuthRequired(authState.loginStatus)) {
|
||||||
authState.loginStatus === "guest" ||
|
|
||||||
authState.loginStatus === "notLoggedIn"
|
|
||||||
) {
|
|
||||||
navigator.openAuth(ROUTES.privateRoom);
|
navigator.openAuth(ROUTES.privateRoom);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type Dispatch, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import type { LoginStatus } from "@/data/dto/auth";
|
||||||
|
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
|
||||||
|
import { ROUTES } from "@/router/routes";
|
||||||
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
import type { AuthEvent } from "@/stores/auth/auth-events";
|
||||||
|
import type { PrivateRoomEvent } from "@/stores/private-room";
|
||||||
|
import type { PrivateRoomUnlockPaywallRequest } from "@/stores/private-room/private-room-state";
|
||||||
|
import { useUserDispatch } from "@/stores/user/user-context";
|
||||||
|
|
||||||
|
export interface UsePrivateRoomBootstrapFlowInput {
|
||||||
|
authDispatch: Dispatch<AuthEvent>;
|
||||||
|
hasInitialized: boolean;
|
||||||
|
isAuthLoading: boolean;
|
||||||
|
loginStatus: LoginStatus;
|
||||||
|
roomDispatch: Dispatch<PrivateRoomEvent>;
|
||||||
|
roomStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePrivateRoomUnlockPaywallNavigationInput {
|
||||||
|
loginStatus: LoginStatus;
|
||||||
|
roomDispatch: Dispatch<PrivateRoomEvent>;
|
||||||
|
unlockPaywallRequest: PrivateRoomUnlockPaywallRequest | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrivateRoomAuthRequired(loginStatus: LoginStatus): boolean {
|
||||||
|
return loginStatus === "guest" || loginStatus === "notLoggedIn";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrivateRoomBootstrapFlow({
|
||||||
|
authDispatch,
|
||||||
|
hasInitialized,
|
||||||
|
isAuthLoading,
|
||||||
|
loginStatus,
|
||||||
|
roomDispatch,
|
||||||
|
roomStatus,
|
||||||
|
}: UsePrivateRoomBootstrapFlowInput): void {
|
||||||
|
const previousLoginStatusRef = useRef(loginStatus);
|
||||||
|
|
||||||
|
useGuestLoginBootstrap({
|
||||||
|
dispatch: authDispatch,
|
||||||
|
hasInitialized,
|
||||||
|
isLoading: isAuthLoading,
|
||||||
|
loginStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitialized || isAuthLoading) return;
|
||||||
|
if (loginStatus === "notLoggedIn") return;
|
||||||
|
|
||||||
|
const previousLoginStatus = previousLoginStatusRef.current;
|
||||||
|
previousLoginStatusRef.current = loginStatus;
|
||||||
|
|
||||||
|
if (roomStatus === "idle") {
|
||||||
|
roomDispatch({ type: "PrivateRoomInit" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousLoginStatus !== loginStatus && roomStatus !== "loading") {
|
||||||
|
roomDispatch({ type: "PrivateRoomRefresh" });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
hasInitialized,
|
||||||
|
isAuthLoading,
|
||||||
|
loginStatus,
|
||||||
|
roomDispatch,
|
||||||
|
roomStatus,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrivateRoomUnlockPaywallNavigation({
|
||||||
|
loginStatus,
|
||||||
|
roomDispatch,
|
||||||
|
unlockPaywallRequest,
|
||||||
|
}: UsePrivateRoomUnlockPaywallNavigationInput): void {
|
||||||
|
const navigator = useAppNavigator();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!unlockPaywallRequest) return;
|
||||||
|
|
||||||
|
if (isPrivateRoomAuthRequired(loginStatus)) {
|
||||||
|
navigator.openAuth(ROUTES.privateRoom);
|
||||||
|
} else {
|
||||||
|
navigator.openSubscription({ type: "topup" });
|
||||||
|
}
|
||||||
|
roomDispatch({ type: "PrivateRoomUnlockPaywallConsumed" });
|
||||||
|
}, [loginStatus, navigator, roomDispatch, unlockPaywallRequest]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrivateRoomUnlockSuccessRefresh(
|
||||||
|
unlockSuccessNonce: number,
|
||||||
|
): void {
|
||||||
|
const userDispatch = useUserDispatch();
|
||||||
|
const lastUnlockSuccessNonceRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (unlockSuccessNonce <= lastUnlockSuccessNonceRef.current) return;
|
||||||
|
lastUnlockSuccessNonceRef.current = unlockSuccessNonce;
|
||||||
|
userDispatch({ type: "UserFetch" });
|
||||||
|
}, [unlockSuccessNonce, userDispatch]);
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { SidebarScreen } from "@/app/sidebar/sidebar-screen";
|
import { SidebarScreen } from "@/app/sidebar/sidebar-screen";
|
||||||
|
|
||||||
export default function SidebarPage() {
|
export default function SidebarPage() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { PageLoadingFallback } from "@/app/_components/core";
|
||||||
|
|
||||||
import { SubscriptionPageClient } from "./subscription-page-client";
|
import { SubscriptionPageClient } from "./subscription-page-client";
|
||||||
|
|
||||||
@@ -14,19 +14,6 @@ export default function SubscriptionPage() {
|
|||||||
|
|
||||||
function SubscriptionFallback() {
|
function SubscriptionFallback() {
|
||||||
return (
|
return (
|
||||||
<MobileShell>
|
<PageLoadingFallback>Loading subscription...</PageLoadingFallback>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
minHeight: "60vh",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "2rem",
|
|
||||||
color: "#666",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Loading subscription...
|
|
||||||
</div>
|
|
||||||
</MobileShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import { usePaymentOrderLifecycle } from "@/app/_hooks/use-payment-order-lifecycle";
|
||||||
clearPendingPaymentOrder,
|
|
||||||
getPendingPaymentOrderForType,
|
|
||||||
} from "@/lib/payment/pending_payment_order";
|
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import {
|
import {
|
||||||
usePaymentDispatch,
|
usePaymentDispatch,
|
||||||
usePaymentState,
|
usePaymentState,
|
||||||
} from "@/stores/payment/payment-context";
|
} from "@/stores/payment/payment-context";
|
||||||
import { useUserDispatch } from "@/stores/user/user-context";
|
|
||||||
import type { PayChannel } from "@/data/dto/payment";
|
import type { PayChannel } from "@/data/dto/payment";
|
||||||
|
|
||||||
import type { SubscriptionType } from "./subscription-screen.helpers";
|
import type { SubscriptionType } from "./subscription-screen.helpers";
|
||||||
@@ -30,16 +26,20 @@ export function useSubscriptionPaymentFlow({
|
|||||||
initialPayChannel,
|
initialPayChannel,
|
||||||
}: UseSubscriptionPaymentFlowInput) {
|
}: UseSubscriptionPaymentFlowInput) {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
const userDispatch = useUserDispatch();
|
|
||||||
const payment = usePaymentState();
|
const payment = usePaymentState();
|
||||||
const paymentDispatch = usePaymentDispatch();
|
const paymentDispatch = usePaymentDispatch();
|
||||||
const refreshedPaidOrderRef = useRef<string | null>(null);
|
|
||||||
const resumedPendingOrderRef = useRef<string | null>(null);
|
|
||||||
const successDialogShownOrderRef = useRef<string | null>(null);
|
const successDialogShownOrderRef = useRef<string | null>(null);
|
||||||
const initialPayChannelAppliedRef = useRef(false);
|
const initialPayChannelAppliedRef = useRef(false);
|
||||||
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
|
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
|
usePaymentOrderLifecycle({
|
||||||
|
payment,
|
||||||
|
paymentDispatch,
|
||||||
|
paymentType: subscriptionType,
|
||||||
|
shouldResumePendingOrder,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (payment.status === "idle") {
|
if (payment.status === "idle") {
|
||||||
initialPayChannelAppliedRef.current = true;
|
initialPayChannelAppliedRef.current = true;
|
||||||
@@ -68,13 +68,6 @@ export function useSubscriptionPaymentFlow({
|
|||||||
paymentDispatch,
|
paymentDispatch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!payment.isPaid || !payment.currentOrderId) return;
|
|
||||||
if (refreshedPaidOrderRef.current === payment.currentOrderId) return;
|
|
||||||
refreshedPaidOrderRef.current = payment.currentOrderId;
|
|
||||||
userDispatch({ type: "UserFetch" });
|
|
||||||
}, [payment.currentOrderId, payment.isPaid, userDispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!payment.isPaid || !payment.currentOrderId) return;
|
if (!payment.isPaid || !payment.currentOrderId) return;
|
||||||
if (successDialogShownOrderRef.current === payment.currentOrderId) return;
|
if (successDialogShownOrderRef.current === payment.currentOrderId) return;
|
||||||
@@ -83,66 +76,6 @@ export function useSubscriptionPaymentFlow({
|
|||||||
setShowPaymentSuccessDialog(true);
|
setShowPaymentSuccessDialog(true);
|
||||||
}, [payment.currentOrderId, payment.isPaid]);
|
}, [payment.currentOrderId, payment.isPaid]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canInspectPendingOrder =
|
|
||||||
payment.status === "ready" ||
|
|
||||||
(!shouldResumePendingOrder &&
|
|
||||||
(payment.isPollingOrder ||
|
|
||||||
payment.isPaid ||
|
|
||||||
payment.status === "failed"));
|
|
||||||
if (!canInspectPendingOrder) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const handlePendingOrder = async () => {
|
|
||||||
const result = await getPendingPaymentOrderForType(subscriptionType);
|
|
||||||
if (cancelled || !result.success || result.data === null) return;
|
|
||||||
|
|
||||||
if (!shouldResumePendingOrder) {
|
|
||||||
await clearPendingPaymentOrder();
|
|
||||||
if (
|
|
||||||
payment.currentOrderId === result.data.orderId &&
|
|
||||||
(payment.isPollingOrder ||
|
|
||||||
payment.isPaid ||
|
|
||||||
payment.status === "failed")
|
|
||||||
) {
|
|
||||||
paymentDispatch({ type: "PaymentReset" });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payment.currentOrderId === result.data.orderId) return;
|
|
||||||
if (resumedPendingOrderRef.current === result.data.orderId) return;
|
|
||||||
|
|
||||||
resumedPendingOrderRef.current = result.data.orderId;
|
|
||||||
paymentDispatch({
|
|
||||||
type: "PaymentReturned",
|
|
||||||
orderId: result.data.orderId,
|
|
||||||
createdAt: result.data.createdAt,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
void handlePendingOrder();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
payment.currentOrderId,
|
|
||||||
payment.isPaid,
|
|
||||||
payment.isPollingOrder,
|
|
||||||
payment.status,
|
|
||||||
paymentDispatch,
|
|
||||||
shouldResumePendingOrder,
|
|
||||||
subscriptionType,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!payment.currentOrderId) return;
|
|
||||||
if (!payment.isPaid && payment.status !== "failed") return;
|
|
||||||
|
|
||||||
void clearPendingPaymentOrder();
|
|
||||||
}, [payment.currentOrderId, payment.isPaid, payment.status]);
|
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
navigator.exitSubscription(returnTo);
|
navigator.exitSubscription(returnTo);
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-14
@@ -1,6 +1,6 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { PageLoadingFallback } from "@/app/_components/core";
|
||||||
|
|
||||||
import { TipPageClient } from "./tip-page-client";
|
import { TipPageClient } from "./tip-page-client";
|
||||||
|
|
||||||
@@ -14,18 +14,8 @@ export default function TipPage() {
|
|||||||
|
|
||||||
function TipFallback() {
|
function TipFallback() {
|
||||||
return (
|
return (
|
||||||
<MobileShell background="#fff6ed">
|
<PageLoadingFallback background="#fff6ed" tone="warm">
|
||||||
<div
|
Loading tip...
|
||||||
style={{
|
</PageLoadingFallback>
|
||||||
minHeight: "60vh",
|
|
||||||
display: "grid",
|
|
||||||
placeItems: "center",
|
|
||||||
padding: "2rem",
|
|
||||||
color: "#8b6265",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Loading tip...
|
|
||||||
</div>
|
|
||||||
</MobileShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ import Image from "next/image";
|
|||||||
import { ArrowLeft, Heart, Sparkles } from "lucide-react";
|
import { ArrowLeft, Heart, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
import { usePaymentOrderLifecycle } from "@/app/_hooks/use-payment-order-lifecycle";
|
||||||
import type { PayChannel } from "@/data/dto/payment";
|
import type { PayChannel } from "@/data/dto/payment";
|
||||||
import {
|
|
||||||
clearPendingPaymentOrder,
|
|
||||||
getPendingPaymentOrderForType,
|
|
||||||
} from "@/lib/payment/pending_payment_order";
|
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import { useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
@@ -17,7 +14,7 @@ import {
|
|||||||
usePaymentDispatch,
|
usePaymentDispatch,
|
||||||
usePaymentState,
|
usePaymentState,
|
||||||
} from "@/stores/payment/payment-context";
|
} from "@/stores/payment/payment-context";
|
||||||
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
import { useUserState } from "@/stores/user/user-context";
|
||||||
|
|
||||||
import { TipCheckoutButton } from "./tip-checkout-button";
|
import { TipCheckoutButton } from "./tip-checkout-button";
|
||||||
import {
|
import {
|
||||||
@@ -39,12 +36,9 @@ export function TipScreen({
|
|||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
const userDispatch = useUserDispatch();
|
|
||||||
const payment = usePaymentState();
|
const payment = usePaymentState();
|
||||||
const paymentDispatch = usePaymentDispatch();
|
const paymentDispatch = usePaymentDispatch();
|
||||||
const initialPayChannelAppliedRef = useRef(false);
|
const initialPayChannelAppliedRef = useRef(false);
|
||||||
const resumedPendingOrderRef = useRef<string | null>(null);
|
|
||||||
const refreshedPaidOrderRef = useRef<string | null>(null);
|
|
||||||
const resolvedInitialPayChannel =
|
const resolvedInitialPayChannel =
|
||||||
initialPayChannel ?? navigator.getDefaultPayChannel();
|
initialPayChannel ?? navigator.getDefaultPayChannel();
|
||||||
|
|
||||||
@@ -66,6 +60,13 @@ export function TipScreen({
|
|||||||
payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null;
|
payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null;
|
||||||
const isAuthLoading = !authState.hasInitialized || authState.isLoading;
|
const isAuthLoading = !authState.hasInitialized || authState.isLoading;
|
||||||
|
|
||||||
|
usePaymentOrderLifecycle({
|
||||||
|
payment,
|
||||||
|
paymentDispatch,
|
||||||
|
paymentType: "tip",
|
||||||
|
shouldResumePendingOrder,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (payment.status === "idle") {
|
if (payment.status === "idle") {
|
||||||
initialPayChannelAppliedRef.current = true;
|
initialPayChannelAppliedRef.current = true;
|
||||||
@@ -124,72 +125,6 @@ export function TipScreen({
|
|||||||
paymentDispatch,
|
paymentDispatch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canInspectPendingOrder =
|
|
||||||
payment.status === "ready" ||
|
|
||||||
(!shouldResumePendingOrder &&
|
|
||||||
(payment.isPollingOrder ||
|
|
||||||
payment.isPaid ||
|
|
||||||
payment.status === "failed"));
|
|
||||||
if (!canInspectPendingOrder) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const handlePendingOrder = async () => {
|
|
||||||
const result = await getPendingPaymentOrderForType("tip");
|
|
||||||
if (cancelled || !result.success || result.data === null) return;
|
|
||||||
|
|
||||||
if (!shouldResumePendingOrder) {
|
|
||||||
await clearPendingPaymentOrder();
|
|
||||||
if (
|
|
||||||
payment.currentOrderId === result.data.orderId &&
|
|
||||||
(payment.isPollingOrder ||
|
|
||||||
payment.isPaid ||
|
|
||||||
payment.status === "failed")
|
|
||||||
) {
|
|
||||||
paymentDispatch({ type: "PaymentReset" });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payment.currentOrderId === result.data.orderId) return;
|
|
||||||
if (resumedPendingOrderRef.current === result.data.orderId) return;
|
|
||||||
|
|
||||||
resumedPendingOrderRef.current = result.data.orderId;
|
|
||||||
paymentDispatch({
|
|
||||||
type: "PaymentReturned",
|
|
||||||
orderId: result.data.orderId,
|
|
||||||
createdAt: result.data.createdAt,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
void handlePendingOrder();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
payment.currentOrderId,
|
|
||||||
payment.isPaid,
|
|
||||||
payment.isPollingOrder,
|
|
||||||
payment.status,
|
|
||||||
paymentDispatch,
|
|
||||||
shouldResumePendingOrder,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!payment.currentOrderId) return;
|
|
||||||
if (!payment.isPaid && payment.status !== "failed") return;
|
|
||||||
|
|
||||||
void clearPendingPaymentOrder();
|
|
||||||
}, [payment.currentOrderId, payment.isPaid, payment.status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!payment.isPaid || !payment.currentOrderId) return;
|
|
||||||
if (refreshedPaidOrderRef.current === payment.currentOrderId) return;
|
|
||||||
refreshedPaidOrderRef.current = payment.currentOrderId;
|
|
||||||
userDispatch({ type: "UserFetch" });
|
|
||||||
}, [payment.currentOrderId, payment.isPaid, userDispatch]);
|
|
||||||
|
|
||||||
const handleOrder = () => {
|
const handleOrder = () => {
|
||||||
if (isAuthLoading) return;
|
if (isAuthLoading) return;
|
||||||
if (!isRealLoginStatus(authState.loginStatus)) {
|
if (!isRealLoginStatus(authState.loginStatus)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user