diff --git a/e2e/specs/mock/auth/email-login-from-other-options.spec.ts b/e2e/specs/mock/auth/email-login-from-other-options.spec.ts index e55239ab..17760cae 100644 --- a/e2e/specs/mock/auth/email-login-from-other-options.spec.ts +++ b/e2e/specs/mock/auth/email-login-from-other-options.spec.ts @@ -31,5 +31,5 @@ test("user can email login from other sign-in options and return to chat", async expect(["desktop", "android"]).toContain(loginRequest?.postDataJSON().platform); await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Profile" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible(); }); diff --git a/e2e/specs/mock/auth/logout-from-profile.spec.ts b/e2e/specs/mock/auth/logout-from-profile.spec.ts index 215fb868..e781fa50 100644 --- a/e2e/specs/mock/auth/logout-from-profile.spec.ts +++ b/e2e/specs/mock/auth/logout-from-profile.spec.ts @@ -27,9 +27,12 @@ test("user can log out from the profile after email login", async ({ await expect(page).toHaveURL(/\/auth(?:\?.*)?$/); await switchToEmailSignIn(page); await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl }); - await expect(page.getByRole("button", { name: "Profile" })).toBeVisible(); + await page.getByRole("link", { name: "Back to home" }).click(); + await expect(page).toHaveURL( + new RegExp(defaultCharacterSplashPath.replace("?", "\\?") + "$"), + ); - await page.getByRole("button", { name: "Profile" }).click(); + await page.getByRole("button", { name: "Menu" }).click(); await expect(page).toHaveURL( new RegExp(defaultCharacterProfilePath.replace("?", "\\?") + "$"), ); diff --git a/e2e/specs/mock/multi-role-commercial.spec.ts b/e2e/specs/mock/multi-role-commercial.spec.ts new file mode 100644 index 00000000..2c269d2c --- /dev/null +++ b/e2e/specs/mock/multi-role-commercial.spec.ts @@ -0,0 +1,211 @@ +import path from "node:path"; + +import { expect, test, type Page } from "@playwright/test"; + +import { mockCoreApis } from "@e2e/fixtures/api-mocks"; +import { apiEnvelope } from "@e2e/fixtures/data/common"; +import { clearBrowserState } from "@e2e/fixtures/test-helpers"; + +const characters = [ + { + id: "elio", + slug: "elio", + displayName: "Elio Silvestri", + shortName: "Elio", + cover: "elio.png", + }, + { + id: "maya-tan", + slug: "maya", + displayName: "Maya Tan", + shortName: "Maya", + cover: "maya.webp", + }, + { + id: "nayeli-cervantes", + slug: "nayeli", + displayName: "Nayeli Cervantes", + shortName: "Nayeli", + cover: "nayeli.webp", + }, +] as const; + +const previewDirectory = path.join( + process.cwd(), + ".codex", + "artifacts", + "frontend-preview", + "2026-07-22", + "multi-role-commercial", +); + +test.beforeEach(async ({ baseURL, context, page }) => { + await clearBrowserState(context, page, baseURL); + await mockCoreApis(page); + await registerMultiRoleCommercialMocks(page); +}); + +for (const character of characters) { + test(`${character.displayName} homepage renders its assigned cover`, async ({ + page, + }, testInfo) => { + const mobile = testInfo.project.name.includes("mobile"); + await page.setViewportSize( + mobile ? { width: 390, height: 844 } : { width: 1440, height: 900 }, + ); + + await page.goto(`/characters/${character.slug}/splash`); + + const cover = page.locator(`img[src*="${character.cover}"]`); + await expect(cover).toBeVisible(); + await expect(page.getByText(character.displayName).last()).toBeVisible(); + await expect + .poll(() => + cover.evaluate((image: HTMLImageElement) => + image.complete ? image.naturalWidth : 0, + ), + ) + .toBeGreaterThan(0); + + if ( + process.env.FRONTEND_PREVIEW_SCREENSHOTS === "1" && + character.id !== "elio" + ) { + await page.screenshot({ + path: path.join( + previewDirectory, + `${character.slug}-${mobile ? "mobile-390x844" : "desktop-1440x900"}.png`, + ), + animations: "disabled", + }); + } + }); + + test(`${character.displayName} can open a character-scoped Private Zoom`, async ({ + page, + }) => { + const albumRequest = page.waitForRequest((request) => { + const url = new URL(request.url()); + return ( + url.pathname === "/api/private-zoom/albums" && + url.searchParams.get("characterId") === character.id + ); + }); + + await page.goto(`/characters/${character.slug}/private-zoom`); + + await albumRequest; + await expect( + page.getByRole("heading", { name: "Private albums" }), + ).toBeVisible(); + await expect(page.getByText(character.displayName).last()).toBeVisible(); + await expect( + page.getByRole("button", { + name: `View locked collection with 8 images from ${character.displayName}`, + }), + ).toBeVisible(); + }); + + test(`${character.displayName} receives a character-scoped Tip catalog`, async ({ + page, + }) => { + const catalogRequest = page.waitForRequest((request) => { + const url = new URL(request.url()); + return ( + url.pathname === "/api/payment/gift-products" && + url.searchParams.get("characterId") === character.id + ); + }); + + await page.goto(`/characters/${character.slug}/tip`); + + await catalogRequest; + await expect( + page.getByRole("heading", { name: `Buy ${character.shortName} a coffee` }), + ).toBeVisible(); + await expect(page.getByRole("radio", { name: /Small Coffee/ })).toBeChecked(); + await expect( + page.getByRole("button", { name: "Order and Buy" }), + ).toBeEnabled(); + await expect( + page.getByText("This character does not have any gifts available yet."), + ).toHaveCount(0); + }); +} + +async function registerMultiRoleCommercialMocks(page: Page): Promise { + await page.route("**/api/private-zoom/albums**", async (route) => { + const url = new URL(route.request().url()); + const characterId = url.searchParams.get("characterId") ?? "elio"; + const character = + characters.find((candidate) => candidate.id === characterId) ?? characters[0]; + + await route.fulfill({ + json: apiEnvelope({ + creditBalance: 1000, + items: [ + { + albumId: `album-${character.id}`, + title: `${character.shortName} private photos`, + content: null, + previewText: "Unlock this private album", + imageCount: 8, + images: [ + { + url: `/images/private-zoom/banner/${character.slug}.png`, + locked: true, + index: 0, + }, + ], + locked: true, + unlocked: false, + unlockCost: 320, + publishedAt: "2026-07-22T10:00:00+08:00", + lockDetail: { locked: true }, + }, + ], + }), + }); + }); + + await page.route("**/api/payment/gift-products**", async (route) => { + const url = new URL(route.request().url()); + const characterId = url.searchParams.get("characterId") ?? "elio"; + const planPrefix = + characterId === "elio" + ? "tip" + : `tip_${characterId.replaceAll("-", "_")}`; + + await route.fulfill({ + json: apiEnvelope({ + characterId, + categories: [ + { + category: "coffee", + name: "Coffee", + productCount: 1, + imageUrl: null, + }, + ], + plans: [ + { + planId: `${planPrefix}_coffee_usd_4_99`, + planName: "Small Coffee", + orderType: "tip", + tipType: "coffee_small", + category: "coffee", + characterId, + description: "A character-scoped coffee gift", + imageUrl: null, + amountCents: 499, + currency: "USD", + autoRenew: false, + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: 0, + promotionType: null, + }, + ], + }), + }); + }); +} diff --git a/public/images/cover/maya.png b/public/images/cover/maya.png deleted file mode 100644 index 5d4316dd..00000000 Binary files a/public/images/cover/maya.png and /dev/null differ diff --git a/public/images/cover/maya.webp b/public/images/cover/maya.webp new file mode 100644 index 00000000..11244a39 Binary files /dev/null and b/public/images/cover/maya.webp differ diff --git a/public/images/cover/nayeli.png b/public/images/cover/nayeli.png deleted file mode 100644 index 053e9ad6..00000000 Binary files a/public/images/cover/nayeli.png and /dev/null differ diff --git a/public/images/cover/nayeli.webp b/public/images/cover/nayeli.webp new file mode 100644 index 00000000..24db6e91 Binary files /dev/null and b/public/images/cover/nayeli.webp differ diff --git a/src/data/constants/character.ts b/src/data/constants/character.ts index 15f2f23d..acc09bb3 100644 --- a/src/data/constants/character.ts +++ b/src/data/constants/character.ts @@ -96,7 +96,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([ }, assets: { avatar: "/images/avatar/maya.png", - cover: "/images/cover/maya.png", + cover: "/images/cover/maya.webp", chatBackground: "/images/chat/bg-chatpage.png", privateZoomBanner: "/images/private-zoom/banner/maya.png", }, @@ -125,7 +125,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([ }, assets: { avatar: "/images/avatar/nayeli.png", - cover: "/images/cover/nayeli.png", + cover: "/images/cover/nayeli.webp", chatBackground: "/images/chat/bg-chatpage.png", privateZoomBanner: "/images/private-zoom/banner/nayeli.png", },