252 lines
9.4 KiB
JavaScript
252 lines
9.4 KiB
JavaScript
/**
|
|
* Verde admin web — shared client helpers, exposed on window.Verde so
|
|
* inline page scripts can use them without ESM imports.
|
|
*/
|
|
|
|
import Echo from 'laravel-echo';
|
|
import Pusher from 'pusher-js';
|
|
|
|
const TOKEN_KEY = 'verde:token';
|
|
const USER_KEY = 'verde:user';
|
|
|
|
window.Pusher = Pusher;
|
|
|
|
function csrfToken() {
|
|
return document.querySelector('meta[name="csrf-token"]')?.content ?? '';
|
|
}
|
|
|
|
function getToken() {
|
|
return localStorage.getItem(TOKEN_KEY);
|
|
}
|
|
|
|
function getUser() {
|
|
try { return JSON.parse(localStorage.getItem(USER_KEY) || 'null'); } catch { return null; }
|
|
}
|
|
|
|
function setSession(token, user) {
|
|
localStorage.setItem(TOKEN_KEY, token);
|
|
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user));
|
|
}
|
|
|
|
function clearSession() {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(USER_KEY);
|
|
}
|
|
|
|
async function apiFetch(path, opts = {}) {
|
|
const token = getToken();
|
|
const method = (opts.method || 'GET').toUpperCase();
|
|
const isFormData = opts.body instanceof FormData;
|
|
|
|
const headers = {
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken(),
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
...(opts.headers || {}),
|
|
};
|
|
|
|
const user = getUser();
|
|
if (user?.tenant?.code) {
|
|
headers['X-Tenant-Code'] = user.tenant.code;
|
|
} else if (user?.tenant_code) {
|
|
headers['X-Tenant-Code'] = user.tenant_code;
|
|
}
|
|
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
if (!isFormData && method !== 'GET' && method !== 'HEAD' && opts.body !== undefined) {
|
|
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
|
}
|
|
|
|
const res = await fetch(path, {
|
|
...opts,
|
|
method,
|
|
credentials: 'same-origin',
|
|
headers,
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
clearSession();
|
|
window.location.href = '/login';
|
|
throw new Error('unauthenticated');
|
|
}
|
|
|
|
let body = null;
|
|
try { body = await res.json(); } catch { /* ignore */ }
|
|
|
|
return { ok: res.ok, status: res.status, body };
|
|
}
|
|
|
|
function requireAuth() {
|
|
if (!getToken()) {
|
|
window.location.href = '/login';
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await apiFetch('/api/v1/auth/logout', { method: 'POST' });
|
|
} catch { /* ignore */ }
|
|
clearSession();
|
|
window.location.href = '/login';
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
if (value === null || value === undefined) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = String(value);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatDate(iso, options = { dateStyle: 'medium', timeStyle: 'short' }) {
|
|
if (!iso) return '—';
|
|
try {
|
|
return new Intl.DateTimeFormat(undefined, options).format(new Date(iso));
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function toast(message, type = 'info') {
|
|
let host = document.getElementById('verde-toasts');
|
|
if (!host) {
|
|
host = document.createElement('div');
|
|
host.id = 'verde-toasts';
|
|
host.className = 'pointer-events-none fixed top-4 right-4 z-50 flex flex-col gap-2';
|
|
document.body.appendChild(host);
|
|
}
|
|
|
|
const colors = {
|
|
success: 'border-verde-200 bg-verde-50 text-verde-800',
|
|
error: 'border-red-200 bg-red-50 text-red-700',
|
|
info: 'border-neutral-200 bg-white text-neutral-800',
|
|
};
|
|
|
|
const el = document.createElement('div');
|
|
el.className = `pointer-events-auto rounded-md border ${colors[type] || colors.info} px-4 py-3 text-sm shadow-md transition-opacity`;
|
|
el.textContent = message;
|
|
host.appendChild(el);
|
|
setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, 3500);
|
|
}
|
|
|
|
/**
|
|
* Lazy Echo init. Only spins up when a page actually subscribes — saves
|
|
* a WebSocket connection on pages that don't need real-time. Returns
|
|
* null when Reverb isn't configured (callers fall back to polling).
|
|
*/
|
|
let _echo = null;
|
|
function getEcho() {
|
|
if (_echo) return _echo;
|
|
|
|
const cfg = window.VERDE_BROADCAST ?? {};
|
|
if (! cfg.key || ! cfg.host) return null;
|
|
|
|
_echo = new Echo({
|
|
broadcaster: 'reverb',
|
|
key: cfg.key,
|
|
wsHost: cfg.host,
|
|
wsPort: Number(cfg.port) || 80,
|
|
wssPort: Number(cfg.port) || 443,
|
|
forceTLS: cfg.scheme === 'https',
|
|
enabledTransports: ['ws', 'wss'],
|
|
authorizer: (channel) => ({
|
|
authorize: (socketId, callback) => {
|
|
fetch('/broadcasting/auth', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'Authorization': `Bearer ${getToken() ?? ''}`,
|
|
'X-CSRF-TOKEN': csrfToken(),
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify({ socket_id: socketId, channel_name: channel.name }),
|
|
})
|
|
.then(r => r.ok ? r.json() : Promise.reject(r))
|
|
.then(data => callback(false, data))
|
|
.catch(err => callback(true, err));
|
|
},
|
|
}),
|
|
});
|
|
|
|
return _echo;
|
|
}
|
|
|
|
function renderPagination(container, meta, onPageChange) {
|
|
if (!container) return;
|
|
if (!meta || meta.last_page <= 1) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const { page, last_page, total } = meta;
|
|
|
|
let html = `
|
|
<div class="flex items-center justify-between border-t border-neutral-100 px-4 py-3 sm:px-6">
|
|
<div class="flex flex-1 justify-between sm:hidden">
|
|
<button ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}" class="pagination-btn relative inline-flex items-center rounded-md border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50">Previous</button>
|
|
<button ${page >= last_page ? 'disabled' : ''} data-page="${page + 1}" class="pagination-btn relative ml-3 inline-flex items-center rounded-md border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50">Next</button>
|
|
</div>
|
|
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
|
<div>
|
|
<p class="text-xs text-neutral-500">
|
|
Showing page <span class="font-semibold text-neutral-900">${page}</span> of <span class="font-semibold text-neutral-900">${last_page}</span>
|
|
(<span class="font-semibold text-neutral-900">${total}</span> total results)
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
|
<button ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}" class="pagination-btn relative inline-flex items-center rounded-l-md px-2 py-2 text-neutral-400 ring-1 ring-inset ring-neutral-300 hover:bg-neutral-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50">
|
|
<span class="sr-only">Previous</span>
|
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" /></svg>
|
|
</button>
|
|
`;
|
|
|
|
// Simple range logic for page numbers
|
|
const start = Math.max(1, page - 2);
|
|
const end = Math.min(last_page, start + 4);
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
html += `
|
|
<button data-page="${i}" class="pagination-btn relative inline-flex items-center px-4 py-2 text-sm font-semibold ${i === page ? 'z-10 bg-verde-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-verde-600' : 'text-neutral-900 ring-1 ring-inset ring-neutral-300 hover:bg-neutral-50 focus:z-20 focus:outline-offset-0'}">${i}</button>
|
|
`;
|
|
}
|
|
|
|
html += `
|
|
<button ${page >= last_page ? 'disabled' : ''} data-page="${page + 1}" class="pagination-btn relative inline-flex items-center rounded-r-md px-2 py-2 text-neutral-400 ring-1 ring-inset ring-neutral-300 hover:bg-neutral-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50">
|
|
<span class="sr-only">Next</span>
|
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" /></svg>
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
container.innerHTML = html;
|
|
container.querySelectorAll('.pagination-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const p = parseInt(btn.dataset.page, 10);
|
|
if (p && p !== page && p >= 1 && p <= last_page) {
|
|
onPageChange(p);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
Object.assign(window.Verde, {
|
|
apiFetch,
|
|
requireAuth,
|
|
logout,
|
|
setSession,
|
|
getToken,
|
|
getUser,
|
|
toast,
|
|
escapeHtml,
|
|
formatDate,
|
|
getEcho,
|
|
renderPagination,
|
|
});
|