44 Commits

Author SHA1 Message Date
Codex aeebd7f704 fix(payment): route Philippine GCash separately from QRIS
Docker Image / Build and Push Docker Image (push) Successful in 2m7s
2026-07-29 19:13:14 +08:00
Codex 089259e5e0 fix(payment): keep character and mobile actions visible
Docker Image / Build and Push Docker Image (push) Successful in 2m6s
2026-07-29 13:51:39 +08:00
Codex 4bfede7dfd fix(payment): resume QRIS after closing dialog 2026-07-29 13:51:39 +08:00
Codex 47c5f4098f feat(payment): confirm renewal only at checkout 2026-07-29 13:51:39 +08:00
Codex 196f9e39dc fix(payment): inherit chat recipient without selector
Docker Image / Build and Push Docker Image (push) Successful in 2m30s
2026-07-28 19:43:37 +08:00
Codex 9121b50e02 feat(payment): add chat support discounts and gratitude
Docker Image / Build and Push Docker Image (push) Successful in 3m1s
(cherry picked from commit ef9b79bc83)
2026-07-28 18:36:52 +08:00
Codex e43912eebf feat(payment): expand Stripe methods and checkout handoff 2026-07-28 18:36:51 +08:00
Codex 38ae06fe04 feat(payment): accept support character guidance scene
Docker Image / Build and Push Docker Image (push) Successful in 2m10s
2026-07-28 12:53:44 +08:00
Codex 995fdbda57 fix(payment): hide first recharge banner
Docker Image / Build and Push Docker Image (push) Successful in 2m18s
2026-07-28 11:36:12 +08:00
Codex ed822c159f chore(release): promote pre changes to production main
Docker Image / Build and Push Docker Image (push) Successful in 2m20s
2026-07-28 10:57:09 +08:00
Codex a2cf62f78b feat(private-zone): default to albums tab
Docker Image / Build and Push Docker Image (push) Successful in 2m7s
2026-07-27 19:05:58 +08:00
Codex 67f292353e fix(payment): portal checkout dialogs to viewport
Docker Image / Build and Push Docker Image (push) Successful in 3m32s
2026-07-27 18:53:37 +08:00
Codex 8c0d1ad3ce feat(chat): render role-bound payment guidance
Docker Image / Build and Push Docker Image (push) Successful in 5m2s
2026-07-27 18:21:13 +08:00
Codex 019caae598 feat(auth): add Messenger topup handoff entry
Docker Image / Build and Push Docker Image (push) Successful in 2m9s
2026-07-27 17:25:31 +08:00
Codex 73e6f341fb fix(ci): deploy frontend production as lzf
Docker Image / Build and Push Docker Image (push) Successful in 2m18s
2026-07-27 16:58:07 +08:00
Codex d6f6bc2d87 test(chat): enforce locked image URL masking
Docker Image / Build and Push Docker Image (push) Successful in 2m15s
2026-07-27 15:44:17 +08:00
Codex b34d3a3a67 feat(subscription): separate selection from payment and add issue feedback
Docker Image / Build and Push Docker Image (push) Successful in 2m2s
(cherry picked from commit fe9d31146b)
2026-07-27 11:42:49 +08:00
Codex 9602fdd94d fix(external-entry): preserve legacy private room links
Docker Image / Build and Push Docker Image (push) Successful in 2m12s
2026-07-27 11:31:59 +08:00
Codex bb1f0d225c feat(chat): send idempotent client message ids
Docker Image / Build and Push Docker Image (push) Successful in 2m18s
2026-07-27 11:00:37 +08:00
Codex 0d5b5c17fa feat(private-zone): add paid video moments
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
2026-07-24 20:19:49 +08:00
Codex 30ab2c2c97 feat(chat): add support action cards and live entitlements
Docker Image / Build and Push Docker Image (push) Successful in 2m2s
2026-07-24 18:08:21 +08:00
Codex 17236bd14e feat(call): add streaming voice call experience
Docker Image / Build and Push Docker Image (push) Successful in 2m3s
2026-07-24 16:42:21 +08:00
Codex 5e0361a199 fix(ci): scope frontend image cleanup
Docker Image / Build and Push Docker Image (push) Successful in 2m10s
2026-07-24 13:17:20 +08:00
Codex 8660fe7b7e fix(pwa): 统一桌面安装图标
Docker Image / Build and Push Docker Image (push) Successful in 2m6s
(cherry picked from commit 29abd746da)
2026-07-24 12:19:40 +08:00
Codex a20a333665 feat(mobile): improve image viewer and install entry
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
(cherry picked from commit 556bfd2919)
2026-07-24 11:58:13 +08:00
Codex 92768047e9 fix(chat): debounce post-send action menu
Docker Image / Build and Push Docker Image (push) Successful in 2m1s
2026-07-23 20:07:17 +08:00
Codex 4dae805a88 feat(payment): support user-bound seven-discount offers
Docker Image / Build and Push Docker Image (push) Successful in 1m55s
2026-07-23 16:13:22 +08:00
Codex 02f6964484 feat(chat): render commercial actions and persist greetings
Docker Image / Build and Push Docker Image (push) Successful in 1m56s
2026-07-23 14:54:23 +08:00
Codex 79397af739 feat(splash): simplify multi-character home layout
Docker Image / Build and Push Docker Image (push) Successful in 2m1s
2026-07-23 13:33:17 +08:00
Codex 770ce6b8fd fix(private-zone): hide incomplete albums and refresh Maya cover
Docker Image / Build and Push Docker Image (push) Successful in 1m59s
2026-07-23 11:51:14 +08:00
Codex 0357fbcaff refactor(private-zone): use canonical product name
Docker Image / Build and Push Docker Image (push) Successful in 2m7s
2026-07-23 10:55:47 +08:00
Codex 30122a44db fix(multi-role): enable Maya and Nayeli commercial flows
Docker Image / Build and Push Docker Image (push) Successful in 2m7s
2026-07-23 10:24:13 +08:00
Codex 2bb3da829e chore(release): promote favorite and Menu navigation to pre
Docker Image / Build and Push Docker Image (push) Successful in 1m55s
2026-07-22 19:38:11 +08:00
Codex 163ba78f06 feat(navigation): add favorite entry and Menu tab 2026-07-22 19:37:41 +08:00
Codex fd2291fa62 docs(release): remove Private Zoom legacy literal [skip ci] 2026-07-22 19:34:48 +08:00
Codex 4e71dbd5f8 docs(private-zoom): remove legacy literal [skip ci] 2026-07-22 19:34:46 +08:00
Codex 36e5dd3c96 docs(release): sync verified Private Zoom rollout [skip ci] 2026-07-22 19:33:03 +08:00
Codex 40184c7631 docs(private-zoom): record verified rollout [skip ci] 2026-07-22 19:33:01 +08:00
Codex 7103652829 chore(release): promote pnpm bootstrap fix to pre
Docker Image / Build and Push Docker Image (push) Successful in 5m56s
2026-07-22 19:29:53 +08:00
Codex 4fceab537d fix(ci): install pnpm from configured registry 2026-07-22 19:29:42 +08:00
Codex dff112eadc chore(release): promote Docker registry fix to pre
Docker Image / Build and Push Docker Image (push) Failing after 1m12s
2026-07-22 19:27:22 +08:00
Codex e1454cd002 fix(ci): use reachable pnpm registry for Docker builds 2026-07-22 19:26:57 +08:00
Codex 437ac3460b chore(release): promote Indonesia QRIS to pre
Docker Image / Build and Push Docker Image (push) Failing after 3m46s
2026-07-22 18:58:54 +08:00
Codex 1e25279e8f feat(payment): support Indonesia QRIS checkout 2026-07-22 18:57:22 +08:00
149 changed files with 5108 additions and 857 deletions
+6 -3
View File
@@ -12,7 +12,8 @@ export interface MockCoreApisOptions {
paidImageFlow?: boolean;
paidImageInsufficientCreditsFlow?: boolean;
paidVoiceInsufficientCreditsFlow?: boolean;
psidLoginFlow?: boolean;
topUpHandoffFlow?: boolean;
checkoutHandoffFlow?: boolean;
}
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
@@ -22,14 +23,16 @@ 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,
checkoutHandoffFlow: options.checkoutHandoffFlow ?? false,
};
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
await registerAuthMocks(page, {
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
psidLoginFlow: chatOptions.psidLoginFlow,
topUpHandoffFlow: chatOptions.topUpHandoffFlow,
checkoutHandoffFlow: chatOptions.checkoutHandoffFlow,
});
await registerCharacterMocks(page);
await registerUserMocks(page);
+13 -5
View File
@@ -1,22 +1,30 @@
import type { Page } from "@playwright/test";
import { apiEnvelope } from "../data/common";
import { emailLoginResponse, guestLoginResponse, psidLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth";
import { checkoutHandoffResponse, emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth";
export interface AuthMockState {
chatSendTokenRefreshFlow: boolean;
isChatSendTokenExpired: () => boolean;
psidLoginFlow: boolean;
topUpHandoffFlow: boolean;
checkoutHandoffFlow: 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/checkout/consume", async (route) => {
if (!state.checkoutHandoffFlow) {
await route.continue();
return;
}
await route.fulfill({ json: apiEnvelope(checkoutHandoffResponse) });
});
await route.fulfill({ json: apiEnvelope(psidLoginResponse) });
await page.route("**/api/auth/handoff/topup/consume", async (route) => {
if (!state.topUpHandoffFlow) {
await route.continue();
return;
}
await route.fulfill({ json: apiEnvelope(topUpHandoffResponse) });
});
await page.route("**/api/auth/guest", async (route) => {
+24 -10
View File
@@ -50,14 +50,28 @@ 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",
},
};
export const checkoutHandoffResponse = {
token: "e2e-checkout-token",
refreshToken: "e2e-checkout-refresh-token",
loginStatus: "email",
user: e2eEmailUser,
checkoutIntent: {
planId: "vip_monthly",
autoRenew: true,
commercialOfferId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
},
};
+4 -5
View File
@@ -2,18 +2,17 @@ import { expect, type Page } from "@playwright/test";
export async function completeVipPayment(page: Page) {
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
await expect(checkoutButton).toBeDisabled();
await page.getByRole("button", { name: /Monthly,/i }).click();
await expect(checkoutButton).toBeEnabled();
await checkoutButton.click();
const renewalDialog = page.getByRole("dialog", {
name: "Automatic Renewal Confirmation",
});
await expect(renewalDialog).toBeVisible();
await renewalDialog.getByRole("button", { name: "Confirm" }).click();
await expect(checkoutButton).toBeEnabled();
const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order");
const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**");
await checkoutButton.click();
await renewalDialog.getByRole("button", { name: "Confirm" }).click();
const createOrderRequest = await createOrderRequestPromise;
expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" });
await orderStatusRequestPromise;
@@ -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");
});
@@ -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/,
);
});
@@ -182,7 +182,7 @@ async function registerFavoriteMenuMocks(page: Page): Promise<void> {
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;
}
@@ -0,0 +1,105 @@
import { expect, test } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { apiEnvelope } from "@e2e/fixtures/data/common";
import {
clearBrowserState,
seedEmailSession,
} from "@e2e/fixtures/test-helpers";
const facebookAndroidUserAgent =
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Version/4.0 Chrome/126.0.0.0 Mobile Safari/537.36 " +
"[FB_IAB/FB4A;FBAV/566.0.0.48.73;]";
const handoffToken = "checkout-handoff-token-that-is-at-least-32-characters";
test.use({
userAgent: facebookAndroidUserAgent,
viewport: { width: 390, height: 844 },
});
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
});
test("consumes the handoff, removes its token, and does not create an order", async ({
page,
}) => {
await mockCoreApis(page, { checkoutHandoffFlow: true });
let createOrderRequests = 0;
page.on("request", (request) => {
if (request.url().includes("/api/payment/create-order")) {
createOrderRequests += 1;
}
});
const consumeRequest = page.waitForRequest(
"**/api/auth/handoff/checkout/consume",
);
await page.goto(
`/external-entry?target=checkout&character=nayeli&handoffToken=${encodeURIComponent(handoffToken)}`,
);
expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken });
await expect(page).toHaveURL(
/\/subscription\?planId=vip_monthly&autoRenew=1&character=nayeli&commercialOfferId=.*&payChannel=stripe/,
);
expect(page.url()).not.toContain("handoffToken");
await expect.poll(() => createOrderRequests).toBe(0);
await expect
.poll(() =>
page.evaluate(() => ({
loginProvider: localStorage.getItem("cozsweet:login_provider"),
loginToken: localStorage.getItem("cozsweet:login_token"),
})),
)
.toEqual({ loginProvider: "email", loginToken: "e2e-checkout-token" });
});
test("offers the Facebook external-browser path before creating a Stripe order", async ({
page,
}) => {
await mockCoreApis(page);
let createOrderRequests = 0;
page.on("request", (request) => {
if (request.url().includes("/api/payment/create-order")) {
createOrderRequests += 1;
}
});
await page.route("**/api/auth/handoff/checkout", async (route) => {
await route.fulfill({
json: apiEnvelope({
externalUrl:
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque-token",
expiresAt: "2026-07-28T16:00:00+00:00",
}),
});
});
await seedEmailSession(page);
await page.goto("/subscription?type=vip&payChannel=stripe&character=maya");
await page.getByRole("button", { name: /Monthly,/i }).click();
const externalButton = page.getByRole("button", {
name: "Open in browser for more payment methods",
});
const paymentButton = page.getByRole("button", { name: "Pay and Top Up" });
await expect(externalButton).toBeVisible();
await expect(externalButton).toBeEnabled();
await expect(paymentButton).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
).toHaveCount(0);
const externalBox = await externalButton.boundingBox();
const paymentBox = await paymentButton.boundingBox();
expect(externalBox).not.toBeNull();
expect(paymentBox).not.toBeNull();
expect(externalBox!.y).toBeLessThan(paymentBox!.y);
expect(paymentBox!.y + paymentBox!.height).toBeLessThanOrEqual(844);
const handoffRequest = page.waitForRequest("**/api/auth/handoff/checkout");
await externalButton.click();
expect((await handoffRequest).postDataJSON()).toMatchObject({
planId: "vip_monthly",
autoRenew: true,
});
expect(createOrderRequests).toBe(0);
});
@@ -0,0 +1,364 @@
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<string, "pending" | "paid">();
const orderPlans = new Map<string, { planId: string; orderType: string }>();
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",
recipientCharacterId: "elio",
});
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&character=elio");
await expect(page.getByText("Choose who you want to support")).toHaveCount(0);
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Elio", exact: true }),
).toHaveCount(0);
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
"aria-pressed",
"true",
);
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
await expect(checkoutButton).toBeEnabled();
await expectCheckoutButtonLayout(page);
await expect(
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
).toHaveCount(0);
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&character=elio");
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
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("closing QRIS restores payment selection and allows switching to Stripe", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup&character=elio");
await expectQrisOrder(
page,
/Pay and Top Up/i,
"dol_1000",
/Rp\s*88[.,]900/,
);
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.getByRole("button", { name: "Pay and Top Up" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
});
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();
await expectQrisOrder(
page,
/Order and Buy/i,
"tip_coffee_usd_4_99",
/Rp\s*89[.,]299/,
);
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(payment.getCreateOrderCount()).toBe(1);
});
@@ -0,0 +1,23 @@
import { expect, test } from "@playwright/test";
test("serves the VIP membership benefits agreement from the same site", async ({
page,
}) => {
const response = await page.goto("/legal/vip-membership-benefits.html");
expect(response?.status()).toBe(200);
await expect(
page.getByRole("heading", { name: "VIP Membership Benefits Agreement" }),
).toBeVisible();
});
test("serves the automatic renewal agreement from the same site", async ({
page,
}) => {
const response = await page.goto("/legal/automatic-renewal.html");
expect(response?.status()).toBe(200);
await expect(
page.getByRole("heading", { name: "Automatic Renewal Agreement" }),
).toBeVisible();
});
@@ -0,0 +1,153 @@
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 phpPlans = {
plans: [
{
planId: "dol_1000",
planName: "1,000 Credits",
orderType: "dol",
amountCents: 49_990,
originalAmountCents: 74_990,
dailyPriceCents: null,
currency: "PHP",
vipDays: null,
dolAmount: 1_000,
creditBalance: 1_000,
},
],
};
async function registerPhilippinesPaymentMocks(
page: Page,
response: "hosted" | "qr",
) {
let createOrderCount = 0;
await page.route("**/api/user/profile", async (route) => {
await route.fulfill({
json: apiEnvelope({ ...e2eEmailUser, countryCode: "PH" }),
});
});
await page.route("**/api/payment/plans**", async (route) => {
await route.fulfill({ json: apiEnvelope(phpPlans) });
});
await page.route("**/api/payment/create-order", async (route) => {
createOrderCount += 1;
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
payParams: {
provider: "ezpay",
countryCode: "PH",
channelCode: "PH_QRPH_DYNAMIC",
channelType: "QR",
payData: "000201010212ph-qr-payload",
...(response === "hosted"
? { cashierUrl: "https://pay.example/gcash-hosted" }
: {}),
firstChargeAmountCents: 49_990,
currency: "PHP",
},
}),
});
});
await page.route("**/api/payment/order-status**", async (route) => {
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
status: "pending",
orderType: "dol",
planId: "dol_1000",
creditsAdded: 0,
}),
});
});
return {
getCreateOrderCount: () => createOrderCount,
};
}
async function preparePhilippinesUser(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: "PH" }),
);
});
}
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
});
test("Philippines checkout prefers the hosted GCash URL even for a QR response", async ({
page,
}) => {
const payment = await registerPhilippinesPaymentMocks(page, "hosted");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await expect(page.getByRole("button", { name: "GCash" })).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByRole("button", { name: "Pay and Top Up" }).click();
await expect(
page.getByRole("alertdialog", { name: "Continue to payment?" }),
).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Pay with GCash / QR Ph" }),
).toHaveCount(0);
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toHaveCount(0);
expect(payment.getCreateOrderCount()).toBe(1);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
});
test("Philippines QR-only fallback is labeled GCash / QR Ph and can switch to Stripe", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const payment = await registerPhilippinesPaymentMocks(page, "qr");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await page.getByRole("button", { name: "Pay and Top Up" }).click();
const dialog = page.getByRole("dialog", {
name: "Pay with GCash / QR Ph",
});
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(/₱\s*499\.90/);
await expect(dialog).not.toContainText("QRIS");
await expect(
dialog.getByRole("img", { name: "GCash / QR Ph payment QR code" }),
).toBeVisible();
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(payment.getCreateOrderCount()).toBe(1);
});
+1
View File
@@ -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",
+12
View File
@@ -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: {}
+65
View File
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<title>Automatic Renewal Agreement | Cozsweet</title>
<style>
:root { font-family: Georgia, "Times New Roman", serif; color: #2b1721; background: #fff8fb; }
* { box-sizing: border-box; }
body { margin: 0; padding: 24px 16px 48px; line-height: 1.65; }
main { width: min(100%, 760px); margin: 0 auto; padding: clamp(22px, 6vw, 48px); border: 1px solid #f3c6dc; border-radius: 24px; background: #fff; box-shadow: 0 18px 50px rgba(133, 45, 88, .1); }
h1 { margin: 0 0 8px; font-size: clamp(28px, 7vw, 42px); line-height: 1.12; }
h2 { margin: 28px 0 8px; font-size: 21px; }
p, li { font-family: Arial, Helvetica, sans-serif; color: #553d49; }
.meta { margin: 0 0 28px; color: #8c6176; font-size: 14px; }
.notice { padding: 14px 16px; border-radius: 14px; background: #fff0f7; }
</style>
</head>
<body>
<main>
<h1>Automatic Renewal Agreement</h1>
<p class="meta">Effective date: July 29, 2026</p>
<p class="notice">
By selecting <strong>Confirm</strong> before payment, you authorize Cozsweet and its payment provider to renew the selected membership automatically under the terms shown below.
</p>
<h2>1. When renewal applies</h2>
<p>
Automatic renewal applies only when the selected membership is identified as auto-renewing in the purchase flow. One-time credit purchases, tips, gifts, lifetime plans, and payment methods explicitly shown as one-time purchases do not automatically renew.
</p>
<h2>2. Billing frequency</h2>
<p>
The plan renews at the end of the billing period shown at checkout, such as monthly, quarterly, or annually. Renewal continues until cancelled. Your payment method may be charged shortly before or on the start of the next period as permitted by the payment provider.
</p>
<h2>3. Renewal amount</h2>
<p>
The initial charge and renewal amount are displayed before confirmation. A promotion may reduce the first charge without reducing later renewals. Unless another renewal amount is shown, renewal uses the applicable regular price for the selected plan, together with any taxes or fees required by law.
</p>
<h2>4. Confirmation</h2>
<p>
Cozsweet asks for renewal confirmation when an account first attempts to pay for an automatically renewing membership in the current agreement version. Cancelling the confirmation does not create an order. Confirming records the acknowledgement in the current browser and continues to payment.
</p>
<h2>5. Cancellation</h2>
<p>
You may cancel future renewal using a cancellation option made available in the product or by using the support/payment-issue path before the next renewal is processed. Cancellation takes effect for future billing; membership already paid for normally remains available until the end of the current term.
</p>
<h2>6. Failed or disputed payments</h2>
<p>
A failed renewal can interrupt membership benefits. If you see a duplicate charge, a long-pending payment, or missing benefits, do not repeatedly submit payment. Use <strong>Payment issue?</strong> on the purchase page and keep the returned Feedback ID. Never provide passwords, full card numbers, security codes, verification codes, or access tokens.
</p>
<h2>7. Material changes</h2>
<p>
If the renewal terms materially change, Cozsweet will update this agreement and request a new acknowledgement when required. The version effective for a payment is the version linked from its confirmation dialog.
</p>
</main>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<title>VIP Membership Benefits Agreement | Cozsweet</title>
<style>
:root { font-family: Georgia, "Times New Roman", serif; color: #2b1721; background: #fff8fb; }
* { box-sizing: border-box; }
body { margin: 0; padding: 24px 16px 48px; line-height: 1.65; }
main { width: min(100%, 760px); margin: 0 auto; padding: clamp(22px, 6vw, 48px); border: 1px solid #f3c6dc; border-radius: 24px; background: #fff; box-shadow: 0 18px 50px rgba(133, 45, 88, .1); }
h1 { margin: 0 0 8px; font-size: clamp(28px, 7vw, 42px); line-height: 1.12; }
h2 { margin: 28px 0 8px; font-size: 21px; }
p, li { font-family: Arial, Helvetica, sans-serif; color: #553d49; }
.meta { margin: 0 0 28px; color: #8c6176; font-size: 14px; }
a { color: #d8327c; }
.notice { padding: 14px 16px; border-radius: 14px; background: #fff0f7; }
</style>
</head>
<body>
<main>
<h1>VIP Membership Benefits Agreement</h1>
<p class="meta">Effective date: July 29, 2026</p>
<p class="notice">
This agreement explains the VIP membership displayed on the Cozsweet purchase page. The plan name, price, currency, duration, credits, and benefits shown at checkout are the terms that apply to your purchase.
</p>
<h2>1. Membership benefits</h2>
<p>
VIP may include the chat access, credits, private content access, or other benefits displayed for the selected plan. Benefits are available only for the membership period and account shown at checkout. A membership does not guarantee that every feature or item is free; some content can still require credits or a separate purchase when clearly shown in the product.
</p>
<h2>2. Plan term and account</h2>
<p>
Membership is attached to the Cozsweet account used for payment. The selected monthly, quarterly, annual, lifetime, or other plan term is shown before payment. Do not share payment credentials or account access with another person.
</p>
<h2>3. Automatic renewal</h2>
<p>
A plan marked as automatically renewing continues until cancelled. Before the first automatically renewing purchase, Cozsweet displays a separate confirmation. Please also read the <a href="/legal/automatic-renewal.html">Automatic Renewal Agreement</a>.
</p>
<h2>4. Prices and promotions</h2>
<p>
The checkout page is the source of truth for the current amount and currency. A first-payment or limited promotion can apply only to the initial charge; the renewal amount may be the regular price displayed in the renewal notice. Cozsweet does not ask you to pay a price that is not shown in the checkout flow.
</p>
<h2>5. Payment and access</h2>
<p>
Benefits are activated only after payment is confirmed and the order is fulfilled. A pending or failed payment does not activate membership. If payment is duplicated, remains pending, or benefits are missing, do not submit repeated payments; use the <strong>Payment issue?</strong> entry on the purchase page and retain the returned Feedback ID.
</p>
<h2>6. Cancellation, refunds, and applicable rights</h2>
<p>
Cancellation stops future renewal but does not normally remove benefits already paid for during the current term. Refund rights depend on applicable law, the payment provider, and the circumstances of the order. Use the product support or payment-issue path for review; never send a password, card number, security code, verification code, or access token.
</p>
<h2>7. Changes</h2>
<p>
If a material membership term changes, the updated version and effective date will be made available before it applies to a new purchase or renewal where required.
</p>
</main>
</body>
</html>
@@ -0,0 +1,77 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { createCheckoutHandoff, openUrlWithExternalBrowser } = vi.hoisted(
() => ({
createCheckoutHandoff: vi.fn(),
openUrlWithExternalBrowser: vi.fn(),
}),
);
vi.mock("@/lib/auth/checkout_handoff", () => ({ createCheckoutHandoff }));
vi.mock("@/utils/url-launcher-util", () => ({
UrlLauncherUtil: { openUrlWithExternalBrowser },
}));
vi.mock("@/utils/browser-detect", () => ({
BrowserDetector: { isFacebookInAppBrowser: () => true },
}));
import { ExternalBrowserCheckoutButton } from "../external-browser-checkout-button";
describe("ExternalBrowserCheckoutButton", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.clearAllMocks();
createCheckoutHandoff.mockResolvedValue({
success: true,
data: {
externalUrl:
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque",
expiresAt: "2026-07-28T16:00:00+00:00",
},
});
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("creates only a handoff before opening the external browser", async () => {
await act(async () => {
root.render(
<ExternalBrowserCheckoutButton
characterSlug="maya"
checkoutIntent={{
planId: "vip_monthly",
autoRenew: true,
commercialOfferId: "offer-1",
}}
/>,
);
});
const button = container.querySelector("button");
expect(button?.textContent).toContain(
"Open in browser for more payment methods",
);
await act(async () => button?.click());
expect(createCheckoutHandoff).toHaveBeenCalledWith({
planId: "vip_monthly",
autoRenew: true,
commercialOfferId: "offer-1",
});
expect(openUrlWithExternalBrowser).toHaveBeenCalledWith(
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque&character=maya",
);
});
});
@@ -7,12 +7,19 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
const hiddenLaunch = {
handleEzpayCancel: vi.fn(),
handleEzpayConfirm: vi.fn(),
handleRegionalQrClose: vi.fn(),
handleStripeClose: vi.fn(),
handleStripeConfirmed: vi.fn(),
isConfirmingEzpay: false,
regionalQrErrorMessage: null,
regionalQrPayment: null,
regionalQrStatus: null,
shouldShowEzpayConfirmDialog: false,
shouldShowRegionalQrDialog: false,
shouldShowStripeDialog: false,
stripeClientSecret: null,
stripeCustomerSessionClientSecret: null,
savedPaymentMethodsEnabled: false,
};
describe("PaymentLaunchDialogs", () => {
@@ -70,7 +77,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 +88,108 @@ 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(
<PaymentLaunchDialogs
currentOrderId="order-id-qris"
externalCheckoutAnalyticsKey="tip.external_checkout"
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
regionalQrPayment: {
qrData: "00020101021226670016COM.NOBUBANK.WWW",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
experience: "qris",
},
regionalQrStatus: "pending",
shouldShowRegionalQrDialog: true,
}}
/>,
),
);
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("labels a Philippine QR fallback as GCash / QR Ph", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-ph-qr"
externalCheckoutAnalyticsKey="payment.external_checkout"
ezpayDescription="Pay with GCash."
launch={{
...hiddenLaunch,
regionalQrPayment: {
qrData: "000201010212ph-qr-payload",
orderId: "order-ph-qr",
amountCents: 49_990,
currency: "PHP",
experience: "gcashQrPh",
},
regionalQrStatus: "pending",
shouldShowRegionalQrDialog: true,
}}
/>,
),
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Pay with GCash / QR Ph");
expect(dialog?.textContent).not.toContain("QRIS");
expect(dialog?.querySelector("svg title")?.textContent).toBe(
"GCash / QR Ph payment QR code",
);
expect(dialog?.textContent).toContain("₱");
const closeButton = Array.from(
dialog?.querySelectorAll("button") ?? [],
).find((button) => button.textContent?.trim() === "Close");
act(() => closeButton?.click());
expect(hiddenLaunch.handleRegionalQrClose).toHaveBeenCalledOnce();
});
it("shows QRIS failure state and handles empty QR data safely", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-id-qris"
externalCheckoutAnalyticsKey="payment.external_checkout"
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
regionalQrErrorMessage: "Payment failed or was cancelled.",
regionalQrPayment: {
qrData: "",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
experience: "qris",
},
regionalQrStatus: "failed",
shouldShowRegionalQrDialog: true,
}}
/>,
),
);
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");
});
});
@@ -62,6 +62,27 @@ describe("PaymentMethodSelector", () => {
);
});
it("labels Indonesia Ezpay as QRIS without the GCash logo", () => {
const html = renderToStaticMarkup(
<PaymentMethodSelector
config={{
canChoosePaymentMethod: true,
initialPayChannel: "ezpay",
showPaymentMethodSelector: true,
ezpayDisplayName: "QRIS",
}}
value="ezpay"
caption="QRIS by default in Indonesia"
analyticsKey="subscription.payment_method"
onChange={() => 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(
<PaymentMethodSelector
@@ -2,18 +2,57 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StripePaymentDialogLoading } from "../lazy-stripe-payment-dialog";
const stripeConfirmPayment = vi.fn(async () => ({}));
const elementsSubmit = vi.fn(async () => ({}));
let capturedElementsOptions: Record<string, unknown> | null = null;
let capturedExpressProps: Record<string, unknown> | null = null;
let capturedPaymentProps: Record<string, unknown> | null = null;
vi.hoisted(() => {
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = "pk_test_checkout";
});
vi.mock("@stripe/stripe-js", () => ({
loadStripe: vi.fn(() => Promise.resolve({})),
}));
vi.mock("@stripe/react-stripe-js", () => ({
Elements: ({
children,
options,
}: {
children: React.ReactNode;
options: Record<string, unknown>;
}) => {
capturedElementsOptions = options;
return <>{children}</>;
},
ExpressCheckoutElement: (props: Record<string, unknown>) => {
capturedExpressProps = props;
return <div data-testid="express-checkout" />;
},
PaymentElement: (props: Record<string, unknown>) => {
capturedPaymentProps = props;
return <div data-testid="payment-element" />;
},
useElements: () => ({ submit: elementsSubmit }),
useStripe: () => ({ confirmPayment: stripeConfirmPayment }),
}));
import { StripePaymentDialog } from "../stripe-payment-dialog";
describe("Stripe payment portal states", () => {
describe("StripePaymentDialog", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.clearAllMocks();
capturedElementsOptions = null;
capturedExpressProps = null;
capturedPaymentProps = null;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
container.style.transform = "translateX(50%)";
document.body.appendChild(container);
root = createRoot(container);
});
@@ -24,40 +63,101 @@ describe("Stripe payment portal states", () => {
document.body.style.overflow = "";
});
it("portals the unavailable state and keeps its explicit close action", () => {
const onClose = vi.fn();
it("renders Express Checkout first and a collapsed Pay by card section last", () => {
act(() =>
root.render(
<StripePaymentDialog
clientSecret="client-secret"
onClose={onClose}
clientSecret="pi_123_secret_payment"
customerSessionClientSecret="cuss_123_secret_saved"
savedPaymentMethodsEnabled
onClose={vi.fn()}
/>,
),
);
const dialog = document.body.querySelector('[role="alertdialog"]');
expect(dialog).not.toBeNull();
expect(container.contains(dialog)).toBe(false);
expect(dialog?.textContent).toContain("Payment unavailable");
expect(dialog?.getAttribute("aria-labelledby")).toBeTruthy();
expect(dialog?.getAttribute("aria-describedby")).toBeTruthy();
const okButton = Array.from(dialog?.querySelectorAll("button") ?? []).find(
(button) => button.textContent === "OK",
);
act(() => okButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onClose).toHaveBeenCalledOnce();
expect(capturedElementsOptions).toMatchObject({
clientSecret: "pi_123_secret_payment",
customerSessionClientSecret: "cuss_123_secret_saved",
});
expect(capturedExpressProps?.options).toMatchObject({
paymentMethodOrder: [
"apple_pay",
"google_pay",
"link",
"paypal",
"amazon_pay",
"klarna",
],
paymentMethods: {
applePay: "always",
googlePay: "always",
link: "auto",
paypal: "auto",
amazonPay: "auto",
klarna: "auto",
},
});
expect(capturedPaymentProps?.options).toMatchObject({
layout: { type: "accordion", defaultCollapsed: true },
paymentMethodOrder: ["card"],
wallets: { applePay: "never", googlePay: "never", link: "never" },
});
const text = document.body.textContent ?? "";
expect(text).toContain("Pay by card");
const express = document.body.querySelector('[data-testid="express-checkout"]');
const card = document.body.querySelector('[data-testid="payment-element"]');
expect(express).not.toBeNull();
expect(card).not.toBeNull();
expect(
express!.compareDocumentPosition(card!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("portals the lazy loading state", () => {
act(() => root.render(<StripePaymentDialogLoading />));
it("does not pass an invalid or disabled Customer Session secret", () => {
act(() =>
root.render(
<StripePaymentDialog
clientSecret="pi_123_secret_payment"
customerSessionClientSecret="invalid"
savedPaymentMethodsEnabled={false}
onClose={vi.fn()}
/>,
),
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).not.toBeNull();
expect(container.contains(dialog)).toBe(false);
expect(dialog?.textContent).toContain("Preparing secure payment");
expect(dialog?.textContent).toContain("Loading payment methods...");
expect(dialog?.querySelector('[aria-busy="true"]')).not.toBeNull();
expect(capturedElementsOptions).not.toHaveProperty(
"customerSessionClientSecret",
);
});
it("uses the same confirmation path for an Express Checkout approval", async () => {
const onConfirmed = vi.fn();
act(() =>
root.render(
<StripePaymentDialog
clientSecret="pi_123_secret_payment"
onClose={vi.fn()}
onConfirmed={onConfirmed}
/>,
),
);
const onConfirm = capturedExpressProps?.onConfirm as
| ((event: { paymentFailed: ReturnType<typeof vi.fn> }) => Promise<void>)
| undefined;
expect(onConfirm).toBeTypeOf("function");
await act(async () => {
await onConfirm?.({ paymentFailed: vi.fn() });
});
expect(stripeConfirmPayment).toHaveBeenCalledTimes(1);
expect(stripeConfirmPayment).toHaveBeenCalledWith(
expect.objectContaining({
elements: expect.any(Object),
redirect: "if_required",
}),
);
expect(onConfirmed).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,91 @@
"use client";
import { useState, useSyncExternalStore } from "react";
import type { CheckoutIntent } from "@/data/schemas/auth";
import {
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
import { createCheckoutHandoff } from "@/lib/auth/checkout_handoff";
import { BrowserDetector } from "@/utils/browser-detect";
import { ExceptionHandler } from "@/core/errors";
import { Result } from "@/utils/result";
import { UrlLauncherUtil } from "@/utils/url-launcher-util";
export interface ExternalBrowserCheckoutButtonProps {
checkoutIntent: CheckoutIntent;
disabled?: boolean;
characterSlug?: string;
}
export function ExternalBrowserCheckoutButton({
checkoutIntent,
disabled = false,
characterSlug = DEFAULT_CHARACTER_SLUG,
}: ExternalBrowserCheckoutButtonProps) {
const isFacebookBrowser = useSyncExternalStore(
() => () => undefined,
() => BrowserDetector.isFacebookInAppBrowser(),
() => false,
);
const [isOpening, setIsOpening] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
if (!isFacebookBrowser) return null;
const handleOpen = async () => {
if (disabled || isOpening) return;
setIsOpening(true);
setErrorMessage(null);
const result = await createCheckoutHandoff(checkoutIntent);
if (Result.isErr(result)) {
setErrorMessage(
ExceptionHandler.message(
result.error,
"Could not open this checkout in your browser. Please sign in and try again.",
),
);
setIsOpening(false);
return;
}
try {
const verifiedCharacter =
getCharacterBySlug(characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
const externalUrl = new URL(
result.data.externalUrl,
window.location.origin,
);
externalUrl.searchParams.set("character", verifiedCharacter);
UrlLauncherUtil.openUrlWithExternalBrowser(externalUrl.toString());
} catch (error) {
setErrorMessage(
ExceptionHandler.message(
error,
"Could not open this checkout in your browser. Please try again.",
),
);
setIsOpening(false);
}
};
return (
<div className="mb-2 w-full text-center">
<button
type="button"
className="min-h-11 w-full cursor-pointer rounded-full border border-black/15 bg-white px-4 py-2.5 text-sm font-semibold text-text-foreground disabled:cursor-not-allowed disabled:opacity-55"
disabled={disabled || isOpening}
onClick={() => void handleOpen()}
>
{isOpening
? "Opening browser..."
: "Open in browser for more payment methods"}
</button>
{errorMessage ? (
<p className="mt-2 text-sm text-[#c0392b]" role="alert">
{errorMessage}
</p>
) : null}
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useId, type ReactNode } from "react";
import { QRCodeSVG } from "qrcode.react";
import { ModalPortal } from "@/app/_components/core/modal-portal";
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
@@ -12,12 +13,19 @@ type PaymentLaunchDialogFlow = Pick<
PaymentLaunchFlow,
| "handleEzpayCancel"
| "handleEzpayConfirm"
| "handleRegionalQrClose"
| "handleStripeClose"
| "handleStripeConfirmed"
| "isConfirmingEzpay"
| "regionalQrErrorMessage"
| "regionalQrPayment"
| "regionalQrStatus"
| "shouldShowEzpayConfirmDialog"
| "shouldShowRegionalQrDialog"
| "shouldShowStripeDialog"
| "stripeClientSecret"
| "stripeCustomerSessionClientSecret"
| "savedPaymentMethodsEnabled"
>;
export interface PaymentLaunchDialogsProps {
@@ -47,9 +55,21 @@ export function PaymentLaunchDialogs({
onConfirm={launch.handleEzpayConfirm}
/>
) : null}
{launch.shouldShowRegionalQrDialog && launch.regionalQrPayment ? (
<RegionalQrPaymentDialog
payment={launch.regionalQrPayment}
status={launch.regionalQrStatus}
errorMessage={launch.regionalQrErrorMessage}
onClose={launch.handleRegionalQrClose}
/>
) : null}
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
<LazyStripePaymentDialog
clientSecret={launch.stripeClientSecret}
customerSessionClientSecret={
launch.stripeCustomerSessionClientSecret
}
savedPaymentMethodsEnabled={launch.savedPaymentMethodsEnabled}
returnPath={stripeReturnPath}
onClose={launch.handleStripeClose}
onConfirmed={launch.handleStripeConfirmed}
@@ -59,6 +79,151 @@ export function PaymentLaunchDialogs({
);
}
interface RegionalQrPaymentDialogProps {
errorMessage: string | null;
onClose: () => void;
payment: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>;
status: PaymentLaunchFlow["regionalQrStatus"];
}
function formatRegionalQrAmount(amountCents: number, currency: string): string {
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
try {
return new Intl.NumberFormat(
normalizedCurrency === "PHP" ? "en-PH" : "id-ID",
{
style: "currency",
currency: normalizedCurrency,
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
},
).format(amountCents / 100);
} catch {
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
}
}
function regionalQrStatusMessage(
status: PaymentLaunchFlow["regionalQrStatus"],
errorMessage: string | null,
paymentName: string,
): string {
if (status === "failed") {
return errorMessage || "Payment failed. Please try again.";
}
if (status === "expired") {
return errorMessage || `This ${paymentName} order has expired.`;
}
return "Waiting for payment";
}
function RegionalQrPaymentDialog({
errorMessage,
onClose,
payment,
status,
}: RegionalQrPaymentDialogProps) {
const titleId = useId();
const copy = regionalQrCopy(payment.experience);
const statusMessage = regionalQrStatusMessage(
status,
errorMessage,
copy.paymentName,
);
return (
<ModalPortal
open
persistent
onClose={onClose}
scrimClassName={styles.overlay}
panelClassName={styles.dialog}
scrimOpacity={0.5}
ariaLabelledBy={titleId}
>
<div className={`${styles.header} text-center`}>
<h2 id={titleId} className={styles.title}>
{copy.title}
</h2>
<p className={styles.content}>
{copy.description}
</p>
</div>
<div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4">
{payment.qrData ? (
<QRCodeSVG
value={payment.qrData}
title={copy.qrTitle}
size={256}
level="M"
marginSize={2}
className="h-auto w-full max-w-64"
/>
) : (
<p className={styles.error} role="alert">
{copy.unavailableMessage}
</p>
)}
</div>
<div className="mb-4 flex flex-col gap-2 text-center">
<p className={styles.content}>Order No. {payment.orderId}</p>
<p className="m-0 text-xl font-bold text-text-foreground">
{formatRegionalQrAmount(payment.amountCents, payment.currency)}
</p>
<p
className={status === "failed" || status === "expired" ? styles.error : styles.content}
role="status"
>
{statusMessage}
</p>
</div>
<div className={styles.actions}>
<button
type="button"
className={`${styles.button} ${styles.secondary}`}
onClick={onClose}
>
Close
</button>
</div>
</ModalPortal>
);
}
function regionalQrCopy(
experience: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>["experience"],
) {
if (experience === "gcashQrPh") {
return {
paymentName: "GCash / QR Ph",
title: "Pay with GCash / QR Ph",
description:
"Open GCash or another QR Ph-compatible app and scan this code.",
qrTitle: "GCash / QR Ph payment QR code",
unavailableMessage:
"GCash / QR Ph data is unavailable. Please close this dialog and try again.",
};
}
if (experience === "qris") {
return {
paymentName: "QRIS",
title: "Scan to pay with QRIS",
description:
"Open a QRIS-compatible banking or wallet app and scan this code.",
qrTitle: "QRIS payment QR code",
unavailableMessage:
"QRIS data is unavailable. Please close this dialog and try again.",
};
}
return {
paymentName: "payment QR",
title: "Scan to pay",
description: "Open a compatible banking or wallet app and scan this code.",
qrTitle: "Payment QR code",
unavailableMessage:
"Payment QR data is unavailable. Please close this dialog and try again.",
};
}
interface EzpayRedirectConfirmDialogProps {
description: ReactNode;
externalCheckoutAnalyticsKey: string;
@@ -91,7 +256,7 @@ function EzpayRedirectConfirmDialog({
>
<div className={styles.header}>
<h2 id={titleId} className={styles.title}>
Continue to GCash?
Continue to payment?
</h2>
<p className={styles.content}>{description}</p>
<p className={styles.content}>Order No. {orderId}</p>
@@ -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 (
<section
@@ -9,6 +9,11 @@ export const stripePaymentDialogStyles = {
content:
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
form: "flex flex-col gap-(--page-section-gap,18px)",
expressSection: "w-full empty:hidden",
cardSection:
"flex w-full flex-col gap-(--spacing-sm,8px) border-t border-black/10 pt-(--page-section-gap,18px)",
cardTitle:
"m-0 text-(length:--responsive-body,16px) font-semibold text-text-foreground",
error:
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
actions: "flex w-full gap-(--spacing-md,12px)",
@@ -4,19 +4,28 @@
*
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
*/
import { useId, useState, type FormEvent } from "react";
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import {
Elements,
ExpressCheckoutElement,
PaymentElement,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import {
loadStripe,
type AvailablePaymentMethods,
type StripeExpressCheckoutElementConfirmEvent,
} from "@stripe/stripe-js";
import { ModalPortal } from "@/app/_components/core/modal-portal";
import { ExceptionHandler } from "@/core/errors";
import { ROUTES } from "@/router/routes";
import { Logger } from "@/utils/logger";
import { BrowserDetector } from "@/utils/browser-detect";
import { PlatformDetector } from "@/utils/platform-detect";
import { getStripeCustomerSessionClientSecret } from "@/lib/payment/payment_launch";
import { behaviorAnalytics } from "@/lib/analytics";
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
@@ -26,8 +35,16 @@ const stripePromise = stripePublishableKey
? loadStripe(stripePublishableKey)
: null;
type ExpressAvailabilityValue = boolean | { available: boolean } | undefined;
function isExpressMethodAvailable(value: ExpressAvailabilityValue): boolean {
return value === true || (typeof value === "object" && value.available);
}
export interface StripePaymentDialogProps {
clientSecret: string;
customerSessionClientSecret?: string | null;
savedPaymentMethodsEnabled?: boolean;
returnPath?: string;
onClose: () => void;
onConfirmed?: () => void;
@@ -35,12 +52,19 @@ export interface StripePaymentDialogProps {
export function StripePaymentDialog({
clientSecret,
customerSessionClientSecret = null,
savedPaymentMethodsEnabled = false,
returnPath = ROUTES.subscription + "/success",
onClose,
onConfirmed,
}: StripePaymentDialogProps) {
const titleId = useId();
const descriptionId = useId();
const savedCardClientSecret = getStripeCustomerSessionClientSecret({
provider: "stripe",
customerSessionClientSecret,
savedPaymentMethodsEnabled,
});
if (!stripePromise) {
return (
@@ -100,6 +124,9 @@ export function StripePaymentDialog({
stripe={stripePromise}
options={{
clientSecret,
...(savedCardClientSecret
? { customerSessionClientSecret: savedCardClientSecret }
: {}),
appearance: {
theme: "stripe",
variables: {
@@ -132,6 +159,20 @@ function StripePaymentForm({
const elements = useElements();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const submittingRef = useRef(false);
const [hasExpressPaymentMethods, setHasExpressPaymentMethods] = useState<
boolean | null
>(null);
const [expressMaxColumns, setExpressMaxColumns] = useState(1);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(min-width: 640px)");
const updateColumns = () => setExpressMaxColumns(query.matches ? 2 : 1);
updateColumns();
query.addEventListener("change", updateColumns);
return () => query.removeEventListener("change", updateColumns);
}, []);
const handleLoadError = (event: Parameters<
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
@@ -150,38 +191,118 @@ function StripePaymentForm({
);
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!stripe || !elements || isSubmitting) return;
const updateExpressAvailability = (
availablePaymentMethods:
| AvailablePaymentMethods
| {
applePay?: { available: boolean };
googlePay?: { available: boolean };
link?: { available: boolean };
paypal?: { available: boolean };
amazonPay?: { available: boolean };
klarna?: { available: boolean };
}
| undefined,
) => {
const availability = {
applePay: isExpressMethodAvailable(availablePaymentMethods?.applePay),
googlePay: isExpressMethodAvailable(availablePaymentMethods?.googlePay),
link: isExpressMethodAvailable(availablePaymentMethods?.link),
paypal: isExpressMethodAvailable(availablePaymentMethods?.paypal),
amazonPay: isExpressMethodAvailable(availablePaymentMethods?.amazonPay),
klarna: isExpressMethodAvailable(availablePaymentMethods?.klarna),
};
setHasExpressPaymentMethods(Object.values(availability).some(Boolean));
const browser = BrowserDetector.isFacebookInAppBrowser()
? "facebook"
: BrowserDetector.getBrowserName() || "unknown";
const platform = PlatformDetector.getPlatform();
const metadata = {
browser,
platform,
availablePaymentMethods: availability,
};
log.info(
"[stripe-payment-dialog] Express Checkout availability",
metadata,
);
behaviorAnalytics.elementClick(
"stripe.express_checkout_availability",
"Stripe Express Checkout availability",
metadata,
);
};
const handleExpressLoadError = (event: Parameters<
NonNullable<
React.ComponentProps<typeof ExpressCheckoutElement>["onLoadError"]
>
>[0]) => {
setHasExpressPaymentMethods(false);
log.warn("[stripe-payment-dialog] Express Checkout load failed", {
type: event.error.type,
code: event.error.code,
message: event.error.message,
});
};
const confirmPayment = async (
options: {
submitPaymentElement: boolean;
expressEvent?: StripeExpressCheckoutElementConfirmEvent;
},
) => {
if (!stripe || !elements || submittingRef.current) return;
submittingRef.current = true;
setIsSubmitting(true);
setErrorMessage(null);
const { error: submitError } = await elements.submit();
if (submitError) {
setErrorMessage(
ExceptionHandler.message(submitError, "Please check your payment info."),
);
setIsSubmitting(false);
return;
if (options.submitPaymentElement) {
let submitError: unknown;
try {
({ error: submitError } = await elements.submit());
} catch (error) {
submitError = error;
}
if (submitError) {
setErrorMessage(
ExceptionHandler.message(
submitError,
"Please check your payment info.",
),
);
submittingRef.current = false;
setIsSubmitting(false);
return;
}
}
const returnUrl = new URL(
returnPath ?? ROUTES.subscription + "/success",
window.location.origin,
);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl.toString(),
},
redirect: "if_required",
});
let confirmationError: unknown;
try {
({ error: confirmationError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl.toString(),
},
redirect: "if_required",
}));
} catch (error) {
confirmationError = error;
}
if (error) {
setErrorMessage(
ExceptionHandler.message(error, "Payment confirmation failed."),
if (confirmationError) {
const message = ExceptionHandler.message(
confirmationError,
"Payment confirmation failed.",
);
setErrorMessage(message);
options.expressEvent?.paymentFailed({ reason: "fail", message });
submittingRef.current = false;
setIsSubmitting(false);
return;
}
@@ -189,9 +310,74 @@ function StripePaymentForm({
onConfirmed?.();
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
await confirmPayment({ submitPaymentElement: true });
};
return (
<form className={styles.form} onSubmit={handleSubmit}>
<PaymentElement onLoadError={handleLoadError} />
<section
className={styles.expressSection}
hidden={hasExpressPaymentMethods === false}
aria-label="Express payment methods"
>
<ExpressCheckoutElement
options={{
paymentMethodOrder: [
"apple_pay",
"google_pay",
"link",
"paypal",
"amazon_pay",
"klarna",
],
paymentMethods: {
applePay: "always",
googlePay: "always",
link: "auto",
paypal: "auto",
amazonPay: "auto",
klarna: "auto",
},
layout: {
maxColumns: expressMaxColumns,
maxRows: 6,
overflow: "never",
},
}}
onReady={(event) =>
updateExpressAvailability(event.availablePaymentMethods)
}
onAvailablePaymentMethodsChange={(event) =>
updateExpressAvailability(event.paymentMethods)
}
onLoadError={handleExpressLoadError}
onConfirm={(event) =>
void confirmPayment({
submitPaymentElement: false,
expressEvent: event,
})
}
/>
</section>
<section className={styles.cardSection} aria-labelledby="pay-by-card-title">
<h3 id="pay-by-card-title" className={styles.cardTitle}>
Pay by card
</h3>
<PaymentElement
options={{
layout: { type: "accordion", defaultCollapsed: true },
paymentMethodOrder: ["card"],
wallets: {
applePay: "never",
googlePay: "never",
link: "never",
},
}}
onLoadError={handleLoadError}
/>
</section>
{errorMessage ? (
<p
role="alert"
+187 -25
View File
@@ -4,9 +4,12 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
import {
getPaymentUrl,
getPaymentUrlHostname,
getStripeCustomerSessionClientSecret,
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
resolveEzpayLaunchTarget,
} from "@/lib/payment/payment_launch";
import { behaviorAnalytics } from "@/lib/analytics";
import type {
@@ -40,19 +43,63 @@ export interface UsePaymentLaunchFlowInput {
subscriptionType: PendingPaymentSubscriptionType;
giftCategory?: string | null;
giftPlanId?: string | null;
countryCode?: string | null;
}
export interface PaymentLaunchFlow {
ezpayPaymentUrl: string | null;
handleEzpayCancel: () => void;
handleEzpayConfirm: () => void;
handleRegionalQrClose: () => void;
handleStripeClose: () => void;
handleStripeConfirmed: () => void;
isConfirmingEzpay: boolean;
regionalQrErrorMessage: string | null;
regionalQrPayment: RegionalQrPaymentDetails | null;
regionalQrStatus: PaymentContextState["orderStatus"];
resetLaunchState: () => void;
shouldShowEzpayConfirmDialog: boolean;
shouldShowRegionalQrDialog: boolean;
shouldShowStripeDialog: boolean;
stripeClientSecret: string | null;
stripeCustomerSessionClientSecret: string | null;
savedPaymentMethodsEnabled: boolean;
}
export interface RegionalQrPaymentDetails {
amountCents: number;
currency: string;
experience: "gcashQrPh" | "qris" | "paymentQr";
orderId: string;
qrData: string;
}
function paymentParamString(
payParams: Record<string, unknown>,
...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<string, unknown>,
): 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(
@@ -112,6 +159,7 @@ export function usePaymentLaunchFlow({
subscriptionType,
giftCategory,
giftPlanId,
countryCode,
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
@@ -121,9 +169,45 @@ export function usePaymentLaunchFlow({
const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams)
: null;
const ezpayPaymentUrl = payment.payParams
? getPaymentUrl(payment.payParams)
const stripeCustomerSessionClientSecret = payment.payParams
? getStripeCustomerSessionClientSecret(payment.payParams)
: null;
const selectedPlan = payment.plans.find(
(item) => item.planId === payment.selectedPlanId,
);
const paymentCurrency = payment.payParams
? paymentParamString(payment.payParams, "currency") ??
selectedPlan?.currency ??
null
: selectedPlan?.currency ?? null;
const ezpayLaunchTarget =
payment.payParams && isEzpayPayment(payment.payParams)
? resolveEzpayLaunchTarget(payment.payParams, {
countryCode,
currency: paymentCurrency,
})
: null;
const ezpayPaymentUrl =
ezpayLaunchTarget?.kind === "url"
? ezpayLaunchTarget.paymentUrl
: null;
const regionalQrPayment: RegionalQrPaymentDetails | null =
payment.payParams &&
payment.currentOrderId &&
ezpayLaunchTarget?.kind === "qr"
? {
amountCents:
paymentParamAmountCents(payment.payParams) ??
selectedPlan?.amountCents ??
0,
currency:
paymentCurrency ??
(ezpayLaunchTarget.experience === "gcashQrPh" ? "PHP" : "IDR"),
experience: ezpayLaunchTarget.experience,
orderId: payment.currentOrderId,
qrData: ezpayLaunchTarget.qrData,
}
: null;
useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
return;
@@ -136,39 +220,95 @@ 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, {
countryCode,
currency: paymentCurrency,
});
const channelType = paymentParamString(
payment.payParams,
"channelType",
"channel_type",
);
const channelCode = paymentParamString(
payment.payParams,
"channelCode",
"channel_code",
);
const namedPaymentUrl = getPaymentUrl(payment.payParams);
log.debug(`[${logScope}] ezpay launch target resolved`, {
countryCode: countryCode?.trim().toUpperCase() ?? null,
currency: paymentCurrency?.trim().toUpperCase() ?? null,
channelType: channelType?.toUpperCase() ?? null,
channelCode,
hasCashierUrl: getPaymentUrlHostname(namedPaymentUrl) !== null,
hasQrData: target.kind === "qr",
paymentUrlHost:
target.kind === "url"
? getPaymentUrlHostname(target.paymentUrl)
: null,
});
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 payment QR code.",
});
return;
}
trackPaymentCheckoutOpened(
payment,
target.experience === "qris"
? "qris_embedded"
: target.experience === "gcashQrPh"
? "gcash_qrph_embedded"
: "payment_qr_embedded",
);
return;
}
const paymentUrl = target.paymentUrl;
if (!AppEnvUtil.isProduction()) {
log.debug(`[${logScope}] ezpay confirmation required`, {
orderId: payment.currentOrderId,
paymentUrl,
paymentUrlHost: getPaymentUrlHostname(paymentUrl),
subscriptionType,
});
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;
window.location.assign(paymentUrl);
trackPaymentCheckoutOpened(payment, paymentUrl);
} catch {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
@@ -189,6 +329,7 @@ export function usePaymentLaunchFlow({
log,
logScope,
characterSlug,
countryCode,
payment.currentOrderId,
payment,
payment.launchNonce,
@@ -197,6 +338,7 @@ export function usePaymentLaunchFlow({
payment.plans,
payment.selectedPlanId,
paymentDispatch,
paymentCurrency,
returnTo,
subscriptionType,
giftCategory,
@@ -213,6 +355,9 @@ export function usePaymentLaunchFlow({
payParams: payment.payParams,
paymentUrl: ezpayPaymentUrl,
});
const shouldShowRegionalQrDialog = Boolean(
regionalQrPayment && !payment.isPaid,
);
function resetLaunchState(): void {
setIsConfirmingEzpay(false);
@@ -263,16 +408,33 @@ export function usePaymentLaunchFlow({
paymentDispatch({ type: "PaymentReset" });
}
function handleRegionalQrClose(): void {
log.debug(`[${logScope}] regional payment QR dialog closed`, {
orderId: regionalQrPayment?.orderId ?? payment.currentOrderId,
experience: regionalQrPayment?.experience ?? null,
subscriptionType,
});
paymentDispatch({ type: "PaymentReset" });
}
return {
ezpayPaymentUrl,
handleEzpayCancel,
handleEzpayConfirm,
handleRegionalQrClose,
handleStripeClose,
handleStripeConfirmed,
isConfirmingEzpay,
regionalQrErrorMessage: payment.errorMessage,
regionalQrPayment,
regionalQrStatus: payment.orderStatus,
resetLaunchState,
shouldShowEzpayConfirmDialog,
shouldShowRegionalQrDialog,
shouldShowStripeDialog,
stripeClientSecret,
stripeCustomerSessionClientSecret,
savedPaymentMethodsEnabled:
stripeCustomerSessionClientSecret !== null,
};
}
+7
View File
@@ -22,6 +22,7 @@ export interface UsePaymentRouteFlowInput {
characterId?: string;
initialCategory?: string | null;
initialPlanId?: string | null;
initialAutoRenew?: boolean | null;
commercialOfferId?: string | null;
resumeOrderId?: string | null;
chatActionId?: string | null;
@@ -44,6 +45,7 @@ export function usePaymentRouteFlow({
characterId,
initialCategory = null,
initialPlanId = null,
initialAutoRenew = null,
commercialOfferId = null,
resumeOrderId = null,
chatActionId = null,
@@ -57,6 +59,7 @@ export function usePaymentRouteFlow({
characterId ?? "",
initialCategory ?? "",
initialPlanId ?? "",
initialAutoRenew === null ? "" : String(initialAutoRenew),
commercialOfferId ?? "",
chatActionId ?? "",
].join(":");
@@ -78,6 +81,9 @@ export function usePaymentRouteFlow({
...(characterId ? { characterId } : {}),
...(initialCategory ? { category: initialCategory } : {}),
...(initialPlanId ? { planId: initialPlanId } : {}),
...(initialAutoRenew === null
? {}
: { autoRenew: initialAutoRenew }),
...(commercialOfferId ? { commercialOfferId } : {}),
...(chatActionId ? { chatActionId } : {}),
});
@@ -89,6 +95,7 @@ export function usePaymentRouteFlow({
chatActionId,
initialCategory,
initialPlanId,
initialAutoRenew,
initialPayChannel,
paymentDispatch,
]);
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { deriveChatSupportCta } from "../chat-support-cta";
describe("deriveChatSupportCta", () => {
const now = Date.parse("2026-07-28T00:00:00.000Z");
it("shows first recharge ahead of a weaker role offer without inventing a timer", () => {
expect(
deriveChatSupportCta({
isFirstRecharge: true,
commercialOffer: {
enabled: true,
commercialOfferId: "offer-1",
characterId: "elio",
planId: "vip_annual",
discountPercent: 30,
pricePercent: 70,
expiresAt: "2026-07-29T00:00:00.000Z",
triggerReason: "price_objection",
message: "I got this for you.",
},
nowMs: now,
}),
).toMatchObject({
kind: "firstRecharge",
label: "Support me · 50% OFF · First recharge",
commercialOfferId: null,
expiresAt: null,
});
});
it("shows a server-anchored 24-hour role offer countdown", () => {
expect(
deriveChatSupportCta({
isFirstRecharge: false,
commercialOffer: {
enabled: true,
commercialOfferId: "offer-1",
characterId: "maya-tan",
planId: "vip_annual",
discountPercent: 30,
pricePercent: 70,
expiresAt: "2026-07-29T00:00:00.000Z",
triggerReason: "price_objection",
message: "I got this for you.",
},
nowMs: now,
}),
).toMatchObject({
kind: "commercialOffer",
label: "Support me · 30% OFF · 23:59:59",
commercialOfferId: "offer-1",
expiresAt: "2026-07-29T00:00:00.000Z",
});
});
it("returns to Support me after an offer expires or is absent", () => {
expect(
deriveChatSupportCta({
isFirstRecharge: false,
commercialOffer: null,
nowMs: now,
}).label,
).toBe("Support me");
expect(
deriveChatSupportCta({
isFirstRecharge: false,
commercialOffer: {
enabled: true,
commercialOfferId: "offer-1",
characterId: "elio",
planId: "vip_annual",
discountPercent: 30,
pricePercent: 70,
expiresAt: "2026-07-27T00:00:00.000Z",
triggerReason: "price_objection",
message: "I got this for you.",
},
nowMs: now,
}).label,
).toBe("Support me");
});
});
+18 -10
View File
@@ -33,8 +33,8 @@ import {
ChatHeader,
ChatInputBar,
ChatInsufficientCreditsBanner,
ChatSupportButton,
ChatUnlockDialogs,
FirstRechargeOfferBanner,
FullscreenImageViewer,
PwaInstallOverlay,
} from "./components";
@@ -46,7 +46,8 @@ import {
import {
deriveIsGuest,
} from "./chat-screen.helpers";
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
import { useChatSupportCta } from "./hooks/use-chat-support-cta";
import { useChatCommercialMessages } from "./hooks/use-chat-commercial-messages";
import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator";
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
@@ -128,11 +129,17 @@ export function ChatScreen() {
const messageLimitBanner = useChatMessageLimitBanner({
upgradePromptVisible: state.upgradePromptVisible,
upgradeReason: state.upgradeReason,
paymentGuidance: state.paymentGuidance,
});
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
const supportCta = useChatSupportCta({
characterId: character.id,
historyLoaded: state.historyLoaded,
loginStatus: authState.loginStatus,
});
useChatCommercialMessages({
characterId: character.id,
loginStatus: authState.loginStatus,
});
const shouldShowPwaInstall =
state.historyLoaded && state.historyMessages.length >= 10;
useChatGuestLogin({
@@ -421,13 +428,13 @@ export function ChatScreen() {
<ChatHeader
isGuest={isGuest}
offerBanner={
<FirstRechargeOfferBanner
visible={firstRechargeOfferBanner.visible}
discountPercent={firstRechargeOfferBanner.discountPercent}
onClick={firstRechargeOfferBanner.claim}
onClose={firstRechargeOfferBanner.close}
variant="compact"
/>
supportCta.visible ? (
<ChatSupportButton
kind={supportCta.kind}
label={supportCta.label}
onClick={supportCta.open}
/>
) : null
}
/>
@@ -464,6 +471,7 @@ export function ChatScreen() {
title={messageLimitBanner.title}
description={messageLimitBanner.description}
ctaLabel={messageLimitBanner.ctaLabel}
guidance={messageLimitBanner.guidance}
onUnlock={messageLimitBanner.unlock}
/>
) : (
+63
View File
@@ -0,0 +1,63 @@
import type { CommercialOfferSummary } from "@/data/schemas/payment";
export type ChatSupportCtaKind =
| "support"
| "firstRecharge"
| "commercialOffer";
export interface ChatSupportCtaView {
kind: ChatSupportCtaKind;
label: string;
commercialOfferId: string | null;
planId: string | null;
expiresAt: string | null;
}
export function deriveChatSupportCta(input: {
isFirstRecharge: boolean;
commercialOffer: CommercialOfferSummary | null;
nowMs: number;
}): ChatSupportCtaView {
if (input.isFirstRecharge) {
return {
kind: "firstRecharge",
label: "Support me · 50% OFF · First recharge",
commercialOfferId: null,
planId: null,
expiresAt: null,
};
}
const offer = input.commercialOffer;
const expiresAtMs = offer ? Date.parse(offer.expiresAt) : Number.NaN;
if (offer?.enabled && Number.isFinite(expiresAtMs) && expiresAtMs > input.nowMs) {
return {
kind: "commercialOffer",
label: `Support me · ${offer.discountPercent}% OFF · ${formatOfferCountdown(expiresAtMs, input.nowMs)}`,
commercialOfferId: offer.commercialOfferId,
planId: offer.planId,
expiresAt: offer.expiresAt,
};
}
return {
kind: "support",
label: "Support me",
commercialOfferId: null,
planId: null,
expiresAt: null,
};
}
export function formatOfferCountdown(expiresAtMs: number, nowMs: number): string {
const totalSeconds = Math.max(
0,
Math.ceil((expiresAtMs - nowMs) / 1000) - 1,
);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return [hours, minutes, seconds]
.map((value) => String(value).padStart(2, "0"))
.join(":");
}
@@ -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("<br/>");
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(
<PaymentGuidanceCard guidance={guide} />,
);
const mismatchHtml = renderWithCharacter(
<PaymentGuidanceCard
guidance={{ ...guide, characterId: "maya-tan", characterName: "Maya Tan" }}
/>,
);
const careHtml = renderWithCharacter(
<PaymentGuidanceCard
guidance={{
...guide,
mode: "careOnly",
title: null,
ctaLabel: null,
purchaseOptions: [],
target: null,
copy: "Please keep money you need for food or medicine.",
}}
/>,
);
expect(guideHtml).toContain('data-payment-guidance-mode="guide"');
expect(guideHtml).toContain("View VIP &amp; 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(<ChatHeader isGuest={true} />);
const memberHtml = renderWithCharacter(
+1
View File
@@ -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
@@ -17,11 +17,13 @@
* - 不直接管理 state machinechat 机器不感知 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}
>
<p className="m-0 mb-(--spacing-sm,8px) text-(length:--responsive-card-title,18px) font-semibold leading-[1.4] text-white opacity-95">
{titleLines.map((line, index) => (
@@ -67,7 +72,12 @@ export function ChatInsufficientCreditsBanner({
{description}
</p>
) : null}
<button
{activeGuidance?.copy ? (
<p className="mx-auto mb-(--spacing-lg,16px) mt-0 max-w-[min(100%,360px)] text-(length:--responsive-caption,14px) font-semibold leading-[1.4] text-white">
{activeGuidance.copy}
</p>
) : null}
{ctaLabel ? <button
type="button"
data-analytics-key="chat.topup"
data-analytics-label="Top up chat credits"
@@ -76,7 +86,7 @@ export function ChatInsufficientCreditsBanner({
aria-label={ctaLabel}
>
{ctaLabel}
</button>
</button> : null}
</div>
);
}
@@ -0,0 +1,32 @@
"use client";
import { Heart, Sparkles } from "lucide-react";
import type { ChatSupportCtaKind } from "../chat-support-cta";
export interface ChatSupportButtonProps {
kind: ChatSupportCtaKind;
label: string;
onClick: () => void;
}
export function ChatSupportButton({
kind,
label,
onClick,
}: ChatSupportButtonProps) {
const Icon = kind === "support" ? Heart : Sparkles;
return (
<button
type="button"
data-analytics-key="chat.support_character"
data-support-state={kind}
className="inline-flex min-h-9 max-w-full items-center justify-center gap-1.5 rounded-full border border-[rgba(255,255,255,0.22)] bg-[linear-gradient(135deg,rgba(246,87,160,0.96),rgba(255,119,84,0.94))] px-3 py-1.5 text-center text-[12px] font-extrabold leading-tight text-white shadow-[0_8px_24px_rgba(246,87,160,0.3)] backdrop-blur-md transition active:scale-95"
aria-label={label}
onClick={onClick}
>
<Icon className="shrink-0" size={14} strokeWidth={2.5} aria-hidden="true" />
<span className="whitespace-normal text-balance">{label}</span>
</button>
);
}
@@ -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}
/>
@@ -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 (
<div
@@ -31,11 +39,16 @@ export function HistoryUnlockDialog({
id="history-unlock-title"
className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground"
>
Unlock previous messages?
{activeGuidance?.title ?? "Unlock previous messages?"}
</h2>
<p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]">
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.
</>
)}
</p>
{errorMessage ? (
<p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-caption,var(--font-size-md,14px)) leading-[1.4] text-[#c0364c]">
@@ -51,7 +64,7 @@ export function HistoryUnlockDialog({
>
Later
</button>
<button
{showAction ? <button
type="button"
data-analytics-key="chat.unlock_history"
data-analytics-label="Unlock chat history"
@@ -59,8 +72,10 @@ export function HistoryUnlockDialog({
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? "Unlocking..." : "Unlock now"}
</button>
{isLoading
? "Unlocking..."
: activeGuidance?.ctaLabel ?? "Unlock now"}
</button> : null}
</div>
</div>
</div>
+2
View File
@@ -12,6 +12,7 @@ export * from "./chat-insufficient-credits-banner";
export * from "./chat-input-bar";
export * from "./chat-input-text-field";
export * from "./chat-send-button";
export * from "./chat-support-button";
export * from "./chat-unlock-dialogs";
export * from "./date-header";
export * from "./first-recharge-offer-banner";
@@ -22,6 +23,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";
@@ -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 (
<div
@@ -33,10 +41,11 @@ export function InsufficientCreditsDialog({
id="insufficient-credits-title"
className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground"
>
Not enough credits
{activeGuidance?.title ?? "Not enough credits"}
</h2>
<p className="mx-(--spacing-md,12px) mb-(--page-section-gap,18px) mt-0 text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]">
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."}
</p>
{showCreditDetail ? (
@@ -70,15 +79,15 @@ export function InsufficientCreditsDialog({
>
Later
</button>
<button
{showPurchaseAction ? <button
type="button"
data-analytics-key="chat.topup"
data-analytics-label="Top up chat credits"
className="flex min-h-(--pwa-button-height,44px) flex-auto cursor-pointer items-center justify-center rounded-bottom-sheet border-0 bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)] text-(length:--responsive-body,var(--font-size-lg,16px)) font-bold text-(--color-pwa-button-text,#ffffff) shadow-[0_6px_14px_rgba(246,87,160,0.28)] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
onClick={onConfirm}
>
Top up now
</button>
{activeGuidance?.ctaLabel ?? "Top up now"}
</button> : null}
</div>
</div>
</div>
+4 -1
View File
@@ -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}
+11 -2
View File
@@ -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 ? (
<PaymentGuidanceCard guidance={paymentGuidance} />
) : isFromAI && chatAction ? (
<ChatActionCard
action={chatAction}
onViewed={onChatActionViewed}
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ArrowRight, CircleDollarSign, X } from "lucide-react";
import type { PaymentGuidance } from "@/data/schemas/chat";
import type { PaymentAnalyticsTriggerReason } from "@/lib/analytics/payment_analytics_context";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
import { useActiveCharacter } from "@/providers/character-provider";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useUserSelector } from "@/stores/user/user-context";
import styles from "./chat-area.module.css";
export interface PaymentGuidanceCardProps {
guidance: PaymentGuidance;
}
function triggerReasonForGuidance(
scene: PaymentGuidance["scene"],
): PaymentAnalyticsTriggerReason {
if (scene === "supportCharacter" || scene === "vip" || scene === "purchaseOptions") {
return "vip_cta";
}
if (scene === "privateMessageLocked" || scene === "historyLocked") {
return "private_topic_limit";
}
return "insufficient_credits";
}
export function PaymentGuidanceCard({ guidance }: PaymentGuidanceCardProps) {
const character = useActiveCharacter();
const navigator = useAppNavigator();
const isVip = useUserSelector((state) => state.context.isVip);
const [dismissed, setDismissed] = useState(false);
const viewedIdRef = useRef<string | null>(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 (
<aside
className={styles.commercialAction}
aria-label={guidance.title ?? guidance.characterName}
data-payment-guidance-id={guidance.guidanceId}
data-payment-guidance-mode={guidance.mode}
data-payment-guidance-scene={guidance.scene}
>
<div className={styles.commercialActionHeader}>
<CircleDollarSign size={18} aria-hidden="true" />
<button
type="button"
className={styles.commercialActionDismiss}
title="Dismiss"
aria-label="Dismiss"
onClick={() => {
setDismissed(true);
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"dismissed",
).catch(() => undefined);
}}
>
<X size={16} aria-hidden="true" />
</button>
</div>
{guidance.title ? (
<strong>{guidance.title}</strong>
) : null}
<p className={styles.commercialActionCopy}>{guidance.copy}</p>
{hasPurchaseAction ? (
<button
type="button"
className={styles.commercialActionButton}
onClick={() => {
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"opened",
).catch(() => undefined);
navigator.openSubscription({
type: subscriptionType,
returnTo: "chat",
chatActionId: guidance.guidanceId,
analytics: {
entryPoint: "chat_input",
triggerReason: triggerReasonForGuidance(guidance.scene),
},
});
}}
>
<span>{guidance.ctaLabel}</span>
<ArrowRight size={17} aria-hidden="true" />
</button>
) : null}
</aside>
);
}
@@ -0,0 +1,38 @@
"use client";
import { useEffect } from "react";
import { createChatWebSocket } from "@/core/net/chat-websocket";
import type { LoginStatus } from "@/data/schemas/auth";
import { getSessionAccessToken } from "@/lib/auth/auth_session";
import { useChatDispatch } from "@/stores/chat/chat-context";
export function useChatCommercialMessages(input: {
characterId: string;
loginStatus: LoginStatus;
}) {
const chatDispatch = useChatDispatch();
useEffect(() => {
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
return;
}
let disposed = false;
let socket: ReturnType<typeof createChatWebSocket> | null = null;
void getSessionAccessToken().then((token) => {
if (disposed || !token) return;
socket = createChatWebSocket(token);
socket.onCommercialMessage = (message) => {
if (message.characterId !== input.characterId) return;
chatDispatch({ type: "ChatCommercialMessageReceived", message });
};
socket.connect();
});
return () => {
disposed = true;
socket?.disconnect();
};
}, [chatDispatch, input.characterId, input.loginStatus]);
}
@@ -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,
};
@@ -0,0 +1,97 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { shallowEqual } from "@xstate/react";
import type { LoginStatus } from "@/data/schemas/auth";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
usePaymentDispatch,
usePaymentSelector,
} from "@/stores/payment/payment-context";
import { useUserSelector } from "@/stores/user/user-context";
import { deriveChatSupportCta } from "../chat-support-cta";
export function useChatSupportCta(input: {
characterId: string;
historyLoaded: boolean;
loginStatus: LoginStatus;
}) {
const navigator = useAppNavigator();
const paymentDispatch = usePaymentDispatch();
const initializedCharacterRef = useRef<string | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const payment = usePaymentSelector(
(state) => ({
commercialOffer: state.context.commercialOffer,
isFirstRecharge: state.context.isFirstRecharge,
supportCharacterId: state.context.supportCharacterId,
}),
shallowEqual,
);
const countryCode = useUserSelector(
(state) => state.context.currentUser?.countryCode,
);
const isAuthenticated =
input.loginStatus !== "notLoggedIn" && input.loginStatus !== "guest";
useEffect(() => {
if (!isAuthenticated) return;
if (initializedCharacterRef.current === input.characterId) return;
initializedCharacterRef.current = input.characterId;
paymentDispatch({
type: "PaymentInit",
characterId: input.characterId,
payChannel: getDefaultPayChannelForCountryCode(countryCode),
});
}, [countryCode, input.characterId, isAuthenticated, paymentDispatch]);
useEffect(() => {
if (!payment.commercialOffer) return;
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [payment.commercialOffer]);
const cta = useMemo(
() =>
deriveChatSupportCta({
isFirstRecharge:
isAuthenticated && payment.supportCharacterId === input.characterId
? payment.isFirstRecharge
: false,
commercialOffer:
isAuthenticated && payment.supportCharacterId === input.characterId
? payment.commercialOffer
: null,
nowMs,
}),
[
input.characterId,
isAuthenticated,
nowMs,
payment.commercialOffer,
payment.isFirstRecharge,
payment.supportCharacterId,
],
);
const open = () => {
navigator.openSubscription({
type: cta.planId?.startsWith("dol_") ? "topup" : "vip",
returnTo: "chat",
...(cta.planId ? { planId: cta.planId } : {}),
...(cta.commercialOfferId
? { commercialOfferId: cta.commercialOfferId }
: {}),
analytics: {
entryPoint: "chat_offer_banner",
triggerReason:
cta.kind === "support" ? "vip_cta" : "commercial_offer",
},
});
};
return { ...cta, visible: input.historyLoaded, open };
}
@@ -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,
+115 -11
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import {
persistExternalEntryPayload,
resolveCheckoutIntentDestination,
resolveExternalEntryDestination,
resolveExternalEntryPromotionType,
resolveExternalEntryTarget,
@@ -21,6 +22,9 @@ 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 { consumeCheckoutHandoff } from "@/lib/auth/checkout_handoff";
import { Result } from "@/utils/result";
import {
isFavoriteEntryRequest,
persistFavoriteEntryIntent,
@@ -38,6 +42,7 @@ interface ExternalEntryPersistProps {
mode: string | null;
promotionType: string | null;
favorite: string | null;
handoffToken: string | null;
}
export default function ExternalEntryPersist({
@@ -50,13 +55,19 @@ 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<string | null>(null);
const [handoffCompleted, setHandoffCompleted] = useState(false);
const [checkoutDestination, setCheckoutDestination] = useState<string | null>(
null,
);
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 +76,15 @@ export default function ExternalEntryPersist({
mode,
promotionType,
});
const normalizedTarget = target?.trim().toLowerCase() ?? "";
const isTopUpHandoff = normalizedTarget === "topup";
const isCheckoutHandoff = normalizedTarget === "checkout";
const isLoginHandoff = isTopUpHandoff || isCheckoutHandoff;
const displayedHandoffError =
handoffError ??
(isLoginHandoff && !hasValue(handoffToken)
? "This checkout link is invalid. Please return and request a new link."
: null);
useEffect(() => {
let cancelled = false;
@@ -116,12 +136,74 @@ 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 (isLoginHandoff) {
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(checkoutDestination ?? destination);
return;
}
if (handoffStartedRef.current) return;
if (!hasValue(handoffToken)) return;
handoffStartedRef.current = true;
void (async () => {
if (isCheckoutHandoff) {
const result = await consumeCheckoutHandoff(handoffToken);
if (Result.isErr(result)) {
log.warn(
"[ExternalEntryPersist] checkout handoff failed",
result.error,
);
window.history.replaceState(
window.history.state,
"",
"/external-entry?target=checkout",
);
setHandoffError(
"This checkout link is invalid, expired, or already used. Please return and request a new link.",
);
return;
}
window.history.replaceState(
window.history.state,
"",
"/external-entry?target=checkout",
);
setCheckoutDestination(
resolveCheckoutIntentDestination(result.data, character),
);
} else {
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;
}
window.history.replaceState(
window.history.state,
"",
"/external-entry?target=topup",
);
}
setHandoffCompleted(true);
authDispatch({ type: "AuthInit" });
})();
return;
}
@@ -150,8 +232,15 @@ export default function ExternalEntryPersist({
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
character,
destination,
checkoutDestination,
hasPersisted,
handoffCompleted,
handoffToken,
isCheckoutHandoff,
isLoginHandoff,
isTopUpHandoff,
psid,
router,
]);
@@ -161,11 +250,26 @@ export default function ExternalEntryPersist({
className="flex flex-1 items-center justify-center"
style={{ minHeight: "100dvh" }}
>
<div
aria-label="Redirecting"
className="size-10 animate-spin rounded-full border-4 border-t-transparent"
style={{ borderColor: "var(--color-accent)" }}
/>
{displayedHandoffError ? (
<div className="mx-6 max-w-sm text-center">
<p className="text-sm text-[var(--color-text-secondary)]">
{displayedHandoffError}
</p>
<button
className="mt-5 rounded-full bg-[var(--color-accent)] px-5 py-2.5 text-sm font-semibold text-white"
type="button"
onClick={() => router.replace(ROUTES.root)}
>
Back to Cozsweet
</button>
</div>
) : (
<div
aria-label="Redirecting"
className="size-10 animate-spin rounded-full border-4 border-t-transparent"
style={{ borderColor: "var(--color-accent)" }}
/>
)}
</div>
);
}
+3
View File
@@ -7,6 +7,8 @@
* `/external-entry?target=chat&character=maya`
* `/external-entry?target=tip`
* `/external-entry?target=private-zone&character=nayeli`
* `/external-entry?target=topup&handoffToken=<opaque-token>`
* `/external-entry?target=checkout&handoffToken=<opaque-token>`
*
* `redirect()` Client
* `router.replace()` URL
@@ -36,6 +38,7 @@ export default async function ExternalEntryPage({
mode={pickParam(params.mode)}
promotionType={pickParam(params.promotion_type)}
favorite={pickParam(params.favorite)}
handoffToken={pickParam(params.handoffToken)}
/>
);
}
@@ -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<Dispatch<PrivateZoneEvent>>();
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;
}
+23 -2
View File
@@ -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",
+28 -2
View File
@@ -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<AuthEvent>;
@@ -25,6 +29,7 @@ export interface UsePrivateZoneUnlockPaywallNavigationInput {
loginStatus: LoginStatus;
roomDispatch: Dispatch<PrivateZoneEvent>;
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,
@@ -34,14 +34,14 @@ describe("SubscriptionPage", () => {
);
});
it("uses Elio for missing source navigation context", async () => {
it("keeps the recipient empty when source navigation has no character", async () => {
const page = await SubscriptionPage({
searchParams: Promise.resolve({}),
});
renderToStaticMarkup(page);
expect(mocks.screenProps).toHaveBeenLastCalledWith(
expect.objectContaining({ sourceCharacterSlug: "elio" }),
expect.objectContaining({ sourceCharacterSlug: null }),
);
});
});
@@ -3,6 +3,16 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const characters = [
{ id: "elio", slug: "elio", displayName: "Elio Silvestri", shortName: "Elio" },
{ id: "maya-tan", slug: "maya", displayName: "Maya Tan", shortName: "Maya" },
{
id: "nayeli-cervantes",
slug: "nayeli",
displayName: "Nayeli Cervantes",
shortName: "Nayeli",
},
];
const payment = {
status: "ready",
plans: [
@@ -41,6 +51,7 @@ const mocks = vi.hoisted(() => {
commercialOffer: null,
selectedPlanId: "vip_monthly",
payChannel: "stripe" as const,
autoRenew: true,
agreed: true,
currentOrderId: null,
isCreatingOrder: false,
@@ -52,7 +63,17 @@ const mocks = vi.hoisted(() => {
payment.selectedPlanId = event.planId;
}
});
return { payment, paymentDispatch, planClick: vi.fn() };
return {
characterCatalog: {
characters,
getBySlug: (slug: string | null) =>
characters.find((character) => character.slug === slug) ?? null,
},
payment,
paymentDispatch,
paymentFlowInput: vi.fn(),
planClick: vi.fn(),
};
});
vi.mock("@/app/_components", () => ({
@@ -80,7 +101,13 @@ vi.mock("@/hooks/use-has-hydrated", () => ({
}));
vi.mock("@/stores/user/user-context", () => ({
useUserState: () => ({ currentUser: { countryCode: "ID" } }),
useUserState: () => ({
currentUser: { id: "user-renewal-1", countryCode: "ID" },
}),
}));
vi.mock("@/providers/character-catalog-provider", () => ({
useCharacterCatalog: () => mocks.characterCatalog,
}));
vi.mock("@/lib/payment/payment_method", () => ({
@@ -97,13 +124,16 @@ vi.mock("@/lib/analytics", () => ({
}));
vi.mock("../use-subscription-payment-flow", () => ({
useSubscriptionPaymentFlow: () => ({
payment: mocks.payment,
paymentDispatch: mocks.paymentDispatch,
showPaymentSuccessDialog: false,
handleBackClick: vi.fn(),
handlePaymentSuccessClose: vi.fn(),
}),
useSubscriptionPaymentFlow: (input: unknown) => {
mocks.paymentFlowInput(input);
return {
payment: mocks.payment,
paymentDispatch: mocks.paymentDispatch,
showPaymentSuccessDialog: false,
handleBackClick: vi.fn(),
handlePaymentSuccessClose: vi.fn(),
};
},
}));
vi.mock("../components", () => ({
@@ -137,26 +167,25 @@ vi.mock("../components", () => ({
))}
</div>
),
SubscriptionCheckoutButton: ({ disabled }: { disabled: boolean }) => (
<button type="button" data-testid="checkout" disabled={disabled}>
SubscriptionCheckoutButton: ({
disabled,
renewalPlan,
renewalConsentSubjectId,
}: {
disabled: boolean;
renewalPlan: { id: string } | null;
renewalConsentSubjectId: string | null;
}) => (
<button
type="button"
data-testid="checkout"
data-renewal-plan={renewalPlan?.id ?? ""}
data-renewal-subject={renewalConsentSubjectId ?? ""}
disabled={disabled}
>
Pay and Top Up
</button>
),
SubscriptionRenewalConfirmationDialog: ({
open,
onCancel,
onConfirm,
}: {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
}) =>
open ? (
<div role="dialog">
<button type="button" onClick={onCancel}>Cancel</button>
<button type="button" onClick={onConfirm}>Confirm</button>
</div>
) : null,
SubscriptionPaymentIssueDialog: () => null,
SubscriptionPaymentSuccessDialog: () => null,
}));
@@ -170,8 +199,16 @@ describe("SubscriptionScreen payment selection flow", () => {
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
mocks.payment.isFirstRecharge = false;
mocks.payment.commercialOffer = null;
for (const plan of mocks.payment.plans) {
delete (plan as Record<string, unknown>).isFirstRechargeOffer;
delete (plan as Record<string, unknown>).firstRechargeDiscountPercent;
delete (plan as Record<string, unknown>).promotionType;
}
mocks.payment.selectedPlanId = "vip_monthly";
mocks.paymentDispatch.mockClear();
mocks.paymentFlowInput.mockClear();
mocks.planClick.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
@@ -183,68 +220,32 @@ describe("SubscriptionScreen payment selection flow", () => {
container.remove();
});
it("confirms VIP selection before enabling the separate checkout button", () => {
act(() => root.render(<SubscriptionScreen />));
it("allows immediate checkout and changes VIP plans without opening renewal confirmation", () => {
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
const checkout = container.querySelector<HTMLButtonElement>(
'[data-testid="checkout"]',
);
expect(checkout?.disabled).toBe(true);
expect(checkout?.disabled).toBe(false);
expect(checkout?.dataset.renewalPlan).toBe("vip_monthly");
expect(checkout?.dataset.renewalSubject).toBe("user-renewal-1");
act(() => clickButton(container, "vip_monthly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
);
act(() => clickButton(container, "Confirm"));
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
type: "PaymentPlanSelected",
planId: "vip_monthly",
});
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
);
expect(checkout?.disabled).toBe(false);
act(() => clickButton(container, "vip_monthly"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => clickButton(container, "vip_quarterly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(mocks.payment.selectedPlanId).toBe("vip_monthly");
});
it("keeps the original selection when VIP confirmation is cancelled", () => {
act(() => root.render(<SubscriptionScreen />));
act(() => clickButton(container, "vip_quarterly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
act(() => clickButton(container, "Cancel"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.payment.selectedPlanId).toBe("vip_monthly");
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith({
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
type: "PaymentPlanSelected",
planId: "vip_quarterly",
});
});
it("requires VIP confirmation again after the page is remounted", () => {
act(() => root.render(<SubscriptionScreen />));
act(() => clickButton(container, "vip_monthly"));
act(() => clickButton(container, "Confirm"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => root.unmount());
root = createRoot(container);
act(() => root.render(<SubscriptionScreen />));
act(() => clickButton(container, "vip_monthly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
});
it("selects a coin package without showing the renewal dialog", () => {
act(() => root.render(<SubscriptionScreen />));
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "coin_1000"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
@@ -256,6 +257,100 @@ describe("SubscriptionScreen payment selection flow", () => {
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
);
});
it("keeps payment guidance bound to the source character", () => {
act(() =>
root.render(
<SubscriptionScreen
sourceCharacterSlug="maya"
chatActionId="019c8f8d-17d3-7a42-b1cc-b6927b1927d5"
/>,
),
);
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");
});
it("does not render the first recharge banner on the subscription page", () => {
mocks.payment.isFirstRecharge = true;
Object.assign(mocks.payment.plans[0] as Record<string, unknown>, {
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
promotionType: "first_recharge_half_price",
});
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
expect(container.textContent).not.toContain("First Recharge Offer");
expect(container.textContent).not.toContain(
"Your first recharge price is already applied",
);
});
it.each(["elio", "maya", "nayeli"])(
"inherits the %s chat character without rendering a recipient selector",
(sourceCharacterSlug) => {
mocks.payment.selectedPlanId = "coin_1000";
act(() =>
root.render(
<SubscriptionScreen
subscriptionType="topup"
sourceCharacterSlug={sourceCharacterSlug}
/>,
),
);
const checkout = container.querySelector<HTMLButtonElement>(
'[data-testid="checkout"]',
);
expect(container.textContent).not.toContain("Supporting ");
expect(container.textContent).not.toContain(
"Choose who you want to support",
);
expect(container.textContent).not.toContain(
"Your VIP or credit purchase supports the character you choose",
);
expect(checkout?.disabled).toBe(false);
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
expect.objectContaining({ sourceCharacterSlug }),
);
},
);
it("does not invent a recipient when opened without a source character", () => {
mocks.payment.selectedPlanId = "coin_1000";
act(() =>
root.render(
<SubscriptionScreen
subscriptionType="topup"
sourceCharacterSlug={null}
/>,
),
);
const checkout = container.querySelector<HTMLButtonElement>(
'[data-testid="checkout"]',
);
expect(container.textContent).not.toContain("Supporting ");
expect(container.textContent).not.toContain(
"Choose who you want to support",
);
expect(container.textContent).not.toContain(
"Your VIP or credit purchase supports the character you choose",
);
expect(checkout?.disabled).toBe(true);
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
expect.objectContaining({ sourceCharacterSlug: null }),
);
});
});
function clickButton(root: ParentNode, label: string): void {
@@ -7,11 +7,9 @@ import {
} from "@/data/schemas/payment";
import {
canCheckoutSubscriptionPlan,
findSelectedSubscriptionPlan,
getDefaultSubscriptionPlanId,
getFirstRechargeOfferView,
requiresVipPlanConfirmation,
toCoinsOfferPlanViews,
toVipOfferPlanViews,
} from "../subscription-screen.helpers";
@@ -280,85 +278,4 @@ describe("subscription screen helpers", () => {
).toBeNull();
});
it("requires confirmation for each unconfirmed VIP plan", () => {
const vipPlans = [
{
id: "vip_monthly",
title: "Monthly",
price: "19.90",
currency: "usd",
originalPrice: "",
},
{
id: "vip_quarterly",
title: "Quarterly",
price: "49.90",
currency: "usd",
originalPrice: "",
},
];
expect(
requiresVipPlanConfirmation({
planId: "vip_monthly",
vipPlans,
confirmedVipPlanIds: new Set(),
}),
).toBe(true);
expect(
requiresVipPlanConfirmation({
planId: "vip_monthly",
vipPlans,
confirmedVipPlanIds: new Set(["vip_monthly"]),
}),
).toBe(false);
expect(
requiresVipPlanConfirmation({
planId: "coin_1000",
vipPlans,
confirmedVipPlanIds: new Set(),
}),
).toBe(false);
});
it("allows checkout only after the selected VIP plan is confirmed", () => {
const vipPlans = [
{
id: "vip_monthly",
title: "Monthly",
price: "19.90",
currency: "usd",
originalPrice: "",
},
];
expect(
canCheckoutSubscriptionPlan({
selectedPlanId: "vip_monthly",
vipPlans,
confirmedVipPlanIds: new Set(),
}),
).toBe(false);
expect(
canCheckoutSubscriptionPlan({
selectedPlanId: "vip_monthly",
vipPlans,
confirmedVipPlanIds: new Set(["vip_monthly"]),
}),
).toBe(true);
expect(
canCheckoutSubscriptionPlan({
selectedPlanId: "coin_1000",
vipPlans,
confirmedVipPlanIds: new Set(),
}),
).toBe(true);
expect(
canCheckoutSubscriptionPlan({
selectedPlanId: "",
vipPlans,
confirmedVipPlanIds: new Set(),
}),
).toBe(false);
});
});
@@ -0,0 +1,234 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
dispatch: vi.fn(),
resetLaunchState: vi.fn(),
payment: {
selectedPlanId: "vip_monthly",
autoRenew: true,
payChannel: "stripe" as "stripe" | "ezpay",
commercialOfferId: null,
chatActionId: null,
currentOrderId: null,
errorMessage: null,
isCreatingOrder: false,
isPollingOrder: false,
},
}));
vi.mock("@/stores/payment/payment-context", () => ({
usePaymentState: () => mocks.payment,
usePaymentDispatch: () => mocks.dispatch,
}));
vi.mock("@/app/_hooks/use-payment-launch-flow", () => ({
usePaymentLaunchFlow: () => ({
resetLaunchState: mocks.resetLaunchState,
launch: {},
}),
}));
vi.mock("@/app/_components/payment/payment-launch-dialogs", () => ({
PaymentLaunchDialogs: () => null,
}));
vi.mock("@/app/_components/payment/external-browser-checkout-button", () => ({
ExternalBrowserCheckoutButton: () => null,
}));
vi.mock("../subscription-cta-button", () => ({
SubscriptionCtaButton: ({
children,
disabled,
onClick,
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
}) => (
<button type="button" disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("../subscription-renewal-confirmation-dialog", () => ({
SubscriptionRenewalConfirmationDialog: ({
open,
onCancel,
onConfirm,
}: {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
}) =>
open ? (
<div role="dialog">
<button type="button" onClick={onCancel}>Cancel</button>
<button type="button" onClick={onConfirm}>Confirm</button>
</div>
) : null,
}));
import { SubscriptionCheckoutButton } from "../subscription-checkout-button";
const renewalPlan = {
id: "vip_monthly",
title: "Monthly",
price: "19.90",
currency: "usd",
originalPrice: "39.90",
};
describe("SubscriptionCheckoutButton renewal confirmation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
localStorage.clear();
mocks.dispatch.mockClear();
mocks.resetLaunchState.mockClear();
mocks.payment.selectedPlanId = "vip_monthly";
mocks.payment.autoRenew = true;
mocks.payment.payChannel = "stripe";
mocks.payment.isCreatingOrder = false;
mocks.payment.isPollingOrder = false;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("shows confirmation on the first auto-renewing VIP checkout and resumes after confirm", () => {
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(mocks.dispatch).not.toHaveBeenCalled();
act(() => clickButton(container, "Confirm"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.resetLaunchState).toHaveBeenCalledOnce();
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
});
});
it("does not ask the same user again after confirmation", () => {
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
act(() => clickButton(container, "Confirm"));
mocks.dispatch.mockClear();
mocks.resetLaunchState.mockClear();
act(() => root.unmount());
root = createRoot(container);
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.resetLaunchState).toHaveBeenCalledOnce();
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
});
});
it("keeps asking after cancel and isolates acknowledgement by user", () => {
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
act(() => clickButton(container, "Cancel"));
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
act(() => clickButton(container, "Confirm"));
mocks.dispatch.mockClear();
act(() => root.unmount());
root = createRoot(container);
renderButton(root, "user-2");
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(mocks.dispatch).not.toHaveBeenCalled();
});
it("skips confirmation for a non-renewing purchase", () => {
mocks.payment.autoRenew = false;
renderButton(root, "user-1", null);
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
});
});
it("skips confirmation for one-time EzPay membership checkout", () => {
mocks.payment.payChannel = "ezpay";
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
});
});
it("keeps checkout disabled while a payment order is still being polled", () => {
mocks.payment.payChannel = "ezpay";
mocks.payment.isPollingOrder = true;
act(() =>
root.render(
<SubscriptionCheckoutButton
disabled
subscriptionType="topup"
sourceCharacterSlug="elio"
/>,
),
);
const checkoutButton = getButton(container, "Processing payment...");
expect(checkoutButton.disabled).toBe(true);
expect(mocks.resetLaunchState).not.toHaveBeenCalled();
expect(mocks.dispatch).not.toHaveBeenCalled();
});
});
function renderButton(
root: Root,
renewalConsentSubjectId: string,
plan: typeof renewalPlan | null = renewalPlan,
): void {
act(() =>
root.render(
<SubscriptionCheckoutButton
subscriptionType="vip"
sourceCharacterSlug="elio"
renewalPlan={plan}
renewalConsentSubjectId={renewalConsentSubjectId}
/>,
),
);
}
function clickButton(root: ParentNode, label: string): void {
getButton(root, label).click();
}
function getButton(root: ParentNode, label: string): HTMLButtonElement {
const button = Array.from(root.querySelectorAll("button")).find(
(item) => item.textContent?.trim() === label,
);
if (!button) throw new Error(`Expected ${label} button`);
return button;
}
@@ -3,8 +3,12 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Result } from "@/utils/result";
import { ApiError } from "@/data/services/api/api_result";
import { SubscriptionPaymentIssueDialog } from "../subscription-payment-issue-dialog";
import {
getPaymentIssueSubmitErrorLogDetails,
SubscriptionPaymentIssueDialog,
} from "../subscription-payment-issue-dialog";
import { SubscriptionRenewalConfirmationDialog } from "../subscription-renewal-confirmation-dialog";
const mocks = vi.hoisted(() => ({
@@ -67,7 +71,14 @@ describe("subscription payment dialogs", () => {
expect(dialog?.textContent).toContain("Automatic Renewal Confirmation");
expect(dialog?.textContent).toContain("Monthly");
expect(dialog?.textContent).toContain("19.90 usd");
expect(dialog?.querySelectorAll("a")).toHaveLength(2);
expect(
Array.from(dialog?.querySelectorAll("a") ?? []).map((link) =>
link.getAttribute("href"),
),
).toEqual([
"/legal/vip-membership-benefits.html",
"/legal/automatic-renewal.html",
]);
act(() => clickButton(dialog, "Confirm"));
expect(onConfirm).toHaveBeenCalledOnce();
@@ -200,6 +211,28 @@ describe("subscription payment dialogs", () => {
);
expect(onClose).toHaveBeenCalledOnce();
});
it("keeps HTTP status and backend error code in payment issue failure diagnostics", () => {
const failure = Result.err(
new ApiError("HTTP_ERROR", "Invalid feedback context", 400, {
detail: {
message: "Invalid feedback context",
errorCode: "INVALID_FEEDBACK_CONTEXT",
},
}),
);
if (failure.success) throw new Error("Expected failure result");
expect(getPaymentIssueSubmitErrorLogDetails(failure.error)).toMatchObject({
code: "UNKNOWN",
message: "Invalid feedback context",
causeName: "ApiError",
causeMessage: "Invalid feedback context",
httpStatus: 400,
apiCode: "HTTP_ERROR",
serverErrorCode: "INVALID_FEEDBACK_CONTEXT",
});
});
});
function clickButton(root: ParentNode | null, label: string): void {
@@ -4,10 +4,17 @@
*
* `SubscriptionCtaButton` payment
*/
import { useState } from "react";
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
import {
hasAutomaticRenewalAcknowledgement,
rememberAutomaticRenewalAcknowledgement,
} from "@/lib/payment/automatic_renewal_acknowledgement";
import {
usePaymentDispatch,
usePaymentState,
@@ -15,15 +22,20 @@ import {
import { Logger } from "@/utils/logger";
import { SubscriptionCtaButton } from "./subscription-cta-button";
import { SubscriptionRenewalConfirmationDialog } from "./subscription-renewal-confirmation-dialog";
import type { VipOfferPlanView } from "./subscription-vip-offer-section";
const log = new Logger("SubscriptionCheckoutButton");
export interface SubscriptionCheckoutButtonProps {
/** 是否可用(未选套餐或 VIP 套餐尚未确认自动续费时为 false */
/** 是否可用(未选套餐、缺少角色来源或支付处理中为 false */
disabled?: boolean;
subscriptionType: "vip" | "topup";
returnTo?: SubscriptionReturnTo;
sourceCharacterSlug?: string;
renewalPlan?: VipOfferPlanView | null;
renewalConsentSubjectId?: string | null;
countryCode?: string | null;
}
export function SubscriptionCheckoutButton({
@@ -31,7 +43,12 @@ export function SubscriptionCheckoutButton({
subscriptionType,
returnTo = null,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
renewalPlan = null,
renewalConsentSubjectId = null,
countryCode = null,
}: SubscriptionCheckoutButtonProps) {
const [showRenewalConfirmation, setShowRenewalConfirmation] =
useState(false);
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const paymentLaunch = usePaymentLaunchFlow({
@@ -42,24 +59,61 @@ export function SubscriptionCheckoutButton({
returnTo: returnTo ?? undefined,
characterSlug: sourceCharacterSlug,
subscriptionType,
countryCode,
});
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
const readyLabel = "Pay and Top Up";
const label = payment.isPollingOrder
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
: readyLabel;
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
: readyLabel;
const handleClick = () => {
const startCheckout = () => {
if (disabled || isLoading) return;
paymentLaunch.resetLaunchState();
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
};
const handleClick = () => {
if (disabled || isLoading) return;
if (
payment.payChannel === "stripe" &&
payment.autoRenew &&
renewalPlan !== null &&
!hasAutomaticRenewalAcknowledgement(renewalConsentSubjectId)
) {
setShowRenewalConfirmation(true);
return;
}
startCheckout();
};
const handleRenewalConfirm = () => {
rememberAutomaticRenewalAcknowledgement(renewalConsentSubjectId);
setShowRenewalConfirmation(false);
startCheckout();
};
return (
<>
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
<ExternalBrowserCheckoutButton
disabled={disabled || isLoading}
characterSlug={sourceCharacterSlug}
checkoutIntent={{
planId: payment.selectedPlanId,
autoRenew: payment.autoRenew,
...(payment.commercialOfferId
? { commercialOfferId: payment.commercialOfferId }
: {}),
...(payment.chatActionId
? { chatActionId: payment.chatActionId }
: {}),
}}
/>
) : null}
<SubscriptionCtaButton
type="button"
data-analytics-key="subscription.checkout"
@@ -86,9 +140,15 @@ export function SubscriptionCheckoutButton({
<PaymentLaunchDialogs
currentOrderId={payment.currentOrderId}
externalCheckoutAnalyticsKey="subscription.external_checkout"
ezpayDescription="Your order has been created. Continue to GCash to finish the payment."
ezpayDescription="Your order has been created. Continue to the secure payment page to finish."
launch={paymentLaunch}
/>
<SubscriptionRenewalConfirmationDialog
open={showRenewalConfirmation}
plan={renewalPlan}
onCancel={() => setShowRenewalConfirmation(false)}
onConfirm={handleRenewalConfirm}
/>
</>
);
}
@@ -6,6 +6,7 @@ import { ModalPortal } from "@/app/_components/core/modal-portal";
import { createFeedbackContext } from "@/app/feedback/feedback-context";
import type { PayChannel } from "@/data/schemas/payment";
import { submitFeedback } from "@/lib/feedback";
import { Logger } from "@/utils/logger";
import type { SubscriptionType } from "../subscription-screen.helpers";
import styles from "./subscription-dialog.module.css";
@@ -33,6 +34,21 @@ const OTHER_DETAILS_MIN_LENGTH = 10;
const OTHER_DETAILS_MAX_LENGTH = 2000;
const SUCCESS_MESSAGE = "Thanks. Your payment issue has been submitted.";
const FAILURE_MESSAGE = "Unable to submit. Please try again.";
const log = new Logger("SubscriptionPaymentIssueDialog");
interface ErrorRecord {
readonly [key: string]: unknown;
}
export interface PaymentIssueSubmitErrorLogDetails {
code: string;
message: string;
causeName?: string;
causeMessage?: string;
httpStatus?: number;
apiCode?: string;
serverErrorCode?: string;
}
export interface SubscriptionPaymentIssueDialogProps {
open: boolean;
@@ -119,6 +135,19 @@ export function SubscriptionPaymentIssueDialog({
});
if (!result.success) {
log.error(
{
...getPaymentIssueSubmitErrorLogDetails(result.error),
paymentIssueReason: reason,
subscriptionType,
planId,
orderId,
payChannel,
countryCode,
characterId,
},
"Payment issue feedback submit failed",
);
setErrorMessage(FAILURE_MESSAGE);
setIsSubmitting(false);
return;
@@ -206,6 +235,32 @@ export function SubscriptionPaymentIssueDialog({
);
}
export function getPaymentIssueSubmitErrorLogDetails(
error: Error,
): PaymentIssueSubmitErrorLogDetails {
const errorRecord = toErrorRecord(error);
const cause = toErrorRecord(errorRecord?.cause);
const responseDetails = toErrorRecord(cause?.details);
const detail = toErrorRecord(responseDetails?.detail);
const causeName = stringValue(cause?.name);
const causeMessage = stringValue(cause?.message);
const httpStatus = numberValue(cause?.status);
const apiCode = stringValue(cause?.code);
const serverErrorCode =
stringValue(detail?.errorCode) ??
stringValue(responseDetails?.errorCode);
return {
code: stringValue(errorRecord?.code) ?? "UNKNOWN",
message: error.message,
...(causeName ? { causeName } : {}),
...(causeMessage ? { causeMessage } : {}),
...(httpStatus !== undefined ? { httpStatus } : {}),
...(apiCode ? { apiCode } : {}),
...(serverErrorCode ? { serverErrorCode } : {}),
};
}
function createIdempotencyKey(): string {
const token =
typeof crypto !== "undefined" && "randomUUID" in crypto
@@ -213,3 +268,18 @@ function createIdempotencyKey(): string {
: `${Date.now()}_${Math.random().toString(36).slice(2)}`;
return `payment_feedback_${token}`;
}
function toErrorRecord(value: unknown): ErrorRecord | null {
if (typeof value !== "object" || value === null) return null;
return value as ErrorRecord;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
@@ -1,14 +1,24 @@
.shell {
display: flex;
flex-direction: column;
height: var(--app-viewport-height, 100dvh);
min-height: var(--app-viewport-height, 100dvh);
overflow: hidden;
background:
radial-gradient(circle at 8% 4%, rgba(255, 255, 255, 0.95) 0 90px, transparent 160px),
linear-gradient(180deg, #fff9fb 0%, #fcf3f4 52%, #fffefe 100%);
}
.scrollArea {
min-height: 0;
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
padding:
calc(var(--page-padding-y, 18px) + var(--app-safe-top, 0px))
calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px))
calc(104px + var(--app-safe-bottom, 0px))
var(--page-section-gap-lg, 22px)
calc(var(--page-padding-x, 20px) + var(--app-safe-left, 0px));
}
@@ -61,6 +71,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;
@@ -168,11 +201,12 @@
}
.ctaSlot {
position: fixed;
position: relative;
z-index: 40;
right: 50%;
bottom: 0;
width: min(100%, var(--app-max-width, 540px));
flex: 0 0 auto;
width: 100%;
max-height: min(50dvh, 360px);
overflow-y: auto;
padding:
12px
calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px))
@@ -186,5 +220,4 @@
);
box-shadow: 0 -12px 30px rgba(87, 35, 59, 0.1);
backdrop-filter: blur(16px);
transform: translateX(50%);
}
+3 -3
View File
@@ -5,12 +5,12 @@ import {
} from "@/lib/analytics/payment_analytics_context";
import {
getFirstPaymentSearchParam,
parsePaymentAutoRenew,
parsePaymentReturnSearchParams,
parseSubscriptionReturnTo,
type PaymentSearchParams,
} from "@/lib/payment/payment_search_params";
import {
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
@@ -30,8 +30,7 @@ export default async function SubscriptionPage({
getFirstPaymentSearchParam(query.type),
);
const sourceCharacterSlug =
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ??
DEFAULT_CHARACTER_SLUG;
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ?? null;
const initialPlanId = getFirstPaymentSearchParam(query.planId);
const commercialOfferId = getFirstPaymentSearchParam(
query.commercialOfferId,
@@ -57,6 +56,7 @@ export default async function SubscriptionPage({
analyticsContext={analyticsContext}
sourceCharacterSlug={sourceCharacterSlug}
initialPlanId={initialPlanId}
initialAutoRenew={parsePaymentAutoRenew(query.autoRenew)}
commercialOfferId={commercialOfferId}
resumeOrderId={resumeOrderId}
chatActionId={chatActionId}
@@ -105,30 +105,6 @@ export function findSelectedSubscriptionPlan(input: {
return plans.find((plan) => plan.id === input.selectedPlanId) ?? null;
}
export function requiresVipPlanConfirmation(input: {
planId: string;
vipPlans: readonly VipOfferPlanView[];
confirmedVipPlanIds: ReadonlySet<string>;
}): boolean {
return (
input.vipPlans.some((plan) => plan.id === input.planId) &&
!input.confirmedVipPlanIds.has(input.planId)
);
}
export function canCheckoutSubscriptionPlan(input: {
selectedPlanId: string;
vipPlans: readonly VipOfferPlanView[];
confirmedVipPlanIds: ReadonlySet<string>;
}): boolean {
if (!input.selectedPlanId) return false;
return !requiresVipPlanConfirmation({
planId: input.selectedPlanId,
vipPlans: input.vipPlans,
confirmedVipPlanIds: input.confirmedVipPlanIds,
});
}
export function getDefaultSubscriptionPlanId(input: {
canSubscribeVip: boolean;
selectedPlanId: string;
+58 -86
View File
@@ -9,12 +9,14 @@ 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 { useCharacterCatalog } from "@/providers/character-catalog-provider";
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 {
@@ -22,16 +24,13 @@ import {
SubscriptionCoinsOfferSection,
SubscriptionPaymentIssueDialog,
SubscriptionPaymentSuccessDialog,
SubscriptionRenewalConfirmationDialog,
SubscriptionVipOfferSection,
} from "./components";
import styles from "./components/subscription-screen.module.css";
import {
canCheckoutSubscriptionPlan,
findSelectedSubscriptionPlan,
getFirstRechargeOfferView,
getDefaultSubscriptionPlanId,
requiresVipPlanConfirmation,
toCoinsOfferPlanViews,
toVipOfferPlanViews,
type SubscriptionType,
@@ -46,8 +45,9 @@ export interface SubscriptionScreenProps {
returnTo?: SubscriptionReturnTo;
initialPayChannel?: PayChannel | null;
analyticsContext?: PaymentAnalyticsContext;
sourceCharacterSlug?: string;
sourceCharacterSlug?: string | null;
initialPlanId?: string | null;
initialAutoRenew?: boolean | null;
commercialOfferId?: string | null;
resumeOrderId?: string | null;
chatActionId?: string | null;
@@ -59,26 +59,29 @@ export function SubscriptionScreen({
returnTo = null,
initialPayChannel = null,
analyticsContext: providedAnalyticsContext,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
sourceCharacterSlug = null,
initialPlanId = null,
initialAutoRenew = null,
commercialOfferId = null,
resumeOrderId = null,
chatActionId = null,
}: SubscriptionScreenProps) {
const [confirmedVipPlanIds, setConfirmedVipPlanIds] = useState<
ReadonlySet<string>
>(() => new Set());
const [pendingVipPlanId, setPendingVipPlanId] = useState<string | null>(null);
const [showPaymentIssueDialog, setShowPaymentIssueDialog] = useState(false);
const [paymentIssueNotice, setPaymentIssueNotice] = useState<string | null>(
null,
);
const characterCatalog = useCharacterCatalog();
const userState = useUserState();
const sourceCharacter = characterCatalog.getBySlug(sourceCharacterSlug);
const hasHydrated = useHasHydrated();
const countryCode = userState.currentUser?.countryCode;
const paymentMethodConfig = getPaymentMethodConfig({
countryCode,
requestedPayChannel: initialPayChannel,
});
const renderedPaymentMethodConfig = hasHydrated
? paymentMethodConfig
: { ...paymentMethodConfig, showPaymentMethodSelector: false };
const {
payment,
paymentDispatch,
@@ -89,9 +92,10 @@ export function SubscriptionScreen({
subscriptionType,
shouldResumePendingOrder,
returnTo,
sourceCharacterSlug,
sourceCharacterSlug: sourceCharacter?.slug ?? null,
initialPayChannel: paymentMethodConfig.initialPayChannel,
initialPlanId,
initialAutoRenew,
commercialOfferId,
resumeOrderId,
chatActionId,
@@ -120,14 +124,14 @@ export function SubscriptionScreen({
});
}, [canSubscribeVip, directCoinsPlans, payment.plans, vipOfferPlans]);
usePaymentPlanAnalytics(displayedPlans, analyticsContext);
const firstRechargeOffer = useMemo(
const hasFirstRechargeOffer = useMemo(
() =>
getFirstRechargeOfferView({
isFirstRecharge: payment.isFirstRecharge,
subscriptionType,
vipPlans: vipOfferPlans,
coinPlans: directCoinsPlans,
}),
}) !== null,
[
directCoinsPlans,
payment.isFirstRecharge,
@@ -145,48 +149,20 @@ export function SubscriptionScreen({
const isPaymentBusy =
payment.isCreatingOrder ||
payment.isPollingOrder;
const selectedPlanIsVip = vipOfferPlans.some(
(plan) => plan.id === payment.selectedPlanId,
);
const selectedVipPlan =
vipOfferPlans.find((plan) => plan.id === payment.selectedPlanId) ?? null;
const selectedPlanIsVip = selectedVipPlan !== null;
const canActivate =
sourceCharacter !== null &&
selectedPlan !== null &&
canCheckoutSubscriptionPlan({
selectedPlanId: payment.selectedPlanId,
vipPlans: vipOfferPlans,
confirmedVipPlanIds,
}) &&
!isPaymentBusy;
const pendingVipPlan =
vipOfferPlans.find((plan) => plan.id === pendingVipPlanId) ?? null;
const handleSelectPlan = (planId: string) => {
const plan = payment.plans.find((item) => item.planId === planId);
if (plan) behaviorAnalytics.planClick(plan, analyticsContext);
if (
requiresVipPlanConfirmation({
planId,
vipPlans: vipOfferPlans,
confirmedVipPlanIds,
})
) {
setPendingVipPlanId(planId);
return;
}
paymentDispatch({ type: "PaymentPlanSelected", planId });
};
const handleConfirmVipPlan = () => {
if (!pendingVipPlanId) return;
const planId = pendingVipPlanId;
setConfirmedVipPlanIds((current) => {
const next = new Set(current);
next.add(planId);
return next;
});
paymentDispatch({ type: "PaymentPlanSelected", planId });
setPendingVipPlanId(null);
};
const handlePaymentMethodChange = (payChannel: PayChannel) => {
paymentDispatch({
type: "PaymentPayChannelChanged",
@@ -232,7 +208,8 @@ export function SubscriptionScreen({
return (
<MobileShell>
<div className={styles.shell}>
<header className={styles.header}>
<div className={styles.scrollArea}>
<header className={styles.header}>
<BackButton
className={styles.backSlot}
onClick={handleBackClick}
@@ -249,7 +226,7 @@ export function SubscriptionScreen({
>
Payment issue?
</button>
</header>
</header>
{paymentIssueNotice ? (
<p className={styles.paymentIssueNotice} role="status">
@@ -257,38 +234,32 @@ export function SubscriptionScreen({
</p>
) : null}
{firstRechargeOffer ? (
{chatActionId && sourceCharacter ? (
<section
className={styles.firstRechargeBanner}
aria-label="First recharge offer"
className={styles.characterSupportBanner}
aria-label={`Support ${sourceCharacter.displayName}`}
data-payment-guidance-character={sourceCharacter.id}
>
<span className={styles.firstRechargeBadge}>
{firstRechargeOffer.badgeText}
</span>
<div className={styles.firstRechargeCopy}>
<h2 className={styles.firstRechargeTitle}>
{firstRechargeOffer.title}
</h2>
<p className={styles.firstRechargeSubtitle}>
{firstRechargeOffer.subtitle}
</p>
{firstRechargeOffer.renewalNotice ? (
<p className={styles.firstRechargeRenewalNotice}>
{firstRechargeOffer.renewalNotice}
</p>
) : null}
</div>
<h2>Keep talking with {sourceCharacter.shortName}</h2>
<p>
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.
</p>
</section>
) : null}
{payment.commercialOffer && !firstRechargeOffer ? (
{payment.commercialOffer && !hasFirstRechargeOffer && sourceCharacter ? (
<section
className={styles.firstRechargeBanner}
aria-label="Elio private offer"
aria-label={`${sourceCharacter.shortName} private offer`}
>
<span className={styles.firstRechargeBadge}>Private offer</span>
<div className={styles.firstRechargeCopy}>
<h2 className={styles.firstRechargeTitle}>Elio got this price for you</h2>
<h2 className={styles.firstRechargeTitle}>
{sourceCharacter.shortName} got this price for you
</h2>
<p className={styles.firstRechargeSubtitle}>
{payment.commercialOffer.message ||
`Your ${payment.commercialOffer.discountPercent}% discount is already applied below.`}
@@ -312,32 +283,33 @@ export function SubscriptionScreen({
/>
</div>
<PaymentMethodSelector
config={paymentMethodConfig}
value={payment.payChannel}
disabled={isPaymentBusy}
caption="GCash by default in the Philippines"
className={styles.paymentMethodSlot}
analyticsKey="subscription.payment_method"
onChange={handlePaymentMethodChange}
/>
<PaymentMethodSelector
config={renderedPaymentMethodConfig}
value={payment.payChannel}
disabled={isPaymentBusy}
caption={
paymentMethodConfig.ezpayDisplayName === "QRIS"
? "QRIS by default in Indonesia"
: "GCash by default in the Philippines"
}
className={styles.paymentMethodSlot}
analyticsKey="subscription.payment_method"
onChange={handlePaymentMethodChange}
/>
</div>
<div className={styles.ctaSlot}>
<SubscriptionCheckoutButton
disabled={!canActivate}
subscriptionType={subscriptionType}
returnTo={returnTo}
sourceCharacterSlug={sourceCharacterSlug}
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
renewalPlan={selectedVipPlan}
renewalConsentSubjectId={userState.currentUser?.id || null}
countryCode={countryCode}
/>
</div>
<SubscriptionRenewalConfirmationDialog
open={pendingVipPlan !== null}
plan={pendingVipPlan}
onCancel={() => setPendingVipPlanId(null)}
onConfirm={handleConfirmVipPlan}
/>
{showPaymentIssueDialog ? (
<SubscriptionPaymentIssueDialog
open
@@ -352,7 +324,7 @@ export function SubscriptionScreen({
orderId={payment.currentOrderId}
payChannel={payment.payChannel}
countryCode={countryCode}
characterId={sourceCharacterSlug}
characterId={sourceCharacter?.id ?? ""}
onClose={() => setShowPaymentIssueDialog(false)}
onSubmitted={setPaymentIssueNotice}
/>
@@ -5,7 +5,10 @@ import { useRouter } from "next/navigation";
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
import type { PayChannel } from "@/data/schemas/payment";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
import {
DEFAULT_CHARACTER,
getCharacterBySlug,
} from "@/data/constants/character";
import {
consumeSubscriptionExitUrl,
resolveSubscriptionSuccessExitUrl,
@@ -19,8 +22,9 @@ export interface UseSubscriptionPaymentFlowInput {
shouldResumePendingOrder: boolean;
returnTo: SubscriptionReturnTo;
initialPayChannel: PayChannel;
sourceCharacterSlug?: string;
sourceCharacterSlug?: string | null;
initialPlanId?: string | null;
initialAutoRenew?: boolean | null;
commercialOfferId?: string | null;
resumeOrderId?: string | null;
chatActionId?: string | null;
@@ -31,18 +35,23 @@ export function useSubscriptionPaymentFlow({
shouldResumePendingOrder,
returnTo,
initialPayChannel,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
sourceCharacterSlug = null,
initialPlanId = null,
initialAutoRenew = null,
commercialOfferId = null,
resumeOrderId = null,
chatActionId = null,
}: UseSubscriptionPaymentFlowInput) {
const router = useRouter();
const supportCharacter = getCharacterBySlug(sourceCharacterSlug);
const exitCharacterSlug = supportCharacter?.slug ?? DEFAULT_CHARACTER.slug;
const { payment, paymentDispatch } = usePaymentRouteFlow({
initialPayChannel,
paymentType: subscriptionType,
shouldResumePendingOrder,
characterId: supportCharacter?.id,
initialPlanId,
initialAutoRenew,
commercialOfferId,
resumeOrderId,
chatActionId,
@@ -62,7 +71,7 @@ export function useSubscriptionPaymentFlow({
const handleBackClick = () => {
void (async () => {
router.replace(
await consumeSubscriptionExitUrl(returnTo, sourceCharacterSlug),
await consumeSubscriptionExitUrl(returnTo, exitCharacterSlug),
);
})();
};
@@ -74,7 +83,7 @@ export function useSubscriptionPaymentFlow({
router.replace(
await resolveSubscriptionSuccessExitUrl(
returnTo,
sourceCharacterSlug,
exitCharacterSlug,
),
);
})();
@@ -359,6 +359,8 @@ function makePaymentState(
selectedGiftCategory: giftCategory.category,
isFirstRecharge: false,
commercialOffer: null,
commercialOfferId: null,
chatActionId: null,
selectedPlanId: giftPlan.planId,
payChannel: "stripe",
autoRenew: false,
+24 -7
View File
@@ -2,6 +2,7 @@
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button";
import { useActiveCharacter } from "@/providers/character-provider";
import {
usePaymentDispatch,
@@ -19,6 +20,7 @@ export interface TipCheckoutButtonProps {
disabled?: boolean;
onOrder: () => void;
returnPath: string;
countryCode?: string | null;
}
export function TipCheckoutButton({
@@ -27,6 +29,7 @@ export function TipCheckoutButton({
disabled = false,
onOrder,
returnPath,
countryCode = null,
}: TipCheckoutButtonProps) {
const character = useActiveCharacter();
const payment = usePaymentState();
@@ -40,16 +43,17 @@ export function TipCheckoutButton({
giftCategory,
giftPlanId,
characterSlug: character.slug,
countryCode,
});
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
const label = payment.isPollingOrder
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
: payment.isPaid
? "Thanks for the coffee"
: "Order and Buy";
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
: payment.isPaid
? "Thanks for the coffee"
: "Order and Buy";
return (
<>
@@ -63,6 +67,19 @@ export function TipCheckoutButton({
>
{label}
</button>
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
<ExternalBrowserCheckoutButton
disabled={disabled || isLoading}
checkoutIntent={{
planId: payment.selectedPlanId,
autoRenew: false,
recipientCharacterId: character.id,
...(payment.chatActionId
? { chatActionId: payment.chatActionId }
: {}),
}}
/>
) : null}
{payment.errorMessage ? (
<p className={styles.checkoutError} role="alert">
{payment.errorMessage}
@@ -71,7 +88,7 @@ export function TipCheckoutButton({
<PaymentLaunchDialogs
currentOrderId={payment.currentOrderId}
externalCheckoutAnalyticsKey="tip.external_checkout"
ezpayDescription="Your coffee order is ready. Continue to GCash to finish the payment."
ezpayDescription="Your gift order is ready. Continue to the secure payment page to finish."
launch={paymentLaunch}
stripeReturnPath={returnPath}
/>
+8 -2
View File
@@ -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({
</section>
<PaymentMethodSelector
config={paymentMethodConfig}
config={renderedPaymentMethodConfig}
value={payment.payChannel}
density="compact"
disabled={isPaymentBusy}
@@ -300,6 +305,7 @@ export function TipScreen({
disabled={!canCreateOrder}
onOrder={handleOrder}
returnPath={returnPath}
countryCode={userState.currentUser?.countryCode}
/>
</div>
</>
+12 -4
View File
@@ -15,22 +15,30 @@ export interface TipSupportPromptState {
readonly isReady: boolean;
}
export function useTipSupportPrompt(): TipSupportPromptState {
export function useTipSupportPrompt(
characterName = "this character",
): TipSupportPromptState {
const [selection, setSelection] =
useState<ReturnType<typeof selectTipSupportPrompt> | 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,
};
}
+2 -2
View File
@@ -13,11 +13,11 @@ export class AppConstants {
/** 会员服务协议文档链接 */
static readonly membershipAgreementUrl =
"https://www.banlv-ai.com/cozsweet/membership-agreement.html";
"/legal/vip-membership-benefits.html";
/** 自动续费服务协议文档链接 */
static readonly autoRenewalAgreementUrl =
"https://www.banlv-ai.com/cozsweet/auto-renewal-agreement.html";
"/legal/automatic-renewal.html";
/** 开发环境 APP 标题 */
static readonly appTitleDevelopment = "Develop";
@@ -0,0 +1,75 @@
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();
});
it("delivers a persisted commercial thank-you as a live role message", () => {
const socket = new ChatWebSocket("ws://example.test/chat", "token");
const onCommercialMessage = vi.fn();
socket.onCommercialMessage = onCommercialMessage;
receive(socket, {
type: "commercial_message",
messageId: "message-1",
message: "Thank you for supporting me.",
characterId: "maya-tan",
createdAt: "2026-07-28T00:00:00.000Z",
});
expect(onCommercialMessage).toHaveBeenCalledWith({
messageId: "message-1",
message: "Thank you for supporting me.",
characterId: "maya-tan",
createdAt: "2026-07-28T00:00:00.000Z",
});
});
});
function receive(socket: ChatWebSocket, payload: unknown): void {
(
socket as unknown as { handleMessage: (data: string) => void }
).handleMessage(JSON.stringify(payload));
}
+44 -1
View File
@@ -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";
@@ -35,6 +37,13 @@ export interface PaywallStatusPayload {
reason: string | null;
}
export interface CommercialMessagePayload {
messageId: string;
message: string;
characterId: string;
createdAt: string;
}
export class ChatWebSocket {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -49,6 +58,8 @@ 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;
onCommercialMessage: ((message: CommercialMessagePayload) => void) | null = null;
onError: ((errorMessage: string) => void) | null = null;
constructor(
@@ -146,7 +157,11 @@ export class ChatWebSocket {
done?: boolean;
audioUrl?: string;
error?: string;
data?: {
messageId?: string;
message?: string;
characterId?: string;
createdAt?: string;
data?: Record<string, unknown> & {
image?: {
type?: string | null;
url?: string | null;
@@ -160,6 +175,10 @@ export class ChatWebSocket {
ruleId?: string;
kind?: string;
orderId?: string | null;
messageId?: string;
message?: string;
characterId?: string;
createdAt?: string;
};
};
try {
@@ -209,6 +228,30 @@ 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 "commercial_message": {
const commercialMessage = payload.data?.messageId
? payload.data
: payload;
if (
commercialMessage?.messageId &&
commercialMessage.message &&
commercialMessage.characterId &&
commercialMessage.createdAt
) {
this.onCommercialMessage?.({
messageId: commercialMessage.messageId,
message: commercialMessage.message,
characterId: commercialMessage.characterId,
createdAt: commercialMessage.createdAt,
});
}
break;
}
case "error":
this.onError?.(payload.error ?? "Unknown error");
break;
@@ -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();
});
});
@@ -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,
);
});
});
+34 -45
View File
@@ -1,20 +1,23 @@
import { ExceptionHandler } from "@/core/errors";
import type { IAuthRepository } from "@/data/repositories/interfaces";
import {
CheckoutHandoffConsumeRequestSchema,
CheckoutHandoffCreateRequestSchema,
type CheckoutHandoffCreateResponse,
type CheckoutIntent,
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 +267,40 @@ export class AuthRepository implements IAuthRepository {
});
}
/** 通过 Facebook PSID 直接登录或返回游客态。 */
async facebookPsidLogin(input: {
psid: string;
deviceId?: string;
bindToGuest?: boolean;
}): Promise<Result<LoginStatusT>> {
/** 消费一次性充值凭证并按后端返回的 canonical 账号建立正式会话。 */
async consumeTopUpHandoff(
handoffToken: string,
): Promise<Result<LoginStatusT>> {
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,
});
await this._saveLoginData(response, response.loginStatus);
return response.loginStatus;
});
}
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;
}
/** 创建订单前生成一次性外部浏览器支付交接链接。 */
async createCheckoutHandoff(
checkoutIntent: CheckoutIntent,
): Promise<Result<CheckoutHandoffCreateResponse>> {
return Result.wrap(() =>
this.api.createCheckoutHandoff(
CheckoutHandoffCreateRequestSchema.parse(checkoutIntent),
),
);
}
await this._saveLoginData(
LoginResponseSchema.parse({
token: data.token,
refreshToken: data.refreshToken,
user: data.user ?? { id: data.userId },
}),
LoginStatus.Facebook,
/** 消费支付交接凭证,保存后端签发的正式登录态并返回购买意图。 */
async consumeCheckoutHandoff(
handoffToken: string,
): Promise<Result<CheckoutIntent>> {
return Result.wrap(async () => {
const response = await this.api.consumeCheckoutHandoff(
CheckoutHandoffConsumeRequestSchema.parse({ handoffToken }),
);
return LoginStatus.Facebook;
await this._saveLoginData(response, response.loginStatus);
return response.checkoutIntent;
});
}
@@ -13,6 +13,8 @@
import type { Result } from "@/utils/result";
import type {
CheckoutHandoffCreateResponse,
CheckoutIntent,
GuestLoginResponse,
LoginStatus,
LoginResponse,
@@ -71,12 +73,18 @@ export interface IAuthRepository {
psid?: string;
}): Promise<Result<void>>;
/** 通过 Facebook PSID 直接登录或返回游客态。 */
facebookPsidLogin(input: {
psid: string;
deviceId?: string;
bindToGuest?: boolean;
}): Promise<Result<LoginStatus>>;
/** 消费一次性充值登录凭证并建立正式会话。 */
consumeTopUpHandoff(handoffToken: string): Promise<Result<LoginStatus>>;
/** 在创建订单前生成10分钟有效的外部浏览器支付链接。 */
createCheckoutHandoff(
checkoutIntent: CheckoutIntent,
): Promise<Result<CheckoutHandoffCreateResponse>>;
/** 消费单次支付交接凭证并保存正式登录态。 */
consumeCheckoutHandoff(
handoffToken: string,
): Promise<Result<CheckoutIntent>>;
/** 刷新 token:先读本地的 refresh token,空则返回错误。 */
refreshToken(): Promise<Result<RefreshTokenResponse>>;
@@ -13,7 +13,10 @@ import type { Result } from "@/utils/result";
export interface IPaymentRepository {
/** 获取套餐列表。 */
getPlans(commercialOfferId?: string): Promise<Result<PaymentPlansResponse>>;
getPlans(
commercialOfferId?: string,
supportCharacterId?: string,
): Promise<Result<PaymentPlansResponse>>;
/** 获取本地缓存套餐列表。 */
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
+11 -3
View File
@@ -24,10 +24,18 @@ export class PaymentRepository implements IPaymentRepository {
constructor(private readonly api: PaymentApi) {}
/** 获取套餐列表。 */
async getPlans(commercialOfferId?: string): Promise<Result<PaymentPlansResponse>> {
async getPlans(
commercialOfferId?: string,
supportCharacterId?: string,
): Promise<Result<PaymentPlansResponse>> {
return Result.wrap(async () => {
const response = await this.api.getPlans(commercialOfferId);
if (!commercialOfferId) await PaymentPlansStorage.setPlans(response);
const response = await this.api.getPlans(
commercialOfferId,
supportCharacterId,
);
if (!commercialOfferId && !supportCharacterId) {
await PaymentPlansStorage.setPlans(response);
}
return response;
});
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
CheckoutHandoffConsumeResponseSchema,
CheckoutHandoffCreateRequestSchema,
} from "../checkout_handoff";
describe("checkout handoff schemas", () => {
it("keeps only the minimal order-free purchase intent", () => {
expect(
CheckoutHandoffCreateRequestSchema.parse({
planId: "vip_monthly",
autoRenew: true,
recipientCharacterId: "elio",
price: 9.99,
}),
).toEqual({
planId: "vip_monthly",
autoRenew: true,
recipientCharacterId: "elio",
});
});
it("parses the formal session and checkout intent returned after consumption", () => {
const parsed = CheckoutHandoffConsumeResponseSchema.parse({
user: {
id: "00000000-0000-0000-0000-000000000123",
username: "checkout-user",
email: "checkout@example.com",
platform: "web",
},
token: "access-token",
refreshToken: "refresh-token",
loginStatus: "email",
checkoutIntent: {
planId: "vip_monthly",
autoRenew: true,
},
});
expect(parsed.checkoutIntent.planId).toBe("vip_monthly");
expect(parsed.loginStatus).toBe("email");
});
});
@@ -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({
+58
View File
@@ -0,0 +1,58 @@
import { z } from "zod";
import { stringOrEmpty } from "../nullable-defaults";
import { UserSchema } from "../user/user";
export const CheckoutIntentSchema = z
.object({
planId: z.string().min(1).max(120),
autoRenew: z.boolean(),
recipientCharacterId: z.string().min(1).max(80).optional(),
commercialOfferId: z.string().min(1).max(80).optional(),
chatActionId: z.uuid().optional(),
})
.readonly();
export const CheckoutHandoffCreateRequestSchema = CheckoutIntentSchema;
export const CheckoutHandoffCreateResponseSchema = z
.object({
externalUrl: z.url(),
expiresAt: z.string().min(1),
})
.readonly();
export const CheckoutHandoffConsumeRequestSchema = z
.object({
handoffToken: z.string().min(32).max(512),
})
.readonly();
export const CheckoutHandoffConsumeResponseSchema = z
.object({
user: UserSchema,
token: z.string().min(1),
refreshToken: stringOrEmpty,
loginStatus: z.enum([
"email",
"google",
"facebook",
"facebookMessenger",
]),
checkoutIntent: CheckoutIntentSchema,
})
.readonly();
export type CheckoutIntent = z.output<typeof CheckoutIntentSchema>;
export type CheckoutHandoffCreateRequest = z.output<
typeof CheckoutHandoffCreateRequestSchema
>;
export type CheckoutHandoffCreateResponse = z.output<
typeof CheckoutHandoffCreateResponseSchema
>;
export type CheckoutHandoffConsumeRequest = z.output<
typeof CheckoutHandoffConsumeRequestSchema
>;
export type CheckoutHandoffConsumeResponse = z.output<
typeof CheckoutHandoffConsumeResponseSchema
>;
+3 -2
View File
@@ -3,19 +3,20 @@
*/
export * from "./facebook_user_data";
export * from "./checkout_handoff";
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";
+1
View File
@@ -3,6 +3,7 @@ export const LoginStatus = {
NotLoggedIn: "notLoggedIn",
Guest: "guest",
Facebook: "facebook",
FacebookMessenger: "facebookMessenger",
Google: "google",
Email: "email",
} as const;
@@ -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;
+1 -1
View File
@@ -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";
@@ -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
>;
@@ -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;
+1 -1
View File
@@ -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";
@@ -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
>;
@@ -0,0 +1,90 @@
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("accepts the supportCharacter guidance scene", () => {
expect(
PaymentGuidanceSchema.parse({
...guidance,
scene: "supportCharacter",
ruleId: "payment_guidance_supportCharacter",
}).scene,
).toBe("supportCharacter");
});
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");
});
});
+10 -1
View File
@@ -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();
@@ -13,6 +13,42 @@ 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",
"supportCharacter",
"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<typeof PaymentGuidanceSchema>;
export const CommercialActionSchema = z
.object({
actionId: z.string().min(1),
@@ -42,6 +78,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();
@@ -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();
@@ -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();
@@ -195,9 +195,11 @@ describe("PaymentPlansResponse", () => {
it("parses one user-bound commercial offer and its discounted plan", () => {
const response = PaymentPlansResponseSchema.parse({
commercialOffer: {
enabled: true,
commercialOfferId: "offer-1",
supportCharacterId: "maya-tan",
commercialOffer: {
enabled: true,
commercialOfferId: "offer-1",
characterId: "maya-tan",
planId: "vip_annual",
discountPercent: 30,
pricePercent: 70,
@@ -225,6 +227,8 @@ describe("PaymentPlansResponse", () => {
});
expect(response.commercialOffer?.commercialOfferId).toBe("offer-1");
expect(response.supportCharacterId).toBe("maya-tan");
expect(response.commercialOffer?.characterId).toBe("maya-tan");
expect(response.plans[0]).toMatchObject({
planId: "vip_annual",
commercialOfferId: "offer-1",
@@ -24,6 +24,7 @@ export const CommercialOfferSummarySchema = z
.object({
enabled: booleanOrFalse,
commercialOfferId: z.string().min(1),
characterId: stringOrEmpty,
planId: z.string().min(1),
discountPercent: numberOrZero,
pricePercent: numberOrZero,
@@ -36,6 +37,7 @@ export const CommercialOfferSummarySchema = z
export const PaymentPlansResponseSchema = z
.object({
isFirstRecharge: booleanOrFalse,
supportCharacterId: stringOrEmpty,
firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema),
commercialOffer: schemaOrNull(CommercialOfferSummarySchema),
plans: arrayOrEmpty(PaymentPlanSchema),
@@ -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],
@@ -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();
@@ -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();
+3 -1
View File
@@ -5,7 +5,9 @@
"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" },
"checkoutHandoffCreate": { "method": "post", "path": "/api/auth/handoff/checkout" },
"checkoutHandoffConsume": { "method": "post", "path": "/api/auth/handoff/checkout/consume" },
"refresh": { "method": "post", "path": "/api/auth/refresh" },
"logout": { "method": "post", "path": "/api/auth/logout" },
"getCurrentUser": { "method": "get", "path": "/api/auth/me" },
+8 -2
View File
@@ -27,8 +27,14 @@ 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;
/** 创建一次性外部浏览器支付交接链接(创建订单前)。 */
static readonly checkoutHandoffCreate = apiContract.checkoutHandoffCreate.path;
/** 消费一次性外部浏览器支付交接凭证。 */
static readonly checkoutHandoffConsume = apiContract.checkoutHandoffConsume.path;
/** 刷新 Token */
static readonly refresh = apiContract.refresh.path;
+41 -11
View File
@@ -7,13 +7,16 @@
import { z } from "zod";
import {
CheckoutHandoffConsumeRequest,
CheckoutHandoffConsumeResponse,
CheckoutHandoffConsumeResponseSchema,
CheckoutHandoffCreateRequest,
CheckoutHandoffCreateResponse,
CheckoutHandoffCreateResponseSchema,
FacebookIdentityRequest,
FacebookIdentityResponse,
FacebookIdentityResponseSchema,
FacebookLoginRequest,
FacebookPsidLoginRequest,
FacebookPsidLoginResponse,
FacebookPsidLoginResponseSchema,
FbIdLoginRequest,
GoogleLoginRequest,
GuestLoginRequest,
@@ -26,6 +29,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 +97,41 @@ export class AuthApi {
return LoginResponseSchema.parse(unwrap(env) as Record<string, unknown>);
}
/**
* Facebook PSID
*/
async facebookPsidLogin(
body: FacebookPsidLoginRequest,
): Promise<FacebookPsidLoginResponse> {
/** 消费一次性充值登录凭证。当前正式登录 token 会由拦截器自动携带。 */
async consumeTopUpHandoff(
body: TopUpHandoffRequest,
): Promise<TopUpHandoffResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.facebookPsidLogin,
ApiPath.topUpHandoffConsume,
{ method: "POST", body },
);
return FacebookPsidLoginResponseSchema.parse(
return TopUpHandoffResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
/** 在创建支付订单前生成一次性外部浏览器交接链接。 */
async createCheckoutHandoff(
body: CheckoutHandoffCreateRequest,
): Promise<CheckoutHandoffCreateResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.checkoutHandoffCreate,
{ method: "POST", body },
);
return CheckoutHandoffCreateResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
/** 消费一次性支付交接凭证并恢复正式登录态与购买意图。 */
async consumeCheckoutHandoff(
body: CheckoutHandoffConsumeRequest,
): Promise<CheckoutHandoffConsumeResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.checkoutHandoffConsume,
{ method: "POST", body },
);
return CheckoutHandoffConsumeResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
+12 -2
View File
@@ -26,9 +26,19 @@ import { ApiEnvelope, unwrap } from "./response_helper";
export class PaymentApi {
/** 获取 VIP 与 Top-up 套餐列表。 */
async getPlans(commercialOfferId?: string): Promise<PaymentPlansResponse> {
async getPlans(
commercialOfferId?: string,
supportCharacterId?: string,
): Promise<PaymentPlansResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans, {
...(commercialOfferId ? { query: { commercialOfferId } } : {}),
...(commercialOfferId || supportCharacterId
? {
query: {
...(commercialOfferId ? { commercialOfferId } : {}),
...(supportCharacterId ? { supportCharacterId } : {}),
},
}
: {}),
});
return PaymentPlansResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
+2 -2
View File
@@ -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";

Some files were not shown because too many files have changed in this diff Show More