// script.js — AussieTradieHub (safe on all pages) // ---------------------------- // Batch N3: Storage helpers // ---------------------------- window.ATHStore = window.ATHStore || (function () { function get(key, fallback) { try { const raw = localStorage.getItem(key); if (raw === null || raw === undefined) return fallback; return JSON.parse(raw); } catch (e) { return fallback; } } function set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { // ignore quota/serialization failures in demo } } function seedOnce(key, seedValue) { const existing = get(key, null); if (existing !== null && existing !== undefined) return existing; const seeded = seedValue || {}; set(key, seeded); return seeded; } return { get, set, seedOnce }; })(); // ---------------------------- // v0.026: Prototype auth (email/password) + session // ---------------------------- // Client-side only (localStorage). This is for MVP UX testing. // It is intentionally separated from `athCurrentUser` so the existing demo // identity/profile/job-ownership model continues to work unchanged. window.ATHAuth = window.ATHAuth || (function () { const USERS_KEY = 'athAuthUsers'; const SESSION_KEY = 'athAuthSession'; function readUsers() { const u = window.ATHStore?.get(USERS_KEY, []); return Array.isArray(u) ? u : []; } function writeUsers(users) { window.ATHStore?.set(USERS_KEY, Array.isArray(users) ? users : []); } function getSession() { const s = window.ATHStore?.get(SESSION_KEY, null); return (s && typeof s === 'object') ? s : null; } function setSession(session) { window.ATHStore?.set(SESSION_KEY, session); try { window.dispatchEvent(new CustomEvent('ath:authchange', { detail: { session: getSession() } })); } catch { } } function clearSession() { try { localStorage.removeItem(SESSION_KEY); } catch { } try { window.dispatchEvent(new CustomEvent('ath:authchange', { detail: { session: null } })); } catch { } } function normalizeEmail(email) { return String(email || '').trim().toLowerCase(); } function isValidEmail(email) { const e = normalizeEmail(email); // permissive RFC-ish check for MVP return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e); } async function sha256Hex(input) { const str = String(input || ''); try { if (!window.crypto?.subtle) throw new Error('no-subtle'); const enc = new TextEncoder(); const buf = await window.crypto.subtle.digest('SHA-256', enc.encode(str)); const bytes = Array.from(new Uint8Array(buf)); return bytes.map(b => b.toString(16).padStart(2, '0')).join(''); } catch { // Fallback: not cryptographically strong. Still avoids plaintext storage. let h = 0; for (let i = 0; i < str.length; i++) h = ((h << 5) - h) + str.charCodeAt(i); return `weak-${Math.abs(h)}`; } } function makeId() { return `auth-${Date.now()}-${Math.random().toString(16).slice(2)}`; } async function signUp(email, password) { const e = normalizeEmail(email); if (!isValidEmail(e)) return { ok: false, error: 'Please enter a valid email.' }; const p = String(password || ''); if (p.length < 6) return { ok: false, error: 'Password must be at least 6 characters.' }; const users = readUsers(); if (users.some(u => normalizeEmail(u?.email) === e)) { return { ok: false, error: 'An account with that email already exists.' }; } const passwordHash = await sha256Hex(p); const user = { id: makeId(), email: e, passwordHash, emailVerified: false, createdAt: new Date().toISOString() }; users.push(user); writeUsers(users); setSession({ userId: user.id, email: user.email, signedInAt: new Date().toISOString() }); return { ok: true, user }; } async function signIn(email, password) { const e = normalizeEmail(email); if (!isValidEmail(e)) return { ok: false, error: 'Please enter a valid email.' }; const p = String(password || ''); const users = readUsers(); const user = users.find(u => normalizeEmail(u?.email) === e); if (!user) return { ok: false, error: 'Incorrect email or password.' }; const hash = await sha256Hex(p); if (String(user.passwordHash) !== String(hash)) { return { ok: false, error: 'Incorrect email or password.' }; } return { ok: true, user }; } async function signInWithGoogle(googleUser) { // googleUser: { email, name, picture, sub } const e = normalizeEmail(googleUser.email); const users = readUsers(); // Check if user exists let user = users.find(u => normalizeEmail(u?.email) === e); if (!user) { // Create new user from Google data user = { id: makeId(), email: e, // No password hash for google-only users, or we could set a random one passwordHash: 'google-auth-no-pass', displayName: googleUser.name, avatar: googleUser.picture, // Store Google avatar URL emailVerified: true, // Google emails are verified createdAt: new Date().toISOString(), authProvider: 'google' }; users.push(user); writeUsers(users); } else { // Update existing user with latest Google info (optional, but good for avatar) // Only update if they don't have a custom avatar set locally? // For MVP, let's update basic info if it's missing or if they are a google user. if (!user.displayName) user.displayName = googleUser.name; if (!user.avatar) user.avatar = googleUser.picture; // Merge changes const idx = users.indexOf(user); if (idx !== -1) { users[idx] = user; writeUsers(users); } } setSession({ userId: user.id, email: user.email, signedInAt: new Date().toISOString(), provider: 'google' }); return { ok: true, user }; } function signOut() { clearSession(); } function getCurrentAuthUser() { const s = getSession(); if (!s?.userId) return null; const users = readUsers(); return users.find(u => String(u?.id) === String(s.userId)) || null; } return { readUsers, getSession, setSession, clearSession, signUp, signIn, signInWithGoogle, signOut, getCurrentAuthUser, normalizeEmail, isValidEmail, }; })(); // ---------------------------- // v0.016: Image helpers (shared) // ---------------------------- // Reusable client-side image processing for localStorage-only uploads. // This is used for avatar upload and job completion photos. window.ATHImages = window.ATHImages || (function () { async function readFileAsDataUrl(file) { return await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result || '')); r.onerror = () => reject(new Error('read-failed')); r.readAsDataURL(file); }); } async function loadImage(dataUrl) { return await new Promise((resolve, reject) => { const i = new Image(); i.onload = () => resolve(i); i.onerror = () => reject(new Error('img-load-failed')); i.src = dataUrl; }); } function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); } // Options: // - maxBytes (default 3MB) // - maxDim (default 1024) // - cropSquare (default false) // - mimePrefer (default 'image/webp') // - quality (default 0.85) async function processImageFile(file, opts) { const o = opts || {}; if (!file) return null; if (!file.type || !file.type.startsWith('image/')) return null; const MAX_BYTES = Number(o.maxBytes || (3 * 1024 * 1024)); if (file.size > MAX_BYTES) return null; const dataUrl = await readFileAsDataUrl(file); const img = await loadImage(dataUrl); const maxDim = clamp(Number(o.maxDim || 1024), 128, 2048); const cropSquare = !!o.cropSquare; const quality = clamp(Number(o.quality ?? 0.85), 0.5, 0.95); let srcW = img.width; let srcH = img.height; if (!srcW || !srcH) return null; // Determine destination size. let dstW = srcW; let dstH = srcH; if (cropSquare) { const s = Math.min(srcW, srcH); dstW = Math.min(maxDim, s); dstH = dstW; } else { const scale = Math.min(1, maxDim / Math.max(srcW, srcH)); dstW = Math.max(1, Math.round(srcW * scale)); dstH = Math.max(1, Math.round(srcH * scale)); } const canvas = document.createElement('canvas'); canvas.width = dstW; canvas.height = dstH; const ctx = canvas.getContext('2d'); if (!ctx) return null; if (cropSquare) { const s = Math.min(srcW, srcH); const sx = Math.floor((srcW - s) / 2); const sy = Math.floor((srcH - s) / 2); ctx.drawImage(img, sx, sy, s, s, 0, 0, dstW, dstH); } else { ctx.drawImage(img, 0, 0, dstW, dstH); } // Prefer webp when supported; fallback to jpeg. const prefer = String(o.mimePrefer || 'image/webp'); let out = ''; try { out = canvas.toDataURL(prefer, quality); if (!out || !out.startsWith('data:image/')) throw new Error('encode-failed'); // Some browsers may ignore webp; detect mismatch. if (prefer.includes('webp') && !out.startsWith('data:image/webp')) throw new Error('no-webp'); } catch (e) { out = canvas.toDataURL('image/jpeg', quality); } return out; } return { processImageFile }; })(); // ---------------------------- // v0.012: Jobs helpers (shared across pages) // ---------------------------- // Single source of truth for: // - job list composition (seed + posted) // - job state overrides (athJobState) // // IMPORTANT: This is intentionally incremental. We keep existing localStorage // keys and data shapes, and only add the minimum helpers needed to avoid // duplicating job-state logic across the Job Board and Profiles. window.ATHJobs = window.ATHJobs || (function () { const JOB_STATE_KEY = 'athJobState'; const POSTED_JOBS_KEY = 'athPostedJobs'; function readJson(key, fallback) { if (window.ATHStore && typeof window.ATHStore.get === 'function') { return window.ATHStore.get(key, fallback); } try { const raw = localStorage.getItem(key); if (!raw) return fallback; const parsed = JSON.parse(raw); return parsed ?? fallback; } catch { return fallback; } } function writeJson(key, value) { if (window.ATHStore && typeof window.ATHStore.set === 'function') { window.ATHStore.set(key, value); return; } try { localStorage.setItem(key, JSON.stringify(value)); } catch { } } // ---- Posted jobs (local) ---- function readPostedJobsFromStorage() { const parsed = readJson(POSTED_JOBS_KEY, []); return Array.isArray(parsed) ? parsed : []; } // Canonical mapping for jobs created via post-job.html function getCurrentUserIdForJobs() { try { const raw = localStorage.getItem('athCurrentUser'); const u = raw ? JSON.parse(raw) : null; if (u && u.id) return String(u.id); } catch { } return 'me'; } function mapPostedJobToCanonical(j) { const state = (j?.state || '').toString().trim().toUpperCase(); const budget = (j?.budget === 0 || j?.budget) ? Number(j.budget) : null; const postedCats = Array.isArray(j?.categories) ? j.categories : (j?.categories ? String(j.categories).split(',') : (j?.category ? [j.category] : [])); const categories = (typeof window.normalizeTradeIds === 'function') ? window.normalizeTradeIds(postedCats) : (postedCats || []).map(v => String(v || '').trim().toLowerCase()).filter(Boolean); return { id: String(j?.id || `posted-${Date.now()}`), title: String(j?.title || 'Untitled Job'), description: String(j?.description || ''), categories: Array.from(new Set(categories.length ? categories : ['other'])), location: state ? `Australia, ${state}` : 'Australia', state: state || 'ALL', budgetMin: budget || 0, budgetMax: budget || 0, timeline: j?.date ? `Preferred: ${j.date}` : 'Flexible', urgency: 'flexible', type: 'one-off', quotes: 0, // Default to current user ("me") so posted jobs show the correct customer profile. customerId: String(j?.customerId || getCurrentUserIdForJobs()), postedAt: j?.createdAt || new Date().toISOString(), status: 'open', _source: 'local' }; } // ---- Job state overrides (athJobState) ---- function readJobStateMap() { const parsed = readJson(JOB_STATE_KEY, {}); return (parsed && typeof parsed === 'object') ? parsed : {}; } function writeJobStateMap(map) { writeJson(JOB_STATE_KEY, map && typeof map === 'object' ? map : {}); } function getJobState(jobId) { const map = readJobStateMap(); const s = map?.[String(jobId)] || {}; return (s && typeof s === 'object') ? s : {}; } function setJobState(jobId, patch) { const map = readJobStateMap(); const id = String(jobId); const base = (map[id] && typeof map[id] === 'object') ? map[id] : {}; map[id] = { ...base, ...(patch || {}), updatedAt: new Date().toISOString() }; writeJobStateMap(map); return map[id]; } function applyOverrides(job) { const s = getJobState(job?.id); if (!s || typeof s !== 'object') return job; return { ...job, status: s.status || job.status, assignedTradieId: s.assignedTradieId || job.assignedTradieId, completedAt: s.completedAt || job.completedAt, inProgressAt: s.inProgressAt || job.inProgressAt, agreedAt: s.agreedAt || job.agreedAt, tradieAcceptedTermsAt: s.tradieAcceptedTermsAt || job.tradieAcceptedTermsAt, }; } // ---- Composition: seed + posted + overrides ---- function getAllJobs() { const baseJobs = Array.isArray(window.JOBS) ? window.JOBS : []; const postedCanonical = readPostedJobsFromStorage().map(mapPostedJobToCanonical); const byId = new Map(); [...postedCanonical, ...baseJobs].forEach((j) => { if (!j || !j.id) return; byId.set(String(j.id), j); }); return Array.from(byId.values()).map(applyOverrides); } return { JOB_STATE_KEY, POSTED_JOBS_KEY, readPostedJobsFromStorage, mapPostedJobToCanonical, readJobStateMap, writeJobStateMap, getJobState, setJobState, applyOverrides, getAllJobs, }; })(); // ---------------------------- // v0.029 (incremental): Shared job details modal // ---------------------------- // Allows pages outside jobs.html (e.g. profile-customer.html) to open a // lightweight, read-only job details modal. // // Design constraints: // - Do not re-architect: reuse ATHJobs.getAllJobs() (seed + posted + overrides). // - Do not introduce new storage keys. // - Keep UI minimal and consistent with jobs.html modal. window.ATHJobDetails = window.ATHJobDetails || (function () { const MODAL_ID = 'athJobModal'; const TITLE_ID = 'athJobModalTitle'; const BODY_ID = 'athJobModalBody'; const CLOSE_ID = 'athJobModalClose'; function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); } function money(n) { const v = Number(n || 0); if (!isFinite(v) || v <= 0) return '—'; try { return v.toLocaleString('en-AU', { style: 'currency', currency: 'AUD', maximumFractionDigits: 0 }); } catch { return `$${Math.round(v)}`; } } function budgetLabel(job) { const min = Number(job?.budgetMin || 0); const max = Number(job?.budgetMax || 0); if (max && min && max !== min) return `${money(min)} – ${money(max)}`; if (max || min) return money(max || min); return '—'; } function ensureModal() { let modal = document.getElementById(MODAL_ID); if (modal) return modal; // Inject a modal identical in structure to jobs.html. const wrap = document.createElement('div'); wrap.innerHTML = ` `; document.body.appendChild(wrap.firstElementChild); modal = document.getElementById(MODAL_ID); // Wire close behavior. const closeBtn = document.getElementById(CLOSE_ID); const close = () => { modal?.classList.add('hidden'); document.body.classList.remove('overflow-hidden'); }; closeBtn?.addEventListener('click', close); modal?.addEventListener('click', (e) => { if (e.target === modal) close(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); }); if (typeof feather !== 'undefined') feather.replace(); return modal; } function open(jobId) { const all = window.ATHJobs?.getAllJobs?.() || []; const job = all.find(j => String(j?.id) === String(jobId)); if (!job) return; const modal = ensureModal(); const titleEl = document.getElementById(TITLE_ID); const bodyEl = document.getElementById(BODY_ID); if (!modal || !titleEl || !bodyEl) return; const catIds = Array.isArray(job.categories) ? job.categories : (typeof window.normalizeTradeIds === 'function' ? window.normalizeTradeIds(job.categories) : []); const chips = (catIds || []).map((cid) => { const label = (typeof window.tradeLabel === 'function') ? window.tradeLabel(cid) : String(cid || ''); return `${escapeHtml(label)}`; }).join(''); const timeline = String(job.timeline || 'Flexible'); const desc = window.ATHIntegrity ? window.ATHIntegrity.sanitizeText(job.description || '').text : (job.description || ''); const st = String(job.status || 'open').replace('_', ' '); titleEl.textContent = job.title || 'Job details'; bodyEl.innerHTML = `
Status: ${escapeHtml(st)}

