diff --git a/docs/backend/FRONTEND_PRIVATE_ROOM_API.md b/docs/backend/FRONTEND_PRIVATE_ROOM_API.md index 2c709ad2..4f27cc7c 100644 --- a/docs/backend/FRONTEND_PRIVATE_ROOM_API.md +++ b/docs/backend/FRONTEND_PRIVATE_ROOM_API.md @@ -216,7 +216,7 @@ Gallery 只浏览 `locked=false` 且 URL 非空的图片,但 URL 中的 `image - React key 使用稳定 `albumId`; - 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length`; -- 锁定相册继续只显示模糊封面和解锁入口; +- 锁定相册显示模糊封面、角色头像、锁标识、图片数量和 `View collection` 入口;卡片不展示视频数量或积分价格,解锁价格只在确认 Dialog 中展示; - 解锁相册过滤锁定或空 URL 图片,并保留剩余图片的原始数组索引; - 1 张图片显示 4:5 大图,2/4 张使用两列,其余使用三列正方形九宫格; - 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片; diff --git a/src/app/private-room/components/__tests__/private-album-card.interaction.test.tsx b/src/app/private-room/components/__tests__/private-album-card.interaction.test.tsx index e64df07c..112ea01d 100644 --- a/src/app/private-room/components/__tests__/private-album-card.interaction.test.tsx +++ b/src/app/private-room/components/__tests__/private-album-card.interaction.test.tsx @@ -64,4 +64,68 @@ describe("PrivateAlbumCard interactions", () => { 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-room/locked.png", locked: true, index: 0 }, + ], + locked: true, + unlocked: false, + unlockCost: 40, + lockDetail: { locked: true }, + }); + + renderLockedCard(album, false, onUnlock); + const avatar = container.querySelector( + '[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, + isUnlocking: boolean, + onUnlock: () => void, + ): void { + act(() => { + root.render( + 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; + } }); diff --git a/src/app/private-room/components/__tests__/private-album-card.test.tsx b/src/app/private-room/components/__tests__/private-album-card.test.tsx index 395e2f3b..718af57b 100644 --- a/src/app/private-room/components/__tests__/private-album-card.test.tsx +++ b/src/app/private-room/components/__tests__/private-album-card.test.tsx @@ -101,7 +101,7 @@ describe("PrivateAlbumCard", () => { expect(html).not.toContain('data-image-index="9"'); }); - it("uses the backend first image as the locked cover", () => { + it("renders the reference-style locked collection over the first image", () => { const html = renderCard( makeAlbum({ content: null, @@ -114,13 +114,39 @@ describe("PrivateAlbumCard", () => { expect(html).toContain("elio.png"); expect(html).toContain( - 'aria-label="Unlock 8 private room photos from Elio Silvestri"', + 'aria-label="Elio Silvestri locked collection"', ); - expect(html).toContain("Only for you."); - expect(html).toContain("320 credits"); + 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(/ { const html = renderCard(makeAlbum()); diff --git a/src/app/private-room/components/private-album-card.tsx b/src/app/private-room/components/private-album-card.tsx index 5541170b..00dc39b0 100644 --- a/src/app/private-room/components/private-album-card.tsx +++ b/src/app/private-room/components/private-album-card.tsx @@ -1,6 +1,6 @@ import type { CSSProperties } from "react"; import Image from "next/image"; -import { ImageIcon, LockKeyhole } from "lucide-react"; +import { Copy, ImageIcon, LockKeyhole } from "lucide-react"; import { CharacterAvatar } from "@/app/_components"; import type { PrivateAlbum } from "@/data/schemas/private-room"; @@ -78,39 +78,59 @@ export function PrivateAlbumCard({ {isLocked ? ( - + + +
+ + +
+ +
+ Unlock to view + + + +
+ ) : previewImages.length > 0 ? (