// 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.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 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, // v0.01 default used a demo customerId; keep it stable for now. customerId: String(j?.customerId || 'michael-roberts'), 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, }; })(); // ---------------------------- // Batch N3: Integrity (no direct contact until payment) // ---------------------------- window.ATHIntegrity = window.ATHIntegrity || (function () { 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; function canShareContact() { // Future: flip true after payment confirmation. // For now: privacy-first prototype rule. return false; } function sanitizeTextJs(input) { const original = String(input || ''); if (canShareContact()) return { text: original, changed: false }; let out = original.replace(EMAIL_RE, '•••••'); out = out.replace(PHONE_RE, (m) => { const digits = String(m).replace(/\D/g, ''); if (digits.length < 8) return m; // ignore too-short sequences return '•••••'; }); return { text: out, changed: out !== original }; } function renderBanner(mountEl) { if (!mountEl) return; if (canShareContact()) { mountEl.innerHTML = ''; return; } mountEl.innerHTML = `
`; 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, sanitizeText: sanitizeTextJs, renderBanner, setInlineNotice }; })(); document.addEventListener('DOMContentLoaded', () => { if (typeof feather !== 'undefined') feather.replace(); initMobileMenu(); 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}`; } // ---------------------------- // Mobile Menu // ---------------------------- function initMobileMenu() { const mobileMenuButton = document.getElementById('mobileMenuButton'); const mobileMenu = document.getElementById('mobileMenu'); if (!mobileMenuButton || !mobileMenu) return; mobileMenuButton.addEventListener('click', () => { mobileMenu.classList.toggle('hidden'); const icon = mobileMenuButton.querySelector('i'); if (icon) { icon.setAttribute('data-feather', mobileMenu.classList.contains('hidden') ? 'menu' : 'x'); if (typeof feather !== 'undefined') feather.replace(); } }); } // ---------------------------- // 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'); // Not on messages page if (!list || !chat || !chatName || !chatMeta || !chatStatus || !chatAvatar) return; // 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.seedOnce('athConversations', (window.CONVERSATIONS && typeof window.CONVERSATIONS === 'object') ? window.CONVERSATIONS : {}); 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:${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(preview)}
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 = `