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