Files
epictravelexpeditions/static/js/admin-portal-v2.js
T
2026-05-22 12:52:45 +00:00

542 lines
36 KiB
JavaScript
Raw 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.
(function(){
'use strict';
const API='https://epictravelexpeditions.com/api';
function authHdr(){return{'Content-Type':'application/json',Authorization:`Bearer ${localStorage.getItem('auth_token')}`};}
async function api(path,opts){
const res=await fetch(API+path,{headers:authHdr(),...opts});
const d=await res.json();
if(!res.ok)throw new Error(d.error||res.statusText);
return d;
}
async function uploadImg(file){
const fd=new FormData();fd.append('file',file);
const res=await fetch(API+'/upload/image',{method:'POST',headers:{Authorization:`Bearer ${localStorage.getItem('auth_token')}`},body:fd});
const d=await res.json();
if(!res.ok)throw new Error(d.error||'Upload failed');
return d.url;
}
async function getImg(inputId,fallback){
const el=document.getElementById(inputId);
if(el&&el.files&&el.files[0]){
const st=document.getElementById(inputId+'-st');
if(st)st.textContent='Uploading…';
try{const url=await uploadImg(el.files[0]);if(st)st.textContent='';return url;}
catch(e){if(st){st.textContent='Upload failed: '+e.message;st.style.color='#dc2626';}throw e;}
}
return fallback!=null?fallback:null;
}
function imgPicker(id,src){
const pr=src
?`<img id="${id}-pr" src="${src}" style="width:52px;height:52px;border-radius:8px;object-fit:cover;border:1px solid #e5e7eb">`
:`<div id="${id}-pr" style="width:52px;height:52px;border-radius:8px;background:#f3f4f6;border:1px solid #e5e7eb;display:flex;align-items:center;justify-content:center;font-size:20px">🖼</div>`;
return `<div style="display:flex;align-items:center;gap:10px">${pr}<label style="display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border:1px dashed #d1d5db;border-radius:7px;cursor:pointer;font-size:12px;color:#6b7280;background:#fff">📁 Choose Image<input type="file" accept="image/jpeg,image/png,image/webp" id="${id}" style="display:none" onchange="(function(el){const f=el.files[0];if(!f)return;const u=URL.createObjectURL(f);const p=document.getElementById('${id}-pr');if(p.tagName==='IMG'){p.src=u;}else{p.outerHTML='<img id=${id}-pr src='+u+' style=width:52px;height:52px;border-radius:8px;object-fit:cover;border:1px solid #e5e7eb>';};})(this)"></label><span id="${id}-st" style="font-size:11px;color:#6b7280"></span></div>`;
}
function daysAgo(s){return Math.floor((Date.now()-new Date(s))/86400000);}
function daysLeft(s){return Math.ceil((new Date(s)-Date.now())/86400000);}
function ageBdg(d){const c=d<30?'bg':d<90?'by':'br';return`<span class="ep-b ${c}">${d}d ago</span>`;}
function expBdg(d){if(d<0)return`<span class="ep-b br">Expired</span>`;const c=d>14?'bg':d>7?'by':'br';return`<span class="ep-b ${c}">${d}d left</span>`;}
function esc(s){const e=document.createElement('div');e.textContent=s||'';return e.innerHTML;}
function catOpts(cats,sel){return cats.map(c=>`<option value="${esc(c.name)}"${c.name===sel?' selected':''}>${esc(c.name)}</option>`).join('');}
function fmtPrice(p){return p!=null?'$'+parseFloat(p).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:2}):'';}
/* CSS */
const css=`
#ep-portal{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827;min-height:100vh;background:#f3f4f6}
#ep-portal *,#ep-portal *::before,#ep-portal *::after{box-sizing:border-box}
#ep-hdr{background:#fff;border-bottom:1px solid #e5e7eb;padding:0 28px;height:60px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 1px 3px rgba(0,0,0,.06)}
.ep-logo{font-size:17px;font-weight:800;color:#1d4ed8;letter-spacing:-.4px}.ep-logo span{color:#6b7280;font-weight:400}
.ep-hdr-acts{display:flex;gap:8px}
.ep-hbtn{padding:6px 14px;border-radius:7px;font-size:13px;font-weight:600;border:1px solid #d1d5db;background:#fff;color:#374151;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:5px}
.ep-hbtn:hover{background:#f9fafb}.ep-hbtn.red{border-color:#fca5a5;color:#dc2626}.ep-hbtn.red:hover{background:#fef2f2}
#ep-tabs{background:#fff;border-bottom:1px solid #e5e7eb;padding:0 28px;display:flex;gap:2px}
.ep-tab{padding:13px 18px;font-size:13px;font-weight:600;color:#6b7280;border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:all .15s;display:inline-flex;align-items:center;gap:7px}
.ep-tab:hover{color:#1d4ed8}.ep-tab.active{color:#1d4ed8;border-bottom-color:#1d4ed8}
.ep-tbadge{background:#fee2e2;color:#dc2626;border-radius:999px;font-size:11px;padding:1px 6px;font-weight:700}
#ep-body{padding:28px;max-width:1180px;margin:0 auto}
.ep-g4{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:28px}
@media(max-width:880px){.ep-g4{grid-template-columns:repeat(2,1fr)}}
.ep-stat{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px 22px;position:relative;overflow:hidden}
.ep-si{font-size:26px;margin-bottom:6px}.ep-sv{font-size:30px;font-weight:800;color:#111827}.ep-sl{font-size:12px;color:#6b7280;margin-top:1px}
.ep-acc{position:absolute;right:0;top:0;bottom:0;width:4px;border-radius:0 12px 12px 0}
.acb{background:#3b82f6}.acg{background:#10b981}.aca{background:#f59e0b}.acp{background:#8b5cf6}
.ep-card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;margin-bottom:22px;overflow:hidden}
.ep-ch{padding:16px 22px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px}
.ep-ct{font-size:14px;font-weight:700;color:#111827}.ep-cs{font-size:12px;color:#6b7280;margin-top:1px}
.ep-cb{padding:22px}
.ep-tbl{width:100%;border-collapse:collapse;font-size:13px}
.ep-tbl th{text-align:left;font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.05em;padding:9px 12px;border-bottom:1px solid #e5e7eb;white-space:nowrap}
.ep-tbl td{padding:11px 12px;border-bottom:1px solid #f3f4f6;vertical-align:middle}
.ep-tbl tr:last-child td{border-bottom:none}.ep-tbl tr:hover td{background:#fafafa}
.ep-b{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700}
.bg{background:#d1fae5;color:#065f46}.by{background:#fef3c7;color:#92400e}.br{background:#fee2e2;color:#991b1b}.bb{background:#dbeafe;color:#1e40af}.bgr{background:#f3f4f6;color:#374151}
.ep-btn{padding:6px 13px;border-radius:7px;font-size:12px;font-weight:600;border:none;cursor:pointer;transition:opacity .15s;display:inline-flex;align-items:center;gap:4px;white-space:nowrap}
.ep-btn:hover{opacity:.82}.ep-btn:disabled{opacity:.4;cursor:not-allowed}
.ep-pri{background:#2563eb;color:#fff}.ep-suc{background:#16a34a;color:#fff}.ep-dan{background:#dc2626;color:#fff}.ep-gho{background:#f3f4f6;color:#374151}
.ep-form{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:640px){.ep-form{grid-template-columns:1fr}}
.ep-full{grid-column:1/-1}.ep-fld{display:flex;flex-direction:column;gap:4px}
.ep-lbl{font-size:11px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.04em}
.ep-inp,.ep-sel,.ep-ta{padding:8px 11px;border:1px solid #d1d5db;border-radius:7px;font-size:13px;color:#111827;background:#fff;outline:none;transition:border-color .15s;width:100%}
.ep-inp:focus,.ep-sel:focus,.ep-ta:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.1)}
.ep-ta{resize:vertical;min-height:72px}
.ep-frow{grid-column:1/-1;display:flex;gap:10px;justify-content:flex-end;padding-top:4px;align-items:center}
.ep-addbox{border:1px dashed #d1d5db;border-radius:10px;padding:18px;margin-bottom:20px;background:#fafafa;display:none}
.ep-addbox.open{display:block}
.ep-thumb{width:40px;height:40px;border-radius:7px;object-fit:cover;background:#e5e7eb}
.ep-smsg{font-size:12px}.ep-smsg.ok{color:#16a34a}.ep-smsg.err{color:#dc2626}
.ep-alert{padding:10px 15px;border-radius:8px;font-size:13px;margin-bottom:16px;border-left:3px solid}
.ep-alert.warn{background:#fffbeb;color:#92400e;border-color:#f59e0b}
.ep-inline{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:14px}
@media(max-width:640px){.ep-inline{grid-column:1fr}}
.ep-cat-row{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:8px;border:1px solid #e5e7eb;background:#fff;margin-bottom:8px}
.ep-cat-nm{flex:1;font-size:13px;font-weight:600}.ep-cat-ct{font-size:11px;color:#9ca3af}
.ep-cat-ei{flex:1;padding:5px 9px;border:1px solid #3b82f6;border-radius:6px;font-size:13px;outline:none}
.ep-tc{border:1px solid #e5e7eb;border-radius:10px;padding:16px;margin-bottom:12px;display:flex;gap:12px;background:#fff}
.ep-tav{width:42px;height:42px;border-radius:50%;object-fit:cover;flex-shrink:0;background:#e5e7eb}
.ep-tavph{width:42px;height:42px;border-radius:50%;background:#2563eb;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:16px;flex-shrink:0}
.ep-tb{flex:1;min-width:0}.ep-tn{font-weight:700;font-size:13px}.ep-tl{font-size:11px;color:#6b7280;margin-bottom:5px}
.ep-tm{font-size:13px;color:#374151;margin-bottom:8px;line-height:1.5}
.ep-te{width:100%;border:1px solid #d1d5db;border-radius:6px;padding:6px 9px;font-size:12px;margin-bottom:7px;resize:vertical;min-height:52px}
.ep-tacts{display:flex;gap:7px;flex-wrap:wrap}
.ep-ftabs{display:flex;gap:7px;margin-bottom:18px;flex-wrap:wrap}
.ep-ft{padding:6px 15px;border-radius:7px;font-size:12px;font-weight:600;cursor:pointer;border:1px solid #d1d5db;background:#fff;color:#374151;transition:all .15s}
.ep-ft.active{background:#2563eb;color:#fff;border-color:#2563eb}
.ep-empty{text-align:center;padding:36px;color:#9ca3af;font-size:13px}
.ep-spec-img{width:52px;height:52px;border-radius:8px;object-fit:cover;background:#e5e7eb;flex-shrink:0}
.ep-price-old{text-decoration:line-through;color:#9ca3af;font-size:11px}
.ep-price-new{color:#16a34a;font-weight:700}
`;
const sel=document.createElement('style');sel.textContent=css;document.head.appendChild(sel);
/* State */
let activeTab='dashboard',tFilter='pending';
let destinations=[],specials=[],testimonials=[],categories=[];
async function loadAll(){
const r=await Promise.allSettled([
fetch(API+'/destinations',{headers:authHdr()}).then(r=>r.json()),
fetch(API+'/specials', {headers:authHdr()}).then(r=>r.json()),
fetch(API+'/testimonials/all',{headers:authHdr()}).then(r=>r.json()),
fetch(API+'/categories', {headers:authHdr()}).then(r=>r.json())
]);
if(r[0].status==='fulfilled'&&Array.isArray(r[0].value))destinations=r[0].value;
if(r[1].status==='fulfilled'&&Array.isArray(r[1].value))specials=r[1].value;
if(r[2].status==='fulfilled'&&Array.isArray(r[2].value))testimonials=r[2].value;
if(r[3].status==='fulfilled'&&Array.isArray(r[3].value))categories=r[3].value;
}
/* Shell */
function buildShell(){
const p=document.createElement('div');p.id='ep-portal';
p.innerHTML=`
<div id="ep-hdr">
<div class="ep-logo">Epic Travel <span>Admin</span></div>
<div class="ep-hdr-acts">
<a href="/" class="ep-hbtn" target="_blank">🌐 View Site</a>
<button class="ep-hbtn red" id="ep-logout">⏻ Logout</button>
</div>
</div>
<div id="ep-tabs">
<button class="ep-tab active" data-tab="dashboard">📊 Dashboard</button>
<button class="ep-tab" data-tab="destinations">🗺 Destinations</button>
<button class="ep-tab" data-tab="specials">⭐ Specials</button>
<button class="ep-tab" data-tab="testimonials">💬 Testimonials <span class="ep-tbadge" id="ep-pb" style="display:none"></span></button>
</div>
<div id="ep-body"></div>`;
p.querySelector('#ep-logout').onclick=()=>{localStorage.removeItem('isAdminAuthenticated');localStorage.removeItem('auth_token');window.location.href='/admin';};
p.querySelectorAll('.ep-tab[data-tab]').forEach(b=>{b.onclick=()=>{activeTab=b.dataset.tab;p.querySelectorAll('.ep-tab').forEach(t=>t.classList.remove('active'));b.classList.add('active');render();};});
return p;
}
/* Dashboard */
function rDash(){
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
const pen=testimonials.filter(t=>t.status==='pending').length;
const app=testimonials.filter(t=>t.status==='approved').length;
let h=`<div class="ep-g4">
<div class="ep-stat"><div class="ep-acc acb"></div><div class="ep-si">🗺</div><div class="ep-sv">${destinations.length}</div><div class="ep-sl">Destinations</div></div>
<div class="ep-stat"><div class="ep-acc acg"></div><div class="ep-si">⭐</div><div class="ep-sv">${specials.length}</div><div class="ep-sl">Active Specials</div></div>
<div class="ep-stat"><div class="ep-acc aca"></div><div class="ep-si">⏳</div><div class="ep-sv">${pen}</div><div class="ep-sl">Pending Reviews</div></div>
<div class="ep-stat"><div class="ep-acc acp"></div><div class="ep-si">✅</div><div class="ep-sv">${app}</div><div class="ep-sl">Approved Testimonials</div></div>
</div>`;
if(pen>0)h+=`<div class="ep-alert warn">⚠️ <strong>${pen}</strong> testimonial${pen>1?'s':''} awaiting review.</div>`;
/* Destinations age */
const sd=[...destinations].sort((a,b)=>new Date(a.created_at)-new Date(b.created_at));
h+=`<div class="ep-card"><div class="ep-ch"><div><div class="ep-ct">🗺 Destinations</div><div class="ep-cs">Oldest entries first</div></div></div><table class="ep-tbl"><thead><tr><th>Destination</th><th>Category</th><th>Price</th><th>In System Since</th></tr></thead><tbody>`;
sd.forEach(d=>{h+=`<tr><td><strong>${esc(d.name)}</strong> <span style="color:#9ca3af;font-size:11px">${esc(d.location)}</span></td><td><span class="ep-b bb">${esc(d.category)}</span></td><td>${fmtPrice(d.price)}</td><td>${ageBdg(daysAgo(d.created_at))}</td></tr>`;});
h+=`</tbody></table></div>`;
/* Specials expiry */
const ss=[...specials].sort((a,b)=>new Date(a.end_date)-new Date(b.end_date));
h+=`<div class="ep-card"><div class="ep-ch"><div><div class="ep-ct">⭐ Weekly Specials</div><div class="ep-cs">Expiring soonest first</div></div></div><table class="ep-tbl"><thead><tr><th>Image</th><th>Destination</th><th>Original Price</th><th>Discount</th><th>Sale Price</th><th>Expires</th><th>Status</th></tr></thead><tbody>`;
ss.forEach(s=>{
const dest=dm[s.destination_id];
const basePrice=s.price!=null?parseFloat(s.price):(dest?parseFloat(dest.price):0);
const salePrice=basePrice*(1-parseFloat(s.discount)/100);
const imgSrc=s.image_path||(dest?dest.image:'');
h+=`<tr>
<td>${imgSrc?`<img src="${esc(imgSrc)}" class="ep-thumb" onerror="this.style.opacity='.2'">`:''}</td>
<td><strong>${esc(dest?dest.name:s.destination_id)}</strong></td>
<td class="ep-price-old">${fmtPrice(basePrice)}</td>
<td><span class="ep-b bg">${parseFloat(s.discount)}% OFF</span></td>
<td class="ep-price-new">${fmtPrice(salePrice)}</td>
<td>${new Date(s.end_date).toLocaleDateString()}</td>
<td>${expBdg(daysLeft(s.end_date))}</td>
</tr>`;
});
h+=`</tbody></table></div>`;
/* Pending testimonials */
const pl=testimonials.filter(t=>t.status==='pending');
if(pl.length){
h+=`<div class="ep-card"><div class="ep-ch"><div class="ep-ct">⏳ Pending Testimonials</div></div><div class="ep-cb">`;
pl.forEach(t=>{
const av=t.image_path?`<img class="ep-tav" src="${esc(t.image_path)}" alt="">`:`<div class="ep-tavph">${esc(t.full_name.charAt(0))}</div>`;
h+=`<div class="ep-tc" data-id="${esc(t.id)}">${av}<div class="ep-tb"><div class="ep-tn">${esc(t.full_name)}</div><div class="ep-tl">${esc(t.location)}</div><div class="ep-tm">${esc(t.message)}</div><div class="ep-tacts"><button class="ep-btn ep-suc" data-qa="${esc(t.id)}">✓ Approve</button><button class="ep-btn ep-dan" data-qd="${esc(t.id)}">✗ Deny</button></div></div></div>`;
});
h+=`</div></div>`;
}
return h;
}
/* Destinations */
function destRow(d){
return`<tr data-did="${esc(d.id)}">
<td><img class="ep-thumb" src="${esc(d.image)}" alt="" onerror="this.style.opacity='.2'"></td>
<td><strong>${esc(d.name)}</strong><br><span style="color:#9ca3af;font-size:11px">${esc(d.location)}</span></td>
<td><span class="ep-b bb">${esc(d.category)}</span></td>
<td>${fmtPrice(d.price)}</td>
<td>⭐ ${parseFloat(d.rating).toFixed(1)}</td>
<td>${ageBdg(daysAgo(d.created_at))}</td>
<td style="white-space:nowrap"><button class="ep-btn ep-gho" data-ed="${esc(d.id)}">✏ Edit</button> <button class="ep-btn ep-dan" data-dd="${esc(d.id)}">🗑</button></td>
</tr>`;
}
function rDest(){
const cc={};destinations.forEach(d=>{cc[d.category]=(cc[d.category]||0)+1;});
return`<div class="ep-card">
<div class="ep-ch"><div><div class="ep-ct">🏷 Categories</div><div class="ep-cs">Add, rename, or remove destination categories</div></div><button class="ep-btn ep-pri" id="ep-cat-tog">+ Add Category</button></div>
<div class="ep-cb">
<div class="ep-addbox" id="ep-cat-box">
<div style="display:flex;gap:10px;align-items:center">
<input class="ep-inp" id="ep-cat-new" placeholder="New category name" style="max-width:260px">
<button class="ep-btn ep-pri" id="ep-cat-save">Save</button>
<button class="ep-btn ep-gho" id="ep-cat-cancel">Cancel</button>
<span class="ep-smsg" id="ep-cat-msg"></span>
</div>
</div>
<div id="ep-cat-list">${categories.map(c=>{const n=cc[c.name]||0;return`<div class="ep-cat-row" data-cid="${c.id}"><span class="ep-cat-nm">${esc(c.name)}</span><span class="ep-cat-ct">${n} destination${n!==1?'s':''}</span><button class="ep-btn ep-gho" data-ren="${c.id}" data-cv="${esc(c.name)}" style="padding:4px 10px;font-size:11px">Rename</button><button class="ep-btn ep-dan" data-dc="${c.id}" data-dn="${esc(c.name)}" style="padding:4px 10px;font-size:11px"${n>0?' disabled title="In use"':''}>Delete</button></div>`;}).join('')}</div>
</div>
</div>
<div class="ep-card">
<div class="ep-ch"><div><div class="ep-ct">🗺 All Destinations (${destinations.length})</div></div><button class="ep-btn ep-pri" id="ep-dest-tog">+ Add Destination</button></div>
<div class="ep-cb">
<div class="ep-addbox" id="ep-dest-box">
<div style="font-weight:700;font-size:13px;margin-bottom:14px">New Destination</div>
<div class="ep-form">
<div class="ep-fld"><span class="ep-lbl">Name *</span><input class="ep-inp" id="ep-dn-name" placeholder="e.g. Paris"></div>
<div class="ep-fld"><span class="ep-lbl">Location *</span><input class="ep-inp" id="ep-dn-loc" placeholder="e.g. France"></div>
<div class="ep-fld"><span class="ep-lbl">Category *</span><select class="ep-sel" id="ep-dn-cat">${catOpts(categories,'')}</select></div>
<div class="ep-fld"><span class="ep-lbl">Price (USD) *</span><input class="ep-inp" id="ep-dn-price" type="number" placeholder="1299"></div>
<div class="ep-fld"><span class="ep-lbl">Rating (15)</span><input class="ep-inp" id="ep-dn-rating" type="number" step=".1" min="1" max="5" value="4.5"></div>
<div class="ep-fld ep-full"><span class="ep-lbl">Image *</span>${imgPicker('ep-dn-img','')}</div>
<div class="ep-fld ep-full"><span class="ep-lbl">Description *</span><textarea class="ep-ta" id="ep-dn-desc"></textarea></div>
<div class="ep-frow"><span class="ep-smsg" id="ep-dn-msg"></span><button class="ep-btn ep-gho" id="ep-dn-cancel">Cancel</button><button class="ep-btn ep-pri" id="ep-dn-save">Save Destination</button></div>
</div>
</div>
<table class="ep-tbl"><thead><tr><th>Photo</th><th>Name & Location</th><th>Category</th><th>Price</th><th>Rating</th><th>In System</th><th>Actions</th></tr></thead>
<tbody id="ep-dest-tbody">${destinations.map(destRow).join('')}</tbody></table>
</div>
</div>`;
}
/* Specials */
function specImg(s){
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
const dest=dm[s.destination_id];
return s.image_path||(dest?dest.image:'')||'';
}
function specRow(s){
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
const dest=dm[s.destination_id];
const base=s.price!=null?parseFloat(s.price):(dest?parseFloat(dest.price):0);
const sale=base*(1-parseFloat(s.discount)/100);
const img=specImg(s);
return`<tr data-sid="${esc(s.id)}">
<td>${img?`<img src="${esc(img)}" class="ep-thumb" onerror="this.style.opacity='.2'">`:''}</td>
<td><strong>${esc(dest?dest.name:s.destination_id)}</strong></td>
<td><span class="ep-b bg">${parseFloat(s.discount)}% OFF</span></td>
<td class="ep-price-old">${fmtPrice(base)}</td>
<td class="ep-price-new">${fmtPrice(sale)}</td>
<td>${new Date(s.end_date).toLocaleDateString()}</td>
<td>${expBdg(daysLeft(s.end_date))}</td>
<td style="white-space:nowrap"><button class="ep-btn ep-gho" data-es="${esc(s.id)}">✏ Edit</button> <button class="ep-btn ep-dan" data-ds="${esc(s.id)}">🗑</button></td>
</tr>`;
}
function rSpec(){
const destOpts=destinations.map(d=>`<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('');
return`<div class="ep-card">
<div class="ep-ch"><div><div class="ep-ct">⭐ Weekly Specials (${specials.length})</div></div><button class="ep-btn ep-pri" id="ep-spec-tog">+ Add Special</button></div>
<div class="ep-cb">
<div class="ep-addbox" id="ep-spec-box">
<div style="font-weight:700;font-size:13px;margin-bottom:14px">New Special</div>
<div class="ep-form">
<div class="ep-fld"><span class="ep-lbl">Destination *</span><select class="ep-sel" id="ep-sn-dest">${destOpts}</select></div>
<div class="ep-fld"><span class="ep-lbl">Original Price (USD) *</span><input class="ep-inp" id="ep-sn-price" type="number" placeholder="1299" title="Base price before discount"></div>
<div class="ep-fld"><span class="ep-lbl">Discount % *</span><input class="ep-inp" id="ep-sn-disc" type="number" min="1" max="99" placeholder="25"></div>
<div class="ep-fld"><span class="ep-lbl">End Date *</span><input class="ep-inp" id="ep-sn-end" type="date"></div>
<div class="ep-fld ep-full"><span class="ep-lbl">Special Image (optional — defaults to destination photo)</span>${imgPicker('ep-sn-img','')}</div>
<div class="ep-fld ep-full"><span class="ep-lbl">Highlights (one per line)</span><textarea class="ep-ta" id="ep-sn-hls" placeholder="Free spa treatment&#10;Complimentary airport transfer"></textarea></div>
<div class="ep-frow"><span class="ep-smsg" id="ep-sn-msg"></span><button class="ep-btn ep-gho" id="ep-sn-cancel">Cancel</button><button class="ep-btn ep-pri" id="ep-sn-save">Save Special</button></div>
</div>
</div>
<table class="ep-tbl"><thead><tr><th>Image</th><th>Destination</th><th>Discount</th><th>Original Price</th><th>Sale Price</th><th>End Date</th><th>Status</th><th>Actions</th></tr></thead>
<tbody id="ep-spec-tbody">${specials.map(specRow).join('')}</tbody></table>
</div>
</div>`;
}
/* Testimonials */
function rTest(){
const cn={pending:0,approved:0,denied:0};testimonials.forEach(t=>cn[t.status]++);
const list=tFilter==='all'?testimonials:testimonials.filter(t=>t.status===tFilter);
let h=`<div class="ep-ftabs">
<button class="ep-ft ${tFilter==='pending'?'active':''}" data-tf="pending">⏳ Pending (${cn.pending})</button>
<button class="ep-ft ${tFilter==='approved'?'active':''}" data-tf="approved">✅ Approved (${cn.approved})</button>
<button class="ep-ft ${tFilter==='denied'?'active':''}" data-tf="denied">❌ Denied (${cn.denied})</button>
<button class="ep-ft ${tFilter==='all'?'active':''}" data-tf="all">All (${testimonials.length})</button>
</div>`;
if(!list.length)return h+`<div class="ep-empty">💬 No testimonials here.</div>`;
list.forEach(t=>{
const av=t.image_path?`<img class="ep-tav" src="${esc(t.image_path)}" alt="">`:`<div class="ep-tavph">${esc(t.full_name.charAt(0))}</div>`;
const sb=`<span class="ep-b ${t.status==='approved'?'bg':t.status==='denied'?'br':'by'}">${t.status}</span>`;
h+=`<div class="ep-tc" data-tid="${esc(t.id)}">${av}<div class="ep-tb">
<div class="ep-tn">${esc(t.full_name)} ${sb}</div><div class="ep-tl">${esc(t.location)}</div>
<div class="ep-tm">${esc(t.message)}</div>
<textarea class="ep-te">${esc(t.message)}</textarea>
<div class="ep-tacts">
${t.status!=='approved'?`<button class="ep-btn ep-suc" data-ta="approve" data-tid="${esc(t.id)}">✓ Approve</button>`:''}
${t.status!=='denied'?`<button class="ep-btn ep-dan" data-ta="deny" data-tid="${esc(t.id)}">✗ Deny</button>`:''}
<button class="ep-btn ep-gho" data-ta="save" data-tid="${esc(t.id)}">💾 Save Edit</button>
<button class="ep-btn ep-dan" data-ta="delete" data-tid="${esc(t.id)}">🗑 Delete</button>
</div></div></div>`;
});
return h;
}
/* Render */
function render(){
const body=document.getElementById('ep-body');if(!body)return;
switch(activeTab){
case'dashboard': body.innerHTML=rDash(); wireDash(); break;
case'destinations':body.innerHTML=rDest(); wireDest(); break;
case'specials': body.innerHTML=rSpec(); wireSpec(); break;
case'testimonials':body.innerHTML=rTest(); wireTest(); break;
}
const pb=document.getElementById('ep-pb');
if(pb){const n=testimonials.filter(t=>t.status==='pending').length;pb.textContent=n||'';pb.style.display=n?'':'none';}
}
/* Wire: Dashboard */
function wireDash(){
document.querySelectorAll('[data-qa]').forEach(b=>b.onclick=async()=>{await api(`/testimonials/${b.dataset.qa}`,{method:'PUT',body:JSON.stringify({status:'approved'})});await loadAll();render();});
document.querySelectorAll('[data-qd]').forEach(b=>b.onclick=async()=>{await api(`/testimonials/${b.dataset.qd}`,{method:'PUT',body:JSON.stringify({status:'denied'})});await loadAll();render();});
}
/* Wire: Destinations */
function wireDest(){
/* Categories */
const ct=document.getElementById('ep-cat-tog'),cb=document.getElementById('ep-cat-box');
if(ct)ct.onclick=()=>cb.classList.toggle('open');
const cc=document.getElementById('ep-cat-cancel');if(cc)cc.onclick=()=>cb.classList.remove('open');
const cs=document.getElementById('ep-cat-save');
if(cs)cs.onclick=async()=>{
const m=document.getElementById('ep-cat-msg'),nm=document.getElementById('ep-cat-new').value.trim();
if(!nm){m.textContent='Name required';m.className='ep-smsg err';return;}
try{await api('/categories',{method:'POST',body:JSON.stringify({name:nm})});m.textContent='Added!';m.className='ep-smsg ok';await loadAll();activeTab='destinations';render();}
catch(e){m.textContent=e.message;m.className='ep-smsg err';}
};
document.querySelectorAll('[data-ren]').forEach(b=>b.onclick=()=>{
const id=b.dataset.ren,cur=b.dataset.cv,row=b.closest('.ep-cat-row');
row.innerHTML=`<input class="ep-cat-ei" id="ep-ren-${id}" value="${esc(cur)}"><button class="ep-btn ep-pri" data-rs="${id}" style="padding:5px 10px;font-size:11px">Save</button><button class="ep-btn ep-gho" data-rc style="padding:5px 10px;font-size:11px">Cancel</button><span class="ep-smsg" id="ep-rm-${id}"></span>`;
row.querySelector('[data-rc]').onclick=()=>{activeTab='destinations';render();};
row.querySelector(`[data-rs="${id}"]`).onclick=async()=>{
const nm=document.getElementById(`ep-ren-${id}`).value.trim(),m=document.getElementById(`ep-rm-${id}`);
if(!nm){m.textContent='Required';m.className='ep-smsg err';return;}
try{await api(`/categories/${id}`,{method:'PUT',body:JSON.stringify({name:nm})});await loadAll();activeTab='destinations';render();}
catch(e){m.textContent=e.message;m.className='ep-smsg err';}
};
});
document.querySelectorAll('[data-dc]').forEach(b=>b.onclick=async()=>{
if(!confirm(`Delete category "${b.dataset.dn}"?`))return;
try{await api(`/categories/${b.dataset.dc}`,{method:'DELETE'});await loadAll();activeTab='destinations';render();}catch(e){alert(e.message);}
});
/* Destination add */
const dt=document.getElementById('ep-dest-tog'),db=document.getElementById('ep-dest-box');
if(dt)dt.onclick=()=>db.classList.toggle('open');
const dn=document.getElementById('ep-dn-cancel');if(dn)dn.onclick=()=>db.classList.remove('open');
const ds=document.getElementById('ep-dn-save');
if(ds)ds.onclick=async()=>{
const m=document.getElementById('ep-dn-msg');
try{
const img=await getImg('ep-dn-img','');
await api('/destinations',{method:'POST',body:JSON.stringify({
name:document.getElementById('ep-dn-name').value.trim(),
location:document.getElementById('ep-dn-loc').value.trim(),
category:document.getElementById('ep-dn-cat').value,
price:document.getElementById('ep-dn-price').value,
rating:document.getElementById('ep-dn-rating').value||'4.5',
image:img,
description:document.getElementById('ep-dn-desc').value.trim()
})});
m.textContent='Saved!';m.className='ep-smsg ok';await loadAll();activeTab='destinations';render();
}catch(e){m.textContent=e.message;m.className='ep-smsg err';}
};
/* Destination edit */
document.querySelectorAll('[data-ed]').forEach(b=>b.onclick=()=>{
const id=b.dataset.ed,d=destinations.find(x=>x.id==id);if(!d)return;
const row=document.querySelector(`tr[data-did="${id}"]`);
row.innerHTML=`<td colspan="7"><div class="ep-inline ep-form">
<div class="ep-fld"><span class="ep-lbl">Name</span><input class="ep-inp" id="ei-n-${id}" value="${esc(d.name)}"></div>
<div class="ep-fld"><span class="ep-lbl">Location</span><input class="ep-inp" id="ei-l-${id}" value="${esc(d.location)}"></div>
<div class="ep-fld"><span class="ep-lbl">Category</span><select class="ep-sel" id="ei-c-${id}">${catOpts(categories,d.category)}</select></div>
<div class="ep-fld"><span class="ep-lbl">Price</span><input class="ep-inp" type="number" id="ei-p-${id}" value="${esc(d.price)}"></div>
<div class="ep-fld"><span class="ep-lbl">Rating</span><input class="ep-inp" type="number" step=".1" id="ei-r-${id}" value="${esc(d.rating)}"></div>
<div class="ep-fld ep-full"><span class="ep-lbl">Image (upload new to replace)</span>${imgPicker('ei-i-'+id,d.image)}</div>
<div class="ep-fld ep-full"><span class="ep-lbl">Description</span><textarea class="ep-ta" id="ei-d-${id}">${esc(d.description)}</textarea></div>
<div class="ep-frow">
<button class="ep-btn ep-gho" data-ec="${id}">Cancel</button>
<button class="ep-btn ep-pri" data-es="${id}">Save Changes</button>
</div>
</div></td>`;
row.querySelector(`[data-ec="${id}"]`).onclick=()=>{row.outerHTML=destRow(d);wireDest();};
row.querySelector(`[data-es="${id}"]`).onclick=async()=>{
try{
const img=await getImg('ei-i-'+id,d.image);
await api(`/destinations/${id}`,{method:'PUT',body:JSON.stringify({
name:document.getElementById(`ei-n-${id}`).value,
location:document.getElementById(`ei-l-${id}`).value,
category:document.getElementById(`ei-c-${id}`).value,
price:document.getElementById(`ei-p-${id}`).value,
rating:document.getElementById(`ei-r-${id}`).value,
image:img,
description:document.getElementById(`ei-d-${id}`).value
})});
await loadAll();activeTab='destinations';render();
}catch(e){alert(e.message);}
};
});
/* Destination delete */
document.querySelectorAll('[data-dd]').forEach(b=>b.onclick=async()=>{
if(!confirm('Delete this destination? This cannot be undone.'))return;
try{await api(`/destinations/${b.dataset.dd}`,{method:'DELETE'});await loadAll();activeTab='destinations';render();}catch(e){alert(e.message);}
});
}
/* Wire: Specials */
function wireSpec(){
const st=document.getElementById('ep-spec-tog'),sb=document.getElementById('ep-spec-box');
if(st)st.onclick=()=>sb.classList.toggle('open');
const sc=document.getElementById('ep-sn-cancel');if(sc)sc.onclick=()=>sb.classList.remove('open');
const ss=document.getElementById('ep-sn-save');
if(ss)ss.onclick=async()=>{
const m=document.getElementById('ep-sn-msg');
try{
const img=await getImg('ep-sn-img',null);
const hls=document.getElementById('ep-sn-hls').value.split('\n').map(l=>l.trim()).filter(Boolean);
await api('/specials',{method:'POST',body:JSON.stringify({
destination_id:document.getElementById('ep-sn-dest').value,
price:document.getElementById('ep-sn-price').value,
discount:document.getElementById('ep-sn-disc').value,
end_date:document.getElementById('ep-sn-end').value,
image_path:img,
highlights:hls
})});
m.textContent='Saved!';m.className='ep-smsg ok';await loadAll();activeTab='specials';render();
}catch(e){m.textContent=e.message;m.className='ep-smsg err';}
};
/* Specials edit */
document.querySelectorAll('[data-es]').forEach(b=>b.onclick=()=>{
const id=b.dataset.es,s=specials.find(x=>x.id==id);if(!s)return;
const dm=Object.fromEntries(destinations.map(d=>[d.id,d]));
const dest=dm[s.destination_id];
const hls=Array.isArray(s.highlights)?s.highlights.join('\n'):'';
const curImg=specImg(s);
const row=document.querySelector(`tr[data-sid="${id}"]`);
row.innerHTML=`<td colspan="8"><div class="ep-inline ep-form">
<div class="ep-fld"><span class="ep-lbl">Original Price</span><input class="ep-inp" type="number" id="si-p-${id}" value="${esc(s.price!=null?s.price:(dest?dest.price:''))}"></div>
<div class="ep-fld"><span class="ep-lbl">Discount %</span><input class="ep-inp" type="number" id="si-d-${id}" value="${esc(s.discount)}"></div>
<div class="ep-fld"><span class="ep-lbl">End Date</span><input class="ep-inp" type="date" id="si-e-${id}" value="${esc(s.end_date)}"></div>
<div class="ep-fld ep-full"><span class="ep-lbl">Image (upload to replace; leave blank to use destination photo)</span>${imgPicker('si-i-'+id,curImg)}</div>
<div class="ep-fld ep-full"><span class="ep-lbl">Highlights (one per line)</span><textarea class="ep-ta" id="si-h-${id}">${esc(hls)}</textarea></div>
<div class="ep-frow">
<button class="ep-btn ep-gho" data-sc="${id}">Cancel</button>
<button class="ep-btn ep-pri" data-sv="${id}">Save</button>
</div>
</div></td>`;
row.querySelector(`[data-sc="${id}"]`).onclick=()=>{row.outerHTML=specRow(s);wireSpec();};
row.querySelector(`[data-sv="${id}"]`).onclick=async()=>{
try{
const newImg=await getImg('si-i-'+id,s.image_path||null);
const hls2=document.getElementById(`si-h-${id}`).value.split('\n').map(l=>l.trim()).filter(Boolean);
await api(`/specials/${id}`,{method:'PUT',body:JSON.stringify({
price:document.getElementById(`si-p-${id}`).value,
discount:document.getElementById(`si-d-${id}`).value,
end_date:document.getElementById(`si-e-${id}`).value,
image_path:newImg,
highlights:hls2
})});
await loadAll();activeTab='specials';render();
}catch(e){alert(e.message);}
};
});
/* Specials delete */
document.querySelectorAll('[data-ds]').forEach(b=>b.onclick=async()=>{
if(!confirm('Delete this special?'))return;
try{await api(`/specials/${b.dataset.ds}`,{method:'DELETE'});await loadAll();activeTab='specials';render();}catch(e){alert(e.message);}
});
}
/* Wire: Testimonials */
function wireTest(){
document.querySelectorAll('[data-tf]').forEach(b=>b.onclick=()=>{tFilter=b.dataset.tf;render();});
document.querySelectorAll('[data-ta]').forEach(b=>b.onclick=async()=>{
const id=b.dataset.tid,action=b.dataset.ta;
const card=document.querySelector(`.ep-tc[data-tid="${id}"]`);
try{
if(action==='approve')await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({status:'approved'})});
else if(action==='deny')await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({status:'denied'})});
else if(action==='save'){const ta=card.querySelector('.ep-te');await api(`/testimonials/${id}`,{method:'PUT',body:JSON.stringify({message:ta.value})});}
else if(action==='delete'){if(!confirm('Delete?'))return;await api(`/testimonials/${id}`,{method:'DELETE'});}
await loadAll();render();
}catch(e){alert(e.message);}
});
}
/* Init */
let injected=false;
function tryInject(){
if(injected)return;
if(!window.location.pathname.startsWith('/admin/dashboard'))return;
if(!localStorage.getItem('isAdminAuthenticated'))return;
const root=document.getElementById('root');
if(!root||!root.children.length)return;
injected=true;obs.disconnect();
Array.from(root.children).forEach(c=>c.style.display='none');
const portal=buildShell();root.insertBefore(portal,root.firstChild);
loadAll().then(render);
}
const obs=new MutationObserver(tryInject);
obs.observe(document.body,{childList:true,subtree:true});
tryInject();
})();