弱网核销功能

This commit is contained in:
2026-07-12 12:00:55 +08:00
parent de01d36cdb
commit 06b1cb22e0
24 changed files with 1434 additions and 42 deletions
+2
View File
@@ -10,6 +10,7 @@ import StoreAccountsPage from './pages/StoreAccountsPage';
import BenefitCouponsPage from './pages/BenefitCouponsPage';
import BenefitLedgersPage from './pages/BenefitLedgersPage';
import RedeemRecordsPage from './pages/RedeemRecordsPage';
import PendingRedeemPage from './pages/PendingRedeemPage';
import RedeemDebugPage from './pages/RedeemDebugPage';
import DeliveriesPage from './pages/DeliveriesPage';
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
@@ -75,6 +76,7 @@ export default function App() {
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
<Route path="/store-bills" element={<StoreBillsPage />} />
<Route path="/store-payouts" element={<Navigate to="/store-bills" replace />} />
@@ -66,6 +66,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/benefit/coupons', label: '权益券' },
{ key: '/benefit/ledgers', label: '流水' },
{ key: '/redeem-records', label: '核销记录' },
{ key: '/redeem-pending', label: '待处理核销' },
{ key: '/redeem/debug', label: '核销调试' },
],
},
@@ -0,0 +1,266 @@
import { useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Image,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
REDEEM_PENDING_STATUS_LABELS,
type RedeemPendingItem,
type RedeemPendingStatus,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
PENDING: 'orange',
COMPLETED: 'green',
REJECTED: 'default',
};
export default function PendingRedeemPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<RedeemPendingItem>(
'/admin/redeem-pending',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo);
if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken);
if (filters.storeId) qs.set('storeId', filters.storeId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<RedeemPendingItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [acting, setActing] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
async function openDetail(id: string) {
const res = await request<RedeemPendingItem>(`/admin/redeem-pending/${id}`);
setDetail(res);
setDrawerOpen(true);
}
async function complete() {
if (!detail) return;
setActing(true);
try {
await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' });
message.success('已补核销');
setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '补核销失败');
} finally {
setActing(false);
}
}
async function reject() {
if (!detail || !rejectReason.trim()) {
message.error('请填写驳回原因');
return;
}
setActing(true);
try {
await request(`/admin/redeem-pending/${detail.id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: rejectReason.trim() }),
});
message.success('已驳回');
setRejectOpen(false);
setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '驳回失败');
} finally {
setActing(false);
}
}
const columns: ColumnsType<RedeemPendingItem> = [
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
{
title: '核销码 ID',
dataIndex: 'redeemToken',
width: 160,
ellipsis: true,
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}</Typography.Text>,
},
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
{
title: '用户',
width: 120,
render: (_, row) => row.user?.userNo || row.user?.phone || '—',
},
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '失败次数', dataIndex: 'failCount', width: 90 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: RedeemPendingStatus) => (
<Tag color={STATUS_COLOR[s]}>{REDEEM_PENDING_STATUS_LABELS[s] || s}</Tag>
),
},
{ title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="pendingNo" label="待处理单号">
<Input allowClear />
</Form.Item>
<Form.Item name="redeemToken" label="核销码 ID">
<Input allowClear />
</Form.Item>
<Form.Item name="storeId" label="门店 ID">
<Input allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(REDEEM_PENDING_STATUS_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="待处理核销详情"
width={560}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail?.status === 'PENDING' && (
<Space>
<Button danger onClick={() => { setRejectReason(''); setRejectOpen(true); }}>
</Button>
<Button type="primary" loading={acting} onClick={() => void complete()}>
</Button>
</Space>
)
}
>
{detail && (
<>
{detail.photoUrl && (
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Image src={detail.photoUrl} alt="核销码照片" style={{ maxHeight: 280 }} />
</div>
)}
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="待处理单号">{detail.pendingNo}</Descriptions.Item>
<Descriptions.Item label="核销码 ID">
<Typography.Text copyable>{detail.redeemToken}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status]}>
{REDEEM_PENDING_STATUS_LABELS[detail.status]}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="金额">¥{detail.amount}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.redeemType}</Descriptions.Item>
<Descriptions.Item label="失败次数">{detail.failCount}</Descriptions.Item>
<Descriptions.Item label="门店">
{detail.store?.name || '—'}{detail.store?.id}
</Descriptions.Item>
<Descriptions.Item label="用户">
{detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'}
</Descriptions.Item>
<Descriptions.Item label="关联核销单">
{detail.redeemRecord?.redeemNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="驳回原因">{detail.rejectReason || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="提交时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="处理时间">
{detail.processedAt ? fmtTime(detail.processedAt) : '—'}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
<Modal
title="驳回待处理单"
open={rejectOpen}
okText="确认驳回"
okButtonProps={{ danger: true, loading: acting, disabled: !rejectReason.trim() }}
onOk={() => void reject()}
onCancel={() => setRejectOpen(false)}
>
<Input.TextArea
rows={3}
placeholder="请填写驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
}
@@ -0,0 +1,159 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { request } from '../lib/api';
import { uploadRedeemPendingPhoto } from '../lib/upload';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import type { RedeemPendingSubmitResult } from '@dukang/shared-types';
type Props = {
redeemToken: string;
failCount: number;
};
export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props) {
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [photoResourceId, setPhotoResourceId] = useState('');
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
const [msg, setMsg] = useState('');
async function handleFile(file: File) {
setUploading(true);
setMsg('');
try {
const registered = await uploadRedeemPendingPhoto(file);
setPhotoResourceId(registered.id);
setPreviewUrl(registered.url);
} catch (e) {
setMsg(e instanceof Error ? e.message : '上传失败');
} finally {
setUploading(false);
}
}
async function pickPhoto() {
setMsg('');
if (isWechatEnv()) {
try {
setUploading(true);
await weixinSdk.init();
const files = await weixinSdk.chooseImages({
count: 1,
sourceType: ['album', 'camera'],
});
if (files?.[0]) {
await handleFile(files[0]);
}
} catch (e) {
const text = e instanceof Error ? e.message : '选图失败';
if (!/cancel/i.test(text)) setMsg(text);
} finally {
setUploading(false);
}
return;
}
inputRef.current?.click();
}
async function submitPending() {
if (!photoResourceId) {
setMsg('请先拍摄或上传核销码照片');
return;
}
setSubmitting(true);
setMsg('');
try {
const res = await request<RedeemPendingSubmitResult>('SHOP_H5', '/shop/redeem/pending', {
method: 'POST',
body: JSON.stringify({
token: redeemToken,
photoResourceId,
failCount,
}),
});
setResult(res);
} catch (e) {
setMsg(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
}
}
function copyText(text: string) {
void navigator.clipboard?.writeText(text).then(
() => setMsg('已复制'),
() => setMsg('复制失败,请手动长按复制'),
);
}
return (
<section className="shop-weaknet-panel" role="alert">
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<p className="body-md" style={{ marginBottom: 12, lineHeight: 1.5 }}>
{failCount}
</p>
<div className="shop-weaknet-token">
<span className="label-md text-muted"> ID</span>
<button type="button" className="shop-weaknet-copy" onClick={() => copyText(redeemToken)}>
{redeemToken}
</button>
</div>
{result ? (
<div className="shop-weaknet-success">
<p className="body-md"></p>
<button type="button" className="shop-weaknet-copy" onClick={() => copyText(result.pendingNo)}>
{result.pendingNo}
</button>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</div>
) : (
<>
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleFile(file);
}}
/>
{previewUrl && (
<img src={previewUrl} alt="核销码照片预览" className="shop-weaknet-preview" />
)}
<div className="shop-weaknet-actions">
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
</button>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={submitting || !photoResourceId}
onClick={() => void submitPending()}
>
{submitting ? '提交中…' : '提交客服人工核销'}
</button>
<button
type="button"
className="shop-btn-outline"
onClick={() => navigate('/redeem/phone')}
>
</button>
</div>
</>
)}
{msg && <p className="shop-redeem-error" style={{ marginTop: 12 }}>{msg}</p>}
</section>
);
}
+11 -2
View File
@@ -124,10 +124,19 @@ async function rawRequest<T>(
if (authToken) headers.Authorization = `Bearer ${authToken}`;
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const json = await res.json();
let json: { code: number; message?: string; data?: T };
try {
json = await res.json();
} catch {
const err = new Error(res.ok ? '接口返回异常' : `网络异常(HTTP ${res.status}`) as Error & {
status?: number;
};
err.status = res.status;
throw err;
}
if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number };
err.status = json.code;
err.status = res.status >= 500 ? res.status : json.code;
throw err;
}
return json.data as T;
+50
View File
@@ -0,0 +1,50 @@
import { REDEEM_WEAKNET_FAIL_THRESHOLD } from '@dukang/shared-types';
import type { RedeemErrorClass, RedeemFailureReportResult } from '@dukang/shared-types';
import { request } from './api';
export function isNetworkError(e: unknown): boolean {
const err = e as Error & { status?: number };
const status = err.status;
const message = (err.message ?? String(e)).toLowerCase();
if (status != null && status >= 500) return true;
if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
return true;
}
if (/failed to fetch|network|timeout|timed out|abort|offline|连接|网络|超时/.test(message)) {
return true;
}
// 无 status 的 fetch 失败通常是网络类
if (status == null && e instanceof TypeError) return true;
return false;
}
export async function reportRedeemFailure(
token: string,
step: 'preview' | 'confirm',
error: unknown,
): Promise<RedeemFailureReportResult | null> {
const err = error as Error & { status?: number };
const errorClass: RedeemErrorClass = isNetworkError(error) ? 'NETWORK' : 'BUSINESS';
try {
return await request<RedeemFailureReportResult>('SHOP_H5', '/shop/redeem/failures', {
method: 'POST',
body: JSON.stringify({
token,
errorClass,
message: err.message?.slice(0, 200),
step,
}),
});
} catch {
if (errorClass === 'NETWORK') {
return {
failCount: REDEEM_WEAKNET_FAIL_THRESHOLD,
thresholdReached: true,
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
};
}
return null;
}
}
export { REDEEM_WEAKNET_FAIL_THRESHOLD };
+91
View File
@@ -0,0 +1,91 @@
import { apiBase } from './api';
import { getStoreProfile } from './api';
const UPLOAD_TIMEOUT_MS = 120_000;
export type UploadFileResult = {
url: string;
ossKey: string;
bucket: string;
mock: boolean;
};
export type RegisteredResource = {
id: string;
url: string;
};
async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
const formData = new FormData();
formData.append('file', file);
formData.append('bizType', bizType);
formData.append('mediaType', 'IMAGE');
const token = localStorage.getItem('accessToken');
const headers: Record<string, string> = { 'X-Client-App': 'SHOP_H5' };
if (token) headers.Authorization = `Bearer ${token}`;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
try {
const res = await fetch(`${apiBase}/common/resources/upload`, {
method: 'POST',
headers,
body: formData,
signal: controller.signal,
});
const json = await res.json();
if (json.code === 401) {
localStorage.removeItem('accessToken');
throw new Error('未登录');
}
if (json.code !== 0) {
throw new Error(json.message || '上传失败');
}
return json.data as UploadFileResult;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error('上传超时,请检查网络后重试');
}
throw e;
} finally {
window.clearTimeout(timeoutId);
}
}
async function registerResource(upload: UploadFileResult, fileName: string): Promise<RegisteredResource> {
const profile = getStoreProfile();
if (!profile?.storeId) throw new Error('门店信息缺失,请重新登录');
const token = localStorage.getItem('accessToken');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': 'SHOP_H5',
};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}/common/resources`, {
method: 'POST',
headers,
body: JSON.stringify({
ownerType: 'STORE',
ownerId: profile.storeId,
bizType: 'REDEEM_PENDING_PHOTO',
mediaType: 'IMAGE',
ossKey: upload.ossKey,
url: upload.url,
ossBucket: upload.bucket,
fileName,
}),
});
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '登记资源失败');
return { id: String(json.data.id), url: String(json.data.url) };
}
/** 上传核销码照片并登记为 CommonResource,返回 resourceId */
export async function uploadRedeemPendingPhoto(file: File): Promise<RegisteredResource> {
const uploaded = await uploadFileToOss(file, 'REDEEM_PENDING_PHOTO');
return registerResource(uploaded, file.name || 'redeem-pending.jpg');
}
+49 -16
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
import { request } from '../lib/api';
import { reportRedeemFailure } from '../lib/redeem-failure';
function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -23,6 +25,8 @@ export default function RedeemConfirmPage() {
const [storeName, setStoreName] = useState('');
const [preview, setPreview] = useState<Preview | null>(null);
const [storeClosed, setStoreClosed] = useState(false);
const [failCount, setFailCount] = useState(0);
const [showWeakNet, setShowWeakNet] = useState(false);
useEffect(() => {
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
@@ -52,9 +56,16 @@ export default function RedeemConfirmPage() {
body: JSON.stringify({ token }),
})
.then(setPreview)
.catch((e) => {
.catch(async (e) => {
setPreview(null);
setMsg(e instanceof Error ? e.message : '无法预览核销码');
const report = await reportRedeemFailure(token, 'preview', e);
if (report?.thresholdReached) {
setFailCount(report.failCount);
setShowWeakNet(true);
} else if (report) {
setFailCount(report.failCount);
}
});
}, [token]);
@@ -78,6 +89,13 @@ export default function RedeemConfirmPage() {
navigate('/redeem/success', { state: { result, storeName, user: preview?.user } });
} catch (e) {
setMsg(e instanceof Error ? e.message : '核销失败');
const report = await reportRedeemFailure(token, 'confirm', e);
if (report?.thresholdReached) {
setFailCount(report.failCount);
setShowWeakNet(true);
} else if (report) {
setFailCount(report.failCount);
}
} finally {
setLoading(false);
}
@@ -139,8 +157,10 @@ export default function RedeemConfirmPage() {
<div className="shop-redeem-details">
<div className="shop-redeem-detail-row">
<span></span>
<span>{token ? `${token.slice(-8)}` : '扫码后显示'}</span>
<span> ID</span>
<span style={{ wordBreak: 'break-all', maxWidth: '60%', textAlign: 'right' }}>
{token || '扫码后显示'}
</span>
</div>
<div className="shop-redeem-detail-row">
<span></span>
@@ -152,26 +172,39 @@ export default function RedeemConfirmPage() {
<span></span>
</div>
)}
{failCount > 0 && !showWeakNet && (
<div className="shop-redeem-detail-row">
<span></span>
<span>{failCount} / 5 </span>
</div>
)}
</div>
{msg && <p className="shop-redeem-error">{msg}</p>}
<button
type="button"
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
disabled={loading || storeClosed || !preview}
onClick={confirm}
>
<span className="material-symbols-outlined shop-fill-icon">
{loading ? 'sync' : 'check_circle'}
</span>
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'}</span>
</button>
<p className="shop-redeem-hint"></p>
{!showWeakNet && (
<>
<button
type="button"
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
disabled={loading || storeClosed || !preview}
onClick={() => void confirm()}
>
<span className="material-symbols-outlined shop-fill-icon">
{loading ? 'sync' : 'check_circle'}
</span>
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'}</span>
</button>
<p className="shop-redeem-hint"></p>
</>
)}
</div>
</section>
{showWeakNet && token && (
<WeakNetFallbackPanel redeemToken={token} failCount={failCount} />
)}
<div className="shop-redeem-ornament">
<span className="material-symbols-outlined" style={{ fontSize: 64, color: 'var(--color-heritage-red)' }}>
wine_bar
+65
View File
@@ -1203,6 +1203,71 @@
margin-top: 8px;
}
.shop-weaknet-panel {
margin: 0 16px 16px;
padding: 16px;
border-radius: 16px;
background: rgba(166, 29, 36, 0.06);
border: 1px solid rgba(166, 29, 36, 0.18);
}
.shop-weaknet-token {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.shop-weaknet-copy {
display: block;
width: 100%;
text-align: left;
word-break: break-all;
padding: 10px 12px;
border-radius: 10px;
border: 1px dashed rgba(166, 29, 36, 0.35);
background: #fff;
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--color-on-surface, #1c1b1b);
cursor: pointer;
}
.shop-weaknet-preview {
width: 100%;
max-height: 220px;
object-fit: contain;
border-radius: 12px;
margin-bottom: 12px;
background: #fff;
}
.shop-weaknet-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.shop-weaknet-success {
padding: 12px;
border-radius: 12px;
background: rgba(46, 125, 50, 0.08);
}
.shop-btn-outline {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 48px;
border-radius: 999px;
border: 1px solid var(--color-heritage-red, #a61d24);
background: transparent;
color: var(--color-heritage-red, #a61d24);
font-size: 15px;
font-weight: 600;
}
.shop-redeem-ornament {
margin-top: auto;
display: flex;
+15
View File
@@ -331,6 +331,21 @@ export default function RedeemPage() {
<p></p>
<p className="redeem-modal-amount-value">¥ {formatMoney(confirmAmount)}</p>
</div>
{token && (
<div style={{ marginTop: 12, textAlign: 'center' }}>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p
style={{
fontFamily: 'ui-monospace, monospace',
fontSize: 12,
wordBreak: 'break-all',
padding: '0 16px',
}}
>
{token}
</p>
</div>
)}
</div>
<button type="button" className="redeem-modal-cancel" onClick={closeModal}>