Files
cozsweet-frontend-nextjs/src/app/private-zone/private-zone-screen.tsx
T

335 lines
9.7 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef } 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 { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import {
usePrivateZoneDispatch,
usePrivateZoneState,
} from "@/stores/private-zone";
import {
PrivateAlbumCard,
PrivateAlbumGallery,
StatusCard,
UnlockConfirmDialog,
} 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 openedGalleryInPageRef = useRef(false);
const galleryState = useMemo(
() => getPrivateAlbumGalleryState(searchParams),
[searchParams],
);
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,
});
usePrivateZoneUnlockSuccessRefresh(state.unlockSuccessNonce);
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 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: "topup",
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 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.sectionHeader}>
<div>
<p className={styles.kicker}>Albums</p>
<h2 id="posts-title" className={styles.sectionTitle}>
Private albums
</h2>
</div>
<span className={styles.postCount}>{state.items.length}</span>
</div>
{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}
{!state.isLoading && state.items.length === 0 && !state.errorMessage ? (
<StatusCard
message="No private albums yet."
actionLabel="Refresh"
onAction={() => dispatch({ type: "PrivateZoneRefresh" })}
/>
) : 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}
{galleryAlbum &&
galleryState &&
!isPrivateAlbumLocked(galleryAlbum) &&
galleryImage ? (
<PrivateAlbumGallery
album={galleryAlbum}
imageIndex={galleryState.imageIndex}
onClose={handleCloseGallery}
onImageIndexChange={handleGalleryIndexChange}
/>
) : null}
</main>
</MobileShell>
);
}