Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion public/configs/default-chains.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@
],
"prefix": "b",
"decimals": 8,
"apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bf-meta.org/wallet/bfmetav2" }]
"apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bf-meta.org/wallet/bfmetav2" }],
"explorer": {
"url": "https://tracker.bf-meta.org",
"queryTx": "https://tracker.bf-meta.org/#/info/event-details/:signature",
"queryAddress": "https://tracker.bf-meta.org/#/info/address-details/:address",
"queryBlock": "https://tracker.bf-meta.org/#/info/block-details/:height"
}
},
{
"id": "bfmeta",
Expand Down
69 changes: 69 additions & 0 deletions src/components/common/copyable-text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { IconCopy as Copy, IconCheck as Check, IconX as Failed } from '@tabler/icons-react'
import { clipboardService } from '@/services/clipboard'

type CopyStatus = 'idle' | 'copied' | 'failed'

interface CopyableTextProps {
text: string
displayText?: string
className?: string
iconClassName?: string
onCopy?: () => void
testId?: string
}

export function CopyableText({
text,
displayText,
className,
iconClassName,
onCopy,
testId,
}: CopyableTextProps) {
const { t } = useTranslation('common')
const [status, setStatus] = useState<CopyStatus>('idle')

const handleCopy = useCallback(async () => {
try {
await clipboardService.write({ text })
setStatus('copied')
onCopy?.()
setTimeout(() => setStatus('idle'), 2000)
} catch {
setStatus('failed')
setTimeout(() => setStatus('idle'), 2000)
}
}, [text, onCopy])

const StatusIcon = status === 'copied' ? Check : status === 'failed' ? Failed : Copy
const iconColor =
status === 'copied'
? 'text-green-500'
: status === 'failed'
? 'text-red-500'
: 'text-muted-foreground'

return (
<button
type="button"
onClick={handleCopy}
{...(testId && { 'data-testid': testId })}
className={cn(
'inline-flex items-center gap-1.5 font-mono transition-colors',
'hover:text-primary focus-visible:ring-ring rounded focus:outline-none focus-visible:ring-2',
className,
)}
title={text}
aria-label={status === 'copied' ? t('copiedToClipboard') : t('clickToCopy')}
>
<span className="break-all">{displayText ?? text}</span>
<StatusIcon className={cn('size-4 shrink-0', iconColor, iconClassName)} aria-hidden="true" />
<span role="status" aria-live="polite" className="sr-only">
{status === 'copied' ? t('copiedToClipboard') : ''}
</span>
</button>
)
}
1 change: 1 addition & 0 deletions src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export { FormField } from './form-field'
export { Alert } from './alert'
export { StepIndicator, ProgressSteps } from './step-indicator'
export { ErrorBoundary } from './error-boundary'
export { CopyableText } from './copyable-text'
1 change: 1 addition & 0 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"contractRiskWarning": "The contract carries a certain level of risk. Users are requested to confirm the security of the contract code themselves. If authorized, the risk will be confirmed",
"copied": "Copied",
"copiedToClipboard": "Copied to clipboard",
"clickToCopy": "Click to copy",
"copy": "Copy",
"copyPrivateKey": "Copy Private key",
"copySuccess": "Copied successfully",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en/transaction.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
"title": "Transaction Details",
"toAddress": "To Address",
"viewExplorer": "View in Explorer",
"openInExplorer": "Open in Explorer",
"share": "Share"
},
"method": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"balance": "余额",
"exceedsBalance": "超出可用余额",
"copiedToClipboard": "已复制到剪贴板",
"clickToCopy": "点击复制",
"passwordStrength": "强度",
"a11y": {
"skipToMain": "跳转到主要内容",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-CN/transaction.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"copied": "已复制",
"copyHash": "复制哈希",
"viewExplorer": "区块浏览器",
"openInExplorer": "在浏览器中打开",
"share": "分享"
},
"method": {
Expand Down
69 changes: 26 additions & 43 deletions src/pages/history/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigation, useActivityParams } from '@/stackflow';
import {
IconCopy as Copy,
IconCheck as Check,
IconShare as Share,
IconExternalLink as ExternalLink,
} from '@tabler/icons-react';
import { PageHeader } from '@/components/layout/page-header';
import { AddressDisplay } from '@/components/wallet/address-display';
import { AmountDisplay, TimeDisplay } from '@/components/common';
import { AmountDisplay, TimeDisplay, CopyableText } from '@/components/common';
import { TransactionStatus as TransactionStatusBadge } from '@/components/transaction/transaction-status';
import { FeeDisplay } from '@/components/transaction/fee-display';
import { SkeletonCard } from '@/components/common/skeleton';
Expand Down Expand Up @@ -55,7 +55,7 @@ export function TransactionDetailPage() {
const chainConfigState = useChainConfigState();
const { transactions, isLoading } = useTransactionHistoryQuery(currentWallet?.id);

const [copied, setCopied] = useState(false);


const txFromHistory = useMemo<TransactionRecord | undefined>(() => {
return transactions.find((tx) => tx.id === txId);
Expand Down Expand Up @@ -92,21 +92,12 @@ export function TransactionDetailPage() {
return queryTx.replace(':signature', hash);
}, [chainConfig?.explorer?.queryTx, parsedTxId?.hash, transaction?.hash]);

// 复制交易哈希
const handleCopyHash = useCallback(async () => {
if (transaction?.hash) {
await clipboardService.write({ text: transaction.hash });
setCopied(true);
setTimeout(() => setCopied(false), 2000);
// 在浏览器中打开
const handleOpenInExplorer = useCallback(() => {
if (explorerTxUrl) {
window.open(explorerTxUrl, '_blank', 'noopener,noreferrer');
}
}, [transaction?.hash]);

const handleCopyFallbackHash = useCallback(async () => {
if (!parsedTxId?.hash) return;
await clipboardService.write({ text: parsedTxId.hash });
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [parsedTxId?.hash]);
}, [explorerTxUrl]);

// 分享(复制浏览器链接)
const [shared, setShared] = useState(false);
Expand Down Expand Up @@ -162,28 +153,24 @@ export function TransactionDetailPage() {
{fallbackHash && (
<div className="bg-card space-y-3 rounded-xl p-4 shadow-sm">
<h3 className="text-muted-foreground text-sm font-medium">{t('detail.hash')}</h3>
<p className="text-muted-foreground font-mono text-xs break-all">{fallbackHash}</p>
<CopyableText
text={fallbackHash}
className="text-muted-foreground text-xs"
/>

<div className="flex gap-2">
<button
onClick={handleCopyFallbackHash}
onClick={handleOpenInExplorer}
disabled={!explorerTxUrl}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-lg py-2.5',
'bg-background border transition-colors',
'hover:bg-muted active:bg-muted/80',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{copied ? (
<>
<Check className="text-green-500 size-4" />
<span className="text-sm">{t('detail.copied')}</span>
</>
) : (
<>
<Copy className="size-4" />
<span className="text-sm">{t('detail.copyHash')}</span>
</>
)}
<ExternalLink className="size-4" />
<span className="text-sm">{t('detail.openInExplorer')}</span>
</button>

<button
Expand Down Expand Up @@ -473,28 +460,24 @@ export function TransactionDetailPage() {
{transaction.hash && (
<div className="bg-card space-y-3 rounded-xl p-4 shadow-sm">
<h3 className="text-muted-foreground text-sm font-medium">{t('detail.hash')}</h3>
<p className="text-muted-foreground font-mono text-xs break-all">{transaction.hash}</p>
<CopyableText
text={transaction.hash}
className="text-muted-foreground text-xs"
/>

<div className="flex gap-2">
<button
onClick={handleCopyHash}
onClick={handleOpenInExplorer}
disabled={!explorerTxUrl}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-lg py-2.5',
'bg-background border transition-colors',
'hover:bg-muted active:bg-muted/80',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{copied ? (
<>
<Check className="text-green-500 size-4" />
<span className="text-sm">{t('detail.copied')}</span>
</>
) : (
<>
<Copy className="size-4" />
<span className="text-sm">{t('detail.copyHash')}</span>
</>
)}
<ExternalLink className="size-4" />
<span className="text-sm">{t('detail.openInExplorer')}</span>
</button>

<button
Expand Down