Categories

${chips || ''}

Location

${escapeHtml(job.location || '—')}

Budget

${escapeHtml(budgetLabel(job))}

Timeline

${escapeHtml(timeline)}

Description

${escapeHtml(desc)}

`; modal.classList.remove('hidden'); document.body.classList.add('overflow-hidden'); if (typeof feather !== 'undefined') feather.replace(); } return { open, ensureModal }; })(); // ---------------------------- // Batch N3: Integrity (no direct contact until payment) // ---------------------------- window.ATHIntegrity = window.ATHIntegrity || (function () { // Batch N3+: Integrity (no direct contact until payment) const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig; // Best-effort phone matcher: long digit runs with optional separators const PHONE_RE = /(?:\+?\d[\d\s().-]{7,}\d)/g; // URLs with scheme or www const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<]+/ig; // Bare domains (e.g., example.com, example.com.au). TLD must be letters. const DOMAIN_RE = /\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,})(?:\/[\w\-./?%&=+#]*)?/ig; function canShareContact() { // Future: flip true after payment confirmation. // For now: privacy-first prototype rule. return false; } function normalizeForScan(input) { let t = String(input || '').toLowerCase(); // normalize whitespace t = t.replace(/[\r\n\t]+/g, ' '); // normalize common obfuscations t = t.replace(/[\[(\{]\s*dot\s*[\]\)\}]/g, '.'); t = t.replace(/[\[(\{]\s*at\s*[\]\)\}]/g, '@'); t = t.replace(/\s+dot\s+/g, '.'); t = t.replace(/\s+at\s+/g, '@'); // collapse spaced-out words: "t r a d i e" -> "tradie" t = t.replace(/\b(?:[a-z]\s+){2,}[a-z]\b/g, (m) => m.replace(/\s+/g, '')); return t; } function scanText(input) { if (canShareContact()) return { hasContact: false, types: { email: false, phone: false, url: false } }; const original = String(input || ''); const norm = normalizeForScan(original); const compact = norm.replace(/[\s\u200B\u200C\u200D\uFEFF_\-()\[\]{}<>|]+/g, ''); // Email: direct OR obfuscated (at/dot) const email = EMAIL_RE.test(original) || EMAIL_RE.test(norm) || EMAIL_RE.test(compact); // Phone: direct pattern OR obfuscated digit runs (e.g., 0x4x1x...) const digits = compact.replace(/x/g, '').replace(/\D/g, ''); const phone = PHONE_RE.test(original) || (digits.length >= 8); // URL/domain: direct or obfuscated const url = URL_RE.test(original) || DOMAIN_RE.test(norm) || DOMAIN_RE.test(compact); // Reset global regex state after .test on /g EMAIL_RE.lastIndex = 0; PHONE_RE.lastIndex = 0; URL_RE.lastIndex = 0; DOMAIN_RE.lastIndex = 0; return { hasContact: !!(email || phone || url), types: { email: !!email, phone: !!phone, url: !!url } }; } function sanitizeTextJs(input) { const original = String(input || ''); if (canShareContact()) return { text: original, changed: false }; let out = original; // Remove emails out = out.replace(EMAIL_RE, '•••••'); // Remove phone-ish sequences out = out.replace(PHONE_RE, (m) => { const digits = String(m).toLowerCase().replace(/x/g, '').replace(/\D/g, ''); if (digits.length < 8) return m; return '•••••'; }); // Remove URLs + bare domains out = out.replace(URL_RE, '•••••'); out = out.replace(DOMAIN_RE, (m) => { // avoid nuking version strings like v0.029 const hasAlpha = /[a-z]/i.test(m); const hasDot = m.includes('.'); if (!hasAlpha || !hasDot) return m; return '•••••'; }); return { text: out, changed: out !== original }; } function renderBanner(mountEl) { if (!mountEl) return; if (canShareContact()) { mountEl.innerHTML = ''; return; } mountEl.innerHTML = `
Contact details are locked until payment is confirmed.
Keep chat and job info inside TradieHub to stay protected.
`; if (typeof feather !== 'undefined') feather.replace(); } function setInlineNotice(el, msg) { if (!el) return; el.textContent = msg || ''; if (!msg) el.classList.add('hidden'); else el.classList.remove('hidden'); } return { canShareContact, scanText, sanitizeText: sanitizeTextJs, renderBanner, setInlineNotice }; })(); // ---------------------------- // v0.0295: Tradie mini availability calendar (profile block) // ---------------------------- window.ATHAvailability = window.ATHAvailability || (function () { const KEY_PREFIX = 'athTradieAvailability:'; function keyFor(tradieId) { return `${KEY_PREFIX}${String(tradieId || 'unknown')}`; } function read(tradieId) { const v = window.ATHStore?.get(keyFor(tradieId), null); if (v && typeof v === 'object') { const overrides = (v.overrides && typeof v.overrides === 'object') ? v.overrides : {}; return { overrides }; } return { overrides: {} }; } function write(tradieId, data) { const overrides = (data && typeof data === 'object' && data.overrides && typeof data.overrides === 'object') ? data.overrides : {}; window.ATHStore?.set(keyFor(tradieId), { overrides }); } function pad2(n) { return String(n).padStart(2, '0'); } function toYmd(d) { const dt = new Date(d); const y = dt.getFullYear(); const m = pad2(dt.getMonth() + 1); const day = pad2(dt.getDate()); return `${y}-${m}-${day}`; } function defaultAvailableForDate(d) { // Default: available Mon–Sat, unavailable Sun. const wd = new Date(d).getDay(); // 0=Sun return wd !== 0; } function isAvailable(tradieId, d, store) { const s = store || read(tradieId); const ymd = toYmd(d); if (s.overrides && Object.prototype.hasOwnProperty.call(s.overrides, ymd)) { return s.overrides[ymd] === 'available'; } return defaultAvailableForDate(d); } function toggle(tradieId, d, store) { const s = store || read(tradieId); const ymd = toYmd(d); const curr = isAvailable(tradieId, d, s); const next = !curr; const def = defaultAvailableForDate(d); // Only store an override when needed. if (next === def) { try { delete s.overrides[ymd]; } catch { } } else { s.overrides[ymd] = next ? 'available' : 'unavailable'; } write(tradieId, s); return s; } function monthLabel(monthDate) { const d = new Date(monthDate); const fmt = d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); return fmt; } function startOfMonth(d) { const dt = new Date(d); dt.setDate(1); dt.setHours(0, 0, 0, 0); return dt; } function addMonths(d, n) { const dt = new Date(d); dt.setMonth(dt.getMonth() + Number(n || 0)); return startOfMonth(dt); } function render(el, opts, monthDate) { if (!el) return; const tradieId = opts?.tradieId; const editable = !!opts?.editable; const m0 = startOfMonth(monthDate || new Date()); const store = read(tradieId); const daysInMonth = new Date(m0.getFullYear(), m0.getMonth() + 1, 0).getDate(); const firstDow = new Date(m0.getFullYear(), m0.getMonth(), 1).getDay(); // Monday-first offset (0..6) const offset = (firstDow + 6) % 7; const weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const header = `
Availability
${monthLabel(m0)}
`; let grid = '
'; for (const w of weekday) { grid += `
${w}
`; } const totalCells = 42; // 6 weeks for (let i = 0; i < totalCells; i++) { const dayNum = i - offset + 1; if (dayNum < 1 || dayNum > daysInMonth) { grid += '
'; continue; } const date = new Date(m0.getFullYear(), m0.getMonth(), dayNum); const avail = isAvailable(tradieId, date, store); const today = (toYmd(date) === toYmd(new Date())); const base = 'h-8 rounded-lg border text-xs flex items-center justify-center relative select-none'; const stateCls = avail ? 'bg-teal-50 border-teal-200 text-teal-900' : 'bg-gray-50 border-gray-200 text-gray-600'; const todayRing = today ? ' ring-2 ring-teal-300' : ''; const cursor = editable ? ' cursor-pointer hover:opacity-90' : ''; grid += `
${dayNum}
`; } grid += '
'; const hint = editable ? '
Tip: click days to toggle availability.
' : '
Green days mean this tradie is generally available.
'; el.innerHTML = `
${header} ${grid} ${hint}
`; // Wire nav const prev = el.querySelector('[data-ath-cal="prev"]'); const next = el.querySelector('[data-ath-cal="next"]'); if (prev) prev.onclick = () => { el.dataset.athCalMonth = String(addMonths(m0, -1).toISOString()); render(el, opts, addMonths(m0, -1)); }; if (next) next.onclick = () => { el.dataset.athCalMonth = String(addMonths(m0, 1).toISOString()); render(el, opts, addMonths(m0, 1)); }; // Wire toggles (editable only) if (editable) { el.querySelectorAll('[data-ath-cal-day]')?.forEach((cell) => { cell.addEventListener('click', () => { const dn = Number(cell.getAttribute('data-ath-cal-day')); if (!dn) return; toggle(tradieId, new Date(m0.getFullYear(), m0.getMonth(), dn)); render(el, opts, m0); }); }); } if (typeof feather !== 'undefined') feather.replace(); } function mountMiniCalendar(el, opts) { if (!el) return; const initial = (() => { try { const raw = el.dataset.athCalMonth; if (raw) return startOfMonth(new Date(raw)); } catch { } return startOfMonth(new Date()); })(); render(el, opts || {}, initial); } return { mountMiniCalendar, read, write, isAvailable }; })(); // ---------------------------- // v0.0297: Customer booked-jobs calendar (profile block) // ---------------------------- // Calendar mental model: show commitments (accepted jobs), not all posted jobs. // - Customer view: jobs with status agreed or in_progress. // - Booking date (for now): agreedAt (fallback inProgressAt, then postedAt). // - Clicking a booked day opens ATHJobDetails modal. window.ATHCustomerBookings = window.ATHCustomerBookings || (function () { function pad2(n) { return String(n).padStart(2, '0'); } function toYmd(d) { const dt = new Date(d); return `${dt.getFullYear()}-${pad2(dt.getMonth() + 1)}-${pad2(dt.getDate())}`; } function startOfMonth(d) { const dt = new Date(d); dt.setDate(1); dt.setHours(0, 0, 0, 0); return dt; } function addMonths(d, n) { const dt = new Date(d); dt.setMonth(dt.getMonth() + Number(n || 0)); return startOfMonth(dt); } function monthLabel(monthDate) { const d = new Date(monthDate); return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); } function bookingDateForJob(job) { const status = String(job?.status || '').toLowerCase(); const raw = (status === 'agreed') ? (job?.agreedAt || job?.inProgressAt || job?.postedAt) : (status === 'in_progress') ? (job?.inProgressAt || job?.agreedAt || job?.postedAt) : null; if (!raw) return null; const dt = new Date(raw); if (isNaN(dt.getTime())) return null; return dt; } function jobsForDay(customerId, dayDate) { const cid = String(customerId || ''); const all = window.ATHJobs?.getAllJobs?.() || []; const ymd = toYmd(dayDate); return all.filter((j) => { if (String(j?.customerId || '') !== cid) return false; const st = String(j?.status || '').toLowerCase(); if (!(st === 'agreed' || st === 'in_progress')) return false; const bd = bookingDateForJob(j); if (!bd) return false; return toYmd(bd) === ymd; }); } function render(el, opts, monthDate) { if (!el) return; const customerId = opts?.customerId; const m0 = startOfMonth(monthDate || new Date()); const daysInMonth = new Date(m0.getFullYear(), m0.getMonth() + 1, 0).getDate(); const firstDow = new Date(m0.getFullYear(), m0.getMonth(), 1).getDay(); const offset = (firstDow + 6) % 7; // Monday-first const weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const header = `
Booked jobs
${monthLabel(m0)}
`; let grid = '
'; for (const w of weekday) { grid += `
${w}
`; } const totalCells = 42; for (let i = 0; i < totalCells; i++) { const dayNum = i - offset + 1; if (dayNum < 1 || dayNum > daysInMonth) { grid += '
'; continue; } const date = new Date(m0.getFullYear(), m0.getMonth(), dayNum); const today = (toYmd(date) === toYmd(new Date())); const jobs = jobsForDay(customerId, date); const has = jobs.length > 0; const base = 'h-8 rounded-lg border text-xs flex items-center justify-center relative select-none'; const stateCls = has ? 'bg-teal-50 border-teal-200 text-teal-900' : 'bg-gray-50 border-gray-200 text-gray-600'; const todayRing = today ? ' ring-2 ring-teal-300' : ''; const cursor = has ? ' cursor-pointer hover:opacity-90' : ''; const badge = has && jobs.length > 1 ? `${jobs.length}` : ''; const ids = has ? jobs.map(j => String(j.id)).join(',') : ''; grid += `
${dayNum} ${badge}
`; } grid += '
'; const hint = '
Shows jobs you have agreed with a tradie (or currently in progress).
'; el.innerHTML = `
${header} ${grid} ${hint}
`; const prev = el.querySelector('[data-ath-book="prev"]'); const next = el.querySelector('[data-ath-book="next"]'); if (prev) prev.onclick = () => { el.dataset.athBookMonth = String(addMonths(m0, -1).toISOString()); render(el, opts, addMonths(m0, -1)); }; if (next) next.onclick = () => { el.dataset.athBookMonth = String(addMonths(m0, 1).toISOString()); render(el, opts, addMonths(m0, 1)); }; el.querySelectorAll('[data-ath-book-day]')?.forEach((cell) => { cell.addEventListener('click', () => { const ids = String(cell.getAttribute('data-ath-book-ids') || '') .split(',') .map(s => s.trim()) .filter(Boolean); if (!ids.length) return; window.ATHJobDetails?.open?.(ids[0]); }); }); if (typeof feather !== 'undefined') feather.replace(); } function mountMiniCalendar(el, opts) { if (!el) return; const initial = (() => { try { const raw = el.dataset.athBookMonth; if (raw) return startOfMonth(new Date(raw)); } catch { } return startOfMonth(new Date()); })(); render(el, opts || {}, initial); } return { mountMiniCalendar }; })(); document.addEventListener('DOMContentLoaded', () => { if (typeof feather !== 'undefined') feather.replace(); initAuthUI(); // Allow deep-link: index.html#signin try { if (String(window.location.hash || '').toLowerCase() === '#signin') openAuthModal('signin'); } catch { } // initMobileMenu(); // Removed v0.0298 initMessagesPage(); initMyProfilePage(); initFilterDrawer(); initIntegrityBanners(); }); // ---------------------------- // Batch N3: Page banners // ---------------------------- function initIntegrityBanners() { window.ATHIntegrity?.renderBanner(document.getElementById('athIntegrityBannerMountJobs')); // Optional mounts on other pages window.ATHIntegrity?.renderBanner(document.getElementById('athIntegrityBannerMountProfileTradie')); window.ATHIntegrity?.renderBanner(document.getElementById('athIntegrityBannerMountProfileCustomer')); } // ---------------------------- // Batch M: Mobile filter drawer // ---------------------------- function initFilterDrawer() { const drawer = document.getElementById('athFiltersDrawer'); const openBtn = document.getElementById('athOpenFilters'); const backdrop = document.getElementById('athFiltersBackdrop'); const closeBtn = document.getElementById('athCloseFilters'); if (!drawer || (!openBtn && !backdrop)) return; // only on pages that include the drawer const open = () => { drawer.classList.add('ath-open'); if (backdrop) backdrop.classList.remove('hidden'); document.body.classList.add('ath-body-lock'); }; const close = () => { drawer.classList.remove('ath-open'); if (backdrop) backdrop.classList.add('hidden'); document.body.classList.remove('ath-body-lock'); }; if (openBtn) openBtn.addEventListener('click', open); if (closeBtn) closeBtn.addEventListener('click', close); if (backdrop) backdrop.addEventListener('click', close); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); }); // If user resizes to desktop, ensure drawer state doesn't trap scroll. const mq = window.matchMedia('(min-width: 1024px)'); const onMq = () => { if (mq.matches) close(); }; if (mq.addEventListener) mq.addEventListener('change', onMq); else if (mq.addListener) mq.addListener(onMq); } // ---------------------------- // Current User (localStorage) // ---------------------------- function getCurrentUser() { try { const raw = localStorage.getItem('athCurrentUser'); if (raw) return JSON.parse(raw); } catch (e) { } return (window.CURRENT_USER_DEFAULT && typeof window.CURRENT_USER_DEFAULT === 'object') ? JSON.parse(JSON.stringify(window.CURRENT_USER_DEFAULT)) : { id: 'me', role: 'dual', displayName: 'Me', avatar: '', location: { suburb: '', state: '', postcode: '' }, contact: { phone: '', email: '' }, privacy: { showLocation: true, addressRule: 'afterAccepted' }, verification: { verified: false, abnFull: '', licenseFull: '' } }; } function setCurrentUser(user) { localStorage.setItem('athCurrentUser', JSON.stringify(user)); } // Merge helper (keeps nested objects intact). Useful for future auth/backends. function saveCurrentUser(patch) { const base = getCurrentUser(); const merged = { ...base, ...patch, location: { ...(base.location || {}), ...((patch || {}).location || {}) }, contact: { ...(base.contact || {}), ...((patch || {}).contact || {}) }, privacy: { ...(base.privacy || {}), ...((patch || {}).privacy || {}) }, verification: { ...(base.verification || {}), ...((patch || {}).verification || {}) }, auth: { ...(base.auth || {}), ...((patch || {}).auth || {}) }, }; setCurrentUser(merged); return merged; } function maskSensitiveKeepLast4(value) { const v = String(value || '').replace(/\s+/g, ''); if (!v) return '—'; const last4 = v.slice(-4); // safest: only reveal last 4 return `**** **** ${last4}`; } // Global initialization document.addEventListener('DOMContentLoaded', () => { // Call other existing inits if they exist and aren't called elsewhere if (typeof initAuthUI === 'function') initAuthUI(); if (typeof initMessagesPage === 'function') initMessagesPage(); if (typeof initProfilePage === 'function') initProfilePage(); }); // ---------------------------- // v0.026: Auth UI (nav button + modal) // ---------------------------- function initAuthUI() { // Safe guard for pages that don't load fully or run twice try { ensureAuthNavButtons(); ensureAuthModal(); syncAuthNavState(); } catch (e) { // keep page usable even if auth UI fails } } function ensureAuthNavButtons() { // "My Profile" appears in the top nav as an anchor with responsive display // classes (e.g. "hidden md:inline-flex"). Tailwind's responsive display can // override the "hidden" class at larger breakpoints, so we force-hide via // inline style when logged out. const navEl = document.querySelector('nav'); const desktopProfileLink = navEl ? Array.from(navEl.querySelectorAll('a[href^="my-profile.html"]')).find(a => !a.closest('#mobileMenu')) : null; const mobileProfileLink = document.querySelector('#mobileMenu a[href^="my-profile.html"]'); const forceProfileVisibility = (a, visible) => { if (!a) return; a.style.display = visible ? '' : 'none'; a.setAttribute('aria-hidden', visible ? 'false' : 'true'); a.tabIndex = visible ? 0 : -1; }; // v0.026b: default-hide profile links until authenticated (avoids flash before syncAuthNavState runs). try { const session = window.ATHAuth?.getSession?.(); const loggedIn = !!session?.userId; forceProfileVisibility(desktopProfileLink, loggedIn); } catch { } // If logged out, block My Profile navigation and open Sign in instead. const guardProfileLink = (a) => { if (!a || a.getAttribute('data-auth-guarded') === '1') return; a.setAttribute('data-auth-guarded', '1'); a.addEventListener('click', (e) => { const session = window.ATHAuth?.getSession?.(); if (session?.userId) return; // allow e.preventDefault(); openAuthModal('signin'); }); }; guardProfileLink(desktopProfileLink); // v0.027: If logged out, block Messages navigation (requires signed-in account) const allMessagesLinks = Array.from(document.querySelectorAll('a[href^="messages.html"]')); const guardMessagesLink = (a) => { if (!a || a.getAttribute('data-auth-guarded-messages') === '1') return; a.setAttribute('data-auth-guarded-messages', '1'); a.addEventListener('click', (e) => { const session = window.ATHAuth?.getSession?.(); if (session?.userId) return; e.preventDefault(); openAuthModal('signin'); }); }; allMessagesLinks.forEach(guardMessagesLink); if (desktopProfileLink && !document.getElementById('athAuthNavBtn')) { const btn = document.createElement('button'); btn.id = 'athAuthNavBtn'; btn.type = 'button'; btn.className = 'ml-3 px-3 py-2 rounded-lg text-sm font-semibold bg-gray-100 hover:bg-gray-200 transition'; btn.addEventListener('click', onAuthNavClick); desktopProfileLink.insertAdjacentElement('afterend', btn); } // authNavBtn for desktop if (desktopProfileLink && !document.getElementById('athAuthNavBtn')) { const btn = document.createElement('button'); btn.id = 'athAuthNavBtn'; btn.type = 'button'; btn.className = 'ml-3 px-3 py-2 rounded-lg text-sm font-semibold bg-gray-100 hover:bg-gray-200 transition'; btn.addEventListener('click', onAuthNavClick); desktopProfileLink.insertAdjacentElement('afterend', btn); } } // Simple route guard for pages that should require auth. function requireAuthOnPage(opts) { const cfg = opts || {}; const onMissing = cfg.onMissing || 'overlay'; // overlay | redirect const current = (window.location && window.location.pathname) ? window.location.pathname.split('/').pop() : ''; const session = window.ATHAuth?.getSession?.(); if (session?.userId) return true; // Ensure modal exists then prompt ensureAuthModal(); openAuthModal('signin'); if (onMissing === 'redirect') { // Send them home; keep a hint so we can reopen the modal. try { window.location.href = `index.html#signin`; } catch { } return false; } // Overlay the page content const main = document.querySelector('main'); if (main && !document.getElementById('athAuthGate')) { main.classList.add('blur-sm', 'pointer-events-none', 'select-none'); const gate = document.createElement('div'); gate.id = 'athAuthGate'; gate.className = 'fixed inset-0 z-[90] flex items-center justify-center p-4'; gate.innerHTML = `

