// SafarNext Admin Worker // Adds: page-wise package assignment (Home / Umrah / Umrah All Packages / Promotions) // Preserves: existing auth, existing dashboard styling, existing "Show in Promotions" behavior, // existing /api/umrah-packages and /api/promotions URLs (now backed by the new // page-assignment system so the dashboard checkboxes actually control them). // // UI REVAMP: only dashboardPage() and its helper functions (icons, form builders, tourFields) // were redesigned: sidebar tabs per website page, modals, dropdown menus, toasts, hover effects. // Everything else (routes, auth, database, API endpoints, form field names) is unchanged. // // UI REVAMP v2: white + navy + orange balanced theme, professional thin icons, Add/Edit modal // header/footer no longer sticky, favicon, animated page loader with mini logo. // NOTE: reuses the live public logo file instead of a re-typed base64 blob, // so the admin dashboard logo is guaranteed to render correctly (byte-for-byte // safe) instead of risking a corrupted/truncated data URI. const LOGO_DATA_URI = "https://safarnext.com.pk/images/logo2.png"; /* ---------- schema bootstrap ---------- */ async function ensureBadgeColumn(env) { try { await env.DB.prepare( "ALTER TABLE packages ADD COLUMN badge TEXT DEFAULT 'Umrah Package'" ).run(); } catch (e) {} } async function ensureDaysColumn(env) { try { await env.DB.prepare( "ALTER TABLE packages ADD COLUMN days INTEGER DEFAULT 0" ).run(); } catch (e) {} } async function ensurePromotionColumns(env) { const migrations = [ "ALTER TABLE packages ADD COLUMN category TEXT DEFAULT 'umrah'", "ALTER TABLE packages ADD COLUMN original_price INTEGER", "ALTER TABLE packages ADD COLUMN promotional_price INTEGER", "ALTER TABLE packages ADD COLUMN valid_until TEXT", "ALTER TABLE packages ADD COLUMN is_promotional INTEGER DEFAULT 0" ]; for (const sql of migrations) { try { await env.DB.prepare(sql).run(); } catch (e) {} } } // Extra fields used by the "browse by city/date" Umrah catalog cards. async function ensureCatalogColumns(env) { const migrations = [ "ALTER TABLE packages ADD COLUMN city TEXT", "ALTER TABLE packages ADD COLUMN date_range TEXT", "ALTER TABLE packages ADD COLUMN room_type TEXT", "ALTER TABLE packages ADD COLUMN breakfast TEXT", "ALTER TABLE packages ADD COLUMN ziyarat TEXT" ]; for (const sql of migrations) { try { await env.DB.prepare(sql).run(); } catch (e) {} } } // Holiday Tours (northern / international) fields. Additive only — existing Umrah // packages keep working exactly as before; these columns are simply NULL for them. // price -> first price (Solo / Per Person) // couple_price -> second price (Couple) // features -> one feature per line const TOUR_SECTIONS = [ { slug: "quick-trips", eyebrow: "01 · Near Islamabad", title: "Quick Trips. Big Views.", subtitle: "Short departures close to Islamabad — A Quick Reset, A First Trek or a Kashmir Escape." }, { slug: "green-valleys", eyebrow: "02 · Naran & Kaghan Route", title: "Green Valleys Easy Escapes", subtitle: "Shorter Northern getaways for Families, Friends and First-Time Mountain Travellers." }, { slug: "passes-meadows", eyebrow: "03 · Babusar & Beyond", title: "Passes, Meadows and Forests.", subtitle: "Babusar Top, Fairy Meadows and Kumrat — where the road climbs and the trails begin." }, { slug: "gilgit-baltistan", eyebrow: "04 · Gilgit-Baltistan", title: "Hunza & Skardu, The Big Journeys", subtitle: "Our Signature Long Tours through Hunza, Naltar, Khunjerab and Skardu" } ]; async function ensureTourColumns(env) { const migrations = [ "ALTER TABLE packages ADD COLUMN tour_section TEXT", "ALTER TABLE packages ADD COLUMN duration_text TEXT", "ALTER TABLE packages ADD COLUMN departure_text TEXT", "ALTER TABLE packages ADD COLUMN features TEXT", "ALTER TABLE packages ADD COLUMN tour_note TEXT", "ALTER TABLE packages ADD COLUMN price_label TEXT", "ALTER TABLE packages ADD COLUMN couple_price INTEGER", "ALTER TABLE packages ADD COLUMN tour_sort INTEGER DEFAULT 0" ]; for (const sql of migrations) { try { await env.DB.prepare(sql).run(); } catch (e) {} } } // Page-wise assignment system. Additive only — never touches existing data. async function ensurePagesSchema(env) { try { await env.DB.prepare(` CREATE TABLE IF NOT EXISTS pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, path TEXT NOT NULL, active INTEGER DEFAULT 1, sort_order INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `).run(); } catch (e) {} try { await env.DB.prepare(` CREATE TABLE IF NOT EXISTS package_page_assignments ( package_id INTEGER NOT NULL, page_id INTEGER NOT NULL, sort_order INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (package_id, page_id) ) `).run(); } catch (e) {} // Seed the initial four pages if they don't exist yet. Safe to re-run. const defaults = [ { name: "Home Page", slug: "home", path: "/", sort_order: 1 }, { name: "Umrah", slug: "umrah", path: "/umrah", sort_order: 2 }, { name: "Umrah All Packages", slug: "umrah-packages", path: "/umrah-packages", sort_order: 3 }, { name: "Promotions", slug: "promo", path: "/promo", sort_order: 4 }, { name: "Holiday Tours", slug: "tours", path: "/tours", sort_order: 5 }, { name: "Umrah Without Air", slug: "umrah-no-air", path: "/umrah-packages", sort_order: 6 } ]; for (const pg of defaults) { try { await env.DB.prepare( "INSERT INTO pages (name, slug, path, sort_order) VALUES (?, ?, ?, ?) ON CONFLICT(slug) DO NOTHING" ).bind(pg.name, pg.slug, pg.path, pg.sort_order).run(); } catch (e) {} } } async function getActivePages(env) { const result = await env.DB.prepare( "SELECT id, name, slug, path FROM pages WHERE active = 1 ORDER BY sort_order ASC" ).all(); return result.results || []; } // Returns { [package_id]: ['home','promo', ...] } async function getAssignmentsMap(env) { const result = await env.DB.prepare(` SELECT a.package_id AS package_id, pg.slug AS slug FROM package_page_assignments a JOIN pages pg ON pg.id = a.page_id `).all(); const map = {}; for (const row of result.results || []) { if (!map[row.package_id]) map[row.package_id] = []; map[row.package_id].push(row.slug); } return map; } async function setPackagePages(env, packageId, slugs) { const uniqueSlugs = [...new Set((slugs || []).filter(Boolean))]; await env.DB.prepare( "DELETE FROM package_page_assignments WHERE package_id = ?" ).bind(packageId).run(); for (const slug of uniqueSlugs) { const page = await env.DB.prepare( "SELECT id FROM pages WHERE slug = ? AND active = 1" ).bind(slug).first(); if (page) { await env.DB.prepare( "INSERT INTO package_page_assignments (package_id, page_id) VALUES (?, ?) ON CONFLICT DO NOTHING" ).bind(packageId, page.id).run(); } } // Keep the existing is_promotional column mirrored so old behavior/queries never break. const isPromo = uniqueSlugs.includes("promo") ? 1 : 0; await env.DB.prepare( "UPDATE packages SET is_promotional = ? WHERE id = ?" ).bind(isPromo, packageId).run(); } async function getPackagesForPage(env, slug) { const result = await env.DB.prepare(` SELECT p.* FROM packages p JOIN package_page_assignments a ON a.package_id = p.id JOIN pages pg ON pg.id = a.page_id WHERE pg.slug = ? AND p.status = 'active' ORDER BY a.sort_order ASC, p.id DESC `).bind(slug).all(); return result.results || []; } /* ---------- worker entry ---------- */ // Runs the schema-bootstrap migrations at most once per warm Worker isolate, // instead of on every single request. All 4 migrations below are idempotent // (already applied), so re-checking them per-request was pure wasted latency. let schemaReady = false; export default { async fetch(request, env) { const url = new URL(request.url); if (!schemaReady) { await ensureBadgeColumn(env); await ensureDaysColumn(env); await ensurePromotionColumns(env); await ensureCatalogColumns(env); await ensureTourColumns(env); await ensurePagesSchema(env); schemaReady = true; } if (request.method === "GET" && url.pathname === "/") { return Response.redirect(url.origin + "/admin", 302); } if (request.method === "GET" && url.pathname === "/admin") { if (!await isLoggedIn(request, env)) { return loginPage(); } return dashboardPage(env); } if (request.method === "POST" && url.pathname === "/login") { return handleLogin(request, env); } if (request.method === "GET" && url.pathname === "/logout") { return handleLogout(request, env); } if (request.method === "POST" && url.pathname === "/package/add") { if (!await isLoggedIn(request, env)) return unauthorized(); return addPackage(request, env); } // Generic, page-driven endpoint. Used by the redesigned/new website pages. // GET /api/packages?page=home | umrah | umrah-packages | promo if (request.method === "GET" && url.pathname === "/api/packages") { const pageSlug = url.searchParams.get("page"); if (!pageSlug) { return jsonResponse([], 400, request); } const results = await getPackagesForPage(env, pageSlug); return jsonResponse(results, 200, request); } if (request.method === "GET" && url.pathname === "/api/pages") { const pages = await getActivePages(env); return jsonResponse(pages, 200, request); } // Holiday Tours page. Same data as GET /api/packages?page=tours, plus the section // headings so the website and the admin dropdown share one source of truth. if (request.method === "GET" && url.pathname === "/api/tour-sections") { return jsonResponse(TOUR_SECTIONS, 200, request); } if (request.method === "GET" && url.pathname === "/api/tours") { const results = await getPackagesForPage(env, "tours"); return jsonResponse(results, 200, request); } // LEGACY URL — unchanged path, but now backed by the "home" page assignment // so the dashboard "Show Package On > Home Page" checkbox actually controls it. if (request.method === "GET" && url.pathname === "/api/umrah-packages") { const results = await getPackagesForPage(env, "home"); return jsonResponse(results, 200, request); } // LEGACY URL — unchanged path, now backed by the "promo" page assignment // (kept in sync with is_promotional, so behavior is identical from the admin's view). if (request.method === "GET" && url.pathname === "/api/promotions") { const results = await getPackagesForPage(env, "promo"); return jsonResponse(results, 200, request); } if (request.method === "POST" && url.pathname === "/package/update") { if (!await isLoggedIn(request, env)) return unauthorized(); return updatePackage(request, env); } if (request.method === "GET" && url.pathname === "/package/deactivate") { if (!await isLoggedIn(request, env)) return unauthorized(); return deactivatePackage(request, env); } if (request.method === "GET" && url.pathname === "/package/activate") { if (!await isLoggedIn(request, env)) return unauthorized(); return activatePackage(request, env); } if (request.method === "GET" && url.pathname === "/package/delete") { if (!await isLoggedIn(request, env)) return unauthorized(); return deletePackage(request, env); } if (request.method === "POST" && url.pathname === "/enquiry/status") { if (!await isLoggedIn(request, env)) return unauthorized(); return updateEnquiryStatus(request, env); } return new Response("Not Found", { status: 404 }); } }; const ALLOWED_ORIGINS = [ "https://safarnext.com.pk", "https://www.safarnext.com.pk" ]; function jsonResponse(data, status = 200, request = null) { const origin = request ? request.headers.get("Origin") : null; const allowOrigin = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]; return new Response(JSON.stringify(data || []), { status, headers: { "Content-Type": "application/json; charset=UTF-8", "Cache-Control": "no-store", "Access-Control-Allow-Origin": allowOrigin, "Vary": "Origin" } }); } /* ---------- auth ---------- */ async function handleLogin(request, env) { const form = await request.formData(); const username = String(form.get("username") || ""); const password = String(form.get("password") || ""); if (!env.ADMIN_PASSWORD || !env.ADMIN_USERNAME) { return new Response("ADMIN_USERNAME or ADMIN_PASSWORD secret is not configured.", { status: 500 }); } if (username !== env.ADMIN_USERNAME || password !== env.ADMIN_PASSWORD) { return loginPage("Incorrect username or password."); } const token = crypto.randomUUID(); const tokenHash = await sha256(token); const expiresAt = Date.now() + 8 * 60 * 60 * 1000; await env.DB.prepare("DELETE FROM admin_sessions WHERE expires_at <= ?").bind(Date.now()).run(); await env.DB.prepare("INSERT INTO admin_sessions (token_hash, expires_at) VALUES (?, ?)").bind(tokenHash, expiresAt).run(); return new Response(null, { status: 302, headers: { "Location": "/admin", "Set-Cookie": `admin_session=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=28800` } }); } async function handleLogout(request, env) { const token = getCookie(request, "admin_session"); if (token) { await env.DB.prepare("DELETE FROM admin_sessions WHERE token_hash = ?").bind(await sha256(token)).run(); } return new Response(null, { status: 302, headers: { "Location": "/admin", "Set-Cookie": "admin_session=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0" } }); } async function isLoggedIn(request, env) { const token = getCookie(request, "admin_session"); if (!token) return false; const tokenHash = await sha256(token); const session = await env.DB.prepare( `SELECT token_hash FROM admin_sessions WHERE token_hash = ? AND expires_at > ?` ).bind(tokenHash, Date.now()).first(); return !!session; } function getCookie(request, name) { const cookie = request.headers.get("Cookie") || ""; const match = cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)); return match ? match[1] : null; } async function sha256(value) { const data = new TextEncoder().encode(value); const digest = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join(""); } /* ---------- package CRUD ---------- */ function readPackageForm(form) { return { name: clean(form.get("name")), price: Number(form.get("price") || 0), airline: clean(form.get("airline")), makkah: clean(form.get("makkah_hotel")), madinah: clean(form.get("madinah_hotel")), nights: Number(form.get("nights") || 0), days: Number(form.get("days") || 0), transport: clean(form.get("transport")), visa: clean(form.get("visa")), image: clean(form.get("image")), description: clean(form.get("description")), badge: clean(form.get("badge")) || "Umrah Package", category: clean(form.get("category")) || "umrah", originalPrice: clean(form.get("original_price")) ? Number(form.get("original_price")) : null, promotionalPrice: clean(form.get("promotional_price")) ? Number(form.get("promotional_price")) : null, validUntil: clean(form.get("valid_until")) || null, city: clean(form.get("city")) || null, dateRange: clean(form.get("date_range")) || null, roomType: clean(form.get("room_type")) || null, breakfast: clean(form.get("breakfast")) || null, ziyarat: clean(form.get("ziyarat")) || null, // Holiday Tours fields (optional; empty for Umrah packages) tourSection: clean(form.get("tour_section")) || null, durationText: clean(form.get("duration_text")) || null, departureText: clean(form.get("departure_text")) || null, features: clean(form.get("features")).replace(/\r\n?/g, "\n") || null, tourNote: clean(form.get("tour_note")) || null, priceLabel: clean(form.get("price_label")) || null, couplePrice: clean(form.get("couple_price")) ? Number(form.get("couple_price")) : null, tourSort: Number(form.get("tour_sort") || 0) || 0, // "pages" checkboxes: etc. formData.getAll gives all checked values. pages: form.getAll("pages").map((v) => String(v)) }; } async function addPackage(request, env) { const form = await request.formData(); const d = readPackageForm(form); const isPromotional = d.pages.includes("promo") ? 1 : 0; if (!d.name || d.price <= 0) { return new Response("Package name and valid price are required.", { status: 400 }); } const inserted = await env.DB.prepare(` INSERT INTO packages (name, price, airline, makkah_hotel, madinah_hotel, nights, days, transport, visa, image, description, badge, category, original_price, promotional_price, valid_until, is_promotional, city, date_range, room_type, breakfast, ziyarat, tour_section, duration_text, departure_text, features, tour_note, price_label, couple_price, tour_sort, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active') `).bind( d.name, d.price, d.airline, d.makkah, d.madinah, d.nights, d.days, d.transport, d.visa, d.image, d.description, d.badge, d.category, d.originalPrice, d.promotionalPrice, d.validUntil, isPromotional, d.city, d.dateRange, d.roomType, d.breakfast, d.ziyarat, d.tourSection, d.durationText, d.departureText, d.features, d.tourNote, d.priceLabel, d.couplePrice, d.tourSort ).run(); const newId = inserted.meta && inserted.meta.last_row_id; if (newId) { await setPackagePages(env, newId, d.pages); } return redirect("/admin"); } async function updatePackage(request, env) { const form = await request.formData(); const id = Number(form.get("id")); const d = readPackageForm(form); const isPromotional = d.pages.includes("promo") ? 1 : 0; if (!id || !d.name || d.price <= 0) { return new Response("Invalid package data.", { status: 400 }); } await env.DB.prepare(` UPDATE packages SET name=?, price=?, airline=?, makkah_hotel=?, madinah_hotel=?, nights=?, days=?, transport=?, visa=?, image=?, description=?, badge=?, category=?, original_price=?, promotional_price=?, valid_until=?, is_promotional=?, city=?, date_range=?, room_type=?, breakfast=?, ziyarat=?, tour_section=?, duration_text=?, departure_text=?, features=?, tour_note=?, price_label=?, couple_price=?, tour_sort=? WHERE id=? `).bind( d.name, d.price, d.airline, d.makkah, d.madinah, d.nights, d.days, d.transport, d.visa, d.image, d.description, d.badge, d.category, d.originalPrice, d.promotionalPrice, d.validUntil, isPromotional, d.city, d.dateRange, d.roomType, d.breakfast, d.ziyarat, d.tourSection, d.durationText, d.departureText, d.features, d.tourNote, d.priceLabel, d.couplePrice, d.tourSort, id ).run(); await setPackagePages(env, id, d.pages); return redirect("/admin"); } async function deactivatePackage(request, env) { const url = new URL(request.url); const id = Number(url.searchParams.get("id")); if (id) { await env.DB.prepare("UPDATE packages SET status = 'inactive' WHERE id = ?").bind(id).run(); } return redirect("/admin"); } async function activatePackage(request, env) { const url = new URL(request.url); const id = Number(url.searchParams.get("id")); if (id) { await env.DB.prepare("UPDATE packages SET status = 'active' WHERE id = ?").bind(id).run(); } return redirect("/admin"); } async function deletePackage(request, env) { const url = new URL(request.url); const id = Number(url.searchParams.get("id")); if (id) { await env.DB.prepare("DELETE FROM package_page_assignments WHERE package_id = ?").bind(id).run(); await env.DB.prepare("DELETE FROM packages WHERE id = ?").bind(id).run(); } return redirect("/admin"); } async function updateEnquiryStatus(request, env) { const form = await request.formData(); const id = Number(form.get("id")); const status = clean(form.get("status")); const allowed = ["new", "contacted", "confirmed", "closed"]; if (!allowed.includes(status)) { return new Response("Invalid status.", { status: 400 }); } await env.DB.prepare("UPDATE enquiries SET status = ? WHERE id = ?").bind(status, id).run(); return redirect("/admin"); } /* ---------- styling ---------- */ function baseStyles() { return ` :root{ --navy:#12345B; --navy-deep:#0B2544; --saffron:#E88A2E; --sand:#F6F0E2; --card:#FFFDF8; --ink:#1C2530; --muted:#707A87; --line:#E7DFCC; --success:#1F8A56; --danger:#B7402D; } *{box-sizing:border-box} body{ margin:0; background:var(--sand); color:var(--ink); font-family:'Inter',system-ui,sans-serif; -webkit-font-smoothing:antialiased; } h1,h2,.display{ font-family:'Fraunces',Georgia,serif; } .eyebrow{ font-family:'Inter',sans-serif; font-weight:700; font-size:11.5px; letter-spacing:.14em; text-transform:uppercase; color:var(--saffron); margin-bottom:6px; display:block; } a{color:inherit} .page-loader{ position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; background:#fff; transition:opacity .45s ease, visibility .45s ease; } .page-loader.hide{ opacity:0; visibility:hidden; } .loader-core{ position:relative; width:124px; height:124px; display:grid; place-items:center; } .loader-core img{ width:66px; height:66px; object-fit:contain; border-radius:16px; animation:loaderPulse 1.4s ease-in-out infinite; } .loader-ring{ position:absolute; inset:0; border-radius:50%; border:3px solid #E6EBF2; border-top-color:#E88A2E; border-right-color:#12345B; animation:loaderSpin 1s linear infinite; } @keyframes loaderSpin{ to{ transform:rotate(360deg); } } @keyframes loaderPulse{ 0%,100%{ transform:scale(.9); opacity:.85; } 50%{ transform:scale(1.08); opacity:1; } } `; } // Animated page loader (mini logo + spinning ring). Shown on every page load / refresh, // fades out as soon as the page has finished loading. function loaderHtml() { return ` " breakouts). function safeJson(value) { return JSON.stringify(value).replace(/Umrah `; const BADGE_OPTIONS = ` `; function sectionHead(iconName, title, sub) { return `
${icon(iconName)}

