Files
thebet365/apps/player/src/views/MobileWalletTransactionDetailView.vue

389 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import { txTypeKey, isCashbackType, txDisplayType, txAmountClass, txSummaryLabel, txRemarkLabel, isDepositReversalType } from '../utils/walletTx';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
type TransactionDetail = {
transactionId: string;
transactionType: string;
displayType?: string;
summary?: string | null;
summaryKind?: 'opening_bonus' | null;
amount: string;
balanceBefore: string;
balanceAfter: string;
frozenBefore: string;
frozenAfter: string;
referenceType: string | null;
referenceId: string | null;
remark: string | null;
createdAt: string;
betNo: string | null;
cashbackBatchNo?: string | null;
};
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const tx = ref<TransactionDetail | null>(null);
const loading = ref(true);
const notFound = ref(false);
async function loadTransaction() {
loading.value = true;
try {
const { data } = await api.get(`/player/wallet/transactions/${route.params.transactionId}`);
if (!data.data) {
notFound.value = true;
return;
}
tx.value = data.data;
} catch {
notFound.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadTransaction);
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: loadTransaction,
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
function txLabel(type: string): string {
const key = txTypeKey(type);
if (key) {
const translated = t(key);
if (translated !== key) return translated;
}
return type;
}
const displayTypeLabel = computed(() => {
if (!tx.value) return '';
return txLabel(txDisplayType(tx.value));
});
const summaryText = computed(() => {
if (!tx.value) return '';
return txSummaryLabel(tx.value, t);
});
const amountClass = computed(() => (tx.value ? txAmountClass(tx.value.amount) : 'zero'));
const formattedTime = computed(() => {
if (!tx.value) return '';
return new Date(tx.value.createdAt).toLocaleString(locale.value);
});
const frozenChanged = computed(() => {
if (!tx.value) return false;
return tx.value.frozenBefore !== tx.value.frozenAfter;
});
const isCashbackTx = computed(
() => !!tx.value && isCashbackType(tx.value.transactionType),
);
const cashbackBatchNo = computed(() => {
if (!isCashbackTx.value) return null;
return tx.value?.cashbackBatchNo ?? tx.value?.referenceId ?? null;
});
const referenceLabel = computed(() => {
if (isCashbackTx.value) return t('wallet.ref_cashback');
if (!tx.value?.referenceType) return '';
if (isDepositReversalType(tx.value.transactionType)) return t('wallet.ref_deposit');
const rt = tx.value.referenceType.toUpperCase();
if (rt === 'BET') return t('wallet.ref_bet');
if (rt === 'DEPOSIT') return t('wallet.ref_deposit');
if (rt === 'WITHDRAW') return t('wallet.ref_withdraw');
return tx.value.referenceType;
});
const remarkText = computed(() => {
if (!tx.value) return '';
return txRemarkLabel(tx.value, t);
});
function goBetDetail() {
if (!tx.value?.betNo) return;
router.push(`/bets/${tx.value.betNo}`);
}
function goCashbackDetail() {
if (!cashbackBatchNo.value) return;
router.push({ path: '/wallet/cashbacks', query: { batchNo: cashbackBatchNo.value } });
}
</script>
<template>
<div class="detail-page">
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<header class="top-bar sub-toolbar">
<button class="back-btn" type="button" @click="router.back()"> {{ t('history.back') }}</button>
</header>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="notFound || !tx" class="state">{{ t('wallet.detail_not_found') }}</div>
<template v-else>
<div class="hero" :class="amountClass">
<span class="hero-type">{{ displayTypeLabel }}</span>
<span v-if="summaryText" class="hero-summary">{{ summaryText }}</span>
<span class="hero-amount">{{ formatMoney(tx.amount, locale) }}</span>
<span class="hero-time">{{ formattedTime }}</span>
</div>
<section class="section">
<div class="section-title">{{ t('wallet.detail_summary') }}</div>
<div class="summary-rows">
<div class="sum-row">
<span>{{ t('wallet.detail_amount') }}</span>
<span :class="amountClass">{{ formatMoney(tx.amount, locale) }}</span>
</div>
<div class="sum-row">
<span>{{ t('wallet.detail_balance_before') }}</span>
<span>{{ formatMoney(tx.balanceBefore, locale) }}</span>
</div>
<div class="sum-row">
<span>{{ t('wallet.detail_balance_after') }}</span>
<span>{{ formatMoney(tx.balanceAfter, locale) }}</span>
</div>
<template v-if="frozenChanged">
<div class="sum-row">
<span>{{ t('wallet.detail_frozen_before') }}</span>
<span>{{ formatMoney(tx.frozenBefore, locale) }}</span>
</div>
<div class="sum-row">
<span>{{ t('wallet.detail_frozen_after') }}</span>
<span>{{ formatMoney(tx.frozenAfter, locale) }}</span>
</div>
</template>
</div>
</section>
<section v-if="tx.referenceType || remarkText" class="section">
<div class="section-title">{{ t('wallet.detail_reference') }}</div>
<div class="summary-rows">
<div v-if="tx.referenceType" class="sum-row">
<span>{{ t('wallet.detail_reference_type') }}</span>
<span>{{ referenceLabel }}</span>
</div>
<div v-if="tx.referenceId && !tx.betNo" class="sum-row">
<span>{{ t('wallet.detail_reference_id') }}</span>
<span class="mono">{{ tx.referenceId }}</span>
</div>
<div v-if="remarkText" class="sum-row">
<span>{{ t('wallet.detail_remark') }}</span>
<span class="remark">{{ remarkText }}</span>
</div>
</div>
<button v-if="tx.betNo" type="button" class="bet-link" @click="goBetDetail">
{{ t('wallet.detail_bet_link') }} · {{ tx.betNo }}
</button>
<button v-if="cashbackBatchNo" type="button" class="bet-link" @click="goCashbackDetail">
{{ t('wallet.detail_cashback_link') }} · {{ cashbackBatchNo }}
</button>
</section>
<section class="section">
<div class="section-title">{{ t('wallet.detail_tx_id') }}</div>
<div class="tx-id">{{ tx.transactionId }}</div>
</section>
</template>
</div>
</template>
<style scoped>
.detail-page {
padding-bottom: 32px;
}
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.top-bar {
margin-bottom: 4px;
}
.back-btn {
background: none;
border: none;
color: var(--primary-light);
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
}
.state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
.hero {
border-radius: var(--radius);
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-bottom: 12px;
text-align: center;
background: var(--bg-card);
border: 1px solid var(--border);
}
.hero.pos {
border-color: rgba(46, 204, 113, 0.35);
background: linear-gradient(135deg, rgba(46, 204, 113, 0.16), rgba(46, 204, 113, 0.05));
}
.hero.neg {
border-color: rgba(255, 255, 255, 0.35);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.05));
}
.hero.zero {
border-color: var(--border);
background: var(--bg-elevated);
}
.hero-type {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
}
.hero-summary {
font-size: 13px;
font-weight: 600;
color: var(--text);
max-width: 100%;
word-break: break-all;
}
.hero-amount {
font-size: 34px;
font-weight: 900;
line-height: 1.1;
}
.hero.pos .hero-amount { color: var(--success); }
.hero.neg .hero-amount { color: var(--primary); }
.hero.zero .hero-amount { color: var(--text-muted); }
.hero-time {
font-size: 11px;
color: var(--neutral);
font-weight: 600;
}
.section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-card) 16px;
margin-bottom: var(--space-section);
}
.section-title {
font-size: 12px;
font-weight: 800;
color: var(--text-muted);
letter-spacing: 0.04em;
margin-bottom: 10px;
}
.summary-rows {
display: flex;
flex-direction: column;
gap: 10px;
}
.sum-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
font-size: 13px;
color: var(--text-muted);
}
.sum-row span:last-child {
color: var(--text);
font-weight: 700;
text-align: right;
word-break: break-all;
}
.pos { color: var(--success) !important; }
.neg { color: var(--primary) !important; }
.zero { color: var(--text-muted) !important; }
.mono {
font-family: ui-monospace, monospace;
font-size: 12px;
}
.remark {
max-width: 60%;
line-height: 1.4;
}
.bet-link {
width: 100%;
margin-top: 12px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border-gold-soft);
background: var(--surface-accent);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.tx-id {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--text-muted);
word-break: break-all;
line-height: 1.5;
}
</style>