617 lines
22 KiB
TypeScript
617 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
|
import QRCode from 'qrcode';
|
|
import type { Locale } from '@/lib/locale';
|
|
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
|
import { isStripeType, getPaymentMeta, getPaymentIconSrc, getPaymentChannelLabel } from '@/lib/pay-utils';
|
|
import { buildOrderStatusUrl } from '@/lib/order/status-url';
|
|
import { TERMINAL_STATUSES } from '@/lib/constants';
|
|
|
|
interface PaymentQRCodeProps {
|
|
orderId: string;
|
|
token?: string;
|
|
payUrl?: string | null;
|
|
qrCode?: string | null;
|
|
clientSecret?: string | null;
|
|
stripePublishableKey?: string | null;
|
|
paymentType?: string;
|
|
amount: number;
|
|
payAmount?: number;
|
|
expiresAt: string;
|
|
statusAccessToken?: string;
|
|
onStatusChange: (status: PublicOrderStatusSnapshot) => void;
|
|
onBack: () => void;
|
|
dark?: boolean;
|
|
isEmbedded?: boolean;
|
|
isMobile?: boolean;
|
|
locale?: Locale;
|
|
}
|
|
|
|
function isVisibleOrderOutcome(data: PublicOrderStatusSnapshot): boolean {
|
|
return data.paymentSuccess || TERMINAL_STATUSES.has(data.status);
|
|
}
|
|
|
|
export default function PaymentQRCode({
|
|
orderId,
|
|
token,
|
|
payUrl,
|
|
qrCode,
|
|
clientSecret,
|
|
stripePublishableKey,
|
|
paymentType,
|
|
amount,
|
|
payAmount: payAmountProp,
|
|
expiresAt,
|
|
statusAccessToken,
|
|
onStatusChange,
|
|
onBack,
|
|
dark = false,
|
|
isEmbedded = false,
|
|
isMobile = false,
|
|
locale = 'zh',
|
|
}: PaymentQRCodeProps) {
|
|
const displayAmount = payAmountProp ?? amount;
|
|
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
|
|
const [timeLeft, setTimeLeft] = useState('');
|
|
const [timeLeftSeconds, setTimeLeftSeconds] = useState(Infinity);
|
|
const [expired, setExpired] = useState(false);
|
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
|
const [imageLoading, setImageLoading] = useState(false);
|
|
const [cancelBlocked, setCancelBlocked] = useState(false);
|
|
const [redirected, setRedirected] = useState(false);
|
|
|
|
const [stripeLoaded, setStripeLoaded] = useState(false);
|
|
const [stripeSubmitting, setStripeSubmitting] = useState(false);
|
|
const [stripeError, setStripeError] = useState('');
|
|
const [stripeSuccess, setStripeSuccess] = useState(false);
|
|
const [stripeLib, setStripeLib] = useState<{
|
|
stripe: import('@stripe/stripe-js').Stripe;
|
|
elements: import('@stripe/stripe-js').StripeElements;
|
|
} | null>(null);
|
|
const [stripePaymentMethod, setStripePaymentMethod] = useState('card');
|
|
const [popupBlocked, setPopupBlocked] = useState(false);
|
|
const paymentMethodListenerAdded = useRef(false);
|
|
|
|
const t = {
|
|
expired: locale === 'en' ? 'Order Expired' : '订单已超时',
|
|
remaining: locale === 'en' ? 'Time Remaining' : '剩余支付时间',
|
|
scanPay: locale === 'en' ? 'Please scan with your payment app' : '请使用支付应用扫码支付',
|
|
back: locale === 'en' ? 'Back' : '返回',
|
|
cancelOrder: locale === 'en' ? 'Cancel Order' : '取消订单',
|
|
h5Hint:
|
|
locale === 'en'
|
|
? 'After payment, please return to this page. The system will confirm automatically.'
|
|
: '支付完成后请返回此页面,系统将自动确认',
|
|
paid: locale === 'en' ? 'Order Paid' : '订单已支付',
|
|
paidCancelBlocked:
|
|
locale === 'en'
|
|
? 'This order has already been paid and cannot be cancelled. The recharge will be credited automatically.'
|
|
: '该订单已支付完成,无法取消。充值将自动到账。',
|
|
backToRecharge: locale === 'en' ? 'Back to Recharge' : '返回充值',
|
|
credited: locale === 'en' ? 'Credited ¥' : '到账 ¥',
|
|
stripeLoadFailed:
|
|
locale === 'en'
|
|
? 'Failed to load payment component. Please refresh and try again.'
|
|
: '支付组件加载失败,请刷新页面重试',
|
|
initFailed:
|
|
locale === 'en' ? 'Payment initialization failed. Please go back and try again.' : '支付初始化失败,请返回重试',
|
|
loadingForm: locale === 'en' ? 'Loading payment form...' : '正在加载支付表单...',
|
|
payFailed: locale === 'en' ? 'Payment failed. Please try again.' : '支付失败,请重试',
|
|
successProcessing: locale === 'en' ? 'Payment successful, processing your order...' : '支付成功,正在处理订单...',
|
|
processing: locale === 'en' ? 'Processing...' : '处理中...',
|
|
payNow: locale === 'en' ? 'Pay' : '支付',
|
|
popupBlocked:
|
|
locale === 'en'
|
|
? 'Popup was blocked by your browser. Please allow popups for this site and try again.'
|
|
: '弹出窗口被浏览器拦截,请允许本站弹出窗口后重试',
|
|
redirectingPrefix: locale === 'en' ? 'Redirecting to ' : '正在跳转到',
|
|
redirectingSuffix: locale === 'en' ? '...' : '...',
|
|
redirectRetryHint:
|
|
locale === 'en'
|
|
? 'If the payment app does not open automatically, go back and try again.'
|
|
: '如未自动拉起支付应用,请返回上一页后重新发起支付。',
|
|
notRedirectedPrefix: locale === 'en' ? 'Not redirected? Open ' : '未跳转?点击前往',
|
|
goPaySuffix: locale === 'en' ? '' : '',
|
|
gotoPrefix: locale === 'en' ? 'Open ' : '前往',
|
|
gotoSuffix: locale === 'en' ? ' to pay' : '支付',
|
|
openScanPrefix: locale === 'en' ? 'Open ' : '请打开',
|
|
openScanSuffix: locale === 'en' ? ' and scan to complete payment' : '扫一扫完成支付',
|
|
};
|
|
|
|
const shouldAutoRedirect = !expired && !isStripeType(paymentType) && !!payUrl && (isMobile || !qrCode);
|
|
|
|
useEffect(() => {
|
|
if (!shouldAutoRedirect || redirected) return;
|
|
setRedirected(true);
|
|
if (isEmbedded) {
|
|
window.open(payUrl!, '_blank');
|
|
} else {
|
|
window.location.replace(payUrl!);
|
|
}
|
|
}, [shouldAutoRedirect, redirected, payUrl, isEmbedded]);
|
|
|
|
const qrPayload = useMemo(() => {
|
|
return (qrCode || '').trim();
|
|
}, [qrCode]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
if (!qrPayload) {
|
|
setQrDataUrl('');
|
|
return;
|
|
}
|
|
|
|
setImageLoading(true);
|
|
QRCode.toDataURL(qrPayload, {
|
|
width: 224,
|
|
margin: 1,
|
|
errorCorrectionLevel: 'M',
|
|
})
|
|
.then((url) => {
|
|
if (!cancelled) {
|
|
setQrDataUrl(url);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setQrDataUrl('');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setImageLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [qrPayload]);
|
|
|
|
const isStripe = isStripeType(paymentType);
|
|
|
|
useEffect(() => {
|
|
if (!isStripe || !clientSecret || !stripePublishableKey) return;
|
|
let cancelled = false;
|
|
|
|
import('@stripe/stripe-js').then(({ loadStripe }) => {
|
|
loadStripe(stripePublishableKey).then((stripe) => {
|
|
if (cancelled) return;
|
|
if (!stripe) {
|
|
setStripeError(t.stripeLoadFailed);
|
|
setStripeLoaded(true);
|
|
return;
|
|
}
|
|
const elements = stripe.elements({
|
|
clientSecret,
|
|
appearance: {
|
|
theme: dark ? 'night' : 'stripe',
|
|
variables: {
|
|
borderRadius: '8px',
|
|
},
|
|
},
|
|
});
|
|
setStripeLib({ stripe, elements });
|
|
setStripeLoaded(true);
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isStripe, clientSecret, stripePublishableKey, dark, t.stripeLoadFailed]);
|
|
|
|
const stripeContainerRef = useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
if (!node || !stripeLib) return;
|
|
let pe = stripeLib.elements.getElement('payment');
|
|
if (pe) {
|
|
pe.mount(node);
|
|
} else {
|
|
pe = stripeLib.elements.create('payment', { layout: 'tabs' });
|
|
pe.mount(node);
|
|
}
|
|
if (!paymentMethodListenerAdded.current) {
|
|
paymentMethodListenerAdded.current = true;
|
|
pe.on('change', (event: { value?: { type?: string } }) => {
|
|
if (event.value?.type) {
|
|
setStripePaymentMethod(event.value.type);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
[stripeLib],
|
|
);
|
|
|
|
const handleStripeSubmit = async () => {
|
|
if (!stripeLib || stripeSubmitting) return;
|
|
|
|
if (isEmbedded && stripePaymentMethod === 'alipay') {
|
|
handleOpenPopup();
|
|
return;
|
|
}
|
|
|
|
setStripeSubmitting(true);
|
|
setStripeError('');
|
|
|
|
const { stripe, elements } = stripeLib;
|
|
const returnUrl = new URL(window.location.href);
|
|
returnUrl.pathname = '/pay/result';
|
|
returnUrl.search = '';
|
|
returnUrl.searchParams.set('order_id', orderId);
|
|
returnUrl.searchParams.set('status', 'success');
|
|
if (statusAccessToken) {
|
|
returnUrl.searchParams.set('access_token', statusAccessToken);
|
|
}
|
|
if (locale === 'en') {
|
|
returnUrl.searchParams.set('lang', 'en');
|
|
}
|
|
|
|
const { error } = await stripe.confirmPayment({
|
|
elements,
|
|
confirmParams: {
|
|
return_url: returnUrl.toString(),
|
|
},
|
|
redirect: 'if_required',
|
|
});
|
|
|
|
if (error) {
|
|
setStripeError(error.message || t.payFailed);
|
|
setStripeSubmitting(false);
|
|
} else {
|
|
setStripeSuccess(true);
|
|
setStripeSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleOpenPopup = () => {
|
|
if (!clientSecret || !stripePublishableKey) return;
|
|
setPopupBlocked(false);
|
|
const popupUrl = new URL(window.location.href);
|
|
popupUrl.pathname = '/pay/stripe-popup';
|
|
popupUrl.search = '';
|
|
popupUrl.searchParams.set('order_id', orderId);
|
|
popupUrl.searchParams.set('amount', String(amount));
|
|
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
|
|
popupUrl.searchParams.set('method', stripePaymentMethod);
|
|
if (statusAccessToken) {
|
|
popupUrl.searchParams.set('access_token', statusAccessToken);
|
|
}
|
|
if (locale === 'en') {
|
|
popupUrl.searchParams.set('lang', 'en');
|
|
}
|
|
|
|
const popup = window.open(popupUrl.toString(), 'stripe_payment', 'width=500,height=700,scrollbars=yes');
|
|
if (!popup || popup.closed) {
|
|
setPopupBlocked(true);
|
|
return;
|
|
}
|
|
const onReady = (event: MessageEvent) => {
|
|
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return;
|
|
window.removeEventListener('message', onReady);
|
|
popup.postMessage(
|
|
{
|
|
type: 'STRIPE_POPUP_INIT',
|
|
clientSecret,
|
|
publishableKey: stripePublishableKey,
|
|
},
|
|
window.location.origin,
|
|
);
|
|
};
|
|
window.addEventListener('message', onReady);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const updateTimer = () => {
|
|
const now = Date.now();
|
|
const expiry = new Date(expiresAt).getTime();
|
|
const diff = expiry - now;
|
|
|
|
if (diff <= 0) {
|
|
setTimeLeft(t.expired);
|
|
setTimeLeftSeconds(0);
|
|
setExpired(true);
|
|
return;
|
|
}
|
|
|
|
const totalSeconds = Math.floor(diff / 1000);
|
|
const minutes = Math.floor(diff / 60000);
|
|
const seconds = Math.floor((diff % 60000) / 1000);
|
|
setTimeLeft(`${minutes}:${seconds.toString().padStart(2, '0')}`);
|
|
setTimeLeftSeconds(totalSeconds);
|
|
};
|
|
|
|
updateTimer();
|
|
const timer = setInterval(updateTimer, 1000);
|
|
return () => clearInterval(timer);
|
|
}, [expiresAt, t.expired]);
|
|
|
|
const pollStatus = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
|
|
if (res.ok) {
|
|
const data = (await res.json()) as PublicOrderStatusSnapshot;
|
|
if (isVisibleOrderOutcome(data)) {
|
|
onStatusChange(data);
|
|
}
|
|
}
|
|
} catch {}
|
|
}, [orderId, onStatusChange, statusAccessToken]);
|
|
|
|
useEffect(() => {
|
|
if (expired) return;
|
|
pollStatus();
|
|
const timer = setInterval(pollStatus, 2000);
|
|
return () => clearInterval(timer);
|
|
}, [pollStatus, expired]);
|
|
|
|
const handleCancel = async () => {
|
|
if (!token) return;
|
|
try {
|
|
const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
|
|
if (!res.ok) return;
|
|
const data = (await res.json()) as PublicOrderStatusSnapshot;
|
|
|
|
if (data.paymentSuccess || TERMINAL_STATUSES.has(data.status)) {
|
|
onStatusChange(data);
|
|
return;
|
|
}
|
|
|
|
const cancelRes = await fetch(`/api/orders/${orderId}/cancel`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token }),
|
|
});
|
|
if (cancelRes.ok) {
|
|
const cancelData = await cancelRes.json();
|
|
if (cancelData.status === 'PAID') {
|
|
setCancelBlocked(true);
|
|
return;
|
|
}
|
|
onStatusChange({
|
|
id: orderId,
|
|
status: 'CANCELLED',
|
|
expiresAt,
|
|
paymentSuccess: false,
|
|
rechargeSuccess: false,
|
|
rechargeStatus: 'closed',
|
|
});
|
|
} else {
|
|
await pollStatus();
|
|
}
|
|
} catch {}
|
|
};
|
|
|
|
const meta = getPaymentMeta(paymentType || 'alipay');
|
|
const iconSrc = getPaymentIconSrc(paymentType || 'alipay');
|
|
const channelLabel = getPaymentChannelLabel(paymentType || 'alipay', locale);
|
|
const iconBgClass = meta.iconBg;
|
|
|
|
if (cancelBlocked) {
|
|
return (
|
|
<div className="flex flex-col items-center space-y-4 py-8">
|
|
<div className={dark ? 'text-6xl text-green-400' : 'text-6xl text-green-600'}>{'✓'}</div>
|
|
<h2 className={['text-xl font-bold', dark ? 'text-green-400' : 'text-green-600'].join(' ')}>{t.paid}</h2>
|
|
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
|
{t.paidCancelBlocked}
|
|
</p>
|
|
<button
|
|
onClick={onBack}
|
|
className={[
|
|
'mt-4 w-full rounded-lg py-3 font-medium text-white',
|
|
dark ? 'bg-blue-600/90 hover:bg-blue-600' : 'bg-blue-600 hover:bg-blue-700',
|
|
].join(' ')}
|
|
>
|
|
{t.backToRecharge}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col items-center space-y-4">
|
|
<div className="text-center">
|
|
<div className={['text-4xl font-bold', dark ? 'text-blue-400' : 'text-blue-600'].join(' ')}>
|
|
{'¥'}
|
|
{displayAmount.toFixed(2)}
|
|
</div>
|
|
{hasFeeDiff && (
|
|
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
|
{t.credited}
|
|
{amount.toFixed(2)}
|
|
</div>
|
|
)}
|
|
<div
|
|
className={`mt-1 text-sm ${expired ? 'text-red-500' : !expired && timeLeftSeconds <= 60 ? 'text-red-500 animate-pulse' : dark ? 'text-slate-400' : 'text-gray-500'}`}
|
|
>
|
|
{expired ? t.expired : `${t.remaining}: ${timeLeft}`}
|
|
</div>
|
|
</div>
|
|
|
|
{!expired && (
|
|
<>
|
|
{isStripe ? (
|
|
<div className="w-full max-w-md space-y-4">
|
|
{!clientSecret || !stripePublishableKey ? (
|
|
<div
|
|
className={[
|
|
'rounded-lg border-2 border-dashed p-8 text-center',
|
|
dark ? 'border-slate-700' : 'border-gray-300',
|
|
].join(' ')}
|
|
>
|
|
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.initFailed}</p>
|
|
</div>
|
|
) : !stripeLoaded ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
|
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
|
{t.loadingForm}
|
|
</span>
|
|
</div>
|
|
) : stripeError && !stripeLib ? (
|
|
<div
|
|
className={[
|
|
'rounded-lg border p-3 text-sm',
|
|
dark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
|
].join(' ')}
|
|
>
|
|
{stripeError}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div
|
|
ref={stripeContainerRef}
|
|
className={[
|
|
'rounded-lg border p-4',
|
|
dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white',
|
|
].join(' ')}
|
|
/>
|
|
{stripeError && (
|
|
<div
|
|
className={[
|
|
'rounded-lg border p-3 text-sm',
|
|
dark ? 'border-red-700/50 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
|
].join(' ')}
|
|
>
|
|
{stripeError}
|
|
</div>
|
|
)}
|
|
{stripeSuccess ? (
|
|
<div className="text-center">
|
|
<div className={dark ? 'text-4xl text-green-400' : 'text-4xl text-green-600'}>{'✓'}</div>
|
|
<p className={['mt-2 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
|
{t.successProcessing}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
disabled={stripeSubmitting}
|
|
onClick={handleStripeSubmit}
|
|
className={[
|
|
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
|
stripeSubmitting ? 'cursor-not-allowed bg-gray-400' : meta.buttonClass,
|
|
].join(' ')}
|
|
>
|
|
{stripeSubmitting ? (
|
|
<span className="inline-flex items-center gap-2">
|
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
{t.processing}
|
|
</span>
|
|
) : (
|
|
`${t.payNow} ¥${amount.toFixed(2)}`
|
|
)}
|
|
</button>
|
|
)}
|
|
{popupBlocked && (
|
|
<div
|
|
className={[
|
|
'rounded-lg border p-3 text-sm',
|
|
dark
|
|
? 'border-amber-700 bg-amber-900/30 text-amber-300'
|
|
: 'border-amber-200 bg-amber-50 text-amber-700',
|
|
].join(' ')}
|
|
>
|
|
{t.popupBlocked}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
) : shouldAutoRedirect ? (
|
|
<>
|
|
<div className="flex items-center justify-center py-6">
|
|
<div
|
|
className={`h-8 w-8 animate-spin rounded-full border-2 border-t-transparent`}
|
|
style={{ borderColor: meta.color, borderTopColor: 'transparent' }}
|
|
/>
|
|
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
|
{`${t.redirectingPrefix}${channelLabel}${t.redirectingSuffix}`}
|
|
</span>
|
|
</div>
|
|
<a
|
|
href={payUrl!}
|
|
target={isEmbedded ? '_blank' : '_self'}
|
|
rel="noopener noreferrer"
|
|
className={`flex w-full items-center justify-center gap-2 rounded-lg py-3 font-medium text-white shadow-md ${meta.buttonClass}`}
|
|
>
|
|
{iconSrc && <img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />}
|
|
{redirected
|
|
? `${t.notRedirectedPrefix}${channelLabel}`
|
|
: `${t.gotoPrefix}${channelLabel}${t.gotoSuffix}`}
|
|
</a>
|
|
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.h5Hint}</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
{qrDataUrl && (
|
|
<div
|
|
className={[
|
|
'relative rounded-lg border p-4',
|
|
dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white',
|
|
].join(' ')}
|
|
>
|
|
{imageLoading && (
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/10">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
|
</div>
|
|
)}
|
|
<img src={qrDataUrl} alt="payment qrcode" className="h-56 w-56 rounded" />
|
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
<span className={`rounded-full p-2 shadow ring-2 ring-white ${iconBgClass}`}>
|
|
<img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!qrDataUrl && (
|
|
<div className="text-center">
|
|
<div
|
|
className={[
|
|
'rounded-lg border-2 border-dashed p-8',
|
|
dark ? 'border-slate-700' : 'border-gray-300',
|
|
].join(' ')}
|
|
>
|
|
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.scanPay}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
|
{`${t.openScanPrefix}${channelLabel}${t.openScanSuffix}`}
|
|
</p>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="flex w-full gap-3">
|
|
<button
|
|
onClick={onBack}
|
|
className={[
|
|
'flex-1 rounded-lg border py-2 text-sm',
|
|
dark
|
|
? 'border-slate-700 text-slate-300 hover:bg-slate-800'
|
|
: 'border-gray-300 text-gray-600 hover:bg-gray-50',
|
|
].join(' ')}
|
|
>
|
|
{t.back}
|
|
</button>
|
|
{!expired && token && (
|
|
<button
|
|
onClick={handleCancel}
|
|
className={[
|
|
'flex-1 rounded-lg border py-2 text-sm',
|
|
dark ? 'border-red-700 text-red-400 hover:bg-red-900/30' : 'border-red-300 text-red-600 hover:bg-red-50',
|
|
].join(' ')}
|
|
>
|
|
{t.cancelOrder}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|