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 <ChatSendButton
disabled={!hasContent || disabled} disabled={!hasContent || disabled}
hasContent={hasContent}
onClick={handleSend} onClick={handleSend}
/> />
</div> </div>
+12 -5
View File
@@ -8,27 +8,34 @@
* - 默认:粉色背景 + 渐变边缘 * - 默认:粉色背景 + 渐变边缘
* - 聚焦 / 激活:完整 primaryGradientaccent → 透明) * - 聚焦 / 激活:完整 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"; import styles from "./chat-send-button.module.css";
export interface ChatSendButtonProps { export interface ChatSendButtonProps {
disabled: boolean; disabled: boolean;
hasContent: boolean;
onClick: () => void; onClick: () => void;
} }
export function ChatSendButton({ disabled, onClick }: ChatSendButtonProps) { export function ChatSendButton({
disabled,
hasContent,
onClick,
}: ChatSendButtonProps) {
const Icon = hasContent ? ArrowUp : Plus;
return ( return (
<button <button
type="button" type="button"
className={`${styles.button} ${disabled ? "" : styles.buttonActive}`} className={`${styles.button} ${disabled ? "" : styles.buttonActive}`}
disabled={disabled} disabled={disabled}
onClick={onClick} 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> </button>
); );
} }
@@ -14,10 +14,10 @@ import {
import { AppEnvUtil, Logger, Result } from "@/utils"; import { AppEnvUtil, Logger, Result } from "@/utils";
import { StripePaymentDialog } from "./stripe-payment-dialog"; import { StripePaymentDialog } from "./stripe-payment-dialog";
import dialogStyles from "./stripe-payment-dialog.module.css";
import { SubscriptionCtaButton } from "./subscription-cta-button"; import { SubscriptionCtaButton } from "./subscription-cta-button";
import styles from "./subscription-cta-button.module.css"; import styles from "./subscription-cta-button.module.css";
const EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS = 10000;
const log = new Logger("SubscriptionCheckoutButton"); const log = new Logger("SubscriptionCheckoutButton");
export interface SubscriptionCheckoutButtonProps { export interface SubscriptionCheckoutButtonProps {
@@ -33,18 +33,10 @@ export function SubscriptionCheckoutButton({
const payment = usePaymentState(); const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
const launchedNonceRef = useRef(0); const launchedNonceRef = useRef(0);
const ezpayRedirectTimeoutRef = useRef<number | null>(null);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
string | null string | null
>(null); >(null);
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
useEffect(() => {
return () => {
if (ezpayRedirectTimeoutRef.current !== null) {
window.clearTimeout(ezpayRedirectTimeoutRef.current);
}
};
}, []);
useEffect(() => { useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) { if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
@@ -62,18 +54,10 @@ export function SubscriptionCheckoutButton({
const isEzpay = isEzpayPayment(payment.payParams); const isEzpay = isEzpayPayment(payment.payParams);
if (!AppEnvUtil.isProduction() && isEzpay) { if (!AppEnvUtil.isProduction() && isEzpay) {
if (ezpayRedirectTimeoutRef.current !== null) { log.debug("[subscription-checkout] ezpay confirmation required", {
window.clearTimeout(ezpayRedirectTimeoutRef.current);
}
void redirectToEzpay({
orderId: payment.currentOrderId, orderId: payment.currentOrderId,
paymentUrl, paymentUrl,
subscriptionType, subscriptionType,
delayMs: EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS,
setTimeoutId: (timeoutId) => {
ezpayRedirectTimeoutRef.current = timeoutId;
},
}); });
return; return;
} }
@@ -118,10 +102,7 @@ export function SubscriptionCheckoutButton({
const handleClick = () => { const handleClick = () => {
if (disabled || isLoading) return; if (disabled || isLoading) return;
if (ezpayRedirectTimeoutRef.current !== null) { setIsConfirmingEzpay(false);
window.clearTimeout(ezpayRedirectTimeoutRef.current);
ezpayRedirectTimeoutRef.current = null;
}
setHiddenStripeClientSecret(null); setHiddenStripeClientSecret(null);
paymentDispatch({ type: "PaymentCreateOrderSubmitted" }); paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
}; };
@@ -148,14 +129,36 @@ export function SubscriptionCheckoutButton({
stripeClientSecret !== null && stripeClientSecret !== null &&
stripeClientSecret !== hiddenStripeClientSecret && stripeClientSecret !== hiddenStripeClientSecret &&
!payment.isPaid; !payment.isPaid;
const pendingEzpayRedirectOrderId = const ezpayPaymentUrl = payment.payParams
? getPaymentUrl(payment.payParams)
: null;
const shouldShowEzpayConfirmDialog =
!AppEnvUtil.isProduction() && !AppEnvUtil.isProduction() &&
payment.payParams && payment.payParams &&
isEzpayPayment(payment.payParams) && isEzpayPayment(payment.payParams) &&
getPaymentUrl(payment.payParams) && ezpayPaymentUrl &&
payment.currentOrderId payment.currentOrderId
? payment.currentOrderId ? true
: null; : 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 ( return (
<> <>
@@ -181,15 +184,13 @@ export function SubscriptionCheckoutButton({
{payment.errorMessage} {payment.errorMessage}
</p> </p>
) : null} ) : null}
{pendingEzpayRedirectOrderId ? ( {shouldShowEzpayConfirmDialog ? (
<div className={styles.pendingRedirect} role="status"> <EzpayRedirectConfirmDialog
<span className={styles.pendingRedirectLabel}> orderId={payment.currentOrderId ?? ""}
Ezpay order created. Redirecting in 5 seconds. isConfirming={isConfirmingEzpay}
</span> onCancel={handleEzpayCancel}
<span className={styles.pendingRedirectOrder}> onConfirm={handleEzpayConfirm}
Order No. {pendingEzpayRedirectOrderId} />
</span>
</div>
) : null} ) : null}
{shouldShowStripeDialog ? ( {shouldShowStripeDialog ? (
<StripePaymentDialog <StripePaymentDialog
@@ -234,22 +235,17 @@ interface RedirectToEzpayInput {
orderId: string | null; orderId: string | null;
paymentUrl: string; paymentUrl: string;
subscriptionType: "vip" | "voice"; subscriptionType: "vip" | "voice";
delayMs?: number;
setTimeoutId?: (timeoutId: number) => void;
} }
async function redirectToEzpay({ async function redirectToEzpay({
orderId, orderId,
paymentUrl, paymentUrl,
subscriptionType, subscriptionType,
delayMs = 0,
setTimeoutId,
}: RedirectToEzpayInput): Promise<void> { }: RedirectToEzpayInput): Promise<void> {
log.debug("[subscription-checkout] redirectToEzpay START", { log.debug("[subscription-checkout] redirectToEzpay START", {
hasOrderId: Boolean(orderId), hasOrderId: Boolean(orderId),
orderId, orderId,
subscriptionType, subscriptionType,
delayMs,
paymentUrl, 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", { log.debug("[subscription-checkout] redirectToEzpay NOW", {
orderId, orderId,
subscriptionType, subscriptionType,
@@ -306,6 +283,60 @@ async function redirectToEzpay({
window.location.href = paymentUrl; 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( function getStripeClientSecret(
payParams: Record<string, unknown>, payParams: Record<string, unknown>,
): string | null { ): string | null {
@@ -60,32 +60,6 @@
animation: spin 0.8s linear infinite; 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 { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);