510 lines
16 KiB
TypeScript
510 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import Image from "next/image";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
|
|
import { CharacterAvatar } from "@/app/_components";
|
|
import {
|
|
AppBottomNav,
|
|
FavoriteEntryButton,
|
|
MobileShell,
|
|
} from "@/app/_components/core";
|
|
import { buildGlobalPageUrl } from "@/router/global-route-context";
|
|
import { ROUTES } from "@/router/routes";
|
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
|
import {
|
|
useActiveCharacter,
|
|
useActiveCharacterRoutes,
|
|
} from "@/providers/character-provider";
|
|
import { isPrivateAlbumLocked } from "@/lib/private-zone/private_album";
|
|
import { behaviorAnalytics } from "@/lib/analytics";
|
|
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
|
|
import { useUserSelector } from "@/stores/user/user-context";
|
|
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
|
import {
|
|
usePrivateZoneDispatch,
|
|
usePrivateZoneState,
|
|
} from "@/stores/private-zone";
|
|
import {
|
|
usePrivateZonePostDispatch,
|
|
usePrivateZonePostState,
|
|
} from "@/stores/private-zone-posts";
|
|
|
|
import {
|
|
PrivateAlbumCard,
|
|
PrivateAlbumGallery,
|
|
PrivateZoneVideoPostCard,
|
|
StatusCard,
|
|
UnlockConfirmDialog,
|
|
VideoPostUnlockDialog,
|
|
} from "./components";
|
|
import styles from "./private-zone-screen.module.css";
|
|
import {
|
|
isPrivateZoneAuthRequired,
|
|
usePrivateZoneBootstrapFlow,
|
|
usePrivateZoneUnlockPaywallNavigation,
|
|
usePrivateZoneUnlockSuccessRefresh,
|
|
} from "./use-private-zone-flow";
|
|
import {
|
|
buildPrivateAlbumGalleryUrl,
|
|
buildPrivateZoneWithoutGalleryUrl,
|
|
getPrivateAlbumGalleryState,
|
|
} from "./private-album-gallery-url";
|
|
import { findPrivateAlbumDisplayImage } from "./private-album-images";
|
|
|
|
export function PrivateZoneScreen() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const navigator = useAppNavigator();
|
|
const character = useActiveCharacter();
|
|
const characterRoutes = useActiveCharacterRoutes();
|
|
const authState = useAuthState();
|
|
const authDispatch = useAuthDispatch();
|
|
const state = usePrivateZoneState();
|
|
const dispatch = usePrivateZoneDispatch();
|
|
const postState = usePrivateZonePostState();
|
|
const postDispatch = usePrivateZonePostDispatch();
|
|
const isVip = useUserSelector((user) => user.context.isVip);
|
|
const [activeTab, setActiveTab] = useState<"moments" | "albums">("moments");
|
|
const openedGalleryInPageRef = useRef(false);
|
|
const previousPostLoginStatusRef = useRef(authState.loginStatus);
|
|
const galleryState = useMemo(
|
|
() => getPrivateAlbumGalleryState(searchParams),
|
|
[searchParams],
|
|
);
|
|
const visibleTab = galleryState ? "albums" : activeTab;
|
|
|
|
usePrivateZoneBootstrapFlow({
|
|
authDispatch,
|
|
hasInitialized: authState.hasInitialized,
|
|
isAuthLoading: authState.isLoading,
|
|
loginStatus: authState.loginStatus,
|
|
roomDispatch: dispatch,
|
|
roomStatus: state.status,
|
|
});
|
|
usePrivateZoneUnlockPaywallNavigation({
|
|
loginStatus: authState.loginStatus,
|
|
roomDispatch: dispatch,
|
|
unlockPaywallRequest: state.unlockPaywallRequest,
|
|
isVip,
|
|
});
|
|
usePrivateZoneUnlockSuccessRefresh(state.unlockSuccessNonce);
|
|
usePrivateZoneUnlockSuccessRefresh(postState.unlockSuccessNonce);
|
|
|
|
useEffect(() => {
|
|
if (!authState.hasInitialized || authState.isLoading) return;
|
|
if (authState.loginStatus === "notLoggedIn") return;
|
|
|
|
const previous = previousPostLoginStatusRef.current;
|
|
previousPostLoginStatusRef.current = authState.loginStatus;
|
|
if (postState.status === "idle") {
|
|
postDispatch({ type: "PrivateZonePostInit" });
|
|
return;
|
|
}
|
|
if (previous !== authState.loginStatus && postState.status !== "loading") {
|
|
postDispatch({ type: "PrivateZonePostRefresh" });
|
|
}
|
|
}, [
|
|
authState.hasInitialized,
|
|
authState.isLoading,
|
|
authState.loginStatus,
|
|
postDispatch,
|
|
postState.status,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const request = postState.unlockPaywallRequest;
|
|
if (!request) return;
|
|
if (isPrivateZoneAuthRequired(authState.loginStatus)) {
|
|
navigator.openAuth(characterRoutes.privateZone);
|
|
} else {
|
|
behaviorAnalytics.paywallShown({
|
|
entryPoint: "private_zone",
|
|
triggerReason: "insufficient_credits",
|
|
});
|
|
const guidance =
|
|
request.paymentGuidance?.characterId === character.id
|
|
? request.paymentGuidance
|
|
: null;
|
|
if (guidance) {
|
|
void recordChatActionEventById(
|
|
guidance.guidanceId,
|
|
character.id,
|
|
"opened",
|
|
).catch(() => undefined);
|
|
}
|
|
navigator.openSubscription({
|
|
type:
|
|
isVip || (guidance && !guidance.purchaseOptions.includes("vip"))
|
|
? "topup"
|
|
: "vip",
|
|
returnTo: "private-zone",
|
|
...(guidance ? { chatActionId: guidance.guidanceId } : {}),
|
|
analytics: {
|
|
entryPoint: "private_zone",
|
|
triggerReason: "insufficient_credits",
|
|
},
|
|
});
|
|
}
|
|
postDispatch({ type: "PrivateZonePostUnlockPaywallConsumed" });
|
|
}, [
|
|
authState.loginStatus,
|
|
characterRoutes.privateZone,
|
|
character.id,
|
|
isVip,
|
|
navigator,
|
|
postDispatch,
|
|
postState.unlockPaywallRequest,
|
|
]);
|
|
|
|
const displayName = character.displayName;
|
|
const avatarUrl = character.assets.avatar;
|
|
const title = character.copy.privateZoneTitle;
|
|
const subtitle = character.copy.privateZoneSubtitle;
|
|
const pendingAlbum = useMemo(
|
|
() =>
|
|
state.items.find(
|
|
(album) => album.albumId === state.pendingConfirmAlbumId,
|
|
) ?? null,
|
|
[state.items, state.pendingConfirmAlbumId],
|
|
);
|
|
const pendingPost = useMemo(
|
|
() =>
|
|
postState.items.find(
|
|
(post) => post.postId === postState.pendingConfirmPostId,
|
|
) ?? null,
|
|
[postState.items, postState.pendingConfirmPostId],
|
|
);
|
|
const galleryAlbum = useMemo(
|
|
() =>
|
|
galleryState
|
|
? state.items.find((album) => album.albumId === galleryState.albumId) ??
|
|
null
|
|
: null,
|
|
[galleryState, state.items],
|
|
);
|
|
const galleryImage = useMemo(
|
|
() =>
|
|
galleryAlbum && galleryState
|
|
? findPrivateAlbumDisplayImage(
|
|
galleryAlbum,
|
|
galleryState.imageIndex,
|
|
)
|
|
: null,
|
|
[galleryAlbum, galleryState],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!galleryState || state.isLoading) return;
|
|
if (
|
|
!galleryAlbum ||
|
|
isPrivateAlbumLocked(galleryAlbum) ||
|
|
!galleryImage
|
|
) {
|
|
router.replace(
|
|
buildPrivateZoneWithoutGalleryUrl(
|
|
searchParams,
|
|
characterRoutes.privateZone,
|
|
),
|
|
{ scroll: false },
|
|
);
|
|
}
|
|
}, [
|
|
characterRoutes.privateZone,
|
|
galleryAlbum,
|
|
galleryImage,
|
|
galleryState,
|
|
router,
|
|
searchParams,
|
|
state.isLoading,
|
|
]);
|
|
|
|
const handleTopUpClick = () => {
|
|
if (isPrivateZoneAuthRequired(authState.loginStatus)) {
|
|
navigator.openAuth(characterRoutes.privateZone);
|
|
return;
|
|
}
|
|
navigator.openSubscription({
|
|
type: isVip ? "topup" : "vip",
|
|
returnTo: "private-zone",
|
|
analytics: {
|
|
entryPoint: "private_zone",
|
|
triggerReason: "vip_cta",
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleOpenGallery = (albumId: string, imageIndex: number) => {
|
|
openedGalleryInPageRef.current = true;
|
|
router.push(
|
|
buildPrivateAlbumGalleryUrl(
|
|
albumId,
|
|
imageIndex,
|
|
characterRoutes.privateZone,
|
|
),
|
|
{ scroll: false },
|
|
);
|
|
};
|
|
|
|
const handlePostUnlock = (postId: string) => {
|
|
if (isPrivateZoneAuthRequired(authState.loginStatus)) {
|
|
navigator.openAuth(characterRoutes.privateZone);
|
|
return;
|
|
}
|
|
postDispatch({ type: "PrivateZonePostUnlockRequested", postId });
|
|
};
|
|
|
|
const handleCloseGallery = () => {
|
|
if (openedGalleryInPageRef.current) {
|
|
openedGalleryInPageRef.current = false;
|
|
router.back();
|
|
return;
|
|
}
|
|
router.replace(
|
|
buildPrivateZoneWithoutGalleryUrl(
|
|
searchParams,
|
|
characterRoutes.privateZone,
|
|
),
|
|
{ scroll: false },
|
|
);
|
|
};
|
|
|
|
const handleGalleryIndexChange = (imageIndex: number) => {
|
|
if (!galleryAlbum) return;
|
|
router.replace(
|
|
buildPrivateAlbumGalleryUrl(
|
|
galleryAlbum.albumId,
|
|
imageIndex,
|
|
characterRoutes.privateZone,
|
|
),
|
|
{ scroll: false },
|
|
);
|
|
};
|
|
|
|
return (
|
|
<MobileShell background="#f3f4f2">
|
|
<main className={styles.shell}>
|
|
<div className={styles.backgroundGlowOne} />
|
|
<div className={styles.backgroundGlowTwo} />
|
|
<div className={styles.favoriteAction}>
|
|
<FavoriteEntryButton
|
|
characterSlug={character.slug}
|
|
tone="dark"
|
|
/>
|
|
</div>
|
|
|
|
<section className={styles.hero} aria-label={title}>
|
|
<div className={styles.heroCover}>
|
|
<Image
|
|
src={character.assets.privateZoneBanner}
|
|
alt=""
|
|
width={2048}
|
|
height={1152}
|
|
className={styles.heroBanner}
|
|
priority
|
|
sizes="(max-width: 540px) 100vw, 540px"
|
|
/>
|
|
|
|
<div className={styles.identity}>
|
|
<div className={styles.avatarFrame}>
|
|
<CharacterAvatar
|
|
src={avatarUrl}
|
|
alt={displayName}
|
|
size="100%"
|
|
imageSize={32}
|
|
priority
|
|
/>
|
|
</div>
|
|
|
|
<p className={styles.identityName}>{displayName}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
data-analytics-key="private_zone.primary_cta"
|
|
data-analytics-label="Open private zone top up"
|
|
className={styles.primaryCta}
|
|
onClick={handleTopUpClick}
|
|
>
|
|
<span>{subtitle}</span>
|
|
</button>
|
|
</section>
|
|
|
|
<section className={styles.postsSection} aria-labelledby="posts-title">
|
|
<div className={styles.contentTabs} role="tablist" aria-label="Private Zone content">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={visibleTab === "moments"}
|
|
className={`${styles.contentTab} ${visibleTab === "moments" ? styles.contentTabActive : ""}`}
|
|
onClick={() => setActiveTab("moments")}
|
|
>
|
|
Moments
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={visibleTab === "albums"}
|
|
className={`${styles.contentTab} ${visibleTab === "albums" ? styles.contentTabActive : ""}`}
|
|
onClick={() => setActiveTab("albums")}
|
|
>
|
|
Albums
|
|
</button>
|
|
</div>
|
|
|
|
<div className={styles.sectionHeader}>
|
|
<div>
|
|
<p className={styles.kicker}>{visibleTab === "moments" ? "Moments" : "Albums"}</p>
|
|
<h2 id="posts-title" className={styles.sectionTitle}>
|
|
{visibleTab === "moments" ? "Private moments" : "Private albums"}
|
|
</h2>
|
|
</div>
|
|
<span className={styles.postCount}>
|
|
{visibleTab === "moments" ? postState.items.length : state.items.length}
|
|
</span>
|
|
</div>
|
|
|
|
{visibleTab === "moments" ? (
|
|
<>
|
|
{postState.errorMessage ? (
|
|
<StatusCard
|
|
message={postState.errorMessage}
|
|
actionLabel="Retry"
|
|
onAction={() => postDispatch({ type: "PrivateZonePostRefresh" })}
|
|
/>
|
|
) : null}
|
|
{postState.isLoading && postState.items.length === 0 ? (
|
|
<StatusCard message="Loading private moments..." />
|
|
) : null}
|
|
{!postState.isLoading && !postState.errorMessage && postState.items.length === 0 ? (
|
|
<StatusCard message="No private moments yet." />
|
|
) : null}
|
|
<div className={styles.timeline}>
|
|
{postState.items.map((post, index) => (
|
|
<PrivateZoneVideoPostCard
|
|
key={post.postId}
|
|
post={post}
|
|
displayName={displayName}
|
|
avatarUrl={avatarUrl}
|
|
index={index}
|
|
isUnlocking={postState.unlockingPostId === post.postId}
|
|
onUnlock={() => handlePostUnlock(post.postId)}
|
|
onPlaybackError={() =>
|
|
postDispatch({ type: "PrivateZonePostRefresh" })
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
{postState.hasMore ? (
|
|
<button
|
|
type="button"
|
|
className={styles.loadMoreButton}
|
|
disabled={postState.isLoadingMore}
|
|
onClick={() => postDispatch({ type: "PrivateZonePostLoadMore" })}
|
|
>
|
|
{postState.isLoadingMore ? "Loading..." : "Load more"}
|
|
</button>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
{state.errorMessage ? (
|
|
<StatusCard
|
|
message={state.errorMessage}
|
|
actionLabel="Retry"
|
|
onAction={() => dispatch({ type: "PrivateZoneRefresh" })}
|
|
/>
|
|
) : null}
|
|
{state.isLoading && state.items.length === 0 ? (
|
|
<StatusCard message="Loading private albums..." />
|
|
) : null}
|
|
<div className={styles.timeline}>
|
|
{state.items.map((album, index) => (
|
|
<PrivateAlbumCard
|
|
key={album.albumId}
|
|
album={album}
|
|
displayName={displayName}
|
|
avatarUrl={avatarUrl}
|
|
index={index}
|
|
isUnlocking={state.unlockingAlbumId === album.albumId}
|
|
onOpenGallery={(imageIndex) =>
|
|
handleOpenGallery(album.albumId, imageIndex)
|
|
}
|
|
onUnlock={() =>
|
|
dispatch({
|
|
type: "PrivateZoneUnlockRequested",
|
|
albumId: album.albumId,
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
<AppBottomNav
|
|
activeItem="privateZone"
|
|
privateZoneLabel={character.copy.privateZoneTitle}
|
|
onChatClick={() =>
|
|
navigator.push(characterRoutes.splash, { scroll: false })
|
|
}
|
|
onPrivateZoneClick={() => undefined}
|
|
onMenuClick={() =>
|
|
navigator.push(
|
|
buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat),
|
|
{ scroll: false },
|
|
)
|
|
}
|
|
/>
|
|
|
|
{pendingAlbum ? (
|
|
<UnlockConfirmDialog
|
|
album={pendingAlbum}
|
|
isUnlocking={state.isUnlocking}
|
|
errorMessage={state.unlockErrorMessage}
|
|
onCancel={() => dispatch({ type: "PrivateZoneUnlockCancelled" })}
|
|
onConfirm={() => dispatch({ type: "PrivateZoneUnlockConfirmed" })}
|
|
/>
|
|
) : state.unlockErrorMessage ? (
|
|
<div className={styles.toast} role="status">
|
|
{state.unlockErrorMessage}
|
|
</div>
|
|
) : null}
|
|
|
|
{pendingPost ? (
|
|
<VideoPostUnlockDialog
|
|
post={pendingPost}
|
|
isUnlocking={postState.isUnlocking}
|
|
errorMessage={postState.unlockErrorMessage}
|
|
onCancel={() =>
|
|
postDispatch({ type: "PrivateZonePostUnlockCancelled" })
|
|
}
|
|
onConfirm={() =>
|
|
postDispatch({ type: "PrivateZonePostUnlockConfirmed" })
|
|
}
|
|
/>
|
|
) : postState.unlockErrorMessage ? (
|
|
<div className={styles.toast} role="status">
|
|
{postState.unlockErrorMessage}
|
|
</div>
|
|
) : null}
|
|
|
|
{galleryAlbum &&
|
|
galleryState &&
|
|
!isPrivateAlbumLocked(galleryAlbum) &&
|
|
galleryImage ? (
|
|
<PrivateAlbumGallery
|
|
album={galleryAlbum}
|
|
imageIndex={galleryState.imageIndex}
|
|
onClose={handleCloseGallery}
|
|
onImageIndexChange={handleGalleryIndexChange}
|
|
/>
|
|
) : null}
|
|
</main>
|
|
</MobileShell>
|
|
);
|
|
}
|