Files
BarangaySystem/resources/js/Pages/MyWallet.vue
2026-06-06 18:43:00 +08:00

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>