fix(payment): hide first recharge banner
Docker Image / Build and Push Docker Image (push) Successful in 2m11s
Docker Image / Build and Push Docker Image (push) Successful in 2m11s
This commit is contained in:
@@ -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<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.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<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 {
|
||||
|
||||
@@ -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 { 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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</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 ? (
|
||||
<section
|
||||
className={styles.firstRechargeBanner}
|
||||
aria-label={`${sourceCharacter.shortName} private offer`}
|
||||
|
||||
Reference in New Issue
Block a user