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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
|
||||
const PAYMENT_METHODS: readonly {
|
||||
channel: PayChannel;
|
||||
title: string;
|
||||
logoSrc?: string;
|
||||
}[] = [
|
||||
{
|
||||
channel: "stripe",
|
||||
title: "Stripe",
|
||||
},
|
||||
{
|
||||
channel: "ezpay",
|
||||
title: "GCash",
|
||||
logoSrc: "/images/subscription/gcash-logo.svg",
|
||||
},
|
||||
];
|
||||
|
||||
export interface PaymentMethodSelectorProps {
|
||||
value: PayChannel;
|
||||
defaultChannel?: PayChannel;
|
||||
disabled?: boolean;
|
||||
caption?: string;
|
||||
analyticsKey: string;
|
||||
onChange: (channel: PayChannel) => void;
|
||||
}
|
||||
|
||||
export function PaymentMethodSelector({
|
||||
value,
|
||||
defaultChannel = "stripe",
|
||||
disabled = false,
|
||||
caption = "Stripe by default",
|
||||
analyticsKey,
|
||||
onChange,
|
||||
}: PaymentMethodSelectorProps) {
|
||||
const paymentMethods =
|
||||
defaultChannel === "ezpay"
|
||||
? [...PAYMENT_METHODS].sort((a, b) =>
|
||||
a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0,
|
||||
)
|
||||
: PAYMENT_METHODS;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-[clamp(8px,1.852vw,10px)]"
|
||||
aria-labelledby="payment-method-title"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-sm px-xs">
|
||||
<h2
|
||||
id="payment-method-title"
|
||||
className="m-0 text-(length:--responsive-card-title,var(--font-size-lg)) font-bold leading-[1.2] text-auth-text-primary"
|
||||
style={{ fontFamily: "var(--font-athelas), var(--font-system)" }}
|
||||
>
|
||||
Payment Method
|
||||
</h2>
|
||||
<span className="whitespace-nowrap text-(length:--responsive-caption,var(--font-size-sm)) leading-[1.2] text-text-secondary">
|
||||
{caption}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-sm">
|
||||
{paymentMethods.map((method) => {
|
||||
const selected = method.channel === value;
|
||||
const classes = [
|
||||
"flex min-h-[clamp(54px,11.481vw,62px)] cursor-pointer items-center justify-center rounded-(--responsive-card-radius-sm,18px) px-[clamp(12px,2.593vw,14px)] py-[clamp(9px,1.852vw,10px)] text-center font-[inherit] text-[#3c3b3b] transition-[border-color,box-shadow,transform] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent active:enabled:scale-98 disabled:cursor-not-allowed disabled:opacity-72",
|
||||
selected
|
||||
? "border-2 border-[#f657a0] bg-[#fff4f9] shadow-[0_8px_18px_rgba(217,47,127,0.24),0_0_0_3px_rgba(217,47,127,0.18)] -translate-y-0.5"
|
||||
: "border border-[rgba(246,87,160,0.18)] bg-white shadow-[0_5px_7px_0_rgba(247,89,168,0.08)]",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
key={method.channel}
|
||||
type="button"
|
||||
data-analytics-key={analyticsKey}
|
||||
data-analytics-label="Select payment method"
|
||||
className={classes}
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
aria-label={method.title}
|
||||
onClick={() => onChange(method.channel)}
|
||||
>
|
||||
{method.logoSrc ? (
|
||||
<Image
|
||||
src={method.logoSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={122}
|
||||
height={29}
|
||||
className="block max-h-8.5 w-[min(100%,92px)] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-(length:--responsive-body,16px) font-bold leading-[1.1] text-[#1e1e1e]">
|
||||
{method.title}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user