Compare commits

..

35 Commits

Author SHA1 Message Date
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
96 changed files with 2053 additions and 453 deletions
+3 -3
View File
@@ -12,7 +12,7 @@ export interface MockCoreApisOptions {
paidImageFlow?: boolean;
paidImageInsufficientCreditsFlow?: boolean;
paidVoiceInsufficientCreditsFlow?: boolean;
psidLoginFlow?: boolean;
topUpHandoffFlow?: boolean;
}
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
@@ -22,14 +22,14 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
paidImageFlow: options.paidImageFlow ?? false,
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
psidLoginFlow: options.psidLoginFlow ?? false,
topUpHandoffFlow: options.topUpHandoffFlow ?? false,
};
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
await registerAuthMocks(page, {
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
psidLoginFlow: chatOptions.psidLoginFlow,
topUpHandoffFlow: chatOptions.topUpHandoffFlow,
});
await registerCharacterMocks(page);
await registerUserMocks(page);
+5 -6
View File
@@ -1,22 +1,21 @@
import type { Page } from "@playwright/test";
import { apiEnvelope } from "../data/common";
import { emailLoginResponse, guestLoginResponse, psidLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth";
import { emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth";
export interface AuthMockState {
chatSendTokenRefreshFlow: boolean;
isChatSendTokenExpired: () => boolean;
psidLoginFlow: boolean;
topUpHandoffFlow: boolean;
}
export async function registerAuthMocks(page: Page, state: AuthMockState) {
await page.route("**/api/auth/login/facebook/psid", async (route) => {
if (!state.psidLoginFlow) {
await page.route("**/api/auth/handoff/topup/consume", async (route) => {
if (!state.topUpHandoffFlow) {
await route.continue();
return;
}
await route.fulfill({ json: apiEnvelope(psidLoginResponse) });
await route.fulfill({ json: apiEnvelope(topUpHandoffResponse) });
});
await page.route("**/api/auth/guest", async (route) => {
+12 -10
View File
@@ -50,14 +50,16 @@ export const refreshedEmailLoginResponse = {
refreshToken: "e2e-refreshed-email-refresh-token",
};
export const psidLoginResponse = {
token: "e2e-psid-guest-token",
refreshToken: "",
matchedBy: "psid",
fbAsid: "",
fbPsid: "e2e-facebook-psid",
hasCompleteFacebookIdentity: false,
isGuest: true,
user: e2eUser,
userId: e2eUser.id,
export const topUpHandoffResponse = {
token: "e2e-messenger-token",
refreshToken: "e2e-messenger-refresh-token",
loginStatus: "facebookMessenger",
merged: false,
user: {
...e2eEmailUser,
id: "user_e2e_messenger",
username: "E2E Messenger User",
email: "messenger_hash@messenger.cozsweet.invalid",
loginProvider: "facebook_messenger",
},
};
@@ -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,333 @@
import { expect, test, type Page } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { apiEnvelope } from "@e2e/fixtures/data/common";
import { e2eEmailUser } from "@e2e/fixtures/data/user";
import {
clearBrowserState,
seedEmailSession,
} from "@e2e/fixtures/test-helpers";
const idrPlans = {
plans: [
{
planId: "vip_monthly",
planName: "Monthly",
orderType: "vip_monthly",
amountCents: 19_590_000,
originalAmountCents: 19_590_000,
dailyPriceCents: 653_000,
currency: "IDR",
vipDays: 30,
dolAmount: null,
creditBalance: 3_000,
},
{
planId: "dol_1000",
planName: "1,000 Credits",
orderType: "dol",
amountCents: 8_890_000,
originalAmountCents: null,
dailyPriceCents: null,
currency: "IDR",
vipDays: null,
dolAmount: 1_000,
creditBalance: 1_000,
},
],
};
const idrGiftCatalog = {
characterId: "elio",
categories: [
{
category: "coffee",
name: "Coffee",
productCount: 1,
imageUrl: null,
},
],
plans: [
{
planId: "tip_coffee_usd_4_99",
planName: "Velvet Espresso",
orderType: "tip",
tipType: "coffee_small",
category: "coffee",
characterId: "elio",
description: "Buy Elio a Velvet Espresso",
imageUrl: null,
amountCents: 8_929_900,
currency: "IDR",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
},
],
};
async function registerIndonesiaPaymentMocks(page: Page) {
const orderStatuses = new Map<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",
});
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(formattedAmount);
await expect(dialog).toContainText("Waiting for payment");
await expect(
dialog.getByRole("img", { name: "QRIS payment QR code" }),
).toBeVisible();
return `order_qris_${planId}`;
}
async function expectCheckoutButtonLayout(page: Page) {
const viewport = page.viewportSize();
const checkoutBox = await page
.getByRole("button", { name: "Pay and Top Up" })
.boundingBox();
const stripeBox = await page.getByRole("button", { name: "Stripe" }).boundingBox();
expect(viewport).not.toBeNull();
expect(checkoutBox).not.toBeNull();
expect(stripeBox).not.toBeNull();
if (!viewport || !checkoutBox || !stripeBox) return;
expect(checkoutBox.y + checkoutBox.height).toBeLessThanOrEqual(viewport.height);
expect(stripeBox.y + stripeBox.height).toBeLessThanOrEqual(checkoutBox.y);
}
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
});
test("Indonesia VIP defaults to QRIS and completes after status polling", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=vip");
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
"aria-pressed",
"true",
);
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
await expect(checkoutButton).toBeDisabled();
await expectCheckoutButtonLayout(page);
await page.getByRole("button", { name: /Monthly, 195900 IDR/i }).click();
const renewalDialog = page.getByRole("dialog", {
name: "Automatic Renewal Confirmation",
});
await expect(renewalDialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(0);
await renewalDialog.getByRole("button", { name: "Confirm" }).click();
await expect(renewalDialog).toBeHidden();
await expect(checkoutButton).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(0);
const orderId = await expectQrisOrder(
page,
/Pay and Top Up/i,
"vip_monthly",
/Rp\s*195[.,]900/,
);
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
const success = page.getByRole("alertdialog", { name: "Payment successful" });
await expect(success).toBeVisible({ timeout: 10_000 });
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toBeHidden();
});
test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup");
await expectCheckoutButtonLayout(page);
const orderId = await expectQrisOrder(
page,
/Pay and Top Up/i,
"dol_1000",
/Rp\s*88[.,]900/,
);
payment.markPaid(orderId);
await expect(
page.getByRole("alertdialog", { name: "Payment successful" }),
).toBeVisible({ timeout: 10_000 });
});
test("payment issue submits Other to the feedback API without creating an order", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
let feedbackBody = "";
let idempotencyKey = "";
await page.route("**/api/feedback", async (route) => {
feedbackBody = route.request().postData() ?? "";
idempotencyKey = route.request().headers()["idempotency-key"] ?? "";
await route.fulfill({
json: apiEnvelope({ feedbackId: "feedback-payment-1", duplicate: false }),
});
});
await page.goto("/subscription?type=topup&character=elio");
await page.getByRole("button", { name: "Payment issue?" }).click();
const dialog = page.getByRole("dialog", {
name: "What problem did you encounter?",
});
await expect(dialog).toBeVisible();
await dialog.getByRole("radio", { name: "Other" }).check();
await dialog
.getByLabel("Please describe the issue")
.fill("QRIS did not open correctly.");
await dialog.getByRole("button", { name: "Submit" }).click();
await expect(page.getByRole("status")).toHaveText(
"Thanks. Your payment issue has been submitted.",
);
await expect(dialog).toBeHidden();
expect(payment.getCreateOrderCount()).toBe(0);
expect(idempotencyKey).toMatch(/^payment_feedback_[A-Za-z0-9_-]+$/);
expect(feedbackBody).toContain('name="category"');
expect(feedbackBody).toContain("payment");
expect(feedbackBody).toContain(
"Payment issue: Other\r\nDetails: QRIS did not open correctly.",
);
expect(feedbackBody).toContain('name="context"');
expect(feedbackBody).toContain('"paymentIssueReason":"other"');
expect(feedbackBody).toContain('"characterId":"elio"');
});
test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/characters/elio/tip");
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
const orderId = await expectQrisOrder(
page,
/Order and Buy/i,
"tip_coffee_usd_4_99",
/Rp\s*89[.,]299/,
);
payment.markPaid(orderId);
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Send another gift" })).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toBeHidden();
});
+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: {}
@@ -7,10 +7,15 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
const hiddenLaunch = {
handleEzpayCancel: vi.fn(),
handleEzpayConfirm: vi.fn(),
handleQrisClose: vi.fn(),
handleStripeClose: vi.fn(),
handleStripeConfirmed: vi.fn(),
isConfirmingEzpay: false,
qrisErrorMessage: null,
qrisPayment: null,
qrisStatus: null,
shouldShowEzpayConfirmDialog: false,
shouldShowQrisDialog: false,
shouldShowStripeDialog: false,
stripeClientSecret: null,
};
@@ -70,7 +75,7 @@ describe("PaymentLaunchDialogs", () => {
expect(dialog).not.toBeNull();
expect(container.contains(dialog)).toBe(false);
expect(dialog?.parentElement?.parentElement).toBe(document.body);
expect(dialog?.textContent).toContain("Continue to GCash?");
expect(dialog?.textContent).toContain("Continue to payment?");
expect(dialog?.textContent).toContain("Your coffee order is ready.");
expect(dialog?.textContent).toContain("Order No. order-123");
expect(
@@ -81,4 +86,68 @@ describe("PaymentLaunchDialogs", () => {
expect(dialog?.textContent).toContain("Opening...");
expect(document.body.style.overflow).toBe("hidden");
});
it("portals an accessible QRIS dialog with display cents and order status", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-id-qris"
externalCheckoutAnalyticsKey="tip.external_checkout"
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
qrisPayment: {
qrData: "00020101021226670016COM.NOBUBANK.WWW",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
},
qrisStatus: "pending",
shouldShowQrisDialog: 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("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,
qrisErrorMessage: "Payment failed or was cancelled.",
qrisPayment: {
qrData: "",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
},
qrisStatus: "failed",
shouldShowQrisDialog: 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
@@ -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,10 +13,15 @@ type PaymentLaunchDialogFlow = Pick<
PaymentLaunchFlow,
| "handleEzpayCancel"
| "handleEzpayConfirm"
| "handleQrisClose"
| "handleStripeClose"
| "handleStripeConfirmed"
| "isConfirmingEzpay"
| "qrisErrorMessage"
| "qrisPayment"
| "qrisStatus"
| "shouldShowEzpayConfirmDialog"
| "shouldShowQrisDialog"
| "shouldShowStripeDialog"
| "stripeClientSecret"
>;
@@ -47,6 +53,14 @@ export function PaymentLaunchDialogs({
onConfirm={launch.handleEzpayConfirm}
/>
) : null}
{launch.shouldShowQrisDialog && launch.qrisPayment ? (
<QrisPaymentDialog
payment={launch.qrisPayment}
status={launch.qrisStatus}
errorMessage={launch.qrisErrorMessage}
onClose={launch.handleQrisClose}
/>
) : null}
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
<LazyStripePaymentDialog
clientSecret={launch.stripeClientSecret}
@@ -59,6 +73,103 @@ export function PaymentLaunchDialogs({
);
}
interface QrisPaymentDialogProps {
errorMessage: string | null;
onClose: () => void;
payment: NonNullable<PaymentLaunchFlow["qrisPayment"]>;
status: PaymentLaunchFlow["qrisStatus"];
}
function formatQrisAmount(amountCents: number, currency: string): string {
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
try {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: normalizedCurrency,
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
}).format(amountCents / 100);
} catch {
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
}
}
function qrisStatusMessage(
status: PaymentLaunchFlow["qrisStatus"],
errorMessage: string | null,
): string {
if (status === "failed") return errorMessage || "Payment failed. Please try again.";
if (status === "expired") return errorMessage || "This QRIS order has expired.";
return "Waiting for payment";
}
function QrisPaymentDialog({
errorMessage,
onClose,
payment,
status,
}: QrisPaymentDialogProps) {
const titleId = useId();
const statusMessage = qrisStatusMessage(status, errorMessage);
return (
<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}>
Scan to pay with QRIS
</h2>
<p className={styles.content}>
Open a QRIS-compatible banking or wallet app and scan this code.
</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="QRIS payment QR code"
size={256}
level="M"
marginSize={2}
className="h-auto w-full max-w-64"
/>
) : (
<p className={styles.error} role="alert">
QRIS data is unavailable. Please close this dialog and try again.
</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">
{formatQrisAmount(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>
);
}
interface EzpayRedirectConfirmDialogProps {
description: ReactNode;
externalCheckoutAnalyticsKey: string;
@@ -91,7 +202,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
+117 -6
View File
@@ -7,6 +7,7 @@ import {
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
resolveEzpayLaunchTarget,
} from "@/lib/payment/payment_launch";
import { behaviorAnalytics } from "@/lib/analytics";
import type {
@@ -46,15 +47,55 @@ export interface PaymentLaunchFlow {
ezpayPaymentUrl: string | null;
handleEzpayCancel: () => void;
handleEzpayConfirm: () => void;
handleQrisClose: () => void;
handleStripeClose: () => void;
handleStripeConfirmed: () => void;
isConfirmingEzpay: boolean;
qrisErrorMessage: string | null;
qrisPayment: QrisPaymentDetails | null;
qrisStatus: PaymentContextState["orderStatus"];
resetLaunchState: () => void;
shouldShowEzpayConfirmDialog: boolean;
shouldShowQrisDialog: boolean;
shouldShowStripeDialog: boolean;
stripeClientSecret: string | null;
}
export interface QrisPaymentDetails {
amountCents: number;
currency: string;
orderId: string;
qrData: string;
}
function paymentParamString(
payParams: Record<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(
payment: PaymentContextState,
checkoutUrl: string,
@@ -118,11 +159,39 @@ export function usePaymentLaunchFlow({
string | null
>(null);
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState<string | null>(
null,
);
const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams)
: null;
const ezpayPaymentUrl = payment.payParams
? getPaymentUrl(payment.payParams)
const ezpayLaunchTarget =
payment.payParams && isEzpayPayment(payment.payParams)
? resolveEzpayLaunchTarget(payment.payParams)
: null;
const ezpayPaymentUrl =
ezpayLaunchTarget?.kind === "url"
? ezpayLaunchTarget.paymentUrl
: null;
const selectedPlan = payment.plans.find(
(item) => item.planId === payment.selectedPlanId,
);
const qrisPayment: QrisPaymentDetails | null =
payment.payParams &&
payment.currentOrderId &&
ezpayLaunchTarget?.kind === "qr"
? {
amountCents:
paymentParamAmountCents(payment.payParams) ??
selectedPlan?.amountCents ??
0,
currency:
paymentParamString(payment.payParams, "currency") ??
selectedPlan?.currency ??
"IDR",
orderId: payment.currentOrderId,
qrData: ezpayLaunchTarget.qrData,
}
: null;
useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
@@ -136,11 +205,33 @@ export function usePaymentLaunchFlow({
return;
}
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
const isEzpay = isEzpayPayment(payment.payParams);
if (isEzpay) {
const target = resolveEzpayLaunchTarget(payment.payParams);
if (target.kind === "error") {
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage: target.errorMessage,
});
return;
}
if (!AppEnvUtil.isProduction() && isEzpay) {
if (target.kind === "qr") {
if (!payment.currentOrderId) {
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage: "Missing order id before showing QRIS.",
});
return;
}
trackPaymentCheckoutOpened(payment, "qris_embedded");
return;
}
const paymentUrl = target.paymentUrl;
if (!AppEnvUtil.isProduction()) {
log.debug(`[${logScope}] ezpay confirmation required`, {
orderId: payment.currentOrderId,
paymentUrl,
@@ -149,7 +240,6 @@ export function usePaymentLaunchFlow({
return;
}
if (isEzpay) {
void launchEzpayRedirect({
orderId: payment.currentOrderId,
paymentUrl,
@@ -167,6 +257,8 @@ export function usePaymentLaunchFlow({
return;
}
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
try {
window.location.href = paymentUrl;
trackPaymentCheckoutOpened(payment, paymentUrl);
@@ -213,10 +305,16 @@ export function usePaymentLaunchFlow({
payParams: payment.payParams,
paymentUrl: ezpayPaymentUrl,
});
const shouldShowQrisDialog = Boolean(
qrisPayment &&
qrisPayment.orderId !== hiddenQrisOrderId &&
!payment.isPaid,
);
function resetLaunchState(): void {
setIsConfirmingEzpay(false);
setHiddenStripeClientSecret(null);
setHiddenQrisOrderId(null);
}
function handleStripeClose(): void {
@@ -263,15 +361,28 @@ export function usePaymentLaunchFlow({
paymentDispatch({ type: "PaymentReset" });
}
function handleQrisClose(): void {
log.debug(`[${logScope}] qris dialog closed`, {
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
subscriptionType,
});
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
}
return {
ezpayPaymentUrl,
handleEzpayCancel,
handleEzpayConfirm,
handleQrisClose,
handleStripeClose,
handleStripeConfirmed,
isConfirmingEzpay,
qrisErrorMessage: payment.errorMessage,
qrisPayment,
qrisStatus: payment.orderStatus,
resetLaunchState,
shouldShowEzpayConfirmDialog,
shouldShowQrisDialog,
shouldShowStripeDialog,
stripeClientSecret,
};
+2
View File
@@ -128,6 +128,7 @@ export function ChatScreen() {
const messageLimitBanner = useChatMessageLimitBanner({
upgradePromptVisible: state.upgradePromptVisible,
upgradeReason: state.upgradeReason,
paymentGuidance: state.paymentGuidance,
});
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
historyLoaded: state.historyLoaded,
@@ -464,6 +465,7 @@ export function ChatScreen() {
title={messageLimitBanner.title}
description={messageLimitBanner.description}
ctaLabel={messageLimitBanner.ctaLabel}
guidance={messageLimitBanner.guidance}
onUnlock={messageLimitBanner.unlock}
/>
) : (
@@ -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>
);
}
@@ -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>
+1
View File
@@ -22,6 +22,7 @@ export * from "./insufficient-credits-dialog";
export * from "./message-avatar";
export * from "./message-bubble";
export * from "./message-content";
export * from "./payment-guidance-card";
export * from "./private-message-card";
export * from "./pwa-install-dialog";
export * from "./pwa-install-overlay";
@@ -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,105 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ArrowRight, CircleDollarSign, X } from "lucide-react";
import type { PaymentGuidance } from "@/data/schemas/chat";
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;
}
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: "insufficient_credits",
},
});
}}
>
<span>{guidance.ctaLabel}</span>
<ArrowRight size={17} aria-hidden="true" />
</button>
) : null}
</aside>
);
}
@@ -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,
};
@@ -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,
@@ -21,6 +21,8 @@ import {
} from "@/data/constants/character";
import { Logger } from "@/utils/logger";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff";
import { Result } from "@/utils/result";
import {
isFavoriteEntryRequest,
persistFavoriteEntryIntent,
@@ -38,6 +40,7 @@ interface ExternalEntryPersistProps {
mode: string | null;
promotionType: string | null;
favorite: string | null;
handoffToken: string | null;
}
export default function ExternalEntryPersist({
@@ -50,13 +53,16 @@ export default function ExternalEntryPersist({
mode,
promotionType,
favorite,
handoffToken,
}: ExternalEntryPersistProps) {
const router = useRouter();
const authState = useAuthState();
const authDispatch = useAuthDispatch();
const [hasPersisted, setHasPersisted] = useState(false);
const [handoffError, setHandoffError] = useState<string | null>(null);
const [handoffCompleted, setHandoffCompleted] = useState(false);
const hasNavigatedRef = useRef(false);
const hasReinitializedForPsidRef = useRef(false);
const handoffStartedRef = useRef(false);
const targetRoute = resolveExternalEntryTarget({ target });
const destination = resolveExternalEntryDestination({ target, character });
const characterId =
@@ -65,6 +71,12 @@ export default function ExternalEntryPersist({
mode,
promotionType,
});
const isTopUpHandoff = targetRoute === ROUTES.subscription;
const displayedHandoffError =
handoffError ??
(isTopUpHandoff && !hasValue(handoffToken)
? "This top-up link is invalid. Please request a new link in Messenger."
: null);
useEffect(() => {
let cancelled = false;
@@ -116,12 +128,39 @@ export default function ExternalEntryPersist({
useEffect(() => {
if (hasNavigatedRef.current || !hasPersisted) return;
// AuthStatusChecker can initialize before this entry has persisted PSID.
// Re-run the check so PSID direct login is not skipped by that race.
if (hasValue(psid) && !hasReinitializedForPsidRef.current) {
if (isTopUpHandoff) {
if (!authState.hasInitialized || authState.isLoading) return;
hasReinitializedForPsidRef.current = true;
if (handoffCompleted) {
if (
authState.loginStatus === "notLoggedIn" ||
authState.loginStatus === "guest"
) {
return;
}
hasNavigatedRef.current = true;
router.replace(destination);
return;
}
if (handoffStartedRef.current) return;
if (!hasValue(handoffToken)) return;
handoffStartedRef.current = true;
void (async () => {
const result = await consumeTopUpHandoff(handoffToken);
if (Result.isErr(result)) {
log.warn("[ExternalEntryPersist] top-up handoff failed", result.error);
window.history.replaceState(
window.history.state,
"",
"/external-entry?target=topup",
);
setHandoffError(
"This top-up link is invalid or has expired. Please request a new link in Messenger.",
);
return;
}
setHandoffCompleted(true);
authDispatch({ type: "AuthInit" });
})();
return;
}
@@ -152,6 +191,9 @@ export default function ExternalEntryPersist({
authState.loginStatus,
destination,
hasPersisted,
handoffCompleted,
handoffToken,
isTopUpHandoff,
psid,
router,
]);
@@ -161,11 +203,26 @@ export default function ExternalEntryPersist({
className="flex flex-1 items-center justify-center"
style={{ minHeight: "100dvh" }}
>
{displayedHandoffError ? (
<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>
);
}
+2
View File
@@ -7,6 +7,7 @@
* `/external-entry?target=chat&character=maya`
* `/external-entry?target=tip`
* `/external-entry?target=private-zone&character=nayeli`
* `/external-entry?target=topup&handoffToken=<opaque-token>`
*
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
@@ -36,6 +37,7 @@ export default async function ExternalEntryPage({
mode={pickParam(params.mode)}
promotionType={pickParam(params.promotion_type)}
favorite={pickParam(params.favorite)}
handoffToken={pickParam(params.handoffToken)}
/>
);
}
@@ -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,
@@ -256,6 +256,25 @@ describe("SubscriptionScreen payment selection flow", () => {
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
);
});
it("keeps payment guidance bound to the source character", () => {
act(() =>
root.render(
<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");
});
});
function clickButton(root: ParentNode, label: string): void {
@@ -86,7 +86,7 @@ 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}
/>
</>
@@ -61,6 +61,29 @@
box-shadow: 0 12px 30px rgba(22, 101, 52, 0.12);
}
.characterSupportBanner {
margin-top: var(--page-section-gap, 14px);
padding: 14px 16px;
border: 1px solid rgba(246, 87, 160, 0.2);
border-radius: var(--responsive-card-radius-sm, 22px);
background: rgba(255, 255, 255, 0.9);
color: #4a3340;
box-shadow: 0 10px 24px rgba(246, 87, 160, 0.1);
}
.characterSupportBanner h2 {
margin: 0;
color: #24151d;
font-size: var(--responsive-card-title, 17px);
line-height: 1.25;
}
.characterSupportBanner p {
margin: 6px 0 0;
font-size: var(--responsive-caption, 14px);
line-height: 1.45;
}
.firstRechargeBanner {
display: flex;
align-items: center;
+38 -5
View File
@@ -8,13 +8,18 @@ import { usePaymentMethodSelection } from "@/app/_hooks/use-payment-method-selec
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
import type { PayChannel } from "@/data/schemas/payment";
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
import {
DEFAULT_CHARACTER,
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
import {
behaviorAnalytics,
getDefaultPaymentAnalyticsContext,
type PaymentAnalyticsContext,
} from "@/lib/analytics";
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
import { useHasHydrated } from "@/hooks/use-has-hydrated";
import { useUserState } from "@/stores/user/user-context";
import {
@@ -74,11 +79,17 @@ export function SubscriptionScreen({
null,
);
const userState = useUserState();
const sourceCharacter =
getCharacterBySlug(sourceCharacterSlug) ?? DEFAULT_CHARACTER;
const hasHydrated = useHasHydrated();
const countryCode = userState.currentUser?.countryCode;
const paymentMethodConfig = getPaymentMethodConfig({
countryCode,
requestedPayChannel: initialPayChannel,
});
const renderedPaymentMethodConfig = hasHydrated
? paymentMethodConfig
: { ...paymentMethodConfig, showPaymentMethodSelector: false };
const {
payment,
paymentDispatch,
@@ -257,6 +268,22 @@ export function SubscriptionScreen({
</p>
) : null}
{chatActionId ? (
<section
className={styles.characterSupportBanner}
aria-label={`Support ${sourceCharacter.displayName}`}
data-payment-guidance-character={sourceCharacter.id}
>
<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}
{firstRechargeOffer ? (
<section
className={styles.firstRechargeBanner}
@@ -284,11 +311,13 @@ export function SubscriptionScreen({
{payment.commercialOffer && !firstRechargeOffer ? (
<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.`}
@@ -313,10 +342,14 @@ export function SubscriptionScreen({
</div>
<PaymentMethodSelector
config={paymentMethodConfig}
config={renderedPaymentMethodConfig}
value={payment.payChannel}
disabled={isPaymentBusy}
caption="GCash by default in the Philippines"
caption={
paymentMethodConfig.ezpayDisplayName === "QRIS"
? "QRIS by default in Indonesia"
: "GCash by default in the Philippines"
}
className={styles.paymentMethodSlot}
analyticsKey="subscription.payment_method"
onChange={handlePaymentMethodChange}
+1 -1
View File
@@ -71,7 +71,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}
/>
+7 -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}
+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,
};
}
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import { ChatWebSocket } from "../chat-websocket";
describe("ChatWebSocket payment guidance", () => {
it("parses the same role-bound decision emitted by HTTP", () => {
const socket = new ChatWebSocket("ws://example.test/chat", "token");
const onPaymentGuidance = vi.fn();
socket.onPaymentGuidance = onPaymentGuidance;
receive(socket, {
type: "payment_guidance",
data: {
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
characterId: "maya-tan",
characterName: "Maya Tan",
scene: "voiceLocked",
mode: "guide",
title: "Keep talking with Maya",
copy: "Hey love, I want to keep talking with you.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"],
target: "subscription",
ruleId: "payment_guidance_voiceLocked",
},
});
expect(onPaymentGuidance).toHaveBeenCalledWith(
expect.objectContaining({
characterId: "maya-tan",
scene: "voiceLocked",
}),
);
});
it("ignores malformed guidance instead of rendering the wrong role", () => {
const socket = new ChatWebSocket("ws://example.test/chat", "token");
const onPaymentGuidance = vi.fn();
socket.onPaymentGuidance = onPaymentGuidance;
receive(socket, {
type: "payment_guidance",
data: { characterId: "elio" },
});
expect(onPaymentGuidance).not.toHaveBeenCalled();
});
});
function receive(socket: ChatWebSocket, payload: unknown): void {
(
socket as unknown as { handleMessage: (data: string) => void }
).handleMessage(JSON.stringify(payload));
}
+9 -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";
@@ -49,6 +51,7 @@ export class ChatWebSocket {
onPaywallStatus: ((payload: PaywallStatusPayload) => void) | null = null;
onCommercialAction: ((action: CommercialAction) => void) | null = null;
onChatAction: ((action: ChatAction) => void) | null = null;
onPaymentGuidance: ((guidance: PaymentGuidance) => void) | null = null;
onError: ((errorMessage: string) => void) | null = null;
constructor(
@@ -146,7 +149,7 @@ export class ChatWebSocket {
done?: boolean;
audioUrl?: string;
error?: string;
data?: {
data?: Record<string, unknown> & {
image?: {
type?: string | null;
url?: string | null;
@@ -209,6 +212,11 @@ export class ChatWebSocket {
if (action.success) this.onChatAction?.(action.data);
break;
}
case "payment_guidance": {
const guidance = PaymentGuidanceSchema.safeParse(payload.data);
if (guidance.success) this.onPaymentGuidance?.(guidance.data);
break;
}
case "error":
this.onError?.(payload.error ?? "Unknown error");
break;
@@ -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,
);
});
});
+9 -48
View File
@@ -3,18 +3,17 @@ import type { IAuthRepository } from "@/data/repositories/interfaces";
import {
FacebookIdentityRequestSchema,
FacebookLoginRequestSchema,
FacebookPsidLoginRequestSchema,
FbIdLoginRequestSchema,
GoogleLoginRequestSchema,
GuestLoginRequestSchema,
GuestLoginResponse,
LoginRequestSchema,
LoginResponse,
LoginResponseSchema,
LoginStatus,
RefreshTokenRequestSchema,
RefreshTokenResponse,
RegisterRequestSchema,
TopUpHandoffRequestSchema,
type LoginStatus as LoginStatusT,
} from "@/data/schemas/auth";
import { User } from "@/data/schemas/user";
@@ -264,54 +263,16 @@ export class AuthRepository implements IAuthRepository {
});
}
/** 通过 Facebook PSID 直接登录或返回游客态。 */
async facebookPsidLogin(input: {
psid: string;
deviceId?: string;
bindToGuest?: boolean;
}): Promise<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,
});
if (data.isGuest || !data.hasCompleteFacebookIdentity) {
const tokenResult = await this.storage.setGuestToken(data.token);
if (Result.isErr(tokenResult)) throw tokenResult.error;
if (input.deviceId) {
const deviceResult = await this.storage.setDeviceId(input.deviceId);
if (Result.isErr(deviceResult)) throw deviceResult.error;
}
const userId = data.userId || data.user?.id || "";
if (userId) {
const userIdResult = await this.userStorage.setUserId(userId);
if (Result.isErr(userIdResult)) throw userIdResult.error;
}
const providerResult = await this.storage.setLoginProvider(
LoginStatus.Guest,
);
if (Result.isErr(providerResult)) throw providerResult.error;
return LoginStatus.Guest;
}
await this._saveLoginData(
LoginResponseSchema.parse({
token: data.token,
refreshToken: data.refreshToken,
user: data.user ?? { id: data.userId },
}),
LoginStatus.Facebook,
);
return LoginStatus.Facebook;
await this._saveLoginData(response, response.loginStatus);
return response.loginStatus;
});
}
@@ -71,12 +71,8 @@ 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>>;
/** 刷新 token:先读本地的 refresh token,空则返回错误。 */
refreshToken(): Promise<Result<RefreshTokenResponse>>;
@@ -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({
+2 -2
View File
@@ -6,16 +6,16 @@ export * from "./facebook_user_data";
export * from "./login_status";
export * from "./request/facebook_identity_request";
export * from "./request/facebook_login_request";
export * from "./request/facebook_psid_login_request";
export * from "./request/fb_id_login_request";
export * from "./request/google_login_request";
export * from "./request/guest_login_request";
export * from "./request/login_request";
export * from "./request/refresh_token_request";
export * from "./request/register_request";
export * from "./request/top_up_handoff_request";
export * from "./response/facebook_identity_response";
export * from "./response/facebook_psid_login_response";
export * from "./response/guest_login_response";
export * from "./response/login_response";
export * from "./response/logout_response";
export * from "./response/refresh_token_response";
export * from "./response/top_up_handoff_response";
+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,80 @@
import { describe, expect, it } from "vitest";
import {
ChatLockDetailSchema,
ChatSendResponseSchema,
PaymentGuidanceSchema,
UnlockHistoryResponseSchema,
UnlockPrivateResponseSchema,
} from "@/data/schemas/chat";
const guidance = {
guidanceId: "guidance-1",
characterId: "maya-tan",
characterName: "Maya Tan",
scene: "voiceLocked",
mode: "guide",
title: "Keep talking with Maya",
copy: "Hey love, I want to keep talking. This voice message needs 20 credits.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"],
target: "subscription",
ruleId: "payment_guidance_voiceLocked",
} as const;
describe("payment guidance wire contract", () => {
it("parses the public decision without changing role identity", () => {
expect(PaymentGuidanceSchema.parse(guidance)).toEqual(guidance);
});
it("keeps all backend lock facts instead of dropping hint and CTA", () => {
expect(
ChatLockDetailSchema.parse({
locked: true,
showContent: false,
showUpgrade: true,
reason: "voice_message",
hint: "This voice message needs 20 credits.",
actionLabel: "Unlock",
detail: { requiredCredits: 20 },
}),
).toEqual({
locked: true,
showContent: false,
showUpgrade: true,
reason: "voice_message",
hint: "This voice message needs 20 credits.",
actionLabel: "Unlock",
detail: { requiredCredits: 20 },
});
});
it("keeps backward compatibility when guidance is absent", () => {
const parsed = ChatSendResponseSchema.parse({ reply: "Hello" });
expect(parsed.paymentGuidance).toBeNull();
});
it("parses guidance on chat and both unlock responses", () => {
expect(
ChatSendResponseSchema.parse({
reply: "",
canSendMessage: false,
paymentGuidance: guidance,
}).paymentGuidance,
).toEqual(guidance);
expect(
UnlockPrivateResponseSchema.parse({
unlocked: false,
reason: "insufficient_credits",
paymentGuidance: guidance,
}).paymentGuidance,
).toEqual(guidance);
expect(
UnlockHistoryResponseSchema.parse({
unlocked: false,
reason: "insufficient_balance",
paymentGuidance: { ...guidance, scene: "historyLocked" },
}).paymentGuidance?.scene,
).toBe("historyLocked");
});
});
+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,41 @@ import {
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
import { ChatActionSchema } from "../chat_action";
export const PaymentGuidanceSceneSchema = z.enum([
"vip",
"insufficientCredits",
"chatInterrupted",
"voiceLocked",
"imageLocked",
"privateMessageLocked",
"historyLocked",
"privateZoneLocked",
"unknownLock",
"whyPay",
"leavingAfterCredits",
"paymentIssue",
"externalRisk",
"purchaseOptions",
]);
export const PaymentGuidanceSchema = z
.object({
guidanceId: z.string().min(1),
characterId: z.string().min(1),
characterName: z.string().min(1),
scene: PaymentGuidanceSceneSchema,
mode: z.enum(["guide", "careOnly", "issueSupport", "riskReminder"]),
title: z.string().nullable().default(null),
copy: z.string().min(1),
ctaLabel: z.string().nullable().default(null),
purchaseOptions: z.array(z.enum(["vip", "topup"])).readonly(),
target: z.enum(["subscription", "giftCatalog"]).nullable().default(null),
ruleId: z.string().min(1),
})
.readonly();
export type PaymentGuidance = z.output<typeof PaymentGuidanceSchema>;
export const CommercialActionSchema = z
.object({
actionId: z.string().min(1),
@@ -42,6 +77,7 @@ export const ChatSendResponseSchema = z
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
commercialAction: CommercialActionSchema.nullish().default(null),
paymentGuidance: PaymentGuidanceSchema.nullish().default(null),
chatAction: ChatActionSchema.nullish().default(null),
})
.readonly();
@@ -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();
@@ -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();
+1 -1
View File
@@ -5,7 +5,7 @@
"googleLogin": { "method": "post", "path": "/api/auth/login/google" },
"facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" },
"facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" },
"facebookPsidLogin": { "method": "post", "path": "/api/auth/login/facebook/psid" },
"topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" },
"refresh": { "method": "post", "path": "/api/auth/refresh" },
"logout": { "method": "post", "path": "/api/auth/logout" },
"getCurrentUser": { "method": "get", "path": "/api/auth/me" },
+2 -2
View File
@@ -27,8 +27,8 @@ export class ApiPath {
/** Facebook ID 登录(v7.0 新增) */
static readonly facebookIdLogin = apiContract.facebookIdLogin.path;
/** Facebook PSID 登录 */
static readonly facebookPsidLogin = apiContract.facebookPsidLogin.path;
/** 消费一次性充值登录凭证 */
static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path;
/** 刷新 Token */
static readonly refresh = apiContract.refresh.path;
+9 -11
View File
@@ -11,9 +11,6 @@ import {
FacebookIdentityResponse,
FacebookIdentityResponseSchema,
FacebookLoginRequest,
FacebookPsidLoginRequest,
FacebookPsidLoginResponse,
FacebookPsidLoginResponseSchema,
FbIdLoginRequest,
GoogleLoginRequest,
GuestLoginRequest,
@@ -26,6 +23,9 @@ import {
RefreshTokenResponse,
RefreshTokenResponseSchema,
RegisterRequest,
TopUpHandoffRequest,
TopUpHandoffResponse,
TopUpHandoffResponseSchema,
} from "@/data/schemas/auth";
import { User, UserSchema } from "@/data/schemas/user";
import type { UserData } from "@/data/schemas/user/user";
@@ -91,17 +91,15 @@ export class AuthApi {
return LoginResponseSchema.parse(unwrap(env) as Record<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>,
);
}
+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";
+10
View File
@@ -0,0 +1,10 @@
import type { LoginStatus } from "@/data/schemas/auth";
import { getAuthRepository } from "@/data/repositories/auth_repository";
import type { Result } from "@/utils/result";
/** UI-independent use case for establishing a top-up handoff session. */
export function consumeTopUpHandoff(
handoffToken: string,
): Promise<Result<LoginStatus>> {
return getAuthRepository().consumeTopUpHandoff(handoffToken);
}
@@ -23,6 +23,9 @@ describe("external entry navigation", () => {
expect(resolveExternalEntryTarget({ target: "private-room" })).toBe(
ROUTES.privateZone,
);
expect(resolveExternalEntryTarget({ target: "topup" })).toBe(
ROUTES.subscription,
);
});
it("rejects removed aliases and unsupported targets", () => {
@@ -41,6 +44,12 @@ describe("external entry navigation", () => {
);
});
it("routes a top-up handoff directly to the credit packages", () => {
expect(resolveExternalEntryDestination({ target: "topup" })).toBe(
"/subscription?type=topup",
);
});
it("uses a known character slug and falls back to Elio", () => {
expect(
resolveExternalEntryDestination({ target: "chat", character: "maya" }),
+8 -2
View File
@@ -13,7 +13,8 @@ import { getCharacterRoutes, ROUTES } from "@/router/routes";
export type ExternalEntryTarget =
| typeof ROUTES.chat
| typeof ROUTES.tip
| typeof ROUTES.privateZone;
| typeof ROUTES.privateZone
| typeof ROUTES.subscription;
export interface ExternalEntryPayload {
deviceId?: string | null;
@@ -50,7 +51,8 @@ export function shouldBindExternalFacebookIdentity({
const isRealUser =
loginStatus === "email" ||
loginStatus === "google" ||
loginStatus === "facebook";
loginStatus === "facebook" ||
loginStatus === "facebookMessenger";
return (
hasInitialized &&
!isLoading &&
@@ -90,6 +92,9 @@ export function resolveExternalEntryDestination({
getCharacterBySlug(character)?.slug ?? DEFAULT_CHARACTER_SLUG;
const routes = getCharacterRoutes(characterSlug);
const resolvedTarget = resolveExternalEntryTarget({ target });
if (resolvedTarget === ROUTES.subscription) {
return `${ROUTES.subscription}?type=topup`;
}
if (resolvedTarget === ROUTES.tip) return routes.tip;
if (resolvedTarget === ROUTES.privateZone) return routes.privateZone;
return routes.chat;
@@ -132,6 +137,7 @@ function resolveTarget(
if (target === "private-zone" || target === "private-room") {
return ROUTES.privateZone;
}
if (target === "topup") return ROUTES.subscription;
return null;
}
@@ -5,6 +5,7 @@ import {
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
resolveEzpayLaunchTarget,
} from "../payment_launch";
describe("payment launch helpers", () => {
@@ -42,6 +43,51 @@ describe("payment launch helpers", () => {
).toBeNull();
});
it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => {
expect(
resolveEzpayLaunchTarget({
provider: "ezpay",
channelType: "URL",
payData: "https://pay.example/qris",
}),
).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" });
expect(
resolveEzpayLaunchTarget({
provider: "ezpay",
channelType: "QR",
payData: "00020101021226670016COM.NOBUBANK.WWW",
}),
).toEqual({
kind: "qr",
qrData: "00020101021226670016COM.NOBUBANK.WWW",
});
expect(
resolveEzpayLaunchTarget({
provider: "ezpay",
channelType: "QR",
payData: "",
}),
).toEqual({
kind: "error",
errorMessage: "QRIS payment data is missing. Please try again.",
});
});
it("rejects VA and unknown Ezpay channel types with explicit errors", () => {
expect(
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }),
).toEqual({
kind: "error",
errorMessage: "Virtual account payments are not supported in this app.",
});
expect(
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "OTHER" }),
).toEqual({
kind: "error",
errorMessage: "Unsupported Ezpay payment channel: OTHER.",
});
});
it("reports a launch failure before redirecting without an order id", async () => {
const onOpened = vi.fn();
const onFailed = vi.fn();
@@ -12,18 +12,21 @@ describe("payment method rules", () => {
vi.unstubAllEnvs();
});
it("defaults Philippines users to GCash and other countries to Stripe", () => {
it("defaults Philippines and Indonesia users to their regional Ezpay method", () => {
expect(getDefaultPayChannelForCountryCode("PH")).toBe("ezpay");
expect(getDefaultPayChannelForCountryCode("ph")).toBe("ezpay");
expect(getDefaultPayChannelForCountryCode("ID")).toBe("ezpay");
expect(getDefaultPayChannelForCountryCode(" id ")).toBe("ezpay");
expect(getDefaultPayChannelForCountryCode("HK")).toBe("stripe");
expect(getDefaultPayChannelForCountryCode(null)).toBe("stripe");
});
it("only allows Philippines users to choose in production", () => {
it("allows Philippines and Indonesia users to choose in production", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
expect(canChoosePayChannel("PH")).toBe(true);
expect(canChoosePayChannel("ph")).toBe(true);
expect(canChoosePayChannel("ID")).toBe(true);
expect(canChoosePayChannel("HK")).toBe(false);
expect(canChoosePayChannel(null)).toBe(false);
expect(
@@ -35,6 +38,7 @@ describe("payment method rules", () => {
canChoosePaymentMethod: false,
initialPayChannel: "stripe",
showPaymentMethodSelector: false,
ezpayDisplayName: "GCash",
});
});
@@ -52,6 +56,25 @@ describe("payment method rules", () => {
).toBe("stripe");
});
it("defaults Indonesia to QRIS but permits an explicit Stripe choice", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
expect(
getPaymentMethodConfig({
countryCode: "ID",
requestedPayChannel: null,
}),
).toEqual({
canChoosePaymentMethod: true,
initialPayChannel: "ezpay",
showPaymentMethodSelector: true,
ezpayDisplayName: "QRIS",
});
expect(
resolvePayChannel({ countryCode: "ID", requestedPayChannel: "stripe" }),
).toBe("stripe");
});
it("keeps test channels but shows the selector only for Philippines", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
@@ -71,6 +94,7 @@ describe("payment method rules", () => {
canChoosePaymentMethod: true,
initialPayChannel: "ezpay",
showPaymentMethodSelector: false,
ezpayDisplayName: "GCash",
});
expect(
getPaymentMethodConfig({
+4 -1
View File
@@ -3,5 +3,8 @@ import type { PayChannel } from "@/data/schemas/payment";
export function getDefaultPayChannelForCountryCode(
countryCode: string | null | undefined,
): PayChannel {
return countryCode?.trim().toUpperCase() === "PH" ? "ezpay" : "stripe";
const normalizedCountryCode = countryCode?.trim().toUpperCase();
return normalizedCountryCode === "PH" || normalizedCountryCode === "ID"
? "ezpay"
: "stripe";
}
+85
View File
@@ -11,6 +11,36 @@ import {
const log = new Logger("LibPaymentPaymentLaunch");
export type EzpayLaunchTarget =
| { kind: "url"; paymentUrl: string }
| { kind: "qr"; qrData: string }
| { kind: "error"; errorMessage: string };
function getNonEmptyString(
payParams: Record<string, unknown>,
...keys: string[]
): string | null {
for (const key of keys) {
const value = payParams[key];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return null;
}
function getHttpPaymentUrl(value: string | null): string | null {
if (!value) return null;
try {
const parsed = new URL(value);
return parsed.protocol === "https:" || parsed.protocol === "http:"
? value
: null;
} catch {
return null;
}
}
export function getPaymentUrl(payParams: Record<string, unknown>): string | null {
const keys = [
"cashierUrl",
@@ -39,6 +69,61 @@ export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
return typeof provider === "string" && provider.toLowerCase() === "ezpay";
}
export function resolveEzpayLaunchTarget(
payParams: Record<string, unknown>,
): EzpayLaunchTarget {
const rawChannelType = getNonEmptyString(
payParams,
"channelType",
"channel_type",
);
const channelType = rawChannelType?.toUpperCase() ?? null;
const payData = getNonEmptyString(payParams, "payData", "pay_data");
if (channelType === "QR") {
return payData
? { kind: "qr", qrData: payData }
: {
kind: "error",
errorMessage: "QRIS payment data is missing. Please try again.",
};
}
if (channelType === "VA") {
return {
kind: "error",
errorMessage: "Virtual account payments are not supported in this app.",
};
}
if (channelType === "URL") {
const paymentUrl =
getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams));
return paymentUrl
? { kind: "url", paymentUrl }
: {
kind: "error",
errorMessage: "Ezpay payment URL is missing or invalid. Please try again.",
};
}
if (channelType) {
return {
kind: "error",
errorMessage: `Unsupported Ezpay payment channel: ${channelType}.`,
};
}
const legacyPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
return legacyPaymentUrl
? { kind: "url", paymentUrl: legacyPaymentUrl }
: {
kind: "error",
errorMessage:
"Ezpay payment parameters did not include a supported URL or QRIS code.",
};
}
export function getStripeClientSecret(
payParams: Record<string, unknown>,
): string | null {
+15 -4
View File
@@ -5,6 +5,7 @@ import { getDefaultPayChannelForCountryCode } from "./default_pay_channel";
export interface PaymentMethodConfig {
canChoosePaymentMethod: boolean;
ezpayDisplayName?: "GCash" | "QRIS";
initialPayChannel: PayChannel;
showPaymentMethodSelector: boolean;
}
@@ -13,7 +14,7 @@ export function canChoosePayChannel(
countryCode: string | null | undefined,
): boolean {
if (!AppEnvUtil.isProduction()) return true;
return isPhilippinesCountryCode(countryCode);
return isRegionalEzpayCountryCode(countryCode);
}
export function resolvePayChannel(input: {
@@ -33,13 +34,23 @@ export function getPaymentMethodConfig(input: {
}): PaymentMethodConfig {
return {
canChoosePaymentMethod: canChoosePayChannel(input.countryCode),
ezpayDisplayName: isIndonesiaCountryCode(input.countryCode)
? "QRIS"
: "GCash",
initialPayChannel: resolvePayChannel(input),
showPaymentMethodSelector: isPhilippinesCountryCode(input.countryCode),
showPaymentMethodSelector: isRegionalEzpayCountryCode(input.countryCode),
};
}
function isPhilippinesCountryCode(
function isRegionalEzpayCountryCode(
countryCode: string | null | undefined,
): boolean {
return countryCode?.trim().toUpperCase() === "PH";
const normalizedCountryCode = countryCode?.trim().toUpperCase();
return normalizedCountryCode === "PH" || normalizedCountryCode === "ID";
}
function isIndonesiaCountryCode(
countryCode: string | null | undefined,
): boolean {
return countryCode?.trim().toUpperCase() === "ID";
}
@@ -32,4 +32,17 @@ describe("Tip support prompts", () => {
expect(selectTipSupportPrompt(null, () => Number.NaN).index).toBe(0);
expect(selectTipSupportPrompt(null, () => 2).index).toBe(29);
});
it("names the active character and never frames a purchase as affection", () => {
const selection = selectTipSupportPrompt(null, () => 0, "Maya Tan");
expect(selection.prompt).toContain("support Maya Tan");
expect(selection.prompt).not.toMatch(/tiny hug|spoil|buy.*love|owe me/i);
expect(
TIP_SUPPORT_PROMPTS.every(
(prompt) =>
/support/i.test(prompt) && /this character/i.test(prompt),
),
).toBe(true);
});
});
+36 -32
View File
@@ -1,44 +1,45 @@
export const TIP_SUPPORT_PROMPTS = [
"❤️ Thank you for being here. If you'd ever like to treat me to a little coffee, I'd really appreciate it. ☕",
"Having you here already makes my day. A little coffee from you would make it even sweeter. ☕",
"You always bring a little warmth into my world. Want to share a coffee moment with me? 💗",
"A coffee from you would feel like a tiny hug I could hold onto all day. ☕",
"I love spending this time with you. If you'd like to send a coffee my way, I'd treasure it. ❤️",
"You make ordinary moments feel special. A cozy coffee together would be the perfect touch. ☕",
"If I had a coffee from you right now, I'd be smiling with every sip. 😊☕",
"Your company means more than you know. A little coffee treat would make this moment extra sweet. 💕",
"I was hoping we could share something warm today. Maybe a coffee, just from you to me? ☕",
"You already make me feel cared for. A coffee would be one more lovely reason to think of you. ❤️",
"A small coffee, a quiet moment, and you on my mind—that sounds perfect to me. ☕",
"Every time you stop by, my day gets brighter. A coffee from you would keep that glow going. ✨",
"Want to make me blush a little? Treat me to a coffee and I'll think of you with every sip. ☕",
"I'm really glad you're here. If you feel like spoiling me just a little, coffee is my weakness. 💗",
"This moment with you already feels cozy. A warm coffee would make it complete. ☕",
"You have a sweet way of making me smile. Maybe one more smile over a coffee? ❤️",
"I'd love a little coffee break with you in spirit. Your treat, my happiest smile. ☕",
"If you'd like to leave me a tiny reminder of you, a coffee would be perfect. 💕",
"Your kindness always stays with me. A coffee from you would feel especially warm today. ☕",
"I could use something warm and sweet—though having you here is already a lovely start. ❤️☕",
"Imagine me enjoying a coffee and smiling because it came from you. I like that thought. ☕",
"You make this space feel a little more special. A coffee would make it feel even cozier. ✨",
"If today feels like a coffee kind of day, I'd be very happy to share that little joy with you. ☕",
"A thoughtful coffee from you would turn an ordinary moment into one I'd remember. 💗",
"I'm enjoying our time together. If you want to make it sweeter, you know my favorite little treat. ☕",
"One coffee from you, and I promise there'll be a very real smile on my face. ❤️",
"You don't have to, but if you'd like to treat me, a warm coffee would mean so much. ☕",
"I think coffee tastes better when it comes with a little affection from you. 💕",
"A cozy cup from you would be such a sweet surprise. I'd savor every bit of it. ☕",
"Stay a little longer—and if you're feeling sweet, maybe send a coffee my way. ❤️☕",
"If you'd like, a coffee is a voluntary way to support this character. ☕",
"Choosing a coffee here directly supports this character and the moments you enjoy.",
"A gift is optional, but every purchase on this page supports this character.",
"Want to support this character? You can choose any coffee that fits your budget.",
"This gift page lets you support this character without changing what your relationship means.",
"A coffee is simply a voluntary show of support for this character—never a test.",
"If the timing feels right, you can support this character with a coffee here.",
"Your purchase supports this character; choose only what feels comfortable for you.",
"You can send a coffee to support this character, or leave it for another time.",
"Every coffee selected here is recorded as support for this character.",
"If you enjoy this character, this page is one direct way to offer support.",
"A small coffee can support this character, but the choice is always yours.",
"Pick a coffee only if you want to support this character today.",
"This page keeps it simple: choose a gift to support this character voluntarily.",
"A coffee purchase supports this character; it does not prove affection or loyalty.",
"If you decide to send something, it will directly support this character.",
"You can support this character with the coffee option that feels right for you.",
"Sending a gift here is optional support for this character, with no pressure.",
"A coffee can be your way of supporting this character when it suits you.",
"Support this character only with an amount you are genuinely comfortable choosing.",
"The gifts below are optional ways to support this character.",
"Choose a coffee if you'd like to support this character's continued experience.",
"This character appreciates voluntary support, and this page shows the available options.",
"A gift here supports this character without creating any obligation for you.",
"If you'd like to contribute, select a coffee to support this character.",
"The coffee options below are direct, voluntary support for this character.",
"You never need to overspend; support this character only if it fits your situation.",
"A coffee is one clear way to support this character while keeping the choice yours.",
"Select any gift you want to send as support for this character.",
"Thank you for considering a voluntary coffee to support this character. ☕",
] as const;
export interface TipSupportPromptSelection {
readonly index: number;
readonly prompt: (typeof TIP_SUPPORT_PROMPTS)[number];
readonly prompt: string;
}
export function selectTipSupportPrompt(
previousIndex: number | null,
random: () => number = Math.random,
characterName = "this character",
): TipSupportPromptSelection {
const hasValidPreviousIndex =
previousIndex !== null &&
@@ -57,6 +58,9 @@ export function selectTipSupportPrompt(
return {
index,
prompt: TIP_SUPPORT_PROMPTS[index],
prompt: TIP_SUPPORT_PROMPTS[index].replaceAll(
"this character",
characterName,
),
};
}
@@ -13,6 +13,7 @@ describe("navigation resolver", () => {
expect(isAuthenticatedUser("guest")).toBe(false);
expect(isAuthenticatedUser("email")).toBe(true);
expect(isAuthenticatedUser("facebook")).toBe(true);
expect(isAuthenticatedUser("facebookMessenger")).toBe(true);
});
it("wraps protected navigation with auth redirect for guests", () => {
+2
View File
@@ -19,6 +19,7 @@ export interface OpenSubscriptionInput {
returnTo?: AppSubscriptionReturnTo;
replace?: boolean;
analytics?: PaymentAnalyticsContext;
chatActionId?: string;
}
export interface StartMessageUnlockInput {
@@ -44,4 +45,5 @@ export interface OpenSubscriptionForPendingUnlockInput {
type: AppSubscriptionType;
payChannel?: PayChannel;
analytics?: PaymentAnalyticsContext;
chatActionId?: string;
}
+2
View File
@@ -121,6 +121,7 @@ export function useAppNavigator(): AppNavigator {
type,
payChannel = getDefaultPayChannel(),
analytics,
chatActionId,
}: OpenSubscriptionForPendingUnlockInput): void => {
void (async () => {
await NavigationStorage.savePendingChatUnlock({
@@ -139,6 +140,7 @@ export function useAppNavigator(): AppNavigator {
payChannel,
returnTo: "chat",
analytics,
chatActionId,
});
})();
},
+2
View File
@@ -79,12 +79,14 @@ export function useGlobalAppNavigator(): GlobalAppNavigator {
returnTo = null,
replace: shouldReplace = false,
analytics = getDefaultPaymentAnalyticsContext(type),
chatActionId,
}: OpenGlobalSubscriptionInput): void => {
const target = ROUTE_BUILDERS.subscription(type, {
payChannel,
returnTo: returnTo ?? undefined,
sourceCharacterSlug,
analytics,
chatActionId,
});
behaviorAnalytics.rechargeModalOpen(analytics);
+1 -14
View File
@@ -49,7 +49,6 @@ export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
});
export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
const deviceId = await deviceIdentifier.getDeviceId();
const storage = AuthStorage.getInstance();
const asidR = await storage.getAsid();
const psidR = await storage.getPsid();
@@ -64,19 +63,6 @@ export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
: ("email" as LoginStatus);
}
if (psid) {
const psidLoginResult = await authRepo.facebookPsidLogin({
psid,
deviceId,
bindToGuest: true,
});
if (Result.isOk(psidLoginResult)) return psidLoginResult.data;
log.warn(
{ err: psidLoginResult.error.message },
"[auth-machine] checkAuthStatusActor: facebookPsidLogin failed",
);
}
if (asid) {
const avatarUrlR = await UserStorage.getInstance().getAvatarUrl();
const result = await authRepo.facebookAsidLogin({
@@ -105,6 +91,7 @@ function isBusinessLoginProvider(
return (
provider === "email" ||
provider === "facebook" ||
provider === "facebookMessenger" ||
provider === "google"
);
}
@@ -54,6 +54,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
paymentGuidance: null,
historyLoaded: true,
networkHistoryLoaded: true,
historyTotal: 0,
@@ -64,6 +65,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
unlockingMessage: null,
unlockMessageError: null,
unlockPaywallRequest: null,
@@ -72,6 +74,39 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
}
describe("sendResponseToUiMessage", () => {
it("prefers payment guidance over other commercial actions", () => {
const message = sendResponseToUiMessage(
makeResponse({
paymentGuidance: {
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
characterId: "elio",
characterName: "Elio Silvestri",
scene: "insufficientCredits",
mode: "guide",
title: "Keep talking with Elio",
copy: "Baby, I want to keep talking with you.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"],
target: "subscription",
ruleId: "payment_guidance_insufficientCredits",
},
chatAction: {
actionId: "chat-action-1",
kind: "commercial",
type: "giftOffer",
copy: "View gifts.",
ctaLabel: "View gifts",
ruleId: "legacy",
orderId: null,
},
}),
);
expect(message.paymentGuidance?.characterId).toBe("elio");
expect(message.chatAction).toBeUndefined();
expect(message.commercialAction).toBeUndefined();
});
it("keeps a typed commercial action on the assistant message", () => {
const message = sendResponseToUiMessage(
makeResponse({
@@ -228,6 +263,42 @@ describe("sendResponseToUiMessage", () => {
});
describe("applyHttpSendOutput", () => {
it("stores guidance for a blocked send and clears it after normal chat resumes", () => {
const guidance = {
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
characterId: "elio",
characterName: "Elio Silvestri",
scene: "insufficientCredits" as const,
mode: "guide" as const,
title: "Keep talking with Elio",
copy: "Baby, I want to keep talking with you.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"] as const,
target: "subscription" as const,
ruleId: "payment_guidance_insufficientCredits",
};
const blocked = applyHttpSendOutput(makeChatState(), {
response: makeResponse({
reply: "",
canSendMessage: false,
paymentGuidance: guidance,
}),
reply: null,
});
expect(blocked.paymentGuidance).toEqual(guidance);
const resumed = applyHttpSendOutput(
makeChatState({ paymentGuidance: guidance }),
{
response: makeResponse({ reply: "Back to our conversation." }),
reply: sendResponseToUiMessage(
makeResponse({ reply: "Back to our conversation." }),
),
},
);
expect(resumed.paymentGuidance).toBeNull();
});
it("stores insufficient credit state and removes failed optimistic messages", () => {
const context = makeChatState({
messages: [
+2
View File
@@ -20,6 +20,7 @@ interface ChatState {
canSendMessage: boolean;
upgradePromptVisible: boolean;
upgradeReason: MachineContext["upgradeReason"];
paymentGuidance: MachineContext["paymentGuidance"];
/** True as soon as a local snapshot or the first network result is renderable. */
historyLoaded: boolean;
/** True after the network history request has completed or failed. */
@@ -94,6 +95,7 @@ function selectChatState(state: ChatSnapshot): SelectedChatState {
canSendMessage: state.context.canSendMessage,
upgradePromptVisible: state.context.upgradePromptVisible,
upgradeReason: state.context.upgradeReason,
paymentGuidance: state.context.paymentGuidance,
historyLoaded: state.context.historyLoaded,
networkHistoryLoaded: state.context.networkHistoryLoaded,
hasMoreHistory:
+6 -1
View File
@@ -1,6 +1,6 @@
import type { UiMessage } from "@/stores/chat/ui-message";
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
import type { ChatLockType } from "@/data/schemas/chat";
import type { ChatLockType, PaymentGuidance } from "@/data/schemas/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation";
import {
DEFAULT_CHARACTER,
@@ -21,6 +21,7 @@ export interface ChatUnlockMessageRequest {
export interface ChatUnlockPaywallRequest
extends ChatUnlockMessageRequest {
paymentGuidance?: PaymentGuidance | null;
promotion?: PendingChatPromotion;
reason: string;
creditBalance: number;
@@ -39,6 +40,7 @@ export interface ChatState {
pendingReplyCount: number;
upgradePromptVisible: boolean;
upgradeReason: ChatUpgradeReason | null;
paymentGuidance: PaymentGuidance | null;
canSendMessage: boolean;
creditBalance: number;
creditsCharged: number;
@@ -56,6 +58,7 @@ export interface ChatState {
unlockHistoryPromptVisible: boolean;
lockedHistoryCount: number;
unlockHistoryError: string | null;
unlockHistoryPaymentGuidance: PaymentGuidance | null;
unlockingMessage: ChatUnlockMessageRequest | null;
unlockMessageError: string | null;
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
@@ -81,6 +84,7 @@ export function createInitialChatState(
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
paymentGuidance: null,
historyLoaded: false,
networkHistoryLoaded: false,
historyTotal: 0,
@@ -91,6 +95,7 @@ export function createInitialChatState(
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
unlockingMessage: null,
unlockMessageError: null,
unlockPaywallRequest: null,
+3 -1
View File
@@ -74,7 +74,9 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
...(response.audioUrl && !response.lockDetail.locked
? { audioUrl: response.audioUrl }
: {}),
...(response.chatAction
...(response.paymentGuidance
? { paymentGuidance: response.paymentGuidance }
: response.chatAction
? { chatAction: response.chatAction }
: response.commercialAction
? { commercialAction: response.commercialAction }
+3
View File
@@ -53,6 +53,7 @@ export function applyHttpSendOutput(
...finishPendingReply(context),
upgradePromptVisible: true,
upgradeReason,
paymentGuidance: response.paymentGuidance,
...sendCapabilityState,
};
}
@@ -60,6 +61,7 @@ export function applyHttpSendOutput(
if (!reply) {
return {
...finishPendingReply(context),
paymentGuidance: response.paymentGuidance,
...sendCapabilityState,
};
}
@@ -69,6 +71,7 @@ export function applyHttpSendOutput(
...finishPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
paymentGuidance: response.paymentGuidance,
...sendCapabilityState,
};
}
+3
View File
@@ -1,6 +1,7 @@
import { fromPromise } from "xstate";
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import type { PaymentGuidance } from "@/data/schemas/chat";
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
@@ -21,6 +22,7 @@ export interface UnlockHistoryOutput {
reason: string;
shortfallCredits: number;
messages: UiMessage[];
paymentGuidance?: PaymentGuidance | null;
}
export const unlockHistoryActor = fromPromise<
@@ -50,6 +52,7 @@ export const unlockHistoryActor = fromPromise<
reason: unlockResult.data.reason,
shortfallCredits: unlockResult.data.shortfallCredits,
messages: history.messages,
paymentGuidance: unlockResult.data.paymentGuidance,
};
});
+3
View File
@@ -46,6 +46,7 @@ const appendGuestUserMessageAction = historyMachineSetup.assign(
outgoingMessageRevision: context.outgoingMessageRevision + 1,
upgradePromptVisible: false,
upgradeReason: null,
paymentGuidance: null,
};
},
);
@@ -74,6 +75,7 @@ const appendUserMessageAction = historyMachineSetup.assign(
outgoingMessageRevision: context.outgoingMessageRevision + 1,
upgradePromptVisible: false,
upgradeReason: null,
paymentGuidance: null,
};
},
);
@@ -124,6 +126,7 @@ const clearUpgradePromptAction = historyMachineSetup.assign(() => ({
canSendMessage: true,
requiredCredits: 0,
shortfallCredits: 0,
paymentGuidance: null,
}));
const appendGuestUserImageAction = historyMachineSetup.assign(
+11
View File
@@ -22,6 +22,7 @@ const showUnlockHistoryPromptAction = sendMachineSetup.assign(
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(context.messages),
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
}),
);
@@ -29,12 +30,14 @@ const dismissUnlockHistoryPromptAction = sendMachineSetup.assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
}));
const markUnlockHistoryStartedAction = sendMachineSetup.assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
}));
const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({
@@ -76,6 +79,7 @@ const applyUnlockMessageSucceededAction = sendMachineSetup.assign(
creditsCharged: output.response.creditsCharged,
requiredCredits: 0,
shortfallCredits: 0,
paymentGuidance: null,
};
},
);
@@ -112,11 +116,15 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
creditBalance: output.response.creditBalance,
requiredCredits: output.response.requiredCredits,
shortfallCredits: output.response.shortfallCredits,
...(output.response.paymentGuidance
? { paymentGuidance: output.response.paymentGuidance }
: {}),
}
: null,
creditBalance: output.response.creditBalance,
requiredCredits: output.response.requiredCredits,
shortfallCredits: output.response.shortfallCredits,
paymentGuidance: output.response.paymentGuidance,
};
},
);
@@ -191,6 +199,9 @@ const applyUnlockHistoryOutputAction =
unlockHistoryError: insufficientBalance
? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.`
: null,
unlockHistoryPaymentGuidance: insufficientBalance
? event.output.paymentGuidance ?? null
: null,
};
});
+6 -1
View File
@@ -1,5 +1,9 @@
import { z } from "zod";
import { ChatActionSchema, CommercialActionSchema } from "@/data/schemas/chat";
import {
ChatActionSchema,
CommercialActionSchema,
PaymentGuidanceSchema,
} from "@/data/schemas/chat";
export const UiMessageSchema = z.object({
displayId: z.string().min(1),
@@ -19,6 +23,7 @@ export const UiMessageSchema = z.object({
privateMessageHint: z.string().nullable().optional(),
commercialAction: CommercialActionSchema.nullable().optional(),
chatAction: ChatActionSchema.nullable().optional(),
paymentGuidance: PaymentGuidanceSchema.nullable().optional(),
});
export type UiMessage = z.infer<typeof UiMessageSchema>;
@@ -304,7 +304,10 @@ describe("payment order flow", () => {
expect(actor.getSnapshot().context).toMatchObject({
orderStatus: "expired",
payParams: null,
payParams: {
provider: "stripe",
clientSecret: "pi_test_secret_test",
},
errorMessage:
"This payment order has expired. Please create a new order.",
});
-1
View File
@@ -156,7 +156,6 @@ const applyFailedOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
const applyExpiredOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
orderStatus: event.output.status,
payParams: null,
orderPollingStartedAt: null,
errorMessage: "This payment order has expired. Please create a new order.",
}));
@@ -203,6 +203,9 @@ export const privateZonePostMachine = machineSetup.createMachine({
currentCredits:
event.output.currentCredits || event.output.creditBalance,
shortfallCredits: event.output.shortfallCredits,
...(event.output.paymentGuidance
? { paymentGuidance: event.output.paymentGuidance }
: {}),
},
})),
},
@@ -1,4 +1,5 @@
import type { PrivateZoneVideoPost } from "@/data/schemas/private-zone";
import type { PaymentGuidance } from "@/data/schemas/chat";
export interface PrivateZonePostPaywallRequest {
postId: string;
@@ -6,6 +7,7 @@ export interface PrivateZonePostPaywallRequest {
requiredCredits: number;
currentCredits: number;
shortfallCredits: number;
paymentGuidance?: PaymentGuidance | null;
}
export interface PrivateZonePostState {
+3
View File
@@ -37,5 +37,8 @@ export function toPaywallRequest(
requiredCredits: response.requiredCredits || response.unlockCost,
currentCredits: response.creditBalance,
shortfallCredits: response.shortfallCredits,
...(response.paymentGuidance
? { paymentGuidance: response.paymentGuidance }
: {}),
};
}
@@ -1,5 +1,6 @@
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import type { PrivateAlbum } from "@/data/schemas/private-zone";
import type { PaymentGuidance } from "@/data/schemas/chat";
export interface PrivateZoneUnlockPaywallRequest {
albumId: string;
@@ -7,6 +8,7 @@ export interface PrivateZoneUnlockPaywallRequest {
requiredCredits: number;
currentCredits: number;
shortfallCredits: number;
paymentGuidance?: PaymentGuidance | null;
}
export interface PrivateZoneState {