Shopaholic / script.js
J0BL1N's picture
Upload 13 files
eed5746 verified
Raw
History Blame Contribute Delete
43.5 kB
// 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
// ----------------------------
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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) {}
};
})();