/* Mandera Portal — Data layer (MData). ------------------------------------------------------------------ LOCAL BACKEND: the entire portal runs on this browser-local store (localStorage). It is genuinely functional — every create/edit/ delete/upload persists and is shared across the portal screens in this browser. It is NOT a real multi-device server and is not secure (passwords in plain text). To go live, replace the MData methods with calls to Supabase / Firebase — see README "Going live". ------------------------------------------------------------------ */ const MD_KEY = 'mandera_portal_db_v4'; function _uid(p) { return (p || 'id') + '_' + Math.random().toString(36).slice(2, 9); } function _now() { return Date.now(); } function _date(offsetDays) { const d = new Date(); d.setDate(d.getDate() + offsetDays); return d.toISOString().slice(0, 10); } /* ---- Onboarding schema (dynamic — the form renders itself from this array). Mirrors Mandera's "Client Onboarding Form" PDF, sections 01–13. Field types: text | textarea | chips (+single for single-select). Add `half:true` to pair short text fields 2-up. Edit freely. */ const ONBOARDING_SCHEMA = [ { section: 'Company Information', fields: [ { key: 'companyName', label: 'Company name', type: 'text', half: true, required: true }, { key: 'website', label: 'Website', type: 'text', half: true }, { key: 'contactPerson', label: 'Primary contact person', type: 'text', half: true, required: true }, { key: 'role', label: 'Position / role', type: 'text', half: true }, { key: 'phone', label: 'Phone number', type: 'text', half: true }, { key: 'email', label: 'Email address', type: 'text', half: true, required: true }, { key: 'locations', label: 'Business location(s)', type: 'text' }, { key: 'yearEstablished', label: 'Year established', type: 'text', half: true }, { key: 'employees', label: 'Number of employees', type: 'text', half: true }, ] }, { section: 'Business Overview', fields: [ { key: 'bizDescription', label: 'Describe your business in one paragraph', type: 'textarea' }, { key: 'productsServices', label: 'What products or services do you offer?', type: 'textarea' }, { key: 'topRevenue', label: 'What service / product generates the most revenue?', type: 'text' }, { key: 'growMost', label: 'What service / product would you like to grow the most?', type: 'text' }, { key: 'differentiator', label: 'What makes your business different from competitors?', type: 'textarea' }, { key: 'whyChoose', label: 'Why should a customer choose you over alternatives?', type: 'textarea' }, { key: 'vision', label: "What is your company's vision?", type: 'textarea' }, { key: 'mission', label: "What is your company's mission?", type: 'textarea' }, { key: 'coreValues', label: "What are your company's core values?", type: 'textarea' }, ] }, { section: 'Business Goals', fields: [ { key: 'top3Business', label: 'What are your top 3 business goals over the next 12 months?', type: 'textarea' }, { key: 'top3Marketing', label: 'What are your top 3 marketing goals?', type: 'textarea' }, { key: 'successLooks', label: 'What does success look like for this project?', type: 'textarea' }, { key: 'revenueGoals', label: 'What are your revenue goals this year?', type: 'text' }, { key: 'challenges', label: 'What are your biggest business challenges right now?', type: 'textarea' }, { key: 'keepsAwake', label: 'What keeps you awake at night regarding the business?', type: 'textarea' }, ] }, { section: 'Target Audience', fields: [ { key: 'idealCustomer', label: 'Who is your ideal customer?', type: 'textarea' }, { key: 'ageRange', label: 'Age range', type: 'text', half: true }, { key: 'gender', label: 'Gender', type: 'text', half: true }, { key: 'audLocation', label: 'Location', type: 'text', half: true }, { key: 'incomeLevel', label: 'Income level', type: 'text', half: true }, { key: 'occupation', label: 'Occupation', type: 'text', half: true }, { key: 'audProblems', label: 'What problems do they face?', type: 'textarea' }, { key: 'audMotivates', label: 'What motivates them to buy?', type: 'textarea' }, { key: 'audObjections', label: 'What objections stop them from buying?', type: 'textarea' }, { key: 'audEmotions', label: 'What emotions usually influence their purchase decisions?', type: 'textarea' }, { key: 'misconceptions', label: 'What misconceptions do customers have about your industry?', type: 'textarea' }, { key: 'notAudience', label: 'Who is NOT your target audience?', type: 'text' }, ] }, { section: 'Competitor Analysis', fields: [ { key: 'topCompetitors', label: 'List your top 5 competitors', type: 'textarea' }, { key: 'compBetter', label: 'What do they do better than you?', type: 'textarea' }, { key: 'youBetter', label: 'What do you do better than them?', type: 'textarea' }, { key: 'admireCompetitors', label: 'Which competitors do you admire?', type: 'text' }, { key: 'admiredBrands', label: 'Are there any brands (inside or outside your industry) whose content you like?', type: 'textarea' }, { key: 'admiredLinks', label: 'Please provide links', type: 'textarea' }, ] }, { section: 'Branding', fields: [ { key: 'brandPersonality', label: 'How would you describe your brand personality?', type: 'textarea' }, { key: 'brandFeel', label: 'How do you want people to feel when they interact with your brand?', type: 'textarea' }, { key: 'brandWords', label: 'What words should people associate with your brand?', type: 'textarea' }, { key: 'brandNeverWords', label: 'What words should people NEVER associate with your brand?', type: 'textarea' }, { key: 'brandAssetsHave', label: 'Do you currently have:', type: 'chips', options: ['Logo', 'Brand Guidelines', 'Brand Colors', 'Brand Fonts'] }, { key: 'brandSatisfied', label: 'Are you satisfied with your current branding?', type: 'chips', options: ['Yes', 'No'], single: true }, { key: 'brandImprovements', label: 'What branding improvements would you like to see?', type: 'textarea' }, ] }, { section: 'Content & Social Media', fields: [ { key: 'activePlatforms', label: 'Which platforms are currently active?', type: 'chips', options: ['Instagram', 'Facebook', 'TikTok', 'LinkedIn', 'YouTube', 'Other'] }, { key: 'socialLinks', label: 'Please provide all social media links', type: 'textarea' }, { key: 'bestContent', label: 'Which content has performed best so far?', type: 'textarea' }, { key: 'worstContent', label: 'Which content has performed worst?', type: 'textarea' }, { key: 'enjoyContent', label: 'What type of content do you enjoy creating?', type: 'text' }, { key: 'dislikeContent', label: 'What type of content do you dislike?', type: 'text' }, { key: 'onCamera', label: 'Are you comfortable appearing on camera?', type: 'chips', options: ['Yes', 'No'], single: true }, { key: 'whoOnCamera', label: 'Who from the team can appear on camera?', type: 'textarea' }, { key: 'avoidTopics', label: 'Are there any topics we should avoid?', type: 'textarea' }, { key: 'existingMedia', label: 'Do you have existing photo / video content we can use?', type: 'text' }, { key: 'testimonials', label: 'Do you have testimonials, reviews, or customer success stories?', type: 'textarea' }, ] }, { section: 'Sales & Conversion', fields: [ { key: 'howFind', label: 'How do customers currently find you?', type: 'textarea' }, { key: 'customerJourney', label: 'What is your average customer journey?', type: 'textarea' }, { key: 'afterLead', label: 'What happens after a lead contacts you?', type: 'textarea' }, { key: 'handlesSales', label: 'Who handles sales inquiries?', type: 'text' }, { key: 'responseTime', label: 'What is your average response time?', type: 'text' }, { key: 'commonQuestions', label: 'What are the most common customer questions?', type: 'textarea' }, { key: 'commonObjections', label: 'What are the most common objections before purchase?', type: 'textarea' }, { key: 'conversionRate', label: 'What percentage of leads typically convert into customers?', type: 'text', half: true }, { key: 'customerValue', label: 'What is the average value of a customer?', type: 'text', half: true }, ] }, { section: 'Marketing History', fields: [ { key: 'workedAgency', label: 'Have you worked with a marketing agency before?', type: 'chips', options: ['Yes', 'No'], single: true }, { key: 'agencyWorked', label: 'What worked well?', type: 'textarea' }, { key: 'agencyFailed', label: "What didn't work?", type: 'textarea' }, { key: 'ranPaidAds', label: 'Have you run paid advertising before?', type: 'chips', options: ['Yes', 'No'], single: true }, { key: 'adPlatform', label: 'Paid ads — platform', type: 'text', half: true }, { key: 'adBudget', label: 'Paid ads — budget', type: 'text', half: true }, { key: 'adResults', label: 'Paid ads — results', type: 'text' }, { key: 'currentMarketing', label: 'What marketing activities are you currently doing?', type: 'textarea' }, ] }, { section: 'Content Production', fields: [ { key: 'filmingSessions', label: 'How many filming sessions can you accommodate monthly?', type: 'text' }, { key: 'coordinator', label: 'Who will coordinate with our team?', type: 'text' }, { key: 'equipment', label: 'What equipment do you currently have?', type: 'textarea' }, { key: 'recordIndependently', label: 'Can your team record footage independently if needed?', type: 'chips', options: ['Yes', 'No'], single: true }, { key: 'featureLocations', label: 'Are there specific locations we should feature?', type: 'textarea' }, { key: 'upcomingEvents', label: 'Are there upcoming events, launches, or promotions?', type: 'textarea' }, ] }, { section: 'KPIs & Success Metrics', fields: [ { key: 'metrics', label: 'What metrics matter most to you?', type: 'chips', options: ['Revenue', 'Leads', 'Messages', 'Calls', 'Website Visits', 'Followers', 'Reach', 'Brand Awareness'], hint: 'Select all that matter' }, { key: 'success3mo', label: 'What would make you consider this project a success after 3 months?', type: 'textarea' }, { key: 'success12mo', label: 'What would make you consider this project a success after 12 months?', type: 'textarea' }, ] }, { section: 'Access & Assets', fields: [ { key: 'provideAccess', label: 'Please provide access to', type: 'chips', options: ['Instagram', 'Facebook', 'TikTok', 'LinkedIn', 'Website', 'Google Business Profile', 'Meta Business Suite'], hint: 'Tick once shared' }, { key: 'linkInstagram', label: 'Instagram link', type: 'text', half: true, hint: 'Paste your profile URL' }, { key: 'linkFacebook', label: 'Facebook link', type: 'text', half: true, hint: 'Paste your page URL' }, { key: 'linkTiktok', label: 'TikTok link', type: 'text', half: true }, { key: 'linkLinkedin', label: 'LinkedIn link', type: 'text', half: true }, { key: 'linkYoutube', label: 'YouTube link', type: 'text', half: true }, { key: 'linkWebsite', label: 'Website link', type: 'text', half: true }, { key: 'linkOther', label: 'Any other links', type: 'textarea', hint: 'Paste any additional social or profile links here' }, { key: 'uploadAssets', label: 'Upload', type: 'chips', options: ['Logo files', 'Brand assets', 'Existing content', 'Product photos', 'Team photos', 'Testimonials'], hint: 'Tick once provided' }, ] }, { section: 'Final Questions', fields: [ { key: 'wishUnderstood', label: 'If there is one thing you wish more customers understood about your business, what would it be?', type: 'textarea' }, { key: 'twiceSuccessful', label: 'If your company became twice as successful in the next year, what would that look like?', type: 'textarea' }, { key: 'anythingElse', label: 'Is there anything else we should know before building your strategy?', type: 'textarea' }, ] }, ]; /* ---- Seed (PRODUCTION) ------------------------------------------- Real owner accounts only. No demo clients or sample data — the portal starts empty and Mandera builds it up from the Accounts and Clients screens. */ function _seed() { const accounts = [ { id: 'u_zakaria', role: 'master', name: 'Zakaria', email: 'zakaria@gttjo.com', password: 'Kareem@2009', status: 'active', onboarded: true, clientId: null }, { id: 'u_faisal', role: 'master', name: 'Faisal', email: 'faisal@manderamarketing.com', password: 'Hashem@2004', status: 'active', onboarded: true, clientId: null }, ]; return { clients: [], accounts, projects: [], shoots: [], calendar: [], timeline: [], files: [], revisions: [], messages: [], notifications: [], invoices: [], teamMessages: [], onboarding: {}, schema: ONBOARDING_SCHEMA, }; } /* ---- Shared-backend sync ------------------------------------------------ MData keeps its synchronous API (screens are unchanged) but now also WRITES THROUGH to a shared MySQL backend (api/portal-api.php) and POLLS it, so accounts / messages / everything sync across devices and people. If the backend is unreachable (e.g. local preview, offline), it silently falls back to the browser-local store so the portal still works. */ const MD_OUTBOX = MD_KEY + '_outbox'; function _loadOutbox() { try { return JSON.parse(localStorage.getItem(MD_OUTBOX)) || []; } catch (e) { return []; } } function _saveOutbox(o) { try { localStorage.setItem(MD_OUTBOX, JSON.stringify(o)); } catch (e) {} } const MData = { _db: null, _serverTime: 0, _outbox: _loadOutbox(), _online: false, _flushing: false, _polling: false, API: (typeof window !== 'undefined' && window.MANDERA_PORTAL_API) || '../api/portal-api.php', load() { if (MData._db) return MData._db; let v = null; try { v = JSON.parse(localStorage.getItem(MD_KEY)); } catch (e) {} if (!v || !v.accounts) { v = _seed(); } else if (!v.schema) v.schema = ONBOARDING_SCHEMA; MData._db = v; MData._ensureMasters(); MData._persist(); return v; }, /* Guarantee the two owner accounts always exist locally so master sign-in works even if the stored data somehow lost them. Never overwrites an existing account (a changed password is preserved). */ _ensureMasters() { const db = MData._db; if (!Array.isArray(db.accounts)) db.accounts = []; _seed().accounts.forEach((m) => { if (!db.accounts.some((a) => a.id === m.id || (a.email && a.email === m.email))) db.accounts.unshift(m); }); }, _persist() { try { localStorage.setItem(MD_KEY, JSON.stringify(MData._db)); return true; } catch (e) { console.warn('Portal storage full — media may be too large to keep locally.', e); return false; } }, reset() { MData._db = _seed(); MData._serverTime = 0; MData._outbox = []; _saveOutbox([]); MData._persist(); }, _emit() { try { window.dispatchEvent(new CustomEvent('mdata-changed')); } catch (e) {} }, isOnline() { return MData._online; }, /* ---- sync engine ---- */ _queue(change) { MData._outbox.push(change); _saveOutbox(MData._outbox); MData._flush(); }, async _flush() { if (MData._flushing || !MData._outbox.length) return; MData._flushing = true; const batch = MData._outbox.slice(); try { const res = await fetch(MData.API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'push', changes: batch }) }); const j = await res.json(); if (j && j.ok) { MData._outbox = MData._outbox.slice(batch.length); _saveOutbox(MData._outbox); MData._online = true; } } catch (e) { MData._online = false; } finally { MData._flushing = false; } }, _applyRecord(rec) { const db = MData._db; if (rec.coll === 'onboarding') { if (rec.deleted) { delete db.onboarding[rec.id]; } else { db.onboarding[rec.id] = rec.data; } return; } if (rec.coll === 'schema') return; // schema is static, not synced if (!Array.isArray(db[rec.coll])) db[rec.coll] = []; const arr = db[rec.coll]; const i = arr.findIndex((x) => x.id === rec.id); if (rec.deleted) { if (i !== -1) arr.splice(i, 1); return; } const val = Object.assign({ id: rec.id }, rec.data); if (i === -1) arr.unshift(val); else arr[i] = val; }, async sync() { MData.load(); await MData._flush(); try { const res = await fetch(MData.API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'pull', since: MData._serverTime }) }); const j = await res.json(); if (j && j.ok && Array.isArray(j.records)) { for (const rec of j.records) { MData._applyRecord(rec); if (rec.updated > MData._serverTime) MData._serverTime = rec.updated; } MData._db.schema = ONBOARDING_SCHEMA; MData._online = true; MData._persist(); if (j.records.length) MData._emit(); } } catch (e) { MData._online = false; } }, startSync(intervalMs) { if (MData._polling) return; MData._polling = true; MData.sync(); setInterval(() => MData.sync(), intervalMs || 7000); window.addEventListener('focus', () => MData.sync()); }, /* collections */ get(coll) { return MData.load()[coll] || []; }, forClient(coll, clientId) { return MData.get(coll).filter((x) => x.clientId === clientId); }, add(coll, rec) { const db = MData.load(); if (!Array.isArray(db[coll])) db[coll] = []; const r = { id: _uid(coll.slice(0, 2)), ...rec }; db[coll].unshift(r); MData._persist(); MData._queue({ coll, id: r.id, data: r }); MData._emit(); return r; }, update(coll, id, patch) { const db = MData.load(); if (!Array.isArray(db[coll])) db[coll] = []; const r = db[coll].find((x) => x.id === id); if (r) { Object.assign(r, patch); MData._persist(); MData._queue({ coll, id, data: r }); MData._emit(); } return r; }, del(coll, id) { const db = MData.load(); if (!Array.isArray(db[coll])) db[coll] = []; db[coll] = db[coll].filter((x) => x.id !== id); MData._persist(); MData._queue({ coll, id, deleted: true }); MData._emit(); }, /* clients */ clients() { return MData.get('clients'); }, client(id) { return MData.clients().find((c) => c.id === id) || null; }, /* onboarding */ onboardingOf(clientId) { return MData.load().onboarding[clientId] || { status: 'pending', data: {} }; }, saveOnboarding(clientId, data, status) { const db = MData.load(); const rec = { status: status || 'submitted', submittedAt: _now(), data }; db.onboarding[clientId] = rec; MData._persist(); MData._queue({ coll: 'onboarding', id: clientId, data: rec }); MData._emit(); }, setOnboardingStatus(clientId, status) { const db = MData.load(); if (db.onboarding[clientId]) { db.onboarding[clientId].status = status; MData._persist(); MData._queue({ coll: 'onboarding', id: clientId, data: db.onboarding[clientId] }); MData._emit(); } }, schema() { return MData.load().schema || ONBOARDING_SCHEMA; }, /* ---- notifications ------------------------------------------------ Write a notification. Target EITHER a client (clientId — shows in that client's Notifications) OR the Mandera team (audience:'team' — shows in every team/master account's Notifications). `link` is the portal section to open when the notification is pressed; `refId` points at the specific record (e.g. a shoot id) to highlight. */ notify(opts) { opts = opts || {}; return MData.add('notifications', { clientId: opts.clientId || null, audience: opts.audience || null, type: opts.type || 'bell', text: opts.text || '', link: opts.link || null, refId: opts.refId || null, ts: Date.now(), read: false, }); }, teamNotifications() { return MData.get('notifications').filter((n) => n.audience === 'team'); }, /* ---- cascade delete a client --------------------------------------- Removes the client record AND everything belonging to it (shoots, content, timeline, files, revisions, messages, notifications, invoices, onboarding) AND every account linked to that client. This is what makes "delete the client" actually clear the workspace. */ deleteClient(clientId) { if (!clientId) return; ['shoots', 'calendar', 'timeline', 'files', 'revisions', 'messages', 'notifications', 'invoices'].forEach((coll) => { MData.get(coll).filter((x) => x.clientId === clientId).forEach((x) => MData.del(coll, x.id)); }); // team notifications that merely reference this client (refId) — clear them too MData.get('notifications').filter((n) => n.refId === clientId).forEach((n) => MData.del('notifications', n.id)); // linked accounts (the client sign-in) MData.get('accounts').filter((a) => a.clientId === clientId).forEach((a) => MData.del('accounts', a.id)); // onboarding submission const db = MData.load(); if (db.onboarding[clientId]) { delete db.onboarding[clientId]; MData._persist(); MData._queue({ coll: 'onboarding', id: clientId, deleted: true }); } MData.del('clients', clientId); MData._emit(); }, }; Object.assign(window, { MData, ONBOARDING_SCHEMA });