From 538af6d45f72164ff405c2330c0e2fac085bbf30 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 14 Jul 2026 12:30:22 +0800 Subject: [PATCH] feat(private-room): migrate to album APIs --- docs/backend/FRONTEND_PRIVATE_ALBUM_API.md | 307 ++++++ docs/backend/FRONTEND_PRIVATE_ROOM_API.md | 973 ------------------ docs/backend/frontend.md | 7 - next.config.ts | 5 + .../private-album-gallery-url.test.ts | 37 + .../__tests__/private-album-card.test.tsx | 91 ++ .../__tests__/private-room-post-card.test.tsx | 106 -- src/app/private-room/components/index.ts | 3 +- ...m-post-card.tsx => private-album-card.tsx} | 62 +- .../components/private-album-gallery.tsx | 113 ++ .../components/unlock-confirm-dialog.tsx | 10 +- .../private-room/private-album-gallery-url.ts | 34 + .../private-room-screen.module.css | 137 ++- src/app/private-room/private-room-screen.tsx | 132 ++- .../__tests__/private_album.test.ts | 103 ++ .../__tests__/private_room.test.ts | 133 --- src/data/dto/private-room/index.ts | 2 +- src/data/dto/private-room/private_album.ts | 37 + .../dto/private-room/private_room_moment.ts | 40 - src/data/dto/private-room/request/index.ts | 2 +- .../request/unlock_private_album_request.ts | 21 + .../unlock_private_room_moment_request.ts | 31 - src/data/dto/private-room/response/index.ts | 4 +- .../response/private_album_unlock_response.ts | 38 + .../response/private_albums_response.ts | 36 + .../response/private_room_moments_response.ts | 54 - .../response/private_room_unlock_response.ts | 43 - .../interfaces/iprivate_room_repository.ts | 19 +- .../repositories/private_room_repository.ts | 28 +- src/data/schemas/private-room/index.ts | 8 +- .../schemas/private-room/private_album.ts | 38 + .../private_album_unlock_response.ts | 42 + .../private-room/private_albums_response.ts | 16 + src/data/schemas/private-room/private_room.ts | 102 -- .../private_room_moments_response.ts | 41 - .../private_room_unlock_response.ts | 36 - .../unlock_private_album_request.ts | 14 + .../unlock_private_room_moment_request.ts | 14 - src/data/services/api/api_path.ts | 10 +- src/data/services/api/private_room_api.ts | 32 +- src/lib/private-room/private_album.ts | 5 + .../__tests__/private-room-machine.test.ts | 359 +++---- .../private-room/private-room-context.tsx | 16 +- .../private-room/private-room-events.ts | 3 +- .../private-room-machine.actors.ts | 34 +- .../private-room-machine.helpers.ts | 89 +- .../private-room/private-room-machine.ts | 131 +-- src/stores/private-room/private-room-state.ts | 23 +- 48 files changed, 1529 insertions(+), 2092 deletions(-) create mode 100644 docs/backend/FRONTEND_PRIVATE_ALBUM_API.md delete mode 100644 docs/backend/FRONTEND_PRIVATE_ROOM_API.md delete mode 100644 docs/backend/frontend.md create mode 100644 src/app/private-room/__tests__/private-album-gallery-url.test.ts create mode 100644 src/app/private-room/components/__tests__/private-album-card.test.tsx delete mode 100644 src/app/private-room/components/__tests__/private-room-post-card.test.tsx rename src/app/private-room/components/{private-room-post-card.tsx => private-album-card.tsx} (62%) create mode 100644 src/app/private-room/components/private-album-gallery.tsx create mode 100644 src/app/private-room/private-album-gallery-url.ts create mode 100644 src/data/dto/private-room/__tests__/private_album.test.ts delete mode 100644 src/data/dto/private-room/__tests__/private_room.test.ts create mode 100644 src/data/dto/private-room/private_album.ts delete mode 100644 src/data/dto/private-room/private_room_moment.ts create mode 100644 src/data/dto/private-room/request/unlock_private_album_request.ts delete mode 100644 src/data/dto/private-room/request/unlock_private_room_moment_request.ts create mode 100644 src/data/dto/private-room/response/private_album_unlock_response.ts create mode 100644 src/data/dto/private-room/response/private_albums_response.ts delete mode 100644 src/data/dto/private-room/response/private_room_moments_response.ts delete mode 100644 src/data/dto/private-room/response/private_room_unlock_response.ts create mode 100644 src/data/schemas/private-room/private_album.ts create mode 100644 src/data/schemas/private-room/private_album_unlock_response.ts create mode 100644 src/data/schemas/private-room/private_albums_response.ts delete mode 100644 src/data/schemas/private-room/private_room.ts delete mode 100644 src/data/schemas/private-room/private_room_moments_response.ts delete mode 100644 src/data/schemas/private-room/private_room_unlock_response.ts create mode 100644 src/data/schemas/private-room/unlock_private_album_request.ts delete mode 100644 src/data/schemas/private-room/unlock_private_room_moment_request.ts create mode 100644 src/lib/private-room/private_album.ts diff --git a/docs/backend/FRONTEND_PRIVATE_ALBUM_API.md b/docs/backend/FRONTEND_PRIVATE_ALBUM_API.md new file mode 100644 index 00000000..427564b8 --- /dev/null +++ b/docs/backend/FRONTEND_PRIVATE_ALBUM_API.md @@ -0,0 +1,307 @@ +# Cozsweet 独立付费图片包前端接口 + +## 1. 用途 + +Cozsweet 前端通过本接口展示独立的 8/15 张私密图片包,并使用用户积分解锁。FB 只导入原始图片素材,后端素材池负责跨批次自动凑成 8 张或 15 张图片包。该功能不走现金支付,不使用旧 `/moments` 接口。 + +## 2. 接口地址 + +| 功能 | 方法 | 路径 | +| --- | --- | --- | +| 图片包列表 | GET | `/api/private-room/albums` | +| 积分解锁 | POST | `/api/private-room/albums/{albumId}/unlock` | + +## 4. 固定价格 + +| 图片数量 | `unlockCost` | 单张价格 | +| ---: | ---: | ---: | +| 8 | 320 credits | 40 credits | +| 15 | 600 credits | 40 credits | + +接口只会返回 `currency: "credits"`。前端不得显示现金价格,也不得调用支付套餐或创建订单接口。 + +## 5. GET /api/private-room/albums + +### 请求参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `character` | string | 否 | `elio` | 角色 ID。 | +| `collectionKey` | string | 否 | 全部 | 只查看一个素材分组。 | +| `limit` | integer | 否 | 20 | 1-50。 | +| `cursor` | ISO datetime | 否 | - | 上一页返回的 `nextCursor`。 | + +### 请求示例 + +```bash +curl 'https://api.banlv-ai.com/api/private-room/albums?character=elio&limit=20' \ + -H 'Authorization: Bearer ' +``` + +### 锁定图片包响应 + +```json +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "items": [ + { + "albumId": "a1b2c3d4-0000-0000-0000-000000000000", + "momentId": "album:a1b2c3d4-0000-0000-0000-000000000000", + "characterId": "elio", + "collectionKey": "manila_202607", + "title": "Private Manila set", + "content": null, + "previewText": "Only for you.", + "imageCount": 8, + "mediaCount": 8, + "images": [ + { + "url": "https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg", + "type": "image", + "locked": true, + "index": 0 + } + ], + "locked": true, + "unlocked": false, + "unlockCost": 320, + "requiredCredits": 320, + "creditCostPerImage": 40, + "currency": "credits", + "canUnlockWithCredits": false, + "publishedAt": "2026-07-13T00:00:00+00:00", + "lockDetail": { + "locked": true, + "showContent": false, + "showUpgrade": true, + "reason": "private_album", + "requiredCredits": 320, + "currentCredits": 100, + "shortfallCredits": 220, + "mediaCount": 8, + "unlockCostPerImage": 40 + } + } + ], + "nextCursor": null, + "hasMore": false, + "creditBalance": 100, + "currency": "credits", + "creditCostPerImage": 40, + "packageOptions": [ + {"imageCount": 8, "creditCost": 320}, + {"imageCount": 15, "creditCost": 600} + ] + } +} +``` + +锁定和解锁状态都会返回完整 `images[].url`。前端必须根据 `locked` / `unlocked` / `lockDetail.locked` 决定是否显示锁层和是否允许查看原图,不能用 URL 是否为空判断解锁状态。 + +### 已解锁图片包 + +同一个用户解锁后,再次请求列表会返回: + +```json +{ + "albumId": "a1b2c3d4-0000-0000-0000-000000000000", + "locked": false, + "unlocked": true, + "content": "Only for you.", + "imageCount": 8, + "images": [ + { + "url": "https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg", + "type": "image", + "locked": false, + "index": 0 + } + ] +} +``` + +### 分页 + +`hasMore=true` 时,把 `nextCursor` 原样传回: + +```http +GET /api/private-room/albums?cursor=&limit=20 +``` + +## 6. POST /api/private-room/albums/{albumId}/unlock + +### 请求格式 + +```http +Content-Type: application/json +Authorization: Bearer +``` + +Body: + +```json +{ + "expectedCost": 320 +} +``` + +| 字段 | 类型 | 必填 | 示例 | 说明 | +| --- | --- | --- | --- | --- | +| `expectedCost` | integer | 否但建议 | 320 | 用户确认时看到的价格;价格变化则拒绝扣分。 | + +### 请求示例 + +```bash +curl -X POST 'https://api.banlv-ai.com/api/private-room/albums/a1b2c3d4-0000-0000-0000-000000000000/unlock' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"expectedCost":320}' +``` + +### 解锁成功 + +```json +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "albumId": "a1b2c3d4-0000-0000-0000-000000000000", + "locked": false, + "unlocked": true, + "reason": "ok", + "unlockCost": 320, + "creditsCharged": 320, + "previousCreditBalance": 500, + "creditBalance": 180, + "images": [ + { + "url": "https://...", + "type": "image", + "locked": false, + "index": 0 + } + ] + } +} +``` + +前端直接使用响应里的 `images` 替换锁卡,并用 `creditBalance` 刷新余额。 + +### 积分不足 + +业务失败仍返回 HTTP 200: + +```json +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "albumId": "a1b2c3d4-0000-0000-0000-000000000000", + "locked": true, + "unlocked": false, + "reason": "insufficient_credits", + "unlockCost": 320, + "requiredCredits": 320, + "creditBalance": 100, + "shortfallCredits": 220, + "creditsCharged": 0, + "images": [ + {"url": "https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg", "type": "image", "locked": true, "index": 0} + ] + } +} +``` + +前端可以打开积分充值页,但后端不会创建图片包现金订单。用户充值积分后,再次点击同一个解锁接口。 + +### 重复解锁 + +```json +{ + "unlocked": true, + "locked": false, + "reason": "already_unlocked", + "creditsCharged": 0, + "creditBalance": 180, + "images": [{"url": "https://...", "locked": false, "index": 0}] +} +``` + +## 7. `reason` 处理 + +| `data.reason` | 含义 | 前端动作 | +| --- | --- | --- | +| `ok` | 已扣积分并解锁 | 显示真实图片并更新余额。 | +| `already_unlocked` | 以前已解锁,本次不扣 | 直接显示返回图片。 | +| `insufficient_credits` | 积分不足,未扣分 | 保持锁定,可引导购买积分。 | +| `cost_changed` | 价格与 `expectedCost` 不一致 | 刷新列表并重新确认。 | +| `unlock_in_progress` | 同一图片包正在并发解锁 | 禁用按钮并稍后刷新。 | +| `deduct_failed` | 扣积分失败 | 保持锁定并允许重试。 | +| `persist_failed_refunded` | 解锁记录失败,积分已尽力退回 | 保持锁定、刷新余额并告警。 | +| `not_found` | 图片包不存在或已停用 | 移除卡片。 | + +## 8. TypeScript 建议 + +```ts +export interface PrivateAlbumImage { + url: string; + type: "image"; + locked: boolean; + index: number; +} + +export interface PrivateAlbum { + albumId: string; + momentId: string; + characterId: string; + collectionKey: string; + title: string; + content: string | null; + previewText: string; + imageCount: 8 | 15; + mediaCount: 8 | 15; + images: PrivateAlbumImage[]; + locked: boolean; + unlocked: boolean; + unlockCost: 320 | 600; + requiredCredits: 320 | 600; + creditCostPerImage: 40; + currency: "credits"; + canUnlockWithCredits: boolean; + publishedAt: string; +} +``` + +## 9. 前端流程 + +1. 进入图片包页面,调用 `GET /api/private-room/albums`。 +2. `locked=true` 时可使用返回 URL 渲染封面/模糊图,但必须覆盖锁层并禁止打开原图。 +3. 用户确认后调用解锁接口并传当前 `expectedCost`。 +4. `reason=ok/already_unlocked` 时使用返回的真实图片。 +5. `reason=insufficient_credits` 时保持锁定并引导购买积分。 +6. 充值完成后重新调用解锁接口,不需要恢复现金订单。 +7. 解锁请求进行中禁用按钮,避免重复点击。 + +## 10. HTTP 错误 + +| HTTP 状态 | 原因 | 处理 | +| ---: | --- | --- | +| 401 | Token 无效或缺失 | 刷新 Token/重新登录。 | +| 422 | `albumId`、query 或 body 格式错误 | 修正请求。 | +| 500 | 数据库或服务异常 | 保持当前锁定状态并允许重试。 | + +## 11. 测试方法 + +1. 先在 pro 执行 `database/private-albums-migration.sql`。 +2. 使用 FB 测试导入生成一个 8 张图片包。 +3. 用少于 320 积分的测试账号请求解锁,确认不扣积分、不出现现金订单。 +4. 给测试账号补足积分,再请求解锁,确认扣 320 且返回 8 个真实 URL。 +5. 重复解锁,确认 `creditsCharged=0`。 +6. 刷新列表,确认仍为 `unlocked=true`。 +7. 15 张图片包按同样方式验证扣 600。 +8. 测试结束后删除测试素材、图片包、图片和解锁记录。 diff --git a/docs/backend/FRONTEND_PRIVATE_ROOM_API.md b/docs/backend/FRONTEND_PRIVATE_ROOM_API.md deleted file mode 100644 index 631871fe..00000000 --- a/docs/backend/FRONTEND_PRIVATE_ROOM_API.md +++ /dev/null @@ -1,973 +0,0 @@ -# Cozsweet Private Room / Moments Frontend API - -Last verified: 2026-07-08 - -This document is for the frontend integration of the Elio private room / Moments-like feed. The feature is backend-only in phase 1: posts come from existing schedule images, all posts are locked by default, and users spend credits to unlock the real image URLs. - -## 1. Scope - -The frontend needs to support three backend endpoints: - -| Purpose | Method | Path | -| --- | --- | --- | -| Get private-room config | `GET` | `/api/private-room/config` | -| Get locked/unlocked Moments feed | `GET` | `/api/private-room/moments` | -| Unlock one Moment post | `POST` | `/api/private-room/moments/{momentId}/unlock` | - -Recommended production base for cozsweet frontend: - -```text -https://api.cozsweet.com -``` - -Project production API base is also: - -```text -https://api.banlv-ai.com -``` - -Use the same API base already used by the cozsweet frontend login/chat client. Do not hardcode both in the same frontend flow. - -## 2. Authentication - -All three endpoints require the normal user Bearer token. - -```http -Authorization: Bearer -``` - -If the token is missing or invalid, production returns: - -```json -{ - "code": 401, - "message": "Not authenticated", - "success": false, - "data": null -} -``` - -Frontend action: - -- If the user is a guest, use the existing guest token flow first. -- If the user is logged in, use the existing login token. -- If this API returns 401, refresh/re-login using the existing auth behavior. - -## 3. Field Mapping vs Payment Plans - -Do not treat private-room unlock price as a payment amount. - -| Area | Price field | Currency field | Meaning | -| --- | --- | --- | --- | -| Payment plans | `amountCents` | `currency`, for example `PHP` or `USD` | Cash price in display cents. `14990` + `PHP` means `PHP 149.90`. | -| Payment plans | `originalAmountCents` | `currency` | Original crossed-out cash price. | -| Payment plans | `dailyPriceCents` | `currency` | VIP daily cash price, if applicable. | -| Private room | `unlockCost` | `currency: "credits"` | Total credits required to unlock this post. | -| Private room | `unlockCostPerImage` | `currency: "credits"` | Credits required per image. Currently `40`. | -| Private room | `requiredCredits` | `currency: "credits"` | Same unlock requirement, useful for lock/paywall UI. | - -Unlock state fields: - -| Field | Type | Meaning | Frontend usage | -| --- | --- | --- | --- | -| `locked` | boolean | `true` means the post is locked. | Primary field for locked UI. | -| `unlocked` | boolean | `true` means the post has been unlocked for this user. | Primary field for unlocked UI if preferred. | -| `lockDetail.locked` | boolean | Same lock decision in chat-compatible structure. | Useful if sharing lock/paywall components with chat. | -| `images[].locked` | boolean | Whether this image placeholder is locked. | Use for image tile state. | -| `images[].url` | string or null | Real image URL only after unlock. | Render `` only when not null. | - -Recommended frontend condition: - -```ts -const isLocked = item.locked || item.lockDetail?.locked; -``` - -## 4. Data Source and Image Access - -Phase 1 data source: - -| Source | Meaning | -| --- | --- | -| Table | `elio_schedules` | -| Single image field | `image_url` | -| Multi-image fields | `image_urls`, `image_paths`, metadata inside `content` | -| Returned post id | `momentId`, for example `schedule:91` | - -Locked posts deliberately do not expose real image URLs: - -```json -{ - "images": [ - { - "url": null, - "type": "image", - "locked": true, - "index": 0 - } - ] -} -``` - -After unlock, the same image item returns a real public Supabase Storage URL: - -```json -{ - "images": [ - { - "url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg", - "type": "image", - "locked": false, - "index": 0 - } - ] -} -``` - -Frontend can render it directly: - -```tsx -{image.url ? : } -``` - -Production image accessibility was verified on 2026-07-08: - -```text -HTTP 200 OK -Content-Type: image/jpeg -Access-Control-Allow-Origin: * -``` - -So the frontend does not need an extra image proxy for the current Supabase public URLs. - -## 5. Common Response Wrapper - -All successful API calls use the standard wrapper: - -```ts -interface ApiResponse { - code: number; - message: string; - success: boolean; - data: T | null; -} -``` - -Important: unlock business failures can still return HTTP 200 with `success: true`, but `data.unlocked` is `false`. For unlock UI, check `data.unlocked`, `data.locked`, and `data.reason`, not only HTTP status. - -## 6. GET /api/private-room/config - -### Purpose - -Get the current user's private-room config, default unlock price, current credit balance, and Elio profile info. - -### Request URL - -```http -GET https://api.cozsweet.com/api/private-room/config -``` - -### Request Format - -No query params and no body. - -### Request Example - -```bash -curl -X GET "https://api.cozsweet.com/api/private-room/config" \ - -H "Authorization: Bearer " -``` - -### Success Response - -```json -{ - "code": 200, - "message": "success", - "success": true, - "data": { - "currency": "credits", - "unlockCostDefault": 40, - "unlockCostPerImage": 40, - "source": "elio_schedules", - "creditBalance": 100, - "profile": { - "characterId": "elio", - "displayName": "Elio Silvestri", - "authorName": "Elio Silvestri", - "avatarUrl": null, - "coverUrl": null, - "title": "Elio Private room", - "subtitle": "Join me, unlock my private room" - } - } -} -``` - -### Key Fields - -| Field | Type | Meaning | -| --- | --- | --- | -| `currency` | string | Always `"credits"` for this feature. | -| `unlockCostDefault` | number | Default single-image unlock price. Currently `40`. | -| `unlockCostPerImage` | number | Price per image. Currently `40`. | -| `creditBalance` | number | User's current credits. | -| `profile` | object | Elio private-room profile. | - -### Frontend Usage - -Call it when entering the private room page if the frontend needs profile/header data before loading the feed. Otherwise, the feed endpoint also returns `profile`, `creditBalance`, and pricing fields. - -## 7. GET /api/private-room/moments - -### Purpose - -Get the private-room feed. Locked posts return placeholders and unlock prices; already-unlocked posts return real image URLs. - -### Request URL - -```http -GET https://api.cozsweet.com/api/private-room/moments?character=elio&limit=20 -``` - -### Query Parameters - -| Field | Type | Required | Example | Meaning | Validation | -| --- | --- | --- | --- | --- | --- | -| `character` | string | No | `elio` | Character/private-room id. Phase 1 supports `elio`. | Defaults to `elio`. | -| `limit` | number | No | `20` | Page size. | Min `1`, max `50`, defaults to `20`. | -| `cursor` | string | No | `1783488000` | Pagination cursor from previous `data.nextCursor`. | Omit for first page. | - -### Request Example - -```bash -curl -X GET "https://api.cozsweet.com/api/private-room/moments?character=elio&limit=20" \ - -H "Authorization: Bearer " -``` - -### Locked Post Response Example - -```json -{ - "code": 200, - "message": "success", - "success": true, - "data": { - "profile": { - "characterId": "elio", - "displayName": "Elio Silvestri", - "authorName": "Elio Silvestri", - "avatarUrl": null, - "coverUrl": null, - "title": "Elio Private room", - "subtitle": "Join me, unlock my private room" - }, - "items": [ - { - "momentId": "schedule:91", - "source": "elio_schedules", - "sourceId": "91", - "characterId": "elio", - "author": { - "id": "elio", - "name": "Elio Silvestri", - "avatarUrl": null - }, - "createdAt": "2026-07-01T08:11:35+00:00", - "publishedAt": "2026-07-01T08:11:35+00:00", - "timeText": "7天前", - "title": "Paris morning", - "content": null, - "text": null, - "textPreview": "Unlock to view private room photos", - "mediaCount": 1, - "images": [ - { - "url": null, - "type": "image", - "locked": true, - "index": 0 - } - ], - "locked": true, - "unlocked": false, - "unlockCost": 40, - "unlockCostPerImage": 40, - "requiredCredits": 40, - "currency": "credits", - "lockDetail": { - "locked": true, - "showContent": false, - "showUpgrade": true, - "reason": "private_room_moment", - "hint": "40 credits · Unlock photo", - "actionLabel": "Unlock", - "type": "private_room_moment", - "requiredCredits": 40, - "currentCredits": 100, - "shortfallCredits": 0, - "mediaCount": 1, - "unlockCostPerImage": 40, - "detail": { - "type": "private_room_moment", - "requiredCredits": 40, - "currentCredits": 100, - "shortfallCredits": 0, - "mediaCount": 1, - "unlockCostPerImage": 40 - } - } - } - ], - "nextCursor": null, - "hasMore": false, - "creditBalance": 100, - "unlockCostDefault": 40, - "unlockCostPerImage": 40, - "currency": "credits", - "source": "elio_schedules" - } -} -``` - -### Already-Unlocked Post Shape - -When the user has already unlocked the post, the list endpoint returns real image URLs directly: - -```json -{ - "momentId": "schedule:91", - "locked": false, - "unlocked": true, - "content": "A quiet morning in Paris.", - "text": "A quiet morning in Paris.", - "mediaCount": 1, - "images": [ - { - "url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg", - "type": "image", - "locked": false, - "index": 0 - } - ], - "lockDetail": { - "locked": false, - "showContent": true, - "showUpgrade": false - } -} -``` - -### Key Item Fields - -| Field | Type | Meaning | -| --- | --- | --- | -| `momentId` | string | Post id. Use this for unlock. Example: `schedule:91`. | -| `source` | string | Current source table, `elio_schedules`. | -| `sourceId` | string | Schedule row id. | -| `title` | string | Optional post title. | -| `content` | string or null | Real text content. Null when locked. | -| `text` | string or null | Same as content, for frontend naming compatibility. | -| `textPreview` | string | Preview text shown while locked. | -| `mediaCount` | number | Number of images in this post. | -| `images` | array | Image placeholders when locked; real URLs when unlocked. | -| `locked` | boolean | Whether this post is locked. | -| `unlocked` | boolean | Whether this post is unlocked. | -| `unlockCost` | number | Total credits required for this post. | -| `unlockCostPerImage` | number | Credits per image. | -| `requiredCredits` | number | Required credits for lock UI. | -| `currency` | string | Always `"credits"`. | -| `lockDetail` | object | Chat-compatible lock detail. | - -### Pagination - -If `data.hasMore` is true, call the same endpoint with: - -```text -cursor= -``` - -## 8. POST /api/private-room/moments/{momentId}/unlock - -### Purpose - -Unlock one private-room post, deduct credits, persist the unlock state, and return real image URLs. - -### Request URL - -`momentId` must be URL encoded because it contains `:`. - -```http -POST https://api.cozsweet.com/api/private-room/moments/schedule%3A91/unlock -``` - -Frontend example: - -```ts -const url = `/api/private-room/moments/${encodeURIComponent(momentId)}/unlock`; -``` - -### Request Format - -Content-Type: - -```http -Content-Type: application/json -``` - -Body is optional, but recommended: - -```json -{ - "expectedCost": 40 -} -``` - -### Body Parameters - -| Field | Type | Required | Example | Meaning | -| --- | --- | --- | --- | --- | -| `expectedCost` | number | No | `40` | The price shown to the user. If backend price changed, unlock returns `reason: "cost_changed"` instead of charging. | - -### Request Example - -```bash -curl -X POST "https://api.cozsweet.com/api/private-room/moments/schedule%3A91/unlock" \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"expectedCost":40}' -``` - -### Unlock Success Response - -```json -{ - "code": 200, - "message": "success", - "success": true, - "data": { - "momentId": "schedule:91", - "source": "elio_schedules", - "sourceId": "91", - "characterId": "elio", - "content": "A quiet morning in Paris.", - "text": "A quiet morning in Paris.", - "mediaCount": 1, - "images": [ - { - "url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg", - "type": "image", - "locked": false, - "index": 0 - } - ], - "locked": false, - "unlocked": true, - "unlockCost": 40, - "unlockCostPerImage": 40, - "requiredCredits": 40, - "currency": "credits", - "reason": "ok", - "creditsCharged": 40, - "creditBalance": 60, - "previousCreditBalance": 100, - "persisted": true, - "lockDetail": { - "locked": false, - "showContent": true, - "showUpgrade": false, - "reason": null, - "hint": null, - "actionLabel": null, - "type": "private_room_moment", - "requiredCredits": 40, - "currentCredits": 60, - "shortfallCredits": 0, - "mediaCount": 1, - "unlockCostPerImage": 40 - } - } -} -``` - -### Already Unlocked Response - -If the same user unlocks the same post again, backend does not charge again: - -```json -{ - "code": 200, - "message": "success", - "success": true, - "data": { - "momentId": "schedule:91", - "locked": false, - "unlocked": true, - "reason": "already_unlocked", - "creditsCharged": 0, - "creditBalance": 60, - "images": [ - { - "url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg", - "type": "image", - "locked": false, - "index": 0 - } - ] - } -} -``` - -### Insufficient Credits Response - -This is a business failure, but it still returns HTTP 200 and `success: true`. - -```json -{ - "code": 200, - "message": "success", - "success": true, - "data": { - "momentId": "schedule:91", - "locked": true, - "unlocked": false, - "reason": "insufficient_credits", - "requiredCredits": 40, - "currentCredits": 10, - "shortfallCredits": 30, - "creditBalance": 10, - "images": [ - { - "url": null, - "type": "image", - "locked": true, - "index": 0 - } - ], - "lockDetail": { - "locked": true, - "showContent": false, - "showUpgrade": true, - "reason": "insufficient_credits", - "hint": "Not enough credits to unlock this private room post.", - "actionLabel": "Unlock", - "requiredCredits": 40, - "currentCredits": 10, - "shortfallCredits": 30, - "mediaCount": 1, - "unlockCostPerImage": 40 - } - } -} -``` - -Frontend action: - -- Keep the post locked. -- Show recharge / insufficient credits UI. -- Do not try to render `images[].url` because it remains null. - -### Cost Changed Response - -If `expectedCost` does not match the backend price: - -```json -{ - "code": 200, - "message": "success", - "success": true, - "data": { - "momentId": "schedule:91", - "locked": true, - "unlocked": false, - "reason": "cost_changed", - "requiredCredits": 40, - "lockDetail": { - "locked": true, - "reason": "cost_changed", - "hint": "Unlock price changed. Please refresh and try again." - } - } -} -``` - -Frontend action: - -- Refresh the feed item. -- Ask the user to confirm again with the latest `unlockCost`. - -### Other Business Reasons - -| `data.reason` | Meaning | Frontend action | -| --- | --- | --- | -| `ok` | Unlock succeeded and credits were charged. | Show real images and refresh credit balance. | -| `already_unlocked` | User had unlocked before. No charge. | Show real images. | -| `insufficient_credits` | Not enough credits. No charge. | Keep locked and open recharge UI. | -| `cost_changed` | Frontend expected cost differs from backend. No charge. | Refresh and ask user to confirm again. | -| `not_found` | Post no longer exists. | Remove item or show unavailable state. | -| `no_media` | Post has no images to unlock. | Hide unlock button. | -| `deduct_failed` | Credit deduction failed. | Keep locked and allow retry. | -| `ok_persist_failed` | Credits charged and images returned, but unlock record fallback may have been used. | Show images; optionally refresh list. | - -## 9. TypeScript Types - -```ts -export interface PrivateRoomProfile { - characterId: string; - displayName: string; - authorName: string; - avatarUrl: string | null; - coverUrl: string | null; - title: string; - subtitle: string; -} - -export interface PrivateRoomImage { - url: string | null; - type: "image"; - locked: boolean; - index: number; -} - -export interface PrivateRoomLockDetail { - locked: boolean; - showContent: boolean; - showUpgrade: boolean; - reason?: string | null; - hint?: string | null; - actionLabel?: string | null; - type?: "private_room_moment"; - requiredCredits?: number; - currentCredits?: number | null; - shortfallCredits?: number; - mediaCount?: number; - unlockCostPerImage?: number; - detail?: { - type: "private_room_moment"; - requiredCredits: number; - currentCredits: number | null; - shortfallCredits: number; - mediaCount: number; - unlockCostPerImage: number; - }; -} - -export interface PrivateRoomMoment { - momentId: string; - source: "elio_schedules"; - sourceId: string; - characterId: string; - author: { - id: string; - name: string; - avatarUrl: string | null; - }; - createdAt: string | null; - publishedAt: string | null; - timeText: string; - title: string; - content: string | null; - text: string | null; - textPreview: string; - mediaCount: number; - images: PrivateRoomImage[]; - locked: boolean; - unlocked: boolean; - unlockCost: number; - unlockCostPerImage: number; - requiredCredits: number; - currency: "credits"; - lockDetail: PrivateRoomLockDetail; -} - -export interface PrivateRoomMomentsData { - profile: PrivateRoomProfile; - items: PrivateRoomMoment[]; - nextCursor: string | null; - hasMore: boolean; - creditBalance: number; - unlockCostDefault: number; - unlockCostPerImage: number; - currency: "credits"; - source: "elio_schedules"; -} - -export interface PrivateRoomUnlockData extends PrivateRoomMoment { - reason: - | "ok" - | "already_unlocked" - | "insufficient_credits" - | "cost_changed" - | "not_found" - | "no_media" - | "deduct_failed" - | "ok_persist_failed"; - creditsCharged?: number; - creditBalance?: number; - previousCreditBalance?: number; - currentCredits?: number; - shortfallCredits?: number; - persisted?: boolean; -} -``` - -## 10. Recommended Frontend Flow - -1. User enters private room page. -2. Call `GET /api/private-room/moments?character=elio&limit=20`. -3. Render each item: - - If `item.locked === true`, show locked image tiles and unlock price. - - If `item.locked === false`, render `images[].url`. -4. User taps unlock. -5. Show confirm UI with `item.unlockCost` and `item.currency`. -6. Call: - -```ts -await api.post( - `/api/private-room/moments/${encodeURIComponent(item.momentId)}/unlock`, - { expectedCost: item.unlockCost }, -); -``` - -7. On response: - - If `data.unlocked === true`, replace the current card with response `data`. - - If `data.reason === "insufficient_credits"`, keep locked and open recharge UI. - - If `data.reason === "cost_changed"`, refresh list and ask for confirmation again. - - For other failure reasons, show a retry/unavailable message. -8. Update visible credit balance from `data.creditBalance` if present. - -## 11. UI Rules - -- Locked state must never render `images[].url` if it is null. -- Do not show broken images for locked posts; use lock tiles based on `mediaCount`. -- Use `unlockCost` for the button price, not `amountCents`. -- Use `currency === "credits"` to display "credits", not PHP/USD. -- For multi-image posts, display total price: - -```ts -`${item.unlockCost} credits` -``` - -Optionally show per-image detail: - -```ts -`${item.unlockCostPerImage} credits/photo` -``` - -- After successful unlock, do not call any separate image API. Use `data.images`. -- To avoid double tapping, disable the unlock button while the POST request is pending. - -## 12. Failure Handling - -| Case | How to detect | Frontend handling | -| --- | --- | --- | -| Not logged in | HTTP 401 or wrapper `code: 401` | Refresh token, guest token, or login. | -| Validation error | HTTP 422 | Check path/body format, especially encoded `momentId`. | -| Insufficient credits | HTTP 200 with `data.reason === "insufficient_credits"` | Open recharge UI. | -| Price changed | HTTP 200 with `data.reason === "cost_changed"` | Refresh item and reconfirm. | -| Already unlocked | HTTP 200 with `data.reason === "already_unlocked"` | Show returned images, no charge. | -| Deduct failed | HTTP 200 with `data.reason === "deduct_failed"` | Keep locked and allow retry. | -| Network/API error | request failed or 5xx | Keep current state and show retry. | - -## 13. Production-Safe Test Method - -These tests do not require creating posts. - -### 13.1 Auth Required - -```bash -curl -i "https://api.cozsweet.com/api/private-room/moments?character=elio&limit=1" -``` - -Expected: - -```text -HTTP/1.1 401 Unauthorized -``` - -### 13.2 Get Config - -```bash -curl -X GET "https://api.cozsweet.com/api/private-room/config" \ - -H "Authorization: Bearer " -``` - -Expected: - -- `data.currency === "credits"` -- `data.unlockCostPerImage === 40` -- `data.creditBalance` is a number - -### 13.3 Get Locked Feed - -```bash -curl -X GET "https://api.cozsweet.com/api/private-room/moments?character=elio&limit=1" \ - -H "Authorization: Bearer " -``` - -Expected for a not-yet-unlocked post: - -- `data.items[0].locked === true` -- `data.items[0].unlocked === false` -- `data.items[0].unlockCost === data.items[0].mediaCount * 40` -- `data.items[0].images[0].url === null` - -### 13.4 Unlock With Insufficient Credits - -Use a test/guest account with fewer credits than `unlockCost`. - -```bash -curl -X POST "https://api.cozsweet.com/api/private-room/moments/schedule%3A91/unlock" \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"expectedCost":40}' -``` - -Expected: - -- HTTP 200 -- `data.unlocked === false` -- `data.locked === true` -- `data.reason === "insufficient_credits"` -- `data.images[0].url === null` - -### 13.5 Unlock Success - -Use only a test account that intentionally has enough credits. This deducts real credits. - -Expected: - -- `data.unlocked === true` -- `data.locked === false` -- `data.creditsCharged === data.unlockCost` -- `data.images[0].url` is a non-empty HTTPS URL -- Repeating the same unlock should return `reason: "already_unlocked"` and `creditsCharged: 0` - -## 14. Confirmed Production Evidence - -Verified on 2026-07-08: - -```text -GET https://api.cozsweet.com/health -HTTP 200 -``` - -Private-room endpoints require auth: - -```text -GET /api/private-room/moments without Bearer token -HTTP 401 Not authenticated -``` - -Backend generated locked item shape: - -```json -{ - "momentId": "schedule:91", - "mediaCount": 1, - "unlockCost": 40, - "unlockCostPerImage": 40, - "currency": "credits", - "locked": true, - "unlocked": false, - "requiredCredits": 40, - "images": [ - { - "url": null, - "type": "image", - "locked": true, - "index": 0 - } - ], - "lockDetail": { - "locked": true, - "requiredCredits": 40 - } -} -``` - -Backend generated unlocked image shape: - -```json -{ - "momentId": "schedule:91", - "locked": false, - "unlocked": true, - "unlockCost": 40, - "currency": "credits", - "images": [ - { - "url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg", - "type": "image", - "locked": false, - "index": 0 - } - ], - "lockDetail": { - "locked": false - } -} -``` - -The sample real image URL returned: - -```text -HTTP 200 OK -Content-Type: image/jpeg -Access-Control-Allow-Origin: * -``` - -## 15. Database / Persistence Notes - -Migration file: - -```text -database/private-room-migration.sql -``` - -Full local path: - -```text -C:\windows_banlv\ai-boyfriend\ai-boyfriend-unified\database\private-room-migration.sql -``` - -Purpose: - -- Create `private_room_unlocks` -- Persist `user_id + moment_key` -- Prevent charging again for already-unlocked posts - -Current production backend also has a fallback to `users.preferences.private_room_unlocked_moment_keys` if the unlock table is missing, so the feature can work before/without the migration. Running the migration is still recommended for cleaner persistence and future analytics. - -## 16. Frontend Integration Checklist - -- Use Bearer token for all three endpoints. -- Encode `momentId` in unlock path with `encodeURIComponent`. -- Render locked placeholders when `locked === true`. -- Render real images only when `images[].url` is not null. -- Use `unlockCost` and `currency: "credits"` for unlock price. -- Send `{ expectedCost: item.unlockCost }` when unlocking. -- Treat `data.unlocked === false` as an unlock business failure even if HTTP is 200. -- Update displayed credit balance from `data.creditBalance`. -- On insufficient credits, open recharge flow. -- On success, replace the card with returned `data` or reload the feed. - -## 17. Rollback / Compatibility Notes - -This document describes the currently deployed backend behavior. Frontend can integrate it without changing existing payment plan fields. - -Compatibility: - -- Existing payment `/api/payment/plans` is unchanged. -- Existing chat lock UI can reuse `lockDetail.locked`, `requiredCredits`, and `shortfallCredits`. -- Locked private-room items do not leak image URLs. - -Rollback if frontend needs to disable the feature: - -- Hide the private-room entry point. -- Do not call `/api/private-room/*`. -- No existing chat/payment flow needs to be rolled back. - -## 18. Needs Confirmation - -- `avatarUrl` and `coverUrl` currently return `null`; frontend should use local/default Elio assets until final profile images are provided. -- Phase 1 content source is `elio_schedules`; if product wants separate Moments content, backend can keep the API contract and change only the source implementation later. diff --git a/docs/backend/frontend.md b/docs/backend/frontend.md deleted file mode 100644 index 4c1ef931..00000000 --- a/docs/backend/frontend.md +++ /dev/null @@ -1,7 +0,0 @@ -前端核心逻辑是: -列表接口:GET /api/private-room/moments?character=elio&limit=20 -解锁接口:POST /api/private-room/moments/${encodeURIComponent(momentId)}/unlock -价格看 unlockCost,单位是 currency: "credits" -是否解锁看 locked/unlocked -锁定时 images[].url = null -解锁成功后直接用返回的 images[].url \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 1ce03977..e5843e61 100644 --- a/next.config.ts +++ b/next.config.ts @@ -20,6 +20,11 @@ const nextConfig: NextConfig = { hostname: "**.supabase.co", pathname: "/storage/v1/object/public/**", }, + { + protocol: "https", + hostname: "dbapi.banlv-ai.com", + pathname: "/storage/v1/object/public/**", + }, { protocol: "https", hostname: "free.picui.cn", diff --git a/src/app/private-room/__tests__/private-album-gallery-url.test.ts b/src/app/private-room/__tests__/private-album-gallery-url.test.ts new file mode 100644 index 00000000..369b0d6a --- /dev/null +++ b/src/app/private-room/__tests__/private-album-gallery-url.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { + buildPrivateAlbumGalleryUrl, + buildPrivateRoomWithoutGalleryUrl, + 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-room?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( + buildPrivateRoomWithoutGalleryUrl( + new URLSearchParams("album=album-1&image=2&source=external"), + ), + ).toBe("/private-room?source=external"); + }); +}); 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 new file mode 100644 index 00000000..c012dcad --- /dev/null +++ b/src/app/private-room/components/__tests__/private-album-card.test.tsx @@ -0,0 +1,91 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { PrivateAlbum } from "@/data/dto/private-room"; + +import { PrivateAlbumCard } from "../private-album-card"; + +const COVER_URL = "/images/private-room/banner.png"; + +function makeAlbum( + overrides: Partial[0]> = {}, +): PrivateAlbum { + return PrivateAlbum.from({ + 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( + undefined} + onUnlock={() => undefined} + />, + ); +} + +describe("PrivateAlbumCard", () => { + it("renders only the first unlocked image and its photo count", () => { + const html = renderCard( + makeAlbum({ + imageCount: 8, + images: [ + { url: COVER_URL, locked: false, index: 0 }, + { + url: "/images/splash/pic-bg-home.png", + locked: false, + index: 1, + }, + ], + }), + ); + + expect(html).toContain("banner.png"); + expect(html).not.toContain("pic-bg-home.png"); + expect(html).toContain("8 photos"); + expect(html).toContain("A quiet morning by the water."); + expect(html).toContain('aria-label="Open 8 private album photos"'); + }); + + it("uses the backend first image as the locked cover", () => { + const html = renderCard( + makeAlbum({ + content: null, + locked: true, + unlocked: false, + lockDetail: { locked: true }, + images: [{ url: COVER_URL, locked: true, index: 0 }], + }), + ); + + expect(html).toContain("banner.png"); + expect(html).toContain( + 'aria-label="Unlock 8 private room photos from Elio Silvestri"', + ); + expect(html).toContain("Only for you."); + expect(html).toContain("320 credits"); + }); + + 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"); + }); +}); 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 deleted file mode 100644 index 7e2e0aed..00000000 --- a/src/app/private-room/components/__tests__/private-room-post-card.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -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/index.ts b/src/app/private-room/components/index.ts index 59a18593..5d324150 100644 --- a/src/app/private-room/components/index.ts +++ b/src/app/private-room/components/index.ts @@ -1,3 +1,4 @@ -export * from "./private-room-post-card"; +export * from "./private-album-card"; +export * from "./private-album-gallery"; export * from "./status-card"; export * from "./unlock-confirm-dialog"; diff --git a/src/app/private-room/components/private-room-post-card.tsx b/src/app/private-room/components/private-album-card.tsx similarity index 62% rename from src/app/private-room/components/private-room-post-card.tsx rename to src/app/private-room/components/private-album-card.tsx index 623bef51..d4188447 100644 --- a/src/app/private-room/components/private-room-post-card.tsx +++ b/src/app/private-room/components/private-album-card.tsx @@ -3,33 +3,34 @@ import Image from "next/image"; import { ImageIcon, LockKeyhole } from "lucide-react"; import { CharacterAvatar } from "@/app/_components"; -import type { PrivateRoomMoment } from "@/data/dto/private-room"; +import type { PrivateAlbum } from "@/data/dto/private-room"; +import { isPrivateAlbumLocked } from "@/lib/private-room/private_album"; import styles from "../private-room-screen.module.css"; -export interface PrivateRoomPostCardProps { - moment: PrivateRoomMoment; +export interface PrivateAlbumCardProps { + album: PrivateAlbum; displayName: string; avatarUrl: string; index: number; isUnlocking: boolean; + onOpenGallery: () => void; onUnlock: () => void; } -export function PrivateRoomPostCard({ - moment, +export function PrivateAlbumCard({ + album, displayName, avatarUrl, index, isUnlocking, + onOpenGallery, onUnlock, -}: PrivateRoomPostCardProps) { - 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; +}: PrivateAlbumCardProps) { + const isLocked = isPrivateAlbumLocked(album); + const firstImage = album.images[0]; + const firstImageUrl = firstImage?.url || null; + const photoCount = album.imageCount || album.images.length; return (
-

