Files
cozsweet-frontend-nextjs/src/app/external-entry/external-entry-persist.tsx
T

233 lines
6.3 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
persistExternalEntryPayload,
resolveExternalEntryDestination,
resolveExternalEntryPromotionType,
resolveExternalEntryTarget,
shouldBindExternalFacebookIdentity,
} from "@/lib/navigation/external_entry";
import {
clearPendingChatPromotion,
savePendingChatPromotion,
} from "@/lib/navigation/chat_unlock_session";
import { ROUTES } from "@/router/routes";
import {
DEFAULT_CHARACTER_ID,
getCharacterBySlug,
} from "@/data/constants/character";
import { Logger } from "@/utils/logger";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff";
import { Result } from "@/utils/result";
import {
isFavoriteEntryRequest,
persistFavoriteEntryIntent,
} from "@/lib/navigation/favorite_entry";
const log = new Logger("ExternalEntryPersist");
interface ExternalEntryPersistProps {
deviceId: string | null;
asid: string | null;
psid: string | null;
avatarUrl: string | null;
target: string | null;
character: string | null;
mode: string | null;
promotionType: string | null;
favorite: string | null;
handoffToken: string | null;
}
export default function ExternalEntryPersist({
deviceId,
asid,
psid,
avatarUrl,
target,
character,
mode,
promotionType,
favorite,
handoffToken,
}: ExternalEntryPersistProps) {
const router = useRouter();
const authState = useAuthState();
const authDispatch = useAuthDispatch();
const [hasPersisted, setHasPersisted] = useState(false);
const [handoffError, setHandoffError] = useState<string | null>(null);
const [handoffCompleted, setHandoffCompleted] = useState(false);
const hasNavigatedRef = useRef(false);
const handoffStartedRef = useRef(false);
const targetRoute = resolveExternalEntryTarget({ target });
const destination = resolveExternalEntryDestination({ target, character });
const characterId =
getCharacterBySlug(character)?.id ?? DEFAULT_CHARACTER_ID;
const resolvedPromotionType = resolveExternalEntryPromotionType({
mode,
promotionType,
});
const isTopUpHandoff = targetRoute === ROUTES.subscription;
const displayedHandoffError =
handoffError ??
(isTopUpHandoff && !hasValue(handoffToken)
? "This top-up link is invalid. Please request a new link in Messenger."
: null);
useEffect(() => {
let cancelled = false;
void (async () => {
const results = await Promise.allSettled([
persistExternalEntryPayload({
deviceId,
asid,
psid,
avatarUrl,
}),
targetRoute === ROUTES.chat && resolvedPromotionType
? savePendingChatPromotion(resolvedPromotionType, characterId)
: clearPendingChatPromotion(),
]);
if (isFavoriteEntryRequest(favorite)) {
persistFavoriteEntryIntent();
}
for (const result of results) {
if (result.status === "rejected") {
log.warn(
"[ExternalEntryPersist] failed to persist payload",
result.reason,
);
}
}
if (!cancelled) setHasPersisted(true);
})();
return () => {
cancelled = true;
};
}, [
avatarUrl,
asid,
character,
characterId,
destination,
deviceId,
favorite,
mode,
promotionType,
psid,
resolvedPromotionType,
targetRoute,
]);
useEffect(() => {
if (hasNavigatedRef.current || !hasPersisted) return;
if (isTopUpHandoff) {
if (!authState.hasInitialized || authState.isLoading) return;
if (handoffCompleted) {
if (
authState.loginStatus === "notLoggedIn" ||
authState.loginStatus === "guest"
) {
return;
}
hasNavigatedRef.current = true;
router.replace(destination);
return;
}
if (handoffStartedRef.current) return;
if (!hasValue(handoffToken)) return;
handoffStartedRef.current = true;
void (async () => {
const result = await consumeTopUpHandoff(handoffToken);
if (Result.isErr(result)) {
log.warn("[ExternalEntryPersist] top-up handoff failed", result.error);
window.history.replaceState(
window.history.state,
"",
"/external-entry?target=topup",
);
setHandoffError(
"This top-up link is invalid or has expired. Please request a new link in Messenger.",
);
return;
}
setHandoffCompleted(true);
authDispatch({ type: "AuthInit" });
})();
return;
}
if (!authState.hasInitialized || authState.isLoading) return;
hasNavigatedRef.current = true;
if (
shouldBindExternalFacebookIdentity({
hasInitialized: authState.hasInitialized,
isLoading: authState.isLoading,
loginStatus: authState.loginStatus,
asid,
psid,
})
) {
authDispatch({
type: "AuthExternalFacebookIdentityBindRequested",
asid: asid ?? undefined,
psid: psid ?? undefined,
});
}
router.replace(destination);
}, [
asid,
authDispatch,
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
destination,
hasPersisted,
handoffCompleted,
handoffToken,
isTopUpHandoff,
psid,
router,
]);
return (
<div
className="flex flex-1 items-center justify-center"
style={{ minHeight: "100dvh" }}
>
{displayedHandoffError ? (
<div className="mx-6 max-w-sm text-center">
<p className="text-sm text-[var(--color-text-secondary)]">
{displayedHandoffError}
</p>
<button
className="mt-5 rounded-full bg-[var(--color-accent)] px-5 py-2.5 text-sm font-semibold text-white"
type="button"
onClick={() => router.replace(ROUTES.root)}
>
Back to Cozsweet
</button>
</div>
) : (
<div
aria-label="Redirecting"
className="size-10 animate-spin rounded-full border-4 border-t-transparent"
style={{ borderColor: "var(--color-accent)" }}
/>
)}
</div>
);
}
function hasValue(value: string | null): value is string {
return typeof value === "string" && value.trim().length > 0;
}