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(() => {
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
|||||||
Reference in New Issue
Block a user