Sign in required

You need to be signed in to view your profile.

Go home

Prototype note: accounts are stored locally in your browser.

`; document.body.appendChild(gate); gate.querySelector('#athAuthGateSignIn')?.addEventListener('click', () => openAuthModal('signin')); // Un-gate once the user signs in. const onAuth = (ev) => { const s = ev?.detail?.session || window.ATHAuth?.getSession?.(); if (!s?.userId) return; try { document.getElementById('athAuthGate')?.remove(); } catch { } try { main.classList.remove('blur-sm', 'pointer-events-none', 'select-none'); } catch { } try { window.removeEventListener('ath:authchange', onAuth); } catch { } // If this page has its own init relying on auth, reload to keep it simple. try { window.location.reload(); } catch { } }; window.addEventListener('ath:authchange', onAuth); } return false; } function ensureAuthModal() { if (document.getElementById('athAuthModal')) return; const wrap = document.createElement('div'); wrap.id = 'athAuthModal'; wrap.className = 'fixed inset-0 z-[100] hidden'; wrap.innerHTML = `

Sign in

Prototype auth (local only). Email verification coming soon.

Note: accounts are stored in your browser only. Other users won't see them.
or
Unverified Email verification is not implemented yet.
`; document.body.appendChild(wrap); if (typeof feather !== 'undefined') feather.replace(); // Wire close wrap.querySelectorAll('[data-auth-close]').forEach((el) => { el.addEventListener('click', () => closeAuthModal()); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAuthModal(); }); // Wire tabs wrap.querySelectorAll('[data-auth-tab]').forEach((btn) => { btn.addEventListener('click', () => setAuthTab(String(btn.getAttribute('data-auth-tab') || 'signin'))); }); // Wire submit const form = wrap.querySelector('#athAuthForm'); if (form) { form.addEventListener('submit', async (e) => { e.preventDefault(); await handleAuthSubmit(); }); } // Init Google Sign-In (check immediately, and retry if needed) tryInitGoogleAuth(wrap); } function tryInitGoogleAuth(wrap) { if (window.google?.accounts?.id) { window.google.accounts.id.initialize({ client_id: '470923800576-9326rggc6nsrukjbgdtvgbcckof6en09.apps.googleusercontent.com', callback: handleGoogleCredentialResponse, auto_select: false, cancel_on_tap_outside: true }); const googleBtn = wrap ? wrap.querySelector('#googleSignInBtn') : document.getElementById('googleSignInBtn'); if (googleBtn) { window.google.accounts.id.renderButton( googleBtn, { theme: 'outline', size: 'large', width: 250 } ); } } else { // Retry once after a short delay in case script is racing setTimeout(() => { if (window.google?.accounts?.id && typeof tryInitGoogleAuth === 'function') { tryInitGoogleAuth(wrap); } }, 500); } } // Make handler global for Google callback window.handleGoogleCredentialResponse = handleGoogleCredentialResponse; // JWT Decode Helper function parseJwt(token) { try { var base64Url = token.split('.')[1]; var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); var jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function (c) { return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); }).join('')); return JSON.parse(jsonPayload); } catch (e) { return null; } } async function handleGoogleCredentialResponse(response) { const data = parseJwt(response.credential); if (data) { const googleUser = { email: data.email, name: data.name, picture: data.picture, sub: data.sub }; const res = await window.ATHAuth?.signInWithGoogle?.(googleUser); if (res?.ok) { syncAuthNavState(); closeAuthModal(); } else { setAuthError(res?.error || 'Google sign in failed.'); } } else { setAuthError('Failed to process Google login.'); } } function onAuthNavClick() { const session = window.ATHAuth?.getSession?.(); if (session?.userId) { window.ATHAuth?.signOut?.(); syncAuthNavState(); // v0.028: If the user logs out while on messages, immediately redirect to Home // to avoid leaving a partly-interactive page behind. try { const p = String(window.location?.pathname || ''); if (p.endsWith('/messages.html') || p.endsWith('messages.html')) { window.location.href = 'index.html'; return; } } catch { } return; } openAuthModal('signin'); } function syncAuthNavState() { const session = window.ATHAuth?.getSession?.(); const email = session?.email ? String(session.email) : ''; const desktop = document.getElementById('athAuthNavBtn'); const mobile = document.getElementById('athAuthNavBtnMobile'); // v0.026c: Hide "My Profile" until authenticated. // NOTE: the nav link uses responsive display classes (e.g. "hidden md:inline-flex"), // which can override the "hidden" class at larger breakpoints. Force-hide via inline style. const navEl = document.querySelector('nav'); const desktopProfileLink = navEl ? Array.from(navEl.querySelectorAll('a[href^="my-profile.html"]')).find(a => !a.closest('#mobileMenu')) : null; const loggedIn = !!session?.userId; const forceProfileVisibility = (a, visible) => { if (!a) return; a.style.display = visible ? '' : 'none'; a.setAttribute('aria-hidden', visible ? 'false' : 'true'); a.tabIndex = visible ? 0 : -1; }; forceProfileVisibility(desktopProfileLink, loggedIn); const label = session?.userId ? (email ? `Logout (${email})` : 'Logout') : 'Sign in'; if (desktop) desktop.textContent = label; } function openAuthModal(tab) { ensureAuthModal(); setAuthTab(tab || 'signin'); const wrap = document.getElementById('athAuthModal'); if (!wrap) return; wrap.classList.remove('hidden'); // reset error setAuthError(''); // focus email setTimeout(() => { try { document.getElementById('athAuthEmail')?.focus(); } catch { } }, 50); } function closeAuthModal() { const wrap = document.getElementById('athAuthModal'); if (!wrap) return; wrap.classList.add('hidden'); } function setAuthError(msg) { const el = document.getElementById('athAuthError'); if (!el) return; const m = String(msg || '').trim(); if (!m) { el.textContent = ''; el.classList.add('hidden'); return; } el.textContent = m; el.classList.remove('hidden'); } function setAuthTab(tab) { const t = (tab === 'signup') ? 'signup' : 'signin'; const wrap = document.getElementById('athAuthModal'); if (!wrap) return; const title = wrap.querySelector('h2'); if (title) title.textContent = (t === 'signup') ? 'Sign up' : 'Sign in'; const btnSignin = wrap.querySelector('[data-auth-tab="signin"]'); const btnSignup = wrap.querySelector('[data-auth-tab="signup"]'); const confirmWrap = document.getElementById('athAuthConfirmWrap'); const pass = document.getElementById('athAuthPassword'); const pass2 = document.getElementById('athAuthPassword2'); const setBtn = (btn, active) => { if (!btn) return; if (active) { btn.className = 'flex-1 px-3 py-2 rounded-lg text-sm font-semibold bg-gray-900 text-white'; } else { btn.className = 'flex-1 px-3 py-2 rounded-lg text-sm font-semibold bg-gray-100 hover:bg-gray-200'; } }; setBtn(btnSignin, t === 'signin'); setBtn(btnSignup, t === 'signup'); if (confirmWrap) confirmWrap.classList.toggle('hidden', t !== 'signup'); if (pass) pass.autocomplete = (t === 'signup') ? 'new-password' : 'current-password'; if (pass2) pass2.value = ''; wrap.setAttribute('data-auth-mode', t); setAuthError(''); } async function handleAuthSubmit() { const wrap = document.getElementById('athAuthModal'); if (!wrap) return; const mode = String(wrap.getAttribute('data-auth-mode') || 'signin'); const email = document.getElementById('athAuthEmail')?.value || ''; const password = document.getElementById('athAuthPassword')?.value || ''; const password2 = document.getElementById('athAuthPassword2')?.value || ''; if (mode === 'signup') { if (String(password2) !== String(password)) { setAuthError('Passwords do not match.'); return; } const res = await window.ATHAuth?.signUp?.(email, password); if (!res?.ok) { setAuthError(res?.error || 'Sign up failed.'); return; } } else { const res = await window.ATHAuth?.signIn?.(email, password); if (!res?.ok) { setAuthError(res?.error || 'Sign in failed.'); return; } } syncAuthNavState(); closeAuthModal(); } // ---------------------------- // Messages Page // ---------------------------- function initMessagesPage() { const list = document.getElementById('conversationsList'); const emptyEl = document.getElementById('conversationsEmpty'); const searchInput = document.getElementById('conversationsSearch'); const chat = document.getElementById('chatScroll'); const input = document.getElementById('messageInput'); const sendBtn = document.getElementById('sendButton'); const chatName = document.getElementById('chatName'); const chatMeta = document.getElementById('chatMeta'); const chatStatus = document.getElementById('chatStatus'); const chatAvatar = document.getElementById('chatAvatar'); const chatOnlineDot = document.getElementById('chatOnlineDot'); const unreadLabel = document.getElementById('unreadCountLabel'); // v0.027a: UI containment + empty-state handling const chatPane = document.getElementById('athChatPane'); const emptyPane = document.getElementById('athMessagesEmptyState'); const emptyTitle = document.getElementById('athMessagesEmptyTitle'); const emptyBody = document.getElementById('athMessagesEmptyBody'); const jobDetailsCard = document.getElementById('jobDetailsCard'); const contextMount = document.getElementById('athMessagesContextPanel'); // Mobile View Logic const msgContainer = document.getElementById('athMessagesContainer'); const backBtn = document.getElementById('chatBackBtn'); const activateMobileList = () => { if (msgContainer) { msgContainer.classList.add('ath-mobile-list-view'); msgContainer.classList.remove('ath-mobile-chat-view'); } }; const activateMobileChat = () => { if (msgContainer) { msgContainer.classList.remove('ath-mobile-list-view'); msgContainer.classList.add('ath-mobile-chat-view'); } }; if (backBtn) { backBtn.addEventListener('click', (e) => { e.preventDefault(); activateMobileList(); }); } const showEmpty = (title, body) => { if (chatPane) chatPane.style.display = 'none'; if (jobDetailsCard) jobDetailsCard.style.display = 'none'; if (emptyTitle) emptyTitle.textContent = title || 'No conversations yet'; if (emptyBody) emptyBody.textContent = body || 'Start a conversation from a job listing.'; if (emptyPane) emptyPane.classList.remove('hidden'); }; const showChat = () => { if (emptyPane) emptyPane.classList.add('hidden'); if (chatPane) chatPane.style.display = ''; if (jobDetailsCard) jobDetailsCard.style.display = ''; }; // Not on messages page if (!list || !chat || !chatName || !chatMeta || !chatStatus || !chatAvatar) return; // v0.027: Require auth for messages + scope conversations per account const session = window.ATHAuth?.getSession?.(); if (!session?.userId) { try { openAuthModal('signin'); } catch { } // Minimal UI: hide chat panes + show sign-in empty state if (input) { input.disabled = true; input.classList.add('opacity-50', 'cursor-not-allowed'); } if (sendBtn) { sendBtn.disabled = true; sendBtn.classList.add('opacity-50', 'cursor-not-allowed'); } if (unreadLabel) unreadLabel.textContent = '—'; showEmpty('Sign in to view messages', 'Sign in to see your conversations and send messages.'); if (list) list.innerHTML = ''; if (emptyEl) { emptyEl.textContent = 'Sign in to view your messages.'; emptyEl.classList.remove('hidden'); } return; } const uid = session.userId; const STORE_KEY = `athConversations:${uid}`; const lastActiveKey = `lastActiveConversation:${uid}`; // File attachment handlers const imageInput = document.getElementById('imageInput'); const fileInput = document.getElementById('fileInput'); if (imageInput) { imageInput.addEventListener('change', (e) => handleFileUpload(e, 'image')); } if (fileInput) { fileInput.addEventListener('change', (e) => handleFileUpload(e, 'file')); } function handleFileUpload(event, type) { const file = event.target.files?.[0]; if (!file) return; // Size limit: 5MB if (file.size > 5 * 1024 * 1024) { alert('File size must be less than 5MB'); event.target.value = ''; return; } const reader = new FileReader(); reader.onload = (e) => { const id = getConversationIdFromUrl() || getDefaultConversationId(); if (!id || !DATA[id]) return; const attachment = { type: type, name: file.name, size: file.size, data: e.target.result // base64 }; const now = Date.now(); const newMessage = { from: 'me', time: 'Now', ts: now, text: type === 'image' ? '📷 Photo' : `📎 ${file.name}`, attachment: attachment, status: 'sent' }; DATA[id].messages.push(newMessage); window.ATHStore.set(STORE_KEY, DATA); event.target.value = ''; // Reset input renderMessages(DATA[id]); renderConversationList(searchInput?.value || ''); setActiveRow(id); // Simulate status updates setTimeout(() => { newMessage.status = 'delivered'; window.ATHStore.set(STORE_KEY, DATA); renderMessages(DATA[id]); setTimeout(() => { newMessage.status = 'read'; window.ATHStore.set(STORE_KEY, DATA); renderMessages(DATA[id]); }, 1500); }, 500); }; reader.readAsDataURL(file); } // Emoji Picker const emojiPickerBtn = document.getElementById('emojiPickerBtn'); let isTogglingPicker = false; if (emojiPickerBtn && input) { emojiPickerBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); isTogglingPicker = true; setTimeout(() => { isTogglingPicker = false; }, 300); showEmojiPicker(); }); } function showEmojiPicker() { const container = document.getElementById('emojiPickerContainer'); if (!container) { console.error('Emoji picker container not found'); return; } // Toggle - remove existing picker const existingPicker = document.getElementById('emojiPickerPopup'); if (existingPicker) { existingPicker.remove(); return; } const emojis = { 'Smileys': ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳'], 'Gestures': ['👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '👇', '☝️', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾'], 'Hearts': ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝'], 'Symbols': ['✅', '❌', '⭐', '🌟', '💫', '✨', '⚡', '🔥', '💥', '💯', '🎉', '🎊', '🎈', '🎁', '🏆', '🥇', '🥈', '🥉'] }; const picker = document.createElement('div'); picker.id = 'emojiPickerPopup'; picker.className = 'bg-white border-2 border-gray-300 rounded-lg shadow-2xl p-4 w-80 max-h-96 overflow-y-auto z-50'; picker.style.position = 'relative'; picker.style.zIndex = '9999'; let pickerHTML = '
'; for (const [category, emojiList] of Object.entries(emojis)) { pickerHTML += `
${category}
${emojiList.map(emoji => ` `).join('')}
`; } pickerHTML += '
'; picker.innerHTML = pickerHTML; container.appendChild(picker); console.log('Emoji picker created and appended'); // Close picker on Escape key (accessibility) const escapeHandler = (e) => { if (e.key === 'Escape') { picker.remove(); document.removeEventListener('keydown', escapeHandler); document.removeEventListener('click', closeHandler); input.focus(); // Return focus to message input } }; document.addEventListener('keydown', escapeHandler); // Close picker on click outside - with proper button detection and toggle prevention setTimeout(() => { const closeHandler = (e) => { // Don't close if we're currently toggling if (isTogglingPicker) return; // Check if click is inside picker OR inside the button (including its children like SVG) if (!picker.contains(e.target) && !emojiPickerBtn.contains(e.target)) { picker.remove(); document.removeEventListener('click', closeHandler); document.removeEventListener('keydown', escapeHandler); } }; document.addEventListener('click', closeHandler); }, 300); // Delay to ensure toggle flag is set } window.insertEmoji = function(emoji) { if (!input) return; const cursorPos = input.selectionStart || 0; const textBefore = input.value.substring(0, cursorPos); const textAfter = input.value.substring(cursorPos); input.value = textBefore + emoji + textAfter; input.focus(); input.setSelectionRange(cursorPos + emoji.length, cursorPos + emoji.length); // Close picker const picker = document.getElementById('emojiPickerPopup'); if (picker) picker.remove(); }; // Message menu toggle window.toggleMessageMenu = function(ts) { const menu = document.getElementById(`menu-${ts}`); if (!menu) return; // Close all other menus document.querySelectorAll('[id^="menu-"]').forEach(m => { if (m.id !== `menu-${ts}`) m.classList.add('hidden'); }); menu.classList.toggle('hidden'); // Close menu on click outside OR Escape key if (!menu.classList.contains('hidden')) { // Escape key handler const escapeHandler = (e) => { if (e.key === 'Escape') { menu.classList.add('hidden'); document.removeEventListener('keydown', escapeHandler); document.removeEventListener('click', closeHandler); } }; document.addEventListener('keydown', escapeHandler); setTimeout(() => { const closeHandler = (e) => { if (!menu.contains(e.target)) { menu.classList.add('hidden'); document.removeEventListener('click', closeHandler); document.removeEventListener('keydown', escapeHandler); } }; document.addEventListener('click', closeHandler); }, 100); } }; // Edit message window.startEditMessage = function(ts) { const id = getConversationIdFromUrl() || getDefaultConversationId(); if (!id || !DATA[id]) return; const message = DATA[id].messages.find(m => m.ts === Number(ts)); if (!message || message.deleted) return; const messageEl = document.getElementById(`msg-${ts}`); if (!messageEl) return; // Close menu const menu = document.getElementById(`menu-${ts}`); if (menu) menu.classList.add('hidden'); // Show edit input const bubbleEl = messageEl.querySelector('.bg-teal-600'); if (!bubbleEl) return; const originalText = message.text; const escapedText = String(originalText).replace(/"/g, '"'); bubbleEl.innerHTML = `
Press Enter to save, Esc to cancel
`; const editInput = document.getElementById(`edit-input-${ts}`); if (editInput) { editInput.focus(); editInput.select(); } }; window.saveEdit = function(ts) { const id = getConversationIdFromUrl() || getDefaultConversationId(); if (!id || !DATA[id]) return; const editInput = document.getElementById(`edit-input-${ts}`); if (!editInput) return; const newText = editInput.value.trim(); if (!newText) { alert('Message cannot be empty'); return; } const message = DATA[id].messages.find(m => m.ts === Number(ts)); if (message) { message.text = newText; message.editedAt = Date.now(); window.ATHStore.set(STORE_KEY, DATA); renderMessages(DATA[id]); renderConversationList(searchInput?.value || ''); } }; window.cancelEdit = function(ts) { const id = getConversationIdFromUrl() || getDefaultConversationId(); if (!id || !DATA[id]) return; renderMessages(DATA[id]); }; // Delete message window.deleteMessage = function(ts) { if (!confirm('Delete this message?')) return; const id = getConversationIdFromUrl() || getDefaultConversationId(); if (!id || !DATA[id]) return; const message = DATA[id].messages.find(m => m.ts === Number(ts)); if (message) { message.deleted = true; message.text = ''; message.attachment = null; window.ATHStore.set(STORE_KEY, DATA); renderMessages(DATA[id]); renderConversationList(searchInput?.value || ''); } // Close menu const menu = document.getElementById(`menu-${ts}`); if (menu) menu.classList.add('hidden'); }; // Batch N3: integrity banner + disable off-platform call/video actions window.ATHIntegrity?.renderBanner(document.getElementById('athIntegrityBannerMountMessages')); document.querySelectorAll('[aria-label="Call"], [aria-label="Video call"]')?.forEach((btn) => { btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed'); btn.title = 'Locked until payment is confirmed'; }); let DATA = window.ATHStore.get(STORE_KEY, {}); if (!DATA || typeof DATA !== 'object') DATA = {}; // Seed conversations from window.CONVERSATIONS if empty (first load for this user) if (Object.keys(DATA).length === 0 && window.CONVERSATIONS) { DATA = JSON.parse(JSON.stringify(window.CONVERSATIONS)); // Deep clone window.ATHStore.set(STORE_KEY, DATA); } const safeText = (t) => (window.ATHIntegrity ? window.ATHIntegrity.sanitizeText(t).text : String(t || '')); const sanitizeNoteEl = document.getElementById('athMessageSanitizeNote'); // ---------------------------- // Helpers (messages) // ---------------------------- const getLastMessage = (id) => { const msgs = DATA?.[id]?.messages || []; return msgs.length ? msgs[msgs.length - 1] : null; }; const getLastTs = (id) => { const m = getLastMessage(id); return m?.ts || 0; }; const getReadTsKey = (id) => `readts:${uid}:${id}`; const getReadTs = (id) => Number(localStorage.getItem(getReadTsKey(id)) || 0); const getUnreadCount = (id) => { const last = getLastMessage(id); if (!last) return 0; // Only treat as unread if the latest message is from them and we haven't read up to that timestamp. if (last.from !== 'them') return 0; return getLastTs(id) > getReadTs(id) ? 1 : 0; }; const getTotalUnread = () => Object.keys(DATA).reduce((acc, id) => acc + getUnreadCount(id), 0); function updateUnreadUI() { if (!unreadLabel) return; const unread = getTotalUnread(); unreadLabel.textContent = unread ? `${unread} unread` : 'All read'; } function markRead(id) { const ts = getLastTs(id); localStorage.setItem(getReadTsKey(id), String(ts)); } function setActiveRow(id) { list.querySelectorAll('.conversation-item').forEach((row) => { const isActive = row.dataset.conversation === id; row.classList.toggle('bg-teal-50', isActive); row.classList.toggle('hover:bg-gray-50', !isActive); }); } // ---------------------------- // Render: sidebar // ---------------------------- function renderConversationList(filterText = '') { const q = (filterText || '').trim().toLowerCase(); const ids = Object.keys(DATA) .sort((a, b) => getLastTs(b) - getLastTs(a)) .filter((id) => { if (!q) return true; const c = DATA[id]; const last = getLastMessage(id); const hay = [c?.name, c?.meta, last?.text].filter(Boolean).join(' ').toLowerCase(); return hay.includes(q); }); // Empty / no-results states if (ids.length === 0) { list.innerHTML = ''; if (emptyEl) { emptyEl.textContent = q ? 'No conversations match your search.' : 'No conversations yet.'; emptyEl.classList.remove('hidden'); } updateUnreadUI(); return; } if (emptyEl) emptyEl.classList.add('hidden'); const active = getConversationIdFromUrl() || getDefaultConversationId(); list.innerHTML = ids.map((id, idx) => { const c = DATA[id]; const last = getLastMessage(id); const preview = safeText(last?.text || ''); const time = last?.time || ''; const unread = getUnreadCount(id); const online = !!c?.online; const tag = c?.tag; const dotClass = online ? 'bg-green-500' : 'bg-gray-400'; const activeClass = (id === active) ? 'bg-teal-50' : ''; const borderClass = idx === ids.length - 1 ? '' : 'border-b border-gray-200'; return `
${escapeHtml(c?.name || '')}

