365 lines
18 KiB
Vue
365 lines
18 KiB
Vue
<script setup>
|
|
import { ref, onMounted, computed, watch } from 'vue';
|
|
import axios from 'axios';
|
|
import { usePageTitle } from '@/composables/Core/usePageTitle';
|
|
import { useNavigate } from '@/composables/Core/useNavigate';
|
|
import { useUIStore } from '@/stores/ui';
|
|
import { injectAmount, generateQrDataUrl } from '@/composables/useQrph';
|
|
|
|
usePageTitle('My Wallet');
|
|
const { navigate } = useNavigate();
|
|
const uiStore = useUIStore();
|
|
|
|
const wallet = ref({ balance: 0, credit: 0, history: [] });
|
|
const isLoading = ref(true);
|
|
const isProcessing = ref(false);
|
|
const topUpAmount = ref(100);
|
|
const showTopUpModal = ref(false);
|
|
const topUpTab = ref('amount'); // 'amount' | 'qrph'
|
|
|
|
const qrph = ref(null); // raw base QRPH string (no amount)
|
|
const qrphDecoded = ref(null); // decoded info object
|
|
const qrphImageUrl = ref(null); // stored static image (no amount)
|
|
const qrphAmountDataUrl = ref(null); // generated QR with amount injected
|
|
const isGeneratingQr = ref(false);
|
|
|
|
const isProceedDisabled = computed(() => {
|
|
return isProcessing.value || !topUpAmount.value || topUpAmount.value <= 0 || !uiStore.top_up_enabled;
|
|
});
|
|
|
|
// Regenerate QR with amount whenever the amount changes or the tab switches to qrph
|
|
const rebuildAmountQr = async () => {
|
|
if (!qrph.value || topUpTab.value !== 'qrph') return;
|
|
const amount = parseFloat(topUpAmount.value);
|
|
if (!amount || amount <= 0) {
|
|
// No amount — just render the base QRPH string as QR
|
|
isGeneratingQr.value = true;
|
|
try {
|
|
qrphAmountDataUrl.value = await generateQrDataUrl(qrph.value);
|
|
} finally {
|
|
isGeneratingQr.value = false;
|
|
}
|
|
return;
|
|
}
|
|
isGeneratingQr.value = true;
|
|
try {
|
|
const modified = injectAmount(qrph.value, amount);
|
|
qrphAmountDataUrl.value = await generateQrDataUrl(modified);
|
|
} finally {
|
|
isGeneratingQr.value = false;
|
|
}
|
|
};
|
|
|
|
watch([topUpTab, topUpAmount], ([tab]) => {
|
|
if (tab === 'qrph') rebuildAmountQr();
|
|
}, { immediate: false });
|
|
|
|
const fetchWalletData = async () => {
|
|
isLoading.value = true;
|
|
try {
|
|
const response = await axios.post('/Financial/Wallet/Get');
|
|
if (response.data.success) {
|
|
wallet.value = {
|
|
balance: response.data.balance,
|
|
credit: response.data.credit,
|
|
history: response.data.history
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch wallet data:', error);
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const fetchQrph = async () => {
|
|
try {
|
|
const response = await axios.post('/Financial/Qrph/Get');
|
|
if (response.data.success && response.data.qrph) {
|
|
qrph.value = response.data.qrph;
|
|
qrphDecoded.value = response.data.decoded;
|
|
qrphImageUrl.value = response.data.image_url || null;
|
|
}
|
|
} catch (error) {
|
|
// QRPH not configured — silent
|
|
}
|
|
};
|
|
|
|
const openTopUpModal = () => {
|
|
// Default to QRPH tab when a payment code is configured
|
|
topUpTab.value = qrph.value ? 'qrph' : 'amount';
|
|
qrphAmountDataUrl.value = null;
|
|
showTopUpModal.value = true;
|
|
if (qrph.value) rebuildAmountQr();
|
|
};
|
|
|
|
const handleTopUp = async () => {
|
|
if (isProceedDisabled.value) return;
|
|
|
|
isProcessing.value = true;
|
|
try {
|
|
if (window.toastr) window.toastr.info('Processing top-up...');
|
|
const response = await axios.post('/Financial/Credit/TopUp', {
|
|
amount: topUpAmount.value,
|
|
method: 'GCASH'
|
|
});
|
|
if (response.data.success) {
|
|
if (window.toastr) window.toastr.success('Top-up successful!');
|
|
showTopUpModal.value = false;
|
|
fetchWalletData();
|
|
}
|
|
} catch (error) {
|
|
if (window.toastr) window.toastr.error('Top-up failed');
|
|
} finally {
|
|
isProcessing.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
fetchWalletData();
|
|
fetchQrph();
|
|
uiStore.refreshSettings();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="my-wallet pb-5">
|
|
<div class="tf-container mt-4">
|
|
<!-- Balance Card -->
|
|
<div class="card border-0 shadow-lg rounded-25 wallet-gradient text-white p-4 mb-4 position-relative overflow-hidden">
|
|
<div class="position-absolute top-0 end-0 p-4 opacity-10">
|
|
<i class="fas fa-wallet fa-6x rotate-15"></i>
|
|
</div>
|
|
<div class="position-relative">
|
|
<p class="smallest fw_6 text-uppercase opacity-75 mb-1">Total Balance</p>
|
|
<h1 class="fw_8 mb-3">₱ {{ Number(wallet.balance).toLocaleString(undefined, {minimumFractionDigits: 2}) }}</h1>
|
|
|
|
<div class="d-flex gap-4">
|
|
<div>
|
|
<p class="smallest fw_6 text-uppercase opacity-75 mb-0">Total Credit</p>
|
|
<p class="fw_7 mb-0">₱ {{ Number(wallet.credit).toLocaleString() }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Grid -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-6">
|
|
<button @click="openTopUpModal"
|
|
:disabled="!uiStore.top_up_enabled && !qrph"
|
|
class="btn btn-white shadow-sm rounded-20 w-100 py-3 transition-all hover-up d-flex flex-column align-items-center gap-2">
|
|
<div :class="[uiStore.top_up_enabled ? 'bg-soft-primary text-primary' : 'bg-light text-muted', 'rounded-circle p-2']">
|
|
<i class="fas fa-plus"></i>
|
|
</div>
|
|
<span class="smallest fw_7">{{ !uiStore.top_up_enabled && !qrph ? 'Top Up (Disabled)' : 'Top Up' }}</span>
|
|
</button>
|
|
</div>
|
|
<div class="col-6">
|
|
<button @click="navigate({ page: 'TransferCredit' })" class="btn btn-white shadow-sm rounded-20 w-100 py-3 transition-all hover-up d-flex flex-column align-items-center gap-2">
|
|
<div class="bg-soft-info text-info rounded-circle p-2">
|
|
<i class="fas fa-paper-plane"></i>
|
|
</div>
|
|
<span class="smallest fw_7">Send Money</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Transaction History -->
|
|
<div class="transactions">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="fw_7 mb-0">Recent Activity</h5>
|
|
<button class="btn btn-link py-0 text-primary fw_6 smallest text-decoration-none">View All</button>
|
|
</div>
|
|
|
|
<div v-if="isLoading" class="text-center py-5">
|
|
<div class="spinner-border text-primary spinner-border-sm" role="status"></div>
|
|
</div>
|
|
|
|
<div v-else-if="wallet.history.length === 0" class="text-center py-5 bg-light rounded-25 opacity-75">
|
|
<i class="fas fa-receipt fa-3x text-muted mb-3 opacity-25"></i>
|
|
<p class="text-muted smaller">No transactions yet</p>
|
|
</div>
|
|
|
|
<div v-else class="transaction-list">
|
|
<div v-for="txn in wallet.history" :key="txn.hashkey" class="card border-0 shadow-sm rounded-20 p-3 mb-2 hover-card">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<div :class="[txn.flow === 'IN' ? 'bg-soft-success text-success' : 'bg-soft-danger text-danger', 'rounded-circle p-2 flex-shrink-0']" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;">
|
|
<i :class="txn.flow === 'IN' ? 'fas fa-arrow-down' : 'fas fa-arrow-up'"></i>
|
|
</div>
|
|
<div class="flex-grow-1 overflow-hidden">
|
|
<h6 class="fw_7 mb-0 text-dark text-truncate">{{ txn.description }}</h6>
|
|
<p class="smallest text-muted mb-0">{{ new Date(txn.created_at).toLocaleDateString() }} • {{ txn.transaction_type }}</p>
|
|
</div>
|
|
<div class="text-end">
|
|
<h6 :class="[txn.flow === 'IN' ? 'text-success' : 'text-danger', 'fw_7 mb-0']">
|
|
{{ txn.flow === 'IN' ? '+' : '-' }} ₱{{ Number(txn.amount).toLocaleString() }}
|
|
</h6>
|
|
<p class="smallest text-muted mb-0">₱{{ Number(txn.balance_after).toLocaleString() }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Top Up Modal -->
|
|
<div v-if="showTopUpModal" class="custom-modal-overlay" @click.self="showTopUpModal = false">
|
|
<div class="card border-0 shadow-lg rounded-25 p-4 w-100 mx-3 animate-slide-up" style="max-width: 420px;">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="fw_7 mb-0">Top Up Credit</h5>
|
|
<button @click="showTopUpModal = false" class="btn btn-sm btn-light rounded-circle" style="width:32px;height:32px;padding:0;">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tabs (only show QRPH tab if a code is configured) -->
|
|
<div v-if="qrph" class="d-flex gap-2 mb-4 p-1 rounded-pill" style="background:var(--bg-secondary, #f5f5f5);">
|
|
<button @click="topUpTab = 'amount'"
|
|
:class="['btn rounded-pill flex-fill py-2 smallest fw_7 transition-all', topUpTab === 'amount' ? 'btn-primary shadow-sm' : 'btn-link text-muted text-decoration-none']">
|
|
<i class="fas fa-coins me-1"></i> Enter Amount
|
|
</button>
|
|
<button @click="topUpTab = 'qrph'; rebuildAmountQr()"
|
|
:class="['btn rounded-pill flex-fill py-2 smallest fw_7 transition-all', topUpTab === 'qrph' ? 'btn-primary shadow-sm' : 'btn-link text-muted text-decoration-none']">
|
|
<i class="fas fa-qrcode me-1"></i> Scan to Pay
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Amount Tab -->
|
|
<div v-if="topUpTab === 'amount'">
|
|
<p class="smallest text-muted text-center mb-4">Select an amount to add to your balance</p>
|
|
|
|
<div class="row g-2 mb-4">
|
|
<div v-for="amt in [100, 200, 500, 1000]" :key="amt" class="col-6">
|
|
<button
|
|
@click="topUpAmount = amt"
|
|
:class="[topUpAmount === amt ? 'btn-primary' : 'btn-outline-primary', 'btn rounded-pill w-100 py-2 fw_7']">
|
|
₱ {{ amt }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group mb-4">
|
|
<label class="smallest fw_6 text-muted mb-1">Custom Amount</label>
|
|
<div class="input-group">
|
|
<span class="input-group-text bg-light border-0 rounded-start-pill ps-3">₱</span>
|
|
<input v-model="topUpAmount" type="number" class="form-control bg-light border-0 rounded-end-pill pe-3 fw_7" placeholder="Enter amount">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex gap-2">
|
|
<button @click="showTopUpModal = false" class="btn btn-light rounded-pill flex-fill py-2 fw_6" :disabled="isProcessing">Cancel</button>
|
|
<button @click="handleTopUp" :disabled="isProceedDisabled" class="btn btn-primary rounded-pill flex-fill py-2 fw_7 shadow-sm d-flex align-items-center justify-content-center gap-2">
|
|
<i v-if="isProcessing" class="fas fa-spinner fa-spin"></i>
|
|
{{ isProcessing ? 'Processing' : 'Proceed' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- QRPH / Scan to Pay Tab -->
|
|
<div v-else-if="topUpTab === 'qrph'" class="text-center">
|
|
|
|
<!-- Merchant Info Banner -->
|
|
<div v-if="qrphDecoded" class="d-flex align-items-center gap-2 mb-3 p-2 rounded-20" style="background:var(--bg-secondary,#f5f5f5);">
|
|
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
|
|
style="width:36px;height:36px;background:rgba(83,61,234,0.12);">
|
|
<i class="fas fa-university" style="color:#533dea;font-size:14px;"></i>
|
|
</div>
|
|
<div class="text-start">
|
|
<p class="mb-0 fw_7" style="font-size:13px;">{{ qrphDecoded.merchant_name || 'Merchant' }}</p>
|
|
<p class="mb-0 text-muted" style="font-size:11px;">
|
|
{{ qrphDecoded.merchant_account?.network || 'InstaPay' }}
|
|
<span v-if="qrphDecoded.merchant_account?.account"> · {{ qrphDecoded.merchant_account.account }}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Amount selector (compact, inside QR tab) -->
|
|
<div class="mb-3">
|
|
<p class="smallest text-muted mb-2">Set amount to embed in QR <span class="text-muted opacity-50">(optional)</span></p>
|
|
<div class="d-flex gap-2 justify-content-center flex-wrap mb-2">
|
|
<button v-for="amt in [100, 200, 500, 1000]" :key="amt"
|
|
@click="topUpAmount = amt; rebuildAmountQr()"
|
|
:class="['btn btn-sm rounded-pill fw_7', topUpAmount === amt ? 'btn-primary' : 'btn-outline-primary']"
|
|
style="font-size:12px;min-width:56px;">
|
|
₱{{ amt }}
|
|
</button>
|
|
</div>
|
|
<div class="input-group input-group-sm mx-auto" style="max-width:180px;">
|
|
<span class="input-group-text bg-light border-0 rounded-start-pill ps-3 smallest">₱</span>
|
|
<input v-model="topUpAmount" type="number" min="1"
|
|
class="form-control bg-light border-0 rounded-end-pill pe-3 fw_7 smallest"
|
|
placeholder="Custom">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- QR Image: generated client-side with amount injected -->
|
|
<div class="qrph-qr-wrapper mx-auto mb-2" style="background:#fff;border-radius:16px;padding:12px;display:inline-block;box-shadow:0 2px 12px rgba(0,0,0,0.08);">
|
|
<div v-if="isGeneratingQr" style="width:220px;height:220px;display:flex;align-items:center;justify-content:center;">
|
|
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
|
|
</div>
|
|
<img v-else-if="qrphAmountDataUrl"
|
|
:src="qrphAmountDataUrl"
|
|
alt="QRPH Payment Code"
|
|
class="rounded-15"
|
|
style="width:220px;height:220px;object-fit:contain;display:block;" />
|
|
<img v-else-if="qrphImageUrl"
|
|
:src="qrphImageUrl"
|
|
alt="QRPH Payment Code"
|
|
class="rounded-15"
|
|
style="width:220px;height:220px;object-fit:contain;display:block;" />
|
|
</div>
|
|
|
|
<!-- Amount badge below QR -->
|
|
<div v-if="topUpAmount > 0" class="mb-3">
|
|
<span class="badge rounded-pill px-3 py-2 fw_7" style="background:rgba(83,61,234,0.12);color:#533dea;font-size:14px;">
|
|
₱ {{ Number(topUpAmount).toLocaleString(undefined, {minimumFractionDigits: 2}) }}
|
|
</span>
|
|
<p class="smallest text-muted mt-1 mb-0">embedded in QR — your banking app will pre-fill this amount</p>
|
|
</div>
|
|
|
|
<p class="smallest text-muted mb-3">
|
|
<i class="fas fa-info-circle me-1 text-primary"></i>
|
|
Scan with GCash, Maya, GoTyme, or any InstaPay app · After payment, contact admin with your reference number.
|
|
</p>
|
|
|
|
<button @click="showTopUpModal = false" class="btn btn-light rounded-pill w-100 py-2 fw_6">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.rounded-25 { border-radius: 25px; }
|
|
.rounded-20 { border-radius: 20px; }
|
|
.wallet-gradient {
|
|
background: linear-gradient(135deg, var(--primary) 0%, #1e40af 100%);
|
|
}
|
|
.bg-soft-primary { background-color: rgba(var(--primary-rgb), 0.1); }
|
|
.bg-soft-info { background-color: rgba(0, 184, 217, 0.1); }
|
|
.bg-soft-success { background-color: rgba(40, 167, 69, 0.1); }
|
|
.bg-soft-danger { background-color: rgba(220, 53, 69, 0.1); }
|
|
.text-info { color: #00B8D9 !important; }
|
|
.rotate-15 { transform: rotate(-15deg); }
|
|
.hover-up:hover { transform: translateY(-3px); }
|
|
.hover-card:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(0,0,0,0.05) !important; }
|
|
.smallest { font-size: 0.75rem; }
|
|
.smaller { font-size: 0.85rem; }
|
|
|
|
.custom-modal-overlay {
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: rgba(0,0,0,0.4);
|
|
backdrop-filter: blur(4px);
|
|
z-index: 10000;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from { transform: translateY(20px); opacity: 0; }
|
|
to { transform: translateY(0); opacity: 1; }
|
|
}
|
|
.animate-slide-up { animation: slideUp 0.3s ease-out; }
|
|
</style>
|