☕ Optimize لتجار القهوة

خطتك وإعلانك — جهزهم مع Optimize ☕

تجاوب على كم سؤال بسيط، وتطلع بخطة إعلانية جاهزة — وتقدر تحوّلها لإعلان فعلي بالذكاء الاصطناعي.

📋خطة
←
✨إعلان
←
🚀إطلاق
44×
عائد فعلي على الصرف حققته حملة قهوة وحدة على Optimize عبر Spark Ads — نتائج كل تاجر تختلف.
`); w.document.close(); w.onload = ()=>{ try{ w.print(); }catch(e2){} }; } else { UIController.toast('افتح نافذة جديدة مسموح بها للمتصفح عشان نقدر نطبع الخطة'); } } EventTracker.log('pdf_downloaded'); } }; /* ============================================================================ 12. StateManager — single mutable state tree + localStorage persistence. ============================================================================ */ const StateManager = { state: { step: 1, leadId: null, answers: { country:null, name:'', phone:'', optimizeStatus:null, businessName:'', sellingStatus:null, salesChannel:null, primaryBusinessUrl:'', adExperience:null, previousPlatforms:[], campaignGoal:null, promoteTarget:null, promoteOther:'', monthlyBudget:null, contentReadiness:[] }, plan: null, planVersion: 1 }, init(){ const saved = StorageService.get('answers'); if(saved) this.state.answers = Object.assign(this.state.answers, saved); this.state.leadId = StorageService.get('lead_id'); this.state.plan = StorageService.get('plan'); this.state.planVersion = StorageService.get('plan_version', 1); }, set(patch){ Object.assign(this.state.answers, patch); StorageService.set('answers', this.state.answers); }, persistPlan(plan){ this.state.plan = plan; StorageService.set('plan', plan); }, ensureLeadId(){ if(!this.state.leadId){ this.state.leadId = Util.uid('lead'); StorageService.set('lead_id', this.state.leadId); } return this.state.leadId; }, reset(){ StorageService.clearAll(); // Reset in-memory state directly rather than relying solely on // location.reload() to reinitialize it — a sandboxed iframe can make // full-page reload unreliable, so we don't want the reset to depend on // a single browser API that might silently do nothing. this.state.answers = { country:null, name:'', phone:'', optimizeStatus:null, businessName:'', sellingStatus:null, salesChannel:null, primaryBusinessUrl:'', adExperience:null, previousPlatforms:[], campaignGoal:null, promoteTarget:null, promoteOther:'', monthlyBudget:null, contentReadiness:[] }; this.state.plan = null; this.state.planVersion = 1; this.state.leadId = null; try{ location.hash=''; }catch(e){} try{ UIController.showView('landing'); EventTracker.log('landing_view'); }catch(e){} try{ location.reload(); }catch(e){} }, }; /* ============================================================================ 13. UIController — rendering + wizard flow + results + modals. ============================================================================ */ const UIController = { goalOptions: [ { id:'sales', label:'مبيعات', icon:'💰' }, { id:'awareness', label:'وعي', icon:'📣' }, { id:'profile_visits', label:'زيارات للحساب', icon:'📈' } ], channelOptions: [ { id:'store', label:'متجر إلكتروني' }, { id:'instagram', label:'Instagram' }, { id:'link', label:'رابط خاص' } ], goalLabel(id){ return (this.goalOptions.find(g=>g.id===id)||{}).label || '—'; }, /* System infers audience instead of asking the merchant to self-identify — "does the merchant need to decide this, or can we?" test. */ inferAudienceLine(a){ if(a.sellingStatus!=='selling') return 'جمهور مستهدف: عملاء محتملين يكتشفون علامتك لأول مرة.'; if(a.campaignGoal==='awareness') return 'جمهور مستهدف: عملاء جدد ما يعرفون علامتك بعد.'; if(a.campaignGoal==='profile_visits') return 'جمهور مستهدف: متابعينك الحاليين ومهتمين جدد بمحتواك.'; // Never claim a lookalike-to-your-existing-customers audience — the // planner collects no customer list/CRM/pixel data that would make that // true; only interest- and intent-based targeting is actually possible. return 'جمهور مستهدف: عملاء مهتمين بالشراء الآن، بالإضافة لجمهور جديد مهتم بالقهوة.'; }, init(){ EventTracker.init(); StateManager.init(); this.bindGlobalEvents(); this.route(); }, route(){ const params = new URLSearchParams(location.search); // Django serves the dashboard at /team/ behind @login_required; ?view=admin // is kept so an existing bookmark still works. if(params.get('view')==='admin' || location.pathname.startsWith('/team')){ AdminDashboard.init(); return; } document.getElementById('admin-view').classList.add('hidden'); const hasPlan = !!StateManager.state.plan; if(hasPlan){ EventTracker.log('return_visit'); this.showView('returning'); this.renderReturning(); } else { EventTracker.log('landing_view'); this.showView('landing'); } }, showView(name){ Util.qsa('.view').forEach(v=>v.classList.add('hidden')); document.getElementById('view-'+name).classList.remove('hidden'); document.getElementById('topnav-progress').classList.toggle('hidden', name!=='wizard'); window.scrollTo({top:0, behavior:'instant' in window ? 'instant':'auto'}); }, bindGlobalEvents(){ document.getElementById('nav-word-slot').innerHTML = LOGO_WORD_SVG; document.getElementById('loading-icon-slot').innerHTML = LOGO_ICON_SVG; document.getElementById('returning-icon-slot').innerHTML = LOGO_ICON_SVG; document.getElementById('footer-logo-slot').innerHTML = LOGO_ICON_SVG; document.getElementById('start-tool-btn').addEventListener('click', ()=>{ EventTracker.log('tool_started'); StateManager.state.step = 1; this.showView('wizard'); this.renderStep(1); }); // AI Creator is visible and directly reachable from the cover — it must // not be hidden behind the full planner. Opening it here works with no // planner_context (all fields optional/nullable in the data contract). document.getElementById('start-creative-btn').addEventListener('click', ()=>{ this.openCreativeWizard('hero'); }); document.getElementById('nav-logo-link').addEventListener('click', (e)=>{ e.preventDefault(); location.href = location.pathname; }); // Modal close buttons Util.qsa('[data-close-modal]').forEach(btn=>{ btn.addEventListener('click', ()=> this.closeModal(btn.getAttribute('data-close-modal'))); }); Util.qsa('.modal-overlay').forEach(ov=>{ ov.addEventListener('click', (e)=>{ if(e.target===ov) this.closeModal(ov.id); }); }); // Returning user actions document.getElementById('open-plan-btn').addEventListener('click', ()=>{ this.renderResults(StateManager.state.plan); }); document.getElementById('update-plan-btn').addEventListener('click', ()=>{ EventTracker.log('plan_update_started'); StateManager.state.step = 3; // reopens the editable core (goal/promotion → budget/content) — not name/phone, which is already known for a returning user. this.showView('wizard'); this.renderStep(3, {isUpdateFlow:true}); }); // Restart flow uses an inline confirm row, not window.confirm() — native // dialogs are frequently blocked or silently no-op inside a sandboxed // iframe (exactly how this artifact renders inside claude.ai), which // made this button appear completely non-functional. const bindRestartButton = ()=>{ const btn = document.getElementById('restart-fresh-btn'); if(!btn) return; btn.addEventListener('click', ()=>{ const zone = document.getElementById('restart-zone'); zone.innerHTML = `

بيتم مسح خطتك الحالية وتبدأ من جديد. متأكد؟

`; document.getElementById('restart-confirm-yes').addEventListener('click', ()=>{ EventTracker.log('plan_reset'); StateManager.reset(); }); document.getElementById('restart-confirm-no').addEventListener('click', ()=>{ zone.innerHTML = ``; bindRestartButton(); }); }); }; bindRestartButton(); // Results CTAs — static/utility ones bound once; the primary (AI) and // secondary (launch) action buttons are wired per-render inside // renderResults() since their onclick handlers close over that render's // `plan`/`a` state. document.getElementById('cta-whatsapp').addEventListener('click', ()=>{ EventTracker.log('whatsapp_clicked'); window.open(CONFIG.links.whatsappUrl,'_blank'); }); document.getElementById('cta-meeting').addEventListener('click', ()=>{ EventTracker.log('meeting_clicked'); window.open(CONFIG.links.meetingUrl,'_blank'); }); document.getElementById('cta-pdf').addEventListener('click', ()=> PDFService.download(StateManager.state.plan, StateManager.state.answers)); document.getElementById('cta-help').addEventListener('click', ()=> this.openHelpModal()); document.getElementById('update-plan-cta').addEventListener('click', ()=> document.getElementById('update-plan-btn').click()); // Help modal this.renderHelpOptions(); document.getElementById('help-submit').addEventListener('click', ()=> this.submitHelp()); // Share modal removed (spec: "share my plan" feature dropped — PDF export is the sharing path). document.getElementById('admin-exit-btn') && document.getElementById('admin-exit-btn').addEventListener('click', ()=>{ location.href = location.pathname; }); }, renderReturning(){ const a = StateManager.state.answers; document.getElementById('returning-title').textContent = `أهلًا ${a.name || ''} 👋`; }, toast(msg){ const t = document.getElementById('toast'); t.innerHTML = msg; t.classList.add('show'); clearTimeout(this._toastTimer); this._toastTimer = setTimeout(()=>t.classList.remove('show'), 2400); }, openModal(id){ document.getElementById(id).classList.add('open'); document.body.classList.add('modal-locked'); }, closeModal(id){ document.getElementById(id).classList.remove('open'); document.body.classList.remove('modal-locked'); if(id==='creative-modal') this.invalidateCreativeSession(); }, renderHelpOptions(){ const wrap = document.getElementById('help-options'); wrap.innerHTML = CONFIG.helpOptions.map((o,i)=>`
${o}
`).join(''); Util.qsa('.opt-row', wrap).forEach(row=>{ row.addEventListener('click', ()=>{ Util.qsa('.opt-row', wrap).forEach(r=>r.classList.remove('selected')); row.classList.add('selected'); this._helpSelected = CONFIG.helpOptions[+row.dataset.help]; }); }); }, openHelpModal(){ EventTracker.log('help_requested', {stage:'opened'}); this.openModal('help-modal'); }, async submitHelp(){ const note = document.getElementById('help-note').value.slice(0,200); const payload = { id:Util.uid('contact'), leadId:StateManager.ensureLeadId(), reason:this._helpSelected||'شيء ثاني', note, timestamp:new Date().toISOString(), name:StateManager.state.answers.name, phone:StateManager.state.answers.phone }; await ApiAdapter.submitContactRequest(payload); EventTracker.log('help_requested', {stage:'submitted', reason:payload.reason}); this.closeModal('help-modal'); this.toast('وصلنا طلبك ✅ بنتواصل معك قريب'); } }; /* ---- UIController: wizard step rendering & navigation ---- */ Object.assign(UIController, { STEP_META: { 1:{ kicker:'حسابك', title:'تستخدم Optimize حاليًا؟', help:'' }, 2:{ kicker:'نشاطك', title:'عندك منتج أو خدمة تبيعها حاليًا؟', help:'هذا يساعدنا نعرف شلون نوصل لعميلك.' }, 3:{ kicker:'هدفك', title:'وش هدفك من حملتك؟', help:'اختر الهدف الأقرب لك حاليًا — وبنبني توصية حملتك عليه.' }, 4:{ kicker:'ميزانيتك', title:'كم تقريبًا ميزانيتك الإعلانية بالشهر؟', help:'رقم تقريبي يكفي — تقدر تعدله بعدين.' }, 5:{ kicker:'', title:'خلصنا تقريبًا ☕', help:'نحتاج اسمك ورقمك عشان نحفظ خطتك.' } }, TOTAL_STEPS: 5, renderStep(n, opts){ opts = opts||{}; StateManager.state.step = n; const meta = this.STEP_META[n]; document.getElementById('progress-label').textContent = meta.kicker; document.getElementById('progress-label').removeAttribute('dir'); document.getElementById('progress-fill').style.width = (n/this.TOTAL_STEPS*100)+'%'; const card = document.getElementById('wizard-card'); card.innerHTML = ` ${meta.kicker ? `
${meta.kicker}
` : ''}

${meta.title}

