Compare commits
33 Commits
pre
...
9121b50e02
| Author | SHA1 | Date | |
|---|---|---|---|
| 9121b50e02 | |||
| e43912eebf | |||
| 38ae06fe04 | |||
| 995fdbda57 | |||
| ed822c159f | |||
| d5b7a1f36c | |||
| 9f829bbc0e | |||
| d01441b8c7 | |||
| ddacd03601 | |||
| 1b121a8ef8 | |||
| b7221f2f70 | |||
| 30f88d3a20 | |||
| 7b09a7f850 | |||
| 38a4645b9c | |||
| 76bffc1a0d | |||
| 19b8fc51d6 | |||
| 9fbf180df6 | |||
| 6721b6eb43 | |||
| 64ba720121 | |||
| b04ef58855 | |||
| 606e6d60ff | |||
| c659c5fc3f | |||
| 537a0a2c36 | |||
| b1f52c68e8 | |||
| 3c7b0c30e0 | |||
| 469512df18 | |||
| 318e4991be | |||
| 2d432e5367 | |||
| d6f104ee3f | |||
| 9ca56c2a04 | |||
| 4639acf232 | |||
| bdd53a6ea1 | |||
| fb9e30cfd1 |
@@ -13,6 +13,7 @@ export interface MockCoreApisOptions {
|
||||
paidImageInsufficientCreditsFlow?: boolean;
|
||||
paidVoiceInsufficientCreditsFlow?: boolean;
|
||||
topUpHandoffFlow?: boolean;
|
||||
checkoutHandoffFlow?: boolean;
|
||||
}
|
||||
|
||||
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
||||
@@ -23,6 +24,7 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
|
||||
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
||||
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
||||
topUpHandoffFlow: options.topUpHandoffFlow ?? false,
|
||||
checkoutHandoffFlow: options.checkoutHandoffFlow ?? false,
|
||||
};
|
||||
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
||||
|
||||
@@ -30,6 +32,7 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
|
||||
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
||||
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
||||
topUpHandoffFlow: chatOptions.topUpHandoffFlow,
|
||||
checkoutHandoffFlow: chatOptions.checkoutHandoffFlow,
|
||||
});
|
||||
await registerCharacterMocks(page);
|
||||
await registerUserMocks(page);
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
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 {
|
||||
chatSendTokenRefreshFlow: boolean;
|
||||
isChatSendTokenExpired: () => boolean;
|
||||
topUpHandoffFlow: boolean;
|
||||
checkoutHandoffFlow: boolean;
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!state.topUpHandoffFlow) {
|
||||
await route.continue();
|
||||
|
||||
@@ -63,3 +63,15 @@ export const topUpHandoffResponse = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,7 +26,10 @@ test("guest unlocks a promoted image through email login and top-up", async ({
|
||||
);
|
||||
await expect(page).toHaveURL(defaultCharacterChatUrl);
|
||||
|
||||
const unlockButton = page.getByRole("button", {
|
||||
const promotedImageCard = page
|
||||
.getByRole("group", { name: "Locked private image" })
|
||||
.last();
|
||||
const unlockButton = promotedImageCard.getByRole("button", {
|
||||
name: "Unlock private image",
|
||||
});
|
||||
await expect(unlockButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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 });
|
||||
|
||||
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&handoffToken=${encodeURIComponent(handoffToken)}`,
|
||||
);
|
||||
|
||||
expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken });
|
||||
await expect(page).toHaveURL(
|
||||
/\/subscription\?planId=vip_monthly&autoRenew=1&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");
|
||||
await page.getByRole("button", { name: /Monthly,/i }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "Automatic Renewal Confirmation" })
|
||||
.getByRole("button", { name: "Confirm" })
|
||||
.click();
|
||||
const externalButton = page.getByRole("button", {
|
||||
name: "Open in browser for more payment methods",
|
||||
});
|
||||
await expect(externalButton).toBeVisible();
|
||||
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);
|
||||
});
|
||||
@@ -173,6 +173,7 @@ async function expectQrisOrder(
|
||||
expect(request.postDataJSON()).toMatchObject({
|
||||
planId,
|
||||
payChannel: "ezpay",
|
||||
recipientCharacterId: "elio",
|
||||
});
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||
@@ -211,6 +212,10 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async
|
||||
await prepareIndonesiaUser(page);
|
||||
await page.goto("/subscription?type=vip");
|
||||
|
||||
await expect(page.getByText("Choose who you want to support")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Elio", exact: true }).click();
|
||||
await expect(page.getByText("Supporting Elio")).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
@@ -251,6 +256,7 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
||||
const payment = await registerIndonesiaPaymentMocks(page);
|
||||
await prepareIndonesiaUser(page);
|
||||
await page.goto("/subscription?type=topup");
|
||||
await page.getByRole("button", { name: "Elio", exact: true }).click();
|
||||
await expectCheckoutButtonLayout(page);
|
||||
|
||||
const orderId = await expectQrisOrder(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ const hiddenLaunch = {
|
||||
shouldShowQrisDialog: false,
|
||||
shouldShowStripeDialog: false,
|
||||
stripeClientSecret: null,
|
||||
stripeCustomerSessionClientSecret: null,
|
||||
savedPaymentMethodsEnabled: false,
|
||||
};
|
||||
|
||||
describe("PaymentLaunchDialogs", () => {
|
||||
|
||||
@@ -2,18 +2,57 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { StripePaymentDialogLoading } from "../lazy-stripe-payment-dialog";
|
||||
const stripeConfirmPayment = vi.fn(async () => ({}));
|
||||
const elementsSubmit = vi.fn(async () => ({}));
|
||||
let capturedElementsOptions: Record<string, unknown> | null = null;
|
||||
let capturedExpressProps: Record<string, unknown> | null = null;
|
||||
let capturedPaymentProps: Record<string, unknown> | null = null;
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = "pk_test_checkout";
|
||||
});
|
||||
|
||||
vi.mock("@stripe/stripe-js", () => ({
|
||||
loadStripe: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
vi.mock("@stripe/react-stripe-js", () => ({
|
||||
Elements: ({
|
||||
children,
|
||||
options,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
options: Record<string, unknown>;
|
||||
}) => {
|
||||
capturedElementsOptions = options;
|
||||
return <>{children}</>;
|
||||
},
|
||||
ExpressCheckoutElement: (props: Record<string, unknown>) => {
|
||||
capturedExpressProps = props;
|
||||
return <div data-testid="express-checkout" />;
|
||||
},
|
||||
PaymentElement: (props: Record<string, unknown>) => {
|
||||
capturedPaymentProps = props;
|
||||
return <div data-testid="payment-element" />;
|
||||
},
|
||||
useElements: () => ({ submit: elementsSubmit }),
|
||||
useStripe: () => ({ confirmPayment: stripeConfirmPayment }),
|
||||
}));
|
||||
|
||||
import { StripePaymentDialog } from "../stripe-payment-dialog";
|
||||
|
||||
describe("Stripe payment portal states", () => {
|
||||
describe("StripePaymentDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedElementsOptions = null;
|
||||
capturedExpressProps = null;
|
||||
capturedPaymentProps = null;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
container.style.transform = "translateX(50%)";
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
@@ -24,40 +63,101 @@ describe("Stripe payment portal states", () => {
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
|
||||
it("portals the unavailable state and keeps its explicit close action", () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
it("renders Express Checkout first and a collapsed Pay by card section last", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="client-secret"
|
||||
onClose={onClose}
|
||||
clientSecret="pi_123_secret_payment"
|
||||
customerSessionClientSecret="cuss_123_secret_saved"
|
||||
savedPaymentMethodsEnabled
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const dialog = document.body.querySelector('[role="alertdialog"]');
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(container.contains(dialog)).toBe(false);
|
||||
expect(dialog?.textContent).toContain("Payment unavailable");
|
||||
expect(dialog?.getAttribute("aria-labelledby")).toBeTruthy();
|
||||
expect(dialog?.getAttribute("aria-describedby")).toBeTruthy();
|
||||
|
||||
const okButton = Array.from(dialog?.querySelectorAll("button") ?? []).find(
|
||||
(button) => button.textContent === "OK",
|
||||
);
|
||||
act(() => okButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
expect(capturedElementsOptions).toMatchObject({
|
||||
clientSecret: "pi_123_secret_payment",
|
||||
customerSessionClientSecret: "cuss_123_secret_saved",
|
||||
});
|
||||
expect(capturedExpressProps?.options).toMatchObject({
|
||||
paymentMethodOrder: [
|
||||
"apple_pay",
|
||||
"google_pay",
|
||||
"link",
|
||||
"paypal",
|
||||
"amazon_pay",
|
||||
"klarna",
|
||||
],
|
||||
paymentMethods: {
|
||||
applePay: "always",
|
||||
googlePay: "always",
|
||||
link: "auto",
|
||||
paypal: "auto",
|
||||
amazonPay: "auto",
|
||||
klarna: "auto",
|
||||
},
|
||||
});
|
||||
expect(capturedPaymentProps?.options).toMatchObject({
|
||||
layout: { type: "accordion", defaultCollapsed: true },
|
||||
paymentMethodOrder: ["card"],
|
||||
wallets: { applePay: "never", googlePay: "never", link: "never" },
|
||||
});
|
||||
const text = document.body.textContent ?? "";
|
||||
expect(text).toContain("Pay by card");
|
||||
const express = document.body.querySelector('[data-testid="express-checkout"]');
|
||||
const card = document.body.querySelector('[data-testid="payment-element"]');
|
||||
expect(express).not.toBeNull();
|
||||
expect(card).not.toBeNull();
|
||||
expect(
|
||||
express!.compareDocumentPosition(card!) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("portals the lazy loading state", () => {
|
||||
act(() => root.render(<StripePaymentDialogLoading />));
|
||||
it("does not pass an invalid or disabled Customer Session secret", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="pi_123_secret_payment"
|
||||
customerSessionClientSecret="invalid"
|
||||
savedPaymentMethodsEnabled={false}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const dialog = document.body.querySelector('[role="dialog"]');
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(container.contains(dialog)).toBe(false);
|
||||
expect(dialog?.textContent).toContain("Preparing secure payment");
|
||||
expect(dialog?.textContent).toContain("Loading payment methods...");
|
||||
expect(dialog?.querySelector('[aria-busy="true"]')).not.toBeNull();
|
||||
expect(capturedElementsOptions).not.toHaveProperty(
|
||||
"customerSessionClientSecret",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the same confirmation path for an Express Checkout approval", async () => {
|
||||
const onConfirmed = vi.fn();
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="pi_123_secret_payment"
|
||||
onClose={vi.fn()}
|
||||
onConfirmed={onConfirmed}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const onConfirm = capturedExpressProps?.onConfirm as
|
||||
| ((event: { paymentFailed: ReturnType<typeof vi.fn> }) => Promise<void>)
|
||||
| undefined;
|
||||
expect(onConfirm).toBeTypeOf("function");
|
||||
await act(async () => {
|
||||
await onConfirm?.({ paymentFailed: vi.fn() });
|
||||
});
|
||||
|
||||
expect(stripeConfirmPayment).toHaveBeenCalledTimes(1);
|
||||
expect(stripeConfirmPayment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
elements: expect.any(Object),
|
||||
redirect: "if_required",
|
||||
}),
|
||||
);
|
||||
expect(onConfirmed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
|
||||
import type { CheckoutIntent } from "@/data/schemas/auth";
|
||||
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;
|
||||
}
|
||||
|
||||
export function ExternalBrowserCheckoutButton({
|
||||
checkoutIntent,
|
||||
disabled = false,
|
||||
}: 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;
|
||||
}
|
||||
UrlLauncherUtil.openUrlWithExternalBrowser(result.data.externalUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-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"
|
||||
| "shouldShowStripeDialog"
|
||||
| "stripeClientSecret"
|
||||
| "stripeCustomerSessionClientSecret"
|
||||
| "savedPaymentMethodsEnabled"
|
||||
>;
|
||||
|
||||
export interface PaymentLaunchDialogsProps {
|
||||
@@ -64,6 +66,10 @@ export function PaymentLaunchDialogs({
|
||||
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
||||
<LazyStripePaymentDialog
|
||||
clientSecret={launch.stripeClientSecret}
|
||||
customerSessionClientSecret={
|
||||
launch.stripeCustomerSessionClientSecret
|
||||
}
|
||||
savedPaymentMethodsEnabled={launch.savedPaymentMethodsEnabled}
|
||||
returnPath={stripeReturnPath}
|
||||
onClose={launch.handleStripeClose}
|
||||
onConfirmed={launch.handleStripeConfirmed}
|
||||
|
||||
@@ -9,6 +9,11 @@ export const stripePaymentDialogStyles = {
|
||||
content:
|
||||
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
|
||||
form: "flex flex-col gap-(--page-section-gap,18px)",
|
||||
expressSection: "w-full empty:hidden",
|
||||
cardSection:
|
||||
"flex w-full flex-col gap-(--spacing-sm,8px) border-t border-black/10 pt-(--page-section-gap,18px)",
|
||||
cardTitle:
|
||||
"m-0 text-(length:--responsive-body,16px) font-semibold text-text-foreground",
|
||||
error:
|
||||
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
|
||||
actions: "flex w-full gap-(--spacing-md,12px)",
|
||||
|
||||
@@ -4,19 +4,28 @@
|
||||
*
|
||||
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
||||
*/
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Elements,
|
||||
ExpressCheckoutElement,
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import {
|
||||
loadStripe,
|
||||
type AvailablePaymentMethods,
|
||||
type StripeExpressCheckoutElementConfirmEvent,
|
||||
} from "@stripe/stripe-js";
|
||||
|
||||
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||
import { ExceptionHandler } from "@/core/errors";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { BrowserDetector } from "@/utils/browser-detect";
|
||||
import { PlatformDetector } from "@/utils/platform-detect";
|
||||
import { getStripeCustomerSessionClientSecret } from "@/lib/payment/payment_launch";
|
||||
import { behaviorAnalytics } from "@/lib/analytics";
|
||||
|
||||
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
||||
|
||||
@@ -26,8 +35,16 @@ const stripePromise = stripePublishableKey
|
||||
? loadStripe(stripePublishableKey)
|
||||
: null;
|
||||
|
||||
type ExpressAvailabilityValue = boolean | { available: boolean } | undefined;
|
||||
|
||||
function isExpressMethodAvailable(value: ExpressAvailabilityValue): boolean {
|
||||
return value === true || (typeof value === "object" && value.available);
|
||||
}
|
||||
|
||||
export interface StripePaymentDialogProps {
|
||||
clientSecret: string;
|
||||
customerSessionClientSecret?: string | null;
|
||||
savedPaymentMethodsEnabled?: boolean;
|
||||
returnPath?: string;
|
||||
onClose: () => void;
|
||||
onConfirmed?: () => void;
|
||||
@@ -35,12 +52,19 @@ export interface StripePaymentDialogProps {
|
||||
|
||||
export function StripePaymentDialog({
|
||||
clientSecret,
|
||||
customerSessionClientSecret = null,
|
||||
savedPaymentMethodsEnabled = false,
|
||||
returnPath = ROUTES.subscription + "/success",
|
||||
onClose,
|
||||
onConfirmed,
|
||||
}: StripePaymentDialogProps) {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const savedCardClientSecret = getStripeCustomerSessionClientSecret({
|
||||
provider: "stripe",
|
||||
customerSessionClientSecret,
|
||||
savedPaymentMethodsEnabled,
|
||||
});
|
||||
|
||||
if (!stripePromise) {
|
||||
return (
|
||||
@@ -100,6 +124,9 @@ export function StripePaymentDialog({
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
...(savedCardClientSecret
|
||||
? { customerSessionClientSecret: savedCardClientSecret }
|
||||
: {}),
|
||||
appearance: {
|
||||
theme: "stripe",
|
||||
variables: {
|
||||
@@ -132,6 +159,20 @@ function StripePaymentForm({
|
||||
const elements = useElements();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const submittingRef = useRef(false);
|
||||
const [hasExpressPaymentMethods, setHasExpressPaymentMethods] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [expressMaxColumns, setExpressMaxColumns] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
const query = window.matchMedia("(min-width: 640px)");
|
||||
const updateColumns = () => setExpressMaxColumns(query.matches ? 2 : 1);
|
||||
updateColumns();
|
||||
query.addEventListener("change", updateColumns);
|
||||
return () => query.removeEventListener("change", updateColumns);
|
||||
}, []);
|
||||
|
||||
const handleLoadError = (event: Parameters<
|
||||
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
|
||||
@@ -150,38 +191,118 @@ function StripePaymentForm({
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!stripe || !elements || isSubmitting) return;
|
||||
const updateExpressAvailability = (
|
||||
availablePaymentMethods:
|
||||
| AvailablePaymentMethods
|
||||
| {
|
||||
applePay?: { available: boolean };
|
||||
googlePay?: { available: boolean };
|
||||
link?: { available: boolean };
|
||||
paypal?: { available: boolean };
|
||||
amazonPay?: { available: boolean };
|
||||
klarna?: { available: boolean };
|
||||
}
|
||||
| undefined,
|
||||
) => {
|
||||
const availability = {
|
||||
applePay: isExpressMethodAvailable(availablePaymentMethods?.applePay),
|
||||
googlePay: isExpressMethodAvailable(availablePaymentMethods?.googlePay),
|
||||
link: isExpressMethodAvailable(availablePaymentMethods?.link),
|
||||
paypal: isExpressMethodAvailable(availablePaymentMethods?.paypal),
|
||||
amazonPay: isExpressMethodAvailable(availablePaymentMethods?.amazonPay),
|
||||
klarna: isExpressMethodAvailable(availablePaymentMethods?.klarna),
|
||||
};
|
||||
setHasExpressPaymentMethods(Object.values(availability).some(Boolean));
|
||||
const browser = BrowserDetector.isFacebookInAppBrowser()
|
||||
? "facebook"
|
||||
: BrowserDetector.getBrowserName() || "unknown";
|
||||
const platform = PlatformDetector.getPlatform();
|
||||
const metadata = {
|
||||
browser,
|
||||
platform,
|
||||
availablePaymentMethods: availability,
|
||||
};
|
||||
log.info(
|
||||
"[stripe-payment-dialog] Express Checkout availability",
|
||||
metadata,
|
||||
);
|
||||
behaviorAnalytics.elementClick(
|
||||
"stripe.express_checkout_availability",
|
||||
"Stripe Express Checkout availability",
|
||||
metadata,
|
||||
);
|
||||
};
|
||||
|
||||
const handleExpressLoadError = (event: Parameters<
|
||||
NonNullable<
|
||||
React.ComponentProps<typeof ExpressCheckoutElement>["onLoadError"]
|
||||
>
|
||||
>[0]) => {
|
||||
setHasExpressPaymentMethods(false);
|
||||
log.warn("[stripe-payment-dialog] Express Checkout load failed", {
|
||||
type: event.error.type,
|
||||
code: event.error.code,
|
||||
message: event.error.message,
|
||||
});
|
||||
};
|
||||
|
||||
const confirmPayment = async (
|
||||
options: {
|
||||
submitPaymentElement: boolean;
|
||||
expressEvent?: StripeExpressCheckoutElementConfirmEvent;
|
||||
},
|
||||
) => {
|
||||
if (!stripe || !elements || submittingRef.current) return;
|
||||
|
||||
submittingRef.current = true;
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
const { error: submitError } = await elements.submit();
|
||||
if (submitError) {
|
||||
setErrorMessage(
|
||||
ExceptionHandler.message(submitError, "Please check your payment info."),
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
if (options.submitPaymentElement) {
|
||||
let submitError: unknown;
|
||||
try {
|
||||
({ error: submitError } = await elements.submit());
|
||||
} catch (error) {
|
||||
submitError = error;
|
||||
}
|
||||
if (submitError) {
|
||||
setErrorMessage(
|
||||
ExceptionHandler.message(
|
||||
submitError,
|
||||
"Please check your payment info.",
|
||||
),
|
||||
);
|
||||
submittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const returnUrl = new URL(
|
||||
returnPath ?? ROUTES.subscription + "/success",
|
||||
window.location.origin,
|
||||
);
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: returnUrl.toString(),
|
||||
},
|
||||
redirect: "if_required",
|
||||
});
|
||||
let confirmationError: unknown;
|
||||
try {
|
||||
({ error: confirmationError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: returnUrl.toString(),
|
||||
},
|
||||
redirect: "if_required",
|
||||
}));
|
||||
} catch (error) {
|
||||
confirmationError = error;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(
|
||||
ExceptionHandler.message(error, "Payment confirmation failed."),
|
||||
if (confirmationError) {
|
||||
const message = ExceptionHandler.message(
|
||||
confirmationError,
|
||||
"Payment confirmation failed.",
|
||||
);
|
||||
setErrorMessage(message);
|
||||
options.expressEvent?.paymentFailed({ reason: "fail", message });
|
||||
submittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -189,9 +310,74 @@ function StripePaymentForm({
|
||||
onConfirmed?.();
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
await confirmPayment({ submitPaymentElement: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<PaymentElement onLoadError={handleLoadError} />
|
||||
<section
|
||||
className={styles.expressSection}
|
||||
hidden={hasExpressPaymentMethods === false}
|
||||
aria-label="Express payment methods"
|
||||
>
|
||||
<ExpressCheckoutElement
|
||||
options={{
|
||||
paymentMethodOrder: [
|
||||
"apple_pay",
|
||||
"google_pay",
|
||||
"link",
|
||||
"paypal",
|
||||
"amazon_pay",
|
||||
"klarna",
|
||||
],
|
||||
paymentMethods: {
|
||||
applePay: "always",
|
||||
googlePay: "always",
|
||||
link: "auto",
|
||||
paypal: "auto",
|
||||
amazonPay: "auto",
|
||||
klarna: "auto",
|
||||
},
|
||||
layout: {
|
||||
maxColumns: expressMaxColumns,
|
||||
maxRows: 6,
|
||||
overflow: "never",
|
||||
},
|
||||
}}
|
||||
onReady={(event) =>
|
||||
updateExpressAvailability(event.availablePaymentMethods)
|
||||
}
|
||||
onAvailablePaymentMethodsChange={(event) =>
|
||||
updateExpressAvailability(event.paymentMethods)
|
||||
}
|
||||
onLoadError={handleExpressLoadError}
|
||||
onConfirm={(event) =>
|
||||
void confirmPayment({
|
||||
submitPaymentElement: false,
|
||||
expressEvent: event,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
<section className={styles.cardSection} aria-labelledby="pay-by-card-title">
|
||||
<h3 id="pay-by-card-title" className={styles.cardTitle}>
|
||||
Pay by card
|
||||
</h3>
|
||||
<PaymentElement
|
||||
options={{
|
||||
layout: { type: "accordion", defaultCollapsed: true },
|
||||
paymentMethodOrder: ["card"],
|
||||
wallets: {
|
||||
applePay: "never",
|
||||
googlePay: "never",
|
||||
link: "never",
|
||||
},
|
||||
}}
|
||||
onLoadError={handleLoadError}
|
||||
/>
|
||||
</section>
|
||||
{errorMessage ? (
|
||||
<p
|
||||
role="alert"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
getPaymentUrl,
|
||||
getStripeCustomerSessionClientSecret,
|
||||
getStripeClientSecret,
|
||||
isEzpayPayment,
|
||||
launchEzpayRedirect,
|
||||
@@ -59,6 +60,8 @@ export interface PaymentLaunchFlow {
|
||||
shouldShowQrisDialog: boolean;
|
||||
shouldShowStripeDialog: boolean;
|
||||
stripeClientSecret: string | null;
|
||||
stripeCustomerSessionClientSecret: string | null;
|
||||
savedPaymentMethodsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface QrisPaymentDetails {
|
||||
@@ -165,6 +168,9 @@ export function usePaymentLaunchFlow({
|
||||
const stripeClientSecret = payment.payParams
|
||||
? getStripeClientSecret(payment.payParams)
|
||||
: null;
|
||||
const stripeCustomerSessionClientSecret = payment.payParams
|
||||
? getStripeCustomerSessionClientSecret(payment.payParams)
|
||||
: null;
|
||||
const ezpayLaunchTarget =
|
||||
payment.payParams && isEzpayPayment(payment.payParams)
|
||||
? resolveEzpayLaunchTarget(payment.payParams)
|
||||
@@ -385,5 +391,8 @@ export function usePaymentLaunchFlow({
|
||||
shouldShowQrisDialog,
|
||||
shouldShowStripeDialog,
|
||||
stripeClientSecret,
|
||||
stripeCustomerSessionClientSecret,
|
||||
savedPaymentMethodsEnabled:
|
||||
stripeCustomerSessionClientSecret !== null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface UsePaymentRouteFlowInput {
|
||||
characterId?: string;
|
||||
initialCategory?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
initialAutoRenew?: boolean | null;
|
||||
commercialOfferId?: string | null;
|
||||
resumeOrderId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
@@ -44,6 +45,7 @@ export function usePaymentRouteFlow({
|
||||
characterId,
|
||||
initialCategory = null,
|
||||
initialPlanId = null,
|
||||
initialAutoRenew = null,
|
||||
commercialOfferId = null,
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
@@ -57,6 +59,7 @@ export function usePaymentRouteFlow({
|
||||
characterId ?? "",
|
||||
initialCategory ?? "",
|
||||
initialPlanId ?? "",
|
||||
initialAutoRenew === null ? "" : String(initialAutoRenew),
|
||||
commercialOfferId ?? "",
|
||||
chatActionId ?? "",
|
||||
].join(":");
|
||||
@@ -78,6 +81,9 @@ export function usePaymentRouteFlow({
|
||||
...(characterId ? { characterId } : {}),
|
||||
...(initialCategory ? { category: initialCategory } : {}),
|
||||
...(initialPlanId ? { planId: initialPlanId } : {}),
|
||||
...(initialAutoRenew === null
|
||||
? {}
|
||||
: { autoRenew: initialAutoRenew }),
|
||||
...(commercialOfferId ? { commercialOfferId } : {}),
|
||||
...(chatActionId ? { chatActionId } : {}),
|
||||
});
|
||||
@@ -89,6 +95,7 @@ export function usePaymentRouteFlow({
|
||||
chatActionId,
|
||||
initialCategory,
|
||||
initialPlanId,
|
||||
initialAutoRenew,
|
||||
initialPayChannel,
|
||||
paymentDispatch,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { deriveChatSupportCta } from "../chat-support-cta";
|
||||
|
||||
describe("deriveChatSupportCta", () => {
|
||||
const now = Date.parse("2026-07-28T00:00:00.000Z");
|
||||
|
||||
it("shows first recharge ahead of a weaker role offer without inventing a timer", () => {
|
||||
expect(
|
||||
deriveChatSupportCta({
|
||||
isFirstRecharge: true,
|
||||
commercialOffer: {
|
||||
enabled: true,
|
||||
commercialOfferId: "offer-1",
|
||||
characterId: "elio",
|
||||
planId: "vip_annual",
|
||||
discountPercent: 30,
|
||||
pricePercent: 70,
|
||||
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||
triggerReason: "price_objection",
|
||||
message: "I got this for you.",
|
||||
},
|
||||
nowMs: now,
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: "firstRecharge",
|
||||
label: "Support me · 50% OFF · First recharge",
|
||||
commercialOfferId: null,
|
||||
expiresAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a server-anchored 24-hour role offer countdown", () => {
|
||||
expect(
|
||||
deriveChatSupportCta({
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: {
|
||||
enabled: true,
|
||||
commercialOfferId: "offer-1",
|
||||
characterId: "maya-tan",
|
||||
planId: "vip_annual",
|
||||
discountPercent: 30,
|
||||
pricePercent: 70,
|
||||
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||
triggerReason: "price_objection",
|
||||
message: "I got this for you.",
|
||||
},
|
||||
nowMs: now,
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: "commercialOffer",
|
||||
label: "Support me · 30% OFF · 23:59:59",
|
||||
commercialOfferId: "offer-1",
|
||||
expiresAt: "2026-07-29T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to Support me after an offer expires or is absent", () => {
|
||||
expect(
|
||||
deriveChatSupportCta({
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: null,
|
||||
nowMs: now,
|
||||
}).label,
|
||||
).toBe("Support me");
|
||||
expect(
|
||||
deriveChatSupportCta({
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: {
|
||||
enabled: true,
|
||||
commercialOfferId: "offer-1",
|
||||
characterId: "elio",
|
||||
planId: "vip_annual",
|
||||
discountPercent: 30,
|
||||
pricePercent: 70,
|
||||
expiresAt: "2026-07-27T00:00:00.000Z",
|
||||
triggerReason: "price_objection",
|
||||
message: "I got this for you.",
|
||||
},
|
||||
nowMs: now,
|
||||
}).label,
|
||||
).toBe("Support me");
|
||||
});
|
||||
});
|
||||
@@ -33,8 +33,8 @@ import {
|
||||
ChatHeader,
|
||||
ChatInputBar,
|
||||
ChatInsufficientCreditsBanner,
|
||||
ChatSupportButton,
|
||||
ChatUnlockDialogs,
|
||||
FirstRechargeOfferBanner,
|
||||
FullscreenImageViewer,
|
||||
PwaInstallOverlay,
|
||||
} from "./components";
|
||||
@@ -46,7 +46,8 @@ import {
|
||||
import {
|
||||
deriveIsGuest,
|
||||
} from "./chat-screen.helpers";
|
||||
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
|
||||
import { useChatSupportCta } from "./hooks/use-chat-support-cta";
|
||||
import { useChatCommercialMessages } from "./hooks/use-chat-commercial-messages";
|
||||
import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator";
|
||||
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
||||
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
||||
@@ -130,10 +131,15 @@ export function ChatScreen() {
|
||||
upgradeReason: state.upgradeReason,
|
||||
paymentGuidance: state.paymentGuidance,
|
||||
});
|
||||
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
|
||||
const supportCta = useChatSupportCta({
|
||||
characterId: character.id,
|
||||
historyLoaded: state.historyLoaded,
|
||||
loginStatus: authState.loginStatus,
|
||||
});
|
||||
useChatCommercialMessages({
|
||||
characterId: character.id,
|
||||
loginStatus: authState.loginStatus,
|
||||
});
|
||||
const shouldShowPwaInstall =
|
||||
state.historyLoaded && state.historyMessages.length >= 10;
|
||||
useChatGuestLogin({
|
||||
@@ -422,13 +428,13 @@ export function ChatScreen() {
|
||||
<ChatHeader
|
||||
isGuest={isGuest}
|
||||
offerBanner={
|
||||
<FirstRechargeOfferBanner
|
||||
visible={firstRechargeOfferBanner.visible}
|
||||
discountPercent={firstRechargeOfferBanner.discountPercent}
|
||||
onClick={firstRechargeOfferBanner.claim}
|
||||
onClose={firstRechargeOfferBanner.close}
|
||||
variant="compact"
|
||||
/>
|
||||
supportCta.visible ? (
|
||||
<ChatSupportButton
|
||||
kind={supportCta.kind}
|
||||
label={supportCta.label}
|
||||
onClick={supportCta.open}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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-text-field";
|
||||
export * from "./chat-send-button";
|
||||
export * from "./chat-support-button";
|
||||
export * from "./chat-unlock-dialogs";
|
||||
export * from "./date-header";
|
||||
export * from "./first-recharge-offer-banner";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowRight, CircleDollarSign, X } from "lucide-react";
|
||||
|
||||
import type { PaymentGuidance } from "@/data/schemas/chat";
|
||||
import type { PaymentAnalyticsTriggerReason } from "@/lib/analytics/payment_analytics_context";
|
||||
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
@@ -15,6 +16,18 @@ export interface PaymentGuidanceCardProps {
|
||||
guidance: PaymentGuidance;
|
||||
}
|
||||
|
||||
function triggerReasonForGuidance(
|
||||
scene: PaymentGuidance["scene"],
|
||||
): PaymentAnalyticsTriggerReason {
|
||||
if (scene === "supportCharacter" || scene === "vip" || scene === "purchaseOptions") {
|
||||
return "vip_cta";
|
||||
}
|
||||
if (scene === "privateMessageLocked" || scene === "historyLocked") {
|
||||
return "private_topic_limit";
|
||||
}
|
||||
return "insufficient_credits";
|
||||
}
|
||||
|
||||
export function PaymentGuidanceCard({ guidance }: PaymentGuidanceCardProps) {
|
||||
const character = useActiveCharacter();
|
||||
const navigator = useAppNavigator();
|
||||
@@ -91,7 +104,7 @@ export function PaymentGuidanceCard({ guidance }: PaymentGuidanceCardProps) {
|
||||
chatActionId: guidance.guidanceId,
|
||||
analytics: {
|
||||
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 {
|
||||
persistExternalEntryPayload,
|
||||
resolveCheckoutIntentDestination,
|
||||
resolveExternalEntryDestination,
|
||||
resolveExternalEntryPromotionType,
|
||||
resolveExternalEntryTarget,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||
import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff";
|
||||
import { consumeCheckoutHandoff } from "@/lib/auth/checkout_handoff";
|
||||
import { Result } from "@/utils/result";
|
||||
import {
|
||||
isFavoriteEntryRequest,
|
||||
@@ -61,6 +63,9 @@ export default function ExternalEntryPersist({
|
||||
const [hasPersisted, setHasPersisted] = useState(false);
|
||||
const [handoffError, setHandoffError] = useState<string | null>(null);
|
||||
const [handoffCompleted, setHandoffCompleted] = useState(false);
|
||||
const [checkoutDestination, setCheckoutDestination] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const hasNavigatedRef = useRef(false);
|
||||
const handoffStartedRef = useRef(false);
|
||||
const targetRoute = resolveExternalEntryTarget({ target });
|
||||
@@ -71,11 +76,14 @@ export default function ExternalEntryPersist({
|
||||
mode,
|
||||
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 =
|
||||
handoffError ??
|
||||
(isTopUpHandoff && !hasValue(handoffToken)
|
||||
? "This top-up link is invalid. Please request a new link in Messenger."
|
||||
(isLoginHandoff && !hasValue(handoffToken)
|
||||
? "This checkout link is invalid. Please return and request a new link."
|
||||
: null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -128,7 +136,7 @@ export default function ExternalEntryPersist({
|
||||
useEffect(() => {
|
||||
if (hasNavigatedRef.current || !hasPersisted) return;
|
||||
|
||||
if (isTopUpHandoff) {
|
||||
if (isLoginHandoff) {
|
||||
if (!authState.hasInitialized || authState.isLoading) return;
|
||||
if (handoffCompleted) {
|
||||
if (
|
||||
@@ -138,25 +146,60 @@ export default function ExternalEntryPersist({
|
||||
return;
|
||||
}
|
||||
hasNavigatedRef.current = true;
|
||||
router.replace(destination);
|
||||
router.replace(checkoutDestination ?? destination);
|
||||
return;
|
||||
}
|
||||
if (handoffStartedRef.current) return;
|
||||
if (!hasValue(handoffToken)) return;
|
||||
handoffStartedRef.current = true;
|
||||
void (async () => {
|
||||
const result = await consumeTopUpHandoff(handoffToken);
|
||||
if (Result.isErr(result)) {
|
||||
log.warn("[ExternalEntryPersist] top-up handoff failed", result.error);
|
||||
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),
|
||||
);
|
||||
} else {
|
||||
const result = await consumeTopUpHandoff(handoffToken);
|
||||
if (Result.isErr(result)) {
|
||||
log.warn(
|
||||
"[ExternalEntryPersist] top-up handoff failed",
|
||||
result.error,
|
||||
);
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
"/external-entry?target=topup",
|
||||
);
|
||||
setHandoffError(
|
||||
"This top-up link is invalid or has expired. Please request a new link in Messenger.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
"/external-entry?target=topup",
|
||||
);
|
||||
setHandoffError(
|
||||
"This top-up link is invalid or has expired. Please request a new link in Messenger.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setHandoffCompleted(true);
|
||||
authDispatch({ type: "AuthInit" });
|
||||
@@ -190,9 +233,12 @@ export default function ExternalEntryPersist({
|
||||
authState.isLoading,
|
||||
authState.loginStatus,
|
||||
destination,
|
||||
checkoutDestination,
|
||||
hasPersisted,
|
||||
handoffCompleted,
|
||||
handoffToken,
|
||||
isCheckoutHandoff,
|
||||
isLoginHandoff,
|
||||
isTopUpHandoff,
|
||||
psid,
|
||||
router,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* `/external-entry?target=tip`
|
||||
* `/external-entry?target=private-zone&character=nayeli`
|
||||
* `/external-entry?target=topup&handoffToken=<opaque-token>`
|
||||
* `/external-entry?target=checkout&handoffToken=<opaque-token>`
|
||||
*
|
||||
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
||||
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
|
||||
|
||||
@@ -34,14 +34,14 @@ describe("SubscriptionPage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Elio for missing source navigation context", async () => {
|
||||
it("requires an explicit selection when source navigation has no character", async () => {
|
||||
const page = await SubscriptionPage({
|
||||
searchParams: Promise.resolve({}),
|
||||
});
|
||||
|
||||
renderToStaticMarkup(page);
|
||||
expect(mocks.screenProps).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sourceCharacterSlug: "elio" }),
|
||||
expect.objectContaining({ sourceCharacterSlug: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,16 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const characters = [
|
||||
{ id: "elio", slug: "elio", displayName: "Elio Silvestri", shortName: "Elio" },
|
||||
{ id: "maya-tan", slug: "maya", displayName: "Maya Tan", shortName: "Maya" },
|
||||
{
|
||||
id: "nayeli-cervantes",
|
||||
slug: "nayeli",
|
||||
displayName: "Nayeli Cervantes",
|
||||
shortName: "Nayeli",
|
||||
},
|
||||
];
|
||||
const payment = {
|
||||
status: "ready",
|
||||
plans: [
|
||||
@@ -52,7 +62,17 @@ const mocks = vi.hoisted(() => {
|
||||
payment.selectedPlanId = event.planId;
|
||||
}
|
||||
});
|
||||
return { payment, paymentDispatch, planClick: vi.fn() };
|
||||
return {
|
||||
characterCatalog: {
|
||||
characters,
|
||||
getBySlug: (slug: string | null) =>
|
||||
characters.find((character) => character.slug === slug) ?? null,
|
||||
},
|
||||
payment,
|
||||
paymentDispatch,
|
||||
paymentFlowInput: vi.fn(),
|
||||
planClick: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/app/_components", () => ({
|
||||
@@ -83,6 +103,10 @@ vi.mock("@/stores/user/user-context", () => ({
|
||||
useUserState: () => ({ currentUser: { countryCode: "ID" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/providers/character-catalog-provider", () => ({
|
||||
useCharacterCatalog: () => mocks.characterCatalog,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/payment/payment_method", () => ({
|
||||
getPaymentMethodConfig: () => ({
|
||||
initialPayChannel: "stripe",
|
||||
@@ -97,13 +121,16 @@ vi.mock("@/lib/analytics", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../use-subscription-payment-flow", () => ({
|
||||
useSubscriptionPaymentFlow: () => ({
|
||||
payment: mocks.payment,
|
||||
paymentDispatch: mocks.paymentDispatch,
|
||||
showPaymentSuccessDialog: false,
|
||||
handleBackClick: vi.fn(),
|
||||
handlePaymentSuccessClose: vi.fn(),
|
||||
}),
|
||||
useSubscriptionPaymentFlow: (input: unknown) => {
|
||||
mocks.paymentFlowInput(input);
|
||||
return {
|
||||
payment: mocks.payment,
|
||||
paymentDispatch: mocks.paymentDispatch,
|
||||
showPaymentSuccessDialog: false,
|
||||
handleBackClick: vi.fn(),
|
||||
handlePaymentSuccessClose: vi.fn(),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../components", () => ({
|
||||
@@ -170,8 +197,16 @@ describe("SubscriptionScreen payment selection flow", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mocks.payment.isFirstRecharge = false;
|
||||
mocks.payment.commercialOffer = null;
|
||||
for (const plan of mocks.payment.plans) {
|
||||
delete (plan as Record<string, unknown>).isFirstRechargeOffer;
|
||||
delete (plan as Record<string, unknown>).firstRechargeDiscountPercent;
|
||||
delete (plan as Record<string, unknown>).promotionType;
|
||||
}
|
||||
mocks.payment.selectedPlanId = "vip_monthly";
|
||||
mocks.paymentDispatch.mockClear();
|
||||
mocks.paymentFlowInput.mockClear();
|
||||
mocks.planClick.mockClear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -275,6 +310,52 @@ describe("SubscriptionScreen payment selection flow", () => {
|
||||
expect(guidance?.textContent).toContain("cannot continue");
|
||||
expect(guidance?.textContent).not.toContain("Elio");
|
||||
});
|
||||
|
||||
it("does not render the first recharge banner on the subscription page", () => {
|
||||
mocks.payment.isFirstRecharge = true;
|
||||
Object.assign(mocks.payment.plans[0] as Record<string, unknown>, {
|
||||
isFirstRechargeOffer: true,
|
||||
firstRechargeDiscountPercent: 50,
|
||||
promotionType: "first_recharge_half_price",
|
||||
});
|
||||
|
||||
act(() => root.render(<SubscriptionScreen />));
|
||||
|
||||
expect(container.textContent).not.toContain("First Recharge Offer");
|
||||
expect(container.textContent).not.toContain(
|
||||
"Your first recharge price is already applied",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a support character selection when opened without a 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).toContain("Choose who you want to support");
|
||||
expect(checkout?.disabled).toBe(true);
|
||||
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sourceCharacterSlug: null }),
|
||||
);
|
||||
|
||||
act(() => clickButton(container, "Maya"));
|
||||
|
||||
expect(container.textContent).toContain("Supporting Maya");
|
||||
expect(checkout?.disabled).toBe(false);
|
||||
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sourceCharacterSlug: "maya" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function clickButton(root: ParentNode, label: string): void {
|
||||
|
||||
@@ -3,8 +3,12 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Result } from "@/utils/result";
|
||||
import { ApiError } from "@/data/services/api/api_result";
|
||||
|
||||
import { SubscriptionPaymentIssueDialog } from "../subscription-payment-issue-dialog";
|
||||
import {
|
||||
getPaymentIssueSubmitErrorLogDetails,
|
||||
SubscriptionPaymentIssueDialog,
|
||||
} from "../subscription-payment-issue-dialog";
|
||||
import { SubscriptionRenewalConfirmationDialog } from "../subscription-renewal-confirmation-dialog";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -200,6 +204,28 @@ describe("subscription payment dialogs", () => {
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps HTTP status and backend error code in payment issue failure diagnostics", () => {
|
||||
const failure = Result.err(
|
||||
new ApiError("HTTP_ERROR", "Invalid feedback context", 400, {
|
||||
detail: {
|
||||
message: "Invalid feedback context",
|
||||
errorCode: "INVALID_FEEDBACK_CONTEXT",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (failure.success) throw new Error("Expected failure result");
|
||||
expect(getPaymentIssueSubmitErrorLogDetails(failure.error)).toMatchObject({
|
||||
code: "UNKNOWN",
|
||||
message: "Invalid feedback context",
|
||||
causeName: "ApiError",
|
||||
causeMessage: "Invalid feedback context",
|
||||
httpStatus: 400,
|
||||
apiCode: "HTTP_ERROR",
|
||||
serverErrorCode: "INVALID_FEEDBACK_CONTEXT",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function clickButton(root: ParentNode | null, label: string): void {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||
import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button";
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||
import {
|
||||
@@ -70,6 +71,21 @@ export function SubscriptionCheckoutButton({
|
||||
>
|
||||
{label}
|
||||
</SubscriptionCtaButton>
|
||||
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
|
||||
<ExternalBrowserCheckoutButton
|
||||
disabled={disabled || isLoading}
|
||||
checkoutIntent={{
|
||||
planId: payment.selectedPlanId,
|
||||
autoRenew: payment.autoRenew,
|
||||
...(payment.commercialOfferId
|
||||
? { commercialOfferId: payment.commercialOfferId }
|
||||
: {}),
|
||||
...(payment.chatActionId
|
||||
? { chatActionId: payment.chatActionId }
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{payment.errorMessage ? (
|
||||
<p
|
||||
role="alert"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||
import { createFeedbackContext } from "@/app/feedback/feedback-context";
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
import { submitFeedback } from "@/lib/feedback";
|
||||
import { Logger } from "@/utils/logger";
|
||||
|
||||
import type { SubscriptionType } from "../subscription-screen.helpers";
|
||||
import styles from "./subscription-dialog.module.css";
|
||||
@@ -33,6 +34,21 @@ const OTHER_DETAILS_MIN_LENGTH = 10;
|
||||
const OTHER_DETAILS_MAX_LENGTH = 2000;
|
||||
const SUCCESS_MESSAGE = "Thanks. Your payment issue has been submitted.";
|
||||
const FAILURE_MESSAGE = "Unable to submit. Please try again.";
|
||||
const log = new Logger("SubscriptionPaymentIssueDialog");
|
||||
|
||||
interface ErrorRecord {
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PaymentIssueSubmitErrorLogDetails {
|
||||
code: string;
|
||||
message: string;
|
||||
causeName?: string;
|
||||
causeMessage?: string;
|
||||
httpStatus?: number;
|
||||
apiCode?: string;
|
||||
serverErrorCode?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionPaymentIssueDialogProps {
|
||||
open: boolean;
|
||||
@@ -119,6 +135,19 @@ export function SubscriptionPaymentIssueDialog({
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
log.error(
|
||||
{
|
||||
...getPaymentIssueSubmitErrorLogDetails(result.error),
|
||||
paymentIssueReason: reason,
|
||||
subscriptionType,
|
||||
planId,
|
||||
orderId,
|
||||
payChannel,
|
||||
countryCode,
|
||||
characterId,
|
||||
},
|
||||
"Payment issue feedback submit failed",
|
||||
);
|
||||
setErrorMessage(FAILURE_MESSAGE);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
@@ -206,6 +235,32 @@ export function SubscriptionPaymentIssueDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export function getPaymentIssueSubmitErrorLogDetails(
|
||||
error: Error,
|
||||
): PaymentIssueSubmitErrorLogDetails {
|
||||
const errorRecord = toErrorRecord(error);
|
||||
const cause = toErrorRecord(errorRecord?.cause);
|
||||
const responseDetails = toErrorRecord(cause?.details);
|
||||
const detail = toErrorRecord(responseDetails?.detail);
|
||||
const causeName = stringValue(cause?.name);
|
||||
const causeMessage = stringValue(cause?.message);
|
||||
const httpStatus = numberValue(cause?.status);
|
||||
const apiCode = stringValue(cause?.code);
|
||||
const serverErrorCode =
|
||||
stringValue(detail?.errorCode) ??
|
||||
stringValue(responseDetails?.errorCode);
|
||||
|
||||
return {
|
||||
code: stringValue(errorRecord?.code) ?? "UNKNOWN",
|
||||
message: error.message,
|
||||
...(causeName ? { causeName } : {}),
|
||||
...(causeMessage ? { causeMessage } : {}),
|
||||
...(httpStatus !== undefined ? { httpStatus } : {}),
|
||||
...(apiCode ? { apiCode } : {}),
|
||||
...(serverErrorCode ? { serverErrorCode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createIdempotencyKey(): string {
|
||||
const token =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
@@ -213,3 +268,18 @@ function createIdempotencyKey(): string {
|
||||
: `${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
return `payment_feedback_${token}`;
|
||||
}
|
||||
|
||||
function toErrorRecord(value: unknown): ErrorRecord | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
return value as ErrorRecord;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,52 @@
|
||||
box-shadow: 0 12px 30px rgba(22, 101, 52, 0.12);
|
||||
}
|
||||
|
||||
.supportCharacterSelector {
|
||||
margin-top: var(--page-section-gap, 14px);
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(246, 87, 160, 0.2);
|
||||
border-radius: var(--responsive-card-radius-sm, 22px);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #4a3340;
|
||||
}
|
||||
|
||||
.supportCharacterSelector h2 {
|
||||
margin: 0;
|
||||
color: #24151d;
|
||||
font-size: var(--responsive-card-title, 17px);
|
||||
}
|
||||
|
||||
.supportCharacterSelector p {
|
||||
margin: 6px 0 10px;
|
||||
font-size: var(--responsive-caption, 13px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.supportCharacterOptions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.supportCharacterOption,
|
||||
.supportCharacterOptionActive {
|
||||
border: 1px solid rgba(246, 87, 160, 0.24);
|
||||
border-radius: 999px;
|
||||
padding: 7px 12px;
|
||||
background: #fff;
|
||||
color: #8e315e;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.supportCharacterOptionActive {
|
||||
border-color: #f657a0;
|
||||
background: #f657a0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.characterSupportBanner {
|
||||
margin-top: var(--page-section-gap, 14px);
|
||||
padding: 14px 16px;
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
} from "@/lib/analytics/payment_analytics_context";
|
||||
import {
|
||||
getFirstPaymentSearchParam,
|
||||
parsePaymentAutoRenew,
|
||||
parsePaymentReturnSearchParams,
|
||||
parseSubscriptionReturnTo,
|
||||
type PaymentSearchParams,
|
||||
} from "@/lib/payment/payment_search_params";
|
||||
import {
|
||||
DEFAULT_CHARACTER_SLUG,
|
||||
getCharacterBySlug,
|
||||
} from "@/data/constants/character";
|
||||
|
||||
@@ -30,8 +30,7 @@ export default async function SubscriptionPage({
|
||||
getFirstPaymentSearchParam(query.type),
|
||||
);
|
||||
const sourceCharacterSlug =
|
||||
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ??
|
||||
DEFAULT_CHARACTER_SLUG;
|
||||
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ?? null;
|
||||
const initialPlanId = getFirstPaymentSearchParam(query.planId);
|
||||
const commercialOfferId = getFirstPaymentSearchParam(
|
||||
query.commercialOfferId,
|
||||
@@ -57,6 +56,7 @@ export default async function SubscriptionPage({
|
||||
analyticsContext={analyticsContext}
|
||||
sourceCharacterSlug={sourceCharacterSlug}
|
||||
initialPlanId={initialPlanId}
|
||||
initialAutoRenew={parsePaymentAutoRenew(query.autoRenew)}
|
||||
commercialOfferId={commercialOfferId}
|
||||
resumeOrderId={resumeOrderId}
|
||||
chatActionId={chatActionId}
|
||||
|
||||
@@ -9,10 +9,10 @@ import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||
import {
|
||||
DEFAULT_CHARACTER,
|
||||
DEFAULT_CHARACTER_SLUG,
|
||||
getCharacterBySlug,
|
||||
} from "@/data/constants/character";
|
||||
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
|
||||
import {
|
||||
behaviorAnalytics,
|
||||
getDefaultPaymentAnalyticsContext,
|
||||
@@ -51,8 +51,9 @@ export interface SubscriptionScreenProps {
|
||||
returnTo?: SubscriptionReturnTo;
|
||||
initialPayChannel?: PayChannel | null;
|
||||
analyticsContext?: PaymentAnalyticsContext;
|
||||
sourceCharacterSlug?: string;
|
||||
sourceCharacterSlug?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
initialAutoRenew?: boolean | null;
|
||||
commercialOfferId?: string | null;
|
||||
resumeOrderId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
@@ -66,6 +67,7 @@ export function SubscriptionScreen({
|
||||
analyticsContext: providedAnalyticsContext,
|
||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
initialPlanId = null,
|
||||
initialAutoRenew = null,
|
||||
commercialOfferId = null,
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
@@ -78,9 +80,15 @@ export function SubscriptionScreen({
|
||||
const [paymentIssueNotice, setPaymentIssueNotice] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const characterCatalog = useCharacterCatalog();
|
||||
const [selectedSupportCharacterSlug, setSelectedSupportCharacterSlug] =
|
||||
useState<string | null>(() =>
|
||||
getCharacterBySlug(sourceCharacterSlug)?.slug ?? null,
|
||||
);
|
||||
const userState = useUserState();
|
||||
const sourceCharacter =
|
||||
getCharacterBySlug(sourceCharacterSlug) ?? DEFAULT_CHARACTER;
|
||||
const sourceCharacter = characterCatalog.getBySlug(
|
||||
selectedSupportCharacterSlug,
|
||||
);
|
||||
const hasHydrated = useHasHydrated();
|
||||
const countryCode = userState.currentUser?.countryCode;
|
||||
const paymentMethodConfig = getPaymentMethodConfig({
|
||||
@@ -100,9 +108,10 @@ export function SubscriptionScreen({
|
||||
subscriptionType,
|
||||
shouldResumePendingOrder,
|
||||
returnTo,
|
||||
sourceCharacterSlug,
|
||||
sourceCharacterSlug: sourceCharacter?.slug ?? null,
|
||||
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
||||
initialPlanId,
|
||||
initialAutoRenew,
|
||||
commercialOfferId,
|
||||
resumeOrderId,
|
||||
chatActionId,
|
||||
@@ -131,14 +140,14 @@ export function SubscriptionScreen({
|
||||
});
|
||||
}, [canSubscribeVip, directCoinsPlans, payment.plans, vipOfferPlans]);
|
||||
usePaymentPlanAnalytics(displayedPlans, analyticsContext);
|
||||
const firstRechargeOffer = useMemo(
|
||||
const hasFirstRechargeOffer = useMemo(
|
||||
() =>
|
||||
getFirstRechargeOfferView({
|
||||
isFirstRecharge: payment.isFirstRecharge,
|
||||
subscriptionType,
|
||||
vipPlans: vipOfferPlans,
|
||||
coinPlans: directCoinsPlans,
|
||||
}),
|
||||
}) !== null,
|
||||
[
|
||||
directCoinsPlans,
|
||||
payment.isFirstRecharge,
|
||||
@@ -160,6 +169,7 @@ export function SubscriptionScreen({
|
||||
(plan) => plan.id === payment.selectedPlanId,
|
||||
);
|
||||
const canActivate =
|
||||
sourceCharacter !== null &&
|
||||
selectedPlan !== null &&
|
||||
canCheckoutSubscriptionPlan({
|
||||
selectedPlanId: payment.selectedPlanId,
|
||||
@@ -268,7 +278,36 @@ export function SubscriptionScreen({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{chatActionId ? (
|
||||
<section className={styles.supportCharacterSelector}>
|
||||
<h2>
|
||||
{sourceCharacter
|
||||
? `Supporting ${sourceCharacter.shortName}`
|
||||
: "Choose who you want to support"}
|
||||
</h2>
|
||||
<p>
|
||||
Your VIP or credit purchase supports the character you choose, and
|
||||
they will thank you after the payment and benefits are confirmed.
|
||||
</p>
|
||||
<div className={styles.supportCharacterOptions}>
|
||||
{characterCatalog.characters.map((character) => (
|
||||
<button
|
||||
key={character.id}
|
||||
type="button"
|
||||
className={
|
||||
sourceCharacter?.id === character.id
|
||||
? styles.supportCharacterOptionActive
|
||||
: styles.supportCharacterOption
|
||||
}
|
||||
aria-pressed={sourceCharacter?.id === character.id}
|
||||
onClick={() => setSelectedSupportCharacterSlug(character.slug)}
|
||||
>
|
||||
{character.shortName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{chatActionId && sourceCharacter ? (
|
||||
<section
|
||||
className={styles.characterSupportBanner}
|
||||
aria-label={`Support ${sourceCharacter.displayName}`}
|
||||
@@ -284,31 +323,7 @@ export function SubscriptionScreen({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{firstRechargeOffer ? (
|
||||
<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 ? (
|
||||
{payment.commercialOffer && !hasFirstRechargeOffer && sourceCharacter ? (
|
||||
<section
|
||||
className={styles.firstRechargeBanner}
|
||||
aria-label={`${sourceCharacter.shortName} private offer`}
|
||||
@@ -360,7 +375,7 @@ export function SubscriptionScreen({
|
||||
disabled={!canActivate}
|
||||
subscriptionType={subscriptionType}
|
||||
returnTo={returnTo}
|
||||
sourceCharacterSlug={sourceCharacterSlug}
|
||||
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -385,7 +400,7 @@ export function SubscriptionScreen({
|
||||
orderId={payment.currentOrderId}
|
||||
payChannel={payment.payChannel}
|
||||
countryCode={countryCode}
|
||||
characterId={sourceCharacterSlug}
|
||||
characterId={sourceCharacter?.id ?? ""}
|
||||
onClose={() => setShowPaymentIssueDialog(false)}
|
||||
onSubmitted={setPaymentIssueNotice}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,11 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import {
|
||||
DEFAULT_CHARACTER,
|
||||
DEFAULT_CHARACTER_SLUG,
|
||||
getCharacterBySlug,
|
||||
} from "@/data/constants/character";
|
||||
import {
|
||||
consumeSubscriptionExitUrl,
|
||||
resolveSubscriptionSuccessExitUrl,
|
||||
@@ -19,8 +23,9 @@ export interface UseSubscriptionPaymentFlowInput {
|
||||
shouldResumePendingOrder: boolean;
|
||||
returnTo: SubscriptionReturnTo;
|
||||
initialPayChannel: PayChannel;
|
||||
sourceCharacterSlug?: string;
|
||||
sourceCharacterSlug?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
initialAutoRenew?: boolean | null;
|
||||
commercialOfferId?: string | null;
|
||||
resumeOrderId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
@@ -33,16 +38,21 @@ export function useSubscriptionPaymentFlow({
|
||||
initialPayChannel,
|
||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
initialPlanId = null,
|
||||
initialAutoRenew = null,
|
||||
commercialOfferId = null,
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
}: UseSubscriptionPaymentFlowInput) {
|
||||
const router = useRouter();
|
||||
const supportCharacter = getCharacterBySlug(sourceCharacterSlug);
|
||||
const exitCharacterSlug = supportCharacter?.slug ?? DEFAULT_CHARACTER.slug;
|
||||
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
||||
initialPayChannel,
|
||||
paymentType: subscriptionType,
|
||||
shouldResumePendingOrder,
|
||||
characterId: supportCharacter?.id,
|
||||
initialPlanId,
|
||||
initialAutoRenew,
|
||||
commercialOfferId,
|
||||
resumeOrderId,
|
||||
chatActionId,
|
||||
@@ -62,7 +72,7 @@ export function useSubscriptionPaymentFlow({
|
||||
const handleBackClick = () => {
|
||||
void (async () => {
|
||||
router.replace(
|
||||
await consumeSubscriptionExitUrl(returnTo, sourceCharacterSlug),
|
||||
await consumeSubscriptionExitUrl(returnTo, exitCharacterSlug),
|
||||
);
|
||||
})();
|
||||
};
|
||||
@@ -74,7 +84,7 @@ export function useSubscriptionPaymentFlow({
|
||||
router.replace(
|
||||
await resolveSubscriptionSuccessExitUrl(
|
||||
returnTo,
|
||||
sourceCharacterSlug,
|
||||
exitCharacterSlug,
|
||||
),
|
||||
);
|
||||
})();
|
||||
|
||||
@@ -359,6 +359,8 @@ function makePaymentState(
|
||||
selectedGiftCategory: giftCategory.category,
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: null,
|
||||
commercialOfferId: null,
|
||||
chatActionId: null,
|
||||
selectedPlanId: giftPlan.planId,
|
||||
payChannel: "stripe",
|
||||
autoRenew: false,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||
import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
@@ -63,6 +64,19 @@ export function TipCheckoutButton({
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
|
||||
<ExternalBrowserCheckoutButton
|
||||
disabled={disabled || isLoading}
|
||||
checkoutIntent={{
|
||||
planId: payment.selectedPlanId,
|
||||
autoRenew: false,
|
||||
recipientCharacterId: character.id,
|
||||
...(payment.chatActionId
|
||||
? { chatActionId: payment.chatActionId }
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{payment.errorMessage ? (
|
||||
<p className={styles.checkoutError} role="alert">
|
||||
{payment.errorMessage}
|
||||
|
||||
@@ -45,6 +45,27 @@ describe("ChatWebSocket payment guidance", () => {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -37,6 +37,13 @@ export interface PaywallStatusPayload {
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface CommercialMessagePayload {
|
||||
messageId: string;
|
||||
message: string;
|
||||
characterId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export class ChatWebSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -52,6 +59,7 @@ export class ChatWebSocket {
|
||||
onCommercialAction: ((action: CommercialAction) => void) | null = null;
|
||||
onChatAction: ((action: ChatAction) => void) | null = null;
|
||||
onPaymentGuidance: ((guidance: PaymentGuidance) => void) | null = null;
|
||||
onCommercialMessage: ((message: CommercialMessagePayload) => void) | null = null;
|
||||
onError: ((errorMessage: string) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -149,6 +157,10 @@ export class ChatWebSocket {
|
||||
done?: boolean;
|
||||
audioUrl?: string;
|
||||
error?: string;
|
||||
messageId?: string;
|
||||
message?: string;
|
||||
characterId?: string;
|
||||
createdAt?: string;
|
||||
data?: Record<string, unknown> & {
|
||||
image?: {
|
||||
type?: string | null;
|
||||
@@ -163,6 +175,10 @@ export class ChatWebSocket {
|
||||
ruleId?: string;
|
||||
kind?: string;
|
||||
orderId?: string | null;
|
||||
messageId?: string;
|
||||
message?: string;
|
||||
characterId?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
};
|
||||
try {
|
||||
@@ -217,6 +233,25 @@ export class ChatWebSocket {
|
||||
if (guidance.success) this.onPaymentGuidance?.(guidance.data);
|
||||
break;
|
||||
}
|
||||
case "commercial_message": {
|
||||
const commercialMessage = payload.data?.messageId
|
||||
? payload.data
|
||||
: payload;
|
||||
if (
|
||||
commercialMessage?.messageId &&
|
||||
commercialMessage.message &&
|
||||
commercialMessage.characterId &&
|
||||
commercialMessage.createdAt
|
||||
) {
|
||||
this.onCommercialMessage?.({
|
||||
messageId: commercialMessage.messageId,
|
||||
message: commercialMessage.message,
|
||||
characterId: commercialMessage.characterId,
|
||||
createdAt: commercialMessage.createdAt,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
this.onError?.(payload.error ?? "Unknown error");
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ExceptionHandler } from "@/core/errors";
|
||||
import type { IAuthRepository } from "@/data/repositories/interfaces";
|
||||
import {
|
||||
CheckoutHandoffConsumeRequestSchema,
|
||||
CheckoutHandoffCreateRequestSchema,
|
||||
type CheckoutHandoffCreateResponse,
|
||||
type CheckoutIntent,
|
||||
FacebookIdentityRequestSchema,
|
||||
FacebookLoginRequestSchema,
|
||||
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,空则直接返回错误;
|
||||
* 调用 API 成功后写回新的 login token + refresh token。
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
import type { Result } from "@/utils/result";
|
||||
import type {
|
||||
CheckoutHandoffCreateResponse,
|
||||
CheckoutIntent,
|
||||
GuestLoginResponse,
|
||||
LoginStatus,
|
||||
LoginResponse,
|
||||
@@ -74,6 +76,16 @@ export interface IAuthRepository {
|
||||
/** 消费一次性充值登录凭证并建立正式会话。 */
|
||||
consumeTopUpHandoff(handoffToken: string): Promise<Result<LoginStatus>>;
|
||||
|
||||
/** 在创建订单前生成10分钟有效的外部浏览器支付链接。 */
|
||||
createCheckoutHandoff(
|
||||
checkoutIntent: CheckoutIntent,
|
||||
): Promise<Result<CheckoutHandoffCreateResponse>>;
|
||||
|
||||
/** 消费单次支付交接凭证并保存正式登录态。 */
|
||||
consumeCheckoutHandoff(
|
||||
handoffToken: string,
|
||||
): Promise<Result<CheckoutIntent>>;
|
||||
|
||||
/** 刷新 token:先读本地的 refresh token,空则返回错误。 */
|
||||
refreshToken(): Promise<Result<RefreshTokenResponse>>;
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import type { Result } from "@/utils/result";
|
||||
|
||||
export interface IPaymentRepository {
|
||||
/** 获取套餐列表。 */
|
||||
getPlans(commercialOfferId?: string): Promise<Result<PaymentPlansResponse>>;
|
||||
getPlans(
|
||||
commercialOfferId?: string,
|
||||
supportCharacterId?: string,
|
||||
): Promise<Result<PaymentPlansResponse>>;
|
||||
|
||||
/** 获取本地缓存套餐列表。 */
|
||||
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
|
||||
|
||||
@@ -24,10 +24,18 @@ export class PaymentRepository implements IPaymentRepository {
|
||||
constructor(private readonly api: PaymentApi) {}
|
||||
|
||||
/** 获取套餐列表。 */
|
||||
async getPlans(commercialOfferId?: string): Promise<Result<PaymentPlansResponse>> {
|
||||
async getPlans(
|
||||
commercialOfferId?: string,
|
||||
supportCharacterId?: string,
|
||||
): Promise<Result<PaymentPlansResponse>> {
|
||||
return Result.wrap(async () => {
|
||||
const response = await this.api.getPlans(commercialOfferId);
|
||||
if (!commercialOfferId) await PaymentPlansStorage.setPlans(response);
|
||||
const response = await this.api.getPlans(
|
||||
commercialOfferId,
|
||||
supportCharacterId,
|
||||
);
|
||||
if (!commercialOfferId && !supportCharacterId) {
|
||||
await PaymentPlansStorage.setPlans(response);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CheckoutHandoffConsumeResponseSchema,
|
||||
CheckoutHandoffCreateRequestSchema,
|
||||
} from "../checkout_handoff";
|
||||
|
||||
describe("checkout handoff schemas", () => {
|
||||
it("keeps only the minimal order-free purchase intent", () => {
|
||||
expect(
|
||||
CheckoutHandoffCreateRequestSchema.parse({
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
recipientCharacterId: "elio",
|
||||
price: 9.99,
|
||||
}),
|
||||
).toEqual({
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
recipientCharacterId: "elio",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses the formal session and checkout intent returned after consumption", () => {
|
||||
const parsed = CheckoutHandoffConsumeResponseSchema.parse({
|
||||
user: {
|
||||
id: "00000000-0000-0000-0000-000000000123",
|
||||
username: "checkout-user",
|
||||
email: "checkout@example.com",
|
||||
platform: "web",
|
||||
},
|
||||
token: "access-token",
|
||||
refreshToken: "refresh-token",
|
||||
loginStatus: "email",
|
||||
checkoutIntent: {
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.checkoutIntent.planId).toBe("vip_monthly");
|
||||
expect(parsed.loginStatus).toBe("email");
|
||||
});
|
||||
});
|
||||
@@ -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 "./checkout_handoff";
|
||||
export * from "./login_status";
|
||||
export * from "./request/facebook_identity_request";
|
||||
export * from "./request/facebook_login_request";
|
||||
|
||||
@@ -27,6 +27,16 @@ describe("payment guidance wire contract", () => {
|
||||
expect(PaymentGuidanceSchema.parse(guidance)).toEqual(guidance);
|
||||
});
|
||||
|
||||
it("accepts the supportCharacter guidance scene", () => {
|
||||
expect(
|
||||
PaymentGuidanceSchema.parse({
|
||||
...guidance,
|
||||
scene: "supportCharacter",
|
||||
ruleId: "payment_guidance_supportCharacter",
|
||||
}).scene,
|
||||
).toBe("supportCharacter");
|
||||
});
|
||||
|
||||
it("keeps all backend lock facts instead of dropping hint and CTA", () => {
|
||||
expect(
|
||||
ChatLockDetailSchema.parse({
|
||||
|
||||
@@ -24,6 +24,7 @@ export const PaymentGuidanceSceneSchema = z.enum([
|
||||
"privateZoneLocked",
|
||||
"unknownLock",
|
||||
"whyPay",
|
||||
"supportCharacter",
|
||||
"leavingAfterCredits",
|
||||
"paymentIssue",
|
||||
"externalRisk",
|
||||
|
||||
@@ -195,9 +195,11 @@ describe("PaymentPlansResponse", () => {
|
||||
|
||||
it("parses one user-bound commercial offer and its discounted plan", () => {
|
||||
const response = PaymentPlansResponseSchema.parse({
|
||||
commercialOffer: {
|
||||
enabled: true,
|
||||
commercialOfferId: "offer-1",
|
||||
supportCharacterId: "maya-tan",
|
||||
commercialOffer: {
|
||||
enabled: true,
|
||||
commercialOfferId: "offer-1",
|
||||
characterId: "maya-tan",
|
||||
planId: "vip_annual",
|
||||
discountPercent: 30,
|
||||
pricePercent: 70,
|
||||
@@ -225,6 +227,8 @@ describe("PaymentPlansResponse", () => {
|
||||
});
|
||||
|
||||
expect(response.commercialOffer?.commercialOfferId).toBe("offer-1");
|
||||
expect(response.supportCharacterId).toBe("maya-tan");
|
||||
expect(response.commercialOffer?.characterId).toBe("maya-tan");
|
||||
expect(response.plans[0]).toMatchObject({
|
||||
planId: "vip_annual",
|
||||
commercialOfferId: "offer-1",
|
||||
|
||||
@@ -24,6 +24,7 @@ export const CommercialOfferSummarySchema = z
|
||||
.object({
|
||||
enabled: booleanOrFalse,
|
||||
commercialOfferId: z.string().min(1),
|
||||
characterId: stringOrEmpty,
|
||||
planId: z.string().min(1),
|
||||
discountPercent: numberOrZero,
|
||||
pricePercent: numberOrZero,
|
||||
@@ -36,6 +37,7 @@ export const CommercialOfferSummarySchema = z
|
||||
export const PaymentPlansResponseSchema = z
|
||||
.object({
|
||||
isFirstRecharge: booleanOrFalse,
|
||||
supportCharacterId: stringOrEmpty,
|
||||
firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema),
|
||||
commercialOffer: schemaOrNull(CommercialOfferSummarySchema),
|
||||
plans: arrayOrEmpty(PaymentPlanSchema),
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" },
|
||||
"facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" },
|
||||
"topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" },
|
||||
"checkoutHandoffCreate": { "method": "post", "path": "/api/auth/handoff/checkout" },
|
||||
"checkoutHandoffConsume": { "method": "post", "path": "/api/auth/handoff/checkout/consume" },
|
||||
"refresh": { "method": "post", "path": "/api/auth/refresh" },
|
||||
"logout": { "method": "post", "path": "/api/auth/logout" },
|
||||
"getCurrentUser": { "method": "get", "path": "/api/auth/me" },
|
||||
|
||||
@@ -30,6 +30,12 @@ export class ApiPath {
|
||||
/** 消费一次性充值登录凭证 */
|
||||
static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path;
|
||||
|
||||
/** 创建一次性外部浏览器支付交接链接(创建订单前)。 */
|
||||
static readonly checkoutHandoffCreate = apiContract.checkoutHandoffCreate.path;
|
||||
|
||||
/** 消费一次性外部浏览器支付交接凭证。 */
|
||||
static readonly checkoutHandoffConsume = apiContract.checkoutHandoffConsume.path;
|
||||
|
||||
/** 刷新 Token */
|
||||
static readonly refresh = apiContract.refresh.path;
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CheckoutHandoffConsumeRequest,
|
||||
CheckoutHandoffConsumeResponse,
|
||||
CheckoutHandoffConsumeResponseSchema,
|
||||
CheckoutHandoffCreateRequest,
|
||||
CheckoutHandoffCreateResponse,
|
||||
CheckoutHandoffCreateResponseSchema,
|
||||
FacebookIdentityRequest,
|
||||
FacebookIdentityResponse,
|
||||
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 到当前用户
|
||||
*/
|
||||
|
||||
@@ -26,9 +26,19 @@ import { ApiEnvelope, unwrap } from "./response_helper";
|
||||
|
||||
export class PaymentApi {
|
||||
/** 获取 VIP 与 Top-up 套餐列表。 */
|
||||
async getPlans(commercialOfferId?: string): Promise<PaymentPlansResponse> {
|
||||
async getPlans(
|
||||
commercialOfferId?: string,
|
||||
supportCharacterId?: string,
|
||||
): Promise<PaymentPlansResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans, {
|
||||
...(commercialOfferId ? { query: { commercialOfferId } } : {}),
|
||||
...(commercialOfferId || supportCharacterId
|
||||
? {
|
||||
query: {
|
||||
...(commercialOfferId ? { commercialOfferId } : {}),
|
||||
...(supportCharacterId ? { supportCharacterId } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return PaymentPlansResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
|
||||
@@ -17,6 +17,17 @@ describe("payment analytics context", () => {
|
||||
entryPoint: "chat_unlock",
|
||||
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", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ export type PaymentAnalyticsTriggerReason =
|
||||
| "insufficient_credits"
|
||||
| "profile_recharge"
|
||||
| "vip_cta"
|
||||
| "commercial_offer"
|
||||
| "ad_landing"
|
||||
| "manual_recharge"
|
||||
| "unknown";
|
||||
@@ -47,6 +48,7 @@ const TRIGGER_REASONS = new Set<PaymentAnalyticsTriggerReason>([
|
||||
"insufficient_credits",
|
||||
"profile_recharge",
|
||||
"vip_cta",
|
||||
"commercial_offer",
|
||||
"ad_landing",
|
||||
"manual_recharge",
|
||||
"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,38 @@ import { describe, expect, it } from "vitest";
|
||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||
|
||||
import {
|
||||
resolveCheckoutIntentDestination,
|
||||
resolveExternalEntryDestination,
|
||||
resolveExternalEntryPromotionType,
|
||||
resolveExternalEntryTarget,
|
||||
shouldBindExternalFacebookIdentity,
|
||||
} 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",
|
||||
}),
|
||||
).toBe(
|
||||
"/subscription?planId=vip_monthly&autoRenew=1&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", () => {
|
||||
it("defaults to 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 type { PendingChatPromotionType } from "@/data/storage/navigation";
|
||||
import type { LoginStatus } from "@/data/schemas/auth";
|
||||
import type { CheckoutIntent } from "@/data/schemas/auth";
|
||||
import {
|
||||
DEFAULT_CHARACTER_SLUG,
|
||||
getCharacterById,
|
||||
getCharacterBySlug,
|
||||
} from "@/data/constants/character";
|
||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||
@@ -100,6 +102,30 @@ export function resolveExternalEntryDestination({
|
||||
return routes.chat;
|
||||
}
|
||||
|
||||
export function resolveCheckoutIntentDestination(
|
||||
intent: CheckoutIntent,
|
||||
): 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");
|
||||
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({
|
||||
deviceId,
|
||||
asid,
|
||||
@@ -138,6 +164,7 @@ function resolveTarget(
|
||||
return ROUTES.privateZone;
|
||||
}
|
||||
if (target === "topup") return ROUTES.subscription;
|
||||
if (target === "checkout") return ROUTES.subscription;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
getPaymentUrl,
|
||||
getStripeCustomerSessionClientSecret,
|
||||
getStripeClientSecret,
|
||||
isEzpayPayment,
|
||||
launchEzpayRedirect,
|
||||
@@ -43,6 +44,28 @@ describe("payment launch helpers", () => {
|
||||
).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", () => {
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({
|
||||
|
||||
@@ -143,6 +143,27 @@ export function getStripeClientSecret(
|
||||
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 {
|
||||
orderId: string | null;
|
||||
paymentUrl: string;
|
||||
|
||||
@@ -22,6 +22,15 @@ export function parsePaymentPayChannel(
|
||||
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(
|
||||
value: PaymentSearchParamValue,
|
||||
): AppSubscriptionReturnTo {
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface OpenSubscriptionInput {
|
||||
replace?: boolean;
|
||||
analytics?: PaymentAnalyticsContext;
|
||||
chatActionId?: string;
|
||||
planId?: string;
|
||||
commercialOfferId?: string;
|
||||
}
|
||||
|
||||
export interface StartMessageUnlockInput {
|
||||
|
||||
@@ -80,6 +80,8 @@ export function useGlobalAppNavigator(): GlobalAppNavigator {
|
||||
replace: shouldReplace = false,
|
||||
analytics = getDefaultPaymentAnalyticsContext(type),
|
||||
chatActionId,
|
||||
planId,
|
||||
commercialOfferId,
|
||||
}: OpenGlobalSubscriptionInput): void => {
|
||||
const target = ROUTE_BUILDERS.subscription(type, {
|
||||
payChannel,
|
||||
@@ -87,6 +89,8 @@ export function useGlobalAppNavigator(): GlobalAppNavigator {
|
||||
sourceCharacterSlug,
|
||||
analytics,
|
||||
chatActionId,
|
||||
planId,
|
||||
commercialOfferId,
|
||||
});
|
||||
|
||||
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?",
|
||||
ctaLabel: "View gifts",
|
||||
target: "giftCatalog",
|
||||
ruleId: "coffee_support_question",
|
||||
ruleId: "coffee_after_thanks",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -127,7 +127,7 @@ describe("sendResponseToUiMessage", () => {
|
||||
copy: "Buy me a coffee?",
|
||||
ctaLabel: "View gifts",
|
||||
target: "giftCatalog",
|
||||
ruleId: "coffee_support_question",
|
||||
ruleId: "coffee_after_thanks",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,15 @@ export type ChatEvent =
|
||||
}
|
||||
| { type: "ChatOlderHistoryLoadFailed"; error: unknown }
|
||||
| { type: "ChatHistoryRefreshRequested" }
|
||||
| {
|
||||
type: "ChatCommercialMessageReceived";
|
||||
message: {
|
||||
messageId: string;
|
||||
message: string;
|
||||
characterId: string;
|
||||
createdAt: string;
|
||||
};
|
||||
}
|
||||
// Chat domain events.
|
||||
| { type: "ChatSendMessage"; content: 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 { appendCommercialMessage } from "../helper/commercial-message";
|
||||
import { shouldPromptUnlockHistory } from "../helper/unlock";
|
||||
import { createInitialChatState } from "../chat-state";
|
||||
import {
|
||||
@@ -53,6 +54,19 @@ const injectPromotionAction = unlockMachineSetup.assign(({ event }) => {
|
||||
|
||||
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({
|
||||
actions: {
|
||||
startGuestSession: startGuestSessionAction,
|
||||
@@ -60,6 +74,7 @@ export const chatMachineSetup = unlockMachineSetup.extend({
|
||||
clearChatSession: clearChatSessionAction,
|
||||
injectPromotion: injectPromotionAction,
|
||||
clearPromotion: clearPromotionAction,
|
||||
appendCommercialMessage: appendCommercialMessageAction,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -258,6 +273,7 @@ export const chatRootStateConfig = chatMachineSetup.createStateConfig({
|
||||
on: {
|
||||
ChatPromotionInjected: { actions: "injectPromotion" },
|
||||
ChatPromotionCleared: { actions: "clearPromotion" },
|
||||
ChatCommercialMessageReceived: { actions: "appendCommercialMessage" },
|
||||
},
|
||||
states: {
|
||||
idle: idleState,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
|
||||
import {
|
||||
createTestPaymentMachine,
|
||||
giftCatalog,
|
||||
lifetimePlan,
|
||||
monthlyPlan,
|
||||
quarterlyPlan,
|
||||
@@ -51,7 +50,7 @@ describe("payment catalog flow", () => {
|
||||
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();
|
||||
actor.send({
|
||||
type: "PaymentInit",
|
||||
@@ -79,8 +78,9 @@ describe("payment catalog flow", () => {
|
||||
snapshot.context.requestedGiftPlanId === null,
|
||||
);
|
||||
expect(actor.getSnapshot().context.selectedPlanId).toBe(
|
||||
giftCatalog.plans[0]?.planId,
|
||||
"tip_flowers_usd_12_99",
|
||||
);
|
||||
expect(actor.getSnapshot().context.selectedGiftCategory).toBe("flowers");
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -105,6 +105,26 @@ describe("payment order flow", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("binds a default VIP or credit order to the selected support character", async () => {
|
||||
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({ createOrderSpy }),
|
||||
).start();
|
||||
actor.send({ type: "PaymentInit", characterId: "maya-tan" });
|
||||
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: "maya-tan",
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("carries the originating chat action into order creation", async () => {
|
||||
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||
const actor = createActor(
|
||||
|
||||
@@ -136,12 +136,20 @@ export function consumeFirstRechargeState(
|
||||
context: PaymentState,
|
||||
): Pick<
|
||||
PaymentState,
|
||||
"plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
|
||||
| "plans"
|
||||
| "isFirstRecharge"
|
||||
| "commercialOffer"
|
||||
| "commercialOfferId"
|
||||
| "selectedPlanId"
|
||||
| "autoRenew"
|
||||
| "errorMessage"
|
||||
> {
|
||||
const plans = context.plans.map(consumeFirstRechargePlan);
|
||||
return {
|
||||
...refreshPlansState(plans, context.selectedPlanId),
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: null,
|
||||
commercialOfferId: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -50,15 +50,23 @@ export function hydrateGiftProductsState(
|
||||
const giftProducts = response.plans.filter(
|
||||
(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
|
||||
? giftProducts.filter(
|
||||
(product) => product.category === selectedGiftCategory,
|
||||
)
|
||||
: [];
|
||||
const canRestoreSelection =
|
||||
restoredCategory === selectedGiftCategory &&
|
||||
visibleProducts.some((product) => product.planId === restoredPlanId);
|
||||
const canRestoreSelection = visibleProducts.some(
|
||||
(product) => product.planId === restoredPlanId,
|
||||
);
|
||||
const selectedPlanId = canRestoreSelection
|
||||
? (restoredPlanId ?? "")
|
||||
: (visibleProducts[0]?.planId ?? "");
|
||||
|
||||
@@ -8,9 +8,9 @@ import type { PaymentPlanCatalog } from "../../payment-state";
|
||||
|
||||
export const loadCachedPaymentPlansActor = fromPromise<
|
||||
PaymentPlansResponse | null,
|
||||
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null }
|
||||
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null; supportCharacterId?: string | null }
|
||||
>(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();
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data;
|
||||
@@ -18,13 +18,16 @@ export const loadCachedPaymentPlansActor = fromPromise<
|
||||
|
||||
export const refreshPaymentPlansActor = fromPromise<
|
||||
PaymentPlansResponse,
|
||||
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null }
|
||||
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null; supportCharacterId?: string | null }
|
||||
>(async ({ input }) => {
|
||||
const paymentRepo = getPaymentRepository();
|
||||
if (input.catalog === "tip") {
|
||||
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;
|
||||
return result.data;
|
||||
});
|
||||
|
||||
@@ -29,29 +29,45 @@ function initializeCatalogState(
|
||||
planCatalog === "tip"
|
||||
? (event.characterId ?? context.giftCharacterId)
|
||||
: null;
|
||||
const supportCharacterId =
|
||||
planCatalog === "default"
|
||||
? (event.characterId ?? context.supportCharacterId)
|
||||
: null;
|
||||
const catalogChanged = planCatalog !== context.planCatalog;
|
||||
const characterChanged = giftCharacterId !== context.giftCharacterId;
|
||||
const characterChanged =
|
||||
giftCharacterId !== context.giftCharacterId ||
|
||||
supportCharacterId !== context.supportCharacterId;
|
||||
const selectionChanged =
|
||||
planCatalog === "tip" &&
|
||||
((event.category ?? null) !== context.selectedGiftCategory ||
|
||||
(event.planId ?? null) !== (context.selectedPlanId || null));
|
||||
planCatalog === "tip"
|
||||
? (event.category ?? null) !== context.selectedGiftCategory ||
|
||||
(event.planId ?? null) !== (context.selectedPlanId || null)
|
||||
: (event.planId ?? null) !== (context.selectedPlanId || null);
|
||||
const commercialOfferId =
|
||||
planCatalog === "default" ? (event.commercialOfferId ?? null) : null;
|
||||
const offerChanged = commercialOfferId !== context.commercialOfferId;
|
||||
const chatActionId = event.chatActionId ?? null;
|
||||
const chatActionChanged = chatActionId !== context.chatActionId;
|
||||
const initialAutoRenew =
|
||||
planCatalog === "tip" ? false : (event.autoRenew ?? true);
|
||||
const autoRenewChanged = initialAutoRenew !== context.autoRenew;
|
||||
|
||||
return {
|
||||
planCatalog,
|
||||
...(event.payChannel ? { payChannel: event.payChannel } : {}),
|
||||
giftCharacterId,
|
||||
supportCharacterId,
|
||||
commercialOfferId,
|
||||
chatActionId,
|
||||
requestedGiftCategory:
|
||||
planCatalog === "tip" ? (event.category ?? null) : null,
|
||||
requestedGiftPlanId:
|
||||
planCatalog === "tip" ? (event.planId ?? null) : null,
|
||||
...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged
|
||||
...(catalogChanged ||
|
||||
characterChanged ||
|
||||
selectionChanged ||
|
||||
offerChanged ||
|
||||
chatActionChanged ||
|
||||
autoRenewChanged
|
||||
? {
|
||||
...resetOrderState(),
|
||||
plans: [],
|
||||
@@ -62,7 +78,7 @@ function initializeCatalogState(
|
||||
planCatalog === "default" ? (event.planId ?? "") : "",
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: null,
|
||||
autoRenew: planCatalog !== "tip",
|
||||
autoRenew: initialAutoRenew,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
@@ -219,6 +235,7 @@ export const loadingCachedPlansState =
|
||||
input: ({ context }) => ({
|
||||
catalog: context.planCatalog,
|
||||
commercialOfferId: context.commercialOfferId,
|
||||
supportCharacterId: context.supportCharacterId,
|
||||
}),
|
||||
onDone: [
|
||||
{
|
||||
@@ -235,9 +252,10 @@ export const loadingCachedPlansState =
|
||||
export const loadingPlansState = catalogMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
src: "refreshPlans",
|
||||
input: ({ context }) => ({
|
||||
catalog: context.planCatalog,
|
||||
commercialOfferId: context.commercialOfferId,
|
||||
input: ({ context }) => ({
|
||||
catalog: context.planCatalog,
|
||||
commercialOfferId: context.commercialOfferId,
|
||||
supportCharacterId: context.supportCharacterId,
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
@@ -256,6 +274,7 @@ export const refreshingPlansState = catalogMachineSetup.createStateConfig({
|
||||
input: ({ context }) => ({
|
||||
catalog: context.planCatalog,
|
||||
commercialOfferId: context.commercialOfferId,
|
||||
supportCharacterId: context.supportCharacterId,
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
|
||||
@@ -192,8 +192,14 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({
|
||||
payChannel: context.payChannel,
|
||||
autoRenew: context.autoRenew,
|
||||
...(event.type === "PaymentCreateOrderSubmitted" &&
|
||||
event.recipientCharacterId
|
||||
? { recipientCharacterId: event.recipientCharacterId }
|
||||
(event.recipientCharacterId || context.supportCharacterId || context.giftCharacterId)
|
||||
? {
|
||||
recipientCharacterId:
|
||||
event.recipientCharacterId ||
|
||||
context.supportCharacterId ||
|
||||
context.giftCharacterId ||
|
||||
undefined,
|
||||
}
|
||||
: {}),
|
||||
...(context.commercialOfferId
|
||||
? { commercialOfferId: context.commercialOfferId }
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface PaymentContextState {
|
||||
selectedGiftCategory: string | null;
|
||||
isFirstRecharge: MachineContext["isFirstRecharge"];
|
||||
commercialOffer: MachineContext["commercialOffer"];
|
||||
commercialOfferId: string | null;
|
||||
chatActionId: string | null;
|
||||
selectedPlanId: string;
|
||||
payChannel: MachineContext["payChannel"];
|
||||
autoRenew: boolean;
|
||||
@@ -76,6 +78,8 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
|
||||
selectedGiftCategory: state.context.selectedGiftCategory,
|
||||
isFirstRecharge: state.context.isFirstRecharge,
|
||||
commercialOffer: state.context.commercialOffer,
|
||||
commercialOfferId: state.context.commercialOfferId,
|
||||
chatActionId: state.context.chatActionId,
|
||||
selectedPlanId: state.context.selectedPlanId,
|
||||
payChannel: state.context.payChannel,
|
||||
autoRenew: state.context.autoRenew,
|
||||
|
||||
@@ -10,6 +10,7 @@ export type PaymentEvent =
|
||||
characterId?: string;
|
||||
category?: string | null;
|
||||
planId?: string | null;
|
||||
autoRenew?: boolean;
|
||||
commercialOfferId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface PaymentState {
|
||||
giftCategories: readonly GiftCategory[];
|
||||
giftProducts: readonly GiftProduct[];
|
||||
giftCharacterId: string | null;
|
||||
supportCharacterId: string | null;
|
||||
selectedGiftCategory: string | null;
|
||||
requestedGiftCategory: string | null;
|
||||
requestedGiftPlanId: string | null;
|
||||
@@ -45,6 +46,7 @@ export const initialState: PaymentState = {
|
||||
giftCategories: [],
|
||||
giftProducts: [],
|
||||
giftCharacterId: null,
|
||||
supportCharacterId: null,
|
||||
selectedGiftCategory: null,
|
||||
requestedGiftCategory: 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]");
|
||||
});
|
||||
});
|
||||
+49
-16
@@ -107,9 +107,13 @@ export class Logger {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return "";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
||||
return JSON.stringify(
|
||||
Logger.toSerializableLogValue(JSON.parse(trimmed)),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch {
|
||||
return value;
|
||||
return Logger.redactSensitiveString(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,28 +153,31 @@ export class Logger {
|
||||
}
|
||||
|
||||
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()) {
|
||||
Logger.writeBrowserConsole(level, this.component, args);
|
||||
Logger.writeBrowserConsole(level, this.component, safeArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
this.logger.debug(...Logger.normalizeLogArgs(args));
|
||||
this.logger.debug(...Logger.normalizeLogArgs(safeArgs));
|
||||
return;
|
||||
case "info":
|
||||
this.logger.info(...Logger.normalizeLogArgs(args));
|
||||
this.logger.info(...Logger.normalizeLogArgs(safeArgs));
|
||||
return;
|
||||
case "warn":
|
||||
this.logger.warn(...Logger.normalizeLogArgs(args));
|
||||
this.logger.warn(...Logger.normalizeLogArgs(safeArgs));
|
||||
return;
|
||||
case "error":
|
||||
this.logger.error(...Logger.normalizeLogArgs(args));
|
||||
this.logger.error(...Logger.normalizeLogArgs(safeArgs));
|
||||
return;
|
||||
case "fatal":
|
||||
this.logger.fatal(...Logger.normalizeLogArgs(args));
|
||||
this.logger.fatal(...Logger.normalizeLogArgs(safeArgs));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -266,6 +273,10 @@ export class Logger {
|
||||
return value.map((item) => Logger.toSerializableLogValue(item, seen));
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return Logger.redactSensitiveString(value);
|
||||
}
|
||||
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return value;
|
||||
}
|
||||
@@ -281,19 +292,39 @@ export class Logger {
|
||||
|
||||
const output: Record<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
error: Error,
|
||||
seen: WeakSet<object>,
|
||||
): Record<string, unknown> {
|
||||
const output: Record<string, unknown> = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
message: Logger.redactSensitiveString(error.message),
|
||||
stack: error.stack
|
||||
? Logger.redactSensitiveString(error.stack)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (seen.has(error)) {
|
||||
@@ -303,10 +334,12 @@ export class Logger {
|
||||
|
||||
for (const key of Object.getOwnPropertyNames(error)) {
|
||||
if (key in output) continue;
|
||||
output[key] = Logger.toSerializableLogValue(
|
||||
(error as unknown as Record<string, unknown>)[key],
|
||||
seen,
|
||||
);
|
||||
output[key] = Logger.isSensitiveLogKey(key)
|
||||
? "[REDACTED]"
|
||||
: Logger.toSerializableLogValue(
|
||||
(error as unknown as Record<string, unknown>)[key],
|
||||
seen,
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user