${escapeHtml(c?.name || 'Conversation')}

${escapeHtml(time)}

${escapeHtml(preview)}

${tag ? `${escapeHtml(tag.label || '')}` : ''} ${unread ? `${unread}` : ''}
`; }).join(''); updateUnreadUI(); } // ---------------------------- // Render: chat header + body // ---------------------------- function renderHeader(c) { chatName.textContent = c?.name || 'Messages'; chatMeta.textContent = c?.meta || ''; const online = !!c?.online; chatStatus.textContent = online ? 'Online now' : 'Offline'; chatStatus.classList.toggle('text-green-600', online); chatStatus.classList.toggle('text-gray-500', !online); chatAvatar.src = c?.avatar || ''; chatAvatar.alt = c?.name || 'Avatar'; if (chatOnlineDot) { chatOnlineDot.classList.toggle('bg-green-500', online); chatOnlineDot.classList.toggle('bg-gray-400', !online); } } function formatMessageTime(ts) { const d = new Date(ts || Date.now()); return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); } function formatDateSeparator(ts) { const d = new Date(ts || Date.now()); const today = new Date(); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); if (d.toDateString() === today.toDateString()) return 'Today'; if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'; return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } function shouldShowDateSeparator(currentMsg, previousMsg) { if (!previousMsg) return true; const current = new Date(currentMsg.ts || Date.now()); const previous = new Date(previousMsg.ts || Date.now()); return current.toDateString() !== previous.toDateString(); } function shouldGroupWithPrevious(currentMsg, previousMsg) { if (!previousMsg) return false; if (currentMsg.from !== previousMsg.from) return false; const timeDiff = (currentMsg.ts || 0) - (previousMsg.ts || 0); return timeDiff < 60000; // Group if within 1 minute } function getStatusIcon(status) { if (!status || status === 'sent') return '✓'; if (status === 'delivered') return '✓✓'; if (status === 'read') return '✓✓'; return ''; } function renderMessages(c) { chat.innerHTML = ''; const msgs = c?.messages || []; msgs.forEach((m, idx) => { const previousMsg = idx > 0 ? msgs[idx - 1] : null; const nextMsg = idx < msgs.length - 1 ? msgs[idx + 1] : null; // Add date separator if needed if (shouldShowDateSeparator(m, previousMsg)) { const separator = document.createElement('div'); separator.className = 'flex justify-center my-4'; separator.innerHTML = `
${formatDateSeparator(m.ts)}
`; chat.appendChild(separator); } const isGrouped = shouldGroupWithPrevious(m, previousMsg); const isLastInGroup = !nextMsg || !shouldGroupWithPrevious(nextMsg, m); // Check if next msg exists first const wrap = document.createElement('div'); wrap.className = m.from === 'me' ? `flex justify-end ${isGrouped ? 'mb-1' : 'mb-4'}` : `flex justify-start ${isGrouped ? 'mb-1' : 'mb-4'}`; const messageTime = formatMessageTime(m.ts); const statusIcon = m.from === 'me' ? getStatusIcon(m.status) : ''; const timeHtml = `
${messageTime} ${statusIcon}
`; // Attachment rendering let attachmentHtml = ''; if (m.attachment) { if (m.attachment.type === 'image') { attachmentHtml = ` ${escapeHtml(m.attachment.name)} `; } else { const fileSize = (m.attachment.size / 1024).toFixed(1) + ' KB'; attachmentHtml = `
${escapeHtml(m.attachment.name)}
${fileSize}
`; } } // Link preview rendering const urlRegex = /(https?:\/\/[^\s]+)/g; const messageText = safeText(m.text); const urls = messageText.match(urlRegex); let linkPreviewHtml = ''; if (urls && urls.length > 0 && !m.attachment) { const url = urls[0]; // Preview first URL only const domain = new URL(url).hostname.replace('www.', ''); linkPreviewHtml = `
${escapeHtml(domain)}
${escapeHtml(url)}
`; } // Convert URLs in text to clickable links let displayText = escapeHtml(messageText); if (urls) { urls.forEach(url => { displayText = displayText.replace( escapeHtml(url), `${escapeHtml(url)}` ); }); } // Reactions const reactionsHtml = m.reactions && Object.keys(m.reactions).length > 0 ? `
${Object.entries(m.reactions).map(([emoji, count]) => `${emoji} ${count > 1 ? count : ''}` ).join('')}
` : ''; const messageId = `msg-${m.ts}`; const editedLabel = m.editedAt ? ' (edited)' : ''; const isDeleted = m.deleted; wrap.innerHTML = m.from === 'me' ? `
${!isDeleted ? `
${attachmentHtml ? attachmentHtml : ''} ${attachmentHtml && m.text && !m.text.match(/^(📷|📎)/) ? `
${displayText}
` : ''} ${!attachmentHtml ? displayText : ''}
${linkPreviewHtml}
` : `
This message was deleted
`}
${reactionsHtml} ${isLastInGroup ? timeHtml + editedLabel : ''}
` : `
${attachmentHtml ? attachmentHtml : ''} ${attachmentHtml && m.text && !m.text.match(/^(📷|📎)/) ? `
${displayText}
` : ''} ${!attachmentHtml ? displayText : ''}
${linkPreviewHtml}
${reactionsHtml} ${isLastInGroup ? timeHtml : ''}
`; chat.appendChild(wrap); }); // Scroll to bottom - using requestAnimationFrame to ensure DOM is laid out const scrollToBottom = () => { if (!chat) return; requestAnimationFrame(() => { chat.scrollTop = chat.scrollHeight; }); }; // Multiple scroll attempts to handle async image loading scrollToBottom(); setTimeout(scrollToBottom, 50); setTimeout(scrollToBottom, 150); setTimeout(scrollToBottom, 300); // Final scroll after all images have loaded const images = chat.querySelectorAll('img'); if (images.length > 0) { Promise.all(Array.from(images).map(img => { if (img.complete) return Promise.resolve(); return new Promise(resolve => { img.addEventListener('load', resolve); img.addEventListener('error', resolve); }); })).then(scrollToBottom); } } // ---------------------------- // v0.028: Messages context panel // - Customer view: list customer's active jobs; clicking a job provides a Jobs Board deep-link. // - Tradie view: showcase tradie's recently completed jobs with completion photos + published reviews. // - Dual counterparty: allow toggling between the two panels. // ---------------------------- function resolveCounterparty(convId) { const cid = String(convId || '').trim(); if (!cid) return { customerId: null, tradieId: null }; let tradieId = null; let customerId = null; const tradies = window.TRADIES || {}; for (const [id, t] of Object.entries(tradies)) { if (t && String(t.conversationId || '') === cid) { tradieId = String(id); break; } } const customers = window.CUSTOMERS || {}; for (const [id, c] of Object.entries(customers)) { if (c && String(c.conversationId || '') === cid) { customerId = String(id); break; } } return { customerId, tradieId }; } function readReviewsStorage() { try { const raw = localStorage.getItem('athReviews'); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function isReviewPublished(r) { if (!r) return false; if (r.visibility === 'published') return true; const base = Date.parse(r.completedAt || '') || 0; if (!base) return false; return (Date.now() - base) >= 7 * 24 * 60 * 60 * 1000; } function getCompletionPhotosForJob(jobId) { try { const s = window.ATHJobs?.getJobState?.(jobId) || {}; const arr = Array.isArray(s.completionPhotos) ? s.completionPhotos : []; return arr.filter(v => typeof v === 'string' && v.startsWith('data:image/')); } catch { return []; } } function statusPillClass(st) { const s = String(st || 'open').trim().toLowerCase(); if (s === 'in_progress') return 'bg-amber-100 text-amber-800'; if (s === 'completed') return 'bg-emerald-100 text-emerald-800'; if (s === 'agreed') return 'bg-blue-100 text-blue-800'; return 'bg-gray-100 text-gray-800'; } function renderContextPanel(convId) { if (!contextMount) return; const resolved = resolveCounterparty(convId); const hasCustomer = !!resolved.customerId; const hasTradie = !!resolved.tradieId; // If we can't resolve, keep the panel lightweight. if (!hasCustomer && !hasTradie) { contextMount.innerHTML = `
Select a conversation to see job context.
`; return; } const modeKey = `athMsgContextMode:${uid}:${String(convId)}`; let mode = localStorage.getItem(modeKey) || ''; if (mode !== 'customer' && mode !== 'tradie') { // default mode = hasCustomer && !hasTradie ? 'customer' : (!hasCustomer && hasTradie ? 'tradie' : 'customer'); } const jobsAll = window.ATHJobs?.getAllJobs?.() || []; const customers = window.CUSTOMERS || {}; const tradies = window.TRADIES || {}; const selectedKey = `athMsgSelectedJob:${uid}:${String(convId)}`; const selectedJobId = localStorage.getItem(selectedKey) || ''; const renderToggle = (dual) => { if (!dual) return ''; const btnCls = (m) => (m === mode) ? 'bg-teal-600 text-white border-teal-600' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'; return `
`; }; function renderCustomerPanel() { const cid = String(resolved.customerId); const c = customers?.[cid]; const activeStates = new Set(['open', 'agreed', 'in_progress']); const jobs = jobsAll .filter(j => String(j?.customerId || '') === cid && activeStates.has(String(j?.status || 'open').toLowerCase())) .sort((a, b) => Date.parse(b?.postedAt || b?.createdAt || '') - Date.parse(a?.postedAt || a?.createdAt || '')); const pick = (jobs.find(j => String(j.id) === String(selectedJobId)) || jobs[0] || null); const chips = (job) => { const ids = Array.isArray(job?.categories) ? job.categories : (typeof window.normalizeTradeIds === 'function' ? window.normalizeTradeIds(job?.categories) : []); return (ids || []).slice(0, 4).map((cid2) => ( `${escapeHtml(typeof window.tradeLabel === 'function' ? window.tradeLabel(cid2) : String(cid2))}` )).join(''); }; const jobList = jobs.length ? `
${jobs.map((j) => { const active = String(j.id) === String(pick?.id); return ` `; }).join('')}
` : `
No active jobs yet.
`; const details = pick ? `
${escapeHtml(pick.title || 'Job')}
${escapeHtml(pick.location || '—')}
${escapeHtml(String(pick.status || 'open').replace('_', ' '))}
${chips(pick)}
Budget
${escapeHtml(String(pick.budgetMax || pick.budgetMin || 0) ? (typeof window.compactMoney === 'function' ? window.compactMoney(Number(pick.budgetMax || pick.budgetMin || 0)) : '$' + String(pick.budgetMax || pick.budgetMin || 0)) : '—')}
Timeline
${escapeHtml(String(pick.timeline || 'Flexible'))}
Open on Job Board ${c ? `View customer profile` : ''}
` : ''; return `
Active jobs from this customer
${jobList} ${details}
`; } function renderTradiePanel() { const tid = String(resolved.tradieId); const t = tradies?.[tid]; const jobs = jobsAll .filter(j => String(j?.assignedTradieId || '') === tid && String(j?.status || '').toLowerCase() === 'completed') .sort((a, b) => { const sa = window.ATHJobs?.getJobState?.(a.id) || {}; const sb = window.ATHJobs?.getJobState?.(b.id) || {}; return Date.parse(sb.completedAt || b.completedAt || '') - Date.parse(sa.completedAt || a.completedAt || ''); }) .slice(0, 6); const allReviews = readReviewsStorage().filter(r => String(r?.targetRole) === 'tradie' && String(r?.targetId) === tid && isReviewPublished(r)); const jobCards = jobs.length ? jobs.map((j) => { const st = window.ATHJobs?.getJobState?.(j.id) || {}; const photos = getCompletionPhotosForJob(j.id).slice(0, 3); const reviewsForJob = allReviews.filter(r => String(r?.jobId) === String(j.id)).slice(0, 2); const photoGrid = photos.length ? `
${photos.map((src, idx) => ` Completion photo ${idx + 1} `).join('')}
` : `
No completion photos uploaded.
`; const reviewsBlock = reviewsForJob.length ? `
${reviewsForJob.map((r) => `
${escapeHtml(String(r.stars || ''))}\u2605 \u2022 ${escapeHtml(new Date(Number(r.ts) || Date.now()).toLocaleDateString())}
${escapeHtml(String(r.text || ''))}
`).join('')}
` : `
No published reviews for this job yet.
`; return `
${escapeHtml(j.title || 'Job')}
Completed: ${escapeHtml(st.completedAt ? new Date(st.completedAt).toLocaleDateString() : (j.completedAt ? new Date(j.completedAt).toLocaleDateString() : '—'))}
completed
${photoGrid} ${reviewsBlock}
`; }).join('') : `
No completed jobs to showcase yet.
`; return `
Recently completed jobs from this tradie
${jobCards}
${t ? `
View tradie profile
` : ''}
`; } const dual = hasCustomer && hasTradie; contextMount.innerHTML = `${renderToggle(dual)}${mode === 'tradie' ? renderTradiePanel() : renderCustomerPanel()}`; // Wire toggle (dual) contextMount.querySelectorAll('[data-ctx-mode]').forEach((btn) => { btn.addEventListener('click', () => { const next = String(btn.getAttribute('data-ctx-mode') || 'customer'); if (next !== 'customer' && next !== 'tradie') return; localStorage.setItem(modeKey, next); renderContextPanel(convId); }); }); // Wire job select (customer panel) contextMount.querySelectorAll('[data-ctx-job]').forEach((btn) => { btn.addEventListener('click', () => { const jid = String(btn.getAttribute('data-ctx-job') || ''); if (!jid) return; localStorage.setItem(selectedKey, jid); renderContextPanel(convId); }); }); try { if (typeof feather !== 'undefined') feather.replace(); } catch { } } // ---------------------------- // Routing + defaults // ---------------------------- function getConversationIdFromUrl() { return new URLSearchParams(location.search).get('conversation'); } function setConversationIdInUrl(id) { const url = new URL(location.href); url.searchParams.set('conversation', id); history.replaceState({}, '', url.toString()); } function getDefaultConversationId() { const lastActive = localStorage.getItem(lastActiveKey); if (lastActive && DATA[lastActive]) return lastActive; const ids = Object.keys(DATA); if (!ids.length) return null; return ids.sort((a, b) => getLastTs(b) - getLastTs(a))[0]; } // v0.027b: When we arrive via a "Contact" link (messages.html?conversation=), // the per-account conversation store may not have that conversation yet. // Create an empty conversation stub for this account so the dialog opens. function resolveCounterpartyByConversationId(convId) { const cid = String(convId || '').trim(); if (!cid) return null; const tradies = window.TRADIES || {}; for (const t of Object.values(tradies)) { if (t && String(t.conversationId || '') === cid) { const meta = [t.trade, t.location].filter(Boolean).join(' \u2022 '); return { name: t.name || 'Tradie', avatar: t.image || '', meta, tag: { label: 'Tradie', color: 'bg-teal-100 text-teal-700' }, online: false }; } } const customers = window.CUSTOMERS || {}; for (const c of Object.values(customers)) { if (c && String(c.conversationId || '') === cid) { const meta = [c.location].filter(Boolean).join(''); return { name: c.name || 'Customer', avatar: c.image || '', meta, tag: { label: 'Customer', color: 'bg-gray-100 text-gray-700' }, online: false }; } } return null; } function ensureConversationExists(convId) { const id = String(convId || '').trim(); if (!id || DATA[id]) return; // Optional URL-provided context (future-proofing) const params = new URLSearchParams(location.search); const nameFromUrl = params.get('name'); const avatarFromUrl = params.get('avatar'); const metaFromUrl = params.get('meta'); const resolved = resolveCounterpartyByConversationId(id) || {}; DATA[id] = { id, name: nameFromUrl ? decodeURIComponent(nameFromUrl) : (resolved.name || 'Conversation'), avatar: avatarFromUrl ? decodeURIComponent(avatarFromUrl) : (resolved.avatar || ''), meta: metaFromUrl ? decodeURIComponent(metaFromUrl) : (resolved.meta || ''), online: !!resolved.online, tag: resolved.tag || null, messages: [] }; window.ATHStore.set(STORE_KEY, DATA); } function load(id) { // If the URL specifies a conversation that doesn't exist in this account yet, // create an empty stub so "Contact" deep-links open the right thread. if (id && !DATA[id]) ensureConversationExists(id); const actualId = DATA[id] ? id : getDefaultConversationId(); if (!actualId) { // No conversations at all renderConversationList(searchInput?.value || ''); if (input) { input.disabled = true; input.classList.add('opacity-50', 'cursor-not-allowed'); } if (sendBtn) { sendBtn.disabled = true; sendBtn.classList.add('opacity-50', 'cursor-not-allowed'); } if (unreadLabel) unreadLabel.textContent = 'All read'; showEmpty('No conversations yet', 'Start a conversation from a job listing.'); updateUnreadUI(); return; } showChat(); if (input) { input.disabled = false; input.classList.remove('opacity-50', 'cursor-not-allowed'); } if (sendBtn) { sendBtn.disabled = false; sendBtn.classList.remove('opacity-50', 'cursor-not-allowed'); } const c = DATA[actualId]; // Mark read + persist active markRead(actualId); localStorage.setItem(lastActiveKey, actualId); // Render setConversationIdInUrl(actualId); renderConversationList(searchInput?.value || ''); setActiveRow(actualId); renderHeader(c); renderMessages(c); updateUnreadUI(); // v0.028: render right-side context panel for this conversation renderContextPanel(actualId); // v0.035: Restore draft for this conversation if (input) { const draft = loadDraft(actualId); input.value = draft; // Focus input if there's a draft if (draft) { setTimeout(() => input.focus(), 150); } } // Force scroll to bottom after everything is rendered setTimeout(() => { if (chat) { chat.scrollTop = chat.scrollHeight; } }, 100); setTimeout(() => { if (chat) { chat.scrollTop = chat.scrollHeight; } }, 300); setTimeout(() => { if (chat) { chat.scrollTop = chat.scrollHeight; } }, 600); } // ---------------------------- // Events // ---------------------------- // Click conversation (delegated) list.addEventListener('click', (e) => { const row = e.target.closest('.conversation-item'); if (!row) return; e.preventDefault(); const id = row.dataset.conversation; if (!id) return; load(id); }); // Search conversations searchInput?.addEventListener('input', () => { renderConversationList(searchInput.value); }); // ---------------------------- // v0.035: Message Drafts // ---------------------------- let draftSaveTimeout = null; const DRAFT_DEBOUNCE_MS = 500; function getDraftKey(convId) { return `athDraft:${uid}:${convId}`; } function saveDraft(convId, text) { const key = getDraftKey(convId); if (text.trim()) { localStorage.setItem(key, text); } else { localStorage.removeItem(key); } } function loadDraft(convId) { const key = getDraftKey(convId); return localStorage.getItem(key) || ''; } function clearDraft(convId) { const key = getDraftKey(convId); localStorage.removeItem(key); } // ---------------------------- // Send message // ---------------------------- function send() { const id = getConversationIdFromUrl() || getDefaultConversationId(); if (!id || !DATA[id]) return; const text = (input?.value || '').trim(); if (!text) return; const now = Date.now(); // Block off-platform contact sharing until payment const scan = window.ATHIntegrity?.scanText ? window.ATHIntegrity.scanText(text) : { hasContact: false }; if (scan?.hasContact) { window.ATHIntegrity?.setInlineNotice(document.getElementById('athMessageSanitizeNote'), 'Contact details (phone, email, or links) are locked until payment is confirmed. Please remove them.'); setTimeout(() => window.ATHIntegrity?.setInlineNotice(document.getElementById('athMessageSanitizeNote'), ''), 3000); return; } const sanitized = window.ATHIntegrity ? window.ATHIntegrity.sanitizeText(text) : { text, changed: false }; const newMessage = { from: 'me', time: 'Now', ts: now, text: sanitized.text, status: 'sent' // New: track message status }; DATA[id].messages.push(newMessage); window.ATHStore.set(STORE_KEY, DATA); input.value = ''; // v0.035: Clear draft after successful send clearDraft(id); // Re-render chat + sidebar preview/time renderMessages(DATA[id]); renderConversationList(searchInput?.value || ''); setActiveRow(id); // Simulate message status updates (delivered -> read) setTimeout(() => { newMessage.status = 'delivered'; window.ATHStore.set(STORE_KEY, DATA); renderMessages(DATA[id]); setTimeout(() => { newMessage.status = 'read'; window.ATHStore.set(STORE_KEY, DATA); renderMessages(DATA[id]); }, 1500); }, 500); } function showTypingIndicator(name) { const indicator = document.getElementById('typingIndicator'); const text = document.getElementById('typingIndicatorText'); if (indicator && text) { text.textContent = `${name} is typing...`; indicator.classList.remove('hidden'); chat.scrollTop = chat.scrollHeight; } } function hideTypingIndicator() { const indicator = document.getElementById('typingIndicator'); if (indicator) { indicator.classList.add('hidden'); } } sendBtn?.addEventListener('click', send); input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }); // v0.035: Auto-save draft on input (with debounce) input?.addEventListener('input', () => { const id = getConversationIdFromUrl() || getDefaultConversationId(); if (!id) return; // Clear existing timeout if (draftSaveTimeout) { clearTimeout(draftSaveTimeout); } // Set new timeout to save draft draftSaveTimeout = setTimeout(() => { const text = input.value; saveDraft(id, text); }, DRAFT_DEBOUNCE_MS); }); // Init renderConversationList(''); // Handle SPA navigation for conversation list list.addEventListener('click', (e) => { const item = e.target.closest('.conversation-item'); if (item) { e.preventDefault(); const id = item.dataset.conversation; if (id) { load(id); activateMobileChat(); // Switch to chat view on mobile // Update URL without reload const url = new URL(window.location); url.searchParams.set('conversation', id); window.history.pushState({}, '', url); } } }); // Initial load logic const urlId = getConversationIdFromUrl(); const defaultId = getDefaultConversationId(); if (urlId) { load(urlId); activateMobileChat(); } else { // Determine if we should load default if (defaultId) load(defaultId); // Explicitly start in list view if no URL param activateMobileList(); } } // Global reaction functions window.showReactionPicker = function(messageTs, event) { event?.stopPropagation(); const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏']; // Remove existing picker const existingPicker = document.getElementById('reactionPicker'); if (existingPicker) existingPicker.remove(); const picker = document.createElement('div'); picker.id = 'reactionPicker'; picker.className = 'absolute z-50 bg-white border border-gray-300 rounded-lg shadow-lg p-2 flex gap-2'; picker.style.left = event?.clientX + 'px'; picker.style.top = (event?.clientY - 50) + 'px'; quickReactions.forEach(emoji => { const btn = document.createElement('button'); btn.textContent = emoji; btn.className = 'text-2xl hover:bg-gray-100 rounded p-1 transition'; btn.onclick = () => { window.toggleReaction(messageTs, emoji); picker.remove(); }; picker.appendChild(btn); }); document.body.appendChild(picker); // Close picker on click outside setTimeout(() => { const closeHandler = (e) => { if (!picker.contains(e.target)) { picker.remove(); document.removeEventListener('click', closeHandler); } }; document.addEventListener('click', closeHandler); }, 100); }; window.toggleReaction = function(messageTs, emoji) { const session = window.ATHAuth?.getSession?.(); if (!session?.userId) return; const uid = session.userId; const STORE_KEY = `athConversations:${uid}`; const DATA = window.ATHStore.get(STORE_KEY, {}); const urlId = new URLSearchParams(location.search).get('conversation'); if (!urlId || !DATA[urlId]) return; const msg = DATA[urlId].messages.find(m => m.ts == messageTs); if (!msg) return; // Initialize reactions object if (!msg.reactions) msg.reactions = {}; // Toggle reaction if (msg.reactions[emoji]) { msg.reactions[emoji]++; } else { msg.reactions[emoji] = 1; } window.ATHStore.set(STORE_KEY, DATA); // Re-render messages by reloading the page section location.reload(); }; // ---------------------------- // Helpers // ---------------------------- function escapeHtml(str) { return String(str) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } // ---------------------------- // My Profile Page // ---------------------------- function initMyProfilePage() { const root = document.getElementById('mpName'); if (!root) return; // not on my-profile // v0.026a: My Profile is private to signed-in users (prototype auth layer). if (!requireAuthOnPage({ onMissing: 'overlay' })) return; const els = { avatarImg: document.getElementById('mpAvatar'), name: document.getElementById('mpName'), subtitle: document.getElementById('mpSubtitle'), roleBadge: document.getElementById('mpRoleBadge'), verifiedBadge: document.getElementById('mpVerifiedBadge'), editBtn: document.getElementById('mpEditBtn'), saveBtn: document.getElementById('mpSaveBtn'), cancelBtn: document.getElementById('mpCancelBtn'), publicTradieLink: document.getElementById('mpPublicTradieLink'), publicCustomerLink: document.getElementById('mpPublicCustomerLink'), displayName: document.getElementById('mpDisplayName'), avatarPickBtn: document.getElementById('mpAvatarPickBtn'), avatarRemoveBtn: document.getElementById('mpAvatarRemoveBtn'), avatarFile: document.getElementById('mpAvatarFile'), suburb: document.getElementById('mpSuburb'), state: document.getElementById('mpState'), postcode: document.getElementById('mpPostcode'), phone: document.getElementById('mpPhone'), email: document.getElementById('mpEmail'), // Batch L: Tradie multi-trade picker tradesSection: document.getElementById('mpTradesSection'), tradesHidden: document.getElementById('mpTradesHidden'), tradesToggle: document.getElementById('mpTradesToggle'), tradesPanel: document.getElementById('mpTradesPanel'), tradesSearch: document.getElementById('mpTradesSearch'), tradesOptions: document.getElementById('mpTradesOptions'), tradesSelected: document.getElementById('mpTradesSelected'), tradesCount: document.getElementById('mpTradesCount'), showLocation: document.getElementById('mpShowLocation'), addressRule: document.getElementById('mpAddressRule'), abn: document.getElementById('mpAbn'), abnMasked: document.getElementById('mpAbnMasked'), license: document.getElementById('mpLicense'), toast: document.getElementById('mpToast'), roleButtons: Array.from(document.querySelectorAll('.mpRoleBtn')) }; let user = getCurrentUser(); let snapshot = JSON.parse(JSON.stringify(user)); let editing = false; const showToast = (msg, ok = true) => { if (!els.toast) return; els.toast.classList.remove('hidden'); els.toast.className = ok ? 'mt-4 text-sm px-3 py-2 rounded-lg border border-emerald-200 bg-emerald-50 text-emerald-800' : 'mt-4 text-sm px-3 py-2 rounded-lg border border-red-200 bg-red-50 text-red-800'; els.toast.textContent = msg; setTimeout(() => { if (els.toast) els.toast.classList.add('hidden'); }, 2200); }; const setDisabled = (disabled) => { [ els.displayName, els.suburb, els.state, els.postcode, els.phone, els.email, els.showLocation, els.addressRule, els.abn, els.license ].forEach((el) => { if (el) el.disabled = disabled; }); // Trade picker controls if (els.tradesToggle) els.tradesToggle.disabled = disabled; if (els.tradesSearch) els.tradesSearch.disabled = disabled; }; const renderRoleUI = () => { const role = (user.role || 'dual'); if (els.roleBadge) els.roleBadge.textContent = role.toUpperCase(); els.roleButtons.forEach((b) => { const active = b.dataset.role === role; b.classList.toggle('bg-teal-600', active); b.classList.toggle('text-white', active); b.classList.toggle('border-teal-600', active); }); if (els.publicTradieLink) { els.publicTradieLink.classList.toggle('hidden', !(role === 'tradie' || role === 'dual')); els.publicTradieLink.href = 'profile-tradesman.html?id=me'; } if (els.publicCustomerLink) { els.publicCustomerLink.classList.toggle('hidden', !(role === 'customer' || role === 'dual')); els.publicCustomerLink.href = 'profile-customer.html?id=me'; } if (els.tradesSection) { els.tradesSection.classList.toggle('hidden', !(role === 'tradie' || role === 'dual')); } }; // Batch L: Tradie multi-trade picker (canonical list from data.js) const tradeCatalog = Array.isArray(window.TRADE_CATEGORIES) ? window.TRADE_CATEGORIES : []; const tradeLabel = (id) => { const found = tradeCatalog.find(t => t.id === id); return found ? found.label : (typeof window.tradeLabel === 'function' ? window.tradeLabel(id) : String(id || 'Other')); }; let selectedTrades = new Set(); function setSelectedTrades(next) { selectedTrades = new Set(Array.isArray(next) ? next.map(String).filter(Boolean) : []); if (els.tradesHidden) els.tradesHidden.value = Array.from(selectedTrades).join(','); if (els.tradesCount) els.tradesCount.textContent = `${selectedTrades.size} selected`; if (els.tradesSelected) { els.tradesSelected.innerHTML = Array.from(selectedTrades).map((id) => ( ` ${escapeHtml(tradeLabel(id))} ` )).join(''); els.tradesSelected.querySelectorAll('[data-remove-trade]').forEach((btn) => { btn.addEventListener('click', () => { if (!editing) return; selectedTrades.delete(btn.getAttribute('data-remove-trade')); setSelectedTrades(Array.from(selectedTrades)); renderTradeOptions(); }); }); } } function renderTradeOptions() { if (!els.tradesOptions) return; const q = (els.tradesSearch?.value || '').toString().trim().toLowerCase(); const rows = tradeCatalog .filter(t => !q || String(t.label).toLowerCase().includes(q) || String(t.id).toLowerCase().includes(q)) .map(t => { const checked = selectedTrades.has(t.id) ? 'checked' : ''; return ``; }); els.tradesOptions.innerHTML = rows.join('') || `

