ShopNex
SPOT AD
Approved ShopNex spot adverts will play here.
ShopNex marketplace home graphic

All Products

Showing all products.

Vendors

Browse vendors. Buyer contact remains through ShopNex Call and ShopNex Chat.

My Cart

Orders

Photos & Videos

Browse supplier store photos and videos.

Promotions

Choose an item currently on promotion.

ShopNex CallShopNex Chat

Products

Photos

Videos

My Account

My Account

Create Vendor Account

Register your business to sell on ShopNex.

After registration, complete your Vendor Profile and business GPS location. Your account will remain Pending Approval until ShopNex Admin reviews it.

My Account

Vendor Login

Login is required every time you open My Account.

Photos

These are the same photos clients see under Photos & Videos. Add a photo here or delete one; deleted photos are removed from the client view immediately.

Add Photos

Videos

These are the same videos clients see under Photos & Videos. Add a video here or delete one; deleted videos are removed from the client view immediately.

Add Videos
ShopNex automatically checks each uploaded video in the background and prevents duplicate ShopNex watermarks.

Promotions Manager

Select the products you want to promote. Every selected item can have its own promotion matrix.

Each item may use a different discount method, discount value, promotional price, start date, end date and active/inactive status. Saving one row does not change another product's promotion.

Item List

Manage products shown to buyers.

Vendor Profile Registration

Vendor Profile Picture
This is the only image stored under Vendor Profile.
Store media is managed separately: use My Account β†’ Photos for store photos and My Account β†’ Videos for store videos. Store media is not saved under Vendor Profile.

Operating Location

Location follows the Electoral Commission administrative hierarchy: District/City β†’ Sub-county/Town/Municipal Division β†’ Parish/Ward β†’ Village/Cell. Current EC statistics report 146 districts, 2,191 sub-counties/towns/divisions, 10,717 parishes and 71,214 villages.
ShopNex loads the available Uganda administrative-unit dataset down to Sub-county/Town/Division β†’ Parish/Ward β†’ Village/Cell. If internet is unavailable, the built-in fallback dataset remains available.
πŸ“ Business GPS Location
You may save and submit your Vendor Profile without sharing GPS on the first interaction. However, the business GPS location must be added before ShopNex Admin can approve the vendor account. ShopNex uses this private business pin internally for delivery calculations, and the GPS coordinates are never shown to buyers.
Business GPS location not captured.
ShopNex will request location directly from the device. On Android, allow Precise location for the browser and keep phone Location ON.
ShopNex Call & Chat
Automatic communication service. When this vendor device has internet, ShopNex automatically connects and reconnects Calls & Chat. Personal phone numbers remain hidden.
Connecting automatically…
No manual Go Online button is required.

Delivery Person Registration

Register ShopNex delivery partners, verify their identity and specify the vehicle used for delivery.

National ID - Front *
Upload a clear photo of the front side of the delivery person's National ID.
National ID - Back *
Upload a clear photo of the back side of the delivery person's National ID.
The NIN and National ID photos are private verification records for ShopNex administration. They are not displayed to buyers.
Current GPS / Google Location Pin
Use the delivery person's device location while registering. This prototype stores latitude/longitude locally.
πŸ—ΊοΈ Google Maps Delivery Billing
For live road distance and traffic-aware ETA, enter a Google Maps JavaScript API key with Distance Matrix enabled. Restrict the key to your ShopNex website domain before production use.
Google Maps not configured. GPS fallback will be used.

Registered Delivery People

πŸ’¬ ShopNex Chat

ShopNex User
Connecting…

πŸ“ž ShopNex Call

SN
ShopNex User
Ready

Incoming ShopNex Contact

Incoming contact

πŸ“Ί Adverts Manager

Upload a spot advert for ShopNex Home. An advert will not air until approved by ShopNex Admin.

Upload Advert Video
Videos are automatically compressed before upload to reduce storage and data usage. Maximum four approved adverts can run on Home at a time.
πŸ›‘οΈ ShopNex Admin / Back Office

Platform control, operations monitoring and management reports.

`} function openOrderDocument(id,type='order'){const o=orders.find(x=>x.id===id);if(!o){alert('Order not found.');return}const w=window.open('','_blank');if(!w){alert('Please allow pop-ups to open the readable document.');return}w.document.open();w.document.write(buildOrderDocumentHtml(o,type));w.document.close()} function downloadOrderDocument(id,type='order'){const o=orders.find(x=>x.id===id);if(!o)return;const html=buildOrderDocumentHtml(o,type);const blob=new Blob([html],{type:'text/html;charset=utf-8'});triggerBlobDownload(blob,`ShopNex_${documentTitle(type).replace(/\s+/g,'_')}_${o.id}.html`);showToast(documentTitle(type)+' downloaded. Open it and use Print / Save as PDF if a PDF copy is required.')} function downloadOrderPDF(id,type='order'){const o=orders.find(x=>x.id===id);if(!o)return;try{if(!window.jspdf||!window.jspdf.jsPDF){downloadOrderDocument(id,type);return}const {jsPDF}=window.jspdf,doc=new jsPDF({unit:'mm',format:'a4'});const title=documentTitle(type),placed=orderPlacedValue(o),final=orderValue(o);let y=16;doc.setFont('helvetica','bold');doc.setFontSize(20);doc.text('ShopNex',14,y);doc.setFontSize(12);doc.text(title+' '+o.id,14,y+9);doc.setFont('helvetica','normal');doc.setFontSize(9);doc.text('Your Marketplace. Together.',14,y+15);doc.text('Order Date: '+String(o.date||''),120,y);doc.text('Status: '+String(o.status||'Placed'),120,y+6);doc.text('Vendor: '+String(o.vendor||''),14,y+24);doc.text('Supplier Code: '+String(o.supplierCode||''),14,y+30);y+=40;doc.setFont('helvetica','bold');doc.text('#',14,y);doc.text('Item',22,y);doc.text('Qty',102,y);doc.text('Placed',116,y);doc.text('Negotiated',143,y);doc.text('Value',175,y);doc.line(14,y+2,196,y+2);doc.setFont('helvetica','normal');for(let idx=0;idx<(o.items||[]).length;idx++){const x=o.items[idx],placedUnit=Number(x.placedUnitPrice??x.unitPrice??0),unit=lineFinalUnit(x),qty=Number(x.qty||0),name=String(x.name||('Item '+(idx+1)));y+=7;if(y>270){doc.addPage();y=18}doc.text(String(idx+1),14,y);const nm=doc.splitTextToSize(name+(x.code?' ('+x.code+')':'')+' | Brand: '+(x.brand||'β€”')+' | '+(x.sku||'SKU β€”')+' | '+(x.uom||'Pieces')+' | '+Math.max(1,Number(x.pcsPerUom||1))+' PCS/UoM',76);doc.text(nm,22,y);doc.text(String(qty),104,y,{align:'right'});doc.text(Number(placedUnit).toLocaleString(),139,y,{align:'right'});doc.text(unit!==placedUnit?Number(unit).toLocaleString():'-',169,y,{align:'right'});doc.text(Number(unit*qty).toLocaleString(),196,y,{align:'right'});y+=Math.max(0,(nm.length-1)*4)}y+=10;if(y>265){doc.addPage();y=20}doc.setFont('helvetica','bold');doc.text('Placed Order Value: UGX '+Number(placed).toLocaleString(),196,y,{align:'right'});if(hasNegotiation(o)){y+=7;doc.text('Negotiated Value: UGX '+Number(final).toLocaleString(),196,y,{align:'right'})}y+=7;doc.text(title+' Value: UGX '+Number(final).toLocaleString(),196,y,{align:'right'});doc.save(`ShopNex_${title.replace(/\s+/g,'_')}_${o.id}.pdf`);showToast(title+' PDF downloaded.')}catch(e){console.error(e);downloadOrderDocument(id,type)}} function persistVendorAuth(){localStorage.setItem("shopnex_vendor_auth_v1",JSON.stringify(vendorAuth));sessionStorage.setItem("shopnex_vendor_logged_in_v1",vendorLoggedIn?"1":"0");try{localStorage.removeItem("shopnex_vendor_logged_in_v1");}catch(e){}} function accountStatus(){return (vendorAuth&&vendorAuth.status)||adminSettings.vendorStatus||"Pending Approval"} function statusClass(st){st=String(st||'').toLowerCase();return st.includes('approve')?'approved':st.includes('reject')?'rejected':st.includes('suspend')?'suspended':'pending'} const SHOPNEX_ADMIN_APPROVAL_EMAIL="BCL-Admin@bclconsultltd.com"; const SHOPNEX_VENDOR_EMAIL_OUTBOX_KEY="shopnex_vendor_admin_email_outbox_v1"; function vendorApprovalLink(applicationId){const base=location.href.split('#')[0];return base+'#shopnex-super-admin-review='+encodeURIComponent(applicationId||'latest')} function queueAdminEmail(payload){let q=[];try{q=JSON.parse(localStorage.getItem(SHOPNEX_VENDOR_EMAIL_OUTBOX_KEY)||'[]')}catch(e){}q.unshift({...payload,queuedAt:new Date().toISOString(),status:'Queued'});localStorage.setItem(SHOPNEX_VENDOR_EMAIL_OUTBOX_KEY,JSON.stringify(q.slice(0,100)))} async function sendVendorApplicationAdminEmail(application){const payload={to:SHOPNEX_ADMIN_APPROVAL_EMAIL,subject:'ShopNex Vendor Application Pending Approval – '+application.businessName,applicationId:application.applicationId,businessName:application.businessName,contactName:application.contactName,phone:application.phone,email:application.email,submittedAt:application.submittedAt,approvalUrl:application.approvalUrl};try{queueAdminEmail(payload)}catch(e){console.warn('Admin email outbox storage warning',e)}try{const res=await fetch('/api/shopnex/vendor-application-notification',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});if(!res.ok)throw new Error('Email endpoint returned '+res.status);let q=[];try{q=JSON.parse(localStorage.getItem(SHOPNEX_VENDOR_EMAIL_OUTBOX_KEY)||'[]')}catch(e){}const hit=q.find(x=>x.applicationId===payload.applicationId);if(hit){hit.status='Sent to backend';hit.sentAt=new Date().toISOString();localStorage.setItem(SHOPNEX_VENDOR_EMAIL_OUTBOX_KEY,JSON.stringify(q))}return true}catch(e){console.warn('ShopNex admin email queued until backend email service is available.',e);return false}} function validateVendorPasswords(){ const p1=el('regPassword'),p2=el('regPassword2'),warn=el('regPasswordWarning'),ok=el('regPasswordMatch'),btn=el('createVendorAccountBtn'); if(!p1||!p2)return true; const hasConfirm=p2.value.length>0,match=p1.value===p2.value; if(warn)warn.style.display=hasConfirm&&!match?'block':'none'; if(ok)ok.style.display=hasConfirm&&match?'block':'none'; try{p2.setCustomValidity(hasConfirm&&!match?'Passwords do not match. Please enter the same password in both fields.':'')}catch(e){} // Keep Create Vendor Account clickable at all times. Validation happens when tapped. if(btn){btn.disabled=false;btn.removeAttribute('disabled');btn.style.pointerEvents='auto';btn.style.opacity='1';} return !hasConfirm||match; } function createVendorAccount(){ const business=(el('regBusinessName')?.value||'').trim(); const contact=(el('regContactName')?.value||'').trim(); const phone=(el('regPhone')?.value||'').trim(); const email=(el('regEmail')?.value||'').trim().toLowerCase(); const pw=el('regPassword')?.value||''; const pw2=el('regPassword2')?.value||''; if(!business||!contact||(!phone&&!email)){alert('Enter the business name, contact person and a mobile number or email.');return;} if(pw.length<6){alert('Password must have at least 6 characters.');return;} if(!pw2){alert('Please confirm your password.');return;} if(pw!==pw2){validateVendorPasswords();el('regPassword2')?.focus();alert('Passwords do not match. Please correct the confirmed password before continuing.');return;} const applicationId='VAPP-'+Date.now().toString(36).toUpperCase(); vendor={...vendor,name:business,contact,tel1:phone,email,vendorType:vendor.vendorType||'Retail'}; vendorAuth={identity:email||phone,phone,email,password:pw,createdAt:Date.now(),applicationId,status:'Pending Profile',profileSubmitted:false}; vendorLoggedIn=true; adminSettings.vendorStatus='Pending Profile'; try{ localStorage.setItem('shopnex_vendor_profile_v17',JSON.stringify(vendor)); localStorage.setItem('shopnex_admin_settings_v1',JSON.stringify(adminSettings)); persistVendorAuth(); }catch(e){console.warn('Local save warning:',e);} // Open Vendor Profile immediately. Approval comes only after profile completion/submission. try{openView('vendorProfile');}catch(e){ console.warn('openView redirect fallback:',e); document.querySelectorAll('.view').forEach(v=>v.classList.remove('active')); el('vendorProfile')?.classList.add('active'); currentView='vendorProfile'; document.body.classList.remove('home-locked'); } try{loadVendor();}catch(e){console.warn('Vendor profile prefill issue:',e);} try{window.scrollTo({top:0,left:0,behavior:'auto'});}catch(e){window.scrollTo(0,0);} try{showToast('Vendor account created. Complete your Vendor Profile, then submit it for approval.');}catch(e){} try{pushOrderNotification('Vendor','Account Registration','Complete your Vendor Profile','Your ShopNex vendor account has been created. Complete and save your Vendor Profile before it is submitted to Admin for approval.','');}catch(e){} } function activateVendorCreateButton(){ const btn=el('createVendorAccountBtn'); if(!btn)return; btn.disabled=false; btn.removeAttribute('disabled'); btn.style.pointerEvents='auto'; btn.style.opacity='1'; if(btn.dataset.boundCreateVendor!=='1'){ btn.dataset.boundCreateVendor='1'; btn.addEventListener('click',function(ev){ev.preventDefault();createVendorAccount();}); } } function vendorLogin(){const id=el('loginIdentity').value.trim().toLowerCase(),pw=el('loginPassword').value;if(!vendorAuth)return alert('No vendor account has been created on this test browser yet.');const okId=[vendorAuth.identity,vendorAuth.email,vendorAuth.phone].filter(Boolean).map(x=>String(x).toLowerCase()).includes(id);if(!okId||vendorAuth.password!==pw)return alert('Incorrect login details.');vendorLoggedIn=true;persistVendorAuth();startVendorAutoCommunications();renderAll();openView('account');showToast('Vendor login successful.')} function vendorLogout(){vendorLoggedIn=false;persistVendorAuth();renderAll();openView('account');showToast('Logged out.')} function renderAccountGateway(){const g=el('accountGateway'),p=el('vendorAccountPanel');if(!g||!p)return;if(!vendorLoggedIn){p.style.display='none';g.style.display='block';g.innerHTML=`

My Account Login Required

Login to continue, or create a vendor account if you are new to ShopNex.

