Merge branch 'dev' into test
This commit is contained in:
@@ -70,6 +70,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||||
/>
|
||||
<ChatSendButton
|
||||
disabled={!hasContent || disabled}
|
||||
hasContent={hasContent}
|
||||
onClick={handleSend}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,27 +8,34 @@
|
||||
* - 默认:粉色背景 + 渐变边缘
|
||||
* - 聚焦 / 激活:完整 primaryGradient(accent → 透明)
|
||||
*
|
||||
* 图标:lucide-react <ArrowUp />(tree-shakable,currentColor 继承父 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);
|
||||
|
||||
Reference in New Issue
Block a user