diff --git a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx new file mode 100644 index 00000000..4925b6a4 --- /dev/null +++ b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx @@ -0,0 +1,267 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const payment = { + status: "ready", + plans: [ + { + planId: "vip_monthly", + planName: "Monthly", + orderType: "vip_monthly", + vipDays: 30, + dolAmount: null, + amountCents: 1990, + originalAmountCents: 3990, + currency: "USD", + }, + { + planId: "vip_quarterly", + planName: "Quarterly", + orderType: "vip_quarterly", + vipDays: 90, + dolAmount: null, + amountCents: 4990, + originalAmountCents: 6990, + currency: "USD", + }, + { + planId: "coin_1000", + planName: "1000 Coins", + orderType: "coins_1000", + vipDays: null, + dolAmount: 1000, + amountCents: 990, + originalAmountCents: null, + currency: "USD", + }, + ], + isFirstRecharge: false, + commercialOffer: null, + selectedPlanId: "vip_monthly", + payChannel: "stripe" as const, + agreed: true, + currentOrderId: null, + isCreatingOrder: false, + isPollingOrder: false, + isLoadingPlans: false, + }; + const paymentDispatch = vi.fn((event: { type: string; planId?: string }) => { + if (event.type === "PaymentPlanSelected" && event.planId) { + payment.selectedPlanId = event.planId; + } + }); + return { payment, paymentDispatch, planClick: vi.fn() }; +}); + +vi.mock("@/app/_components", () => ({ + BackButton: () => , +})); + +vi.mock("@/app/_components/core", () => ({ + MobileShell: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("@/app/_components/payment/payment-method-selector", () => ({ + PaymentMethodSelector: () =>
Payment methods
, +})); + +vi.mock("@/app/_hooks/use-payment-method-selection", () => ({ + usePaymentMethodSelection: () => undefined, +})); + +vi.mock("@/app/_hooks/use-payment-plan-analytics", () => ({ + usePaymentPlanAnalytics: () => undefined, +})); + +vi.mock("@/hooks/use-has-hydrated", () => ({ + useHasHydrated: () => true, +})); + +vi.mock("@/stores/user/user-context", () => ({ + useUserState: () => ({ currentUser: { countryCode: "ID" } }), +})); + +vi.mock("@/lib/payment/payment_method", () => ({ + getPaymentMethodConfig: () => ({ + initialPayChannel: "stripe", + showPaymentMethodSelector: true, + ezpayDisplayName: "QRIS", + }), +})); + +vi.mock("@/lib/analytics", () => ({ + behaviorAnalytics: { planClick: mocks.planClick }, + getDefaultPaymentAnalyticsContext: () => ({}), +})); + +vi.mock("../use-subscription-payment-flow", () => ({ + useSubscriptionPaymentFlow: () => ({ + payment: mocks.payment, + paymentDispatch: mocks.paymentDispatch, + showPaymentSuccessDialog: false, + handleBackClick: vi.fn(), + handlePaymentSuccessClose: vi.fn(), + }), +})); + +vi.mock("../components", () => ({ + SubscriptionVipOfferSection: ({ + plans, + onSelectPlan, + }: { + plans: Array<{ id: string }>; + onSelectPlan: (id: string) => void; + }) => ( +
+ {plans.map((plan) => ( + + ))} +
+ ), + SubscriptionCoinsOfferSection: ({ + plans, + onSelectPlan, + }: { + plans: Array<{ id: string }>; + onSelectPlan: (id: string) => void; + }) => ( +
+ {plans.map((plan) => ( + + ))} +
+ ), + SubscriptionCheckoutButton: ({ disabled }: { disabled: boolean }) => ( + + ), + SubscriptionRenewalConfirmationDialog: ({ + open, + onCancel, + onConfirm, + }: { + open: boolean; + onCancel: () => void; + onConfirm: () => void; + }) => + open ? ( +
+ + +
+ ) : null, + SubscriptionPaymentIssueDialog: () => null, + SubscriptionPaymentSuccessDialog: () => null, +})); + +import { SubscriptionScreen } from "../subscription-screen"; + +describe("SubscriptionScreen payment selection flow", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + mocks.payment.selectedPlanId = "vip_monthly"; + mocks.paymentDispatch.mockClear(); + mocks.planClick.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("confirms VIP selection before enabling the separate checkout button", () => { + act(() => root.render()); + const checkout = container.querySelector( + '[data-testid="checkout"]', + ); + expect(checkout?.disabled).toBe(true); + + act(() => clickButton(container, "vip_monthly")); + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + expect(mocks.paymentDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }), + ); + + act(() => clickButton(container, "Confirm")); + expect(mocks.paymentDispatch).toHaveBeenCalledWith({ + type: "PaymentPlanSelected", + planId: "vip_monthly", + }); + expect(mocks.paymentDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }), + ); + expect(checkout?.disabled).toBe(false); + + act(() => clickButton(container, "vip_monthly")); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + + act(() => clickButton(container, "vip_quarterly")); + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + expect(mocks.payment.selectedPlanId).toBe("vip_monthly"); + }); + + it("keeps the original selection when VIP confirmation is cancelled", () => { + act(() => root.render()); + act(() => clickButton(container, "vip_quarterly")); + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + + act(() => clickButton(container, "Cancel")); + + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(mocks.payment.selectedPlanId).toBe("vip_monthly"); + expect(mocks.paymentDispatch).not.toHaveBeenCalledWith({ + type: "PaymentPlanSelected", + planId: "vip_quarterly", + }); + }); + + it("requires VIP confirmation again after the page is remounted", () => { + act(() => root.render()); + act(() => clickButton(container, "vip_monthly")); + act(() => clickButton(container, "Confirm")); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + + act(() => root.unmount()); + root = createRoot(container); + act(() => root.render()); + act(() => clickButton(container, "vip_monthly")); + + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + }); + + it("selects a coin package without showing the renewal dialog", () => { + act(() => root.render()); + act(() => clickButton(container, "coin_1000")); + + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(mocks.paymentDispatch).toHaveBeenCalledWith({ + type: "PaymentPlanSelected", + planId: "coin_1000", + }); + expect(mocks.paymentDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }), + ); + }); +}); + +function clickButton(root: ParentNode, label: string): void { + const button = Array.from(root.querySelectorAll("button")).find( + (item) => item.textContent?.trim() === label, + ); + if (!button) throw new Error(`Expected ${label} button`); + button.click(); +} diff --git a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts index f73ab57c..ae3f3b46 100644 --- a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts +++ b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts @@ -7,9 +7,11 @@ import { } from "@/data/schemas/payment"; import { + canCheckoutSubscriptionPlan, findSelectedSubscriptionPlan, getDefaultSubscriptionPlanId, getFirstRechargeOfferView, + requiresVipPlanConfirmation, toCoinsOfferPlanViews, toVipOfferPlanViews, } from "../subscription-screen.helpers"; @@ -277,4 +279,86 @@ describe("subscription screen helpers", () => { }), ).toBeNull(); }); + + it("requires confirmation for each unconfirmed VIP plan", () => { + const vipPlans = [ + { + id: "vip_monthly", + title: "Monthly", + price: "19.90", + currency: "usd", + originalPrice: "", + }, + { + id: "vip_quarterly", + title: "Quarterly", + price: "49.90", + currency: "usd", + originalPrice: "", + }, + ]; + + expect( + requiresVipPlanConfirmation({ + planId: "vip_monthly", + vipPlans, + confirmedVipPlanIds: new Set(), + }), + ).toBe(true); + expect( + requiresVipPlanConfirmation({ + planId: "vip_monthly", + vipPlans, + confirmedVipPlanIds: new Set(["vip_monthly"]), + }), + ).toBe(false); + expect( + requiresVipPlanConfirmation({ + planId: "coin_1000", + vipPlans, + confirmedVipPlanIds: new Set(), + }), + ).toBe(false); + }); + + it("allows checkout only after the selected VIP plan is confirmed", () => { + const vipPlans = [ + { + id: "vip_monthly", + title: "Monthly", + price: "19.90", + currency: "usd", + originalPrice: "", + }, + ]; + + expect( + canCheckoutSubscriptionPlan({ + selectedPlanId: "vip_monthly", + vipPlans, + confirmedVipPlanIds: new Set(), + }), + ).toBe(false); + expect( + canCheckoutSubscriptionPlan({ + selectedPlanId: "vip_monthly", + vipPlans, + confirmedVipPlanIds: new Set(["vip_monthly"]), + }), + ).toBe(true); + expect( + canCheckoutSubscriptionPlan({ + selectedPlanId: "coin_1000", + vipPlans, + confirmedVipPlanIds: new Set(), + }), + ).toBe(true); + expect( + canCheckoutSubscriptionPlan({ + selectedPlanId: "", + vipPlans, + confirmedVipPlanIds: new Set(), + }), + ).toBe(false); + }); }); diff --git a/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx b/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx new file mode 100644 index 00000000..942584e7 --- /dev/null +++ b/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx @@ -0,0 +1,224 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Result } from "@/utils/result"; + +import { SubscriptionPaymentIssueDialog } from "../subscription-payment-issue-dialog"; +import { SubscriptionRenewalConfirmationDialog } from "../subscription-renewal-confirmation-dialog"; + +const mocks = vi.hoisted(() => ({ + submitFeedback: vi.fn(), +})); + +vi.mock("@/lib/feedback", () => ({ + submitFeedback: mocks.submitFeedback, +})); + +vi.mock("@/app/feedback/feedback-context", () => ({ + createFeedbackContext: () => ({ + appVersion: "test", + platform: "web", + browser: "Chrome", + viewport: "390x844@3", + }), +})); + +describe("subscription payment dialogs", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + mocks.submitFeedback.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.body.querySelectorAll('[role="dialog"]').forEach((node) => node.remove()); + }); + + it("confirms a VIP plan without submitting payment", () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + act(() => + root.render( + , + ), + ); + + const dialog = document.body.querySelector('[role="dialog"]'); + expect(dialog?.textContent).toContain("Automatic Renewal Confirmation"); + expect(dialog?.textContent).toContain("Monthly"); + expect(dialog?.textContent).toContain("19.90 usd"); + expect(dialog?.querySelectorAll("a")).toHaveLength(2); + + act(() => clickButton(dialog, "Confirm")); + expect(onConfirm).toHaveBeenCalledOnce(); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("submits Other as a Manager payment issue with one description field", async () => { + mocks.submitFeedback.mockResolvedValue( + Result.ok({ feedbackId: "feedback-payment-1" }), + ); + const onClose = vi.fn(); + const onSubmitted = vi.fn(); + + act(() => + root.render( + , + ), + ); + + const other = document.body.querySelector( + 'input[value="other"]', + ); + expect( + Array.from(document.body.querySelectorAll('input[type="radio"]')).map( + (input) => input.parentElement?.textContent?.trim(), + ), + ).toEqual([ + "Concerned about card information security", + "Insufficient card or wallet balance", + "No supported payment method available", + "Too expensive", + "Other", + ]); + act(() => { + other?.click(); + }); + const textarea = document.body.querySelector("textarea"); + expect(textarea?.getAttribute("placeholder")).toBe( + "Please describe the payment problem you encountered.", + ); + act(() => setTextareaValue(textarea, "QRIS did not open correctly.")); + + await act(async () => { + clickButton(document.body, "Submit"); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.submitFeedback).toHaveBeenCalledWith({ + category: "payment", + content: "Payment issue: Other\nDetails: QRIS did not open correctly.", + context: { + appVersion: "test", + platform: "web", + browser: "Chrome", + viewport: "390x844@3", + sourcePage: "subscription", + paymentIssueReason: "other", + subscriptionType: "vip", + planId: "vip_monthly", + payChannel: "ezpay", + countryCode: "ID", + characterId: "maya", + }, + images: [], + idempotencyKey: expect.stringMatching(/^payment_feedback_[A-Za-z0-9_-]+$/), + }); + expect(onSubmitted).toHaveBeenCalledWith( + "Thanks. Your payment issue has been submitted.", + ); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("keeps a failed payment issue open and reuses its idempotency key for retry", async () => { + mocks.submitFeedback + .mockResolvedValueOnce(Result.err(new Error("backend unavailable"))) + .mockResolvedValueOnce(Result.ok({ feedbackId: "feedback-payment-2" })); + const onClose = vi.fn(); + + act(() => + root.render( + undefined} + />, + ), + ); + + const reason = document.body.querySelector( + 'input[value="tooExpensive"]', + ); + act(() => reason?.click()); + await act(async () => { + clickButton(document.body, "Submit"); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain( + "Unable to submit. Please try again.", + ); + expect(onClose).not.toHaveBeenCalled(); + + await act(async () => { + clickButton(document.body, "Submit"); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.submitFeedback).toHaveBeenCalledTimes(2); + expect(mocks.submitFeedback.mock.calls[0]?.[0].idempotencyKey).toBe( + mocks.submitFeedback.mock.calls[1]?.[0].idempotencyKey, + ); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); + +function clickButton(root: ParentNode | null, label: string): void { + const button = Array.from(root?.querySelectorAll("button") ?? []).find( + (item) => item.textContent?.trim() === label, + ); + if (!button) throw new Error(`Expected ${label} button`); + button.click(); +} + +function setTextareaValue( + element: HTMLTextAreaElement | null, + value: string, +): void { + if (!element) throw new Error("Expected textarea"); + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + setter?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); +} diff --git a/src/app/subscription/components/index.ts b/src/app/subscription/components/index.ts index 73cb8368..8d27ca6c 100644 --- a/src/app/subscription/components/index.ts +++ b/src/app/subscription/components/index.ts @@ -6,4 +6,6 @@ export * from "./subscription-checkout-button"; export * from "./subscription-coins-offer-section"; export * from "./subscription-cta-button"; export * from "./subscription-payment-success-dialog"; +export * from "./subscription-payment-issue-dialog"; +export * from "./subscription-renewal-confirmation-dialog"; export * from "./subscription-vip-offer-section"; diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index 12c595b6..25ae8317 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -19,7 +19,7 @@ import { SubscriptionCtaButton } from "./subscription-cta-button"; const log = new Logger("SubscriptionCheckoutButton"); export interface SubscriptionCheckoutButtonProps { - /** 是否可用(未选套餐 / 未勾选协议 → false) */ + /** 是否可用(未选套餐或 VIP 套餐尚未确认自动续费时为 false) */ disabled?: boolean; subscriptionType: "vip" | "topup"; returnTo?: SubscriptionReturnTo; @@ -44,22 +44,13 @@ export function SubscriptionCheckoutButton({ subscriptionType, }); - const isLoading = - payment.isCreatingOrder || - payment.isPollingOrder; - const readyLabel = - subscriptionType === "topup" ? "Pay and Top Up" : "Pay and Activiate"; - const agreementLabel = - subscriptionType === "topup" - ? "Confirm the agreement and Top Up" - : "Confirm the agreement and Activate"; + const isLoading = payment.isCreatingOrder || payment.isPollingOrder; + const readyLabel = "Pay and Top Up"; const label = payment.isPollingOrder - ? "Processing payment..." - : payment.isCreatingOrder - ? "Creating order..." - : payment.agreed - ? readyLabel - : agreementLabel; + ? "Processing payment..." + : payment.isCreatingOrder + ? "Creating order..." + : readyLabel; const handleClick = () => { if (disabled || isLoading) return; diff --git a/src/app/subscription/components/subscription-dialog.module.css b/src/app/subscription/components/subscription-dialog.module.css new file mode 100644 index 00000000..a47a9267 --- /dev/null +++ b/src/app/subscription/components/subscription-dialog.module.css @@ -0,0 +1,187 @@ +.scrim { + position: fixed; + inset: 0; + z-index: 90; + display: flex; + align-items: center; + justify-content: center; + padding: + calc(20px + var(--app-safe-top, 0px)) + calc(16px + var(--app-safe-right, 0px)) + calc(20px + var(--app-safe-bottom, 0px)) + calc(16px + var(--app-safe-left, 0px)); +} + +.panel { + width: min(100%, 420px); + max-height: min(86dvh, 720px); + overflow-y: auto; + border: 1px solid rgba(255, 95, 174, 0.24); + border-radius: 24px; + background: #ffffff; + color: #23171d; + box-shadow: 0 28px 80px rgba(42, 20, 31, 0.3); +} + +.content { + display: flex; + flex-direction: column; + gap: 16px; + padding: clamp(20px, 5vw, 28px); +} + +.title { + margin: 0; + color: #23171d; + font-size: clamp(20px, 5vw, 24px); + font-weight: 900; + line-height: 1.2; +} + +.description { + margin: 0; + color: #65545d; + font-size: 14px; + line-height: 1.65; +} + +.link { + color: #db327f; + font-weight: 800; + text-decoration: underline; + text-underline-offset: 2px; +} + +.reasonList { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + border: 0; +} + +.reasonOption { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 11px 13px; + border: 1px solid #eadce3; + border-radius: 14px; + background: #fffafb; + color: #403139; + cursor: pointer; + font-size: 14px; + font-weight: 700; + line-height: 1.35; +} + +.reasonOption:has(input:checked) { + border-color: #f657a0; + background: #fff0f7; + box-shadow: 0 0 0 2px rgba(246, 87, 160, 0.12); +} + +.reasonOption input { + flex: 0 0 auto; + width: 20px; + height: 20px; + accent-color: #f657a0; +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; + color: #403139; + font-size: 14px; + font-weight: 800; +} + +.field textarea { + width: 100%; + min-height: 112px; + resize: vertical; + border: 1px solid #dccbd4; + border-radius: 14px; + background: #ffffff; + color: #23171d; + padding: 12px; + font: inherit; + font-weight: 500; + line-height: 1.5; +} + +.field textarea:focus-visible { + border-color: #f657a0; + outline: 2px solid rgba(246, 87, 160, 0.18); +} + +.error { + margin: 0; + color: #b42318; + font-size: 13px; + font-weight: 700; + line-height: 1.4; +} + +.actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 2px; +} + +.secondaryButton, +.primaryButton { + min-height: 46px; + border-radius: 999px; + padding: 10px 18px; + cursor: pointer; + font: inherit; + font-size: 15px; + font-weight: 900; +} + +.secondaryButton { + border: 1px solid #d9c7d0; + background: #f7f1f4; + color: #55434c; +} + +.primaryButton { + border: 0; + background: linear-gradient(135deg, #ff67b3 0%, #f657a0 100%); + color: #ffffff; + box-shadow: 0 8px 20px rgba(246, 87, 160, 0.26); +} + +.secondaryButton:disabled, +.primaryButton:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 360px) { + .content { + padding: 18px; + } + + .reasonOption { + font-size: 13px; + } +} diff --git a/src/app/subscription/components/subscription-payment-issue-dialog.tsx b/src/app/subscription/components/subscription-payment-issue-dialog.tsx new file mode 100644 index 00000000..17f6f203 --- /dev/null +++ b/src/app/subscription/components/subscription-payment-issue-dialog.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { type FormEvent, useMemo, useState } from "react"; + +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 type { SubscriptionType } from "../subscription-screen.helpers"; +import styles from "./subscription-dialog.module.css"; + +const PAYMENT_ISSUE_REASONS = [ + { + value: "cardSecurityConcern", + label: "Concerned about card information security", + }, + { + value: "insufficientBalance", + label: "Insufficient card or wallet balance", + }, + { + value: "unsupportedPaymentMethod", + label: "No supported payment method available", + }, + { value: "tooExpensive", label: "Too expensive" }, + { value: "other", label: "Other" }, +] as const; + +type PaymentIssueReason = (typeof PAYMENT_ISSUE_REASONS)[number]["value"]; + +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."; + +export interface SubscriptionPaymentIssueDialogProps { + open: boolean; + subscriptionType: SubscriptionType; + planId: string | null; + orderId: string | null; + payChannel: PayChannel; + countryCode?: string | null; + characterId: string; + onClose: () => void; + onSubmitted: (message: string) => void; +} + +export function SubscriptionPaymentIssueDialog({ + open, + subscriptionType, + planId, + orderId, + payChannel, + countryCode, + characterId, + onClose, + onSubmitted, +}: SubscriptionPaymentIssueDialogProps) { + const [reason, setReason] = useState(null); + const [details, setDetails] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [idempotencyKey] = useState(createIdempotencyKey); + + const normalizedDetails = details.trim(); + const detailsError = useMemo(() => { + if (reason !== "other") return null; + if (normalizedDetails.length < OTHER_DETAILS_MIN_LENGTH) { + return `Please describe the issue in at least ${OTHER_DETAILS_MIN_LENGTH} characters.`; + } + if (normalizedDetails.length > OTHER_DETAILS_MAX_LENGTH) { + return `Your description cannot exceed ${OTHER_DETAILS_MAX_LENGTH} characters.`; + } + return null; + }, [normalizedDetails, reason]); + const canSubmit = reason !== null && detailsError === null && !isSubmitting; + + const handleClose = () => { + if (isSubmitting) return; + onClose(); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!reason || !canSubmit) { + setErrorMessage( + detailsError ?? "Please select the payment problem you encountered.", + ); + return; + } + + const reasonLabel = PAYMENT_ISSUE_REASONS.find( + (item) => item.value === reason, + )?.label; + if (!reasonLabel) return; + + setIsSubmitting(true); + setErrorMessage(null); + const result = await submitFeedback({ + category: "payment", + content: + reason === "other" + ? `Payment issue: Other\nDetails: ${normalizedDetails}` + : `Payment issue: ${reasonLabel}`, + context: { + ...createFeedbackContext(), + sourcePage: "subscription", + paymentIssueReason: reason, + subscriptionType, + ...(planId ? { planId } : {}), + ...(orderId ? { orderId } : {}), + payChannel, + ...(countryCode ? { countryCode } : {}), + characterId, + }, + images: [], + idempotencyKey, + }); + + if (!result.success) { + setErrorMessage(FAILURE_MESSAGE); + setIsSubmitting(false); + return; + } + + setIsSubmitting(false); + onSubmitted(SUCCESS_MESSAGE); + onClose(); + }; + + return ( + +
+

What problem did you encounter?

+
+ Payment problem + {PAYMENT_ISSUE_REASONS.map((item) => ( + + ))} +
+ + {reason === "other" ? ( +