// 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 = `
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, 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(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 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' ? `
${escapeHtml(safeText(m.text))}
` : `
${escapeHtml(safeText(m.text))}
`; 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 = '
No messages yet.
'; 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) => ( ` ${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) {} }; })();