`;return}g.style.display='none';p.style.display='block'} function notifyVendorAccountDecision(status){if(!vendorAuth)return;vendorAuth.status=status;persistVendorAuth();let title='',body='';if(status==='Approved'){title='Vendor Account Approved';body='Congratulations. Your ShopNex vendor account has been approved. You can now operate your store on ShopNex.'}else if(status==='Rejected'){title='Vendor Account Rejected';body='Your ShopNex vendor account application was not approved. Please contact ShopNex Support or update the required information and resubmit.'}else if(status==='Suspended'){title='Vendor Account Suspended';body='Your ShopNex vendor account has been suspended. Please contact ShopNex Support.'}else{title='Vendor Account Under Review';body='Your ShopNex vendor account is currently under review.'}pushOrderNotification('Vendor','Account '+status,title,body,'')} function renderAccount(){renderAccountGateway();if(!vendorLoggedIn)return;const st=accountStatus();el("acctName").textContent=vendor.name||"Vendor";el("acctMeta").textContent=`ShopNex Vendor β€’ ${supplierCode} β€’ ${vendor.vendorType||""}`;el("acctAvatar").innerHTML=vendor.photo?``:"SP";el("acctApprovalStatus").innerHTML=`${esc(st)}`;const msg=el("acctApprovalMessage");if(msg){const cls=statusClass(st);const text=st==='Approved'?'Your account is approved and your ShopNex store is active.':st==='Rejected'?'Your vendor application was rejected. Review your details or contact ShopNex Support.':st==='Suspended'?'Your vendor account is suspended. Contact ShopNex Support.':st==='Pending Profile'?'Your account is created. Complete and save your Vendor Profile before the application is sent to ShopNex Admin for approval.':(vendorProfilePendingItemsFromData().length?'Your Vendor Profile is saved and waiting for ShopNex Admin review. Pending before approval: '+vendorProfilePendingItemsFromData().join(', ')+'.':'Your completed Vendor Profile is waiting for ShopNex Admin review. You can continue preparing your store while approval is pending.');msg.innerHTML=`
Account Status: ${esc(st)}
${esc(text)}
`}} function applyLiveProductSearch(v){filters.productHint=v;renderProducts()} function applyLiveVendorSearch(v){filters.vendorHint=v;renderVendors()} function applyQuickLocation(v){filters.region="";filters.district=v;filters.subcounty="";filters.parish="";filters.village="";renderAll()} function applyQuickCategory(v){filters.category=v;renderAll()} function applyQuickVendorType(v){filters.vendorType=v;renderAll()} function initQuickFilters(){const districts=[];sortedKeys(locationHierarchy).forEach(r=>sortedKeys(locationHierarchy[r]).forEach(d=>districts.push(d)));["quickLocationProducts","quickLocationVendors"].forEach(id=>{const s=el(id);if(!s)return;s.innerHTML=''+districts.sort((a,b)=>a.localeCompare(b)).map(x=>``).join("")});const pc=el("quickCategoryProducts");if(pc)pc.innerHTML=''+productCategories.map(x=>``).join("");const vt=el("quickVendorTypeVendors");if(vt)vt.innerHTML=''+vendorTypes.map(x=>``).join("")} function syncQuickFilters(){["quickLocationProducts","quickLocationVendors"].forEach(id=>{const s=el(id);if(s)s.value=filters.district||""});const pc=el("quickCategoryProducts");if(pc)pc.value=filters.category||"";const vt=el("quickVendorTypeVendors");if(vt)vt.value=filters.vendorType||"";const ps=el("allProductsSearch");if(ps&&document.activeElement!==ps)ps.value=filters.productHint||"";const vs=el("vendorsSearch");if(vs&&document.activeElement!==vs)vs.value=filters.vendorHint||""} function setSelect(id,values,selected,placeholder){const s=el(id);s.innerHTML=``+(values||[]).map(v=>``).join("")} function openVendorProfile(){openView("vendorProfile");loadVendor()} function loadVendor(){el("vpName").value=vendor.name||"";setSelect("vpVendorType",vendorTypes,vendor.vendorType,"Select Vendor Type");vendorTypeChanged(true);el("vpContact").value=vendor.contact||"";el("vpPosition").value=vendor.position||"";el("vpTel1").value=vendor.tel1||"";el("vpTel2").value=vendor.tel2||"";el("vpEmail").value=vendor.email||"";el("vpAbout").value=vendor.about||"";el("vpPreview").innerHTML=vendor.photo?``:"";el("vpDeletePhoto").style.display=vendor.photo?"inline-block":"none";setSelect("vpRegion",sortedKeys(locationHierarchy),vendor.region,"Select Region");if(vendor.region)el("vpRegion").value=vendor.region;vpRegionChanged(true);el("vpGpsLat").value=Number.isFinite(Number(vendor.lat))?vendor.lat:"";el("vpGpsLng").value=Number.isFinite(Number(vendor.lng))?vendor.lng:"";updateVendorGpsStatus()} function vendorTypeChanged(preserve=false){const type=el("vpVendorType").value;const field=el("vpSupermarketSizeField");field.style.display=type==="Supermarket"?"block":"none";if(type==="Supermarket"){setSelect("vpSupermarketSize",supermarketTypes,preserve?vendor.supermarketType:"","Select Supermarket Type");if(preserve&&supermarketTypes.includes(vendor.supermarketType))el("vpSupermarketSize").value=vendor.supermarketType}else{el("vpSupermarketSize").value=""}} function vpRegionChanged(preserve=false){const r=el("vpRegion").value,vals=sortedKeys(locationHierarchy[r]);setSelect("vpDistrict",vals,preserve?vendor.district:"","Select District / City");if(preserve&&vals.includes(vendor.district))el("vpDistrict").value=vendor.district;vpDistrictChanged(preserve)} function vpDistrictChanged(preserve=false){const r=el("vpRegion").value,d=el("vpDistrict").value,vals=sortedKeys((locationHierarchy[r]||{})[d]);setSelect("vpSubcounty",vals,preserve?vendor.subcounty:"","Select Sub-county / Town / Division");if(preserve&&vals.includes(vendor.subcounty))el("vpSubcounty").value=vendor.subcounty;vpSubChanged(preserve)} function vpSubChanged(preserve=false){const r=el("vpRegion").value,d=el("vpDistrict").value,s=el("vpSubcounty").value,vals=sortedKeys((((locationHierarchy[r]||{})[d]||{})[s]));setSelect("vpParish",vals,preserve?vendor.parish:"","Select Parish / Ward");if(preserve&&vals.includes(vendor.parish))el("vpParish").value=vendor.parish;vpParishChanged(preserve)} function vpParishChanged(preserve=false){const r=el("vpRegion").value,d=el("vpDistrict").value,s=el("vpSubcounty").value,p=el("vpParish").value,vals=(((((locationHierarchy[r]||{})[d]||{})[s]||{})[p])||[]).slice().sort((a,b)=>a.localeCompare(b));setSelect("vpVillage",vals,preserve?vendor.village:"","Select Village / Cell");if(preserve&&vals.includes(vendor.village))el("vpVillage").value=vendor.village} async function previewVendorPhoto(){ const f=el("vpPhoto").files[0];if(!f)return; if(!f.type.startsWith('image/')){vendorProfilePopup('Please select an image file for the Vendor Profile Picture.',false);el("vpPhoto").value='';return} try{ // Keep the profile picture small enough for reliable browser storage in the web-test version. vendor.photo=await compressIdentityImage(f,600,.72); el("vpPreview").innerHTML=``; el("vpDeletePhoto").style.display="inline-block"; }catch(e){console.error('Vendor profile picture processing failed',e);vendorProfilePopup('The profile picture could not be processed. Please choose another image.',false)} } function removeVendorPhoto(){vendor.photo="";el("vpPhoto").value="";el("vpPreview").innerHTML="";el("vpDeletePhoto").style.display="none"} function compressVendorPhotoDataUrl(src,maxSide=600,quality=.72){ return new Promise((resolve,reject)=>{ const img=new Image(); img.onerror=()=>reject(new Error('Invalid vendor profile picture')); img.onload=()=>{ let w=img.width,h=img.height;const scale=Math.min(1,maxSide/Math.max(w,h)); w=Math.max(1,Math.round(w*scale));h=Math.max(1,Math.round(h*scale)); const c=document.createElement('canvas');c.width=w;c.height=h; const ctx=c.getContext('2d');if(!ctx)return reject(new Error('Image compression is unavailable')); ctx.drawImage(img,0,0,w,h);resolve(c.toDataURL('image/jpeg',quality)); }; img.src=src; }); } function clearVendorProfileMissingHighlights(){ document.querySelectorAll('#vendorProfile .field.profile-missing').forEach(x=>x.classList.remove('profile-missing')); } function markVendorProfileFieldMissing(id){ const node=el(id); if(!node)return; const field=node.closest('.field'); if(field)field.classList.add('profile-missing'); } function validateVendorProfileRequiredFields(){ clearVendorProfileMissingHighlights(); const required=[ ['vpName','Business / Store Name'], ['vpVendorType','Vendor Type'], ['vpContact','Contact Person'], ['vpPosition','Position Held'], ['vpTel1','Tel 1'], ['vpEmail','Email'], ['vpAbout','Business Description'], ['vpRegion','Region'], ['vpDistrict','District / City'], ['vpSubcounty','Sub-county / Town / Division'], ['vpParish','Parish / Ward'], ['vpVillage','Village / Cell'] ]; if(el('vpVendorType')?.value==='Supermarket') required.splice(2,0,['vpSupermarketSize','Supermarket Type']); const missing=[]; required.forEach(([id,label])=>{const n=el(id);if(!n||!String(n.value||'').trim()){missing.push(label);markVendorProfileFieldMissing(id)}}); if(el('vpEmail')?.value && !/^\S+@\S+\.\S+$/.test(el('vpEmail').value.trim())){ if(!missing.includes('Email')) missing.push('Valid Email'); markVendorProfileFieldMissing('vpEmail'); } return missing; } function vendorProfilePendingItemsFromData(){ const pending=[]; const checks=[ ['Business / Store Name',vendor.name],['Vendor Type',vendor.vendorType],['Contact Person',vendor.contact], ['Position Held',vendor.position],['Tel 1',vendor.tel1],['Email',vendor.email],['Business Description',vendor.about], ['Region',vendor.region],['District / City',vendor.district],['Sub-county / Town / Division',vendor.subcounty], ['Parish / Ward',vendor.parish],['Village / Cell',vendor.village] ]; if(vendor.vendorType==='Supermarket')checks.splice(2,0,['Supermarket Type',vendor.supermarketType]); checks.forEach(([label,value])=>{if(!String(value||'').trim())pending.push(label)}); if(vendor.email && !/^\S+@\S+\.\S+$/.test(String(vendor.email).trim()))pending.push('Valid Email'); if(!vendorHasBusinessGPS())pending.push('Business GPS Location'); return [...new Set(pending)]; } function saveVendorSystemChatMessage(text){ try{ const key='shopnex_chat_'+supplierCode+'_general'; const a=JSON.parse(localStorage.getItem(key)||'[]'); const signature='PROFILE-PENDING:'+(vendorAuth?.applicationId||supplierCode)+':'+text; if(a.some(x=>x&&x.signature===signature))return; a.push({sender:'admin',text:String(text||''),time:new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}),system:true,signature}); localStorage.setItem(key,JSON.stringify(a.slice(-200))); }catch(e){console.warn('Vendor system chat save warning',e)} } function notifyVendorOfPendingProfileItems(pending){ const list=(pending||[]).filter(Boolean); const text=list.length ? 'ShopNex profile review: Your profile has been saved, but Admin cannot approve it yet. Please complete: '+list.join(', ')+'. After updating the pending item(s), save the Vendor Profile again.' : 'ShopNex profile review: Your Vendor Profile is complete and ready for Admin approval.'; saveVendorSystemChatMessage(text); try{pushOrderNotification('Vendor','Profile Review',list.length?'Profile pending information':'Profile ready for approval',text,'')}catch(e){console.warn('Vendor pending notification warning',e)} } function vendorProfilePopup(message, ok=true){ try{showToast(message)}catch(e){console.warn('ShopNex toast unavailable',e)} // Show the confirmation immediately so a technical save failure can never fail silently. try{window.alert(message)}catch(e){console.warn('ShopNex popup unavailable',e)} } async function verifyVendorProfileSaved(expectedVendor){ let raw=null; try{raw=localStorage.getItem(SHOPNEX_VENDOR_PROFILE_KEY)}catch(e){} if(!raw){try{raw=await shopNexIdbGet(SHOPNEX_VENDOR_PROFILE_KEY)}catch(e){}} if(!raw) throw new Error('Vendor profile storage verification failed: no saved record found.'); let saved; try{saved=JSON.parse(raw)}catch(e){throw new Error('Vendor profile storage verification failed: saved record is unreadable.')} if(!saved||String(saved.name||'')!==String(expectedVendor.name||'')||String(saved.email||'')!==String(expectedVendor.email||'')){ throw new Error('Vendor profile storage verification failed: saved data does not match the profile.'); } return true; } async function saveVendor(evt){ if(evt&&typeof evt.preventDefault==='function')evt.preventDefault(); const btn=el('saveVendorProfileBtn'); if(btn){btn.disabled=true;btn.textContent='Saving Vendor Profile...'} try{ // Highlight incomplete profile fields for guidance, but DO NOT block saving. const formMissing=validateVendorProfileRequiredFields(); const hasGps=el("vpGpsLat").value!==""&&el("vpGpsLng").value!==""&&Number.isFinite(Number(el("vpGpsLat").value))&&Number.isFinite(Number(el("vpGpsLng").value)); const gpsLat=hasGps?Number(el("vpGpsLat").value):null,gpsLng=hasGps?Number(el("vpGpsLng").value):null; vendor={...vendor,name:el("vpName").value.trim(),vendorType:el("vpVendorType").value,supermarketType:el("vpVendorType").value==="Supermarket"?el("vpSupermarketSize").value:"",contact:el("vpContact").value.trim(),position:el("vpPosition").value.trim(),tel1:el("vpTel1").value.trim(),tel2:el("vpTel2").value.trim(),email:el("vpEmail").value.trim(),about:el("vpAbout").value.trim(),region:el("vpRegion").value,district:el("vpDistrict").value,subcounty:el("vpSubcounty").value,parish:el("vpParish").value,village:el("vpVillage").value,lat:gpsLat,lng:gpsLng}; vendor.businessType=vendor.vendorType; // Always save the vendor profile, even when GPS or other approval information is still pending. // Older test versions could leave a very large raw profile picture in memory/localStorage. // Re-compress it before saving so the browser does not throw a storage quota error. if(vendor.photo && /^data:image\//.test(vendor.photo) && vendor.photo.length>700000){ try{vendor.photo=await compressVendorPhotoDataUrl(vendor.photo,600,.72)}catch(e){console.warn('Existing vendor photo compression warning',e)} } const storageBackend=await persistVendorProfileReliable(vendor); // Do not report success until the browser confirms that the profile was actually stored. await verifyVendorProfileSaved(vendor); const pending=vendorProfilePendingItemsFromData(); vendor.profilePendingItems=pending; try{saveVendorData()}catch(e){console.warn('Pending metadata save warning; core vendor profile is already saved.',e)} let firstSubmission=false; if(vendorAuth){ if(!vendorAuth.profileSubmitted){firstSubmission=true;vendorAuth.profileSubmitted=true;vendorAuth.profileSubmittedAt=Date.now();} vendorAuth.status=pending.length?'Pending Approval':'Pending Approval'; vendorAuth.profilePendingItems=pending; adminSettings.vendorStatus='Pending Approval'; try{persistVendorAuth();saveAdminSettings()}catch(e){console.warn('Vendor approval state save warning',e)} const applicationId=vendorAuth.applicationId||('VAPP-'+Date.now().toString(36).toUpperCase());vendorAuth.applicationId=applicationId; try{persistVendorAuth()}catch(e){console.warn('Application ID save warning',e)} if(firstSubmission){ const approvalUrl=vendorApprovalLink(applicationId),submittedAt=new Date().toISOString(); try{pushOrderNotification('Admin','Vendor Approval','Vendor application: '+vendor.name,'Application '+applicationId+' is waiting for review. '+(pending.length?'Pending: '+pending.join(', ')+'.':'All required profile information is complete.')+' Open Admin > Vendors to review.','')}catch(e){console.warn('Admin notification warning',e)} try{Promise.resolve(sendVendorApplicationAdminEmail({applicationId,businessName:vendor.name,contactName:vendor.contact,phone:vendor.tel1,email:vendor.email,submittedAt,approvalUrl})).catch(e=>console.warn('Admin email notification warning',e));}catch(e){console.warn('Admin email notification warning',e)} } } // Non-essential notifications must never turn a successful profile save into a false technical-error message. try{notifyVendorOfPendingProfileItems(pending)}catch(e){console.warn('Pending-profile notification warning',e)} try{renderAll()}catch(e){console.warn('Render warning after vendor save',e)} const successMsg=pending.length ? 'Vendor Profile saved successfully. Approval is pending: '+pending.join(', ')+'.' : 'Vendor Profile saved successfully and is ready for ShopNex Admin approval.'; vendorProfilePopup(successMsg+' Saved using '+storageBackend+'.',true); setTimeout(()=>{try{openView("account")}catch(e){console.warn('Account redirect warning',e)}},120); return true; }catch(err){ console.error('Save Vendor Profile failed:',err); const quota=err && (err.name==='QuotaExceededError'||err.name==='NS_ERROR_DOM_QUOTA_REACHED'||/quota|storage/i.test(String(err.message||''))); if(quota){ vendorProfilePopup('Vendor Profile could not be saved because this test browser storage is full. Please delete large test photos/videos, then save again. Your Business GPS is not the cause.',false); }else{ vendorProfilePopup('Vendor Profile could not be saved. Error: '+String(err?.message||err||'Unknown browser error'),false); } return false; }finally{ if(btn){btn.disabled=false;btn.textContent='Save Vendor Profile'} } } // Remove a red required-field highlight as soon as the vendor fills the field. document.addEventListener('input',e=>{if(e.target&&e.target.closest('#vendorProfile')&&e.target.closest('.field.profile-missing')&&String(e.target.value||'').trim())e.target.closest('.field').classList.remove('profile-missing')}); document.addEventListener('change',e=>{if(e.target&&e.target.closest('#vendorProfile')&&e.target.closest('.field.profile-missing')&&String(e.target.value||'').trim())e.target.closest('.field').classList.remove('profile-missing')}); function nextCode(){let m=0;productsData.forEach(i=>m=Math.max(m,Number(String(i.code||"").split("/")[1])||0));return supplierCode+"/"+(m+1)} function renderItemList(){el("items").innerHTML=productsData.length?productsData.map(i=>`
${esc(i.name)}
${esc(i.code)} β€’ ${esc(i.category)}
${productPackMeta(i)}${productPriceHtml(i,true)}
`).join(""):'
No items added yet.
'} function quickSetStock(id,status){const i=productsData.find(x=>x.id===id);if(!i)return;i.stock=status;localStorage.setItem("shopnex_items_v17",JSON.stringify(productsData));renderAll();showToast(i.name+" marked "+status+".")} function openItemForm(id=""){const i=productsData.find(x=>x.id===id);el("editId").value=id;el("itemTitle").textContent=id?"Edit Item":"Add Item";el("itemName").value=i?.name||"";el("itemCategory").innerHTML=productCategories.map(c=>``).join("");el("itemSku").value=i?.sku||"";el("itemBrand").value=i?.brand||"";el("itemUom").value=i?.uom||"Pieces";el("itemPcsPerUom").value=Math.max(1,Number(i?.pcsPerUom||1));el("itemPrice").value=i?.price||"";el("priceType").value=i?.priceType||"Fixed";el("stock").value=i?.stock||"In Stock";el("description").value=i?.description||"";currentItemPhoto=i?.photo||"";el("itemPreview").innerHTML=currentItemPhoto?``:"";el("deleteItemPhoto").style.display=currentItemPhoto?"inline-block":"none";el("itemModal").classList.add("show")} function closeItemModal(){el("itemModal").classList.remove("show")} function previewItemPhoto(){const f=el("itemPhotoInput").files[0];if(!f)return;const r=new FileReader();r.onload=e=>{currentItemPhoto=e.target.result;el("itemPreview").innerHTML=``;el("deleteItemPhoto").style.display="inline-block"};r.readAsDataURL(f)} function removeItemPhoto(){currentItemPhoto="";el("itemPreview").innerHTML="";el("deleteItemPhoto").style.display="none"} function resetItemForm(){el("editId").value="";el("itemTitle").textContent="Add Item";el("itemName").value="";el("itemCategory").value=productCategories[0];el("itemSku").value="";el("itemBrand").value="";el("itemUom").value="Pieces";el("itemPcsPerUom").value="1";el("itemPrice").value="";el("priceType").value="Fixed";el("stock").value="In Stock";el("description").value="";el("itemPhotoInput").value="";currentItemPhoto="";el("itemPreview").innerHTML="";el("deleteItemPhoto").style.display="none"} function refreshItemListView(){ productsData=JSON.parse(localStorage.getItem("shopnex_items_v17")||"[]"); renderItemList(); renderProducts(); renderVendorStore(); if(typeof renderAdmin==="function")renderAdmin(); } function showFreshItemList(){ closeItemModal(); if(currentView!=="itemList")openView("itemList"); refreshItemListView(); const list=el("items"); if(list){void list.offsetHeight;} } function saveItem(){ const id=el("editId").value,n=el("itemName").value.trim(),p=Number(el("itemPrice").value); if(!n||el("itemPrice").value===""||p<0){alert("Enter valid product name and price.");return} const pcs=Math.max(1,Math.floor(Number(el("itemPcsPerUom").value||1)));const data={name:n,category:el("itemCategory").value,sku:el("itemSku").value.trim(),brand:el("itemBrand").value.trim(),uom:el("itemUom").value||"Pieces",pcsPerUom:pcs,price:p,priceType:el("priceType").value,stock:el("stock").value,description:el("description").value.trim(),photo:currentItemPhoto,supplierCode}; const isEdit=Boolean(id);let oldPrice=null; if(isEdit){const found=productsData.find(x=>x.id===id);if(found){oldPrice=Number(found.price||0);Object.assign(found,data)}} else{productsData.unshift({id:"itm"+Date.now(),code:nextCode(),...data});notifyFavoriteMarketUpdate("New Item",vendor.name+" added a new item: "+n+".")} if(isEdit&&oldPrice!==null&&p{ refreshItemListView(); openItemForm(); const first=el("itemName"); if(first){first.focus();first.scrollIntoView({block:"center"})} },0); } let bulkProductRows=[]; function openBulkProductUpload(){if(!vendorLoggedIn){showToast('Please login as vendor first.');return}bulkProductRows=[];el('bulkProductFile').value='';el('bulkProductSummary').innerHTML='';el('bulkProductPreview').innerHTML='';el('bulkImportBtn').style.display='none';el('bulkProductModal').classList.add('show')} function closeBulkProductUpload(){el('bulkProductModal').classList.remove('show')} function downloadProductTemplate(){ if(typeof XLSX==='undefined'){alert('Excel library did not load. Please check your internet connection and try again.');return} const rows=[['Product Name','Category','SKU','Brand','UoM','PCS per UoM','Price (UGX)','Pricing Type','Stock Status','Description'],['Sugar 1kg','Supermarkets','SUG-001','Kakira','Pieces',1,5000,'Fixed','In Stock','White sugar 1kg'],['Mineral Water 500ml','Supermarkets','WAT-500','Rwenzori','Cartons',24,18000,'Fixed','In Stock','24 bottles per carton']]; const wb=XLSX.utils.book_new(),ws=XLSX.utils.aoa_to_sheet(rows);ws['!cols']=[{wch:28},{wch:24},{wch:16},{wch:20},{wch:14},{wch:14},{wch:16},{wch:16},{wch:16},{wch:38}];XLSX.utils.book_append_sheet(wb,ws,'Products');XLSX.writeFile(wb,'ShopNex_Product_Upload_Template.xlsx') } function normalizeBulkHeader(h){return String(h||'').trim().toLowerCase().replace(/[^a-z0-9]+/g,' ')} function bulkValue(row,aliases){const keys=Object.keys(row);for(const a of aliases){const target=normalizeBulkHeader(a),k=keys.find(x=>normalizeBulkHeader(x)===target);if(k!=null)return row[k]}return ''} function validateBulkProduct(row,rowNo){ const name=String(bulkValue(row,['Product Name','Product'])||'').trim(),category=String(bulkValue(row,['Category','Product Category'])||'').trim(),sku=String(bulkValue(row,['SKU'])||'').trim(),brand=String(bulkValue(row,['Brand','Product Brand','Manufacturer'])||'').trim(),uom=String(bulkValue(row,['UoM','Unit of Measure'])||'Pieces').trim()||'Pieces',pcsRaw=bulkValue(row,['PCS per UoM','PCS/UoM','Pieces per UoM']),priceRaw=bulkValue(row,['Price (UGX)','Price UGX','Price']),pricing=String(bulkValue(row,['Pricing Type'])||'Fixed').trim(),stock=String(bulkValue(row,['Stock Status','Stock'])||'In Stock').trim(),description=String(bulkValue(row,['Description'])||'').trim(); const pcs=Math.max(1,Math.floor(Number(pcsRaw||1))),price=Number(priceRaw),errors=[]; if(!name)errors.push('Product Name missing');if(!category)errors.push('Category missing');if(priceRaw===''||!Number.isFinite(price)||price<0)errors.push('Invalid Price');if(!['Fixed','Negotiable'].includes(pricing))errors.push('Pricing Type must be Fixed or Negotiable');if(!['In Stock','Out of Stock'].includes(stock))errors.push('Stock must be In Stock or Out of Stock'); return {rowNo,valid:errors.length===0,errors,name,category,sku,brand,uom,pcsPerUom:pcs,price:Number.isFinite(price)?price:0,priceType:pricing,stock,description,supplierCode}; } function previewBulkProducts(){const f=el('bulkProductFile').files[0];if(!f)return;if(typeof XLSX==='undefined'){alert('Excel library did not load. Please check your internet connection and try again.');return}const r=new FileReader();r.onload=e=>{try{const wb=XLSX.read(e.target.result,{type:'array'}),ws=wb.Sheets[wb.SheetNames[0]],rows=XLSX.utils.sheet_to_json(ws,{defval:''});bulkProductRows=rows.map((x,idx)=>validateBulkProduct(x,idx+2));const valid=bulkProductRows.filter(x=>x.valid).length,bad=bulkProductRows.length-valid;el('bulkProductSummary').innerHTML=`
${valid} valid product(s) β€’ ${bad} row(s) need correction.
Product Codes are not read from the file. ShopNex will generate them internally and assign every imported item to ${esc(vendor.name||supplierCode)}.
`;el('bulkProductPreview').innerHTML=bulkProductRows.slice(0,50).map(x=>`
Row ${x.rowNo}: ${esc(x.name||'Unnamed product')}
Brand: ${esc(x.brand||'β€”')} β€’ ${esc(x.sku||'SKU β€”')} β€’ ${esc(x.uom)} β€’ ${x.pcsPerUom} PCS/UoM β€’ ${money(x.price)}
${x.valid?'Ready':`
${x.errors.map(esc).join(' β€’ ')}
`}
`).join('')+(bulkProductRows.length>50?`
Showing first 50 of ${bulkProductRows.length} rows.
`:'');el('bulkImportBtn').style.display=valid?'block':'none'}catch(err){bulkProductRows=[];el('bulkProductSummary').innerHTML='
Could not read this file. Please use the ShopNex template.
';el('bulkImportBtn').style.display='none'}};r.readAsArrayBuffer(f)} function importBulkProducts(){if(!vendorLoggedIn){alert('Vendor login required.');return}const valid=bulkProductRows.filter(x=>x.valid);if(!valid.length){alert('There are no valid products to import.');return}valid.forEach(x=>productsData.unshift({id:'itm'+Date.now()+Math.random(),code:nextCode(),name:x.name,category:x.category,sku:x.sku,brand:x.brand,uom:x.uom,pcsPerUom:x.pcsPerUom,price:x.price,priceType:x.priceType,stock:x.stock,description:x.description,photo:'',supplierCode}));saveProducts();closeBulkProductUpload();renderAll();showToast(valid.length+' products imported successfully. Product Codes generated by ShopNex.')} function deleteItem(id){ if(!confirm("Delete this item?"))return; productsData=productsData.filter(x=>x.id!==id); localStorage.setItem("shopnex_items_v17",JSON.stringify(productsData)); showFreshItemList(); } // ===== ShopNex peer-to-peer Chat & Audio Call ===== let commPeer=null,commPeerReady=false,commRole="buyer",activeDataConn=null,activeMediaCall=null,localAudioStream=null,pendingIncomingCall=null,pendingIncomingConn=null,currentCommItem=null,currentChatPeerId=""; const buyerDeviceId=localStorage.getItem("shopnex_buyer_peer_id")||("shopnex-buyer-"+Math.random().toString(36).slice(2,10));localStorage.setItem("shopnex_buyer_peer_id",buyerDeviceId); function vendorPeerId(){return "shopnex-vendor-"+supplierCode.toLowerCase()} function setCommStatus(text,online=false){const t=el("vendorCommStatus"),d=el("vendorCommDot");if(t)t.textContent=text;if(d)d.classList.toggle("online",online)} function destroyCommPeer(){try{activeDataConn?.close()}catch(e){};try{activeMediaCall?.close()}catch(e){};try{commPeer?.destroy()}catch(e){};commPeer=null;commPeerReady=false;activeDataConn=null;activeMediaCall=null;setCommStatus("Offline",false)} function initCommPeer(role="buyer"){ if(typeof Peer==="undefined"){alert("ShopNex communication service could not load. Check your internet connection.");return null} const desired=role==="vendor"?vendorPeerId():buyerDeviceId; if(commPeer && !commPeer.destroyed && commRole===role)return commPeer; destroyCommPeer();commRole=role;commPeer=new Peer(desired,{debug:1}); commPeer.on("open",id=>{commPeerReady=true;if(role==="vendor")setCommStatus("Online β€” ready to receive ShopNex calls & chats",true);updateChatConnectionStatus()}); commPeer.on("connection",conn=>handleIncomingDataConnection(conn)); commPeer.on("call",call=>handleIncomingMediaCall(call)); commPeer.on("disconnected",()=>{commPeerReady=false;if(role==="vendor")setCommStatus("Disconnected β€” reconnecting automatically…",false);scheduleVendorReconnect(1000)}); commPeer.on("close",()=>{commPeerReady=false;if(role==="vendor")setCommStatus(navigator.onLine?"Reconnecting automatically…":"No internet β€” waiting to reconnect…",false);scheduleVendorReconnect(1200)}); commPeer.on("error",err=>{console.warn("ShopNex Peer error",err);commPeerReady=false;if(role==="vendor")setCommStatus(err.type==="unavailable-id"?"Vendor communication is active on another device":"Connection problem β€” reconnecting automatically…",false);updateChatConnectionStatus("Connection unavailable");if(role==="vendor"&&err.type!=="unavailable-id")scheduleVendorReconnect(1800)}); return commPeer } let vendorReconnectTimer=null; function vendorDeviceConfigured(){return !!(vendorAuth||vendor?.name&&vendor.name!=="Demo Vendor")} function scheduleVendorReconnect(delay=1200){if(!vendorDeviceConfigured()||!navigator.onLine)return;clearTimeout(vendorReconnectTimer);vendorReconnectTimer=setTimeout(()=>{if(commRole==="vendor"&&commPeerReady)return;try{if(commPeer&&!commPeer.destroyed){commPeer.destroy()} }catch(e){} commPeer=null;initCommPeer("vendor")},delay)} function startVendorAutoCommunications(){if(!vendorDeviceConfigured())return;if(!navigator.onLine){setCommStatus("No internet β€” waiting to reconnect automatically…",false);return}if(commRole==="vendor"&&commPeer&&!commPeer.destroyed&&commPeerReady){setCommStatus("Online β€” Calls & Chat active automatically",true);return}scheduleVendorReconnect(50)} function toggleVendorCommunications(){startVendorAutoCommunications()} window.addEventListener("online",()=>{setCommStatus("Internet restored β€” reconnecting automatically…",false);startVendorAutoCommunications()}); window.addEventListener("offline",()=>{commPeerReady=false;setCommStatus("No internet β€” waiting to reconnect automatically…",false)}); document.addEventListener("visibilitychange",()=>{if(!document.hidden)startVendorAutoCommunications()}); setInterval(()=>{if(vendorDeviceConfigured()&&navigator.onLine&&(!commPeer||commPeer.destroyed||!commPeerReady))startVendorAutoCommunications()},5000); setTimeout(startVendorAutoCommunications,300); function ensureBuyerPeer(cb){const p=initCommPeer("buyer");if(!p)return;if(commPeerReady){cb();return}p.once("open",()=>cb())} function chatStoreKey(){return "shopnex_chat_"+supplierCode+"_"+(currentCommItem?.id||"general")} function loadChatHistory(){try{return JSON.parse(localStorage.getItem(chatStoreKey())||"[]")}catch(e){return []}} function saveChatMessage(msg){const a=loadChatHistory();a.push(msg);localStorage.setItem(chatStoreKey(),JSON.stringify(a.slice(-200)));renderChatMessages()} function renderChatMessages(){const box=el("chatMessages");if(!box)return;const a=loadChatHistory();box.innerHTML=a.length?a.map(m=>`
${esc(m.text)}
${esc(m.time||"")}
`).join(""):'
No messages yet. Start the conversation.
';box.scrollTop=box.scrollHeight} function updateChatConnectionStatus(force){const t=el("chatStatus"),d=el("chatDot");if(!t||!d)return;const on=activeDataConn&&activeDataConn.open;t.textContent=force||(on?"Connected on ShopNex":"Connecting…");d.classList.toggle("online",!!on)} function bindDataConnection(conn){activeDataConn=conn;conn.on("open",()=>{updateChatConnectionStatus();renderChatMessages()});conn.on("data",data=>{if(!data||data.type!=="chat")return;const msg={sender:commRole==="vendor"?"buyer":"vendor",text:String(data.text||""),time:data.time||new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})};saveChatMessage(msg);pushCommNotification("Chat","Unread Message","New ShopNex Chat",String(data.text||""),{itemId:data.itemId||conn.metadata?.itemId||"",itemName:conn.metadata?.itemName||"",peerId:conn.peer||""});if(currentView!=="shopNexChatView")openIncomingChat(conn,data)});conn.on("close",()=>updateChatConnectionStatus("Disconnected"));conn.on("error",()=>updateChatConnectionStatus("Connection problem"))} function handleIncomingDataConnection(conn){pendingIncomingConn=conn;bindDataConnection(conn);conn.on("open",()=>{el("incomingTitle").textContent="πŸ’¬ Incoming ShopNex Chat";el("incomingMeta").textContent=`A ShopNex user wants to chat${conn.metadata?.itemName?" about "+conn.metadata.itemName:""}.`;el("incomingActions").innerHTML='';openView("incomingComm")})} function openIncomingChat(conn,data){pendingIncomingConn=conn;el("incomingTitle").textContent="πŸ’¬ New ShopNex Message";el("incomingMeta").textContent=String(data.text||"");el("incomingActions").innerHTML='';openView("incomingComm")} function acceptIncomingChat(){if(pendingIncomingConn?.metadata?.itemId)currentCommItem=productsData.find(x=>x.id===pendingIncomingConn.metadata.itemId)||null;el("chatPartner").textContent=commRole==="vendor"?"ShopNex Buyer":vendor.name;el("chatContext").textContent=currentCommItem?`About: ${currentCommItem.name} β€’ ${currentCommItem.code}`:"Private ShopNex conversation";openView("shopNexChatView");renderChatMessages();updateChatConnectionStatus()} function declineIncomingChat(){try{pendingIncomingConn?.close()}catch(e){};pendingIncomingConn=null;openView("account")} function openShopNexChat(i){currentCommItem=i;el("chatPartner").textContent=vendor.name;el("chatContext").textContent=`Negotiation: ${i.name} β€’ ${i.code}`;openView("shopNexChatView");renderChatMessages();updateChatConnectionStatus();ensureBuyerPeer(()=>{const conn=commPeer.connect(vendorPeerId(),{reliable:true,metadata:{type:"chat",itemId:i.id,itemName:i.name,from:"buyer"}});bindDataConnection(conn)})} async function sendShopNexMessage(){ const input=el("chatInput"),text=(input?.value||"").trim();if(!text)return; const time=new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); const msg={type:"chat",text,time,itemId:currentCommItem?.id||""}; let stored=false; if(window.ShopNexBackend?.configured&&ShopNexBackend.authenticated){ try{await ShopNexBackend.sendStoredMessage(currentCommItem,text);stored=true}catch(e){console.warn("Stored chat failed",e)} } if(activeDataConn&&activeDataConn.open){try{activeDataConn.send(msg)}catch(e){}} if(!stored && (!activeDataConn||!activeDataConn.open)){updateChatConnectionStatus("Message queued locally β€” connection will retry");return} saveChatMessage({sender:"buyer",text,time});input.value="";renderChatMessages(); updateChatConnectionStatus(stored?"Sent β€” stored securely":"Sent"); } function isPromotionLive(promo){ if(!promo||!promo.enabled)return false; const today=new Date(); today.setHours(0,0,0,0); if(promo.startDate){const s=new Date(promo.startDate+'T00:00:00');if(todaye)return false} return true; } function activePromotionFor(productId){const p=promotionFor(productId);return p&&p.status!=='Paused'&&isPromotionLive(p)?p:null} function effectiveProductPrice(product){const p=activePromotionFor(product.id);return p?calcPromoPrice(product,p):Number(product.price||0)} function promotionLabel(p){if(!p)return'';if(p.discountType==='Percentage')return `-${Number(p.discountValue||0)}%`;if(p.discountType==='Fixed Amount')return `SAVE ${money(p.discountValue||0)}`;return 'PROMO'} function productPriceHtml(product,showConfigured=false){ const live=activePromotionFor(product.id); if(live){const pp=calcPromoPrice(product,live);return `
${money(product.price)}${money(pp)}${esc(promotionLabel(live))}
${live.endDate?`
Promotion ends ${esc(live.endDate)}
`:''}`} if(showConfigured){const p=promotionFor(product.id);if(p&&p.enabled){const pp=calcPromoPrice(product,p);let status=p.status==='Paused'?'Paused':(isPromotionLive(p)?'Active':'Scheduled / expired');return `
${money(product.price)}${money(pp)}${esc(status)}
`}} return `
${money(product.price)}
` } function renderPromotionMatrix(){ const host=el('promotionMatrix'); if(!host)return; if(!productsData.length){host.innerHTML='
Add products to the Item List first, then create promotions here.
';return} host.innerHTML=`
${productsData.map(i=>{ const p=promotionFor(i.id)||{}; const selected=Boolean(p.enabled); const pp=calcPromoPrice(i,p); const afterText=selected?money(pp):'Not selected'; return `` }).join('')}
PromoteItemPrice Before PromotionDiscount MethodDiscount ValueSet Promo PricePrice After PromotionStart DateEnd DateStatusAction
${esc(i.name)}
${esc(i.code||'')}
${selected?'Promotion selected':'Not selected for promotion'}
${money(i.price)}
${afterText}
${selected?`
${money(i.price)} β†’ ${money(pp)}
`:''}
${p.productId?``:''}
`; } function previewPromotion(id){ const i=productsData.find(x=>x.id===id); if(!i)return; const enabled=Boolean(el('pm_en_'+id)?.checked); const p={enabled:true,discountType:el('pm_type_'+id).value,discountValue:Number(el('pm_val_'+id).value||0),promoPrice:Number(el('pm_price_'+id).value||0)}; const after=calcPromoPrice(i,p); const box=el('pm_preview_'+id); if(box)box.innerHTML=enabled?`${money(after)}
${money(i.price)} β†’ ${money(after)}
`:'Not selected'; const summary=el('pm_summary_'+id); if(summary){ let text='Not selected for promotion'; if(enabled){ if(p.discountType==='Percentage')text=`${Number(p.discountValue||0)}% off β€’ ${money(i.price)} β†’ ${money(after)}`; else if(p.discountType==='Fixed Amount')text=`Save ${money(p.discountValue)} β€’ ${money(i.price)} β†’ ${money(after)}`; else text=`Promo price β€’ ${money(i.price)} β†’ ${money(after)}`; } summary.textContent=text; } } function savePromotion(id){ const i=productsData.find(x=>x.id===id); if(!i)return; const enabled=el('pm_en_'+id).checked; const discountType=el('pm_type_'+id).value; const discountValue=Number(el('pm_val_'+id).value||0); const promoPrice=Number(el('pm_price_'+id).value||0); const startDate=el('pm_start_'+id).value; const endDate=el('pm_end_'+id).value; const status=el('pm_status_'+id).value; if(enabled && discountType==='Percentage' && (discountValue<=0||discountValue>=100)){alert('Enter a percentage greater than 0 and less than 100.');return} if(enabled && discountType==='Fixed Amount' && (discountValue<=0||discountValue>=Number(i.price||0))){alert('Fixed discount must be greater than 0 and less than the normal price.');return} if(enabled && discountType==='Set Promotional Price' && (promoPrice<0||promoPrice>=Number(i.price||0))){alert('Promotional price must be lower than the normal price.');return} if(startDate&&endDate&&new Date(endDate)p.productId!==id);localStorage.setItem('shopnex_promotions_v1',JSON.stringify(promotionsData));renderAll(); } function renderPromotions(){ const host=el('promotionBuyerGrid'); if(!host)return; if(adminSettings&&adminSettings.promotionsEnabled===false){host.innerHTML='
Promotions are temporarily unavailable.
';return} const rows=productsData.map(i=>({i,p:promotionFor(i.id)})).filter(x=>x.p&&x.p.enabled&&x.p.status!=='Paused'&&isPromotionLive(x.p)&&!x.i.adminDisabled); host.innerHTML=rows.length?rows.map(({i,p})=>{ const oldPrice=Number(i.price||0),pp=calcPromoPrice(i,p); const discountPct=oldPrice>0?Math.max(0,Math.round(((oldPrice-pp)/oldPrice)*100)):0; return `
${i.photo?`${esc(i.name)}`:`
πŸ“¦
`}
ON PROMOTION
${esc(i.name)}
${esc(i.code||'')} β€’ ${esc(vendor.name)}
${productPackMeta(i)}
Old price: ${money(oldPrice)}
New price: ${money(pp)}
Discount: ${discountPct}%
${p.endDate?'Ends '+esc(p.endDate):'Limited-time offer'}
${esc(i.stock||'In Stock')}
` }).join(''):'
No active promotions at the moment. Items selected and activated in Promotions Manager will appear here automatically.
'; } function parseCSVLine(line){const out=[];let cur="",quoted=false;for(let i=0;ih.trim().toLowerCase().replace(/\s+/g,"_"));const idx=(...names)=>{for(const n of names){const i=headers.indexOf(n);if(i>=0)return i}return -1};const ir=idx("region"),id=idx("district","city"),is=idx("subcounty","sub_county","town_council","division"),ip=idx("parish","ward"),iv=idx("village","cell");if([ir,id,is,ip,iv].some(i=>i<0))return null;const h={};for(let n=1;nn.textContent="Loading Uganda Sub-county, Parish and Village/Cell data…");const res=await fetch(FULL_UGANDA_CSV,{cache:"force-cache"});if(!res.ok)throw new Error("HTTP "+res.status);const csv=await res.text();const built=buildHierarchyFromCSV(csv);if(!built)throw new Error("Could not parse location hierarchy");locationHierarchy=built;initQuickFilters();if(currentView==="vendorProfile")loadVendor();notes.forEach(n=>n.textContent="Uganda location data loaded successfully down to Sub-county/Town/Division β†’ Parish/Ward β†’ Village/Cell.")}catch(err){initQuickFilters();if(currentView==="vendorProfile")loadVendor();notes.forEach(n=>n.textContent="Using the built-in Uganda location list. Full village-level data will load automatically when internet access is available.")}} function showMediaTab(tab){const photos=tab==="photos";el("photosPane").classList.toggle("active",photos);el("videosPane").classList.toggle("active",!photos);el("photosTabBtn").classList.toggle("active",photos);el("videosTabBtn").classList.toggle("active",!photos);if(photos)stopAllMediaVideos();else setTimeout(setupVideoAutoplay,0)} function setMediaSearch(type,value){mediaSearch[type]=(value||"").toLowerCase().trim();renderMedia();if(type==="videos")setTimeout(setupVideoAutoplay,0)} function mediaMeta(m){const cats=(m.categories&&m.categories.length?m.categories:productsData.map(x=>x.category));return{supplierCode:m.supplierCode||supplierCode,supplierName:m.supplierName||vendor.name,businessType:m.businessType||vendor.vendorType,categories:cats,location:m.location||vendorLoc()}} function mediaHay(m){const x=mediaMeta(m);return [m.caption||"",x.supplierName,x.supplierCode,x.businessType,x.categories.join(" "),x.location].join(" ").toLowerCase()} function mediaScore(m){const x=mediaMeta(m);let score=0;score+=(visitProfile.suppliers[x.supplierCode]||0)*10;score+=(visitProfile.businessTypes[x.businessType]||0)*5;x.categories.forEach(c=>score+=(visitProfile.categories[c]||0)*3);score+=(visitProfile.locations[x.location]||0)*4;return score+(m.createdAt||0)/1e13} function rankedMedia(arr,type){const q=mediaSearch[type]||"";const filtered=arr.filter(m=>!q||mediaHay(m).includes(q)).slice();if(type==="videos")return filtered.sort((a,b)=>{const bp=vendorPoints(mediaMeta(b).supplierCode,"mtd"),ap=vendorPoints(mediaMeta(a).supplierCode,"mtd");return (bp-ap)||Number(b.createdAt||0)-Number(a.createdAt||0)});return filtered.sort((a,b)=>mediaScore(b)-mediaScore(a))} function mediaStats(m){return{views:Number(m.views||0),likes:Number(m.likes||0),downloads:Number(m.downloads||0)}} function socialControls(m,type){const st=mediaStats(m),liked=mediaLikedIds.has(m.id);return `
`} function watermarkHtml(cls='shopnex-watermark'){if(cls==='photo-watermark')return `
ShopNex
`;return `
Shared via ShopNex
`} function renderMedia(){const photos=rankedMedia(storePhotos,"photos"),videos=rankedMedia(storeVideos,"videos");el("photoFeed").innerHTML=photos.length?photos.map((m,idx)=>{const x=mediaMeta(m);return `
Store photo ${idx+1}${watermarkHtml('photo-watermark')}
${esc(x.supplierName)}
${esc(x.businessType)} β€’ ${esc(x.location)}
${socialControls(m,'photo')}
`}).join(""):'
No supplier photos match your search.
';el("videoFeed").innerHTML=videos.length?videos.map((m,idx)=>{const x=mediaMeta(m);return `
${esc(x.supplierName)} β€’ ${esc(x.businessType)} β€’ ${esc(x.location)}
MTD Points: ${Number(vendorPoints(x.supplierCode,"mtd")).toLocaleString(undefined,{maximumFractionDigits:2})}
${m.hasShopNexWatermark?'':watermarkHtml()}${socialControls(m,'video')}
`}).join(""):'
No supplier videos match your search.
';setTimeout(setupPhotoViews,0)} function findMedia(id,type){return (type==='photo'?storePhotos:storeVideos).find(x=>x.id===id)} function persistMediaOnly(){localStorage.setItem("shopnex_store_photos_v18",JSON.stringify(storePhotos));localStorage.setItem("shopnex_store_videos_v18",JSON.stringify(storeVideos))} function countMediaView(id,type){const key=type+':'+id;if(mediaViewedSession.has(key))return;const m=findMedia(id,type);if(!m)return;mediaViewedSession.add(key);m.views=Number(m.views||0)+1;persistMediaOnly();renderMedia();renderVendorStore()} function toggleMediaLike(id,type){const m=findMedia(id,type);if(!m)return;if(mediaLikedIds.has(id)){mediaLikedIds.delete(id);m.likes=Math.max(0,Number(m.likes||0)-1)}else{mediaLikedIds.add(id);m.likes=Number(m.likes||0)+1}localStorage.setItem("shopnex_media_likes_v1",JSON.stringify([...mediaLikedIds]));persistMediaOnly();renderMedia();renderVendorStore();if(type==='video')setTimeout(setupVideoAutoplay,0)} function setupPhotoViews(){if(photoViewObserver){photoViewObserver.disconnect();photoViewObserver=null}const imgs=[...document.querySelectorAll('.shopnex-photo-view')];if(!imgs.length)return;photoViewObserver=new IntersectionObserver(entries=>{entries.forEach(e=>{if(e.isIntersecting&&e.intersectionRatio>=.6){const id=e.target.dataset.mediaId;if(id)countMediaView(id,'photo')}})},{threshold:[.6]});imgs.forEach(i=>photoViewObserver.observe(i))} function triggerBlobDownload(blob,name){const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=name;a.type=blob.type||'';a.rel='noopener';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),5000)} let shopNexDownloadLogoPromise=null; function loadShopNexDownloadLogo(){if(shopNexDownloadLogoPromise)return shopNexDownloadLogoPromise;shopNexDownloadLogoPromise=new Promise((resolve,reject)=>{const box=document.createElement('div');box.innerHTML=watermarkHtml();const src=box.querySelector('.wm-logo-img')?.src;if(!src){reject(new Error('ShopNex watermark logo not found'));return}const img=new Image();img.onload=()=>resolve(img);img.onerror=reject;img.src=src});return shopNexDownloadLogoPromise} function drawShopNexBrand(ctx,w,h,logoImg){const base=Math.max(52,Math.round(Math.min(w,h)*.075)),pad=Math.max(14,Math.round(base*.28)),logoW=Math.round(base*1.06),logoH=Math.round(base*.98);ctx.save();ctx.font=`800 ${Math.max(14,Math.round(base*.27))}px Arial`;const shared='Shared via ',shop='Shop',nex='Nex',textW=ctx.measureText(shared+shop+nex).width;const boxW=Math.round(logoW+base*.20+textW+base*.34),boxH=Math.round(Math.max(logoH,base*.56)+base*.30);const x=Math.max(pad,w-boxW-pad),y=Math.max(pad,h-boxH-pad);ctx.globalAlpha=.94;ctx.fillStyle='rgba(255,255,255,.94)';ctx.beginPath();const r=Math.max(10,Math.round(base*.20));if(ctx.roundRect)ctx.roundRect(x,y,boxW,boxH,r);else ctx.rect(x,y,boxW,boxH);ctx.fill();ctx.globalAlpha=1;if(logoImg)ctx.drawImage(logoImg,x+Math.round(base*.14),y+(boxH-logoH)/2,logoW,logoH);let tx=x+Math.round(base*.14)+logoW+Math.round(base*.16);const ty=y+boxH/2;ctx.textAlign='left';ctx.textBaseline='middle';ctx.font=`800 ${Math.max(14,Math.round(base*.27))}px Arial`;ctx.fillStyle='#17357f';ctx.fillText(shared,tx,ty);tx+=ctx.measureText(shared).width;ctx.font=`900 ${Math.max(14,Math.round(base*.27))}px Arial`;ctx.fillStyle='#071d81';ctx.fillText(shop,tx,ty);tx+=ctx.measureText(shop).width;ctx.fillStyle='#ff6900';ctx.fillText(nex,tx,ty);ctx.restore()} async function downloadBrandedPhoto(m){const img=new Image();img.src=m.src;await new Promise((res,rej)=>{img.onload=res;img.onerror=rej});const logo=await loadShopNexDownloadLogo();const c=document.createElement('canvas');c.width=img.naturalWidth||1080;c.height=img.naturalHeight||1080;const ctx=c.getContext('2d');ctx.drawImage(img,0,0,c.width,c.height);drawShopNexBrand(ctx,c.width,c.height,logo);const blob=await new Promise(res=>c.toBlob(res,'image/jpeg',.94));if(blob)triggerBlobDownload(blob,`ShopNex_${m.id}.jpg`)} async function downloadBrandedVideo(m){const v=document.createElement('video');v.src=m.src;v.muted=false;v.volume=1;v.playsInline=true;v.preload='auto';await new Promise((res,rej)=>{v.onloadedmetadata=res;v.onerror=rej});const logo=await loadShopNexDownloadLogo();const w=v.videoWidth||720,h=v.videoHeight||1280;const c=document.createElement('canvas');c.width=w;c.height=h;const ctx=c.getContext('2d');const canvasStream=c.captureStream(30);const combined=new MediaStream(canvasStream.getVideoTracks());let audioCtx=null,audioSource=null,audioDest=null;try{const AC=window.AudioContext||window.webkitAudioContext;if(AC){audioCtx=new AC();audioSource=audioCtx.createMediaElementSource(v);audioDest=audioCtx.createMediaStreamDestination();audioSource.connect(audioDest);audioDest.stream.getAudioTracks().forEach(t=>combined.addTrack(t));if(audioCtx.state==='suspended')await audioCtx.resume()}}catch(audioErr){console.warn('ShopNex audio capture fallback:',audioErr)} if(!combined.getAudioTracks().length&&typeof v.captureStream==='function'){try{const direct=v.captureStream();direct.getAudioTracks().forEach(t=>combined.addTrack(t))}catch(e){console.warn('Direct audio capture unavailable',e)}} /* WhatsApp-compatible ShopNex export: MP4/H.264 + AAC only. Do not silently create WebM, because many WhatsApp/Android combinations reject it. */ const candidates=['video/mp4;codecs=avc1.42E01E,mp4a.40.2','video/mp4;codecs=avc1,mp4a.40.2','video/mp4']; const mime=candidates.find(x=>MediaRecorder.isTypeSupported(x))||''; if(!mime)throw new Error('This browser cannot encode WhatsApp-compatible MP4/H.264 video. Please update Chrome/Android System WebView.'); const rec=new MediaRecorder(combined,{mimeType:mime,videoBitsPerSecond:5000000,audioBitsPerSecond:128000});const chunks=[];rec.ondataavailable=e=>{if(e.data&&e.data.size)chunks.push(e.data)};const done=new Promise((res,rej)=>{rec.onstop=res;rec.onerror=e=>rej(e.error||e)});rec.start(500);v.currentTime=0;await v.play();await new Promise(resolve=>{function frame(){ctx.drawImage(v,0,0,w,h);drawShopNexBrand(ctx,w,h,logo);if(v.ended||v.currentTime>=v.duration-.05){resolve();return}requestAnimationFrame(frame)};frame()});v.pause();rec.stop();await done;if(audioSource){try{audioSource.disconnect()}catch(e){}}if(audioCtx){try{await audioCtx.close()}catch(e){}}const blob=new Blob(chunks,{type:'video/mp4'});if(!blob.size)throw new Error('MP4 export produced an empty file.');triggerBlobDownload(blob,`ShopNex_${m.id}.mp4`)} async function downloadBrandedMedia(id,type,btn){flashDownloadButton(btn);const m=findMedia(id,type);if(!m)return;try{if(type==='photo')await downloadBrandedPhoto(m);else await downloadBrandedVideo(m);m.downloads=Number(m.downloads||0)+1;persistMediaOnly();renderMedia();renderVendorStore();alert('Saved with ShopNex branding.')}catch(e){console.error(e);alert(type==='video'?'ShopNex could not create a WhatsApp-compatible MP4 on this device. Please update Chrome/Android System WebView and try again.':'Could not create the WhatsApp-compatible JPG download on this device.')}} function bump(obj,key,amount=1){if(!key)return;obj[key]=(obj[key]||0)+amount} function recordMediaInterest(id,type){const m=(type==="photo"?storePhotos:storeVideos).find(x=>x.id===id);if(!m)return;const x=mediaMeta(m);bump(visitProfile.suppliers,x.supplierCode,1);bump(visitProfile.businessTypes,x.businessType,1);x.categories.forEach(c=>bump(visitProfile.categories,c,.25));bump(visitProfile.locations,x.location,.5);localStorage.setItem("shopnex_visit_profile_v20",JSON.stringify(visitProfile))} function visitSupplier(mediaId){const m=storePhotos.concat(storeVideos).find(x=>x.id===mediaId);const x=m?mediaMeta(m):{supplierCode,businessType:vendor.vendorType,categories:productsData.map(i=>i.category),location:vendorLoc()};bump(visitProfile.suppliers,x.supplierCode,3);bump(visitProfile.businessTypes,x.businessType,2);x.categories.forEach(c=>bump(visitProfile.categories,c,.5));bump(visitProfile.locations,x.location,2);localStorage.setItem("shopnex_visit_profile_v20",JSON.stringify(visitProfile));openView("vendorStore")} function persistStoreMedia(){localStorage.setItem("shopnex_store_photos_v18",JSON.stringify(storePhotos));localStorage.setItem("shopnex_store_videos_v18",JSON.stringify(storeVideos));renderMedia();renderStoreMediaManager();renderVendorStore()} function addStorePhotos(){const files=[...(el("storePhotoInput")?.files||[])];files.forEach(f=>{const r=new FileReader();r.onload=e=>{storePhotos.push({id:"ph"+Date.now()+Math.random(),src:e.target.result,caption:vendor.name,supplierCode,supplierName:vendor.name,businessType:vendor.vendorType,categories:productsData.map(i=>i.category),location:vendorLoc(),createdAt:Date.now()});persistStoreMedia();notifyFavoriteMarketUpdate("New Photo",vendor.name+" added a new photo on ShopNex.")};r.readAsDataURL(f)});if(el("storePhotoInput"))el("storePhotoInput").value=""} async function detectShopNexWatermark(file){ // Automatic background check for the standalone test build. // Production: replace/augment this with server-side logo/OCR detection. if(/shop[ _-]?nex/i.test(file?.name||"")) return true; return await new Promise(resolve=>{ const url=URL.createObjectURL(file),v=document.createElement('video'); v.muted=true;v.playsInline=true;v.preload='metadata';v.src=url; let finished=false; const done=x=>{if(finished)return;finished=true;try{URL.revokeObjectURL(url)}catch(e){}resolve(!!x)}; v.onerror=()=>done(false); v.onloadedmetadata=()=>{ // This build performs an automatic visual brand-color scan in the normal // watermark area. The hosted backend will use ShopNex logo matching/OCR. const seek=Math.min(Math.max((v.duration||1)*0.35,0),Math.max((v.duration||1)-0.1,0)); try{v.currentTime=seek}catch(e){done(false)} }; v.onseeked=()=>{ try{ const w=240,h=Math.max(135,Math.round(240*(v.videoHeight||360)/(v.videoWidth||640))); const c=document.createElement('canvas');c.width=w;c.height=h;const ctx=c.getContext('2d',{willReadFrequently:true});ctx.drawImage(v,0,0,w,h); // Scan right/bottom region where ShopNex watermark is normally placed. const sx=Math.floor(w*.55),sy=Math.floor(h*.45),sw=w-sx,sh=h-sy; const d=ctx.getImageData(sx,sy,sw,sh).data; let blue=0,orange=0,white=0,total=d.length/4; for(let i=0;i105 && b>r*1.25 && b>g*1.05) blue++; if(r>180 && g>55 && g<175 && b<90) orange++; if(r>220&&g>220&&b>220) white++; } const branded=(blue/total>.025 && orange/total>.004 && white/total>.015); done(branded); }catch(e){done(false)} }; setTimeout(()=>done(false),4500); }); } async function processUploadedVideos(input){ const files=[...(input?.files||[])]; for(const f of files){ const already=await detectShopNexWatermark(f); const src=await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>res(e.target.result);r.onerror=rej;r.readAsDataURL(f)}); storeVideos.push({id:"vid"+Date.now()+Math.random(),src,supplierCode,supplierName:vendor.name,businessType:vendor.vendorType,categories:productsData.map(i=>i.category),location:vendorLoc(),hasShopNexWatermark:already,watermarkDetection:'automatic',createdAt:Date.now()}); persistStoreMedia();notifyFavoriteMarketUpdate("New Video",vendor.name+" added a new video on ShopNex."); } if(input)input.value=""; } function addStoreVideos(){return processUploadedVideos(el("storeVideoInput"))} function addAccountVideos(){return processUploadedVideos(el("accountVideoInput"))} function addAccountPhotos(){const input=el("accountPhotoInput");const files=[...(input?.files||[])];files.forEach(f=>{const r=new FileReader();r.onload=e=>{storePhotos.push({id:"ph"+Date.now()+Math.random(),src:e.target.result,caption:vendor.name,supplierCode,supplierName:vendor.name,businessType:vendor.vendorType,categories:productsData.map(i=>i.category),location:vendorLoc(),createdAt:Date.now()});persistStoreMedia()};r.readAsDataURL(f)});if(input)input.value=""} function deleteStorePhoto(id){ const item=storePhotos.find(x=>x.id===id); if(!item)return; if(!confirm("Delete this photo? It will also disappear immediately from the client Photos view."))return; storePhotos=storePhotos.filter(x=>x.id!==id); persistStoreMedia(); } function deleteStoreVideo(id){ const item=storeVideos.find(x=>x.id===id); if(!item)return; if(!confirm("Delete this video? It will also disappear immediately from the client Videos view."))return; storeVideos=storeVideos.filter(x=>x.id!==id); persistStoreMedia(); } function renderStoreMediaManager(){const pm=el("storePhotoManager"),vm=el("storeVideoManager"),apm=el("accountPhotoManager"),avm=el("accountVideoManager");const photoHtml=storePhotos.length?storePhotos.map(m=>`
Visible to clients
`).join(""):'
No store photos uploaded.
';const videoHtml=storeVideos.length?storeVideos.map(m=>`
Visible to clients β€’ ${m.hasShopNexWatermark?'ShopNex watermark detected automatically β€” duplicate prevented':'No ShopNex watermark detected β€” ShopNex branding applied'}
`).join(""):'
No store videos uploaded.
';if(pm)pm.innerHTML=photoHtml;if(vm)vm.innerHTML=videoHtml;if(apm)apm.innerHTML=photoHtml;if(avm)avm.innerHTML=videoHtml} function renderVendorStore(){if(!el("storeName"))return;el("storeName").textContent=vendor.name;el("storeMeta").textContent=`${vendor.vendorType}${vendor.vendorType==="Supermarket"&&vendor.supermarketType?" β€’ "+vendor.supermarketType:""} β€’ ${supplierCode} β€’ ${vendorLoc()}`;el("storeAbout").textContent=vendor.about||"";el("storeAvatar").innerHTML=vendor.photo?``:"SP";const storeSearch=el("storeProductSearch");const q=(storeSearch?.value||"").toLowerCase().trim();const storeItems=productsData.filter(i=>{if(i.adminDisabled)return false;const hay=[i.name,i.category,i.description,i.code,i.sku,i.uom,i.priceType,i.stock].join(" ").toLowerCase();return !q||hay.includes(q)});el("storeProducts").innerHTML=storeItems.length?storeItems.map(productCard).join(""):(productsData.length?'
No products in this store match your search.
':'
This supplier has not added products yet.
');el("storePhotos").innerHTML=storePhotos.length?storePhotos.map((m,i)=>`
Store photo ${i+1}${watermarkHtml('photo-watermark')}
${socialControls(m,'photo')}
`).join(""):'
No store photos yet.
';el("storeVideos").innerHTML=storeVideos.length?storeVideos.map(m=>`
${m.hasShopNexWatermark?'':watermarkHtml()}${socialControls(m,'video')}
`).join(""):'
No store videos yet.
';setTimeout(setupPhotoViews,0)} let mediaObserver=null; let shopNexAudioUnlocked=false; function stopAllMediaVideos(){document.querySelectorAll(".shopnex-media-video").forEach(v=>{try{v.pause();v.currentTime=0}catch(e){}});if(mediaObserver){mediaObserver.disconnect();mediaObserver=null}} function unlockShopNexVideoAudio(){ shopNexAudioUnlocked=true; const vids=[...document.querySelectorAll(".shopnex-media-video")]; const active=vids.find(v=>{const r=v.getBoundingClientRect();return r.topinnerHeight*.28}); if(active){active.muted=false;active.volume=1;active.play().catch(()=>{});} } function activateShopNexMediaAudioFromGesture(){ shopNexAudioUnlocked=true; const vids=[...document.querySelectorAll(".shopnex-media-video")]; vids.forEach(v=>{v.muted=false;v.volume=1;v.playsInline=true;v.preload="auto"}); const active=vids.find(v=>{const r=v.getBoundingClientRect();return r.topinnerHeight*.22})||vids[0]; if(active){ vids.forEach(x=>{if(x!==active){try{x.pause()}catch(e){}}}); active.play().catch(()=>{}); } } function setupVideoAutoplay(){ if(currentView!=="media"||!el("videosPane").classList.contains("active"))return; stopAllMediaVideos(); const vids=[...document.querySelectorAll(".shopnex-media-video")]; if(!vids.length)return; // Browsers require one user gesture before sound can autoplay. Any tap/touch in the video feed unlocks audio for subsequent scrolling. const feed=el("videoFeed"); if(feed&&!feed.dataset.audioUnlockBound){ feed.dataset.audioUnlockBound="1"; feed.addEventListener("pointerdown",unlockShopNexVideoAudio,{passive:true}); feed.addEventListener("touchstart",unlockShopNexVideoAudio,{passive:true}); } vids.forEach(v=>{v.playsInline=true;v.preload="auto";v.volume=1;}); mediaObserver=new IntersectionObserver(entries=>{entries.forEach(entry=>{ const v=entry.target; if(entry.isIntersecting&&entry.intersectionRatio>=0.65){ vids.forEach(x=>{if(x!==v){try{x.pause()}catch(e){}}}); v.muted=false;v.volume=1; const id=v.dataset.mediaId; v.play().then(()=>{if(id){recordMediaInterest(id,"video");countMediaView(id,"video")}}).catch(()=>{ // Autoplay-with-sound can be blocked until the first user interaction. Keep the video moving, then enable sound immediately on the first touch. v.muted=true; v.play().catch(()=>{}); v.title="Tap once to enable video sound"; v.addEventListener("pointerdown",()=>{shopNexAudioUnlocked=true;v.muted=false;v.volume=1;v.play().catch(()=>{})},{once:true}); }); }else{try{v.pause()}catch(e){}} })},{threshold:[0,.25,.65,.9,1]}); vids.forEach(v=>mediaObserver.observe(v)); } // ===== Admin / Back Office data & reports ===== let adminSettings=JSON.parse(localStorage.getItem("shopnex_admin_settings_v1")||"null")||{marketplaceEnabled:true,promotionsEnabled:true,mediaEnabled:true,vendorStatus:"Active",commissionRate:5,currency:"UGX"}; function persistAdminSettings(){localStorage.setItem("shopnex_admin_settings_v1",JSON.stringify(adminSettings));renderAdmin()} function totalSales(){return orders.reduce((s,o)=>s+orderGross(o),0)} function adminCommission(){return totalSales()*Number(adminSettings.commissionRate||0)/100} function adminSafeDate(v){try{return new Date(v).toLocaleString()}catch(e){return v||""}} const VENDOR_POINT_RATE=0.01; function orderGross(o){return orderValue(o)} function orderTime(o){const t=Number(o.createdAt)||Date.parse(o.date||"");return Number.isFinite(t)?t:0} function orderSupplier(o){return o.supplierCode||supplierCode} function startOfPeriod(period,now=new Date()){const d=new Date(now);d.setHours(0,0,0,0);if(period==="today")return d.getTime();if(period==="mtd"){d.setDate(1);return d.getTime()}if(period==="qtd"){d.setMonth(Math.floor(d.getMonth()/3)*3,1);return d.getTime()}if(period==="ytd"){d.setMonth(0,1);return d.getTime()}return 0} function vendorGross(scode,period){const start=startOfPeriod(period);return orders.reduce((sum,o)=>orderSupplier(o)===scode&&orderTime(o)>=start?sum+orderGross(o):sum,0)} function vendorPoints(scode,period){return vendorGross(scode,period)*VENDOR_POINT_RATE} function knownSupplierCodes(){const codes=new Set([supplierCode]);orders.forEach(o=>codes.add(orderSupplier(o)));storeVideos.forEach(m=>codes.add(mediaMeta(m).supplierCode));storePhotos.forEach(m=>codes.add(mediaMeta(m).supplierCode));return [...codes].filter(Boolean)} function supplierNameForCode(code){if(code===supplierCode)return vendor.name;const m=storeVideos.concat(storePhotos).find(x=>mediaMeta(x).supplierCode===code);if(m)return mediaMeta(m).supplierName;const o=orders.find(x=>orderSupplier(x)===code);return o?.vendor||code} function exportPoints(){downloadCSV("ShopNex_Vendor_Points_Report.csv",["Supplier Code","Vendor","Today Gross UGX","Today Points","MTD Gross UGX","MTD Points","Quarter Gross UGX","Quarter Points","YTD Gross UGX","YTD Points"],knownSupplierCodes().sort((a,b)=>vendorPoints(b,"mtd")-vendorPoints(a,"mtd")).map(code=>[code,supplierNameForCode(code),vendorGross(code,"today"),vendorPoints(code,"today"),vendorGross(code,"mtd"),vendorPoints(code,"mtd"),vendorGross(code,"qtd"),vendorPoints(code,"qtd"),vendorGross(code,"ytd"),vendorPoints(code,"ytd")]))} function setAdminSetting(k,v){if(k==="commissionRate")v=Math.max(0,Math.min(100,Number(v)||0));adminSettings[k]=v;persistAdminSettings()} function toggleAdminSetting(k,checked){adminSettings[k]=Boolean(checked);persistAdminSettings()} function vendorHasBusinessGPS(){return Number.isFinite(Number(vendor.lat))&&Number.isFinite(Number(vendor.lng))&&vendor.lat!==null&&vendor.lng!==null&&vendor.lat!==''&&vendor.lng!==''} function setVendorAdminStatus(v){ const old=adminSettings.vendorStatus; const pending=vendorProfilePendingItemsFromData(); if(v==='Approved'&&pending.length){ vendor.profilePendingItems=pending;saveVendorData(); const body='Approval cannot go through yet. Pending vendor information: '+pending.join(', ')+'.'; try{pushOrderNotification('Admin','Approval Blocked','Pending profile items: '+(vendor.name||supplierCode),body,'')}catch(e){} notifyVendorOfPendingProfileItems(pending); alert('Approval cannot go through yet.\n\nPending information:\nβ€’ '+pending.join('\nβ€’ ')+'\n\nThe vendor has been notified in ShopNex Chat.'); showToast('Approval blocked: '+pending.length+' pending item'+(pending.length===1?'':'s')+'.'); renderAll(); return false; } adminSettings.vendorStatus=v;persistAdminSettings(); if(vendorAuth&&old!==v)notifyVendorAccountDecision(v); renderAll();return true } function showAdminTab(name){document.querySelectorAll('.admin-pane').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.admin-tabs button').forEach(x=>x.classList.remove('active'));const pane=el('adminPane'+name.charAt(0).toUpperCase()+name.slice(1));if(pane)pane.classList.add('active');const btn=el('adminTab'+name.charAt(0).toUpperCase()+name.slice(1));if(btn)btn.classList.add('active');renderAdmin()} function reportTable(headers,rows){if(!rows.length)return '
No records available for this report.
';return '
'+headers.map(h=>'').join('')+''+rows.map(r=>''+r.map(c=>'').join('')+'').join('')+'
'+esc(h)+'
'+c+'
'} function csvEscape(v){v=String(v??'');return /[",\n]/.test(v)?'"'+v.replace(/"/g,'""')+'"':v} function downloadCSV(name,headers,rows){const csv=[headers,...rows].map(r=>r.map(csvEscape).join(',')).join('\n');const b=new Blob([csv],{type:'text/csv;charset=utf-8'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000)} function exportProducts(){downloadCSV('ShopNex_Product_Report.csv',['Product Code','SKU','Product','Category','UoM','PCS per UoM','Price UGX','Pricing Type','Stock','Vendor'],productsData.map(i=>[i.code,i.sku||'',i.name,i.category,i.uom||'Pieces',Math.max(1,Number(i.pcsPerUom||1)),i.price,i.priceType,i.stock,vendor.name]))} function exportOrders(){downloadCSV('ShopNex_Order_Sales_Report.csv',['Order ID','Date','Vendor','Lines','Placed Value UGX','Negotiated/Final Value UGX','Status'],orders.map(o=>[o.id,o.date,o.vendor,(o.items||[]).length,orderPlacedValue(o),orderValue(o),o.status||'Placed']))} function exportVendor(){downloadCSV('ShopNex_Vendor_Register.csv',['Supplier Code','Vendor','Vendor Type','Status','Region','District','Subcounty','Parish','Village','Contact Person','Position','Tel 1','Tel 2','Email'],[[supplierCode,vendor.name,(vendor.vendorType+(vendor.vendorType==="Supermarket"&&vendor.supermarketType?" - "+vendor.supermarketType:"")),adminSettings.vendorStatus,vendor.region,vendor.district,vendor.subcounty,vendor.parish,vendor.village,vendor.contact,vendor.position,vendor.tel1,vendor.tel2,vendor.email]])} function exportMedia(){downloadCSV('ShopNex_Media_Report.csv',['Type','Media ID','Vendor','Vendor Type','Location','Views','Likes','Downloads','Created'],storePhotos.map(m=>['Photo',m.id,m.supplierName||vendor.name,m.businessType||vendor.vendorType,m.location||vendorLoc(),Number(m.views||0),Number(m.likes||0),Number(m.downloads||0),m.createdAt?new Date(m.createdAt).toLocaleString():'']).concat(storeVideos.map(m=>['Video',m.id,m.supplierName||vendor.name,m.businessType||vendor.vendorType,m.location||vendorLoc(),Number(m.views||0),Number(m.likes||0),Number(m.downloads||0),m.createdAt?new Date(m.createdAt).toLocaleString():''])))} function setOrderStatus(id,status){const o=orders.find(x=>x.id===id);if(!o)return;o.status=status;addOrderEvent(o,status,'Admin status updated to '+status);persistOrders();if(['Confirmed','Delivered','Invoiced','Receipted'].includes(status))notifyOrderStage(o,status);renderAll()} function adminDeleteProduct(id){if(!adminGuard())return;auditAdmin('PRODUCT DELETE REQUEST',id);if(!confirm('Admin: delete this product from ShopNex?'))return;productsData=productsData.filter(x=>x.id!==id);localStorage.setItem("shopnex_items_v17",JSON.stringify(productsData));renderAll()} function adminDeleteMedia(type,id){if(!adminGuard())return;auditAdmin('MEDIA DELETE REQUEST',type+' '+id);if(!confirm('Admin: remove this media item?'))return;if(type==='Photo')storePhotos=storePhotos.filter(x=>x.id!==id);else storeVideos=storeVideos.filter(x=>x.id!==id);persistStoreMedia();renderAll()} function openAdminOrder(id){orderViewRole='admin';openView('orders');renderOrders();setTimeout(()=>{const b=[...document.querySelectorAll('#ordersList .card .name')].find(n=>n.textContent===id);if(b)b.scrollIntoView({behavior:'smooth',block:'start'})},0)} function renderAdmin(){if(!adminAuthenticated)return;if(!el('adminKpis'))return;const sales=totalSales(),commission=adminCommission(),inStock=productsData.filter(i=>i.stock==='In Stock').length;el('adminKpis').innerHTML=`
1
REGISTERED VENDORS
${productsData.length}
PRODUCTS
${orders.length}
ORDERS
${money(sales)}
GROSS ORDER VALUE
${money(commission)}
EST. SHOPNEX COMMISSION
${storePhotos.length+storeVideos.length}
MEDIA ITEMS
`; const d=el('adminPaneDashboard');if(d)d.innerHTML=`
πŸ” SUPER ADMIN SESSION ACTIVE β€’ Hidden from buyers and vendors β€’

