Merge branch 'dev' into test

This commit is contained in:
2026-06-22 18:59:19 +08:00
4 changed files with 104 additions and 91 deletions
@@ -70,6 +70,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
/>
<ChatSendButton
disabled={!hasContent || disabled}
hasContent={hasContent}
onClick={handleSend}
/>
</div>
+12 -5
View File
@@ -8,27 +8,34 @@
* - 默认:粉色背景 + 渐变边缘
* - 聚焦 / 激活:完整 primaryGradientaccent → 透明)
*
* 图标:lucide-react <ArrowUp />tree-shakablecurrentColor 继承父 color
* 图标:空输入显示 <Plus />,有内容时显示 <ArrowUp />
*/
import { ArrowUp } from "lucide-react";
import { ArrowUp, Plus } from "lucide-react";
import styles from "./chat-send-button.module.css";
export interface ChatSendButtonProps {
disabled: boolean;
hasContent: boolean;
onClick: () => void;
}
export function ChatSendButton({ disabled, onClick }: ChatSendButtonProps) {
export function ChatSendButton({
disabled,
hasContent,
onClick,
}: ChatSendButtonProps) {
const Icon = hasContent ? ArrowUp : Plus;
return (
<button
type="button"
className={`${styles.button} ${disabled ? "" : styles.buttonActive}`}
disabled={disabled}
onClick={onClick}
aria-label="Send message"
aria-label={hasContent ? "Send message" : "Add message content"}
>
<ArrowUp className={styles.icon} size={24} aria-hidden="true" />
<Icon className={styles.icon} size={24} aria-hidden="true" />
</button>
);
}
@@ -14,10 +14,10 @@ import {
import { AppEnvUtil, Logger, Result } from "@/utils";
import { StripePaymentDialog } from "./stripe-payment-dialog";
import dialogStyles from "./stripe-payment-dialog.module.css";
import { SubscriptionCtaButton } from "./subscription-cta-button";
import styles from "./subscription-cta-button.module.css";
const EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS = 10000;
const log = new Logger("SubscriptionCheckoutButton");
export interface SubscriptionCheckoutButtonProps {
@@ -33,18 +33,10 @@ export function SubscriptionCheckoutButton({
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const launchedNonceRef = useRef(0);
const ezpayRedirectTimeoutRef = useRef<number | null>(null);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
string | null
>(null);
useEffect(() => {
return () => {
if (ezpayRedirectTimeoutRef.current !== null) {
window.clearTimeout(ezpayRedirectTimeoutRef.current);
}
};
}, []);
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
@@ -62,18 +54,10 @@ export function SubscriptionCheckoutButton({
const isEzpay = isEzpayPayment(payment.payParams);
if (!AppEnvUtil.isProduction() && isEzpay) {
if (ezpayRedirectTimeoutRef.current !== null) {
window.clearTimeout(ezpayRedirectTimeoutRef.current);
}
void redirectToEzpay({
log.debug("[subscription-checkout] ezpay confirmation required", {
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType,
delayMs: EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS,
setTimeoutId: (timeoutId) => {
ezpayRedirectTimeoutRef.current = timeoutId;
},
});
return;
}
@@ -118,10 +102,7 @@ export function SubscriptionCheckoutButton({
const handleClick = () => {
if (disabled || isLoading) return;
if (ezpayRedirectTimeoutRef.current !== null) {
window.clearTimeout(ezpayRedirectTimeoutRef.current);
ezpayRedirectTimeoutRef.current = null;
}
setIsConfirmingEzpay(false);
setHiddenStripeClientSecret(null);
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
};
@@ -148,14 +129,36 @@ export function SubscriptionCheckoutButton({
stripeClientSecret !== null &&
stripeClientSecret !== hiddenStripeClientSecret &&
!payment.isPaid;
const pendingEzpayRedirectOrderId =
const ezpayPaymentUrl = payment.payParams
? getPaymentUrl(payment.payParams)
: null;
const shouldShowEzpayConfirmDialog =
!AppEnvUtil.isProduction() &&
payment.payParams &&
isEzpayPayment(payment.payParams) &&
getPaymentUrl(payment.payParams) &&
ezpayPaymentUrl &&
payment.currentOrderId
? payment.currentOrderId
: null;
? true
: false;
const handleEzpayConfirm = () => {
if (!payment.currentOrderId || !ezpayPaymentUrl) return;
setIsConfirmingEzpay(true);
void redirectToEzpay({
orderId: payment.currentOrderId,
paymentUrl: ezpayPaymentUrl,
subscriptionType,
});
};
const handleEzpayCancel = () => {
log.debug("[subscription-checkout] ezpay confirmation cancelled", {
orderId: payment.currentOrderId,
subscriptionType,
});
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentReset" });
};
return (
<>
@@ -181,15 +184,13 @@ export function SubscriptionCheckoutButton({
{payment.errorMessage}
</p>
) : null}
{pendingEzpayRedirectOrderId ? (
<div className={styles.pendingRedirect} role="status">
<span className={styles.pendingRedirectLabel}>
Ezpay order created. Redirecting in 5 seconds.
</span>
<span className={styles.pendingRedirectOrder}>
Order No. {pendingEzpayRedirectOrderId}
</span>
</div>
{shouldShowEzpayConfirmDialog ? (
<EzpayRedirectConfirmDialog
orderId={payment.currentOrderId ?? ""}
isConfirming={isConfirmingEzpay}
onCancel={handleEzpayCancel}
onConfirm={handleEzpayConfirm}
/>
) : null}
{shouldShowStripeDialog ? (
<StripePaymentDialog
@@ -234,22 +235,17 @@ interface RedirectToEzpayInput {
orderId: string | null;
paymentUrl: string;
subscriptionType: "vip" | "voice";
delayMs?: number;
setTimeoutId?: (timeoutId: number) => void;
}
async function redirectToEzpay({
orderId,
paymentUrl,
subscriptionType,
delayMs = 0,
setTimeoutId,
}: RedirectToEzpayInput): Promise<void> {
log.debug("[subscription-checkout] redirectToEzpay START", {
hasOrderId: Boolean(orderId),
orderId,
subscriptionType,
delayMs,
paymentUrl,
});
@@ -279,25 +275,6 @@ async function redirectToEzpay({
});
}
if (delayMs > 0) {
const timeoutId = window.setTimeout(() => {
log.debug("[subscription-checkout] redirectToEzpay DELAY elapsed", {
orderId,
subscriptionType,
paymentUrl,
});
window.location.href = paymentUrl;
}, delayMs);
setTimeoutId?.(timeoutId);
log.debug("[subscription-checkout] redirectToEzpay DELAY scheduled", {
orderId,
subscriptionType,
delayMs,
timeoutId,
});
return;
}
log.debug("[subscription-checkout] redirectToEzpay NOW", {
orderId,
subscriptionType,
@@ -306,6 +283,60 @@ async function redirectToEzpay({
window.location.href = paymentUrl;
}
interface EzpayRedirectConfirmDialogProps {
orderId: string;
isConfirming: boolean;
onCancel: () => void;
onConfirm: () => void;
}
function EzpayRedirectConfirmDialog({
orderId,
isConfirming,
onCancel,
onConfirm,
}: EzpayRedirectConfirmDialogProps) {
return (
<div
className={dialogStyles.overlay}
role="alertdialog"
aria-modal="true"
aria-labelledby="ezpay-redirect-title"
>
<div className={dialogStyles.dialog}>
<div className={dialogStyles.header}>
<h2 id="ezpay-redirect-title" className={dialogStyles.title}>
Continue to Ezpay?
</h2>
<p className={dialogStyles.content}>
Your order has been created. Continue to Ezpay to finish the
payment.
</p>
<p className={dialogStyles.content}>Order No. {orderId}</p>
</div>
<div className={dialogStyles.actions}>
<button
type="button"
className={`${dialogStyles.button} ${dialogStyles.secondary}`}
disabled={isConfirming}
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
className={`${dialogStyles.button} ${dialogStyles.primary}`}
disabled={isConfirming}
onClick={onConfirm}
>
{isConfirming ? "Opening..." : "Continue"}
</button>
</div>
</div>
</div>
);
}
function getStripeClientSecret(
payParams: Record<string, unknown>,
): string | null {
@@ -60,32 +60,6 @@
animation: spin 0.8s linear infinite;
}
.pendingRedirect {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: var(--spacing-sm);
padding: 10px 12px;
border: 1px solid rgba(246, 87, 160, 0.2);
border-radius: 14px;
background: #ffffff;
color: #3c3b3b;
text-align: center;
}
.pendingRedirectLabel {
font-size: var(--font-size-sm);
line-height: 1.3;
}
.pendingRedirectOrder {
color: #1e1e1e;
font-size: var(--font-size-md);
font-weight: 700;
line-height: 1.3;
word-break: break-all;
}
@keyframes spin {
to {
transform: rotate(360deg);