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

1203 lines
48 KiB
Vue
Raw Permalink 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.

<template>
<div v-if="userStore.loading && !userStore.user" class="loading-container d-flex justify-content-center align-items-center" style="min-height: 50vh;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-else-if="!userStore.isLoggedIn" class="login-redirect-container mt-5">
<center><h3 style="padding:10px;">Please Login</h3></center>
<Login />
</div>
<div v-else class="account-settings-page">
<br><br><br>
<div class="mt-1">
<div class="tf-container">
<CardSimple class="mb-4">
<div class="inner d-flex flex-column align-items-center justify-content-center py-3">
<div class="box-avatar" @click="viewFullScreen" style="position: relative; cursor: pointer;">
<img :src="displayPhotoUrl" id="account_settings_profile_picture" alt="image" style="object-fit: cover;">
<div class="camera-edit-badge" @click.stop="triggerFilePicker">
<i class="fas fa-camera"></i>
</div>
<input type="file" ref="fileInput" @change="onFileSelected" accept="image/*" style="display: none;">
</div>
<div class="info text-center">
<h2 class="fw_8 mt-3 mb-1" style="color: var(--text-primary);">{{ userStore.fullName }}</h2>
<p class="text-muted small">@{{ userStore.user?.username }}</p>
</div>
</div>
<!-- Balance Card -->
<div class="balance-summary-card mx-3 mb-3 p-3 rounded-20 bg-primary text-white d-flex justify-content-between align-items-center shadow-sm" @click="navigate({ page: 'MyWallet' })" style="cursor: pointer;">
<div>
<p class="smallest fw_6 mb-0 text-white-50">Current Balance</p>
<h3 class="fw_8 mb-0">{{ userStore.user?.total_balance || 0 }}</h3>
</div>
<div class="bg-white-20 rounded-circle p-2">
<i class="fas fa-chevron-right text-white"></i>
</div>
</div>
<ul class="list-user-info-container mt-4 mb-0 list-unstyled">
<li class="list-user-info d-flex align-items-center p-3">
<i class="fas fa-user-circle me-3 text-primary"></i>
<h5 class="mb-0">{{ userStore.fullName }}</h5>
</li>
<li v-if="userStore.mobileNumber" class="list-user-info d-flex align-items-center p-3">
<i class="fas fa-mobile-alt me-3 text-primary"></i>
<h5 class="mb-0">{{ userStore.mobileNumber }}</h5>
</li>
<li v-if="userStore.email" class="list-user-info d-flex align-items-center p-3">
<i class="fas fa-envelope me-3 text-primary"></i>
<h5 class="mb-0">{{ userStore.email }}</h5>
</li>
<li class="list-user-info border-none d-flex align-items-center p-3">
<i class="fas fa-id-badge me-3 text-primary"></i>
<h5 class="mb-0">Account Type: {{ accountTypeName }}</h5>
</li>
</ul>
</CardSimple>
</div>
</div>
<div class="tf-container">
<CardSimple title="Settings & Tools">
<ul class="box-service mt-3">
<li>
<a href="javascript:void(0);" @click="toggleDarkMode">
<div class="icon-box" style="background: var(--accent-soft); border-radius: 12px;">
<i :class="uiStore.darkMode ? 'fas fa-sun' : 'fas fa-moon'" style="font-size: 24px; color: #533dea;"></i>
</div>
<span class="mt-2" style="font-size: 13px; font-weight: 600;">{{ uiStore.darkMode ? 'Light' : 'Dark' }}</span>
</a>
</li>
<li>
<a href="javascript:void(0);" @click="openChangePasswordModal">
<div class="icon-box" style="background: var(--accent-soft); border-radius: 12px;">
<i class="fas fa-key" style="font-size: 24px; color: var(--accent-color);"></i>
</div>
<span class="mt-2" style="font-size: 13px; font-weight: 600;">Password</span>
</a>
</li>
<li>
<a href="javascript:void(0);" @click="clearCacheAndReload">
<div class="icon-box" style="background: rgba(57, 163, 248, 0.15); border-radius: 12px;">
<i class="fas fa-sync" style="font-size: 24px; color: #39a3f8;"></i>
</div>
<span class="mt-2" style="font-size: 13px; font-weight: 600;">Reset App</span>
</a>
</li>
<li>
<a href="javascript:void(0);" @click="showNotes">
<div class="icon-box" style="background: rgba(242, 195, 28, 0.15); border-radius: 12px;">
<i class="fas fa-sticky-note" style="font-size: 24px; color: #f2c71c;"></i>
</div>
<span class="mt-2" style="font-size: 13px; font-weight: 600;">Notes</span>
</a>
</li>
<li>
<a href="javascript:void(0);" @click="openTransferBalanceModal">
<div class="icon-box" style="background: rgba(40, 199, 111, 0.15); border-radius: 12px;">
<i class="fas fa-exchange-alt" style="font-size: 24px; color: #28c76f;"></i>
</div>
<span class="mt-2" style="font-size: 13px; font-weight: 600;">Transfer</span>
</a>
</li>
<li>
<a href="javascript:void(0);" @click="navigate({ page: 'MyWallet' })">
<div class="icon-box" style="background: rgba(30, 64, 175, 0.15); border-radius: 12px;">
<i class="fas fa-wallet" style="font-size: 24px; color: #1e40af;"></i>
</div>
<span class="mt-2" style="font-size: 13px; font-weight: 600;">My Wallet</span>
</a>
</li>
<li>
<a href="javascript:void(0);" @click="showTableDensityModal">
<div class="icon-box" style="background: rgba(83, 61, 234, 0.15); border-radius: 12px;">
<i class="fas fa-bars" style="font-size: 24px; color: #533dea;"></i>
</div>
<span class="mt-2" style="font-size: 13px; font-weight: 600;">Density</span>
</a>
</li>
<li>
<a href="javascript:void(0);" @click="logout">
<div class="icon-box" style="background: rgba(234, 52, 52, 0.15); border-radius: 12px;">
<i class="fas fa-sign-out-alt" style="font-size: 24px; color: #ea3434;"></i>
</div>
<span class="mt-2" style="font-size: 13px; font-weight: 600;">Logout</span>
</a>
</li>
</ul>
</CardSimple>
<!-- QRPH Payment Code ULTIMATE only -->
<CardSimple v-if="userStore.acctType === 'ult'" title="Payment QR Code (QRPH)" class="mb-4">
<div class="p-3">
<p class="smallest text-muted mb-3">
Set your personal QRPH string (from GCash, Maya, or your bank's "Receive" QR) so members can scan it when topping up their wallet.
</p>
<!-- Current code preview -->
<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="flex-grow-1 text-start overflow-hidden">
<p class="mb-0 fw_7 text-truncate" style="font-size:13px;">{{ qrphDecoded.merchant_name || 'Merchant' }}</p>
<p class="mb-0 text-muted" style="font-size:11px;">
{{ qrphDecoded.merchant_account?.network || '' }}
<span v-if="qrphDecoded.merchant_account?.account"> · {{ qrphDecoded.merchant_account.account }}</span>
<span v-if="!qrphDecoded.valid" class="text-warning ms-1"><i class="fas fa-exclamation-triangle"></i> CRC mismatch</span>
<span v-else class="text-success ms-1"><i class="fas fa-check-circle"></i> Valid</span>
</p>
</div>
<button @click="openQrphModal" class="btn btn-sm btn-outline-primary rounded-pill smallest fw_6 flex-shrink-0">
Update
</button>
</div>
<button v-else @click="openQrphModal" class="btn btn-primary rounded-pill w-100 py-2 fw_7 d-flex align-items-center justify-content-center gap-2">
<i class="fas fa-qrcode"></i> Set QRPH Code
</button>
</div>
</CardSimple>
<div class="text-center mt-5 mb-2">
<p class="mb-0" style="font-size: 10px; color: #a0a0a0; font-weight: 700; letter-spacing: 0.8px; text-transform: uppercase; opacity: 0.6;">
{{ uiStore.appName }}
</p>
<p class="mb-0" style="font-size: 11px; color: #533dea; font-weight: 600; opacity: 0.8;">
Version v{{ appVersion }} (Build {{ buildId }})
</p>
</div>
</div>
<br><br>
</div>
</template>
<script>
import { defineComponent, computed, h, ref, onMounted } from 'vue';
import { useAuth } from '../composables/Core/useAuth.js';
import { useModal } from '../composables/Core/useModal.js';
import { useNavigate } from '../composables/Core/useNavigate.js';
import axios from 'axios';
import Login from './Auth/Login.vue';
import { usePageTitle } from '../composables/Core/usePageTitle';
import { useUserNotes } from '../composables/useUserNotes';
import { useUIStore } from '../stores/ui.js';
import { useUserSettings } from '../composables/useUserSettings.js';
import TableDensityToggle from '../Components/Core/TableDensityToggle.vue';
import CardSimple from '../Components/Core/CardSimple.vue';
import InputGroupButton from '../Components/Core/Forms/InputGroupButton.vue';
import { getUserTypeLabel } from '../utils/userTypeLabels.js';
export default defineComponent({
name: 'AccountSettings',
components: {
Login
},
setup() {
const { user, isPublic, userStore } = useAuth();
const { fetchNotes, dismissNotes, notes, loading, error } = useUserNotes();
usePageTitle('Account Settings');
const modal = useModal();
const { navigate } = useNavigate();
const uiStore = useUIStore();
const { settings, updateSetting } = useUserSettings();
const fileInput = ref(null);
const isUpdatingPhoto = ref(false);
const photoCacheBust = ref(Date.now());
const displayPhotoUrl = computed(() => {
const url = userStore.photoUrl;
// If it's the placeholder from UI Avatars, return as-is
if (url.includes('ui-avatars.com')) return url;
// Add cache busting for real photos
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}t=${photoCacheBust.value}`;
});
const triggerFilePicker = () => {
fileInput.value.click();
};
const viewFullScreen = () => {
modal.open({
title: false,
body: h('div', {
class: 'full-screen-image-container d-flex justify-content-center align-items-center',
style: 'min-height: 50vh; background: #000; border-radius: 20px; overflow: hidden; position: relative; padding: 10px;'
}, [
h('img', {
src: displayPhotoUrl.value,
style: 'max-width: 100%; max-height: 80vh; object-fit: contain; border-radius: 12px;'
}),
h('button', {
class: 'btn-close btn-close-white',
style: 'position: absolute; top: 15px; right: 15px; z-index: 10; background-color: rgba(255,255,255,0.2); padding: 12px; border-radius: 50%; border: none; font-size: 20px; line-height: 1; color: white;',
onClick: modal.close
}, '×')
]),
footer: null
});
};
const onFileSelected = async (event) => {
const file = event.target.files[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
modal.open({
title: 'Invalid File',
body: 'Please select an image file.',
footer: h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close')
});
return;
}
// Validate file size (e.g., 5MB)
if (file.size > 5 * 1024 * 1024) {
modal.open({
title: 'File Too Large',
body: 'Image size should be less than 5MB.',
footer: h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close')
});
return;
}
const formData = new FormData();
formData.append('photo', file);
try {
isUpdatingPhoto.value = true;
modal.open({
title: 'Updating Photo',
body: h('div', { class: 'text-center py-4' }, [
h('div', { class: 'spinner-border text-primary', role: 'status' }),
h('p', { class: 'mt-2' }, 'Uploading your new photo...')
])
});
const response = await axios.post('/user/updatephoto', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (response.data && response.data.success) {
modal.open({
title: 'Success',
body: 'Profile photo updated successfully.',
footer: h('button', {
class: 'btn btn-primary',
onClick: () => {
modal.close();
// Refresh full user data in background
userStore.fetchCurrentUser().then(() => {
// Force UI update with cache bust again after fetch
photoCacheBust.value = Date.now();
});
}
}, 'OK')
});
} else {
modal.open({
title: 'Failed',
body: 'Failed to update profile photo: ' + (response.data.message || 'Unknown error'),
footer: h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close')
});
}
} catch (error) {
modal.open({
title: 'Error',
body: 'An error occurred while uploading the photo: ' + (error.response?.data?.message || error.message),
footer: h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close')
});
} finally {
isUpdatingPhoto.value = false;
}
};
// For password change binding
const oldPassword = ref('');
const newPassword = ref('');
const newPasswordConfirm = ref('');
const isChangingPassword = ref(false);
const passwordError = ref('');
const openChangePasswordModal = () => {
oldPassword.value = '';
newPassword.value = '';
newPasswordConfirm.value = '';
passwordError.value = '';
const ChangePasswordForm = {
setup() {
return () => h('div', { class: 'change-password-modal-body' }, [
passwordError.value ? h('div', { class: 'alert alert-error mb-3' }, passwordError.value) : null,
h('div', { class: 'form-group mb-3' }, [
h('div', { class: 'input-group' }, [
h('div', { class: 'input-group-prepend' }, [
h('span', { class: 'input-group-text' }, [
h('i', { class: 'icon-secure1', style: 'font-size: 20px; color: #533dea;' })
])
]),
h('input', {
type: 'password',
class: 'form-control',
placeholder: 'Current Password',
style: 'height: 50px; border-left: none;',
value: oldPassword.value,
onInput: e => oldPassword.value = e.target.value
})
])
]),
h('div', { class: 'form-group mb-3' }, [
h('div', { class: 'input-group' }, [
h('div', { class: 'input-group-prepend' }, [
h('span', { class: 'input-group-text' }, [
h('i', { class: 'icon-secure1', style: 'font-size: 20px; color: #533dea;' })
])
]),
h('input', {
type: 'password',
class: 'form-control',
placeholder: 'New Password',
style: 'height: 50px; border-left: none;',
value: newPassword.value,
onInput: e => newPassword.value = e.target.value
})
])
]),
h('div', { class: 'form-group mb-3' }, [
h('div', { class: 'input-group' }, [
h('div', { class: 'input-group-prepend' }, [
h('span', { class: 'input-group-text' }, [
h('i', { class: 'icon-secure1', style: 'font-size: 20px; color: #533dea;' })
])
]),
h('input', {
type: 'password',
class: 'form-control',
placeholder: 'Confirm Password',
style: 'height: 50px; border-left: none;',
value: newPasswordConfirm.value,
onInput: e => newPasswordConfirm.value = e.target.value
})
])
])
]);
}
};
modal.yesNoModal({
title: 'Change Password',
body: h(ChangePasswordForm),
yesText: 'Change Password',
noText: 'Close',
onYes: () => {
submitChangePassword();
}
});
};
const submitChangePassword = async () => {
if (!oldPassword.value || newPassword.value.length < 6 || newPassword.value !== newPasswordConfirm.value) {
modal.open({
title: 'Error',
body: 'Invalid password inputs. Password must be at least 6 characters and match.',
footer: h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close')
});
return;
}
try {
isChangingPassword.value = true;
const response = await axios.post('/user/changemypassword', {
current_password: oldPassword.value,
new_password: newPassword.value,
new_confirm_password: newPasswordConfirm.value
});
if (response.data === true || (typeof response.data === 'string' && response.data.includes('Password changed successfully'))) {
modal.open({
title: 'Success',
body: 'Password has been changed successfully. You will be logged out.',
footer: h('button', {
class: 'btn btn-primary',
onClick: () => {
modal.close();
window.location.href = '/go/logoutnow';
}
}, 'OK')
});
} else {
modal.open({
title: 'Failed',
body: 'Unable to change password. ' + JSON.stringify(response.data),
footer: h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close')
});
}
} catch (error) {
modal.open({
title: 'Failed',
body: 'An error occurred while changing your password. ' + (error.response?.data?.message || ''),
footer: h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close')
});
} finally {
isChangingPassword.value = false;
}
};
const clearCacheAndReload = () => {
if (typeof window.clearCacheAndReload === 'function') {
window.clearCacheAndReload();
} else {
localStorage.clear();
sessionStorage.clear();
window.location.reload(true);
}
};
const showNotes = async () => {
// Fetch notes using composable
await fetchNotes();
if (error.value) {
modal.open({
title: 'Error',
body: error.value,
footer: h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close')
});
return;
}
const notesContent = notes.value;
if (notesContent && notesContent.trim() !== '') {
modal.continueCancelModal({
title: 'Notes',
body: h('div', {
style: 'white-space: pre-wrap; font-size: 16px; line-height: 1.5; color: #333;'
}, notesContent),
continueText: 'Delete Note',
cancelText: 'Close',
continueClass: 'btn btn-danger',
cancelClass: 'btn btn-secondary',
onContinue: async () => {
const success = await dismissNotes();
if (success) {
// Refresh notes after dismissal
await fetchNotes();
}
}
});
} else {
modal.open({
title: 'No Notes',
body: h('div', { class: 'text-center py-3' }, [
h('i', { class: 'icon-copy2 mb-3', style: 'font-size: 48px; color: #ccc; display: block;' }),
h('p', { style: 'font-size: 16px; color: #717171;' }, 'No notes found.')
]),
footer: h('button', {
class: 'btn btn-primary w-100',
style: 'height: 52px; border-radius: 16px; font-weight: 700;',
onClick: modal.close
}, 'OK')
});
}
};
const logout = () => {
if (typeof window.clearCacheAndLogout === 'function') {
window.clearCacheAndLogout();
} else {
localStorage.clear();
sessionStorage.clear();
window.location.href = '/go/logoutnow';
}
};
const toggleDarkMode = async () => {
const newMode = !uiStore.darkMode;
uiStore.setDarkMode(newMode);
try {
await axios.post('/UserSettings/Update', {
dark_mode: newMode
});
} catch (error) {
console.error('Failed to update dark mode setting on server:', error);
}
};
const showTableDensityModal = () => {
const density = ref(settings.value.table_density || 'comfortable');
const DensityWrapper = {
setup() {
return () => h('div', { class: 'text-center py-3' }, [
h('p', { class: 'mb-4 text-muted' }, 'Choose how dense you want your data tables to appear.'),
h('div', { class: 'd-flex justify-content-center' }, [
h(TableDensityToggle, {
modelValue: density.value,
'onUpdate:modelValue': (val) => {
density.value = val;
updateSetting('table_density', val);
}
})
]),
h('div', { class: 'mt-4 p-3 rounded-3 bg-light text-start' }, [
h('div', { class: 'small fw_6 mb-1 text-dark' }, 'Preview:'),
h('div', {
class: 'density-table-preview border rounded overflow-hidden',
'data-table-density': density.value
}, [
h('table', { class: 'table table-sm density-table mb-0' }, [
h('tr', [h('td', 'Sample Row 1'), h('td', '₱1,000.00')]),
h('tr', [h('td', 'Sample Row 2'), h('td', '₱2,500.00')])
])
])
])
]);
}
};
modal.open({
title: 'Table Density',
body: h(DensityWrapper),
footer: h('button', {
class: 'btn btn-primary w-100',
onClick: modal.close
}, 'Done')
});
};
// Balance Transfer Logic
const descendants = ref([]);
const isLoadingDescendants = ref(false);
const fetchDescendants = async () => {
try {
isLoadingDescendants.value = true;
const response = await axios.get('/admin/users/list');
if (response.data && response.data.users) {
// Filter out the current user from the descendants list
descendants.value = response.data.users.filter(u => u.hashkey !== userStore.user?.hashkey);
}
} catch (err) {
console.error('Failed to fetch descendants', err);
} finally {
isLoadingDescendants.value = false;
}
};
const openTransferBalanceModal = async () => {
modal.open({
title: 'Please Wait',
body: h('div', { class: 'text-center py-4' }, [
h('div', { class: 'spinner-border text-primary', role: 'status' }),
h('p', { class: 'mt-2' }, 'Loading your children list...')
])
});
await fetchDescendants();
if (descendants.value.length === 0) {
modal.open({
title: 'Notice',
body: h('div', { class: 'text-center py-3' }, [
h('i', { class: 'icon-user mb-3', style: 'font-size: 48px; color: #ccc; display: block;' }),
h('p', { style: 'font-size: 16px; color: #717171;' }, 'You do not have any child accounts to transfer points to.')
]),
footer: h('button', {
class: 'btn btn-primary w-100',
style: 'height: 52px; border-radius: 16px; font-weight: 700;',
onClick: modal.close
}, 'OK')
});
return;
}
const selectedUserHash = ref('');
const transferAmount = ref('');
const isSending = ref(false);
const TransferForm = {
setup() {
return () => h('div', { class: 'transfer-balance-modal-body' }, [
h('div', { class: 'form-group mb-4' }, [
h('label', { class: 'mb-2 fw_6', style: 'font-size: 14px; color: #666;' }, 'Select Recipient'),
h('div', { class: 'input-group shadow-sm', style: 'border-radius: 16px; overflow: hidden;' }, [
h('span', { class: 'input-group-text border-0 bg-white px-3' }, [
h('i', { class: 'icon-user', style: 'font-size: 20px; color: #28c76f;' })
]),
h('select', {
class: 'form-control border-0 py-3',
style: 'font-size: 16px; font-weight: 500;',
value: selectedUserHash.value,
onChange: e => selectedUserHash.value = e.target.value
}, [
h('option', { value: '' }, 'Choose a child...'),
...(descendants.value.map(u => h('option', { value: u.hashkey }, `${u.fullname || u.username} (@${u.username}) - Bal: ${u.total_balance}`)))
])
])
]),
h('div', { class: 'form-group mb-4' }, [
h('label', { class: 'mb-2 fw_6', style: 'font-size: 14px; color: #666;' }, 'Amount to Transfer'),
h('div', { class: 'input-group shadow-sm', style: 'border-radius: 16px; overflow: hidden;' }, [
h('span', { class: 'input-group-text border-0 bg-white px-3' }, [
h('i', { class: 'icon-credit-card2', style: 'font-size: 20px; color: #28c76f;' })
]),
h('input', {
type: 'number',
class: 'form-control border-0 py-3',
placeholder: '0.00',
style: 'font-size: 16px; font-weight: 600;',
value: transferAmount.value,
onInput: e => transferAmount.value = e.target.value
})
])
]),
h('div', {
class: 'p-3 rounded-4 d-flex justify-content-between align-items-center',
style: 'background: rgba(40, 199, 111, 0.05);'
}, [
h('span', { style: 'color: #666; font-size: 14px;' }, 'Your Current Balance:'),
h('span', { style: 'color: #28c76f; font-weight: 800; font-size: 18px;' }, userStore.user?.total_balance || 0)
])
]);
}
};
modal.yesNoModal({
title: 'Transfer Points',
body: h(TransferForm),
yesText: 'Confirm Transfer',
noText: 'Cancel',
yesClass: 'btn btn-primary w-50 py-2 rounded-3 shadow-sm fw-bold',
onYes: async () => {
if (!selectedUserHash.value) {
modal.quickDismiss({ title: 'Error', body: 'Please select a recipient.' });
return;
}
if (!transferAmount.value || isNaN(transferAmount.value) || parseFloat(transferAmount.value) <= 0) {
modal.quickDismiss({ title: 'Error', body: 'Please enter a valid amount.' });
return;
}
const amount = parseFloat(transferAmount.value);
if (amount > (userStore.user?.total_balance || 0) && userStore.acctType !== 'ult' && userStore.acctType !== 'ultimate') {
modal.quickDismiss({ title: 'Insufficient Balance', body: 'You do not have enough balance for this transfer.' });
return;
}
try {
modal.open({
title: 'Please Wait',
body: h('div', { class: 'text-center py-4' }, [
h('div', { class: 'spinner-border text-primary', role: 'status' }),
h('p', { class: 'mt-2' }, 'Processing transfer...')
])
});
const response = await axios.post('/user/sendmycredit', {
target_user: selectedUserHash.value,
amount: amount
});
if (response.data === true || response.data?.success) {
modal.open({
title: 'Success',
body: h('div', { class: 'text-center py-3' }, [
h('i', { class: 'icon-checkmark mb-3', style: 'font-size: 48px; color: #28c76f; display: block;' }),
h('p', { style: 'font-size: 16px; color: #333; font-weight: 600;' }, `Successfully transferred ${amount} points.`)
]),
footer: h('button', {
class: 'btn btn-primary w-100',
style: 'height: 52px; border-radius: 16px; font-weight: 700;',
onClick: modal.close
}, 'OK')
});
userStore.fetchCurrentUser(); // Refresh balance
} else {
modal.quickDismiss({ title: 'Error', body: response.data || 'Failed to transfer points.' });
}
} catch (err) {
modal.quickDismiss({ title: 'Error', body: 'An error occurred: ' + (err.response?.data || err.message) });
}
}
});
};
const accountTypeName = computed(() => getUserTypeLabel(userStore.acctType, uiStore.app_mode));
// ── QRPH Management (ULTIMATE only) ────────────────────────────────────
const qrphCode = ref('');
const qrphDecoded = ref(null);
const qrphImageUrl = ref(null); // stored image URL from /RequestData/File/...
// shared write-back refs from modal
const qrphInput = ref('');
const qrphImgHashkey = ref('');
const isSavingQrph = ref(false);
const fetchQrphCode = async () => {
if (userStore.acctType !== 'ult') return;
try {
const res = await axios.post('/Financial/Qrph/Get');
if (res.data.success && res.data.qrph) {
qrphCode.value = res.data.qrph;
qrphDecoded.value = res.data.decoded;
qrphImageUrl.value = res.data.image_url || null;
}
} catch (_) {}
};
const saveQrph = async () => {
isSavingQrph.value = true;
try {
const res = await axios.post('/Financial/Qrph/Set', {
qrph_code: qrphInput.value ? qrphInput.value.trim() : '',
image_hashkey: qrphImgHashkey.value || '',
});
if (res.data.success) {
qrphCode.value = qrphInput.value ? qrphInput.value.trim() : '';
qrphDecoded.value = res.data.decoded || null;
qrphImageUrl.value = res.data.image_url || null;
if (window.toastr) window.toastr.success(res.data.message || 'QRPH code saved.');
modal.close();
} else {
if (window.toastr) window.toastr.error('Failed to save QRPH code.');
}
} catch (_) {
if (window.toastr) window.toastr.error('Error saving QRPH code.');
} finally {
isSavingQrph.value = false;
}
};
const openQrphModal = () => {
qrphInput.value = qrphCode.value;
qrphImgHashkey.value = '';
const QrphForm = {
setup() {
const scanState = ref(qrphCode.value ? 'done' : 'idle'); // pre-fill if already set
const scanError = ref('');
const decodedRaw = ref(qrphCode.value);
const decodedInfo = ref(qrphDecoded.value);
const imagePreviewUrl = ref(qrphImageUrl.value); // show stored image on re-open
const showManual = ref(false);
const isUploading = ref(false);
const scanAndUpload = async (file) => {
if (!file) return;
imagePreviewUrl.value = URL.createObjectURL(file);
scanState.value = 'scanning';
scanError.value = '';
decodedRaw.value = '';
decodedInfo.value = null;
isUploading.value = false;
try {
// 1. Decode QR locally — no external API
const { Html5Qrcode } = await import('html5-qrcode');
const reader = new Html5Qrcode('qrph-hidden-reader');
const result = await reader.scanFile(file, false);
await reader.clear();
decodedRaw.value = result;
qrphInput.value = result;
// 2. Parse EMV fields
const parseRes = await axios.post('/Financial/Qrph/Decode', { qrph_code: result });
if (parseRes.data.success) {
decodedInfo.value = parseRes.data.decoded;
// Warn if this is a dynamic (single-use / expiring) QR
if (parseRes.data.decoded?.initiation_method === 'Dynamic') {
if (window.toastr) window.toastr.warning(
'This looks like a dynamic (single-use) QR — it may expire. Use your personal static "Receive Money" QR instead.'
);
}
}
// 3. Upload the original image so it can be served directly later
isUploading.value = true;
const formData = new FormData();
formData.append('file', file);
const uploadRes = await axios.post('/File/Upload/QrphPayment', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (uploadRes.data?.success && uploadRes.data?.hashkey) {
qrphImgHashkey.value = uploadRes.data.hashkey;
// Replace blob URL with the permanent server URL
imagePreviewUrl.value = `/RequestData/File/${uploadRes.data.hashkey}`;
}
scanState.value = 'done';
} catch (err) {
if (scanState.value === 'scanning') {
// QR decode failure
scanState.value = 'error';
scanError.value = err?.message?.includes('No MultiFormat Readers')
? 'No QR code found in this image. Try a clearer, higher-resolution screenshot.'
: (err?.message || 'Could not read QR code.');
imagePreviewUrl.value = null;
decodedRaw.value = '';
qrphInput.value = '';
} else {
// Image upload failure — QR was decoded fine, just warn
if (window.toastr) window.toastr.warning('QR read OK but image upload failed — QRPH string will still be saved.');
scanState.value = 'done';
}
} finally {
isUploading.value = false;
}
};
const onFileInput = (e) => {
const file = e.target.files?.[0];
if (file) scanAndUpload(file);
};
const onDrop = (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (file) scanAndUpload(file);
};
const reset = () => {
scanState.value = 'idle';
scanError.value = '';
decodedRaw.value = '';
decodedInfo.value = null;
imagePreviewUrl.value = null;
qrphInput.value = '';
qrphImgHashkey.value = '';
};
const onManualInput = async (e) => {
decodedRaw.value = e.target.value;
qrphInput.value = e.target.value;
decodedInfo.value = null;
if (e.target.value.trim().length > 10) {
try {
const res = await axios.post('/Financial/Qrph/Decode', { qrph_code: e.target.value.trim() });
if (res.data.success) decodedInfo.value = res.data.decoded;
} catch (_) {}
}
};
return () => h('div', { class: 'qrph-modal-body' }, [
// Required hidden element for Html5Qrcode
h('div', { id: 'qrph-hidden-reader', style: 'display:none;' }),
// ── Drop Zone (not shown once done) ────────────────────────────
scanState.value !== 'done'
? h('label', {
class: 'qrph-drop-zone d-flex flex-column align-items-center justify-content-center gap-2 rounded-20 mb-3',
style: 'border:2px dashed var(--accent-color,#533dea);min-height:150px;cursor:pointer;background:rgba(83,61,234,0.04);',
onDragover: e => e.preventDefault(),
onDrop,
}, [
scanState.value === 'scanning'
? h('i', { class: 'fas fa-spinner fa-spin fa-2x', style: 'color:var(--accent-color,#533dea);' })
: h('i', { class: 'fas fa-qrcode fa-2x', style: 'color:var(--accent-color,#533dea);opacity:0.7;' }),
h('span', { class: 'fw_7', style: 'font-size:13px;color:var(--text-primary);' },
scanState.value === 'scanning' ? 'Reading & uploading…' : 'Tap or drop your QR screenshot here'),
scanState.value !== 'scanning'
? h('span', { class: 'text-muted', style: 'font-size:11px;' }, 'PNG · JPG · screenshot from GCash / Maya / bank')
: null,
h('input', { type: 'file', accept: 'image/*', style: 'display:none;', onChange: onFileInput }),
])
: null,
// ── Error ───────────────────────────────────────────────────────
scanState.value === 'error'
? h('div', { class: 'alert alert-warning d-flex align-items-start gap-2 rounded-20 mb-3', style: 'font-size:12px;' }, [
h('i', { class: 'fas fa-exclamation-triangle mt-1 flex-shrink-0' }),
h('div', [
h('strong', 'Could not read QR: '), scanError.value, h('br'),
h('button', { class: 'btn btn-link p-0 smallest text-warning', onClick: reset }, 'Try again'),
]),
])
: null,
// ── Success card ────────────────────────────────────────────────
scanState.value === 'done'
? h('div', { class: 'd-flex align-items-start gap-3 p-3 rounded-20 mb-3', style: 'background:var(--bg-secondary,#f5f5f5)' }, [
imagePreviewUrl.value
? h('img', {
src: imagePreviewUrl.value,
alt: 'QR',
class: 'rounded-15 flex-shrink-0',
style: 'width:90px;height:90px;object-fit:contain;background:#fff;padding:4px;',
})
: null,
h('div', { class: 'flex-grow-1' }, [
h('p', { class: 'fw_7 mb-1', style: 'font-size:13px;' }, decodedInfo.value?.merchant_name || 'Merchant'),
h('p', { class: 'text-muted mb-1', style: 'font-size:11px;' },
(decodedInfo.value?.merchant_account?.network || '') +
(decodedInfo.value?.merchant_account?.account ? ' · ' + decodedInfo.value.merchant_account.account : '')),
h('div', { class: 'd-flex align-items-center gap-2 mb-1 flex-wrap' }, [
decodedInfo.value?.valid
? h('span', { class: 'badge bg-success smallest' }, [h('i', { class: 'fas fa-check-circle me-1' }), 'Valid QRPH'])
: h('span', { class: 'badge bg-warning text-dark smallest' }, [h('i', { class: 'fas fa-exclamation-triangle me-1' }), 'CRC mismatch']),
decodedInfo.value?.initiation_method === 'Static'
? h('span', { class: 'badge bg-primary smallest' }, [h('i', { class: 'fas fa-infinity me-1' }), 'Static (permanent)'])
: decodedInfo.value?.initiation_method === 'Dynamic'
? h('span', { class: 'badge bg-danger smallest' }, [h('i', { class: 'fas fa-clock me-1' }), 'Dynamic — may expire!'])
: null,
]),
isUploading.value
? h('p', { class: 'text-muted smallest mb-0' }, [h('i', { class: 'fas fa-spinner fa-spin me-1' }), 'Uploading image…'])
: h('p', { class: 'text-success smallest mb-0' }, [h('i', { class: 'fas fa-image me-1' }), 'Image stored — members will see your actual QR']),
]),
h('button', {
class: 'btn btn-sm btn-outline-secondary rounded-circle flex-shrink-0',
style: 'width:28px;height:28px;padding:0;font-size:10px;',
title: 'Use a different QR image',
onClick: reset,
}, [h('i', { class: 'fas fa-redo' })]),
])
: null,
// ── Manual fallback ─────────────────────────────────────────────
h('div', { class: 'mt-2' }, [
h('button', {
class: 'btn btn-link p-0 smallest text-muted text-decoration-none',
onClick: () => { showManual.value = !showManual.value; },
}, [
h('i', { class: `fas fa-chevron-${showManual.value ? 'up' : 'down'} me-1` }),
showManual.value ? 'Hide manual entry' : 'Enter QRPH string manually',
]),
showManual.value
? h('div', { class: 'mt-2' }, [
h('textarea', {
class: 'form-control rounded-20 smallest mt-1',
rows: 3,
placeholder: '00020101021226…',
style: 'font-family:monospace;resize:none;font-size:11px;',
value: decodedRaw.value,
onInput: onManualInput,
}),
decodedInfo.value
? h('p', { class: 'text-success smallest mt-1 mb-0' }, [
h('i', { class: 'fas fa-check-circle me-1' }),
`${decodedInfo.value.merchant_name || 'OK'} · ${decodedInfo.value.merchant_account?.network || ''}`,
])
: null,
])
: null,
]),
]);
}
};
modal.yesNoModal({
title: 'Set Payment QR (QRPH)',
body: h(QrphForm),
yesText: 'Save',
noText: 'Cancel',
onYes: () => saveQrph(),
});
};
// ── End QRPH ────────────────────────────────────────────────────────────
onMounted(() => {
if (!userStore.user && !userStore.loading) {
userStore.fetchCurrentUser();
}
fetchQrphCode();
});
return {
user,
userStore,
openChangePasswordModal,
clearCacheAndReload,
showNotes,
logout,
appVersion: __APP_VERSION__,
buildId: __BUILD_ID__,
isChangingPassword,
isPublic,
accountTypeName,
openTransferBalanceModal,
notes,
loading,
error,
fileInput,
triggerFilePicker,
onFileSelected,
isUpdatingPhoto,
displayPhotoUrl,
viewFullScreen,
uiStore,
toggleDarkMode,
showTableDensityModal,
navigate,
// QRPH
qrphDecoded,
openQrphModal,
};
}
});
</script>
<style>
.rounded-20 { border-radius: 20px; }
.bg-white-20 { background-color: rgba(255, 255, 255, 0.2); }
.smallest { font-size: 0.75rem; }
.balance-summary-card {
transition: all 0.2s ease;
}
.balance-summary-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(var(--primary-rgb), 0.3) !important;
}
/* Modal Specific Styles - Non-scoped because modal is teleported */
.change-password-modal-body .input-group-text {
background-color: var(--bg-card, #fff);
border-right: none;
border-radius: 10px 0 0 10px;
display: flex;
align-items: center;
justify-content: center;
min-width: 50px;
height: 50px;
}
.change-password-modal-body .form-control {
border-radius: 0 10px 10px 0;
border-left: none;
height: 50px;
background-color: var(--bg-card, #fff);
color: var(--text-primary, #1e1e1e);
}
.change-password-modal-body .form-control:focus {
box-shadow: none;
border-color: var(--accent-color, #533dea);
}
.change-password-modal-body .input-group-prepend {
height: 50px;
}
.change-password-modal-body .alert-error {
background-color: rgba(229, 62, 62, 0.1);
color: #e53e3e;
border: 1px solid rgba(229, 62, 62, 0.3);
border-radius: 10px;
padding: 10px 15px;
}
/* Modal UI Improvements - Making it flush and premium */
.modal-content {
border-radius: 24px !important;
border: none !important;
box-shadow: 0 20px 40px rgba(0,0,0,0.15) !important;
overflow: hidden;
background-color: var(--bg-card, #fff);
}
.modal-header {
padding: 24px 24px 16px !important;
border-bottom: none !important;
}
.modal-title {
font-weight: 800 !important;
color: var(--text-primary, #1e1e1e);
}
.modal-body {
padding: 24px !important;
color: var(--text-primary, #1e1e1e);
}
.modal-footer {
padding: 8px 24px 24px !important;
border-top: none !important;
display: flex !important;
gap: 12px !important;
}
.modal-footer .btn {
flex: 1;
margin: 0 !important;
height: 52px;
border-radius: 16px !important;
font-weight: 700 !important;
font-size: 16px !important;
text-transform: none !important;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.modal-footer .btn-secondary {
background-color: var(--bg-secondary, #f5f5f5) !important;
border: none !important;
color: var(--text-secondary, #717171) !important;
}
.modal-footer .btn-danger, .modal-footer .btn-primary {
background-color: var(--accent-color, #533dea) !important;
border: none !important;
color: #fff !important;
box-shadow: 0 8px 16px rgba(83, 61, 234, 0.2) !important;
}
.camera-edit-badge {
position: absolute;
bottom: 5px;
right: 5px;
background: var(--accent-color);
color: white;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid var(--bg-card);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
transition: all 0.2s ease;
}
.list-user-info-container {
background: var(--bg-card-secondary, rgba(255,255,255,0.05));
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.list-user-info {
border-bottom: 1px solid var(--border-color) !important;
transition: background 0.2s ease;
}
.list-user-info:hover {
background: rgba(var(--accent-color-rgb), 0.05);
}
.list-user-info.border-none {
border-bottom: none !important;
}
</style>