Compare commits
10 Commits
main
...
dae04f75dc
| Author | SHA1 | Date | |
|---|---|---|---|
| dae04f75dc | |||
| 8f8e067d82 | |||
| 3c74d30189 | |||
| 4cbdd0da7c | |||
| e071f83474 | |||
| 3536045794 | |||
| 74b7eae18b | |||
| 59e4eac736 | |||
| 2e402de15b | |||
| a530850039 |
@@ -13,6 +13,7 @@ export interface MockCoreApisOptions {
|
|||||||
paidImageInsufficientCreditsFlow?: boolean;
|
paidImageInsufficientCreditsFlow?: boolean;
|
||||||
paidVoiceInsufficientCreditsFlow?: boolean;
|
paidVoiceInsufficientCreditsFlow?: boolean;
|
||||||
topUpHandoffFlow?: boolean;
|
topUpHandoffFlow?: boolean;
|
||||||
|
checkoutHandoffFlow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
||||||
@@ -23,6 +24,7 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
|
|||||||
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
||||||
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
||||||
topUpHandoffFlow: options.topUpHandoffFlow ?? false,
|
topUpHandoffFlow: options.topUpHandoffFlow ?? false,
|
||||||
|
checkoutHandoffFlow: options.checkoutHandoffFlow ?? false,
|
||||||
};
|
};
|
||||||
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
|
|||||||
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
||||||
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
||||||
topUpHandoffFlow: chatOptions.topUpHandoffFlow,
|
topUpHandoffFlow: chatOptions.topUpHandoffFlow,
|
||||||
|
checkoutHandoffFlow: chatOptions.checkoutHandoffFlow,
|
||||||
});
|
});
|
||||||
await registerCharacterMocks(page);
|
await registerCharacterMocks(page);
|
||||||
await registerUserMocks(page);
|
await registerUserMocks(page);
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
import { apiEnvelope } from "../data/common";
|
import { apiEnvelope } from "../data/common";
|
||||||
import { emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth";
|
import { checkoutHandoffResponse, emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth";
|
||||||
|
|
||||||
export interface AuthMockState {
|
export interface AuthMockState {
|
||||||
chatSendTokenRefreshFlow: boolean;
|
chatSendTokenRefreshFlow: boolean;
|
||||||
isChatSendTokenExpired: () => boolean;
|
isChatSendTokenExpired: () => boolean;
|
||||||
topUpHandoffFlow: boolean;
|
topUpHandoffFlow: boolean;
|
||||||
|
checkoutHandoffFlow: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAuthMocks(page: Page, state: AuthMockState) {
|
export async function registerAuthMocks(page: Page, state: AuthMockState) {
|
||||||
|
await page.route("**/api/auth/handoff/checkout/consume", async (route) => {
|
||||||
|
if (!state.checkoutHandoffFlow) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({ json: apiEnvelope(checkoutHandoffResponse) });
|
||||||
|
});
|
||||||
|
|
||||||
await page.route("**/api/auth/handoff/topup/consume", async (route) => {
|
await page.route("**/api/auth/handoff/topup/consume", async (route) => {
|
||||||
if (!state.topUpHandoffFlow) {
|
if (!state.topUpHandoffFlow) {
|
||||||
await route.continue();
|
await route.continue();
|
||||||
|
|||||||
@@ -63,3 +63,15 @@ export const topUpHandoffResponse = {
|
|||||||
loginProvider: "facebook_messenger",
|
loginProvider: "facebook_messenger",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const checkoutHandoffResponse = {
|
||||||
|
token: "e2e-checkout-token",
|
||||||
|
refreshToken: "e2e-checkout-refresh-token",
|
||||||
|
loginStatus: "email",
|
||||||
|
user: e2eEmailUser,
|
||||||
|
checkoutIntent: {
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,18 +2,17 @@ import { expect, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
export async function completeVipPayment(page: Page) {
|
export async function completeVipPayment(page: Page) {
|
||||||
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
|
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||||
await expect(checkoutButton).toBeDisabled();
|
await expect(checkoutButton).toBeEnabled();
|
||||||
await page.getByRole("button", { name: /Monthly,/i }).click();
|
|
||||||
|
await checkoutButton.click();
|
||||||
const renewalDialog = page.getByRole("dialog", {
|
const renewalDialog = page.getByRole("dialog", {
|
||||||
name: "Automatic Renewal Confirmation",
|
name: "Automatic Renewal Confirmation",
|
||||||
});
|
});
|
||||||
await expect(renewalDialog).toBeVisible();
|
await expect(renewalDialog).toBeVisible();
|
||||||
await renewalDialog.getByRole("button", { name: "Confirm" }).click();
|
|
||||||
await expect(checkoutButton).toBeEnabled();
|
|
||||||
|
|
||||||
const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order");
|
const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order");
|
||||||
const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**");
|
const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**");
|
||||||
await checkoutButton.click();
|
await renewalDialog.getByRole("button", { name: "Confirm" }).click();
|
||||||
const createOrderRequest = await createOrderRequestPromise;
|
const createOrderRequest = await createOrderRequestPromise;
|
||||||
expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" });
|
expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" });
|
||||||
await orderStatusRequestPromise;
|
await orderStatusRequestPromise;
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
seedEmailSession,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const facebookAndroidUserAgent =
|
||||||
|
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Version/4.0 Chrome/126.0.0.0 Mobile Safari/537.36 " +
|
||||||
|
"[FB_IAB/FB4A;FBAV/566.0.0.48.73;]";
|
||||||
|
const handoffToken = "checkout-handoff-token-that-is-at-least-32-characters";
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
userAgent: facebookAndroidUserAgent,
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("consumes the handoff, removes its token, and does not create an order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockCoreApis(page, { checkoutHandoffFlow: true });
|
||||||
|
let createOrderRequests = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("/api/payment/create-order")) {
|
||||||
|
createOrderRequests += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const consumeRequest = page.waitForRequest(
|
||||||
|
"**/api/auth/handoff/checkout/consume",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/external-entry?target=checkout&character=nayeli&handoffToken=${encodeURIComponent(handoffToken)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken });
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
/\/subscription\?planId=vip_monthly&autoRenew=1&character=nayeli&commercialOfferId=.*&payChannel=stripe/,
|
||||||
|
);
|
||||||
|
expect(page.url()).not.toContain("handoffToken");
|
||||||
|
await expect.poll(() => createOrderRequests).toBe(0);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
loginProvider: localStorage.getItem("cozsweet:login_provider"),
|
||||||
|
loginToken: localStorage.getItem("cozsweet:login_token"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.toEqual({ loginProvider: "email", loginToken: "e2e-checkout-token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offers the Facebook external-browser path before creating a Stripe order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockCoreApis(page);
|
||||||
|
let createOrderRequests = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("/api/payment/create-order")) {
|
||||||
|
createOrderRequests += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.route("**/api/auth/handoff/checkout", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
externalUrl:
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque-token",
|
||||||
|
expiresAt: "2026-07-28T16:00:00+00:00",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedEmailSession(page);
|
||||||
|
await page.goto("/subscription?type=vip&payChannel=stripe&character=maya");
|
||||||
|
await page.getByRole("button", { name: /Monthly,/i }).click();
|
||||||
|
const externalButton = page.getByRole("button", {
|
||||||
|
name: "Open in browser for more payment methods",
|
||||||
|
});
|
||||||
|
const paymentButton = page.getByRole("button", { name: "Pay and Top Up" });
|
||||||
|
await expect(externalButton).toBeVisible();
|
||||||
|
await expect(externalButton).toBeEnabled();
|
||||||
|
await expect(paymentButton).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
const externalBox = await externalButton.boundingBox();
|
||||||
|
const paymentBox = await paymentButton.boundingBox();
|
||||||
|
expect(externalBox).not.toBeNull();
|
||||||
|
expect(paymentBox).not.toBeNull();
|
||||||
|
expect(externalBox!.y).toBeLessThan(paymentBox!.y);
|
||||||
|
expect(paymentBox!.y + paymentBox!.height).toBeLessThanOrEqual(844);
|
||||||
|
const handoffRequest = page.waitForRequest("**/api/auth/handoff/checkout");
|
||||||
|
await externalButton.click();
|
||||||
|
expect((await handoffRequest).postDataJSON()).toMatchObject({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
});
|
||||||
|
expect(createOrderRequests).toBe(0);
|
||||||
|
});
|
||||||
@@ -85,11 +85,14 @@ async function registerIndonesiaPaymentMocks(page: Page) {
|
|||||||
});
|
});
|
||||||
await page.route("**/api/payment/create-order", async (route) => {
|
await page.route("**/api/payment/create-order", async (route) => {
|
||||||
createOrderCount += 1;
|
createOrderCount += 1;
|
||||||
const body = route.request().postDataJSON() as { planId: string };
|
const body = route.request().postDataJSON() as {
|
||||||
|
planId: string;
|
||||||
|
payChannel: "ezpay" | "stripe";
|
||||||
|
};
|
||||||
const plan =
|
const plan =
|
||||||
idrPlans.plans.find((item) => item.planId === body.planId) ??
|
idrPlans.plans.find((item) => item.planId === body.planId) ??
|
||||||
idrGiftCatalog.plans.find((item) => item.planId === body.planId);
|
idrGiftCatalog.plans.find((item) => item.planId === body.planId);
|
||||||
const orderId = `order_qris_${body.planId}`;
|
const orderId = `order_${body.payChannel}_${body.planId}`;
|
||||||
orderStatuses.set(orderId, "pending");
|
orderStatuses.set(orderId, "pending");
|
||||||
orderPlans.set(orderId, {
|
orderPlans.set(orderId, {
|
||||||
planId: body.planId,
|
planId: body.planId,
|
||||||
@@ -98,7 +101,13 @@ async function registerIndonesiaPaymentMocks(page: Page) {
|
|||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
json: apiEnvelope({
|
json: apiEnvelope({
|
||||||
orderId,
|
orderId,
|
||||||
payParams: {
|
payParams:
|
||||||
|
body.payChannel === "stripe"
|
||||||
|
? {
|
||||||
|
provider: "stripe",
|
||||||
|
clientSecret: "pi_e2e_secret_mock",
|
||||||
|
}
|
||||||
|
: {
|
||||||
provider: "ezpay",
|
provider: "ezpay",
|
||||||
countryCode: "ID",
|
countryCode: "ID",
|
||||||
channelCode: "ID_QRIS_DYNAMIC_QR",
|
channelCode: "ID_QRIS_DYNAMIC_QR",
|
||||||
@@ -173,6 +182,7 @@ async function expectQrisOrder(
|
|||||||
expect(request.postDataJSON()).toMatchObject({
|
expect(request.postDataJSON()).toMatchObject({
|
||||||
planId,
|
planId,
|
||||||
payChannel: "ezpay",
|
payChannel: "ezpay",
|
||||||
|
recipientCharacterId: "elio",
|
||||||
});
|
});
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||||
@@ -182,7 +192,7 @@ async function expectQrisOrder(
|
|||||||
await expect(
|
await expect(
|
||||||
dialog.getByRole("img", { name: "QRIS payment QR code" }),
|
dialog.getByRole("img", { name: "QRIS payment QR code" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
return `order_qris_${planId}`;
|
return `order_ezpay_${planId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectCheckoutButtonLayout(page: Page) {
|
async function expectCheckoutButtonLayout(page: Page) {
|
||||||
@@ -209,26 +219,24 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async
|
|||||||
}) => {
|
}) => {
|
||||||
const payment = await registerIndonesiaPaymentMocks(page);
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
await prepareIndonesiaUser(page);
|
await prepareIndonesiaUser(page);
|
||||||
await page.goto("/subscription?type=vip");
|
await page.goto("/subscription?type=vip&character=elio");
|
||||||
|
|
||||||
|
await expect(page.getByText("Choose who you want to support")).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Elio", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
|
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
|
||||||
"aria-pressed",
|
"aria-pressed",
|
||||||
"true",
|
"true",
|
||||||
);
|
);
|
||||||
const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" });
|
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();
|
await expect(checkoutButton).toBeEnabled();
|
||||||
|
await expectCheckoutButtonLayout(page);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }),
|
||||||
|
).toHaveCount(0);
|
||||||
expect(payment.getCreateOrderCount()).toBe(0);
|
expect(payment.getCreateOrderCount()).toBe(0);
|
||||||
|
|
||||||
const orderId = await expectQrisOrder(
|
const orderId = await expectQrisOrder(
|
||||||
@@ -250,7 +258,8 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async
|
|||||||
test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
||||||
const payment = await registerIndonesiaPaymentMocks(page);
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
await prepareIndonesiaUser(page);
|
await prepareIndonesiaUser(page);
|
||||||
await page.goto("/subscription?type=topup");
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
await expect(page.getByText("Supporting Elio")).toHaveCount(0);
|
||||||
await expectCheckoutButtonLayout(page);
|
await expectCheckoutButtonLayout(page);
|
||||||
|
|
||||||
const orderId = await expectQrisOrder(
|
const orderId = await expectQrisOrder(
|
||||||
@@ -266,6 +275,80 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
|||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("closing QRIS restores a resumable checkout without creating a duplicate order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
|
await prepareIndonesiaUser(page);
|
||||||
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
|
||||||
|
const orderId = await expectQrisOrder(
|
||||||
|
page,
|
||||||
|
/Pay and Top Up/i,
|
||||||
|
"dol_1000",
|
||||||
|
/Rp\s*88[.,]900/,
|
||||||
|
);
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||||
|
await dialog.getByRole("button", { name: "Close" }).click();
|
||||||
|
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
const resumeButton = page.getByRole("button", {
|
||||||
|
name: "Resume QRIS payment",
|
||||||
|
});
|
||||||
|
await expect(resumeButton).toBeEnabled();
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
|
||||||
|
await resumeButton.click();
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
|
||||||
|
payment.markPaid(orderId);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("alertdialog", { name: "Payment successful" }),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing QRIS allows switching to Stripe and creating a Stripe order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
|
await prepareIndonesiaUser(page);
|
||||||
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
|
||||||
|
await expectQrisOrder(
|
||||||
|
page,
|
||||||
|
/Pay and Top Up/i,
|
||||||
|
"dol_1000",
|
||||||
|
/Rp\s*88[.,]900/,
|
||||||
|
);
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||||
|
await dialog.getByRole("button", { name: "Close" }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
|
||||||
|
const stripeButton = page.getByRole("button", {
|
||||||
|
name: "Stripe",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(stripeButton).toBeEnabled();
|
||||||
|
await stripeButton.click();
|
||||||
|
await expect(stripeButton).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Resume QRIS payment" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
const stripeRequestPromise = page.waitForRequest(
|
||||||
|
"**/api/payment/create-order",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Pay and Top Up" }).click();
|
||||||
|
const stripeRequest = await stripeRequestPromise;
|
||||||
|
expect(stripeRequest.postDataJSON()).toMatchObject({
|
||||||
|
planId: "dol_1000",
|
||||||
|
payChannel: "stripe",
|
||||||
|
recipientCharacterId: "elio",
|
||||||
|
});
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
test("payment issue submits Other to the feedback API without creating an order", async ({
|
test("payment issue submits Other to the feedback API without creating an order", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -323,6 +406,18 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
|
|||||||
"tip_coffee_usd_4_99",
|
"tip_coffee_usd_4_99",
|
||||||
/Rp\s*89[.,]299/,
|
/Rp\s*89[.,]299/,
|
||||||
);
|
);
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||||
|
await dialog.getByRole("button", { name: "Close" }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
|
||||||
|
const resumeButton = page.getByRole("button", {
|
||||||
|
name: "Resume QRIS payment",
|
||||||
|
});
|
||||||
|
await expect(resumeButton).toBeEnabled();
|
||||||
|
await resumeButton.click();
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
|
||||||
payment.markPaid(orderId);
|
payment.markPaid(orderId);
|
||||||
|
|
||||||
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("serves the VIP membership benefits agreement from the same site", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const response = await page.goto("/legal/vip-membership-benefits.html");
|
||||||
|
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "VIP Membership Benefits Agreement" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serves the automatic renewal agreement from the same site", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const response = await page.goto("/legal/automatic-renewal.html");
|
||||||
|
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Automatic Renewal Agreement" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<title>Automatic Renewal Agreement | Cozsweet</title>
|
||||||
|
<style>
|
||||||
|
:root { font-family: Georgia, "Times New Roman", serif; color: #2b1721; background: #fff8fb; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; padding: 24px 16px 48px; line-height: 1.65; }
|
||||||
|
main { width: min(100%, 760px); margin: 0 auto; padding: clamp(22px, 6vw, 48px); border: 1px solid #f3c6dc; border-radius: 24px; background: #fff; box-shadow: 0 18px 50px rgba(133, 45, 88, .1); }
|
||||||
|
h1 { margin: 0 0 8px; font-size: clamp(28px, 7vw, 42px); line-height: 1.12; }
|
||||||
|
h2 { margin: 28px 0 8px; font-size: 21px; }
|
||||||
|
p, li { font-family: Arial, Helvetica, sans-serif; color: #553d49; }
|
||||||
|
.meta { margin: 0 0 28px; color: #8c6176; font-size: 14px; }
|
||||||
|
.notice { padding: 14px 16px; border-radius: 14px; background: #fff0f7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Automatic Renewal Agreement</h1>
|
||||||
|
<p class="meta">Effective date: July 29, 2026</p>
|
||||||
|
|
||||||
|
<p class="notice">
|
||||||
|
By selecting <strong>Confirm</strong> before payment, you authorize Cozsweet and its payment provider to renew the selected membership automatically under the terms shown below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. When renewal applies</h2>
|
||||||
|
<p>
|
||||||
|
Automatic renewal applies only when the selected membership is identified as auto-renewing in the purchase flow. One-time credit purchases, tips, gifts, lifetime plans, and payment methods explicitly shown as one-time purchases do not automatically renew.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Billing frequency</h2>
|
||||||
|
<p>
|
||||||
|
The plan renews at the end of the billing period shown at checkout, such as monthly, quarterly, or annually. Renewal continues until cancelled. Your payment method may be charged shortly before or on the start of the next period as permitted by the payment provider.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Renewal amount</h2>
|
||||||
|
<p>
|
||||||
|
The initial charge and renewal amount are displayed before confirmation. A promotion may reduce the first charge without reducing later renewals. Unless another renewal amount is shown, renewal uses the applicable regular price for the selected plan, together with any taxes or fees required by law.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Confirmation</h2>
|
||||||
|
<p>
|
||||||
|
Cozsweet asks for renewal confirmation when an account first attempts to pay for an automatically renewing membership in the current agreement version. Cancelling the confirmation does not create an order. Confirming records the acknowledgement in the current browser and continues to payment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Cancellation</h2>
|
||||||
|
<p>
|
||||||
|
You may cancel future renewal using a cancellation option made available in the product or by using the support/payment-issue path before the next renewal is processed. Cancellation takes effect for future billing; membership already paid for normally remains available until the end of the current term.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Failed or disputed payments</h2>
|
||||||
|
<p>
|
||||||
|
A failed renewal can interrupt membership benefits. If you see a duplicate charge, a long-pending payment, or missing benefits, do not repeatedly submit payment. Use <strong>Payment issue?</strong> on the purchase page and keep the returned Feedback ID. Never provide passwords, full card numbers, security codes, verification codes, or access tokens.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Material changes</h2>
|
||||||
|
<p>
|
||||||
|
If the renewal terms materially change, Cozsweet will update this agreement and request a new acknowledgement when required. The version effective for a payment is the version linked from its confirmation dialog.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<title>VIP Membership Benefits Agreement | Cozsweet</title>
|
||||||
|
<style>
|
||||||
|
:root { font-family: Georgia, "Times New Roman", serif; color: #2b1721; background: #fff8fb; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; padding: 24px 16px 48px; line-height: 1.65; }
|
||||||
|
main { width: min(100%, 760px); margin: 0 auto; padding: clamp(22px, 6vw, 48px); border: 1px solid #f3c6dc; border-radius: 24px; background: #fff; box-shadow: 0 18px 50px rgba(133, 45, 88, .1); }
|
||||||
|
h1 { margin: 0 0 8px; font-size: clamp(28px, 7vw, 42px); line-height: 1.12; }
|
||||||
|
h2 { margin: 28px 0 8px; font-size: 21px; }
|
||||||
|
p, li { font-family: Arial, Helvetica, sans-serif; color: #553d49; }
|
||||||
|
.meta { margin: 0 0 28px; color: #8c6176; font-size: 14px; }
|
||||||
|
a { color: #d8327c; }
|
||||||
|
.notice { padding: 14px 16px; border-radius: 14px; background: #fff0f7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>VIP Membership Benefits Agreement</h1>
|
||||||
|
<p class="meta">Effective date: July 29, 2026</p>
|
||||||
|
|
||||||
|
<p class="notice">
|
||||||
|
This agreement explains the VIP membership displayed on the Cozsweet purchase page. The plan name, price, currency, duration, credits, and benefits shown at checkout are the terms that apply to your purchase.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Membership benefits</h2>
|
||||||
|
<p>
|
||||||
|
VIP may include the chat access, credits, private content access, or other benefits displayed for the selected plan. Benefits are available only for the membership period and account shown at checkout. A membership does not guarantee that every feature or item is free; some content can still require credits or a separate purchase when clearly shown in the product.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Plan term and account</h2>
|
||||||
|
<p>
|
||||||
|
Membership is attached to the Cozsweet account used for payment. The selected monthly, quarterly, annual, lifetime, or other plan term is shown before payment. Do not share payment credentials or account access with another person.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Automatic renewal</h2>
|
||||||
|
<p>
|
||||||
|
A plan marked as automatically renewing continues until cancelled. Before the first automatically renewing purchase, Cozsweet displays a separate confirmation. Please also read the <a href="/legal/automatic-renewal.html">Automatic Renewal Agreement</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Prices and promotions</h2>
|
||||||
|
<p>
|
||||||
|
The checkout page is the source of truth for the current amount and currency. A first-payment or limited promotion can apply only to the initial charge; the renewal amount may be the regular price displayed in the renewal notice. Cozsweet does not ask you to pay a price that is not shown in the checkout flow.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Payment and access</h2>
|
||||||
|
<p>
|
||||||
|
Benefits are activated only after payment is confirmed and the order is fulfilled. A pending or failed payment does not activate membership. If payment is duplicated, remains pending, or benefits are missing, do not submit repeated payments; use the <strong>Payment issue?</strong> entry on the purchase page and retain the returned Feedback ID.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Cancellation, refunds, and applicable rights</h2>
|
||||||
|
<p>
|
||||||
|
Cancellation stops future renewal but does not normally remove benefits already paid for during the current term. Refund rights depend on applicable law, the payment provider, and the circumstances of the order. Use the product support or payment-issue path for review; never send a password, card number, security code, verification code, or access token.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Changes</h2>
|
||||||
|
<p>
|
||||||
|
If a material membership term changes, the updated version and effective date will be made available before it applies to a new purchase or renewal where required.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { createCheckoutHandoff, openUrlWithExternalBrowser } = vi.hoisted(
|
||||||
|
() => ({
|
||||||
|
createCheckoutHandoff: vi.fn(),
|
||||||
|
openUrlWithExternalBrowser: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/checkout_handoff", () => ({ createCheckoutHandoff }));
|
||||||
|
vi.mock("@/utils/url-launcher-util", () => ({
|
||||||
|
UrlLauncherUtil: { openUrlWithExternalBrowser },
|
||||||
|
}));
|
||||||
|
vi.mock("@/utils/browser-detect", () => ({
|
||||||
|
BrowserDetector: { isFacebookInAppBrowser: () => true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ExternalBrowserCheckoutButton } from "../external-browser-checkout-button";
|
||||||
|
|
||||||
|
describe("ExternalBrowserCheckoutButton", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
createCheckoutHandoff.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
externalUrl:
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque",
|
||||||
|
expiresAt: "2026-07-28T16:00:00+00:00",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates only a handoff before opening the external browser", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<ExternalBrowserCheckoutButton
|
||||||
|
characterSlug="maya"
|
||||||
|
checkoutIntent={{
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = container.querySelector("button");
|
||||||
|
expect(button?.textContent).toContain(
|
||||||
|
"Open in browser for more payment methods",
|
||||||
|
);
|
||||||
|
await act(async () => button?.click());
|
||||||
|
|
||||||
|
expect(createCheckoutHandoff).toHaveBeenCalledWith({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
});
|
||||||
|
expect(openUrlWithExternalBrowser).toHaveBeenCalledWith(
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque&character=maya",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,8 @@ const hiddenLaunch = {
|
|||||||
shouldShowQrisDialog: false,
|
shouldShowQrisDialog: false,
|
||||||
shouldShowStripeDialog: false,
|
shouldShowStripeDialog: false,
|
||||||
stripeClientSecret: null,
|
stripeClientSecret: null,
|
||||||
|
stripeCustomerSessionClientSecret: null,
|
||||||
|
savedPaymentMethodsEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("PaymentLaunchDialogs", () => {
|
describe("PaymentLaunchDialogs", () => {
|
||||||
|
|||||||
@@ -2,18 +2,57 @@ import { act } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { StripePaymentDialogLoading } from "../lazy-stripe-payment-dialog";
|
const stripeConfirmPayment = vi.fn(async () => ({}));
|
||||||
|
const elementsSubmit = vi.fn(async () => ({}));
|
||||||
|
let capturedElementsOptions: Record<string, unknown> | null = null;
|
||||||
|
let capturedExpressProps: Record<string, unknown> | null = null;
|
||||||
|
let capturedPaymentProps: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = "pk_test_checkout";
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@stripe/stripe-js", () => ({
|
||||||
|
loadStripe: vi.fn(() => Promise.resolve({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@stripe/react-stripe-js", () => ({
|
||||||
|
Elements: ({
|
||||||
|
children,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
capturedElementsOptions = options;
|
||||||
|
return <>{children}</>;
|
||||||
|
},
|
||||||
|
ExpressCheckoutElement: (props: Record<string, unknown>) => {
|
||||||
|
capturedExpressProps = props;
|
||||||
|
return <div data-testid="express-checkout" />;
|
||||||
|
},
|
||||||
|
PaymentElement: (props: Record<string, unknown>) => {
|
||||||
|
capturedPaymentProps = props;
|
||||||
|
return <div data-testid="payment-element" />;
|
||||||
|
},
|
||||||
|
useElements: () => ({ submit: elementsSubmit }),
|
||||||
|
useStripe: () => ({ confirmPayment: stripeConfirmPayment }),
|
||||||
|
}));
|
||||||
|
|
||||||
import { StripePaymentDialog } from "../stripe-payment-dialog";
|
import { StripePaymentDialog } from "../stripe-payment-dialog";
|
||||||
|
|
||||||
describe("Stripe payment portal states", () => {
|
describe("StripePaymentDialog", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
capturedElementsOptions = null;
|
||||||
|
capturedExpressProps = null;
|
||||||
|
capturedPaymentProps = null;
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
container.style.transform = "translateX(50%)";
|
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
});
|
});
|
||||||
@@ -24,40 +63,129 @@ describe("Stripe payment portal states", () => {
|
|||||||
document.body.style.overflow = "";
|
document.body.style.overflow = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
it("portals the unavailable state and keeps its explicit close action", () => {
|
it("renders express methods and wallets before card with English Stripe copy", () => {
|
||||||
const onClose = vi.fn();
|
|
||||||
|
|
||||||
act(() =>
|
act(() =>
|
||||||
root.render(
|
root.render(
|
||||||
<StripePaymentDialog
|
<StripePaymentDialog
|
||||||
clientSecret="client-secret"
|
clientSecret="pi_123_secret_payment"
|
||||||
onClose={onClose}
|
customerSessionClientSecret="cuss_123_secret_saved"
|
||||||
|
savedPaymentMethodsEnabled
|
||||||
|
onClose={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const dialog = document.body.querySelector('[role="alertdialog"]');
|
expect(capturedElementsOptions).toMatchObject({
|
||||||
expect(dialog).not.toBeNull();
|
clientSecret: "pi_123_secret_payment",
|
||||||
expect(container.contains(dialog)).toBe(false);
|
customerSessionClientSecret: "cuss_123_secret_saved",
|
||||||
expect(dialog?.textContent).toContain("Payment unavailable");
|
locale: "en",
|
||||||
expect(dialog?.getAttribute("aria-labelledby")).toBeTruthy();
|
});
|
||||||
expect(dialog?.getAttribute("aria-describedby")).toBeTruthy();
|
expect(capturedExpressProps?.options).toMatchObject({
|
||||||
|
paymentMethodOrder: [
|
||||||
const okButton = Array.from(dialog?.querySelectorAll("button") ?? []).find(
|
"link",
|
||||||
(button) => button.textContent === "OK",
|
"paypal",
|
||||||
);
|
"amazon_pay",
|
||||||
act(() => okButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
"klarna",
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
],
|
||||||
|
paymentMethods: {
|
||||||
|
applePay: "never",
|
||||||
|
googlePay: "never",
|
||||||
|
link: "auto",
|
||||||
|
paypal: "auto",
|
||||||
|
amazonPay: "auto",
|
||||||
|
klarna: "auto",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(capturedPaymentProps?.options).toMatchObject({
|
||||||
|
layout: { type: "accordion", defaultCollapsed: true },
|
||||||
|
paymentMethodOrder: ["wechat_pay", "alipay", "card"],
|
||||||
|
wallets: { applePay: "auto", googlePay: "auto", link: "never" },
|
||||||
|
});
|
||||||
|
const text = document.body.textContent ?? "";
|
||||||
|
expect(text).toContain("Choose a payment method");
|
||||||
|
const express = document.body.querySelector('[data-testid="express-checkout"]');
|
||||||
|
const card = document.body.querySelector('[data-testid="payment-element"]');
|
||||||
|
expect(express).not.toBeNull();
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
express!.compareDocumentPosition(card!) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("portals the lazy loading state", () => {
|
it("keeps Payment Element wallet fallbacks when Express Checkout is unavailable", () => {
|
||||||
act(() => root.render(<StripePaymentDialogLoading />));
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="pi_123_secret_payment"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const dialog = document.body.querySelector('[role="dialog"]');
|
const onReady = capturedExpressProps?.onReady as
|
||||||
expect(dialog).not.toBeNull();
|
| ((event: { availablePaymentMethods: undefined }) => void)
|
||||||
expect(container.contains(dialog)).toBe(false);
|
| undefined;
|
||||||
expect(dialog?.textContent).toContain("Preparing secure payment");
|
expect(onReady).toBeTypeOf("function");
|
||||||
expect(dialog?.textContent).toContain("Loading payment methods...");
|
act(() => onReady?.({ availablePaymentMethods: undefined }));
|
||||||
expect(dialog?.querySelector('[aria-busy="true"]')).not.toBeNull();
|
|
||||||
|
const expressSection = document.body.querySelector(
|
||||||
|
'[aria-label="Express payment methods"]',
|
||||||
|
);
|
||||||
|
expect(expressSection).toHaveProperty("hidden", true);
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="payment-element"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(capturedPaymentProps?.options).toMatchObject({
|
||||||
|
wallets: { applePay: "auto", googlePay: "auto", link: "never" },
|
||||||
|
paymentMethodOrder: ["wechat_pay", "alipay", "card"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pass an invalid or disabled Customer Session secret", () => {
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="pi_123_secret_payment"
|
||||||
|
customerSessionClientSecret="invalid"
|
||||||
|
savedPaymentMethodsEnabled={false}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capturedElementsOptions).not.toHaveProperty(
|
||||||
|
"customerSessionClientSecret",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the same confirmation path for an Express Checkout approval", async () => {
|
||||||
|
const onConfirmed = vi.fn();
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="pi_123_secret_payment"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onConfirmed={onConfirmed}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConfirm = capturedExpressProps?.onConfirm as
|
||||||
|
| ((event: { paymentFailed: ReturnType<typeof vi.fn> }) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
expect(onConfirm).toBeTypeOf("function");
|
||||||
|
await act(async () => {
|
||||||
|
await onConfirm?.({ paymentFailed: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stripeConfirmPayment).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stripeConfirmPayment).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
elements: expect.any(Object),
|
||||||
|
redirect: "if_required",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onConfirmed).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import type { CheckoutIntent } from "@/data/schemas/auth";
|
||||||
|
import {
|
||||||
|
DEFAULT_CHARACTER_SLUG,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
|
import { createCheckoutHandoff } from "@/lib/auth/checkout_handoff";
|
||||||
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
import { UrlLauncherUtil } from "@/utils/url-launcher-util";
|
||||||
|
|
||||||
|
export interface ExternalBrowserCheckoutButtonProps {
|
||||||
|
checkoutIntent: CheckoutIntent;
|
||||||
|
disabled?: boolean;
|
||||||
|
characterSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExternalBrowserCheckoutButton({
|
||||||
|
checkoutIntent,
|
||||||
|
disabled = false,
|
||||||
|
characterSlug = DEFAULT_CHARACTER_SLUG,
|
||||||
|
}: ExternalBrowserCheckoutButtonProps) {
|
||||||
|
const isFacebookBrowser = useSyncExternalStore(
|
||||||
|
() => () => undefined,
|
||||||
|
() => BrowserDetector.isFacebookInAppBrowser(),
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const [isOpening, setIsOpening] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!isFacebookBrowser) return null;
|
||||||
|
|
||||||
|
const handleOpen = async () => {
|
||||||
|
if (disabled || isOpening) return;
|
||||||
|
setIsOpening(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
const result = await createCheckoutHandoff(checkoutIntent);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
setErrorMessage(
|
||||||
|
ExceptionHandler.message(
|
||||||
|
result.error,
|
||||||
|
"Could not open this checkout in your browser. Please sign in and try again.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsOpening(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const verifiedCharacter =
|
||||||
|
getCharacterBySlug(characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
|
||||||
|
const externalUrl = new URL(
|
||||||
|
result.data.externalUrl,
|
||||||
|
window.location.origin,
|
||||||
|
);
|
||||||
|
externalUrl.searchParams.set("character", verifiedCharacter);
|
||||||
|
UrlLauncherUtil.openUrlWithExternalBrowser(externalUrl.toString());
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(
|
||||||
|
ExceptionHandler.message(
|
||||||
|
error,
|
||||||
|
"Could not open this checkout in your browser. Please try again.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsOpening(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-2 w-full text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-h-11 w-full cursor-pointer rounded-full border border-black/15 bg-white px-4 py-2.5 text-sm font-semibold text-text-foreground disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
|
disabled={disabled || isOpening}
|
||||||
|
onClick={() => void handleOpen()}
|
||||||
|
>
|
||||||
|
{isOpening
|
||||||
|
? "Opening browser..."
|
||||||
|
: "Open in browser for more payment methods"}
|
||||||
|
</button>
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="mt-2 text-sm text-[#c0392b]" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ type PaymentLaunchDialogFlow = Pick<
|
|||||||
| "shouldShowQrisDialog"
|
| "shouldShowQrisDialog"
|
||||||
| "shouldShowStripeDialog"
|
| "shouldShowStripeDialog"
|
||||||
| "stripeClientSecret"
|
| "stripeClientSecret"
|
||||||
|
| "stripeCustomerSessionClientSecret"
|
||||||
|
| "savedPaymentMethodsEnabled"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface PaymentLaunchDialogsProps {
|
export interface PaymentLaunchDialogsProps {
|
||||||
@@ -64,6 +66,10 @@ export function PaymentLaunchDialogs({
|
|||||||
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
||||||
<LazyStripePaymentDialog
|
<LazyStripePaymentDialog
|
||||||
clientSecret={launch.stripeClientSecret}
|
clientSecret={launch.stripeClientSecret}
|
||||||
|
customerSessionClientSecret={
|
||||||
|
launch.stripeCustomerSessionClientSecret
|
||||||
|
}
|
||||||
|
savedPaymentMethodsEnabled={launch.savedPaymentMethodsEnabled}
|
||||||
returnPath={stripeReturnPath}
|
returnPath={stripeReturnPath}
|
||||||
onClose={launch.handleStripeClose}
|
onClose={launch.handleStripeClose}
|
||||||
onConfirmed={launch.handleStripeConfirmed}
|
onConfirmed={launch.handleStripeConfirmed}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export const stripePaymentDialogStyles = {
|
|||||||
content:
|
content:
|
||||||
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
|
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
|
||||||
form: "flex flex-col gap-(--page-section-gap,18px)",
|
form: "flex flex-col gap-(--page-section-gap,18px)",
|
||||||
|
expressSection: "w-full empty:hidden",
|
||||||
|
cardSection:
|
||||||
|
"flex w-full flex-col gap-(--spacing-sm,8px) border-t border-black/10 pt-(--page-section-gap,18px)",
|
||||||
|
cardTitle:
|
||||||
|
"m-0 text-(length:--responsive-body,16px) font-semibold text-text-foreground",
|
||||||
error:
|
error:
|
||||||
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
|
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
|
||||||
actions: "flex w-full gap-(--spacing-md,12px)",
|
actions: "flex w-full gap-(--spacing-md,12px)",
|
||||||
|
|||||||
@@ -4,19 +4,28 @@
|
|||||||
*
|
*
|
||||||
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
||||||
*/
|
*/
|
||||||
import { useId, useState, type FormEvent } from "react";
|
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
|
||||||
import {
|
import {
|
||||||
Elements,
|
Elements,
|
||||||
|
ExpressCheckoutElement,
|
||||||
PaymentElement,
|
PaymentElement,
|
||||||
useElements,
|
useElements,
|
||||||
useStripe,
|
useStripe,
|
||||||
} from "@stripe/react-stripe-js";
|
} from "@stripe/react-stripe-js";
|
||||||
import { loadStripe } from "@stripe/stripe-js";
|
import {
|
||||||
|
loadStripe,
|
||||||
|
type AvailablePaymentMethods,
|
||||||
|
type StripeExpressCheckoutElementConfirmEvent,
|
||||||
|
} from "@stripe/stripe-js";
|
||||||
|
|
||||||
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||||
import { ExceptionHandler } from "@/core/errors";
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
|
import { PlatformDetector } from "@/utils/platform-detect";
|
||||||
|
import { getStripeCustomerSessionClientSecret } from "@/lib/payment/payment_launch";
|
||||||
|
import { behaviorAnalytics } from "@/lib/analytics";
|
||||||
|
|
||||||
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
||||||
|
|
||||||
@@ -26,8 +35,16 @@ const stripePromise = stripePublishableKey
|
|||||||
? loadStripe(stripePublishableKey)
|
? loadStripe(stripePublishableKey)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
type ExpressAvailabilityValue = boolean | { available: boolean } | undefined;
|
||||||
|
|
||||||
|
function isExpressMethodAvailable(value: ExpressAvailabilityValue): boolean {
|
||||||
|
return value === true || (typeof value === "object" && value.available);
|
||||||
|
}
|
||||||
|
|
||||||
export interface StripePaymentDialogProps {
|
export interface StripePaymentDialogProps {
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
|
customerSessionClientSecret?: string | null;
|
||||||
|
savedPaymentMethodsEnabled?: boolean;
|
||||||
returnPath?: string;
|
returnPath?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirmed?: () => void;
|
onConfirmed?: () => void;
|
||||||
@@ -35,12 +52,19 @@ export interface StripePaymentDialogProps {
|
|||||||
|
|
||||||
export function StripePaymentDialog({
|
export function StripePaymentDialog({
|
||||||
clientSecret,
|
clientSecret,
|
||||||
|
customerSessionClientSecret = null,
|
||||||
|
savedPaymentMethodsEnabled = false,
|
||||||
returnPath = ROUTES.subscription + "/success",
|
returnPath = ROUTES.subscription + "/success",
|
||||||
onClose,
|
onClose,
|
||||||
onConfirmed,
|
onConfirmed,
|
||||||
}: StripePaymentDialogProps) {
|
}: StripePaymentDialogProps) {
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const descriptionId = useId();
|
const descriptionId = useId();
|
||||||
|
const savedCardClientSecret = getStripeCustomerSessionClientSecret({
|
||||||
|
provider: "stripe",
|
||||||
|
customerSessionClientSecret,
|
||||||
|
savedPaymentMethodsEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
if (!stripePromise) {
|
if (!stripePromise) {
|
||||||
return (
|
return (
|
||||||
@@ -100,6 +124,10 @@ export function StripePaymentDialog({
|
|||||||
stripe={stripePromise}
|
stripe={stripePromise}
|
||||||
options={{
|
options={{
|
||||||
clientSecret,
|
clientSecret,
|
||||||
|
locale: "en",
|
||||||
|
...(savedCardClientSecret
|
||||||
|
? { customerSessionClientSecret: savedCardClientSecret }
|
||||||
|
: {}),
|
||||||
appearance: {
|
appearance: {
|
||||||
theme: "stripe",
|
theme: "stripe",
|
||||||
variables: {
|
variables: {
|
||||||
@@ -132,6 +160,20 @@ function StripePaymentForm({
|
|||||||
const elements = useElements();
|
const elements = useElements();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const submittingRef = useRef(false);
|
||||||
|
const [hasExpressPaymentMethods, setHasExpressPaymentMethods] = useState<
|
||||||
|
boolean | null
|
||||||
|
>(null);
|
||||||
|
const [expressMaxColumns, setExpressMaxColumns] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== "function") return;
|
||||||
|
const query = window.matchMedia("(min-width: 640px)");
|
||||||
|
const updateColumns = () => setExpressMaxColumns(query.matches ? 2 : 1);
|
||||||
|
updateColumns();
|
||||||
|
query.addEventListener("change", updateColumns);
|
||||||
|
return () => query.removeEventListener("change", updateColumns);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLoadError = (event: Parameters<
|
const handleLoadError = (event: Parameters<
|
||||||
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
|
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
|
||||||
@@ -150,38 +192,118 @@ function StripePaymentForm({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const updateExpressAvailability = (
|
||||||
event.preventDefault();
|
availablePaymentMethods:
|
||||||
if (!stripe || !elements || isSubmitting) return;
|
| AvailablePaymentMethods
|
||||||
|
| {
|
||||||
|
applePay?: { available: boolean };
|
||||||
|
googlePay?: { available: boolean };
|
||||||
|
link?: { available: boolean };
|
||||||
|
paypal?: { available: boolean };
|
||||||
|
amazonPay?: { available: boolean };
|
||||||
|
klarna?: { available: boolean };
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
) => {
|
||||||
|
const availability = {
|
||||||
|
applePay: isExpressMethodAvailable(availablePaymentMethods?.applePay),
|
||||||
|
googlePay: isExpressMethodAvailable(availablePaymentMethods?.googlePay),
|
||||||
|
link: isExpressMethodAvailable(availablePaymentMethods?.link),
|
||||||
|
paypal: isExpressMethodAvailable(availablePaymentMethods?.paypal),
|
||||||
|
amazonPay: isExpressMethodAvailable(availablePaymentMethods?.amazonPay),
|
||||||
|
klarna: isExpressMethodAvailable(availablePaymentMethods?.klarna),
|
||||||
|
};
|
||||||
|
setHasExpressPaymentMethods(Object.values(availability).some(Boolean));
|
||||||
|
const browser = BrowserDetector.isFacebookInAppBrowser()
|
||||||
|
? "facebook"
|
||||||
|
: BrowserDetector.getBrowserName() || "unknown";
|
||||||
|
const platform = PlatformDetector.getPlatform();
|
||||||
|
const metadata = {
|
||||||
|
browser,
|
||||||
|
platform,
|
||||||
|
availablePaymentMethods: availability,
|
||||||
|
};
|
||||||
|
log.info(
|
||||||
|
"[stripe-payment-dialog] Express Checkout availability",
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"stripe.express_checkout_availability",
|
||||||
|
"Stripe Express Checkout availability",
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExpressLoadError = (event: Parameters<
|
||||||
|
NonNullable<
|
||||||
|
React.ComponentProps<typeof ExpressCheckoutElement>["onLoadError"]
|
||||||
|
>
|
||||||
|
>[0]) => {
|
||||||
|
setHasExpressPaymentMethods(false);
|
||||||
|
log.warn("[stripe-payment-dialog] Express Checkout load failed", {
|
||||||
|
type: event.error.type,
|
||||||
|
code: event.error.code,
|
||||||
|
message: event.error.message,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPayment = async (
|
||||||
|
options: {
|
||||||
|
submitPaymentElement: boolean;
|
||||||
|
expressEvent?: StripeExpressCheckoutElementConfirmEvent;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (!stripe || !elements || submittingRef.current) return;
|
||||||
|
|
||||||
|
submittingRef.current = true;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
const { error: submitError } = await elements.submit();
|
if (options.submitPaymentElement) {
|
||||||
|
let submitError: unknown;
|
||||||
|
try {
|
||||||
|
({ error: submitError } = await elements.submit());
|
||||||
|
} catch (error) {
|
||||||
|
submitError = error;
|
||||||
|
}
|
||||||
if (submitError) {
|
if (submitError) {
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
ExceptionHandler.message(submitError, "Please check your payment info."),
|
ExceptionHandler.message(
|
||||||
|
submitError,
|
||||||
|
"Please check your payment info.",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
submittingRef.current = false;
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const returnUrl = new URL(
|
const returnUrl = new URL(
|
||||||
returnPath ?? ROUTES.subscription + "/success",
|
returnPath ?? ROUTES.subscription + "/success",
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
);
|
);
|
||||||
const { error } = await stripe.confirmPayment({
|
let confirmationError: unknown;
|
||||||
|
try {
|
||||||
|
({ error: confirmationError } = await stripe.confirmPayment({
|
||||||
elements,
|
elements,
|
||||||
confirmParams: {
|
confirmParams: {
|
||||||
return_url: returnUrl.toString(),
|
return_url: returnUrl.toString(),
|
||||||
},
|
},
|
||||||
redirect: "if_required",
|
redirect: "if_required",
|
||||||
});
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
confirmationError = error;
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (confirmationError) {
|
||||||
setErrorMessage(
|
const message = ExceptionHandler.message(
|
||||||
ExceptionHandler.message(error, "Payment confirmation failed."),
|
confirmationError,
|
||||||
|
"Payment confirmation failed.",
|
||||||
);
|
);
|
||||||
|
setErrorMessage(message);
|
||||||
|
options.expressEvent?.paymentFailed({ reason: "fail", message });
|
||||||
|
submittingRef.current = false;
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -189,9 +311,75 @@ function StripePaymentForm({
|
|||||||
onConfirmed?.();
|
onConfirmed?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await confirmPayment({ submitPaymentElement: true });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className={styles.form} onSubmit={handleSubmit}>
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
<PaymentElement onLoadError={handleLoadError} />
|
<section
|
||||||
|
className={styles.expressSection}
|
||||||
|
hidden={hasExpressPaymentMethods === false}
|
||||||
|
aria-label="Express payment methods"
|
||||||
|
>
|
||||||
|
<ExpressCheckoutElement
|
||||||
|
options={{
|
||||||
|
paymentMethodOrder: [
|
||||||
|
"link",
|
||||||
|
"paypal",
|
||||||
|
"amazon_pay",
|
||||||
|
"klarna",
|
||||||
|
],
|
||||||
|
paymentMethods: {
|
||||||
|
applePay: "never",
|
||||||
|
googlePay: "never",
|
||||||
|
link: "auto",
|
||||||
|
paypal: "auto",
|
||||||
|
amazonPay: "auto",
|
||||||
|
klarna: "auto",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
maxColumns: expressMaxColumns,
|
||||||
|
maxRows: 6,
|
||||||
|
overflow: "never",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onReady={(event) =>
|
||||||
|
updateExpressAvailability(event.availablePaymentMethods)
|
||||||
|
}
|
||||||
|
onAvailablePaymentMethodsChange={(event) =>
|
||||||
|
updateExpressAvailability(event.paymentMethods)
|
||||||
|
}
|
||||||
|
onLoadError={handleExpressLoadError}
|
||||||
|
onConfirm={(event) =>
|
||||||
|
void confirmPayment({
|
||||||
|
submitPaymentElement: false,
|
||||||
|
expressEvent: event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
className={styles.cardSection}
|
||||||
|
aria-labelledby="payment-method-title"
|
||||||
|
>
|
||||||
|
<h3 id="payment-method-title" className={styles.cardTitle}>
|
||||||
|
Choose a payment method
|
||||||
|
</h3>
|
||||||
|
<PaymentElement
|
||||||
|
options={{
|
||||||
|
layout: { type: "accordion", defaultCollapsed: true },
|
||||||
|
paymentMethodOrder: ["wechat_pay", "alipay", "card"],
|
||||||
|
wallets: {
|
||||||
|
applePay: "auto",
|
||||||
|
googlePay: "auto",
|
||||||
|
link: "never",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onLoadError={handleLoadError}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getPaymentUrl,
|
getPaymentUrl,
|
||||||
|
getStripeCustomerSessionClientSecret,
|
||||||
getStripeClientSecret,
|
getStripeClientSecret,
|
||||||
isEzpayPayment,
|
isEzpayPayment,
|
||||||
launchEzpayRedirect,
|
launchEzpayRedirect,
|
||||||
@@ -48,8 +49,10 @@ export interface PaymentLaunchFlow {
|
|||||||
handleEzpayCancel: () => void;
|
handleEzpayCancel: () => void;
|
||||||
handleEzpayConfirm: () => void;
|
handleEzpayConfirm: () => void;
|
||||||
handleQrisClose: () => void;
|
handleQrisClose: () => void;
|
||||||
|
handleQrisResume: () => void;
|
||||||
handleStripeClose: () => void;
|
handleStripeClose: () => void;
|
||||||
handleStripeConfirmed: () => void;
|
handleStripeConfirmed: () => void;
|
||||||
|
hasHiddenQrisPayment: boolean;
|
||||||
isConfirmingEzpay: boolean;
|
isConfirmingEzpay: boolean;
|
||||||
qrisErrorMessage: string | null;
|
qrisErrorMessage: string | null;
|
||||||
qrisPayment: QrisPaymentDetails | null;
|
qrisPayment: QrisPaymentDetails | null;
|
||||||
@@ -59,6 +62,8 @@ export interface PaymentLaunchFlow {
|
|||||||
shouldShowQrisDialog: boolean;
|
shouldShowQrisDialog: boolean;
|
||||||
shouldShowStripeDialog: boolean;
|
shouldShowStripeDialog: boolean;
|
||||||
stripeClientSecret: string | null;
|
stripeClientSecret: string | null;
|
||||||
|
stripeCustomerSessionClientSecret: string | null;
|
||||||
|
savedPaymentMethodsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QrisPaymentDetails {
|
export interface QrisPaymentDetails {
|
||||||
@@ -165,6 +170,9 @@ export function usePaymentLaunchFlow({
|
|||||||
const stripeClientSecret = payment.payParams
|
const stripeClientSecret = payment.payParams
|
||||||
? getStripeClientSecret(payment.payParams)
|
? getStripeClientSecret(payment.payParams)
|
||||||
: null;
|
: null;
|
||||||
|
const stripeCustomerSessionClientSecret = payment.payParams
|
||||||
|
? getStripeCustomerSessionClientSecret(payment.payParams)
|
||||||
|
: null;
|
||||||
const ezpayLaunchTarget =
|
const ezpayLaunchTarget =
|
||||||
payment.payParams && isEzpayPayment(payment.payParams)
|
payment.payParams && isEzpayPayment(payment.payParams)
|
||||||
? resolveEzpayLaunchTarget(payment.payParams)
|
? resolveEzpayLaunchTarget(payment.payParams)
|
||||||
@@ -310,6 +318,12 @@ export function usePaymentLaunchFlow({
|
|||||||
qrisPayment.orderId !== hiddenQrisOrderId &&
|
qrisPayment.orderId !== hiddenQrisOrderId &&
|
||||||
!payment.isPaid,
|
!payment.isPaid,
|
||||||
);
|
);
|
||||||
|
const hasHiddenQrisPayment = Boolean(
|
||||||
|
qrisPayment &&
|
||||||
|
qrisPayment.orderId === hiddenQrisOrderId &&
|
||||||
|
payment.isPollingOrder &&
|
||||||
|
!payment.isPaid,
|
||||||
|
);
|
||||||
|
|
||||||
function resetLaunchState(): void {
|
function resetLaunchState(): void {
|
||||||
setIsConfirmingEzpay(false);
|
setIsConfirmingEzpay(false);
|
||||||
@@ -369,13 +383,24 @@ export function usePaymentLaunchFlow({
|
|||||||
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
|
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleQrisResume(): void {
|
||||||
|
if (!hasHiddenQrisPayment) return;
|
||||||
|
log.debug(`[${logScope}] qris dialog resumed`, {
|
||||||
|
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
|
||||||
|
subscriptionType,
|
||||||
|
});
|
||||||
|
setHiddenQrisOrderId(null);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ezpayPaymentUrl,
|
ezpayPaymentUrl,
|
||||||
handleEzpayCancel,
|
handleEzpayCancel,
|
||||||
handleEzpayConfirm,
|
handleEzpayConfirm,
|
||||||
handleQrisClose,
|
handleQrisClose,
|
||||||
|
handleQrisResume,
|
||||||
handleStripeClose,
|
handleStripeClose,
|
||||||
handleStripeConfirmed,
|
handleStripeConfirmed,
|
||||||
|
hasHiddenQrisPayment,
|
||||||
isConfirmingEzpay,
|
isConfirmingEzpay,
|
||||||
qrisErrorMessage: payment.errorMessage,
|
qrisErrorMessage: payment.errorMessage,
|
||||||
qrisPayment,
|
qrisPayment,
|
||||||
@@ -385,5 +410,8 @@ export function usePaymentLaunchFlow({
|
|||||||
shouldShowQrisDialog,
|
shouldShowQrisDialog,
|
||||||
shouldShowStripeDialog,
|
shouldShowStripeDialog,
|
||||||
stripeClientSecret,
|
stripeClientSecret,
|
||||||
|
stripeCustomerSessionClientSecret,
|
||||||
|
savedPaymentMethodsEnabled:
|
||||||
|
stripeCustomerSessionClientSecret !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface UsePaymentRouteFlowInput {
|
|||||||
characterId?: string;
|
characterId?: string;
|
||||||
initialCategory?: string | null;
|
initialCategory?: string | null;
|
||||||
initialPlanId?: string | null;
|
initialPlanId?: string | null;
|
||||||
|
initialAutoRenew?: boolean | null;
|
||||||
commercialOfferId?: string | null;
|
commercialOfferId?: string | null;
|
||||||
resumeOrderId?: string | null;
|
resumeOrderId?: string | null;
|
||||||
chatActionId?: string | null;
|
chatActionId?: string | null;
|
||||||
@@ -44,6 +45,7 @@ export function usePaymentRouteFlow({
|
|||||||
characterId,
|
characterId,
|
||||||
initialCategory = null,
|
initialCategory = null,
|
||||||
initialPlanId = null,
|
initialPlanId = null,
|
||||||
|
initialAutoRenew = null,
|
||||||
commercialOfferId = null,
|
commercialOfferId = null,
|
||||||
resumeOrderId = null,
|
resumeOrderId = null,
|
||||||
chatActionId = null,
|
chatActionId = null,
|
||||||
@@ -57,6 +59,7 @@ export function usePaymentRouteFlow({
|
|||||||
characterId ?? "",
|
characterId ?? "",
|
||||||
initialCategory ?? "",
|
initialCategory ?? "",
|
||||||
initialPlanId ?? "",
|
initialPlanId ?? "",
|
||||||
|
initialAutoRenew === null ? "" : String(initialAutoRenew),
|
||||||
commercialOfferId ?? "",
|
commercialOfferId ?? "",
|
||||||
chatActionId ?? "",
|
chatActionId ?? "",
|
||||||
].join(":");
|
].join(":");
|
||||||
@@ -78,6 +81,9 @@ export function usePaymentRouteFlow({
|
|||||||
...(characterId ? { characterId } : {}),
|
...(characterId ? { characterId } : {}),
|
||||||
...(initialCategory ? { category: initialCategory } : {}),
|
...(initialCategory ? { category: initialCategory } : {}),
|
||||||
...(initialPlanId ? { planId: initialPlanId } : {}),
|
...(initialPlanId ? { planId: initialPlanId } : {}),
|
||||||
|
...(initialAutoRenew === null
|
||||||
|
? {}
|
||||||
|
: { autoRenew: initialAutoRenew }),
|
||||||
...(commercialOfferId ? { commercialOfferId } : {}),
|
...(commercialOfferId ? { commercialOfferId } : {}),
|
||||||
...(chatActionId ? { chatActionId } : {}),
|
...(chatActionId ? { chatActionId } : {}),
|
||||||
});
|
});
|
||||||
@@ -89,6 +95,7 @@ export function usePaymentRouteFlow({
|
|||||||
chatActionId,
|
chatActionId,
|
||||||
initialCategory,
|
initialCategory,
|
||||||
initialPlanId,
|
initialPlanId,
|
||||||
|
initialAutoRenew,
|
||||||
initialPayChannel,
|
initialPayChannel,
|
||||||
paymentDispatch,
|
paymentDispatch,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { deriveChatSupportCta } from "../chat-support-cta";
|
||||||
|
|
||||||
|
describe("deriveChatSupportCta", () => {
|
||||||
|
const now = Date.parse("2026-07-28T00:00:00.000Z");
|
||||||
|
|
||||||
|
it("shows first recharge ahead of a weaker role offer without inventing a timer", () => {
|
||||||
|
expect(
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge: true,
|
||||||
|
commercialOffer: {
|
||||||
|
enabled: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
characterId: "elio",
|
||||||
|
planId: "vip_annual",
|
||||||
|
discountPercent: 30,
|
||||||
|
pricePercent: 70,
|
||||||
|
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||||
|
triggerReason: "price_objection",
|
||||||
|
message: "I got this for you.",
|
||||||
|
},
|
||||||
|
nowMs: now,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
kind: "firstRecharge",
|
||||||
|
label: "Support me · 50% OFF · First recharge",
|
||||||
|
commercialOfferId: null,
|
||||||
|
expiresAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a server-anchored 24-hour role offer countdown", () => {
|
||||||
|
expect(
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge: false,
|
||||||
|
commercialOffer: {
|
||||||
|
enabled: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
characterId: "maya-tan",
|
||||||
|
planId: "vip_annual",
|
||||||
|
discountPercent: 30,
|
||||||
|
pricePercent: 70,
|
||||||
|
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||||
|
triggerReason: "price_objection",
|
||||||
|
message: "I got this for you.",
|
||||||
|
},
|
||||||
|
nowMs: now,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
kind: "commercialOffer",
|
||||||
|
label: "Support me · 30% OFF · 23:59:59",
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to Support me after an offer expires or is absent", () => {
|
||||||
|
expect(
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge: false,
|
||||||
|
commercialOffer: null,
|
||||||
|
nowMs: now,
|
||||||
|
}).label,
|
||||||
|
).toBe("Support me");
|
||||||
|
expect(
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge: false,
|
||||||
|
commercialOffer: {
|
||||||
|
enabled: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
characterId: "elio",
|
||||||
|
planId: "vip_annual",
|
||||||
|
discountPercent: 30,
|
||||||
|
pricePercent: 70,
|
||||||
|
expiresAt: "2026-07-27T00:00:00.000Z",
|
||||||
|
triggerReason: "price_objection",
|
||||||
|
message: "I got this for you.",
|
||||||
|
},
|
||||||
|
nowMs: now,
|
||||||
|
}).label,
|
||||||
|
).toBe("Support me");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,8 +33,8 @@ import {
|
|||||||
ChatHeader,
|
ChatHeader,
|
||||||
ChatInputBar,
|
ChatInputBar,
|
||||||
ChatInsufficientCreditsBanner,
|
ChatInsufficientCreditsBanner,
|
||||||
|
ChatSupportButton,
|
||||||
ChatUnlockDialogs,
|
ChatUnlockDialogs,
|
||||||
FirstRechargeOfferBanner,
|
|
||||||
FullscreenImageViewer,
|
FullscreenImageViewer,
|
||||||
PwaInstallOverlay,
|
PwaInstallOverlay,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
@@ -46,7 +46,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
deriveIsGuest,
|
deriveIsGuest,
|
||||||
} from "./chat-screen.helpers";
|
} from "./chat-screen.helpers";
|
||||||
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
|
import { useChatSupportCta } from "./hooks/use-chat-support-cta";
|
||||||
|
import { useChatCommercialMessages } from "./hooks/use-chat-commercial-messages";
|
||||||
import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator";
|
import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator";
|
||||||
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
||||||
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
||||||
@@ -130,10 +131,15 @@ export function ChatScreen() {
|
|||||||
upgradeReason: state.upgradeReason,
|
upgradeReason: state.upgradeReason,
|
||||||
paymentGuidance: state.paymentGuidance,
|
paymentGuidance: state.paymentGuidance,
|
||||||
});
|
});
|
||||||
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
|
const supportCta = useChatSupportCta({
|
||||||
|
characterId: character.id,
|
||||||
historyLoaded: state.historyLoaded,
|
historyLoaded: state.historyLoaded,
|
||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
});
|
});
|
||||||
|
useChatCommercialMessages({
|
||||||
|
characterId: character.id,
|
||||||
|
loginStatus: authState.loginStatus,
|
||||||
|
});
|
||||||
const shouldShowPwaInstall =
|
const shouldShowPwaInstall =
|
||||||
state.historyLoaded && state.historyMessages.length >= 10;
|
state.historyLoaded && state.historyMessages.length >= 10;
|
||||||
useChatGuestLogin({
|
useChatGuestLogin({
|
||||||
@@ -422,13 +428,13 @@ export function ChatScreen() {
|
|||||||
<ChatHeader
|
<ChatHeader
|
||||||
isGuest={isGuest}
|
isGuest={isGuest}
|
||||||
offerBanner={
|
offerBanner={
|
||||||
<FirstRechargeOfferBanner
|
supportCta.visible ? (
|
||||||
visible={firstRechargeOfferBanner.visible}
|
<ChatSupportButton
|
||||||
discountPercent={firstRechargeOfferBanner.discountPercent}
|
kind={supportCta.kind}
|
||||||
onClick={firstRechargeOfferBanner.claim}
|
label={supportCta.label}
|
||||||
onClose={firstRechargeOfferBanner.close}
|
onClick={supportCta.open}
|
||||||
variant="compact"
|
|
||||||
/>
|
/>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { CommercialOfferSummary } from "@/data/schemas/payment";
|
||||||
|
|
||||||
|
export type ChatSupportCtaKind =
|
||||||
|
| "support"
|
||||||
|
| "firstRecharge"
|
||||||
|
| "commercialOffer";
|
||||||
|
|
||||||
|
export interface ChatSupportCtaView {
|
||||||
|
kind: ChatSupportCtaKind;
|
||||||
|
label: string;
|
||||||
|
commercialOfferId: string | null;
|
||||||
|
planId: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveChatSupportCta(input: {
|
||||||
|
isFirstRecharge: boolean;
|
||||||
|
commercialOffer: CommercialOfferSummary | null;
|
||||||
|
nowMs: number;
|
||||||
|
}): ChatSupportCtaView {
|
||||||
|
if (input.isFirstRecharge) {
|
||||||
|
return {
|
||||||
|
kind: "firstRecharge",
|
||||||
|
label: "Support me · 50% OFF · First recharge",
|
||||||
|
commercialOfferId: null,
|
||||||
|
planId: null,
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const offer = input.commercialOffer;
|
||||||
|
const expiresAtMs = offer ? Date.parse(offer.expiresAt) : Number.NaN;
|
||||||
|
if (offer?.enabled && Number.isFinite(expiresAtMs) && expiresAtMs > input.nowMs) {
|
||||||
|
return {
|
||||||
|
kind: "commercialOffer",
|
||||||
|
label: `Support me · ${offer.discountPercent}% OFF · ${formatOfferCountdown(expiresAtMs, input.nowMs)}`,
|
||||||
|
commercialOfferId: offer.commercialOfferId,
|
||||||
|
planId: offer.planId,
|
||||||
|
expiresAt: offer.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "support",
|
||||||
|
label: "Support me",
|
||||||
|
commercialOfferId: null,
|
||||||
|
planId: null,
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOfferCountdown(expiresAtMs: number, nowMs: number): string {
|
||||||
|
const totalSeconds = Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil((expiresAtMs - nowMs) / 1000) - 1,
|
||||||
|
);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return [hours, minutes, seconds]
|
||||||
|
.map((value) => String(value).padStart(2, "0"))
|
||||||
|
.join(":");
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Heart, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
import type { ChatSupportCtaKind } from "../chat-support-cta";
|
||||||
|
|
||||||
|
export interface ChatSupportButtonProps {
|
||||||
|
kind: ChatSupportCtaKind;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatSupportButton({
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: ChatSupportButtonProps) {
|
||||||
|
const Icon = kind === "support" ? Heart : Sparkles;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-analytics-key="chat.support_character"
|
||||||
|
data-support-state={kind}
|
||||||
|
className="inline-flex min-h-9 max-w-full items-center justify-center gap-1.5 rounded-full border border-[rgba(255,255,255,0.22)] bg-[linear-gradient(135deg,rgba(246,87,160,0.96),rgba(255,119,84,0.94))] px-3 py-1.5 text-center text-[12px] font-extrabold leading-tight text-white shadow-[0_8px_24px_rgba(246,87,160,0.3)] backdrop-blur-md transition active:scale-95"
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Icon className="shrink-0" size={14} strokeWidth={2.5} aria-hidden="true" />
|
||||||
|
<span className="whitespace-normal text-balance">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export * from "./chat-insufficient-credits-banner";
|
|||||||
export * from "./chat-input-bar";
|
export * from "./chat-input-bar";
|
||||||
export * from "./chat-input-text-field";
|
export * from "./chat-input-text-field";
|
||||||
export * from "./chat-send-button";
|
export * from "./chat-send-button";
|
||||||
|
export * from "./chat-support-button";
|
||||||
export * from "./chat-unlock-dialogs";
|
export * from "./chat-unlock-dialogs";
|
||||||
export * from "./date-header";
|
export * from "./date-header";
|
||||||
export * from "./first-recharge-offer-banner";
|
export * from "./first-recharge-offer-banner";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { ArrowRight, CircleDollarSign, X } from "lucide-react";
|
import { ArrowRight, CircleDollarSign, X } from "lucide-react";
|
||||||
|
|
||||||
import type { PaymentGuidance } from "@/data/schemas/chat";
|
import type { PaymentGuidance } from "@/data/schemas/chat";
|
||||||
|
import type { PaymentAnalyticsTriggerReason } from "@/lib/analytics/payment_analytics_context";
|
||||||
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
|
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
|
||||||
import { useActiveCharacter } from "@/providers/character-provider";
|
import { useActiveCharacter } from "@/providers/character-provider";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
@@ -15,6 +16,18 @@ export interface PaymentGuidanceCardProps {
|
|||||||
guidance: PaymentGuidance;
|
guidance: PaymentGuidance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerReasonForGuidance(
|
||||||
|
scene: PaymentGuidance["scene"],
|
||||||
|
): PaymentAnalyticsTriggerReason {
|
||||||
|
if (scene === "supportCharacter" || scene === "vip" || scene === "purchaseOptions") {
|
||||||
|
return "vip_cta";
|
||||||
|
}
|
||||||
|
if (scene === "privateMessageLocked" || scene === "historyLocked") {
|
||||||
|
return "private_topic_limit";
|
||||||
|
}
|
||||||
|
return "insufficient_credits";
|
||||||
|
}
|
||||||
|
|
||||||
export function PaymentGuidanceCard({ guidance }: PaymentGuidanceCardProps) {
|
export function PaymentGuidanceCard({ guidance }: PaymentGuidanceCardProps) {
|
||||||
const character = useActiveCharacter();
|
const character = useActiveCharacter();
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
@@ -91,7 +104,7 @@ export function PaymentGuidanceCard({ guidance }: PaymentGuidanceCardProps) {
|
|||||||
chatActionId: guidance.guidanceId,
|
chatActionId: guidance.guidanceId,
|
||||||
analytics: {
|
analytics: {
|
||||||
entryPoint: "chat_input",
|
entryPoint: "chat_input",
|
||||||
triggerReason: "insufficient_credits",
|
triggerReason: triggerReasonForGuidance(guidance.scene),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { createChatWebSocket } from "@/core/net/chat-websocket";
|
||||||
|
import type { LoginStatus } from "@/data/schemas/auth";
|
||||||
|
import { getSessionAccessToken } from "@/lib/auth/auth_session";
|
||||||
|
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||||
|
|
||||||
|
export function useChatCommercialMessages(input: {
|
||||||
|
characterId: string;
|
||||||
|
loginStatus: LoginStatus;
|
||||||
|
}) {
|
||||||
|
const chatDispatch = useChatDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let socket: ReturnType<typeof createChatWebSocket> | null = null;
|
||||||
|
void getSessionAccessToken().then((token) => {
|
||||||
|
if (disposed || !token) return;
|
||||||
|
socket = createChatWebSocket(token);
|
||||||
|
socket.onCommercialMessage = (message) => {
|
||||||
|
if (message.characterId !== input.characterId) return;
|
||||||
|
chatDispatch({ type: "ChatCommercialMessageReceived", message });
|
||||||
|
};
|
||||||
|
socket.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
socket?.disconnect();
|
||||||
|
};
|
||||||
|
}, [chatDispatch, input.characterId, input.loginStatus]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { shallowEqual } from "@xstate/react";
|
||||||
|
|
||||||
|
import type { LoginStatus } from "@/data/schemas/auth";
|
||||||
|
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
||||||
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
import {
|
||||||
|
usePaymentDispatch,
|
||||||
|
usePaymentSelector,
|
||||||
|
} from "@/stores/payment/payment-context";
|
||||||
|
import { useUserSelector } from "@/stores/user/user-context";
|
||||||
|
|
||||||
|
import { deriveChatSupportCta } from "../chat-support-cta";
|
||||||
|
|
||||||
|
export function useChatSupportCta(input: {
|
||||||
|
characterId: string;
|
||||||
|
historyLoaded: boolean;
|
||||||
|
loginStatus: LoginStatus;
|
||||||
|
}) {
|
||||||
|
const navigator = useAppNavigator();
|
||||||
|
const paymentDispatch = usePaymentDispatch();
|
||||||
|
const initializedCharacterRef = useRef<string | null>(null);
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
const payment = usePaymentSelector(
|
||||||
|
(state) => ({
|
||||||
|
commercialOffer: state.context.commercialOffer,
|
||||||
|
isFirstRecharge: state.context.isFirstRecharge,
|
||||||
|
supportCharacterId: state.context.supportCharacterId,
|
||||||
|
}),
|
||||||
|
shallowEqual,
|
||||||
|
);
|
||||||
|
const countryCode = useUserSelector(
|
||||||
|
(state) => state.context.currentUser?.countryCode,
|
||||||
|
);
|
||||||
|
const isAuthenticated =
|
||||||
|
input.loginStatus !== "notLoggedIn" && input.loginStatus !== "guest";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
if (initializedCharacterRef.current === input.characterId) return;
|
||||||
|
initializedCharacterRef.current = input.characterId;
|
||||||
|
paymentDispatch({
|
||||||
|
type: "PaymentInit",
|
||||||
|
characterId: input.characterId,
|
||||||
|
payChannel: getDefaultPayChannelForCountryCode(countryCode),
|
||||||
|
});
|
||||||
|
}, [countryCode, input.characterId, isAuthenticated, paymentDispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!payment.commercialOffer) return;
|
||||||
|
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [payment.commercialOffer]);
|
||||||
|
|
||||||
|
const cta = useMemo(
|
||||||
|
() =>
|
||||||
|
deriveChatSupportCta({
|
||||||
|
isFirstRecharge:
|
||||||
|
isAuthenticated && payment.supportCharacterId === input.characterId
|
||||||
|
? payment.isFirstRecharge
|
||||||
|
: false,
|
||||||
|
commercialOffer:
|
||||||
|
isAuthenticated && payment.supportCharacterId === input.characterId
|
||||||
|
? payment.commercialOffer
|
||||||
|
: null,
|
||||||
|
nowMs,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
input.characterId,
|
||||||
|
isAuthenticated,
|
||||||
|
nowMs,
|
||||||
|
payment.commercialOffer,
|
||||||
|
payment.isFirstRecharge,
|
||||||
|
payment.supportCharacterId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
navigator.openSubscription({
|
||||||
|
type: cta.planId?.startsWith("dol_") ? "topup" : "vip",
|
||||||
|
returnTo: "chat",
|
||||||
|
...(cta.planId ? { planId: cta.planId } : {}),
|
||||||
|
...(cta.commercialOfferId
|
||||||
|
? { commercialOfferId: cta.commercialOfferId }
|
||||||
|
: {}),
|
||||||
|
analytics: {
|
||||||
|
entryPoint: "chat_offer_banner",
|
||||||
|
triggerReason:
|
||||||
|
cta.kind === "support" ? "vip_cta" : "commercial_offer",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...cta, visible: input.historyLoaded, open };
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
persistExternalEntryPayload,
|
persistExternalEntryPayload,
|
||||||
|
resolveCheckoutIntentDestination,
|
||||||
resolveExternalEntryDestination,
|
resolveExternalEntryDestination,
|
||||||
resolveExternalEntryPromotionType,
|
resolveExternalEntryPromotionType,
|
||||||
resolveExternalEntryTarget,
|
resolveExternalEntryTarget,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff";
|
import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff";
|
||||||
|
import { consumeCheckoutHandoff } from "@/lib/auth/checkout_handoff";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
import {
|
import {
|
||||||
isFavoriteEntryRequest,
|
isFavoriteEntryRequest,
|
||||||
@@ -61,6 +63,9 @@ export default function ExternalEntryPersist({
|
|||||||
const [hasPersisted, setHasPersisted] = useState(false);
|
const [hasPersisted, setHasPersisted] = useState(false);
|
||||||
const [handoffError, setHandoffError] = useState<string | null>(null);
|
const [handoffError, setHandoffError] = useState<string | null>(null);
|
||||||
const [handoffCompleted, setHandoffCompleted] = useState(false);
|
const [handoffCompleted, setHandoffCompleted] = useState(false);
|
||||||
|
const [checkoutDestination, setCheckoutDestination] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const hasNavigatedRef = useRef(false);
|
const hasNavigatedRef = useRef(false);
|
||||||
const handoffStartedRef = useRef(false);
|
const handoffStartedRef = useRef(false);
|
||||||
const targetRoute = resolveExternalEntryTarget({ target });
|
const targetRoute = resolveExternalEntryTarget({ target });
|
||||||
@@ -71,11 +76,14 @@ export default function ExternalEntryPersist({
|
|||||||
mode,
|
mode,
|
||||||
promotionType,
|
promotionType,
|
||||||
});
|
});
|
||||||
const isTopUpHandoff = targetRoute === ROUTES.subscription;
|
const normalizedTarget = target?.trim().toLowerCase() ?? "";
|
||||||
|
const isTopUpHandoff = normalizedTarget === "topup";
|
||||||
|
const isCheckoutHandoff = normalizedTarget === "checkout";
|
||||||
|
const isLoginHandoff = isTopUpHandoff || isCheckoutHandoff;
|
||||||
const displayedHandoffError =
|
const displayedHandoffError =
|
||||||
handoffError ??
|
handoffError ??
|
||||||
(isTopUpHandoff && !hasValue(handoffToken)
|
(isLoginHandoff && !hasValue(handoffToken)
|
||||||
? "This top-up link is invalid. Please request a new link in Messenger."
|
? "This checkout link is invalid. Please return and request a new link."
|
||||||
: null);
|
: null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -128,7 +136,7 @@ export default function ExternalEntryPersist({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasNavigatedRef.current || !hasPersisted) return;
|
if (hasNavigatedRef.current || !hasPersisted) return;
|
||||||
|
|
||||||
if (isTopUpHandoff) {
|
if (isLoginHandoff) {
|
||||||
if (!authState.hasInitialized || authState.isLoading) return;
|
if (!authState.hasInitialized || authState.isLoading) return;
|
||||||
if (handoffCompleted) {
|
if (handoffCompleted) {
|
||||||
if (
|
if (
|
||||||
@@ -138,16 +146,45 @@ export default function ExternalEntryPersist({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasNavigatedRef.current = true;
|
hasNavigatedRef.current = true;
|
||||||
router.replace(destination);
|
router.replace(checkoutDestination ?? destination);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (handoffStartedRef.current) return;
|
if (handoffStartedRef.current) return;
|
||||||
if (!hasValue(handoffToken)) return;
|
if (!hasValue(handoffToken)) return;
|
||||||
handoffStartedRef.current = true;
|
handoffStartedRef.current = true;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
if (isCheckoutHandoff) {
|
||||||
|
const result = await consumeCheckoutHandoff(handoffToken);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
log.warn(
|
||||||
|
"[ExternalEntryPersist] checkout handoff failed",
|
||||||
|
result.error,
|
||||||
|
);
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
"",
|
||||||
|
"/external-entry?target=checkout",
|
||||||
|
);
|
||||||
|
setHandoffError(
|
||||||
|
"This checkout link is invalid, expired, or already used. Please return and request a new link.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
"",
|
||||||
|
"/external-entry?target=checkout",
|
||||||
|
);
|
||||||
|
setCheckoutDestination(
|
||||||
|
resolveCheckoutIntentDestination(result.data, character),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
const result = await consumeTopUpHandoff(handoffToken);
|
const result = await consumeTopUpHandoff(handoffToken);
|
||||||
if (Result.isErr(result)) {
|
if (Result.isErr(result)) {
|
||||||
log.warn("[ExternalEntryPersist] top-up handoff failed", result.error);
|
log.warn(
|
||||||
|
"[ExternalEntryPersist] top-up handoff failed",
|
||||||
|
result.error,
|
||||||
|
);
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
window.history.state,
|
window.history.state,
|
||||||
"",
|
"",
|
||||||
@@ -158,6 +195,12 @@ export default function ExternalEntryPersist({
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
"",
|
||||||
|
"/external-entry?target=topup",
|
||||||
|
);
|
||||||
|
}
|
||||||
setHandoffCompleted(true);
|
setHandoffCompleted(true);
|
||||||
authDispatch({ type: "AuthInit" });
|
authDispatch({ type: "AuthInit" });
|
||||||
})();
|
})();
|
||||||
@@ -189,10 +232,14 @@ export default function ExternalEntryPersist({
|
|||||||
authState.hasInitialized,
|
authState.hasInitialized,
|
||||||
authState.isLoading,
|
authState.isLoading,
|
||||||
authState.loginStatus,
|
authState.loginStatus,
|
||||||
|
character,
|
||||||
destination,
|
destination,
|
||||||
|
checkoutDestination,
|
||||||
hasPersisted,
|
hasPersisted,
|
||||||
handoffCompleted,
|
handoffCompleted,
|
||||||
handoffToken,
|
handoffToken,
|
||||||
|
isCheckoutHandoff,
|
||||||
|
isLoginHandoff,
|
||||||
isTopUpHandoff,
|
isTopUpHandoff,
|
||||||
psid,
|
psid,
|
||||||
router,
|
router,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* `/external-entry?target=tip`
|
* `/external-entry?target=tip`
|
||||||
* `/external-entry?target=private-zone&character=nayeli`
|
* `/external-entry?target=private-zone&character=nayeli`
|
||||||
* `/external-entry?target=topup&handoffToken=<opaque-token>`
|
* `/external-entry?target=topup&handoffToken=<opaque-token>`
|
||||||
|
* `/external-entry?target=checkout&handoffToken=<opaque-token>`
|
||||||
*
|
*
|
||||||
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
||||||
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
|
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ describe("SubscriptionPage", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses Elio for missing source navigation context", async () => {
|
it("keeps the recipient empty when source navigation has no character", async () => {
|
||||||
const page = await SubscriptionPage({
|
const page = await SubscriptionPage({
|
||||||
searchParams: Promise.resolve({}),
|
searchParams: Promise.resolve({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
renderToStaticMarkup(page);
|
renderToStaticMarkup(page);
|
||||||
expect(mocks.screenProps).toHaveBeenLastCalledWith(
|
expect(mocks.screenProps).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({ sourceCharacterSlug: "elio" }),
|
expect.objectContaining({ sourceCharacterSlug: null }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => {
|
const mocks = vi.hoisted(() => {
|
||||||
|
const characters = [
|
||||||
|
{ id: "elio", slug: "elio", displayName: "Elio Silvestri", shortName: "Elio" },
|
||||||
|
{ id: "maya-tan", slug: "maya", displayName: "Maya Tan", shortName: "Maya" },
|
||||||
|
{
|
||||||
|
id: "nayeli-cervantes",
|
||||||
|
slug: "nayeli",
|
||||||
|
displayName: "Nayeli Cervantes",
|
||||||
|
shortName: "Nayeli",
|
||||||
|
},
|
||||||
|
];
|
||||||
const payment = {
|
const payment = {
|
||||||
status: "ready",
|
status: "ready",
|
||||||
plans: [
|
plans: [
|
||||||
@@ -41,6 +51,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
commercialOffer: null,
|
commercialOffer: null,
|
||||||
selectedPlanId: "vip_monthly",
|
selectedPlanId: "vip_monthly",
|
||||||
payChannel: "stripe" as const,
|
payChannel: "stripe" as const,
|
||||||
|
autoRenew: true,
|
||||||
agreed: true,
|
agreed: true,
|
||||||
currentOrderId: null,
|
currentOrderId: null,
|
||||||
isCreatingOrder: false,
|
isCreatingOrder: false,
|
||||||
@@ -52,7 +63,17 @@ const mocks = vi.hoisted(() => {
|
|||||||
payment.selectedPlanId = event.planId;
|
payment.selectedPlanId = event.planId;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { payment, paymentDispatch, planClick: vi.fn() };
|
return {
|
||||||
|
characterCatalog: {
|
||||||
|
characters,
|
||||||
|
getBySlug: (slug: string | null) =>
|
||||||
|
characters.find((character) => character.slug === slug) ?? null,
|
||||||
|
},
|
||||||
|
payment,
|
||||||
|
paymentDispatch,
|
||||||
|
paymentFlowInput: vi.fn(),
|
||||||
|
planClick: vi.fn(),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@/app/_components", () => ({
|
vi.mock("@/app/_components", () => ({
|
||||||
@@ -80,7 +101,13 @@ vi.mock("@/hooks/use-has-hydrated", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/stores/user/user-context", () => ({
|
vi.mock("@/stores/user/user-context", () => ({
|
||||||
useUserState: () => ({ currentUser: { countryCode: "ID" } }),
|
useUserState: () => ({
|
||||||
|
currentUser: { id: "user-renewal-1", countryCode: "ID" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/providers/character-catalog-provider", () => ({
|
||||||
|
useCharacterCatalog: () => mocks.characterCatalog,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/payment/payment_method", () => ({
|
vi.mock("@/lib/payment/payment_method", () => ({
|
||||||
@@ -97,13 +124,16 @@ vi.mock("@/lib/analytics", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../use-subscription-payment-flow", () => ({
|
vi.mock("../use-subscription-payment-flow", () => ({
|
||||||
useSubscriptionPaymentFlow: () => ({
|
useSubscriptionPaymentFlow: (input: unknown) => {
|
||||||
|
mocks.paymentFlowInput(input);
|
||||||
|
return {
|
||||||
payment: mocks.payment,
|
payment: mocks.payment,
|
||||||
paymentDispatch: mocks.paymentDispatch,
|
paymentDispatch: mocks.paymentDispatch,
|
||||||
showPaymentSuccessDialog: false,
|
showPaymentSuccessDialog: false,
|
||||||
handleBackClick: vi.fn(),
|
handleBackClick: vi.fn(),
|
||||||
handlePaymentSuccessClose: vi.fn(),
|
handlePaymentSuccessClose: vi.fn(),
|
||||||
}),
|
};
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../components", () => ({
|
vi.mock("../components", () => ({
|
||||||
@@ -137,26 +167,25 @@ vi.mock("../components", () => ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
SubscriptionCheckoutButton: ({ disabled }: { disabled: boolean }) => (
|
SubscriptionCheckoutButton: ({
|
||||||
<button type="button" data-testid="checkout" disabled={disabled}>
|
disabled,
|
||||||
|
renewalPlan,
|
||||||
|
renewalConsentSubjectId,
|
||||||
|
}: {
|
||||||
|
disabled: boolean;
|
||||||
|
renewalPlan: { id: string } | null;
|
||||||
|
renewalConsentSubjectId: string | null;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="checkout"
|
||||||
|
data-renewal-plan={renewalPlan?.id ?? ""}
|
||||||
|
data-renewal-subject={renewalConsentSubjectId ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
Pay and Top Up
|
Pay and Top Up
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
SubscriptionRenewalConfirmationDialog: ({
|
|
||||||
open,
|
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}) =>
|
|
||||||
open ? (
|
|
||||||
<div role="dialog">
|
|
||||||
<button type="button" onClick={onCancel}>Cancel</button>
|
|
||||||
<button type="button" onClick={onConfirm}>Confirm</button>
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
SubscriptionPaymentIssueDialog: () => null,
|
SubscriptionPaymentIssueDialog: () => null,
|
||||||
SubscriptionPaymentSuccessDialog: () => null,
|
SubscriptionPaymentSuccessDialog: () => null,
|
||||||
}));
|
}));
|
||||||
@@ -170,8 +199,16 @@ describe("SubscriptionScreen payment selection flow", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
mocks.payment.isFirstRecharge = false;
|
||||||
|
mocks.payment.commercialOffer = null;
|
||||||
|
for (const plan of mocks.payment.plans) {
|
||||||
|
delete (plan as Record<string, unknown>).isFirstRechargeOffer;
|
||||||
|
delete (plan as Record<string, unknown>).firstRechargeDiscountPercent;
|
||||||
|
delete (plan as Record<string, unknown>).promotionType;
|
||||||
|
}
|
||||||
mocks.payment.selectedPlanId = "vip_monthly";
|
mocks.payment.selectedPlanId = "vip_monthly";
|
||||||
mocks.paymentDispatch.mockClear();
|
mocks.paymentDispatch.mockClear();
|
||||||
|
mocks.paymentFlowInput.mockClear();
|
||||||
mocks.planClick.mockClear();
|
mocks.planClick.mockClear();
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@@ -183,68 +220,32 @@ describe("SubscriptionScreen payment selection flow", () => {
|
|||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("confirms VIP selection before enabling the separate checkout button", () => {
|
it("allows immediate checkout and changes VIP plans without opening renewal confirmation", () => {
|
||||||
act(() => root.render(<SubscriptionScreen />));
|
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||||
const checkout = container.querySelector<HTMLButtonElement>(
|
const checkout = container.querySelector<HTMLButtonElement>(
|
||||||
'[data-testid="checkout"]',
|
'[data-testid="checkout"]',
|
||||||
);
|
);
|
||||||
expect(checkout?.disabled).toBe(true);
|
expect(checkout?.disabled).toBe(false);
|
||||||
|
expect(checkout?.dataset.renewalPlan).toBe("vip_monthly");
|
||||||
|
expect(checkout?.dataset.renewalSubject).toBe("user-renewal-1");
|
||||||
|
|
||||||
act(() => clickButton(container, "vip_monthly"));
|
act(() => clickButton(container, "vip_monthly"));
|
||||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
|
||||||
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => clickButton(container, "Confirm"));
|
|
||||||
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
|
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
|
||||||
type: "PaymentPlanSelected",
|
type: "PaymentPlanSelected",
|
||||||
planId: "vip_monthly",
|
planId: "vip_monthly",
|
||||||
});
|
});
|
||||||
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
|
|
||||||
);
|
|
||||||
expect(checkout?.disabled).toBe(false);
|
|
||||||
|
|
||||||
act(() => clickButton(container, "vip_monthly"));
|
|
||||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
|
||||||
act(() => clickButton(container, "vip_quarterly"));
|
act(() => clickButton(container, "vip_quarterly"));
|
||||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
|
||||||
expect(mocks.payment.selectedPlanId).toBe("vip_monthly");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the original selection when VIP confirmation is cancelled", () => {
|
|
||||||
act(() => root.render(<SubscriptionScreen />));
|
|
||||||
act(() => clickButton(container, "vip_quarterly"));
|
|
||||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
|
||||||
|
|
||||||
act(() => clickButton(container, "Cancel"));
|
|
||||||
|
|
||||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
expect(mocks.payment.selectedPlanId).toBe("vip_monthly");
|
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
|
||||||
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith({
|
|
||||||
type: "PaymentPlanSelected",
|
type: "PaymentPlanSelected",
|
||||||
planId: "vip_quarterly",
|
planId: "vip_quarterly",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires VIP confirmation again after the page is remounted", () => {
|
|
||||||
act(() => root.render(<SubscriptionScreen />));
|
|
||||||
act(() => clickButton(container, "vip_monthly"));
|
|
||||||
act(() => clickButton(container, "Confirm"));
|
|
||||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
|
||||||
|
|
||||||
act(() => root.unmount());
|
|
||||||
root = createRoot(container);
|
|
||||||
act(() => root.render(<SubscriptionScreen />));
|
|
||||||
act(() => clickButton(container, "vip_monthly"));
|
|
||||||
|
|
||||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selects a coin package without showing the renewal dialog", () => {
|
it("selects a coin package without showing the renewal dialog", () => {
|
||||||
act(() => root.render(<SubscriptionScreen />));
|
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||||
act(() => clickButton(container, "coin_1000"));
|
act(() => clickButton(container, "coin_1000"));
|
||||||
|
|
||||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
@@ -275,6 +276,81 @@ describe("SubscriptionScreen payment selection flow", () => {
|
|||||||
expect(guidance?.textContent).toContain("cannot continue");
|
expect(guidance?.textContent).toContain("cannot continue");
|
||||||
expect(guidance?.textContent).not.toContain("Elio");
|
expect(guidance?.textContent).not.toContain("Elio");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not render the first recharge banner on the subscription page", () => {
|
||||||
|
mocks.payment.isFirstRecharge = true;
|
||||||
|
Object.assign(mocks.payment.plans[0] as Record<string, unknown>, {
|
||||||
|
isFirstRechargeOffer: true,
|
||||||
|
firstRechargeDiscountPercent: 50,
|
||||||
|
promotionType: "first_recharge_half_price",
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||||
|
|
||||||
|
expect(container.textContent).not.toContain("First Recharge Offer");
|
||||||
|
expect(container.textContent).not.toContain(
|
||||||
|
"Your first recharge price is already applied",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["elio", "maya", "nayeli"])(
|
||||||
|
"inherits the %s chat character without rendering a recipient selector",
|
||||||
|
(sourceCharacterSlug) => {
|
||||||
|
mocks.payment.selectedPlanId = "coin_1000";
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<SubscriptionScreen
|
||||||
|
subscriptionType="topup"
|
||||||
|
sourceCharacterSlug={sourceCharacterSlug}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkout = container.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-testid="checkout"]',
|
||||||
|
);
|
||||||
|
expect(container.textContent).not.toContain("Supporting ");
|
||||||
|
expect(container.textContent).not.toContain(
|
||||||
|
"Choose who you want to support",
|
||||||
|
);
|
||||||
|
expect(container.textContent).not.toContain(
|
||||||
|
"Your VIP or credit purchase supports the character you choose",
|
||||||
|
);
|
||||||
|
expect(checkout?.disabled).toBe(false);
|
||||||
|
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ sourceCharacterSlug }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("does not invent a recipient when opened without a source character", () => {
|
||||||
|
mocks.payment.selectedPlanId = "coin_1000";
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<SubscriptionScreen
|
||||||
|
subscriptionType="topup"
|
||||||
|
sourceCharacterSlug={null}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkout = container.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-testid="checkout"]',
|
||||||
|
);
|
||||||
|
expect(container.textContent).not.toContain("Supporting ");
|
||||||
|
expect(container.textContent).not.toContain(
|
||||||
|
"Choose who you want to support",
|
||||||
|
);
|
||||||
|
expect(container.textContent).not.toContain(
|
||||||
|
"Your VIP or credit purchase supports the character you choose",
|
||||||
|
);
|
||||||
|
expect(checkout?.disabled).toBe(true);
|
||||||
|
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ sourceCharacterSlug: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function clickButton(root: ParentNode, label: string): void {
|
function clickButton(root: ParentNode, label: string): void {
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ import {
|
|||||||
} from "@/data/schemas/payment";
|
} from "@/data/schemas/payment";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
canCheckoutSubscriptionPlan,
|
|
||||||
findSelectedSubscriptionPlan,
|
findSelectedSubscriptionPlan,
|
||||||
getDefaultSubscriptionPlanId,
|
getDefaultSubscriptionPlanId,
|
||||||
getFirstRechargeOfferView,
|
getFirstRechargeOfferView,
|
||||||
requiresVipPlanConfirmation,
|
|
||||||
toCoinsOfferPlanViews,
|
toCoinsOfferPlanViews,
|
||||||
toVipOfferPlanViews,
|
toVipOfferPlanViews,
|
||||||
} from "../subscription-screen.helpers";
|
} from "../subscription-screen.helpers";
|
||||||
@@ -280,85 +278,4 @@ describe("subscription screen helpers", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires confirmation for each unconfirmed VIP plan", () => {
|
|
||||||
const vipPlans = [
|
|
||||||
{
|
|
||||||
id: "vip_monthly",
|
|
||||||
title: "Monthly",
|
|
||||||
price: "19.90",
|
|
||||||
currency: "usd",
|
|
||||||
originalPrice: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "vip_quarterly",
|
|
||||||
title: "Quarterly",
|
|
||||||
price: "49.90",
|
|
||||||
currency: "usd",
|
|
||||||
originalPrice: "",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(
|
|
||||||
requiresVipPlanConfirmation({
|
|
||||||
planId: "vip_monthly",
|
|
||||||
vipPlans,
|
|
||||||
confirmedVipPlanIds: new Set(),
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
requiresVipPlanConfirmation({
|
|
||||||
planId: "vip_monthly",
|
|
||||||
vipPlans,
|
|
||||||
confirmedVipPlanIds: new Set(["vip_monthly"]),
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
requiresVipPlanConfirmation({
|
|
||||||
planId: "coin_1000",
|
|
||||||
vipPlans,
|
|
||||||
confirmedVipPlanIds: new Set(),
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows checkout only after the selected VIP plan is confirmed", () => {
|
|
||||||
const vipPlans = [
|
|
||||||
{
|
|
||||||
id: "vip_monthly",
|
|
||||||
title: "Monthly",
|
|
||||||
price: "19.90",
|
|
||||||
currency: "usd",
|
|
||||||
originalPrice: "",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(
|
|
||||||
canCheckoutSubscriptionPlan({
|
|
||||||
selectedPlanId: "vip_monthly",
|
|
||||||
vipPlans,
|
|
||||||
confirmedVipPlanIds: new Set(),
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
canCheckoutSubscriptionPlan({
|
|
||||||
selectedPlanId: "vip_monthly",
|
|
||||||
vipPlans,
|
|
||||||
confirmedVipPlanIds: new Set(["vip_monthly"]),
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
canCheckoutSubscriptionPlan({
|
|
||||||
selectedPlanId: "coin_1000",
|
|
||||||
vipPlans,
|
|
||||||
confirmedVipPlanIds: new Set(),
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
canCheckoutSubscriptionPlan({
|
|
||||||
selectedPlanId: "",
|
|
||||||
vipPlans,
|
|
||||||
confirmedVipPlanIds: new Set(),
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
dispatch: vi.fn(),
|
||||||
|
handleQrisResume: vi.fn(),
|
||||||
|
hasHiddenQrisPayment: false,
|
||||||
|
resetLaunchState: vi.fn(),
|
||||||
|
payment: {
|
||||||
|
selectedPlanId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
payChannel: "stripe" as "stripe" | "ezpay",
|
||||||
|
commercialOfferId: null,
|
||||||
|
chatActionId: null,
|
||||||
|
currentOrderId: null,
|
||||||
|
errorMessage: null,
|
||||||
|
isCreatingOrder: false,
|
||||||
|
isPollingOrder: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/payment/payment-context", () => ({
|
||||||
|
usePaymentState: () => mocks.payment,
|
||||||
|
usePaymentDispatch: () => mocks.dispatch,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/_hooks/use-payment-launch-flow", () => ({
|
||||||
|
usePaymentLaunchFlow: () => ({
|
||||||
|
handleQrisResume: mocks.handleQrisResume,
|
||||||
|
hasHiddenQrisPayment: mocks.hasHiddenQrisPayment,
|
||||||
|
resetLaunchState: mocks.resetLaunchState,
|
||||||
|
launch: {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/_components/payment/payment-launch-dialogs", () => ({
|
||||||
|
PaymentLaunchDialogs: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/_components/payment/external-browser-checkout-button", () => ({
|
||||||
|
ExternalBrowserCheckoutButton: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../subscription-cta-button", () => ({
|
||||||
|
SubscriptionCtaButton: ({
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) => (
|
||||||
|
<button type="button" disabled={disabled} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../subscription-renewal-confirmation-dialog", () => ({
|
||||||
|
SubscriptionRenewalConfirmationDialog: ({
|
||||||
|
open,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) =>
|
||||||
|
open ? (
|
||||||
|
<div role="dialog">
|
||||||
|
<button type="button" onClick={onCancel}>Cancel</button>
|
||||||
|
<button type="button" onClick={onConfirm}>Confirm</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SubscriptionCheckoutButton } from "../subscription-checkout-button";
|
||||||
|
|
||||||
|
const renewalPlan = {
|
||||||
|
id: "vip_monthly",
|
||||||
|
title: "Monthly",
|
||||||
|
price: "19.90",
|
||||||
|
currency: "usd",
|
||||||
|
originalPrice: "39.90",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SubscriptionCheckoutButton renewal confirmation", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
localStorage.clear();
|
||||||
|
mocks.dispatch.mockClear();
|
||||||
|
mocks.handleQrisResume.mockClear();
|
||||||
|
mocks.hasHiddenQrisPayment = false;
|
||||||
|
mocks.resetLaunchState.mockClear();
|
||||||
|
mocks.payment.selectedPlanId = "vip_monthly";
|
||||||
|
mocks.payment.autoRenew = true;
|
||||||
|
mocks.payment.payChannel = "stripe";
|
||||||
|
mocks.payment.isCreatingOrder = false;
|
||||||
|
mocks.payment.isPollingOrder = false;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows confirmation on the first auto-renewing VIP checkout and resumes after confirm", () => {
|
||||||
|
renderButton(root, "user-1");
|
||||||
|
|
||||||
|
act(() => clickButton(container, "Pay and Top Up"));
|
||||||
|
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||||
|
expect(mocks.dispatch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => clickButton(container, "Confirm"));
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
expect(mocks.resetLaunchState).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||||
|
type: "PaymentCreateOrderSubmitted",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not ask the same user again after confirmation", () => {
|
||||||
|
renderButton(root, "user-1");
|
||||||
|
act(() => clickButton(container, "Pay and Top Up"));
|
||||||
|
act(() => clickButton(container, "Confirm"));
|
||||||
|
mocks.dispatch.mockClear();
|
||||||
|
mocks.resetLaunchState.mockClear();
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
root = createRoot(container);
|
||||||
|
renderButton(root, "user-1");
|
||||||
|
act(() => clickButton(container, "Pay and Top Up"));
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
expect(mocks.resetLaunchState).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||||
|
type: "PaymentCreateOrderSubmitted",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps asking after cancel and isolates acknowledgement by user", () => {
|
||||||
|
renderButton(root, "user-1");
|
||||||
|
act(() => clickButton(container, "Pay and Top Up"));
|
||||||
|
act(() => clickButton(container, "Cancel"));
|
||||||
|
act(() => clickButton(container, "Pay and Top Up"));
|
||||||
|
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => clickButton(container, "Confirm"));
|
||||||
|
mocks.dispatch.mockClear();
|
||||||
|
act(() => root.unmount());
|
||||||
|
root = createRoot(container);
|
||||||
|
renderButton(root, "user-2");
|
||||||
|
act(() => clickButton(container, "Pay and Top Up"));
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||||
|
expect(mocks.dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips confirmation for a non-renewing purchase", () => {
|
||||||
|
mocks.payment.autoRenew = false;
|
||||||
|
renderButton(root, "user-1", null);
|
||||||
|
|
||||||
|
act(() => clickButton(container, "Pay and Top Up"));
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||||
|
type: "PaymentCreateOrderSubmitted",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips confirmation for one-time EzPay membership checkout", () => {
|
||||||
|
mocks.payment.payChannel = "ezpay";
|
||||||
|
renderButton(root, "user-1");
|
||||||
|
|
||||||
|
act(() => clickButton(container, "Pay and Top Up"));
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||||
|
type: "PaymentCreateOrderSubmitted",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopens a hidden QRIS order without creating another order", () => {
|
||||||
|
mocks.payment.payChannel = "ezpay";
|
||||||
|
mocks.payment.isPollingOrder = true;
|
||||||
|
mocks.hasHiddenQrisPayment = true;
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<SubscriptionCheckoutButton
|
||||||
|
disabled
|
||||||
|
subscriptionType="topup"
|
||||||
|
sourceCharacterSlug="elio"
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resumeButton = getButton(container, "Resume QRIS payment");
|
||||||
|
expect(resumeButton.disabled).toBe(false);
|
||||||
|
act(() => resumeButton.click());
|
||||||
|
|
||||||
|
expect(mocks.handleQrisResume).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.resetLaunchState).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderButton(
|
||||||
|
root: Root,
|
||||||
|
renewalConsentSubjectId: string,
|
||||||
|
plan: typeof renewalPlan | null = renewalPlan,
|
||||||
|
): void {
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<SubscriptionCheckoutButton
|
||||||
|
subscriptionType="vip"
|
||||||
|
sourceCharacterSlug="elio"
|
||||||
|
renewalPlan={plan}
|
||||||
|
renewalConsentSubjectId={renewalConsentSubjectId}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickButton(root: ParentNode, label: string): void {
|
||||||
|
getButton(root, label).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getButton(root: ParentNode, label: string): HTMLButtonElement {
|
||||||
|
const button = Array.from(root.querySelectorAll("button")).find(
|
||||||
|
(item) => item.textContent?.trim() === label,
|
||||||
|
);
|
||||||
|
if (!button) throw new Error(`Expected ${label} button`);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
@@ -3,8 +3,12 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
import { ApiError } from "@/data/services/api/api_result";
|
||||||
|
|
||||||
import { SubscriptionPaymentIssueDialog } from "../subscription-payment-issue-dialog";
|
import {
|
||||||
|
getPaymentIssueSubmitErrorLogDetails,
|
||||||
|
SubscriptionPaymentIssueDialog,
|
||||||
|
} from "../subscription-payment-issue-dialog";
|
||||||
import { SubscriptionRenewalConfirmationDialog } from "../subscription-renewal-confirmation-dialog";
|
import { SubscriptionRenewalConfirmationDialog } from "../subscription-renewal-confirmation-dialog";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
@@ -67,7 +71,14 @@ describe("subscription payment dialogs", () => {
|
|||||||
expect(dialog?.textContent).toContain("Automatic Renewal Confirmation");
|
expect(dialog?.textContent).toContain("Automatic Renewal Confirmation");
|
||||||
expect(dialog?.textContent).toContain("Monthly");
|
expect(dialog?.textContent).toContain("Monthly");
|
||||||
expect(dialog?.textContent).toContain("19.90 usd");
|
expect(dialog?.textContent).toContain("19.90 usd");
|
||||||
expect(dialog?.querySelectorAll("a")).toHaveLength(2);
|
expect(
|
||||||
|
Array.from(dialog?.querySelectorAll("a") ?? []).map((link) =>
|
||||||
|
link.getAttribute("href"),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"/legal/vip-membership-benefits.html",
|
||||||
|
"/legal/automatic-renewal.html",
|
||||||
|
]);
|
||||||
|
|
||||||
act(() => clickButton(dialog, "Confirm"));
|
act(() => clickButton(dialog, "Confirm"));
|
||||||
expect(onConfirm).toHaveBeenCalledOnce();
|
expect(onConfirm).toHaveBeenCalledOnce();
|
||||||
@@ -200,6 +211,28 @@ describe("subscription payment dialogs", () => {
|
|||||||
);
|
);
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps HTTP status and backend error code in payment issue failure diagnostics", () => {
|
||||||
|
const failure = Result.err(
|
||||||
|
new ApiError("HTTP_ERROR", "Invalid feedback context", 400, {
|
||||||
|
detail: {
|
||||||
|
message: "Invalid feedback context",
|
||||||
|
errorCode: "INVALID_FEEDBACK_CONTEXT",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failure.success) throw new Error("Expected failure result");
|
||||||
|
expect(getPaymentIssueSubmitErrorLogDetails(failure.error)).toMatchObject({
|
||||||
|
code: "UNKNOWN",
|
||||||
|
message: "Invalid feedback context",
|
||||||
|
causeName: "ApiError",
|
||||||
|
causeMessage: "Invalid feedback context",
|
||||||
|
httpStatus: 400,
|
||||||
|
apiCode: "HTTP_ERROR",
|
||||||
|
serverErrorCode: "INVALID_FEEDBACK_CONTEXT",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function clickButton(root: ParentNode | null, label: string): void {
|
function clickButton(root: ParentNode | null, label: string): void {
|
||||||
|
|||||||
@@ -4,10 +4,17 @@
|
|||||||
*
|
*
|
||||||
* 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。
|
* 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。
|
||||||
*/
|
*/
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||||
|
import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button";
|
||||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||||
|
import {
|
||||||
|
hasAutomaticRenewalAcknowledgement,
|
||||||
|
rememberAutomaticRenewalAcknowledgement,
|
||||||
|
} from "@/lib/payment/automatic_renewal_acknowledgement";
|
||||||
import {
|
import {
|
||||||
usePaymentDispatch,
|
usePaymentDispatch,
|
||||||
usePaymentState,
|
usePaymentState,
|
||||||
@@ -15,15 +22,19 @@ import {
|
|||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
|
|
||||||
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
||||||
|
import { SubscriptionRenewalConfirmationDialog } from "./subscription-renewal-confirmation-dialog";
|
||||||
|
import type { VipOfferPlanView } from "./subscription-vip-offer-section";
|
||||||
|
|
||||||
const log = new Logger("SubscriptionCheckoutButton");
|
const log = new Logger("SubscriptionCheckoutButton");
|
||||||
|
|
||||||
export interface SubscriptionCheckoutButtonProps {
|
export interface SubscriptionCheckoutButtonProps {
|
||||||
/** 是否可用(未选套餐或 VIP 套餐尚未确认自动续费时为 false) */
|
/** 是否可用(未选套餐、缺少角色来源或支付处理中为 false) */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
subscriptionType: "vip" | "topup";
|
subscriptionType: "vip" | "topup";
|
||||||
returnTo?: SubscriptionReturnTo;
|
returnTo?: SubscriptionReturnTo;
|
||||||
sourceCharacterSlug?: string;
|
sourceCharacterSlug?: string;
|
||||||
|
renewalPlan?: VipOfferPlanView | null;
|
||||||
|
renewalConsentSubjectId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubscriptionCheckoutButton({
|
export function SubscriptionCheckoutButton({
|
||||||
@@ -31,7 +42,11 @@ export function SubscriptionCheckoutButton({
|
|||||||
subscriptionType,
|
subscriptionType,
|
||||||
returnTo = null,
|
returnTo = null,
|
||||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||||
|
renewalPlan = null,
|
||||||
|
renewalConsentSubjectId = null,
|
||||||
}: SubscriptionCheckoutButtonProps) {
|
}: SubscriptionCheckoutButtonProps) {
|
||||||
|
const [showRenewalConfirmation, setShowRenewalConfirmation] =
|
||||||
|
useState(false);
|
||||||
const payment = usePaymentState();
|
const payment = usePaymentState();
|
||||||
const paymentDispatch = usePaymentDispatch();
|
const paymentDispatch = usePaymentDispatch();
|
||||||
const paymentLaunch = usePaymentLaunchFlow({
|
const paymentLaunch = usePaymentLaunchFlow({
|
||||||
@@ -44,27 +59,71 @@ export function SubscriptionCheckoutButton({
|
|||||||
subscriptionType,
|
subscriptionType,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
|
const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
|
||||||
|
const isLoading =
|
||||||
|
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
|
||||||
const readyLabel = "Pay and Top Up";
|
const readyLabel = "Pay and Top Up";
|
||||||
const label = payment.isPollingOrder
|
const label = isResumingQris
|
||||||
|
? "Resume QRIS payment"
|
||||||
|
: payment.isPollingOrder
|
||||||
? "Processing payment..."
|
? "Processing payment..."
|
||||||
: payment.isCreatingOrder
|
: payment.isCreatingOrder
|
||||||
? "Creating order..."
|
? "Creating order..."
|
||||||
: readyLabel;
|
: readyLabel;
|
||||||
|
|
||||||
const handleClick = () => {
|
const startCheckout = () => {
|
||||||
if (disabled || isLoading) return;
|
if (disabled || isLoading) return;
|
||||||
paymentLaunch.resetLaunchState();
|
paymentLaunch.resetLaunchState();
|
||||||
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
|
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isResumingQris) {
|
||||||
|
paymentLaunch.handleQrisResume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (disabled || isLoading) return;
|
||||||
|
if (
|
||||||
|
payment.payChannel === "stripe" &&
|
||||||
|
payment.autoRenew &&
|
||||||
|
renewalPlan !== null &&
|
||||||
|
!hasAutomaticRenewalAcknowledgement(renewalConsentSubjectId)
|
||||||
|
) {
|
||||||
|
setShowRenewalConfirmation(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startCheckout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenewalConfirm = () => {
|
||||||
|
rememberAutomaticRenewalAcknowledgement(renewalConsentSubjectId);
|
||||||
|
setShowRenewalConfirmation(false);
|
||||||
|
startCheckout();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
|
||||||
|
<ExternalBrowserCheckoutButton
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
characterSlug={sourceCharacterSlug}
|
||||||
|
checkoutIntent={{
|
||||||
|
planId: payment.selectedPlanId,
|
||||||
|
autoRenew: payment.autoRenew,
|
||||||
|
...(payment.commercialOfferId
|
||||||
|
? { commercialOfferId: payment.commercialOfferId }
|
||||||
|
: {}),
|
||||||
|
...(payment.chatActionId
|
||||||
|
? { chatActionId: payment.chatActionId }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<SubscriptionCtaButton
|
<SubscriptionCtaButton
|
||||||
type="button"
|
type="button"
|
||||||
data-analytics-key="subscription.checkout"
|
data-analytics-key="subscription.checkout"
|
||||||
data-analytics-label="Start subscription checkout"
|
data-analytics-label="Start subscription checkout"
|
||||||
disabled={disabled}
|
disabled={disabled && !isResumingQris}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
@@ -89,6 +148,12 @@ export function SubscriptionCheckoutButton({
|
|||||||
ezpayDescription="Your order has been created. Continue to the secure payment page to finish."
|
ezpayDescription="Your order has been created. Continue to the secure payment page to finish."
|
||||||
launch={paymentLaunch}
|
launch={paymentLaunch}
|
||||||
/>
|
/>
|
||||||
|
<SubscriptionRenewalConfirmationDialog
|
||||||
|
open={showRenewalConfirmation}
|
||||||
|
plan={renewalPlan}
|
||||||
|
onCancel={() => setShowRenewalConfirmation(false)}
|
||||||
|
onConfirm={handleRenewalConfirm}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ModalPortal } from "@/app/_components/core/modal-portal";
|
|||||||
import { createFeedbackContext } from "@/app/feedback/feedback-context";
|
import { createFeedbackContext } from "@/app/feedback/feedback-context";
|
||||||
import type { PayChannel } from "@/data/schemas/payment";
|
import type { PayChannel } from "@/data/schemas/payment";
|
||||||
import { submitFeedback } from "@/lib/feedback";
|
import { submitFeedback } from "@/lib/feedback";
|
||||||
|
import { Logger } from "@/utils/logger";
|
||||||
|
|
||||||
import type { SubscriptionType } from "../subscription-screen.helpers";
|
import type { SubscriptionType } from "../subscription-screen.helpers";
|
||||||
import styles from "./subscription-dialog.module.css";
|
import styles from "./subscription-dialog.module.css";
|
||||||
@@ -33,6 +34,21 @@ const OTHER_DETAILS_MIN_LENGTH = 10;
|
|||||||
const OTHER_DETAILS_MAX_LENGTH = 2000;
|
const OTHER_DETAILS_MAX_LENGTH = 2000;
|
||||||
const SUCCESS_MESSAGE = "Thanks. Your payment issue has been submitted.";
|
const SUCCESS_MESSAGE = "Thanks. Your payment issue has been submitted.";
|
||||||
const FAILURE_MESSAGE = "Unable to submit. Please try again.";
|
const FAILURE_MESSAGE = "Unable to submit. Please try again.";
|
||||||
|
const log = new Logger("SubscriptionPaymentIssueDialog");
|
||||||
|
|
||||||
|
interface ErrorRecord {
|
||||||
|
readonly [key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentIssueSubmitErrorLogDetails {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
causeName?: string;
|
||||||
|
causeMessage?: string;
|
||||||
|
httpStatus?: number;
|
||||||
|
apiCode?: string;
|
||||||
|
serverErrorCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubscriptionPaymentIssueDialogProps {
|
export interface SubscriptionPaymentIssueDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -119,6 +135,19 @@ export function SubscriptionPaymentIssueDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
log.error(
|
||||||
|
{
|
||||||
|
...getPaymentIssueSubmitErrorLogDetails(result.error),
|
||||||
|
paymentIssueReason: reason,
|
||||||
|
subscriptionType,
|
||||||
|
planId,
|
||||||
|
orderId,
|
||||||
|
payChannel,
|
||||||
|
countryCode,
|
||||||
|
characterId,
|
||||||
|
},
|
||||||
|
"Payment issue feedback submit failed",
|
||||||
|
);
|
||||||
setErrorMessage(FAILURE_MESSAGE);
|
setErrorMessage(FAILURE_MESSAGE);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
@@ -206,6 +235,32 @@ export function SubscriptionPaymentIssueDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPaymentIssueSubmitErrorLogDetails(
|
||||||
|
error: Error,
|
||||||
|
): PaymentIssueSubmitErrorLogDetails {
|
||||||
|
const errorRecord = toErrorRecord(error);
|
||||||
|
const cause = toErrorRecord(errorRecord?.cause);
|
||||||
|
const responseDetails = toErrorRecord(cause?.details);
|
||||||
|
const detail = toErrorRecord(responseDetails?.detail);
|
||||||
|
const causeName = stringValue(cause?.name);
|
||||||
|
const causeMessage = stringValue(cause?.message);
|
||||||
|
const httpStatus = numberValue(cause?.status);
|
||||||
|
const apiCode = stringValue(cause?.code);
|
||||||
|
const serverErrorCode =
|
||||||
|
stringValue(detail?.errorCode) ??
|
||||||
|
stringValue(responseDetails?.errorCode);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: stringValue(errorRecord?.code) ?? "UNKNOWN",
|
||||||
|
message: error.message,
|
||||||
|
...(causeName ? { causeName } : {}),
|
||||||
|
...(causeMessage ? { causeMessage } : {}),
|
||||||
|
...(httpStatus !== undefined ? { httpStatus } : {}),
|
||||||
|
...(apiCode ? { apiCode } : {}),
|
||||||
|
...(serverErrorCode ? { serverErrorCode } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createIdempotencyKey(): string {
|
function createIdempotencyKey(): string {
|
||||||
const token =
|
const token =
|
||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
@@ -213,3 +268,18 @@ function createIdempotencyKey(): string {
|
|||||||
: `${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
: `${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
return `payment_feedback_${token}`;
|
return `payment_feedback_${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toErrorRecord(value: unknown): ErrorRecord | null {
|
||||||
|
if (typeof value !== "object" || value === null) return null;
|
||||||
|
return value as ErrorRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" && Number.isFinite(value)
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
.shell {
|
.shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
height: var(--app-viewport-height, 100dvh);
|
||||||
min-height: var(--app-viewport-height, 100dvh);
|
min-height: var(--app-viewport-height, 100dvh);
|
||||||
|
overflow: hidden;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 8% 4%, rgba(255, 255, 255, 0.95) 0 90px, transparent 160px),
|
radial-gradient(circle at 8% 4%, rgba(255, 255, 255, 0.95) 0 90px, transparent 160px),
|
||||||
linear-gradient(180deg, #fff9fb 0%, #fcf3f4 52%, #fffefe 100%);
|
linear-gradient(180deg, #fff9fb 0%, #fcf3f4 52%, #fffefe 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollArea {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
padding:
|
padding:
|
||||||
calc(var(--page-padding-y, 18px) + var(--app-safe-top, 0px))
|
calc(var(--page-padding-y, 18px) + var(--app-safe-top, 0px))
|
||||||
calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px))
|
calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px))
|
||||||
calc(104px + var(--app-safe-bottom, 0px))
|
var(--page-section-gap-lg, 22px)
|
||||||
calc(var(--page-padding-x, 20px) + var(--app-safe-left, 0px));
|
calc(var(--page-padding-x, 20px) + var(--app-safe-left, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,11 +201,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ctaSlot {
|
.ctaSlot {
|
||||||
position: fixed;
|
position: relative;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
right: 50%;
|
flex: 0 0 auto;
|
||||||
bottom: 0;
|
width: 100%;
|
||||||
width: min(100%, var(--app-max-width, 540px));
|
max-height: min(50dvh, 360px);
|
||||||
|
overflow-y: auto;
|
||||||
padding:
|
padding:
|
||||||
12px
|
12px
|
||||||
calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px))
|
calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px))
|
||||||
@@ -209,5 +220,4 @@
|
|||||||
);
|
);
|
||||||
box-shadow: 0 -12px 30px rgba(87, 35, 59, 0.1);
|
box-shadow: 0 -12px 30px rgba(87, 35, 59, 0.1);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
transform: translateX(50%);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import {
|
|||||||
} from "@/lib/analytics/payment_analytics_context";
|
} from "@/lib/analytics/payment_analytics_context";
|
||||||
import {
|
import {
|
||||||
getFirstPaymentSearchParam,
|
getFirstPaymentSearchParam,
|
||||||
|
parsePaymentAutoRenew,
|
||||||
parsePaymentReturnSearchParams,
|
parsePaymentReturnSearchParams,
|
||||||
parseSubscriptionReturnTo,
|
parseSubscriptionReturnTo,
|
||||||
type PaymentSearchParams,
|
type PaymentSearchParams,
|
||||||
} from "@/lib/payment/payment_search_params";
|
} from "@/lib/payment/payment_search_params";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CHARACTER_SLUG,
|
|
||||||
getCharacterBySlug,
|
getCharacterBySlug,
|
||||||
} from "@/data/constants/character";
|
} from "@/data/constants/character";
|
||||||
|
|
||||||
@@ -30,8 +30,7 @@ export default async function SubscriptionPage({
|
|||||||
getFirstPaymentSearchParam(query.type),
|
getFirstPaymentSearchParam(query.type),
|
||||||
);
|
);
|
||||||
const sourceCharacterSlug =
|
const sourceCharacterSlug =
|
||||||
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ??
|
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ?? null;
|
||||||
DEFAULT_CHARACTER_SLUG;
|
|
||||||
const initialPlanId = getFirstPaymentSearchParam(query.planId);
|
const initialPlanId = getFirstPaymentSearchParam(query.planId);
|
||||||
const commercialOfferId = getFirstPaymentSearchParam(
|
const commercialOfferId = getFirstPaymentSearchParam(
|
||||||
query.commercialOfferId,
|
query.commercialOfferId,
|
||||||
@@ -57,6 +56,7 @@ export default async function SubscriptionPage({
|
|||||||
analyticsContext={analyticsContext}
|
analyticsContext={analyticsContext}
|
||||||
sourceCharacterSlug={sourceCharacterSlug}
|
sourceCharacterSlug={sourceCharacterSlug}
|
||||||
initialPlanId={initialPlanId}
|
initialPlanId={initialPlanId}
|
||||||
|
initialAutoRenew={parsePaymentAutoRenew(query.autoRenew)}
|
||||||
commercialOfferId={commercialOfferId}
|
commercialOfferId={commercialOfferId}
|
||||||
resumeOrderId={resumeOrderId}
|
resumeOrderId={resumeOrderId}
|
||||||
chatActionId={chatActionId}
|
chatActionId={chatActionId}
|
||||||
|
|||||||
@@ -105,30 +105,6 @@ export function findSelectedSubscriptionPlan(input: {
|
|||||||
return plans.find((plan) => plan.id === input.selectedPlanId) ?? null;
|
return plans.find((plan) => plan.id === input.selectedPlanId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requiresVipPlanConfirmation(input: {
|
|
||||||
planId: string;
|
|
||||||
vipPlans: readonly VipOfferPlanView[];
|
|
||||||
confirmedVipPlanIds: ReadonlySet<string>;
|
|
||||||
}): boolean {
|
|
||||||
return (
|
|
||||||
input.vipPlans.some((plan) => plan.id === input.planId) &&
|
|
||||||
!input.confirmedVipPlanIds.has(input.planId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canCheckoutSubscriptionPlan(input: {
|
|
||||||
selectedPlanId: string;
|
|
||||||
vipPlans: readonly VipOfferPlanView[];
|
|
||||||
confirmedVipPlanIds: ReadonlySet<string>;
|
|
||||||
}): boolean {
|
|
||||||
if (!input.selectedPlanId) return false;
|
|
||||||
return !requiresVipPlanConfirmation({
|
|
||||||
planId: input.selectedPlanId,
|
|
||||||
vipPlans: input.vipPlans,
|
|
||||||
confirmedVipPlanIds: input.confirmedVipPlanIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultSubscriptionPlanId(input: {
|
export function getDefaultSubscriptionPlanId(input: {
|
||||||
canSubscribeVip: boolean;
|
canSubscribeVip: boolean;
|
||||||
selectedPlanId: string;
|
selectedPlanId: string;
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ import { usePaymentMethodSelection } from "@/app/_hooks/use-payment-method-selec
|
|||||||
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
|
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
|
||||||
import type { PayChannel } from "@/data/schemas/payment";
|
import type { PayChannel } from "@/data/schemas/payment";
|
||||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||||
import {
|
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
DEFAULT_CHARACTER,
|
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
|
||||||
DEFAULT_CHARACTER_SLUG,
|
|
||||||
getCharacterBySlug,
|
|
||||||
} from "@/data/constants/character";
|
|
||||||
import {
|
import {
|
||||||
behaviorAnalytics,
|
behaviorAnalytics,
|
||||||
getDefaultPaymentAnalyticsContext,
|
getDefaultPaymentAnalyticsContext,
|
||||||
@@ -27,16 +24,13 @@ import {
|
|||||||
SubscriptionCoinsOfferSection,
|
SubscriptionCoinsOfferSection,
|
||||||
SubscriptionPaymentIssueDialog,
|
SubscriptionPaymentIssueDialog,
|
||||||
SubscriptionPaymentSuccessDialog,
|
SubscriptionPaymentSuccessDialog,
|
||||||
SubscriptionRenewalConfirmationDialog,
|
|
||||||
SubscriptionVipOfferSection,
|
SubscriptionVipOfferSection,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import styles from "./components/subscription-screen.module.css";
|
import styles from "./components/subscription-screen.module.css";
|
||||||
import {
|
import {
|
||||||
canCheckoutSubscriptionPlan,
|
|
||||||
findSelectedSubscriptionPlan,
|
findSelectedSubscriptionPlan,
|
||||||
getFirstRechargeOfferView,
|
getFirstRechargeOfferView,
|
||||||
getDefaultSubscriptionPlanId,
|
getDefaultSubscriptionPlanId,
|
||||||
requiresVipPlanConfirmation,
|
|
||||||
toCoinsOfferPlanViews,
|
toCoinsOfferPlanViews,
|
||||||
toVipOfferPlanViews,
|
toVipOfferPlanViews,
|
||||||
type SubscriptionType,
|
type SubscriptionType,
|
||||||
@@ -51,8 +45,9 @@ export interface SubscriptionScreenProps {
|
|||||||
returnTo?: SubscriptionReturnTo;
|
returnTo?: SubscriptionReturnTo;
|
||||||
initialPayChannel?: PayChannel | null;
|
initialPayChannel?: PayChannel | null;
|
||||||
analyticsContext?: PaymentAnalyticsContext;
|
analyticsContext?: PaymentAnalyticsContext;
|
||||||
sourceCharacterSlug?: string;
|
sourceCharacterSlug?: string | null;
|
||||||
initialPlanId?: string | null;
|
initialPlanId?: string | null;
|
||||||
|
initialAutoRenew?: boolean | null;
|
||||||
commercialOfferId?: string | null;
|
commercialOfferId?: string | null;
|
||||||
resumeOrderId?: string | null;
|
resumeOrderId?: string | null;
|
||||||
chatActionId?: string | null;
|
chatActionId?: string | null;
|
||||||
@@ -64,23 +59,20 @@ export function SubscriptionScreen({
|
|||||||
returnTo = null,
|
returnTo = null,
|
||||||
initialPayChannel = null,
|
initialPayChannel = null,
|
||||||
analyticsContext: providedAnalyticsContext,
|
analyticsContext: providedAnalyticsContext,
|
||||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
sourceCharacterSlug = null,
|
||||||
initialPlanId = null,
|
initialPlanId = null,
|
||||||
|
initialAutoRenew = null,
|
||||||
commercialOfferId = null,
|
commercialOfferId = null,
|
||||||
resumeOrderId = null,
|
resumeOrderId = null,
|
||||||
chatActionId = null,
|
chatActionId = null,
|
||||||
}: SubscriptionScreenProps) {
|
}: SubscriptionScreenProps) {
|
||||||
const [confirmedVipPlanIds, setConfirmedVipPlanIds] = useState<
|
|
||||||
ReadonlySet<string>
|
|
||||||
>(() => new Set());
|
|
||||||
const [pendingVipPlanId, setPendingVipPlanId] = useState<string | null>(null);
|
|
||||||
const [showPaymentIssueDialog, setShowPaymentIssueDialog] = useState(false);
|
const [showPaymentIssueDialog, setShowPaymentIssueDialog] = useState(false);
|
||||||
const [paymentIssueNotice, setPaymentIssueNotice] = useState<string | null>(
|
const [paymentIssueNotice, setPaymentIssueNotice] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const characterCatalog = useCharacterCatalog();
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
const sourceCharacter =
|
const sourceCharacter = characterCatalog.getBySlug(sourceCharacterSlug);
|
||||||
getCharacterBySlug(sourceCharacterSlug) ?? DEFAULT_CHARACTER;
|
|
||||||
const hasHydrated = useHasHydrated();
|
const hasHydrated = useHasHydrated();
|
||||||
const countryCode = userState.currentUser?.countryCode;
|
const countryCode = userState.currentUser?.countryCode;
|
||||||
const paymentMethodConfig = getPaymentMethodConfig({
|
const paymentMethodConfig = getPaymentMethodConfig({
|
||||||
@@ -100,9 +92,10 @@ export function SubscriptionScreen({
|
|||||||
subscriptionType,
|
subscriptionType,
|
||||||
shouldResumePendingOrder,
|
shouldResumePendingOrder,
|
||||||
returnTo,
|
returnTo,
|
||||||
sourceCharacterSlug,
|
sourceCharacterSlug: sourceCharacter?.slug ?? null,
|
||||||
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
||||||
initialPlanId,
|
initialPlanId,
|
||||||
|
initialAutoRenew,
|
||||||
commercialOfferId,
|
commercialOfferId,
|
||||||
resumeOrderId,
|
resumeOrderId,
|
||||||
chatActionId,
|
chatActionId,
|
||||||
@@ -131,14 +124,14 @@ export function SubscriptionScreen({
|
|||||||
});
|
});
|
||||||
}, [canSubscribeVip, directCoinsPlans, payment.plans, vipOfferPlans]);
|
}, [canSubscribeVip, directCoinsPlans, payment.plans, vipOfferPlans]);
|
||||||
usePaymentPlanAnalytics(displayedPlans, analyticsContext);
|
usePaymentPlanAnalytics(displayedPlans, analyticsContext);
|
||||||
const firstRechargeOffer = useMemo(
|
const hasFirstRechargeOffer = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getFirstRechargeOfferView({
|
getFirstRechargeOfferView({
|
||||||
isFirstRecharge: payment.isFirstRecharge,
|
isFirstRecharge: payment.isFirstRecharge,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
vipPlans: vipOfferPlans,
|
vipPlans: vipOfferPlans,
|
||||||
coinPlans: directCoinsPlans,
|
coinPlans: directCoinsPlans,
|
||||||
}),
|
}) !== null,
|
||||||
[
|
[
|
||||||
directCoinsPlans,
|
directCoinsPlans,
|
||||||
payment.isFirstRecharge,
|
payment.isFirstRecharge,
|
||||||
@@ -156,48 +149,20 @@ export function SubscriptionScreen({
|
|||||||
const isPaymentBusy =
|
const isPaymentBusy =
|
||||||
payment.isCreatingOrder ||
|
payment.isCreatingOrder ||
|
||||||
payment.isPollingOrder;
|
payment.isPollingOrder;
|
||||||
const selectedPlanIsVip = vipOfferPlans.some(
|
const selectedVipPlan =
|
||||||
(plan) => plan.id === payment.selectedPlanId,
|
vipOfferPlans.find((plan) => plan.id === payment.selectedPlanId) ?? null;
|
||||||
);
|
const selectedPlanIsVip = selectedVipPlan !== null;
|
||||||
const canActivate =
|
const canActivate =
|
||||||
|
sourceCharacter !== null &&
|
||||||
selectedPlan !== null &&
|
selectedPlan !== null &&
|
||||||
canCheckoutSubscriptionPlan({
|
|
||||||
selectedPlanId: payment.selectedPlanId,
|
|
||||||
vipPlans: vipOfferPlans,
|
|
||||||
confirmedVipPlanIds,
|
|
||||||
}) &&
|
|
||||||
!isPaymentBusy;
|
!isPaymentBusy;
|
||||||
const pendingVipPlan =
|
|
||||||
vipOfferPlans.find((plan) => plan.id === pendingVipPlanId) ?? null;
|
|
||||||
|
|
||||||
const handleSelectPlan = (planId: string) => {
|
const handleSelectPlan = (planId: string) => {
|
||||||
const plan = payment.plans.find((item) => item.planId === planId);
|
const plan = payment.plans.find((item) => item.planId === planId);
|
||||||
if (plan) behaviorAnalytics.planClick(plan, analyticsContext);
|
if (plan) behaviorAnalytics.planClick(plan, analyticsContext);
|
||||||
if (
|
|
||||||
requiresVipPlanConfirmation({
|
|
||||||
planId,
|
|
||||||
vipPlans: vipOfferPlans,
|
|
||||||
confirmedVipPlanIds,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
setPendingVipPlanId(planId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
paymentDispatch({ type: "PaymentPlanSelected", planId });
|
paymentDispatch({ type: "PaymentPlanSelected", planId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmVipPlan = () => {
|
|
||||||
if (!pendingVipPlanId) return;
|
|
||||||
const planId = pendingVipPlanId;
|
|
||||||
setConfirmedVipPlanIds((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
next.add(planId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
paymentDispatch({ type: "PaymentPlanSelected", planId });
|
|
||||||
setPendingVipPlanId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePaymentMethodChange = (payChannel: PayChannel) => {
|
const handlePaymentMethodChange = (payChannel: PayChannel) => {
|
||||||
paymentDispatch({
|
paymentDispatch({
|
||||||
type: "PaymentPayChannelChanged",
|
type: "PaymentPayChannelChanged",
|
||||||
@@ -243,6 +208,7 @@ export function SubscriptionScreen({
|
|||||||
return (
|
return (
|
||||||
<MobileShell>
|
<MobileShell>
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell}>
|
||||||
|
<div className={styles.scrollArea}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<BackButton
|
<BackButton
|
||||||
className={styles.backSlot}
|
className={styles.backSlot}
|
||||||
@@ -268,7 +234,7 @@ export function SubscriptionScreen({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{chatActionId ? (
|
{chatActionId && sourceCharacter ? (
|
||||||
<section
|
<section
|
||||||
className={styles.characterSupportBanner}
|
className={styles.characterSupportBanner}
|
||||||
aria-label={`Support ${sourceCharacter.displayName}`}
|
aria-label={`Support ${sourceCharacter.displayName}`}
|
||||||
@@ -284,31 +250,7 @@ export function SubscriptionScreen({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{firstRechargeOffer ? (
|
{payment.commercialOffer && !hasFirstRechargeOffer && sourceCharacter ? (
|
||||||
<section
|
|
||||||
className={styles.firstRechargeBanner}
|
|
||||||
aria-label="First recharge offer"
|
|
||||||
>
|
|
||||||
<span className={styles.firstRechargeBadge}>
|
|
||||||
{firstRechargeOffer.badgeText}
|
|
||||||
</span>
|
|
||||||
<div className={styles.firstRechargeCopy}>
|
|
||||||
<h2 className={styles.firstRechargeTitle}>
|
|
||||||
{firstRechargeOffer.title}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.firstRechargeSubtitle}>
|
|
||||||
{firstRechargeOffer.subtitle}
|
|
||||||
</p>
|
|
||||||
{firstRechargeOffer.renewalNotice ? (
|
|
||||||
<p className={styles.firstRechargeRenewalNotice}>
|
|
||||||
{firstRechargeOffer.renewalNotice}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{payment.commercialOffer && !firstRechargeOffer ? (
|
|
||||||
<section
|
<section
|
||||||
className={styles.firstRechargeBanner}
|
className={styles.firstRechargeBanner}
|
||||||
aria-label={`${sourceCharacter.shortName} private offer`}
|
aria-label={`${sourceCharacter.shortName} private offer`}
|
||||||
@@ -344,7 +286,7 @@ export function SubscriptionScreen({
|
|||||||
<PaymentMethodSelector
|
<PaymentMethodSelector
|
||||||
config={renderedPaymentMethodConfig}
|
config={renderedPaymentMethodConfig}
|
||||||
value={payment.payChannel}
|
value={payment.payChannel}
|
||||||
disabled={isPaymentBusy}
|
disabled={payment.isCreatingOrder}
|
||||||
caption={
|
caption={
|
||||||
paymentMethodConfig.ezpayDisplayName === "QRIS"
|
paymentMethodConfig.ezpayDisplayName === "QRIS"
|
||||||
? "QRIS by default in Indonesia"
|
? "QRIS by default in Indonesia"
|
||||||
@@ -354,23 +296,19 @@ export function SubscriptionScreen({
|
|||||||
analyticsKey="subscription.payment_method"
|
analyticsKey="subscription.payment_method"
|
||||||
onChange={handlePaymentMethodChange}
|
onChange={handlePaymentMethodChange}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.ctaSlot}>
|
<div className={styles.ctaSlot}>
|
||||||
<SubscriptionCheckoutButton
|
<SubscriptionCheckoutButton
|
||||||
disabled={!canActivate}
|
disabled={!canActivate}
|
||||||
subscriptionType={subscriptionType}
|
subscriptionType={subscriptionType}
|
||||||
returnTo={returnTo}
|
returnTo={returnTo}
|
||||||
sourceCharacterSlug={sourceCharacterSlug}
|
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
|
||||||
|
renewalPlan={selectedVipPlan}
|
||||||
|
renewalConsentSubjectId={userState.currentUser?.id || null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubscriptionRenewalConfirmationDialog
|
|
||||||
open={pendingVipPlan !== null}
|
|
||||||
plan={pendingVipPlan}
|
|
||||||
onCancel={() => setPendingVipPlanId(null)}
|
|
||||||
onConfirm={handleConfirmVipPlan}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showPaymentIssueDialog ? (
|
{showPaymentIssueDialog ? (
|
||||||
<SubscriptionPaymentIssueDialog
|
<SubscriptionPaymentIssueDialog
|
||||||
open
|
open
|
||||||
@@ -385,7 +323,7 @@ export function SubscriptionScreen({
|
|||||||
orderId={payment.currentOrderId}
|
orderId={payment.currentOrderId}
|
||||||
payChannel={payment.payChannel}
|
payChannel={payment.payChannel}
|
||||||
countryCode={countryCode}
|
countryCode={countryCode}
|
||||||
characterId={sourceCharacterSlug}
|
characterId={sourceCharacter?.id ?? ""}
|
||||||
onClose={() => setShowPaymentIssueDialog(false)}
|
onClose={() => setShowPaymentIssueDialog(false)}
|
||||||
onSubmitted={setPaymentIssueNotice}
|
onSubmitted={setPaymentIssueNotice}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
|
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
|
||||||
import type { PayChannel } from "@/data/schemas/payment";
|
import type { PayChannel } from "@/data/schemas/payment";
|
||||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
import {
|
||||||
|
DEFAULT_CHARACTER,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
import {
|
import {
|
||||||
consumeSubscriptionExitUrl,
|
consumeSubscriptionExitUrl,
|
||||||
resolveSubscriptionSuccessExitUrl,
|
resolveSubscriptionSuccessExitUrl,
|
||||||
@@ -19,8 +22,9 @@ export interface UseSubscriptionPaymentFlowInput {
|
|||||||
shouldResumePendingOrder: boolean;
|
shouldResumePendingOrder: boolean;
|
||||||
returnTo: SubscriptionReturnTo;
|
returnTo: SubscriptionReturnTo;
|
||||||
initialPayChannel: PayChannel;
|
initialPayChannel: PayChannel;
|
||||||
sourceCharacterSlug?: string;
|
sourceCharacterSlug?: string | null;
|
||||||
initialPlanId?: string | null;
|
initialPlanId?: string | null;
|
||||||
|
initialAutoRenew?: boolean | null;
|
||||||
commercialOfferId?: string | null;
|
commercialOfferId?: string | null;
|
||||||
resumeOrderId?: string | null;
|
resumeOrderId?: string | null;
|
||||||
chatActionId?: string | null;
|
chatActionId?: string | null;
|
||||||
@@ -31,18 +35,23 @@ export function useSubscriptionPaymentFlow({
|
|||||||
shouldResumePendingOrder,
|
shouldResumePendingOrder,
|
||||||
returnTo,
|
returnTo,
|
||||||
initialPayChannel,
|
initialPayChannel,
|
||||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
sourceCharacterSlug = null,
|
||||||
initialPlanId = null,
|
initialPlanId = null,
|
||||||
|
initialAutoRenew = null,
|
||||||
commercialOfferId = null,
|
commercialOfferId = null,
|
||||||
resumeOrderId = null,
|
resumeOrderId = null,
|
||||||
chatActionId = null,
|
chatActionId = null,
|
||||||
}: UseSubscriptionPaymentFlowInput) {
|
}: UseSubscriptionPaymentFlowInput) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const supportCharacter = getCharacterBySlug(sourceCharacterSlug);
|
||||||
|
const exitCharacterSlug = supportCharacter?.slug ?? DEFAULT_CHARACTER.slug;
|
||||||
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
||||||
initialPayChannel,
|
initialPayChannel,
|
||||||
paymentType: subscriptionType,
|
paymentType: subscriptionType,
|
||||||
shouldResumePendingOrder,
|
shouldResumePendingOrder,
|
||||||
|
characterId: supportCharacter?.id,
|
||||||
initialPlanId,
|
initialPlanId,
|
||||||
|
initialAutoRenew,
|
||||||
commercialOfferId,
|
commercialOfferId,
|
||||||
resumeOrderId,
|
resumeOrderId,
|
||||||
chatActionId,
|
chatActionId,
|
||||||
@@ -62,7 +71,7 @@ export function useSubscriptionPaymentFlow({
|
|||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
router.replace(
|
router.replace(
|
||||||
await consumeSubscriptionExitUrl(returnTo, sourceCharacterSlug),
|
await consumeSubscriptionExitUrl(returnTo, exitCharacterSlug),
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
@@ -74,7 +83,7 @@ export function useSubscriptionPaymentFlow({
|
|||||||
router.replace(
|
router.replace(
|
||||||
await resolveSubscriptionSuccessExitUrl(
|
await resolveSubscriptionSuccessExitUrl(
|
||||||
returnTo,
|
returnTo,
|
||||||
sourceCharacterSlug,
|
exitCharacterSlug,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -359,6 +359,8 @@ function makePaymentState(
|
|||||||
selectedGiftCategory: giftCategory.category,
|
selectedGiftCategory: giftCategory.category,
|
||||||
isFirstRecharge: false,
|
isFirstRecharge: false,
|
||||||
commercialOffer: null,
|
commercialOffer: null,
|
||||||
|
commercialOfferId: null,
|
||||||
|
chatActionId: null,
|
||||||
selectedPlanId: giftPlan.planId,
|
selectedPlanId: giftPlan.planId,
|
||||||
payChannel: "stripe",
|
payChannel: "stripe",
|
||||||
autoRenew: false,
|
autoRenew: false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||||
|
import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button";
|
||||||
import { useActiveCharacter } from "@/providers/character-provider";
|
import { useActiveCharacter } from "@/providers/character-provider";
|
||||||
import {
|
import {
|
||||||
usePaymentDispatch,
|
usePaymentDispatch,
|
||||||
@@ -42,8 +43,12 @@ export function TipCheckoutButton({
|
|||||||
characterSlug: character.slug,
|
characterSlug: character.slug,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
|
const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
|
||||||
const label = payment.isPollingOrder
|
const isLoading =
|
||||||
|
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
|
||||||
|
const label = isResumingQris
|
||||||
|
? "Resume QRIS payment"
|
||||||
|
: payment.isPollingOrder
|
||||||
? "Processing payment..."
|
? "Processing payment..."
|
||||||
: payment.isCreatingOrder
|
: payment.isCreatingOrder
|
||||||
? "Creating order..."
|
? "Creating order..."
|
||||||
@@ -58,11 +63,24 @@ export function TipCheckoutButton({
|
|||||||
data-analytics-key="tip.checkout"
|
data-analytics-key="tip.checkout"
|
||||||
data-analytics-label="Buy coffee tip"
|
data-analytics-label="Buy coffee tip"
|
||||||
className={styles.checkoutButton}
|
className={styles.checkoutButton}
|
||||||
disabled={disabled || isLoading}
|
disabled={(disabled && !isResumingQris) || isLoading}
|
||||||
onClick={onOrder}
|
onClick={isResumingQris ? paymentLaunch.handleQrisResume : onOrder}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
|
||||||
|
<ExternalBrowserCheckoutButton
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
checkoutIntent={{
|
||||||
|
planId: payment.selectedPlanId,
|
||||||
|
autoRenew: false,
|
||||||
|
recipientCharacterId: character.id,
|
||||||
|
...(payment.chatActionId
|
||||||
|
? { chatActionId: payment.chatActionId }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{payment.errorMessage ? (
|
{payment.errorMessage ? (
|
||||||
<p className={styles.checkoutError} role="alert">
|
<p className={styles.checkoutError} role="alert">
|
||||||
{payment.errorMessage}
|
{payment.errorMessage}
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ export function TipScreen({
|
|||||||
config={renderedPaymentMethodConfig}
|
config={renderedPaymentMethodConfig}
|
||||||
value={payment.payChannel}
|
value={payment.payChannel}
|
||||||
density="compact"
|
density="compact"
|
||||||
disabled={isPaymentBusy}
|
disabled={payment.isCreatingOrder}
|
||||||
className={styles.paymentMethodSlot}
|
className={styles.paymentMethodSlot}
|
||||||
analyticsKey="tip.payment_method"
|
analyticsKey="tip.payment_method"
|
||||||
onChange={handlePaymentMethodChange}
|
onChange={handlePaymentMethodChange}
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ export class AppConstants {
|
|||||||
|
|
||||||
/** 会员服务协议文档链接 */
|
/** 会员服务协议文档链接 */
|
||||||
static readonly membershipAgreementUrl =
|
static readonly membershipAgreementUrl =
|
||||||
"https://www.banlv-ai.com/cozsweet/membership-agreement.html";
|
"/legal/vip-membership-benefits.html";
|
||||||
|
|
||||||
/** 自动续费服务协议文档链接 */
|
/** 自动续费服务协议文档链接 */
|
||||||
static readonly autoRenewalAgreementUrl =
|
static readonly autoRenewalAgreementUrl =
|
||||||
"https://www.banlv-ai.com/cozsweet/auto-renewal-agreement.html";
|
"/legal/automatic-renewal.html";
|
||||||
|
|
||||||
/** 开发环境 APP 标题 */
|
/** 开发环境 APP 标题 */
|
||||||
static readonly appTitleDevelopment = "Develop";
|
static readonly appTitleDevelopment = "Develop";
|
||||||
|
|||||||
@@ -45,6 +45,27 @@ describe("ChatWebSocket payment guidance", () => {
|
|||||||
|
|
||||||
expect(onPaymentGuidance).not.toHaveBeenCalled();
|
expect(onPaymentGuidance).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("delivers a persisted commercial thank-you as a live role message", () => {
|
||||||
|
const socket = new ChatWebSocket("ws://example.test/chat", "token");
|
||||||
|
const onCommercialMessage = vi.fn();
|
||||||
|
socket.onCommercialMessage = onCommercialMessage;
|
||||||
|
|
||||||
|
receive(socket, {
|
||||||
|
type: "commercial_message",
|
||||||
|
messageId: "message-1",
|
||||||
|
message: "Thank you for supporting me.",
|
||||||
|
characterId: "maya-tan",
|
||||||
|
createdAt: "2026-07-28T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onCommercialMessage).toHaveBeenCalledWith({
|
||||||
|
messageId: "message-1",
|
||||||
|
message: "Thank you for supporting me.",
|
||||||
|
characterId: "maya-tan",
|
||||||
|
createdAt: "2026-07-28T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function receive(socket: ChatWebSocket, payload: unknown): void {
|
function receive(socket: ChatWebSocket, payload: unknown): void {
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export interface PaywallStatusPayload {
|
|||||||
reason: string | null;
|
reason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommercialMessagePayload {
|
||||||
|
messageId: string;
|
||||||
|
message: string;
|
||||||
|
characterId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ChatWebSocket {
|
export class ChatWebSocket {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -52,6 +59,7 @@ export class ChatWebSocket {
|
|||||||
onCommercialAction: ((action: CommercialAction) => void) | null = null;
|
onCommercialAction: ((action: CommercialAction) => void) | null = null;
|
||||||
onChatAction: ((action: ChatAction) => void) | null = null;
|
onChatAction: ((action: ChatAction) => void) | null = null;
|
||||||
onPaymentGuidance: ((guidance: PaymentGuidance) => void) | null = null;
|
onPaymentGuidance: ((guidance: PaymentGuidance) => void) | null = null;
|
||||||
|
onCommercialMessage: ((message: CommercialMessagePayload) => void) | null = null;
|
||||||
onError: ((errorMessage: string) => void) | null = null;
|
onError: ((errorMessage: string) => void) | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -149,6 +157,10 @@ export class ChatWebSocket {
|
|||||||
done?: boolean;
|
done?: boolean;
|
||||||
audioUrl?: string;
|
audioUrl?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
messageId?: string;
|
||||||
|
message?: string;
|
||||||
|
characterId?: string;
|
||||||
|
createdAt?: string;
|
||||||
data?: Record<string, unknown> & {
|
data?: Record<string, unknown> & {
|
||||||
image?: {
|
image?: {
|
||||||
type?: string | null;
|
type?: string | null;
|
||||||
@@ -163,6 +175,10 @@ export class ChatWebSocket {
|
|||||||
ruleId?: string;
|
ruleId?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
orderId?: string | null;
|
orderId?: string | null;
|
||||||
|
messageId?: string;
|
||||||
|
message?: string;
|
||||||
|
characterId?: string;
|
||||||
|
createdAt?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@@ -217,6 +233,25 @@ export class ChatWebSocket {
|
|||||||
if (guidance.success) this.onPaymentGuidance?.(guidance.data);
|
if (guidance.success) this.onPaymentGuidance?.(guidance.data);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "commercial_message": {
|
||||||
|
const commercialMessage = payload.data?.messageId
|
||||||
|
? payload.data
|
||||||
|
: payload;
|
||||||
|
if (
|
||||||
|
commercialMessage?.messageId &&
|
||||||
|
commercialMessage.message &&
|
||||||
|
commercialMessage.characterId &&
|
||||||
|
commercialMessage.createdAt
|
||||||
|
) {
|
||||||
|
this.onCommercialMessage?.({
|
||||||
|
messageId: commercialMessage.messageId,
|
||||||
|
message: commercialMessage.message,
|
||||||
|
characterId: commercialMessage.characterId,
|
||||||
|
createdAt: commercialMessage.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "error":
|
case "error":
|
||||||
this.onError?.(payload.error ?? "Unknown error");
|
this.onError?.(payload.error ?? "Unknown error");
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { ExceptionHandler } from "@/core/errors";
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
import type { IAuthRepository } from "@/data/repositories/interfaces";
|
import type { IAuthRepository } from "@/data/repositories/interfaces";
|
||||||
import {
|
import {
|
||||||
|
CheckoutHandoffConsumeRequestSchema,
|
||||||
|
CheckoutHandoffCreateRequestSchema,
|
||||||
|
type CheckoutHandoffCreateResponse,
|
||||||
|
type CheckoutIntent,
|
||||||
FacebookIdentityRequestSchema,
|
FacebookIdentityRequestSchema,
|
||||||
FacebookLoginRequestSchema,
|
FacebookLoginRequestSchema,
|
||||||
FbIdLoginRequestSchema,
|
FbIdLoginRequestSchema,
|
||||||
@@ -276,6 +280,30 @@ export class AuthRepository implements IAuthRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 创建订单前生成一次性外部浏览器支付交接链接。 */
|
||||||
|
async createCheckoutHandoff(
|
||||||
|
checkoutIntent: CheckoutIntent,
|
||||||
|
): Promise<Result<CheckoutHandoffCreateResponse>> {
|
||||||
|
return Result.wrap(() =>
|
||||||
|
this.api.createCheckoutHandoff(
|
||||||
|
CheckoutHandoffCreateRequestSchema.parse(checkoutIntent),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消费支付交接凭证,保存后端签发的正式登录态并返回购买意图。 */
|
||||||
|
async consumeCheckoutHandoff(
|
||||||
|
handoffToken: string,
|
||||||
|
): Promise<Result<CheckoutIntent>> {
|
||||||
|
return Result.wrap(async () => {
|
||||||
|
const response = await this.api.consumeCheckoutHandoff(
|
||||||
|
CheckoutHandoffConsumeRequestSchema.parse({ handoffToken }),
|
||||||
|
);
|
||||||
|
await this._saveLoginData(response, response.loginStatus);
|
||||||
|
return response.checkoutIntent;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新 token:先读本地的 refresh token,空则直接返回错误;
|
* 刷新 token:先读本地的 refresh token,空则直接返回错误;
|
||||||
* 调用 API 成功后写回新的 login token + refresh token。
|
* 调用 API 成功后写回新的 login token + refresh token。
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
import type { Result } from "@/utils/result";
|
import type { Result } from "@/utils/result";
|
||||||
import type {
|
import type {
|
||||||
|
CheckoutHandoffCreateResponse,
|
||||||
|
CheckoutIntent,
|
||||||
GuestLoginResponse,
|
GuestLoginResponse,
|
||||||
LoginStatus,
|
LoginStatus,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -74,6 +76,16 @@ export interface IAuthRepository {
|
|||||||
/** 消费一次性充值登录凭证并建立正式会话。 */
|
/** 消费一次性充值登录凭证并建立正式会话。 */
|
||||||
consumeTopUpHandoff(handoffToken: string): Promise<Result<LoginStatus>>;
|
consumeTopUpHandoff(handoffToken: string): Promise<Result<LoginStatus>>;
|
||||||
|
|
||||||
|
/** 在创建订单前生成10分钟有效的外部浏览器支付链接。 */
|
||||||
|
createCheckoutHandoff(
|
||||||
|
checkoutIntent: CheckoutIntent,
|
||||||
|
): Promise<Result<CheckoutHandoffCreateResponse>>;
|
||||||
|
|
||||||
|
/** 消费单次支付交接凭证并保存正式登录态。 */
|
||||||
|
consumeCheckoutHandoff(
|
||||||
|
handoffToken: string,
|
||||||
|
): Promise<Result<CheckoutIntent>>;
|
||||||
|
|
||||||
/** 刷新 token:先读本地的 refresh token,空则返回错误。 */
|
/** 刷新 token:先读本地的 refresh token,空则返回错误。 */
|
||||||
refreshToken(): Promise<Result<RefreshTokenResponse>>;
|
refreshToken(): Promise<Result<RefreshTokenResponse>>;
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import type { Result } from "@/utils/result";
|
|||||||
|
|
||||||
export interface IPaymentRepository {
|
export interface IPaymentRepository {
|
||||||
/** 获取套餐列表。 */
|
/** 获取套餐列表。 */
|
||||||
getPlans(commercialOfferId?: string): Promise<Result<PaymentPlansResponse>>;
|
getPlans(
|
||||||
|
commercialOfferId?: string,
|
||||||
|
supportCharacterId?: string,
|
||||||
|
): Promise<Result<PaymentPlansResponse>>;
|
||||||
|
|
||||||
/** 获取本地缓存套餐列表。 */
|
/** 获取本地缓存套餐列表。 */
|
||||||
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
|
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
|
||||||
|
|||||||
@@ -24,10 +24,18 @@ export class PaymentRepository implements IPaymentRepository {
|
|||||||
constructor(private readonly api: PaymentApi) {}
|
constructor(private readonly api: PaymentApi) {}
|
||||||
|
|
||||||
/** 获取套餐列表。 */
|
/** 获取套餐列表。 */
|
||||||
async getPlans(commercialOfferId?: string): Promise<Result<PaymentPlansResponse>> {
|
async getPlans(
|
||||||
|
commercialOfferId?: string,
|
||||||
|
supportCharacterId?: string,
|
||||||
|
): Promise<Result<PaymentPlansResponse>> {
|
||||||
return Result.wrap(async () => {
|
return Result.wrap(async () => {
|
||||||
const response = await this.api.getPlans(commercialOfferId);
|
const response = await this.api.getPlans(
|
||||||
if (!commercialOfferId) await PaymentPlansStorage.setPlans(response);
|
commercialOfferId,
|
||||||
|
supportCharacterId,
|
||||||
|
);
|
||||||
|
if (!commercialOfferId && !supportCharacterId) {
|
||||||
|
await PaymentPlansStorage.setPlans(response);
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CheckoutHandoffConsumeResponseSchema,
|
||||||
|
CheckoutHandoffCreateRequestSchema,
|
||||||
|
} from "../checkout_handoff";
|
||||||
|
|
||||||
|
describe("checkout handoff schemas", () => {
|
||||||
|
it("keeps only the minimal order-free purchase intent", () => {
|
||||||
|
expect(
|
||||||
|
CheckoutHandoffCreateRequestSchema.parse({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
recipientCharacterId: "elio",
|
||||||
|
price: 9.99,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
recipientCharacterId: "elio",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses the formal session and checkout intent returned after consumption", () => {
|
||||||
|
const parsed = CheckoutHandoffConsumeResponseSchema.parse({
|
||||||
|
user: {
|
||||||
|
id: "00000000-0000-0000-0000-000000000123",
|
||||||
|
username: "checkout-user",
|
||||||
|
email: "checkout@example.com",
|
||||||
|
platform: "web",
|
||||||
|
},
|
||||||
|
token: "access-token",
|
||||||
|
refreshToken: "refresh-token",
|
||||||
|
loginStatus: "email",
|
||||||
|
checkoutIntent: {
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.checkoutIntent.planId).toBe("vip_monthly");
|
||||||
|
expect(parsed.loginStatus).toBe("email");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { stringOrEmpty } from "../nullable-defaults";
|
||||||
|
import { UserSchema } from "../user/user";
|
||||||
|
|
||||||
|
export const CheckoutIntentSchema = z
|
||||||
|
.object({
|
||||||
|
planId: z.string().min(1).max(120),
|
||||||
|
autoRenew: z.boolean(),
|
||||||
|
recipientCharacterId: z.string().min(1).max(80).optional(),
|
||||||
|
commercialOfferId: z.string().min(1).max(80).optional(),
|
||||||
|
chatActionId: z.uuid().optional(),
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export const CheckoutHandoffCreateRequestSchema = CheckoutIntentSchema;
|
||||||
|
|
||||||
|
export const CheckoutHandoffCreateResponseSchema = z
|
||||||
|
.object({
|
||||||
|
externalUrl: z.url(),
|
||||||
|
expiresAt: z.string().min(1),
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export const CheckoutHandoffConsumeRequestSchema = z
|
||||||
|
.object({
|
||||||
|
handoffToken: z.string().min(32).max(512),
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export const CheckoutHandoffConsumeResponseSchema = z
|
||||||
|
.object({
|
||||||
|
user: UserSchema,
|
||||||
|
token: z.string().min(1),
|
||||||
|
refreshToken: stringOrEmpty,
|
||||||
|
loginStatus: z.enum([
|
||||||
|
"email",
|
||||||
|
"google",
|
||||||
|
"facebook",
|
||||||
|
"facebookMessenger",
|
||||||
|
]),
|
||||||
|
checkoutIntent: CheckoutIntentSchema,
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export type CheckoutIntent = z.output<typeof CheckoutIntentSchema>;
|
||||||
|
export type CheckoutHandoffCreateRequest = z.output<
|
||||||
|
typeof CheckoutHandoffCreateRequestSchema
|
||||||
|
>;
|
||||||
|
export type CheckoutHandoffCreateResponse = z.output<
|
||||||
|
typeof CheckoutHandoffCreateResponseSchema
|
||||||
|
>;
|
||||||
|
export type CheckoutHandoffConsumeRequest = z.output<
|
||||||
|
typeof CheckoutHandoffConsumeRequestSchema
|
||||||
|
>;
|
||||||
|
export type CheckoutHandoffConsumeResponse = z.output<
|
||||||
|
typeof CheckoutHandoffConsumeResponseSchema
|
||||||
|
>;
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./facebook_user_data";
|
export * from "./facebook_user_data";
|
||||||
|
export * from "./checkout_handoff";
|
||||||
export * from "./login_status";
|
export * from "./login_status";
|
||||||
export * from "./request/facebook_identity_request";
|
export * from "./request/facebook_identity_request";
|
||||||
export * from "./request/facebook_login_request";
|
export * from "./request/facebook_login_request";
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ describe("payment guidance wire contract", () => {
|
|||||||
expect(PaymentGuidanceSchema.parse(guidance)).toEqual(guidance);
|
expect(PaymentGuidanceSchema.parse(guidance)).toEqual(guidance);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts the supportCharacter guidance scene", () => {
|
||||||
|
expect(
|
||||||
|
PaymentGuidanceSchema.parse({
|
||||||
|
...guidance,
|
||||||
|
scene: "supportCharacter",
|
||||||
|
ruleId: "payment_guidance_supportCharacter",
|
||||||
|
}).scene,
|
||||||
|
).toBe("supportCharacter");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps all backend lock facts instead of dropping hint and CTA", () => {
|
it("keeps all backend lock facts instead of dropping hint and CTA", () => {
|
||||||
expect(
|
expect(
|
||||||
ChatLockDetailSchema.parse({
|
ChatLockDetailSchema.parse({
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const PaymentGuidanceSceneSchema = z.enum([
|
|||||||
"privateZoneLocked",
|
"privateZoneLocked",
|
||||||
"unknownLock",
|
"unknownLock",
|
||||||
"whyPay",
|
"whyPay",
|
||||||
|
"supportCharacter",
|
||||||
"leavingAfterCredits",
|
"leavingAfterCredits",
|
||||||
"paymentIssue",
|
"paymentIssue",
|
||||||
"externalRisk",
|
"externalRisk",
|
||||||
|
|||||||
@@ -195,9 +195,11 @@ describe("PaymentPlansResponse", () => {
|
|||||||
|
|
||||||
it("parses one user-bound commercial offer and its discounted plan", () => {
|
it("parses one user-bound commercial offer and its discounted plan", () => {
|
||||||
const response = PaymentPlansResponseSchema.parse({
|
const response = PaymentPlansResponseSchema.parse({
|
||||||
|
supportCharacterId: "maya-tan",
|
||||||
commercialOffer: {
|
commercialOffer: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
commercialOfferId: "offer-1",
|
commercialOfferId: "offer-1",
|
||||||
|
characterId: "maya-tan",
|
||||||
planId: "vip_annual",
|
planId: "vip_annual",
|
||||||
discountPercent: 30,
|
discountPercent: 30,
|
||||||
pricePercent: 70,
|
pricePercent: 70,
|
||||||
@@ -225,6 +227,8 @@ describe("PaymentPlansResponse", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.commercialOffer?.commercialOfferId).toBe("offer-1");
|
expect(response.commercialOffer?.commercialOfferId).toBe("offer-1");
|
||||||
|
expect(response.supportCharacterId).toBe("maya-tan");
|
||||||
|
expect(response.commercialOffer?.characterId).toBe("maya-tan");
|
||||||
expect(response.plans[0]).toMatchObject({
|
expect(response.plans[0]).toMatchObject({
|
||||||
planId: "vip_annual",
|
planId: "vip_annual",
|
||||||
commercialOfferId: "offer-1",
|
commercialOfferId: "offer-1",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const CommercialOfferSummarySchema = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: booleanOrFalse,
|
enabled: booleanOrFalse,
|
||||||
commercialOfferId: z.string().min(1),
|
commercialOfferId: z.string().min(1),
|
||||||
|
characterId: stringOrEmpty,
|
||||||
planId: z.string().min(1),
|
planId: z.string().min(1),
|
||||||
discountPercent: numberOrZero,
|
discountPercent: numberOrZero,
|
||||||
pricePercent: numberOrZero,
|
pricePercent: numberOrZero,
|
||||||
@@ -36,6 +37,7 @@ export const CommercialOfferSummarySchema = z
|
|||||||
export const PaymentPlansResponseSchema = z
|
export const PaymentPlansResponseSchema = z
|
||||||
.object({
|
.object({
|
||||||
isFirstRecharge: booleanOrFalse,
|
isFirstRecharge: booleanOrFalse,
|
||||||
|
supportCharacterId: stringOrEmpty,
|
||||||
firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema),
|
firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema),
|
||||||
commercialOffer: schemaOrNull(CommercialOfferSummarySchema),
|
commercialOffer: schemaOrNull(CommercialOfferSummarySchema),
|
||||||
plans: arrayOrEmpty(PaymentPlanSchema),
|
plans: arrayOrEmpty(PaymentPlanSchema),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" },
|
"facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" },
|
||||||
"facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" },
|
"facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" },
|
||||||
"topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" },
|
"topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" },
|
||||||
|
"checkoutHandoffCreate": { "method": "post", "path": "/api/auth/handoff/checkout" },
|
||||||
|
"checkoutHandoffConsume": { "method": "post", "path": "/api/auth/handoff/checkout/consume" },
|
||||||
"refresh": { "method": "post", "path": "/api/auth/refresh" },
|
"refresh": { "method": "post", "path": "/api/auth/refresh" },
|
||||||
"logout": { "method": "post", "path": "/api/auth/logout" },
|
"logout": { "method": "post", "path": "/api/auth/logout" },
|
||||||
"getCurrentUser": { "method": "get", "path": "/api/auth/me" },
|
"getCurrentUser": { "method": "get", "path": "/api/auth/me" },
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export class ApiPath {
|
|||||||
/** 消费一次性充值登录凭证 */
|
/** 消费一次性充值登录凭证 */
|
||||||
static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path;
|
static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path;
|
||||||
|
|
||||||
|
/** 创建一次性外部浏览器支付交接链接(创建订单前)。 */
|
||||||
|
static readonly checkoutHandoffCreate = apiContract.checkoutHandoffCreate.path;
|
||||||
|
|
||||||
|
/** 消费一次性外部浏览器支付交接凭证。 */
|
||||||
|
static readonly checkoutHandoffConsume = apiContract.checkoutHandoffConsume.path;
|
||||||
|
|
||||||
/** 刷新 Token */
|
/** 刷新 Token */
|
||||||
static readonly refresh = apiContract.refresh.path;
|
static readonly refresh = apiContract.refresh.path;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CheckoutHandoffConsumeRequest,
|
||||||
|
CheckoutHandoffConsumeResponse,
|
||||||
|
CheckoutHandoffConsumeResponseSchema,
|
||||||
|
CheckoutHandoffCreateRequest,
|
||||||
|
CheckoutHandoffCreateResponse,
|
||||||
|
CheckoutHandoffCreateResponseSchema,
|
||||||
FacebookIdentityRequest,
|
FacebookIdentityRequest,
|
||||||
FacebookIdentityResponse,
|
FacebookIdentityResponse,
|
||||||
FacebookIdentityResponseSchema,
|
FacebookIdentityResponseSchema,
|
||||||
@@ -104,6 +110,32 @@ export class AuthApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在创建支付订单前生成一次性外部浏览器交接链接。 */
|
||||||
|
async createCheckoutHandoff(
|
||||||
|
body: CheckoutHandoffCreateRequest,
|
||||||
|
): Promise<CheckoutHandoffCreateResponse> {
|
||||||
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
|
ApiPath.checkoutHandoffCreate,
|
||||||
|
{ method: "POST", body },
|
||||||
|
);
|
||||||
|
return CheckoutHandoffCreateResponseSchema.parse(
|
||||||
|
unwrap(env) as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消费一次性支付交接凭证并恢复正式登录态与购买意图。 */
|
||||||
|
async consumeCheckoutHandoff(
|
||||||
|
body: CheckoutHandoffConsumeRequest,
|
||||||
|
): Promise<CheckoutHandoffConsumeResponse> {
|
||||||
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
|
ApiPath.checkoutHandoffConsume,
|
||||||
|
{ method: "POST", body },
|
||||||
|
);
|
||||||
|
return CheckoutHandoffConsumeResponseSchema.parse(
|
||||||
|
unwrap(env) as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 绑定 Facebook ASID / PSID 到当前用户
|
* 绑定 Facebook ASID / PSID 到当前用户
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -26,9 +26,19 @@ import { ApiEnvelope, unwrap } from "./response_helper";
|
|||||||
|
|
||||||
export class PaymentApi {
|
export class PaymentApi {
|
||||||
/** 获取 VIP 与 Top-up 套餐列表。 */
|
/** 获取 VIP 与 Top-up 套餐列表。 */
|
||||||
async getPlans(commercialOfferId?: string): Promise<PaymentPlansResponse> {
|
async getPlans(
|
||||||
|
commercialOfferId?: string,
|
||||||
|
supportCharacterId?: string,
|
||||||
|
): Promise<PaymentPlansResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans, {
|
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans, {
|
||||||
...(commercialOfferId ? { query: { commercialOfferId } } : {}),
|
...(commercialOfferId || supportCharacterId
|
||||||
|
? {
|
||||||
|
query: {
|
||||||
|
...(commercialOfferId ? { commercialOfferId } : {}),
|
||||||
|
...(supportCharacterId ? { supportCharacterId } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
return PaymentPlansResponseSchema.parse(
|
return PaymentPlansResponseSchema.parse(
|
||||||
unwrap(env) as Record<string, unknown>,
|
unwrap(env) as Record<string, unknown>,
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ describe("payment analytics context", () => {
|
|||||||
entryPoint: "chat_unlock",
|
entryPoint: "chat_unlock",
|
||||||
triggerReason: "ad_landing",
|
triggerReason: "ad_landing",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parsePaymentAnalyticsContext({
|
||||||
|
entryPoint: "chat_offer_banner",
|
||||||
|
triggerReason: "commercial_offer",
|
||||||
|
subscriptionType: "vip",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
entryPoint: "chat_offer_banner",
|
||||||
|
triggerReason: "commercial_offer",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back independently for invalid query values", () => {
|
it("falls back independently for invalid query values", () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type PaymentAnalyticsTriggerReason =
|
|||||||
| "insufficient_credits"
|
| "insufficient_credits"
|
||||||
| "profile_recharge"
|
| "profile_recharge"
|
||||||
| "vip_cta"
|
| "vip_cta"
|
||||||
|
| "commercial_offer"
|
||||||
| "ad_landing"
|
| "ad_landing"
|
||||||
| "manual_recharge"
|
| "manual_recharge"
|
||||||
| "unknown";
|
| "unknown";
|
||||||
@@ -47,6 +48,7 @@ const TRIGGER_REASONS = new Set<PaymentAnalyticsTriggerReason>([
|
|||||||
"insufficient_credits",
|
"insufficient_credits",
|
||||||
"profile_recharge",
|
"profile_recharge",
|
||||||
"vip_cta",
|
"vip_cta",
|
||||||
|
"commercial_offer",
|
||||||
"ad_landing",
|
"ad_landing",
|
||||||
"manual_recharge",
|
"manual_recharge",
|
||||||
"unknown",
|
"unknown",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type {
|
||||||
|
CheckoutHandoffCreateResponse,
|
||||||
|
CheckoutIntent,
|
||||||
|
} from "@/data/schemas/auth";
|
||||||
|
import { getAuthRepository } from "@/data/repositories/auth_repository";
|
||||||
|
import type { Result } from "@/utils/result";
|
||||||
|
|
||||||
|
export function createCheckoutHandoff(
|
||||||
|
checkoutIntent: CheckoutIntent,
|
||||||
|
): Promise<Result<CheckoutHandoffCreateResponse>> {
|
||||||
|
return getAuthRepository().createCheckoutHandoff(checkoutIntent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeCheckoutHandoff(
|
||||||
|
handoffToken: string,
|
||||||
|
): Promise<Result<CheckoutIntent>> {
|
||||||
|
return getAuthRepository().consumeCheckoutHandoff(handoffToken);
|
||||||
|
}
|
||||||
@@ -3,12 +3,41 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
resolveCheckoutIntentDestination,
|
||||||
resolveExternalEntryDestination,
|
resolveExternalEntryDestination,
|
||||||
resolveExternalEntryPromotionType,
|
resolveExternalEntryPromotionType,
|
||||||
resolveExternalEntryTarget,
|
resolveExternalEntryTarget,
|
||||||
shouldBindExternalFacebookIdentity,
|
shouldBindExternalFacebookIdentity,
|
||||||
} from "../external_entry";
|
} from "../external_entry";
|
||||||
|
|
||||||
|
describe("checkout external browser destination", () => {
|
||||||
|
it("restores subscription intent without carrying the handoff token", () => {
|
||||||
|
expect(
|
||||||
|
resolveCheckoutIntentDestination(
|
||||||
|
{
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
chatActionId: "00000000-0000-0000-0000-000000000123",
|
||||||
|
},
|
||||||
|
"nayeli",
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
"/subscription?planId=vip_monthly&autoRenew=1&character=nayeli&commercialOfferId=offer-1&chatActionId=00000000-0000-0000-0000-000000000123&payChannel=stripe",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores a character tip intent by canonical character id", () => {
|
||||||
|
expect(
|
||||||
|
resolveCheckoutIntentDestination({
|
||||||
|
planId: "maya_coffee",
|
||||||
|
autoRenew: false,
|
||||||
|
recipientCharacterId: "maya-tan",
|
||||||
|
}),
|
||||||
|
).toBe("/characters/maya/tip?planId=maya_coffee&payChannel=stripe");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("external entry navigation", () => {
|
describe("external entry navigation", () => {
|
||||||
it("defaults to chat", () => {
|
it("defaults to chat", () => {
|
||||||
expect(resolveExternalEntryTarget({})).toBe(ROUTES.chat);
|
expect(resolveExternalEntryTarget({})).toBe(ROUTES.chat);
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
|||||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||||
import type { PendingChatPromotionType } from "@/data/storage/navigation";
|
import type { PendingChatPromotionType } from "@/data/storage/navigation";
|
||||||
import type { LoginStatus } from "@/data/schemas/auth";
|
import type { LoginStatus } from "@/data/schemas/auth";
|
||||||
|
import type { CheckoutIntent } from "@/data/schemas/auth";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CHARACTER_SLUG,
|
DEFAULT_CHARACTER_SLUG,
|
||||||
|
getCharacterById,
|
||||||
getCharacterBySlug,
|
getCharacterBySlug,
|
||||||
} from "@/data/constants/character";
|
} from "@/data/constants/character";
|
||||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||||
@@ -100,6 +102,34 @@ export function resolveExternalEntryDestination({
|
|||||||
return routes.chat;
|
return routes.chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveCheckoutIntentDestination(
|
||||||
|
intent: CheckoutIntent,
|
||||||
|
sourceCharacterSlug?: string | null,
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams({ planId: intent.planId });
|
||||||
|
|
||||||
|
if (intent.recipientCharacterId) {
|
||||||
|
const character = getCharacterById(intent.recipientCharacterId);
|
||||||
|
const routes = getCharacterRoutes(
|
||||||
|
character?.slug ?? DEFAULT_CHARACTER_SLUG,
|
||||||
|
);
|
||||||
|
if (intent.chatActionId) params.set("chatActionId", intent.chatActionId);
|
||||||
|
params.set("payChannel", "stripe");
|
||||||
|
return `${routes.tip}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.set("autoRenew", intent.autoRenew ? "1" : "0");
|
||||||
|
const characterSlug =
|
||||||
|
getCharacterBySlug(sourceCharacterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
|
||||||
|
params.set("character", characterSlug);
|
||||||
|
if (intent.commercialOfferId) {
|
||||||
|
params.set("commercialOfferId", intent.commercialOfferId);
|
||||||
|
}
|
||||||
|
if (intent.chatActionId) params.set("chatActionId", intent.chatActionId);
|
||||||
|
params.set("payChannel", "stripe");
|
||||||
|
return `${ROUTES.subscription}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function persistExternalEntryPayload({
|
export async function persistExternalEntryPayload({
|
||||||
deviceId,
|
deviceId,
|
||||||
asid,
|
asid,
|
||||||
@@ -138,6 +168,7 @@ function resolveTarget(
|
|||||||
return ROUTES.privateZone;
|
return ROUTES.privateZone;
|
||||||
}
|
}
|
||||||
if (target === "topup") return ROUTES.subscription;
|
if (target === "topup") return ROUTES.subscription;
|
||||||
|
if (target === "checkout") return ROUTES.subscription;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getPaymentUrl,
|
getPaymentUrl,
|
||||||
|
getStripeCustomerSessionClientSecret,
|
||||||
getStripeClientSecret,
|
getStripeClientSecret,
|
||||||
isEzpayPayment,
|
isEzpayPayment,
|
||||||
launchEzpayRedirect,
|
launchEzpayRedirect,
|
||||||
@@ -43,6 +44,28 @@ describe("payment launch helpers", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("enables saved cards only with an explicit flag and a Customer Session secret", () => {
|
||||||
|
expect(
|
||||||
|
getStripeCustomerSessionClientSecret({
|
||||||
|
provider: "stripe",
|
||||||
|
customerSessionClientSecret: "cuss_123_secret_456",
|
||||||
|
savedPaymentMethodsEnabled: true,
|
||||||
|
}),
|
||||||
|
).toBe("cuss_123_secret_456");
|
||||||
|
expect(
|
||||||
|
getStripeCustomerSessionClientSecret({
|
||||||
|
customerSessionClientSecret: "cuss_123_secret_456",
|
||||||
|
savedPaymentMethodsEnabled: false,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
getStripeCustomerSessionClientSecret({
|
||||||
|
customerSessionClientSecret: "invalid-customer-session",
|
||||||
|
savedPaymentMethodsEnabled: true,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => {
|
it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEzpayLaunchTarget({
|
resolveEzpayLaunchTarget({
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export const AUTOMATIC_RENEWAL_AGREEMENT_VERSION = "2026-07-29";
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = "cozsweet.automatic-renewal-acknowledged";
|
||||||
|
|
||||||
|
function storageKey(subjectId: string | null | undefined): string | null {
|
||||||
|
const normalized = subjectId?.trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
return `${STORAGE_PREFIX}:${AUTOMATIC_RENEWAL_AGREEMENT_VERSION}:${encodeURIComponent(normalized)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAutomaticRenewalAcknowledgement(
|
||||||
|
subjectId: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const key = storageKey(subjectId);
|
||||||
|
if (!key || typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(key) === "confirmed";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberAutomaticRenewalAcknowledgement(
|
||||||
|
subjectId: string | null | undefined,
|
||||||
|
): void {
|
||||||
|
const key = storageKey(subjectId);
|
||||||
|
if (!key || typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, "confirmed");
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in privacy-restricted in-app browsers.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,27 @@ export function getStripeClientSecret(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStripeCustomerSessionClientSecret(
|
||||||
|
payParams: Record<string, unknown>,
|
||||||
|
): string | null {
|
||||||
|
const provider = payParams.provider;
|
||||||
|
const isStripeProvider =
|
||||||
|
typeof provider !== "string" || provider.toLowerCase() === "stripe";
|
||||||
|
const enabled = payParams.savedPaymentMethodsEnabled === true;
|
||||||
|
const clientSecret = payParams.customerSessionClientSecret;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isStripeProvider &&
|
||||||
|
enabled &&
|
||||||
|
typeof clientSecret === "string" &&
|
||||||
|
/^cuss_[^\s]+_secret_[^\s]+$/i.test(clientSecret)
|
||||||
|
) {
|
||||||
|
return clientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LaunchEzpayRedirectInput {
|
export interface LaunchEzpayRedirectInput {
|
||||||
orderId: string | null;
|
orderId: string | null;
|
||||||
paymentUrl: string;
|
paymentUrl: string;
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export function parsePaymentPayChannel(
|
|||||||
return channel === "ezpay" || channel === "stripe" ? channel : null;
|
return channel === "ezpay" || channel === "stripe" ? channel : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parsePaymentAutoRenew(
|
||||||
|
value: PaymentSearchParamValue,
|
||||||
|
): boolean | null {
|
||||||
|
const autoRenew = getFirstPaymentSearchParam(value);
|
||||||
|
if (autoRenew === "1" || autoRenew === "true") return true;
|
||||||
|
if (autoRenew === "0" || autoRenew === "false") return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseSubscriptionReturnTo(
|
export function parseSubscriptionReturnTo(
|
||||||
value: PaymentSearchParamValue,
|
value: PaymentSearchParamValue,
|
||||||
): AppSubscriptionReturnTo {
|
): AppSubscriptionReturnTo {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface OpenSubscriptionInput {
|
|||||||
replace?: boolean;
|
replace?: boolean;
|
||||||
analytics?: PaymentAnalyticsContext;
|
analytics?: PaymentAnalyticsContext;
|
||||||
chatActionId?: string;
|
chatActionId?: string;
|
||||||
|
planId?: string;
|
||||||
|
commercialOfferId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartMessageUnlockInput {
|
export interface StartMessageUnlockInput {
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export function useGlobalAppNavigator(): GlobalAppNavigator {
|
|||||||
replace: shouldReplace = false,
|
replace: shouldReplace = false,
|
||||||
analytics = getDefaultPaymentAnalyticsContext(type),
|
analytics = getDefaultPaymentAnalyticsContext(type),
|
||||||
chatActionId,
|
chatActionId,
|
||||||
|
planId,
|
||||||
|
commercialOfferId,
|
||||||
}: OpenGlobalSubscriptionInput): void => {
|
}: OpenGlobalSubscriptionInput): void => {
|
||||||
const target = ROUTE_BUILDERS.subscription(type, {
|
const target = ROUTE_BUILDERS.subscription(type, {
|
||||||
payChannel,
|
payChannel,
|
||||||
@@ -87,6 +89,8 @@ export function useGlobalAppNavigator(): GlobalAppNavigator {
|
|||||||
sourceCharacterSlug,
|
sourceCharacterSlug,
|
||||||
analytics,
|
analytics,
|
||||||
chatActionId,
|
chatActionId,
|
||||||
|
planId,
|
||||||
|
commercialOfferId,
|
||||||
});
|
});
|
||||||
|
|
||||||
behaviorAnalytics.rechargeModalOpen(analytics);
|
behaviorAnalytics.rechargeModalOpen(analytics);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { appendCommercialMessage } from "../helper/commercial-message";
|
||||||
|
|
||||||
|
describe("chat commercial message", () => {
|
||||||
|
const incoming = {
|
||||||
|
messageId: "payment-thanks-1",
|
||||||
|
message: "Thank you for supporting me.",
|
||||||
|
characterId: "maya-tan",
|
||||||
|
createdAt: "2026-07-28T08:30:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("appends one persisted payment thank-you to the matching character chat", () => {
|
||||||
|
const messages = appendCommercialMessage([], "maya-tan", incoming);
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0]).toMatchObject({
|
||||||
|
remoteId: "payment-thanks-1",
|
||||||
|
content: "Thank you for supporting me.",
|
||||||
|
isFromAI: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores duplicate delivery and a message for another character", () => {
|
||||||
|
const messages = appendCommercialMessage([], "maya-tan", incoming);
|
||||||
|
|
||||||
|
expect(appendCommercialMessage(messages, "maya-tan", incoming)).toEqual(
|
||||||
|
messages,
|
||||||
|
);
|
||||||
|
expect(appendCommercialMessage(messages, "elio", incoming)).toEqual(
|
||||||
|
messages,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -116,7 +116,7 @@ describe("sendResponseToUiMessage", () => {
|
|||||||
copy: "Buy me a coffee?",
|
copy: "Buy me a coffee?",
|
||||||
ctaLabel: "View gifts",
|
ctaLabel: "View gifts",
|
||||||
target: "giftCatalog",
|
target: "giftCatalog",
|
||||||
ruleId: "coffee_support_question",
|
ruleId: "coffee_after_thanks",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -127,7 +127,7 @@ describe("sendResponseToUiMessage", () => {
|
|||||||
copy: "Buy me a coffee?",
|
copy: "Buy me a coffee?",
|
||||||
ctaLabel: "View gifts",
|
ctaLabel: "View gifts",
|
||||||
target: "giftCatalog",
|
target: "giftCatalog",
|
||||||
ruleId: "coffee_support_question",
|
ruleId: "coffee_after_thanks",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export type ChatEvent =
|
|||||||
}
|
}
|
||||||
| { type: "ChatOlderHistoryLoadFailed"; error: unknown }
|
| { type: "ChatOlderHistoryLoadFailed"; error: unknown }
|
||||||
| { type: "ChatHistoryRefreshRequested" }
|
| { type: "ChatHistoryRefreshRequested" }
|
||||||
|
| {
|
||||||
|
type: "ChatCommercialMessageReceived";
|
||||||
|
message: {
|
||||||
|
messageId: string;
|
||||||
|
message: string;
|
||||||
|
characterId: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
// Chat domain events.
|
// Chat domain events.
|
||||||
| { type: "ChatSendMessage"; content: string }
|
| { type: "ChatSendMessage"; content: string }
|
||||||
| { type: "ChatSendImage"; imageBase64: string }
|
| { type: "ChatSendImage"; imageBase64: string }
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createRemoteUiMessageIdentity, type UiMessage } from "../ui-message";
|
||||||
|
import { todayString } from "@/utils/date";
|
||||||
|
|
||||||
|
export interface IncomingCommercialMessage {
|
||||||
|
messageId: string;
|
||||||
|
message: string;
|
||||||
|
characterId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendCommercialMessage(
|
||||||
|
messages: readonly UiMessage[],
|
||||||
|
activeCharacterId: string,
|
||||||
|
incoming: IncomingCommercialMessage,
|
||||||
|
): UiMessage[] {
|
||||||
|
if (incoming.characterId !== activeCharacterId) return [...messages];
|
||||||
|
if (messages.some((message) => message.remoteId === incoming.messageId)) {
|
||||||
|
return [...messages];
|
||||||
|
}
|
||||||
|
const createdAt = new Date(incoming.createdAt);
|
||||||
|
return [
|
||||||
|
...messages,
|
||||||
|
{
|
||||||
|
...createRemoteUiMessageIdentity(incoming.messageId, true),
|
||||||
|
content: incoming.message,
|
||||||
|
isFromAI: true,
|
||||||
|
date: todayString(Number.isNaN(createdAt.getTime()) ? new Date() : createdAt),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createChatPromotionState } from "../helper/promotion";
|
import { createChatPromotionState } from "../helper/promotion";
|
||||||
|
import { appendCommercialMessage } from "../helper/commercial-message";
|
||||||
import { shouldPromptUnlockHistory } from "../helper/unlock";
|
import { shouldPromptUnlockHistory } from "../helper/unlock";
|
||||||
import { createInitialChatState } from "../chat-state";
|
import { createInitialChatState } from "../chat-state";
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +54,19 @@ const injectPromotionAction = unlockMachineSetup.assign(({ event }) => {
|
|||||||
|
|
||||||
const clearPromotionAction = unlockMachineSetup.assign({ promotion: null });
|
const clearPromotionAction = unlockMachineSetup.assign({ promotion: null });
|
||||||
|
|
||||||
|
const appendCommercialMessageAction = unlockMachineSetup.assign(
|
||||||
|
({ context, event }) => {
|
||||||
|
if (event.type !== "ChatCommercialMessageReceived") return {};
|
||||||
|
return {
|
||||||
|
messages: appendCommercialMessage(
|
||||||
|
context.messages,
|
||||||
|
context.characterId,
|
||||||
|
event.message,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const chatMachineSetup = unlockMachineSetup.extend({
|
export const chatMachineSetup = unlockMachineSetup.extend({
|
||||||
actions: {
|
actions: {
|
||||||
startGuestSession: startGuestSessionAction,
|
startGuestSession: startGuestSessionAction,
|
||||||
@@ -60,6 +74,7 @@ export const chatMachineSetup = unlockMachineSetup.extend({
|
|||||||
clearChatSession: clearChatSessionAction,
|
clearChatSession: clearChatSessionAction,
|
||||||
injectPromotion: injectPromotionAction,
|
injectPromotion: injectPromotionAction,
|
||||||
clearPromotion: clearPromotionAction,
|
clearPromotion: clearPromotionAction,
|
||||||
|
appendCommercialMessage: appendCommercialMessageAction,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,6 +273,7 @@ export const chatRootStateConfig = chatMachineSetup.createStateConfig({
|
|||||||
on: {
|
on: {
|
||||||
ChatPromotionInjected: { actions: "injectPromotion" },
|
ChatPromotionInjected: { actions: "injectPromotion" },
|
||||||
ChatPromotionCleared: { actions: "clearPromotion" },
|
ChatPromotionCleared: { actions: "clearPromotion" },
|
||||||
|
ChatCommercialMessageReceived: { actions: "appendCommercialMessage" },
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
idle: idleState,
|
idle: idleState,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createTestPaymentMachine,
|
createTestPaymentMachine,
|
||||||
giftCatalog,
|
|
||||||
lifetimePlan,
|
lifetimePlan,
|
||||||
monthlyPlan,
|
monthlyPlan,
|
||||||
quarterlyPlan,
|
quarterlyPlan,
|
||||||
@@ -51,7 +50,7 @@ describe("payment catalog flow", () => {
|
|||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores a valid first-category product and rejects another category", async () => {
|
it("restores a valid tip product across categories from checkout handoff", async () => {
|
||||||
const actor = createActor(createTestPaymentMachine()).start();
|
const actor = createActor(createTestPaymentMachine()).start();
|
||||||
actor.send({
|
actor.send({
|
||||||
type: "PaymentInit",
|
type: "PaymentInit",
|
||||||
@@ -79,8 +78,9 @@ describe("payment catalog flow", () => {
|
|||||||
snapshot.context.requestedGiftPlanId === null,
|
snapshot.context.requestedGiftPlanId === null,
|
||||||
);
|
);
|
||||||
expect(actor.getSnapshot().context.selectedPlanId).toBe(
|
expect(actor.getSnapshot().context.selectedPlanId).toBe(
|
||||||
giftCatalog.plans[0]?.planId,
|
"tip_flowers_usd_12_99",
|
||||||
);
|
);
|
||||||
|
expect(actor.getSnapshot().context.selectedGiftCategory).toBe("flowers");
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,29 @@ describe("payment order flow", () => {
|
|||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["elio", "maya-tan", "nayeli-cervantes"])(
|
||||||
|
"binds a default VIP or credit order to the source chat character %s",
|
||||||
|
async (characterId) => {
|
||||||
|
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||||
|
const actor = createActor(
|
||||||
|
createTestPaymentMachine({ createOrderSpy }),
|
||||||
|
).start();
|
||||||
|
actor.send({ type: "PaymentInit", characterId });
|
||||||
|
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||||
|
|
||||||
|
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||||
|
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||||
|
|
||||||
|
expect(createOrderSpy).toHaveBeenCalledWith({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
payChannel: "stripe",
|
||||||
|
autoRenew: true,
|
||||||
|
recipientCharacterId: characterId,
|
||||||
|
});
|
||||||
|
actor.stop();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("carries the originating chat action into order creation", async () => {
|
it("carries the originating chat action into order creation", async () => {
|
||||||
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||||
const actor = createActor(
|
const actor = createActor(
|
||||||
@@ -278,6 +301,37 @@ describe("payment order flow", () => {
|
|||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("can abandon a pending QRIS order and switch to Stripe", async () => {
|
||||||
|
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||||
|
const actor = createActor(
|
||||||
|
createTestPaymentMachine({ createOrderSpy, orderStatus: "pending" }),
|
||||||
|
).start();
|
||||||
|
await initialize(actor);
|
||||||
|
actor.send({ type: "PaymentPayChannelChanged", payChannel: "ezpay" });
|
||||||
|
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||||
|
await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment"));
|
||||||
|
|
||||||
|
actor.send({ type: "PaymentPayChannelChanged", payChannel: "stripe" });
|
||||||
|
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||||
|
|
||||||
|
expect(actor.getSnapshot().context).toMatchObject({
|
||||||
|
payChannel: "stripe",
|
||||||
|
currentOrderId: null,
|
||||||
|
payParams: null,
|
||||||
|
orderStatus: null,
|
||||||
|
orderPollingStartedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||||
|
await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment"));
|
||||||
|
expect(createOrderSpy).toHaveBeenNthCalledWith(2, {
|
||||||
|
planId: "vip_monthly",
|
||||||
|
payChannel: "stripe",
|
||||||
|
autoRenew: true,
|
||||||
|
});
|
||||||
|
actor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("moves to failed when polling reports failure", async () => {
|
it("moves to failed when polling reports failure", async () => {
|
||||||
const actor = createActor(
|
const actor = createActor(
|
||||||
createTestPaymentMachine({ orderStatus: "failed" }),
|
createTestPaymentMachine({ orderStatus: "failed" }),
|
||||||
|
|||||||
@@ -136,12 +136,20 @@ export function consumeFirstRechargeState(
|
|||||||
context: PaymentState,
|
context: PaymentState,
|
||||||
): Pick<
|
): Pick<
|
||||||
PaymentState,
|
PaymentState,
|
||||||
"plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
|
| "plans"
|
||||||
|
| "isFirstRecharge"
|
||||||
|
| "commercialOffer"
|
||||||
|
| "commercialOfferId"
|
||||||
|
| "selectedPlanId"
|
||||||
|
| "autoRenew"
|
||||||
|
| "errorMessage"
|
||||||
> {
|
> {
|
||||||
const plans = context.plans.map(consumeFirstRechargePlan);
|
const plans = context.plans.map(consumeFirstRechargePlan);
|
||||||
return {
|
return {
|
||||||
...refreshPlansState(plans, context.selectedPlanId),
|
...refreshPlansState(plans, context.selectedPlanId),
|
||||||
isFirstRecharge: false,
|
isFirstRecharge: false,
|
||||||
|
commercialOffer: null,
|
||||||
|
commercialOfferId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,15 +50,23 @@ export function hydrateGiftProductsState(
|
|||||||
const giftProducts = response.plans.filter(
|
const giftProducts = response.plans.filter(
|
||||||
(product) => product.characterId === characterId,
|
(product) => product.characterId === characterId,
|
||||||
);
|
);
|
||||||
const selectedGiftCategory = giftCategories[0]?.category ?? null;
|
const restoredProduct = restoredPlanId
|
||||||
|
? giftProducts.find((product) => product.planId === restoredPlanId)
|
||||||
|
: null;
|
||||||
|
const selectedGiftCategory =
|
||||||
|
restoredProduct?.category ??
|
||||||
|
(restoredCategory &&
|
||||||
|
giftCategories.some((category) => category.category === restoredCategory)
|
||||||
|
? restoredCategory
|
||||||
|
: (giftCategories[0]?.category ?? null));
|
||||||
const visibleProducts = selectedGiftCategory
|
const visibleProducts = selectedGiftCategory
|
||||||
? giftProducts.filter(
|
? giftProducts.filter(
|
||||||
(product) => product.category === selectedGiftCategory,
|
(product) => product.category === selectedGiftCategory,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
const canRestoreSelection =
|
const canRestoreSelection = visibleProducts.some(
|
||||||
restoredCategory === selectedGiftCategory &&
|
(product) => product.planId === restoredPlanId,
|
||||||
visibleProducts.some((product) => product.planId === restoredPlanId);
|
);
|
||||||
const selectedPlanId = canRestoreSelection
|
const selectedPlanId = canRestoreSelection
|
||||||
? (restoredPlanId ?? "")
|
? (restoredPlanId ?? "")
|
||||||
: (visibleProducts[0]?.planId ?? "");
|
: (visibleProducts[0]?.planId ?? "");
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import type { PaymentPlanCatalog } from "../../payment-state";
|
|||||||
|
|
||||||
export const loadCachedPaymentPlansActor = fromPromise<
|
export const loadCachedPaymentPlansActor = fromPromise<
|
||||||
PaymentPlansResponse | null,
|
PaymentPlansResponse | null,
|
||||||
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null }
|
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null; supportCharacterId?: string | null }
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
if (input.catalog === "tip" || input.commercialOfferId) return null;
|
if (input.catalog === "tip" || input.commercialOfferId || input.supportCharacterId) return null;
|
||||||
const result = await getPaymentRepository().getCachedPlans();
|
const result = await getPaymentRepository().getCachedPlans();
|
||||||
if (Result.isErr(result)) throw result.error;
|
if (Result.isErr(result)) throw result.error;
|
||||||
return result.data;
|
return result.data;
|
||||||
@@ -18,13 +18,16 @@ export const loadCachedPaymentPlansActor = fromPromise<
|
|||||||
|
|
||||||
export const refreshPaymentPlansActor = fromPromise<
|
export const refreshPaymentPlansActor = fromPromise<
|
||||||
PaymentPlansResponse,
|
PaymentPlansResponse,
|
||||||
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null }
|
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null; supportCharacterId?: string | null }
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
const paymentRepo = getPaymentRepository();
|
const paymentRepo = getPaymentRepository();
|
||||||
if (input.catalog === "tip") {
|
if (input.catalog === "tip") {
|
||||||
throw new Error("Gift catalogs must use the gift products actor.");
|
throw new Error("Gift catalogs must use the gift products actor.");
|
||||||
}
|
}
|
||||||
const result = await paymentRepo.getPlans(input.commercialOfferId ?? undefined);
|
const result = await paymentRepo.getPlans(
|
||||||
|
input.commercialOfferId ?? undefined,
|
||||||
|
input.supportCharacterId ?? undefined,
|
||||||
|
);
|
||||||
if (Result.isErr(result)) throw result.error;
|
if (Result.isErr(result)) throw result.error;
|
||||||
return result.data;
|
return result.data;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,29 +29,45 @@ function initializeCatalogState(
|
|||||||
planCatalog === "tip"
|
planCatalog === "tip"
|
||||||
? (event.characterId ?? context.giftCharacterId)
|
? (event.characterId ?? context.giftCharacterId)
|
||||||
: null;
|
: null;
|
||||||
|
const supportCharacterId =
|
||||||
|
planCatalog === "default"
|
||||||
|
? (event.characterId ?? context.supportCharacterId)
|
||||||
|
: null;
|
||||||
const catalogChanged = planCatalog !== context.planCatalog;
|
const catalogChanged = planCatalog !== context.planCatalog;
|
||||||
const characterChanged = giftCharacterId !== context.giftCharacterId;
|
const characterChanged =
|
||||||
|
giftCharacterId !== context.giftCharacterId ||
|
||||||
|
supportCharacterId !== context.supportCharacterId;
|
||||||
const selectionChanged =
|
const selectionChanged =
|
||||||
planCatalog === "tip" &&
|
planCatalog === "tip"
|
||||||
((event.category ?? null) !== context.selectedGiftCategory ||
|
? (event.category ?? null) !== context.selectedGiftCategory ||
|
||||||
(event.planId ?? null) !== (context.selectedPlanId || null));
|
(event.planId ?? null) !== (context.selectedPlanId || null)
|
||||||
|
: (event.planId ?? null) !== (context.selectedPlanId || null);
|
||||||
const commercialOfferId =
|
const commercialOfferId =
|
||||||
planCatalog === "default" ? (event.commercialOfferId ?? null) : null;
|
planCatalog === "default" ? (event.commercialOfferId ?? null) : null;
|
||||||
const offerChanged = commercialOfferId !== context.commercialOfferId;
|
const offerChanged = commercialOfferId !== context.commercialOfferId;
|
||||||
const chatActionId = event.chatActionId ?? null;
|
const chatActionId = event.chatActionId ?? null;
|
||||||
const chatActionChanged = chatActionId !== context.chatActionId;
|
const chatActionChanged = chatActionId !== context.chatActionId;
|
||||||
|
const initialAutoRenew =
|
||||||
|
planCatalog === "tip" ? false : (event.autoRenew ?? true);
|
||||||
|
const autoRenewChanged = initialAutoRenew !== context.autoRenew;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
planCatalog,
|
planCatalog,
|
||||||
...(event.payChannel ? { payChannel: event.payChannel } : {}),
|
...(event.payChannel ? { payChannel: event.payChannel } : {}),
|
||||||
giftCharacterId,
|
giftCharacterId,
|
||||||
|
supportCharacterId,
|
||||||
commercialOfferId,
|
commercialOfferId,
|
||||||
chatActionId,
|
chatActionId,
|
||||||
requestedGiftCategory:
|
requestedGiftCategory:
|
||||||
planCatalog === "tip" ? (event.category ?? null) : null,
|
planCatalog === "tip" ? (event.category ?? null) : null,
|
||||||
requestedGiftPlanId:
|
requestedGiftPlanId:
|
||||||
planCatalog === "tip" ? (event.planId ?? null) : null,
|
planCatalog === "tip" ? (event.planId ?? null) : null,
|
||||||
...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged
|
...(catalogChanged ||
|
||||||
|
characterChanged ||
|
||||||
|
selectionChanged ||
|
||||||
|
offerChanged ||
|
||||||
|
chatActionChanged ||
|
||||||
|
autoRenewChanged
|
||||||
? {
|
? {
|
||||||
...resetOrderState(),
|
...resetOrderState(),
|
||||||
plans: [],
|
plans: [],
|
||||||
@@ -62,7 +78,7 @@ function initializeCatalogState(
|
|||||||
planCatalog === "default" ? (event.planId ?? "") : "",
|
planCatalog === "default" ? (event.planId ?? "") : "",
|
||||||
isFirstRecharge: false,
|
isFirstRecharge: false,
|
||||||
commercialOffer: null,
|
commercialOffer: null,
|
||||||
autoRenew: planCatalog !== "tip",
|
autoRenew: initialAutoRenew,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
@@ -219,6 +235,7 @@ export const loadingCachedPlansState =
|
|||||||
input: ({ context }) => ({
|
input: ({ context }) => ({
|
||||||
catalog: context.planCatalog,
|
catalog: context.planCatalog,
|
||||||
commercialOfferId: context.commercialOfferId,
|
commercialOfferId: context.commercialOfferId,
|
||||||
|
supportCharacterId: context.supportCharacterId,
|
||||||
}),
|
}),
|
||||||
onDone: [
|
onDone: [
|
||||||
{
|
{
|
||||||
@@ -238,6 +255,7 @@ export const loadingPlansState = catalogMachineSetup.createStateConfig({
|
|||||||
input: ({ context }) => ({
|
input: ({ context }) => ({
|
||||||
catalog: context.planCatalog,
|
catalog: context.planCatalog,
|
||||||
commercialOfferId: context.commercialOfferId,
|
commercialOfferId: context.commercialOfferId,
|
||||||
|
supportCharacterId: context.supportCharacterId,
|
||||||
}),
|
}),
|
||||||
onDone: {
|
onDone: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
@@ -256,6 +274,7 @@ export const refreshingPlansState = catalogMachineSetup.createStateConfig({
|
|||||||
input: ({ context }) => ({
|
input: ({ context }) => ({
|
||||||
catalog: context.planCatalog,
|
catalog: context.planCatalog,
|
||||||
commercialOfferId: context.commercialOfferId,
|
commercialOfferId: context.commercialOfferId,
|
||||||
|
supportCharacterId: context.supportCharacterId,
|
||||||
}),
|
}),
|
||||||
onDone: {
|
onDone: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
|
|||||||
@@ -192,8 +192,14 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({
|
|||||||
payChannel: context.payChannel,
|
payChannel: context.payChannel,
|
||||||
autoRenew: context.autoRenew,
|
autoRenew: context.autoRenew,
|
||||||
...(event.type === "PaymentCreateOrderSubmitted" &&
|
...(event.type === "PaymentCreateOrderSubmitted" &&
|
||||||
event.recipientCharacterId
|
(event.recipientCharacterId || context.supportCharacterId || context.giftCharacterId)
|
||||||
? { recipientCharacterId: event.recipientCharacterId }
|
? {
|
||||||
|
recipientCharacterId:
|
||||||
|
event.recipientCharacterId ||
|
||||||
|
context.supportCharacterId ||
|
||||||
|
context.giftCharacterId ||
|
||||||
|
undefined,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(context.commercialOfferId
|
...(context.commercialOfferId
|
||||||
? { commercialOfferId: context.commercialOfferId }
|
? { commercialOfferId: context.commercialOfferId }
|
||||||
@@ -259,6 +265,10 @@ const pollingOrderState = paymentMachineSetup.createStateConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
|
PaymentPayChannelChanged: {
|
||||||
|
target: "ready",
|
||||||
|
actions: "changePayChannel",
|
||||||
|
},
|
||||||
PaymentReset: {
|
PaymentReset: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
actions: "resetOrder",
|
actions: "resetOrder",
|
||||||
@@ -271,6 +281,10 @@ const waitingForPaymentState = paymentMachineSetup.createStateConfig({
|
|||||||
[POLL_DELAY_MS]: "pollingOrder",
|
[POLL_DELAY_MS]: "pollingOrder",
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
|
PaymentPayChannelChanged: {
|
||||||
|
target: "ready",
|
||||||
|
actions: "changePayChannel",
|
||||||
|
},
|
||||||
PaymentReset: {
|
PaymentReset: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
actions: "resetOrder",
|
actions: "resetOrder",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface PaymentContextState {
|
|||||||
selectedGiftCategory: string | null;
|
selectedGiftCategory: string | null;
|
||||||
isFirstRecharge: MachineContext["isFirstRecharge"];
|
isFirstRecharge: MachineContext["isFirstRecharge"];
|
||||||
commercialOffer: MachineContext["commercialOffer"];
|
commercialOffer: MachineContext["commercialOffer"];
|
||||||
|
commercialOfferId: string | null;
|
||||||
|
chatActionId: string | null;
|
||||||
selectedPlanId: string;
|
selectedPlanId: string;
|
||||||
payChannel: MachineContext["payChannel"];
|
payChannel: MachineContext["payChannel"];
|
||||||
autoRenew: boolean;
|
autoRenew: boolean;
|
||||||
@@ -76,6 +78,8 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
|
|||||||
selectedGiftCategory: state.context.selectedGiftCategory,
|
selectedGiftCategory: state.context.selectedGiftCategory,
|
||||||
isFirstRecharge: state.context.isFirstRecharge,
|
isFirstRecharge: state.context.isFirstRecharge,
|
||||||
commercialOffer: state.context.commercialOffer,
|
commercialOffer: state.context.commercialOffer,
|
||||||
|
commercialOfferId: state.context.commercialOfferId,
|
||||||
|
chatActionId: state.context.chatActionId,
|
||||||
selectedPlanId: state.context.selectedPlanId,
|
selectedPlanId: state.context.selectedPlanId,
|
||||||
payChannel: state.context.payChannel,
|
payChannel: state.context.payChannel,
|
||||||
autoRenew: state.context.autoRenew,
|
autoRenew: state.context.autoRenew,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type PaymentEvent =
|
|||||||
characterId?: string;
|
characterId?: string;
|
||||||
category?: string | null;
|
category?: string | null;
|
||||||
planId?: string | null;
|
planId?: string | null;
|
||||||
|
autoRenew?: boolean;
|
||||||
commercialOfferId?: string | null;
|
commercialOfferId?: string | null;
|
||||||
chatActionId?: string | null;
|
chatActionId?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface PaymentState {
|
|||||||
giftCategories: readonly GiftCategory[];
|
giftCategories: readonly GiftCategory[];
|
||||||
giftProducts: readonly GiftProduct[];
|
giftProducts: readonly GiftProduct[];
|
||||||
giftCharacterId: string | null;
|
giftCharacterId: string | null;
|
||||||
|
supportCharacterId: string | null;
|
||||||
selectedGiftCategory: string | null;
|
selectedGiftCategory: string | null;
|
||||||
requestedGiftCategory: string | null;
|
requestedGiftCategory: string | null;
|
||||||
requestedGiftPlanId: string | null;
|
requestedGiftPlanId: string | null;
|
||||||
@@ -45,6 +46,7 @@ export const initialState: PaymentState = {
|
|||||||
giftCategories: [],
|
giftCategories: [],
|
||||||
giftProducts: [],
|
giftProducts: [],
|
||||||
giftCharacterId: null,
|
giftCharacterId: null,
|
||||||
|
supportCharacterId: null,
|
||||||
selectedGiftCategory: null,
|
selectedGiftCategory: null,
|
||||||
requestedGiftCategory: null,
|
requestedGiftCategory: null,
|
||||||
requestedGiftPlanId: null,
|
requestedGiftPlanId: null,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Logger } from "../logger";
|
||||||
|
|
||||||
|
describe("Logger Stripe secret redaction", () => {
|
||||||
|
it("redacts nested Stripe secret fields and values before formatting", () => {
|
||||||
|
const output = Logger.formatValue({
|
||||||
|
payParams: {
|
||||||
|
clientSecret: "pi_123_secret_card",
|
||||||
|
nested: {
|
||||||
|
customerSessionClientSecret: "cuss_123_secret_saved",
|
||||||
|
handoffToken: "one-time-checkout-token",
|
||||||
|
safe: "order-123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toContain("order-123");
|
||||||
|
expect(output).not.toContain("secret_card");
|
||||||
|
expect(output).not.toContain("secret_saved");
|
||||||
|
expect(output).not.toContain("one-time-checkout-token");
|
||||||
|
expect(output.match(/\[REDACTED\]/g)?.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts a secret embedded in a serialized response string", () => {
|
||||||
|
const output = Logger.formatValue(
|
||||||
|
JSON.stringify({ customerSessionClientSecret: "cuss_1_secret_value" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(output).not.toContain("secret_value");
|
||||||
|
expect(output).toContain("[REDACTED]");
|
||||||
|
});
|
||||||
|
});
|
||||||
+46
-13
@@ -107,9 +107,13 @@ export class Logger {
|
|||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed.length === 0) return "";
|
if (trimmed.length === 0) return "";
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
return JSON.stringify(
|
||||||
|
Logger.toSerializableLogValue(JSON.parse(trimmed)),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return value;
|
return Logger.redactSensitiveString(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,28 +153,31 @@ export class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private write(level: LogLevel, args: LogArgs): void {
|
private write(level: LogLevel, args: LogArgs): void {
|
||||||
Logger.reportImportantProductionLog(level, this.component, args);
|
const safeArgs = args.map((value) =>
|
||||||
|
Logger.toSerializableLogValue(value),
|
||||||
|
);
|
||||||
|
Logger.reportImportantProductionLog(level, this.component, safeArgs);
|
||||||
|
|
||||||
if (Logger.shouldUseBrowserConsole()) {
|
if (Logger.shouldUseBrowserConsole()) {
|
||||||
Logger.writeBrowserConsole(level, this.component, args);
|
Logger.writeBrowserConsole(level, this.component, safeArgs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case "debug":
|
case "debug":
|
||||||
this.logger.debug(...Logger.normalizeLogArgs(args));
|
this.logger.debug(...Logger.normalizeLogArgs(safeArgs));
|
||||||
return;
|
return;
|
||||||
case "info":
|
case "info":
|
||||||
this.logger.info(...Logger.normalizeLogArgs(args));
|
this.logger.info(...Logger.normalizeLogArgs(safeArgs));
|
||||||
return;
|
return;
|
||||||
case "warn":
|
case "warn":
|
||||||
this.logger.warn(...Logger.normalizeLogArgs(args));
|
this.logger.warn(...Logger.normalizeLogArgs(safeArgs));
|
||||||
return;
|
return;
|
||||||
case "error":
|
case "error":
|
||||||
this.logger.error(...Logger.normalizeLogArgs(args));
|
this.logger.error(...Logger.normalizeLogArgs(safeArgs));
|
||||||
return;
|
return;
|
||||||
case "fatal":
|
case "fatal":
|
||||||
this.logger.fatal(...Logger.normalizeLogArgs(args));
|
this.logger.fatal(...Logger.normalizeLogArgs(safeArgs));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,6 +273,10 @@ export class Logger {
|
|||||||
return value.map((item) => Logger.toSerializableLogValue(item, seen));
|
return value.map((item) => Logger.toSerializableLogValue(item, seen));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return Logger.redactSensitiveString(value);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof value !== "object" || value === null) {
|
if (typeof value !== "object" || value === null) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -281,19 +292,39 @@ export class Logger {
|
|||||||
|
|
||||||
const output: Record<string, unknown> = {};
|
const output: Record<string, unknown> = {};
|
||||||
for (const [key, item] of Object.entries(value)) {
|
for (const [key, item] of Object.entries(value)) {
|
||||||
output[key] = Logger.toSerializableLogValue(item, seen);
|
output[key] = Logger.isSensitiveLogKey(key)
|
||||||
|
? "[REDACTED]"
|
||||||
|
: Logger.toSerializableLogValue(item, seen);
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static isSensitiveLogKey(key: string): boolean {
|
||||||
|
const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === "clientsecret" ||
|
||||||
|
normalized === "customersessionclientsecret" ||
|
||||||
|
normalized === "handofftoken" ||
|
||||||
|
normalized === "accesstoken" ||
|
||||||
|
normalized === "refreshtoken" ||
|
||||||
|
normalized === "authorization"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static redactSensitiveString(value: string): string {
|
||||||
|
return /_secret_/i.test(value) ? "[REDACTED]" : value;
|
||||||
|
}
|
||||||
|
|
||||||
private static serializeError(
|
private static serializeError(
|
||||||
error: Error,
|
error: Error,
|
||||||
seen: WeakSet<object>,
|
seen: WeakSet<object>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const output: Record<string, unknown> = {
|
const output: Record<string, unknown> = {
|
||||||
name: error.name,
|
name: error.name,
|
||||||
message: error.message,
|
message: Logger.redactSensitiveString(error.message),
|
||||||
stack: error.stack,
|
stack: error.stack
|
||||||
|
? Logger.redactSensitiveString(error.stack)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (seen.has(error)) {
|
if (seen.has(error)) {
|
||||||
@@ -303,7 +334,9 @@ export class Logger {
|
|||||||
|
|
||||||
for (const key of Object.getOwnPropertyNames(error)) {
|
for (const key of Object.getOwnPropertyNames(error)) {
|
||||||
if (key in output) continue;
|
if (key in output) continue;
|
||||||
output[key] = Logger.toSerializableLogValue(
|
output[key] = Logger.isSensitiveLogKey(key)
|
||||||
|
? "[REDACTED]"
|
||||||
|
: Logger.toSerializableLogValue(
|
||||||
(error as unknown as Record<string, unknown>)[key],
|
(error as unknown as Record<string, unknown>)[key],
|
||||||
seen,
|
seen,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user