From 995fdbda5707929802575b5fa2a10699b3b4a065 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 11:10:51 +0800 Subject: [PATCH] fix(payment): hide first recharge banner --- .../subscription-screen-flow.test.tsx | 23 ++++++ .../subscription-payment-dialogs.test.tsx | 28 +++++++- .../subscription-payment-issue-dialog.tsx | 70 +++++++++++++++++++ src/app/subscription/subscription-screen.tsx | 30 +------- 4 files changed, 123 insertions(+), 28 deletions(-) diff --git a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx index 51129ad1..f7a4bb0f 100644 --- a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx +++ b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx @@ -170,6 +170,13 @@ 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).isFirstRechargeOffer; + delete (plan as Record).firstRechargeDiscountPercent; + delete (plan as Record).promotionType; + } mocks.payment.selectedPlanId = "vip_monthly"; mocks.paymentDispatch.mockClear(); mocks.planClick.mockClear(); @@ -275,6 +282,22 @@ 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, { + isFirstRechargeOffer: true, + firstRechargeDiscountPercent: 50, + promotionType: "first_recharge_half_price", + }); + + act(() => root.render()); + + 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 { diff --git a/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx b/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx index 942584e7..f9c69c8e 100644 --- a/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx +++ b/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx @@ -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 { diff --git a/src/app/subscription/components/subscription-payment-issue-dialog.tsx b/src/app/subscription/components/subscription-payment-issue-dialog.tsx index 17f6f203..6aeb32bd 100644 --- a/src/app/subscription/components/subscription-payment-issue-dialog.tsx +++ b/src/app/subscription/components/subscription-payment-issue-dialog.tsx @@ -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; +} diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 7b8e3b37..036c59af 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -131,14 +131,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, @@ -284,31 +284,7 @@ export function SubscriptionScreen({ ) : null} - {firstRechargeOffer ? ( -
- - {firstRechargeOffer.badgeText} - -
-

- {firstRechargeOffer.title} -

-

- {firstRechargeOffer.subtitle} -

- {firstRechargeOffer.renewalNotice ? ( -

- {firstRechargeOffer.renewalNotice} -

- ) : null} -
-
- ) : null} - - {payment.commercialOffer && !firstRechargeOffer ? ( + {payment.commercialOffer && !hasFirstRechargeOffer ? (