diff --git a/src/app/private-room/components/__tests__/private-room-post-card.test.tsx b/src/app/private-room/components/__tests__/private-room-post-card.test.tsx new file mode 100644 index 00000000..7e2e0aed --- /dev/null +++ b/src/app/private-room/components/__tests__/private-room-post-card.test.tsx @@ -0,0 +1,106 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { PrivateRoomMoment } from "@/data/dto/private-room"; + +import { PrivateRoomPostCard } from "../private-room-post-card"; + +function makeMoment( + overrides: Partial[0]> = {}, +): PrivateRoomMoment { + return PrivateRoomMoment.from({ + momentId: "schedule:91", + author: { + id: "elio", + name: "Elio Silvestri", + avatarUrl: null, + }, + publishedAt: "2026-07-01T08:11:35+00:00", + timeText: "7 days ago", + title: "Private morning", + content: "A quiet morning by the water.", + text: "A quiet morning by the water.", + textPreview: "Unlock to view private room photos", + mediaCount: 1, + images: [], + locked: false, + unlockCost: 40, + ...overrides, + }); +} + +function renderCard(moment: PrivateRoomMoment): string { + return renderToStaticMarkup( + undefined} + />, + ); +} + +describe("PrivateRoomPostCard", () => { + it("renders only the first unlocked image and a multi-photo badge", () => { + const html = renderCard( + makeMoment({ + mediaCount: 3, + images: [ + { + url: "/images/private-room/banner.png", + type: "image", + locked: false, + index: 0, + }, + { + url: "/images/splash/pic-bg-home.png", + type: "image", + locked: false, + index: 1, + }, + ], + }), + ); + + expect(html).toContain("banner.png"); + expect(html).not.toContain("pic-bg-home.png"); + expect(html).toContain("3 photos"); + expect(html).toContain("A quiet morning by the water."); + }); + + it("renders a locked cover without attempting to render a missing URL", () => { + const html = renderCard( + makeMoment({ + locked: true, + mediaCount: 2, + content: null, + text: null, + images: [ + { + url: null, + type: "image", + locked: true, + index: 0, + }, + ], + }), + ); + + expect(html).toContain( + 'aria-label="Unlock 2 private room photos from Elio Silvestri"', + ); + expect(html).toContain("Unlock to view private room photos"); + expect(html).toContain("40 credits"); + expect(html).toContain("2 photos"); + expect(html).not.toContain("No photo available"); + }); + + it("renders an empty cover when an unlocked moment has no first image", () => { + const html = renderCard(makeMoment()); + + expect(html).toContain('aria-label="No private moment photo available"'); + expect(html).toContain("No photo available"); + }); +}); diff --git a/src/app/private-room/components/private-room-post-card.tsx b/src/app/private-room/components/private-room-post-card.tsx index ade300c4..50fdddaf 100644 --- a/src/app/private-room/components/private-room-post-card.tsx +++ b/src/app/private-room/components/private-room-post-card.tsx @@ -23,11 +23,12 @@ export function PrivateRoomPostCard({ isUnlocking, onUnlock, }: PrivateRoomPostCardProps) { - const isLocked = moment.locked || moment.lockDetail.locked; - const imageUrls = moment.images - .filter((image) => !image.locked && image.url) - .map((image) => image.url as string); + const isLocked = moment.locked; + const firstImage = moment.images[0]; + const firstImageUrl = + !isLocked && firstImage?.locked !== true ? firstImage?.url : null; const photoCount = moment.mediaCount || moment.images.length || 1; + const postText = moment.text || moment.content; return (
- {moment.text || moment.content ? ( -

{moment.text || moment.content}

- ) : null} - {isLocked ? ( + ) : firstImageUrl ? ( +
+ {`${moment.title + {photoCount > 1 ? ( + + + ) : null} +
) : ( -
- {imageUrls.map((url, imageIndex) => ( - - ))} +
+
)} + + {postText ?

{postText}

: null}
); } diff --git a/src/app/private-room/private-room-screen.module.css b/src/app/private-room/private-room-screen.module.css index 3f3d0ba7..8e56625a 100644 --- a/src/app/private-room/private-room-screen.module.css +++ b/src/app/private-room/private-room-screen.module.css @@ -134,27 +134,21 @@ } .primaryCta { - width: fit-content; - max-width: calc(100% - var(--app-safe-left, 0px) - var(--app-safe-right, 0px) - clamp(36px, 10.37vw, 56px)); - align-self: flex-start; - margin: - 0 - calc(var(--app-safe-right, 0px) + clamp(18px, 5.185vw, 28px)) - 0 - calc(var(--app-safe-left, 0px) + clamp(18px, 5.185vw, 28px)); - min-height: 36px; - padding: 0 18px; - border: 1px solid rgba(255, 255, 255, 0.62); + 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(129, 125, 119, 0.94), rgba(104, 101, 96, 0.92)), - rgba(116, 112, 106, 0.9); + linear-gradient(135deg, rgba(255, 255, 255, 0.16), transparent 38%), + linear-gradient(90deg, #ff67e0, var(--color-accent, #f84d96)); color: #ffffff; - font-size: clamp(11px, 2.963vw, 14px); + font-size: clamp(14px, 3.333vw, 16px); line-height: 1.2; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.22), - 0 10px 22px rgba(47, 35, 40, 0.12); - backdrop-filter: blur(14px); + inset 0 1px 0 rgba(255, 255, 255, 0.24), + 0 14px 30px rgba(248, 77, 150, 0.26); } .primaryCta span { @@ -291,23 +285,29 @@ } .lockedPreview { - display: grid; + position: relative; + display: flex; width: 100%; - grid-template-columns: auto 1fr; + aspect-ratio: 4 / 5; align-items: center; - gap: 14px; + justify-content: center; margin-top: 14px; padding: clamp(16px, 4.074vw, 22px); border: 1px solid rgba(255, 116, 159, 0.24); border-radius: clamp(20px, 5.185vw, 28px); background: - linear-gradient(135deg, rgba(255, 246, 239, 0.98), rgba(255, 231, 240, 0.9)), + radial-gradient(circle at 22% 18%, rgba(255, 255, 255, 0.94), transparent 29%), + radial-gradient(circle at 82% 82%, rgba(255, 103, 224, 0.18), transparent 35%), + linear-gradient(145deg, rgba(255, 246, 239, 0.98), rgba(255, 225, 239, 0.94)), #ffffff; color: #21171b; cursor: pointer; font: inherit; - text-align: left; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 12px 28px rgba(255, 116, 159, 0.12); + overflow: hidden; + text-align: center; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.82), + 0 14px 32px rgba(255, 116, 159, 0.14); transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; } @@ -316,22 +316,35 @@ opacity: 0.72; } +.lockedPreviewContent { + position: relative; + z-index: 1; + display: flex; + max-width: 260px; + flex-direction: column; + align-items: center; + gap: 14px; +} + .previewIcon { display: grid; - width: 46px; - height: 46px; + width: 58px; + height: 58px; place-items: center; - border-radius: 17px; - background: linear-gradient(135deg, #ff7aa9, #ffb36d); + border: 1px solid rgba(255, 255, 255, 0.68); + border-radius: 20px; + background: linear-gradient(135deg, #ff7ac3, var(--color-accent, #f84d96)); color: #ffffff; - box-shadow: 0 12px 24px rgba(255, 104, 146, 0.24); + box-shadow: 0 14px 28px rgba(248, 77, 150, 0.26); } .previewText { display: flex; min-width: 0; flex-direction: column; - gap: 4px; + align-items: center; + gap: 8px; + text-align: center; } .previewText strong { @@ -350,26 +363,63 @@ .previewText small { display: inline-flex; align-items: center; + justify-content: center; gap: 5px; color: #a94c64; font-size: clamp(12px, 2.963vw, 15px); font-weight: 760; } -.imageGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); - gap: 10px; +.mediaCover { + position: relative; margin-top: 14px; + overflow: hidden; + border-radius: clamp(20px, 5.185vw, 28px); + background: #f3eef0; + box-shadow: 0 14px 32px rgba(131, 72, 85, 0.14); } -.unlockedImage { +.momentCoverImage { + display: block; width: 100%; height: auto; - aspect-ratio: 1; - border-radius: 22px; + aspect-ratio: 4 / 5; object-fit: cover; - box-shadow: 0 14px 32px rgba(131, 72, 85, 0.14); +} + +.mediaCountBadge { + position: absolute; + top: 12px; + right: 12px; + display: inline-flex; + min-height: 30px; + align-items: center; + gap: 6px; + padding: 0 11px; + border: 1px solid rgba(255, 255, 255, 0.5); + border-radius: 999px; + background: rgba(28, 19, 23, 0.72); + color: #ffffff; + font-size: 12px; + font-weight: 800; + box-shadow: 0 8px 18px rgba(28, 19, 23, 0.18); + backdrop-filter: blur(12px); +} + +.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 { diff --git a/src/data/dto/private-room/private_room_moment.ts b/src/data/dto/private-room/private_room_moment.ts index 9baf1843..1e39fdf5 100644 --- a/src/data/dto/private-room/private_room_moment.ts +++ b/src/data/dto/private-room/private_room_moment.ts @@ -6,11 +6,7 @@ import { export class PrivateRoomMoment { declare readonly momentId: string; - declare readonly source: string; - declare readonly sourceId: string; - declare readonly characterId: string; declare readonly author: PrivateRoomMomentData["author"]; - declare readonly createdAt: string | null; declare readonly publishedAt: string | null; declare readonly timeText: string; declare readonly title: string; @@ -20,12 +16,7 @@ export class PrivateRoomMoment { declare readonly mediaCount: number; declare readonly images: PrivateRoomMomentData["images"]; declare readonly locked: boolean; - declare readonly unlocked: boolean; declare readonly unlockCost: number; - declare readonly unlockCostPerImage: number; - declare readonly requiredCredits: number; - declare readonly currency: string; - declare readonly lockDetail: PrivateRoomMomentData["lockDetail"]; protected constructor(input: PrivateRoomMomentInput, freeze = true) { const data = PrivateRoomMomentSchema.parse(input); @@ -47,5 +38,3 @@ export class PrivateRoomMoment { } export type PrivateRoomProfile = import("@/data/schemas/private-room").PrivateRoomProfileData; -export type PrivateRoomImage = import("@/data/schemas/private-room").PrivateRoomImageData; -export type PrivateRoomLockDetail = import("@/data/schemas/private-room").PrivateRoomLockDetailData; diff --git a/src/data/dto/private-room/response/private_room_unlock_response.ts b/src/data/dto/private-room/response/private_room_unlock_response.ts index 3c5d933f..9cd8c9ab 100644 --- a/src/data/dto/private-room/response/private_room_unlock_response.ts +++ b/src/data/dto/private-room/response/private_room_unlock_response.ts @@ -8,6 +8,8 @@ import { import { PrivateRoomMoment } from "../private_room_moment"; export class PrivateRoomUnlockResponse extends PrivateRoomMoment { + declare readonly unlocked: boolean; + declare readonly requiredCredits: number; declare readonly reason: PrivateRoomUnlockReason | string; declare readonly creditsCharged: number; declare readonly creditBalance: number; diff --git a/src/stores/private-room/__tests__/private-room-machine.test.ts b/src/stores/private-room/__tests__/private-room-machine.test.ts index 9e387491..9ad516a9 100644 --- a/src/stores/private-room/__tests__/private-room-machine.test.ts +++ b/src/stores/private-room/__tests__/private-room-machine.test.ts @@ -186,7 +186,7 @@ describe("privateRoomMachine", () => { await waitFor(actor, (snapshot) => snapshot.matches("ready")); const context = actor.getSnapshot().context; - expect(context.items[0]?.unlocked).toBe(true); + expect(context.items[0]?.locked).toBe(false); expect(context.items[0]?.images[0]?.url).toContain("https://"); expect(context.unlockSuccessNonce).toBe(1); @@ -219,7 +219,7 @@ describe("privateRoomMachine", () => { await waitFor(actor, (snapshot) => snapshot.matches("ready")); const context = actor.getSnapshot().context; - expect(context.items[0]?.unlocked).toBe(false); + expect(context.items[0]?.locked).toBe(true); expect(context.unlockPaywallRequest).toMatchObject({ momentId: "schedule:91", reason: "insufficient_credits",