feat(payment): share payment method selector
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PaymentMethodSelector } from "../payment-method-selector";
|
||||
|
||||
describe("PaymentMethodSelector", () => {
|
||||
it("orders the default channel first and renders page analytics", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PaymentMethodSelector
|
||||
value="ezpay"
|
||||
defaultChannel="ezpay"
|
||||
caption="GCash by default"
|
||||
analyticsKey="tip.payment_method"
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-labelledby="payment-method-title"');
|
||||
expect(html).toContain("Payment Method");
|
||||
expect(html).toContain("GCash by default");
|
||||
expect(html.indexOf('aria-label="GCash"')).toBeLessThan(
|
||||
html.indexOf('aria-label="Stripe"'),
|
||||
);
|
||||
const selectedButton = html.match(
|
||||
/<button[^>]*aria-pressed="true"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(selectedButton).toBeDefined();
|
||||
expect(selectedButton).toContain(
|
||||
'data-analytics-key="tip.payment_method"',
|
||||
);
|
||||
expect(selectedButton).toContain("border-[#f657a0]");
|
||||
expect(selectedButton).not.toContain("border-[rgba(246,87,160,0.18)]");
|
||||
expect(selectedButton).toContain("bg-[#fff4f9]");
|
||||
expect(selectedButton).not.toContain("bg-white");
|
||||
expect(selectedButton).toContain("0_0_0_3px_rgba(217,47,127,0.18)");
|
||||
expect(selectedButton).toContain("-translate-y-0.5");
|
||||
expect(html).toContain("/images/subscription/gcash-logo.svg");
|
||||
});
|
||||
|
||||
it("disables both payment methods while payment is busy", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PaymentMethodSelector
|
||||
value="stripe"
|
||||
disabled
|
||||
analyticsKey="subscription.payment_method"
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html.match(/ disabled=""/g)).toHaveLength(2);
|
||||
expect(html).toContain(
|
||||
'data-analytics-key="subscription.payment_method"',
|
||||
);
|
||||
});
|
||||
});
|
||||
+7
-5
@@ -4,7 +4,7 @@ import Image from "next/image";
|
||||
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
|
||||
const PAYMENT_METHODS: {
|
||||
const PAYMENT_METHODS: readonly {
|
||||
channel: PayChannel;
|
||||
title: string;
|
||||
logoSrc?: string;
|
||||
@@ -20,21 +20,23 @@ const PAYMENT_METHODS: {
|
||||
},
|
||||
];
|
||||
|
||||
export interface SubscriptionPaymentMethodProps {
|
||||
export interface PaymentMethodSelectorProps {
|
||||
value: PayChannel;
|
||||
defaultChannel?: PayChannel;
|
||||
disabled?: boolean;
|
||||
caption?: string;
|
||||
analyticsKey: string;
|
||||
onChange: (channel: PayChannel) => void;
|
||||
}
|
||||
|
||||
export function SubscriptionPaymentMethod({
|
||||
export function PaymentMethodSelector({
|
||||
value,
|
||||
defaultChannel = "stripe",
|
||||
disabled = false,
|
||||
caption = "Stripe by default",
|
||||
analyticsKey,
|
||||
onChange,
|
||||
}: SubscriptionPaymentMethodProps) {
|
||||
}: PaymentMethodSelectorProps) {
|
||||
const paymentMethods =
|
||||
defaultChannel === "ezpay"
|
||||
? [...PAYMENT_METHODS].sort((a, b) =>
|
||||
@@ -75,7 +77,7 @@ export function SubscriptionPaymentMethod({
|
||||
<button
|
||||
key={method.channel}
|
||||
type="button"
|
||||
data-analytics-key="subscription.payment_method"
|
||||
data-analytics-key={analyticsKey}
|
||||
data-analytics-label="Select payment method"
|
||||
className={classes}
|
||||
disabled={disabled}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
import type { PaymentMethodConfig } from "@/lib/payment/payment_method";
|
||||
|
||||
import { usePaymentMethodSelection } from "../use-payment-method-selection";
|
||||
|
||||
describe("usePaymentMethodSelection", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("forces Stripe when payment method selection is unavailable", () => {
|
||||
const onChange = vi.fn();
|
||||
renderHarness(root, {
|
||||
config: { canChoosePaymentMethod: false, initialPayChannel: "stripe" },
|
||||
currentPayChannel: "ezpay",
|
||||
onChange,
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("stripe");
|
||||
});
|
||||
|
||||
it("applies the default channel once when no return channel was requested", () => {
|
||||
const onChange = vi.fn();
|
||||
const config = {
|
||||
canChoosePaymentMethod: true,
|
||||
initialPayChannel: "ezpay",
|
||||
} as const;
|
||||
renderHarness(root, {
|
||||
config,
|
||||
currentPayChannel: "stripe",
|
||||
onChange,
|
||||
});
|
||||
renderHarness(root, {
|
||||
config,
|
||||
currentPayChannel: "ezpay",
|
||||
onChange,
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("ezpay");
|
||||
});
|
||||
|
||||
it("preserves an explicit return channel and pauses while payment is busy", () => {
|
||||
const onChange = vi.fn();
|
||||
renderHarness(root, {
|
||||
config: { canChoosePaymentMethod: true, initialPayChannel: "ezpay" },
|
||||
currentPayChannel: "stripe",
|
||||
requestedPayChannel: "stripe",
|
||||
onChange,
|
||||
});
|
||||
renderHarness(root, {
|
||||
config: { canChoosePaymentMethod: false, initialPayChannel: "stripe" },
|
||||
currentPayChannel: "ezpay",
|
||||
isPaymentBusy: true,
|
||||
onChange,
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
interface HarnessProps {
|
||||
config: PaymentMethodConfig;
|
||||
currentPayChannel: PayChannel;
|
||||
requestedPayChannel?: PayChannel | null;
|
||||
isPaymentBusy?: boolean;
|
||||
onChange: (payChannel: PayChannel) => void;
|
||||
}
|
||||
|
||||
function renderHarness(root: Root, props: HarnessProps): void {
|
||||
act(() => {
|
||||
root.render(<Harness {...props} />);
|
||||
});
|
||||
}
|
||||
|
||||
function Harness({
|
||||
config,
|
||||
currentPayChannel,
|
||||
requestedPayChannel = null,
|
||||
isPaymentBusy = false,
|
||||
onChange,
|
||||
}: HarnessProps) {
|
||||
usePaymentMethodSelection({
|
||||
config,
|
||||
currentPayChannel,
|
||||
requestedPayChannel,
|
||||
isPaymentBusy,
|
||||
onChange,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
import type { PaymentMethodConfig } from "@/lib/payment/payment_method";
|
||||
|
||||
export interface UsePaymentMethodSelectionInput {
|
||||
config: PaymentMethodConfig;
|
||||
currentPayChannel: PayChannel;
|
||||
isPaymentBusy: boolean;
|
||||
requestedPayChannel: PayChannel | null;
|
||||
onChange: (payChannel: PayChannel) => void;
|
||||
}
|
||||
|
||||
export function usePaymentMethodSelection({
|
||||
config,
|
||||
currentPayChannel,
|
||||
isPaymentBusy,
|
||||
requestedPayChannel,
|
||||
onChange,
|
||||
}: UsePaymentMethodSelectionInput): void {
|
||||
const appliedDefaultChannelRef = useRef<PayChannel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaymentBusy) return;
|
||||
|
||||
if (!config.canChoosePaymentMethod) {
|
||||
appliedDefaultChannelRef.current = null;
|
||||
if (currentPayChannel !== "stripe") onChange("stripe");
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestedPayChannel !== null) return;
|
||||
if (appliedDefaultChannelRef.current === config.initialPayChannel) return;
|
||||
appliedDefaultChannelRef.current = config.initialPayChannel;
|
||||
|
||||
if (currentPayChannel !== config.initialPayChannel) {
|
||||
onChange(config.initialPayChannel);
|
||||
}
|
||||
}, [
|
||||
config.canChoosePaymentMethod,
|
||||
config.initialPayChannel,
|
||||
currentPayChannel,
|
||||
isPaymentBusy,
|
||||
onChange,
|
||||
requestedPayChannel,
|
||||
]);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PaymentPlanSchema,
|
||||
@@ -7,12 +7,9 @@ import {
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
import {
|
||||
canChooseSubscriptionPayChannel,
|
||||
findSelectedSubscriptionPlan,
|
||||
getDefaultPayChannelForCountryCode,
|
||||
getDefaultSubscriptionPlanId,
|
||||
getFirstRechargeOfferView,
|
||||
resolveSubscriptionPayChannel,
|
||||
toCoinsOfferPlanViews,
|
||||
toVipOfferPlanViews,
|
||||
} from "../subscription-screen.helpers";
|
||||
@@ -34,68 +31,6 @@ function makePlan(overrides: Partial<PaymentPlanInput>): PaymentPlan {
|
||||
}
|
||||
|
||||
describe("subscription screen helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("defaults Philippines users to GCash and other countries to Stripe", () => {
|
||||
expect(getDefaultPayChannelForCountryCode("PH")).toBe("ezpay");
|
||||
expect(getDefaultPayChannelForCountryCode("ph")).toBe("ezpay");
|
||||
expect(getDefaultPayChannelForCountryCode("HK")).toBe("stripe");
|
||||
expect(getDefaultPayChannelForCountryCode("")).toBe("stripe");
|
||||
expect(getDefaultPayChannelForCountryCode(null)).toBe("stripe");
|
||||
});
|
||||
|
||||
it("only allows payment method switching for Philippines users in production", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
|
||||
|
||||
expect(canChooseSubscriptionPayChannel("PH")).toBe(true);
|
||||
expect(canChooseSubscriptionPayChannel("ph")).toBe(true);
|
||||
expect(canChooseSubscriptionPayChannel("HK")).toBe(false);
|
||||
expect(canChooseSubscriptionPayChannel(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows payment method switching in non-production", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
|
||||
|
||||
expect(canChooseSubscriptionPayChannel("HK")).toBe(true);
|
||||
expect(canChooseSubscriptionPayChannel(null)).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves subscription pay channel by country in production", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
|
||||
|
||||
expect(
|
||||
resolveSubscriptionPayChannel({
|
||||
countryCode: "PH",
|
||||
requestedPayChannel: null,
|
||||
}),
|
||||
).toBe("ezpay");
|
||||
expect(
|
||||
resolveSubscriptionPayChannel({
|
||||
countryCode: "PH",
|
||||
requestedPayChannel: "stripe",
|
||||
}),
|
||||
).toBe("stripe");
|
||||
expect(
|
||||
resolveSubscriptionPayChannel({
|
||||
countryCode: "HK",
|
||||
requestedPayChannel: "ezpay",
|
||||
}),
|
||||
).toBe("stripe");
|
||||
});
|
||||
|
||||
it("keeps requested pay channel in non-production", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
|
||||
|
||||
expect(
|
||||
resolveSubscriptionPayChannel({
|
||||
countryCode: "HK",
|
||||
requestedPayChannel: "ezpay",
|
||||
}),
|
||||
).toBe("ezpay");
|
||||
});
|
||||
|
||||
it("maps at most three VIP plans and all coin plans", () => {
|
||||
const plans = [
|
||||
makePlan({ planId: "vip_monthly", planName: "Monthly" }),
|
||||
|
||||
@@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { StripePaymentDialog } from "@/app/_components/payment/stripe-payment-dialog";
|
||||
|
||||
import { SubscriptionCtaButton } from "../subscription-cta-button";
|
||||
import { SubscriptionPaymentMethod } from "../subscription-payment-method";
|
||||
import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog";
|
||||
import { SubscriptionCoinsOfferSection } from "../subscription-coins-offer-section";
|
||||
import { SubscriptionVipOfferSection } from "../subscription-vip-offer-section";
|
||||
@@ -49,38 +48,6 @@ describe("subscription Tailwind components", () => {
|
||||
expect(html).toContain("OK");
|
||||
});
|
||||
|
||||
it("renders SubscriptionPaymentMethod with default ordering and selection", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SubscriptionPaymentMethod
|
||||
value="ezpay"
|
||||
defaultChannel="ezpay"
|
||||
caption="GCash by default"
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-labelledby="payment-method-title"');
|
||||
expect(html).toContain("Payment Method");
|
||||
expect(html).toContain("GCash by default");
|
||||
expect(html.indexOf('aria-label="GCash"')).toBeLessThan(
|
||||
html.indexOf('aria-label="Stripe"'),
|
||||
);
|
||||
const selectedButton = html.match(
|
||||
/<button[^>]*aria-pressed="true"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(selectedButton).toBeDefined();
|
||||
expect(selectedButton).toContain(
|
||||
'data-analytics-key="subscription.payment_method"',
|
||||
);
|
||||
expect(selectedButton).toContain("border-[#f657a0]");
|
||||
expect(selectedButton).not.toContain("border-[rgba(246,87,160,0.18)]");
|
||||
expect(selectedButton).toContain("bg-[#fff4f9]");
|
||||
expect(selectedButton).not.toContain("bg-white");
|
||||
expect(selectedButton).toContain("0_0_0_3px_rgba(217,47,127,0.18)");
|
||||
expect(selectedButton).toContain("-translate-y-0.5");
|
||||
expect(html).toContain("/images/subscription/gcash-logo.svg");
|
||||
});
|
||||
|
||||
it("renders StripePaymentDialog fallback with Tailwind dialog utilities", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StripePaymentDialog
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
export * from "./subscription-checkout-button";
|
||||
export * from "./subscription-coins-offer-section";
|
||||
export * from "./subscription-cta-button";
|
||||
export * from "./subscription-payment-method";
|
||||
export * from "./subscription-payment-success-dialog";
|
||||
export * from "./subscription-vip-offer-section";
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type { PayChannel, PaymentPlan } from "@/data/schemas/payment";
|
||||
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
||||
import { AppEnvUtil } from "@/utils/app-env";
|
||||
export { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
||||
import type { PaymentPlan } from "@/data/schemas/payment";
|
||||
|
||||
import type { CoinsOfferPlanView } from "./components/subscription-coins-offer-section";
|
||||
import type { VipOfferPlanView } from "./components/subscription-vip-offer-section";
|
||||
@@ -15,24 +12,6 @@ export interface FirstRechargeOfferView {
|
||||
renewalNotice: string | null;
|
||||
}
|
||||
|
||||
export function canChooseSubscriptionPayChannel(
|
||||
countryCode: string | null | undefined,
|
||||
): boolean {
|
||||
if (!AppEnvUtil.isProduction()) return true;
|
||||
return countryCode?.trim().toUpperCase() === "PH";
|
||||
}
|
||||
|
||||
export function resolveSubscriptionPayChannel(input: {
|
||||
countryCode: string | null | undefined;
|
||||
requestedPayChannel: PayChannel | null | undefined;
|
||||
}): PayChannel {
|
||||
if (!canChooseSubscriptionPayChannel(input.countryCode)) return "stripe";
|
||||
return (
|
||||
input.requestedPayChannel ??
|
||||
getDefaultPayChannelForCountryCode(input.countryCode)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVipPlan(plan: PaymentPlan): boolean {
|
||||
return plan.vipDays !== null;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import { BackButton } from "@/app/_components";
|
||||
import { Checkbox, MobileShell } from "@/app/_components/core";
|
||||
import { PaymentMethodSelector } from "@/app/_components/payment/payment-method-selector";
|
||||
import { usePaymentMethodSelection } from "@/app/_hooks/use-payment-method-selection";
|
||||
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
|
||||
import { AppConstants } from "@/core/app_constants";
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
@@ -13,22 +15,20 @@ import {
|
||||
getDefaultPaymentAnalyticsContext,
|
||||
type PaymentAnalyticsContext,
|
||||
} from "@/lib/analytics";
|
||||
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
|
||||
import {
|
||||
SubscriptionCheckoutButton,
|
||||
SubscriptionCoinsOfferSection,
|
||||
SubscriptionPaymentMethod,
|
||||
SubscriptionPaymentSuccessDialog,
|
||||
SubscriptionVipOfferSection,
|
||||
} from "./components";
|
||||
import styles from "./components/subscription-screen.module.css";
|
||||
import {
|
||||
findSelectedSubscriptionPlan,
|
||||
canChooseSubscriptionPayChannel,
|
||||
getFirstRechargeOfferView,
|
||||
getDefaultSubscriptionPlanId,
|
||||
resolveSubscriptionPayChannel,
|
||||
toCoinsOfferPlanViews,
|
||||
toVipOfferPlanViews,
|
||||
type SubscriptionType,
|
||||
@@ -56,13 +56,10 @@ export function SubscriptionScreen({
|
||||
}: SubscriptionScreenProps) {
|
||||
const userState = useUserState();
|
||||
const countryCode = userState.currentUser?.countryCode;
|
||||
const canChoosePaymentMethod =
|
||||
canChooseSubscriptionPayChannel(countryCode);
|
||||
const resolvedInitialPayChannel = resolveSubscriptionPayChannel({
|
||||
const paymentMethodConfig = getPaymentMethodConfig({
|
||||
countryCode,
|
||||
requestedPayChannel: initialPayChannel,
|
||||
});
|
||||
const phDefaultPayChannelAppliedRef = useRef(false);
|
||||
const {
|
||||
payment,
|
||||
paymentDispatch,
|
||||
@@ -74,7 +71,7 @@ export function SubscriptionScreen({
|
||||
shouldResumePendingOrder,
|
||||
returnTo,
|
||||
characterSlug,
|
||||
initialPayChannel: resolvedInitialPayChannel,
|
||||
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
||||
});
|
||||
const canSubscribeVip = subscriptionType === "vip";
|
||||
const analyticsContext =
|
||||
@@ -134,35 +131,20 @@ export function SubscriptionScreen({
|
||||
paymentDispatch({ type: "PaymentPlanSelected", planId });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaymentBusy) return;
|
||||
|
||||
if (!canChoosePaymentMethod) {
|
||||
if (payment.payChannel === "stripe") return;
|
||||
paymentDispatch({
|
||||
type: "PaymentPayChannelChanged",
|
||||
payChannel: "stripe",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialPayChannel !== null) return;
|
||||
if (phDefaultPayChannelAppliedRef.current) return;
|
||||
phDefaultPayChannelAppliedRef.current = true;
|
||||
|
||||
if (payment.payChannel === resolvedInitialPayChannel) return;
|
||||
const handlePaymentMethodChange = (payChannel: PayChannel) => {
|
||||
paymentDispatch({
|
||||
type: "PaymentPayChannelChanged",
|
||||
payChannel: resolvedInitialPayChannel,
|
||||
payChannel,
|
||||
});
|
||||
}, [
|
||||
canChoosePaymentMethod,
|
||||
initialPayChannel,
|
||||
};
|
||||
|
||||
usePaymentMethodSelection({
|
||||
config: paymentMethodConfig,
|
||||
currentPayChannel: payment.payChannel,
|
||||
isPaymentBusy,
|
||||
payment.payChannel,
|
||||
paymentDispatch,
|
||||
resolvedInitialPayChannel,
|
||||
]);
|
||||
requestedPayChannel: initialPayChannel,
|
||||
onChange: handlePaymentMethodChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const defaultPlanId = getDefaultSubscriptionPlanId({
|
||||
@@ -242,19 +224,15 @@ export function SubscriptionScreen({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canChoosePaymentMethod ? (
|
||||
{paymentMethodConfig.canChoosePaymentMethod ? (
|
||||
<section className={styles.paymentMethodSlot}>
|
||||
<SubscriptionPaymentMethod
|
||||
<PaymentMethodSelector
|
||||
value={payment.payChannel}
|
||||
defaultChannel={resolvedInitialPayChannel}
|
||||
defaultChannel={paymentMethodConfig.initialPayChannel}
|
||||
disabled={isPaymentBusy}
|
||||
caption="GCash by default in the Philippines"
|
||||
onChange={(payChannel) => {
|
||||
paymentDispatch({
|
||||
type: "PaymentPayChannelChanged",
|
||||
payChannel,
|
||||
});
|
||||
}}
|
||||
analyticsKey="subscription.payment_method"
|
||||
onChange={handlePaymentMethodChange}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
.header,
|
||||
.hero,
|
||||
.productCard,
|
||||
.paymentMethodSlot,
|
||||
.statusMessage,
|
||||
.successCard,
|
||||
.checkoutSlot {
|
||||
@@ -407,6 +408,10 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.paymentMethodSlot {
|
||||
margin-top: clamp(18px, 5.185vw, 28px);
|
||||
}
|
||||
|
||||
.successCard {
|
||||
justify-content: flex-start;
|
||||
border-color: rgba(255, 84, 135, 0.16);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ArrowLeft, Heart, Sparkles } from "lucide-react";
|
||||
|
||||
import { CharacterAvatar } from "@/app/_components";
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
import { PaymentMethodSelector } from "@/app/_components/payment/payment-method-selector";
|
||||
import { usePaymentMethodSelection } from "@/app/_hooks/use-payment-method-selection";
|
||||
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
|
||||
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
behaviorAnalytics,
|
||||
type PaymentAnalyticsContext,
|
||||
} from "@/lib/analytics";
|
||||
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
|
||||
import {
|
||||
buildTipCoffeePath,
|
||||
DEFAULT_TIP_COFFEE_TYPE,
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
useActiveCharacterRoutes,
|
||||
} from "@/providers/character-provider";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
|
||||
import { TipCheckoutButton } from "./tip-checkout-button";
|
||||
import {
|
||||
@@ -59,6 +63,11 @@ export function TipScreen({
|
||||
const character = useActiveCharacter();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const authState = useAuthState();
|
||||
const userState = useUserState();
|
||||
const paymentMethodConfig = getPaymentMethodConfig({
|
||||
countryCode: userState.currentUser?.countryCode,
|
||||
requestedPayChannel: initialPayChannel,
|
||||
});
|
||||
const [selectedCoffeeType, setSelectedCoffeeType] =
|
||||
useState<TipCoffeeType>(coffeeType);
|
||||
const coffeeOption = getTipCoffeeOption(selectedCoffeeType);
|
||||
@@ -66,11 +75,9 @@ export function TipScreen({
|
||||
selectedCoffeeType,
|
||||
characterRoutes.tip,
|
||||
);
|
||||
const resolvedInitialPayChannel =
|
||||
initialPayChannel ?? navigator.getDefaultPayChannel();
|
||||
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
||||
catalog: "tip",
|
||||
initialPayChannel: resolvedInitialPayChannel,
|
||||
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
||||
paymentType: "tip",
|
||||
shouldResumePendingOrder,
|
||||
});
|
||||
@@ -120,6 +127,21 @@ export function TipScreen({
|
||||
payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy;
|
||||
const isAuthLoading = !authState.hasInitialized || authState.isLoading;
|
||||
|
||||
const handlePaymentMethodChange = (payChannel: PayChannel) => {
|
||||
paymentDispatch({
|
||||
type: "PaymentPayChannelChanged",
|
||||
payChannel,
|
||||
});
|
||||
};
|
||||
|
||||
usePaymentMethodSelection({
|
||||
config: paymentMethodConfig,
|
||||
currentPayChannel: payment.payChannel,
|
||||
isPaymentBusy,
|
||||
requestedPayChannel: initialPayChannel,
|
||||
onChange: handlePaymentMethodChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (payment.isLoadingPlans || payment.isCreatingOrder || payment.isPollingOrder) {
|
||||
return;
|
||||
@@ -270,6 +292,19 @@ export function TipScreen({
|
||||
/>
|
||||
</section>
|
||||
|
||||
{paymentMethodConfig.canChoosePaymentMethod ? (
|
||||
<section className={styles.paymentMethodSlot}>
|
||||
<PaymentMethodSelector
|
||||
value={payment.payChannel}
|
||||
defaultChannel={paymentMethodConfig.initialPayChannel}
|
||||
disabled={isPaymentBusy}
|
||||
caption="GCash by default in the Philippines"
|
||||
analyticsKey="tip.payment_method"
|
||||
onChange={handlePaymentMethodChange}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{showMissingPlan ? (
|
||||
<p className={styles.statusMessage} role="alert">
|
||||
Coffee tip is not available yet. Please try again later.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getDefaultPayChannelForCountryCode } from "../default_pay_channel";
|
||||
import {
|
||||
canChoosePayChannel,
|
||||
getPaymentMethodConfig,
|
||||
resolvePayChannel,
|
||||
} from "../payment_method";
|
||||
|
||||
describe("payment method rules", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("defaults Philippines users to GCash and other countries to Stripe", () => {
|
||||
expect(getDefaultPayChannelForCountryCode("PH")).toBe("ezpay");
|
||||
expect(getDefaultPayChannelForCountryCode("ph")).toBe("ezpay");
|
||||
expect(getDefaultPayChannelForCountryCode("HK")).toBe("stripe");
|
||||
expect(getDefaultPayChannelForCountryCode(null)).toBe("stripe");
|
||||
});
|
||||
|
||||
it("only allows Philippines users to choose in production", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
|
||||
|
||||
expect(canChoosePayChannel("PH")).toBe(true);
|
||||
expect(canChoosePayChannel("ph")).toBe(true);
|
||||
expect(canChoosePayChannel("HK")).toBe(false);
|
||||
expect(canChoosePayChannel(null)).toBe(false);
|
||||
expect(
|
||||
getPaymentMethodConfig({
|
||||
countryCode: null,
|
||||
requestedPayChannel: "ezpay",
|
||||
}),
|
||||
).toEqual({
|
||||
canChoosePaymentMethod: false,
|
||||
initialPayChannel: "stripe",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves requested and default channels for Philippines", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
|
||||
|
||||
expect(
|
||||
resolvePayChannel({ countryCode: "PH", requestedPayChannel: null }),
|
||||
).toBe("ezpay");
|
||||
expect(
|
||||
resolvePayChannel({
|
||||
countryCode: "PH",
|
||||
requestedPayChannel: "stripe",
|
||||
}),
|
||||
).toBe("stripe");
|
||||
});
|
||||
|
||||
it("allows every user and keeps the requested channel outside production", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
|
||||
|
||||
expect(canChoosePayChannel(null)).toBe(true);
|
||||
expect(
|
||||
resolvePayChannel({
|
||||
countryCode: "HK",
|
||||
requestedPayChannel: "ezpay",
|
||||
}),
|
||||
).toBe("ezpay");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
import { AppEnvUtil } from "@/utils/app-env";
|
||||
|
||||
import { getDefaultPayChannelForCountryCode } from "./default_pay_channel";
|
||||
|
||||
export interface PaymentMethodConfig {
|
||||
canChoosePaymentMethod: boolean;
|
||||
initialPayChannel: PayChannel;
|
||||
}
|
||||
|
||||
export function canChoosePayChannel(
|
||||
countryCode: string | null | undefined,
|
||||
): boolean {
|
||||
if (!AppEnvUtil.isProduction()) return true;
|
||||
return countryCode?.trim().toUpperCase() === "PH";
|
||||
}
|
||||
|
||||
export function resolvePayChannel(input: {
|
||||
countryCode: string | null | undefined;
|
||||
requestedPayChannel: PayChannel | null | undefined;
|
||||
}): PayChannel {
|
||||
if (!canChoosePayChannel(input.countryCode)) return "stripe";
|
||||
return (
|
||||
input.requestedPayChannel ??
|
||||
getDefaultPayChannelForCountryCode(input.countryCode)
|
||||
);
|
||||
}
|
||||
|
||||
export function getPaymentMethodConfig(input: {
|
||||
countryCode: string | null | undefined;
|
||||
requestedPayChannel: PayChannel | null | undefined;
|
||||
}): PaymentMethodConfig {
|
||||
return {
|
||||
canChoosePaymentMethod: canChoosePayChannel(input.countryCode),
|
||||
initialPayChannel: resolvePayChannel(input),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user