refactor: 优化界面样式
This commit is contained in:
@@ -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<Parameters<typeof PrivateRoomMoment.from>[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(
|
||||||
|
<PrivateRoomPostCard
|
||||||
|
moment={moment}
|
||||||
|
displayName="Elio Silvestri"
|
||||||
|
avatarUrl="/images/chat/pic-chat-elio.png"
|
||||||
|
index={0}
|
||||||
|
isUnlocking={false}
|
||||||
|
onUnlock={() => 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,11 +23,12 @@ export function PrivateRoomPostCard({
|
|||||||
isUnlocking,
|
isUnlocking,
|
||||||
onUnlock,
|
onUnlock,
|
||||||
}: PrivateRoomPostCardProps) {
|
}: PrivateRoomPostCardProps) {
|
||||||
const isLocked = moment.locked || moment.lockDetail.locked;
|
const isLocked = moment.locked;
|
||||||
const imageUrls = moment.images
|
const firstImage = moment.images[0];
|
||||||
.filter((image) => !image.locked && image.url)
|
const firstImageUrl =
|
||||||
.map((image) => image.url as string);
|
!isLocked && firstImage?.locked !== true ? firstImage?.url : null;
|
||||||
const photoCount = moment.mediaCount || moment.images.length || 1;
|
const photoCount = moment.mediaCount || moment.images.length || 1;
|
||||||
|
const postText = moment.text || moment.content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -55,10 +56,6 @@ export function PrivateRoomPostCard({
|
|||||||
</time>
|
</time>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{moment.text || moment.content ? (
|
|
||||||
<p className={styles.postText}>{moment.text || moment.content}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isLocked ? (
|
{isLocked ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -67,33 +64,49 @@ export function PrivateRoomPostCard({
|
|||||||
onClick={onUnlock}
|
onClick={onUnlock}
|
||||||
aria-label={`Unlock ${photoCount} private room photos from ${displayName}`}
|
aria-label={`Unlock ${photoCount} private room photos from ${displayName}`}
|
||||||
>
|
>
|
||||||
<div className={styles.previewIcon}>
|
<div className={styles.lockedPreviewContent}>
|
||||||
<LockKeyhole size={18} aria-hidden="true" />
|
<div className={styles.previewIcon}>
|
||||||
</div>
|
<LockKeyhole size={22} aria-hidden="true" />
|
||||||
<div className={styles.previewText}>
|
</div>
|
||||||
<strong>{moment.unlockCost} credits</strong>
|
<div className={styles.previewText}>
|
||||||
<span>{moment.textPreview || "Unlock to view"}</span>
|
<span>{moment.textPreview || "Unlock to view"}</span>
|
||||||
<small>
|
<strong>{moment.unlockCost} credits</strong>
|
||||||
<ImageIcon size={13} aria-hidden="true" />
|
<small>
|
||||||
{photoCount} {photoCount === 1 ? "photo" : "photos"}
|
<ImageIcon size={13} aria-hidden="true" />
|
||||||
</small>
|
{photoCount} {photoCount === 1 ? "photo" : "photos"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
) : firstImageUrl ? (
|
||||||
|
<div className={styles.mediaCover}>
|
||||||
|
<Image
|
||||||
|
src={firstImageUrl}
|
||||||
|
alt={`${moment.title || displayName} private moment`}
|
||||||
|
width={720}
|
||||||
|
height={900}
|
||||||
|
className={styles.momentCoverImage}
|
||||||
|
sizes="(max-width: 540px) calc(100vw - 36px), 484px"
|
||||||
|
/>
|
||||||
|
{photoCount > 1 ? (
|
||||||
|
<span className={styles.mediaCountBadge}>
|
||||||
|
<ImageIcon size={13} aria-hidden="true" />
|
||||||
|
{photoCount} photos
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.imageGrid}>
|
<div
|
||||||
{imageUrls.map((url, imageIndex) => (
|
className={styles.emptyMediaCover}
|
||||||
<Image
|
role="img"
|
||||||
key={`${moment.momentId}-${imageIndex}`}
|
aria-label="No private moment photo available"
|
||||||
src={url}
|
>
|
||||||
alt=""
|
<ImageIcon size={32} aria-hidden="true" />
|
||||||
width={420}
|
<span>No photo available</span>
|
||||||
height={420}
|
|
||||||
className={styles.unlockedImage}
|
|
||||||
sizes="(max-width: 540px) 86vw, 460px"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{postText ? <p className={styles.postText}>{postText}</p> : null}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,27 +134,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.primaryCta {
|
.primaryCta {
|
||||||
width: fit-content;
|
width: calc(100% - var(--app-safe-left, 0px) - var(--app-safe-right, 0px) - clamp(36px, 10.37vw, 56px));
|
||||||
max-width: calc(100% - var(--app-safe-left, 0px) - var(--app-safe-right, 0px) - clamp(36px, 10.37vw, 56px));
|
align-self: center;
|
||||||
align-self: flex-start;
|
margin: 0 auto;
|
||||||
margin:
|
min-height: 50px;
|
||||||
0
|
padding: 0 clamp(20px, 5.185vw, 28px);
|
||||||
calc(var(--app-safe-right, 0px) + clamp(18px, 5.185vw, 28px))
|
border: 1px solid rgba(255, 255, 255, 0.48);
|
||||||
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);
|
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, rgba(129, 125, 119, 0.94), rgba(104, 101, 96, 0.92)),
|
linear-gradient(135deg, rgba(255, 255, 255, 0.16), transparent 38%),
|
||||||
rgba(116, 112, 106, 0.9);
|
linear-gradient(90deg, #ff67e0, var(--color-accent, #f84d96));
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: clamp(11px, 2.963vw, 14px);
|
font-size: clamp(14px, 3.333vw, 16px);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.22),
|
inset 0 1px 0 rgba(255, 255, 255, 0.24),
|
||||||
0 10px 22px rgba(47, 35, 40, 0.12);
|
0 14px 30px rgba(248, 77, 150, 0.26);
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.primaryCta span {
|
.primaryCta span {
|
||||||
@@ -291,23 +285,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lockedPreview {
|
.lockedPreview {
|
||||||
display: grid;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
grid-template-columns: auto 1fr;
|
aspect-ratio: 4 / 5;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
justify-content: center;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
padding: clamp(16px, 4.074vw, 22px);
|
padding: clamp(16px, 4.074vw, 22px);
|
||||||
border: 1px solid rgba(255, 116, 159, 0.24);
|
border: 1px solid rgba(255, 116, 159, 0.24);
|
||||||
border-radius: clamp(20px, 5.185vw, 28px);
|
border-radius: clamp(20px, 5.185vw, 28px);
|
||||||
background:
|
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;
|
#ffffff;
|
||||||
color: #21171b;
|
color: #21171b;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
text-align: left;
|
overflow: hidden;
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 12px 28px rgba(255, 116, 159, 0.12);
|
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;
|
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,22 +316,35 @@
|
|||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lockedPreviewContent {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
max-width: 260px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.previewIcon {
|
.previewIcon {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 46px;
|
width: 58px;
|
||||||
height: 46px;
|
height: 58px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 17px;
|
border: 1px solid rgba(255, 255, 255, 0.68);
|
||||||
background: linear-gradient(135deg, #ff7aa9, #ffb36d);
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(135deg, #ff7ac3, var(--color-accent, #f84d96));
|
||||||
color: #ffffff;
|
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 {
|
.previewText {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewText strong {
|
.previewText strong {
|
||||||
@@ -350,26 +363,63 @@
|
|||||||
.previewText small {
|
.previewText small {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
color: #a94c64;
|
color: #a94c64;
|
||||||
font-size: clamp(12px, 2.963vw, 15px);
|
font-size: clamp(12px, 2.963vw, 15px);
|
||||||
font-weight: 760;
|
font-weight: 760;
|
||||||
}
|
}
|
||||||
|
|
||||||
.imageGrid {
|
.mediaCover {
|
||||||
display: grid;
|
position: relative;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 14px;
|
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%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 4 / 5;
|
||||||
border-radius: 22px;
|
|
||||||
object-fit: cover;
|
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 {
|
.statusCard {
|
||||||
|
|||||||
@@ -6,11 +6,7 @@ import {
|
|||||||
|
|
||||||
export class PrivateRoomMoment {
|
export class PrivateRoomMoment {
|
||||||
declare readonly momentId: string;
|
declare readonly momentId: string;
|
||||||
declare readonly source: string;
|
|
||||||
declare readonly sourceId: string;
|
|
||||||
declare readonly characterId: string;
|
|
||||||
declare readonly author: PrivateRoomMomentData["author"];
|
declare readonly author: PrivateRoomMomentData["author"];
|
||||||
declare readonly createdAt: string | null;
|
|
||||||
declare readonly publishedAt: string | null;
|
declare readonly publishedAt: string | null;
|
||||||
declare readonly timeText: string;
|
declare readonly timeText: string;
|
||||||
declare readonly title: string;
|
declare readonly title: string;
|
||||||
@@ -20,12 +16,7 @@ export class PrivateRoomMoment {
|
|||||||
declare readonly mediaCount: number;
|
declare readonly mediaCount: number;
|
||||||
declare readonly images: PrivateRoomMomentData["images"];
|
declare readonly images: PrivateRoomMomentData["images"];
|
||||||
declare readonly locked: boolean;
|
declare readonly locked: boolean;
|
||||||
declare readonly unlocked: boolean;
|
|
||||||
declare readonly unlockCost: number;
|
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) {
|
protected constructor(input: PrivateRoomMomentInput, freeze = true) {
|
||||||
const data = PrivateRoomMomentSchema.parse(input);
|
const data = PrivateRoomMomentSchema.parse(input);
|
||||||
@@ -47,5 +38,3 @@ export class PrivateRoomMoment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PrivateRoomProfile = import("@/data/schemas/private-room").PrivateRoomProfileData;
|
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;
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
import { PrivateRoomMoment } from "../private_room_moment";
|
import { PrivateRoomMoment } from "../private_room_moment";
|
||||||
|
|
||||||
export class PrivateRoomUnlockResponse extends PrivateRoomMoment {
|
export class PrivateRoomUnlockResponse extends PrivateRoomMoment {
|
||||||
|
declare readonly unlocked: boolean;
|
||||||
|
declare readonly requiredCredits: number;
|
||||||
declare readonly reason: PrivateRoomUnlockReason | string;
|
declare readonly reason: PrivateRoomUnlockReason | string;
|
||||||
declare readonly creditsCharged: number;
|
declare readonly creditsCharged: number;
|
||||||
declare readonly creditBalance: number;
|
declare readonly creditBalance: number;
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ describe("privateRoomMachine", () => {
|
|||||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||||
|
|
||||||
const context = actor.getSnapshot().context;
|
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.items[0]?.images[0]?.url).toContain("https://");
|
||||||
expect(context.unlockSuccessNonce).toBe(1);
|
expect(context.unlockSuccessNonce).toBe(1);
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ describe("privateRoomMachine", () => {
|
|||||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||||
|
|
||||||
const context = actor.getSnapshot().context;
|
const context = actor.getSnapshot().context;
|
||||||
expect(context.items[0]?.unlocked).toBe(false);
|
expect(context.items[0]?.locked).toBe(true);
|
||||||
expect(context.unlockPaywallRequest).toMatchObject({
|
expect(context.unlockPaywallRequest).toMatchObject({
|
||||||
momentId: "schedule:91",
|
momentId: "schedule:91",
|
||||||
reason: "insufficient_credits",
|
reason: "insufficient_credits",
|
||||||
|
|||||||
Reference in New Issue
Block a user