feat(private-zone): add paid video moments
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
@@ -18,17 +18,24 @@ import {
|
||||
useActiveCharacterRoutes,
|
||||
} from "@/providers/character-provider";
|
||||
import { isPrivateAlbumLocked } from "@/lib/private-zone/private_album";
|
||||
import { behaviorAnalytics } from "@/lib/analytics";
|
||||
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 {
|
||||
@@ -54,11 +61,16 @@ export function PrivateZoneScreen() {
|
||||
const authDispatch = useAuthDispatch();
|
||||
const state = usePrivateZoneState();
|
||||
const dispatch = usePrivateZoneDispatch();
|
||||
const postState = usePrivateZonePostState();
|
||||
const postDispatch = usePrivateZonePostDispatch();
|
||||
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,
|
||||
@@ -74,6 +86,56 @@ export function PrivateZoneScreen() {
|
||||
unlockPaywallRequest: state.unlockPaywallRequest,
|
||||
});
|
||||
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",
|
||||
});
|
||||
navigator.openSubscription({
|
||||
type: "topup",
|
||||
returnTo: "private-zone",
|
||||
analytics: {
|
||||
entryPoint: "private_zone",
|
||||
triggerReason: "insufficient_credits",
|
||||
},
|
||||
});
|
||||
}
|
||||
postDispatch({ type: "PrivateZonePostUnlockPaywallConsumed" });
|
||||
}, [
|
||||
authState.loginStatus,
|
||||
characterRoutes.privateZone,
|
||||
navigator,
|
||||
postDispatch,
|
||||
postState.unlockPaywallRequest,
|
||||
]);
|
||||
|
||||
const displayName = character.displayName;
|
||||
const avatarUrl = character.assets.avatar;
|
||||
@@ -86,6 +148,13 @@ export function PrivateZoneScreen() {
|
||||
) ?? 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
|
||||
@@ -157,6 +226,14 @@ export function PrivateZoneScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -235,49 +312,116 @@ export function PrivateZoneScreen() {
|
||||
</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}>Albums</p>
|
||||
<p className={styles.kicker}>{visibleTab === "moments" ? "Moments" : "Albums"}</p>
|
||||
<h2 id="posts-title" className={styles.sectionTitle}>
|
||||
Private albums
|
||||
{visibleTab === "moments" ? "Private moments" : "Private albums"}
|
||||
</h2>
|
||||
</div>
|
||||
<span className={styles.postCount}>{state.items.length}</span>
|
||||
<span className={styles.postCount}>
|
||||
{visibleTab === "moments" ? postState.items.length : 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}
|
||||
|
||||
<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>
|
||||
{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
|
||||
@@ -309,6 +453,24 @@ export function PrivateZoneScreen() {
|
||||
</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) &&
|
||||
|
||||
Reference in New Issue
Block a user