/* Labour Tech data layer. Works in two modes with the same API: - DEMO : records live in this browser (localStorage). No account needed. - LIVE : records live in Supabase. Add URL + anon key in Admin › Settings. Screens never care which mode is on. */ const LT_TABLES = ['businesses', 'subscriptions', 'payments', 'workers', 'reveals', 'invoices', 'quotes', 'leads', 'registrations']; /* Live project. The publishable key is public by design — row-level security protects the data, not the key. Never put a service-role key in this file. */ const LT_SUPABASE = { url: 'https://tbloyqxgftrsowshpxyr.supabase.co', key: 'sb_publishable_7CStIuv6JneEzeYyetHtWA_bOGm0E0Q' }; const LTData = (() => { const cfgKey = 'lt_supabase_cfg'; let client = null; const cfg = () => { try { return JSON.parse(localStorage.getItem(cfgKey) || 'null'); } catch (e) { return null; } }; const setCfg = c => { localStorage.setItem(cfgKey, JSON.stringify(c)); client = null; }; const isLive = () => { const c = cfg(); return !!(c && c.url && c.key && window.supabase); }; const sb = () => { if (!isLive()) return null; if (!client) { const c = cfg(); client = window.supabase.createClient(c.url, c.key); } return client; }; /* Public form submissions always go to the live project, whichever mode the screens are in. Only tables with an anon-insert policy work here: quotes, leads, registrations, lead businesses. */ let pub = null; const pubClient = () => { if (!window.supabase) return null; if (!pub) pub = window.supabase.createClient(LT_SUPABASE.url, LT_SUPABASE.key); return pub; }; async function submit(table, row) { /* No .select() here — an anonymous visitor may write but may not read back. */ const rec = Object.assign({ id: uid(), created_at: new Date().toISOString() }, row); const c = pubClient(); if (c) { const { error } = await c.from(table).insert(rec); if (!error) return rec; console.warn('[LTData submit]', table, error.message); } return insert(table, rec); } const lsKey = t => 'lt_tbl_' + t; const lsAll = t => { try { return JSON.parse(localStorage.getItem(lsKey(t)) || '[]'); } catch (e) { return []; } }; const lsSave = (t, rows) => localStorage.setItem(lsKey(t), JSON.stringify(rows)); const uid = () => 'r' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); async function list(table, opts = {}) { if (isLive()) { let q = sb().from(table).select('*'); if (opts.eq) Object.entries(opts.eq).forEach(([k, v]) => { q = q.eq(k, v); }); const { data, error } = await q; if (error) { console.warn('[LTData]', error.message); return []; } return data || []; } let rows = lsAll(table); if (opts.eq) rows = rows.filter(r => Object.entries(opts.eq).every(([k, v]) => r[k] === v)); return rows; } async function insert(table, row) { const rec = Object.assign({ id: uid(), created_at: new Date().toISOString() }, row); if (isLive()) { const { data, error } = await sb().from(table).insert(rec).select(); if (error) throw error; return data[0]; } const rows = lsAll(table); rows.unshift(rec); lsSave(table, rows); return rec; } async function insertMany(table, list) { const recs = list.map(r => Object.assign({ id: uid(), created_at: new Date().toISOString() }, r)); if (isLive()) { const { error } = await sb().from(table).insert(recs); if (error) throw error; return recs; } lsSave(table, recs.concat(lsAll(table))); return recs; } async function update(table, id, patch) { if (isLive()) { const { error } = await sb().from(table).update(patch).eq('id', id); if (error) throw error; return; } lsSave(table, lsAll(table).map(r => r.id === id ? Object.assign({}, r, patch) : r)); } async function remove(table, id) { if (isLive()) { const { error } = await sb().from(table).delete().eq('id', id); if (error) throw error; return; } lsSave(table, lsAll(table).filter(r => r.id !== id)); } async function clear(table) { if (!isLive()) lsSave(table, []); } return { list, insert, insertMany, update, remove, clear, submit, cfg, setCfg, isLive, tables: LT_TABLES, live: LT_SUPABASE }; })(); /* ---- auth: email + password. Signup is instant; email verification is a soft gate that only blocks contact reveals and quote requests. ---- */ const LT_ADMIN_IDS = ['1df44434-2aab-447a-965d-0b23118b92b0']; const LT_FREEMAIL = ['gmail.com', 'yahoo.com', 'yahoo.in', 'hotmail.com', 'outlook.com', 'rediffmail.com', 'live.com', 'icloud.com', 'proton.me']; const LTAuth = (() => { let c = null; const client = () => { if (!window.supabase) return null; if (!c) c = window.supabase.createClient(LT_SUPABASE.url, LT_SUPABASE.key); return c; }; const need = () => { const s = client(); if (!s) throw new Error('Auth library not loaded.'); return s; }; const here = p => location.href.replace(/[^/]*$/, '') + p; const local = (k, v) => { try { return v === undefined ? localStorage.getItem(k) : (localStorage.setItem(k, v), v); } catch (e) { return null; } }; return { client, available: () => !!window.supabase, isFreemail: email => LT_FREEMAIL.includes(String(email).split('@')[1] || ''), async signUp(email, password, meta) { const s = need(); const { data, error } = await s.auth.signUp({ email, password, options: { data: Object.assign({ lt_email_verified: false }, meta || {}) } }); if (error) throw error; /* session is null when "Confirm email" is ON in Supabase */ return { user: data.user, session: data.session }; }, async signIn(email, password) { const s = need(); const { data, error } = await s.auth.signInWithPassword({ email, password }); if (error) throw error; return data.user; }, /* sends the verification / sign-in link that lands back on the dashboard */ async sendVerification(email) { const s = need(); const { error } = await s.auth.signInWithOtp({ email, options: { shouldCreateUser: false, emailRedirectTo: here('dashboard.html?verified=1') } }); if (error) throw error; }, async sendReset(email) { const s = need(); const { error } = await s.auth.resetPasswordForEmail(email, { redirectTo: here('login.html?reset=1') }); if (error) throw error; }, async setPassword(password) { const s = need(); const { error } = await s.auth.updateUser({ password }); if (error) throw error; }, /* call on dashboard load: stamps the account verified after a link click */ async markVerified() { const s = client(); if (!s) return; try { await s.auth.updateUser({ data: { lt_email_verified: true } }); } catch (e) {} local('lt_verified', '1'); }, /* our own namespaced flag — Supabase owns `email_verified` and `email_confirmed_at` and sets both true at signup when "Confirm email" is off, so neither can gate. */ verified(u) { if (u) return (u.user_metadata || {}).lt_email_verified === true; return local('lt_verified') === '1'; }, isAdmin(u) { return !!u && LT_ADMIN_IDS.includes(u.id); }, /* staff check — the same app_admins table the admin panel reads. Returns 'owner' | 'executive' | null. Never throws. */ async staffRole() { const s = client(); if (!s) return null; try { const { data: { user } } = await s.auth.getUser(); if (!user) return null; const { data, error } = await s.from('app_admins').select('role').eq('user_id', user.id).limit(1); if (error || !data || !data.length) return null; return data[0].role || 'owner'; } catch (e) { return null; } }, async user() { const s = client(); if (!s) return null; const { data } = await s.auth.getUser(); return data ? data.user : null; }, async signOut() { const s = client(); if (s) await s.auth.signOut(); try { localStorage.removeItem('lt_signed_in'); localStorage.removeItem('lt_verified'); } catch (e) {} }, }; })(); /* ---- soft verification gate: blocks contact reveals and quote requests only ---- */ const LTVerify = (() => { const get = k => { try { return localStorage.getItem(k); } catch (e) { return null; } }; const set = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} }; return { /* 'demo' = no real account signed in, nothing to nag about */ state() { const v = get('lt_verified'); return v === '1' ? 'verified' : (v === '0' ? 'unverified' : 'demo'); }, async onLoad() { if (!/[?&]verified=1/.test(location.search)) return; await LTAuth.markVerified(); try { history.replaceState({}, '', location.pathname); } catch (e) {} }, async check() { let u = null; try { u = await LTAuth.user(); } catch (e) {} if (!u) { try { localStorage.removeItem('lt_verified'); } catch (e) {} return { state: 'demo', email: '' }; } const ok = LTAuth.verified(u); set('lt_verified', ok ? '1' : '0'); return { state: ok ? 'verified' : 'unverified', email: u.email || '' }; }, allowed() { return this.state() !== 'unverified'; }, blockedMsg: 'Verify your email address before revealing a contact. Use the link we sent you, or the Resend button in the banner above.', }; })(); /* ---- plans ---- */ const LT_PLANS = { contractor: { key: 'contractor', name: 'Labour Contractors', price: 1990, unit: '/ month', reveals: 25, seats: 0 }, epc: { key: 'epc', name: 'EPC / general contractor', price: 27500, price2: 24500, unit: '/ site / month', reveals: 150, seats: 50, bands: [[50, 27500, 24500], [75, 40000, 36000], [100, 51500, 46500]] }, builder: { key: 'builder', name: 'Builder / developer', price: 32500, price2: 28500, unit: '/ site / month', reveals: 300, seats: 50, bands: [[50, 32500, 28500], [75, 47000, 41500], [100, 60500, 53500]] }, }; const GST_RATE = 0.18; const inr = n => '₹' + Number(n || 0).toLocaleString('en-IN'); const withGst = n => Math.round(n * (1 + GST_RATE)); /* ---- sample seed (clearly labelled; replace with your real records) ---- */ const TRADES = ['Masonry', 'Helper', 'Carpentry', 'Bar bending', 'Shuttering', 'Electrical', 'Plumbing', 'Painting', 'Tiling', 'Welding']; const CITIES = ['Pune city', 'PCMC', 'Chakan / Talegaon', 'Wagholi / Shikrapur']; const DISTRICTS = ['Nanded', 'Latur', 'Beed', 'Jalna', 'Gaya (BR)', 'Katihar (BR)', 'Balangir (OD)', 'Sagar (MP)']; const LEVELS = ['Skilled', 'Semi-skilled', 'Helper']; const RATE = { Masonry: 780, Helper: 540, Carpentry: 820, 'Bar bending': 790, Shuttering: 745, Electrical: 910, Plumbing: 880, Painting: 700, Tiling: 830, Welding: 900 }; function seedWorkers(n = 1000) { const out = []; for (let i = 1; i <= n; i++) { const t = TRADES[i % TRADES.length]; const lvl = t === 'Helper' ? 'Helper' : LEVELS[i % 2]; out.push({ id: 'w' + String(i).padStart(4, '0'), code: 'LT-W-' + String(i).padStart(4, '0'), name: 'Sample worker ' + String(i).padStart(4, '0'), mobile: '9' + String(700000000 + i * 137).slice(0, 9), trade: t, level: lvl, experience: (i % 18) + 1, city: CITIES[i % CITIES.length], district: DISTRICTS[i % DISTRICTS.length], availability: i % 4 === 0 ? 'Engaged' : (i % 7 === 0 ? 'Available in 15 days' : 'Available'), day_rate: RATE[t] + ((i % 5) - 2) * 15, id_verified: i % 8 !== 0, consent: true, source: 'Sample data', created_at: new Date().toISOString(), }); } return out; } function seedBusinesses() { const mk = (n, type, city) => ({ id: 'b' + n, name: 'Client ' + n + ' — rename me', type, city, contact: 'Contact person', mobile: '', email: '', gstin: '', status: 'Verified', source: 'Sample data', created_at: new Date().toISOString() }); const rows = []; for (let i = 1; i <= 5; i++) rows.push(mk(String(i).padStart(2, '0'), 'contractor', 'Pune')); for (let i = 6; i <= 7; i++) rows.push(mk(String(i).padStart(2, '0'), 'epc', 'Pune')); for (let i = 8; i <= 12; i++) rows.push(mk(String(i).padStart(2, '0'), 'builder', i % 2 ? 'PCMC' : 'Pune city')); return rows; } async function LTSeedIfEmpty() { if (LTData.isLive()) return; if ((await LTData.list('workers')).length === 0) await LTData.insertMany('workers', seedWorkers()); if ((await LTData.list('businesses')).length === 0) { const biz = seedBusinesses(); await LTData.insertMany('businesses', biz); await LTData.insertMany('subscriptions', biz.map(b => ({ business_id: b.id, business_name: b.name, plan: b.type, cycle: 'monthly', amount: LT_PLANS[b.type].price, status: 'Pending', reveals_used: 0, reveals_limit: LT_PLANS[b.type].reveals, started_at: null, renews_at: null, }))); } } function useTable(table, deps = []) { const [rows, setRows] = React.useState(null); const reload = React.useCallback(() => { LTData.list(table).then(setRows); }, [table]); React.useEffect(() => { reload(); }, deps.concat([reload])); return [rows, reload]; } function csvParse(text) { const lines = text.replace(/\r/g, '').split('\n').filter(l => l.trim()); if (!lines.length) return { head: [], rows: [] }; const split = l => { const out = []; let cur = '', q = false; for (const ch of l) { if (ch === '"') q = !q; else if (ch === ',' && !q) { out.push(cur); cur = ''; } else cur += ch; } out.push(cur); return out.map(s => s.trim()); }; const head = split(lines[0]); return { head, rows: lines.slice(1).map(l => { const c = split(l); const o = {}; head.forEach((h, i) => { o[h] = c[i] || ''; }); return o; }) }; } function numToWords(n) { n = Math.round(Number(n) || 0); if (n === 0) return 'Zero'; const ones = ['', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen']; const tens = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', 'Eighty', 'Ninety']; const two = x => x < 20 ? ones[x] : tens[Math.floor(x / 10)] + (x % 10 ? ' ' + ones[x % 10] : ''); const three = x => (x >= 100 ? ones[Math.floor(x / 100)] + ' Hundred' + (x % 100 ? ' and ' + two(x % 100) : '') : two(x)); const parts = []; const cr = Math.floor(n / 10000000); n %= 10000000; const lk = Math.floor(n / 100000); n %= 100000; const th = Math.floor(n / 1000); n %= 1000; if (cr) parts.push(three(cr) + ' Crore'); if (lk) parts.push(three(lk) + ' Lakh'); if (th) parts.push(three(th) + ' Thousand'); if (n) parts.push(three(n)); return parts.join(' ').trim(); } Object.assign(window, { numToWords, LTData, LTAuth, LT_SUPABASE, LT_PLANS, GST_RATE, inr, withGst, LTSeedIfEmpty, useTable, csvParse, seedWorkers, TRADES, CITIES });