${title}

${sub}

`; } function fInput(prefix, o) { const id = `${prefix}_${o.id}`; return `
${o.extra || ""}
`; } function fSelect(prefix, o) { const id = `${prefix}_${o.id}`; return `
`; } function fTextarea(prefix, o) { const id = `${prefix}_${o.id}`; return `
`; } // One shared form body for both the Add and the Edit modal. Field names are exactly the ones the // server's readPackageForm() expects; ids follow the original "_" pattern // (edit_name, edit_makkah, edit_madinah, ... ) that editPackage() relies on. function packageFormBody(prefix, pagesHtml) { const imgPreview = ``; return `
${sectionHead("edit", "Basics", "Name, category, card highlight, image and description")}
${fInput(prefix, { id: "name", name: "name", label: "Package name", ph: "Economy Umrah", req: true })} ${fSelect(prefix, { id: "category", name: "category", label: "Category", options: CATEGORY_OPTIONS })} ${fSelect(prefix, { id: "badge", name: "badge", label: "Card Highlight", options: BADGE_OPTIONS })} ${fInput(prefix, { id: "image", name: "image", label: "Image URL", ph: "https://... (tours: https://safarnext.com.pk/images/xx.jpeg)", full: true, extra: imgPreview })} ${fTextarea(prefix, { id: "description", name: "description", label: "Description", rows: 4, ph: "Package description", full: true })}
${sectionHead("dollar", "Pricing", "Regular price, optional sale price and offer validity")}
${fInput(prefix, { id: "price", name: "price", label: "Price (PKR)", type: "number", ph: "149999", req: true })} ${fInput(prefix, { id: "original_price", name: "original_price", label: "Original Price (PKR)", type: "number", ph: "Leave blank if not on sale" })} ${fInput(prefix, { id: "promotional_price", name: "promotional_price", label: "Promotional Price (PKR)", type: "number", ph: "Discounted price" })} ${fInput(prefix, { id: "valid_until", name: "valid_until", label: "Valid Until", type: "date" })}
${sectionHead("plane", "Itinerary details", "Flights, hotels, transport and catalog info")}
${fInput(prefix, { id: "airline", name: "airline", label: "Airline", ph: "Saudia / PIA / Airblue" })} ${fInput(prefix, { id: "nights", name: "nights", label: "Nights", type: "number", ph: "10" })} ${fInput(prefix, { id: "days", name: "days", label: "Days", type: "number", min: "1", ph: "04" })} ${fInput(prefix, { id: "makkah", name: "makkah_hotel", label: "Makkah Hotel", ph: "Hotel name" })} ${fInput(prefix, { id: "madinah", name: "madinah_hotel", label: "Madinah Hotel", ph: "Hotel name" })} ${fInput(prefix, { id: "transport", name: "transport", label: "Transport", ph: "Private / Shared" })} ${fInput(prefix, { id: "visa", name: "visa", label: "Visa", ph: "Included" })} ${fInput(prefix, { id: "city", name: "city", label: "Departure City", ph: "Lahore / Karachi" })} ${fInput(prefix, { id: "date_range", name: "date_range", label: "Date Range", ph: "22 Aug 2026 - 11 Sep 2026" })} ${fInput(prefix, { id: "room_type", name: "room_type", label: "Room Type", ph: "Quad Sharing" })} ${fInput(prefix, { id: "breakfast", name: "breakfast", label: "Breakfast", ph: "Yes, daily" })} ${fInput(prefix, { id: "ziyarat", name: "ziyarat", label: "Ziyarat", ph: "Guided Ziyarat Tour Included" })}
${tourFields(prefix)}
${sectionHead("layers", "Show package on", "Pick the website pages where this package should appear")}
${pagesHtml}
`; } /* ---------- dashboard ---------- */ async function dashboardPage(env) { const packageData = await env.DB.prepare("SELECT * FROM packages ORDER BY id DESC").all(); const enquiryData = await env.DB.prepare("SELECT * FROM enquiries ORDER BY id DESC LIMIT 20").all(); const packages = packageData.results || []; const enquiries = enquiryData.results || []; const pages = await getActivePages(env); const assignmentsMap = await getAssignmentsMap(env); const activePackages = packages.filter((p) => p.status === "active").length; const inactivePackages = packages.length - activePackages; const newEnquiries = enquiries.filter((e) => (e.status || "new") === "new").length; const categoryLabel = { umrah: "Umrah", northern: "Northern Tours", international: "International Tours" }; const pageLabelBySlug = Object.fromEntries(pages.map((pg) => [pg.slug, pg.name])); // Attach a `_pages` array (slugs) to each package object for use in dashboard JS + rendering. for (const p of packages) { p._pages = assignmentsMap[p.id] || []; } function pageCheckboxes(idPrefix, selectedSlugs) { return pages.map((pg) => { const checked = selectedSlugs.includes(pg.slug) ? "checked" : ""; return ` `; }).join(""); } function showingOnTags(slugs) { if (!slugs || !slugs.length) { return `Not assigned to any page`; } return slugs.map((slug) => `${escapeHtml(pageLabelBySlug[slug] || slug)}`).join(""); } /* ----- sidebar ----- */ const pageNav = pages.map((pg) => { const total = packages.filter((p) => (p._pages || []).includes(pg.slug)).length; return ``; }).join(""); /* ----- tab definitions (drive the banner in the browser) ----- */ const tabsData = { overview: { view: "overview", eyebrow: "Dashboard", title: "Welcome back", desc: "A quick look at your packages, website pages and customer enquiries.", path: null }, all: { view: "packages", eyebrow: "Package catalog", title: "All Packages", desc: "Every package across all website pages, in one place.", path: null }, enquiries: { view: "enquiries", eyebrow: "Customer enquiries", title: "Recent Enquiries", desc: "The latest 20 enquiries from your website. Update status right from the list.", path: null } }; for (const pg of pages) { tabsData["page:" + pg.slug] = { view: "packages", eyebrow: "Website page", title: pg.name, desc: "Packages that appear on the " + pg.name + " page of safarnext.com.pk.", path: pg.path }; } const jsIcons = { success: icon("check"), error: icon("alert"), alert: icon("alert"), trash: icon("trash"), power: icon("power"), x: icon("x") }; /* ----- overview tiles ----- */ const pageTiles = pages.map((pg) => { const assigned = packages.filter((p) => (p._pages || []).includes(pg.slug)); const activeCount = assigned.filter((p) => p.status === "active").length; return ` `; }).join(""); const latestEnquiries = enquiries.slice(0, 5).map((e) => { const st = e.status || "new"; return `
  • ${escapeHtml(String(e.name || "?").trim().slice(0, 1).toUpperCase() || "?")}
    ${escapeHtml(e.name)}${escapeHtml(e.whatsapp)}${e.travel_date ? " · " + escapeHtml(e.travel_date) : ""}
    ${escapeHtml(st)}
  • `; }).join(""); /* ----- package cards ----- */ const packageCards = packages.map((p, idx) => { const catKey = p.category || "umrah"; const isActive = p.status === "active"; const statusText = isActive ? "active" : (p.status || "inactive"); const infoTags = [ p.airline ? `Airline${escapeHtml(p.airline)}` : null, p.makkah_hotel ? `Makkah${escapeHtml(p.makkah_hotel)}` : null, p.madinah_hotel ? `Madinah${escapeHtml(p.madinah_hotel)}` : null, p.transport ? `Transport${escapeHtml(p.transport)}` : null, p.visa ? `Visa${escapeHtml(p.visa)}` : null ].filter(Boolean).map((t) => `${t}`).join(""); const tourLine = (p.duration_text || p.departure_text) ? `
    ${p.duration_text ? `${icon("clock")}${escapeHtml(p.duration_text)}` : ""}${p.departure_text ? `${icon("calendar")}${escapeHtml(p.departure_text)}` : ""}
    ` : ""; const media = `
    ${escapeHtml(p.name).slice(0, 1).toUpperCase()}
    ${p.image ? `` : ""}
    ${p.badge ? `${escapeHtml(p.badge)}` : ""} ${escapeHtml(statusText)}
    `; const pagesAttr = escapeHtml((p._pages || []).join(",")); const searchText = escapeHtml([p.id, p.name, p.badge, p.airline, p.city, p.makkah_hotel, p.madinah_hotel, catKey, statusText].filter(Boolean).join(" ").toLowerCase()); const safeName = escapeHtml(p.name); const statusMenuItem = isActive ? `${icon("power")}Deactivate` : `${icon("power")}Activate`; return `
    ${media}
    #${p.id} · ${categoryLabel[catKey] || "Umrah"} · ${Number(p.nights || 0)} Nights

    ${safeName}

    ${tourLine} ${infoTags ? `
    ${infoTags}
    ` : ""}
    Showing on: ${showingOnTags(p._pages)}
    ${p.description ? `

    ${escapeHtml(p.description)}

    ` : ""}
    ${Number(p.is_promotional) === 1 && p.promotional_price ? `
    PKR ${Number(p.promotional_price).toLocaleString()} PKR ${Number(p.original_price || p.price || 0).toLocaleString()}
    ` : `
    PKR ${Number(p.price || 0).toLocaleString()}
    `}
    ${escapeHtml(p.price_label || "Per person")} ${Number(p.is_promotional) === 1 ? `· Promo${p.valid_until ? ` until ${escapeHtml(p.valid_until)}` : ""}` : ""}
    ${p.couple_price ? `
    CouplePKR ${Number(p.couple_price).toLocaleString()}
    ` : ""}
    `; }).join(""); /* ----- enquiries ----- */ const enqCounts = { all: enquiries.length, new: 0, contacted: 0, confirmed: 0, closed: 0 }; for (const e of enquiries) { const st = e.status || "new"; if (enqCounts[st] !== undefined) enqCounts[st]++; } const enquiryRows = enquiries.map((e) => { const st = e.status || "new"; const searchText = escapeHtml([e.id, e.name, e.whatsapp, e.email, e.travel_date].filter(Boolean).join(" ").toLowerCase()); return ` #${e.id}
    ${escapeHtml(String(e.name || "?").trim().slice(0, 1).toUpperCase() || "?")}${escapeHtml(e.name)}
    ${escapeHtml(e.email || "-")} ${escapeHtml(e.travel_date || "-")}
    `; }).join(""); return html(` SafarNext Admin Dashboard ${loaderHtml()}
    SafarNext
    Website pages

    Manage by page

    ${pageTiles || `
    No website pages configured yet.
    `}
    Customer enquiries

    Latest enquiries

    ${latestEnquiries ? `
      ${latestEnquiries}
    ` : `
    No enquiries yet.
    `}