mirror of
https://github.com/myronblair/epictravelexpeditions
synced 2026-06-30 17:50:08 -05:00
542 lines
36 KiB
JavaScript
542 lines
36 KiB
JavaScript
(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 (1–5)</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 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();
|
||
})();
|