feat(subscription): add payment channel selector

This commit is contained in:
2026-06-22 16:13:06 +08:00
parent ec8c559a19
commit 5ed43a37ce
7 changed files with 169 additions and 9 deletions
+1
View File
@@ -7,6 +7,7 @@ export * from "./subscription-banner";
export * from "./subscription-benefits-card";
export * from "./subscription-checkout-button";
export * from "./subscription-cta-button";
export * from "./subscription-payment-method";
export * from "./subscription-plan-card";
export * from "./subscription-user-row";
export * from "./stripe-payment-dialog";
@@ -1,13 +1,5 @@
"use client";
/**
* 订阅页主操作按钮
*
* 视觉规格(与设计稿对齐):
* - 全宽,52px 高,--radius-xxxl 圆角
* - 粉色渐变(#f96ADE → #f657A0
* - 白字 16px/600 + 粉色阴影
* - :disabled 灰化
*/
import type { ButtonHTMLAttributes, ReactNode } from "react";
import styles from "./subscription-cta-button.module.css";
@@ -0,0 +1,80 @@
.root {
display: flex;
flex-direction: column;
gap: 10px;
}
.header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-sm);
padding: 0 var(--spacing-xs);
}
.title {
margin: 0;
font-family: var(--font-athelas), var(--font-system);
font-size: var(--font-size-lg);
font-weight: 700;
color: var(--color-auth-text-primary);
line-height: 1.2;
}
.caption {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: 1.2;
white-space: nowrap;
}
.options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-sm);
}
.option {
display: flex;
min-height: 62px;
align-items: center;
justify-content: center;
padding: 10px 14px;
border: 1px solid rgba(246, 87, 160, 0.18);
border-radius: 18px;
background: #ffffff;
box-shadow: 0 5px 7px 0 rgba(247, 89, 168, 0.08);
color: #3c3b3b;
cursor: pointer;
font: inherit;
text-align: center;
transition: border-color 0.15s ease, box-shadow 0.15s ease,
transform 0.05s ease;
}
.option:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.option:not(:disabled):active {
transform: scale(0.98);
}
.option:disabled {
cursor: not-allowed;
opacity: 0.72;
}
.selected {
border-color: #f657a0;
border-width: 2px;
box-shadow: 0 6px 12px 0 rgba(247, 89, 168, 0.14);
}
.optionTitle {
color: #1e1e1e;
font-size: 16px;
font-weight: 700;
line-height: 1.1;
}
@@ -0,0 +1,66 @@
"use client";
import type { PayChannel } from "@/data/dto/payment";
import styles from "./subscription-payment-method.module.css";
const PAYMENT_METHODS: Array<{
channel: PayChannel;
title: string;
}> = [
{
channel: "stripe",
title: "Stripe",
},
{
channel: "ezpay",
title: "Ezpay",
},
];
export interface SubscriptionPaymentMethodProps {
value: PayChannel;
disabled?: boolean;
onChange: (channel: PayChannel) => void;
}
export function SubscriptionPaymentMethod({
value,
disabled = false,
onChange,
}: SubscriptionPaymentMethodProps) {
return (
<section className={styles.root} aria-labelledby="payment-method-title">
<div className={styles.header}>
<h2 id="payment-method-title" className={styles.title}>
Payment Method
</h2>
<span className={styles.caption}>Stripe by default</span>
</div>
<div className={styles.options}>
{PAYMENT_METHODS.map((method) => {
const selected = method.channel === value;
const classes = [
styles.option,
selected ? styles.selected : "",
]
.filter(Boolean)
.join(" ");
return (
<button
key={method.channel}
type="button"
className={classes}
disabled={disabled}
aria-pressed={selected}
onClick={() => onChange(method.channel)}
>
<span className={styles.optionTitle}>{method.title}</span>
</button>
);
})}
</div>
</section>
);
}
@@ -59,6 +59,10 @@
min-height: 160px;
}
.paymentMethodSlot {
margin-top: var(--spacing-lg);
}
.ctaSlot {
margin-top: var(--spacing-xl);
}
@@ -32,6 +32,7 @@ import {
SubscriptionBenefitsCard,
SubscriptionCheckoutButton,
SubscriptionCtaButton,
SubscriptionPaymentMethod,
SubscriptionPlanCard,
SubscriptionUserRow,
} from "./components";
@@ -250,6 +251,21 @@ export function SubscriptionScreen({
)}
</section>
{!isVip ? (
<section className={styles.paymentMethodSlot}>
<SubscriptionPaymentMethod
value={payment.payChannel}
disabled={isPaymentBusy}
onChange={(payChannel) =>
paymentDispatch({
type: "PaymentPayChannelChanged",
payChannel,
})
}
/>
</section>
) : null}
<div className={styles.ctaSlot}>
{isVip ? (
<SubscriptionCtaButton type="button" disabled>
+1
View File
@@ -108,6 +108,7 @@ export const paymentMachine = setup({
},
PaymentPayChannelChanged: {
actions: assign(({ event }) => ({
...resetOrderState(),
payChannel: event.payChannel,
errorMessage: null,
})),