// script.js — AussieTradieHub (safe on all pages)
// ----------------------------
// Batch N3: Storage helpers
// ----------------------------
window.ATHStore = window.ATHStore || (function () {
function get(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (raw === null || raw === undefined) return fallback;
return JSON.parse(raw);
} catch (e) {
return fallback;
}
}
function set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
// ignore quota/serialization failures in demo
}
}
function seedOnce(key, seedValue) {
const existing = get(key, null);
if (existing !== null && existing !== undefined) return existing;
const seeded = seedValue || {};
set(key, seeded);
return seeded;
}
return { get, set, seedOnce };
})();
// ----------------------------
// v0.026: Prototype auth (email/password) + session
// ----------------------------
// Client-side only (localStorage). This is for MVP UX testing.
// It is intentionally separated from `athCurrentUser` so the existing demo
// identity/profile/job-ownership model continues to work unchanged.
window.ATHAuth = window.ATHAuth || (function () {
const USERS_KEY = 'athAuthUsers';
const SESSION_KEY = 'athAuthSession';
function readUsers() {
const u = window.ATHStore?.get(USERS_KEY, []);
return Array.isArray(u) ? u : [];
}
function writeUsers(users) {
window.ATHStore?.set(USERS_KEY, Array.isArray(users) ? users : []);
}
function getSession() {
const s = window.ATHStore?.get(SESSION_KEY, null);
return (s && typeof s === 'object') ? s : null;
}
function setSession(session) {
window.ATHStore?.set(SESSION_KEY, session);
try {
window.dispatchEvent(new CustomEvent('ath:authchange', { detail: { session: getSession() } }));
} catch { }
}
function clearSession() {
try { localStorage.removeItem(SESSION_KEY); } catch { }
try {
window.dispatchEvent(new CustomEvent('ath:authchange', { detail: { session: null } }));
} catch { }
}
function normalizeEmail(email) {
return String(email || '').trim().toLowerCase();
}
function isValidEmail(email) {
const e = normalizeEmail(email);
// permissive RFC-ish check for MVP
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e);
}
async function sha256Hex(input) {
const str = String(input || '');
try {
if (!window.crypto?.subtle) throw new Error('no-subtle');
const enc = new TextEncoder();
const buf = await window.crypto.subtle.digest('SHA-256', enc.encode(str));
const bytes = Array.from(new Uint8Array(buf));
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
} catch {
// Fallback: not cryptographically strong. Still avoids plaintext storage.
let h = 0;
for (let i = 0; i < str.length; i++) h = ((h << 5) - h) + str.charCodeAt(i);
return `weak-${Math.abs(h)}`;
}
}
function makeId() {
return `auth-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
async function signUp(email, password) {
const e = normalizeEmail(email);
if (!isValidEmail(e)) return { ok: false, error: 'Please enter a valid email.' };
const p = String(password || '');
if (p.length < 6) return { ok: false, error: 'Password must be at least 6 characters.' };
const users = readUsers();
if (users.some(u => normalizeEmail(u?.email) === e)) {
return { ok: false, error: 'An account with that email already exists.' };
}
const passwordHash = await sha256Hex(p);
const user = {
id: makeId(),
email: e,
passwordHash,
emailVerified: false,
createdAt: new Date().toISOString()
};
users.push(user);
writeUsers(users);
setSession({ userId: user.id, email: user.email, signedInAt: new Date().toISOString() });
return { ok: true, user };
}
async function signIn(email, password) {
const e = normalizeEmail(email);
if (!isValidEmail(e)) return { ok: false, error: 'Please enter a valid email.' };
const p = String(password || '');
const users = readUsers();
const user = users.find(u => normalizeEmail(u?.email) === e);
if (!user) return { ok: false, error: 'Incorrect email or password.' };
const hash = await sha256Hex(p);
if (String(user.passwordHash) !== String(hash)) {
return { ok: false, error: 'Incorrect email or password.' };
}
return { ok: true, user };
}
async function signInWithGoogle(googleUser) {
// googleUser: { email, name, picture, sub }
const e = normalizeEmail(googleUser.email);
const users = readUsers();
// Check if user exists
let user = users.find(u => normalizeEmail(u?.email) === e);
if (!user) {
// Create new user from Google data
user = {
id: makeId(),
email: e,
// No password hash for google-only users, or we could set a random one
passwordHash: 'google-auth-no-pass',
displayName: googleUser.name,
avatar: googleUser.picture, // Store Google avatar URL
emailVerified: true, // Google emails are verified
createdAt: new Date().toISOString(),
authProvider: 'google'
};
users.push(user);
writeUsers(users);
} else {
// Update existing user with latest Google info (optional, but good for avatar)
// Only update if they don't have a custom avatar set locally?
// For MVP, let's update basic info if it's missing or if they are a google user.
if (!user.displayName) user.displayName = googleUser.name;
if (!user.avatar) user.avatar = googleUser.picture;
// Merge changes
const idx = users.indexOf(user);
if (idx !== -1) {
users[idx] = user;
writeUsers(users);
}
}
setSession({ userId: user.id, email: user.email, signedInAt: new Date().toISOString(), provider: 'google' });
return { ok: true, user };
}
function signOut() {
clearSession();
}
function getCurrentAuthUser() {
const s = getSession();
if (!s?.userId) return null;
const users = readUsers();
return users.find(u => String(u?.id) === String(s.userId)) || null;
}
return {
readUsers,
getSession,
setSession,
clearSession,
signUp,
signIn,
signInWithGoogle,
signOut,
getCurrentAuthUser,
normalizeEmail,
isValidEmail,
};
})();
// ----------------------------
// v0.016: Image helpers (shared)
// ----------------------------
// Reusable client-side image processing for localStorage-only uploads.
// This is used for avatar upload and job completion photos.
window.ATHImages = window.ATHImages || (function () {
async function readFileAsDataUrl(file) {
return await new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result || ''));
r.onerror = () => reject(new Error('read-failed'));
r.readAsDataURL(file);
});
}
async function loadImage(dataUrl) {
return await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error('img-load-failed'));
i.src = dataUrl;
});
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
// Options:
// - maxBytes (default 3MB)
// - maxDim (default 1024)
// - cropSquare (default false)
// - mimePrefer (default 'image/webp')
// - quality (default 0.85)
async function processImageFile(file, opts) {
const o = opts || {};
if (!file) return null;
if (!file.type || !file.type.startsWith('image/')) return null;
const MAX_BYTES = Number(o.maxBytes || (3 * 1024 * 1024));
if (file.size > MAX_BYTES) return null;
const dataUrl = await readFileAsDataUrl(file);
const img = await loadImage(dataUrl);
const maxDim = clamp(Number(o.maxDim || 1024), 128, 2048);
const cropSquare = !!o.cropSquare;
const quality = clamp(Number(o.quality ?? 0.85), 0.5, 0.95);
let srcW = img.width;
let srcH = img.height;
if (!srcW || !srcH) return null;
// Determine destination size.
let dstW = srcW;
let dstH = srcH;
if (cropSquare) {
const s = Math.min(srcW, srcH);
dstW = Math.min(maxDim, s);
dstH = dstW;
} else {
const scale = Math.min(1, maxDim / Math.max(srcW, srcH));
dstW = Math.max(1, Math.round(srcW * scale));
dstH = Math.max(1, Math.round(srcH * scale));
}
const canvas = document.createElement('canvas');
canvas.width = dstW;
canvas.height = dstH;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
if (cropSquare) {
const s = Math.min(srcW, srcH);
const sx = Math.floor((srcW - s) / 2);
const sy = Math.floor((srcH - s) / 2);
ctx.drawImage(img, sx, sy, s, s, 0, 0, dstW, dstH);
} else {
ctx.drawImage(img, 0, 0, dstW, dstH);
}
// Prefer webp when supported; fallback to jpeg.
const prefer = String(o.mimePrefer || 'image/webp');
let out = '';
try {
out = canvas.toDataURL(prefer, quality);
if (!out || !out.startsWith('data:image/')) throw new Error('encode-failed');
// Some browsers may ignore webp; detect mismatch.
if (prefer.includes('webp') && !out.startsWith('data:image/webp')) throw new Error('no-webp');
} catch (e) {
out = canvas.toDataURL('image/jpeg', quality);
}
return out;
}
return { processImageFile };
})();
// ----------------------------
// v0.012: Jobs helpers (shared across pages)
// ----------------------------
// Single source of truth for:
// - job list composition (seed + posted)
// - job state overrides (athJobState)
//
// IMPORTANT: This is intentionally incremental. We keep existing localStorage
// keys and data shapes, and only add the minimum helpers needed to avoid
// duplicating job-state logic across the Job Board and Profiles.
window.ATHJobs = window.ATHJobs || (function () {
const JOB_STATE_KEY = 'athJobState';
const POSTED_JOBS_KEY = 'athPostedJobs';
function readJson(key, fallback) {
if (window.ATHStore && typeof window.ATHStore.get === 'function') {
return window.ATHStore.get(key, fallback);
}
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
const parsed = JSON.parse(raw);
return parsed ?? fallback;
} catch {
return fallback;
}
}
function writeJson(key, value) {
if (window.ATHStore && typeof window.ATHStore.set === 'function') {
window.ATHStore.set(key, value);
return;
}
try {
localStorage.setItem(key, JSON.stringify(value));
} catch { }
}
// ---- Posted jobs (local) ----
function readPostedJobsFromStorage() {
const parsed = readJson(POSTED_JOBS_KEY, []);
return Array.isArray(parsed) ? parsed : [];
}
// Canonical mapping for jobs created via post-job.html
function getCurrentUserIdForJobs() {
try {
const raw = localStorage.getItem('athCurrentUser');
const u = raw ? JSON.parse(raw) : null;
if (u && u.id) return String(u.id);
} catch { }
return 'me';
}
function mapPostedJobToCanonical(j) {
const state = (j?.state || '').toString().trim().toUpperCase();
const budget = (j?.budget === 0 || j?.budget) ? Number(j.budget) : null;
const postedCats = Array.isArray(j?.categories)
? j.categories
: (j?.categories ? String(j.categories).split(',') : (j?.category ? [j.category] : []));
const categories = (typeof window.normalizeTradeIds === 'function')
? window.normalizeTradeIds(postedCats)
: (postedCats || []).map(v => String(v || '').trim().toLowerCase()).filter(Boolean);
return {
id: String(j?.id || `posted-${Date.now()}`),
title: String(j?.title || 'Untitled Job'),
description: String(j?.description || ''),
categories: Array.from(new Set(categories.length ? categories : ['other'])),
location: state ? `Australia, ${state}` : 'Australia',
state: state || 'ALL',
budgetMin: budget || 0,
budgetMax: budget || 0,
timeline: j?.date ? `Preferred: ${j.date}` : 'Flexible',
urgency: 'flexible',
type: 'one-off',
quotes: 0,
// Default to current user ("me") so posted jobs show the correct customer profile.
customerId: String(j?.customerId || getCurrentUserIdForJobs()),
postedAt: j?.createdAt || new Date().toISOString(),
status: 'open',
_source: 'local'
};
}
// ---- Job state overrides (athJobState) ----
function readJobStateMap() {
const parsed = readJson(JOB_STATE_KEY, {});
return (parsed && typeof parsed === 'object') ? parsed : {};
}
function writeJobStateMap(map) {
writeJson(JOB_STATE_KEY, map && typeof map === 'object' ? map : {});
}
function getJobState(jobId) {
const map = readJobStateMap();
const s = map?.[String(jobId)] || {};
return (s && typeof s === 'object') ? s : {};
}
function setJobState(jobId, patch) {
const map = readJobStateMap();
const id = String(jobId);
const base = (map[id] && typeof map[id] === 'object') ? map[id] : {};
map[id] = { ...base, ...(patch || {}), updatedAt: new Date().toISOString() };
writeJobStateMap(map);
return map[id];
}
function applyOverrides(job) {
const s = getJobState(job?.id);
if (!s || typeof s !== 'object') return job;
return {
...job,
status: s.status || job.status,
assignedTradieId: s.assignedTradieId || job.assignedTradieId,
completedAt: s.completedAt || job.completedAt,
inProgressAt: s.inProgressAt || job.inProgressAt,
agreedAt: s.agreedAt || job.agreedAt,
tradieAcceptedTermsAt: s.tradieAcceptedTermsAt || job.tradieAcceptedTermsAt,
};
}
// ---- Composition: seed + posted + overrides ----
function getAllJobs() {
const baseJobs = Array.isArray(window.JOBS) ? window.JOBS : [];
const postedCanonical = readPostedJobsFromStorage().map(mapPostedJobToCanonical);
const byId = new Map();
[...postedCanonical, ...baseJobs].forEach((j) => {
if (!j || !j.id) return;
byId.set(String(j.id), j);
});
return Array.from(byId.values()).map(applyOverrides);
}
return {
JOB_STATE_KEY,
POSTED_JOBS_KEY,
readPostedJobsFromStorage,
mapPostedJobToCanonical,
readJobStateMap,
writeJobStateMap,
getJobState,
setJobState,
applyOverrides,
getAllJobs,
};
})();
// ----------------------------
// v0.029 (incremental): Shared job details modal
// ----------------------------
// Allows pages outside jobs.html (e.g. profile-customer.html) to open a
// lightweight, read-only job details modal.
//
// Design constraints:
// - Do not re-architect: reuse ATHJobs.getAllJobs() (seed + posted + overrides).
// - Do not introduce new storage keys.
// - Keep UI minimal and consistent with jobs.html modal.
window.ATHJobDetails = window.ATHJobDetails || (function () {
const MODAL_ID = 'athJobModal';
const TITLE_ID = 'athJobModalTitle';
const BODY_ID = 'athJobModalBody';
const CLOSE_ID = 'athJobModalClose';
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}[ch]));
}
function money(n) {
const v = Number(n || 0);
if (!isFinite(v) || v <= 0) return '—';
try {
return v.toLocaleString('en-AU', { style: 'currency', currency: 'AUD', maximumFractionDigits: 0 });
} catch {
return `$${Math.round(v)}`;
}
}
function budgetLabel(job) {
const min = Number(job?.budgetMin || 0);
const max = Number(job?.budgetMax || 0);
if (max && min && max !== min) return `${money(min)} – ${money(max)}`;
if (max || min) return money(max || min);
return '—';
}
function ensureModal() {
let modal = document.getElementById(MODAL_ID);
if (modal) return modal;
// Inject a modal identical in structure to jobs.html.
const wrap = document.createElement('div');
wrap.innerHTML = `
`;
document.body.appendChild(wrap.firstElementChild);
modal = document.getElementById(MODAL_ID);
// Wire close behavior.
const closeBtn = document.getElementById(CLOSE_ID);
const close = () => {
modal?.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
};
closeBtn?.addEventListener('click', close);
modal?.addEventListener('click', (e) => { if (e.target === modal) close(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
if (typeof feather !== 'undefined') feather.replace();
return modal;
}
function open(jobId) {
const all = window.ATHJobs?.getAllJobs?.() || [];
const job = all.find(j => String(j?.id) === String(jobId));
if (!job) return;
const modal = ensureModal();
const titleEl = document.getElementById(TITLE_ID);
const bodyEl = document.getElementById(BODY_ID);
if (!modal || !titleEl || !bodyEl) return;
const catIds = Array.isArray(job.categories)
? job.categories
: (typeof window.normalizeTradeIds === 'function' ? window.normalizeTradeIds(job.categories) : []);
const chips = (catIds || []).map((cid) => {
const label = (typeof window.tradeLabel === 'function') ? window.tradeLabel(cid) : String(cid || '');
return `${escapeHtml(label)}`;
}).join('');
const timeline = String(job.timeline || 'Flexible');
const desc = window.ATHIntegrity ? window.ATHIntegrity.sanitizeText(job.description || '').text : (job.description || '');
const st = String(job.status || 'open').replace('_', ' ');
titleEl.textContent = job.title || 'Job details';
bodyEl.innerHTML = `
Status: ${escapeHtml(st)}
Categories
${chips || '—'}
Location
${escapeHtml(job.location || '—')}
Budget
${escapeHtml(budgetLabel(job))}
Timeline
${escapeHtml(timeline)}
Description
${escapeHtml(desc)}
`;
modal.classList.remove('hidden');
document.body.classList.add('overflow-hidden');
if (typeof feather !== 'undefined') feather.replace();
}
return { open, ensureModal };
})();
// ----------------------------
// Batch N3: Integrity (no direct contact until payment)
// ----------------------------
window.ATHIntegrity = window.ATHIntegrity || (function () {
// Batch N3+: Integrity (no direct contact until payment)
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig;
// Best-effort phone matcher: long digit runs with optional separators
const PHONE_RE = /(?:\+?\d[\d\s().-]{7,}\d)/g;
// URLs with scheme or www
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<]+/ig;
// Bare domains (e.g., example.com, example.com.au). TLD must be letters.
const DOMAIN_RE = /\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,})(?:\/[\w\-./?%&=+#]*)?/ig;
function canShareContact() {
// Future: flip true after payment confirmation.
// For now: privacy-first prototype rule.
return false;
}
function normalizeForScan(input) {
let t = String(input || '').toLowerCase();
// normalize whitespace
t = t.replace(/[\r\n\t]+/g, ' ');
// normalize common obfuscations
t = t.replace(/[\[(\{]\s*dot\s*[\]\)\}]/g, '.');
t = t.replace(/[\[(\{]\s*at\s*[\]\)\}]/g, '@');
t = t.replace(/\s+dot\s+/g, '.');
t = t.replace(/\s+at\s+/g, '@');
// collapse spaced-out words: "t r a d i e" -> "tradie"
t = t.replace(/\b(?:[a-z]\s+){2,}[a-z]\b/g, (m) => m.replace(/\s+/g, ''));
return t;
}
function scanText(input) {
if (canShareContact()) return { hasContact: false, types: { email: false, phone: false, url: false } };
const original = String(input || '');
const norm = normalizeForScan(original);
const compact = norm.replace(/[\s\u200B\u200C\u200D\uFEFF_\-()\[\]{}<>|]+/g, '');
// Email: direct OR obfuscated (at/dot)
const email = EMAIL_RE.test(original) || EMAIL_RE.test(norm) || EMAIL_RE.test(compact);
// Phone: direct pattern OR obfuscated digit runs (e.g., 0x4x1x...)
const digits = compact.replace(/x/g, '').replace(/\D/g, '');
const phone = PHONE_RE.test(original) || (digits.length >= 8);
// URL/domain: direct or obfuscated
const url = URL_RE.test(original) || DOMAIN_RE.test(norm) || DOMAIN_RE.test(compact);
// Reset global regex state after .test on /g
EMAIL_RE.lastIndex = 0;
PHONE_RE.lastIndex = 0;
URL_RE.lastIndex = 0;
DOMAIN_RE.lastIndex = 0;
return { hasContact: !!(email || phone || url), types: { email: !!email, phone: !!phone, url: !!url } };
}
function sanitizeTextJs(input) {
const original = String(input || '');
if (canShareContact()) return { text: original, changed: false };
let out = original;
// Remove emails
out = out.replace(EMAIL_RE, '•••••');
// Remove phone-ish sequences
out = out.replace(PHONE_RE, (m) => {
const digits = String(m).toLowerCase().replace(/x/g, '').replace(/\D/g, '');
if (digits.length < 8) return m;
return '•••••';
});
// Remove URLs + bare domains
out = out.replace(URL_RE, '•••••');
out = out.replace(DOMAIN_RE, (m) => {
// avoid nuking version strings like v0.029
const hasAlpha = /[a-z]/i.test(m);
const hasDot = m.includes('.');
if (!hasAlpha || !hasDot) return m;
return '•••••';
});
return { text: out, changed: out !== original };
}
function renderBanner(mountEl) {
if (!mountEl) return;
if (canShareContact()) {
mountEl.innerHTML = '';
return;
}
mountEl.innerHTML = `
Contact details are locked until payment is confirmed.
Keep chat and job info inside TradieHub to stay protected.
`;
if (typeof feather !== 'undefined') feather.replace();
}
function setInlineNotice(el, msg) {
if (!el) return;
el.textContent = msg || '';
if (!msg) el.classList.add('hidden');
else el.classList.remove('hidden');
}
return { canShareContact, scanText, sanitizeText: sanitizeTextJs, renderBanner, setInlineNotice };
})();
// ----------------------------
// v0.0295: Tradie mini availability calendar (profile block)
// ----------------------------
window.ATHAvailability = window.ATHAvailability || (function () {
const KEY_PREFIX = 'athTradieAvailability:';
function keyFor(tradieId) {
return `${KEY_PREFIX}${String(tradieId || 'unknown')}`;
}
function read(tradieId) {
const v = window.ATHStore?.get(keyFor(tradieId), null);
if (v && typeof v === 'object') {
const overrides = (v.overrides && typeof v.overrides === 'object') ? v.overrides : {};
return { overrides };
}
return { overrides: {} };
}
function write(tradieId, data) {
const overrides = (data && typeof data === 'object' && data.overrides && typeof data.overrides === 'object') ? data.overrides : {};
window.ATHStore?.set(keyFor(tradieId), { overrides });
}
function pad2(n) {
return String(n).padStart(2, '0');
}
function toYmd(d) {
const dt = new Date(d);
const y = dt.getFullYear();
const m = pad2(dt.getMonth() + 1);
const day = pad2(dt.getDate());
return `${y}-${m}-${day}`;
}
function defaultAvailableForDate(d) {
// Default: available Mon–Sat, unavailable Sun.
const wd = new Date(d).getDay(); // 0=Sun
return wd !== 0;
}
function isAvailable(tradieId, d, store) {
const s = store || read(tradieId);
const ymd = toYmd(d);
if (s.overrides && Object.prototype.hasOwnProperty.call(s.overrides, ymd)) {
return s.overrides[ymd] === 'available';
}
return defaultAvailableForDate(d);
}
function toggle(tradieId, d, store) {
const s = store || read(tradieId);
const ymd = toYmd(d);
const curr = isAvailable(tradieId, d, s);
const next = !curr;
const def = defaultAvailableForDate(d);
// Only store an override when needed.
if (next === def) {
try { delete s.overrides[ymd]; } catch { }
} else {
s.overrides[ymd] = next ? 'available' : 'unavailable';
}
write(tradieId, s);
return s;
}
function monthLabel(monthDate) {
const d = new Date(monthDate);
const fmt = d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
return fmt;
}
function startOfMonth(d) {
const dt = new Date(d);
dt.setDate(1);
dt.setHours(0, 0, 0, 0);
return dt;
}
function addMonths(d, n) {
const dt = new Date(d);
dt.setMonth(dt.getMonth() + Number(n || 0));
return startOfMonth(dt);
}
function render(el, opts, monthDate) {
if (!el) return;
const tradieId = opts?.tradieId;
const editable = !!opts?.editable;
const m0 = startOfMonth(monthDate || new Date());
const store = read(tradieId);
const daysInMonth = new Date(m0.getFullYear(), m0.getMonth() + 1, 0).getDate();
const firstDow = new Date(m0.getFullYear(), m0.getMonth(), 1).getDay();
// Monday-first offset (0..6)
const offset = (firstDow + 6) % 7;
const weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const header = `
`;
let grid = '';
for (const w of weekday) {
grid += `
${w}
`;
}
const totalCells = 42; // 6 weeks
for (let i = 0; i < totalCells; i++) {
const dayNum = i - offset + 1;
if (dayNum < 1 || dayNum > daysInMonth) {
grid += '
';
continue;
}
const date = new Date(m0.getFullYear(), m0.getMonth(), dayNum);
const avail = isAvailable(tradieId, date, store);
const today = (toYmd(date) === toYmd(new Date()));
const base = 'h-8 rounded-lg border text-xs flex items-center justify-center relative select-none';
const stateCls = avail
? 'bg-teal-50 border-teal-200 text-teal-900'
: 'bg-gray-50 border-gray-200 text-gray-600';
const todayRing = today ? ' ring-2 ring-teal-300' : '';
const cursor = editable ? ' cursor-pointer hover:opacity-90' : '';
grid += `
${dayNum}
`;
}
grid += '
';
const hint = editable
? 'Tip: click days to toggle availability.
'
: 'Green days mean this tradie is generally available.
';
el.innerHTML = `
${header}
${grid}
${hint}
`;
// Wire nav
const prev = el.querySelector('[data-ath-cal="prev"]');
const next = el.querySelector('[data-ath-cal="next"]');
if (prev) prev.onclick = () => {
el.dataset.athCalMonth = String(addMonths(m0, -1).toISOString());
render(el, opts, addMonths(m0, -1));
};
if (next) next.onclick = () => {
el.dataset.athCalMonth = String(addMonths(m0, 1).toISOString());
render(el, opts, addMonths(m0, 1));
};
// Wire toggles (editable only)
if (editable) {
el.querySelectorAll('[data-ath-cal-day]')?.forEach((cell) => {
cell.addEventListener('click', () => {
const dn = Number(cell.getAttribute('data-ath-cal-day'));
if (!dn) return;
toggle(tradieId, new Date(m0.getFullYear(), m0.getMonth(), dn));
render(el, opts, m0);
});
});
}
if (typeof feather !== 'undefined') feather.replace();
}
function mountMiniCalendar(el, opts) {
if (!el) return;
const initial = (() => {
try {
const raw = el.dataset.athCalMonth;
if (raw) return startOfMonth(new Date(raw));
} catch { }
return startOfMonth(new Date());
})();
render(el, opts || {}, initial);
}
return { mountMiniCalendar, read, write, isAvailable };
})();
// ----------------------------
// v0.0297: Customer booked-jobs calendar (profile block)
// ----------------------------
// Calendar mental model: show commitments (accepted jobs), not all posted jobs.
// - Customer view: jobs with status agreed or in_progress.
// - Booking date (for now): agreedAt (fallback inProgressAt, then postedAt).
// - Clicking a booked day opens ATHJobDetails modal.
window.ATHCustomerBookings = window.ATHCustomerBookings || (function () {
function pad2(n) { return String(n).padStart(2, '0'); }
function toYmd(d) {
const dt = new Date(d);
return `${dt.getFullYear()}-${pad2(dt.getMonth() + 1)}-${pad2(dt.getDate())}`;
}
function startOfMonth(d) {
const dt = new Date(d);
dt.setDate(1);
dt.setHours(0, 0, 0, 0);
return dt;
}
function addMonths(d, n) {
const dt = new Date(d);
dt.setMonth(dt.getMonth() + Number(n || 0));
return startOfMonth(dt);
}
function monthLabel(monthDate) {
const d = new Date(monthDate);
return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
}
function bookingDateForJob(job) {
const status = String(job?.status || '').toLowerCase();
const raw = (status === 'agreed')
? (job?.agreedAt || job?.inProgressAt || job?.postedAt)
: (status === 'in_progress')
? (job?.inProgressAt || job?.agreedAt || job?.postedAt)
: null;
if (!raw) return null;
const dt = new Date(raw);
if (isNaN(dt.getTime())) return null;
return dt;
}
function jobsForDay(customerId, dayDate) {
const cid = String(customerId || '');
const all = window.ATHJobs?.getAllJobs?.() || [];
const ymd = toYmd(dayDate);
return all.filter((j) => {
if (String(j?.customerId || '') !== cid) return false;
const st = String(j?.status || '').toLowerCase();
if (!(st === 'agreed' || st === 'in_progress')) return false;
const bd = bookingDateForJob(j);
if (!bd) return false;
return toYmd(bd) === ymd;
});
}
function render(el, opts, monthDate) {
if (!el) return;
const customerId = opts?.customerId;
const m0 = startOfMonth(monthDate || new Date());
const daysInMonth = new Date(m0.getFullYear(), m0.getMonth() + 1, 0).getDate();
const firstDow = new Date(m0.getFullYear(), m0.getMonth(), 1).getDay();
const offset = (firstDow + 6) % 7; // Monday-first
const weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const header = `
`;
let grid = '';
for (const w of weekday) {
grid += `
${w}
`;
}
const totalCells = 42;
for (let i = 0; i < totalCells; i++) {
const dayNum = i - offset + 1;
if (dayNum < 1 || dayNum > daysInMonth) {
grid += '
';
continue;
}
const date = new Date(m0.getFullYear(), m0.getMonth(), dayNum);
const today = (toYmd(date) === toYmd(new Date()));
const jobs = jobsForDay(customerId, date);
const has = jobs.length > 0;
const base = 'h-8 rounded-lg border text-xs flex items-center justify-center relative select-none';
const stateCls = has
? 'bg-teal-50 border-teal-200 text-teal-900'
: 'bg-gray-50 border-gray-200 text-gray-600';
const todayRing = today ? ' ring-2 ring-teal-300' : '';
const cursor = has ? ' cursor-pointer hover:opacity-90' : '';
const badge = has && jobs.length > 1
? `
${jobs.length}`
: '';
const ids = has ? jobs.map(j => String(j.id)).join(',') : '';
grid += `
${dayNum}
${badge}
`;
}
grid += '
';
const hint = 'Shows jobs you have agreed with a tradie (or currently in progress).
';
el.innerHTML = `
${header}
${grid}
${hint}
`;
const prev = el.querySelector('[data-ath-book="prev"]');
const next = el.querySelector('[data-ath-book="next"]');
if (prev) prev.onclick = () => {
el.dataset.athBookMonth = String(addMonths(m0, -1).toISOString());
render(el, opts, addMonths(m0, -1));
};
if (next) next.onclick = () => {
el.dataset.athBookMonth = String(addMonths(m0, 1).toISOString());
render(el, opts, addMonths(m0, 1));
};
el.querySelectorAll('[data-ath-book-day]')?.forEach((cell) => {
cell.addEventListener('click', () => {
const ids = String(cell.getAttribute('data-ath-book-ids') || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
if (!ids.length) return;
window.ATHJobDetails?.open?.(ids[0]);
});
});
if (typeof feather !== 'undefined') feather.replace();
}
function mountMiniCalendar(el, opts) {
if (!el) return;
const initial = (() => {
try {
const raw = el.dataset.athBookMonth;
if (raw) return startOfMonth(new Date(raw));
} catch { }
return startOfMonth(new Date());
})();
render(el, opts || {}, initial);
}
return { mountMiniCalendar };
})();
document.addEventListener('DOMContentLoaded', () => {
if (typeof feather !== 'undefined') feather.replace();
initAuthUI();
// Allow deep-link: index.html#signin
try {
if (String(window.location.hash || '').toLowerCase() === '#signin') openAuthModal('signin');
} catch { }
// initMobileMenu(); // Removed v0.0298
initMessagesPage();
initMyProfilePage();
initFilterDrawer();
initIntegrityBanners();
});
// ----------------------------
// Batch N3: Page banners
// ----------------------------
function initIntegrityBanners() {
window.ATHIntegrity?.renderBanner(document.getElementById('athIntegrityBannerMountJobs'));
// Optional mounts on other pages
window.ATHIntegrity?.renderBanner(document.getElementById('athIntegrityBannerMountProfileTradie'));
window.ATHIntegrity?.renderBanner(document.getElementById('athIntegrityBannerMountProfileCustomer'));
}
// ----------------------------
// Batch M: Mobile filter drawer
// ----------------------------
function initFilterDrawer() {
const drawer = document.getElementById('athFiltersDrawer');
const openBtn = document.getElementById('athOpenFilters');
const backdrop = document.getElementById('athFiltersBackdrop');
const closeBtn = document.getElementById('athCloseFilters');
if (!drawer || (!openBtn && !backdrop)) return; // only on pages that include the drawer
const open = () => {
drawer.classList.add('ath-open');
if (backdrop) backdrop.classList.remove('hidden');
document.body.classList.add('ath-body-lock');
};
const close = () => {
drawer.classList.remove('ath-open');
if (backdrop) backdrop.classList.add('hidden');
document.body.classList.remove('ath-body-lock');
};
if (openBtn) openBtn.addEventListener('click', open);
if (closeBtn) closeBtn.addEventListener('click', close);
if (backdrop) backdrop.addEventListener('click', close);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') close();
});
// If user resizes to desktop, ensure drawer state doesn't trap scroll.
const mq = window.matchMedia('(min-width: 1024px)');
const onMq = () => {
if (mq.matches) close();
};
if (mq.addEventListener) mq.addEventListener('change', onMq);
else if (mq.addListener) mq.addListener(onMq);
}
// ----------------------------
// Current User (localStorage)
// ----------------------------
function getCurrentUser() {
try {
const raw = localStorage.getItem('athCurrentUser');
if (raw) return JSON.parse(raw);
} catch (e) { }
return (window.CURRENT_USER_DEFAULT && typeof window.CURRENT_USER_DEFAULT === 'object')
? JSON.parse(JSON.stringify(window.CURRENT_USER_DEFAULT))
: { id: 'me', role: 'dual', displayName: 'Me', avatar: '', location: { suburb: '', state: '', postcode: '' }, contact: { phone: '', email: '' }, privacy: { showLocation: true, addressRule: 'afterAccepted' }, verification: { verified: false, abnFull: '', licenseFull: '' } };
}
function setCurrentUser(user) {
localStorage.setItem('athCurrentUser', JSON.stringify(user));
}
// Merge helper (keeps nested objects intact). Useful for future auth/backends.
function saveCurrentUser(patch) {
const base = getCurrentUser();
const merged = {
...base,
...patch,
location: { ...(base.location || {}), ...((patch || {}).location || {}) },
contact: { ...(base.contact || {}), ...((patch || {}).contact || {}) },
privacy: { ...(base.privacy || {}), ...((patch || {}).privacy || {}) },
verification: { ...(base.verification || {}), ...((patch || {}).verification || {}) },
auth: { ...(base.auth || {}), ...((patch || {}).auth || {}) },
};
setCurrentUser(merged);
return merged;
}
function maskSensitiveKeepLast4(value) {
const v = String(value || '').replace(/\s+/g, '');
if (!v) return '—';
const last4 = v.slice(-4);
// safest: only reveal last 4
return `**** **** ${last4}`;
}
// Global initialization
document.addEventListener('DOMContentLoaded', () => {
// Call other existing inits if they exist and aren't called elsewhere
if (typeof initAuthUI === 'function') initAuthUI();
if (typeof initMessagesPage === 'function') initMessagesPage();
if (typeof initProfilePage === 'function') initProfilePage();
});
// ----------------------------
// v0.026: Auth UI (nav button + modal)
// ----------------------------
function initAuthUI() {
// Safe guard for pages that don't load fully or run twice
try {
ensureAuthNavButtons();
ensureAuthModal();
syncAuthNavState();
} catch (e) {
// keep page usable even if auth UI fails
}
}
function ensureAuthNavButtons() {
// "My Profile" appears in the top nav as an anchor with responsive display
// classes (e.g. "hidden md:inline-flex"). Tailwind's responsive display can
// override the "hidden" class at larger breakpoints, so we force-hide via
// inline style when logged out.
const navEl = document.querySelector('nav');
const desktopProfileLink = navEl
? Array.from(navEl.querySelectorAll('a[href^="my-profile.html"]')).find(a => !a.closest('#mobileMenu'))
: null;
const mobileProfileLink = document.querySelector('#mobileMenu a[href^="my-profile.html"]');
const forceProfileVisibility = (a, visible) => {
if (!a) return;
a.style.display = visible ? '' : 'none';
a.setAttribute('aria-hidden', visible ? 'false' : 'true');
a.tabIndex = visible ? 0 : -1;
};
// v0.026b: default-hide profile links until authenticated (avoids flash before syncAuthNavState runs).
try {
const session = window.ATHAuth?.getSession?.();
const loggedIn = !!session?.userId;
forceProfileVisibility(desktopProfileLink, loggedIn);
} catch { }
// If logged out, block My Profile navigation and open Sign in instead.
const guardProfileLink = (a) => {
if (!a || a.getAttribute('data-auth-guarded') === '1') return;
a.setAttribute('data-auth-guarded', '1');
a.addEventListener('click', (e) => {
const session = window.ATHAuth?.getSession?.();
if (session?.userId) return; // allow
e.preventDefault();
openAuthModal('signin');
});
};
guardProfileLink(desktopProfileLink);
// v0.027: If logged out, block Messages navigation (requires signed-in account)
const allMessagesLinks = Array.from(document.querySelectorAll('a[href^="messages.html"]'));
const guardMessagesLink = (a) => {
if (!a || a.getAttribute('data-auth-guarded-messages') === '1') return;
a.setAttribute('data-auth-guarded-messages', '1');
a.addEventListener('click', (e) => {
const session = window.ATHAuth?.getSession?.();
if (session?.userId) return;
e.preventDefault();
openAuthModal('signin');
});
};
allMessagesLinks.forEach(guardMessagesLink);
if (desktopProfileLink && !document.getElementById('athAuthNavBtn')) {
const btn = document.createElement('button');
btn.id = 'athAuthNavBtn';
btn.type = 'button';
btn.className = 'ml-3 px-3 py-2 rounded-lg text-sm font-semibold bg-gray-100 hover:bg-gray-200 transition';
btn.addEventListener('click', onAuthNavClick);
desktopProfileLink.insertAdjacentElement('afterend', btn);
}
// authNavBtn for desktop
if (desktopProfileLink && !document.getElementById('athAuthNavBtn')) {
const btn = document.createElement('button');
btn.id = 'athAuthNavBtn';
btn.type = 'button';
btn.className = 'ml-3 px-3 py-2 rounded-lg text-sm font-semibold bg-gray-100 hover:bg-gray-200 transition';
btn.addEventListener('click', onAuthNavClick);
desktopProfileLink.insertAdjacentElement('afterend', btn);
}
}
// Simple route guard for pages that should require auth.
function requireAuthOnPage(opts) {
const cfg = opts || {};
const onMissing = cfg.onMissing || 'overlay'; // overlay | redirect
const current = (window.location && window.location.pathname) ? window.location.pathname.split('/').pop() : '';
const session = window.ATHAuth?.getSession?.();
if (session?.userId) return true;
// Ensure modal exists then prompt
ensureAuthModal();
openAuthModal('signin');
if (onMissing === 'redirect') {
// Send them home; keep a hint so we can reopen the modal.
try { window.location.href = `index.html#signin`; } catch { }
return false;
}
// Overlay the page content
const main = document.querySelector('main');
if (main && !document.getElementById('athAuthGate')) {
main.classList.add('blur-sm', 'pointer-events-none', 'select-none');
const gate = document.createElement('div');
gate.id = 'athAuthGate';
gate.className = 'fixed inset-0 z-[90] flex items-center justify-center p-4';
gate.innerHTML = `
You need to be signed in to view your profile.
Prototype note: accounts are stored locally in your browser.
`;
document.body.appendChild(gate);
gate.querySelector('#athAuthGateSignIn')?.addEventListener('click', () => openAuthModal('signin'));
// Un-gate once the user signs in.
const onAuth = (ev) => {
const s = ev?.detail?.session || window.ATHAuth?.getSession?.();
if (!s?.userId) return;
try { document.getElementById('athAuthGate')?.remove(); } catch { }
try { main.classList.remove('blur-sm', 'pointer-events-none', 'select-none'); } catch { }
try { window.removeEventListener('ath:authchange', onAuth); } catch { }
// If this page has its own init relying on auth, reload to keep it simple.
try { window.location.reload(); } catch { }
};
window.addEventListener('ath:authchange', onAuth);
}
return false;
}
function ensureAuthModal() {
if (document.getElementById('athAuthModal')) return;
const wrap = document.createElement('div');
wrap.id = 'athAuthModal';
wrap.className = 'fixed inset-0 z-[100] hidden';
wrap.innerHTML = `
Prototype auth (local only). Email verification coming soon.
Note: accounts are stored in your browser only. Other users won't see them.
`;
document.body.appendChild(wrap);
if (typeof feather !== 'undefined') feather.replace();
// Wire close
wrap.querySelectorAll('[data-auth-close]').forEach((el) => {
el.addEventListener('click', () => closeAuthModal());
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAuthModal();
});
// Wire tabs
wrap.querySelectorAll('[data-auth-tab]').forEach((btn) => {
btn.addEventListener('click', () => setAuthTab(String(btn.getAttribute('data-auth-tab') || 'signin')));
});
// Wire submit
const form = wrap.querySelector('#athAuthForm');
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
await handleAuthSubmit();
});
}
// Init Google Sign-In (check immediately, and retry if needed)
tryInitGoogleAuth(wrap);
}
function tryInitGoogleAuth(wrap) {
if (window.google?.accounts?.id) {
window.google.accounts.id.initialize({
client_id: '470923800576-9326rggc6nsrukjbgdtvgbcckof6en09.apps.googleusercontent.com',
callback: handleGoogleCredentialResponse,
auto_select: false,
cancel_on_tap_outside: true
});
const googleBtn = wrap ? wrap.querySelector('#googleSignInBtn') : document.getElementById('googleSignInBtn');
if (googleBtn) {
window.google.accounts.id.renderButton(
googleBtn,
{ theme: 'outline', size: 'large', width: 250 }
);
}
} else {
// Retry once after a short delay in case script is racing
setTimeout(() => {
if (window.google?.accounts?.id && typeof tryInitGoogleAuth === 'function') {
tryInitGoogleAuth(wrap);
}
}, 500);
}
}
// Make handler global for Google callback
window.handleGoogleCredentialResponse = handleGoogleCredentialResponse;
// JWT Decode Helper
function parseJwt(token) {
try {
var base64Url = token.split('.')[1];
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
var jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
} catch (e) {
return null;
}
}
async function handleGoogleCredentialResponse(response) {
const data = parseJwt(response.credential);
if (data) {
const googleUser = {
email: data.email,
name: data.name,
picture: data.picture,
sub: data.sub
};
const res = await window.ATHAuth?.signInWithGoogle?.(googleUser);
if (res?.ok) {
syncAuthNavState();
closeAuthModal();
} else {
setAuthError(res?.error || 'Google sign in failed.');
}
} else {
setAuthError('Failed to process Google login.');
}
}
function onAuthNavClick() {
const session = window.ATHAuth?.getSession?.();
if (session?.userId) {
window.ATHAuth?.signOut?.();
syncAuthNavState();
// v0.028: If the user logs out while on messages, immediately redirect to Home
// to avoid leaving a partly-interactive page behind.
try {
const p = String(window.location?.pathname || '');
if (p.endsWith('/messages.html') || p.endsWith('messages.html')) {
window.location.href = 'index.html';
return;
}
} catch { }
return;
}
openAuthModal('signin');
}
function syncAuthNavState() {
const session = window.ATHAuth?.getSession?.();
const email = session?.email ? String(session.email) : '';
const desktop = document.getElementById('athAuthNavBtn');
const mobile = document.getElementById('athAuthNavBtnMobile');
// v0.026c: Hide "My Profile" until authenticated.
// NOTE: the nav link uses responsive display classes (e.g. "hidden md:inline-flex"),
// which can override the "hidden" class at larger breakpoints. Force-hide via inline style.
const navEl = document.querySelector('nav');
const desktopProfileLink = navEl
? Array.from(navEl.querySelectorAll('a[href^="my-profile.html"]')).find(a => !a.closest('#mobileMenu'))
: null;
const loggedIn = !!session?.userId;
const forceProfileVisibility = (a, visible) => {
if (!a) return;
a.style.display = visible ? '' : 'none';
a.setAttribute('aria-hidden', visible ? 'false' : 'true');
a.tabIndex = visible ? 0 : -1;
};
forceProfileVisibility(desktopProfileLink, loggedIn);
const label = session?.userId
? (email ? `Logout (${email})` : 'Logout')
: 'Sign in';
if (desktop) desktop.textContent = label;
}
function openAuthModal(tab) {
ensureAuthModal();
setAuthTab(tab || 'signin');
const wrap = document.getElementById('athAuthModal');
if (!wrap) return;
wrap.classList.remove('hidden');
// reset error
setAuthError('');
// focus email
setTimeout(() => {
try { document.getElementById('athAuthEmail')?.focus(); } catch { }
}, 50);
}
function closeAuthModal() {
const wrap = document.getElementById('athAuthModal');
if (!wrap) return;
wrap.classList.add('hidden');
}
function setAuthError(msg) {
const el = document.getElementById('athAuthError');
if (!el) return;
const m = String(msg || '').trim();
if (!m) {
el.textContent = '';
el.classList.add('hidden');
return;
}
el.textContent = m;
el.classList.remove('hidden');
}
function setAuthTab(tab) {
const t = (tab === 'signup') ? 'signup' : 'signin';
const wrap = document.getElementById('athAuthModal');
if (!wrap) return;
const title = wrap.querySelector('h2');
if (title) title.textContent = (t === 'signup') ? 'Sign up' : 'Sign in';
const btnSignin = wrap.querySelector('[data-auth-tab="signin"]');
const btnSignup = wrap.querySelector('[data-auth-tab="signup"]');
const confirmWrap = document.getElementById('athAuthConfirmWrap');
const pass = document.getElementById('athAuthPassword');
const pass2 = document.getElementById('athAuthPassword2');
const setBtn = (btn, active) => {
if (!btn) return;
if (active) {
btn.className = 'flex-1 px-3 py-2 rounded-lg text-sm font-semibold bg-gray-900 text-white';
} else {
btn.className = 'flex-1 px-3 py-2 rounded-lg text-sm font-semibold bg-gray-100 hover:bg-gray-200';
}
};
setBtn(btnSignin, t === 'signin');
setBtn(btnSignup, t === 'signup');
if (confirmWrap) confirmWrap.classList.toggle('hidden', t !== 'signup');
if (pass) pass.autocomplete = (t === 'signup') ? 'new-password' : 'current-password';
if (pass2) pass2.value = '';
wrap.setAttribute('data-auth-mode', t);
setAuthError('');
}
async function handleAuthSubmit() {
const wrap = document.getElementById('athAuthModal');
if (!wrap) return;
const mode = String(wrap.getAttribute('data-auth-mode') || 'signin');
const email = document.getElementById('athAuthEmail')?.value || '';
const password = document.getElementById('athAuthPassword')?.value || '';
const password2 = document.getElementById('athAuthPassword2')?.value || '';
if (mode === 'signup') {
if (String(password2) !== String(password)) {
setAuthError('Passwords do not match.');
return;
}
const res = await window.ATHAuth?.signUp?.(email, password);
if (!res?.ok) {
setAuthError(res?.error || 'Sign up failed.');
return;
}
} else {
const res = await window.ATHAuth?.signIn?.(email, password);
if (!res?.ok) {
setAuthError(res?.error || 'Sign in failed.');
return;
}
}
syncAuthNavState();
closeAuthModal();
}
// ----------------------------
// Messages Page
// ----------------------------
function initMessagesPage() {
const list = document.getElementById('conversationsList');
const emptyEl = document.getElementById('conversationsEmpty');
const searchInput = document.getElementById('conversationsSearch');
const chat = document.getElementById('chatScroll');
const input = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendButton');
const chatName = document.getElementById('chatName');
const chatMeta = document.getElementById('chatMeta');
const chatStatus = document.getElementById('chatStatus');
const chatAvatar = document.getElementById('chatAvatar');
const chatOnlineDot = document.getElementById('chatOnlineDot');
const unreadLabel = document.getElementById('unreadCountLabel');
// v0.027a: UI containment + empty-state handling
const chatPane = document.getElementById('athChatPane');
const emptyPane = document.getElementById('athMessagesEmptyState');
const emptyTitle = document.getElementById('athMessagesEmptyTitle');
const emptyBody = document.getElementById('athMessagesEmptyBody');
const jobDetailsCard = document.getElementById('jobDetailsCard');
const contextMount = document.getElementById('athMessagesContextPanel');
// Mobile View Logic
const msgContainer = document.getElementById('athMessagesContainer');
const backBtn = document.getElementById('chatBackBtn');
const activateMobileList = () => {
if (msgContainer) {
msgContainer.classList.add('ath-mobile-list-view');
msgContainer.classList.remove('ath-mobile-chat-view');
}
};
const activateMobileChat = () => {
if (msgContainer) {
msgContainer.classList.remove('ath-mobile-list-view');
msgContainer.classList.add('ath-mobile-chat-view');
}
};
if (backBtn) {
backBtn.addEventListener('click', (e) => {
e.preventDefault();
activateMobileList();
});
}
const showEmpty = (title, body) => {
if (chatPane) chatPane.style.display = 'none';
if (jobDetailsCard) jobDetailsCard.style.display = 'none';
if (emptyTitle) emptyTitle.textContent = title || 'No conversations yet';
if (emptyBody) emptyBody.textContent = body || 'Start a conversation from a job listing.';
if (emptyPane) emptyPane.classList.remove('hidden');
};
const showChat = () => {
if (emptyPane) emptyPane.classList.add('hidden');
if (chatPane) chatPane.style.display = '';
if (jobDetailsCard) jobDetailsCard.style.display = '';
};
// Not on messages page
if (!list || !chat || !chatName || !chatMeta || !chatStatus || !chatAvatar) return;
// v0.027: Require auth for messages + scope conversations per account
const session = window.ATHAuth?.getSession?.();
if (!session?.userId) {
try { openAuthModal('signin'); } catch { }
// Minimal UI: hide chat panes + show sign-in empty state
if (input) {
input.disabled = true;
input.classList.add('opacity-50', 'cursor-not-allowed');
}
if (sendBtn) {
sendBtn.disabled = true;
sendBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
if (unreadLabel) unreadLabel.textContent = '—';
showEmpty('Sign in to view messages', 'Sign in to see your conversations and send messages.');
if (list) list.innerHTML = '';
if (emptyEl) {
emptyEl.textContent = 'Sign in to view your messages.';
emptyEl.classList.remove('hidden');
}
return;
}
const uid = session.userId;
const STORE_KEY = `athConversations:${uid}`;
const lastActiveKey = `lastActiveConversation:${uid}`;
// File attachment handlers
const imageInput = document.getElementById('imageInput');
const fileInput = document.getElementById('fileInput');
if (imageInput) {
imageInput.addEventListener('change', (e) => handleFileUpload(e, 'image'));
}
if (fileInput) {
fileInput.addEventListener('change', (e) => handleFileUpload(e, 'file'));
}
function handleFileUpload(event, type) {
const file = event.target.files?.[0];
if (!file) return;
// Size limit: 5MB
if (file.size > 5 * 1024 * 1024) {
alert('File size must be less than 5MB');
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const id = getConversationIdFromUrl() || getDefaultConversationId();
if (!id || !DATA[id]) return;
const attachment = {
type: type,
name: file.name,
size: file.size,
data: e.target.result // base64
};
const now = Date.now();
const newMessage = {
from: 'me',
time: 'Now',
ts: now,
text: type === 'image' ? '📷 Photo' : `📎 ${file.name}`,
attachment: attachment,
status: 'sent'
};
DATA[id].messages.push(newMessage);
window.ATHStore.set(STORE_KEY, DATA);
event.target.value = ''; // Reset input
renderMessages(DATA[id]);
renderConversationList(searchInput?.value || '');
setActiveRow(id);
// Simulate status updates
setTimeout(() => {
newMessage.status = 'delivered';
window.ATHStore.set(STORE_KEY, DATA);
renderMessages(DATA[id]);
setTimeout(() => {
newMessage.status = 'read';
window.ATHStore.set(STORE_KEY, DATA);
renderMessages(DATA[id]);
}, 1500);
}, 500);
};
reader.readAsDataURL(file);
}
// Emoji Picker
const emojiPickerBtn = document.getElementById('emojiPickerBtn');
let isTogglingPicker = false;
if (emojiPickerBtn && input) {
emojiPickerBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
isTogglingPicker = true;
setTimeout(() => { isTogglingPicker = false; }, 300);
showEmojiPicker();
});
}
function showEmojiPicker() {
const container = document.getElementById('emojiPickerContainer');
if (!container) {
console.error('Emoji picker container not found');
return;
}
// Toggle - remove existing picker
const existingPicker = document.getElementById('emojiPickerPopup');
if (existingPicker) {
existingPicker.remove();
return;
}
const emojis = {
'Smileys': ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳'],
'Gestures': ['👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '👇', '☝️', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾'],
'Hearts': ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝'],
'Symbols': ['✅', '❌', '⭐', '🌟', '💫', '✨', '⚡', '🔥', '💥', '💯', '🎉', '🎊', '🎈', '🎁', '🏆', '🥇', '🥈', '🥉']
};
const picker = document.createElement('div');
picker.id = 'emojiPickerPopup';
picker.className = 'bg-white border-2 border-gray-300 rounded-lg shadow-2xl p-4 w-80 max-h-96 overflow-y-auto z-50';
picker.style.position = 'relative';
picker.style.zIndex = '9999';
let pickerHTML = '';
for (const [category, emojiList] of Object.entries(emojis)) {
pickerHTML += `
${category}
${emojiList.map(emoji => `
`).join('')}
`;
}
pickerHTML += '
';
picker.innerHTML = pickerHTML;
container.appendChild(picker);
console.log('Emoji picker created and appended');
// Close picker on Escape key (accessibility)
const escapeHandler = (e) => {
if (e.key === 'Escape') {
picker.remove();
document.removeEventListener('keydown', escapeHandler);
document.removeEventListener('click', closeHandler);
input.focus(); // Return focus to message input
}
};
document.addEventListener('keydown', escapeHandler);
// Close picker on click outside - with proper button detection and toggle prevention
setTimeout(() => {
const closeHandler = (e) => {
// Don't close if we're currently toggling
if (isTogglingPicker) return;
// Check if click is inside picker OR inside the button (including its children like SVG)
if (!picker.contains(e.target) && !emojiPickerBtn.contains(e.target)) {
picker.remove();
document.removeEventListener('click', closeHandler);
document.removeEventListener('keydown', escapeHandler);
}
};
document.addEventListener('click', closeHandler);
}, 300); // Delay to ensure toggle flag is set
}
window.insertEmoji = function(emoji) {
if (!input) return;
const cursorPos = input.selectionStart || 0;
const textBefore = input.value.substring(0, cursorPos);
const textAfter = input.value.substring(cursorPos);
input.value = textBefore + emoji + textAfter;
input.focus();
input.setSelectionRange(cursorPos + emoji.length, cursorPos + emoji.length);
// Close picker
const picker = document.getElementById('emojiPickerPopup');
if (picker) picker.remove();
};
// Message menu toggle
window.toggleMessageMenu = function(ts) {
const menu = document.getElementById(`menu-${ts}`);
if (!menu) return;
// Close all other menus
document.querySelectorAll('[id^="menu-"]').forEach(m => {
if (m.id !== `menu-${ts}`) m.classList.add('hidden');
});
menu.classList.toggle('hidden');
// Close menu on click outside OR Escape key
if (!menu.classList.contains('hidden')) {
// Escape key handler
const escapeHandler = (e) => {
if (e.key === 'Escape') {
menu.classList.add('hidden');
document.removeEventListener('keydown', escapeHandler);
document.removeEventListener('click', closeHandler);
}
};
document.addEventListener('keydown', escapeHandler);
setTimeout(() => {
const closeHandler = (e) => {
if (!menu.contains(e.target)) {
menu.classList.add('hidden');
document.removeEventListener('click', closeHandler);
document.removeEventListener('keydown', escapeHandler);
}
};
document.addEventListener('click', closeHandler);
}, 100);
}
};
// Edit message
window.startEditMessage = function(ts) {
const id = getConversationIdFromUrl() || getDefaultConversationId();
if (!id || !DATA[id]) return;
const message = DATA[id].messages.find(m => m.ts === Number(ts));
if (!message || message.deleted) return;
const messageEl = document.getElementById(`msg-${ts}`);
if (!messageEl) return;
// Close menu
const menu = document.getElementById(`menu-${ts}`);
if (menu) menu.classList.add('hidden');
// Show edit input
const bubbleEl = messageEl.querySelector('.bg-teal-600');
if (!bubbleEl) return;
const originalText = message.text;
const escapedText = String(originalText).replace(/"/g, '"');
bubbleEl.innerHTML = `
Press Enter to save, Esc to cancel
`;
const editInput = document.getElementById(`edit-input-${ts}`);
if (editInput) {
editInput.focus();
editInput.select();
}
};
window.saveEdit = function(ts) {
const id = getConversationIdFromUrl() || getDefaultConversationId();
if (!id || !DATA[id]) return;
const editInput = document.getElementById(`edit-input-${ts}`);
if (!editInput) return;
const newText = editInput.value.trim();
if (!newText) {
alert('Message cannot be empty');
return;
}
const message = DATA[id].messages.find(m => m.ts === Number(ts));
if (message) {
message.text = newText;
message.editedAt = Date.now();
window.ATHStore.set(STORE_KEY, DATA);
renderMessages(DATA[id]);
renderConversationList(searchInput?.value || '');
}
};
window.cancelEdit = function(ts) {
const id = getConversationIdFromUrl() || getDefaultConversationId();
if (!id || !DATA[id]) return;
renderMessages(DATA[id]);
};
// Delete message
window.deleteMessage = function(ts) {
if (!confirm('Delete this message?')) return;
const id = getConversationIdFromUrl() || getDefaultConversationId();
if (!id || !DATA[id]) return;
const message = DATA[id].messages.find(m => m.ts === Number(ts));
if (message) {
message.deleted = true;
message.text = '';
message.attachment = null;
window.ATHStore.set(STORE_KEY, DATA);
renderMessages(DATA[id]);
renderConversationList(searchInput?.value || '');
}
// Close menu
const menu = document.getElementById(`menu-${ts}`);
if (menu) menu.classList.add('hidden');
};
// Batch N3: integrity banner + disable off-platform call/video actions
window.ATHIntegrity?.renderBanner(document.getElementById('athIntegrityBannerMountMessages'));
document.querySelectorAll('[aria-label="Call"], [aria-label="Video call"]')?.forEach((btn) => {
btn.disabled = true;
btn.classList.add('opacity-50', 'cursor-not-allowed');
btn.title = 'Locked until payment is confirmed';
});
let DATA = window.ATHStore.get(STORE_KEY, {});
if (!DATA || typeof DATA !== 'object') DATA = {};
// Seed conversations from window.CONVERSATIONS if empty (first load for this user)
if (Object.keys(DATA).length === 0 && window.CONVERSATIONS) {
DATA = JSON.parse(JSON.stringify(window.CONVERSATIONS)); // Deep clone
window.ATHStore.set(STORE_KEY, DATA);
}
const safeText = (t) => (window.ATHIntegrity ? window.ATHIntegrity.sanitizeText(t).text : String(t || ''));
const sanitizeNoteEl = document.getElementById('athMessageSanitizeNote');
// ----------------------------
// Helpers (messages)
// ----------------------------
const getLastMessage = (id) => {
const msgs = DATA?.[id]?.messages || [];
return msgs.length ? msgs[msgs.length - 1] : null;
};
const getLastTs = (id) => {
const m = getLastMessage(id);
return m?.ts || 0;
};
const getReadTsKey = (id) => `readts:${uid}:${id}`;
const getReadTs = (id) => Number(localStorage.getItem(getReadTsKey(id)) || 0);
const getUnreadCount = (id) => {
const last = getLastMessage(id);
if (!last) return 0;
// Only treat as unread if the latest message is from them and we haven't read up to that timestamp.
if (last.from !== 'them') return 0;
return getLastTs(id) > getReadTs(id) ? 1 : 0;
};
const getTotalUnread = () => Object.keys(DATA).reduce((acc, id) => acc + getUnreadCount(id), 0);
function updateUnreadUI() {
if (!unreadLabel) return;
const unread = getTotalUnread();
unreadLabel.textContent = unread ? `${unread} unread` : 'All read';
}
function markRead(id) {
const ts = getLastTs(id);
localStorage.setItem(getReadTsKey(id), String(ts));
}
function setActiveRow(id) {
list.querySelectorAll('.conversation-item').forEach((row) => {
const isActive = row.dataset.conversation === id;
row.classList.toggle('bg-teal-50', isActive);
row.classList.toggle('hover:bg-gray-50', !isActive);
});
}
// ----------------------------
// Render: sidebar
// ----------------------------
function renderConversationList(filterText = '') {
const q = (filterText || '').trim().toLowerCase();
const ids = Object.keys(DATA)
.sort((a, b) => getLastTs(b) - getLastTs(a))
.filter((id) => {
if (!q) return true;
const c = DATA[id];
const last = getLastMessage(id);
const hay = [c?.name, c?.meta, last?.text].filter(Boolean).join(' ').toLowerCase();
return hay.includes(q);
});
// Empty / no-results states
if (ids.length === 0) {
list.innerHTML = '';
if (emptyEl) {
emptyEl.textContent = q ? 'No conversations match your search.' : 'No conversations yet.';
emptyEl.classList.remove('hidden');
}
updateUnreadUI();
return;
}
if (emptyEl) emptyEl.classList.add('hidden');
const active = getConversationIdFromUrl() || getDefaultConversationId();
list.innerHTML = ids.map((id, idx) => {
const c = DATA[id];
const last = getLastMessage(id);
const preview = safeText(last?.text || '');
const time = last?.time || '';
const unread = getUnreadCount(id);
const online = !!c?.online;
const tag = c?.tag;
const dotClass = online ? 'bg-green-500' : 'bg-gray-400';
const activeClass = (id === active) ? 'bg-teal-50' : '';
const borderClass = idx === ids.length - 1 ? '' : 'border-b border-gray-200';
return `
${escapeHtml(c?.name || 'Conversation')}
${escapeHtml(time)}
${escapeHtml(preview)}
${tag ? `${escapeHtml(tag.label || '')}` : ''}
${unread ? `${unread}` : ''}
`;
}).join('');
updateUnreadUI();
}
// ----------------------------
// Render: chat header + body
// ----------------------------
function renderHeader(c) {
chatName.textContent = c?.name || 'Messages';
chatMeta.textContent = c?.meta || '';
const online = !!c?.online;
chatStatus.textContent = online ? 'Online now' : 'Offline';
chatStatus.classList.toggle('text-green-600', online);
chatStatus.classList.toggle('text-gray-500', !online);
chatAvatar.src = c?.avatar || '';
chatAvatar.alt = c?.name || 'Avatar';
if (chatOnlineDot) {
chatOnlineDot.classList.toggle('bg-green-500', online);
chatOnlineDot.classList.toggle('bg-gray-400', !online);
}
}
function formatMessageTime(ts) {
const d = new Date(ts || Date.now());
return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
function formatDateSeparator(ts) {
const d = new Date(ts || Date.now());
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (d.toDateString() === today.toDateString()) return 'Today';
if (d.toDateString() === yesterday.toDateString()) return 'Yesterday';
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function shouldShowDateSeparator(currentMsg, previousMsg) {
if (!previousMsg) return true;
const current = new Date(currentMsg.ts || Date.now());
const previous = new Date(previousMsg.ts || Date.now());
return current.toDateString() !== previous.toDateString();
}
function shouldGroupWithPrevious(currentMsg, previousMsg) {
if (!previousMsg) return false;
if (currentMsg.from !== previousMsg.from) return false;
const timeDiff = (currentMsg.ts || 0) - (previousMsg.ts || 0);
return timeDiff < 60000; // Group if within 1 minute
}
function getStatusIcon(status) {
if (!status || status === 'sent') return '✓';
if (status === 'delivered') return '✓✓';
if (status === 'read') return '✓✓';
return '';
}
function renderMessages(c) {
chat.innerHTML = '';
const msgs = c?.messages || [];
msgs.forEach((m, idx) => {
const previousMsg = idx > 0 ? msgs[idx - 1] : null;
const nextMsg = idx < msgs.length - 1 ? msgs[idx + 1] : null;
// Add date separator if needed
if (shouldShowDateSeparator(m, previousMsg)) {
const separator = document.createElement('div');
separator.className = 'flex justify-center my-4';
separator.innerHTML = `
${formatDateSeparator(m.ts)}
`;
chat.appendChild(separator);
}
const isGrouped = shouldGroupWithPrevious(m, previousMsg);
const isLastInGroup = !nextMsg || !shouldGroupWithPrevious(nextMsg, m); // Check if next msg exists first
const wrap = document.createElement('div');
wrap.className = m.from === 'me'
? `flex justify-end ${isGrouped ? 'mb-1' : 'mb-4'}`
: `flex justify-start ${isGrouped ? 'mb-1' : 'mb-4'}`;
const messageTime = formatMessageTime(m.ts);
const statusIcon = m.from === 'me' ? getStatusIcon(m.status) : '';
const timeHtml = `${messageTime} ${statusIcon}
`;
// Attachment rendering
let attachmentHtml = '';
if (m.attachment) {
if (m.attachment.type === 'image') {
attachmentHtml = `
`;
} else {
const fileSize = (m.attachment.size / 1024).toFixed(1) + ' KB';
attachmentHtml = `
${escapeHtml(m.attachment.name)}
${fileSize}
`;
}
}
// Link preview rendering
const urlRegex = /(https?:\/\/[^\s]+)/g;
const messageText = safeText(m.text);
const urls = messageText.match(urlRegex);
let linkPreviewHtml = '';
if (urls && urls.length > 0 && !m.attachment) {
const url = urls[0]; // Preview first URL only
const domain = new URL(url).hostname.replace('www.', '');
linkPreviewHtml = `
`;
}
// Convert URLs in text to clickable links
let displayText = escapeHtml(messageText);
if (urls) {
urls.forEach(url => {
displayText = displayText.replace(
escapeHtml(url),
`${escapeHtml(url)}`
);
});
}
// Reactions
const reactionsHtml = m.reactions && Object.keys(m.reactions).length > 0
? `${Object.entries(m.reactions).map(([emoji, count]) =>
`${emoji} ${count > 1 ? count : ''}`
).join('')}
`
: '';
const messageId = `msg-${m.ts}`;
const editedLabel = m.editedAt ? ' (edited)' : '';
const isDeleted = m.deleted;
wrap.innerHTML = m.from === 'me'
? `
${!isDeleted ? `
${attachmentHtml ? attachmentHtml : ''}
${attachmentHtml && m.text && !m.text.match(/^(📷|📎)/) ? `
${displayText}
` : ''}
${!attachmentHtml ? displayText : ''}
${linkPreviewHtml}
` : `
This message was deleted
`}
${reactionsHtml}
${isLastInGroup ? timeHtml + editedLabel : ''}
`
: `
${attachmentHtml ? attachmentHtml : ''}
${attachmentHtml && m.text && !m.text.match(/^(📷|📎)/) ? `
${displayText}
` : ''}
${!attachmentHtml ? displayText : ''}
${linkPreviewHtml}
${reactionsHtml}
${isLastInGroup ? timeHtml : ''}
`;
chat.appendChild(wrap);
});
// Scroll to bottom - using requestAnimationFrame to ensure DOM is laid out
const scrollToBottom = () => {
if (!chat) return;
requestAnimationFrame(() => {
chat.scrollTop = chat.scrollHeight;
});
};
// Multiple scroll attempts to handle async image loading
scrollToBottom();
setTimeout(scrollToBottom, 50);
setTimeout(scrollToBottom, 150);
setTimeout(scrollToBottom, 300);
// Final scroll after all images have loaded
const images = chat.querySelectorAll('img');
if (images.length > 0) {
Promise.all(Array.from(images).map(img => {
if (img.complete) return Promise.resolve();
return new Promise(resolve => {
img.addEventListener('load', resolve);
img.addEventListener('error', resolve);
});
})).then(scrollToBottom);
}
}
// ----------------------------
// v0.028: Messages context panel
// - Customer view: list customer's active jobs; clicking a job provides a Jobs Board deep-link.
// - Tradie view: showcase tradie's recently completed jobs with completion photos + published reviews.
// - Dual counterparty: allow toggling between the two panels.
// ----------------------------
function resolveCounterparty(convId) {
const cid = String(convId || '').trim();
if (!cid) return { customerId: null, tradieId: null };
let tradieId = null;
let customerId = null;
const tradies = window.TRADIES || {};
for (const [id, t] of Object.entries(tradies)) {
if (t && String(t.conversationId || '') === cid) { tradieId = String(id); break; }
}
const customers = window.CUSTOMERS || {};
for (const [id, c] of Object.entries(customers)) {
if (c && String(c.conversationId || '') === cid) { customerId = String(id); break; }
}
return { customerId, tradieId };
}
function readReviewsStorage() {
try {
const raw = localStorage.getItem('athReviews');
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function isReviewPublished(r) {
if (!r) return false;
if (r.visibility === 'published') return true;
const base = Date.parse(r.completedAt || '') || 0;
if (!base) return false;
return (Date.now() - base) >= 7 * 24 * 60 * 60 * 1000;
}
function getCompletionPhotosForJob(jobId) {
try {
const s = window.ATHJobs?.getJobState?.(jobId) || {};
const arr = Array.isArray(s.completionPhotos) ? s.completionPhotos : [];
return arr.filter(v => typeof v === 'string' && v.startsWith('data:image/'));
} catch {
return [];
}
}
function statusPillClass(st) {
const s = String(st || 'open').trim().toLowerCase();
if (s === 'in_progress') return 'bg-amber-100 text-amber-800';
if (s === 'completed') return 'bg-emerald-100 text-emerald-800';
if (s === 'agreed') return 'bg-blue-100 text-blue-800';
return 'bg-gray-100 text-gray-800';
}
function renderContextPanel(convId) {
if (!contextMount) return;
const resolved = resolveCounterparty(convId);
const hasCustomer = !!resolved.customerId;
const hasTradie = !!resolved.tradieId;
// If we can't resolve, keep the panel lightweight.
if (!hasCustomer && !hasTradie) {
contextMount.innerHTML = `
Select a conversation to see job context.
`;
return;
}
const modeKey = `athMsgContextMode:${uid}:${String(convId)}`;
let mode = localStorage.getItem(modeKey) || '';
if (mode !== 'customer' && mode !== 'tradie') {
// default
mode = hasCustomer && !hasTradie ? 'customer' : (!hasCustomer && hasTradie ? 'tradie' : 'customer');
}
const jobsAll = window.ATHJobs?.getAllJobs?.() || [];
const customers = window.CUSTOMERS || {};
const tradies = window.TRADIES || {};
const selectedKey = `athMsgSelectedJob:${uid}:${String(convId)}`;
const selectedJobId = localStorage.getItem(selectedKey) || '';
const renderToggle = (dual) => {
if (!dual) return '';
const btnCls = (m) => (m === mode)
? 'bg-teal-600 text-white border-teal-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50';
return `
`;
};
function renderCustomerPanel() {
const cid = String(resolved.customerId);
const c = customers?.[cid];
const activeStates = new Set(['open', 'agreed', 'in_progress']);
const jobs = jobsAll
.filter(j => String(j?.customerId || '') === cid && activeStates.has(String(j?.status || 'open').toLowerCase()))
.sort((a, b) => Date.parse(b?.postedAt || b?.createdAt || '') - Date.parse(a?.postedAt || a?.createdAt || ''));
const pick = (jobs.find(j => String(j.id) === String(selectedJobId)) || jobs[0] || null);
const chips = (job) => {
const ids = Array.isArray(job?.categories) ? job.categories : (typeof window.normalizeTradeIds === 'function' ? window.normalizeTradeIds(job?.categories) : []);
return (ids || []).slice(0, 4).map((cid2) => (
`${escapeHtml(typeof window.tradeLabel === 'function' ? window.tradeLabel(cid2) : String(cid2))}`
)).join('');
};
const jobList = jobs.length ? `
${jobs.map((j) => {
const active = String(j.id) === String(pick?.id);
return `
`;
}).join('')}
` : `No active jobs yet.
`;
const details = pick ? `
${escapeHtml(pick.title || 'Job')}
${escapeHtml(pick.location || '—')}
${escapeHtml(String(pick.status || 'open').replace('_', ' '))}
${chips(pick)}
Budget
${escapeHtml(String(pick.budgetMax || pick.budgetMin || 0) ? (typeof window.compactMoney === 'function' ? window.compactMoney(Number(pick.budgetMax || pick.budgetMin || 0)) : '$' + String(pick.budgetMax || pick.budgetMin || 0)) : '—')}
Timeline
${escapeHtml(String(pick.timeline || 'Flexible'))}
` : '';
return `
Active jobs from this customer
${jobList}
${details}
`;
}
function renderTradiePanel() {
const tid = String(resolved.tradieId);
const t = tradies?.[tid];
const jobs = jobsAll
.filter(j => String(j?.assignedTradieId || '') === tid && String(j?.status || '').toLowerCase() === 'completed')
.sort((a, b) => {
const sa = window.ATHJobs?.getJobState?.(a.id) || {};
const sb = window.ATHJobs?.getJobState?.(b.id) || {};
return Date.parse(sb.completedAt || b.completedAt || '') - Date.parse(sa.completedAt || a.completedAt || '');
})
.slice(0, 6);
const allReviews = readReviewsStorage().filter(r => String(r?.targetRole) === 'tradie' && String(r?.targetId) === tid && isReviewPublished(r));
const jobCards = jobs.length ? jobs.map((j) => {
const st = window.ATHJobs?.getJobState?.(j.id) || {};
const photos = getCompletionPhotosForJob(j.id).slice(0, 3);
const reviewsForJob = allReviews.filter(r => String(r?.jobId) === String(j.id)).slice(0, 2);
const photoGrid = photos.length ? `
${photos.map((src, idx) => `
})
`).join('')}
` : `No completion photos uploaded.
`;
const reviewsBlock = reviewsForJob.length ? `
${reviewsForJob.map((r) => `
${escapeHtml(String(r.stars || ''))}\u2605 \u2022 ${escapeHtml(new Date(Number(r.ts) || Date.now()).toLocaleDateString())}
${escapeHtml(String(r.text || ''))}
`).join('')}
` : `No published reviews for this job yet.
`;
return `
${escapeHtml(j.title || 'Job')}
Completed: ${escapeHtml(st.completedAt ? new Date(st.completedAt).toLocaleDateString() : (j.completedAt ? new Date(j.completedAt).toLocaleDateString() : '—'))}
completed
${photoGrid}
${reviewsBlock}
`;
}).join('') : `No completed jobs to showcase yet.
`;
return `
Recently completed jobs from this tradie
${jobCards}
${t ? `
` : ''}
`;
}
const dual = hasCustomer && hasTradie;
contextMount.innerHTML = `${renderToggle(dual)}${mode === 'tradie' ? renderTradiePanel() : renderCustomerPanel()}`;
// Wire toggle (dual)
contextMount.querySelectorAll('[data-ctx-mode]').forEach((btn) => {
btn.addEventListener('click', () => {
const next = String(btn.getAttribute('data-ctx-mode') || 'customer');
if (next !== 'customer' && next !== 'tradie') return;
localStorage.setItem(modeKey, next);
renderContextPanel(convId);
});
});
// Wire job select (customer panel)
contextMount.querySelectorAll('[data-ctx-job]').forEach((btn) => {
btn.addEventListener('click', () => {
const jid = String(btn.getAttribute('data-ctx-job') || '');
if (!jid) return;
localStorage.setItem(selectedKey, jid);
renderContextPanel(convId);
});
});
try { if (typeof feather !== 'undefined') feather.replace(); } catch { }
}
// ----------------------------
// Routing + defaults
// ----------------------------
function getConversationIdFromUrl() {
return new URLSearchParams(location.search).get('conversation');
}
function setConversationIdInUrl(id) {
const url = new URL(location.href);
url.searchParams.set('conversation', id);
history.replaceState({}, '', url.toString());
}
function getDefaultConversationId() {
const lastActive = localStorage.getItem(lastActiveKey);
if (lastActive && DATA[lastActive]) return lastActive;
const ids = Object.keys(DATA);
if (!ids.length) return null;
return ids.sort((a, b) => getLastTs(b) - getLastTs(a))[0];
}
// v0.027b: When we arrive via a "Contact" link (messages.html?conversation=),
// the per-account conversation store may not have that conversation yet.
// Create an empty conversation stub for this account so the dialog opens.
function resolveCounterpartyByConversationId(convId) {
const cid = String(convId || '').trim();
if (!cid) return null;
const tradies = window.TRADIES || {};
for (const t of Object.values(tradies)) {
if (t && String(t.conversationId || '') === cid) {
const meta = [t.trade, t.location].filter(Boolean).join(' \u2022 ');
return {
name: t.name || 'Tradie',
avatar: t.image || '',
meta,
tag: { label: 'Tradie', color: 'bg-teal-100 text-teal-700' },
online: false
};
}
}
const customers = window.CUSTOMERS || {};
for (const c of Object.values(customers)) {
if (c && String(c.conversationId || '') === cid) {
const meta = [c.location].filter(Boolean).join('');
return {
name: c.name || 'Customer',
avatar: c.image || '',
meta,
tag: { label: 'Customer', color: 'bg-gray-100 text-gray-700' },
online: false
};
}
}
return null;
}
function ensureConversationExists(convId) {
const id = String(convId || '').trim();
if (!id || DATA[id]) return;
// Optional URL-provided context (future-proofing)
const params = new URLSearchParams(location.search);
const nameFromUrl = params.get('name');
const avatarFromUrl = params.get('avatar');
const metaFromUrl = params.get('meta');
const resolved = resolveCounterpartyByConversationId(id) || {};
DATA[id] = {
id,
name: nameFromUrl ? decodeURIComponent(nameFromUrl) : (resolved.name || 'Conversation'),
avatar: avatarFromUrl ? decodeURIComponent(avatarFromUrl) : (resolved.avatar || ''),
meta: metaFromUrl ? decodeURIComponent(metaFromUrl) : (resolved.meta || ''),
online: !!resolved.online,
tag: resolved.tag || null,
messages: []
};
window.ATHStore.set(STORE_KEY, DATA);
}
function load(id) {
// If the URL specifies a conversation that doesn't exist in this account yet,
// create an empty stub so "Contact" deep-links open the right thread.
if (id && !DATA[id]) ensureConversationExists(id);
const actualId = DATA[id] ? id : getDefaultConversationId();
if (!actualId) {
// No conversations at all
renderConversationList(searchInput?.value || '');
if (input) {
input.disabled = true;
input.classList.add('opacity-50', 'cursor-not-allowed');
}
if (sendBtn) {
sendBtn.disabled = true;
sendBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
if (unreadLabel) unreadLabel.textContent = 'All read';
showEmpty('No conversations yet', 'Start a conversation from a job listing.');
updateUnreadUI();
return;
}
showChat();
if (input) {
input.disabled = false;
input.classList.remove('opacity-50', 'cursor-not-allowed');
}
if (sendBtn) {
sendBtn.disabled = false;
sendBtn.classList.remove('opacity-50', 'cursor-not-allowed');
}
const c = DATA[actualId];
// Mark read + persist active
markRead(actualId);
localStorage.setItem(lastActiveKey, actualId);
// Render
setConversationIdInUrl(actualId);
renderConversationList(searchInput?.value || '');
setActiveRow(actualId);
renderHeader(c);
renderMessages(c);
updateUnreadUI();
// v0.028: render right-side context panel for this conversation
renderContextPanel(actualId);
// v0.035: Restore draft for this conversation
if (input) {
const draft = loadDraft(actualId);
input.value = draft;
// Focus input if there's a draft
if (draft) {
setTimeout(() => input.focus(), 150);
}
}
// Force scroll to bottom after everything is rendered
setTimeout(() => {
if (chat) {
chat.scrollTop = chat.scrollHeight;
}
}, 100);
setTimeout(() => {
if (chat) {
chat.scrollTop = chat.scrollHeight;
}
}, 300);
setTimeout(() => {
if (chat) {
chat.scrollTop = chat.scrollHeight;
}
}, 600);
}
// ----------------------------
// Events
// ----------------------------
// Click conversation (delegated)
list.addEventListener('click', (e) => {
const row = e.target.closest('.conversation-item');
if (!row) return;
e.preventDefault();
const id = row.dataset.conversation;
if (!id) return;
load(id);
});
// Search conversations
searchInput?.addEventListener('input', () => {
renderConversationList(searchInput.value);
});
// ----------------------------
// v0.035: Message Drafts
// ----------------------------
let draftSaveTimeout = null;
const DRAFT_DEBOUNCE_MS = 500;
function getDraftKey(convId) {
return `athDraft:${uid}:${convId}`;
}
function saveDraft(convId, text) {
const key = getDraftKey(convId);
if (text.trim()) {
localStorage.setItem(key, text);
} else {
localStorage.removeItem(key);
}
}
function loadDraft(convId) {
const key = getDraftKey(convId);
return localStorage.getItem(key) || '';
}
function clearDraft(convId) {
const key = getDraftKey(convId);
localStorage.removeItem(key);
}
// ----------------------------
// Send message
// ----------------------------
function send() {
const id = getConversationIdFromUrl() || getDefaultConversationId();
if (!id || !DATA[id]) return;
const text = (input?.value || '').trim();
if (!text) return;
const now = Date.now();
// Block off-platform contact sharing until payment
const scan = window.ATHIntegrity?.scanText ? window.ATHIntegrity.scanText(text) : { hasContact: false };
if (scan?.hasContact) {
window.ATHIntegrity?.setInlineNotice(document.getElementById('athMessageSanitizeNote'),
'Contact details (phone, email, or links) are locked until payment is confirmed. Please remove them.');
setTimeout(() => window.ATHIntegrity?.setInlineNotice(document.getElementById('athMessageSanitizeNote'), ''), 3000);
return;
}
const sanitized = window.ATHIntegrity ? window.ATHIntegrity.sanitizeText(text) : { text, changed: false };
const newMessage = {
from: 'me',
time: 'Now',
ts: now,
text: sanitized.text,
status: 'sent' // New: track message status
};
DATA[id].messages.push(newMessage);
window.ATHStore.set(STORE_KEY, DATA);
input.value = '';
// v0.035: Clear draft after successful send
clearDraft(id);
// Re-render chat + sidebar preview/time
renderMessages(DATA[id]);
renderConversationList(searchInput?.value || '');
setActiveRow(id);
// Simulate message status updates (delivered -> read)
setTimeout(() => {
newMessage.status = 'delivered';
window.ATHStore.set(STORE_KEY, DATA);
renderMessages(DATA[id]);
setTimeout(() => {
newMessage.status = 'read';
window.ATHStore.set(STORE_KEY, DATA);
renderMessages(DATA[id]);
}, 1500);
}, 500);
}
function showTypingIndicator(name) {
const indicator = document.getElementById('typingIndicator');
const text = document.getElementById('typingIndicatorText');
if (indicator && text) {
text.textContent = `${name} is typing...`;
indicator.classList.remove('hidden');
chat.scrollTop = chat.scrollHeight;
}
}
function hideTypingIndicator() {
const indicator = document.getElementById('typingIndicator');
if (indicator) {
indicator.classList.add('hidden');
}
}
sendBtn?.addEventListener('click', send);
input?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
send();
}
});
// v0.035: Auto-save draft on input (with debounce)
input?.addEventListener('input', () => {
const id = getConversationIdFromUrl() || getDefaultConversationId();
if (!id) return;
// Clear existing timeout
if (draftSaveTimeout) {
clearTimeout(draftSaveTimeout);
}
// Set new timeout to save draft
draftSaveTimeout = setTimeout(() => {
const text = input.value;
saveDraft(id, text);
}, DRAFT_DEBOUNCE_MS);
});
// Init
renderConversationList('');
// Handle SPA navigation for conversation list
list.addEventListener('click', (e) => {
const item = e.target.closest('.conversation-item');
if (item) {
e.preventDefault();
const id = item.dataset.conversation;
if (id) {
load(id);
activateMobileChat(); // Switch to chat view on mobile
// Update URL without reload
const url = new URL(window.location);
url.searchParams.set('conversation', id);
window.history.pushState({}, '', url);
}
}
});
// Initial load logic
const urlId = getConversationIdFromUrl();
const defaultId = getDefaultConversationId();
if (urlId) {
load(urlId);
activateMobileChat();
} else {
// Determine if we should load default
if (defaultId) load(defaultId);
// Explicitly start in list view if no URL param
activateMobileList();
}
}
// Global reaction functions
window.showReactionPicker = function(messageTs, event) {
event?.stopPropagation();
const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
// Remove existing picker
const existingPicker = document.getElementById('reactionPicker');
if (existingPicker) existingPicker.remove();
const picker = document.createElement('div');
picker.id = 'reactionPicker';
picker.className = 'absolute z-50 bg-white border border-gray-300 rounded-lg shadow-lg p-2 flex gap-2';
picker.style.left = event?.clientX + 'px';
picker.style.top = (event?.clientY - 50) + 'px';
quickReactions.forEach(emoji => {
const btn = document.createElement('button');
btn.textContent = emoji;
btn.className = 'text-2xl hover:bg-gray-100 rounded p-1 transition';
btn.onclick = () => {
window.toggleReaction(messageTs, emoji);
picker.remove();
};
picker.appendChild(btn);
});
document.body.appendChild(picker);
// Close picker on click outside
setTimeout(() => {
const closeHandler = (e) => {
if (!picker.contains(e.target)) {
picker.remove();
document.removeEventListener('click', closeHandler);
}
};
document.addEventListener('click', closeHandler);
}, 100);
};
window.toggleReaction = function(messageTs, emoji) {
const session = window.ATHAuth?.getSession?.();
if (!session?.userId) return;
const uid = session.userId;
const STORE_KEY = `athConversations:${uid}`;
const DATA = window.ATHStore.get(STORE_KEY, {});
const urlId = new URLSearchParams(location.search).get('conversation');
if (!urlId || !DATA[urlId]) return;
const msg = DATA[urlId].messages.find(m => m.ts == messageTs);
if (!msg) return;
// Initialize reactions object
if (!msg.reactions) msg.reactions = {};
// Toggle reaction
if (msg.reactions[emoji]) {
msg.reactions[emoji]++;
} else {
msg.reactions[emoji] = 1;
}
window.ATHStore.set(STORE_KEY, DATA);
// Re-render messages by reloading the page section
location.reload();
};
// ----------------------------
// Helpers
// ----------------------------
function escapeHtml(str) {
return String(str)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
// ----------------------------
// My Profile Page
// ----------------------------
function initMyProfilePage() {
const root = document.getElementById('mpName');
if (!root) return; // not on my-profile
// v0.026a: My Profile is private to signed-in users (prototype auth layer).
if (!requireAuthOnPage({ onMissing: 'overlay' })) return;
const els = {
avatarImg: document.getElementById('mpAvatar'),
name: document.getElementById('mpName'),
subtitle: document.getElementById('mpSubtitle'),
roleBadge: document.getElementById('mpRoleBadge'),
verifiedBadge: document.getElementById('mpVerifiedBadge'),
editBtn: document.getElementById('mpEditBtn'),
saveBtn: document.getElementById('mpSaveBtn'),
cancelBtn: document.getElementById('mpCancelBtn'),
publicTradieLink: document.getElementById('mpPublicTradieLink'),
publicCustomerLink: document.getElementById('mpPublicCustomerLink'),
displayName: document.getElementById('mpDisplayName'),
avatarPickBtn: document.getElementById('mpAvatarPickBtn'),
avatarRemoveBtn: document.getElementById('mpAvatarRemoveBtn'),
avatarFile: document.getElementById('mpAvatarFile'),
suburb: document.getElementById('mpSuburb'),
state: document.getElementById('mpState'),
postcode: document.getElementById('mpPostcode'),
phone: document.getElementById('mpPhone'),
email: document.getElementById('mpEmail'),
// Batch L: Tradie multi-trade picker
tradesSection: document.getElementById('mpTradesSection'),
tradesHidden: document.getElementById('mpTradesHidden'),
tradesToggle: document.getElementById('mpTradesToggle'),
tradesPanel: document.getElementById('mpTradesPanel'),
tradesSearch: document.getElementById('mpTradesSearch'),
tradesOptions: document.getElementById('mpTradesOptions'),
tradesSelected: document.getElementById('mpTradesSelected'),
tradesCount: document.getElementById('mpTradesCount'),
showLocation: document.getElementById('mpShowLocation'),
addressRule: document.getElementById('mpAddressRule'),
abn: document.getElementById('mpAbn'),
abnMasked: document.getElementById('mpAbnMasked'),
license: document.getElementById('mpLicense'),
toast: document.getElementById('mpToast'),
roleButtons: Array.from(document.querySelectorAll('.mpRoleBtn'))
};
let user = getCurrentUser();
let snapshot = JSON.parse(JSON.stringify(user));
let editing = false;
const showToast = (msg, ok = true) => {
if (!els.toast) return;
els.toast.classList.remove('hidden');
els.toast.className = ok
? 'mt-4 text-sm px-3 py-2 rounded-lg border border-emerald-200 bg-emerald-50 text-emerald-800'
: 'mt-4 text-sm px-3 py-2 rounded-lg border border-red-200 bg-red-50 text-red-800';
els.toast.textContent = msg;
setTimeout(() => { if (els.toast) els.toast.classList.add('hidden'); }, 2200);
};
const setDisabled = (disabled) => {
[
els.displayName, els.suburb, els.state, els.postcode, els.phone, els.email,
els.showLocation, els.addressRule, els.abn, els.license
].forEach((el) => { if (el) el.disabled = disabled; });
// Trade picker controls
if (els.tradesToggle) els.tradesToggle.disabled = disabled;
if (els.tradesSearch) els.tradesSearch.disabled = disabled;
};
const renderRoleUI = () => {
const role = (user.role || 'dual');
if (els.roleBadge) els.roleBadge.textContent = role.toUpperCase();
els.roleButtons.forEach((b) => {
const active = b.dataset.role === role;
b.classList.toggle('bg-teal-600', active);
b.classList.toggle('text-white', active);
b.classList.toggle('border-teal-600', active);
});
if (els.publicTradieLink) {
els.publicTradieLink.classList.toggle('hidden', !(role === 'tradie' || role === 'dual'));
els.publicTradieLink.href = 'profile-tradesman.html?id=me';
}
if (els.publicCustomerLink) {
els.publicCustomerLink.classList.toggle('hidden', !(role === 'customer' || role === 'dual'));
els.publicCustomerLink.href = 'profile-customer.html?id=me';
}
if (els.tradesSection) {
els.tradesSection.classList.toggle('hidden', !(role === 'tradie' || role === 'dual'));
}
};
// Batch L: Tradie multi-trade picker (canonical list from data.js)
const tradeCatalog = Array.isArray(window.TRADE_CATEGORIES) ? window.TRADE_CATEGORIES : [];
const tradeLabel = (id) => {
const found = tradeCatalog.find(t => t.id === id);
return found ? found.label : (typeof window.tradeLabel === 'function' ? window.tradeLabel(id) : String(id || 'Other'));
};
let selectedTrades = new Set();
function setSelectedTrades(next) {
selectedTrades = new Set(Array.isArray(next) ? next.map(String).filter(Boolean) : []);
if (els.tradesHidden) els.tradesHidden.value = Array.from(selectedTrades).join(',');
if (els.tradesCount) els.tradesCount.textContent = `${selectedTrades.size} selected`;
if (els.tradesSelected) {
els.tradesSelected.innerHTML = Array.from(selectedTrades).map((id) => (
`
${escapeHtml(tradeLabel(id))}
`
)).join('');
els.tradesSelected.querySelectorAll('[data-remove-trade]').forEach((btn) => {
btn.addEventListener('click', () => {
if (!editing) return;
selectedTrades.delete(btn.getAttribute('data-remove-trade'));
setSelectedTrades(Array.from(selectedTrades));
renderTradeOptions();
});
});
}
}
function renderTradeOptions() {
if (!els.tradesOptions) return;
const q = (els.tradesSearch?.value || '').toString().trim().toLowerCase();
const rows = tradeCatalog
.filter(t => !q || String(t.label).toLowerCase().includes(q) || String(t.id).toLowerCase().includes(q))
.map(t => {
const checked = selectedTrades.has(t.id) ? 'checked' : '';
return ``;
});
els.tradesOptions.innerHTML = rows.join('') || `No trades found.
`;
els.tradesOptions.querySelectorAll('input[data-trade-id]').forEach((box) => {
box.addEventListener('change', () => {
if (!editing) return;
const id = box.getAttribute('data-trade-id');
if (box.checked) selectedTrades.add(id);
else selectedTrades.delete(id);
setSelectedTrades(Array.from(selectedTrades));
});
});
}
els.tradesToggle?.addEventListener('click', () => {
if (!editing) return;
els.tradesPanel?.classList.toggle('hidden');
renderTradeOptions();
});
els.tradesSearch?.addEventListener('input', renderTradeOptions);
const render = () => {
if (els.avatarImg) els.avatarImg.src = user.avatarDataUrl || user.avatar || 'https://static.photos/people/320x240/301';
if (els.name) els.name.textContent = user.displayName || 'My Profile';
if (els.verifiedBadge) {
const verified = !!user.verification?.verified;
els.verifiedBadge.classList.toggle('hidden', !verified);
}
if (els.displayName) els.displayName.value = user.displayName || '';
if (els.suburb) els.suburb.value = user.location?.suburb || '';
if (els.state) els.state.value = user.location?.state || '';
if (els.postcode) els.postcode.value = user.location?.postcode || '';
if (els.phone) els.phone.value = user.contact?.phone || '';
if (els.email) els.email.value = user.contact?.email || '';
if (els.showLocation) els.showLocation.checked = !!user.privacy?.showLocation;
if (els.addressRule) els.addressRule.value = user.privacy?.addressRule || 'afterAccepted';
if (els.abn) els.abn.value = user.verification?.abnFull || '';
if (els.abnMasked) els.abnMasked.textContent = maskSensitiveKeepLast4(user.verification?.abnFull || '');
if (els.license) els.license.value = user.verification?.licenseFull || '';
if (!editing) {
const fromUser = Array.isArray(user.tradie?.trades) ? user.tradie.trades : (typeof window.inferTradeIdsFromText === 'function' ? window.inferTradeIdsFromText(user.tradie?.trade) : []);
setSelectedTrades(fromUser || []);
renderTradeOptions();
}
renderRoleUI();
if (typeof feather !== 'undefined') feather.replace();
};
const validate = () => {
const name = (els.displayName?.value || '').trim();
if (!name) return { ok: false, msg: 'Display name is required.' };
const st = (els.state?.value || '').trim();
const pc = (els.postcode?.value || '').trim();
if (pc && !/^\d{4}$/.test(pc)) return { ok: false, msg: 'Postcode must be 4 digits.' };
if (st && st.length > 3) return { ok: false, msg: 'State looks invalid.' };
const email = (els.email?.value || '').trim();
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { ok: false, msg: 'Email format looks invalid.' };
return { ok: true };
};
const enterEdit = () => {
editing = true;
snapshot = JSON.parse(JSON.stringify(user));
setDisabled(false);
setAvatarButtonsEnabled(true);
if (els.editBtn) els.editBtn.classList.add('hidden');
if (els.saveBtn) els.saveBtn.classList.remove('hidden');
if (els.cancelBtn) els.cancelBtn.classList.remove('hidden');
};
const exitEdit = () => {
editing = false;
setDisabled(true);
setAvatarButtonsEnabled(false);
if (els.editBtn) els.editBtn.classList.remove('hidden');
if (els.saveBtn) els.saveBtn.classList.add('hidden');
if (els.cancelBtn) els.cancelBtn.classList.add('hidden');
};
const applyFromFields = () => {
user.displayName = (els.displayName?.value || '').trim();
user.location = {
suburb: (els.suburb?.value || '').trim(),
state: (els.state?.value || '').trim().toUpperCase(),
postcode: (els.postcode?.value || '').trim()
};
user.contact = {
phone: (els.phone?.value || '').trim(),
email: (els.email?.value || '').trim()
};
user.privacy = {
showLocation: !!els.showLocation?.checked,
addressRule: els.addressRule?.value || 'afterAccepted'
};
user.verification = user.verification || { verified: false, abnFull: '', licenseFull: '' };
user.verification.abnFull = (els.abn?.value || '').trim();
user.verification.licenseFull = (els.license?.value || '').trim();
// Batch L: persist tradie trades (even in Dual mode)
user.tradie = user.tradie || { trades: [] };
user.tradie.trades = Array.from(selectedTrades);
};
// ----------------------------
// Avatar upload (prototype)
// Stores a compressed square image in localStorage as avatarDataUrl.
// ----------------------------
async function processAvatarFile(file) {
if (!file) return null;
if (!file.type || !file.type.startsWith('image/')) {
showToast('Please choose an image file.', false);
return null;
}
const MAX_BYTES = 3 * 1024 * 1024; // 3MB
if (file.size > MAX_BYTES) {
showToast('Image is too large (max 3MB).', false);
return null;
}
if (!window.ATHImages || typeof window.ATHImages.processImageFile !== 'function') return null;
return await window.ATHImages.processImageFile(file, {
maxBytes: MAX_BYTES,
maxDim: 384,
cropSquare: true,
mimePrefer: 'image/webp',
quality: 0.85
});
}
function setAvatarButtonsEnabled(enabled) {
if (els.avatarPickBtn) els.avatarPickBtn.disabled = !enabled;
if (els.avatarRemoveBtn) els.avatarRemoveBtn.disabled = !enabled;
}
els.avatarPickBtn?.addEventListener('click', () => {
if (!editing) return;
els.avatarFile?.click();
});
els.avatarFile?.addEventListener('change', async () => {
if (!editing) return;
const file = els.avatarFile.files && els.avatarFile.files[0];
const processed = await processAvatarFile(file);
// reset input so picking same file again still triggers change
if (els.avatarFile) els.avatarFile.value = '';
if (!processed) return;
user.avatarDataUrl = processed;
render();
});
els.avatarRemoveBtn?.addEventListener('click', () => {
if (!editing) return;
user.avatarDataUrl = '';
render();
});
// Role selection
els.roleButtons.forEach((btn) => {
btn.addEventListener('click', () => {
if (!editing) return;
user.role = btn.dataset.role;
renderRoleUI();
});
});
els.editBtn?.addEventListener('click', () => enterEdit());
els.cancelBtn?.addEventListener('click', () => { user = JSON.parse(JSON.stringify(snapshot)); render(); exitEdit(); showToast('Cancelled changes.', true); });
els.saveBtn?.addEventListener('click', () => {
const v = validate();
if (!v.ok) return showToast(v.msg, false);
applyFromFields();
setCurrentUser(user);
render();
exitEdit();
showToast('Saved.', true);
});
// Initial state
render();
exitEdit();
}
// ----------------------------
// Batch N1: Profile reviews rendering (read-only)
// ----------------------------
(function () {
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function formatDate(ts) {
const d = new Date(Number(ts || Date.now()));
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
}
function renderStars(stars) {
const s = Math.max(0, Math.min(5, Number(stars || 0)));
return Array.from({ length: 5 }).map((_, i) => {
const filled = i < s;
const cls = filled ? 'fill-current' : '';
return ``;
}).join('');
}
function getReviewCount(entity, opts) {
if (!entity) return 0;
const baseTotal = Number.isFinite(Number(entity.reviewCount)) ? Number(entity.reviewCount) : (Array.isArray(entity.reviews) ? entity.reviews.length : 0);
const id = String(opts?.id || '');
const role = String(opts?.role || '');
const local = id ? getLocalPublishedReviews(id, role) : [];
// Only add local items that are beyond demo base arrays
return baseTotal + local.length;
}
function readLocalReviews() {
try {
const raw = localStorage.getItem('athReviews');
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function getLocalPublishedReviews(targetId, targetRole) {
const tid = String(targetId || '');
if (!tid) return [];
const role = String(targetRole || '');
const list = readLocalReviews();
const out = [];
const now = Date.now();
for (const r of list) {
if (!r) continue;
if (String(r.targetId) !== tid) continue;
if (role && String(r.targetRole) !== role) continue;
if (r.visibility === 'published') {
out.push(r);
continue;
}
const base = Date.parse(r.completedAt || '') || 0;
if (base && (now - base) >= 7 * 24 * 60 * 60 * 1000) {
out.push({ ...r, visibility: 'published' });
}
}
return out;
}
function getReviews(entity, opts) {
const base = Array.isArray(entity?.reviews) ? entity.reviews : [];
const id = String(opts?.id || '');
const role = String(opts?.role || '');
const local = id ? getLocalPublishedReviews(id, role) : [];
// map local format to display format
const mapped = local.map(r => ({
stars: r.stars,
text: r.text,
ts: r.ts,
byRole: r.reviewerRole === 'customer' ? 'Customer' : r.reviewerRole === 'tradie' ? 'Tradie' : ''
}));
return [...mapped, ...base];
}
window.ATHRender = window.ATHRender || {};
window.ATHRender.renderReviewsInto = function renderReviewsInto(container, entity, emptyText, opts) {
if (!container) return;
const reviews = getReviews(entity, opts);
const total = getReviewCount(entity, opts);
if (!reviews.length) {
container.innerHTML = `${escapeHtml(emptyText || 'No reviews yet.')}
`;
return;
}
const header = (total > reviews.length)
? `Showing ${reviews.length} of ${total} reviews
`
: '';
const items = reviews.map((r) => {
const who = r.byRole ? `• ${escapeHtml(r.byRole)}` : '';
return `
${renderStars(r.stars)}
${escapeHtml(formatDate(r.ts))}
${escapeHtml(r.text || '')}
${who}
`;
}).join('');
container.innerHTML = `${header}${items}
`;
// feather icons
try {
if (typeof feather !== 'undefined' && feather && typeof feather.replace === 'function') feather.replace();
} catch (e) { }
};
})();