No trades found.

`; els.tradesOptions.querySelectorAll('input[data-trade-id]').forEach((box) => { box.addEventListener('change', () => { if (!editing) return; const id = box.getAttribute('data-trade-id'); if (box.checked) selectedTrades.add(id); else selectedTrades.delete(id); setSelectedTrades(Array.from(selectedTrades)); }); }); } els.tradesToggle?.addEventListener('click', () => { if (!editing) return; els.tradesPanel?.classList.toggle('hidden'); renderTradeOptions(); }); els.tradesSearch?.addEventListener('input', renderTradeOptions); const render = () => { if (els.avatarImg) els.avatarImg.src = user.avatarDataUrl || user.avatar || 'https://static.photos/people/320x240/301'; if (els.name) els.name.textContent = user.displayName || 'My Profile'; if (els.verifiedBadge) { const verified = !!user.verification?.verified; els.verifiedBadge.classList.toggle('hidden', !verified); } if (els.displayName) els.displayName.value = user.displayName || ''; if (els.suburb) els.suburb.value = user.location?.suburb || ''; if (els.state) els.state.value = user.location?.state || ''; if (els.postcode) els.postcode.value = user.location?.postcode || ''; if (els.phone) els.phone.value = user.contact?.phone || ''; if (els.email) els.email.value = user.contact?.email || ''; if (els.showLocation) els.showLocation.checked = !!user.privacy?.showLocation; if (els.addressRule) els.addressRule.value = user.privacy?.addressRule || 'afterAccepted'; if (els.abn) els.abn.value = user.verification?.abnFull || ''; if (els.abnMasked) els.abnMasked.textContent = maskSensitiveKeepLast4(user.verification?.abnFull || ''); if (els.license) els.license.value = user.verification?.licenseFull || ''; if (!editing) { const fromUser = Array.isArray(user.tradie?.trades) ? user.tradie.trades : (typeof window.inferTradeIdsFromText === 'function' ? window.inferTradeIdsFromText(user.tradie?.trade) : []); setSelectedTrades(fromUser || []); renderTradeOptions(); } renderRoleUI(); if (typeof feather !== 'undefined') feather.replace(); }; const validate = () => { const name = (els.displayName?.value || '').trim(); if (!name) return { ok: false, msg: 'Display name is required.' }; const st = (els.state?.value || '').trim(); const pc = (els.postcode?.value || '').trim(); if (pc && !/^\d{4}$/.test(pc)) return { ok: false, msg: 'Postcode must be 4 digits.' }; if (st && st.length > 3) return { ok: false, msg: 'State looks invalid.' }; const email = (els.email?.value || '').trim(); if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { ok: false, msg: 'Email format looks invalid.' }; return { ok: true }; }; const enterEdit = () => { editing = true; snapshot = JSON.parse(JSON.stringify(user)); setDisabled(false); setAvatarButtonsEnabled(true); if (els.editBtn) els.editBtn.classList.add('hidden'); if (els.saveBtn) els.saveBtn.classList.remove('hidden'); if (els.cancelBtn) els.cancelBtn.classList.remove('hidden'); }; const exitEdit = () => { editing = false; setDisabled(true); setAvatarButtonsEnabled(false); if (els.editBtn) els.editBtn.classList.remove('hidden'); if (els.saveBtn) els.saveBtn.classList.add('hidden'); if (els.cancelBtn) els.cancelBtn.classList.add('hidden'); }; const applyFromFields = () => { user.displayName = (els.displayName?.value || '').trim(); user.location = { suburb: (els.suburb?.value || '').trim(), state: (els.state?.value || '').trim().toUpperCase(), postcode: (els.postcode?.value || '').trim() }; user.contact = { phone: (els.phone?.value || '').trim(), email: (els.email?.value || '').trim() }; user.privacy = { showLocation: !!els.showLocation?.checked, addressRule: els.addressRule?.value || 'afterAccepted' }; user.verification = user.verification || { verified: false, abnFull: '', licenseFull: '' }; user.verification.abnFull = (els.abn?.value || '').trim(); user.verification.licenseFull = (els.license?.value || '').trim(); // Batch L: persist tradie trades (even in Dual mode) user.tradie = user.tradie || { trades: [] }; user.tradie.trades = Array.from(selectedTrades); }; // ---------------------------- // Avatar upload (prototype) // Stores a compressed square image in localStorage as avatarDataUrl. // ---------------------------- async function processAvatarFile(file) { if (!file) return null; if (!file.type || !file.type.startsWith('image/')) { showToast('Please choose an image file.', false); return null; } const MAX_BYTES = 3 * 1024 * 1024; // 3MB if (file.size > MAX_BYTES) { showToast('Image is too large (max 3MB).', false); return null; } if (!window.ATHImages || typeof window.ATHImages.processImageFile !== 'function') return null; return await window.ATHImages.processImageFile(file, { maxBytes: MAX_BYTES, maxDim: 384, cropSquare: true, mimePrefer: 'image/webp', quality: 0.85 }); } function setAvatarButtonsEnabled(enabled) { if (els.avatarPickBtn) els.avatarPickBtn.disabled = !enabled; if (els.avatarRemoveBtn) els.avatarRemoveBtn.disabled = !enabled; } els.avatarPickBtn?.addEventListener('click', () => { if (!editing) return; els.avatarFile?.click(); }); els.avatarFile?.addEventListener('change', async () => { if (!editing) return; const file = els.avatarFile.files && els.avatarFile.files[0]; const processed = await processAvatarFile(file); // reset input so picking same file again still triggers change if (els.avatarFile) els.avatarFile.value = ''; if (!processed) return; user.avatarDataUrl = processed; render(); }); els.avatarRemoveBtn?.addEventListener('click', () => { if (!editing) return; user.avatarDataUrl = ''; render(); }); // Role selection els.roleButtons.forEach((btn) => { btn.addEventListener('click', () => { if (!editing) return; user.role = btn.dataset.role; renderRoleUI(); }); }); els.editBtn?.addEventListener('click', () => enterEdit()); els.cancelBtn?.addEventListener('click', () => { user = JSON.parse(JSON.stringify(snapshot)); render(); exitEdit(); showToast('Cancelled changes.', true); }); els.saveBtn?.addEventListener('click', () => { const v = validate(); if (!v.ok) return showToast(v.msg, false); applyFromFields(); setCurrentUser(user); render(); exitEdit(); showToast('Saved.', true); }); // Initial state render(); exitEdit(); } // ---------------------------- // Batch N1: Profile reviews rendering (read-only) // ---------------------------- (function () { function escapeHtml(str) { return String(str || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function formatDate(ts) { const d = new Date(Number(ts || Date.now())); return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' }); } function renderStars(stars) { const s = Math.max(0, Math.min(5, Number(stars || 0))); return Array.from({ length: 5 }).map((_, i) => { const filled = i < s; const cls = filled ? 'fill-current' : ''; return ``; }).join(''); } function getReviewCount(entity, opts) { if (!entity) return 0; const baseTotal = Number.isFinite(Number(entity.reviewCount)) ? Number(entity.reviewCount) : (Array.isArray(entity.reviews) ? entity.reviews.length : 0); const id = String(opts?.id || ''); const role = String(opts?.role || ''); const local = id ? getLocalPublishedReviews(id, role) : []; // Only add local items that are beyond demo base arrays return baseTotal + local.length; } function readLocalReviews() { try { const raw = localStorage.getItem('athReviews'); if (!raw) return []; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function getLocalPublishedReviews(targetId, targetRole) { const tid = String(targetId || ''); if (!tid) return []; const role = String(targetRole || ''); const list = readLocalReviews(); const out = []; const now = Date.now(); for (const r of list) { if (!r) continue; if (String(r.targetId) !== tid) continue; if (role && String(r.targetRole) !== role) continue; if (r.visibility === 'published') { out.push(r); continue; } const base = Date.parse(r.completedAt || '') || 0; if (base && (now - base) >= 7 * 24 * 60 * 60 * 1000) { out.push({ ...r, visibility: 'published' }); } } return out; } function getReviews(entity, opts) { const base = Array.isArray(entity?.reviews) ? entity.reviews : []; const id = String(opts?.id || ''); const role = String(opts?.role || ''); const local = id ? getLocalPublishedReviews(id, role) : []; // map local format to display format const mapped = local.map(r => ({ stars: r.stars, text: r.text, ts: r.ts, byRole: r.reviewerRole === 'customer' ? 'Customer' : r.reviewerRole === 'tradie' ? 'Tradie' : '' })); return [...mapped, ...base]; } window.ATHRender = window.ATHRender || {}; window.ATHRender.renderReviewsInto = function renderReviewsInto(container, entity, emptyText, opts) { if (!container) return; const reviews = getReviews(entity, opts); const total = getReviewCount(entity, opts); if (!reviews.length) { container.innerHTML = `
${escapeHtml(emptyText || 'No reviews yet.')}
`; return; } const header = (total > reviews.length) ? `
Showing ${reviews.length} of ${total} reviews
` : ''; const items = reviews.map((r) => { const who = r.byRole ? `• ${escapeHtml(r.byRole)}` : ''; return `
${renderStars(r.stars)}
${escapeHtml(formatDate(r.ts))}
${escapeHtml(r.text || '')}
${who}
`; }).join(''); container.innerHTML = `${header}
${items}
`; // feather icons try { if (typeof feather !== 'undefined' && feather && typeof feather.replace === 'function') feather.replace(); } catch (e) { } }; })();