{moment.author.name || displayName}

+

{displayName}

- {moment.title || "Private room drop"} + {album.title || "Private album"}

@@ -65,13 +66,23 @@ export function PrivateRoomPostCard({ onClick={onUnlock} aria-label={`Unlock ${photoCount} private room photos from ${displayName}`} > + {firstImageUrl ? ( + + ) : null} +
); } -function formatMomentTime(value: string | null): string { +function formatAlbumTime(value: string | null): string { if (!value) return ""; const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; diff --git a/src/app/private-room/components/private-album-gallery.tsx b/src/app/private-room/components/private-album-gallery.tsx new file mode 100644 index 00000000..860b24b8 --- /dev/null +++ b/src/app/private-room/components/private-album-gallery.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import Image from "next/image"; +import { ChevronLeft, ChevronRight, X } from "lucide-react"; + +import type { PrivateAlbum } from "@/data/dto/private-room"; + +import styles from "../private-room-screen.module.css"; + +export interface PrivateAlbumGalleryProps { + album: PrivateAlbum; + imageIndex: number; + onClose: () => void; + onImageIndexChange: (index: number) => void; +} + +export function PrivateAlbumGallery({ + album, + imageIndex, + onClose, + onImageIndexChange, +}: PrivateAlbumGalleryProps) { + const touchStartXRef = useRef(null); + const currentImage = album.images[imageIndex]; + const hasPrevious = imageIndex > 0; + const hasNext = imageIndex < album.images.length - 1; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + if (event.key === "ArrowLeft" && hasPrevious) { + onImageIndexChange(imageIndex - 1); + } + if (event.key === "ArrowRight" && hasNext) { + onImageIndexChange(imageIndex + 1); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [hasNext, hasPrevious, imageIndex, onClose, onImageIndexChange]); + + if (!currentImage?.url) return null; + + const handleTouchEnd = (event: React.TouchEvent) => { + const touchStartX = touchStartXRef.current; + touchStartXRef.current = null; + if (touchStartX === null) return; + const delta = event.changedTouches[0]?.clientX ?? touchStartX; + const distance = delta - touchStartX; + if (distance > 48 && hasPrevious) onImageIndexChange(imageIndex - 1); + if (distance < -48 && hasNext) onImageIndexChange(imageIndex + 1); + }; + + return ( +
{ + touchStartXRef.current = event.touches[0]?.clientX ?? null; + }} + onTouchEnd={handleTouchEnd} + > + + +
+ {`${album.title +
+ + + {imageIndex + 1} / {album.images.length} + + + {hasPrevious ? ( + + ) : null} + {hasNext ? ( + + ) : null} +
+ ); +} diff --git a/src/app/private-room/components/unlock-confirm-dialog.tsx b/src/app/private-room/components/unlock-confirm-dialog.tsx index cc2533db..4d51ab3a 100644 --- a/src/app/private-room/components/unlock-confirm-dialog.tsx +++ b/src/app/private-room/components/unlock-confirm-dialog.tsx @@ -1,9 +1,9 @@ -import type { PrivateRoomMoment } from "@/data/dto/private-room"; +import type { PrivateAlbum } from "@/data/dto/private-room"; import styles from "../private-room-screen.module.css"; export interface UnlockConfirmDialogProps { - moment: PrivateRoomMoment; + album: PrivateAlbum; isUnlocking: boolean; errorMessage: string | null; onCancel: () => void; @@ -11,7 +11,7 @@ export interface UnlockConfirmDialogProps { } export function UnlockConfirmDialog({ - moment, + album, isUnlocking, errorMessage, onCancel, @@ -20,9 +20,9 @@ export function UnlockConfirmDialog({ return (
-

Unlock private moment?

+

Unlock private album?

- This will use {moment.unlockCost} credits. + Unlock {album.imageCount} photos for {album.unlockCost} credits.

{errorMessage ? (

{errorMessage}

diff --git a/src/app/private-room/private-album-gallery-url.ts b/src/app/private-room/private-album-gallery-url.ts new file mode 100644 index 00000000..4bcb91e5 --- /dev/null +++ b/src/app/private-room/private-album-gallery-url.ts @@ -0,0 +1,34 @@ +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, +): `/private-room?${string}` { + const params = new URLSearchParams({ + [PRIVATE_ALBUM_QUERY_PARAM]: albumId, + [PRIVATE_ALBUM_IMAGE_QUERY_PARAM]: String(imageIndex), + }); + return `${ROUTES.privateRoom}?${params.toString()}` as const; +} + +export function buildPrivateRoomWithoutGalleryUrl(input: { + toString: () => string; +}): 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 ? `${ROUTES.privateRoom}?${query}` : ROUTES.privateRoom; +} diff --git a/src/app/private-room/private-room-screen.module.css b/src/app/private-room/private-room-screen.module.css index f7d74a70..2acbea60 100644 --- a/src/app/private-room/private-room-screen.module.css +++ b/src/app/private-room/private-room-screen.module.css @@ -121,8 +121,7 @@ white-space: nowrap; } -.primaryCta, -.loadMoreButton { +.primaryCta { display: inline-flex; min-height: 48px; align-items: center; @@ -320,7 +319,7 @@ .lockedPreviewContent { position: relative; - z-index: 1; + z-index: 2; display: flex; max-width: 260px; flex-direction: column; @@ -328,6 +327,19 @@ gap: 14px; } +.lockedCoverImage { + object-fit: cover; + filter: blur(18px); + transform: scale(1.08); +} + +.lockedPreviewScrim { + position: absolute; + inset: 0; + z-index: 1; + background: rgba(28, 19, 23, 0.46); +} + .previewIcon { display: grid; width: 58px; @@ -374,10 +386,17 @@ .mediaCover { position: relative; + display: block; + width: 100%; margin-top: 14px; + padding: 0; + border: 0; overflow: hidden; border-radius: clamp(20px, 5.185vw, 28px); background: #f3eef0; + cursor: pointer; + font: inherit; + text-align: inherit; box-shadow: 0 14px 32px rgba(131, 72, 85, 0.14); } @@ -439,16 +458,87 @@ margin: 0; } -.statusCard button, -.loadMoreButton { +.statusCard button { padding: 0 18px; background: rgba(255, 93, 149, 0.12); color: #a94c64; } -.loadMoreButton { - width: 100%; - margin-top: 16px; +.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; + touch-action: pan-y; +} + +.galleryImageFrame { + position: absolute; + inset: 0; +} + +.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 { @@ -535,21 +625,42 @@ } .primaryCta:hover, -.lockedPreview:hover, -.loadMoreButton:hover { +.lockedPreview:hover { filter: brightness(1.04); transform: translateY(-1px); } +.mediaCover:hover { + filter: brightness(1.02); +} + +.galleryClose:hover, +.galleryNav:hover { + background: rgba(38, 38, 38, 0.82); +} + .primaryCta:active, -.lockedPreview:active, -.loadMoreButton:active { +.lockedPreview:active { transform: translateY(1px) scale(0.99); } +.mediaCover:active { + transform: scale(0.995); +} + +.galleryClose:active { + transform: scale(0.94); +} + +.galleryNav:active { + transform: translateY(-50%) scale(0.94); +} + .primaryCta:focus-visible, .lockedPreview:focus-visible, -.loadMoreButton:focus-visible, +.mediaCover:focus-visible, +.galleryClose:focus-visible, +.galleryNav:focus-visible, .dialogPrimary:focus-visible, .dialogSecondary:focus-visible { outline: 2px solid #ff5d95; diff --git a/src/app/private-room/private-room-screen.tsx b/src/app/private-room/private-room-screen.tsx index 78e8a0b9..4992e931 100644 --- a/src/app/private-room/private-room-screen.tsx +++ b/src/app/private-room/private-room-screen.tsx @@ -1,13 +1,14 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import Image from "next/image"; -import { RefreshCw } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; import { CharacterAvatar } from "@/app/_components"; import { AppBottomNav, MobileShell } from "@/app/_components/core"; import { ROUTES } from "@/router/routes"; import { useAppNavigator } from "@/router/use-app-navigator"; +import { isPrivateAlbumLocked } from "@/lib/private-room/private_album"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { usePrivateRoomDispatch, @@ -15,7 +16,8 @@ import { } from "@/stores/private-room"; import { - PrivateRoomPostCard, + PrivateAlbumCard, + PrivateAlbumGallery, StatusCard, UnlockConfirmDialog, } from "./components"; @@ -26,21 +28,32 @@ import { usePrivateRoomUnlockPaywallNavigation, usePrivateRoomUnlockSuccessRefresh, } from "./use-private-room-flow"; +import { + buildPrivateAlbumGalleryUrl, + buildPrivateRoomWithoutGalleryUrl, + getPrivateAlbumGalleryState, +} from "./private-album-gallery-url"; const FALLBACK_PROFILE = { name: "Elio Silvestri", - handle: "@elio.private", avatar: "/images/chat/pic-chat-elio.png", title: "Elio Private room", subtitle: "Join me, unlock my private room", } as const; export function PrivateRoomScreen() { + const router = useRouter(); + const searchParams = useSearchParams(); const navigator = useAppNavigator(); const authState = useAuthState(); const authDispatch = useAuthDispatch(); const state = usePrivateRoomState(); const dispatch = usePrivateRoomDispatch(); + const openedGalleryInPageRef = useRef(false); + const galleryState = useMemo( + () => getPrivateAlbumGalleryState(searchParams), + [searchParams], + ); usePrivateRoomBootstrapFlow({ authDispatch, @@ -57,19 +70,39 @@ export function PrivateRoomScreen() { }); usePrivateRoomUnlockSuccessRefresh(state.unlockSuccessNonce); - const profile = state.profile; - const displayName = - profile?.displayName || profile?.authorName || FALLBACK_PROFILE.name; - const avatarUrl = profile?.avatarUrl || FALLBACK_PROFILE.avatar; - const title = profile?.title || FALLBACK_PROFILE.title; - const subtitle = profile?.subtitle || FALLBACK_PROFILE.subtitle; - const pendingMoment = useMemo( + const displayName = FALLBACK_PROFILE.name; + const avatarUrl = FALLBACK_PROFILE.avatar; + const title = FALLBACK_PROFILE.title; + const subtitle = FALLBACK_PROFILE.subtitle; + const pendingAlbum = useMemo( () => state.items.find( - (item) => item.momentId === state.pendingConfirmMomentId, + (album) => album.albumId === state.pendingConfirmAlbumId, ) ?? null, - [state.items, state.pendingConfirmMomentId], + [state.items, state.pendingConfirmAlbumId], ); + const galleryAlbum = useMemo( + () => + galleryState + ? state.items.find((album) => album.albumId === galleryState.albumId) ?? + null + : null, + [galleryState, state.items], + ); + + useEffect(() => { + if (!galleryState || state.isLoading) return; + const image = galleryAlbum?.images[galleryState.imageIndex]; + if ( + !galleryAlbum || + isPrivateAlbumLocked(galleryAlbum) || + !image?.url + ) { + router.replace(buildPrivateRoomWithoutGalleryUrl(searchParams), { + scroll: false, + }); + } + }, [galleryAlbum, galleryState, router, searchParams, state.isLoading]); const handleTopUpClick = () => { if (isPrivateRoomAuthRequired(authState.loginStatus)) { @@ -79,6 +112,30 @@ export function PrivateRoomScreen() { navigator.openSubscription({ type: "topup" }); }; + const handleOpenGallery = (albumId: string) => { + openedGalleryInPageRef.current = true; + router.push(buildPrivateAlbumGalleryUrl(albumId, 0), { scroll: false }); + }; + + const handleCloseGallery = () => { + if (openedGalleryInPageRef.current) { + openedGalleryInPageRef.current = false; + router.back(); + return; + } + router.replace(buildPrivateRoomWithoutGalleryUrl(searchParams), { + scroll: false, + }); + }; + + const handleGalleryIndexChange = (imageIndex: number) => { + if (!galleryAlbum) return; + router.replace( + buildPrivateAlbumGalleryUrl(galleryAlbum.albumId, imageIndex), + { scroll: false }, + ); + }; + return (
@@ -124,9 +181,9 @@ export function PrivateRoomScreen() {
-

Posts

+

Albums

- Private moments + Private albums

{state.items.length} @@ -141,47 +198,36 @@ export function PrivateRoomScreen() { ) : null} {state.isLoading && state.items.length === 0 ? ( - + ) : null} {!state.isLoading && state.items.length === 0 && !state.errorMessage ? ( dispatch({ type: "PrivateRoomRefresh" })} /> ) : null}
- {state.items.map((moment, index) => ( - ( + handleOpenGallery(album.albumId)} onUnlock={() => dispatch({ type: "PrivateRoomUnlockRequested", - momentId: moment.momentId, + albumId: album.albumId, }) } /> ))}
- - {state.hasMore ? ( - - ) : null}
undefined} /> - {pendingMoment ? ( + {pendingAlbum ? ( dispatch({ type: "PrivateRoomUnlockCancelled" })} @@ -203,6 +249,18 @@ export function PrivateRoomScreen() { {state.unlockErrorMessage}
) : null} + + {galleryAlbum && + galleryState && + !isPrivateAlbumLocked(galleryAlbum) && + galleryAlbum.images[galleryState.imageIndex]?.url ? ( + + ) : null} ); diff --git a/src/data/dto/private-room/__tests__/private_album.test.ts b/src/data/dto/private-room/__tests__/private_album.test.ts new file mode 100644 index 00000000..9e2cd676 --- /dev/null +++ b/src/data/dto/private-room/__tests__/private_album.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { + PrivateAlbumsResponse, + PrivateAlbumUnlockResponse, + UnlockPrivateAlbumRequest, +} from "@/data/dto/private-room"; +import { ApiPath } from "@/data/services/api"; + +const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000"; +const COVER_URL = + "https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg"; + +const lockedAlbum = { + albumId: ALBUM_ID, + momentId: `album:${ALBUM_ID}`, + characterId: "elio", + collectionKey: "manila_202607", + title: "Private Manila set", + content: null, + previewText: "Only for you.", + imageCount: 8, + mediaCount: 8, + images: [{ url: COVER_URL, type: "image", locked: true, index: 0 }], + locked: true, + unlocked: false, + unlockCost: 320, + requiredCredits: 320, + creditCostPerImage: 40, + currency: "credits", + canUnlockWithCredits: false, + publishedAt: "2026-07-13T00:00:00+00:00", + lockDetail: { + locked: true, + showContent: false, + showUpgrade: true, + reason: "private_album", + }, +}; + +describe("Private Album DTOs", () => { + it("keeps a locked album cover URL while preserving the lock state", () => { + const response = PrivateAlbumsResponse.fromJson({ + items: [lockedAlbum], + creditBalance: 100, + nextCursor: null, + hasMore: false, + }); + + expect(response.items[0]?.locked).toBe(true); + expect(response.items[0]?.unlocked).toBe(false); + expect(response.items[0]?.images[0]?.url).toBe(COVER_URL); + expect(response.items[0]?.lockDetail).toEqual({ locked: true }); + }); + + it("strips response fields that are not used by the frontend", () => { + const response = PrivateAlbumsResponse.fromJson({ + items: [lockedAlbum], + creditBalance: 100, + packageOptions: [{ imageCount: 8, creditCost: 320 }], + }); + const albumJson = response.items[0]?.toJson(); + + expect(albumJson).not.toHaveProperty("momentId"); + expect(albumJson).not.toHaveProperty("characterId"); + expect(albumJson).not.toHaveProperty("collectionKey"); + expect(response.toJson()).not.toHaveProperty("packageOptions"); + }); + + it("parses an unlock response with all album images", () => { + const response = PrivateAlbumUnlockResponse.fromJson({ + albumId: ALBUM_ID, + locked: false, + unlocked: true, + reason: "ok", + unlockCost: 320, + creditBalance: 180, + images: Array.from({ length: 8 }, (_, index) => ({ + url: `${COVER_URL}?image=${index}`, + locked: false, + index, + })), + creditsCharged: 320, + }); + + expect(response.unlocked).toBe(true); + expect(response.images).toHaveLength(8); + expect(response.creditBalance).toBe(180); + expect(response.toJson()).not.toHaveProperty("creditsCharged"); + }); + + it("serializes the expected unlock cost", () => { + expect( + UnlockPrivateAlbumRequest.from({ expectedCost: 320 }).toJson(), + ).toEqual({ expectedCost: 320 }); + }); + + it("encodes the album id in the unlock path", () => { + expect(ApiPath.privateRoomAlbumUnlock("album:91")).toBe( + "/api/private-room/albums/album%3A91/unlock", + ); + }); +}); diff --git a/src/data/dto/private-room/__tests__/private_room.test.ts b/src/data/dto/private-room/__tests__/private_room.test.ts deleted file mode 100644 index f6df6ebe..00000000 --- a/src/data/dto/private-room/__tests__/private_room.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - PrivateRoomMomentsResponse, - PrivateRoomUnlockResponse, -} from "@/data/dto/private-room"; -import { ApiPath } from "@/data/services/api"; - -const lockedMoment = { - momentId: "schedule:91", - source: "elio_schedules", - sourceId: "91", - characterId: "elio", - author: { - id: "elio", - name: "Elio Silvestri", - avatarUrl: null, - }, - createdAt: "2026-07-01T08:11:35+00:00", - publishedAt: "2026-07-01T08:11:35+00:00", - timeText: "7 days ago", - title: "Paris morning", - content: null, - text: null, - textPreview: "Unlock to view private room photos", - mediaCount: 1, - images: [ - { - url: null, - type: "image", - locked: true, - index: 0, - }, - ], - locked: true, - unlocked: false, - unlockCost: 40, - unlockCostPerImage: 40, - requiredCredits: 40, - currency: "credits", - lockDetail: { - locked: true, - showContent: false, - showUpgrade: true, - reason: "private_room_moment", - hint: "40 credits · Unlock photo", - actionLabel: "Unlock", - type: "private_room_moment", - requiredCredits: 40, - currentCredits: 100, - shortfallCredits: 0, - mediaCount: 1, - unlockCostPerImage: 40, - }, -}; - -describe("PrivateRoom DTOs", () => { - it("parses locked moments without image URLs", () => { - const response = PrivateRoomMomentsResponse.from({ - profile: { - characterId: "elio", - displayName: "Elio Silvestri", - authorName: "Elio Silvestri", - avatarUrl: null, - coverUrl: null, - title: "Elio Private room", - subtitle: "Join me, unlock my private room", - }, - items: [lockedMoment], - nextCursor: null, - hasMore: false, - creditBalance: 100, - unlockCostDefault: 40, - unlockCostPerImage: 40, - currency: "credits", - source: "elio_schedules", - }); - - expect(response.items[0]?.locked).toBe(true); - expect(response.items[0]?.images[0]?.url).toBeNull(); - expect(response.items[0]?.unlockCost).toBe(40); - expect(response.profile.displayName).toBe("Elio Silvestri"); - }); - - it("parses unlock success with real image URLs", () => { - const response = PrivateRoomUnlockResponse.from({ - ...lockedMoment, - locked: false, - unlocked: true, - content: "A quiet morning in Paris.", - text: "A quiet morning in Paris.", - images: [ - { - url: "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/photo.jpg", - type: "image", - locked: false, - index: 0, - }, - ], - reason: "ok", - creditsCharged: 40, - creditBalance: 60, - previousCreditBalance: 100, - persisted: true, - }); - - expect(response.unlocked).toBe(true); - expect(response.reason).toBe("ok"); - expect(response.images[0]?.url).toContain("https://"); - expect(response.creditBalance).toBe(60); - }); - - it("parses insufficient credits as business data", () => { - const response = PrivateRoomUnlockResponse.from({ - ...lockedMoment, - reason: "insufficient_credits", - currentCredits: 10, - shortfallCredits: 30, - creditBalance: 10, - }); - - expect(response.unlocked).toBe(false); - expect(response.locked).toBe(true); - expect(response.reason).toBe("insufficient_credits"); - expect(response.images[0]?.url).toBeNull(); - }); - - it("encodes moment id in the unlock path", () => { - expect(ApiPath.privateRoomMomentUnlock("schedule:91")).toBe( - "/api/private-room/moments/schedule%3A91/unlock", - ); - }); -}); diff --git a/src/data/dto/private-room/index.ts b/src/data/dto/private-room/index.ts index 42b1dd43..8ea5042e 100644 --- a/src/data/dto/private-room/index.ts +++ b/src/data/dto/private-room/index.ts @@ -1,3 +1,3 @@ -export * from "./private_room_moment"; +export * from "./private_album"; export * from "./request"; export * from "./response"; diff --git a/src/data/dto/private-room/private_album.ts b/src/data/dto/private-room/private_album.ts new file mode 100644 index 00000000..16cbae97 --- /dev/null +++ b/src/data/dto/private-room/private_album.ts @@ -0,0 +1,37 @@ +import { + PrivateAlbumSchema, + type PrivateAlbumData, + type PrivateAlbumInput, +} from "@/data/schemas/private-room"; + +export class PrivateAlbum { + declare readonly albumId: string; + declare readonly title: string; + declare readonly content: string | null; + declare readonly previewText: string; + declare readonly imageCount: number; + declare readonly images: PrivateAlbumData["images"]; + declare readonly locked: boolean; + declare readonly unlocked: boolean; + declare readonly unlockCost: number; + declare readonly publishedAt: string | null; + declare readonly lockDetail: PrivateAlbumData["lockDetail"]; + + private constructor(input: PrivateAlbumInput) { + const data = PrivateAlbumSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: PrivateAlbumInput): PrivateAlbum { + return new PrivateAlbum(input); + } + + static fromJson(json: unknown): PrivateAlbum { + return PrivateAlbum.from(json as PrivateAlbumInput); + } + + toJson(): PrivateAlbumData { + return PrivateAlbumSchema.parse(this); + } +} diff --git a/src/data/dto/private-room/private_room_moment.ts b/src/data/dto/private-room/private_room_moment.ts deleted file mode 100644 index 1e39fdf5..00000000 --- a/src/data/dto/private-room/private_room_moment.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - PrivateRoomMomentSchema, - type PrivateRoomMomentData, - type PrivateRoomMomentInput, -} from "@/data/schemas/private-room"; - -export class PrivateRoomMoment { - declare readonly momentId: string; - declare readonly author: PrivateRoomMomentData["author"]; - declare readonly publishedAt: string | null; - declare readonly timeText: string; - declare readonly title: string; - declare readonly content: string | null; - declare readonly text: string | null; - declare readonly textPreview: string; - declare readonly mediaCount: number; - declare readonly images: PrivateRoomMomentData["images"]; - declare readonly locked: boolean; - declare readonly unlockCost: number; - - protected constructor(input: PrivateRoomMomentInput, freeze = true) { - const data = PrivateRoomMomentSchema.parse(input); - Object.assign(this, data); - if (freeze) Object.freeze(this); - } - - static from(input: PrivateRoomMomentInput): PrivateRoomMoment { - return new PrivateRoomMoment(input); - } - - static fromJson(json: unknown): PrivateRoomMoment { - return PrivateRoomMoment.from(json as PrivateRoomMomentInput); - } - - toJson(): PrivateRoomMomentData { - return PrivateRoomMomentSchema.parse(this); - } -} - -export type PrivateRoomProfile = import("@/data/schemas/private-room").PrivateRoomProfileData; diff --git a/src/data/dto/private-room/request/index.ts b/src/data/dto/private-room/request/index.ts index 38e853e0..70348de0 100644 --- a/src/data/dto/private-room/request/index.ts +++ b/src/data/dto/private-room/request/index.ts @@ -1 +1 @@ -export * from "./unlock_private_room_moment_request"; +export * from "./unlock_private_album_request"; diff --git a/src/data/dto/private-room/request/unlock_private_album_request.ts b/src/data/dto/private-room/request/unlock_private_album_request.ts new file mode 100644 index 00000000..772da0a4 --- /dev/null +++ b/src/data/dto/private-room/request/unlock_private_album_request.ts @@ -0,0 +1,21 @@ +import { + UnlockPrivateAlbumRequestSchema, + type UnlockPrivateAlbumRequestData, + type UnlockPrivateAlbumRequestInput, +} from "@/data/schemas/private-room"; + +export class UnlockPrivateAlbumRequest { + private constructor(input: UnlockPrivateAlbumRequestInput) { + const data = UnlockPrivateAlbumRequestSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: UnlockPrivateAlbumRequestInput): UnlockPrivateAlbumRequest { + return new UnlockPrivateAlbumRequest(input); + } + + toJson(): UnlockPrivateAlbumRequestData { + return UnlockPrivateAlbumRequestSchema.parse(this); + } +} diff --git a/src/data/dto/private-room/request/unlock_private_room_moment_request.ts b/src/data/dto/private-room/request/unlock_private_room_moment_request.ts deleted file mode 100644 index 28acc0c5..00000000 --- a/src/data/dto/private-room/request/unlock_private_room_moment_request.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - UnlockPrivateRoomMomentRequestSchema, - type UnlockPrivateRoomMomentRequestData, - type UnlockPrivateRoomMomentRequestInput, -} from "@/data/schemas/private-room"; - -export class UnlockPrivateRoomMomentRequest { - declare readonly expectedCost: number; - - private constructor(input: UnlockPrivateRoomMomentRequestInput) { - const data = UnlockPrivateRoomMomentRequestSchema.parse(input); - Object.assign(this, data); - Object.freeze(this); - } - - static from( - input: UnlockPrivateRoomMomentRequestInput, - ): UnlockPrivateRoomMomentRequest { - return new UnlockPrivateRoomMomentRequest(input); - } - - static fromJson(json: unknown): UnlockPrivateRoomMomentRequest { - return UnlockPrivateRoomMomentRequest.from( - json as UnlockPrivateRoomMomentRequestInput, - ); - } - - toJson(): UnlockPrivateRoomMomentRequestData { - return UnlockPrivateRoomMomentRequestSchema.parse(this); - } -} diff --git a/src/data/dto/private-room/response/index.ts b/src/data/dto/private-room/response/index.ts index 243a7043..e3ff32f4 100644 --- a/src/data/dto/private-room/response/index.ts +++ b/src/data/dto/private-room/response/index.ts @@ -1,2 +1,2 @@ -export * from "./private_room_moments_response"; -export * from "./private_room_unlock_response"; +export * from "./private_albums_response"; +export * from "./private_album_unlock_response"; diff --git a/src/data/dto/private-room/response/private_album_unlock_response.ts b/src/data/dto/private-room/response/private_album_unlock_response.ts new file mode 100644 index 00000000..d696f220 --- /dev/null +++ b/src/data/dto/private-room/response/private_album_unlock_response.ts @@ -0,0 +1,38 @@ +import { + PrivateAlbumUnlockResponseSchema, + type PrivateAlbumUnlockReason, + type PrivateAlbumUnlockResponseData, + type PrivateAlbumUnlockResponseInput, +} from "@/data/schemas/private-room"; + +export class PrivateAlbumUnlockResponse { + declare readonly albumId: string; + declare readonly locked: boolean; + declare readonly unlocked: boolean; + declare readonly reason: PrivateAlbumUnlockReason | string; + declare readonly unlockCost: number; + declare readonly requiredCredits: number; + declare readonly creditBalance: number; + declare readonly shortfallCredits: number; + declare readonly images: PrivateAlbumUnlockResponseData["images"]; + + private constructor(input: PrivateAlbumUnlockResponseInput) { + const data = PrivateAlbumUnlockResponseSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: PrivateAlbumUnlockResponseInput): PrivateAlbumUnlockResponse { + return new PrivateAlbumUnlockResponse(input); + } + + static fromJson(json: unknown): PrivateAlbumUnlockResponse { + return PrivateAlbumUnlockResponse.from( + json as PrivateAlbumUnlockResponseInput, + ); + } + + toJson(): PrivateAlbumUnlockResponseData { + return PrivateAlbumUnlockResponseSchema.parse(this); + } +} diff --git a/src/data/dto/private-room/response/private_albums_response.ts b/src/data/dto/private-room/response/private_albums_response.ts new file mode 100644 index 00000000..bf3d4ad3 --- /dev/null +++ b/src/data/dto/private-room/response/private_albums_response.ts @@ -0,0 +1,36 @@ +import { + PrivateAlbumsResponseSchema, + type PrivateAlbumsResponseData, + type PrivateAlbumsResponseInput, +} from "@/data/schemas/private-room"; + +import { PrivateAlbum } from "../private_album"; + +export class PrivateAlbumsResponse { + declare readonly items: PrivateAlbum[]; + declare readonly creditBalance: number; + + private constructor(input: PrivateAlbumsResponseInput) { + const data = PrivateAlbumsResponseSchema.parse(input); + Object.assign(this, { + items: data.items.map((item) => PrivateAlbum.from(item)), + creditBalance: data.creditBalance, + }); + Object.freeze(this); + } + + static from(input: PrivateAlbumsResponseInput): PrivateAlbumsResponse { + return new PrivateAlbumsResponse(input); + } + + static fromJson(json: unknown): PrivateAlbumsResponse { + return PrivateAlbumsResponse.from(json as PrivateAlbumsResponseInput); + } + + toJson(): PrivateAlbumsResponseData { + return { + items: this.items.map((item) => item.toJson()), + creditBalance: this.creditBalance, + }; + } +} diff --git a/src/data/dto/private-room/response/private_room_moments_response.ts b/src/data/dto/private-room/response/private_room_moments_response.ts deleted file mode 100644 index fdd874ce..00000000 --- a/src/data/dto/private-room/response/private_room_moments_response.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - PrivateRoomMomentsResponseSchema, - type PrivateRoomMomentsResponseData, - type PrivateRoomMomentsResponseInput, -} from "@/data/schemas/private-room"; - -import { PrivateRoomMoment } from "../private_room_moment"; - -export class PrivateRoomMomentsResponse { - declare readonly profile: PrivateRoomMomentsResponseData["profile"]; - declare readonly items: PrivateRoomMoment[]; - declare readonly nextCursor: string | null; - declare readonly hasMore: boolean; - declare readonly creditBalance: number; - declare readonly unlockCostDefault: number; - declare readonly unlockCostPerImage: number; - declare readonly currency: string; - declare readonly source: string; - - private constructor(input: PrivateRoomMomentsResponseInput) { - const data = PrivateRoomMomentsResponseSchema.parse(input); - Object.assign(this, { - ...data, - items: data.items.map((item) => PrivateRoomMoment.from(item)), - }); - Object.freeze(this); - } - - static from( - input: PrivateRoomMomentsResponseInput, - ): PrivateRoomMomentsResponse { - return new PrivateRoomMomentsResponse(input); - } - - static fromJson(json: unknown): PrivateRoomMomentsResponse { - return PrivateRoomMomentsResponse.from( - json as PrivateRoomMomentsResponseInput, - ); - } - - toJson(): PrivateRoomMomentsResponseData { - return { - profile: this.profile, - items: this.items.map((item) => item.toJson()), - nextCursor: this.nextCursor, - hasMore: this.hasMore, - creditBalance: this.creditBalance, - unlockCostDefault: this.unlockCostDefault, - unlockCostPerImage: this.unlockCostPerImage, - currency: this.currency, - source: this.source, - }; - } -} 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 deleted file mode 100644 index 9cd8c9ab..00000000 --- a/src/data/dto/private-room/response/private_room_unlock_response.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - PrivateRoomUnlockResponseSchema, - type PrivateRoomUnlockReason, - type PrivateRoomUnlockResponseData, - type PrivateRoomUnlockResponseInput, -} from "@/data/schemas/private-room"; - -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; - declare readonly previousCreditBalance: number; - declare readonly currentCredits: number; - declare readonly shortfallCredits: number; - declare readonly persisted: boolean; - - private constructor(input: PrivateRoomUnlockResponseInput) { - const data = PrivateRoomUnlockResponseSchema.parse(input); - super(data, false); - Object.assign(this, data); - Object.freeze(this); - } - - static override from( - input: PrivateRoomUnlockResponseInput, - ): PrivateRoomUnlockResponse { - return new PrivateRoomUnlockResponse(input); - } - - static override fromJson(json: unknown): PrivateRoomUnlockResponse { - return PrivateRoomUnlockResponse.from( - json as PrivateRoomUnlockResponseInput, - ); - } - - override toJson(): PrivateRoomUnlockResponseData { - return PrivateRoomUnlockResponseSchema.parse(this); - } -} diff --git a/src/data/repositories/interfaces/iprivate_room_repository.ts b/src/data/repositories/interfaces/iprivate_room_repository.ts index 957cbda5..36addc8e 100644 --- a/src/data/repositories/interfaces/iprivate_room_repository.ts +++ b/src/data/repositories/interfaces/iprivate_room_repository.ts @@ -1,22 +1,21 @@ import type { - PrivateRoomMomentsResponse, - PrivateRoomUnlockResponse, + PrivateAlbumsResponse, + PrivateAlbumUnlockResponse, } from "@/data/dto/private-room"; import type { Result } from "@/utils/result"; -export interface GetPrivateRoomMomentsInput { +export interface GetPrivateAlbumsInput { character?: string; limit?: number; - cursor?: string | null; } export interface IPrivateRoomRepository { - getMoments( - input?: GetPrivateRoomMomentsInput, - ): Promise>; + getAlbums( + input?: GetPrivateAlbumsInput, + ): Promise>; - unlockMoment( - momentId: string, + unlockAlbum( + albumId: string, expectedCost: number, - ): Promise>; + ): Promise>; } diff --git a/src/data/repositories/private_room_repository.ts b/src/data/repositories/private_room_repository.ts index b7c15164..65c6178f 100644 --- a/src/data/repositories/private_room_repository.ts +++ b/src/data/repositories/private_room_repository.ts @@ -1,16 +1,16 @@ "use client"; import { - UnlockPrivateRoomMomentRequest, - type PrivateRoomMomentsResponse, - type PrivateRoomUnlockResponse, + UnlockPrivateAlbumRequest, + type PrivateAlbumsResponse, + type PrivateAlbumUnlockResponse, } from "@/data/dto/private-room"; import { PrivateRoomApi, privateRoomApi, } from "@/data/services/api/private_room_api"; import type { - GetPrivateRoomMomentsInput, + GetPrivateAlbumsInput, IPrivateRoomRepository, } from "@/data/repositories/interfaces"; import { Result } from "@/utils/result"; @@ -20,20 +20,20 @@ import { createLazySingleton } from "./lazy_singleton"; export class PrivateRoomRepository implements IPrivateRoomRepository { constructor(private readonly api: PrivateRoomApi) {} - getMoments( - input: GetPrivateRoomMomentsInput = {}, - ): Promise> { - return Result.wrap(() => this.api.getMoments(input)); + getAlbums( + input: GetPrivateAlbumsInput = {}, + ): Promise> { + return Result.wrap(() => this.api.getAlbums(input)); } - unlockMoment( - momentId: string, + unlockAlbum( + albumId: string, expectedCost: number, - ): Promise> { + ): Promise> { return Result.wrap(() => - this.api.unlockMoment( - momentId, - UnlockPrivateRoomMomentRequest.from({ expectedCost }), + this.api.unlockAlbum( + albumId, + UnlockPrivateAlbumRequest.from({ expectedCost }), ), ); } diff --git a/src/data/schemas/private-room/index.ts b/src/data/schemas/private-room/index.ts index 328c63c6..ed5d3256 100644 --- a/src/data/schemas/private-room/index.ts +++ b/src/data/schemas/private-room/index.ts @@ -1,4 +1,4 @@ -export * from "./private_room"; -export * from "./private_room_moments_response"; -export * from "./private_room_unlock_response"; -export * from "./unlock_private_room_moment_request"; +export * from "./private_album"; +export * from "./private_albums_response"; +export * from "./private_album_unlock_response"; +export * from "./unlock_private_album_request"; diff --git a/src/data/schemas/private-room/private_album.ts b/src/data/schemas/private-room/private_album.ts new file mode 100644 index 00000000..ceb88498 --- /dev/null +++ b/src/data/schemas/private-room/private_album.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { + arrayOrEmpty, + booleanOrFalse, + numberOrZero, + schemaOr, + stringOrEmpty, + stringOrNull, +} from "../nullable-defaults"; + +export const PrivateAlbumImageSchema = z.object({ + url: stringOrEmpty, + locked: booleanOrFalse, + index: numberOrZero, +}); + +export const PrivateAlbumLockDetailSchema = z.object({ + locked: booleanOrFalse, +}); + +export const PrivateAlbumSchema = z.object({ + albumId: stringOrEmpty, + title: stringOrEmpty, + content: stringOrNull, + previewText: stringOrEmpty, + imageCount: numberOrZero, + images: arrayOrEmpty(PrivateAlbumImageSchema), + locked: booleanOrFalse, + unlocked: booleanOrFalse, + unlockCost: numberOrZero, + publishedAt: stringOrNull, + lockDetail: schemaOr(PrivateAlbumLockDetailSchema, { locked: false }), +}); + +export type PrivateAlbumImageData = z.output; +export type PrivateAlbumInput = z.input; +export type PrivateAlbumData = z.output; diff --git a/src/data/schemas/private-room/private_album_unlock_response.ts b/src/data/schemas/private-room/private_album_unlock_response.ts new file mode 100644 index 00000000..883f434f --- /dev/null +++ b/src/data/schemas/private-room/private_album_unlock_response.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +import { + arrayOrEmpty, + booleanOrFalse, + numberOrZero, + stringOrEmpty, +} from "../nullable-defaults"; +import { PrivateAlbumImageSchema } from "./private_album"; + +export const PrivateAlbumUnlockReasonSchema = z.enum([ + "ok", + "already_unlocked", + "insufficient_credits", + "cost_changed", + "unlock_in_progress", + "deduct_failed", + "persist_failed_refunded", + "not_found", +]); + +export const PrivateAlbumUnlockResponseSchema = z.object({ + albumId: stringOrEmpty, + locked: booleanOrFalse, + unlocked: booleanOrFalse, + reason: PrivateAlbumUnlockReasonSchema.or(stringOrEmpty), + unlockCost: numberOrZero, + requiredCredits: numberOrZero, + creditBalance: numberOrZero, + shortfallCredits: numberOrZero, + images: arrayOrEmpty(PrivateAlbumImageSchema), +}); + +export type PrivateAlbumUnlockReason = z.output< + typeof PrivateAlbumUnlockReasonSchema +>; +export type PrivateAlbumUnlockResponseInput = z.input< + typeof PrivateAlbumUnlockResponseSchema +>; +export type PrivateAlbumUnlockResponseData = z.output< + typeof PrivateAlbumUnlockResponseSchema +>; diff --git a/src/data/schemas/private-room/private_albums_response.ts b/src/data/schemas/private-room/private_albums_response.ts new file mode 100644 index 00000000..31891a80 --- /dev/null +++ b/src/data/schemas/private-room/private_albums_response.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { arrayOrEmpty, numberOrZero } from "../nullable-defaults"; +import { PrivateAlbumSchema } from "./private_album"; + +export const PrivateAlbumsResponseSchema = z.object({ + items: arrayOrEmpty(PrivateAlbumSchema), + creditBalance: numberOrZero, +}); + +export type PrivateAlbumsResponseInput = z.input< + typeof PrivateAlbumsResponseSchema +>; +export type PrivateAlbumsResponseData = z.output< + typeof PrivateAlbumsResponseSchema +>; diff --git a/src/data/schemas/private-room/private_room.ts b/src/data/schemas/private-room/private_room.ts deleted file mode 100644 index 479f7d8d..00000000 --- a/src/data/schemas/private-room/private_room.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { z } from "zod"; - -import { - arrayOrEmpty, - booleanOrFalse, - numberOrZero, - schemaOr, - stringOrEmpty, - stringOrNull, -} from "../nullable-defaults"; - -export const PrivateRoomProfileSchema = z.object({ - characterId: stringOrEmpty, - displayName: stringOrEmpty, - authorName: stringOrEmpty, - avatarUrl: stringOrNull, - coverUrl: stringOrNull, - title: stringOrEmpty, - subtitle: stringOrEmpty, -}); - -export const PrivateRoomImageSchema = z.object({ - url: stringOrNull, - type: stringOrEmpty, - locked: booleanOrFalse, - index: numberOrZero, -}); - -export const PrivateRoomLockDetailSchema = z.object({ - locked: booleanOrFalse, - showContent: booleanOrFalse, - showUpgrade: booleanOrFalse, - reason: stringOrNull, - hint: stringOrNull, - actionLabel: stringOrNull, - type: stringOrNull, - requiredCredits: numberOrZero, - currentCredits: numberOrZero, - shortfallCredits: numberOrZero, - mediaCount: numberOrZero, - unlockCostPerImage: numberOrZero, - detail: z.unknown().nullable().default(null), -}); - -export const PrivateRoomAuthorSchema = z.object({ - id: stringOrEmpty, - name: stringOrEmpty, - avatarUrl: stringOrNull, -}); - -export const PrivateRoomMomentSchema = z.object({ - momentId: stringOrEmpty, - source: stringOrEmpty, - sourceId: stringOrEmpty, - characterId: stringOrEmpty, - author: schemaOr(PrivateRoomAuthorSchema, { - id: "", - name: "", - avatarUrl: null, - }), - createdAt: stringOrNull, - publishedAt: stringOrNull, - timeText: stringOrEmpty, - title: stringOrEmpty, - content: stringOrNull, - text: stringOrNull, - textPreview: stringOrEmpty, - mediaCount: numberOrZero, - images: arrayOrEmpty(PrivateRoomImageSchema), - locked: booleanOrFalse, - unlocked: booleanOrFalse, - unlockCost: numberOrZero, - unlockCostPerImage: numberOrZero, - requiredCredits: numberOrZero, - currency: stringOrEmpty, - lockDetail: schemaOr(PrivateRoomLockDetailSchema, { - locked: false, - showContent: false, - showUpgrade: false, - reason: null, - hint: null, - actionLabel: null, - type: null, - requiredCredits: 0, - currentCredits: 0, - shortfallCredits: 0, - mediaCount: 0, - unlockCostPerImage: 0, - detail: null, - }), -}); - -export type PrivateRoomProfileData = z.output< - typeof PrivateRoomProfileSchema ->; -export type PrivateRoomImageData = z.output; -export type PrivateRoomLockDetailData = z.output< - typeof PrivateRoomLockDetailSchema ->; -export type PrivateRoomAuthorData = z.output; -export type PrivateRoomMomentInput = z.input; -export type PrivateRoomMomentData = z.output; diff --git a/src/data/schemas/private-room/private_room_moments_response.ts b/src/data/schemas/private-room/private_room_moments_response.ts deleted file mode 100644 index 0257f253..00000000 --- a/src/data/schemas/private-room/private_room_moments_response.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { z } from "zod"; - -import { - arrayOrEmpty, - booleanOrFalse, - numberOrZero, - schemaOr, - stringOrEmpty, - stringOrNull, -} from "../nullable-defaults"; -import { - PrivateRoomMomentSchema, - PrivateRoomProfileSchema, -} from "./private_room"; - -export const PrivateRoomMomentsResponseSchema = z.object({ - profile: schemaOr(PrivateRoomProfileSchema, { - characterId: "elio", - displayName: "Elio Silvestri", - authorName: "Elio Silvestri", - avatarUrl: null, - coverUrl: null, - title: "Elio Private room", - subtitle: "Join me, unlock my private room", - }), - items: arrayOrEmpty(PrivateRoomMomentSchema), - nextCursor: stringOrNull, - hasMore: booleanOrFalse, - creditBalance: numberOrZero, - unlockCostDefault: numberOrZero, - unlockCostPerImage: numberOrZero, - currency: stringOrEmpty, - source: stringOrEmpty, -}); - -export type PrivateRoomMomentsResponseInput = z.input< - typeof PrivateRoomMomentsResponseSchema ->; -export type PrivateRoomMomentsResponseData = z.output< - typeof PrivateRoomMomentsResponseSchema ->; diff --git a/src/data/schemas/private-room/private_room_unlock_response.ts b/src/data/schemas/private-room/private_room_unlock_response.ts deleted file mode 100644 index d2feae04..00000000 --- a/src/data/schemas/private-room/private_room_unlock_response.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { z } from "zod"; - -import { booleanOrFalse, numberOrZero, stringOrEmpty } from "../nullable-defaults"; -import { PrivateRoomMomentSchema } from "./private_room"; - -export const PrivateRoomUnlockReasonSchema = z.enum([ - "ok", - "already_unlocked", - "insufficient_credits", - "cost_changed", - "not_found", - "no_media", - "deduct_failed", - "ok_persist_failed", -]); - -export const PrivateRoomUnlockResponseSchema = - PrivateRoomMomentSchema.extend({ - reason: PrivateRoomUnlockReasonSchema.or(stringOrEmpty), - creditsCharged: numberOrZero, - creditBalance: numberOrZero, - previousCreditBalance: numberOrZero, - currentCredits: numberOrZero, - shortfallCredits: numberOrZero, - persisted: booleanOrFalse, - }); - -export type PrivateRoomUnlockReason = z.output< - typeof PrivateRoomUnlockReasonSchema ->; -export type PrivateRoomUnlockResponseInput = z.input< - typeof PrivateRoomUnlockResponseSchema ->; -export type PrivateRoomUnlockResponseData = z.output< - typeof PrivateRoomUnlockResponseSchema ->; diff --git a/src/data/schemas/private-room/unlock_private_album_request.ts b/src/data/schemas/private-room/unlock_private_album_request.ts new file mode 100644 index 00000000..3874f2b3 --- /dev/null +++ b/src/data/schemas/private-room/unlock_private_album_request.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +import { numberOrZero } from "../nullable-defaults"; + +export const UnlockPrivateAlbumRequestSchema = z.object({ + expectedCost: numberOrZero, +}); + +export type UnlockPrivateAlbumRequestInput = z.input< + typeof UnlockPrivateAlbumRequestSchema +>; +export type UnlockPrivateAlbumRequestData = z.output< + typeof UnlockPrivateAlbumRequestSchema +>; diff --git a/src/data/schemas/private-room/unlock_private_room_moment_request.ts b/src/data/schemas/private-room/unlock_private_room_moment_request.ts deleted file mode 100644 index 07705c7d..00000000 --- a/src/data/schemas/private-room/unlock_private_room_moment_request.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from "zod"; - -import { numberOrZero } from "../nullable-defaults"; - -export const UnlockPrivateRoomMomentRequestSchema = z.object({ - expectedCost: numberOrZero, -}); - -export type UnlockPrivateRoomMomentRequestInput = z.input< - typeof UnlockPrivateRoomMomentRequestSchema ->; -export type UnlockPrivateRoomMomentRequestData = z.output< - typeof UnlockPrivateRoomMomentRequestSchema ->; diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 677a8886..24e5153b 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -84,12 +84,12 @@ export class ApiPath { static readonly chatUnlockHistory = `${ApiPath._chat}/unlock-history`; // ============ 私密空间相关 ============ - /** 获取私密空间动态列表 */ - static readonly privateRoomMoments = `${ApiPath._privateRoom}/moments`; + /** 获取私密图片包列表 */ + static readonly privateRoomAlbums = `${ApiPath._privateRoom}/albums`; - /** 解锁私密空间动态 */ - static privateRoomMomentUnlock(momentId: string): string { - return `${ApiPath.privateRoomMoments}/${encodeURIComponent(momentId)}/unlock`; + /** 解锁私密图片包 */ + static privateRoomAlbumUnlock(albumId: string): string { + return `${ApiPath.privateRoomAlbums}/${encodeURIComponent(albumId)}/unlock`; } // ============ 数据看板相关 ============ diff --git a/src/data/services/api/private_room_api.ts b/src/data/services/api/private_room_api.ts index ea4b64b3..cfcc9ea4 100644 --- a/src/data/services/api/private_room_api.ts +++ b/src/data/services/api/private_room_api.ts @@ -1,48 +1,46 @@ import { - PrivateRoomMomentsResponse, - PrivateRoomUnlockResponse, - UnlockPrivateRoomMomentRequest, + PrivateAlbumsResponse, + PrivateAlbumUnlockResponse, + UnlockPrivateAlbumRequest, } from "@/data/dto/private-room"; import { ApiPath } from "./api_path"; import { httpClient } from "./http_client"; import { type ApiEnvelope, unwrap } from "./response_helper"; -export interface GetPrivateRoomMomentsInput { +export interface GetPrivateAlbumsInput { character?: string; limit?: number; - cursor?: string | null; } export class PrivateRoomApi { - async getMoments( - input: GetPrivateRoomMomentsInput = {}, - ): Promise { + async getAlbums( + input: GetPrivateAlbumsInput = {}, + ): Promise { const env = await httpClient>( - ApiPath.privateRoomMoments, + ApiPath.privateRoomAlbums, { query: { character: input.character ?? "elio", limit: input.limit ?? 20, - ...(input.cursor ? { cursor: input.cursor } : {}), }, }, ); - return PrivateRoomMomentsResponse.fromJson(unwrap(env)); + return PrivateAlbumsResponse.fromJson(unwrap(env)); } - async unlockMoment( - momentId: string, - body: UnlockPrivateRoomMomentRequest, - ): Promise { + async unlockAlbum( + albumId: string, + body: UnlockPrivateAlbumRequest, + ): Promise { const env = await httpClient>( - ApiPath.privateRoomMomentUnlock(momentId), + ApiPath.privateRoomAlbumUnlock(albumId), { method: "POST", body: body.toJson(), }, ); - return PrivateRoomUnlockResponse.fromJson(unwrap(env)); + return PrivateAlbumUnlockResponse.fromJson(unwrap(env)); } } diff --git a/src/lib/private-room/private_album.ts b/src/lib/private-room/private_album.ts new file mode 100644 index 00000000..d810f0b1 --- /dev/null +++ b/src/lib/private-room/private_album.ts @@ -0,0 +1,5 @@ +import type { PrivateAlbum } from "@/data/dto/private-room"; + +export function isPrivateAlbumLocked(album: PrivateAlbum): boolean { + return album.locked || !album.unlocked || album.lockDetail.locked; +} 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 9ad516a9..c5cce261 100644 --- a/src/stores/private-room/__tests__/private-room-machine.test.ts +++ b/src/stores/private-room/__tests__/private-room-machine.test.ts @@ -2,259 +2,208 @@ import { describe, expect, it } from "vitest"; import { createActor, fromPromise, waitFor } from "xstate"; import { - PrivateRoomMomentsResponse, - PrivateRoomUnlockResponse, + PrivateAlbumsResponse, + PrivateAlbumUnlockResponse, } from "@/data/dto/private-room"; import { privateRoomMachine } from "@/stores/private-room/private-room-machine"; -const baseMoment = { - momentId: "schedule:91", - source: "elio_schedules", - sourceId: "91", - characterId: "elio", - author: { - id: "elio", - name: "Elio Silvestri", - avatarUrl: null, - }, - createdAt: "2026-07-01T08:11:35+00:00", - publishedAt: "2026-07-01T08:11:35+00:00", - timeText: "7 days ago", - title: "Paris morning", - content: null, - text: null, - textPreview: "Unlock to view private room photos", - mediaCount: 1, - images: [ - { - url: null, - type: "image", - locked: true, - index: 0, - }, - ], - locked: true, - unlocked: false, - unlockCost: 40, - unlockCostPerImage: 40, - requiredCredits: 40, - currency: "credits", - lockDetail: { - locked: true, - showContent: false, - showUpgrade: true, - reason: "private_room_moment", - hint: "40 credits · Unlock photo", - actionLabel: "Unlock", - type: "private_room_moment", - requiredCredits: 40, - currentCredits: 100, - shortfallCredits: 0, - mediaCount: 1, - unlockCostPerImage: 40, - }, -}; +const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000"; +const COVER_URL = + "https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg"; -function makeMomentsResponse( - overrides: Partial[0]> = {}, -): PrivateRoomMomentsResponse { - return PrivateRoomMomentsResponse.from({ - profile: { - characterId: "elio", - displayName: "Elio Silvestri", - authorName: "Elio Silvestri", - avatarUrl: null, - coverUrl: null, - title: "Elio Private room", - subtitle: "Join me, unlock my private room", - }, - items: [baseMoment], - nextCursor: null, - hasMore: false, - creditBalance: 100, - unlockCostDefault: 40, - unlockCostPerImage: 40, - currency: "credits", - source: "elio_schedules", +function makeAlbum(index = 0) { + return { + albumId: index === 0 ? ALBUM_ID : `album-${index}`, + title: `Private album ${index + 1}`, + content: null, + previewText: "Only for you.", + imageCount: 8, + images: [{ url: `${COVER_URL}?album=${index}`, locked: true, index: 0 }], + locked: true, + unlocked: false, + unlockCost: 320, + publishedAt: "2026-07-13T00:00:00+00:00", + lockDetail: { locked: true }, + }; +} + +function makeAlbumsResponse( + overrides: Partial[0]> = {}, +): PrivateAlbumsResponse { + return PrivateAlbumsResponse.from({ + items: [makeAlbum()], + creditBalance: 500, ...overrides, }); } function makeUnlockResponse( - overrides: Partial[0]> = {}, -): PrivateRoomUnlockResponse { - return PrivateRoomUnlockResponse.from({ - ...baseMoment, + overrides: Partial[0]> = {}, +): PrivateAlbumUnlockResponse { + return PrivateAlbumUnlockResponse.from({ + albumId: ALBUM_ID, locked: false, unlocked: true, - images: [ - { - url: "https://example.supabase.co/storage/v1/object/public/elio-schedules/photo.jpg", - type: "image", - locked: false, - index: 0, - }, - ], reason: "ok", - creditsCharged: 40, - creditBalance: 60, + unlockCost: 320, + requiredCredits: 320, + creditBalance: 180, + shortfallCredits: 0, + images: Array.from({ length: 8 }, (_, index) => ({ + url: `${COVER_URL}?image=${index}`, + locked: false, + index, + })), ...overrides, }); } -function createTestMachine( - options: { - firstPage?: PrivateRoomMomentsResponse; - morePage?: PrivateRoomMomentsResponse; - unlockResponse?: PrivateRoomUnlockResponse; - } = {}, -) { +function createTestMachine(options: { + loadResponse?: PrivateAlbumsResponse; + unlockResponse?: PrivateAlbumUnlockResponse; + onLoad?: () => void; +} = {}) { return privateRoomMachine.provide({ actors: { - loadMoments: fromPromise(async () => options.firstPage ?? makeMomentsResponse()), - loadMoreMoments: fromPromise(async () => - options.morePage ?? - makeMomentsResponse({ - items: [ - { - ...baseMoment, - momentId: "schedule:92", - sourceId: "92", - }, - ], - hasMore: false, - nextCursor: null, - }), - ), - unlockMoment: fromPromise(async () => - options.unlockResponse ?? makeUnlockResponse(), + loadAlbums: fromPromise(async () => { + options.onLoad?.(); + return options.loadResponse ?? makeAlbumsResponse(); + }), + unlockAlbum: fromPromise(async () => + (options.unlockResponse ?? makeUnlockResponse()), ), }, }); } +async function loadMachine(options: Parameters[0] = {}) { + const actor = createActor(createTestMachine(options)).start(); + actor.send({ type: "PrivateRoomInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + return actor; +} + +async function unlockFirstAlbum( + actor: Awaited>, +) { + actor.send({ + type: "PrivateRoomUnlockRequested", + albumId: ALBUM_ID, + }); + actor.send({ type: "PrivateRoomUnlockConfirmed" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); +} + describe("privateRoomMachine", () => { - it("loads the first page", async () => { - const actor = createActor(createTestMachine()).start(); - - actor.send({ type: "PrivateRoomInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - const context = actor.getSnapshot().context; - expect(context.profile?.displayName).toBe("Elio Silvestri"); - expect(context.items).toHaveLength(1); - expect(context.creditBalance).toBe(100); - - actor.stop(); - }); - - it("appends more moments without overwriting existing items", async () => { - const actor = createActor( - createTestMachine({ - firstPage: makeMomentsResponse({ - hasMore: true, - nextCursor: "cursor-1", - }), + it("loads only the first album page into its non-paginated state", async () => { + const actor = await loadMachine({ + loadResponse: makeAlbumsResponse({ + items: Array.from({ length: 20 }, (_, index) => makeAlbum(index)), }), - ).start(); - - actor.send({ type: "PrivateRoomInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - actor.send({ type: "PrivateRoomLoadMore" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - expect(actor.getSnapshot().context.items.map((item) => item.momentId)).toEqual([ - "schedule:91", - "schedule:92", - ]); + }); + const context = actor.getSnapshot().context; + expect(context.items).toHaveLength(20); + expect(context.creditBalance).toBe(500); + expect(context).not.toHaveProperty("hasMore"); + expect(context).not.toHaveProperty("nextCursor"); actor.stop(); }); - it("replaces a locked moment after unlock success", async () => { - const actor = createActor(createTestMachine()).start(); - - actor.send({ type: "PrivateRoomInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - actor.send({ - type: "PrivateRoomUnlockRequested", - momentId: "schedule:91", - }); - actor.send({ type: "PrivateRoomUnlockConfirmed" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - + it("patches the album images after a successful unlock", async () => { + const actor = await loadMachine(); + await unlockFirstAlbum(actor); const context = actor.getSnapshot().context; - expect(context.items[0]?.locked).toBe(false); - expect(context.items[0]?.images[0]?.url).toContain("https://"); + + expect(context.items[0]).toMatchObject({ + albumId: ALBUM_ID, + locked: false, + unlocked: true, + title: "Private album 1", + }); + expect(context.items[0]?.images).toHaveLength(8); + expect(context.items[0]?.lockDetail).toEqual({ locked: false }); expect(context.unlockSuccessNonce).toBe(1); - actor.stop(); }); - it("exposes a paywall request for insufficient credits", async () => { - const actor = createActor( - createTestMachine({ - unlockResponse: makeUnlockResponse({ - ...baseMoment, - reason: "insufficient_credits", - locked: true, - unlocked: false, - creditBalance: 10, - currentCredits: 10, - shortfallCredits: 30, - }), + it("keeps the album locked and exposes a paywall request when credits are low", async () => { + const actor = await loadMachine({ + unlockResponse: makeUnlockResponse({ + locked: true, + unlocked: false, + reason: "insufficient_credits", + creditBalance: 100, + shortfallCredits: 220, + images: [{ url: COVER_URL, locked: true, index: 0 }], }), - ).start(); - - actor.send({ type: "PrivateRoomInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - actor.send({ - type: "PrivateRoomUnlockRequested", - momentId: "schedule:91", }); - actor.send({ type: "PrivateRoomUnlockConfirmed" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - + await unlockFirstAlbum(actor); const context = actor.getSnapshot().context; - expect(context.items[0]?.locked).toBe(true); - expect(context.unlockPaywallRequest).toMatchObject({ - momentId: "schedule:91", - reason: "insufficient_credits", - shortfallCredits: 30, - }); + expect(context.items[0]?.locked).toBe(true); + expect(context.unlockPaywallRequest).toEqual({ + albumId: ALBUM_ID, + reason: "insufficient_credits", + requiredCredits: 320, + currentCredits: 100, + shortfallCredits: 220, + }); actor.stop(); }); - it("refreshes and keeps an unlock error when the cost changes", async () => { - const actor = createActor( - createTestMachine({ - unlockResponse: makeUnlockResponse({ - ...baseMoment, - reason: "cost_changed", - locked: true, - unlocked: false, - }), + it("reloads the first page when the expected cost changed", async () => { + let loadCount = 0; + const actor = await loadMachine({ + onLoad: () => { + loadCount += 1; + }, + unlockResponse: makeUnlockResponse({ + locked: true, + unlocked: false, + reason: "cost_changed", }), - ).start(); - - actor.send({ type: "PrivateRoomInit" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); - - actor.send({ - type: "PrivateRoomUnlockRequested", - momentId: "schedule:91", }); - actor.send({ type: "PrivateRoomUnlockConfirmed" }); - await waitFor(actor, (snapshot) => snapshot.matches("ready")); + await unlockFirstAlbum(actor); + expect(loadCount).toBe(2); expect(actor.getSnapshot().context.unlockErrorMessage).toBe( "Unlock price changed. Please confirm again.", ); + actor.stop(); + }); + it("removes an album that no longer exists", async () => { + const actor = await loadMachine({ + unlockResponse: makeUnlockResponse({ + locked: true, + unlocked: false, + reason: "not_found", + images: [], + }), + }); + await unlockFirstAlbum(actor); + + expect(actor.getSnapshot().context.items).toHaveLength(0); + actor.stop(); + }); + + it("keeps a refunded persistence failure locked and updates the balance", async () => { + const actor = await loadMachine({ + unlockResponse: makeUnlockResponse({ + locked: true, + unlocked: false, + reason: "persist_failed_refunded", + creditBalance: 500, + images: [{ url: COVER_URL, locked: true, index: 0 }], + }), + }); + await unlockFirstAlbum(actor); + const context = actor.getSnapshot().context; + + expect(context.items[0]?.locked).toBe(true); + expect(context.creditBalance).toBe(500); + expect(context.unlockErrorMessage).toContain("refunded"); actor.stop(); }); }); diff --git a/src/stores/private-room/private-room-context.tsx b/src/stores/private-room/private-room-context.tsx index 52b20b78..854ece10 100644 --- a/src/stores/private-room/private-room-context.tsx +++ b/src/stores/private-room/private-room-context.tsx @@ -12,19 +12,15 @@ import type { export interface PrivateRoomContextState { status: string; - profile: MachineContext["profile"]; items: MachineContext["items"]; - nextCursor: string | null; - hasMore: boolean; creditBalance: number; errorMessage: string | null; - unlockingMomentId: string | null; + unlockingAlbumId: string | null; unlockErrorMessage: string | null; - pendingConfirmMomentId: string | null; + pendingConfirmAlbumId: string | null; unlockPaywallRequest: MachineContext["unlockPaywallRequest"]; unlockSuccessNonce: number; isLoading: boolean; - isLoadingMore: boolean; isUnlocking: boolean; } @@ -63,19 +59,15 @@ function selectPrivateRoomState( ): PrivateRoomContextState { return { status: String(state.value), - profile: state.context.profile, items: state.context.items, - nextCursor: state.context.nextCursor, - hasMore: state.context.hasMore, creditBalance: state.context.creditBalance, errorMessage: state.context.errorMessage, - unlockingMomentId: state.context.unlockingMomentId, + unlockingAlbumId: state.context.unlockingAlbumId, unlockErrorMessage: state.context.unlockErrorMessage, - pendingConfirmMomentId: state.context.pendingConfirmMomentId, + pendingConfirmAlbumId: state.context.pendingConfirmAlbumId, unlockPaywallRequest: state.context.unlockPaywallRequest, unlockSuccessNonce: state.context.unlockSuccessNonce, isLoading: state.matches("loading"), - isLoadingMore: state.matches("loadingMore"), isUnlocking: state.matches("unlocking"), }; } diff --git a/src/stores/private-room/private-room-events.ts b/src/stores/private-room/private-room-events.ts index 4cfe63c0..bfe40f7d 100644 --- a/src/stores/private-room/private-room-events.ts +++ b/src/stores/private-room/private-room-events.ts @@ -1,8 +1,7 @@ export type PrivateRoomEvent = | { type: "PrivateRoomInit" } | { type: "PrivateRoomRefresh" } - | { type: "PrivateRoomLoadMore" } - | { type: "PrivateRoomUnlockRequested"; momentId: string } + | { type: "PrivateRoomUnlockRequested"; albumId: string } | { type: "PrivateRoomUnlockConfirmed" } | { type: "PrivateRoomUnlockCancelled" } | { type: "PrivateRoomUnlockPaywallConsumed" }; diff --git a/src/stores/private-room/private-room-machine.actors.ts b/src/stores/private-room/private-room-machine.actors.ts index 913ed720..25fdb538 100644 --- a/src/stores/private-room/private-room-machine.actors.ts +++ b/src/stores/private-room/private-room-machine.actors.ts @@ -1,8 +1,8 @@ import { fromPromise } from "xstate"; import type { - PrivateRoomMomentsResponse, - PrivateRoomUnlockResponse, + PrivateAlbumsResponse, + PrivateAlbumUnlockResponse, } from "@/data/dto/private-room"; import { getPrivateRoomRepository } from "@/data/repositories/private_room_repository"; import { Result } from "@/utils"; @@ -12,10 +12,10 @@ import { PRIVATE_ROOM_PAGE_SIZE, } from "./private-room-machine.helpers"; -export const loadPrivateRoomMomentsActor = - fromPromise(async () => { +export const loadPrivateAlbumsActor = + fromPromise(async () => { const repo = getPrivateRoomRepository(); - const result = await repo.getMoments({ + const result = await repo.getAlbums({ character: PRIVATE_ROOM_CHARACTER, limit: PRIVATE_ROOM_PAGE_SIZE, }); @@ -23,27 +23,13 @@ export const loadPrivateRoomMomentsActor = return result.data; }); -export const loadMorePrivateRoomMomentsActor = fromPromise< - PrivateRoomMomentsResponse, - { cursor: string | null } +export const unlockPrivateAlbumActor = fromPromise< + PrivateAlbumUnlockResponse, + { albumId: string; expectedCost: number } >(async ({ input }) => { const repo = getPrivateRoomRepository(); - const result = await repo.getMoments({ - character: PRIVATE_ROOM_CHARACTER, - limit: PRIVATE_ROOM_PAGE_SIZE, - cursor: input.cursor, - }); - if (Result.isErr(result)) throw result.error; - return result.data; -}); - -export const unlockPrivateRoomMomentActor = fromPromise< - PrivateRoomUnlockResponse, - { momentId: string; expectedCost: number } ->(async ({ input }) => { - const repo = getPrivateRoomRepository(); - const result = await repo.unlockMoment( - input.momentId, + const result = await repo.unlockAlbum( + input.albumId, input.expectedCost, ); if (Result.isErr(result)) throw result.error; diff --git a/src/stores/private-room/private-room-machine.helpers.ts b/src/stores/private-room/private-room-machine.helpers.ts index fa001022..ffd3ef03 100644 --- a/src/stores/private-room/private-room-machine.helpers.ts +++ b/src/stores/private-room/private-room-machine.helpers.ts @@ -1,7 +1,7 @@ -import type { - PrivateRoomMoment, - PrivateRoomMomentsResponse, - PrivateRoomUnlockResponse, +import { + PrivateAlbum, + type PrivateAlbumsResponse, + type PrivateAlbumUnlockResponse, } from "@/data/dto/private-room"; import type { @@ -12,77 +12,82 @@ import type { export const PRIVATE_ROOM_CHARACTER = "elio"; export const PRIVATE_ROOM_PAGE_SIZE = 20; -export function applyMomentsResponse( - response: PrivateRoomMomentsResponse, +export function applyAlbumsResponse( + response: PrivateAlbumsResponse, ): Partial { return { - profile: response.profile, items: response.items, - nextCursor: response.nextCursor, - hasMore: response.hasMore, creditBalance: response.creditBalance, errorMessage: null, }; } -export function appendMomentsResponse( - context: PrivateRoomState, - response: PrivateRoomMomentsResponse, -): Partial { - const knownIds = new Set(context.items.map((item) => item.momentId)); - const nextItems = [ - ...context.items, - ...response.items.filter((item) => !knownIds.has(item.momentId)), - ]; - return { - profile: response.profile, - items: nextItems, - nextCursor: response.nextCursor, - hasMore: response.hasMore, - creditBalance: response.creditBalance, - errorMessage: null, - }; +export function patchAlbumFromUnlock( + album: PrivateAlbum, + response: PrivateAlbumUnlockResponse, +): PrivateAlbum { + return PrivateAlbum.from({ + ...album.toJson(), + locked: response.locked, + unlocked: response.unlocked, + unlockCost: response.unlockCost || album.unlockCost, + images: response.images.length > 0 ? response.images : album.images, + lockDetail: { locked: response.locked }, + }); } -export function replaceMoment( - items: readonly PrivateRoomMoment[], - nextMoment: PrivateRoomMoment, -): PrivateRoomMoment[] { - return items.map((item) => - item.momentId === nextMoment.momentId ? nextMoment : item, +export function patchAlbumInList( + items: readonly PrivateAlbum[], + response: PrivateAlbumUnlockResponse, +): PrivateAlbum[] { + return items.map((album) => + album.albumId === response.albumId + ? patchAlbumFromUnlock(album, response) + : album, ); } +export function removeAlbum( + items: readonly PrivateAlbum[], + albumId: string, +): PrivateAlbum[] { + return items.filter((album) => album.albumId !== albumId); +} + export function toPrivateRoomErrorMessage(error: unknown): string { if (error instanceof Error && error.message) return error.message; return "Private room is temporarily unavailable. Please try again."; } export function toUnlockErrorMessage( - response: PrivateRoomUnlockResponse, + response: PrivateAlbumUnlockResponse, ): string | null { switch (response.reason) { case "cost_changed": return "Unlock price changed. Please confirm again."; - case "not_found": - return "This private room post is no longer available."; - case "no_media": - return "This private room post has no photos to unlock."; + case "unlock_in_progress": + return "This album is already unlocking. Please try again shortly."; case "deduct_failed": return "Credit deduction failed. Please try again."; + case "persist_failed_refunded": + return "Unlock failed and your credits were refunded. Please try again."; + case "not_found": + return "This private album is no longer available."; default: - return response.unlocked ? null : "Unlock failed. Please try again."; + return response.unlocked && !response.locked + ? null + : "Unlock failed. Please try again."; } } export function toPaywallRequest( - response: PrivateRoomUnlockResponse, + response: PrivateAlbumUnlockResponse, ): PrivateRoomUnlockPaywallRequest { return { - momentId: response.momentId, + albumId: response.albumId, reason: response.reason, - requiredCredits: response.requiredCredits, - currentCredits: response.currentCredits || response.creditBalance, + requiredCredits: response.requiredCredits || response.unlockCost, + currentCredits: response.creditBalance, shortfallCredits: response.shortfallCredits, }; } diff --git a/src/stores/private-room/private-room-machine.ts b/src/stores/private-room/private-room-machine.ts index 37f51dba..255fb3a9 100644 --- a/src/stores/private-room/private-room-machine.ts +++ b/src/stores/private-room/private-room-machine.ts @@ -1,20 +1,19 @@ import { assign, setup } from "xstate"; -import type { PrivateRoomMoment } from "@/data/dto/private-room"; +import type { PrivateAlbum } from "@/data/dto/private-room"; import type { PrivateRoomEvent } from "./private-room-events"; import { - appendMomentsResponse, - applyMomentsResponse, - replaceMoment, + applyAlbumsResponse, + patchAlbumInList, + removeAlbum, toPaywallRequest, toPrivateRoomErrorMessage, toUnlockErrorMessage, } from "./private-room-machine.helpers"; import { - loadMorePrivateRoomMomentsActor, - loadPrivateRoomMomentsActor, - unlockPrivateRoomMomentActor, + loadPrivateAlbumsActor, + unlockPrivateAlbumActor, } from "./private-room-machine.actors"; import { initialState, type PrivateRoomState } from "./private-room-state"; @@ -22,11 +21,11 @@ export type { PrivateRoomEvent } from "./private-room-events"; export type { PrivateRoomState } from "./private-room-state"; export { initialState } from "./private-room-state"; -function getPendingMoment(context: PrivateRoomState): PrivateRoomMoment | null { - if (!context.pendingConfirmMomentId) return null; +function getPendingAlbum(context: PrivateRoomState): PrivateAlbum | null { + if (!context.pendingConfirmAlbumId) return null; return ( context.items.find( - (item) => item.momentId === context.pendingConfirmMomentId, + (album) => album.albumId === context.pendingConfirmAlbumId, ) ?? null ); } @@ -37,9 +36,8 @@ export const privateRoomMachine = setup({ events: {} as PrivateRoomEvent, }, actors: { - loadMoments: loadPrivateRoomMomentsActor, - loadMoreMoments: loadMorePrivateRoomMomentsActor, - unlockMoment: unlockPrivateRoomMomentActor, + loadAlbums: loadPrivateAlbumsActor, + unlockAlbum: unlockPrivateAlbumActor, }, }).createMachine({ id: "privateRoom", @@ -58,10 +56,10 @@ export const privateRoomMachine = setup({ unlockPaywallRequest: null, }), invoke: { - src: "loadMoments", + src: "loadAlbums", onDone: { target: "ready", - actions: assign(({ event }) => applyMomentsResponse(event.output)), + actions: assign(({ event }) => applyAlbumsResponse(event.output)), }, onError: { target: "failed", @@ -81,90 +79,55 @@ export const privateRoomMachine = setup({ ready: { on: { - PrivateRoomInit: { - target: "loading", - }, - PrivateRoomRefresh: { - target: "loading", - }, - PrivateRoomLoadMore: [ - { - guard: ({ context }) => - context.hasMore && context.nextCursor !== null, - target: "loadingMore", - }, - ], + PrivateRoomInit: "loading", + PrivateRoomRefresh: "loading", PrivateRoomUnlockRequested: { actions: assign(({ event }) => ({ - pendingConfirmMomentId: event.momentId, + pendingConfirmAlbumId: event.albumId, unlockErrorMessage: null, unlockPaywallRequest: null, })), }, PrivateRoomUnlockCancelled: { - actions: assign({ - pendingConfirmMomentId: null, - }), + actions: assign({ pendingConfirmAlbumId: null }), }, PrivateRoomUnlockConfirmed: [ { - guard: ({ context }) => getPendingMoment(context) !== null, + guard: ({ context }) => getPendingAlbum(context) !== null, target: "unlocking", }, ], PrivateRoomUnlockPaywallConsumed: { - actions: assign({ - unlockPaywallRequest: null, - }), - }, - }, - }, - - loadingMore: { - invoke: { - src: "loadMoreMoments", - input: ({ context }) => ({ - cursor: context.nextCursor, - }), - onDone: { - target: "ready", - actions: assign(({ context, event }) => - appendMomentsResponse(context, event.output), - ), - }, - onError: { - target: "ready", - actions: assign(({ event }) => ({ - errorMessage: toPrivateRoomErrorMessage(event.error), - })), + actions: assign({ unlockPaywallRequest: null }), }, }, }, unlocking: { entry: assign(({ context }) => ({ - unlockingMomentId: context.pendingConfirmMomentId, + unlockingAlbumId: context.pendingConfirmAlbumId, unlockErrorMessage: null, unlockPaywallRequest: null, })), invoke: { - src: "unlockMoment", + src: "unlockAlbum", input: ({ context }) => { - const pendingMoment = getPendingMoment(context); + const pendingAlbum = getPendingAlbum(context); return { - momentId: pendingMoment?.momentId ?? "", - expectedCost: pendingMoment?.unlockCost ?? 0, + albumId: pendingAlbum?.albumId ?? "", + expectedCost: pendingAlbum?.unlockCost ?? 0, }; }, onDone: [ { - guard: ({ event }) => event.output.unlocked, + guard: ({ event }) => + event.output.unlocked && !event.output.locked, target: "ready", actions: assign(({ context, event }) => ({ - items: replaceMoment(context.items, event.output), - creditBalance: event.output.creditBalance || context.creditBalance, - pendingConfirmMomentId: null, - unlockingMomentId: null, + items: patchAlbumInList(context.items, event.output), + creditBalance: event.output.creditBalance, + pendingConfirmAlbumId: null, + unlockingAlbumId: null, unlockErrorMessage: null, unlockSuccessNonce: context.unlockSuccessNonce + 1, })), @@ -174,10 +137,10 @@ export const privateRoomMachine = setup({ event.output.reason === "insufficient_credits", target: "ready", actions: assign(({ context, event }) => ({ - items: replaceMoment(context.items, event.output), - creditBalance: event.output.creditBalance || context.creditBalance, - pendingConfirmMomentId: null, - unlockingMomentId: null, + items: patchAlbumInList(context.items, event.output), + creditBalance: event.output.creditBalance, + pendingConfirmAlbumId: null, + unlockingAlbumId: null, unlockErrorMessage: null, unlockPaywallRequest: toPaywallRequest(event.output), })), @@ -186,17 +149,29 @@ export const privateRoomMachine = setup({ guard: ({ event }) => event.output.reason === "cost_changed", target: "loading", actions: assign(({ event }) => ({ - pendingConfirmMomentId: null, - unlockingMomentId: null, + pendingConfirmAlbumId: null, + unlockingAlbumId: null, + unlockErrorMessage: toUnlockErrorMessage(event.output), + })), + }, + { + guard: ({ event }) => event.output.reason === "not_found", + target: "ready", + actions: assign(({ context, event }) => ({ + items: removeAlbum(context.items, event.output.albumId), + creditBalance: event.output.creditBalance, + pendingConfirmAlbumId: null, + unlockingAlbumId: null, unlockErrorMessage: toUnlockErrorMessage(event.output), })), }, { target: "ready", actions: assign(({ context, event }) => ({ - items: replaceMoment(context.items, event.output), - pendingConfirmMomentId: null, - unlockingMomentId: null, + items: patchAlbumInList(context.items, event.output), + creditBalance: event.output.creditBalance, + pendingConfirmAlbumId: null, + unlockingAlbumId: null, unlockErrorMessage: toUnlockErrorMessage(event.output), })), }, @@ -204,8 +179,8 @@ export const privateRoomMachine = setup({ onError: { target: "ready", actions: assign(({ event }) => ({ - pendingConfirmMomentId: null, - unlockingMomentId: null, + pendingConfirmAlbumId: null, + unlockingAlbumId: null, unlockErrorMessage: toPrivateRoomErrorMessage(event.error), })), }, diff --git a/src/stores/private-room/private-room-state.ts b/src/stores/private-room/private-room-state.ts index 16f87d6b..c7bc69c0 100644 --- a/src/stores/private-room/private-room-state.ts +++ b/src/stores/private-room/private-room-state.ts @@ -1,10 +1,7 @@ -import type { - PrivateRoomMoment, - PrivateRoomProfile, -} from "@/data/dto/private-room"; +import type { PrivateAlbum } from "@/data/dto/private-room"; export interface PrivateRoomUnlockPaywallRequest { - momentId: string; + albumId: string; reason: string; requiredCredits: number; currentCredits: number; @@ -12,29 +9,23 @@ export interface PrivateRoomUnlockPaywallRequest { } export interface PrivateRoomState { - profile: PrivateRoomProfile | null; - items: PrivateRoomMoment[]; - nextCursor: string | null; - hasMore: boolean; + items: PrivateAlbum[]; creditBalance: number; errorMessage: string | null; - unlockingMomentId: string | null; + unlockingAlbumId: string | null; unlockErrorMessage: string | null; - pendingConfirmMomentId: string | null; + pendingConfirmAlbumId: string | null; unlockPaywallRequest: PrivateRoomUnlockPaywallRequest | null; unlockSuccessNonce: number; } export const initialState: PrivateRoomState = { - profile: null, items: [], - nextCursor: null, - hasMore: false, creditBalance: 0, errorMessage: null, - unlockingMomentId: null, + unlockingAlbumId: null, unlockErrorMessage: null, - pendingConfirmMomentId: null, + pendingConfirmAlbumId: null, unlockPaywallRequest: null, unlockSuccessNonce: 0, };