mirror of
https://github.com/myronblair/epictravelexpeditions
synced 2026-06-30 17:50:08 -05:00
Initial commit
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
(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();
|
||||
})();
|
||||
@@ -0,0 +1,541 @@
|
||||
(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();
|
||||
})();
|
||||
@@ -0,0 +1,208 @@
|
||||
(function () {
|
||||
const API = 'https://epictravelexpeditions.com/api';
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#et-admin-section { padding: 32px 0; }
|
||||
#et-admin-section h3 { font-size: 1.4rem; font-weight: 700; color: #111827; margin-bottom: 16px; }
|
||||
.et-admin-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.et-admin-avatar {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
object-fit: cover; flex-shrink: 0; background: #e5e7eb;
|
||||
}
|
||||
.et-admin-avatar-ph {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
background: #2563eb; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 18px; flex-shrink: 0;
|
||||
}
|
||||
.et-admin-body { flex: 1; min-width: 0; }
|
||||
.et-admin-name { font-weight: 700; font-size: 14px; }
|
||||
.et-admin-loc { font-size: 12px; color: #6b7280; margin-bottom: 6px; }
|
||||
.et-admin-msg { font-size: 13px; color: #374151; margin-bottom: 10px; }
|
||||
.et-admin-status {
|
||||
display: inline-block;
|
||||
padding: 2px 10px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.et-status-pending { background: #fef3c7; color: #92400e; }
|
||||
.et-status-approved { background: #d1fae5; color: #065f46; }
|
||||
.et-status-denied { background: #fee2e2; color: #991b1b; }
|
||||
.et-admin-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.et-btn {
|
||||
padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 600;
|
||||
border: none; cursor: pointer; transition: opacity .15s;
|
||||
}
|
||||
.et-btn:hover { opacity: .8; }
|
||||
.et-btn-approve { background: #16a34a; color: #fff; }
|
||||
.et-btn-deny { background: #dc2626; color: #fff; }
|
||||
.et-btn-delete { background: #6b7280; color: #fff; }
|
||||
.et-btn-save { background: #2563eb; color: #fff; }
|
||||
.et-edit-area {
|
||||
width: 100%; box-sizing: border-box;
|
||||
border: 1px solid #d1d5db; border-radius: 6px;
|
||||
padding: 8px 10px; font-size: 13px;
|
||||
resize: vertical; min-height: 60px; margin-bottom: 6px;
|
||||
}
|
||||
.et-tabs { display: flex; gap: 4px; margin-bottom: 20px; }
|
||||
.et-tab {
|
||||
padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600;
|
||||
cursor: pointer; border: 1px solid #d1d5db; background: #fff; color: #374151;
|
||||
}
|
||||
.et-tab.active { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||
.et-empty { color: #9ca3af; font-size: 14px; text-align: center; padding: 32px 0; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
function statusBadge(s) {
|
||||
return `<span class="et-admin-status et-status-${s}">${s}</span>`;
|
||||
}
|
||||
|
||||
function avatarHtml(t) {
|
||||
if (t.image_path) return `<img class="et-admin-avatar" src="${t.image_path}" alt="">`;
|
||||
return `<div class="et-admin-avatar-ph">${t.full_name.charAt(0).toUpperCase()}</div>`;
|
||||
}
|
||||
|
||||
async function applyAction(id, payload) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${API}/testimonials/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function deleteTestimonial(id) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
await fetch(`${API}/testimonials/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
|
||||
function renderCards(list, container, reload) {
|
||||
container.innerHTML = '';
|
||||
if (!list.length) {
|
||||
container.innerHTML = '<p class="et-empty">No testimonials in this category.</p>';
|
||||
return;
|
||||
}
|
||||
list.forEach(t => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'et-admin-card';
|
||||
card.dataset.id = t.id;
|
||||
card.innerHTML = `
|
||||
${avatarHtml(t)}
|
||||
<div class="et-admin-body">
|
||||
<div class="et-admin-name">${t.full_name}</div>
|
||||
<div class="et-admin-loc">${t.location}</div>
|
||||
${statusBadge(t.status)}
|
||||
<div class="et-admin-msg">${t.message}</div>
|
||||
<textarea class="et-edit-area" data-orig="${t.message.replace(/"/g,'"')}">${t.message}</textarea>
|
||||
<div class="et-admin-actions">
|
||||
${t.status !== 'approved' ? '<button class="et-btn et-btn-approve" data-action="approve">Approve</button>' : ''}
|
||||
${t.status !== 'denied' ? '<button class="et-btn et-btn-deny" data-action="deny">Deny</button>' : ''}
|
||||
<button class="et-btn et-btn-save" data-action="save">Save Edit</button>
|
||||
<button class="et-btn et-btn-delete" data-action="delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.querySelectorAll('[data-action]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const action = btn.dataset.action;
|
||||
const textarea = card.querySelector('.et-edit-area');
|
||||
try {
|
||||
if (action === 'approve') await applyAction(t.id, { status: 'approved' });
|
||||
else if (action === 'deny') await applyAction(t.id, { status: 'denied' });
|
||||
else if (action === 'save') await applyAction(t.id, { message: textarea.value });
|
||||
else if (action === 'delete') { if (!confirm('Delete this testimonial?')) return; await deleteTestimonial(t.id); }
|
||||
reload();
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
});
|
||||
});
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function buildAdminPanel(mountEl) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${API}/testimonials/all`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const all = await res.json();
|
||||
|
||||
const section = document.createElement('div');
|
||||
section.id = 'et-admin-section';
|
||||
section.innerHTML = `
|
||||
<h3>Testimonials</h3>
|
||||
<div class="et-tabs">
|
||||
<button class="et-tab active" data-filter="pending">Pending (${all.filter(t=>t.status==='pending').length})</button>
|
||||
<button class="et-tab" data-filter="approved">Approved (${all.filter(t=>t.status==='approved').length})</button>
|
||||
<button class="et-tab" data-filter="denied">Denied (${all.filter(t=>t.status==='denied').length})</button>
|
||||
<button class="et-tab" data-filter="all">All (${all.length})</button>
|
||||
</div>
|
||||
<div id="et-admin-cards"></div>
|
||||
`;
|
||||
|
||||
let currentFilter = 'pending';
|
||||
const cardsEl = section.querySelector('#et-admin-cards');
|
||||
|
||||
async function reload() {
|
||||
const res = await fetch(`${API}/testimonials/all`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const fresh = await res.json();
|
||||
const filtered = currentFilter === 'all' ? fresh : fresh.filter(t => t.status === currentFilter);
|
||||
renderCards(filtered, cardsEl, reload);
|
||||
// Update counts
|
||||
section.querySelectorAll('.et-tab').forEach(tab => {
|
||||
const f = tab.dataset.filter;
|
||||
const count = f === 'all' ? fresh.length : fresh.filter(t => t.status === f).length;
|
||||
tab.textContent = `${f.charAt(0).toUpperCase()+f.slice(1)} (${count})`;
|
||||
if (f === currentFilter) tab.classList.add('active');
|
||||
else tab.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
section.querySelectorAll('.et-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
currentFilter = tab.dataset.filter;
|
||||
reload();
|
||||
});
|
||||
});
|
||||
|
||||
const initialFiltered = all.filter(t => t.status === 'pending');
|
||||
renderCards(initialFiltered, cardsEl, reload);
|
||||
mountEl.appendChild(section);
|
||||
}
|
||||
|
||||
// Watch for admin dashboard
|
||||
let adminDone = false;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (adminDone) return;
|
||||
if (!window.location.pathname.startsWith('/admin/dashboard')) return;
|
||||
if (!localStorage.getItem('isAdminAuthenticated')) return;
|
||||
|
||||
// Find the main content area of the admin panel
|
||||
const main = document.querySelector('[class*="max-w-7xl"]');
|
||||
if (!main) return;
|
||||
if (document.getElementById('et-admin-section')) return;
|
||||
|
||||
adminDone = true;
|
||||
observer.disconnect();
|
||||
buildAdminPanel(main);
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license React
|
||||
* react-dom-client.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license lucide-react v0.507.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* react-router v7.11.0
|
||||
*
|
||||
* Copyright (c) Remix Software Inc.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,313 @@
|
||||
(function () {
|
||||
const API = 'https://epictravelexpeditions.com/api';
|
||||
|
||||
/* ── Styles ── */
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.et-scroll-track { overflow: hidden; position: relative; }
|
||||
.et-scroll-track::before,
|
||||
.et-scroll-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; bottom: 0;
|
||||
width: 80px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.et-scroll-track::before { left: 0; background: linear-gradient(to right, white, transparent); }
|
||||
.et-scroll-track::after { right: 0; background: linear-gradient(to left, white, transparent); }
|
||||
.et-scroll-inner {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
width: max-content;
|
||||
animation: et-slide 40s linear infinite;
|
||||
}
|
||||
.et-scroll-inner:hover { animation-play-state: paused; }
|
||||
@keyframes et-slide {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
.et-card {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.06);
|
||||
transition: box-shadow .2s;
|
||||
}
|
||||
.et-card:hover { box-shadow: 0 6px 20px rgba(0,0,0,.12); }
|
||||
.et-avatar {
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.et-avatar-placeholder {
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 22px; font-weight: 700;
|
||||
}
|
||||
.et-name { font-weight: 700; font-size: 15px; color: #111827; margin: 0; }
|
||||
.et-loc { font-size: 13px; color: #6b7280; margin: 2px 0 12px; }
|
||||
.et-msg { font-size: 14px; color: #374151; line-height: 1.6; font-style: italic; }
|
||||
|
||||
/* ── Form ── */
|
||||
#et-form-section {
|
||||
background: #f9fafb;
|
||||
padding: 64px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
#et-form-section h2 {
|
||||
font-size: 2rem; font-weight: 700; color: #111827; margin-bottom: 8px;
|
||||
}
|
||||
#et-form-section p {
|
||||
color: #6b7280; margin-bottom: 32px;
|
||||
}
|
||||
#et-form {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
#et-form input, #et-form textarea {
|
||||
width: 100%; box-sizing: border-box;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
#et-form input:focus, #et-form textarea:focus { border-color: #3b82f6; }
|
||||
#et-form textarea { resize: vertical; min-height: 100px; }
|
||||
.et-file-label {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
background: #fff;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.et-file-label:hover { border-color: #3b82f6; color: #3b82f6; }
|
||||
#et-file-input { display: none; }
|
||||
#et-preview { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; display: none; }
|
||||
#et-submit {
|
||||
padding: 12px 24px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
align-self: flex-end;
|
||||
}
|
||||
#et-submit:hover { background: #1d4ed8; }
|
||||
#et-submit:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
#et-msg { font-size: 14px; text-align: center; }
|
||||
.et-success { color: #16a34a; }
|
||||
.et-error { color: #dc2626; }
|
||||
#et-img-row { display: flex; align-items: center; gap: 12px; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
/* ── Build a testimonial card ── */
|
||||
function buildCard(t) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'et-card';
|
||||
|
||||
let avatarHtml;
|
||||
if (t.image_path) {
|
||||
avatarHtml = `<img class="et-avatar" src="${t.image_path}" alt="${t.full_name}" onerror="this.style.display='none'">`;
|
||||
} else {
|
||||
const initial = t.full_name.charAt(0).toUpperCase();
|
||||
avatarHtml = `<div class="et-avatar-placeholder">${initial}</div>`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
||||
${avatarHtml}
|
||||
<div>
|
||||
<p class="et-name">${t.full_name}</p>
|
||||
<p class="et-loc">${t.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="et-msg">“${t.message}”</p>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
/* ── Replace static testimonials section ── */
|
||||
function replaceTestimonials(section, testimonials) {
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'et-scroll-inner';
|
||||
|
||||
const cards = testimonials.map(buildCard);
|
||||
cards.forEach(c => inner.appendChild(c));
|
||||
// Duplicate for seamless loop
|
||||
cards.forEach(c => inner.appendChild(c.cloneNode(true)));
|
||||
|
||||
const track = document.createElement('div');
|
||||
track.className = 'et-scroll-track';
|
||||
track.appendChild(inner);
|
||||
|
||||
// Keep the heading, replace the grid
|
||||
const heading = section.querySelector('[class*="grid"]') || section.lastElementChild;
|
||||
if (heading) heading.replaceWith(track);
|
||||
else section.appendChild(track);
|
||||
}
|
||||
|
||||
/* ── Upload image (no auth required for testimonials) ── */
|
||||
async function uploadImage(file) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch(`${API}/testimonials/upload`, { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Upload failed');
|
||||
return data.url;
|
||||
}
|
||||
|
||||
/* ── Submission form ── */
|
||||
function buildForm() {
|
||||
const section = document.createElement('section');
|
||||
section.id = 'et-form-section';
|
||||
section.innerHTML = `
|
||||
<h2>Share Your Experience</h2>
|
||||
<p>Traveled with us? We'd love to hear about it.</p>
|
||||
<form id="et-form" novalidate>
|
||||
<input id="et-name" type="text" placeholder="Full Name *" required>
|
||||
<input id="et-location" type="text" placeholder="City, State / Country *" required>
|
||||
<textarea id="et-msg-input" placeholder="Tell us about your trip *" required></textarea>
|
||||
<div id="et-img-row">
|
||||
<label class="et-file-label" for="et-file-input">
|
||||
📷 Add a photo (optional)
|
||||
</label>
|
||||
<input id="et-file-input" type="file" accept="image/jpeg,image/png,image/webp">
|
||||
<img id="et-preview" src="" alt="preview">
|
||||
</div>
|
||||
<div id="et-msg"></div>
|
||||
<button id="et-submit" type="submit">Submit Testimonial</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
setTimeout(() => {
|
||||
const form = document.getElementById('et-form');
|
||||
const nameEl = document.getElementById('et-name');
|
||||
const locEl = document.getElementById('et-location');
|
||||
const msgEl = document.getElementById('et-msg-input');
|
||||
const fileEl = document.getElementById('et-file-input');
|
||||
const preview = document.getElementById('et-preview');
|
||||
const statusEl = document.getElementById('et-msg');
|
||||
const submitBtn = document.getElementById('et-submit');
|
||||
|
||||
fileEl.addEventListener('change', () => {
|
||||
const f = fileEl.files[0];
|
||||
if (f) {
|
||||
preview.src = URL.createObjectURL(f);
|
||||
preview.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
statusEl.textContent = '';
|
||||
const name = nameEl.value.trim();
|
||||
const loc = locEl.value.trim();
|
||||
const msg = msgEl.value.trim();
|
||||
if (!name || !loc || !msg) {
|
||||
statusEl.textContent = 'Please fill in all required fields.';
|
||||
statusEl.className = 'et-error';
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Submitting…';
|
||||
|
||||
try {
|
||||
let imagePath = null;
|
||||
if (fileEl.files[0]) {
|
||||
imagePath = await uploadImage(fileEl.files[0]);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API}/testimonials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ full_name: name, location: loc, message: msg, image_path: imagePath })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Submission failed');
|
||||
|
||||
statusEl.textContent = 'Thank you! Your testimonial has been submitted for review.';
|
||||
statusEl.className = 'et-success';
|
||||
form.reset();
|
||||
preview.style.display = 'none';
|
||||
} catch (err) {
|
||||
statusEl.textContent = err.message;
|
||||
statusEl.className = 'et-error';
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Submit Testimonial';
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
/* ── Main: observe DOM until static testimonials appear ── */
|
||||
async function init() {
|
||||
let testimonials = [];
|
||||
try {
|
||||
const res = await fetch(`${API}/testimonials`);
|
||||
testimonials = await res.json();
|
||||
} catch (_) {}
|
||||
|
||||
let done = false;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (done) return;
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.nodeValue && node.nodeValue.includes('What Our Travelers Say')) {
|
||||
// Walk up to the section/div container
|
||||
let el = node.parentElement;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (el && (el.tagName === 'SECTION' || (el.className && el.className.includes('py-')))) break;
|
||||
el = el ? el.parentElement : null;
|
||||
}
|
||||
if (!el) break;
|
||||
done = true;
|
||||
observer.disconnect();
|
||||
|
||||
if (testimonials.length > 0) {
|
||||
replaceTestimonials(el, testimonials);
|
||||
}
|
||||
|
||||
// Inject form after the section
|
||||
const formSection = buildForm();
|
||||
el.after(formSection);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user