refactor(private-zoom): rename full feature surface

This commit is contained in:
Codex
2026-07-22 18:21:47 +08:00
parent e813333607
commit ed3b34ce1c
102 changed files with 695 additions and 519 deletions
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
getResistedGalleryDragDistance,
resolveGallerySwipeDirection,
} from "../private-album-gallery-motion";
describe("private album Gallery motion", () => {
it("switches after crossing the viewport distance threshold", () => {
expect(
resolveGallerySwipeDirection({
distance: -72,
durationMs: 500,
viewportWidth: 390,
hasPrevious: true,
hasNext: true,
}),
).toBe(1);
expect(
resolveGallerySwipeDirection({
distance: 72,
durationMs: 500,
viewportWidth: 390,
hasPrevious: true,
hasNext: true,
}),
).toBe(-1);
});
it("switches on a short fast swipe but not a slow short drag", () => {
const baseInput = {
distance: -30,
viewportWidth: 390,
hasPrevious: true,
hasNext: true,
};
expect(
resolveGallerySwipeDirection({ ...baseInput, durationMs: 50 }),
).toBe(1);
expect(
resolveGallerySwipeDirection({ ...baseInput, durationMs: 500 }),
).toBe(0);
});
it("does not move beyond the first or last image", () => {
expect(
resolveGallerySwipeDirection({
distance: 100,
durationMs: 100,
viewportWidth: 390,
hasPrevious: false,
hasNext: true,
}),
).toBe(0);
expect(
resolveGallerySwipeDirection({
distance: -100,
durationMs: 100,
viewportWidth: 390,
hasPrevious: true,
hasNext: false,
}),
).toBe(0);
});
it("applies resistance only while pulling past an edge", () => {
expect(getResistedGalleryDragDistance(100, false, true)).toBeCloseTo(28);
expect(getResistedGalleryDragDistance(-100, true, false)).toBeCloseTo(-28);
expect(getResistedGalleryDragDistance(-100, false, true)).toBe(-100);
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
buildPrivateAlbumGalleryUrl,
buildPrivateZoomWithoutGalleryUrl,
getPrivateAlbumGalleryState,
} from "../private-album-gallery-url";
describe("private album gallery URL", () => {
it("builds and parses an album image URL", () => {
const url = buildPrivateAlbumGalleryUrl("album:91", 3);
const params = new URL(url, "https://cozsweet.test").searchParams;
expect(url).toBe("/private-zoom?album=album%3A91&image=3");
expect(getPrivateAlbumGalleryState(params)).toEqual({
albumId: "album:91",
imageIndex: 3,
});
});
it("rejects missing albums and invalid image indexes", () => {
expect(getPrivateAlbumGalleryState(new URLSearchParams("image=0"))).toBeNull();
expect(
getPrivateAlbumGalleryState(
new URLSearchParams("album=album-1&image=-1"),
),
).toBeNull();
});
it("removes gallery params without dropping other query values", () => {
expect(
buildPrivateZoomWithoutGalleryUrl(
new URLSearchParams("album=album-1&image=2&source=external"),
),
).toBe("/private-zoom?source=external");
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { PrivateAlbumSchema } from "@/data/schemas/private-zoom";
import {
findPrivateAlbumDisplayImage,
getPrivateAlbumDisplayImages,
getPrivateAlbumGridLayout,
} from "../private-album-images";
describe("private album display images", () => {
const album = PrivateAlbumSchema.parse({
albumId: "album-1",
images: [
{ url: " /images/photo-1.png ", locked: false, index: 0 },
{ url: "/images/photo-2.png", locked: true, index: 1 },
{ url: " ", locked: false, index: 2 },
{ url: "/images/photo-4.png", locked: false, index: 3 },
],
unlocked: true,
lockDetail: { locked: false },
});
it("filters unavailable images while preserving source indexes", () => {
expect(getPrivateAlbumDisplayImages(album)).toEqual([
{ sourceIndex: 0, url: "/images/photo-1.png" },
{ sourceIndex: 3, url: "/images/photo-4.png" },
]);
});
it("resolves Gallery images by their original source index", () => {
expect(findPrivateAlbumDisplayImage(album, 3)).toEqual({
sourceIndex: 3,
url: "/images/photo-4.png",
});
expect(findPrivateAlbumDisplayImage(album, 1)).toBeNull();
expect(findPrivateAlbumDisplayImage(album, 2)).toBeNull();
});
it.each([
[0, "empty"],
[1, "single"],
[2, "double"],
[3, "triple"],
[4, "double"],
[5, "triple"],
[9, "triple"],
] as const)("uses the expected grid for %i images", (count, layout) => {
expect(getPrivateAlbumGridLayout(count)).toBe(layout);
});
});
@@ -0,0 +1,120 @@
import { act, type Dispatch } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getCharacterBySlug } from "@/data/constants/character";
import type { LoginStatus } from "@/data/schemas/auth";
import { CharacterProvider } from "@/providers/character-provider";
import { getCharacterRoutes } from "@/router/routes";
import type { PrivateZoomEvent } from "@/stores/private-zoom";
import type { PrivateZoomUnlockPaywallRequest } from "@/stores/private-zoom/private-zoom-state";
import { usePrivateZoomUnlockPaywallNavigation } from "../use-private-zoom-flow";
const mocks = vi.hoisted(() => ({
openAuth: vi.fn(),
openSubscription: vi.fn(),
paywallShown: vi.fn(),
}));
vi.mock("@/router/use-app-navigator", () => ({
useAppNavigator: () => ({
openAuth: mocks.openAuth,
openSubscription: mocks.openSubscription,
}),
}));
vi.mock("@/lib/analytics", () => ({
behaviorAnalytics: {
paywallShown: mocks.paywallShown,
},
}));
const unlockPaywallRequest: PrivateZoomUnlockPaywallRequest = {
albumId: "album-1",
reason: "insufficient_credits",
requiredCredits: 10,
currentCredits: 3,
shortfallCredits: 7,
};
describe("Private Zoom paywall navigation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
mocks.openAuth.mockReset();
mocks.openSubscription.mockReset();
mocks.paywallShown.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("opens top up with a private zoom return target", () => {
const roomDispatch = vi.fn<Dispatch<PrivateZoomEvent>>();
renderHarness(root, "email", roomDispatch);
expect(mocks.openSubscription).toHaveBeenCalledWith({
type: "topup",
returnTo: "private-zoom",
analytics: {
entryPoint: "private_album_unlock",
triggerReason: "insufficient_credits",
},
});
expect(roomDispatch).toHaveBeenCalledWith({
type: "PrivateZoomUnlockPaywallConsumed",
});
});
it("keeps guest users on the private zoom auth return path", () => {
const roomDispatch = vi.fn<Dispatch<PrivateZoomEvent>>();
renderHarness(root, "guest", roomDispatch);
expect(mocks.openAuth).toHaveBeenCalledWith(
getCharacterRoutes("maya").privateZoom,
);
expect(mocks.openSubscription).not.toHaveBeenCalled();
});
});
function renderHarness(
root: Root,
loginStatus: LoginStatus,
roomDispatch: Dispatch<PrivateZoomEvent>,
): void {
const character = getCharacterBySlug("maya");
if (!character) throw new Error("Missing Maya character fixture");
act(() => {
root.render(
<CharacterProvider character={character}>
<Harness loginStatus={loginStatus} roomDispatch={roomDispatch} />
</CharacterProvider>,
);
});
}
function Harness({
loginStatus,
roomDispatch,
}: {
loginStatus: LoginStatus;
roomDispatch: Dispatch<PrivateZoomEvent>;
}) {
usePrivateZoomUnlockPaywallNavigation({
loginStatus,
roomDispatch,
unlockPaywallRequest,
});
return null;
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { isPrivateZoomAuthRequired } from "../use-private-zoom-flow";
describe("isPrivateZoomAuthRequired", () => {
it.each(["guest", "notLoggedIn"] as const)(
"requires auth for %s users",
(loginStatus) => {
expect(isPrivateZoomAuthRequired(loginStatus)).toBe(true);
},
);
it.each(["email", "facebook", "google"] as const)(
"allows %s users to top up directly",
(loginStatus) => {
expect(isPrivateZoomAuthRequired(loginStatus)).toBe(false);
},
);
});
@@ -0,0 +1,131 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PrivateAlbumSchema } from "@/data/schemas/private-zoom";
import { PrivateAlbumCard } from "../private-album-card";
describe("PrivateAlbumCard interactions", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("opens the selected thumbnail with its original source index", () => {
const onOpenGallery = vi.fn();
const album = PrivateAlbumSchema.parse({
albumId: "album-1",
title: "Private afternoon",
imageCount: 4,
images: [
{ url: "", locked: false, index: 0 },
{ url: "/images/private-zoom/locked.png", locked: true, index: 1 },
{ url: "/images/private-zoom/photo-2.png", locked: false, index: 2 },
{ url: "/images/private-zoom/photo-3.png", locked: false, index: 3 },
],
locked: false,
unlocked: true,
lockDetail: { locked: false },
});
act(() => {
root.render(
<PrivateAlbumCard
album={album}
displayName="Elio Silvestri"
avatarUrl="/images/avatar/elio.png"
index={0}
isUnlocking={false}
onOpenGallery={onOpenGallery}
onUnlock={() => undefined}
/>,
);
});
const thumbnail = container.querySelector<HTMLButtonElement>(
'button[data-image-index="3"]',
);
act(() => thumbnail?.click());
expect(onOpenGallery).toHaveBeenCalledOnce();
expect(onOpenGallery).toHaveBeenCalledWith(3);
});
it("unlocks only from the collection CTA and disables repeat actions", () => {
const onUnlock = vi.fn();
const album = PrivateAlbumSchema.parse({
albumId: "album-locked",
title: "Locked afternoon",
imageCount: 3,
images: [
{ url: "/images/private-zoom/locked.png", locked: true, index: 0 },
],
locked: true,
unlocked: false,
unlockCost: 40,
lockDetail: { locked: true },
});
renderLockedCard(album, false, onUnlock);
const avatar = container.querySelector<HTMLElement>(
'[aria-label="Elio Silvestri locked collection"]',
);
act(() => avatar?.parentElement?.click());
expect(onUnlock).not.toHaveBeenCalled();
const button = findCollectionButton();
act(() => button.click());
expect(onUnlock).toHaveBeenCalledOnce();
renderLockedCard(album, true, onUnlock);
const disabledButton = findCollectionButton();
expect(disabledButton.disabled).toBe(true);
expect(disabledButton.textContent).toBe("Opening...");
act(() => disabledButton.click());
expect(onUnlock).toHaveBeenCalledOnce();
});
function renderLockedCard(
album: ReturnType<typeof PrivateAlbumSchema.parse>,
isUnlocking: boolean,
onUnlock: () => void,
): void {
act(() => {
root.render(
<PrivateAlbumCard
album={album}
displayName="Elio Silvestri"
avatarUrl="/images/avatar/elio.png"
index={0}
isUnlocking={isUnlocking}
onOpenGallery={() => undefined}
onUnlock={onUnlock}
/>,
);
});
}
function findCollectionButton(): HTMLButtonElement {
const button = Array.from(container.querySelectorAll("button")).find(
(item) =>
item.textContent === "View collection" ||
item.textContent === "Opening...",
);
if (!button) throw new Error("Missing View collection button");
return button;
}
});
@@ -0,0 +1,164 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import {
PrivateAlbumSchema,
type PrivateAlbum,
type PrivateAlbumInput,
} from "@/data/schemas/private-zoom";
import { PrivateAlbumCard } from "../private-album-card";
const COVER_URL = "/images/private-zoom/banner/elio.png";
function makeAlbum(overrides: Partial<PrivateAlbumInput> = {}): PrivateAlbum {
return PrivateAlbumSchema.parse({
albumId: "album-1",
title: "Private morning",
content: "A quiet morning by the water.",
previewText: "Only for you.",
imageCount: 8,
images: [],
locked: false,
unlocked: true,
unlockCost: 320,
publishedAt: "2026-07-01T08:11:35+00:00",
lockDetail: { locked: false },
...overrides,
});
}
function renderCard(album: PrivateAlbum): string {
return renderToStaticMarkup(
<PrivateAlbumCard
album={album}
displayName="Elio Silvestri"
avatarUrl="/images/avatar/elio.png"
index={0}
isUnlocking={false}
onOpenGallery={() => undefined}
onUnlock={() => undefined}
/>,
);
}
describe("PrivateAlbumCard", () => {
it("renders every available unlocked image in the adaptive grid", () => {
const html = renderCard(
makeAlbum({
imageCount: 2,
images: [
{ url: COVER_URL, locked: false, index: 0 },
{
url: "/images/cover/elio.png",
locked: false,
index: 1,
},
],
}),
);
expect(html).toContain("elio.png");
expect(html).toContain("%2Fimages%2Fcover%2Felio.png");
expect(html).toContain('data-grid-layout="double"');
expect(html).toContain('data-image-index="0"');
expect(html).toContain('data-image-index="1"');
expect(html).toContain("A quiet morning by the water.");
expect(html).toContain(
'aria-label="Open photo 2 of 2 from Elio Silvestri"',
);
expect(html).toContain('data-analytics-key="private_album.open_gallery"');
});
it.each([
{ count: 1, layout: "single" },
{ count: 2, layout: "double" },
{ count: 3, layout: "triple" },
{ count: 4, layout: "double" },
{ count: 5, layout: "triple" },
{ count: 9, layout: "triple" },
])("uses the $layout layout for $count images", ({ count, layout }) => {
const html = renderCard(
makeAlbum({
imageCount: count,
images: makeImages(count),
}),
);
expect(html).toContain(`data-grid-layout="${layout}"`);
});
it("limits the preview to nine images and shows the remaining count", () => {
const html = renderCard(
makeAlbum({
imageCount: 12,
images: makeImages(12),
}),
);
expect(html.match(/data-image-index=/g)).toHaveLength(9);
expect(html).toContain("+3");
expect(html).not.toContain('data-image-index="9"');
});
it("renders the reference-style locked collection over the first image", () => {
const html = renderCard(
makeAlbum({
content: null,
locked: true,
unlocked: false,
lockDetail: { locked: true },
images: [{ url: COVER_URL, locked: true, index: 0 }],
}),
);
expect(html).toContain("elio.png");
expect(html).toContain(
'aria-label="Elio Silvestri locked collection"',
);
expect(html).toContain("lucide-copy");
expect(html).toContain("lucide-lock-keyhole");
expect(html).toContain("Unlock to view");
expect(html).toContain("8 Images");
expect(html).toContain("View collection");
expect(html).toContain(
'aria-label="View locked collection with 8 images from Elio Silvestri"',
);
expect(html).not.toContain("Only for you.");
expect(html).not.toContain("320 credits");
expect(html).not.toContain("Videos");
expect(html).toContain('data-analytics-key="private_album.unlock"');
});
it("uses singular image copy and works without a cover", () => {
const html = renderCard(
makeAlbum({
imageCount: 0,
locked: true,
unlocked: false,
lockDetail: { locked: true },
images: [{ url: "", locked: true, index: 0 }],
}),
);
expect(html).toContain("1 Image");
expect(html).not.toContain("1 Images");
expect(html.match(/<img/g)).toHaveLength(2);
expect(html).toContain("View collection");
});
it("renders an empty cover when an unlocked album has no image", () => {
const html = renderCard(makeAlbum());
expect(html).toContain('aria-label="No private album photo available"');
expect(html).toContain("No photo available");
});
});
function makeImages(count: number) {
return Array.from({ length: count }, (_, index) => ({
url: `/images/private-zoom/photo-${index}.png`,
locked: false,
index,
}));
}
@@ -0,0 +1,154 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PrivateAlbumSchema } from "@/data/schemas/private-zoom";
import { PrivateAlbumGallery } from "../private-album-gallery";
const album = PrivateAlbumSchema.parse({
albumId: "album-1",
title: "Private afternoon",
imageCount: 5,
images: [
{ url: "/images/private-zoom/photo-0.png", locked: false, index: 0 },
{ url: "", locked: false, index: 1 },
{ url: "/images/private-zoom/locked.png", locked: true, index: 2 },
{ url: "/images/private-zoom/photo-3.png", locked: false, index: 3 },
{ url: "/images/private-zoom/photo-4.png", locked: false, index: 4 },
],
locked: false,
unlocked: true,
lockDetail: { locked: false },
});
describe("PrivateAlbumGallery", () => {
let container: HTMLDivElement;
let root: Root;
let onClose: () => void;
let onImageIndexChange: (index: number) => void;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperties(HTMLElement.prototype, {
setPointerCapture: {
configurable: true,
value: vi.fn(),
},
hasPointerCapture: {
configurable: true,
value: vi.fn(() => true),
},
releasePointerCapture: {
configurable: true,
value: vi.fn(),
},
});
onClose = vi.fn();
onImageIndexChange = vi.fn();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("follows a drag and switches to the next available source index", () => {
renderGallery(0);
const viewport = getViewport();
Object.defineProperty(viewport, "clientWidth", {
configurable: true,
value: 390,
});
dispatchPointer(viewport, "pointerdown", 300);
dispatchPointer(viewport, "pointermove", 200);
expect(viewport.querySelector<HTMLElement>("[aria-hidden=false]")?.style.getPropertyValue("--gallery-drag-offset")).toBe("-100px");
dispatchPointer(viewport, "pointerup", 200);
expect(onImageIndexChange).toHaveBeenCalledWith(3);
expect(container.textContent).toContain("2 / 3");
});
it("snaps back on pointer cancellation without changing images", () => {
renderGallery(3);
const viewport = getViewport();
Object.defineProperty(viewport, "clientWidth", {
configurable: true,
value: 390,
});
dispatchPointer(viewport, "pointerdown", 300);
dispatchPointer(viewport, "pointermove", 200);
dispatchPointer(viewport, "pointercancel", 200);
expect(onImageIndexChange).not.toHaveBeenCalled();
expect(container.textContent).toContain("2 / 3");
});
it("uses the same available indexes for controls and keyboard navigation", () => {
renderGallery(3);
act(() => {
container
.querySelector<HTMLButtonElement>('button[aria-label="Next photo"]')
?.click();
});
expect(onImageIndexChange).toHaveBeenLastCalledWith(4);
act(() => {
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }),
);
});
expect(onImageIndexChange).toHaveBeenLastCalledWith(3);
act(() => {
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
});
expect(onClose).toHaveBeenCalledOnce();
});
function renderGallery(imageIndex: number): void {
act(() => {
root.render(
<PrivateAlbumGallery
album={album}
imageIndex={imageIndex}
onClose={onClose}
onImageIndexChange={onImageIndexChange}
/>,
);
});
}
function getViewport(): HTMLDivElement {
const viewport = container.querySelector<HTMLDivElement>(
'[data-testid="private-album-gallery-viewport"]',
);
if (!viewport) throw new Error("Missing Gallery viewport");
return viewport;
}
});
function dispatchPointer(
target: HTMLElement,
type: "pointerdown" | "pointermove" | "pointerup" | "pointercancel",
clientX: number,
): void {
const event = new MouseEvent(type, {
bubbles: true,
button: 0,
clientX,
});
Object.defineProperty(event, "pointerId", { value: 1 });
act(() => target.dispatchEvent(event));
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./private-album-card";
export * from "./private-album-gallery";
export * from "./status-card";
export * from "./unlock-confirm-dialog";
@@ -0,0 +1,199 @@
import type { CSSProperties } from "react";
import Image from "next/image";
import { Copy, ImageIcon, LockKeyhole } from "lucide-react";
import { CharacterAvatar } from "@/app/_components";
import type { PrivateAlbum } from "@/data/schemas/private-zoom";
import { isPrivateAlbumLocked } from "@/lib/private-zoom/private_album";
import {
getPrivateAlbumDisplayImages,
getPrivateAlbumGridLayout,
PRIVATE_ALBUM_GRID_LIMIT,
} from "../private-album-images";
import styles from "../private-zoom-screen.module.css";
export interface PrivateAlbumCardProps {
album: PrivateAlbum;
displayName: string;
avatarUrl: string;
index: number;
isUnlocking: boolean;
onOpenGallery: (imageIndex: number) => void;
onUnlock: () => void;
}
export function PrivateAlbumCard({
album,
displayName,
avatarUrl,
index,
isUnlocking,
onOpenGallery,
onUnlock,
}: PrivateAlbumCardProps) {
const isLocked = isPrivateAlbumLocked(album);
const firstImage = album.images[0];
const firstImageUrl = firstImage?.url || null;
const photoCount = album.imageCount || album.images.length;
const displayImages = getPrivateAlbumDisplayImages(album);
const previewImages = displayImages.slice(0, PRIVATE_ALBUM_GRID_LIMIT);
const gridLayout = getPrivateAlbumGridLayout(previewImages.length);
const overflowCount = Math.max(
0,
displayImages.length - PRIVATE_ALBUM_GRID_LIMIT,
);
const gridLayoutClassName =
gridLayout === "single"
? styles.mediaGridSingle
: gridLayout === "double"
? styles.mediaGridDouble
: styles.mediaGridTriple;
return (
<article
className={styles.postCard}
style={{ "--reveal-index": index } as CSSProperties}
>
<header className={styles.postHeader}>
<div className={styles.postAuthor}>
<CharacterAvatar
src={avatarUrl}
alt=""
size={38}
imageSize={38}
className={styles.postAvatar}
/>
<div>
<p className={styles.authorName}>{displayName}</p>
<p className={styles.authorSubline}>
{album.title || "Private album"}
</p>
</div>
</div>
<time className={styles.postTime}>
{formatAlbumTime(album.publishedAt)}
</time>
</header>
{isLocked ? (
<div className={styles.lockedPreview}>
<div className={styles.lockedPreviewBackdrop} aria-hidden="true">
{firstImageUrl ? (
<Image
src={firstImageUrl}
alt=""
fill
sizes="(max-width: 540px) calc(100vw - 36px), 484px"
className={styles.lockedCoverImage}
/>
) : null}
<span className={styles.lockedPreviewScrim} />
</div>
<div className={styles.lockedPreviewPanel}>
<span className={styles.lockedCollectionIcon} aria-hidden="true">
<Copy size={17} strokeWidth={2} />
</span>
<div
className={styles.lockedPreviewAvatarFrame}
role="img"
aria-label={`${displayName} locked collection`}
>
<CharacterAvatar
src={avatarUrl}
alt=""
size="100%"
imageSize={72}
className={styles.lockedPreviewAvatar}
/>
<span className={styles.lockedAvatarBadge} aria-hidden="true">
<LockKeyhole size={15} strokeWidth={2.2} />
</span>
</div>
<div className={styles.lockedPreviewContent}>
<strong className={styles.lockedPreviewTitle}>Unlock to view</strong>
<span className={styles.lockedImageCount}>
<ImageIcon size={19} strokeWidth={1.9} aria-hidden="true" />
{photoCount} {photoCount === 1 ? "Image" : "Images"}
</span>
<button
type="button"
data-analytics-key="private_album.unlock"
data-analytics-label="Unlock private album"
className={styles.lockedPreviewCta}
disabled={isUnlocking}
onClick={onUnlock}
aria-label={`View locked collection with ${photoCount} ${photoCount === 1 ? "image" : "images"} from ${displayName}`}
>
{isUnlocking ? "Opening..." : "View collection"}
</button>
</div>
</div>
</div>
) : previewImages.length > 0 ? (
<div
className={`${styles.mediaGrid} ${gridLayoutClassName}`}
data-grid-layout={gridLayout}
data-image-count={displayImages.length}
>
{previewImages.map((image, imagePosition) => (
<button
type="button"
key={`${image.sourceIndex}:${image.url}`}
data-analytics-key="private_album.open_gallery"
data-analytics-label="Open private album gallery"
data-image-index={image.sourceIndex}
className={styles.mediaGridItem}
onClick={() => onOpenGallery(image.sourceIndex)}
aria-label={`Open photo ${imagePosition + 1} of ${displayImages.length} from ${displayName}`}
>
<Image
src={image.url}
alt=""
fill
sizes={getGridImageSizes(gridLayout)}
className={styles.mediaGridImage}
/>
{imagePosition === PRIVATE_ALBUM_GRID_LIMIT - 1 &&
overflowCount > 0 ? (
<span className={styles.mediaGridOverflow} aria-hidden="true">
+{overflowCount}
</span>
) : null}
</button>
))}
</div>
) : (
<div
className={styles.emptyMediaCover}
role="img"
aria-label="No private album photo available"
>
<ImageIcon size={32} aria-hidden="true" />
<span>No photo available</span>
</div>
)}
{album.content ? <p className={styles.postText}>{album.content}</p> : null}
</article>
);
}
function getGridImageSizes(layout: string): string {
if (layout === "single") return "(max-width: 540px) 68vw, 280px";
if (layout === "double") return "(max-width: 540px) 34vw, 154px";
return "(max-width: 540px) 29vw, 150px";
}
function formatAlbumTime(value: string | null): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
@@ -0,0 +1,244 @@
"use client";
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import Image from "next/image";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import type { PrivateAlbum } from "@/data/schemas/private-zoom";
import {
getResistedGalleryDragDistance,
resolveGallerySwipeDirection,
} from "../private-album-gallery-motion";
import { getPrivateAlbumDisplayImages } from "../private-album-images";
import styles from "../private-zoom-screen.module.css";
export interface PrivateAlbumGalleryProps {
album: PrivateAlbum;
imageIndex: number;
onClose: () => void;
onImageIndexChange: (index: number) => void;
}
interface ActivePointerGesture {
pointerId: number;
startX: number;
startTime: number;
startPosition: number;
viewportWidth: number;
}
type GallerySlideStyle = CSSProperties & {
"--gallery-slide-offset": string;
"--gallery-drag-offset": string;
};
export function PrivateAlbumGallery({
album,
imageIndex,
onClose,
onImageIndexChange,
}: PrivateAlbumGalleryProps) {
const displayImages = useMemo(
() => getPrivateAlbumDisplayImages(album),
[album],
);
const [activeSourceIndex, setActiveSourceIndex] = useState(imageIndex);
const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const gestureRef = useRef<ActivePointerGesture | null>(null);
const activePosition = displayImages.findIndex(
(image) => image.sourceIndex === activeSourceIndex,
);
const currentImage = displayImages[activePosition];
const hasPrevious = activePosition > 0;
const hasNext =
activePosition >= 0 && activePosition < displayImages.length - 1;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key === "ArrowLeft" && hasPrevious) {
const previousImage = displayImages[activePosition - 1];
if (previousImage) {
setActiveSourceIndex(previousImage.sourceIndex);
onImageIndexChange(previousImage.sourceIndex);
}
}
if (event.key === "ArrowRight" && hasNext) {
const nextImage = displayImages[activePosition + 1];
if (nextImage) {
setActiveSourceIndex(nextImage.sourceIndex);
onImageIndexChange(nextImage.sourceIndex);
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [
activePosition,
displayImages,
hasNext,
hasPrevious,
onClose,
onImageIndexChange,
]);
if (!currentImage) return null;
const changeToPosition = (position: number) => {
const nextImage = displayImages[position];
if (!nextImage || position === activePosition) return;
setActiveSourceIndex(nextImage.sourceIndex);
setDragOffset(0);
setIsDragging(false);
onImageIndexChange(nextImage.sourceIndex);
};
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || displayImages.length <= 1) return;
const viewportWidth = event.currentTarget.clientWidth || window.innerWidth;
gestureRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startTime: event.timeStamp,
startPosition: activePosition,
viewportWidth,
};
event.currentTarget.setPointerCapture(event.pointerId);
setDragOffset(0);
setIsDragging(true);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
event.preventDefault();
const distance = event.clientX - gesture.startX;
setDragOffset(
getResistedGalleryDragDistance(
distance,
gesture.startPosition > 0,
gesture.startPosition < displayImages.length - 1,
),
);
};
const finishPointerGesture = (
event: ReactPointerEvent<HTMLDivElement>,
cancelled: boolean,
) => {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
gestureRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const distance = event.clientX - gesture.startX;
const direction = cancelled
? 0
: resolveGallerySwipeDirection({
distance,
durationMs: event.timeStamp - gesture.startTime,
viewportWidth: gesture.viewportWidth,
hasPrevious: gesture.startPosition > 0,
hasNext: gesture.startPosition < displayImages.length - 1,
});
setIsDragging(false);
setDragOffset(0);
if (direction !== 0) {
changeToPosition(gesture.startPosition + direction);
}
};
return (
<div
className={styles.galleryOverlay}
role="dialog"
aria-modal="true"
aria-label={album.title || "Private album gallery"}
>
<button
type="button"
className={styles.galleryClose}
onClick={onClose}
aria-label="Close private album gallery"
>
<X size={24} aria-hidden="true" />
</button>
<div
className={styles.galleryViewport}
data-testid="private-album-gallery-viewport"
data-dragging={isDragging ? "true" : "false"}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={(event) => finishPointerGesture(event, false)}
onPointerCancel={(event) => finishPointerGesture(event, true)}
onLostPointerCapture={(event) => finishPointerGesture(event, true)}
>
{displayImages.map((image, position) => {
if (Math.abs(position - activePosition) > 1) return null;
const slideStyle = {
"--gallery-slide-offset": `${(position - activePosition) * 100}%`,
"--gallery-drag-offset": `${dragOffset}px`,
} as GallerySlideStyle;
return (
<div
key={`${image.sourceIndex}:${image.url}`}
className={styles.gallerySlide}
style={slideStyle}
aria-hidden={position !== activePosition}
>
<Image
src={image.url}
alt={`${album.title || "Private album"} photo ${position + 1}`}
fill
loading="eager"
fetchPriority={position === activePosition ? "high" : "auto"}
draggable={false}
sizes="(max-width: 540px) 100vw, 540px"
className={styles.galleryImage}
/>
</div>
);
})}
</div>
<span className={styles.galleryCounter} aria-live="polite">
{activePosition + 1} / {displayImages.length}
</span>
{hasPrevious ? (
<button
type="button"
className={`${styles.galleryNav} ${styles.galleryPrevious}`}
onClick={() => changeToPosition(activePosition - 1)}
aria-label="Previous photo"
>
<ChevronLeft size={30} aria-hidden="true" />
</button>
) : null}
{hasNext ? (
<button
type="button"
className={`${styles.galleryNav} ${styles.galleryNext}`}
onClick={() => changeToPosition(activePosition + 1)}
aria-label="Next photo"
>
<ChevronRight size={30} aria-hidden="true" />
</button>
) : null}
</div>
);
}
@@ -0,0 +1,24 @@
import styles from "../private-zoom-screen.module.css";
export interface StatusCardProps {
message: string;
actionLabel?: string;
onAction?: () => void;
}
export function StatusCard({
message,
actionLabel,
onAction,
}: StatusCardProps) {
return (
<div className={styles.statusCard}>
<p>{message}</p>
{actionLabel && onAction ? (
<button type="button" onClick={onAction}>
{actionLabel}
</button>
) : null}
</div>
);
}
@@ -0,0 +1,53 @@
import type { PrivateAlbum } from "@/data/schemas/private-zoom";
import styles from "../private-zoom-screen.module.css";
export interface UnlockConfirmDialogProps {
album: PrivateAlbum;
isUnlocking: boolean;
errorMessage: string | null;
onCancel: () => void;
onConfirm: () => void;
}
export function UnlockConfirmDialog({
album,
isUnlocking,
errorMessage,
onCancel,
onConfirm,
}: UnlockConfirmDialogProps) {
return (
<div className={styles.dialogOverlay} role="alertdialog" aria-modal="true">
<div className={styles.dialog}>
<h2 className={styles.dialogTitle}>Unlock private album?</h2>
<p className={styles.dialogCopy}>
Unlock {album.imageCount} photos for {album.unlockCost} credits.
</p>
{errorMessage ? (
<p className={styles.dialogError}>{errorMessage}</p>
) : null}
<div className={styles.dialogActions}>
<button
type="button"
className={styles.dialogSecondary}
disabled={isUnlocking}
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
data-analytics-key="private_album.unlock_confirm"
data-analytics-label="Confirm private album unlock"
className={styles.dialogPrimary}
disabled={isUnlocking}
onClick={onConfirm}
>
{isUnlocking ? "Unlocking..." : "Unlock"}
</button>
</div>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
import {
appendRouteSearchParams,
getCharacterRoutes,
type RouteSearchParams,
} from "@/router/routes";
export default async function PrivateZoomPage({
searchParams,
}: {
searchParams: Promise<RouteSearchParams>;
}) {
redirect(
appendRouteSearchParams(
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).privateZoom,
await searchParams,
),
);
}
@@ -0,0 +1,54 @@
export const GALLERY_DISTANCE_RATIO = 0.18;
export const GALLERY_MIN_DISTANCE_PX = 56;
export const GALLERY_FAST_SWIPE_MIN_DISTANCE_PX = 24;
export const GALLERY_FAST_SWIPE_VELOCITY_PX_PER_MS = 0.45;
export const GALLERY_EDGE_RESISTANCE = 0.28;
export type GallerySwipeDirection = -1 | 0 | 1;
export interface ResolveGallerySwipeInput {
distance: number;
durationMs: number;
viewportWidth: number;
hasPrevious: boolean;
hasNext: boolean;
}
export function getResistedGalleryDragDistance(
distance: number,
hasPrevious: boolean,
hasNext: boolean,
): number {
if (distance > 0 && !hasPrevious) {
return distance * GALLERY_EDGE_RESISTANCE;
}
if (distance < 0 && !hasNext) {
return distance * GALLERY_EDGE_RESISTANCE;
}
return distance;
}
export function resolveGallerySwipeDirection({
distance,
durationMs,
viewportWidth,
hasPrevious,
hasNext,
}: ResolveGallerySwipeInput): GallerySwipeDirection {
if (distance === 0) return 0;
const absoluteDistance = Math.abs(distance);
const distanceThreshold = Math.max(
GALLERY_MIN_DISTANCE_PX,
Math.max(0, viewportWidth) * GALLERY_DISTANCE_RATIO,
);
const velocity = absoluteDistance / Math.max(1, durationMs);
const passedDistanceThreshold = absoluteDistance >= distanceThreshold;
const passedVelocityThreshold =
absoluteDistance >= GALLERY_FAST_SWIPE_MIN_DISTANCE_PX &&
velocity >= GALLERY_FAST_SWIPE_VELOCITY_PX_PER_MS;
if (!passedDistanceThreshold && !passedVelocityThreshold) return 0;
if (distance > 0) return hasPrevious ? -1 : 0;
return hasNext ? 1 : 0;
}
@@ -0,0 +1,36 @@
import { ROUTES } from "@/router/routes";
export const PRIVATE_ALBUM_QUERY_PARAM = "album";
export const PRIVATE_ALBUM_IMAGE_QUERY_PARAM = "image";
export function getPrivateAlbumGalleryState(input: {
get: (key: string) => string | null;
}): { albumId: string; imageIndex: number } | null {
const albumId = input.get(PRIVATE_ALBUM_QUERY_PARAM)?.trim();
const imageIndex = Number(input.get(PRIVATE_ALBUM_IMAGE_QUERY_PARAM) ?? "0");
if (!albumId || !Number.isInteger(imageIndex) || imageIndex < 0) return null;
return { albumId, imageIndex };
}
export function buildPrivateAlbumGalleryUrl(
albumId: string,
imageIndex: number,
basePath: string = ROUTES.privateZoom,
): string {
const params = new URLSearchParams({
[PRIVATE_ALBUM_QUERY_PARAM]: albumId,
[PRIVATE_ALBUM_IMAGE_QUERY_PARAM]: String(imageIndex),
});
return `${basePath}?${params.toString()}`;
}
export function buildPrivateZoomWithoutGalleryUrl(
input: { toString: () => string },
basePath: string = ROUTES.privateZoom,
): string {
const params = new URLSearchParams(input.toString());
params.delete(PRIVATE_ALBUM_QUERY_PARAM);
params.delete(PRIVATE_ALBUM_IMAGE_QUERY_PARAM);
const query = params.toString();
return query ? `${basePath}?${query}` : basePath;
}
@@ -0,0 +1,44 @@
import type { PrivateAlbum } from "@/data/schemas/private-zoom";
export const PRIVATE_ALBUM_GRID_LIMIT = 9;
export interface PrivateAlbumDisplayImage {
readonly sourceIndex: number;
readonly url: string;
}
export type PrivateAlbumGridLayout =
| "empty"
| "single"
| "double"
| "triple";
export function getPrivateAlbumDisplayImages(
album: PrivateAlbum,
): readonly PrivateAlbumDisplayImage[] {
return album.images.flatMap((image, sourceIndex) => {
const url = image.url.trim();
if (image.locked || url.length === 0) return [];
return [{ sourceIndex, url }];
});
}
export function findPrivateAlbumDisplayImage(
album: PrivateAlbum,
sourceIndex: number,
): PrivateAlbumDisplayImage | null {
return (
getPrivateAlbumDisplayImages(album).find(
(image) => image.sourceIndex === sourceIndex,
) ?? null
);
}
export function getPrivateAlbumGridLayout(
imageCount: number,
): PrivateAlbumGridLayout {
if (imageCount <= 0) return "empty";
if (imageCount === 1) return "single";
if (imageCount === 2 || imageCount === 4) return "double";
return "triple";
}
@@ -0,0 +1,810 @@
.shell {
position: relative;
min-height: var(--app-viewport-height, 100dvh);
padding:
0
0
calc(
var(--app-safe-bottom, 0px) + var(--app-bottom-nav-height, 64px) +
var(--page-section-gap, 18px)
)
0;
overflow: hidden auto;
background:
radial-gradient(circle at 12% 10%, rgba(255, 255, 255, 0.82), transparent 32%),
radial-gradient(circle at 90% 4%, rgba(210, 214, 211, 0.3), transparent 30%),
linear-gradient(180deg, #f3f4f2 0%, #f7f7f5 46%, #fbfbfa 100%);
color: #21171b;
}
.backgroundGlowOne,
.backgroundGlowTwo {
position: absolute;
z-index: 0;
pointer-events: none;
border-radius: 999px;
filter: blur(8px);
}
.backgroundGlowOne {
top: 118px;
right: -82px;
width: 170px;
height: 170px;
background: rgba(190, 195, 191, 0.18);
}
.backgroundGlowTwo {
bottom: 92px;
left: -92px;
width: 190px;
height: 190px;
background: rgba(222, 224, 220, 0.3);
}
.hero,
.postsSection {
position: relative;
z-index: 1;
}
.hero {
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
animation: riseIn 0.45s ease both;
}
.heroCover {
position: relative;
min-height: clamp(220px, 66vw, 350px);
overflow: hidden;
background: #f1eeee;
isolation: isolate;
}
.heroBanner {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.01);
animation: coverEaseIn 0.75s ease both;
}
.identity {
position: absolute;
bottom: 14px;
left: calc(var(--app-safe-left, 0px) + clamp(14px, 3.704vw, 20px));
z-index: 2;
display: flex;
width: fit-content;
max-width: calc(100% - var(--app-safe-left, 0px) - var(--app-safe-right, 0px) - clamp(28px, 7.407vw, 40px));
min-height: 42px;
align-items: center;
gap: 7px;
padding: 5px 10px 5px 5px;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 10px 24px rgba(39, 28, 32, 0.1);
backdrop-filter: blur(16px);
animation: riseIn 0.45s ease both;
animation-delay: 80ms;
}
.avatarFrame {
display: grid;
width: 32px;
height: 32px;
flex: 0 0 auto;
place-items: center;
border: 2px solid rgba(255, 255, 255, 0.92);
border-radius: 9999px;
background: linear-gradient(145deg, #ffb36d, #ff5d95);
box-shadow: 0 6px 14px rgba(131, 72, 85, 0.12);
overflow: hidden;
}
.identityName {
min-width: 0;
margin: 0;
overflow: hidden;
color: #33282b;
font-size: clamp(11px, 2.963vw, 14px);
font-weight: 760;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.primaryCta {
display: inline-flex;
min-height: 48px;
align-items: center;
justify-content: center;
gap: 9px;
border: 0;
border-radius: 999px;
cursor: pointer;
font: inherit;
font-weight: 850;
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
}
.primaryCta {
width: calc(100% - var(--app-safe-left, 0px) - var(--app-safe-right, 0px) - clamp(36px, 10.37vw, 56px));
align-self: center;
margin: 0 auto;
min-height: 50px;
padding: 0 clamp(20px, 5.185vw, 28px);
border: 1px solid rgba(255, 255, 255, 0.48);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.16), transparent 38%),
linear-gradient(90deg, #ff67e0, var(--color-accent, #f84d96));
color: #ffffff;
font-size: clamp(14px, 3.333vw, 16px);
line-height: 1.2;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.24),
0 14px 30px rgba(248, 77, 150, 0.26);
}
.primaryCta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.postsSection {
margin-top: clamp(20px, 5.185vw, 28px);
padding:
0
calc(var(--app-safe-right, 0px) + clamp(18px, 5.185vw, 28px))
0
calc(var(--app-safe-left, 0px) + clamp(18px, 5.185vw, 28px));
}
.sectionHeader {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 14px;
margin-bottom: 14px;
}
.kicker {
margin: 0;
color: #ff5d95;
font-size: clamp(11px, 2.593vw, 13px);
font-weight: 900;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.sectionTitle {
margin: 4px 0 0;
color: #25191d;
font-size: clamp(21px, 5.185vw, 28px);
font-weight: 900;
letter-spacing: -0.03em;
}
.postCount {
display: inline-flex;
min-width: 42px;
height: 34px;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 116, 159, 0.18);
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
color: #a94c64;
font-size: 14px;
font-weight: 880;
box-shadow: 0 8px 18px rgba(131, 72, 85, 0.08);
}
.timeline {
display: flex;
flex-direction: column;
gap: 14px;
}
.postCard,
.statusCard {
border: 1px solid rgba(44, 29, 34, 0.07);
border-radius: clamp(22px, 5.926vw, 32px);
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 16px 42px rgba(131, 72, 85, 0.1);
backdrop-filter: blur(18px);
}
.postCard {
padding: clamp(14px, 3.704vw, 20px);
animation: riseIn 0.42s ease both;
animation-delay: calc(var(--reveal-index, 0) * 90ms + 80ms);
}
.postHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.postAuthor {
display: flex;
min-width: 0;
align-items: center;
gap: 11px;
}
.postAvatar {
flex: 0 0 auto;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: 9999px;
box-shadow: 0 8px 18px rgba(131, 72, 85, 0.12);
}
.authorName,
.authorSubline,
.postText {
margin: 0;
}
.authorName {
color: #26191d;
font-size: clamp(14px, 3.333vw, 17px);
font-weight: 820;
line-height: 1.15;
}
.authorSubline {
margin-top: 3px;
color: #a58b92;
font-size: clamp(11px, 2.778vw, 13px);
font-weight: 680;
}
.postTime {
flex: 0 0 auto;
color: #7b666d;
font-size: clamp(12px, 2.963vw, 15px);
font-weight: 760;
}
.postText {
margin-top: 14px;
color: #4c3a40;
font-size: clamp(14px, 3.333vw, 17px);
font-weight: 650;
line-height: 1.55;
}
.lockedPreview {
position: relative;
width: 100%;
aspect-ratio: 4 / 5;
margin-top: 14px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: clamp(20px, 5.185vw, 26px);
background:
radial-gradient(circle at 72% 18%, rgba(184, 141, 113, 0.72), transparent 42%),
radial-gradient(circle at 18% 32%, rgba(79, 78, 105, 0.78), transparent 48%),
linear-gradient(160deg, #615d6d 0%, #6c5b50 48%, #c5c0b6 100%);
color: #ffffff;
isolation: isolate;
overflow: hidden;
text-align: center;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 16px 34px rgba(34, 25, 29, 0.18);
}
.lockedPreviewBackdrop {
position: absolute;
inset: 0;
z-index: 0;
}
.lockedCoverImage {
object-fit: cover;
object-position: center;
filter: blur(13px) saturate(0.8);
transform: scale(1.05);
}
.lockedPreviewScrim {
position: absolute;
inset: 0;
z-index: 1;
background:
linear-gradient(180deg, rgba(23, 21, 25, 0.12), rgba(28, 25, 27, 0.18) 48%, rgba(36, 31, 30, 0.3)),
rgba(32, 29, 34, 0.14);
}
.lockedPreviewPanel {
position: absolute;
top: 40%;
right: clamp(8px, 2.593vw, 14px);
left: clamp(8px, 2.593vw, 14px);
z-index: 2;
min-height: clamp(190px, 48vw, 226px);
padding: clamp(48px, 12.222vw, 60px) clamp(18px, 4.63vw, 25px)
clamp(20px, 5.185vw, 28px);
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: clamp(18px, 4.815vw, 25px);
background: rgba(32, 30, 35, 0.62);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 14px 32px rgba(19, 16, 18, 0.2);
backdrop-filter: blur(14px);
}
.lockedPreviewContent {
display: flex;
width: min(100%, 300px);
margin-inline: auto;
flex-direction: column;
align-items: center;
gap: 8px;
}
.lockedCollectionIcon {
position: absolute;
top: 14px;
left: 14px;
z-index: 3;
display: grid;
width: 28px;
height: 28px;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.36);
border-radius: 7px;
background: rgba(24, 23, 28, 0.52);
color: #ffffff;
box-shadow: 0 5px 14px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
}
.lockedPreviewAvatarFrame {
position: absolute;
top: clamp(-36px, -8.148vw, -31px);
left: 50%;
z-index: 4;
width: clamp(62px, 17.037vw, 72px);
height: clamp(62px, 17.037vw, 72px);
border: 3px solid rgba(255, 255, 255, 0.94);
border-radius: 999px;
background: #ffffff;
box-shadow: 0 12px 28px rgba(29, 21, 25, 0.28);
transform: translateX(-50%);
}
.lockedPreviewAvatar {
border-radius: 999px;
}
.lockedAvatarBadge {
position: absolute;
right: -3px;
bottom: 0;
display: grid;
width: clamp(24px, 6.296vw, 28px);
height: clamp(24px, 6.296vw, 28px);
place-items: center;
border: 2px solid rgba(255, 255, 255, 0.94);
border-radius: 999px;
background: #ffffff;
color: #29262d;
box-shadow: 0 6px 14px rgba(22, 17, 19, 0.2);
}
.lockedPreviewTitle {
color: #ffffff;
font-size: clamp(19px, 4.815vw, 26px);
font-weight: 850;
line-height: 1.15;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.28);
}
.lockedImageCount {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
color: rgba(255, 255, 255, 0.92);
font-size: clamp(14px, 3.333vw, 17px);
font-weight: 620;
line-height: 1.2;
}
.lockedPreviewCta {
min-width: min(100%, 178px);
min-height: 46px;
margin-top: clamp(16px, 4.074vw, 22px);
padding: 0 24px;
border: 0;
border-radius: 999px;
background: #ffffff;
color: #28252a;
cursor: pointer;
font: inherit;
font-size: clamp(14px, 3.519vw, 17px);
font-weight: 850;
box-shadow: 0 10px 24px rgba(18, 14, 16, 0.22);
transition: transform 0.16s ease, box-shadow 0.16s ease,
background 0.16s ease;
}
.lockedPreviewCta:disabled {
cursor: wait;
opacity: 0.72;
}
.mediaGrid {
display: grid;
gap: 4px;
margin-top: 14px;
}
.mediaGridSingle {
width: min(72%, 280px);
grid-template-columns: minmax(0, 1fr);
}
.mediaGridDouble {
width: min(72%, 320px);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mediaGridTriple {
width: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.mediaGridItem {
position: relative;
display: block;
width: 100%;
aspect-ratio: 1;
padding: 0;
border: 0;
overflow: hidden;
border-radius: 6px;
background: #f3eef0;
cursor: pointer;
font: inherit;
transition: filter 0.16s ease, transform 0.16s ease;
}
.mediaGridSingle .mediaGridItem {
aspect-ratio: 4 / 5;
}
.mediaGridImage {
object-fit: cover;
user-select: none;
}
.mediaGridOverflow {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
background: rgba(24, 18, 21, 0.56);
color: #ffffff;
font-size: clamp(22px, 6.296vw, 34px);
font-weight: 760;
letter-spacing: -0.02em;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.34);
}
.emptyMediaCover {
display: grid;
width: 100%;
aspect-ratio: 4 / 5;
place-items: center;
align-content: center;
gap: 10px;
margin-top: 14px;
border: 1px solid rgba(44, 29, 34, 0.08);
border-radius: clamp(20px, 5.185vw, 28px);
background: linear-gradient(145deg, #f7f2ef, #efe7e9);
color: #8d747c;
font-size: 14px;
font-weight: 760;
}
.statusCard {
display: grid;
gap: 12px;
margin-bottom: 14px;
padding: 18px;
color: #755f66;
font-size: 15px;
font-weight: 700;
text-align: center;
}
.statusCard p {
margin: 0;
}
.statusCard button {
padding: 0 18px;
background: rgba(255, 93, 149, 0.12);
color: #a94c64;
}
.galleryOverlay {
position: fixed;
inset: 0;
z-index: 80;
width: min(100vw, var(--app-max-width, 540px));
height: var(--app-viewport-height, 100dvh);
margin-inline: auto;
overflow: hidden;
background: #090909;
color: #ffffff;
overscroll-behavior: contain;
}
.galleryViewport {
position: absolute;
inset: 0;
overflow: hidden;
cursor: grab;
touch-action: pan-y;
user-select: none;
}
.galleryViewport[data-dragging="true"] {
cursor: grabbing;
}
.gallerySlide {
position: absolute;
inset: 0;
pointer-events: none;
transform: translate3d(
calc(var(--gallery-slide-offset, 0%) + var(--gallery-drag-offset, 0px)),
0,
0
);
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
.galleryViewport[data-dragging="true"] .gallerySlide {
transition: none;
}
.galleryImage {
object-fit: contain;
user-select: none;
}
.galleryClose,
.galleryNav {
position: absolute;
z-index: 2;
display: grid;
place-items: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 999px;
background: rgba(16, 16, 16, 0.58);
color: #ffffff;
cursor: pointer;
backdrop-filter: blur(12px);
transition: background 0.18s ease, transform 0.18s ease;
}
.galleryClose {
top: calc(var(--app-safe-top, 0px) + 14px);
right: calc(var(--app-safe-right, 0px) + 14px);
width: 44px;
height: 44px;
}
.galleryNav {
top: 50%;
width: 46px;
height: 46px;
transform: translateY(-50%);
}
.galleryPrevious {
left: calc(var(--app-safe-left, 0px) + 10px);
}
.galleryNext {
right: calc(var(--app-safe-right, 0px) + 10px);
}
.galleryCounter {
position: absolute;
bottom: calc(var(--app-safe-bottom, 0px) + 18px);
left: 50%;
z-index: 2;
min-width: 62px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(16, 16, 16, 0.62);
font-size: 13px;
font-weight: 800;
text-align: center;
transform: translateX(-50%);
backdrop-filter: blur(12px);
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 24px;
background: rgba(34, 23, 28, 0.36);
backdrop-filter: blur(10px);
}
.dialog {
width: min(100%, 360px);
padding: 22px;
border-radius: 26px;
background: #ffffff;
box-shadow: 0 24px 70px rgba(34, 23, 28, 0.22);
}
.dialogTitle {
margin: 0;
color: #21171b;
font-size: 22px;
font-weight: 900;
}
.dialogCopy,
.dialogError {
margin: 10px 0 0;
color: #755f66;
font-size: 15px;
font-weight: 650;
line-height: 1.45;
}
.dialogError {
color: #c63f67;
}
.dialogActions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 20px;
}
.dialogPrimary,
.dialogSecondary {
min-height: 46px;
border: 0;
border-radius: 999px;
cursor: pointer;
font: inherit;
font-weight: 850;
}
.dialogPrimary {
background: linear-gradient(135deg, #ff7aa9, #ffb36d);
color: #ffffff;
}
.dialogSecondary {
background: rgba(44, 29, 34, 0.08);
color: #5f4b52;
}
.toast {
position: fixed;
right: max(calc((100vw - var(--app-max-width, 540px)) / 2 + 18px), 18px);
bottom: calc(
var(--app-safe-bottom, 0px) + var(--app-bottom-nav-height, 64px) + 12px
);
left: max(calc((100vw - var(--app-max-width, 540px)) / 2 + 18px), 18px);
z-index: 45;
padding: 14px 16px;
border-radius: 18px;
background: rgba(33, 23, 27, 0.88);
color: #ffffff;
font-size: 14px;
font-weight: 750;
text-align: center;
}
.primaryCta:hover {
filter: brightness(1.04);
transform: translateY(-1px);
}
.lockedPreviewCta:hover:not(:disabled) {
background: #f7f5f6;
box-shadow: 0 12px 28px rgba(18, 14, 16, 0.28);
transform: translateY(-1px);
}
.mediaGridItem:hover {
filter: brightness(1.02);
}
.galleryClose:hover,
.galleryNav:hover {
background: rgba(38, 38, 38, 0.82);
}
.primaryCta:active {
transform: translateY(1px) scale(0.99);
}
.lockedPreviewCta:active:not(:disabled) {
transform: translateY(1px) scale(0.99);
}
.mediaGridItem:active {
transform: scale(0.995);
}
.galleryClose:active {
transform: scale(0.94);
}
.galleryNav:active {
transform: translateY(-50%) scale(0.94);
}
.primaryCta:focus-visible,
.lockedPreviewCta:focus-visible,
.mediaGridItem:focus-visible,
.galleryClose:focus-visible,
.galleryNav:focus-visible,
.dialogPrimary:focus-visible,
.dialogSecondary:focus-visible {
outline: 2px solid #ff5d95;
outline-offset: 3px;
}
@keyframes riseIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes coverEaseIn {
from {
opacity: 0.82;
transform: scale(1.05);
}
to {
opacity: 1;
transform: scale(1.01);
}
}
@media (prefers-reduced-motion: reduce) {
.gallerySlide {
transition: none;
}
}
@@ -0,0 +1,316 @@
"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, MobileShell } from "@/app/_components/core";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { isPrivateAlbumLocked } from "@/lib/private-zoom/private_album";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import {
usePrivateZoomDispatch,
usePrivateZoomState,
} from "@/stores/private-zoom";
import {
PrivateAlbumCard,
PrivateAlbumGallery,
StatusCard,
UnlockConfirmDialog,
} from "./components";
import styles from "./private-zoom-screen.module.css";
import {
isPrivateZoomAuthRequired,
usePrivateZoomBootstrapFlow,
usePrivateZoomUnlockPaywallNavigation,
usePrivateZoomUnlockSuccessRefresh,
} from "./use-private-zoom-flow";
import {
buildPrivateAlbumGalleryUrl,
buildPrivateZoomWithoutGalleryUrl,
getPrivateAlbumGalleryState,
} from "./private-album-gallery-url";
import { findPrivateAlbumDisplayImage } from "./private-album-images";
export function PrivateZoomScreen() {
const router = useRouter();
const searchParams = useSearchParams();
const navigator = useAppNavigator();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
const authState = useAuthState();
const authDispatch = useAuthDispatch();
const state = usePrivateZoomState();
const dispatch = usePrivateZoomDispatch();
const openedGalleryInPageRef = useRef(false);
const galleryState = useMemo(
() => getPrivateAlbumGalleryState(searchParams),
[searchParams],
);
usePrivateZoomBootstrapFlow({
authDispatch,
hasInitialized: authState.hasInitialized,
isAuthLoading: authState.isLoading,
loginStatus: authState.loginStatus,
roomDispatch: dispatch,
roomStatus: state.status,
});
usePrivateZoomUnlockPaywallNavigation({
loginStatus: authState.loginStatus,
roomDispatch: dispatch,
unlockPaywallRequest: state.unlockPaywallRequest,
});
usePrivateZoomUnlockSuccessRefresh(state.unlockSuccessNonce);
const displayName = character.displayName;
const avatarUrl = character.assets.avatar;
const title = character.copy.privateZoomTitle;
const subtitle = character.copy.privateZoomSubtitle;
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(
buildPrivateZoomWithoutGalleryUrl(
searchParams,
characterRoutes.privateZoom,
),
{ scroll: false },
);
}
}, [
characterRoutes.privateZoom,
galleryAlbum,
galleryImage,
galleryState,
router,
searchParams,
state.isLoading,
]);
const handleTopUpClick = () => {
if (isPrivateZoomAuthRequired(authState.loginStatus)) {
navigator.openAuth(characterRoutes.privateZoom);
return;
}
navigator.openSubscription({
type: "topup",
returnTo: "private-zoom",
analytics: {
entryPoint: "private_zoom",
triggerReason: "vip_cta",
},
});
};
const handleOpenGallery = (albumId: string, imageIndex: number) => {
openedGalleryInPageRef.current = true;
router.push(
buildPrivateAlbumGalleryUrl(
albumId,
imageIndex,
characterRoutes.privateZoom,
),
{ scroll: false },
);
};
const handleCloseGallery = () => {
if (openedGalleryInPageRef.current) {
openedGalleryInPageRef.current = false;
router.back();
return;
}
router.replace(
buildPrivateZoomWithoutGalleryUrl(
searchParams,
characterRoutes.privateZoom,
),
{ scroll: false },
);
};
const handleGalleryIndexChange = (imageIndex: number) => {
if (!galleryAlbum) return;
router.replace(
buildPrivateAlbumGalleryUrl(
galleryAlbum.albumId,
imageIndex,
characterRoutes.privateZoom,
),
{ scroll: false },
);
};
return (
<MobileShell background="#f3f4f2">
<main className={styles.shell}>
<div className={styles.backgroundGlowOne} />
<div className={styles.backgroundGlowTwo} />
<section className={styles.hero} aria-label={title}>
<div className={styles.heroCover}>
<Image
src={character.assets.privateZoomBanner}
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_zoom.primary_cta"
data-analytics-label="Open private zoom 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: "PrivateZoomRefresh" })}
/>
) : 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: "PrivateZoomRefresh" })}
/>
) : 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: "PrivateZoomUnlockRequested",
albumId: album.albumId,
})
}
/>
))}
</div>
</section>
<AppBottomNav
activeItem="privateZoom"
privateZoomLabel={character.copy.privateZoomTitle}
onChatClick={() =>
navigator.push(characterRoutes.splash, { scroll: false })
}
onPrivateZoomClick={() => undefined}
/>
{pendingAlbum ? (
<UnlockConfirmDialog
album={pendingAlbum}
isUnlocking={state.isUnlocking}
errorMessage={state.unlockErrorMessage}
onCancel={() => dispatch({ type: "PrivateZoomUnlockCancelled" })}
onConfirm={() => dispatch({ type: "PrivateZoomUnlockConfirmed" })}
/>
) : 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>
);
}
@@ -0,0 +1,123 @@
"use client";
import { type Dispatch, useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/schemas/auth";
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
import { behaviorAnalytics } from "@/lib/analytics";
import { useActiveCharacterRoutes } from "@/providers/character-provider";
import { useAppNavigator } from "@/router/use-app-navigator";
import type { AuthEvent } from "@/stores/auth/auth-events";
import type { PrivateZoomEvent } from "@/stores/private-zoom";
import type { PrivateZoomUnlockPaywallRequest } from "@/stores/private-zoom/private-zoom-state";
import { useUserDispatch } from "@/stores/user/user-context";
export interface UsePrivateZoomBootstrapFlowInput {
authDispatch: Dispatch<AuthEvent>;
hasInitialized: boolean;
isAuthLoading: boolean;
loginStatus: LoginStatus;
roomDispatch: Dispatch<PrivateZoomEvent>;
roomStatus: string;
}
export interface UsePrivateZoomUnlockPaywallNavigationInput {
loginStatus: LoginStatus;
roomDispatch: Dispatch<PrivateZoomEvent>;
unlockPaywallRequest: PrivateZoomUnlockPaywallRequest | null;
}
export function isPrivateZoomAuthRequired(loginStatus: LoginStatus): boolean {
return loginStatus === "guest" || loginStatus === "notLoggedIn";
}
export function usePrivateZoomBootstrapFlow({
authDispatch,
hasInitialized,
isAuthLoading,
loginStatus,
roomDispatch,
roomStatus,
}: UsePrivateZoomBootstrapFlowInput): 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: "PrivateZoomInit" });
return;
}
if (previousLoginStatus !== loginStatus && roomStatus !== "loading") {
roomDispatch({ type: "PrivateZoomRefresh" });
}
}, [
hasInitialized,
isAuthLoading,
loginStatus,
roomDispatch,
roomStatus,
]);
}
export function usePrivateZoomUnlockPaywallNavigation({
loginStatus,
roomDispatch,
unlockPaywallRequest,
}: UsePrivateZoomUnlockPaywallNavigationInput): void {
const navigator = useAppNavigator();
const characterRoutes = useActiveCharacterRoutes();
useEffect(() => {
if (!unlockPaywallRequest) return;
if (isPrivateZoomAuthRequired(loginStatus)) {
navigator.openAuth(characterRoutes.privateZoom);
} else {
behaviorAnalytics.paywallShown({
entryPoint: "private_album_unlock",
triggerReason: "insufficient_credits",
});
navigator.openSubscription({
type: "topup",
returnTo: "private-zoom",
analytics: {
entryPoint: "private_album_unlock",
triggerReason: "insufficient_credits",
},
});
}
roomDispatch({ type: "PrivateZoomUnlockPaywallConsumed" });
}, [
characterRoutes.privateZoom,
loginStatus,
navigator,
roomDispatch,
unlockPaywallRequest,
]);
}
export function usePrivateZoomUnlockSuccessRefresh(
unlockSuccessNonce: number,
): void {
const userDispatch = useUserDispatch();
const lastUnlockSuccessNonceRef = useRef(0);
useEffect(() => {
if (unlockSuccessNonce <= lastUnlockSuccessNonceRef.current) return;
lastUnlockSuccessNonceRef.current = unlockSuccessNonce;
userDispatch({ type: "UserFetch" });
}, [unlockSuccessNonce, userDispatch]);
}