fix(payment): hide first recharge banner
Docker Image / Build and Push Docker Image (push) Successful in 2m11s

This commit is contained in:
Codex
2026-07-28 11:10:51 +08:00
parent a2cf62f78b
commit a530850039
4 changed files with 123 additions and 28 deletions
@@ -170,6 +170,13 @@ describe("SubscriptionScreen payment selection flow", () => {
beforeEach(() => { beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true; .IS_REACT_ACT_ENVIRONMENT = true;
mocks.payment.isFirstRecharge = false;
mocks.payment.commercialOffer = null;
for (const plan of mocks.payment.plans) {
delete (plan as Record<string, unknown>).isFirstRechargeOffer;
delete (plan as Record<string, unknown>).firstRechargeDiscountPercent;
delete (plan as Record<string, unknown>).promotionType;
}
mocks.payment.selectedPlanId = "vip_monthly"; mocks.payment.selectedPlanId = "vip_monthly";
mocks.paymentDispatch.mockClear(); mocks.paymentDispatch.mockClear();
mocks.planClick.mockClear(); mocks.planClick.mockClear();
@@ -275,6 +282,22 @@ describe("SubscriptionScreen payment selection flow", () => {
expect(guidance?.textContent).toContain("cannot continue"); expect(guidance?.textContent).toContain("cannot continue");
expect(guidance?.textContent).not.toContain("Elio"); expect(guidance?.textContent).not.toContain("Elio");
}); });
it("does not render the first recharge banner on the subscription page", () => {
mocks.payment.isFirstRecharge = true;
Object.assign(mocks.payment.plans[0] as Record<string, unknown>, {
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
promotionType: "first_recharge_half_price",
});
act(() => root.render(<SubscriptionScreen />));
expect(container.textContent).not.toContain("First Recharge Offer");
expect(container.textContent).not.toContain(
"Your first recharge price is already applied",
);
});
}); });
function clickButton(root: ParentNode, label: string): void { 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
import { ApiError } from "@/data/services/api/api_result";
import { SubscriptionPaymentIssueDialog } from "../subscription-payment-issue-dialog"; import {
getPaymentIssueSubmitErrorLogDetails,
SubscriptionPaymentIssueDialog,
} from "../subscription-payment-issue-dialog";
import { SubscriptionRenewalConfirmationDialog } from "../subscription-renewal-confirmation-dialog"; import { SubscriptionRenewalConfirmationDialog } from "../subscription-renewal-confirmation-dialog";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
@@ -200,6 +204,28 @@ describe("subscription payment dialogs", () => {
); );
expect(onClose).toHaveBeenCalledOnce(); expect(onClose).toHaveBeenCalledOnce();
}); });
it("keeps HTTP status and backend error code in payment issue failure diagnostics", () => {
const failure = Result.err(
new ApiError("HTTP_ERROR", "Invalid feedback context", 400, {
detail: {
message: "Invalid feedback context",
errorCode: "INVALID_FEEDBACK_CONTEXT",
},
}),
);
if (failure.success) throw new Error("Expected failure result");
expect(getPaymentIssueSubmitErrorLogDetails(failure.error)).toMatchObject({
code: "UNKNOWN",
message: "Invalid feedback context",
causeName: "ApiError",
causeMessage: "Invalid feedback context",
httpStatus: 400,
apiCode: "HTTP_ERROR",
serverErrorCode: "INVALID_FEEDBACK_CONTEXT",
});
});
}); });
function clickButton(root: ParentNode | null, label: string): void { function clickButton(root: ParentNode | null, label: string): void {
@@ -6,6 +6,7 @@ import { ModalPortal } from "@/app/_components/core/modal-portal";
import { createFeedbackContext } from "@/app/feedback/feedback-context"; import { createFeedbackContext } from "@/app/feedback/feedback-context";
import type { PayChannel } from "@/data/schemas/payment"; import type { PayChannel } from "@/data/schemas/payment";
import { submitFeedback } from "@/lib/feedback"; import { submitFeedback } from "@/lib/feedback";
import { Logger } from "@/utils/logger";
import type { SubscriptionType } from "../subscription-screen.helpers"; import type { SubscriptionType } from "../subscription-screen.helpers";
import styles from "./subscription-dialog.module.css"; import styles from "./subscription-dialog.module.css";
@@ -33,6 +34,21 @@ const OTHER_DETAILS_MIN_LENGTH = 10;
const OTHER_DETAILS_MAX_LENGTH = 2000; const OTHER_DETAILS_MAX_LENGTH = 2000;
const SUCCESS_MESSAGE = "Thanks. Your payment issue has been submitted."; const SUCCESS_MESSAGE = "Thanks. Your payment issue has been submitted.";
const FAILURE_MESSAGE = "Unable to submit. Please try again."; const FAILURE_MESSAGE = "Unable to submit. Please try again.";
const log = new Logger("SubscriptionPaymentIssueDialog");
interface ErrorRecord {
readonly [key: string]: unknown;
}
export interface PaymentIssueSubmitErrorLogDetails {
code: string;
message: string;
causeName?: string;
causeMessage?: string;
httpStatus?: number;
apiCode?: string;
serverErrorCode?: string;
}
export interface SubscriptionPaymentIssueDialogProps { export interface SubscriptionPaymentIssueDialogProps {
open: boolean; open: boolean;
@@ -119,6 +135,19 @@ export function SubscriptionPaymentIssueDialog({
}); });
if (!result.success) { if (!result.success) {
log.error(
{
...getPaymentIssueSubmitErrorLogDetails(result.error),
paymentIssueReason: reason,
subscriptionType,
planId,
orderId,
payChannel,
countryCode,
characterId,
},
"Payment issue feedback submit failed",
);
setErrorMessage(FAILURE_MESSAGE); setErrorMessage(FAILURE_MESSAGE);
setIsSubmitting(false); setIsSubmitting(false);
return; return;
@@ -206,6 +235,32 @@ export function SubscriptionPaymentIssueDialog({
); );
} }
export function getPaymentIssueSubmitErrorLogDetails(
error: Error,
): PaymentIssueSubmitErrorLogDetails {
const errorRecord = toErrorRecord(error);
const cause = toErrorRecord(errorRecord?.cause);
const responseDetails = toErrorRecord(cause?.details);
const detail = toErrorRecord(responseDetails?.detail);
const causeName = stringValue(cause?.name);
const causeMessage = stringValue(cause?.message);
const httpStatus = numberValue(cause?.status);
const apiCode = stringValue(cause?.code);
const serverErrorCode =
stringValue(detail?.errorCode) ??
stringValue(responseDetails?.errorCode);
return {
code: stringValue(errorRecord?.code) ?? "UNKNOWN",
message: error.message,
...(causeName ? { causeName } : {}),
...(causeMessage ? { causeMessage } : {}),
...(httpStatus !== undefined ? { httpStatus } : {}),
...(apiCode ? { apiCode } : {}),
...(serverErrorCode ? { serverErrorCode } : {}),
};
}
function createIdempotencyKey(): string { function createIdempotencyKey(): string {
const token = const token =
typeof crypto !== "undefined" && "randomUUID" in crypto typeof crypto !== "undefined" && "randomUUID" in crypto
@@ -213,3 +268,18 @@ function createIdempotencyKey(): string {
: `${Date.now()}_${Math.random().toString(36).slice(2)}`; : `${Date.now()}_${Math.random().toString(36).slice(2)}`;
return `payment_feedback_${token}`; return `payment_feedback_${token}`;
} }
function toErrorRecord(value: unknown): ErrorRecord | null {
if (typeof value !== "object" || value === null) return null;
return value as ErrorRecord;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
+3 -27
View File
@@ -131,14 +131,14 @@ export function SubscriptionScreen({
}); });
}, [canSubscribeVip, directCoinsPlans, payment.plans, vipOfferPlans]); }, [canSubscribeVip, directCoinsPlans, payment.plans, vipOfferPlans]);
usePaymentPlanAnalytics(displayedPlans, analyticsContext); usePaymentPlanAnalytics(displayedPlans, analyticsContext);
const firstRechargeOffer = useMemo( const hasFirstRechargeOffer = useMemo(
() => () =>
getFirstRechargeOfferView({ getFirstRechargeOfferView({
isFirstRecharge: payment.isFirstRecharge, isFirstRecharge: payment.isFirstRecharge,
subscriptionType, subscriptionType,
vipPlans: vipOfferPlans, vipPlans: vipOfferPlans,
coinPlans: directCoinsPlans, coinPlans: directCoinsPlans,
}), }) !== null,
[ [
directCoinsPlans, directCoinsPlans,
payment.isFirstRecharge, payment.isFirstRecharge,
@@ -284,31 +284,7 @@ export function SubscriptionScreen({
</section> </section>
) : null} ) : null}
{firstRechargeOffer ? ( {payment.commercialOffer && !hasFirstRechargeOffer ? (
<section
className={styles.firstRechargeBanner}
aria-label="First recharge offer"
>
<span className={styles.firstRechargeBadge}>
{firstRechargeOffer.badgeText}
</span>
<div className={styles.firstRechargeCopy}>
<h2 className={styles.firstRechargeTitle}>
{firstRechargeOffer.title}
</h2>
<p className={styles.firstRechargeSubtitle}>
{firstRechargeOffer.subtitle}
</p>
{firstRechargeOffer.renewalNotice ? (
<p className={styles.firstRechargeRenewalNotice}>
{firstRechargeOffer.renewalNotice}
</p>
) : null}
</div>
</section>
) : null}
{payment.commercialOffer && !firstRechargeOffer ? (
<section <section
className={styles.firstRechargeBanner} className={styles.firstRechargeBanner}
aria-label={`${sourceCharacter.shortName} private offer`} aria-label={`${sourceCharacter.shortName} private offer`}