feat(payment): share payment method selector

This commit is contained in:
2026-07-17 18:27:24 +08:00
parent 66445a9972
commit b22db6d147
13 changed files with 386 additions and 173 deletions
@@ -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,
]);
}