Spaces:
Running
Running
| // 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 = ` | |
| <div class="ath-integrity-banner flex items-start gap-3 p-3 rounded-xl border border-teal-200 bg-teal-50 text-teal-900"> | |
| <div class="mt-0.5"><i data-feather="lock" class="w-4 h-4"></i></div> | |
| <div class="text-sm"> | |
| <div class="font-semibold">Contact details are locked until payment is confirmed.</div> | |
| <div class="text-teal-800">Keep chat and job info inside TradieHub to stay protected.</div> | |
| </div> | |
| </div> | |
| `; | |
| 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 ` | |
| <a href="messages.html?conversation=${encodeURIComponent(id)}" | |
| class="conversation-item block p-4 hover:bg-gray-50 ${borderClass} ${activeClass}" | |
| data-conversation="${escapeHtml(id)}"> | |
| <div class="flex items-start space-x-3"> | |
| <div class="relative"> | |
| <img src="${escapeHtml(c?.avatar || '')}" alt="${escapeHtml(c?.name || '')}" class="w-10 h-10 rounded-full object-cover border-2 border-teal-100"> | |
| <span class="absolute -bottom-1 -right-1 w-3 h-3 ${dotClass} rounded-full border-2 border-white"></span> | |
| </div> | |
| <div class="flex-1 min-w-0"> | |
| <div class="flex justify-between items-start mb-1"> | |
| <h3 class="font-bold text-gray-900 truncate pr-2">${escapeHtml(c?.name || 'Conversation')}</h3> | |
| <span class="text-xs text-gray-500 whitespace-nowrap">${escapeHtml(time)}</span> | |
| </div> | |
| <p class="text-sm text-gray-600 truncate mb-1">${escapeHtml(preview)}</p> | |
| <div class="flex items-center"> | |
| ${tag ? `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${escapeHtml(tag.color || '')} mr-2">${escapeHtml(tag.label || '')}</span>` : ''} | |
| ${unread ? `<span class="bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">${unread}</span>` : ''} | |
| </div> | |
| </div> | |
| </div> | |
| </a> | |
| `; | |
| }).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 renderMessages(c) { | |
| chat.innerHTML = ''; | |
| const msgs = c?.messages || []; | |
| msgs.forEach((m) => { | |
| const wrap = document.createElement('div'); | |
| wrap.className = m.from === 'me' ? 'flex justify-end mb-4' : 'flex justify-start mb-4'; | |
| wrap.innerHTML = | |
| m.from === 'me' | |
| ? `<div class="bg-teal-600 text-white p-3 rounded-xl max-w-lg">${escapeHtml(safeText(m.text))}</div>` | |
| : `<div class="bg-white border border-gray-200 p-3 rounded-xl max-w-lg">${escapeHtml(safeText(m.text))}</div>`; | |
| chat.appendChild(wrap); | |
| }); | |
| chat.scrollTop = chat.scrollHeight; | |
| } | |
| // ---------------------------- | |
| // 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('lastActiveConversation'); | |
| 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]; | |
| } | |
| function load(id) { | |
| const actualId = DATA[id] ? id : getDefaultConversationId(); | |
| if (!actualId) { | |
| // No conversations at all | |
| renderConversationList(searchInput?.value || ''); | |
| chat.innerHTML = '<div class="text-sm text-gray-500">No messages yet.</div>'; | |
| renderHeader({ name: 'Messages', meta: '', online: false, avatar: '' }); | |
| updateUnreadUI(); | |
| return; | |
| } | |
| const c = DATA[actualId]; | |
| // Mark read + persist active | |
| markRead(actualId); | |
| localStorage.setItem('lastActiveConversation', actualId); | |
| // Render | |
| setConversationIdInUrl(actualId); | |
| renderConversationList(searchInput?.value || ''); | |
| setActiveRow(actualId); | |
| renderHeader(c); | |
| renderMessages(c); | |
| updateUnreadUI(); | |
| } | |
| // ---------------------------- | |
| // 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); | |
| }); | |
| // 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(); | |
| const sanitized = window.ATHIntegrity ? window.ATHIntegrity.sanitizeText(text) : { text, changed: false }; | |
| DATA[id].messages.push({ from: 'me', time: 'Now', ts: now, text: sanitized.text }); | |
| window.ATHStore.set('athConversations', DATA); | |
| if (sanitized.changed) { | |
| window.ATHIntegrity?.setInlineNotice(document.getElementById('athMessageSanitizeNote'), 'Contact details were removed. Payment is required to share direct contact.'); | |
| setTimeout(() => window.ATHIntegrity?.setInlineNotice(document.getElementById('athMessageSanitizeNote'), ''), 2500); | |
| } | |
| input.value = ''; | |
| // Re-render chat + sidebar preview/time | |
| renderMessages(DATA[id]); | |
| renderConversationList(searchInput?.value || ''); | |
| setActiveRow(id); | |
| } | |
| sendBtn?.addEventListener('click', send); | |
| input?.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| send(); | |
| } | |
| }); | |
| // Init | |
| renderConversationList(''); | |
| load(getConversationIdFromUrl() || getDefaultConversationId()); | |
| } | |
| // ---------------------------- | |
| // 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 | |
| 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) => ( | |
| `<span class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-teal-50 text-teal-700 text-xs font-medium"> | |
| ${escapeHtml(tradeLabel(id))} | |
| <button type="button" data-remove-trade="${escapeHtml(id)}" class="text-teal-700 hover:text-teal-900" ${editing ? '' : 'disabled'}>×</button> | |
| </span>` | |
| )).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 `<label class="flex items-center gap-2 text-sm text-gray-700"> | |
| <input type="checkbox" data-trade-id="${escapeHtml(t.id)}" class="rounded border-gray-300 text-teal-600 focus:ring-teal-500" ${checked} ${editing ? '' : 'disabled'} /> | |
| <span>${escapeHtml(t.label)}</span> | |
| </label>`; | |
| }); | |
| els.tradesOptions.innerHTML = rows.join('') || `<p class="text-sm text-gray-500">No trades found.</p>`; | |
| 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, '"') | |
| .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 `<i data-feather="star" class="w-4 h-4 ${cls}"></i>`; | |
| }).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 = `<div class="text-sm text-gray-500">${escapeHtml(emptyText || 'No reviews yet.')}</div>`; | |
| return; | |
| } | |
| const header = (total > reviews.length) | |
| ? `<div class="text-xs text-gray-500 mb-3">Showing ${reviews.length} of ${total} reviews</div>` | |
| : ''; | |
| const items = reviews.map((r) => { | |
| const who = r.byRole ? `<span class="text-xs text-gray-500">• ${escapeHtml(r.byRole)}</span>` : ''; | |
| return ` | |
| <div class="border border-gray-200 rounded-xl p-4"> | |
| <div class="flex items-center justify-between gap-3"> | |
| <div class="flex items-center text-amber-400">${renderStars(r.stars)}</div> | |
| <div class="text-xs text-gray-500">${escapeHtml(formatDate(r.ts))}</div> | |
| </div> | |
| <div class="mt-2 text-sm text-gray-700">${escapeHtml(r.text || '')}</div> | |
| <div class="mt-2 text-xs text-gray-500">${who}</div> | |
| </div> | |
| `; | |
| }).join(''); | |
| container.innerHTML = `${header}<div class="space-y-3">${items}</div>`; | |
| // feather icons | |
| try { | |
| if (typeof feather !== 'undefined' && feather && typeof feather.replace === 'function') feather.replace(); | |
| } catch (e) {} | |
| }; | |
| })(); | |