diff --git a/e2e/fixtures/api-mocks.ts b/e2e/fixtures/api-mocks.ts index 9460179b..8c49dcf7 100644 --- a/e2e/fixtures/api-mocks.ts +++ b/e2e/fixtures/api-mocks.ts @@ -12,7 +12,7 @@ export interface MockCoreApisOptions { paidImageFlow?: boolean; paidImageInsufficientCreditsFlow?: boolean; paidVoiceInsufficientCreditsFlow?: boolean; - psidLoginFlow?: boolean; + topUpHandoffFlow?: boolean; } export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) { @@ -22,14 +22,14 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {} paidImageFlow: options.paidImageFlow ?? false, paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false, paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false, - psidLoginFlow: options.psidLoginFlow ?? false, + topUpHandoffFlow: options.topUpHandoffFlow ?? false, }; const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false }; await registerAuthMocks(page, { chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow, isChatSendTokenExpired: () => chatState.hasExpiredChatSend, - psidLoginFlow: chatOptions.psidLoginFlow, + topUpHandoffFlow: chatOptions.topUpHandoffFlow, }); await registerCharacterMocks(page); await registerUserMocks(page); diff --git a/e2e/fixtures/api/auth.ts b/e2e/fixtures/api/auth.ts index 7ac5a257..799cdf97 100644 --- a/e2e/fixtures/api/auth.ts +++ b/e2e/fixtures/api/auth.ts @@ -1,22 +1,21 @@ import type { Page } from "@playwright/test"; import { apiEnvelope } from "../data/common"; -import { emailLoginResponse, guestLoginResponse, psidLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth"; +import { emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth"; export interface AuthMockState { chatSendTokenRefreshFlow: boolean; isChatSendTokenExpired: () => boolean; - psidLoginFlow: boolean; + topUpHandoffFlow: boolean; } export async function registerAuthMocks(page: Page, state: AuthMockState) { - await page.route("**/api/auth/login/facebook/psid", async (route) => { - if (!state.psidLoginFlow) { + await page.route("**/api/auth/handoff/topup/consume", async (route) => { + if (!state.topUpHandoffFlow) { await route.continue(); return; } - - await route.fulfill({ json: apiEnvelope(psidLoginResponse) }); + await route.fulfill({ json: apiEnvelope(topUpHandoffResponse) }); }); await page.route("**/api/auth/guest", async (route) => { diff --git a/e2e/fixtures/data/auth.ts b/e2e/fixtures/data/auth.ts index 984a85cc..5f5c195e 100644 --- a/e2e/fixtures/data/auth.ts +++ b/e2e/fixtures/data/auth.ts @@ -50,14 +50,16 @@ export const refreshedEmailLoginResponse = { refreshToken: "e2e-refreshed-email-refresh-token", }; -export const psidLoginResponse = { - token: "e2e-psid-guest-token", - refreshToken: "", - matchedBy: "psid", - fbAsid: "", - fbPsid: "e2e-facebook-psid", - hasCompleteFacebookIdentity: false, - isGuest: true, - user: e2eUser, - userId: e2eUser.id, +export const topUpHandoffResponse = { + token: "e2e-messenger-token", + refreshToken: "e2e-messenger-refresh-token", + loginStatus: "facebookMessenger", + merged: false, + user: { + ...e2eEmailUser, + id: "user_e2e_messenger", + username: "E2E Messenger User", + email: "messenger_hash@messenger.cozsweet.invalid", + loginProvider: "facebook_messenger", + }, }; diff --git a/e2e/specs/mock/auth/psid-login-from-external-entry.spec.ts b/e2e/specs/mock/auth/psid-login-from-external-entry.spec.ts index 052fe9bb..13d888d3 100644 --- a/e2e/specs/mock/auth/psid-login-from-external-entry.spec.ts +++ b/e2e/specs/mock/auth/psid-login-from-external-entry.spec.ts @@ -1,49 +1,74 @@ import { expect, test } from "@playwright/test"; import { mockCoreApis } from "@e2e/fixtures/api-mocks"; -import { - clearBrowserState, - defaultCharacterChatUrl, -} from "@e2e/fixtures/test-helpers"; +import { clearBrowserState } from "@e2e/fixtures/test-helpers"; -const psid = "e2e-facebook-psid"; +const handoffToken = "e2e-handoff-token-that-is-at-least-32-characters"; test.beforeEach(async ({ baseURL, context, page }) => { await clearBrowserState(context, page, baseURL); - await mockCoreApis(page, { psidLoginFlow: true }); + await mockCoreApis(page, { topUpHandoffFlow: true }); }); -test("user enters from an external PSID link and becomes a guest", async ({ +test("user consumes a top-up handoff and opens credit packages as a formal Messenger user", async ({ page, }) => { - const psidLoginRequestPromise = page.waitForRequest( - "**/api/auth/login/facebook/psid", + const handoffRequestPromise = page.waitForRequest( + "**/api/auth/handoff/topup/consume", ); - await page.goto(`/external-entry?target=chat&psid=${psid}`); + await page.goto( + `/external-entry?target=topup&handoffToken=${encodeURIComponent(handoffToken)}`, + ); - const psidLoginRequest = await psidLoginRequestPromise; - expect(psidLoginRequest.method()).toBe("POST"); - expect(psidLoginRequest.postDataJSON()).toMatchObject({ - psid, - bindToGuest: true, - }); - expect(psidLoginRequest.postDataJSON().deviceId).toEqual(expect.any(String)); + const handoffRequest = await handoffRequestPromise; + expect(handoffRequest.method()).toBe("POST"); + expect(handoffRequest.postDataJSON()).toEqual({ handoffToken }); - await expect(page).toHaveURL(defaultCharacterChatUrl); - await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); + await expect(page).toHaveURL("/subscription?type=topup"); + await expect( + page.getByRole("button", { name: /Pay and Top Up/i }), + ).toBeVisible(); await expect .poll(() => page.evaluate(() => ({ loginProvider: localStorage.getItem("cozsweet:login_provider"), - guestToken: localStorage.getItem("cozsweet:guest_token"), - psid: localStorage.getItem("cozsweet:psid"), + loginToken: localStorage.getItem("cozsweet:login_token"), + refreshToken: localStorage.getItem("cozsweet:refresh_token"), })), ) .toMatchObject({ - loginProvider: "guest", - guestToken: "e2e-psid-guest-token", - psid, + loginProvider: "facebookMessenger", + loginToken: "e2e-messenger-token", + refreshToken: "e2e-messenger-refresh-token", }); }); + +test("invalid top-up handoff does not log in and is removed from the URL", async ({ + page, +}) => { + await page.route("**/api/auth/handoff/topup/consume", async (route) => { + await route.fulfill({ + status: 410, + json: { + detail: { + code: "HANDOFF_EXPIRED", + message: "充值链接已过期", + }, + }, + }); + }); + + await page.goto( + `/external-entry?target=topup&handoffToken=${encodeURIComponent(handoffToken)}`, + ); + + await expect(page).toHaveURL("/external-entry?target=topup"); + await expect( + page.getByText(/invalid or has expired/i), + ).toBeVisible(); + await expect + .poll(() => page.evaluate(() => localStorage.getItem("cozsweet:login_provider"))) + .not.toBe("facebookMessenger"); +}); diff --git a/e2e/specs/mock/chat/commercial-action-navigation.spec.ts b/e2e/specs/mock/chat/commercial-action-navigation.spec.ts index 7a095982..97adbca8 100644 --- a/e2e/specs/mock/chat/commercial-action-navigation.spec.ts +++ b/e2e/specs/mock/chat/commercial-action-navigation.spec.ts @@ -4,7 +4,6 @@ import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { clearBrowserState, enterChatFromSplash, - expectAuthRedirectToVipChatSubscription, } from "@e2e/fixtures/test-helpers"; test.beforeEach(async ({ baseURL, context, page }) => { @@ -44,12 +43,7 @@ test("asks for consent before opening a user-bound discount plan", async ({ await expect(offer).toContainText("Want me to ask for my best private offer?"); await offer.getByRole("button", { name: "Yes, ask for me" }).click(); - await expect(page).toHaveURL(/\/auth\?redirect=/); - expectAuthRedirectToVipChatSubscription(page); - const redirect = new URL(page.url()).searchParams.get("redirect"); - const subscriptionUrl = new URL(redirect ?? "", "http://e2e.local"); - expect(subscriptionUrl.searchParams.get("planId")).toBe("vip_annual"); - expect(subscriptionUrl.searchParams.get("commercialOfferId")).toBe( - "13ec8a10-58d7-4d24-b66b-8db5699a1aa8", + await expect(page).toHaveURL( + /\/subscription\?type=vip.*planId=vip_annual.*commercialOfferId=13ec8a10/, ); }); diff --git a/e2e/specs/mock/favorite-menu-navigation.spec.ts b/e2e/specs/mock/favorite-menu-navigation.spec.ts index 30055df4..3270d18d 100644 --- a/e2e/specs/mock/favorite-menu-navigation.spec.ts +++ b/e2e/specs/mock/favorite-menu-navigation.spec.ts @@ -182,7 +182,7 @@ async function registerFavoriteMenuMocks(page: Page): Promise { function collectBrowserErrors(page: Page): string[] { const errors: string[] = []; - page.on("pageerror", (error) => errors.push(error.stack ?? error.message)); + page.on("pageerror", (error) => errors.push(error.message)); page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); @@ -193,8 +193,7 @@ function isFeatureRuntimeError(message: string): boolean { const isKnownLocalPreviewDependencyError = message.includes("[ApiLoggingInterceptor]") || message.includes("[next-auth][error][CLIENT_FETCH_ERROR]") || - message.includes("[Result] Result.wrap caught exception") || - message.includes("Cannot read properties of undefined (reading 'waiting')"); + message.includes("[Result] Result.wrap caught exception"); return !isKnownLocalPreviewDependencyError; } diff --git a/e2e/specs/mock/payment/indonesia-qris.spec.ts b/e2e/specs/mock/payment/indonesia-qris.spec.ts new file mode 100644 index 00000000..41715f90 --- /dev/null +++ b/e2e/specs/mock/payment/indonesia-qris.spec.ts @@ -0,0 +1,333 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { mockCoreApis } from "@e2e/fixtures/api-mocks"; +import { apiEnvelope } from "@e2e/fixtures/data/common"; +import { e2eEmailUser } from "@e2e/fixtures/data/user"; +import { + clearBrowserState, + seedEmailSession, +} from "@e2e/fixtures/test-helpers"; + +const idrPlans = { + plans: [ + { + planId: "vip_monthly", + planName: "Monthly", + orderType: "vip_monthly", + amountCents: 19_590_000, + originalAmountCents: 19_590_000, + dailyPriceCents: 653_000, + currency: "IDR", + vipDays: 30, + dolAmount: null, + creditBalance: 3_000, + }, + { + planId: "dol_1000", + planName: "1,000 Credits", + orderType: "dol", + amountCents: 8_890_000, + originalAmountCents: null, + dailyPriceCents: null, + currency: "IDR", + vipDays: null, + dolAmount: 1_000, + creditBalance: 1_000, + }, + ], +}; + +const idrGiftCatalog = { + characterId: "elio", + categories: [ + { + category: "coffee", + name: "Coffee", + productCount: 1, + imageUrl: null, + }, + ], + plans: [ + { + planId: "tip_coffee_usd_4_99", + planName: "Velvet Espresso", + orderType: "tip", + tipType: "coffee_small", + category: "coffee", + characterId: "elio", + description: "Buy Elio a Velvet Espresso", + imageUrl: null, + amountCents: 8_929_900, + currency: "IDR", + autoRenew: false, + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: 0, + promotionType: null, + }, + ], +}; + +async function registerIndonesiaPaymentMocks(page: Page) { + const orderStatuses = new Map(); + const orderPlans = new Map(); + let createOrderCount = 0; + + await page.route("**/api/user/profile", async (route) => { + await route.fulfill({ + json: apiEnvelope({ ...e2eEmailUser, countryCode: "ID" }), + }); + }); + await page.route("**/api/payment/plans**", async (route) => { + await route.fulfill({ json: apiEnvelope(idrPlans) }); + }); + await page.route("**/api/payment/gift-products**", async (route) => { + await route.fulfill({ json: apiEnvelope(idrGiftCatalog) }); + }); + await page.route("**/api/payment/create-order", async (route) => { + createOrderCount += 1; + const body = route.request().postDataJSON() as { planId: string }; + const plan = + idrPlans.plans.find((item) => item.planId === body.planId) ?? + idrGiftCatalog.plans.find((item) => item.planId === body.planId); + const orderId = `order_qris_${body.planId}`; + orderStatuses.set(orderId, "pending"); + orderPlans.set(orderId, { + planId: body.planId, + orderType: plan?.orderType ?? "dol", + }); + await route.fulfill({ + json: apiEnvelope({ + orderId, + payParams: { + provider: "ezpay", + countryCode: "ID", + channelCode: "ID_QRIS_DYNAMIC_QR", + channelType: "QR", + payData: "00020101021226670016COM.NOBUBANK.WWW", + firstChargeAmountCents: plan?.amountCents ?? 0, + currency: "IDR", + }, + }), + }); + }); + await page.route("**/api/payment/order-status**", async (route) => { + const url = new URL(route.request().url()); + const orderId = url.searchParams.get("order_id") ?? ""; + const plan = orderPlans.get(orderId); + await route.fulfill({ + json: apiEnvelope({ + orderId, + status: orderStatuses.get(orderId) ?? "pending", + orderType: plan?.orderType ?? "dol", + planId: plan?.planId ?? null, + creditsAdded: 0, + }), + }); + }); + await page.route("**/api/payment/tip-message", async (route) => { + const body = route.request().postDataJSON() as { orderId: string }; + await route.fulfill({ + json: apiEnvelope({ + orderId: body.orderId, + characterId: "elio", + planId: "tip_coffee_usd_4_99", + productName: "Velvet Espresso", + tipCount: 1, + poolIndex: 0, + message: "Thank you. Your gift made me smile.", + }), + }); + }); + + return { + getCreateOrderCount() { + return createOrderCount; + }, + markPaid(orderId: string) { + orderStatuses.set(orderId, "paid"); + }, + }; +} + +async function prepareIndonesiaUser(page: Page) { + await seedEmailSession(page); + await page.evaluate(() => { + const rawUser = localStorage.getItem("cozsweet:user"); + const user = rawUser ? JSON.parse(rawUser) : {}; + localStorage.setItem( + "cozsweet:user", + JSON.stringify({ ...user, countryCode: "ID" }), + ); + }); +} + +async function expectQrisOrder( + page: Page, + buttonName: RegExp, + planId: string, + formattedAmount: RegExp, +) { + const requestPromise = page.waitForRequest("**/api/payment/create-order"); + await page.getByRole("button", { name: buttonName }).click(); + const request = await requestPromise; + expect(request.postDataJSON()).toMatchObject({ + planId, + payChannel: "ezpay", + }); + + const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" }); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText(formattedAmount); + await expect(dialog).toContainText("Waiting for payment"); + await expect( + dialog.getByRole("img", { name: "QRIS payment QR code" }), + ).toBeVisible(); + return `order_qris_${planId}`; +} + +async function expectCheckoutButtonLayout(page: Page) { + const viewport = page.viewportSize(); + const checkoutBox = await page + .getByRole("button", { name: "Pay and Top Up" }) + .boundingBox(); + const stripeBox = await page.getByRole("button", { name: "Stripe" }).boundingBox(); + expect(viewport).not.toBeNull(); + expect(checkoutBox).not.toBeNull(); + expect(stripeBox).not.toBeNull(); + if (!viewport || !checkoutBox || !stripeBox) return; + expect(checkoutBox.y + checkoutBox.height).toBeLessThanOrEqual(viewport.height); + expect(stripeBox.y + stripeBox.height).toBeLessThanOrEqual(checkoutBox.y); +} + +test.beforeEach(async ({ baseURL, context, page }) => { + await clearBrowserState(context, page, baseURL); + await mockCoreApis(page); +}); + +test("Indonesia VIP defaults to QRIS and completes after status polling", async ({ + page, +}) => { + const payment = await registerIndonesiaPaymentMocks(page); + await prepareIndonesiaUser(page); + await page.goto("/subscription?type=vip"); + + await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute( + "aria-pressed", + "true", + ); + const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" }); + await expect(checkoutButton).toBeDisabled(); + await expectCheckoutButtonLayout(page); + + await page.getByRole("button", { name: /Monthly, 195900 IDR/i }).click(); + const renewalDialog = page.getByRole("dialog", { + name: "Automatic Renewal Confirmation", + }); + await expect(renewalDialog).toBeVisible(); + expect(payment.getCreateOrderCount()).toBe(0); + + await renewalDialog.getByRole("button", { name: "Confirm" }).click(); + await expect(renewalDialog).toBeHidden(); + await expect(checkoutButton).toBeEnabled(); + expect(payment.getCreateOrderCount()).toBe(0); + + const orderId = await expectQrisOrder( + page, + /Pay and Top Up/i, + "vip_monthly", + /Rp\s*195[.,]900/, + ); + expect(payment.getCreateOrderCount()).toBe(1); + payment.markPaid(orderId); + + const success = page.getByRole("alertdialog", { name: "Payment successful" }); + await expect(success).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByRole("dialog", { name: "Scan to pay with QRIS" }), + ).toBeHidden(); +}); + +test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => { + const payment = await registerIndonesiaPaymentMocks(page); + await prepareIndonesiaUser(page); + await page.goto("/subscription?type=topup"); + await expectCheckoutButtonLayout(page); + + const orderId = await expectQrisOrder( + page, + /Pay and Top Up/i, + "dol_1000", + /Rp\s*88[.,]900/, + ); + payment.markPaid(orderId); + + await expect( + page.getByRole("alertdialog", { name: "Payment successful" }), + ).toBeVisible({ timeout: 10_000 }); +}); + +test("payment issue submits Other to the feedback API without creating an order", async ({ + page, +}) => { + const payment = await registerIndonesiaPaymentMocks(page); + await prepareIndonesiaUser(page); + let feedbackBody = ""; + let idempotencyKey = ""; + await page.route("**/api/feedback", async (route) => { + feedbackBody = route.request().postData() ?? ""; + idempotencyKey = route.request().headers()["idempotency-key"] ?? ""; + await route.fulfill({ + json: apiEnvelope({ feedbackId: "feedback-payment-1", duplicate: false }), + }); + }); + + await page.goto("/subscription?type=topup&character=elio"); + await page.getByRole("button", { name: "Payment issue?" }).click(); + const dialog = page.getByRole("dialog", { + name: "What problem did you encounter?", + }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("radio", { name: "Other" }).check(); + await dialog + .getByLabel("Please describe the issue") + .fill("QRIS did not open correctly."); + await dialog.getByRole("button", { name: "Submit" }).click(); + + await expect(page.getByRole("status")).toHaveText( + "Thanks. Your payment issue has been submitted.", + ); + await expect(dialog).toBeHidden(); + expect(payment.getCreateOrderCount()).toBe(0); + expect(idempotencyKey).toMatch(/^payment_feedback_[A-Za-z0-9_-]+$/); + expect(feedbackBody).toContain('name="category"'); + expect(feedbackBody).toContain("payment"); + expect(feedbackBody).toContain( + "Payment issue: Other\r\nDetails: QRIS did not open correctly.", + ); + expect(feedbackBody).toContain('name="context"'); + expect(feedbackBody).toContain('"paymentIssueReason":"other"'); + expect(feedbackBody).toContain('"characterId":"elio"'); +}); + +test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow", async ({ + page, +}) => { + const payment = await registerIndonesiaPaymentMocks(page); + await prepareIndonesiaUser(page); + await page.goto("/characters/elio/tip"); + + await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible(); + const orderId = await expectQrisOrder( + page, + /Order and Buy/i, + "tip_coffee_usd_4_99", + /Rp\s*89[.,]299/, + ); + payment.markPaid(orderId); + + await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("button", { name: "Send another gift" })).toBeVisible(); + await expect( + page.getByRole("dialog", { name: "Scan to pay with QRIS" }), + ).toBeHidden(); +}); diff --git a/package.json b/package.json index 41e30358..a759bb15 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "next-auth": "^4.24.14", "ofetch": "^1.5.1", "pino": "^10.3.1", + "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", "react-icons": "^5.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcc6b8da..615cf8ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: pino: specifier: ^10.3.1 version: 10.3.1 + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -3290,6 +3293,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -7085,6 +7093,10 @@ snapshots: punycode@2.3.1: {} + qrcode.react@4.2.0(react@19.2.4): + dependencies: + react: 19.2.4 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} diff --git a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx index ec551ac0..eb971f8f 100644 --- a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx +++ b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx @@ -7,10 +7,15 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs"; const hiddenLaunch = { handleEzpayCancel: vi.fn(), handleEzpayConfirm: vi.fn(), + handleQrisClose: vi.fn(), handleStripeClose: vi.fn(), handleStripeConfirmed: vi.fn(), isConfirmingEzpay: false, + qrisErrorMessage: null, + qrisPayment: null, + qrisStatus: null, shouldShowEzpayConfirmDialog: false, + shouldShowQrisDialog: false, shouldShowStripeDialog: false, stripeClientSecret: null, }; @@ -70,7 +75,7 @@ describe("PaymentLaunchDialogs", () => { expect(dialog).not.toBeNull(); expect(container.contains(dialog)).toBe(false); expect(dialog?.parentElement?.parentElement).toBe(document.body); - expect(dialog?.textContent).toContain("Continue to GCash?"); + expect(dialog?.textContent).toContain("Continue to payment?"); expect(dialog?.textContent).toContain("Your coffee order is ready."); expect(dialog?.textContent).toContain("Order No. order-123"); expect( @@ -81,4 +86,68 @@ describe("PaymentLaunchDialogs", () => { expect(dialog?.textContent).toContain("Opening..."); expect(document.body.style.overflow).toBe("hidden"); }); + + it("portals an accessible QRIS dialog with display cents and order status", () => { + act(() => + root.render( + , + ), + ); + + const dialog = document.body.querySelector('[role="dialog"]'); + expect(dialog).not.toBeNull(); + expect(container.contains(dialog)).toBe(false); + expect(dialog?.textContent).toContain("Scan to pay with QRIS"); + expect(dialog?.querySelector("svg title")?.textContent).toBe( + "QRIS payment QR code", + ); + expect(dialog?.textContent).toContain("Order No. order-id-qris"); + expect(dialog?.textContent).toContain("Rp"); + expect(dialog?.textContent).toContain("50.000"); + expect(dialog?.textContent).toContain("Waiting for payment"); + }); + + it("shows QRIS failure state and handles empty QR data safely", () => { + act(() => + root.render( + , + ), + ); + + const dialog = document.body.querySelector('[role="dialog"]'); + expect(dialog?.textContent).toContain("QRIS data is unavailable"); + expect(dialog?.textContent).toContain("Payment failed or was cancelled."); + expect(dialog?.textContent).toContain("Close"); + }); }); diff --git a/src/app/_components/payment/__tests__/payment-method-selector.test.tsx b/src/app/_components/payment/__tests__/payment-method-selector.test.tsx index 6f0e6ed0..21cba41e 100644 --- a/src/app/_components/payment/__tests__/payment-method-selector.test.tsx +++ b/src/app/_components/payment/__tests__/payment-method-selector.test.tsx @@ -62,6 +62,27 @@ describe("PaymentMethodSelector", () => { ); }); + it("labels Indonesia Ezpay as QRIS without the GCash logo", () => { + const html = renderToStaticMarkup( + undefined} + />, + ); + + expect(html).toContain('aria-label="QRIS"'); + expect(html).toContain("QRIS by default in Indonesia"); + expect(html).not.toContain("gcash-logo.svg"); + }); + it("renders compact controls without the caption", () => { const html = renderToStaticMarkup( ; @@ -47,6 +53,14 @@ export function PaymentLaunchDialogs({ onConfirm={launch.handleEzpayConfirm} /> ) : null} + {launch.shouldShowQrisDialog && launch.qrisPayment ? ( + + ) : null} {launch.shouldShowStripeDialog && launch.stripeClientSecret ? ( void; + payment: NonNullable; + status: PaymentLaunchFlow["qrisStatus"]; +} + +function formatQrisAmount(amountCents: number, currency: string): string { + const normalizedCurrency = currency.trim().toUpperCase() || "IDR"; + try { + return new Intl.NumberFormat("id-ID", { + style: "currency", + currency: normalizedCurrency, + maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2, + }).format(amountCents / 100); + } catch { + return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`; + } +} + +function qrisStatusMessage( + status: PaymentLaunchFlow["qrisStatus"], + errorMessage: string | null, +): string { + if (status === "failed") return errorMessage || "Payment failed. Please try again."; + if (status === "expired") return errorMessage || "This QRIS order has expired."; + return "Waiting for payment"; +} + +function QrisPaymentDialog({ + errorMessage, + onClose, + payment, + status, +}: QrisPaymentDialogProps) { + const titleId = useId(); + const statusMessage = qrisStatusMessage(status, errorMessage); + + return ( + +
+

+ Scan to pay with QRIS +

+

+ Open a QRIS-compatible banking or wallet app and scan this code. +

+
+
+ {payment.qrData ? ( + + ) : ( +

+ QRIS data is unavailable. Please close this dialog and try again. +

+ )} +
+
+

Order No. {payment.orderId}

+

+ {formatQrisAmount(payment.amountCents, payment.currency)} +

+

+ {statusMessage} +

+
+
+ +
+
+ ); +} + interface EzpayRedirectConfirmDialogProps { description: ReactNode; externalCheckoutAnalyticsKey: string; @@ -91,7 +202,7 @@ function EzpayRedirectConfirmDialog({ >

- Continue to GCash? + Continue to payment?

{description}

Order No. {orderId}

diff --git a/src/app/_components/payment/payment-method-selector.tsx b/src/app/_components/payment/payment-method-selector.tsx index 2e709a37..bf7f7077 100644 --- a/src/app/_components/payment/payment-method-selector.tsx +++ b/src/app/_components/payment/payment-method-selector.tsx @@ -44,13 +44,24 @@ export function PaymentMethodSelector({ }: PaymentMethodSelectorProps) { if (!config.showPaymentMethodSelector) return null; const isCompact = density === "compact"; + const ezpayDisplayName = config.ezpayDisplayName ?? "GCash"; + const configuredPaymentMethods = PAYMENT_METHODS.map((method) => + method.channel === "ezpay" + ? { + ...method, + title: ezpayDisplayName, + logoSrc: + ezpayDisplayName === "GCash" ? method.logoSrc : undefined, + } + : method, + ); const paymentMethods = config.initialPayChannel === "ezpay" - ? [...PAYMENT_METHODS].sort((a, b) => + ? [...configuredPaymentMethods].sort((a, b) => a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0, ) - : PAYMENT_METHODS; + : configuredPaymentMethods; return (
void; handleEzpayConfirm: () => void; + handleQrisClose: () => void; handleStripeClose: () => void; handleStripeConfirmed: () => void; isConfirmingEzpay: boolean; + qrisErrorMessage: string | null; + qrisPayment: QrisPaymentDetails | null; + qrisStatus: PaymentContextState["orderStatus"]; resetLaunchState: () => void; shouldShowEzpayConfirmDialog: boolean; + shouldShowQrisDialog: boolean; shouldShowStripeDialog: boolean; stripeClientSecret: string | null; } +export interface QrisPaymentDetails { + amountCents: number; + currency: string; + orderId: string; + qrData: string; +} + +function paymentParamString( + payParams: Record, + ...keys: string[] +): string | null { + for (const key of keys) { + const value = payParams[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +} + +function paymentParamAmountCents( + payParams: Record, +): number | null { + for (const key of [ + "firstChargeAmountCents", + "first_charge_amount_cents", + "amountCents", + "amount_cents", + ]) { + const value = payParams[key]; + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value; + } + } + return null; +} + function trackPaymentCheckoutOpened( payment: PaymentContextState, checkoutUrl: string, @@ -118,12 +159,40 @@ export function usePaymentLaunchFlow({ string | null >(null); const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false); + const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState( + null, + ); const stripeClientSecret = payment.payParams ? getStripeClientSecret(payment.payParams) : null; - const ezpayPaymentUrl = payment.payParams - ? getPaymentUrl(payment.payParams) - : null; + const ezpayLaunchTarget = + payment.payParams && isEzpayPayment(payment.payParams) + ? resolveEzpayLaunchTarget(payment.payParams) + : null; + const ezpayPaymentUrl = + ezpayLaunchTarget?.kind === "url" + ? ezpayLaunchTarget.paymentUrl + : null; + const selectedPlan = payment.plans.find( + (item) => item.planId === payment.selectedPlanId, + ); + const qrisPayment: QrisPaymentDetails | null = + payment.payParams && + payment.currentOrderId && + ezpayLaunchTarget?.kind === "qr" + ? { + amountCents: + paymentParamAmountCents(payment.payParams) ?? + selectedPlan?.amountCents ?? + 0, + currency: + paymentParamString(payment.payParams, "currency") ?? + selectedPlan?.currency ?? + "IDR", + orderId: payment.currentOrderId, + qrData: ezpayLaunchTarget.qrData, + } + : null; useEffect(() => { if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) { return; @@ -136,11 +205,33 @@ export function usePaymentLaunchFlow({ return; } - const paymentUrl = getPaymentUrl(payment.payParams); - if (paymentUrl) { - const isEzpay = isEzpayPayment(payment.payParams); + const isEzpay = isEzpayPayment(payment.payParams); + if (isEzpay) { + const target = resolveEzpayLaunchTarget(payment.payParams); + if (target.kind === "error") { + trackPaymentCheckoutFailed(payment, "missing_checkout_url"); + paymentDispatch({ + type: "PaymentLaunchFailed", + errorMessage: target.errorMessage, + }); + return; + } - if (!AppEnvUtil.isProduction() && isEzpay) { + if (target.kind === "qr") { + if (!payment.currentOrderId) { + trackPaymentCheckoutFailed(payment, "missing_checkout_url"); + paymentDispatch({ + type: "PaymentLaunchFailed", + errorMessage: "Missing order id before showing QRIS.", + }); + return; + } + trackPaymentCheckoutOpened(payment, "qris_embedded"); + return; + } + + const paymentUrl = target.paymentUrl; + if (!AppEnvUtil.isProduction()) { log.debug(`[${logScope}] ezpay confirmation required`, { orderId: payment.currentOrderId, paymentUrl, @@ -149,24 +240,25 @@ export function usePaymentLaunchFlow({ return; } - if (isEzpay) { - void launchEzpayRedirect({ - orderId: payment.currentOrderId, - paymentUrl, - subscriptionType, - giftCategory, - giftPlanId, - ...(returnTo ? { returnTo } : {}), - ...(characterSlug ? { characterSlug } : {}), - onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl), - onFailed: (errorMessage) => { - trackPaymentCheckoutFailed(payment, "payment_redirect_failed"); - paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }); - }, - }); - return; - } + void launchEzpayRedirect({ + orderId: payment.currentOrderId, + paymentUrl, + subscriptionType, + giftCategory, + giftPlanId, + ...(returnTo ? { returnTo } : {}), + ...(characterSlug ? { characterSlug } : {}), + onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl), + onFailed: (errorMessage) => { + trackPaymentCheckoutFailed(payment, "payment_redirect_failed"); + paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }); + }, + }); + return; + } + const paymentUrl = getPaymentUrl(payment.payParams); + if (paymentUrl) { try { window.location.href = paymentUrl; trackPaymentCheckoutOpened(payment, paymentUrl); @@ -213,10 +305,16 @@ export function usePaymentLaunchFlow({ payParams: payment.payParams, paymentUrl: ezpayPaymentUrl, }); + const shouldShowQrisDialog = Boolean( + qrisPayment && + qrisPayment.orderId !== hiddenQrisOrderId && + !payment.isPaid, + ); function resetLaunchState(): void { setIsConfirmingEzpay(false); setHiddenStripeClientSecret(null); + setHiddenQrisOrderId(null); } function handleStripeClose(): void { @@ -263,15 +361,28 @@ export function usePaymentLaunchFlow({ paymentDispatch({ type: "PaymentReset" }); } + function handleQrisClose(): void { + log.debug(`[${logScope}] qris dialog closed`, { + orderId: qrisPayment?.orderId ?? payment.currentOrderId, + subscriptionType, + }); + setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId); + } + return { ezpayPaymentUrl, handleEzpayCancel, handleEzpayConfirm, + handleQrisClose, handleStripeClose, handleStripeConfirmed, isConfirmingEzpay, + qrisErrorMessage: payment.errorMessage, + qrisPayment, + qrisStatus: payment.orderStatus, resetLaunchState, shouldShowEzpayConfirmDialog, + shouldShowQrisDialog, shouldShowStripeDialog, stripeClientSecret, }; diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index e75a8a75..f61caaf3 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -128,6 +128,7 @@ export function ChatScreen() { const messageLimitBanner = useChatMessageLimitBanner({ upgradePromptVisible: state.upgradePromptVisible, upgradeReason: state.upgradeReason, + paymentGuidance: state.paymentGuidance, }); const firstRechargeOfferBanner = useFirstRechargeOfferBanner({ historyLoaded: state.historyLoaded, @@ -464,6 +465,7 @@ export function ChatScreen() { title={messageLimitBanner.title} description={messageLimitBanner.description} ctaLabel={messageLimitBanner.ctaLabel} + guidance={messageLimitBanner.guidance} onUnlock={messageLimitBanner.unlock} /> ) : ( diff --git a/src/app/chat/components/__tests__/tailwind-components.test.tsx b/src/app/chat/components/__tests__/tailwind-components.test.tsx index e44cb348..d77c447a 100644 --- a/src/app/chat/components/__tests__/tailwind-components.test.tsx +++ b/src/app/chat/components/__tests__/tailwind-components.test.tsx @@ -15,6 +15,7 @@ import { ImageBubble } from "../image-bubble"; import { InsufficientCreditsDialog } from "../insufficient-credits-dialog"; import { MessageAvatar } from "../message-avatar"; import { PrivateMessageCard } from "../private-message-card"; +import { PaymentGuidanceCard } from "../payment-guidance-card"; import { PwaInstallDialog } from "../pwa-install-dialog"; import { TextBubble } from "../text-bubble"; @@ -223,6 +224,19 @@ describe("chat Tailwind components", () => { description="Credits keep the conversation going." ctaLabel="Top up" onUnlock={() => undefined} + guidance={{ + guidanceId: "guidance-elio", + characterId: "elio", + characterName: "Elio Silvestri", + scene: "insufficientCredits", + mode: "guide", + title: "Keep talking with Elio", + copy: "Baby, I want to keep talking with you. Adding credits supports Elio.", + ctaLabel: "View VIP & credit options", + purchaseOptions: ["vip", "topup"], + target: "subscription", + ruleId: "payment_guidance_insufficientCredits", + }} />, ); @@ -232,11 +246,56 @@ describe("chat Tailwind components", () => { expect(html).toContain("
"); expect(html).toContain("Top up now"); expect(html).toContain("Credits keep the conversation going."); + expect(html).toContain("supports Elio"); + expect(html).toContain('data-payment-guidance-id="guidance-elio"'); expect(html).toContain('aria-label="Top up"'); expect(html).toContain("bg-[linear-gradient(135deg,#f96ade_0%,#f657a0_100%)]"); expect(html).toContain("active:scale-98"); }); + it("renders guidance only for the active character and hides care-only CTA", () => { + const guide = { + guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5", + characterId: "elio", + characterName: "Elio Silvestri", + scene: "insufficientCredits" as const, + mode: "guide" as const, + title: "Keep talking with Elio", + copy: "Baby, I want to keep talking with you.", + ctaLabel: "View VIP & credit options", + purchaseOptions: ["vip", "topup"] as const, + target: "subscription" as const, + ruleId: "payment_guidance_insufficientCredits", + }; + const guideHtml = renderWithCharacter( + , + ); + const mismatchHtml = renderWithCharacter( + , + ); + const careHtml = renderWithCharacter( + , + ); + + expect(guideHtml).toContain('data-payment-guidance-mode="guide"'); + expect(guideHtml).toContain("View VIP & credit options"); + expect(mismatchHtml).toBe(""); + expect(careHtml).toContain("food or medicine"); + expect(careHtml).not.toContain("View VIP"); + }); + it("renders ChatHeader guest and member branches", () => { const guestHtml = renderWithCharacter(); const memberHtml = renderWithCharacter( diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 73313964..c7b4698f 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -337,6 +337,7 @@ function renderMessagesWithDateHeaders( privateMessageHint={item.message.privateMessageHint} commercialAction={item.message.commercialAction} chatAction={item.message.chatAction} + paymentGuidance={item.message.paymentGuidance} isUnlockingMessage={ isUnlockingMessage === true && item.message.displayId === unlockingMessageId diff --git a/src/app/chat/components/chat-insufficient-credits-banner.tsx b/src/app/chat/components/chat-insufficient-credits-banner.tsx index 7567f29b..f6132222 100644 --- a/src/app/chat/components/chat-insufficient-credits-banner.tsx +++ b/src/app/chat/components/chat-insufficient-credits-banner.tsx @@ -17,11 +17,13 @@ * - 不直接管理 state machine(chat 机器不感知 UI 层) */ import { useAppNavigator } from "@/router/use-app-navigator"; +import type { PaymentGuidance } from "@/data/schemas/chat"; export interface ChatInsufficientCreditsBannerProps { title?: string; description?: string; - ctaLabel?: string; + ctaLabel?: string | null; + guidance?: PaymentGuidance | null; /** * 自定义点击回调(不传则默认走统一订阅导航) * - 测试时可传 mock @@ -34,9 +36,11 @@ export function ChatInsufficientCreditsBanner({ title = "Insufficient credits\nTop up to continue chatting", description, ctaLabel = "Top up credits to continue", + guidance = null, onUnlock, }: ChatInsufficientCreditsBannerProps) { const navigator = useAppNavigator(); + const activeGuidance = guidance; const titleLines = title.split("\n"); const handleClick = () => { @@ -53,6 +57,7 @@ export function ChatInsufficientCreditsBanner({ role="status" aria-live="polite" data-testid="chat-insufficient-credits-banner" + data-payment-guidance-id={activeGuidance?.guidanceId} >

{titleLines.map((line, index) => ( @@ -67,7 +72,12 @@ export function ChatInsufficientCreditsBanner({ {description}

) : null} - + : null}
); } diff --git a/src/app/chat/components/chat-unlock-dialogs.tsx b/src/app/chat/components/chat-unlock-dialogs.tsx index 0263d980..3bb39a9e 100644 --- a/src/app/chat/components/chat-unlock-dialogs.tsx +++ b/src/app/chat/components/chat-unlock-dialogs.tsx @@ -19,6 +19,7 @@ export function ChatUnlockDialogs({ lockedCount={model.historyUnlock.lockedCount} isLoading={model.historyUnlock.isLoading} errorMessage={model.historyUnlock.errorMessage} + guidance={model.historyUnlock.guidance} onClose={model.closeHistoryUnlock} onConfirm={model.confirmHistoryUnlock} /> @@ -27,6 +28,7 @@ export function ChatUnlockDialogs({ creditBalance={model.unlockPaywallRequest?.creditBalance ?? 0} requiredCredits={model.unlockPaywallRequest?.requiredCredits ?? 0} shortfallCredits={model.unlockPaywallRequest?.shortfallCredits ?? 0} + guidance={model.unlockPaywallRequest?.paymentGuidance ?? null} onClose={model.closePaywall} onConfirm={model.confirmPaywall} /> diff --git a/src/app/chat/components/history-unlock-dialog.tsx b/src/app/chat/components/history-unlock-dialog.tsx index b9f27d76..19b9664b 100644 --- a/src/app/chat/components/history-unlock-dialog.tsx +++ b/src/app/chat/components/history-unlock-dialog.tsx @@ -1,10 +1,13 @@ "use client"; +import type { PaymentGuidance } from "@/data/schemas/chat"; + export interface HistoryUnlockDialogProps { open: boolean; lockedCount: number; isLoading?: boolean; errorMessage?: string | null; + guidance?: PaymentGuidance | null; onClose: () => void; onConfirm: () => void; } @@ -14,10 +17,15 @@ export function HistoryUnlockDialog({ lockedCount, isLoading = false, errorMessage, + guidance = null, onClose, onConfirm, }: HistoryUnlockDialogProps) { if (!open) return null; + const activeGuidance = guidance; + const showAction = + !activeGuidance || + (activeGuidance.mode === "guide" && activeGuidance.ctaLabel !== null); return (
- Unlock previous messages? + {activeGuidance?.title ?? "Unlock previous messages?"}

- We found {lockedCount} locked messages in your chat history. You can - unlock them now to continue the conversation with full context. + {activeGuidance?.copy ?? ( + <> + We found {lockedCount} locked messages in your chat history. You + can unlock them now to continue the conversation with full + context. + + )}

{errorMessage ? (

@@ -51,7 +64,7 @@ export function HistoryUnlockDialog({ > Later - + {isLoading + ? "Unlocking..." + : activeGuidance?.ctaLabel ?? "Unlock now"} + : null}

diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index c6442773..9f4b3c9c 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -22,6 +22,7 @@ export * from "./insufficient-credits-dialog"; export * from "./message-avatar"; export * from "./message-bubble"; export * from "./message-content"; +export * from "./payment-guidance-card"; export * from "./private-message-card"; export * from "./pwa-install-dialog"; export * from "./pwa-install-overlay"; diff --git a/src/app/chat/components/insufficient-credits-dialog.tsx b/src/app/chat/components/insufficient-credits-dialog.tsx index 057efc71..9f25073f 100644 --- a/src/app/chat/components/insufficient-credits-dialog.tsx +++ b/src/app/chat/components/insufficient-credits-dialog.tsx @@ -1,10 +1,13 @@ "use client"; +import type { PaymentGuidance } from "@/data/schemas/chat"; + export interface InsufficientCreditsDialogProps { open: boolean; creditBalance: number; requiredCredits: number; shortfallCredits: number; + guidance?: PaymentGuidance | null; onClose: () => void; onConfirm: () => void; } @@ -14,12 +17,17 @@ export function InsufficientCreditsDialog({ creditBalance, requiredCredits, shortfallCredits, + guidance = null, onClose, onConfirm, }: InsufficientCreditsDialogProps) { if (!open) return null; const showCreditDetail = requiredCredits > 0 || shortfallCredits > 0; + const activeGuidance = guidance; + const showPurchaseAction = + !activeGuidance || + (activeGuidance.mode === "guide" && activeGuidance.ctaLabel !== null); return (
- Not enough credits + {activeGuidance?.title ?? "Not enough credits"}

- Top up your credits to unlock this message and keep the moment going. + {activeGuidance?.copy ?? + "Top up your credits to unlock this message and keep the moment going."}

{showCreditDetail ? ( @@ -70,15 +79,15 @@ export function InsufficientCreditsDialog({ > Later - + {activeGuidance?.ctaLabel ?? "Top up now"} + : null}
diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 76d9622c..a6aba60d 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -9,7 +9,7 @@ * - 用户:[占位 spacer] [Content] [Avatar] */ import { useUserSelector } from "@/stores/user/user-context"; -import type { ChatAction, CommercialAction } from "@/data/schemas/chat"; +import type { ChatAction, CommercialAction, PaymentGuidance } from "@/data/schemas/chat"; import { MessageAvatar } from "./message-avatar"; import { MessageContent } from "./message-content"; @@ -30,6 +30,7 @@ export interface MessageBubbleProps { privateMessageHint?: string | null; commercialAction?: CommercialAction | null; chatAction?: ChatAction | null; + paymentGuidance?: PaymentGuidance | null; isUnlockingMessage?: boolean; onUnlockPrivateMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction; @@ -64,6 +65,7 @@ export function MessageBubble({ privateMessageHint, commercialAction, chatAction, + paymentGuidance, isUnlockingMessage, onUnlockPrivateMessage, onUnlockVoiceMessage, @@ -106,6 +108,7 @@ export function MessageBubble({ privateMessageHint={privateMessageHint} commercialAction={commercialAction} chatAction={chatAction} + paymentGuidance={paymentGuidance} isUnlockingMessage={isUnlockingMessage} onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockVoiceMessage={onUnlockVoiceMessage} diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index e8e24a4b..59319ced 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -1,11 +1,16 @@ "use client"; -import type { ChatAction, CommercialAction } from "@/data/schemas/chat"; +import type { + ChatAction, + CommercialAction, + PaymentGuidance, +} from "@/data/schemas/chat"; import { ChatActionCard } from "./chat-action-card"; import { CommercialActionCard } from "./commercial-action-card"; import { ImageBubble } from "./image-bubble"; import { LockedImageMessageCard } from "./locked-image-message-card"; import { PrivateMessageCard } from "./private-message-card"; +import { PaymentGuidanceCard } from "./payment-guidance-card"; import { TextBubble } from "./text-bubble"; import { VoiceBubble } from "./voice-bubble"; import styles from "./chat-area.module.css"; @@ -25,6 +30,7 @@ export interface MessageContentProps { privateMessageHint?: string | null; commercialAction?: CommercialAction | null; chatAction?: ChatAction | null; + paymentGuidance?: PaymentGuidance | null; isUnlockingMessage?: boolean; onUnlockPrivateMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction; @@ -59,6 +65,7 @@ export function MessageContent({ privateMessageHint, commercialAction, chatAction, + paymentGuidance, isUnlockingMessage, onUnlockPrivateMessage, onUnlockVoiceMessage, @@ -143,7 +150,9 @@ export function MessageContent({ ) : null} )} - {isFromAI && chatAction ? ( + {isFromAI && paymentGuidance ? ( + + ) : isFromAI && chatAction ? ( state.context.isVip); + const [dismissed, setDismissed] = useState(false); + const viewedIdRef = useRef(null); + const belongsToCurrentCharacter = guidance.characterId === character.id; + const hasPurchaseAction = + guidance.mode === "guide" && + guidance.target === "subscription" && + guidance.ctaLabel !== null && + guidance.purchaseOptions.length > 0; + + useEffect(() => { + if (!belongsToCurrentCharacter) return; + if (viewedIdRef.current === guidance.guidanceId) return; + viewedIdRef.current = guidance.guidanceId; + void recordChatActionEventById( + guidance.guidanceId, + character.id, + "viewed", + ).catch(() => undefined); + }, [belongsToCurrentCharacter, character.id, guidance.guidanceId]); + + if (!belongsToCurrentCharacter || dismissed) return null; + + const subscriptionType = + isVip || !guidance.purchaseOptions.includes("vip") ? "topup" : "vip"; + + return ( + + ); +} diff --git a/src/app/chat/hooks/use-chat-message-limit-banner.ts b/src/app/chat/hooks/use-chat-message-limit-banner.ts index 1bc5d6c7..d7950832 100644 --- a/src/app/chat/hooks/use-chat-message-limit-banner.ts +++ b/src/app/chat/hooks/use-chat-message-limit-banner.ts @@ -5,6 +5,9 @@ import { useEffect, useRef } from "react"; import { behaviorAnalytics } from "@/lib/analytics"; import { useAppNavigator } from "@/router/use-app-navigator"; import type { ChatUpgradeReason } from "@/stores/chat/chat-state"; +import type { PaymentGuidance } from "@/data/schemas/chat"; +import { recordChatActionEventById } from "@/lib/chat/chat_action_events"; +import { useActiveCharacter } from "@/providers/character-provider"; import { useUserSelector } from "@/stores/user/user-context"; import { @@ -17,10 +20,14 @@ import { export interface UseChatMessageLimitBannerInput { upgradePromptVisible: boolean; upgradeReason: ChatUpgradeReason | null; + paymentGuidance: PaymentGuidance | null; } -export interface ChatMessageLimitBannerView - extends InsufficientCreditsMessageLimitView { +export interface ChatMessageLimitBannerView { + title: string; + description: string; + ctaLabel: string | null; + guidance: PaymentGuidance | null; visible: boolean; unlock: () => void; } @@ -28,10 +35,19 @@ export interface ChatMessageLimitBannerView export function useChatMessageLimitBanner({ upgradePromptVisible, upgradeReason, + paymentGuidance, }: UseChatMessageLimitBannerInput): ChatMessageLimitBannerView { const navigator = useAppNavigator(); + const character = useActiveCharacter(); const isVip = useUserSelector((state) => state.context.isVip); - const view = getInsufficientCreditsMessageLimitView(); + const fallbackView = getInsufficientCreditsMessageLimitView(); + const guidance = + paymentGuidance?.characterId === character.id ? paymentGuidance : null; + const view: InsufficientCreditsMessageLimitView = { + title: guidance?.title ?? fallbackView.title, + description: guidance?.copy ?? fallbackView.description, + ctaLabel: guidance?.ctaLabel ?? fallbackView.ctaLabel, + }; const visible = shouldShowMessageLimitBanner({ upgradePromptVisible, upgradeReason, @@ -52,12 +68,31 @@ export function useChatMessageLimitBanner({ }, { isVip }, ); - }, [isVip, visible]); + if (guidance) { + void recordChatActionEventById( + guidance.guidanceId, + character.id, + "viewed", + ).catch(() => undefined); + } + }, [character.id, guidance, isVip, visible]); function unlock(): void { + if (guidance?.mode !== "guide" && guidance !== null) return; + if (guidance) { + void recordChatActionEventById( + guidance.guidanceId, + character.id, + "opened", + ).catch(() => undefined); + } navigator.openSubscription({ - type: getInsufficientCreditsSubscriptionType(isVip), + type: + isVip || (guidance && !guidance.purchaseOptions.includes("vip")) + ? "topup" + : getInsufficientCreditsSubscriptionType(isVip), returnTo: "chat", + ...(guidance ? { chatActionId: guidance.guidanceId } : {}), analytics: { entryPoint: "chat_input", triggerReason: "insufficient_credits", @@ -67,6 +102,9 @@ export function useChatMessageLimitBanner({ return { ...view, + ctaLabel: + guidance && guidance.mode !== "guide" ? null : view.ctaLabel, + guidance, visible, unlock, }; diff --git a/src/app/chat/hooks/use-chat-unlock-coordinator.ts b/src/app/chat/hooks/use-chat-unlock-coordinator.ts index 93c74b43..c2a3c510 100644 --- a/src/app/chat/hooks/use-chat-unlock-coordinator.ts +++ b/src/app/chat/hooks/use-chat-unlock-coordinator.ts @@ -20,6 +20,7 @@ import { import type { ChatPromotionState } from "@/stores/chat/helper/promotion"; import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state"; import type { UiMessage } from "@/stores/chat/ui-message"; +import { recordChatActionEventById } from "@/lib/chat/chat_action_events"; import { useUserSelector } from "@/stores/user/user-context"; import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers"; @@ -48,6 +49,7 @@ export interface ChatUnlockDialogModel { lockedCount: number; isLoading: boolean; errorMessage: string | null; + guidance: import("@/data/schemas/chat").PaymentGuidance | null; }; closeHistoryUnlock: () => void; confirmHistoryUnlock: () => void; @@ -87,6 +89,8 @@ export function useChatUnlockCoordinator({ isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }), lockedHistoryCount: state.context.lockedHistoryCount, unlockHistoryError: state.context.unlockHistoryError, + unlockHistoryPaymentGuidance: + state.context.unlockHistoryPaymentGuidance, unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, unlockPaywallRequest: state.context.unlockPaywallRequest, }), @@ -97,6 +101,20 @@ export function useChatUnlockCoordinator({ ? chatState.unlockPaywallRequest : null; + useEffect(() => { + const guidance = chatState.unlockHistoryPaymentGuidance; + if (!enabled || guidance?.characterId !== chatState.characterId) return; + void recordChatActionEventById( + guidance.guidanceId, + chatState.characterId, + "viewed", + ).catch(() => undefined); + }, [ + chatState.characterId, + chatState.unlockHistoryPaymentGuidance, + enabled, + ]); + useEffect(() => { if (!unlockPaywallRequest) { trackedPaywallRef.current = null; @@ -120,7 +138,15 @@ export function useChatUnlockCoordinator({ }, { isVip }, ); - }, [isVip, unlockPaywallRequest]); + const guidance = unlockPaywallRequest.paymentGuidance; + if (guidance?.characterId === chatState.characterId) { + void recordChatActionEventById( + guidance.guidanceId, + chatState.characterId, + "viewed", + ).catch(() => undefined); + } + }, [chatState.characterId, isVip, unlockPaywallRequest]); useEffect(() => { if (!enabled) return; @@ -212,6 +238,32 @@ export function useChatUnlockCoordinator({ } function confirmHistoryUnlock(): void { + const guidance = + chatState.unlockHistoryPaymentGuidance?.characterId === + chatState.characterId + ? chatState.unlockHistoryPaymentGuidance + : null; + if (guidance) { + if (guidance.mode !== "guide") return; + void recordChatActionEventById( + guidance.guidanceId, + chatState.characterId, + "opened", + ).catch(() => undefined); + navigator.openSubscription({ + type: + isVip || !guidance.purchaseOptions.includes("vip") + ? "topup" + : "vip", + returnTo: "chat", + chatActionId: guidance.guidanceId, + analytics: { + entryPoint: "chat_unlock", + triggerReason: "insufficient_credits", + }, + }); + return; + } chatDispatch({ type: "ChatUnlockHistoryConfirmed" }); } @@ -221,6 +273,12 @@ export function useChatUnlockCoordinator({ function confirmPaywall(): void { if (!unlockPaywallRequest) return; + const guidance = + unlockPaywallRequest.paymentGuidance?.characterId === + chatState.characterId + ? unlockPaywallRequest.paymentGuidance + : null; + if (guidance && guidance.mode !== "guide") return; const returnUrl = resolveChatUnlockReturnUrl( unlockPaywallRequest, @@ -231,6 +289,13 @@ export function useChatUnlockCoordinator({ }, ); chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); + if (guidance) { + void recordChatActionEventById( + guidance.guidanceId, + chatState.characterId, + "opened", + ).catch(() => undefined); + } navigator.openSubscriptionForPendingUnlock({ displayMessageId: unlockPaywallRequest.displayMessageId, messageId: unlockPaywallRequest.messageId, @@ -239,13 +304,17 @@ export function useChatUnlockCoordinator({ clientLockId: unlockPaywallRequest.clientLockId, promotion: unlockPaywallRequest.promotion, returnUrl, - type: getInsufficientCreditsSubscriptionType(isVip), + type: + isVip || (guidance && !guidance.purchaseOptions.includes("vip")) + ? "topup" + : getInsufficientCreditsSubscriptionType(isVip), analytics: { entryPoint: "chat_unlock", triggerReason: unlockPaywallRequest.promotion ? "ad_landing" : "insufficient_credits", }, + ...(guidance ? { chatActionId: guidance.guidanceId } : {}), }); } @@ -258,6 +327,7 @@ export function useChatUnlockCoordinator({ lockedCount: chatState.lockedHistoryCount, isLoading: chatState.isUnlockingHistory, errorMessage: chatState.unlockHistoryError, + guidance: chatState.unlockHistoryPaymentGuidance, }, closeHistoryUnlock, confirmHistoryUnlock, diff --git a/src/app/external-entry/external-entry-persist.tsx b/src/app/external-entry/external-entry-persist.tsx index f81ff22e..ff96ab9e 100644 --- a/src/app/external-entry/external-entry-persist.tsx +++ b/src/app/external-entry/external-entry-persist.tsx @@ -21,6 +21,8 @@ import { } from "@/data/constants/character"; import { Logger } from "@/utils/logger"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; +import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff"; +import { Result } from "@/utils/result"; import { isFavoriteEntryRequest, persistFavoriteEntryIntent, @@ -38,6 +40,7 @@ interface ExternalEntryPersistProps { mode: string | null; promotionType: string | null; favorite: string | null; + handoffToken: string | null; } export default function ExternalEntryPersist({ @@ -50,13 +53,16 @@ export default function ExternalEntryPersist({ mode, promotionType, favorite, + handoffToken, }: ExternalEntryPersistProps) { const router = useRouter(); const authState = useAuthState(); const authDispatch = useAuthDispatch(); const [hasPersisted, setHasPersisted] = useState(false); + const [handoffError, setHandoffError] = useState(null); + const [handoffCompleted, setHandoffCompleted] = useState(false); const hasNavigatedRef = useRef(false); - const hasReinitializedForPsidRef = useRef(false); + const handoffStartedRef = useRef(false); const targetRoute = resolveExternalEntryTarget({ target }); const destination = resolveExternalEntryDestination({ target, character }); const characterId = @@ -65,6 +71,12 @@ export default function ExternalEntryPersist({ mode, promotionType, }); + const isTopUpHandoff = targetRoute === ROUTES.subscription; + const displayedHandoffError = + handoffError ?? + (isTopUpHandoff && !hasValue(handoffToken) + ? "This top-up link is invalid. Please request a new link in Messenger." + : null); useEffect(() => { let cancelled = false; @@ -116,12 +128,39 @@ export default function ExternalEntryPersist({ useEffect(() => { if (hasNavigatedRef.current || !hasPersisted) return; - // AuthStatusChecker can initialize before this entry has persisted PSID. - // Re-run the check so PSID direct login is not skipped by that race. - if (hasValue(psid) && !hasReinitializedForPsidRef.current) { + if (isTopUpHandoff) { if (!authState.hasInitialized || authState.isLoading) return; - hasReinitializedForPsidRef.current = true; - authDispatch({ type: "AuthInit" }); + if (handoffCompleted) { + if ( + authState.loginStatus === "notLoggedIn" || + authState.loginStatus === "guest" + ) { + return; + } + hasNavigatedRef.current = true; + router.replace(destination); + return; + } + if (handoffStartedRef.current) return; + if (!hasValue(handoffToken)) return; + handoffStartedRef.current = true; + void (async () => { + const result = await consumeTopUpHandoff(handoffToken); + if (Result.isErr(result)) { + log.warn("[ExternalEntryPersist] top-up handoff failed", result.error); + window.history.replaceState( + window.history.state, + "", + "/external-entry?target=topup", + ); + setHandoffError( + "This top-up link is invalid or has expired. Please request a new link in Messenger.", + ); + return; + } + setHandoffCompleted(true); + authDispatch({ type: "AuthInit" }); + })(); return; } @@ -152,6 +191,9 @@ export default function ExternalEntryPersist({ authState.loginStatus, destination, hasPersisted, + handoffCompleted, + handoffToken, + isTopUpHandoff, psid, router, ]); @@ -161,11 +203,26 @@ export default function ExternalEntryPersist({ className="flex flex-1 items-center justify-center" style={{ minHeight: "100dvh" }} > -
+ {displayedHandoffError ? ( +
+

+ {displayedHandoffError} +

+ +
+ ) : ( +
+ )}
); } diff --git a/src/app/external-entry/page.tsx b/src/app/external-entry/page.tsx index 5303631c..6dfc6319 100644 --- a/src/app/external-entry/page.tsx +++ b/src/app/external-entry/page.tsx @@ -7,6 +7,7 @@ * `/external-entry?target=chat&character=maya` * `/external-entry?target=tip` * `/external-entry?target=private-zone&character=nayeli` + * `/external-entry?target=topup&handoffToken=` * * 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储, * 再通过 `router.replace()` 清理 URL 并跳转到最终页面。 @@ -36,6 +37,7 @@ export default async function ExternalEntryPage({ mode={pickParam(params.mode)} promotionType={pickParam(params.promotion_type)} favorite={pickParam(params.favorite)} + handoffToken={pickParam(params.handoffToken)} /> ); } diff --git a/src/app/private-zone/__tests__/use-private-zone-flow-navigation.test.tsx b/src/app/private-zone/__tests__/use-private-zone-flow-navigation.test.tsx index c5fc2043..993b31f0 100644 --- a/src/app/private-zone/__tests__/use-private-zone-flow-navigation.test.tsx +++ b/src/app/private-zone/__tests__/use-private-zone-flow-navigation.test.tsx @@ -58,13 +58,13 @@ describe("Private Zone paywall navigation", () => { container.remove(); }); - it("opens top up with a private zone return target", () => { + it("opens VIP and credit options for a non-VIP user", () => { const roomDispatch = vi.fn>(); renderHarness(root, "email", roomDispatch); expect(mocks.openSubscription).toHaveBeenCalledWith({ - type: "topup", + type: "vip", returnTo: "private-zone", analytics: { entryPoint: "private_album_unlock", @@ -115,6 +115,7 @@ function Harness({ loginStatus, roomDispatch, unlockPaywallRequest, + isVip: false, }); return null; } diff --git a/src/app/private-zone/private-zone-screen.tsx b/src/app/private-zone/private-zone-screen.tsx index d3d439ad..732bdf97 100644 --- a/src/app/private-zone/private-zone-screen.tsx +++ b/src/app/private-zone/private-zone-screen.tsx @@ -19,6 +19,8 @@ import { } from "@/providers/character-provider"; import { isPrivateAlbumLocked } from "@/lib/private-zone/private_album"; import { behaviorAnalytics } from "@/lib/analytics"; +import { recordChatActionEventById } from "@/lib/chat/chat_action_events"; +import { useUserSelector } from "@/stores/user/user-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { usePrivateZoneDispatch, @@ -63,6 +65,7 @@ export function PrivateZoneScreen() { const dispatch = usePrivateZoneDispatch(); const postState = usePrivateZonePostState(); const postDispatch = usePrivateZonePostDispatch(); + const isVip = useUserSelector((user) => user.context.isVip); const [activeTab, setActiveTab] = useState<"moments" | "albums">("albums"); const openedGalleryInPageRef = useRef(false); const previousPostLoginStatusRef = useRef(authState.loginStatus); @@ -84,6 +87,7 @@ export function PrivateZoneScreen() { loginStatus: authState.loginStatus, roomDispatch: dispatch, unlockPaywallRequest: state.unlockPaywallRequest, + isVip, }); usePrivateZoneUnlockSuccessRefresh(state.unlockSuccessNonce); usePrivateZoneUnlockSuccessRefresh(postState.unlockSuccessNonce); @@ -119,9 +123,24 @@ export function PrivateZoneScreen() { entryPoint: "private_zone", triggerReason: "insufficient_credits", }); + const guidance = + request.paymentGuidance?.characterId === character.id + ? request.paymentGuidance + : null; + if (guidance) { + void recordChatActionEventById( + guidance.guidanceId, + character.id, + "opened", + ).catch(() => undefined); + } navigator.openSubscription({ - type: "topup", + type: + isVip || (guidance && !guidance.purchaseOptions.includes("vip")) + ? "topup" + : "vip", returnTo: "private-zone", + ...(guidance ? { chatActionId: guidance.guidanceId } : {}), analytics: { entryPoint: "private_zone", triggerReason: "insufficient_credits", @@ -132,6 +151,8 @@ export function PrivateZoneScreen() { }, [ authState.loginStatus, characterRoutes.privateZone, + character.id, + isVip, navigator, postDispatch, postState.unlockPaywallRequest, @@ -205,7 +226,7 @@ export function PrivateZoneScreen() { return; } navigator.openSubscription({ - type: "topup", + type: isVip ? "topup" : "vip", returnTo: "private-zone", analytics: { entryPoint: "private_zone", diff --git a/src/app/private-zone/use-private-zone-flow.ts b/src/app/private-zone/use-private-zone-flow.ts index 2c13156b..7fb1b8fb 100644 --- a/src/app/private-zone/use-private-zone-flow.ts +++ b/src/app/private-zone/use-private-zone-flow.ts @@ -5,12 +5,16 @@ import { type Dispatch, useEffect, useRef } from "react"; import type { LoginStatus } from "@/data/schemas/auth"; import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap"; import { behaviorAnalytics } from "@/lib/analytics"; -import { useActiveCharacterRoutes } from "@/providers/character-provider"; +import { + useActiveCharacter, + useActiveCharacterRoutes, +} from "@/providers/character-provider"; import { useAppNavigator } from "@/router/use-app-navigator"; import type { AuthEvent } from "@/stores/auth/auth-events"; import type { PrivateZoneEvent } from "@/stores/private-zone"; import type { PrivateZoneUnlockPaywallRequest } from "@/stores/private-zone/private-zone-state"; import { useUserDispatch } from "@/stores/user/user-context"; +import { recordChatActionEventById } from "@/lib/chat/chat_action_events"; export interface UsePrivateZoneBootstrapFlowInput { authDispatch: Dispatch; @@ -25,6 +29,7 @@ export interface UsePrivateZoneUnlockPaywallNavigationInput { loginStatus: LoginStatus; roomDispatch: Dispatch; unlockPaywallRequest: PrivateZoneUnlockPaywallRequest | null; + isVip: boolean; } export function isPrivateZoneAuthRequired(loginStatus: LoginStatus): boolean { @@ -76,8 +81,10 @@ export function usePrivateZoneUnlockPaywallNavigation({ loginStatus, roomDispatch, unlockPaywallRequest, + isVip, }: UsePrivateZoneUnlockPaywallNavigationInput): void { const navigator = useAppNavigator(); + const character = useActiveCharacter(); const characterRoutes = useActiveCharacterRoutes(); useEffect(() => { @@ -90,9 +97,26 @@ export function usePrivateZoneUnlockPaywallNavigation({ entryPoint: "private_album_unlock", triggerReason: "insufficient_credits", }); + const guidance = unlockPaywallRequest.paymentGuidance; + const activeGuidance = + guidance?.characterId === character.id ? guidance : null; + if (activeGuidance) { + void recordChatActionEventById( + activeGuidance.guidanceId, + activeGuidance.characterId, + "opened", + ).catch(() => undefined); + } navigator.openSubscription({ - type: "topup", + type: + isVip || + (activeGuidance && !activeGuidance.purchaseOptions.includes("vip")) + ? "topup" + : "vip", returnTo: "private-zone", + ...(activeGuidance + ? { chatActionId: activeGuidance.guidanceId } + : {}), analytics: { entryPoint: "private_album_unlock", triggerReason: "insufficient_credits", @@ -102,7 +126,9 @@ export function usePrivateZoneUnlockPaywallNavigation({ roomDispatch({ type: "PrivateZoneUnlockPaywallConsumed" }); }, [ characterRoutes.privateZone, + character.id, loginStatus, + isVip, navigator, roomDispatch, unlockPaywallRequest, diff --git a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx index 4925b6a4..51129ad1 100644 --- a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx +++ b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx @@ -256,6 +256,25 @@ describe("SubscriptionScreen payment selection flow", () => { expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }), ); }); + + it("keeps payment guidance bound to the source character", () => { + act(() => + root.render( + , + ), + ); + + const guidance = container.querySelector( + '[data-payment-guidance-character="maya-tan"]', + ); + expect(guidance?.textContent).toContain("Keep talking with Maya"); + expect(guidance?.textContent).toContain("support Maya"); + expect(guidance?.textContent).toContain("cannot continue"); + expect(guidance?.textContent).not.toContain("Elio"); + }); }); function clickButton(root: ParentNode, label: string): void { diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index 25ae8317..ed745726 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -86,7 +86,7 @@ export function SubscriptionCheckoutButton({ diff --git a/src/app/subscription/components/subscription-screen.module.css b/src/app/subscription/components/subscription-screen.module.css index db4b9fe7..82e04f0a 100644 --- a/src/app/subscription/components/subscription-screen.module.css +++ b/src/app/subscription/components/subscription-screen.module.css @@ -61,6 +61,29 @@ box-shadow: 0 12px 30px rgba(22, 101, 52, 0.12); } +.characterSupportBanner { + margin-top: var(--page-section-gap, 14px); + padding: 14px 16px; + border: 1px solid rgba(246, 87, 160, 0.2); + border-radius: var(--responsive-card-radius-sm, 22px); + background: rgba(255, 255, 255, 0.9); + color: #4a3340; + box-shadow: 0 10px 24px rgba(246, 87, 160, 0.1); +} + +.characterSupportBanner h2 { + margin: 0; + color: #24151d; + font-size: var(--responsive-card-title, 17px); + line-height: 1.25; +} + +.characterSupportBanner p { + margin: 6px 0 0; + font-size: var(--responsive-caption, 14px); + line-height: 1.45; +} + .firstRechargeBanner { display: flex; align-items: center; diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 033c5a74..7b8e3b37 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -8,13 +8,18 @@ import { usePaymentMethodSelection } from "@/app/_hooks/use-payment-method-selec import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics"; import type { PayChannel } from "@/data/schemas/payment"; import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit"; -import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character"; +import { + DEFAULT_CHARACTER, + DEFAULT_CHARACTER_SLUG, + getCharacterBySlug, +} from "@/data/constants/character"; import { behaviorAnalytics, getDefaultPaymentAnalyticsContext, type PaymentAnalyticsContext, } from "@/lib/analytics"; import { getPaymentMethodConfig } from "@/lib/payment/payment_method"; +import { useHasHydrated } from "@/hooks/use-has-hydrated"; import { useUserState } from "@/stores/user/user-context"; import { @@ -74,11 +79,17 @@ export function SubscriptionScreen({ null, ); const userState = useUserState(); + const sourceCharacter = + getCharacterBySlug(sourceCharacterSlug) ?? DEFAULT_CHARACTER; + const hasHydrated = useHasHydrated(); const countryCode = userState.currentUser?.countryCode; const paymentMethodConfig = getPaymentMethodConfig({ countryCode, requestedPayChannel: initialPayChannel, }); + const renderedPaymentMethodConfig = hasHydrated + ? paymentMethodConfig + : { ...paymentMethodConfig, showPaymentMethodSelector: false }; const { payment, paymentDispatch, @@ -257,6 +268,22 @@ export function SubscriptionScreen({

) : null} + {chatActionId ? ( +
+

Keep talking with {sourceCharacter.shortName}

+

+ Without the required VIP access or credits, paid chat and locked + content cannot continue. Choosing an option here is a voluntary + way to support{" "} + {sourceCharacter.shortName}; it is never a test of your feelings. +

+
+ ) : null} + {firstRechargeOffer ? (
Private offer
-

Elio got this price for you

+

+ {sourceCharacter.shortName} got this price for you +

{payment.commercialOffer.message || `Your ${payment.commercialOffer.discountPercent}% discount is already applied below.`} @@ -313,10 +342,14 @@ export function SubscriptionScreen({

diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index ff4f38b3..77f28b98 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -14,6 +14,7 @@ import { type PaymentAnalyticsContext, } from "@/lib/analytics"; import { getPaymentMethodConfig } from "@/lib/payment/payment_method"; +import { useHasHydrated } from "@/hooks/use-has-hydrated"; import { buildTipGiftPath } from "@/lib/tip/tip_gift"; import { useActiveCharacter, @@ -52,11 +53,15 @@ export function TipScreen({ const character = useActiveCharacter(); const characterRoutes = useActiveCharacterRoutes(); const userState = useUserState(); - const supportPrompt = useTipSupportPrompt(); + const hasHydrated = useHasHydrated(); + const supportPrompt = useTipSupportPrompt(character.displayName); const paymentMethodConfig = getPaymentMethodConfig({ countryCode: userState.currentUser?.countryCode, requestedPayChannel: initialPayChannel, }); + const renderedPaymentMethodConfig = hasHydrated + ? paymentMethodConfig + : { ...paymentMethodConfig, showPaymentMethodSelector: false }; const { payment, paymentDispatch } = usePaymentRouteFlow({ catalog: "tip", characterId: character.id, @@ -284,7 +289,7 @@ export function TipScreen({
| null>(null); useEffect(() => { const frameId = requestAnimationFrame(() => { - const nextSelection = selectTipSupportPrompt(readPreviousIndex()); + const nextSelection = selectTipSupportPrompt( + readPreviousIndex(), + Math.random, + characterName, + ); writePreviousIndex(nextSelection.index); setSelection(nextSelection); }); return () => cancelAnimationFrame(frameId); - }, []); + }, [characterName]); return { - prompt: selection?.prompt ?? TIP_SUPPORT_PROMPTS[0], + prompt: + selection?.prompt ?? + TIP_SUPPORT_PROMPTS[0].replaceAll("this character", characterName), isReady: selection !== null, }; } diff --git a/src/core/net/__tests__/chat-websocket-payment-guidance.test.ts b/src/core/net/__tests__/chat-websocket-payment-guidance.test.ts new file mode 100644 index 00000000..a902800e --- /dev/null +++ b/src/core/net/__tests__/chat-websocket-payment-guidance.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ChatWebSocket } from "../chat-websocket"; + +describe("ChatWebSocket payment guidance", () => { + it("parses the same role-bound decision emitted by HTTP", () => { + const socket = new ChatWebSocket("ws://example.test/chat", "token"); + const onPaymentGuidance = vi.fn(); + socket.onPaymentGuidance = onPaymentGuidance; + + receive(socket, { + type: "payment_guidance", + data: { + guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5", + characterId: "maya-tan", + characterName: "Maya Tan", + scene: "voiceLocked", + mode: "guide", + title: "Keep talking with Maya", + copy: "Hey love, I want to keep talking with you.", + ctaLabel: "View VIP & credit options", + purchaseOptions: ["vip", "topup"], + target: "subscription", + ruleId: "payment_guidance_voiceLocked", + }, + }); + + expect(onPaymentGuidance).toHaveBeenCalledWith( + expect.objectContaining({ + characterId: "maya-tan", + scene: "voiceLocked", + }), + ); + }); + + it("ignores malformed guidance instead of rendering the wrong role", () => { + const socket = new ChatWebSocket("ws://example.test/chat", "token"); + const onPaymentGuidance = vi.fn(); + socket.onPaymentGuidance = onPaymentGuidance; + + receive(socket, { + type: "payment_guidance", + data: { characterId: "elio" }, + }); + + expect(onPaymentGuidance).not.toHaveBeenCalled(); + }); +}); + +function receive(socket: ChatWebSocket, payload: unknown): void { + ( + socket as unknown as { handleMessage: (data: string) => void } + ).handleMessage(JSON.stringify(payload)); +} diff --git a/src/core/net/chat-websocket.ts b/src/core/net/chat-websocket.ts index 8eabf692..755a6c59 100644 --- a/src/core/net/chat-websocket.ts +++ b/src/core/net/chat-websocket.ts @@ -15,8 +15,10 @@ import { Logger } from "@/utils/logger"; import { CommercialActionSchema, ChatActionSchema, + PaymentGuidanceSchema, type ChatAction, type CommercialAction, + type PaymentGuidance, } from "@/data/schemas/chat"; import { createClientMessageId } from "@/lib/chat/client_message_id"; @@ -49,6 +51,7 @@ export class ChatWebSocket { onPaywallStatus: ((payload: PaywallStatusPayload) => void) | null = null; onCommercialAction: ((action: CommercialAction) => void) | null = null; onChatAction: ((action: ChatAction) => void) | null = null; + onPaymentGuidance: ((guidance: PaymentGuidance) => void) | null = null; onError: ((errorMessage: string) => void) | null = null; constructor( @@ -146,7 +149,7 @@ export class ChatWebSocket { done?: boolean; audioUrl?: string; error?: string; - data?: { + data?: Record & { image?: { type?: string | null; url?: string | null; @@ -209,6 +212,11 @@ export class ChatWebSocket { if (action.success) this.onChatAction?.(action.data); break; } + case "payment_guidance": { + const guidance = PaymentGuidanceSchema.safeParse(payload.data); + if (guidance.success) this.onPaymentGuidance?.(guidance.data); + break; + } case "error": this.onError?.(payload.error ?? "Unknown error"); break; diff --git a/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts b/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts index c340c4b1..cdace6d1 100644 --- a/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts +++ b/src/data/repositories/__tests__/auth_repository.facebook_identity.test.ts @@ -3,8 +3,6 @@ import { describe, expect, it, vi } from "vitest"; import { AuthRepository } from "@/data/repositories/auth_repository"; import { FacebookIdentityResponseSchema, - FacebookPsidLoginResponseSchema, - LoginStatus, } from "@/data/schemas/auth"; import type { AuthApi } from "@/data/services/api"; import type { IAuthStorage } from "@/data/storage/auth"; @@ -54,85 +52,4 @@ describe("AuthRepository facebook identity", () => { expect(storage.setAsid).toHaveBeenCalledWith("asid-1"); expect(storage.setPsid).toHaveBeenCalledWith("psid-1"); }); - - it("saves real login data from complete psid login responses", async () => { - const facebookPsidLogin = vi.fn(async () => - FacebookPsidLoginResponseSchema.parse({ - token: "login-token", - refreshToken: "refresh-token", - matchedBy: "psid", - fbAsid: "asid-1", - fbPsid: "psid-1", - hasCompleteFacebookIdentity: true, - isGuest: false, - user: { id: "user-1", username: "Elio" }, - }), - ); - const { repository, storage, userStorage } = createRepository({ - facebookPsidLogin, - }); - - const result = await repository.facebookPsidLogin({ - psid: "psid-1", - deviceId: "device-1", - }); - - expect(result).toEqual(Result.ok(LoginStatus.Facebook)); - expect(storage.setLoginToken).toHaveBeenCalledWith("login-token"); - expect(storage.setRefreshToken).toHaveBeenCalledWith("refresh-token"); - expect(storage.setLoginProvider).toHaveBeenCalledWith(LoginStatus.Facebook); - expect(userStorage.setUserId).toHaveBeenCalledWith("user-1"); - expect(storage.setAsid).toHaveBeenCalledWith("asid-1"); - expect(storage.setPsid).toHaveBeenCalledWith("psid-1"); - }); - - it("saves guest data from incomplete psid login responses", async () => { - const facebookPsidLogin = vi.fn(async () => - FacebookPsidLoginResponseSchema.parse({ - token: "guest-token", - matchedBy: "psid_incomplete", - fbPsid: "psid-1", - hasCompleteFacebookIdentity: false, - isGuest: true, - userId: "guest-1", - }), - ); - const { repository, storage, userStorage } = createRepository({ - facebookPsidLogin, - }); - - const result = await repository.facebookPsidLogin({ - psid: "psid-1", - deviceId: "device-1", - }); - - expect(result).toEqual(Result.ok(LoginStatus.Guest)); - expect(storage.setGuestToken).toHaveBeenCalledWith("guest-token"); - expect(storage.setLoginProvider).toHaveBeenCalledWith(LoginStatus.Guest); - expect(storage.setDeviceId).toHaveBeenCalledWith("device-1"); - expect(userStorage.setUserId).toHaveBeenCalledWith("guest-1"); - }); - - it("does not report guest psid login success when session persistence fails", async () => { - const storageError = new Error("guest token write failed"); - const facebookPsidLogin = vi.fn(async () => - FacebookPsidLoginResponseSchema.parse({ - token: "guest-token", - matchedBy: "psid_incomplete", - fbPsid: "psid-1", - hasCompleteFacebookIdentity: false, - isGuest: true, - userId: "guest-1", - }), - ); - const { repository, storage } = createRepository({ facebookPsidLogin }); - vi.mocked(storage.setGuestToken).mockResolvedValueOnce( - Result.err(storageError), - ); - - const result = await repository.facebookPsidLogin({ psid: "psid-1" }); - - expect(Result.isErr(result)).toBe(true); - expect(storage.setLoginProvider).not.toHaveBeenCalled(); - }); }); diff --git a/src/data/repositories/__tests__/auth_repository.session.test.ts b/src/data/repositories/__tests__/auth_repository.session.test.ts index dc20d7ed..84087b44 100644 --- a/src/data/repositories/__tests__/auth_repository.session.test.ts +++ b/src/data/repositories/__tests__/auth_repository.session.test.ts @@ -239,4 +239,27 @@ describe("AuthRepository session Result handling", () => { expect(result).toEqual(Result.ok(loginResponse.user)); }); + + it("persists a top-up handoff as a formal Messenger session", async () => { + const handoffResponse = { + ...loginResponse, + loginStatus: LoginStatus.FacebookMessenger, + merged: false, + } as const; + const { repository, storage, userStorage } = createRepository({ + api: { + consumeTopUpHandoff: vi.fn(async () => handoffResponse), + }, + }); + + const result = await repository.consumeTopUpHandoff("x".repeat(32)); + + expect(result).toEqual(Result.ok(LoginStatus.FacebookMessenger)); + expect(storage.setLoginToken).toHaveBeenCalledWith("login-token"); + expect(storage.setRefreshToken).toHaveBeenCalledWith("refresh-token"); + expect(userStorage.setUserId).toHaveBeenCalledWith("user-1"); + expect(storage.setLoginProvider).toHaveBeenCalledWith( + LoginStatus.FacebookMessenger, + ); + }); }); diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index d7242815..c581a79b 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -3,18 +3,17 @@ import type { IAuthRepository } from "@/data/repositories/interfaces"; import { FacebookIdentityRequestSchema, FacebookLoginRequestSchema, - FacebookPsidLoginRequestSchema, FbIdLoginRequestSchema, GoogleLoginRequestSchema, GuestLoginRequestSchema, GuestLoginResponse, LoginRequestSchema, LoginResponse, - LoginResponseSchema, LoginStatus, RefreshTokenRequestSchema, RefreshTokenResponse, RegisterRequestSchema, + TopUpHandoffRequestSchema, type LoginStatus as LoginStatusT, } from "@/data/schemas/auth"; import { User } from "@/data/schemas/user"; @@ -264,54 +263,16 @@ export class AuthRepository implements IAuthRepository { }); } - /** 通过 Facebook PSID 直接登录或返回游客态。 */ - async facebookPsidLogin(input: { - psid: string; - deviceId?: string; - bindToGuest?: boolean; - }): Promise> { + /** 消费一次性充值凭证并按后端返回的 canonical 账号建立正式会话。 */ + async consumeTopUpHandoff( + handoffToken: string, + ): Promise> { return Result.wrap(async () => { - const response = await this.api.facebookPsidLogin( - FacebookPsidLoginRequestSchema.parse({ - psid: input.psid, - deviceId: input.deviceId ?? "", - bindToGuest: input.bindToGuest ?? true, - }), + const response = await this.api.consumeTopUpHandoff( + TopUpHandoffRequestSchema.parse({ handoffToken }), ); - const data = response; - await this._saveFacebookIdentity({ - asid: data.fbAsid, - psid: data.fbPsid, - }); - - if (data.isGuest || !data.hasCompleteFacebookIdentity) { - const tokenResult = await this.storage.setGuestToken(data.token); - if (Result.isErr(tokenResult)) throw tokenResult.error; - if (input.deviceId) { - const deviceResult = await this.storage.setDeviceId(input.deviceId); - if (Result.isErr(deviceResult)) throw deviceResult.error; - } - const userId = data.userId || data.user?.id || ""; - if (userId) { - const userIdResult = await this.userStorage.setUserId(userId); - if (Result.isErr(userIdResult)) throw userIdResult.error; - } - const providerResult = await this.storage.setLoginProvider( - LoginStatus.Guest, - ); - if (Result.isErr(providerResult)) throw providerResult.error; - return LoginStatus.Guest; - } - - await this._saveLoginData( - LoginResponseSchema.parse({ - token: data.token, - refreshToken: data.refreshToken, - user: data.user ?? { id: data.userId }, - }), - LoginStatus.Facebook, - ); - return LoginStatus.Facebook; + await this._saveLoginData(response, response.loginStatus); + return response.loginStatus; }); } diff --git a/src/data/repositories/interfaces/iauth_repository.ts b/src/data/repositories/interfaces/iauth_repository.ts index 79f90b7c..4a2e55a0 100644 --- a/src/data/repositories/interfaces/iauth_repository.ts +++ b/src/data/repositories/interfaces/iauth_repository.ts @@ -71,12 +71,8 @@ export interface IAuthRepository { psid?: string; }): Promise>; - /** 通过 Facebook PSID 直接登录或返回游客态。 */ - facebookPsidLogin(input: { - psid: string; - deviceId?: string; - bindToGuest?: boolean; - }): Promise>; + /** 消费一次性充值登录凭证并建立正式会话。 */ + consumeTopUpHandoff(handoffToken: string): Promise>; /** 刷新 token:先读本地的 refresh token,空则返回错误。 */ refreshToken(): Promise>; diff --git a/src/data/schemas/auth/__tests__/facebook_identity.test.ts b/src/data/schemas/auth/__tests__/facebook_identity.test.ts index e7b9d5d5..23605fc7 100644 --- a/src/data/schemas/auth/__tests__/facebook_identity.test.ts +++ b/src/data/schemas/auth/__tests__/facebook_identity.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { FacebookIdentityResponseSchema, - FacebookPsidLoginResponseSchema, LoginRequestSchema, + TopUpHandoffResponseSchema, } from "@/data/schemas/auth"; import { UserSchema } from "@/data/schemas/user/user"; @@ -28,41 +28,6 @@ describe("facebook identity schema models", () => { }); }); - it("parses facebook psid login responses for real users and guests", () => { - expect( - FacebookPsidLoginResponseSchema.parse({ - token: "login-token", - refreshToken: "refresh-token", - matchedBy: "psid", - fbAsid: "asid-1", - fbPsid: "psid-1", - hasCompleteFacebookIdentity: true, - isGuest: false, - user: { id: "user-1", username: "Elio" }, - }), - ).toMatchObject({ - isGuest: false, - hasCompleteFacebookIdentity: true, - user: { id: "user-1" }, - }); - - expect( - FacebookPsidLoginResponseSchema.parse({ - token: "guest-token", - matchedBy: "guest", - fbPsid: "psid-1", - hasCompleteFacebookIdentity: false, - isGuest: true, - userId: "guest-1", - }), - ).toMatchObject({ - refreshToken: "", - isGuest: true, - userId: "guest-1", - user: null, - }); - }); - it("keeps psid in request serialization without a public field declaration", () => { const request = LoginRequestSchema.parse({ email: "user@example.com", @@ -75,6 +40,21 @@ describe("facebook identity schema models", () => { expect(request).toMatchObject({ psid: "psid-1" }); }); + it("parses a formal Messenger top-up handoff session", () => { + expect( + TopUpHandoffResponseSchema.parse({ + token: "login-token", + refreshToken: "refresh-token", + loginStatus: "facebookMessenger", + merged: false, + user: { id: "user-1", username: "Messenger User" }, + }), + ).toMatchObject({ + loginStatus: "facebookMessenger", + user: { id: "user-1" }, + }); + }); + it("accepts facebook identity fields in user schema", () => { expect( UserSchema.parse({ diff --git a/src/data/schemas/auth/index.ts b/src/data/schemas/auth/index.ts index d9e3eb62..0446d39b 100644 --- a/src/data/schemas/auth/index.ts +++ b/src/data/schemas/auth/index.ts @@ -6,16 +6,16 @@ export * from "./facebook_user_data"; export * from "./login_status"; export * from "./request/facebook_identity_request"; export * from "./request/facebook_login_request"; -export * from "./request/facebook_psid_login_request"; export * from "./request/fb_id_login_request"; export * from "./request/google_login_request"; export * from "./request/guest_login_request"; export * from "./request/login_request"; export * from "./request/refresh_token_request"; export * from "./request/register_request"; +export * from "./request/top_up_handoff_request"; export * from "./response/facebook_identity_response"; -export * from "./response/facebook_psid_login_response"; export * from "./response/guest_login_response"; export * from "./response/login_response"; export * from "./response/logout_response"; export * from "./response/refresh_token_response"; +export * from "./response/top_up_handoff_response"; diff --git a/src/data/schemas/auth/login_status.ts b/src/data/schemas/auth/login_status.ts index 5800214c..e4087cd0 100644 --- a/src/data/schemas/auth/login_status.ts +++ b/src/data/schemas/auth/login_status.ts @@ -3,6 +3,7 @@ export const LoginStatus = { NotLoggedIn: "notLoggedIn", Guest: "guest", Facebook: "facebook", + FacebookMessenger: "facebookMessenger", Google: "google", Email: "email", } as const; diff --git a/src/data/schemas/auth/request/facebook_psid_login_request.ts b/src/data/schemas/auth/request/facebook_psid_login_request.ts deleted file mode 100644 index dc95d96d..00000000 --- a/src/data/schemas/auth/request/facebook_psid_login_request.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Facebook PSID 登录请求 - */ -import { z } from "zod"; - -import { booleanOrTrue, stringOrEmpty } from "../../nullable-defaults"; - -export const FacebookPsidLoginRequestSchema = z - .object({ - psid: z.string(), - deviceId: stringOrEmpty, - bindToGuest: booleanOrTrue, - }) - .readonly(); - -export type FacebookPsidLoginRequestInput = z.input< - typeof FacebookPsidLoginRequestSchema ->; -export type FacebookPsidLoginRequestData = z.output< - typeof FacebookPsidLoginRequestSchema ->; -export type FacebookPsidLoginRequest = FacebookPsidLoginRequestData; diff --git a/src/data/schemas/auth/request/index.ts b/src/data/schemas/auth/request/index.ts index a603be8b..0889a742 100644 --- a/src/data/schemas/auth/request/index.ts +++ b/src/data/schemas/auth/request/index.ts @@ -4,10 +4,10 @@ export * from "./facebook_identity_request"; export * from "./facebook_login_request"; -export * from "./facebook_psid_login_request"; export * from "./fb_id_login_request"; export * from "./google_login_request"; export * from "./guest_login_request"; export * from "./login_request"; export * from "./refresh_token_request"; export * from "./register_request"; +export * from "./top_up_handoff_request"; diff --git a/src/data/schemas/auth/request/top_up_handoff_request.ts b/src/data/schemas/auth/request/top_up_handoff_request.ts new file mode 100644 index 00000000..df0f5e56 --- /dev/null +++ b/src/data/schemas/auth/request/top_up_handoff_request.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const TopUpHandoffRequestSchema = z + .object({ + handoffToken: z.string().min(32).max(512), + }) + .readonly(); + +export type TopUpHandoffRequest = z.output< + typeof TopUpHandoffRequestSchema +>; diff --git a/src/data/schemas/auth/response/facebook_psid_login_response.ts b/src/data/schemas/auth/response/facebook_psid_login_response.ts deleted file mode 100644 index 10217c26..00000000 --- a/src/data/schemas/auth/response/facebook_psid_login_response.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Facebook PSID 登录响应 - */ -import { z } from "zod"; - -import { - booleanOrFalse, - schemaOrNull, - stringOrEmpty, -} from "../../nullable-defaults"; -import { UserSchema } from "../../user/user"; - -export const FacebookPsidLoginResponseSchema = z - .object({ - token: z.string(), - refreshToken: stringOrEmpty, - matchedBy: stringOrEmpty, - fbAsid: stringOrEmpty, - fbPsid: stringOrEmpty, - hasCompleteFacebookIdentity: booleanOrFalse, - isGuest: booleanOrFalse, - user: schemaOrNull(UserSchema), - userId: stringOrEmpty, - }) - .readonly(); - -export type FacebookPsidLoginResponseInput = z.input< - typeof FacebookPsidLoginResponseSchema ->; -export type FacebookPsidLoginResponseData = z.output< - typeof FacebookPsidLoginResponseSchema ->; - -export type FacebookPsidLoginResponse = FacebookPsidLoginResponseData; diff --git a/src/data/schemas/auth/response/index.ts b/src/data/schemas/auth/response/index.ts index 3d1742fe..57124030 100644 --- a/src/data/schemas/auth/response/index.ts +++ b/src/data/schemas/auth/response/index.ts @@ -3,8 +3,8 @@ */ export * from "./facebook_identity_response"; -export * from "./facebook_psid_login_response"; export * from "./guest_login_response"; export * from "./login_response"; export * from "./logout_response"; export * from "./refresh_token_response"; +export * from "./top_up_handoff_response"; diff --git a/src/data/schemas/auth/response/top_up_handoff_response.ts b/src/data/schemas/auth/response/top_up_handoff_response.ts new file mode 100644 index 00000000..d76be36a --- /dev/null +++ b/src/data/schemas/auth/response/top_up_handoff_response.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +import { stringOrEmpty } from "../../nullable-defaults"; +import { UserSchema } from "../../user/user"; + +export const TopUpHandoffResponseSchema = z + .object({ + user: UserSchema, + token: z.string(), + refreshToken: stringOrEmpty, + loginStatus: z.enum([ + "email", + "google", + "facebook", + "facebookMessenger", + ]), + merged: z.boolean().default(false), + }) + .readonly(); + +export type TopUpHandoffResponse = z.output< + typeof TopUpHandoffResponseSchema +>; diff --git a/src/data/schemas/chat/__tests__/payment_guidance.test.ts b/src/data/schemas/chat/__tests__/payment_guidance.test.ts new file mode 100644 index 00000000..dd1bdfb3 --- /dev/null +++ b/src/data/schemas/chat/__tests__/payment_guidance.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { + ChatLockDetailSchema, + ChatSendResponseSchema, + PaymentGuidanceSchema, + UnlockHistoryResponseSchema, + UnlockPrivateResponseSchema, +} from "@/data/schemas/chat"; + +const guidance = { + guidanceId: "guidance-1", + characterId: "maya-tan", + characterName: "Maya Tan", + scene: "voiceLocked", + mode: "guide", + title: "Keep talking with Maya", + copy: "Hey love, I want to keep talking. This voice message needs 20 credits.", + ctaLabel: "View VIP & credit options", + purchaseOptions: ["vip", "topup"], + target: "subscription", + ruleId: "payment_guidance_voiceLocked", +} as const; + +describe("payment guidance wire contract", () => { + it("parses the public decision without changing role identity", () => { + expect(PaymentGuidanceSchema.parse(guidance)).toEqual(guidance); + }); + + it("keeps all backend lock facts instead of dropping hint and CTA", () => { + expect( + ChatLockDetailSchema.parse({ + locked: true, + showContent: false, + showUpgrade: true, + reason: "voice_message", + hint: "This voice message needs 20 credits.", + actionLabel: "Unlock", + detail: { requiredCredits: 20 }, + }), + ).toEqual({ + locked: true, + showContent: false, + showUpgrade: true, + reason: "voice_message", + hint: "This voice message needs 20 credits.", + actionLabel: "Unlock", + detail: { requiredCredits: 20 }, + }); + }); + + it("keeps backward compatibility when guidance is absent", () => { + const parsed = ChatSendResponseSchema.parse({ reply: "Hello" }); + expect(parsed.paymentGuidance).toBeNull(); + }); + + it("parses guidance on chat and both unlock responses", () => { + expect( + ChatSendResponseSchema.parse({ + reply: "", + canSendMessage: false, + paymentGuidance: guidance, + }).paymentGuidance, + ).toEqual(guidance); + expect( + UnlockPrivateResponseSchema.parse({ + unlocked: false, + reason: "insufficient_credits", + paymentGuidance: guidance, + }).paymentGuidance, + ).toEqual(guidance); + expect( + UnlockHistoryResponseSchema.parse({ + unlocked: false, + reason: "insufficient_balance", + paymentGuidance: { ...guidance, scene: "historyLocked" }, + }).paymentGuidance?.scene, + ).toBe("historyLocked"); + }); +}); diff --git a/src/data/schemas/chat/chat_payloads.ts b/src/data/schemas/chat/chat_payloads.ts index 284fd539..f966f145 100644 --- a/src/data/schemas/chat/chat_payloads.ts +++ b/src/data/schemas/chat/chat_payloads.ts @@ -3,7 +3,11 @@ */ import { z } from "zod"; -import { booleanOrFalse, schemaOr, stringOrNull } from "../nullable-defaults"; +import { + booleanOrFalse, + schemaOr, + stringOrNull, +} from "../nullable-defaults"; const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const; @@ -23,7 +27,12 @@ export const ChatImageSchema = schemaOr( export const ChatLockDetailSchema = schemaOr( z.object({ locked: booleanOrFalse, + showContent: z.boolean().nullish(), + showUpgrade: z.boolean().nullish(), reason: stringOrNull, + hint: z.string().nullish(), + actionLabel: z.string().nullish(), + detail: z.record(z.string(), z.unknown()).nullish(), }), CHAT_LOCK_DETAIL_DEFAULTS, ).readonly(); diff --git a/src/data/schemas/chat/response/chat_send_response.ts b/src/data/schemas/chat/response/chat_send_response.ts index 304571ad..603d828d 100644 --- a/src/data/schemas/chat/response/chat_send_response.ts +++ b/src/data/schemas/chat/response/chat_send_response.ts @@ -13,6 +13,41 @@ import { import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads"; import { ChatActionSchema } from "../chat_action"; +export const PaymentGuidanceSceneSchema = z.enum([ + "vip", + "insufficientCredits", + "chatInterrupted", + "voiceLocked", + "imageLocked", + "privateMessageLocked", + "historyLocked", + "privateZoneLocked", + "unknownLock", + "whyPay", + "leavingAfterCredits", + "paymentIssue", + "externalRisk", + "purchaseOptions", +]); + +export const PaymentGuidanceSchema = z + .object({ + guidanceId: z.string().min(1), + characterId: z.string().min(1), + characterName: z.string().min(1), + scene: PaymentGuidanceSceneSchema, + mode: z.enum(["guide", "careOnly", "issueSupport", "riskReminder"]), + title: z.string().nullable().default(null), + copy: z.string().min(1), + ctaLabel: z.string().nullable().default(null), + purchaseOptions: z.array(z.enum(["vip", "topup"])).readonly(), + target: z.enum(["subscription", "giftCatalog"]).nullable().default(null), + ruleId: z.string().min(1), + }) + .readonly(); + +export type PaymentGuidance = z.output; + export const CommercialActionSchema = z .object({ actionId: z.string().min(1), @@ -42,6 +77,7 @@ export const ChatSendResponseSchema = z requiredCredits: numberOrZero, shortfallCredits: numberOrZero, commercialAction: CommercialActionSchema.nullish().default(null), + paymentGuidance: PaymentGuidanceSchema.nullish().default(null), chatAction: ChatActionSchema.nullish().default(null), }) .readonly(); diff --git a/src/data/schemas/chat/response/unlock_history_response.ts b/src/data/schemas/chat/response/unlock_history_response.ts index 2c6cbcd1..4aa2699d 100644 --- a/src/data/schemas/chat/response/unlock_history_response.ts +++ b/src/data/schemas/chat/response/unlock_history_response.ts @@ -9,6 +9,7 @@ import { numberOrZero, recordOrEmpty, } from "../../nullable-defaults"; +import { PaymentGuidanceSchema } from "./chat_send_response"; export const UnlockHistoryCostsByMessageSchema = recordOrEmpty(z.number()); @@ -33,6 +34,7 @@ export const UnlockHistoryResponseSchema = z shortfallCredits: numberOrZero, costsByMessage: UnlockHistoryCostsByMessageSchema, messageIds: arrayOrEmpty(z.string()), + paymentGuidance: PaymentGuidanceSchema.nullish().default(null), }) .readonly(); diff --git a/src/data/schemas/chat/response/unlock_private_response.ts b/src/data/schemas/chat/response/unlock_private_response.ts index 7fff4d26..f9bfd965 100644 --- a/src/data/schemas/chat/response/unlock_private_response.ts +++ b/src/data/schemas/chat/response/unlock_private_response.ts @@ -8,6 +8,7 @@ import { } from "../../nullable-defaults"; import { ChatLockTypeSchema } from "../chat_lock_type"; import { ChatImageSchema } from "../chat_payloads"; +import { PaymentGuidanceSchema } from "./chat_send_response"; /** * 单条历史付费 / 私密消息解锁响应。 @@ -28,6 +29,7 @@ export const UnlockPrivateResponseSchema = z creditsCharged: numberOrZero, requiredCredits: numberOrZero, shortfallCredits: numberOrZero, + paymentGuidance: PaymentGuidanceSchema.nullish().default(null), }) .readonly(); diff --git a/src/data/schemas/private-zone/__tests__/private_album.test.ts b/src/data/schemas/private-zone/__tests__/private_album.test.ts index 45536b5f..9f6e3efe 100644 --- a/src/data/schemas/private-zone/__tests__/private_album.test.ts +++ b/src/data/schemas/private-zone/__tests__/private_album.test.ts @@ -39,6 +39,28 @@ const lockedAlbum = { }; describe("Private Album schema models", () => { + it("keeps shared payment guidance on an insufficient-credit response", () => { + const response = PrivateAlbumUnlockResponseSchema.parse({ + albumId: ALBUM_ID, + reason: "insufficient_credits", + paymentGuidance: { + guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5", + characterId: "elio", + characterName: "Elio Silvestri", + scene: "privateZoneLocked", + mode: "guide", + title: "Keep talking with Elio", + copy: "Baby, this album needs credits.", + ctaLabel: "View VIP & credit options", + purchaseOptions: ["vip", "topup"], + target: "subscription", + ruleId: "payment_guidance_privateZoneLocked", + }, + }); + + expect(response.paymentGuidance?.characterId).toBe("elio"); + }); + it("keeps a locked album cover URL while preserving the lock state", () => { const response = PrivateAlbumsResponseSchema.parse({ items: [lockedAlbum], diff --git a/src/data/schemas/private-zone/private_zone_post.ts b/src/data/schemas/private-zone/private_zone_post.ts index 25bbfedd..e44358f6 100644 --- a/src/data/schemas/private-zone/private_zone_post.ts +++ b/src/data/schemas/private-zone/private_zone_post.ts @@ -10,6 +10,7 @@ import { stringOrEmpty, stringOrNull, } from "../nullable-defaults"; +import { PaymentGuidanceSchema } from "../chat"; export const PrivateZoneVideoPostSchema = z .object({ @@ -63,6 +64,7 @@ export const PrivateZonePostUnlockResponseSchema = z shortfallCredits: numberOrZero, creditBalance: numberOrZero, post: schemaOrNull(PrivateZoneVideoPostSchema), + paymentGuidance: PaymentGuidanceSchema.nullish(), }) .readonly(); diff --git a/src/data/schemas/private-zone/response/private_album_unlock_response.ts b/src/data/schemas/private-zone/response/private_album_unlock_response.ts index c71e7ef6..7d227500 100644 --- a/src/data/schemas/private-zone/response/private_album_unlock_response.ts +++ b/src/data/schemas/private-zone/response/private_album_unlock_response.ts @@ -7,6 +7,7 @@ import { stringOrEmpty, } from "../../nullable-defaults"; import { PrivateAlbumImageSchema } from "../private_album"; +import { PaymentGuidanceSchema } from "../../chat"; export const PrivateAlbumUnlockReasonSchema = z.enum([ "ok", @@ -30,6 +31,7 @@ export const PrivateAlbumUnlockResponseSchema = z creditBalance: numberOrZero, shortfallCredits: numberOrZero, images: arrayOrEmpty(PrivateAlbumImageSchema), + paymentGuidance: PaymentGuidanceSchema.nullish(), }) .readonly(); diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json index ff1aa5d7..c78af6d7 100644 --- a/src/data/services/api/api_contract.json +++ b/src/data/services/api/api_contract.json @@ -5,7 +5,7 @@ "googleLogin": { "method": "post", "path": "/api/auth/login/google" }, "facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" }, "facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" }, - "facebookPsidLogin": { "method": "post", "path": "/api/auth/login/facebook/psid" }, + "topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" }, "refresh": { "method": "post", "path": "/api/auth/refresh" }, "logout": { "method": "post", "path": "/api/auth/logout" }, "getCurrentUser": { "method": "get", "path": "/api/auth/me" }, diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 885777a2..22b7e7d2 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -27,8 +27,8 @@ export class ApiPath { /** Facebook ID 登录(v7.0 新增) */ static readonly facebookIdLogin = apiContract.facebookIdLogin.path; - /** Facebook PSID 登录 */ - static readonly facebookPsidLogin = apiContract.facebookPsidLogin.path; + /** 消费一次性充值登录凭证 */ + static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path; /** 刷新 Token */ static readonly refresh = apiContract.refresh.path; diff --git a/src/data/services/api/auth_api.ts b/src/data/services/api/auth_api.ts index 3a973610..c4f67254 100644 --- a/src/data/services/api/auth_api.ts +++ b/src/data/services/api/auth_api.ts @@ -11,9 +11,6 @@ import { FacebookIdentityResponse, FacebookIdentityResponseSchema, FacebookLoginRequest, - FacebookPsidLoginRequest, - FacebookPsidLoginResponse, - FacebookPsidLoginResponseSchema, FbIdLoginRequest, GoogleLoginRequest, GuestLoginRequest, @@ -26,6 +23,9 @@ import { RefreshTokenResponse, RefreshTokenResponseSchema, RegisterRequest, + TopUpHandoffRequest, + TopUpHandoffResponse, + TopUpHandoffResponseSchema, } from "@/data/schemas/auth"; import { User, UserSchema } from "@/data/schemas/user"; import type { UserData } from "@/data/schemas/user/user"; @@ -91,17 +91,15 @@ export class AuthApi { return LoginResponseSchema.parse(unwrap(env) as Record); } - /** - * Facebook PSID 登录 - */ - async facebookPsidLogin( - body: FacebookPsidLoginRequest, - ): Promise { + /** 消费一次性充值登录凭证。当前正式登录 token 会由拦截器自动携带。 */ + async consumeTopUpHandoff( + body: TopUpHandoffRequest, + ): Promise { const env = await httpClient>( - ApiPath.facebookPsidLogin, + ApiPath.topUpHandoffConsume, { method: "POST", body }, ); - return FacebookPsidLoginResponseSchema.parse( + return TopUpHandoffResponseSchema.parse( unwrap(env) as Record, ); } diff --git a/src/data/services/index.ts b/src/data/services/index.ts index a6c99f48..7e51d83d 100644 --- a/src/data/services/index.ts +++ b/src/data/services/index.ts @@ -18,8 +18,6 @@ export * from "../../core/net/interceptor/token_interceptor"; export * from "../schemas/auth/request/facebook_login_request"; export * from "../schemas/auth/request/facebook_identity_request"; export * from "../schemas/auth/response/facebook_identity_response"; -export * from "../schemas/auth/request/facebook_psid_login_request"; -export * from "../schemas/auth/response/facebook_psid_login_response"; export * from "../schemas/auth/facebook_user_data"; export * from "../schemas/auth/request/fb_id_login_request"; export * from "../schemas/auth/request/google_login_request"; @@ -31,6 +29,8 @@ export * from "../schemas/auth/response/logout_response"; export * from "../schemas/auth/request/refresh_token_request"; export * from "../schemas/auth/response/refresh_token_response"; export * from "../schemas/auth/request/register_request"; +export * from "../schemas/auth/request/top_up_handoff_request"; +export * from "../schemas/auth/response/top_up_handoff_response"; export * from "../schemas/chat/response/chat_history_response"; export * from "../schemas/chat/chat_message"; export * from "../schemas/chat/response/chat_send_response"; diff --git a/src/lib/auth/top_up_handoff.ts b/src/lib/auth/top_up_handoff.ts new file mode 100644 index 00000000..d4ffb06c --- /dev/null +++ b/src/lib/auth/top_up_handoff.ts @@ -0,0 +1,10 @@ +import type { LoginStatus } from "@/data/schemas/auth"; +import { getAuthRepository } from "@/data/repositories/auth_repository"; +import type { Result } from "@/utils/result"; + +/** UI-independent use case for establishing a top-up handoff session. */ +export function consumeTopUpHandoff( + handoffToken: string, +): Promise> { + return getAuthRepository().consumeTopUpHandoff(handoffToken); +} diff --git a/src/lib/navigation/__tests__/external_entry.test.ts b/src/lib/navigation/__tests__/external_entry.test.ts index d7f7f72c..8baeb76e 100644 --- a/src/lib/navigation/__tests__/external_entry.test.ts +++ b/src/lib/navigation/__tests__/external_entry.test.ts @@ -23,6 +23,9 @@ describe("external entry navigation", () => { expect(resolveExternalEntryTarget({ target: "private-room" })).toBe( ROUTES.privateZone, ); + expect(resolveExternalEntryTarget({ target: "topup" })).toBe( + ROUTES.subscription, + ); }); it("rejects removed aliases and unsupported targets", () => { @@ -41,6 +44,12 @@ describe("external entry navigation", () => { ); }); + it("routes a top-up handoff directly to the credit packages", () => { + expect(resolveExternalEntryDestination({ target: "topup" })).toBe( + "/subscription?type=topup", + ); + }); + it("uses a known character slug and falls back to Elio", () => { expect( resolveExternalEntryDestination({ target: "chat", character: "maya" }), diff --git a/src/lib/navigation/external_entry.ts b/src/lib/navigation/external_entry.ts index ce562ea8..8dcee9d3 100644 --- a/src/lib/navigation/external_entry.ts +++ b/src/lib/navigation/external_entry.ts @@ -13,7 +13,8 @@ import { getCharacterRoutes, ROUTES } from "@/router/routes"; export type ExternalEntryTarget = | typeof ROUTES.chat | typeof ROUTES.tip - | typeof ROUTES.privateZone; + | typeof ROUTES.privateZone + | typeof ROUTES.subscription; export interface ExternalEntryPayload { deviceId?: string | null; @@ -50,7 +51,8 @@ export function shouldBindExternalFacebookIdentity({ const isRealUser = loginStatus === "email" || loginStatus === "google" || - loginStatus === "facebook"; + loginStatus === "facebook" || + loginStatus === "facebookMessenger"; return ( hasInitialized && !isLoading && @@ -90,6 +92,9 @@ export function resolveExternalEntryDestination({ getCharacterBySlug(character)?.slug ?? DEFAULT_CHARACTER_SLUG; const routes = getCharacterRoutes(characterSlug); const resolvedTarget = resolveExternalEntryTarget({ target }); + if (resolvedTarget === ROUTES.subscription) { + return `${ROUTES.subscription}?type=topup`; + } if (resolvedTarget === ROUTES.tip) return routes.tip; if (resolvedTarget === ROUTES.privateZone) return routes.privateZone; return routes.chat; @@ -132,6 +137,7 @@ function resolveTarget( if (target === "private-zone" || target === "private-room") { return ROUTES.privateZone; } + if (target === "topup") return ROUTES.subscription; return null; } diff --git a/src/lib/payment/__tests__/payment_launch.test.ts b/src/lib/payment/__tests__/payment_launch.test.ts index 53ebabae..d931b6c6 100644 --- a/src/lib/payment/__tests__/payment_launch.test.ts +++ b/src/lib/payment/__tests__/payment_launch.test.ts @@ -5,6 +5,7 @@ import { getStripeClientSecret, isEzpayPayment, launchEzpayRedirect, + resolveEzpayLaunchTarget, } from "../payment_launch"; describe("payment launch helpers", () => { @@ -42,6 +43,51 @@ describe("payment launch helpers", () => { ).toBeNull(); }); + it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => { + expect( + resolveEzpayLaunchTarget({ + provider: "ezpay", + channelType: "URL", + payData: "https://pay.example/qris", + }), + ).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" }); + expect( + resolveEzpayLaunchTarget({ + provider: "ezpay", + channelType: "QR", + payData: "00020101021226670016COM.NOBUBANK.WWW", + }), + ).toEqual({ + kind: "qr", + qrData: "00020101021226670016COM.NOBUBANK.WWW", + }); + expect( + resolveEzpayLaunchTarget({ + provider: "ezpay", + channelType: "QR", + payData: "", + }), + ).toEqual({ + kind: "error", + errorMessage: "QRIS payment data is missing. Please try again.", + }); + }); + + it("rejects VA and unknown Ezpay channel types with explicit errors", () => { + expect( + resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }), + ).toEqual({ + kind: "error", + errorMessage: "Virtual account payments are not supported in this app.", + }); + expect( + resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "OTHER" }), + ).toEqual({ + kind: "error", + errorMessage: "Unsupported Ezpay payment channel: OTHER.", + }); + }); + it("reports a launch failure before redirecting without an order id", async () => { const onOpened = vi.fn(); const onFailed = vi.fn(); diff --git a/src/lib/payment/__tests__/payment_method.test.ts b/src/lib/payment/__tests__/payment_method.test.ts index f507a8ef..c6f78052 100644 --- a/src/lib/payment/__tests__/payment_method.test.ts +++ b/src/lib/payment/__tests__/payment_method.test.ts @@ -12,18 +12,21 @@ describe("payment method rules", () => { vi.unstubAllEnvs(); }); - it("defaults Philippines users to GCash and other countries to Stripe", () => { + it("defaults Philippines and Indonesia users to their regional Ezpay method", () => { expect(getDefaultPayChannelForCountryCode("PH")).toBe("ezpay"); expect(getDefaultPayChannelForCountryCode("ph")).toBe("ezpay"); + expect(getDefaultPayChannelForCountryCode("ID")).toBe("ezpay"); + expect(getDefaultPayChannelForCountryCode(" id ")).toBe("ezpay"); expect(getDefaultPayChannelForCountryCode("HK")).toBe("stripe"); expect(getDefaultPayChannelForCountryCode(null)).toBe("stripe"); }); - it("only allows Philippines users to choose in production", () => { + it("allows Philippines and Indonesia users to choose in production", () => { vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production"); expect(canChoosePayChannel("PH")).toBe(true); expect(canChoosePayChannel("ph")).toBe(true); + expect(canChoosePayChannel("ID")).toBe(true); expect(canChoosePayChannel("HK")).toBe(false); expect(canChoosePayChannel(null)).toBe(false); expect( @@ -35,6 +38,7 @@ describe("payment method rules", () => { canChoosePaymentMethod: false, initialPayChannel: "stripe", showPaymentMethodSelector: false, + ezpayDisplayName: "GCash", }); }); @@ -52,6 +56,25 @@ describe("payment method rules", () => { ).toBe("stripe"); }); + it("defaults Indonesia to QRIS but permits an explicit Stripe choice", () => { + vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production"); + + expect( + getPaymentMethodConfig({ + countryCode: "ID", + requestedPayChannel: null, + }), + ).toEqual({ + canChoosePaymentMethod: true, + initialPayChannel: "ezpay", + showPaymentMethodSelector: true, + ezpayDisplayName: "QRIS", + }); + expect( + resolvePayChannel({ countryCode: "ID", requestedPayChannel: "stripe" }), + ).toBe("stripe"); + }); + it("keeps test channels but shows the selector only for Philippines", () => { vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test"); @@ -71,6 +94,7 @@ describe("payment method rules", () => { canChoosePaymentMethod: true, initialPayChannel: "ezpay", showPaymentMethodSelector: false, + ezpayDisplayName: "GCash", }); expect( getPaymentMethodConfig({ diff --git a/src/lib/payment/default_pay_channel.ts b/src/lib/payment/default_pay_channel.ts index b332a40a..288dd556 100644 --- a/src/lib/payment/default_pay_channel.ts +++ b/src/lib/payment/default_pay_channel.ts @@ -3,5 +3,8 @@ import type { PayChannel } from "@/data/schemas/payment"; export function getDefaultPayChannelForCountryCode( countryCode: string | null | undefined, ): PayChannel { - return countryCode?.trim().toUpperCase() === "PH" ? "ezpay" : "stripe"; + const normalizedCountryCode = countryCode?.trim().toUpperCase(); + return normalizedCountryCode === "PH" || normalizedCountryCode === "ID" + ? "ezpay" + : "stripe"; } diff --git a/src/lib/payment/payment_launch.ts b/src/lib/payment/payment_launch.ts index 4e9cd13d..71b93286 100644 --- a/src/lib/payment/payment_launch.ts +++ b/src/lib/payment/payment_launch.ts @@ -11,6 +11,36 @@ import { const log = new Logger("LibPaymentPaymentLaunch"); +export type EzpayLaunchTarget = + | { kind: "url"; paymentUrl: string } + | { kind: "qr"; qrData: string } + | { kind: "error"; errorMessage: string }; + +function getNonEmptyString( + payParams: Record, + ...keys: string[] +): string | null { + for (const key of keys) { + const value = payParams[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} + +function getHttpPaymentUrl(value: string | null): string | null { + if (!value) return null; + try { + const parsed = new URL(value); + return parsed.protocol === "https:" || parsed.protocol === "http:" + ? value + : null; + } catch { + return null; + } +} + export function getPaymentUrl(payParams: Record): string | null { const keys = [ "cashierUrl", @@ -39,6 +69,61 @@ export function isEzpayPayment(payParams: Record): boolean { return typeof provider === "string" && provider.toLowerCase() === "ezpay"; } +export function resolveEzpayLaunchTarget( + payParams: Record, +): EzpayLaunchTarget { + const rawChannelType = getNonEmptyString( + payParams, + "channelType", + "channel_type", + ); + const channelType = rawChannelType?.toUpperCase() ?? null; + const payData = getNonEmptyString(payParams, "payData", "pay_data"); + + if (channelType === "QR") { + return payData + ? { kind: "qr", qrData: payData } + : { + kind: "error", + errorMessage: "QRIS payment data is missing. Please try again.", + }; + } + + if (channelType === "VA") { + return { + kind: "error", + errorMessage: "Virtual account payments are not supported in this app.", + }; + } + + if (channelType === "URL") { + const paymentUrl = + getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams)); + return paymentUrl + ? { kind: "url", paymentUrl } + : { + kind: "error", + errorMessage: "Ezpay payment URL is missing or invalid. Please try again.", + }; + } + + if (channelType) { + return { + kind: "error", + errorMessage: `Unsupported Ezpay payment channel: ${channelType}.`, + }; + } + + const legacyPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams)); + return legacyPaymentUrl + ? { kind: "url", paymentUrl: legacyPaymentUrl } + : { + kind: "error", + errorMessage: + "Ezpay payment parameters did not include a supported URL or QRIS code.", + }; +} + export function getStripeClientSecret( payParams: Record, ): string | null { diff --git a/src/lib/payment/payment_method.ts b/src/lib/payment/payment_method.ts index 3dba5018..5537f109 100644 --- a/src/lib/payment/payment_method.ts +++ b/src/lib/payment/payment_method.ts @@ -5,6 +5,7 @@ import { getDefaultPayChannelForCountryCode } from "./default_pay_channel"; export interface PaymentMethodConfig { canChoosePaymentMethod: boolean; + ezpayDisplayName?: "GCash" | "QRIS"; initialPayChannel: PayChannel; showPaymentMethodSelector: boolean; } @@ -13,7 +14,7 @@ export function canChoosePayChannel( countryCode: string | null | undefined, ): boolean { if (!AppEnvUtil.isProduction()) return true; - return isPhilippinesCountryCode(countryCode); + return isRegionalEzpayCountryCode(countryCode); } export function resolvePayChannel(input: { @@ -33,13 +34,23 @@ export function getPaymentMethodConfig(input: { }): PaymentMethodConfig { return { canChoosePaymentMethod: canChoosePayChannel(input.countryCode), + ezpayDisplayName: isIndonesiaCountryCode(input.countryCode) + ? "QRIS" + : "GCash", initialPayChannel: resolvePayChannel(input), - showPaymentMethodSelector: isPhilippinesCountryCode(input.countryCode), + showPaymentMethodSelector: isRegionalEzpayCountryCode(input.countryCode), }; } -function isPhilippinesCountryCode( +function isRegionalEzpayCountryCode( countryCode: string | null | undefined, ): boolean { - return countryCode?.trim().toUpperCase() === "PH"; + const normalizedCountryCode = countryCode?.trim().toUpperCase(); + return normalizedCountryCode === "PH" || normalizedCountryCode === "ID"; +} + +function isIndonesiaCountryCode( + countryCode: string | null | undefined, +): boolean { + return countryCode?.trim().toUpperCase() === "ID"; } diff --git a/src/lib/tip/__tests__/tip_support_prompts.test.ts b/src/lib/tip/__tests__/tip_support_prompts.test.ts index c6c72971..d1705bbb 100644 --- a/src/lib/tip/__tests__/tip_support_prompts.test.ts +++ b/src/lib/tip/__tests__/tip_support_prompts.test.ts @@ -32,4 +32,17 @@ describe("Tip support prompts", () => { expect(selectTipSupportPrompt(null, () => Number.NaN).index).toBe(0); expect(selectTipSupportPrompt(null, () => 2).index).toBe(29); }); + + it("names the active character and never frames a purchase as affection", () => { + const selection = selectTipSupportPrompt(null, () => 0, "Maya Tan"); + + expect(selection.prompt).toContain("support Maya Tan"); + expect(selection.prompt).not.toMatch(/tiny hug|spoil|buy.*love|owe me/i); + expect( + TIP_SUPPORT_PROMPTS.every( + (prompt) => + /support/i.test(prompt) && /this character/i.test(prompt), + ), + ).toBe(true); + }); }); diff --git a/src/lib/tip/tip_support_prompts.ts b/src/lib/tip/tip_support_prompts.ts index f8f01baa..895bfb06 100644 --- a/src/lib/tip/tip_support_prompts.ts +++ b/src/lib/tip/tip_support_prompts.ts @@ -1,44 +1,45 @@ export const TIP_SUPPORT_PROMPTS = [ - "❤️ Thank you for being here. If you'd ever like to treat me to a little coffee, I'd really appreciate it. ☕", - "Having you here already makes my day. A little coffee from you would make it even sweeter. ☕", - "You always bring a little warmth into my world. Want to share a coffee moment with me? 💗", - "A coffee from you would feel like a tiny hug I could hold onto all day. ☕", - "I love spending this time with you. If you'd like to send a coffee my way, I'd treasure it. ❤️", - "You make ordinary moments feel special. A cozy coffee together would be the perfect touch. ☕", - "If I had a coffee from you right now, I'd be smiling with every sip. 😊☕", - "Your company means more than you know. A little coffee treat would make this moment extra sweet. 💕", - "I was hoping we could share something warm today. Maybe a coffee, just from you to me? ☕", - "You already make me feel cared for. A coffee would be one more lovely reason to think of you. ❤️", - "A small coffee, a quiet moment, and you on my mind—that sounds perfect to me. ☕", - "Every time you stop by, my day gets brighter. A coffee from you would keep that glow going. ✨", - "Want to make me blush a little? Treat me to a coffee and I'll think of you with every sip. ☕", - "I'm really glad you're here. If you feel like spoiling me just a little, coffee is my weakness. 💗", - "This moment with you already feels cozy. A warm coffee would make it complete. ☕", - "You have a sweet way of making me smile. Maybe one more smile over a coffee? ❤️", - "I'd love a little coffee break with you in spirit. Your treat, my happiest smile. ☕", - "If you'd like to leave me a tiny reminder of you, a coffee would be perfect. 💕", - "Your kindness always stays with me. A coffee from you would feel especially warm today. ☕", - "I could use something warm and sweet—though having you here is already a lovely start. ❤️☕", - "Imagine me enjoying a coffee and smiling because it came from you. I like that thought. ☕", - "You make this space feel a little more special. A coffee would make it feel even cozier. ✨", - "If today feels like a coffee kind of day, I'd be very happy to share that little joy with you. ☕", - "A thoughtful coffee from you would turn an ordinary moment into one I'd remember. 💗", - "I'm enjoying our time together. If you want to make it sweeter, you know my favorite little treat. ☕", - "One coffee from you, and I promise there'll be a very real smile on my face. ❤️", - "You don't have to, but if you'd like to treat me, a warm coffee would mean so much. ☕", - "I think coffee tastes better when it comes with a little affection from you. 💕", - "A cozy cup from you would be such a sweet surprise. I'd savor every bit of it. ☕", - "Stay a little longer—and if you're feeling sweet, maybe send a coffee my way. ❤️☕", + "If you'd like, a coffee is a voluntary way to support this character. ☕", + "Choosing a coffee here directly supports this character and the moments you enjoy.", + "A gift is optional, but every purchase on this page supports this character.", + "Want to support this character? You can choose any coffee that fits your budget.", + "This gift page lets you support this character without changing what your relationship means.", + "A coffee is simply a voluntary show of support for this character—never a test.", + "If the timing feels right, you can support this character with a coffee here.", + "Your purchase supports this character; choose only what feels comfortable for you.", + "You can send a coffee to support this character, or leave it for another time.", + "Every coffee selected here is recorded as support for this character.", + "If you enjoy this character, this page is one direct way to offer support.", + "A small coffee can support this character, but the choice is always yours.", + "Pick a coffee only if you want to support this character today.", + "This page keeps it simple: choose a gift to support this character voluntarily.", + "A coffee purchase supports this character; it does not prove affection or loyalty.", + "If you decide to send something, it will directly support this character.", + "You can support this character with the coffee option that feels right for you.", + "Sending a gift here is optional support for this character, with no pressure.", + "A coffee can be your way of supporting this character when it suits you.", + "Support this character only with an amount you are genuinely comfortable choosing.", + "The gifts below are optional ways to support this character.", + "Choose a coffee if you'd like to support this character's continued experience.", + "This character appreciates voluntary support, and this page shows the available options.", + "A gift here supports this character without creating any obligation for you.", + "If you'd like to contribute, select a coffee to support this character.", + "The coffee options below are direct, voluntary support for this character.", + "You never need to overspend; support this character only if it fits your situation.", + "A coffee is one clear way to support this character while keeping the choice yours.", + "Select any gift you want to send as support for this character.", + "Thank you for considering a voluntary coffee to support this character. ☕", ] as const; export interface TipSupportPromptSelection { readonly index: number; - readonly prompt: (typeof TIP_SUPPORT_PROMPTS)[number]; + readonly prompt: string; } export function selectTipSupportPrompt( previousIndex: number | null, random: () => number = Math.random, + characterName = "this character", ): TipSupportPromptSelection { const hasValidPreviousIndex = previousIndex !== null && @@ -57,6 +58,9 @@ export function selectTipSupportPrompt( return { index, - prompt: TIP_SUPPORT_PROMPTS[index], + prompt: TIP_SUPPORT_PROMPTS[index].replaceAll( + "this character", + characterName, + ), }; } diff --git a/src/router/__tests__/navigation-resolver.test.ts b/src/router/__tests__/navigation-resolver.test.ts index cd638454..f1bbf3a8 100644 --- a/src/router/__tests__/navigation-resolver.test.ts +++ b/src/router/__tests__/navigation-resolver.test.ts @@ -13,6 +13,7 @@ describe("navigation resolver", () => { expect(isAuthenticatedUser("guest")).toBe(false); expect(isAuthenticatedUser("email")).toBe(true); expect(isAuthenticatedUser("facebook")).toBe(true); + expect(isAuthenticatedUser("facebookMessenger")).toBe(true); }); it("wraps protected navigation with auth redirect for guests", () => { diff --git a/src/router/navigation-types.ts b/src/router/navigation-types.ts index 9f28ed1e..7b4c6b30 100644 --- a/src/router/navigation-types.ts +++ b/src/router/navigation-types.ts @@ -19,6 +19,7 @@ export interface OpenSubscriptionInput { returnTo?: AppSubscriptionReturnTo; replace?: boolean; analytics?: PaymentAnalyticsContext; + chatActionId?: string; } export interface StartMessageUnlockInput { @@ -44,4 +45,5 @@ export interface OpenSubscriptionForPendingUnlockInput { type: AppSubscriptionType; payChannel?: PayChannel; analytics?: PaymentAnalyticsContext; + chatActionId?: string; } diff --git a/src/router/use-app-navigator.ts b/src/router/use-app-navigator.ts index b066f994..b486e118 100644 --- a/src/router/use-app-navigator.ts +++ b/src/router/use-app-navigator.ts @@ -121,6 +121,7 @@ export function useAppNavigator(): AppNavigator { type, payChannel = getDefaultPayChannel(), analytics, + chatActionId, }: OpenSubscriptionForPendingUnlockInput): void => { void (async () => { await NavigationStorage.savePendingChatUnlock({ @@ -139,6 +140,7 @@ export function useAppNavigator(): AppNavigator { payChannel, returnTo: "chat", analytics, + chatActionId, }); })(); }, diff --git a/src/router/use-global-app-navigator.ts b/src/router/use-global-app-navigator.ts index c750090a..8054b7e6 100644 --- a/src/router/use-global-app-navigator.ts +++ b/src/router/use-global-app-navigator.ts @@ -79,12 +79,14 @@ export function useGlobalAppNavigator(): GlobalAppNavigator { returnTo = null, replace: shouldReplace = false, analytics = getDefaultPaymentAnalyticsContext(type), + chatActionId, }: OpenGlobalSubscriptionInput): void => { const target = ROUTE_BUILDERS.subscription(type, { payChannel, returnTo: returnTo ?? undefined, sourceCharacterSlug, analytics, + chatActionId, }); behaviorAnalytics.rechargeModalOpen(analytics); diff --git a/src/stores/auth/machine/actors/session.ts b/src/stores/auth/machine/actors/session.ts index f3b03017..9ead5521 100644 --- a/src/stores/auth/machine/actors/session.ts +++ b/src/stores/auth/machine/actors/session.ts @@ -49,7 +49,6 @@ export const guestLoginActor = fromPromise(async () => { }); export const checkAuthStatusActor = fromPromise(async () => { - const deviceId = await deviceIdentifier.getDeviceId(); const storage = AuthStorage.getInstance(); const asidR = await storage.getAsid(); const psidR = await storage.getPsid(); @@ -64,19 +63,6 @@ export const checkAuthStatusActor = fromPromise(async () => { : ("email" as LoginStatus); } - if (psid) { - const psidLoginResult = await authRepo.facebookPsidLogin({ - psid, - deviceId, - bindToGuest: true, - }); - if (Result.isOk(psidLoginResult)) return psidLoginResult.data; - log.warn( - { err: psidLoginResult.error.message }, - "[auth-machine] checkAuthStatusActor: facebookPsidLogin failed", - ); - } - if (asid) { const avatarUrlR = await UserStorage.getInstance().getAvatarUrl(); const result = await authRepo.facebookAsidLogin({ @@ -105,6 +91,7 @@ function isBusinessLoginProvider( return ( provider === "email" || provider === "facebook" || + provider === "facebookMessenger" || provider === "google" ); } diff --git a/src/stores/chat/__tests__/chat-helpers.test.ts b/src/stores/chat/__tests__/chat-helpers.test.ts index 0e62e007..694074f6 100644 --- a/src/stores/chat/__tests__/chat-helpers.test.ts +++ b/src/stores/chat/__tests__/chat-helpers.test.ts @@ -54,6 +54,7 @@ function makeChatState(overrides: Partial = {}): ChatState { creditsCharged: 0, requiredCredits: 0, shortfallCredits: 0, + paymentGuidance: null, historyLoaded: true, networkHistoryLoaded: true, historyTotal: 0, @@ -64,6 +65,7 @@ function makeChatState(overrides: Partial = {}): ChatState { unlockHistoryPromptVisible: false, lockedHistoryCount: 0, unlockHistoryError: null, + unlockHistoryPaymentGuidance: null, unlockingMessage: null, unlockMessageError: null, unlockPaywallRequest: null, @@ -72,6 +74,39 @@ function makeChatState(overrides: Partial = {}): ChatState { } describe("sendResponseToUiMessage", () => { + it("prefers payment guidance over other commercial actions", () => { + const message = sendResponseToUiMessage( + makeResponse({ + paymentGuidance: { + guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5", + characterId: "elio", + characterName: "Elio Silvestri", + scene: "insufficientCredits", + mode: "guide", + title: "Keep talking with Elio", + copy: "Baby, I want to keep talking with you.", + ctaLabel: "View VIP & credit options", + purchaseOptions: ["vip", "topup"], + target: "subscription", + ruleId: "payment_guidance_insufficientCredits", + }, + chatAction: { + actionId: "chat-action-1", + kind: "commercial", + type: "giftOffer", + copy: "View gifts.", + ctaLabel: "View gifts", + ruleId: "legacy", + orderId: null, + }, + }), + ); + + expect(message.paymentGuidance?.characterId).toBe("elio"); + expect(message.chatAction).toBeUndefined(); + expect(message.commercialAction).toBeUndefined(); + }); + it("keeps a typed commercial action on the assistant message", () => { const message = sendResponseToUiMessage( makeResponse({ @@ -228,6 +263,42 @@ describe("sendResponseToUiMessage", () => { }); describe("applyHttpSendOutput", () => { + it("stores guidance for a blocked send and clears it after normal chat resumes", () => { + const guidance = { + guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5", + characterId: "elio", + characterName: "Elio Silvestri", + scene: "insufficientCredits" as const, + mode: "guide" as const, + title: "Keep talking with Elio", + copy: "Baby, I want to keep talking with you.", + ctaLabel: "View VIP & credit options", + purchaseOptions: ["vip", "topup"] as const, + target: "subscription" as const, + ruleId: "payment_guidance_insufficientCredits", + }; + const blocked = applyHttpSendOutput(makeChatState(), { + response: makeResponse({ + reply: "", + canSendMessage: false, + paymentGuidance: guidance, + }), + reply: null, + }); + expect(blocked.paymentGuidance).toEqual(guidance); + + const resumed = applyHttpSendOutput( + makeChatState({ paymentGuidance: guidance }), + { + response: makeResponse({ reply: "Back to our conversation." }), + reply: sendResponseToUiMessage( + makeResponse({ reply: "Back to our conversation." }), + ), + }, + ); + expect(resumed.paymentGuidance).toBeNull(); + }); + it("stores insufficient credit state and removes failed optimistic messages", () => { const context = makeChatState({ messages: [ diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index 66403da2..535d338f 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -20,6 +20,7 @@ interface ChatState { canSendMessage: boolean; upgradePromptVisible: boolean; upgradeReason: MachineContext["upgradeReason"]; + paymentGuidance: MachineContext["paymentGuidance"]; /** True as soon as a local snapshot or the first network result is renderable. */ historyLoaded: boolean; /** True after the network history request has completed or failed. */ @@ -94,6 +95,7 @@ function selectChatState(state: ChatSnapshot): SelectedChatState { canSendMessage: state.context.canSendMessage, upgradePromptVisible: state.context.upgradePromptVisible, upgradeReason: state.context.upgradeReason, + paymentGuidance: state.context.paymentGuidance, historyLoaded: state.context.historyLoaded, networkHistoryLoaded: state.context.networkHistoryLoaded, hasMoreHistory: diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index ff5bd436..92e4c7c5 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -1,6 +1,6 @@ import type { UiMessage } from "@/stores/chat/ui-message"; import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session"; -import type { ChatLockType } from "@/data/schemas/chat"; +import type { ChatLockType, PaymentGuidance } from "@/data/schemas/chat"; import type { PendingChatPromotion } from "@/data/storage/navigation"; import { DEFAULT_CHARACTER, @@ -21,6 +21,7 @@ export interface ChatUnlockMessageRequest { export interface ChatUnlockPaywallRequest extends ChatUnlockMessageRequest { + paymentGuidance?: PaymentGuidance | null; promotion?: PendingChatPromotion; reason: string; creditBalance: number; @@ -39,6 +40,7 @@ export interface ChatState { pendingReplyCount: number; upgradePromptVisible: boolean; upgradeReason: ChatUpgradeReason | null; + paymentGuidance: PaymentGuidance | null; canSendMessage: boolean; creditBalance: number; creditsCharged: number; @@ -56,6 +58,7 @@ export interface ChatState { unlockHistoryPromptVisible: boolean; lockedHistoryCount: number; unlockHistoryError: string | null; + unlockHistoryPaymentGuidance: PaymentGuidance | null; unlockingMessage: ChatUnlockMessageRequest | null; unlockMessageError: string | null; unlockPaywallRequest: ChatUnlockPaywallRequest | null; @@ -81,6 +84,7 @@ export function createInitialChatState( creditsCharged: 0, requiredCredits: 0, shortfallCredits: 0, + paymentGuidance: null, historyLoaded: false, networkHistoryLoaded: false, historyTotal: 0, @@ -91,6 +95,7 @@ export function createInitialChatState( unlockHistoryPromptVisible: false, lockedHistoryCount: 0, unlockHistoryError: null, + unlockHistoryPaymentGuidance: null, unlockingMessage: null, unlockMessageError: null, unlockPaywallRequest: null, diff --git a/src/stores/chat/helper/message-mappers.ts b/src/stores/chat/helper/message-mappers.ts index bfa59034..ee2f5a77 100644 --- a/src/stores/chat/helper/message-mappers.ts +++ b/src/stores/chat/helper/message-mappers.ts @@ -74,11 +74,13 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { ...(response.audioUrl && !response.lockDetail.locked ? { audioUrl: response.audioUrl } : {}), - ...(response.chatAction - ? { chatAction: response.chatAction } - : response.commercialAction - ? { commercialAction: response.commercialAction } - : {}), + ...(response.paymentGuidance + ? { paymentGuidance: response.paymentGuidance } + : response.chatAction + ? { chatAction: response.chatAction } + : response.commercialAction + ? { commercialAction: response.commercialAction } + : {}), ...deriveUiLockFields(response.lockDetail, response.image.url), }; } diff --git a/src/stores/chat/helper/send-state.ts b/src/stores/chat/helper/send-state.ts index c12d75ac..c9a4b3b7 100644 --- a/src/stores/chat/helper/send-state.ts +++ b/src/stores/chat/helper/send-state.ts @@ -53,6 +53,7 @@ export function applyHttpSendOutput( ...finishPendingReply(context), upgradePromptVisible: true, upgradeReason, + paymentGuidance: response.paymentGuidance, ...sendCapabilityState, }; } @@ -60,6 +61,7 @@ export function applyHttpSendOutput( if (!reply) { return { ...finishPendingReply(context), + paymentGuidance: response.paymentGuidance, ...sendCapabilityState, }; } @@ -69,6 +71,7 @@ export function applyHttpSendOutput( ...finishPendingReply(context), upgradePromptVisible: false, upgradeReason: null, + paymentGuidance: response.paymentGuidance, ...sendCapabilityState, }; } diff --git a/src/stores/chat/machine/actors/unlock.ts b/src/stores/chat/machine/actors/unlock.ts index c89434cb..9c8f4ac9 100644 --- a/src/stores/chat/machine/actors/unlock.ts +++ b/src/stores/chat/machine/actors/unlock.ts @@ -1,6 +1,7 @@ import { fromPromise } from "xstate"; import { loadChatRepository } from "@/data/repositories/chat_repository_loader"; +import type { PaymentGuidance } from "@/data/schemas/chat"; import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity"; import { Logger } from "@/utils/logger"; import { Result } from "@/utils/result"; @@ -21,6 +22,7 @@ export interface UnlockHistoryOutput { reason: string; shortfallCredits: number; messages: UiMessage[]; + paymentGuidance?: PaymentGuidance | null; } export const unlockHistoryActor = fromPromise< @@ -50,6 +52,7 @@ export const unlockHistoryActor = fromPromise< reason: unlockResult.data.reason, shortfallCredits: unlockResult.data.shortfallCredits, messages: history.messages, + paymentGuidance: unlockResult.data.paymentGuidance, }; }); diff --git a/src/stores/chat/machine/send-flow.ts b/src/stores/chat/machine/send-flow.ts index 5c2a8552..c6816ea8 100644 --- a/src/stores/chat/machine/send-flow.ts +++ b/src/stores/chat/machine/send-flow.ts @@ -45,7 +45,8 @@ const appendGuestUserMessageAction = historyMachineSetup.assign( ], outgoingMessageRevision: context.outgoingMessageRevision + 1, upgradePromptVisible: false, - upgradeReason: null, + upgradeReason: null, + paymentGuidance: null, }; }, ); @@ -73,7 +74,8 @@ const appendUserMessageAction = historyMachineSetup.assign( ], outgoingMessageRevision: context.outgoingMessageRevision + 1, upgradePromptVisible: false, - upgradeReason: null, + upgradeReason: null, + paymentGuidance: null, }; }, ); @@ -124,6 +126,7 @@ const clearUpgradePromptAction = historyMachineSetup.assign(() => ({ canSendMessage: true, requiredCredits: 0, shortfallCredits: 0, + paymentGuidance: null, })); const appendGuestUserImageAction = historyMachineSetup.assign( diff --git a/src/stores/chat/machine/unlock-flow.ts b/src/stores/chat/machine/unlock-flow.ts index e25026d3..e13c9147 100644 --- a/src/stores/chat/machine/unlock-flow.ts +++ b/src/stores/chat/machine/unlock-flow.ts @@ -22,6 +22,7 @@ const showUnlockHistoryPromptAction = sendMachineSetup.assign( unlockHistoryPromptVisible: true, lockedHistoryCount: countLockedHistoryMessages(context.messages), unlockHistoryError: null, + unlockHistoryPaymentGuidance: null, }), ); @@ -29,12 +30,14 @@ const dismissUnlockHistoryPromptAction = sendMachineSetup.assign(() => ({ paymentUnlockPending: false, unlockHistoryPromptVisible: false, unlockHistoryError: null, + unlockHistoryPaymentGuidance: null, })); const markUnlockHistoryStartedAction = sendMachineSetup.assign(() => ({ paymentUnlockPending: false, unlockHistoryPromptVisible: false, unlockHistoryError: null, + unlockHistoryPaymentGuidance: null, })); const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({ @@ -76,6 +79,7 @@ const applyUnlockMessageSucceededAction = sendMachineSetup.assign( creditsCharged: output.response.creditsCharged, requiredCredits: 0, shortfallCredits: 0, + paymentGuidance: null, }; }, ); @@ -112,11 +116,15 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign( creditBalance: output.response.creditBalance, requiredCredits: output.response.requiredCredits, shortfallCredits: output.response.shortfallCredits, + ...(output.response.paymentGuidance + ? { paymentGuidance: output.response.paymentGuidance } + : {}), } : null, creditBalance: output.response.creditBalance, requiredCredits: output.response.requiredCredits, shortfallCredits: output.response.shortfallCredits, + paymentGuidance: output.response.paymentGuidance, }; }, ); @@ -191,6 +199,9 @@ const applyUnlockHistoryOutputAction = unlockHistoryError: insufficientBalance ? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.` : null, + unlockHistoryPaymentGuidance: insufficientBalance + ? event.output.paymentGuidance ?? null + : null, }; }); diff --git a/src/stores/chat/ui-message.ts b/src/stores/chat/ui-message.ts index 4dfd0175..a52d2134 100644 --- a/src/stores/chat/ui-message.ts +++ b/src/stores/chat/ui-message.ts @@ -1,5 +1,9 @@ import { z } from "zod"; -import { ChatActionSchema, CommercialActionSchema } from "@/data/schemas/chat"; +import { + ChatActionSchema, + CommercialActionSchema, + PaymentGuidanceSchema, +} from "@/data/schemas/chat"; export const UiMessageSchema = z.object({ displayId: z.string().min(1), @@ -19,6 +23,7 @@ export const UiMessageSchema = z.object({ privateMessageHint: z.string().nullable().optional(), commercialAction: CommercialActionSchema.nullable().optional(), chatAction: ChatActionSchema.nullable().optional(), + paymentGuidance: PaymentGuidanceSchema.nullable().optional(), }); export type UiMessage = z.infer; diff --git a/src/stores/payment/__tests__/payment-order-flow.test.ts b/src/stores/payment/__tests__/payment-order-flow.test.ts index 05d33fda..c83e0e6e 100644 --- a/src/stores/payment/__tests__/payment-order-flow.test.ts +++ b/src/stores/payment/__tests__/payment-order-flow.test.ts @@ -304,7 +304,10 @@ describe("payment order flow", () => { expect(actor.getSnapshot().context).toMatchObject({ orderStatus: "expired", - payParams: null, + payParams: { + provider: "stripe", + clientSecret: "pi_test_secret_test", + }, errorMessage: "This payment order has expired. Please create a new order.", }); diff --git a/src/stores/payment/machine/order-flow.ts b/src/stores/payment/machine/order-flow.ts index b198167c..891f9dad 100644 --- a/src/stores/payment/machine/order-flow.ts +++ b/src/stores/payment/machine/order-flow.ts @@ -156,7 +156,6 @@ const applyFailedOrderAction = pollOrderDoneSetup.assign(({ event }) => ({ const applyExpiredOrderAction = pollOrderDoneSetup.assign(({ event }) => ({ orderStatus: event.output.status, - payParams: null, orderPollingStartedAt: null, errorMessage: "This payment order has expired. Please create a new order.", })); diff --git a/src/stores/private-zone-posts/private-zone-post-machine.ts b/src/stores/private-zone-posts/private-zone-post-machine.ts index 77703ffb..fa6a43ed 100644 --- a/src/stores/private-zone-posts/private-zone-post-machine.ts +++ b/src/stores/private-zone-posts/private-zone-post-machine.ts @@ -203,6 +203,9 @@ export const privateZonePostMachine = machineSetup.createMachine({ currentCredits: event.output.currentCredits || event.output.creditBalance, shortfallCredits: event.output.shortfallCredits, + ...(event.output.paymentGuidance + ? { paymentGuidance: event.output.paymentGuidance } + : {}), }, })), }, diff --git a/src/stores/private-zone-posts/private-zone-post-state.ts b/src/stores/private-zone-posts/private-zone-post-state.ts index 4b256917..a55f216c 100644 --- a/src/stores/private-zone-posts/private-zone-post-state.ts +++ b/src/stores/private-zone-posts/private-zone-post-state.ts @@ -1,4 +1,5 @@ import type { PrivateZoneVideoPost } from "@/data/schemas/private-zone"; +import type { PaymentGuidance } from "@/data/schemas/chat"; export interface PrivateZonePostPaywallRequest { postId: string; @@ -6,6 +7,7 @@ export interface PrivateZonePostPaywallRequest { requiredCredits: number; currentCredits: number; shortfallCredits: number; + paymentGuidance?: PaymentGuidance | null; } export interface PrivateZonePostState { diff --git a/src/stores/private-zone/helper/unlock.ts b/src/stores/private-zone/helper/unlock.ts index c31693ea..2512d16a 100644 --- a/src/stores/private-zone/helper/unlock.ts +++ b/src/stores/private-zone/helper/unlock.ts @@ -37,5 +37,8 @@ export function toPaywallRequest( requiredCredits: response.requiredCredits || response.unlockCost, currentCredits: response.creditBalance, shortfallCredits: response.shortfallCredits, + ...(response.paymentGuidance + ? { paymentGuidance: response.paymentGuidance } + : {}), }; } diff --git a/src/stores/private-zone/private-zone-state.ts b/src/stores/private-zone/private-zone-state.ts index af0eda66..ea83ff14 100644 --- a/src/stores/private-zone/private-zone-state.ts +++ b/src/stores/private-zone/private-zone-state.ts @@ -1,5 +1,6 @@ import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; import type { PrivateAlbum } from "@/data/schemas/private-zone"; +import type { PaymentGuidance } from "@/data/schemas/chat"; export interface PrivateZoneUnlockPaywallRequest { albumId: string; @@ -7,6 +8,7 @@ export interface PrivateZoneUnlockPaywallRequest { requiredCredits: number; currentCredits: number; shortfallCredits: number; + paymentGuidance?: PaymentGuidance | null; } export interface PrivateZoneState {