${meta.help ? `

${meta.help}

` : ''}
`; document.getElementById('wz-back').addEventListener('click', ()=>{ if(n===1){ this.showView('landing'); return; } this.renderStep(n-1, opts); }); document.getElementById('wz-next').addEventListener('click', ()=>{ if(n===this.TOTAL_STEPS){ this.finishWizard(opts); return; } this.renderStep(n+1, opts); }); const renderers = { 1:this.renderStepBody1, 2:this.renderStepBody2, 3:this.renderStepBody3, 4:this.renderStepBody4, 5:this.renderStepBody5 }; renderers[n].call(this, document.getElementById('step-body'), opts); this.updatePreview(); }, setNextEnabled(ok){ const b=document.getElementById('wz-next'); if(b) b.disabled = !ok; }, /* ---------------- STEP 1: Optimize account + prior advertising experience (merged) ---------------- */ renderStepBody1(root){ const a = StateManager.state.answers; root.innerHTML = `
نعم، عندي حساب
لا، أول مرة
هل سبق وجرّبت تسوي حملات إعلانية؟
نعم، سبق وسوّيت
لا، أول مرة
`; const check = ()=>{ const optOk = !!a.optimizeStatus && (a.optimizeStatus==='no' || (a.businessName||'').trim().length>1); this.setNextEnabled(optOk && !!a.adExperience); }; Util.qsa('#opt-status .opt-row', root).forEach(row=>{ if(row.dataset.v===a.optimizeStatus) row.classList.add('selected'); row.addEventListener('click', ()=>{ Util.qsa('#opt-status .opt-row', root).forEach(r=>r.classList.remove('selected')); row.classList.add('selected'); StateManager.set({optimizeStatus:row.dataset.v}); EventTracker.log('optimize_status_selected', {value:row.dataset.v}); document.getElementById('block-linking').classList.toggle('hidden', row.dataset.v!=='yes'); check(); }); }); const bizInp = document.getElementById('inp-business'); bizInp.addEventListener('input', Util.debounce(()=>{ StateManager.set({businessName:bizInp.value.trim()}); check(); },150)); if(a.optimizeStatus==='yes') document.getElementById('block-linking').classList.remove('hidden'); Util.qsa('#opt-adexp .opt-row', root).forEach(row=>{ if(row.dataset.v===a.adExperience) row.classList.add('selected'); row.addEventListener('click', ()=>{ Util.qsa('#opt-adexp .opt-row', root).forEach(r=>r.classList.remove('selected')); row.classList.add('selected'); StateManager.set({adExperience:row.dataset.v}); EventTracker.log('ad_experience_selected', {value:row.dataset.v}); document.getElementById('block-prev-platforms').classList.toggle('hidden', row.dataset.v!=='yes'); // "no" invalidates any previously-picked platforms — a merchant who // flips from "yes + TikTok" back to "no" must not leave TikTok // sitting in state where a future edit could silently reuse it. if(row.dataset.v!=='yes') StateManager.set({previousPlatforms:[]}); check(); }); }); const platGrid = document.getElementById('prev-plat-grid'); Util.qsa('.opt-card', platGrid).forEach(card=>{ if((a.previousPlatforms||[]).includes(card.dataset.v)) card.classList.add('selected'); card.addEventListener('click', ()=>{ card.classList.toggle('selected'); StateManager.set({previousPlatforms:Util.qsa('.opt-card.selected',platGrid).map(c=>c.dataset.v)}); }); }); if(a.adExperience==='yes') document.getElementById('block-prev-platforms').classList.remove('hidden'); check(); }, /* ---------------- STEP 2: Selling status + one flexible business URL ---------------- */ renderStepBody2(root){ const a = StateManager.state.answers; root.innerHTML = `
نعم، أبيع الآن
أستعد للإطلاق قريب
حاليًا أستكشف فقط
`; const urlOk = ()=> a.sellingStatus!=='selling' || Util.isValidUrl(a.primaryBusinessUrl); const check = ()=> this.setNextEnabled(!!a.sellingStatus && (a.sellingStatus!=='selling' || (!!a.salesChannel && urlOk()))); Util.qsa('#opt-selling .opt-row', root).forEach(row=>{ if(row.dataset.v===a.sellingStatus) row.classList.add('selected'); row.addEventListener('click', ()=>{ Util.qsa('#opt-selling .opt-row', root).forEach(r=>r.classList.remove('selected')); row.classList.add('selected'); StateManager.set({sellingStatus:row.dataset.v}); EventTracker.log('selling_status_selected', {value:row.dataset.v}); document.getElementById('block-channel').classList.toggle('hidden', row.dataset.v!=='selling'); // Leaving "selling" invalidates the channel/URL collected for it — // an "exploring" merchant must never carry a stale store URL into // the lead record or the readiness/recommendation engines. if(row.dataset.v!=='selling') StateManager.set({salesChannel:null, primaryBusinessUrl:''}); this.updatePreview(); check(); }); }); const channelGrid = document.getElementById('channel-grid'); Util.qsa('.opt-row', channelGrid).forEach(row=>{ if(row.dataset.v===a.salesChannel) row.classList.add('selected'); row.addEventListener('click', ()=>{ Util.qsa('.opt-row', channelGrid).forEach(r=>r.classList.remove('selected')); row.classList.add('selected'); StateManager.set({salesChannel:row.dataset.v}); EventTracker.log('sales_channel_selected', {channel:row.dataset.v}); check(); }); }); if(a.sellingStatus==='selling') document.getElementById('block-channel').classList.remove('hidden'); // BUG FIX: same class of bug as the AI registration gate — `this.value` // inside a function passed through Util.debounce never binds to the // input element (debounce wraps callbacks in arrow functions, which // don't forward `this`), so every keystroke here silently threw and the // required URL field could never actually satisfy validation. Fixed by // capturing the element directly via closure. const urlInp = document.getElementById('inp-url'); const showUrlError = ()=>{ const val = (a.primaryBusinessUrl||'').trim(); urlInp.closest('.field').classList.toggle('has-error', val.length>0 && !Util.isValidUrl(val)); }; urlInp.addEventListener('input', Util.debounce(()=>{ StateManager.set({primaryBusinessUrl:urlInp.value.trim()}); check(); showUrlError(); },150)); showUrlError(); check(); }, /* ---------------- STEP 3: Goal (single-select, exactly 3) + compact promotion chips ---------------- */ renderStepBody3(root){ const a = StateManager.state.answers; root.innerHTML = `
${this.goalOptions.map(o=>`
${o.icon} ${o.label}
`).join('')}
وش ناوي تروّج له؟ اختياري
${CONFIG.promotionOptions.map(o=>`
✓${o.label}
`).join('')}
`; const check = ()=> this.setNextEnabled(!!a.campaignGoal); const goalGrid = document.getElementById('goal-grid'); Util.qsa('.opt-row', goalGrid).forEach(row=>{ if(row.dataset.v===a.campaignGoal) row.classList.add('selected'); row.addEventListener('click', ()=>{ Util.qsa('.opt-row', goalGrid).forEach(r=>r.classList.remove('selected')); row.classList.add('selected'); StateManager.set({campaignGoal:row.dataset.v}); EventTracker.log('goal_selected', {value:row.dataset.v}); this.updatePreview(); check(); }); }); const promoGrid = document.getElementById('promote-grid'); Util.qsa('.opt-card', promoGrid).forEach(card=>{ if(card.dataset.v===a.promoteTarget) card.classList.add('selected'); card.addEventListener('click', ()=>{ Util.qsa('.opt-card', promoGrid).forEach(c=>c.classList.remove('selected')); card.classList.add('selected'); StateManager.set({promoteTarget:card.dataset.v}); document.getElementById('promo-other-field').classList.toggle('hidden', card.dataset.v!=='other'); }); }); if(a.promoteTarget==='other') document.getElementById('promo-other-field').classList.remove('hidden'); document.getElementById('inp-promo-other').addEventListener('input', function(){ StateManager.set({promoteOther:this.value.trim()}); }); check(); }, /* ---------------- STEP 4: Budget (slider only, no presets) + content readiness (3 categories) ---------------- */ /* ---------------- STEP 4: Country (required first) + Budget + Content ---------------- Root-cause fix: country determines currency and budget bounds, but was previously only asked in Step 5 — AFTER the budget slider had already rendered using a hardcoded Saudi fallback for every other market. A Kuwaiti merchant could set "1,500" thinking SAR, then only pick "Kuwait" afterward, at which point that same raw 1,500 silently became KWD — a data-contract bug, not a cosmetic one. Country is now mandatory here, first, before any budget number is ever shown or stored. */ renderStepBody4(root){ const a = StateManager.state.answers; if(!a.country){ root.innerHTML = `
وين نشاطك؟
${Object.keys(CONFIG.countries).map(code=>{ const cc = CONFIG.countries[code]; return `
✓${cc.flag}${cc.name}
`; }).join('')}
`; Util.qsa('.opt-card', root).forEach(card=>{ card.addEventListener('click', ()=>{ // Changing country always resets monthlyBudget — a raw number // from one currency is meaningless carried into another; there is // no live FX conversion in this prototype, so we never pretend. StateManager.set({country:card.dataset.v, monthlyBudget:null}); EventTracker.log('country_selected', {country:card.dataset.v}); this.renderStepBody4(root); }); }); this.setNextEnabled(false); return; } const c = CONFIG.countries[a.country]; // A merchant returning to this step already has a real, deliberately // chosen value in a.monthlyBudget — only the very first render of an // untouched slider falls back to a visual suggestion that must not be // recorded as if it were confirmed (see setBudget(initial) below). const hadExplicitBudget = !!a.monthlyBudget; const initial = a.monthlyBudget || c.chips[1]; root.innerHTML = `
${c.flag} ${c.name}
${initial.toLocaleString()} ${c.symbol} / شهريًا
وش متوفر عندك من محتوى؟
✓🎥 عندي فيديوهات جاهزة
✓🖼️ عندي صور جاهزة
✨ أنشئ لي المحتوى بالـAI
ما عندك محتوى جاهز؟ عادي — اختر هذا الخيار، وبعد ما تخلّص خطتك خلّ Optimize يصمم لك إعلان من صورة منتجك.
`; document.getElementById('country-change-link').addEventListener('click', ()=>{ StateManager.set({country:null, monthlyBudget:null}); this.renderStepBody4(root); }); const slider = document.getElementById('budget-slider'); const display = document.getElementById('budget-display'); const setBudget = (v, persist)=>{ v = Util.clamp(+v, +slider.min, +slider.max); display.textContent = v.toLocaleString(); slider.value = v; // persist defaults to true — real slider movement always saves. Only // the initial render of a default the merchant hasn't touched yet // skips saving, so an untouched suggestion is never mistaken later for // an intentional budget choice (e.g. in the plan or the readiness score). if(persist !== false) StateManager.set({monthlyBudget:v}); this.updatePreview(); }; slider.addEventListener('input', ()=> setBudget(slider.value)); slider.addEventListener('change', ()=> EventTracker.log('budget_selected', {value:+slider.value, currency:c.currency})); setBudget(initial, hadExplicitBudget); const contentGrid = document.getElementById('content-grid'); const aiCard = document.getElementById('ai-content-card'); if((a.contentReadiness||[]).includes('ai')) aiCard.classList.add('selected'); const recompute = ()=>{ const gridChosen = Util.qsa('.opt-card.selected', contentGrid).map(cc=>cc.dataset.v); const chosen = aiCard.classList.contains('selected') ? ['ai', ...gridChosen] : gridChosen; StateManager.set({contentReadiness:chosen}); EventTracker.log('content_selected', {content:chosen}); this.setNextEnabled(chosen.length>0); }; aiCard.addEventListener('click', ()=>{ aiCard.classList.toggle('selected'); recompute(); }); document.getElementById('ai-content-try-btn').addEventListener('click', (e)=>{ // Only records the choice — must never pull the merchant out of the // form mid-flow. The AI creator opens later from the Results page // once the form is actually complete. e.stopPropagation(); aiCard.classList.add('selected'); recompute(); }); Util.qsa('.opt-card', contentGrid).forEach(card=>{ if((a.contentReadiness||[]).includes(card.dataset.v)) card.classList.add('selected'); card.addEventListener('click', ()=>{ card.classList.toggle('selected'); recompute(); }); }); recompute(); }, /* ---------------- STEP 5: Name + Phone — MOVED TO THE END, right before the reveal. Registration now happens after the merchant has already invested in the planning questions, not before receiving any value. ---------------- */ renderStepBody5(root){ const a = StateManager.state.answers; const c = CONFIG.countries[a.country] || CONFIG.countries.SA; root.innerHTML = `
${c.flag} ${c.dial}
نحفظ رقمك لنتواصل معك بس لو احتجت مساعدة أو حبينا نطوّر خطتك.
`; const nameInp = document.getElementById('inp-name'); nameInp.addEventListener('input', Util.debounce(()=>{ StateManager.set({name:nameInp.value.trim()}); this.updatePreview(); this.validateFinalStep(); },150)); const phoneInp = document.getElementById('inp-phone'); phoneInp.addEventListener('input', Util.debounce(()=>{ // Arabic-Indic/Persian digits must convert to English digits, not be // stripped as if they were letters — Util.toEnglishDigits runs before // the non-digit strip so a number typed in Arabic numerals still ends // up as a valid phone number instead of an empty string. const digits = Util.toEnglishDigits(phoneInp.value).replace(/[^\d]/g,''); phoneInp.value = digits; StateManager.set({phone:digits}); this.validateFinalStep(); },150)); this.validateFinalStep(); }, validateFinalStep(){ const a = StateManager.state.answers; const ok = !!a.country && a.name && a.name.length>=2 && a.phone && a.phone.length>=8; const phoneField = document.getElementById('inp-phone'); if(phoneField){ const bad = a.phone && a.phone.length>0 && a.phone.length<8; phoneField.closest('.field').classList.toggle('has-error', !!bad); let err = phoneField.closest('.field').querySelector('.field-error'); if(!err){ err = Util.el('div',{class:'field-error'},'تأكد من رقم الجوال.'); phoneField.closest('.field').appendChild(err); } } this.setNextEnabled(ok); }, /* ---------------- Live preview panel ---------------- */ updatePreview(){ const a = StateManager.state.answers; const items = [ { k:'البيع حاليًا', v: a.sellingStatus ? ({selling:'أبيع الآن',soon:'أستعد للإطلاق',exploring:'أستكشف فقط'}[a.sellingStatus]) : null, ico:'🏪' }, { k:'هدفك', v: a.campaignGoal ? this.goalLabel(a.campaignGoal) : null, ico:'🎯' }, { k:'الميزانية', v: a.monthlyBudget ? Util.fmtMoney(a.monthlyBudget, (CONFIG.countries[a.country]||CONFIG.countries.SA).symbol) : null, ico:'💰' }, { k:'المحتوى', v: (a.contentReadiness||[]).length ? (a.contentReadiness.includes('ai')?'✨ بالـAI':'جاهز') : null, ico:'🎥' }, ]; document.getElementById('preview-items').innerHTML = items.map(it=>`
${it.ico}
${it.k}
${it.v || 'بنحددها بعد شوي'}
`).join(''); }, /* ---------------- Finish wizard -> loading -> results ---------------- */ finishWizard(opts){ opts = opts||{}; this.showView('loading'); const msgs = ['نراجع إجاباتك…','نختار أفضل بداية…','خطتك جاهزة ☕']; let i=0; const textEl = document.getElementById('loading-text'); textEl.textContent = msgs[0]; const iv = setInterval(()=>{ i++; if(i!c.ok); if(!worst) return null; return worst.text; }, renderResults(plan, isUpdate){ const a = StateManager.state.answers; const c = CONFIG.countries[a.country] || CONFIG.countries.SA; this.showView('results'); EventTracker.log('plan_viewed'); document.getElementById('results-title').textContent = `خطتك جاهزة ${a.name?('يا '+a.name):''} ☕`; document.getElementById('update-banner').classList.toggle('hidden', !isUpdate); document.getElementById('update-time-note').textContent = 'آخر تحديث: ' + Util.todayStr(); // ONE-SENTENCE PLAN SUMMARY — the immediate payoff, before any detail. const rec = plan.rec; const goalLabelText = a.campaignGoal ? this.goalLabel(a.campaignGoal) : 'حملتك'; document.getElementById('plan-summary-line').textContent = `خطتك: ${rec.label}، ${Util.fmtMoney(plan.tiers.recommended.daily, c.symbol)} باليوم، لهدف ${goalLabelText} ☕`; // READINESS — compact pill + one honest sentence, no full checklist by default. const pct = plan.readiness.score; const tierColors = { ready:{bg:'#E3F5EA',fg:'#0F7A47'}, close:{bg:'#FFF1CF',fg:'#8A6800'}, needs_prep:{bg:'#FFE7D6',fg:'#B8560E'}, foundation:{bg:'#FFE1E9',fg:'#C40047'} }; const tc = tierColors[plan.readiness.tier]; const pill = document.getElementById('readiness-pill'); pill.textContent = `${plan.readiness.tierLabel} — ${pct}%`; pill.style.background = tc.bg; pill.style.color = tc.fg; const worst = plan.readiness.checklist.find(x=>!x.ok); document.getElementById('readiness-note').textContent = worst ? worst.text : 'كل شي جاهز من ناحيتنا.'; // PLATFORM — goal reason merged in directly, confidence language adapts // to how much real data we actually have (never false certainty). document.getElementById('platform-emoji').textContent = rec.emoji; document.getElementById('platform-name').textContent = rec.label; const dataPoints = [a.sellingStatus==='selling', !!a.campaignGoal, (a.contentReadiness||[]).length>0, a.adExperience==='yes', !!a.monthlyBudget].filter(Boolean).length; const confident = dataPoints>=4; document.getElementById('platform-reason').textContent = confident ? `الأنسب لك — ${rec.reason}` : `بداية مناسبة لك — ${rec.reason}`; EventTracker.log('recommended_platform_viewed', {platform:rec.primary}); const secWrap = document.getElementById('platform-secondary'); if(rec.showSecondary){ secWrap.classList.remove('hidden'); document.getElementById('platform-secondary-text').textContent = `الخطوة الثانية لاحقًا: ${rec.secondaryLabel}`; } else secWrap.classList.add('hidden'); document.getElementById('spark-callout').classList.toggle('hidden', !rec.sparkOpportunity); // BUDGET — three tiers, no arithmetic explanation, all three equally credible visually. const tiers = plan.tiers; const tierBenefitPhrase = { basic:'بداية عملية للاختبار.', recommended:'مساحة أفضل للتعلّم والاختبار.', wider:'مساحة أوسع لاختبار محتوى أكثر.' }; const tierOrder = ['basic','recommended','wider']; document.getElementById('tier-grid').innerHTML = tierOrder.map(key=>{ const t = tiers[key]; const isRec = key==='recommended'; return `
${isRec?`⭐ الأنسب لك الآن`:''}
${Util.escape(t.label)}
${Util.fmtMoney(t.daily, c.symbol)}
/ يوم لمدة ${t.days} يوم
إجمالي ${Util.fmtMoney(t.total, c.symbol)}
${Util.escape(tierBenefitPhrase[key])}
`; }).join(''); // Only real safety warning kept — never an arithmetic explanation of our own math. const reserveNote = document.getElementById('reserve-note'); const basicTier = tiers.basic; if(basicTier && basicTier.belowViableMinimum){ reserveNote.classList.remove('hidden'); reserveNote.style.background='#FFE1E9'; reserveNote.style.borderColor='#FFD3E2'; document.getElementById('reserve-note-text').textContent = `ميزانيتك الحالية أقل من الحد المقترح لتشغيل الحملة — أقل حملة قابلة للتشغيل: ${Util.fmtMoney(basicTier.viableMinimum, c.symbol)}.`; } else reserveNote.classList.add('hidden'); // The AI-creation action always gets the purple primary slot and the // launch action always gets the (distinctly colored) secondary slot — // fixed roles, not swapped by whether the merchant flagged wanting AI // content, so the purple button is reliably "create with AI" everywhere. const primaryBtn = document.getElementById('cta-primary-action'); const secondaryBtn = document.getElementById('cta-secondary-action'); primaryBtn.textContent = 'أنشئ إعلانك باستخدام أداة الـ AI 🪄'; secondaryBtn.textContent = 'أطلق حملتك على Optimize'; primaryBtn.onclick = ()=>{ EventTracker.log('ai_creator_opened', {entry:'results_primary'}); this.openCreativeWizard('results'); }; secondaryBtn.onclick = ()=>{ EventTracker.log('launch_cta_clicked'); window.open(CONFIG.links.optimizeAppUrl,'_blank'); }; // WHY THIS PLAN — exactly two reasons, computed once in buildPlan() and // read from the plan object here (same pattern as contentRecs/launchSteps // below) — single source of truth, never recomputed independently. const [reasonA, reasonB] = plan.whyReasons; document.getElementById('why-plan-list').innerHTML = [reasonA, reasonB].map(t=>`
  • ${Util.escape(t)}
  • `).join(''); // WHAT TO PREPARE — max two adaptive, actionable items. Computed once in // buildPlan() and read here — the exact same values the PDF falls back // to reading, so the two can never disagree. const prepItems = plan.contentRecs; document.getElementById('content-recs').innerHTML = prepItems.map((t,i)=>`
    ${i+1}${Util.escape(t)}
    `).join(''); // LAUNCH STEPS — max three, no post-launch tasks mixed in. Same shared // source as the PDF. const actionSteps = plan.launchSteps; document.getElementById('action-plan-list').innerHTML = actionSteps.map(s=>`
  • ${Util.escape(s)}
  • `).join(''); // Missing const missingCard = document.getElementById('missing-card'); if(plan.missing){ missingCard.classList.remove('hidden'); document.getElementById('missing-text').textContent = plan.missing; } else missingCard.classList.add('hidden'); window.scrollTo({top:0, behavior:'instant' in window ? 'instant':'auto'}); } }); /* ============================================================================ 14. Creative Wizard — "اصنع إعلانك" (rebuilt per latest direction). Mental model: fill a guided form → review structured understanding → call the same-origin Gemini backend → get a finished static ad image back. The old copy/paste-prompt model is fully retired. Independent from the main wizard; opens from the Results page and inherits planner context (goal/platform/country) instead of re-asking it. ============================================================================ */ Object.assign(UIController, { creative: { stepIndex:0, stepList:[], mode:'form', concepts:[], activeConceptIndex:-1, lastError:null, data:{} }, // Deliberately siblings of `creative`, not fields inside it — a new // creative session (openCreativeWizard) replaces `this.creative` wholesale, // but the in-flight-request guard below must survive that so a stale // response from an abandoned session can still be recognized and ignored. creativeActiveToken: null, creativeAbortController: null, invalidateCreativeSession(){ if(this.creativeAbortController){ try{ this.creativeAbortController.abort(); }catch(e){} } this.creativeActiveToken = null; }, computeCreativeSteps(){ // CTA step removed — the Creative Engine selects CTA automatically from // campaign context (goal/product/offer), the merchant is never asked to // do the copywriter's job. Ratio moved into the Review screen. return ['product','brand_references','message','payment','context']; }, freshCreativeData(){ return { brand: { name:'', logo:null, brand_colors:[], brand_guideline_files:[], previous_brand_ads:[], external_references:[] }, product: { name:'', description:'', image:null, price:'', currency:'', show_price:true }, creative_message: { has_offer:false, offer: { type:null, old_price:'', new_price:'', discount_percentage:'', discount_code:'', bundle_details:'', bundle_price:'', gift_details:'', free_shipping_condition:'', bogo_buy:'', bogo_get:'', bogo_details:'', cashback_details:'', other_details:'', duration_type:'none', duration_value:'' }, primary_focus:'auto', primary_focus_other:'', // cta/cta_other are never shown in the UI — the merchant does not // select a CTA, the future generation backend does, based on goal + // offer + product context. cta_auto_select:true documents that // choice explicitly for whoever wires up the real backend later; // cta/cta_other stay reserved in the contract in case a future // "override the auto-picked CTA" control is ever added. cta:null, cta_other:'', cta_auto_select:true }, payment: { methods:[] }, additional_context:'', // The merchant-approved ad copy shown on the Review screen. Empty // fields mean "let the system decide" — see build_prompt()'s // approved_copy branch, which only locks in wording that's actually // filled in here. creative_copy: { headline:'', message:'', offer_line:'', cta:'' }, output: { primary_ratio:'9:16', requested_placement:null, alternate_ratios:[] }, reference_selection: [], // which brandReferenceOptions ids are chosen — UI-only, not sent onward beyond informing which asset arrays are populated planner_context: { campaign_goal:null, platform:null, country:null, promote_target:null, promote_other:'', selling_status:null, sales_channel:null, content_readiness:[] } }; }, openCreativeWizard(entry){ // Defensive: a fresh session must never be reachable by a response tied // to whatever session (if any) came before it. this.invalidateCreativeSession(); EventTracker.log('creative_started', {entry: entry||'results'}); const a = StateManager.state.answers; const plan = StateManager.state.plan; const data = this.freshCreativeData(); // Inherit context the Planner already knows — never re-ask goal/platform/country. data.planner_context.campaign_goal = a.campaignGoal || null; data.planner_context.platform = (plan && plan.rec) ? plan.rec.primary : null; data.planner_context.country = a.country || null; data.planner_context.promote_target = a.promoteTarget || null; data.planner_context.promote_other = a.promoteOther || ''; data.planner_context.selling_status = a.sellingStatus || null; data.planner_context.sales_channel = a.salesChannel || null; data.planner_context.content_readiness = Array.isArray(a.contentReadiness) ? [...a.contentReadiness] : []; // Pre-select the output ratio from the known platform — merchant can still change it in Review. if(data.planner_context.platform==='instagram') data.output.primary_ratio = '4:5'; else data.output.primary_ratio = '9:16'; // TikTok/Snapchat default, and the safe default with no platform context const c = CONFIG.countries[a.country]; if(c) data.product.currency = c.currency; this.creative = { stepIndex:0, stepList:this.computeCreativeSteps(), mode:'form', concepts:[], activeConceptIndex:-1, lastError:null, data }; this.openModal('creative-modal'); this.renderCreativeStep(); }, /* A visual-reference type that requires an uploaded file (as opposed to "brand_colors", which only needs the color pickers, or "none") must not let the merchant continue on the strength of just having selected the card — the card being checked and a real file being attached are two different things. */ hasRequiredReferenceFiles(cd){ return cd.reference_selection.every(v=>{ if(v==='brand_guideline') return cd.brand.brand_guideline_files.length>0; if(v==='previous_ads') return cd.brand.previous_brand_ads.length>0; if(v==='external_refs') return cd.brand.external_references.length>0; return true; // 'none' and 'brand_colors' need no file }); }, /* Reads real files via FileReader and only pushes to the array once the browser has actually finished reading them — never fabricate an upload. */ handleMultiFileInput(fileList, arr, cb){ const files = Array.from(fileList||[]); if(!files.length) return; let remaining = files.length; files.forEach(f=>{ const reader = new FileReader(); reader.onload = ()=>{ arr.push(reader.result); remaining--; if(remaining===0) cb(); }; reader.onerror = ()=>{ remaining--; if(remaining===0) cb(); }; reader.readAsDataURL(f); }); }, renderImageGrid(images, removeHandlerName){ if(!images.length) return ''; return `
    ${images.map((src,i)=>`
    `).join('')}
    `; }, renderCreativeDots(){ const total = this.creative.stepList.length; const dots = document.getElementById('creative-dots'); if(!dots) return; dots.classList.remove('hidden'); dots.innerHTML = Array.from({length:total}).map((_,i)=>{ const cls = i`; }).join(''); }, renderCreativeStep(){ this.creative.mode = 'form'; const body = document.getElementById('creative-body'); const cd = this.creative.data; const id = this.creative.stepList[this.creative.stepIndex]; this.renderCreativeDots(); const isFirst = this.creative.stepIndex===0; const isLast = this.creative.stepIndex===this.creative.stepList.length-1; const nav = (canNext, nextLabel)=>`
    `; /* ---------------- STEP: Product & Brand basics ---------------- */ if(id==='product'){ body.innerHTML = `
    صورة المنتج مطلوب ${cd.product.image ? `
    ` : ``}

    يُفضّل PNG بخلفية شفافة — بنعتني بالباقي.

    شعار العلامة يُفضّل — غير إلزامي ${cd.brand.logo ? `
    ` : `
    🖼️
    اضغط لرفع الشعار
    `}
    ` + nav(!!cd.brand.name && !!cd.product.name && !!cd.product.image); const check = ()=> this.setCwNext(!!cd.brand.name && !!cd.product.name && !!cd.product.image); document.getElementById('cw-brand-name').addEventListener('input', function(){ cd.brand.name=this.value.trim(); check(); }); document.getElementById('cw-product-name').addEventListener('input', function(){ cd.product.name=this.value.trim(); check(); }); document.getElementById('cw-desc-toggle').addEventListener('click', ()=>{ document.getElementById('cw-desc-field').classList.remove('hidden'); document.getElementById('cw-desc-toggle').classList.add('hidden'); document.getElementById('cw-desc').focus(); }); document.getElementById('cw-desc').addEventListener('input', function(){ cd.product.description=this.value.trim(); }); const addBtn = document.getElementById('cw-add-product-img'); if(addBtn) addBtn.addEventListener('click', ()=> document.getElementById('cw-product-img-input').click()); document.getElementById('cw-product-img-input').addEventListener('change', (e)=>{ const f = e.target.files[0]; if(!f) return; const reader = new FileReader(); reader.onload = ()=>{ Util.downscaleImage(reader.result, 1600).then(src=>{ cd.product.image = src; EventTracker.log('creative_asset_uploaded', {type:'product_image'}); this.renderCreativeStep(); }); }; reader.readAsDataURL(f); }); const removeBtn = document.getElementById('cw-remove-product-img'); if(removeBtn) removeBtn.addEventListener('click', ()=>{ cd.product.image=null; this.renderCreativeStep(); }); const logoUploadBox = document.getElementById('cw-logo-upload'); if(logoUploadBox) logoUploadBox.addEventListener('click', ()=> document.getElementById('cw-logo-input').click()); document.getElementById('cw-logo-input').addEventListener('change', (e)=>{ const f = e.target.files[0]; if(!f) return; const reader = new FileReader(); reader.onload = ()=>{ Util.downscaleImage(reader.result, 1600).then(src=>{ cd.brand.logo = src; EventTracker.log('creative_asset_uploaded', {type:'logo'}); this.renderCreativeStep(); }); }; reader.readAsDataURL(f); }); const removeLogoBtn = document.getElementById('cw-remove-logo'); if(removeLogoBtn) removeLogoBtn.addEventListener('click', ()=>{ cd.brand.logo=null; this.renderCreativeStep(); }); } /* ---------------- STEP: Brand & Visual References ---------------- */ else if(id==='brand_references'){ body.innerHTML = `
    ${CONFIG.brandReferenceOptions.map(o=>`
    ✓${o.label}${o.hint?`${o.hint}`:''}
    `).join('')}
    ` + nav(cd.reference_selection.length>0 && this.hasRequiredReferenceFiles(cd)); const uploadMap = { brand_guideline: { arr:cd.brand.brand_guideline_files, gridId:'grid-brand_guideline', addId:'add-brand_guideline', inputId:'input-brand_guideline' }, previous_ads: { arr:cd.brand.previous_brand_ads, gridId:'grid-previous_ads', addId:'add-previous_ads', inputId:'input-previous_ads' }, external_refs: { arr:cd.brand.external_references, gridId:'grid-external_refs', addId:'add-external_refs', inputId:'input-external_refs' } }; Object.keys(uploadMap).forEach(key=>{ const m = uploadMap[key]; document.getElementById(m.addId).addEventListener('click', ()=> document.getElementById(m.inputId).click()); document.getElementById(m.inputId).addEventListener('change', (e)=>{ this.handleMultiFileInput(e.target.files, m.arr, ()=>{ EventTracker.log('creative_asset_uploaded', {type:key}); this.renderCreativeStep(); }); }); document.getElementById(m.gridId).addEventListener('click', (e)=>{ const btn = e.target.closest('.thumb-remove'); if(!btn) return; m.arr.splice(+btn.dataset.i,1); this.renderCreativeStep(); }); }); const refGrid = document.getElementById('ref-grid'); const toggleBlock = (val, show)=>{ const b = document.getElementById('ref-block-'+val); if(b) b.classList.toggle('hidden', !show); }; // Deselecting a reference type must clear its uploaded data too — a // deselected asset must never keep silently influencing the payload. const clearReferenceData = (val)=>{ if(val==='brand_guideline') cd.brand.brand_guideline_files = []; else if(val==='previous_ads') cd.brand.previous_brand_ads = []; else if(val==='external_refs') cd.brand.external_references = []; else if(val==='brand_colors') cd.brand.brand_colors = []; }; Util.qsa('.opt-card', refGrid).forEach(card=>{ if(cd.reference_selection.includes(card.dataset.v)) card.classList.add('selected'); card.addEventListener('click', ()=>{ const isNone = card.dataset.v==='none'; if(isNone){ const willSelect = !card.classList.contains('selected'); Util.qsa('.opt-card', refGrid).forEach(c=>{ if(c.classList.contains('selected') && c.dataset.v!=='none') clearReferenceData(c.dataset.v); c.classList.remove('selected'); toggleBlock(c.dataset.v,false); }); if(willSelect) card.classList.add('selected'); } else { const wasSelected = card.classList.contains('selected'); card.classList.toggle('selected'); if(wasSelected && !card.classList.contains('selected')) clearReferenceData(card.dataset.v); const noneCard = Util.qs('.opt-card[data-v="none"]', refGrid); if(noneCard){ noneCard.classList.remove('selected'); } toggleBlock(card.dataset.v, card.classList.contains('selected')); } cd.reference_selection = Util.qsa('.opt-card.selected', refGrid).map(c=>c.dataset.v); EventTracker.log('creative_reference_selected', {values:cd.reference_selection}); this.setCwNext(cd.reference_selection.length>0 && this.hasRequiredReferenceFiles(cd)); }); }); cd.reference_selection.forEach(val=> toggleBlock(val, true)); const c1 = document.getElementById('cw-color-1'), c2 = document.getElementById('cw-color-2'); if(c1){ const syncColors = ()=>{ cd.brand.brand_colors = [c1.value, c2.value]; }; c1.addEventListener('input', syncColors); c2.addEventListener('input', syncColors); } } /* ---------------- STEP: Offer / Primary message ---------------- */ else if(id==='message'){ const cm = cd.creative_message; body.innerHTML = `
    ` + nav(false); // Shared by the offer-type switch and the has_offer "no" toggle below — // a value that belonged only to the previous offer must never survive // into a different offer (or no offer at all) and leak into the // review screen or the generation payload. const clearOfferFields = ()=>{ Object.assign(cm.offer, { old_price:'', new_price:'', discount_percentage:'', discount_code:'', bundle_details:'', bundle_price:'', gift_details:'', free_shipping_condition:'', bogo_buy:'', bogo_get:'', bogo_details:'', cashback_details:'', other_details:'' }); }; const checkComplete = ()=>{ let ok = false; if(!cm.has_offer){ const needsPrice = cd.product.show_price!==false; ok = (!needsPrice || !!cd.product.price) && (!!cm.primary_focus && (cm.primary_focus!=='other' || !!cm.primary_focus_other)); } else { if(!cm.offer.type) ok=false; else if(cm.offer.type==='before_after'){ const before=parseFloat(Util.toEnglishDigits(cm.offer.old_price)), after=parseFloat(Util.toEnglishDigits(cm.offer.new_price)); ok = !isNaN(before) && !isNaN(after) && before>0 && after>0 && before>after; } else if(cm.offer.type==='percentage'){ const pct = parseFloat(Util.toEnglishDigits(cm.offer.discount_percentage)); ok = !isNaN(pct) && pct>0 && pct<=100; } else if(cm.offer.type==='code') ok = !!cm.offer.discount_code; else if(cm.offer.type==='bundle') ok = !!cm.offer.bundle_details; else if(cm.offer.type==='gift') ok = !!cm.offer.gift_details; else if(cm.offer.type==='bogo') ok = !!cm.offer.bogo_buy && !!cm.offer.bogo_get; else if(cm.offer.type==='cashback') ok = !!cm.offer.cashback_details; else if(cm.offer.type==='other') ok = !!cm.offer.other_details; else ok = true; // free_shipping, seasonal have no mandatory follow-up // "مدة العرض" itself stays optional, but choosing a specific/custom // duration without actually entering it is not a complete answer. if(ok && cm.offer.duration_type && cm.offer.duration_type!=='none' && !cm.offer.duration_value) ok = false; } this.setCwNext(ok); }; Util.qsa('.chip-btn', body).forEach(btn=>{ if((btn.dataset.v==='yes')===cm.has_offer) btn.classList.add('active'); btn.addEventListener('click', ()=>{ Util.qsa('.chip-btn', body).forEach(b=>b.classList.remove('active')); btn.classList.add('active'); const nowHasOffer = btn.dataset.v==='yes'; if(cm.has_offer && !nowHasOffer){ // Turning the offer off entirely must not leave its old type or // field values active in the background, in case the merchant // flips back to "yes" later without touching anything else. cm.offer.type = null; cm.offer.duration_type = 'none'; cm.offer.duration_value = ''; clearOfferFields(); } cm.has_offer = nowHasOffer; document.getElementById('no-offer-fields').classList.toggle('hidden', cm.has_offer); document.getElementById('yes-offer-fields').classList.toggle('hidden', !cm.has_offer); EventTracker.log('creative_offer_set', {has_offer:cm.has_offer}); checkComplete(); }); }); if(cm.has_offer){ document.getElementById('yes-offer-fields').classList.remove('hidden'); } else { document.getElementById('no-offer-fields').classList.remove('hidden'); } // NO-offer branch wiring document.getElementById('cw-price').addEventListener('input', function(){ cd.product.price=Util.toEnglishDigits(this.value.trim()); checkComplete(); }); document.getElementById('cw-hide-price').addEventListener('change', function(){ cd.product.show_price = !this.checked; checkComplete(); }); const focusGrid = document.getElementById('focus-grid'); const focusToggle = document.getElementById('focus-manual-toggle'); const focusManualBlock = document.getElementById('focus-manual-block'); if(cm.primary_focus && cm.primary_focus!=='auto'){ focusManualBlock.classList.remove('hidden'); focusToggle.classList.add('hidden'); } focusToggle.addEventListener('click', ()=>{ focusManualBlock.classList.remove('hidden'); focusToggle.classList.add('hidden'); }); Util.qsa('.opt-row', focusGrid).forEach(row=>{ if(row.dataset.v===cm.primary_focus) row.classList.add('selected'); row.addEventListener('click', ()=>{ Util.qsa('.opt-row', focusGrid).forEach(r=>r.classList.remove('selected')); row.classList.add('selected'); cm.primary_focus = row.dataset.v; document.getElementById('focus-other-field').classList.toggle('hidden', cm.primary_focus!=='other'); checkComplete(); }); }); if(cm.primary_focus==='other') document.getElementById('focus-other-field').classList.remove('hidden'); document.getElementById('cw-focus-other').addEventListener('input', function(){ cm.primary_focus_other=this.value.trim(); checkComplete(); }); // YES-offer branch wiring const renderCondFields = ()=>{ const box = document.getElementById('offer-cond-fields'); const t = cm.offer.type; if(!t){ box.innerHTML=''; return; } if(t==='before_after'){ box.innerHTML = `
    `; const upd = ()=>{ cm.offer.old_price=Util.toEnglishDigits(document.getElementById('of-old').value.trim()); cm.offer.new_price=Util.toEnglishDigits(document.getElementById('of-new').value.trim()); const before=parseFloat(cm.offer.old_price), after=parseFloat(cm.offer.new_price); const validOrder = !isNaN(before)&&!isNaN(after)&&before>0&&after>0&&before>after; document.getElementById('of-price-error').classList.toggle('hidden', !cm.offer.old_price || !cm.offer.new_price || validOrder); checkComplete(); }; document.getElementById('of-old').addEventListener('input', upd); document.getElementById('of-new').addEventListener('input', upd); } else if(t==='percentage'){ box.innerHTML = `
    `; document.getElementById('of-pct').addEventListener('input', function(){ cm.offer.discount_percentage=Util.toEnglishDigits(this.value.trim()); const pct = parseFloat(cm.offer.discount_percentage); const validPct = !isNaN(pct) && pct>0 && pct<=100; document.getElementById('of-pct-error').classList.toggle('hidden', !cm.offer.discount_percentage || validPct); checkComplete(); }); document.getElementById('of-code2').addEventListener('input', function(){ cm.offer.discount_code=Util.toEnglishDigits(this.value.trim()); }); } else if(t==='code'){ box.innerHTML = `
    `; document.getElementById('of-code').addEventListener('input', function(){ cm.offer.discount_code=this.value.trim(); checkComplete(); }); } else if(t==='bundle'){ box.innerHTML = `
    `; document.getElementById('of-bundle').addEventListener('input', function(){ cm.offer.bundle_details=this.value.trim(); checkComplete(); }); document.getElementById('of-bundle-price').addEventListener('input', function(){ cm.offer.bundle_price=Util.toEnglishDigits(this.value.trim()); }); } else if(t==='gift'){ box.innerHTML = `
    `; document.getElementById('of-gift').addEventListener('input', function(){ cm.offer.gift_details=this.value.trim(); checkComplete(); }); } else if(t==='free_shipping'){ box.innerHTML = `
    `; document.getElementById('of-ship').addEventListener('input', function(){ cm.offer.free_shipping_condition=this.value.trim(); }); } else if(t==='bogo'){ box.innerHTML = `
    `; const upd = ()=>{ cm.offer.bogo_buy=Util.toEnglishDigits(document.getElementById('of-buy').value.trim()); cm.offer.bogo_get=Util.toEnglishDigits(document.getElementById('of-get').value.trim()); cm.offer.bogo_details = cm.offer.bogo_buy && cm.offer.bogo_get ? `اشترِ ${cm.offer.bogo_buy} واحصل على ${cm.offer.bogo_get}` : ''; checkComplete(); }; document.getElementById('of-buy').addEventListener('input', upd); document.getElementById('of-get').addEventListener('input', upd); } else if(t==='cashback'){ box.innerHTML = `
    `; document.getElementById('of-cb').addEventListener('input', function(){ cm.offer.cashback_details=this.value.trim(); checkComplete(); }); } else if(t==='seasonal'){ box.innerHTML = `
    `; document.getElementById('of-season').addEventListener('input', function(){ cm.offer.other_details=this.value.trim(); }); } else if(t==='other'){ box.innerHTML = `
    `; document.getElementById('of-other').addEventListener('input', function(){ cm.offer.other_details=this.value.trim(); checkComplete(); }); } }; Util.qsa('#offer-type-grid .opt-row', body).forEach(row=>{ if(row.dataset.v===cm.offer.type) row.classList.add('selected'); row.addEventListener('click', ()=>{ Util.qsa('#offer-type-grid .opt-row', body).forEach(r=>r.classList.remove('selected')); row.classList.add('selected'); if(cm.offer.type !== row.dataset.v){ // Switching offer type must clear every sibling field — a stale // before/after price must never leak into the review summary or // the generation payload as if it were still an active fact. clearOfferFields(); } cm.offer.type = row.dataset.v; renderCondFields(); checkComplete(); }); }); const updateDurError = ()=>{ const errEl = document.getElementById('of-dur-error'); if(!errEl) return; const needsVal = cm.offer.duration_type && cm.offer.duration_type!=='none'; errEl.classList.toggle('hidden', !needsVal || !!cm.offer.duration_value); }; Util.qsa('#offer-dur-grid .opt-row', body).forEach(row=>{ if(row.dataset.v===cm.offer.duration_type) row.classList.add('selected'); row.addEventListener('click', ()=>{ Util.qsa('#offer-dur-grid .opt-row', body).forEach(r=>r.classList.remove('selected')); row.classList.add('selected'); cm.offer.duration_type = row.dataset.v; document.getElementById('offer-dur-custom').classList.toggle('hidden', row.dataset.v==='none'); updateDurError(); checkComplete(); }); }); if(cm.offer.duration_type && cm.offer.duration_type!=='none') document.getElementById('offer-dur-custom').classList.remove('hidden'); const durInp = document.getElementById('cw-offer-dur-val'); if(durInp){ durInp.value = cm.offer.duration_value||''; durInp.addEventListener('input', function(){ cm.offer.duration_value=this.value.trim(); updateDurError(); checkComplete(); }); } updateDurError(); renderCondFields(); checkComplete(); } /* ---------------- STEP: CTA + Payment ---------------- */ else if(id==='payment'){ body.innerHTML = `

    إذا كانت متوفرة عندك، اختر اللي تبي نبرزه.

    ${CONFIG.creativePaymentMethods.slice(0,3).map(p=>`
    ✓${p}
    `).join('')}
    ` + nav(true); const allCards = ()=> Util.qsa('#pay-grid-primary .opt-card, #pay-grid-secondary .opt-card', body); allCards().forEach(card=>{ if(cd.payment.methods.includes(card.dataset.v)) card.classList.add('selected'); card.addEventListener('click', ()=>{ const already = card.classList.contains('selected'); if(!already && cd.payment.methods.length>=3){ this.toast('تقدر تختار حتى 3 طرق دفع بس'); return; } card.classList.toggle('selected'); cd.payment.methods = allCards().filter(c=>c.classList.contains('selected')).map(c=>c.dataset.v); EventTracker.log('creative_payment_set', {methods:cd.payment.methods}); }); }); const moreToggle = document.getElementById('pay-more-toggle'); const secondaryGrid = document.getElementById('pay-grid-secondary'); if(CONFIG.creativePaymentMethods.slice(3).some(p=>cd.payment.methods.includes(p))) secondaryGrid.classList.remove('hidden'); moreToggle.addEventListener('click', ()=> secondaryGrid.classList.toggle('hidden')); } /* ---------------- STEP: Open context ---------------- */ else if(id==='context'){ const max = 280; body.innerHTML = `

    أي تفاصيل تساعدنا نطلع إعلان أقرب لك — شي لازم يظهر، شي ما تبيه، تفاصيل مهمة عن المنتج، أو فكرة في بالك.

    ${cd.additional_context.length} / ${max}
    ` + nav(true, 'مراجعة الإعلان'); document.getElementById('cw-notes').addEventListener('input', function(){ cd.additional_context=this.value.slice(0,max); document.getElementById('cw-notes-counter').textContent = cd.additional_context.length+' / '+max; }); } document.getElementById('cw-back').addEventListener('click', ()=>{ if(isFirst){ this.closeModal('creative-modal'); return; } this.creative.stepIndex--; this.renderCreativeStep(); }); document.getElementById('cw-next').addEventListener('click', ()=>{ if(!isLast){ this.creative.stepIndex++; this.renderCreativeStep(); } else { this.renderCreativeReview(); } }); }, setCwNext(ok){ const b=document.getElementById('cw-next'); if(b) b.disabled=!ok; }, /* ---------------- Review — "هذا اللي فهمناه" ---------------- */ focusLabel(id){ return (CONFIG.primaryFocusOptions.find(o=>o.id===id)||{}).label || id; }, offerSummary(cd){ const cm = cd.creative_message, o = cm.offer; if(!cm.has_offer){ const priceLine = cd.product.show_price===false ? 'السعر لن يظهر بالإعلان' : (cd.product.price ? `${cd.product.price} ${cd.product.currency||''}` : '—'); const focus = cm.primary_focus==='other' ? (cm.primary_focus_other||'—') : (cm.primary_focus==='auto' ? 'يقرره النظام' : this.focusLabel(cm.primary_focus)); return `بدون عرض — السعر: ${priceLine} · التركيز: ${focus}`; } const label = (CONFIG.offerTypes.find(t=>t.id===o.type)||{}).label || '—'; let detail = ''; if(o.type==='before_after') detail = `${o.old_price} ← ${o.new_price}`; else if(o.type==='percentage') detail = o.discount_percentage + (o.discount_code?` (كود: ${o.discount_code})`:''); else if(o.type==='code') detail = o.discount_code; else if(o.type==='bundle') detail = o.bundle_details + (o.bundle_price?` — ${o.bundle_price}`:''); else if(o.type==='gift') detail = o.gift_details; else if(o.type==='free_shipping') detail = o.free_shipping_condition || ''; else if(o.type==='bogo') detail = o.bogo_details; else if(o.type==='cashback') detail = o.cashback_details; else if(o.type==='seasonal' || o.type==='other') detail = o.other_details || ''; const durMap = { fixed:'لمدة محددة', until_date:'حتى تاريخ محدد' }; let dur = ''; if(o.duration_type && durMap[o.duration_type]) dur = ` — ${durMap[o.duration_type]}${o.duration_value?': '+o.duration_value:''}`; return `${label}${detail?' — '+detail:''}${dur}`; }, /* Instant, free, no-AI-call first draft for the Review screen's editable copy fields — built purely from what the merchant already typed. The optional "اقترح نص لي" button (CreativeCopyService) can replace this with a Gemini-written draft on request, but nothing here ever calls the backend automatically. */ buildDefaultCopyDraft(cd){ const cm = cd.creative_message, o = cm.offer; const product = cd.product.name || 'منتجك'; const brand = cd.brand.name || ''; const currency = cd.product.currency || ''; let offerLine = ''; if(cm.has_offer){ if(o.type==='before_after' && o.old_price && o.new_price) offerLine = `من ${o.old_price} إلى ${o.new_price} ${currency}`.trim(); else if(o.type==='percentage' && o.discount_percentage) offerLine = `خصم ${o.discount_percentage}` + (o.discount_code ? ` — كود ${o.discount_code}` : ''); else if(o.type==='code' && o.discount_code) offerLine = `كود الخصم ${o.discount_code}`; else if(o.type==='bundle' && o.bundle_details) offerLine = o.bundle_details + (o.bundle_price ? ` — ${o.bundle_price} ${currency}`.trim() : ''); else if(o.type==='gift' && o.gift_details) offerLine = `هدية: ${o.gift_details}`; else if(o.type==='free_shipping') offerLine = 'شحن مجاني' + (o.free_shipping_condition ? ` — ${o.free_shipping_condition}` : ''); else if(o.type==='bogo' && o.bogo_details) offerLine = o.bogo_details; else if(o.type==='cashback' && o.cashback_details) offerLine = `كاش باك: ${o.cashback_details}`; else if((o.type==='seasonal'||o.type==='other') && o.other_details) offerLine = o.other_details; } else if(cd.product.show_price!==false && cd.product.price){ offerLine = `${cd.product.price} ${currency}`.trim(); } const goal = StateManager.state.answers.campaignGoal || cd.planner_context.campaign_goal; const cta = { sales:'اطلب الآن', awareness:'تعرف أكثر', profile_visits:'زورونا الآن' }[goal] || 'اطلب الآن'; const headline = brand ? `${product} من ${brand}` : product; const message = cd.product.description || `اكتشف ${product} الآن`; return { headline, message, offer_line:offerLine, cta }; }, renderCreativeReview(){ this.creative.mode = 'review'; const cd = this.creative.data; const dots = document.getElementById('creative-dots'); if(dots) dots.classList.add('hidden'); const body = document.getElementById('creative-body'); const refLabels = { brand_guideline:'ملف الهوية البصرية', previous_ads:'إعلانات سابقة', external_refs:'مراجع خارجية', brand_colors:'ألوان محددة', none:'ما فيه — اختار النظام' }; const refSummary = cd.reference_selection.length ? cd.reference_selection.map(r=>refLabels[r]||r).join('، ') : 'ما فيه'; const paymentSummary = cd.payment.methods.length ? cd.payment.methods.join('، ') : 'ما تحدد'; // The copy fields start pre-filled with an instant, free, local draft — // never overwritten on re-render so a merchant's own edits (or a fetched // AI suggestion) always survive going back to an earlier step and // returning here. if(!cd.creative_copy) cd.creative_copy = { headline:'', message:'', offer_line:'', cta:'' }; const cc = cd.creative_copy; if(!cc.headline && !cc.message && !cc.offer_line && !cc.cta) Object.assign(cc, this.buildDefaultCopyDraft(cd)); const section = (title, value, stepId)=>`
    ${title}
    ${value}
    `; body.innerHTML = ` ${section('العلامة والمنتج', `${Util.escape(cd.brand.name)} — ${Util.escape(cd.product.name)}${cd.product.image?' · صورة مرفقة':''}${cd.brand.logo?' · شعار مرفق':''}`, 'product')} ${section('المراجع البصرية', Util.escape(refSummary), 'brand_references')} ${section('الرسالة الأساسية', Util.escape(this.offerSummary(cd)), 'message')}
    النص الإعلاني

    هذا مسودة أولية — عدّلها كما تحب، والإعلان النهائي بيستخدم نفس النص اللي تعتمده هنا بالضبط.

    ${section('طرق الدفع', Util.escape(paymentSummary), 'payment')} ${cd.additional_context ? section('ملاحظات إضافية', Util.escape(cd.additional_context), 'context') : ''}
    المقاس
    ${CONFIG.outputRatioOptions.map(o=>``).join('')}
    `; // Digits only — never rewrite the merchant's approved wording. Matches // the same normalize-on-store (not on-screen) pattern already used for // price/offer fields elsewhere in this wizard. document.getElementById('copy-headline').addEventListener('input', function(){ cc.headline = Util.toEnglishDigits(this.value); }); document.getElementById('copy-message').addEventListener('input', function(){ cc.message = Util.toEnglishDigits(this.value); }); document.getElementById('copy-offer').addEventListener('input', function(){ cc.offer_line = Util.toEnglishDigits(this.value); }); document.getElementById('copy-cta').addEventListener('input', function(){ cc.cta = Util.toEnglishDigits(this.value); }); document.getElementById('cw-suggest-copy').addEventListener('click', ()=>{ const btn = document.getElementById('cw-suggest-copy'); const status = document.getElementById('copy-suggest-status'); btn.disabled = true; status.textContent = 'نجهز اقتراح...'; EventTracker.log('creative_copy_suggest_requested', {}); CreativeCopyService.suggest(this.buildCreativePayload()).then(res=>{ btn.disabled = false; if(res.status === 'succeeded'){ Object.assign(cc, { headline:res.headline, message:res.message, offer_line:res.offer_line, cta:res.cta }); this.renderCreativeReview(); } else { status.textContent = res.error || 'تعذّر اقتراح النص. حاول مرة أخرى.'; } }); }); Util.qsa('.ratio-chip', body).forEach(btn=>{ btn.addEventListener('click', ()=>{ Util.qsa('.ratio-chip', body).forEach(b=>b.classList.remove('active')); btn.classList.add('active'); cd.output.primary_ratio = btn.dataset.v; EventTracker.log('creative_output_ratio_set', {ratio:btn.dataset.v, via:'review'}); }); }); Util.qsa('.review-edit', body).forEach(btn=>{ btn.addEventListener('click', ()=>{ const stepId = btn.dataset.step; this.creative.stepIndex = this.creative.stepList.indexOf(stepId); if(this.creative.stepIndex<0) this.creative.stepIndex = 0; if(dots) dots.classList.remove('hidden'); this.renderCreativeStep(); }); }); document.getElementById('cw-back').addEventListener('click', ()=>{ this.creative.stepIndex = this.creative.stepList.length-1; if(dots) dots.classList.remove('hidden'); this.renderCreativeStep(); }); document.getElementById('cw-next').addEventListener('click', ()=>{ EventTracker.log('creative_review_edited', {}); const a = StateManager.state.answers; // Identity gate — collected once, right before the merchant actually // generates anything. Never asked twice: if the Planner already has // this (e.g. entering from Results), the gate is skipped automatically. if(!a.name || !a.phone){ this.renderCreativeRegister(); return; } this.runCreativeGeneration(); }); }, /* ---------------- Registration gate — name, phone, Optimize-user status, store/product link. This is the ONLY place identity is required for the AI Creator, and it's asked right before generation, not on entry, so a merchant who came straight from the hero still gets to build the ad before we ask anything of them. Saved into the shared answers object so the main Planner never re-asks it either. ---------------- */ renderCreativeRegister(){ this.creative.mode = 'register'; const dots = document.getElementById('creative-dots'); if(dots) dots.classList.add('hidden'); const body = document.getElementById('creative-body'); const a = StateManager.state.answers; const defaultCC = a.country || 'SA'; // Same class of bug fixed earlier in the main Planner: a visually-shown // default country must actually be persisted, not just displayed — a // direct-hero entrant who never touches this dropdown must not end up // with StateManager.state.answers.country still null. if(!a.country) StateManager.set({country:defaultCC}); const syncCreativeCurrency = ()=>{ const cc = CONFIG.countries[StateManager.state.answers.country]; if(cc) this.creative.data.product.currency = cc.currency; }; syncCreativeCurrency(); EventTracker.log('registration_view', {via:'creative_gate'}); body.innerHTML = `
    تستخدم Optimize حاليًا؟
    يرجى إدخال رابط صحيح، مثال: https://example.com
    `; const check = ()=>{ // The URL is optional — empty is fine — but whatever is typed must // actually be a link, never accepted as free text. const urlOk = !(a.primaryBusinessUrl||'').trim() || Util.isValidUrl(a.primaryBusinessUrl); const ok = (a.name||'').trim().length>=2 && (a.phone||'').length>=8 && !!a.optimizeStatus && urlOk; this.setCwNext(ok); }; // BUG FIX: these listeners used `function(){ this.value ... }`, relying // on addEventListener's `this` binding. But Util.debounce wraps the // callback in arrow functions, which do NOT forward that `this` binding // through to a plain function call — every keystroke threw a silent // "Cannot read properties of undefined" error, so name/phone never // actually reached StateManager and the button could never legitimately // enable. Fixed by capturing the elements directly via closure instead // of relying on `this` — the same safe pattern already used correctly // in the main Planner wizard's own name/phone step. const nameInp = document.getElementById('reg-name'); const phoneInp = document.getElementById('reg-phone'); nameInp.addEventListener('input', Util.debounce(()=>{ StateManager.set({name:nameInp.value.trim()}); check(); },150)); document.getElementById('reg-cc').addEventListener('change', function(){ StateManager.set({country:this.value}); syncCreativeCurrency(); }); phoneInp.addEventListener('input', Util.debounce(()=>{ const digits = Util.toEnglishDigits(phoneInp.value).replace(/[^\d]/g,''); phoneInp.value = digits; StateManager.set({phone:digits}); check(); },150)); Util.qsa('#reg-optimize-row .chip-btn', body).forEach(btn=>{ if(btn.dataset.v===a.optimizeStatus) btn.classList.add('active'); btn.addEventListener('click', ()=>{ Util.qsa('#reg-optimize-row .chip-btn', body).forEach(b=>b.classList.remove('active')); btn.classList.add('active'); StateManager.set({optimizeStatus:btn.dataset.v}); check(); }); }); const urlInp = document.getElementById('reg-url'); const showRegUrlError = ()=>{ const val = (a.primaryBusinessUrl||'').trim(); urlInp.closest('.field').classList.toggle('has-error', val.length>0 && !Util.isValidUrl(val)); }; urlInp.addEventListener('input', Util.debounce(()=>{ StateManager.set({primaryBusinessUrl:urlInp.value.trim()}); check(); showRegUrlError(); },150)); showRegUrlError(); // BUG FIX: every other step-render function in this file ends with an // initial check() call to correctly set the "next" button's state from // whatever data may already exist (e.g. re-opening this screen, or a // field populated by paste/autofill before its debounced listener has // fired). This screen was missing that call — the button could stay // disabled even when all three required fields were already valid. check(); document.getElementById('cw-back').addEventListener('click', ()=> this.renderCreativeReview()); document.getElementById('cw-next').addEventListener('click', ()=>{ EventTracker.log('registration_completed', {via:'creative_gate'}); // Save a lead record even when entering directly from the AI Creator — // without this, the direct-entry path produces zero usable data, // which defeats the entire point of building a user base from the tool. const leadId = StateManager.ensureLeadId(); ApiAdapter.submitLead({ id: leadId, name: a.name, phone: a.phone, country: a.country, optimizeUser: a.optimizeStatus==='yes', primaryBusinessUrl: a.primaryBusinessUrl||'', source: 'ai_creator_direct', currentStage: 'registered', visits: 1, lastActivity: new Date().toISOString(), demo:false }); this.runCreativeGeneration(); }); check(); }, /* ---------------- Generation (prototype placeholder architecture) ---------------- */ buildCreativePayload(){ // Explicit CreativeRequest v1: only data that is relevant to the creative // engine is sent. UI-only state such as reference_selection never leaves // the browser. const cd = this.creative.data || {}; const a = StateManager.state.answers || {}; const plan = StateManager.state.plan || null; const clone = (v)=> JSON.parse(JSON.stringify(v)); return { schema_version:'1.0', // Lets the server attribute this generation to the right merchant. lead_id: StorageService.get('lead_id') || null, session_id: EventTracker.sessionId || StorageService.get('session_id') || null, planner_context:{ campaign_goal:a.campaignGoal || cd.planner_context?.campaign_goal || null, platform:(plan && plan.rec) ? plan.rec.primary : (cd.planner_context?.platform || null), country:a.country || cd.planner_context?.country || null, promote_target:a.promoteTarget || cd.planner_context?.promote_target || null, promote_other:a.promoteOther || cd.planner_context?.promote_other || '', selling_status:a.sellingStatus || cd.planner_context?.selling_status || null, sales_channel:a.salesChannel || cd.planner_context?.sales_channel || null, content_readiness:Array.isArray(a.contentReadiness) ? [...a.contentReadiness] : clone(cd.planner_context?.content_readiness || []) }, brand:{ name:cd.brand?.name || '', logo:cd.brand?.logo || null, brand_colors:clone(cd.brand?.brand_colors || []), brand_guideline_files:clone(cd.brand?.brand_guideline_files || []), previous_brand_ads:clone(cd.brand?.previous_brand_ads || []), external_references:clone(cd.brand?.external_references || []) }, product:{ name:cd.product?.name || '', description:cd.product?.description || '', image:cd.product?.image || null, price:cd.product?.price || '', currency:cd.product?.currency || '', show_price:cd.product?.show_price !== false }, creative_message:{ has_offer:!!cd.creative_message?.has_offer, offer:clone(cd.creative_message?.offer || {}), primary_focus:cd.creative_message?.primary_focus || 'auto', primary_focus_other:cd.creative_message?.primary_focus_other || '', cta_auto_select:true, // The merchant's approved wording from the Review screen. The image // model is instructed to render this verbatim — see build_prompt()'s // approved_copy branch — never to compose its own headline/CTA. // Blank fields mean "let the system decide," same as before. approved_copy:clone(cd.creative_copy || {}) }, payment:{ methods:clone(cd.payment?.methods || []) }, additional_context:cd.additional_context || '', output:{ primary_ratio:cd.output?.primary_ratio || '9:16', requested_placement:cd.output?.requested_placement || null, alternate_ratios:clone(cd.output?.alternate_ratios || []) } }; }, // Hard ceiling for real, brand-new creative directions — resize variants // are exempt (see runCreativeGeneration's isAdaptation branch), and this // constant is the single source both the button-hiding UI and the actual // generation guard read, so they can never disagree. MAX_CONCEPTS: 3, runCreativeGeneration(opts){ opts = opts || {}; const isAdaptation = !!opts.isAdaptation; // Enforced here, not just by hiding the "try another" button — every // entry point that can request a brand-new concept (try-another, the // review screen's "أنشئ إعلاني", the edit-details round trip) all funnel // through this one function, so a single guard here covers all of them. if(!isAdaptation && this.creative.concepts.length >= this.MAX_CONCEPTS){ this.creative.activeConceptIndex = this.creative.concepts.length-1; this.renderCreativeResult(); return; } // A resize always targets the currently-displayed, already-approved // suggestion — never a brand-new creative direction. const approvedConcept = isAdaptation ? this.creative.concepts[this.creative.activeConceptIndex] : null; this.creative.mode = 'result'; const body = document.getElementById('creative-body'); const dots = document.getElementById('creative-dots'); if(dots) dots.classList.add('hidden'); body.innerHTML = `
    ✨
    جالسين نجهّز إعلانك..
    `; // Cancel whatever request was still in flight before starting a new one, // and mint a token stored OUTSIDE this.creative (which openCreativeWizard // replaces wholesale on a new session) so a response that arrives after // the modal was closed or a new ad flow started is recognized as stale // and dropped, never applied to state it no longer belongs to. if(this.creativeAbortController){ try{ this.creativeAbortController.abort(); }catch(e){} } const abortController = (typeof AbortController!=='undefined') ? new AbortController() : null; this.creativeAbortController = abortController; const requestToken = Util.uid('gen'); this.creativeActiveToken = requestToken; const payload = this.buildCreativePayload(); if(isAdaptation && opts.resizeRatio){ // The resize target ratio is sent for THIS request only — it must // never overwrite cd.output.primary_ratio, or the next brand-new // concept would silently inherit whatever ratio was last resized to. payload.output.primary_ratio = opts.resizeRatio; } if(approvedConcept && approvedConcept.imageUrl){ // Sent back so the backend can reformat this exact design instead of // inventing a new one — see build_prompt()'s is_resize branch. payload.output.is_adaptation = true; payload.approved_design_image = approvedConcept.imageUrl; } EventTracker.log('creative_generation_requested', {ratio:payload.output.primary_ratio, isAdaptation}); CreativeGenerationService.generate(payload, abortController && abortController.signal).then(concept=>{ if(requestToken !== this.creativeActiveToken || concept.status === 'aborted') return; concept.isAdaptation = isAdaptation; concept.approved = false; if(isAdaptation && approvedConcept){ if(concept.status === 'succeeded'){ // A resize variant is filed under the approved suggestion it came // from — it never joins the Previous/Next suggestion list, so // paging through suggestions can never suddenly show a different // design. approvedConcept.resizeVariants = approvedConcept.resizeVariants || {}; approvedConcept.resizeVariants[concept.ratio] = concept; approvedConcept.activeResizeRatio = concept.ratio; this.creative.lastError = null; } else { // A failed resize must never blank out the approved ad the // merchant is still looking at — leave activeResizeRatio (and // whatever was already showing) untouched, and only surface the // error as a banner. Retrying the same ratio calls the backend // again rather than replaying a cached failure. this.creative.lastError = concept.error || 'تعذّر توليد هذا المقاس. إعلانك المعتمد لم يتأثر.'; } } else if(concept.status === 'succeeded'){ this.creative.concepts.push(concept); this.creative.activeConceptIndex = this.creative.concepts.length-1; this.creative.lastError = null; } else { // A failed brand-new attempt is never stored as a nav-able suggestion // and never burns one of the merchant's 3 real attempts — they never // actually got to see a result. Whatever was already saved stays put. this.creative.lastError = concept.error || 'حصل خطأ غير متوقع.'; } // Pixel conversion: a genuinely new creative that actually came back. // Ratio adaptations and failed generations are not conversions. if(!isAdaptation && concept.status==='succeeded'){ MetaPixel.track('ai_image_ad', { ratio: payload.output.primary_ratio, platform: payload.planner_context.platform, country: payload.planner_context.country, concept_number: this.creative.conceptCount }); } this.renderCreativeResult(); }); }, renderCreativeResult(){ this.creative.mode = 'result'; const body = document.getElementById('creative-body'); const concepts = this.creative.concepts; const idx = this.creative.activeConceptIndex; const concept = concepts[idx]; const total = concepts.length; const maxConcepts = this.MAX_CONCEPTS; if(!concept){ // No successful suggestion exists yet — the very first attempt failed. const ratio = this.creative.data.output.primary_ratio || '9:16'; body.innerHTML = `
    ⚠️
    تعذّر توليد الإعلان
    ${Util.escape(this.creative.lastError || 'حصل خطأ غير متوقع.')}
    `; document.getElementById('cw-retry').addEventListener('click', ()=>{ this.creative.lastError=null; this.runCreativeGeneration(); }); document.getElementById('cw-done').addEventListener('click', ()=> this.closeModal('creative-modal')); return; } EventTracker.log('creative_concept_viewed', {index:idx, ratio:concept.ratio}); const firstTimeBanner = (total===1 && idx===0 && !concept.activeResizeRatio) ? `
    ✨ عندك حتى 3 اتجاهات إبداعية تقدر تجربها وتختار الأنسب لك.
    ` : ''; const errorBanner = this.creative.lastError ? `
    ${Util.escape(this.creative.lastError)}
    ` : ''; const actionsHtml = !concept.approved ? `
    ${totalجرّب مقترح آخر` : `
    جربت الاتجاهات الثلاثة المتاحة — اختر أفضلها وطوّرها من التفاصيل.
    `}
    ` : `
    `; // The currently-displayed version: the original approved suggestion, or // one of its cached resize variants — never re-fetched, always the exact // saved result. const displayed = (concept.activeResizeRatio && concept.resizeVariants && concept.resizeVariants[concept.activeResizeRatio]) ? concept.resizeVariants[concept.activeResizeRatio] : concept; // Preview reflects the displayed version's ACTUAL status — a real image // when the backend succeeded, an honest placeholder box otherwise. Never // render a finished-looking box for a request that hasn't actually // succeeded. let previewHtml; if(displayed.status === 'succeeded' && displayed.imageUrl){ previewHtml = `
    الإعلان المولّد
    `; } else if(displayed.status === 'failed'){ previewHtml = `
    ⚠️
    تعذّر توليد هذا المقاس
    `; } else { previewHtml = `
    🖼️
    معاينة الإعلان — مقاس ${Util.escape(displayed.ratio)}
    `; } body.innerHTML = ` ${firstTimeBanner} ${errorBanner} ${previewHtml} ${total>1?`
    مقترح ${idx+1} من ${total}
    `:''} ${actionsHtml} `; if(total>1){ const prevBtn = document.getElementById('concept-prev'), nextBtn = document.getElementById('concept-next'); // Prev/Next only ever move the pointer between already-saved // suggestions — they never call the backend or change a saved result. if(prevBtn) prevBtn.addEventListener('click', ()=>{ this.creative.activeConceptIndex--; this.creative.lastError=null; this.renderCreativeResult(); }); if(nextBtn) nextBtn.addEventListener('click', ()=>{ this.creative.activeConceptIndex++; this.creative.lastError=null; this.renderCreativeResult(); }); } const approveBtn = document.getElementById('cw-approve'); if(approveBtn) approveBtn.addEventListener('click', ()=>{ // Only one concept can be the active approved concept at a time — // approving a different one must revoke the previous approval rather // than leaving both marked approved. this.creative.concepts.forEach(c=>{ c.approved = false; }); concept.approved = true; EventTracker.log('creative_concept_approved', {index:idx}); // Tell the server, so the team dashboard's "approved" count is real. if(concept.ref) Ingest.post(Ingest.MARK_URL, { ref:concept.ref, approved:true }); this.renderCreativeResult(); }); const downloadBtn = document.getElementById('cw-download'); if(downloadBtn) downloadBtn.addEventListener('click', ()=>{ const a = document.createElement('a'); a.href = displayed.imageUrl; a.download = `optimize-ad-${displayed.id}.png`; document.body.appendChild(a); a.click(); a.remove(); EventTracker.log('pdf_downloaded', {type:'creative_image'}); if(displayed.ref) Ingest.post(Ingest.MARK_URL, { ref:displayed.ref, downloaded:true }); }); const regenBtn = document.getElementById('cw-regenerate'); if(regenBtn) regenBtn.addEventListener('click', ()=>{ EventTracker.log('creative_regenerate_requested', {}); this.creative.lastError = null; this.runCreativeGeneration(); }); document.getElementById('cw-edit-details').addEventListener('click', ()=> this.renderCreativeReview()); const resizeBtn = document.getElementById('cw-resize'); if(resizeBtn) resizeBtn.addEventListener('click', ()=>{ const picker = document.getElementById('resize-picker'); picker.classList.toggle('hidden'); }); Util.qsa('#resize-grid .opt-row', body).forEach(row=>{ if(row.dataset.v===(concept.activeResizeRatio || this.creative.data.output.primary_ratio)) row.classList.add('selected'); row.addEventListener('click', ()=>{ const ratio = row.dataset.v; // Recorded for analytics only — cd.output.primary_ratio (the default // ratio the next brand-new concept will use) is deliberately left // untouched; the resize target ratio is passed straight through to // runCreativeGeneration instead (see opts.resizeRatio there). this.creative.data.output.alternate_ratios = Array.from(new Set([...this.creative.data.output.alternate_ratios, ratio])); EventTracker.log('creative_output_ratio_set', {ratio, via:'resize'}); // Already generated this size for this approved ad — show the saved // result again instead of calling the backend a second time. if(concept.resizeVariants && concept.resizeVariants[ratio]){ concept.activeResizeRatio = ratio; this.renderCreativeResult(); return; } this.creative.lastError = null; this.runCreativeGeneration({isAdaptation:true, resizeRatio:ratio}); }); }); document.getElementById('cw-done').addEventListener('click', ()=> this.closeModal('creative-modal')); } }); /* ============================================================================ 15. AdminDashboard — prototype-only, gated by ?view=admin (spec §30: never linked from public UI). Production MUST put this behind real auth (GET /api/admin/overview, GET /api/admin/leads with session/JWT check). ============================================================================ */ /* ============================================================================ ADMIN CRM v2 — I18n layer. Every visible admin label goes through I18n.t(key). Business VALUES (lifecycle stage, contact status, event names…) are NEVER translated strings — they are stored as stable English-ish keys (e.g. 'replied', 'campaign_launched') and only their DISPLAY label is looked up here. This is what keeps filtering/reporting language-neutral per the spec. ============================================================================ */ const I18n = { dicts: { ar: { login_title:'دخول فريق Optimize', login_sub:'هذا النظام مخصص لفريق Optimize فقط.', login_email_label:'بريدك الإلكتروني', login_button:'دخول', login_password_label:'كلمة المرور', login_error:'البريد أو كلمة المرور غير صحيحة', login_missing:'اكتب البريد وكلمة المرور', refresh:'تحديث', last_updated:'آخر تحديث', nav_overview:'نظرة عامة', nav_customers:'العملاء', nav_followups:'المتابعات', nav_creatives:'الإعلانات المُنشأة', nav_team:'الفريق', nav_ai:'مساعد التحليل', nav_section_admin:'إدارة', nav_audit:'سجل النشاطات', nav_settings:'الإعدادات', logout:'تسجيل خروج', exit_public:'للواجهة العامة', close:'إغلاق', overview_title:'نظرة عامة', customers_title:'العملاء', followups_title:'المتابعات', creatives_title:'الإعلانات المُنشأة', team_title:'الفريق', ai_title:'مساعد التحليل (Optimize AI Analyst)', audit_title:'سجل نشاطات الفريق', settings_title:'الإعدادات', needs_attention:'يحتاج إجراء الآن', search_placeholder:'بحث بالاسم، الجوال، أو المتجر...', owner:'الموظف المسؤول', priority:'الأولوية', lifecycle:'مرحلة العميل', last_activity:'آخر نشاط', visits:'الزيارات', ai_usage:'استخدام AI', contact_status:'التواصل', campaign_status:'الحملة', next_followup:'المتابعة القادمة', all:'الكل', save_view:'حفظ هذا الفلتر', my_customers:'عملائي', export_csv:'تصدير CSV', tab_overview:'نظرة عامة', tab_planner:'الخطة', tab_creatives:'الإعلانات', tab_activity:'النشاط', tab_notes:'الملاحظات', notes_placeholder:'أضف ملاحظة...', add_note:'إضافة', app_downloaded:'حمّل التطبيق؟', campaign_launched:'أطلق حملة؟', source_system:'موثّق تلقائيًا', source_manual:'تحديث يدوي', updated_by:'بواسطة', unknown:'غير معروف', yes:'نعم', no:'لا', not_yet:'لسه لا', verified:'مؤكد', not_contacted:'لم يُتواصل معه', attempted:'حاولنا التواصل', replied:'رد', no_response:'لا رد', meeting_booked:'تم حجز اجتماع', follow_up_required:'يحتاج متابعة', not_interested:'غير مهتم', converted:'تحوّل لعميل', ask_ai_placeholder:'اسأل عن العملاء، الأداء، أو الفرص...', ask:'اسأل', demo_note:'بيانات Demo فقط — لا يوجد عملاء حقيقيون بهذا النموذج.' }, en: { login_title:'Optimize Team Login', login_sub:'This system is restricted to the Optimize team.', login_email_label:'Your email', login_button:'Sign in', login_password_label:'Password', login_error:'Incorrect email or password', login_missing:'Enter your email and password', refresh:'Refresh', last_updated:'Last updated', nav_overview:'Overview', nav_customers:'Customers', nav_followups:'Follow-ups', nav_creatives:'Creatives', nav_team:'Team', nav_ai:'AI Analyst', nav_section_admin:'Admin', nav_audit:'Audit Log', nav_settings:'Settings', logout:'Log out', exit_public:'Exit to public site', close:'Close', overview_title:'Overview', customers_title:'Customers', followups_title:'Follow-ups', creatives_title:'Creatives', team_title:'Team', ai_title:'Optimize AI Analyst', audit_title:'Team Audit Log', settings_title:'Settings', needs_attention:'Needs attention now', search_placeholder:'Search name, phone, or store...', owner:'Owner', priority:'Priority', lifecycle:'Lifecycle', last_activity:'Last activity', visits:'Visits', ai_usage:'AI usage', contact_status:'Contact', campaign_status:'Campaign', next_followup:'Next follow-up', all:'All', save_view:'Save this filter', my_customers:'My customers', export_csv:'Export CSV', tab_overview:'Overview', tab_planner:'Planner', tab_creatives:'Creatives', tab_activity:'Activity', tab_notes:'Notes', notes_placeholder:'Add a note...', add_note:'Add', app_downloaded:'App downloaded?', campaign_launched:'Launched a campaign?', source_system:'System verified', source_manual:'Manual update', updated_by:'by', unknown:'Unknown', yes:'Yes', no:'No', not_yet:'Not yet', verified:'Verified', not_contacted:'Not contacted', attempted:'Attempted', replied:'Replied', no_response:'No response', meeting_booked:'Meeting booked', follow_up_required:'Follow-up required', not_interested:'Not interested', converted:'Converted', ask_ai_placeholder:'Ask about customers, performance, or opportunities...', ask:'Ask', demo_note:'Demo data only — no real customers in this prototype.' } }, current: 'ar', t(key){ return (this.dicts[this.current] && this.dicts[this.current][key]) || key; }, setLang(lang){ this.current = lang; StorageService.set('admin_lang', lang); const staff = AuthService.currentStaff(); if(staff){ staff.preferred_language = lang; AdminApi.saveStaff(staff); } this.apply(); }, init(){ const staff = AuthService.currentStaff(); this.current = (staff && staff.preferred_language) || StorageService.get('admin_lang') || (navigator.language||'ar').slice(0,2)==='en' ? 'en' : 'ar'; if(staff && staff.preferred_language) this.current = staff.preferred_language; }, apply(){ const root = document.getElementById('admin-view'); root.setAttribute('dir', this.current==='ar' ? 'rtl' : 'ltr'); root.setAttribute('lang', this.current); Util.qsa('[data-i18n]', root).forEach(el=>{ el.textContent = this.t(el.dataset.i18n); }); const sw = document.getElementById('sb-lang-switcher'); if(sw) sw.innerHTML = ``; Util.qsa('#sb-lang-switcher button').forEach(b=> b.addEventListener('click', (e)=>{ e.stopPropagation(); this.setLang(b.dataset.l); AdminDashboard.rerenderCurrentPage(); })); } }; /* ============================================================================ ADMIN CRM v2 — AuthService (mock). ⚠️ Client-side domain check is a UX convenience ONLY — see the HTML comment above the login screen. Real security lives entirely server-side. Role permission matrix below is enforced in the UI for this prototype; production MUST re-enforce every one of these on the backend per request. ============================================================================ */ const PERMISSIONS = { super_admin: { view_all_customers:true, edit_crm_fields:true, assign_owner:true, add_notes:true, export_data:true, view_audit:true, manage_team:true, manage_settings:true, view_analytics:true }, manager: { view_all_customers:true, edit_crm_fields:true, assign_owner:true, add_notes:true, export_data:true, view_audit:true, manage_team:true, manage_settings:true, view_analytics:true }, account_manager:{ view_all_customers:true, edit_crm_fields:true, assign_owner:false, add_notes:true, export_data:false, view_audit:false, manage_team:false, manage_settings:false, view_analytics:false }, marketing: { view_all_customers:true, edit_crm_fields:false, assign_owner:false, add_notes:false, export_data:true, view_audit:false, manage_team:false, manage_settings:false, view_analytics:true }, analyst: { view_all_customers:true, edit_crm_fields:false, assign_owner:false, add_notes:false, export_data:true, view_audit:false, manage_team:false, manage_settings:false, view_analytics:true } }; const ROLE_LABELS = { super_admin:'Super Admin', manager:'Admin / Manager', account_manager:'Account Manager', marketing:'Marketing / Product', analyst:'Analyst (Read Only)' }; /* Real, server-backed auth. The client no longer decides who is allowed in: every /api/team/* endpoint is @login_required in Django, so the permission checks below are UI convenience only -- bypassing them in dev tools gets you a nicer-looking page and exactly zero extra data. */ const AuthService = { _staff: null, currentStaff(){ return this._staff; }, can(action){ const staff = this._staff; if(!staff) return false; return !!(PERMISSIONS[staff.role] && PERMISSIONS[staff.role][action]); }, csrfToken(){ const m = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/); return m ? decodeURIComponent(m[1]) : ''; }, async refreshSession(){ try{ const r = await fetch('/api/team/session', { credentials:'same-origin' }); const d = await r.json(); this._staff = d.authenticated ? d.user : null; } catch(e){ this._staff = null; } return this._staff; }, async signIn(username, password){ try{ const r = await fetch('/api/team/login', { method:'POST', credentials:'same-origin', headers:{ 'Content-Type':'application/json', 'X-CSRFToken': this.csrfToken() }, body: JSON.stringify({ username, password }) }); if(!r.ok) return { ok:false, error:'invalid' }; await this.refreshSession(); return { ok:true, staff:this._staff }; } catch(e){ return { ok:false, error:'network' }; } }, async signOut(){ try{ await fetch('/api/team/logout', { method:'POST', credentials:'same-origin', headers:{ 'X-CSRFToken': this.csrfToken() } }); } catch(e){ /* falling through still clears the local view */ } this._staff = null; } }; /* ============================================================================ ADMIN CRM v2 — Service layer (AdminApi / CRMApi / AnalyticsApi). Every method here is the ONLY place that touches storage for its domain. Swapping to a real backend later means rewriting the bodies of these methods to call fetch() instead of StorageService — the UI code below never touches storage directly, so nothing above this layer changes. ============================================================================ */ /* Server-backed, but deliberately still synchronous. Every render function below calls AdminApi.customers() etc. directly. Rather than make all of them async (and touch a hundred call sites), the dashboard pulls one snapshot from /api/team/bootstrap on load and these accessors read it. At team-tool volume one request is also cheaper than a dozen. Call AdminApi.refresh() to pull a fresh snapshot. */ const AdminApi = { _snap: { merchants:[], recent_generations:[], daily_generations:[], totals:{}, lifecycle:{}, by_source:{}, truncated:false, generated_at:null }, _detail: {}, // public_id -> per-merchant history, fetched on demand async refresh(){ const r = await fetch('/api/team/bootstrap', { credentials:'same-origin' }); if(!r.ok) throw new Error('bootstrap failed: '+r.status); this._snap = await r.json(); this._detail = {}; return this._snap; }, snapshot(){ return this._snap; }, totals(){ return this._snap.totals || {}; }, generatedAt(){ return this._snap.generated_at; }, isTruncated(){ return !!this._snap.truncated; }, allStaff(){ // The team list lives in Django (see `manage.py create_team`). The // dashboard only knows about the signed-in user, so owner assignment is // intentionally unavailable until the follow-up phase adds an endpoint. const me = AuthService.currentStaff(); return me ? [{ id:me.id, name:me.name, email:me.email, role:me.role }] : []; }, customers(){ return this._snap.merchants || []; }, customer(id){ return this.customers().find(c=>c.id===id); }, allCreatives(){ return this._snap.recent_generations || []; }, dailyGenerations(){ return this._snap.daily_generations || []; }, async loadDetail(id){ if(this._detail[id]) return this._detail[id]; const r = await fetch('/api/team/merchant/'+encodeURIComponent(id), { credentials:'same-origin' }); if(!r.ok) return null; this._detail[id] = await r.json(); return this._detail[id]; }, detail(id){ return this._detail[id] || null; }, sessionsFor(cid){ const d=this.detail(cid); return d? d.visits : []; }, submissionsFor(cid){ const d=this.detail(cid); return d? d.submissions : []; }, creativesFor(cid){ const d=this.detail(cid); return d? d.creatives : []; }, eventsFor(cid){ const d=this.detail(cid); return d? d.events : []; }, notesFor(cid){ return []; }, // notes arrive with the follow-up phase // Writes are not available in this phase. Say so plainly rather than // accepting input and dropping it on the floor. saveStaff(){ return false; }, saveCustomer(){ return false; }, addNote(){ UIController.toast('الملاحظات تجي بالمرحلة القادمة'); return null; }, savedViews(){ return []; }, saveView(){ return false; } }; /* Follow-up fields (owner, contact status, notes, audit) are the NEXT phase. Until the server has write endpoints and an audit table, these refuse loudly. A silent accept would show the change in the UI, lose it on refresh, and make the dashboard untrustworthy -- which is worse than not having it. */ const CRMApi = { PHASE_MESSAGE: 'تعديل بيانات المتابعة يجي بالمرحلة القادمة — حالياً اللوحة للقراءة فقط.', updateField(){ UIController.toast(this.PHASE_MESSAGE); return false; }, assignOwner(){ UIController.toast(this.PHASE_MESSAGE); return false; }, logAudit(){ /* server-side audit arrives with the write endpoints */ }, allAudit(){ return []; } }; const AnalyticsApi = { /* Every KPI here is computed from something that actually exists in the demo data. If a metric has no real data source (e.g. verified campaign spend from Optimize's own backend), AdminDashboard renders "Integration required" instead of a fabricated number — never a fake zero. */ funnel(){ // Cumulative: reaching a stage means you passed every earlier one. const customers = AdminApi.customers(); const stageOrder = ['visitor','identified','planner_started','plan_generated','creative_created','contacted','campaign_launched']; const counts = {}; stageOrder.forEach(s=> counts[s]=0); customers.forEach(c=>{ const idx = stageOrder.indexOf(c.lifecycle); for(let i=0;i<=idx;i++) counts[stageOrder[i]]++; }); return stageOrder.map(s=>({ stage:s, count:counts[s] })); }, aiFunnel(){ const customers = AdminApi.customers(); const started = customers.filter(c=>c.usage.ai_opens>0).length; const generated = customers.filter(c=>c.usage.ai_generations>0).length; const approved = customers.filter(c=>c.usage.approved_creatives>0).length; const downloaded = customers.filter(c=>c.usage.downloads>0).length; return [{stage:'started',count:started},{stage:'generated',count:generated},{stage:'approved',count:approved},{stage:'downloaded',count:downloaded}]; }, needsAttention(){ /* Only queues that real usage data can actually answer. 'Overdue follow-up' is omitted on purpose: follow-up dates are set by the team, and that field does not exist yet, so the queue would always read zero and look like good news. */ const customers = AdminApi.customers(); return { hot_not_contacted: customers.filter(c=> (c.priority_override||c.priority_system)==='A'), generated_never_approved: customers.filter(c=> c.usage.ai_generations>0 && c.usage.approved_creatives===0), approved_never_downloaded: customers.filter(c=> c.usage.approved_creatives>0 && c.usage.downloads===0), returning_visitors: customers.filter(c=> c.usage.visits>1), asked_for_help: customers.filter(c=> c.usage.contact_requests>0), failed_generations: customers.filter(c=> c.usage.ai_generations>c.usage.successful_generations) }; } }; const AdminDashboard = { currentPage: 'overview', currentCustomerId: null, async init(){ document.getElementById('admin-view').classList.remove('hidden'); document.getElementById('app-shell').classList.add('hidden'); // No seeding. An empty dashboard means nobody has used the tool yet, which // is a true answer -- fabricated demo rows would not be. I18n.init(); this.bindLogin(); const staff = await AuthService.refreshSession(); if(staff){ I18n.init(); await this.startShell(); } else document.getElementById('admin-login-screen').classList.remove('hidden'); }, bindLogin(){ const logo = document.getElementById('admin-login-logo'); if(logo) logo.innerHTML = LOGO_WORD_SVG; I18n.apply(); const btn = document.getElementById('admin-login-btn'); const err = document.getElementById('admin-login-error'); const doLogin = async ()=>{ const email = document.getElementById('admin-login-email').value.trim(); const password = document.getElementById('admin-login-password').value; if(!email || !password){ err.textContent = I18n.t('login_missing'); err.classList.add('show'); return; } btn.disabled = true; btn.textContent = '...'; const res = await AuthService.signIn(email, password); btn.disabled = false; btn.textContent = I18n.t('login_button'); if(!res.ok){ // Same message whether the account is unknown or the password is // wrong -- never confirm which accounts exist. err.textContent = I18n.t('login_error'); err.classList.add('show'); document.getElementById('admin-login-password').value = ''; return; } err.classList.remove('show'); I18n.init(); document.getElementById('admin-login-screen').classList.add('hidden'); await this.startShell(); }; btn.addEventListener('click', doLogin); ['admin-login-email','admin-login-password'].forEach(id=> document.getElementById(id).addEventListener('keydown', (e)=>{ if(e.key==='Enter') doLogin(); })); }, async startShell(){ document.getElementById('admin-shell').classList.remove('hidden'); document.getElementById('admin-icon-slot2').innerHTML = LOGO_ICON_SVG; I18n.apply(); this.bindSidebar(); this.renderStaffCorner(); try{ await AdminApi.refresh(); } catch(e){ // Show the failure instead of an empty dashboard that reads as "no usage". document.getElementById('page-overview').innerHTML = '
    ما قدرنا نجيب البيانات من الخادم.' + '

    ' + 'حدّث الصفحة، وإذا استمرت المشكلة راجع سجل الخادم.

    '; console.error('bootstrap failed', e); return; } this.navigateTo('overview'); document.getElementById('admin-exit-btn').addEventListener('click', ()=>{ document.getElementById('admin-view').classList.add('hidden'); document.getElementById('app-shell').classList.remove('hidden'); }); document.getElementById('admin-logout-btn').addEventListener('click', async ()=>{ await AuthService.signOut(); document.getElementById('admin-shell').classList.add('hidden'); document.getElementById('admin-login-screen').classList.remove('hidden'); document.getElementById('admin-login-email').value = ''; document.getElementById('admin-login-password').value = ''; }); document.getElementById('c360-close').addEventListener('click', ()=> document.getElementById('c360-overlay').classList.remove('open')); const refreshBtn = document.getElementById('admin-refresh-btn'); if(refreshBtn) refreshBtn.addEventListener('click', async ()=>{ refreshBtn.disabled = true; try{ await AdminApi.refresh(); this.rerenderCurrentPage(); UIController.toast('✓'); } catch(e){ UIController.toast('ما قدرنا نحدّث البيانات'); } refreshBtn.disabled = false; }); }, renderStaffCorner(){ const staff = AuthService.currentStaff(); if(!staff) return; document.getElementById('admin-sb-avatar').textContent = staff.name.slice(0,1).toUpperCase(); document.getElementById('admin-sb-name').textContent = staff.name; document.getElementById('admin-sb-role').textContent = ROLE_LABELS[staff.role] || staff.role; }, bindSidebar(){ Util.qsa('.admin-sb-item[data-page]').forEach(item=>{ item.addEventListener('click', ()=> this.navigateTo(item.dataset.page)); }); }, navigateTo(page){ this.currentPage = page; Util.qsa('.admin-sb-item[data-page]').forEach(i=> i.classList.toggle('active', i.dataset.page===page)); Util.qsa('.admin-page').forEach(p=> p.classList.add('hidden')); document.getElementById('page-'+page).classList.remove('hidden'); const titles = { overview:'overview_title', customers:'customers_title', followups:'followups_title', creatives:'creatives_title', team:'team_title', ai:'ai_title', audit:'audit_title', settings:'settings_title' }; document.getElementById('admin-page-title').textContent = I18n.t(titles[page]); const note = document.getElementById('admin-updated-note'); if(note){ const at = AdminApi.generatedAt(); note.textContent = at ? I18n.t('last_updated')+': '+this.fmtDateTime(at) : ''; } const renderers = { overview:'renderOverview', customers:'renderCustomers', followups:'renderFollowups', creatives:'renderCreatives', team:'renderTeam', ai:'renderAI', audit:'renderAudit', settings:'renderSettings' }; this[renderers[page]](); }, rerenderCurrentPage(){ this.renderStaffCorner(); this.navigateTo(this.currentPage); }, ownerName(id){ const s = AdminApi.allStaff().find(x=>x.id===id); return s ? s.name : null; }, fmtDate(iso){ return new Date(iso).toLocaleDateString(I18n.current==='ar'?'ar-SA':'en-GB', {day:'numeric',month:'short',year:'numeric', numberingSystem:'latn'}); }, fmtDateTime(iso){ return new Date(iso).toLocaleString(I18n.current==='ar'?'ar-SA':'en-GB', {day:'numeric',month:'short',hour:'2-digit',minute:'2-digit', numberingSystem:'latn'}); }, priorityOf(c){ return c.priority_override || c.priority_system; }, /* ============================= OVERVIEW ============================= */ renderOverview(){ const el = document.getElementById('page-overview'); const customers = AdminApi.customers(); const na = AnalyticsApi.needsAttention(); const funnel = AnalyticsApi.funnel(); const aiFunnel = AnalyticsApi.aiFunnel(); /* Every tile counts rows that actually exist in the database. Where a number cannot be known yet -- verified campaign launches need Optimize's own backend -- the tile says so rather than showing a misleading zero. */ const T = AdminApi.totals(); const ar = I18n.current==='ar'; const kpis = [ { l:ar?'كل من استخدم الأداة':'Everyone who used the tool', v: T.merchants||0 }, { l:ar?'عرّفوا عن نفسهم':'Identified themselves', v: T.identified||0 }, { l:ar?'خطط مكتملة':'Completed plans', v: T.plans||0 }, { l:ar?'إعلانات نجحت':'Ads generated', v: T.generations_succeeded||0, note: (T.generations_failed ? (ar?`${T.generations_failed} محاولة فشلت`:`${T.generations_failed} failed`) : '') }, { l:ar?'إعلانات معتمدة':'Approved ads', v: T.approved||0 }, { l:ar?'تنزيلات':'Downloads', v: T.downloads||0 }, { l:ar?'زوار عائدون':'Returning users', v: T.returning||0 }, { l:ar?'نشط آخر ٧ أيام':'Active last 7 days', v: T.active_7d||0 }, { l:ar?'طلبات مساعدة':'Help requests', v: T.contact_requests||0 }, { l:ar?'حملات مُطلقة':'Campaigns launched', v: '—', note: ar?'يحتاج ربط مع Optimize':'Needs Optimize integration' } ]; const naList = [ { key:'hot_not_contacted', label: ar?'عملاء أولوية A':'Priority A customers', items:na.hot_not_contacted }, { key:'asked_for_help', label: ar?'طلبوا مساعدة':'Asked for help', items:na.asked_for_help }, { key:'generated_never_approved', label: ar?'أنشأوا إعلان ولم يعتمدوه':'Generated but never approved', items:na.generated_never_approved }, { key:'approved_never_downloaded', label: ar?'اعتمدوا إعلان ولم ينزّلوه':'Approved but never downloaded', items:na.approved_never_downloaded }, { key:'failed_generations', label: ar?'واجهوا فشل في التوليد':'Hit generation failures', items:na.failed_generations }, { key:'returning_visitors', label: ar?'زوار عائدون':'Returning visitors', items:na.returning_visitors } ].filter(x=>x.items.length); const funnelLabels = { visitor:'Visitor', identified:'Identified', planner_started:'Planner Started', plan_generated:'Plan Generated', creative_created:'Creative Created', contacted:'Contacted', campaign_launched:'Launched' }; const maxFunnel = Math.max(1, funnel[0].count); el.innerHTML = ` ${naList.length ? `

    🔥 ${I18n.t('needs_attention')}

    ${naList.map(x=>`
    ${x.label}${x.items.length}
    `).join('')}
    ` : ''}
    ${kpis.map(k=>`
    ${k.v}
    ${k.l}
    ${k.note?`
    ${k.note}
    `:''}
    `).join('')}

    ${I18n.current==='ar'?'مسار التحويل (Funnel)':'Conversion Funnel'}

    ${funnel.map(f=>`
    ${funnelLabels[f.stage]}
    ${f.count}
    `).join('')}

    ${I18n.current==='ar'?'مسار الذكاء الاصطناعي':'AI Creator Funnel'}

    ${aiFunnel.map(f=>`
    ${f.stage}${f.count}
    `).join('')}
    ${AdminApi.isTruncated() ? `

    ${ar?'يتم عرض أول':'Showing the first'} ${AdminApi.snapshot().row_limit} ${ar?'عميل فقط — الإجماليات أعلاه كاملة.':'customers only — the totals above are complete.'}

    ` : ''}`; Util.qsa('.na-item', el).forEach(item=>{ item.addEventListener('click', ()=>{ this._pendingCustomerFilter = na[item.dataset.na].map(c=>c.id); this.navigateTo('customers'); }); }); }, /* ============================= CUSTOMERS ============================= */ renderCustomers(){ const el = document.getElementById('page-customers'); const staff = AuthService.currentStaff(); el.innerHTML = `
    ${AuthService.can('export_data') ? `` : ''}
    Customer${I18n.current==='ar'?'المتجر':'Business'}${I18n.t('owner')}${I18n.t('lifecycle')} ${I18n.t('priority')}${I18n.t('last_activity')}${I18n.t('visits')}${I18n.t('contact_status')}
    `; const renderTable = ()=>{ let list = AdminApi.customers(); if(this._pendingCustomerFilter){ list = list.filter(c=>this._pendingCustomerFilter.includes(c.id)); } const q = document.getElementById('cust-search').value.trim().toLowerCase(); if(q) list = list.filter(c=> (c.name||'').toLowerCase().includes(q) || (c.phone||'').includes(q) || (c.business_name||'').toLowerCase().includes(q)); const fp = document.getElementById('f-priority').value; if(fp) list = list.filter(c=>this.priorityOf(c)===fp); const fl = document.getElementById('f-lifecycle').value; if(fl) list = list.filter(c=>c.lifecycle===fl); const fc = document.getElementById('f-country').value; if(fc) list = list.filter(c=>c.country===fc); if(this._filterMine) list = list.filter(c=>c.owner_id===staff.id); document.getElementById('cust-tbody').innerHTML = list.map(c=>` ${Util.escape(c.name)}
    ${CONFIG.countries[c.country]?CONFIG.countries[c.country].flag:''} ${Util.escape(c.phone)}
    ${Util.escape(c.business_name||'—')} ${c.owner_id ? Util.escape(this.ownerName(c.owner_id)) : `—`} ${c.lifecycle} ${this.priorityOf(c)} ${this.fmtDate(c.last_activity)} ${c.usage.visits} ${I18n.t(c.crm.contacted.value)} `).join('') || `${I18n.current==='ar'?'لا يوجد عملاء مطابقون':'No matching customers'}`; Util.qsa('#cust-tbody tr[data-id]').forEach(tr=> tr.addEventListener('click', ()=> this.openCustomer360(tr.dataset.id))); }; ['cust-search','f-priority','f-lifecycle','f-country'].forEach(id=> document.getElementById(id).addEventListener('input', renderTable)); document.getElementById('view-mine').addEventListener('click', ()=>{ this._filterMine = true; document.getElementById('view-mine').classList.add('active'); renderTable(); }); document.getElementById('view-clear').addEventListener('click', ()=>{ this._filterMine = false; this._pendingCustomerFilter = null; document.getElementById('view-mine').classList.remove('active'); renderTable(); }); const exportBtn = document.getElementById('export-csv-btn'); if(exportBtn) exportBtn.addEventListener('click', ()=> this.exportCustomersCsv(AdminApi.customers())); renderTable(); }, exportCustomersCsv(list){ const headers = ['name','phone','country','business_name','lifecycle','priority','owner','last_activity','visits','contact_status']; const rows = list.map(c=>[c.name,c.phone,c.country,c.business_name,c.lifecycle,this.priorityOf(c),this.ownerName(c.owner_id)||'',c.last_activity,c.usage.visits,c.crm.contacted.value]); const csv = [headers.join(',')].concat(rows.map(r=> r.map(v=>`"${String(v).replace(/"/g,'""')}"`).join(','))).join('\n'); const blob = new Blob([csv], {type:'text/csv'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'customers_export.csv'; document.body.appendChild(a); a.click(); a.remove(); CRMApi.logAudit('export', 'customers', null, null, list.length+' rows'); }, /* ============================= FOLLOW-UPS ============================= */ renderFollowups(){ const el = document.getElementById('page-followups'); const customers = AdminApi.customers(); const now = Date.now(); const ar = I18n.current==='ar'; /* Queues built only from observed behaviour. Date-based queues need the team to set follow-up dates -- that field arrives with the next phase, so showing an always-empty "Overdue" list would be misleading. */ const queues = [ { label: ar?'أولوية A':'Priority A', items: customers.filter(c=> this.priorityOf(c)==='A') }, { label: ar?'طلبوا مساعدة':'Asked for help', items: customers.filter(c=> c.usage.contact_requests>0) }, { label: ar?'اعتمدوا إعلان ولم ينزّلوه':'Approved but not downloaded', items: customers.filter(c=> c.usage.approved_creatives>0 && c.usage.downloads===0) }, { label: ar?'أنشأوا ولم يعتمدوا':'Generated but not approved', items: customers.filter(c=> c.usage.ai_generations>0 && c.usage.approved_creatives===0) }, { label: ar?'واجهوا فشل في التوليد':'Hit generation failures', items: customers.filter(c=> c.usage.ai_generations>c.usage.successful_generations) } ]; el.innerHTML = queues.map(q=> !q.items.length ? '' : `

    ${q.label} (${q.items.length})

    ${q.items.map(c=>``).join('')}
    ${Util.escape(c.name)}${Util.escape(c.business_name||'')}${c.owner_id?Util.escape(this.ownerName(c.owner_id)):'—'}${this.priorityOf(c)}
    `).join('') || `

    ${I18n.current==='ar'?'لا توجد متابعات معلّقة 🎉':'No pending follow-ups 🎉'}

    `; Util.qsa('.data-row', el).forEach(tr=> tr.addEventListener('click', ()=> this.openCustomer360(tr.dataset.id))); }, /* ============================= CREATIVES LIBRARY ============================= */ renderCreatives(){ const el = document.getElementById('page-creatives'); const creatives = AdminApi.allCreatives(); el.innerHTML = ` `; Util.qsa('.gallery-card', el).forEach(card=> card.addEventListener('click', ()=> this.openCustomer360(card.dataset.cid, 'creatives'))); }, /* ============================= TEAM ============================= */ renderTeam(){ const el = document.getElementById('page-team'); const staff = AdminApi.allStaff(); const customers = AdminApi.customers(); el.innerHTML = `
    ${staff.map(s=>{ const mine = customers.filter(c=>c.owner_id===s.id); const contacted = mine.filter(c=>['replied','meeting_booked','converted'].includes(c.crm.contacted.value)).length; const launched = mine.filter(c=>c.crm.campaign_launched.value==='yes_verified').length; return ``; }).join('')}
    ${I18n.current==='ar'?'الموظف':'Employee'}Role${I18n.current==='ar'?'عملاء مسندون':'Assigned'}${I18n.current==='ar'?'تم التواصل':'Contacted'}${I18n.current==='ar'?'حملات مُطلقة':'Launches'}
    ${Util.escape(s.name)}
    ${s.email}
    ${ROLE_LABELS[s.role]}${mine.length}${contacted}${launched}
    `; }, /* ============================= AI ANALYST (rule-based MVP — not a live LLM call) ============================= */ renderAI(){ const el = document.getElementById('page-ai'); const sampleQs = I18n.current==='ar' ? ['مين أهم العملاء اللي لازم أتواصل معهم اليوم؟','اعطني العملاء اللي أنشأوا إعلان لكن ما أطلقوا حملة','أي مصدر جاب لنا أفضل Leads؟'] : ['Who should I follow up with today?','Show customers who generated ads but never launched','Which source brought the best leads?']; el.innerHTML = `

    ${I18n.current==='ar'?'نسخة أولى قائمة على قواعد ثابتة تحلل بيانات الـDemo الحالية فقط — ليست نموذج ذكاء اصطناعي حي متصل بمزوّد خارجي بهذي النسخة.':'MVP rule-based version analyzing current demo data only — not a live LLM call in this prototype.'}

    ${sampleQs.map(q=>`${q}`).join('')}
    `; const answer = (q)=>{ const customers = AdminApi.customers(); const na = AnalyticsApi.needsAttention(); let html = ''; if(/تواصل|follow.?up|contact/i.test(q)){ const list = na.hot_not_contacted.slice(0,5); html = `${I18n.current==='ar'?'العملاء الأولوية العالية بدون تواصل':'High-priority customers with no contact'} (n=${na.hot_not_contacted.length}):`; } else if(/أنشأوا إعلان|generated.*never|no.?launch|approve/i.test(q)){ const list = na.generated_never_approved.slice(0,5); html = `${I18n.current==='ar'?'أنشأوا إعلان لكن لم يعتمدوه':'Generated ads but never approved'} (n=${na.generated_never_approved.length}):`; } else if(/مصدر|source/i.test(q)){ const bySource = {}; customers.forEach(c=>{ const s=c.acquisition.utm_source||'direct'; bySource[s]=(bySource[s]||0)+1; }); html = `${I18n.current==='ar'?'توزيع العملاء حسب المصدر':'Customers by source'}:`; } else { html = `${I18n.current==='ar'?'ما قدرت أفهم السؤال بهذي النسخة التجريبية':"Couldn't match this question in the MVP version"}. ${I18n.current==='ar'?'جرّب أحد الأسئلة الجاهزة فوق.':'Try one of the sample questions above.'}`; } document.getElementById('ai-answer-slot').innerHTML = `
    ${html}
    ${I18n.current==='ar'?'مصدر البيانات: عملاء Demo الحاليون فقط':'Data source: current demo customers only'} · n=${customers.length}
    `; Util.qsa('#ai-answer-slot a[data-cid]').forEach(a=> a.addEventListener('click', (e)=>{ e.preventDefault(); this.openCustomer360(a.dataset.cid); })); }; Util.qsa('.ai-q-chip', el).forEach(chip=> chip.addEventListener('click', ()=>{ document.getElementById('ai-q-input').value = chip.textContent; answer(chip.textContent); })); document.getElementById('ai-q-ask').addEventListener('click', ()=> answer(document.getElementById('ai-q-input').value)); }, /* ============================= AUDIT LOG ============================= */ renderAudit(){ const el = document.getElementById('page-audit'); if(!AuthService.can('view_audit')){ el.innerHTML = `

    ${I18n.current==='ar'?'صلاحياتك لا تسمح بعرض سجل النشاطات.':'Your role cannot view the audit log.'}

    `; return; } const audit = CRMApi.allAudit(); el.innerHTML = `
    ${audit.map(a=>{ const cust = a.customer_id ? AdminApi.customer(a.customer_id) : null; return ``; }).join('') || ``}
    ${I18n.current==='ar'?'الوقت':'Time'}${I18n.current==='ar'?'الموظف':'Employee'}ActionCustomer${I18n.current==='ar'?'قبل → بعد':'Before → After'}
    ${this.fmtDateTime(a.timestamp)}${Util.escape(a.employee)}${a.action}${cust?Util.escape(cust.name):'—'}${Util.escape(String(a.before))} → ${Util.escape(String(a.after))}
    —
    `; }, /* ============================= SETTINGS ============================= */ renderSettings(){ const el = document.getElementById('page-settings'); if(!AuthService.can('manage_settings')){ el.innerHTML = `

    ${I18n.current==='ar'?'صلاحياتك لا تسمح بتعديل الإعدادات.':'Your role cannot edit settings.'}

    `; return; } el.innerHTML = `

    ${I18n.current==='ar'?'مصفوفة الصلاحيات':'Roles & Permissions Matrix'}

    ${Object.keys(ROLE_LABELS).map(r=>``).join('')}${Object.keys(PERMISSIONS.super_admin).map(action=>`${Object.keys(ROLE_LABELS).map(r=>``).join('')}`).join('')}
    ${ROLE_LABELS[r]}
    ${action}${PERMISSIONS[r][action]?'✅':'—'}

    ${I18n.current==='ar'?'روابط CTA':'CTA Links'}

    ${I18n.current==='ar'?'الحساب':'Account'}

    ${I18n.current==='ar' ? 'الحسابات والصلاحيات تُدار من Django Admin على الخادم. لتغيير كلمة مرورك استخدم صفحة Django Admin.' : 'Accounts and roles are managed in Django Admin on the server. Change your password there.'}

    ${I18n.current==='ar'?'تغيير كلمة المرور':'Change password'}
    `; document.getElementById('settings-save').addEventListener('click', ()=>{ CONFIG.links.whatsappUrl = document.getElementById('s-whatsapp').value; CONFIG.links.meetingUrl = document.getElementById('s-meeting').value; CONFIG.links.optimizeAppUrl = document.getElementById('s-optimize').value; UIController.toast('✅'); }); }, /* ============================= CUSTOMER 360 ============================= */ async openCustomer360(id, initialTab){ const c = AdminApi.customer(id); if(!c) return; this.currentCustomerId = id; // Per-merchant history is fetched on demand rather than shipped in the // bootstrap payload, which keeps the initial load small. await AdminApi.loadDetail(id); document.getElementById('c360-name').textContent = c.name; document.getElementById('c360-sub').textContent = (c.business_name||'') + ' · ' + (CONFIG.countries[c.country]?CONFIG.countries[c.country].name:c.country); document.getElementById('c360-side').innerHTML = this.renderC360Side(c); document.getElementById('c360-content').innerHTML = this.renderC360Tabs(c); this.bindC360(c); this.showC360Tab(c, initialTab || 'overview'); document.getElementById('c360-overlay').classList.add('open'); }, renderC360Side(c){ const staff = AdminApi.allStaff(); const contactOpts = ['not_contacted','attempted','replied','no_response','meeting_booked','follow_up_required','not_interested','converted']; return `
    ${I18n.t('source_manual')} ${I18n.t('updated_by')} ${Util.escape(c.crm.contacted.updatedBy||'—')}
    ${c.crm.app_downloaded.source==='system'?I18n.t('source_system'):I18n.t('source_manual')}
    UTM: ${Util.escape(c.acquisition.utm_source)} / ${Util.escape(c.acquisition.utm_campaign)}
    `; }, bindC360(c){ const wire = (id, field)=> document.getElementById(id).addEventListener('change', (e)=>{ if(field==='owner_id'){ CRMApi.assignOwner(c.id, e.target.value||null); } else if(field==='priority_override'){ CRMApi.updateField(c.id, 'priority_override', e.target.value||null); } else { CRMApi.updateField(c.id, field, e.target.value); } UIController.toast('✅'); this.renderStaffCorner(); }); wire('c360-owner','owner_id'); wire('c360-priority','priority_override'); wire('c360-contact','crm.contacted.value'); wire('c360-app','crm.app_downloaded.value'); wire('c360-campaign','crm.campaign_launched.value'); Util.qsa('.c360-tab-btn', document.getElementById('c360-content')).forEach(btn=>{ btn.addEventListener('click', ()=> this.showC360Tab(c, btn.dataset.tab)); }); const noteBtn = document.getElementById('c360-add-note-btn'); if(noteBtn) noteBtn.addEventListener('click', ()=>{ const staff = AuthService.currentStaff(); const val = document.getElementById('c360-note-input').value.trim(); if(!val) return; AdminApi.addNote(c.id, val, staff.name); document.getElementById('c360-note-input').value = ''; this.showC360Tab(c, 'notes'); }); }, showC360Tab(c, tab){ Util.qsa('.c360-tab-btn', document.getElementById('c360-content')).forEach(b=> b.classList.toggle('active', b.dataset.tab===tab)); Util.qsa('.c360-tab-panel', document.getElementById('c360-content')).forEach(p=> p.classList.toggle('hidden', p.dataset.tab!==tab)); }, renderC360Tabs(c){ const sessions = AdminApi.sessionsFor(c.id); const submissions = AdminApi.submissionsFor(c.id); const creatives = AdminApi.creativesFor(c.id); const notes = AdminApi.notesFor(c.id); const timelineEvents = []; sessions.forEach(s=> timelineEvents.push({ t:s.started_at, text: (I18n.current==='ar'?'زيارة من ':'Visit from ') + s.source })); submissions.forEach(s=> timelineEvents.push({ t:s.submitted_at, text: (I18n.current==='ar'?'خطة مُولّدة — ميزانية ':'Plan generated — budget ') + s.monthly_budget })); creatives.forEach(cr=> timelineEvents.push({ t:cr.created_at, text: (I18n.current==='ar'?'إعلان مُولّد — ':'Creative generated — ') + cr.ratio + (cr.approved?(I18n.current==='ar'?' (اعتمد)':' (approved)'):'') })); notes.forEach(n=> timelineEvents.push({ t:n.created_at, text: (I18n.current==='ar'?'ملاحظة من ':'Note by ') + n.author })); timelineEvents.sort((a,b)=> new Date(b.t)-new Date(a.t)); return `
    ${I18n.t('tab_overview')}
    ${I18n.t('tab_planner')} (${submissions.length})
    ${I18n.t('tab_creatives')} (${creatives.length})
    ${I18n.t('tab_activity')}
    ${I18n.t('tab_notes')} (${notes.length})
    ${c.usage.visits}
    ${I18n.t('visits')}
    ${c.usage.plans_generated}
    ${I18n.t('tab_planner')}
    ${c.usage.ai_generations}
    AI
    ${c.usage.downloads}
    ${I18n.current==='ar'?'تنزيلات':'Downloads'}
    ${I18n.current==='ar'?'المنصة المقترحة':'Recommended platform'}${c.recommended_platform}
    ${I18n.current==='ar'?'Readiness':'Readiness'}${c.readiness_score}%
    ${I18n.current==='ar'?'أول ظهور':'First seen'}${this.fmtDate(c.first_seen)}
    `; } }; /* ============================================================================ 16. BOOTSTRAP ============================================================================ */ document.addEventListener('DOMContentLoaded', function(){ UIController.init(); }); /* Expose for console/demo convenience only (not part of the product surface) */ window.__optimizePlanner = { CONFIG, StateManager, UIController, AdminDashboard, EventTracker, StorageService, BudgetEngine, ReadinessEngine, RecommendationEngine, LeadScoringEngine, CreativeGenerationService }; })();