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 "./loading-indicator";
|
||||
export * from "./mobile-shell";
|
||||
export * from "./page-loading-fallback";
|
||||
export * from "./page-scaffold";
|
||||
export * from "./responsive-mobile-shell";
|
||||
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 { MobileShell } from "@/app/_components/core";
|
||||
import { PageLoadingFallback } from "@/app/_components/core";
|
||||
import { ChatScreen } from "@/app/chat/chat-screen";
|
||||
|
||||
export default function ChatPage() {
|
||||
@@ -15,17 +13,8 @@ export default function ChatPage() {
|
||||
|
||||
function ChatFallback() {
|
||||
return (
|
||||
<MobileShell background="#111111">
|
||||
<div
|
||||
style={{
|
||||
minHeight: "60vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255, 255, 255, 0.72)",
|
||||
}}
|
||||
>
|
||||
Loading chat...
|
||||
</div>
|
||||
</MobileShell>
|
||||
<PageLoadingFallback background="#111111" tone="dark">
|
||||
Loading chat...
|
||||
</PageLoadingFallback>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ImageIcon,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
|
||||
import type { PrivateRoomMoment } from "@/data/dto/private-room";
|
||||
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
||||
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||
@@ -20,9 +19,14 @@ import {
|
||||
usePrivateRoomDispatch,
|
||||
usePrivateRoomState,
|
||||
} from "@/stores/private-room";
|
||||
import { useUserDispatch } from "@/stores/user/user-context";
|
||||
|
||||
import styles from "./private-room-screen.module.css";
|
||||
import {
|
||||
isPrivateRoomAuthRequired,
|
||||
usePrivateRoomBootstrapFlow,
|
||||
usePrivateRoomUnlockPaywallNavigation,
|
||||
usePrivateRoomUnlockSuccessRefresh,
|
||||
} from "./use-private-room-flow";
|
||||
|
||||
const FALLBACK_PROFILE = {
|
||||
name: "Elio Silvestri",
|
||||
@@ -37,70 +41,23 @@ export function PrivateRoomScreen() {
|
||||
const navigator = useAppNavigator();
|
||||
const authState = useAuthState();
|
||||
const authDispatch = useAuthDispatch();
|
||||
const userDispatch = useUserDispatch();
|
||||
const state = usePrivateRoomState();
|
||||
const dispatch = usePrivateRoomDispatch();
|
||||
const previousLoginStatusRef = useRef(authState.loginStatus);
|
||||
const lastUnlockSuccessNonceRef = useRef(0);
|
||||
|
||||
useGuestLoginBootstrap({
|
||||
dispatch: authDispatch,
|
||||
usePrivateRoomBootstrapFlow({
|
||||
authDispatch,
|
||||
hasInitialized: authState.hasInitialized,
|
||||
isLoading: authState.isLoading,
|
||||
isAuthLoading: authState.isLoading,
|
||||
loginStatus: authState.loginStatus,
|
||||
roomDispatch: dispatch,
|
||||
roomStatus: state.status,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authState.hasInitialized || authState.isLoading) return;
|
||||
if (authState.loginStatus === "notLoggedIn") return;
|
||||
|
||||
const previousLoginStatus = previousLoginStatusRef.current;
|
||||
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]);
|
||||
usePrivateRoomUnlockPaywallNavigation({
|
||||
loginStatus: authState.loginStatus,
|
||||
roomDispatch: dispatch,
|
||||
unlockPaywallRequest: state.unlockPaywallRequest,
|
||||
});
|
||||
usePrivateRoomUnlockSuccessRefresh(state.unlockSuccessNonce);
|
||||
|
||||
const profile = state.profile;
|
||||
const displayName =
|
||||
@@ -117,10 +74,7 @@ export function PrivateRoomScreen() {
|
||||
);
|
||||
|
||||
const handleTopUpClick = () => {
|
||||
if (
|
||||
authState.loginStatus === "guest" ||
|
||||
authState.loginStatus === "notLoggedIn"
|
||||
) {
|
||||
if (isPrivateRoomAuthRequired(authState.loginStatus)) {
|
||||
navigator.openAuth(ROUTES.privateRoom);
|
||||
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";
|
||||
|
||||
export default function SidebarPage() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
import { PageLoadingFallback } from "@/app/_components/core";
|
||||
|
||||
import { SubscriptionPageClient } from "./subscription-page-client";
|
||||
|
||||
@@ -14,19 +14,6 @@ export default function SubscriptionPage() {
|
||||
|
||||
function SubscriptionFallback() {
|
||||
return (
|
||||
<MobileShell>
|
||||
<div
|
||||
style={{
|
||||
minHeight: "60vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "2rem",
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
Loading subscription...
|
||||
</div>
|
||||
</MobileShell>
|
||||
<PageLoadingFallback>Loading subscription...</PageLoadingFallback>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,12 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
clearPendingPaymentOrder,
|
||||
getPendingPaymentOrderForType,
|
||||
} from "@/lib/payment/pending_payment_order";
|
||||
import { usePaymentOrderLifecycle } from "@/app/_hooks/use-payment-order-lifecycle";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
usePaymentState,
|
||||
} from "@/stores/payment/payment-context";
|
||||
import { useUserDispatch } from "@/stores/user/user-context";
|
||||
import type { PayChannel } from "@/data/dto/payment";
|
||||
|
||||
import type { SubscriptionType } from "./subscription-screen.helpers";
|
||||
@@ -30,16 +26,20 @@ export function useSubscriptionPaymentFlow({
|
||||
initialPayChannel,
|
||||
}: UseSubscriptionPaymentFlowInput) {
|
||||
const navigator = useAppNavigator();
|
||||
const userDispatch = useUserDispatch();
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const refreshedPaidOrderRef = useRef<string | null>(null);
|
||||
const resumedPendingOrderRef = useRef<string | null>(null);
|
||||
const successDialogShownOrderRef = useRef<string | null>(null);
|
||||
const initialPayChannelAppliedRef = useRef(false);
|
||||
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
|
||||
useState(false);
|
||||
|
||||
usePaymentOrderLifecycle({
|
||||
payment,
|
||||
paymentDispatch,
|
||||
paymentType: subscriptionType,
|
||||
shouldResumePendingOrder,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (payment.status === "idle") {
|
||||
initialPayChannelAppliedRef.current = true;
|
||||
@@ -68,13 +68,6 @@ export function useSubscriptionPaymentFlow({
|
||||
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(() => {
|
||||
if (!payment.isPaid || !payment.currentOrderId) return;
|
||||
if (successDialogShownOrderRef.current === payment.currentOrderId) return;
|
||||
@@ -83,66 +76,6 @@ export function useSubscriptionPaymentFlow({
|
||||
setShowPaymentSuccessDialog(true);
|
||||
}, [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 = () => {
|
||||
navigator.exitSubscription(returnTo);
|
||||
};
|
||||
|
||||
+4
-14
@@ -1,6 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
import { PageLoadingFallback } from "@/app/_components/core";
|
||||
|
||||
import { TipPageClient } from "./tip-page-client";
|
||||
|
||||
@@ -14,18 +14,8 @@ export default function TipPage() {
|
||||
|
||||
function TipFallback() {
|
||||
return (
|
||||
<MobileShell background="#fff6ed">
|
||||
<div
|
||||
style={{
|
||||
minHeight: "60vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
padding: "2rem",
|
||||
color: "#8b6265",
|
||||
}}
|
||||
>
|
||||
Loading tip...
|
||||
</div>
|
||||
</MobileShell>
|
||||
<PageLoadingFallback background="#fff6ed" tone="warm">
|
||||
Loading tip...
|
||||
</PageLoadingFallback>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,8 @@ import Image from "next/image";
|
||||
import { ArrowLeft, Heart, Sparkles } from "lucide-react";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
import { usePaymentOrderLifecycle } from "@/app/_hooks/use-payment-order-lifecycle";
|
||||
import type { PayChannel } from "@/data/dto/payment";
|
||||
import {
|
||||
clearPendingPaymentOrder,
|
||||
getPendingPaymentOrderForType,
|
||||
} from "@/lib/payment/pending_payment_order";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
@@ -17,7 +14,7 @@ import {
|
||||
usePaymentDispatch,
|
||||
usePaymentState,
|
||||
} 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 {
|
||||
@@ -39,12 +36,9 @@ export function TipScreen({
|
||||
const navigator = useAppNavigator();
|
||||
const authState = useAuthState();
|
||||
const userState = useUserState();
|
||||
const userDispatch = useUserDispatch();
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const initialPayChannelAppliedRef = useRef(false);
|
||||
const resumedPendingOrderRef = useRef<string | null>(null);
|
||||
const refreshedPaidOrderRef = useRef<string | null>(null);
|
||||
const resolvedInitialPayChannel =
|
||||
initialPayChannel ?? navigator.getDefaultPayChannel();
|
||||
|
||||
@@ -66,6 +60,13 @@ export function TipScreen({
|
||||
payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null;
|
||||
const isAuthLoading = !authState.hasInitialized || authState.isLoading;
|
||||
|
||||
usePaymentOrderLifecycle({
|
||||
payment,
|
||||
paymentDispatch,
|
||||
paymentType: "tip",
|
||||
shouldResumePendingOrder,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (payment.status === "idle") {
|
||||
initialPayChannelAppliedRef.current = true;
|
||||
@@ -124,72 +125,6 @@ export function TipScreen({
|
||||
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 = () => {
|
||||
if (isAuthLoading) return;
|
||||
if (!isRealLoginStatus(authState.loginStatus)) {
|
||||
|
||||
Reference in New Issue
Block a user