Executive Summary

${adminSettings.marketplaceEnabled?'Marketplace Live':'Marketplace Paused'}
Back-office figures are generated from the test data saved in this browser. When ShopNex moves to a central database, this same dashboard can report across all users, vendors and devices.
Vendor Status
${esc(adminSettings.vendorStatus)}
In-stock Products
${inStock} of ${productsData.length}
Photos
${storePhotos.length}
Videos
${storeVideos.length}

Management Reports

Use the report tabs above for detailed registers and downloadable CSV reports suitable for Excel.

`; const superp=el('adminPaneSuper');if(superp){const audit=adminAudit();superp.innerHTML=`

Final Controller

Full administrative authority for this ShopNex prototype. Use disable/suspend where possible so historical records remain intact.

Admin Audit Trail

${audit.length?audit.slice(0,100).map(x=>`
${esc(x.action)} β€’ ${esc(new Date(x.at).toLocaleString())}
${esc(x.detail||'')}
`).join(''):'
No administrative actions recorded yet.
'}
`;} const c=el('adminPaneControl');if(c)c.innerHTML=`

Platform Controls

`; const v=el('adminPaneVendors');if(v){const pending=vendorProfilePendingItemsFromData();v.innerHTML=`

Vendor Register

${pending.length?`
⚠ Pending before approval (${pending.length})
${pending.map(x=>'β€’ '+esc(x)).join('
')}
The same pending list is automatically shared with the vendor in ShopNex Chat.
`:`
βœ“ Profile complete
No required information is pending. Admin may approve this vendor.
`}${reportTable(['Supplier Code','Vendor','Vendor Type','Status','Location','Contact'],[[esc(supplierCode),esc(vendor.name),esc(vendor.vendorType+(vendor.vendorType==="Supermarket"&&vendor.supermarketType?" β€’ "+vendor.supermarketType:"")),`${esc(adminSettings.vendorStatus)}`,esc(vendorLoc())+(vendorHasBusinessGPS()?'
GPS captured':'
GPS missing β€” approval blocked'),esc([vendor.contact,vendor.position,vendor.tel1,vendor.email].filter(Boolean).join(' β€’ ')||'Not provided')]])}
`;} const p=el('adminPaneProducts');if(p)p.innerHTML=`

Product / Inventory Report

${reportTable(['Code','SKU','Brand','Product','Category','UoM','PCS/UoM','Regular Price','Current Selling Price','Promotion','Type','Stock','Admin'],productsData.map(i=>{const ap=activePromotionFor(i.id);return [esc(i.code),esc(i.sku||''),esc(i.brand||''),esc(i.name),esc(i.category),esc(i.uom||'Pieces'),String(Math.max(1,Number(i.pcsPerUom||1))),money(i.price),money(effectiveProductPrice(i)),ap?esc(promotionLabel(ap)):'β€”',esc(i.priceType),`${esc(i.stock)}`,` `]}))}`; const o=el('adminPaneOrders');if(o)o.innerHTML=`

Orders & Sales Report

Gross order value: ${money(sales)} β€’ Estimated ShopNex commission at ${adminSettings.commissionRate}%: ${money(commission)}. Admin can open any order below and perform vendor lifecycle actions.
${reportTable(['Order','Date','Vendor','Lines','Placed Value','Final Value','Status','Actions'],orders.map(x=>[esc(x.id),esc(x.date),esc(x.vendor),(x.items||[]).length,money(orderPlacedValue(x)),money(orderValue(x)),``,` `]))}`; const pts=el('adminPanePoints');if(pts){const pointRows=knownSupplierCodes().sort((a,b)=>vendorPoints(b,'mtd')-vendorPoints(a,'mtd')).map(code=>[esc(code),esc(supplierNameForCode(code)),money(vendorGross(code,'today')),Number(vendorPoints(code,'today')).toLocaleString(undefined,{maximumFractionDigits:2}),money(vendorGross(code,'mtd')),Number(vendorPoints(code,'mtd')).toLocaleString(undefined,{maximumFractionDigits:2}),money(vendorGross(code,'qtd')),Number(vendorPoints(code,'qtd')).toLocaleString(undefined,{maximumFractionDigits:2}),money(vendorGross(code,'ytd')),Number(vendorPoints(code,'ytd')).toLocaleString(undefined,{maximumFractionDigits:2})]);pts.innerHTML=`

Vendor Points Report

Points rate: 1% of gross sales. Video ranking uses Month-to-Date points in descending order; ties are broken by newest video first.
${reportTable(['Supplier','Vendor','Today Gross','Today Points','MTD Gross','MTD Points','Quarter Gross','Quarter Points','YTD Gross','YTD Points'],pointRows)}`;} const m=el('adminPaneMediareport');if(m)m.innerHTML=`

Media Report

${reportTable(['Type','ID','Vendor','MTD Points','Views','Likes','Downloads','Location','Created','Admin'],storePhotos.map(x=>{const z=mediaMeta(x);return ['Photo',esc(x.id),esc(z.supplierName),Number(vendorPoints(z.supplierCode,'mtd')).toLocaleString(undefined,{maximumFractionDigits:2}),Number(x.views||0),Number(x.likes||0),Number(x.downloads||0),esc(z.location),esc(x.createdAt?new Date(x.createdAt).toLocaleString():''),``]}).concat(storeVideos.map(x=>{const z=mediaMeta(x);return ['Video',esc(x.id),esc(z.supplierName),Number(vendorPoints(z.supplierCode,'mtd')).toLocaleString(undefined,{maximumFractionDigits:2}),Number(x.views||0),Number(x.likes||0),Number(x.downloads||0),esc(z.location),esc(x.createdAt?new Date(x.createdAt).toLocaleString():''),``]})))}`; const l=el('adminPaneLocations');if(l)l.innerHTML=`

Location Report

${reportTable(['Region','District','Sub-county / Division','Parish / Ward','Village / Cell','Vendors','Products'],[[esc(vendor.region||''),esc(vendor.district||''),esc(vendor.subcounty||''),esc(vendor.parish||''),esc(vendor.village||''),'1',String(productsData.length)]])}`; const a=el('adminPaneActivity');if(a){const supplierRows=Object.entries(visitProfile.suppliers||{}).sort((x,y)=>y[1]-x[1]).map(([k,n])=>['Supplier interest',esc(k),String(n)]);const catRows=Object.entries(visitProfile.categories||{}).sort((x,y)=>y[1]-x[1]).map(([k,n])=>['Category interest',esc(k),Number(n).toFixed(2)]);const locRows=Object.entries(visitProfile.locations||{}).sort((x,y)=>y[1]-x[1]).map(([k,n])=>['Location interest',esc(k),Number(n).toFixed(2)]);a.innerHTML=`

Buyer Activity / Interest Report

This prototype records interest signals generated when users view media or visit suppliers on this device.
${reportTable(['Metric','Dimension','Score'],supplierRows.concat(catRows,locRows))}`} } orders.forEach(o=>{(o.items||[]).forEach(x=>{if(x.placedUnitPrice==null&&x.unitPrice!=null)x.placedUnitPrice=Number(x.unitPrice);if(x.negotiableAtOrder==null&&x.priceTypeAtOrder==='Negotiable')x.negotiableAtOrder=true;if(x.negotiableAtOrder==null&&!x.priceTypeAtOrder)orderLineNegotiable(x)});if(o.placedGrossValue==null)o.placedGrossValue=orderPlacedValue(o)});persistOrders(); // ===== Hidden Super Admin Controller ===== const SHOPNEX_ADMIN_PASSWORD_KEY='shopnex_admin_password_v2'; const SHOPNEX_ADMIN_AUDIT_KEY='shopnex_admin_audit_v1'; const SHOPNEX_ADMIN_DEFAULT_PASSWORD='ShopNex@2026'; let adminAuthenticated=false, adminTapCount=0, adminTapTimer=null, adminHoldTimer=null, adminLastTap=0, adminHoldOpened=false; function adminPassword(){try{return localStorage.getItem(SHOPNEX_ADMIN_PASSWORD_KEY)||SHOPNEX_ADMIN_DEFAULT_PASSWORD}catch(e){return SHOPNEX_ADMIN_DEFAULT_PASSWORD}} function adminSecretTap(){ if(adminHoldOpened){adminHoldOpened=false;return;} const now=Date.now(); if(now-adminLastTap>1800) adminTapCount=0; adminLastTap=now; adminTapCount++; clearTimeout(adminTapTimer); adminTapTimer=setTimeout(()=>{adminTapCount=0;adminLastTap=0},1800); if(adminTapCount>=3){ adminTapCount=0; adminLastTap=0; clearTimeout(adminTapTimer); openAdminLogin(); } } // Hidden admin access works two ways: press and hold the ShopNex brand for 2 seconds, // or tap it 3 times quickly. Both are supported on Android touch screens and desktop browsers. function adminHoldStart(){ clearTimeout(adminHoldTimer); adminHoldOpened=false; adminHoldTimer=setTimeout(()=>{ adminHoldOpened=true; adminTapCount=0; adminLastTap=0; clearTimeout(adminTapTimer); try{if(navigator.vibrate)navigator.vibrate(60)}catch(e){} openAdminLogin(); },2000); } function adminHoldCancel(){clearTimeout(adminHoldTimer);adminHoldTimer=null;} function bindAdminSecretTrigger(){ const brand=el('shopnexBrand'); if(!brand||brand.dataset.adminTriggerBound==='1')return; brand.dataset.adminTriggerBound='1'; if(window.PointerEvent){ brand.addEventListener('pointerdown',adminHoldStart,{passive:true}); ['pointerup','pointercancel','pointerleave'].forEach(ev=>brand.addEventListener(ev,adminHoldCancel,{passive:true})); }else{ brand.addEventListener('touchstart',adminHoldStart,{passive:true}); ['touchend','touchcancel','touchmove'].forEach(ev=>brand.addEventListener(ev,adminHoldCancel,{passive:true})); brand.addEventListener('mousedown',adminHoldStart); ['mouseup','mouseleave'].forEach(ev=>brand.addEventListener(ev,adminHoldCancel)); } } function openAdminLogin(){const x=el('adminLock');if(x)x.classList.add('show');setTimeout(()=>el('adminPasswordInput')?.focus(),80)} function checkSecretAdminUrl(){if(location.hash==='#shopnex-super-admin'||location.hash.startsWith('#shopnex-super-admin-review=')){sessionStorage.setItem('shopnex_admin_review_link',location.hash.startsWith('#shopnex-super-admin-review=')?'1':'0');openAdminLogin();}} window.addEventListener('hashchange',checkSecretAdminUrl); window.addEventListener('load',()=>setTimeout(checkSecretAdminUrl,120)); function resetAdminTestPassword(){try{localStorage.removeItem(SHOPNEX_ADMIN_PASSWORD_KEY)}catch(e){}const x=el('adminPasswordInput');if(x)x.value=SHOPNEX_ADMIN_DEFAULT_PASSWORD;const er=el('adminLoginError');if(er){er.style.color='#087a55';er.textContent='Test Admin password reset. Tap Login to Admin.'}} function closeAdminLogin(){el('adminLock')?.classList.remove('show');if(el('adminPasswordInput'))el('adminPasswordInput').value='';if(el('adminLoginError'))el('adminLoginError').textContent=''} function openAdminDashboard(){ // Open Admin directly and do not route through vendor-login protection. stopAllMediaVideos(); document.querySelectorAll('.view').forEach(v=>v.classList.remove('active')); const target=el('admin'); if(!target){alert('Admin dashboard could not be found. Please reload ShopNex.');return false;} target.classList.add('active'); if(currentView!=='admin')historyStack.push('admin'); currentView='admin'; document.body.classList.remove('home-locked'); document.querySelectorAll('.nav').forEach(n=>n.classList.remove('active')); try{renderAdmin();}catch(e){console.error('Admin render error',e);} window.scrollTo({top:0,behavior:'auto'}); return true; } function adminLogin(){ const input=el('adminPasswordInput'); const error=el('adminLoginError'); if(!input)return; const entered=String(input.value||'').trim(); if(!entered){if(error)error.textContent='Enter the Admin password.';input.focus();return;} let valid=false; try{valid=(entered===adminPassword());}catch(e){valid=(entered===SHOPNEX_ADMIN_DEFAULT_PASSWORD);} if(!valid){ if(error)error.textContent='Incorrect admin password. For this test version use ShopNex@2026.'; try{auditAdmin('LOGIN FAILED','Incorrect password attempt')}catch(e){} input.select(); return; } adminAuthenticated=true; try{sessionStorage.setItem('shopnex_admin_authenticated','1')}catch(e){} closeAdminLogin(); // Open the dashboard BEFORE any optional storage/audit work so a full browser storage cannot block login. if(!openAdminDashboard())return; try{auditAdmin('LOGIN','Super Admin login')}catch(e){console.warn('Admin audit storage unavailable',e)} try{ if(sessionStorage.getItem('shopnex_admin_review_link')==='1'){ sessionStorage.removeItem('shopnex_admin_review_link'); showAdminTab('vendors'); showToast('Vendor application opened for review.'); }else{ showAdminTab('dashboard'); showToast('Super Admin access granted.'); } }catch(e){showAdminTab('dashboard');} } function adminLogout(){auditAdmin('LOGOUT','Super Admin logout');adminAuthenticated=false;try{sessionStorage.removeItem('shopnex_admin_authenticated')}catch(e){}openView('home');showToast('Admin logged out.')} function auditAdmin(action,detail){try{let a=[];try{a=JSON.parse(localStorage.getItem(SHOPNEX_ADMIN_AUDIT_KEY)||'[]')}catch(e){}a.unshift({at:new Date().toISOString(),action,detail});a=a.slice(0,200);localStorage.setItem(SHOPNEX_ADMIN_AUDIT_KEY,JSON.stringify(a));return true}catch(e){console.warn('Admin audit could not be saved',e);return false}} function adminAudit(){try{return JSON.parse(localStorage.getItem(SHOPNEX_ADMIN_AUDIT_KEY)||'[]')}catch(e){return []}} function adminChangePassword(){if(!adminAuthenticated)return openAdminLogin();const old=prompt('Enter current Admin password:');if(old!==adminPassword())return alert('Current password is incorrect.');const n=prompt('Enter new Admin password (minimum 8 characters):');if(!n||n.length<8)return alert('Password must have at least 8 characters.');const c=prompt('Confirm new Admin password:');if(c!==n)return alert('Passwords do not match.');localStorage.setItem(SHOPNEX_ADMIN_PASSWORD_KEY,n);auditAdmin('PASSWORD CHANGED','Admin password changed');alert('Admin password changed successfully.')} function adminGuard(){if(!adminAuthenticated){openAdminLogin();return false}return true} function adminToggleProduct(id){if(!adminGuard())return;const i=productsData.find(x=>x.id===id);if(!i)return;i.adminDisabled=!i.adminDisabled;saveProducts();auditAdmin(i.adminDisabled?'PRODUCT DISABLED':'PRODUCT ENABLED',`${i.code||id} ${i.name||''}`);renderAll()} function adminCreateProduct(){if(!adminGuard())return;const name=prompt('Product name:');if(!name)return;const price=Number(prompt('Price (UGX):')||0);const priceType=(prompt('Price type: Fixed or Negotiable','Fixed')||'Fixed');const seq=productsData.length+1;const item={id:'ADM-P-'+Date.now(),code:`${supplierCode}/${seq}`,name,price,priceType:/neg/i.test(priceType)?'Negotiable':'Fixed',category:'Others',sku:'',uom:'Pieces',pcsPerUom:1,stock:'In Stock',supplierCode,adminCreated:true};productsData.push(item);saveProducts();auditAdmin('PRODUCT CREATED',`${item.code} ${name}`);renderAll()} function adminCreateTransaction(){if(!adminGuard())return;const value=Number(prompt('Transaction / order value (UGX):')||0);if(value<0)return alert('Invalid value.');const id='ADM-'+Date.now();orders.unshift({id,date:new Date().toLocaleString(),vendor:vendor.name||'Admin Created',supplierCode,items:[],total:value,adminManualValue:value,status:'Placed',adminCreated:true,timeline:[{stage:'Placed',at:new Date().toISOString(),by:'ShopNex Admin'}]});saveOrders();auditAdmin('TRANSACTION CREATED',`${id} ${money(value)}`);renderAll()} function adminDeleteTransaction(id){if(!adminGuard())return;if(!confirm('Permanently delete this transaction? This action will be recorded.'))return;const x=orders.find(o=>o.id===id);orders=orders.filter(o=>o.id!==id);saveOrders();auditAdmin('TRANSACTION DELETED',`${id} ${x?.vendor||''}`);renderAll()} function adminToggleVendor(){if(!adminGuard())return;const next=adminSettings.vendorStatus==='Suspended'?'Approved':'Suspended';setVendorAdminStatus(next);saveAdminSettings();auditAdmin(next==='Suspended'?'VENDOR DISABLED':'VENDOR ENABLED',`${supplierCode} ${vendor.name||''}`);renderAll()} function adminDeleteVendor(){if(!adminGuard())return;if(!confirm('Delete the current vendor profile and its products? Historical orders will be retained for audit.'))return;const nm=vendor.name||supplierCode;vendor={};productsData=[];saveVendorData();saveProducts();auditAdmin('VENDOR DELETED',nm);renderAll()} function adminCreateVendor(){ if(!adminGuard())return; let modal=el('adminCreateVendorModal'); if(!modal){ modal=document.createElement('div'); modal.id='adminCreateVendorModal'; modal.className='modal show'; modal.innerHTML=`

βž• Create Vendor

Create a vendor directly from the ShopNex Admin controller.

The vendor is created as Approved by Admin. The vendor can then log in and complete or update the Vendor Profile, including the private business GPS location.
`; document.body.appendChild(modal); }else modal.classList.add('show'); setTimeout(()=>{const x=el('adminVendorName');if(x)x.focus()},50); } function closeAdminCreateVendor(){const m=el('adminCreateVendorModal');if(m)m.classList.remove('show')} function saveAdminCreatedVendor(){ if(!adminGuard())return; const name=(el('adminVendorName')?.value||'').trim(); const type=(el('adminVendorType')?.value||'General Merchandise').trim()||'General Merchandise'; const contact=(el('adminVendorContact')?.value||'').trim(); const phone=(el('adminVendorPhone')?.value||'').trim(); const email=(el('adminVendorEmail')?.value||'').trim().toLowerCase(); const password=el('adminVendorPassword')?.value||''; if(!name)return alert('Enter the Business / Store Name.'); if(!phone&&!email)return alert('Enter a mobile number or email for the vendor login.'); if(password.length<6)return alert('Temporary password must have at least 6 characters.'); vendor={...vendor,name,vendorType:type,contact,tel1:phone,email}; const applicationId='VADM-'+Date.now().toString(36).toUpperCase(); vendorAuth={identity:email||phone,phone,email,password,createdAt:Date.now(),applicationId,status:'Approved',profileSubmitted:false,createdByAdmin:true}; adminSettings.vendorStatus='Approved'; saveVendorData();saveAdminSettings();persistVendorAuth(); auditAdmin('VENDOR CREATED',`${name} β€’ ${email||phone}`); closeAdminCreateVendor(); renderAll(); showToast('Vendor created successfully. The vendor can now log in and complete the profile.'); } function adminClearAudit(){if(!adminGuard())return;if(!confirm('Clear the visible local audit trail?'))return;localStorage.removeItem(SHOPNEX_ADMIN_AUDIT_KEY);auditAdmin('AUDIT RESET','Audit trail reset by Super Admin');renderAdmin()} function renderAll(){renderProducts();renderVendors();renderCart();renderOrders();renderAccount();renderItemList();renderPromotionMatrix();renderPromotions();renderMedia();renderStoreMediaManager();renderVendorStore();syncQuickFilters();renderAdmin()} /* ===== v31 Spot Advert engine ===== */ const SHOPNEX_ADVERTS_KEY='shopnex_spot_adverts_v31'; const SHOPNEX_AD_PROGRESS_KEY='shopnex_spot_ad_progress_v31'; function loadSpotAdverts(){try{return JSON.parse(localStorage.getItem(SHOPNEX_ADVERTS_KEY)||'[]')}catch(e){return[]}} function saveSpotAdverts(a){try{localStorage.setItem(SHOPNEX_ADVERTS_KEY,JSON.stringify(a));return true}catch(e){alert('This advert video is too large for browser test storage. Please use a shorter/compressed video.');return false}} function fileToDataURL(file){return new Promise((resolve,reject)=>{const r=new FileReader();r.onload=()=>resolve(r.result);r.onerror=reject;r.readAsDataURL(file)})} async function compressAdvertVideo(file){ // Browser-side re-encode: max 720p, ~0.85 Mbps video. Keeps audio when the browser supports Web Audio capture. if(!window.MediaRecorder) return file; const url=URL.createObjectURL(file), video=document.createElement('video'); video.src=url; video.preload='metadata'; video.playsInline=true; video.muted=false; await new Promise((res,rej)=>{video.onloadedmetadata=res;video.onerror=rej}); const maxW=720,maxH=720,scale=Math.min(1,maxW/video.videoWidth,maxH/video.videoHeight),w=Math.max(2,Math.round(video.videoWidth*scale/2)*2),h=Math.max(2,Math.round(video.videoHeight*scale/2)*2); const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;const ctx=canvas.getContext('2d'); const fps=24,stream=canvas.captureStream(fps);let audioCtx=null; try{audioCtx=new (window.AudioContext||window.webkitAudioContext)();const source=audioCtx.createMediaElementSource(video),dest=audioCtx.createMediaStreamDestination();source.connect(dest);source.connect(audioCtx.destination);dest.stream.getAudioTracks().forEach(t=>stream.addTrack(t));}catch(e){video.muted=true} const types=['video/webm;codecs=vp9,opus','video/webm;codecs=vp8,opus','video/webm'];const mimeType=types.find(t=>MediaRecorder.isTypeSupported(t))||''; const rec=new MediaRecorder(stream,mimeType?{mimeType,videoBitsPerSecond:850000,audioBitsPerSecond:96000}:{videoBitsPerSecond:850000});const chunks=[];rec.ondataavailable=e=>{if(e.data&&e.data.size)chunks.push(e.data)}; const done=new Promise((res,rej)=>{rec.onstop=()=>res(new Blob(chunks,{type:rec.mimeType||'video/webm'}));rec.onerror=e=>rej(e.error||e)}); let drawing=true;function draw(){if(!drawing)return;try{ctx.drawImage(video,0,0,w,h)}catch(e){}requestAnimationFrame(draw)} rec.start(1000);draw();await video.play();await new Promise(res=>video.onended=res);drawing=false;rec.stop();const blob=await done;URL.revokeObjectURL(url);try{await audioCtx?.close()}catch(e){} return blob.size && blob.sizex.vendorCode===supplierCode);box.innerHTML=a.length?a.map(x=>``).join(''):'
No adverts uploaded yet.
'} function vendorDeleteAdvert(id){let a=loadSpotAdverts();const x=a.find(z=>z.id===id);if(!x||x.vendorCode!==supplierCode)return;a=a.filter(z=>z.id!==id);saveSpotAdverts(a);renderVendorAdverts();renderAdminAdverts()} function renderAdminAdverts(){const box=el('adminPaneAdverts');if(!box)return;const a=loadSpotAdverts();const approved=a.filter(x=>x.status==='Approved').sort((x,y)=>(x.sequence||99)-(y.sequence||99));box.innerHTML=`

Spot Adverts Approval & Sequence

Approved ${approved.length}/4
Only approved adverts air on Home. Maximum 4 approved adverts. Use ↑ and ↓ to set the running sequence.
`+(a.length?a.map(x=>``).join(''):'
No advert submissions yet.
')} function normalizeAdvertSequence(a){let n=1;a.filter(x=>x.status==='Approved').sort((x,y)=>(x.sequence||99)-(y.sequence||99)).forEach(x=>x.sequence=n++);return a} function adminApproveAdvert(id){if(!adminGuard())return;let a=loadSpotAdverts();if(a.filter(x=>x.status==='Approved').length>=4)return alert('Maximum 4 approved adverts can run at the same time. Reject or remove one before approving another.');const x=a.find(z=>z.id===id);if(!x)return;x.status='Approved';x.approvedAt=new Date().toISOString();x.sequence=a.filter(z=>z.status==='Approved'&&z.id!==id).length+1;saveSpotAdverts(normalizeAdvertSequence(a));auditAdmin('ADVERT APPROVED',x.name||id);pushOrderNotification('Vendor','Advert Approved','Spot Advert Approved',(x.name||'Your advert')+' has been approved and added to the Home advert sequence.','');renderAdminAdverts();resumeHomeSpotAds(true)} function adminRejectAdvert(id){if(!adminGuard())return;let a=loadSpotAdverts();const x=a.find(z=>z.id===id);if(!x)return;x.status='Rejected';x.sequence=999;saveSpotAdverts(normalizeAdvertSequence(a));auditAdmin('ADVERT REJECTED',x.name||id);pushOrderNotification('Vendor','Advert Rejected','Spot Advert Rejected',(x.name||'Your advert')+' was not approved. Please review it and submit again.','');renderAdminAdverts();resumeHomeSpotAds(true)} function adminDeleteAdvert(id){if(!adminGuard())return;let a=loadSpotAdverts();const x=a.find(z=>z.id===id);a=a.filter(z=>z.id!==id);saveSpotAdverts(normalizeAdvertSequence(a));auditAdmin('ADVERT DELETED',x?.name||id);renderAdminAdverts();resumeHomeSpotAds(true)} function adminMoveAdvert(id,dir){if(!adminGuard())return;let a=loadSpotAdverts(),q=a.filter(x=>x.status==='Approved').sort((x,y)=>x.sequence-y.sequence),i=q.findIndex(x=>x.id===id),j=i+dir;if(i<0||j<0||j>=q.length)return;[q[i].sequence,q[j].sequence]=[q[j].sequence,q[i].sequence];saveSpotAdverts(a);renderAdminAdverts();resumeHomeSpotAds(true)} let homeAdActiveId=null; function approvedSpotAdverts(){return loadSpotAdverts().filter(x=>x.status==='Approved').sort((a,b)=>(a.sequence||99)-(b.sequence||99)).slice(0,4)} function saveHomeAdProgress(){const v=el('homeSpotAdVideo');if(!v||!homeAdActiveId)return;localStorage.setItem(SHOPNEX_AD_PROGRESS_KEY,JSON.stringify({id:homeAdActiveId,time:Number(v.currentTime||0)}))} function pauseHomeSpotAds(){const v=el('homeSpotAdVideo');if(v&&!v.paused){saveHomeAdProgress();v.pause()}} function playHomeAd(ad,time=0){const v=el('homeSpotAdVideo'),empty=el('homeSpotAdEmpty'),q=approvedSpotAdverts();if(!v||!ad)return;homeAdActiveId=ad.id;empty.style.display='none';v.style.display='block';if(v.src!==ad.video)v.src=ad.video;v.onloadedmetadata=()=>{try{v.currentTime=Math.min(Math.max(0,time),Math.max(0,(v.duration||0)-.15))}catch(e){};v.muted=true;v.play().catch(()=>{})};v.onended=()=>{localStorage.setItem(SHOPNEX_AD_PROGRESS_KEY,JSON.stringify({id:ad.id,time:0}));const now=approvedSpotAdverts(),i=now.findIndex(x=>x.id===ad.id),next=now[(i+1)%now.length];if(next)playHomeAd(next,0)};const idx=q.findIndex(x=>x.id===ad.id);if(el('homeAdCounter'))el('homeAdCounter').textContent=q.length?`${idx+1}/${q.length}`:'';if(v.readyState>=1){try{v.currentTime=Math.min(Math.max(0,time),Math.max(0,(v.duration||0)-.15))}catch(e){};v.muted=true;v.play().catch(()=>{})}} function resumeHomeSpotAds(reset=false){if(currentView!=='home')return;const q=approvedSpotAdverts(),v=el('homeSpotAdVideo'),empty=el('homeSpotAdEmpty');if(!v||!empty)return;if(!q.length){v.pause();v.removeAttribute('src');v.load();v.style.display='none';empty.style.display='flex';if(el('homeAdCounter'))el('homeAdCounter').textContent='';homeAdActiveId=null;return}let p={};try{p=JSON.parse(localStorage.getItem(SHOPNEX_AD_PROGRESS_KEY)||'{}')}catch(e){}let ad=!reset&&q.find(x=>x.id===p.id);if(!ad)ad=q[0];playHomeAd(ad,!reset&&p.id===ad.id?Number(p.time||0):0)} window.addEventListener('beforeunload',saveHomeAdProgress);document.addEventListener('visibilitychange',()=>{if(document.hidden)pauseHomeSpotAds();else if(currentView==='home')resumeHomeSpotAds()});setInterval(()=>{if(currentView==='home')saveHomeAdProgress()},1500); const _renderAdminOriginal=renderAdmin;renderAdmin=function(){_renderAdminOriginal();renderAdminAdverts()}; initQuickFilters();bindAdminSecretTrigger();activateVendorCreateButton();renderAll();updateNotificationBadge();renderNotifications();loadFullUgandaLocations();renderDeliveryPeople();restoreVendorProfileFallback();setTimeout(resumeHomeSpotAds,120);