Home Our Services Portfolio Industries Blog About Us Clients Contact Start Free Trial

Oracubes Technologies — ERP Software Company in Sialkot, Pakistan since 2003. Proprietary ERP products CONIAC, AUGER, SQUID, EAGLE EYE 7, SMART SHOP, SMART RESTAURANT & BANQUET MANAGEMENT, plus Odoo implementation, web design, app development & IT consultancy.

Oracubes Technologies logo
ORACUBES
360 SOLUTION COMPANY
ERP · WEB · APP · CONSULTANCY
A Leading ERP Solution Provider — Sialkot, Pakistan
SCROLL

We Build Digital Solutions That Scale

Since 2003, Oracubes Technologies has built 7 proprietary ERP products — CONIAC (sportswear & garments), AUGER (education), SQUID (surgical), EAGLE EYE 7 (hotels), SMART SHOP (retail), SMART RESTAURANT and BANQUET MANAGEMENT — alongside Odoo ERP implementation, web design, app development and IT consultancy for businesses across Pakistan and beyond.

ERP Software
Web Design
App Development
Consultancy
Odoo ERP
Digital Solutions
Mobile Apps
Sialkot Pakistan
ERP Software
Web Design
App Development
Consultancy
Odoo ERP
Digital Solutions
Mobile Apps
Sialkot Pakistan
What We Do
Our Core Services
End-to-end technology solutions crafted specifically for your business needs.
🌐

Web Design

Stunning, fast, SEO-optimised websites that convert visitors into customers. Built with modern frameworks.

UI/UXWordPressReact
📱

App Development

Native and cross-platform iOS & Android apps that deliver seamless experiences and real business results.

FlutterReact NativeiOS/Android
⚙️

E.R.P. Software

Complete Odoo ERP implementation — accounting, inventory, HR, sales and operations in one powerful system.

OdooAccountingInventory
💡

Consultancy

Strategic IT consulting helping you choose the right technologies and achieve measurable digital transformation.

IT StrategyDigital Transform
View All Services →Our 7 ERP Products →
Our Clients
Trusted by Leading Businesses
Companies across Sialkot and beyond rely on us to power their digital operations.

Ready to Transform Your Business?

Get a free consultation and discover how Oracubes Technologies can help you scale.

FAQ
Common Questions
What does Oracubes Technologies do?
+
Oracubes Technologies is an ERP software company founded in 2003 in Sialkot, Pakistan. We build 7 proprietary ERP products for specific industries and also provide Odoo ERP implementation, web design, mobile app development and IT consultancy.
What ERP products does Oracubes offer?
+
We offer 7 ERP products: CONIAC (sportswear & garments manufacturing), AUGER (schools, colleges & universities), SQUID (surgical instrument manufacturers), EAGLE EYE 7 (hotel management), SMART SHOP (retail POS), SMART RESTAURANT (restaurant POS) and BANQUET MANAGEMENT (banquet halls & event venues).
Who founded Oracubes Technologies?
+
Oracubes Technologies was founded in 2003 by Aziz ur Rehman, our CEO, who has more than 25 years of programming and management experience across textiles, surgical manufacturing and software development.
What modules does Oracubes ERP include?
+
Our ERP is modular: Accounts, HR & Attendance, Store & Inventory, Procurement, Production (PPC), Point of Sale, Export & Shipment, and Administration. You switch on the modules your business needs.
Where is Oracubes Technologies located?
+
We are based at Office No.28, Second Floor, SBCC Building, Chamber of Commerce, Sialkot, Punjab, Pakistan, and serve clients locally and worldwide.
What industries does Oracubes serve?
+
Our dedicated ERP products cover sportswear & garments, education, surgical, retail, hotels & hospitality, restaurants and event venues. We also serve logistics, wholesale, eCommerce, travel, healthcare, trading, construction and manpower supply.
Can you integrate Odoo with Shopify or WooCommerce?
+
Yes. We integrate Odoo and our ERP products with Shopify, WooCommerce, Amazon, Microsoft, Salesforce, SAP and many other platforms, including ZATCA e-invoicing.
How do I get started?
+
Contact us via the form, call or WhatsApp +92 345 6822204, or call +92 300 9495640. We will schedule a free consultation to understand your needs and propose a solution.
Our Services

Our Services

End-to-end technology solutions tailored to your business — from ERP to mobile apps.

01 — Web Design
Modern Websites That Convert
We design and build fast, beautiful, SEO-optimised websites that represent your brand and drive real results.
🎨

UI/UX Design

User-centered design that looks stunning and delivers intuitive, engaging experiences across all devices.

💻

Custom Web Development

Fully custom websites built with React, Next.js, or WordPress — tailored exactly to your business needs.

🛒

eCommerce Websites

Full-featured online stores with payment integration, inventory management and order tracking.

Speed & SEO Optimization

Google-ready websites with fast load times, clean code and proper SEO structure built in from day one.

02 — App Development
Mobile Apps Built to Perform
Native and cross-platform mobile applications for iOS and Android that users love.
📱

iOS App Development

High-performance native iOS apps built with Swift — optimized for iPhone and iPad.

🤖

Android App Development

Feature-rich native Android apps using Kotlin and Java for the full Android ecosystem.

🔄

Cross-Platform (Flutter)

One codebase, two platforms. Flutter apps that feel truly native on both iOS and Android.

🔗

API & Backend Integration

Seamless integration with your existing ERP, CRM or third-party services for a connected experience.

03 — ERP Software
Odoo ERP — All-in-One Business System
Complete Odoo implementation, customization and ongoing support — streamline every function of your business.
💰

Accounting & Finance

Automate invoicing, expense tracking, bank reconciliation and financial reporting with Odoo Accounting.

InvoicingExpensesReporting
📦

Inventory Management

Real-time stock tracking, warehouse management, automated reordering and multi-location support.

StockWarehousePurchase
👥

HR & Payroll

Employee management, recruitment, leave tracking, payroll processing and attendance management.

EmployeesPayrollRecruitment
📊

CRM & Sales

Manage your entire sales pipeline, track leads, automate follow-ups and close deals faster.

CRMSalesPOS
🏭

Manufacturing

Production planning, bill of materials, work orders, quality control and shop floor management.

MRPPLMQuality
🎯

Marketing Automation

Email marketing, SMS campaigns, social media marketing and marketing analytics all in one platform.

EmailSMSSocial
04 — Consultancy
Strategic IT Advisory
We help businesses make the right technology decisions — saving time, money and resources.
🗺️

IT Strategy & Roadmap

Define a clear digital transformation roadmap aligned with your business goals and budget.

🔍

ERP Health Check

Audit your existing ERP system, identify bottlenecks and get a clear plan for optimization.

👨‍💼

Functional Consulting

Our functional experts map your business processes to the right Odoo modules and workflows.

🤝

Partner & Referral Program

Join our partner program and earn commissions while helping businesses in your network adopt Odoo ERP.

05 — Chatbot Development
Intelligent Chatbots That Convert
We build smart website and WhatsApp chatbots that act as your 24/7 digital salesperson — greeting visitors, answering questions instantly and capturing leads while you sleep.
🤖

AI-Powered Website Chatbots

Smart bots that greet visitors, answer FAQs instantly and guide users toward taking action — around the clock.

24/7AI
💬

WhatsApp & Social Chatbots

Automated conversations on WhatsApp and social media so customers get instant replies on the channels they already use.

WhatsAppSocial
🎯

Lead Capture & Qualification

Bots that qualify visitors, recommend the right products or services and hand warm leads straight to your team.

LeadsSales
⚙️

Custom Automation & Training

Fully customised business-automation chatbots integrated with your website, trained and continuously optimised for your audience.

AutomationIntegration

Ready to Get Started?

Tell us about your project and we'll get back to you within 24 hours with a free quote.

Integrations

Seamless Integrations

We connect Odoo ERP with the world's leading platforms so your business data flows seamlessly.

eCommerce Platforms
Sell More, Manage Less
Sync your online store with Odoo ERP for real-time inventory, orders and customer data.
🛍️

Shopify

Real-time product, order and inventory sync between Shopify and Odoo.

🛒

WooCommerce

Bi-directional sync for orders, customers and stock with your WooCommerce store.

📦

Amazon

Manage Amazon marketplace orders, FBA inventory and seller metrics inside Odoo.

🔵

Magento

Enterprise eCommerce integration with full product catalog and order management.

Enterprise Platforms
Connect Your Enterprise Systems
Bridge Odoo with your existing enterprise software for seamless data flow across your organization.
📊

Microsoft

Integrate with Microsoft 365, Azure AD, Teams and Power BI for seamless data collaboration.

Salesforce

Sync CRM data, contacts, leads and opportunities between Salesforce and Odoo.

🗄️

SAP

Connect SAP ERP with Odoo for unified business process management across platforms.

🔵

Oracle

Robust Oracle database and application integration for enterprise-level data management.

Payment & Logistics
Payment & Shipping Integrations
Automate payments, shipping and logistics through tight Odoo integrations.
💳

Stripe

Accept credit cards and online payments directly in Odoo with Stripe integration.

💰

PayPal

PayPal payment gateway integration for eCommerce and invoicing in Odoo.

🚚

FedEx / DHL

Auto-generate shipping labels, track packages and manage returns from Odoo.

📋

ZATCA / e-Invoicing

Saudi Arabia ZATCA e-Fatoora Round II compliant electronic invoicing integration.

Need a Custom Integration?

Don't see your platform listed? We build custom integrations for any system. Let's talk.

Industries

Industries We Serve

Deep domain expertise across multiple sectors to deliver solutions that truly fit your industry.

Verticals
Solutions Built for Your Industry
We have deep experience in these industries — our solutions are pre-configured to your sector's specific needs.
🏪

Retail

POS, inventory, loyalty programs and multi-branch management for retail chains.

🏭

Manufacturing

Production planning, BOM, work orders, quality control and shop floor management.

🚚

Logistics

Fleet management, route optimization, freight costing and real-time shipment tracking.

📦

Wholesale

Bulk order management, multi-warehouse, price lists and customer-specific pricing.

🍽️

Restaurants

Table management, kitchen display, online ordering integration and recipe costing.

🛍️

eCommerce

Online store, Shopify/WooCommerce sync, dropshipping and returns management.

✈️

Travel & Tours

Booking management, tour packages, customer CRM and payment processing.

🏥

Healthcare

Patient management, clinic scheduling, inventory and billing for healthcare facilities.

🎓

Education

Student management, fee collection, timetables and e-learning portal integration.

🔧

Trading

Multi-currency, import/export documentation, LC management and supplier portals.

🏗️

Construction

Project costing, subcontractor management, equipment tracking and budget control.

👷

Manpower Supply

Talent acquisition, contract management, payroll and client billing automation.

Don't See Your Industry?

We work with all types of businesses. Contact us and we'll show you how we can help.

About Us

About Oracubes Technologies

An ERP software company based in Sialkot, Pakistan — delivering world-class digital solutions since 2003.

Our Story
Built in Sialkot,
Built for Growth

Oracubes Technologies was founded in 2003 by Aziz ur Rehman with a single mission: to bring world-class ERP and technology solutions to businesses in Sialkot and Pakistan at competitive prices.

Over 20+ years we built 7 proprietary ERP products — CONIAC, AUGER, SQUID, EAGLE EYE 7, SMART SHOP, SMART RESTAURANT and BANQUET MANAGEMENT — and expanded into Odoo implementation, web development and mobile applications, becoming a true one-stop technology partner.

Today, we serve 50+ clients across Pakistan and internationally, with a passionate team of developers, designers and consultants dedicated to your success.

100+
Projects Delivered
50+
Happy Clients
20+
Years Experience
10+
Team Members
Why Choose Us
Why Businesses Trust Oracubes
We combine technical excellence with real business understanding.
🏙️

Based in Sialkot

Locally based in Sialkot's Chamber of Commerce area with deep understanding of Pakistan's business landscape.

Fast Delivery

Projects delivered on schedule with clear milestones and consistent communication throughout.

🔧

Custom Solutions

No templates. Every solution built from scratch to match your exact business requirements.

🛡️

Ongoing Support

Full post-launch support, maintenance and training included on all projects.

💰

Competitive Pricing

World-class technology at Pakistan-competitive rates — maximum ROI for every investment.

🌍

Global Standards

Our work meets international quality benchmarks — serving clients across Pakistan, Middle East and Europe.

Our Team
The People Behind Oracubes
A passionate team of developers, designers, consultants and marketers committed to your success.
Leadership
CEO

Aziz ur Rehman

Founder & CEO
CTO

Chief Technical

CTO & Lead Architect
Technical Team
SR

Sr. Odoo Developer

ERP Backend
WD

Web Developer

Frontend / React
MD

Mobile Developer

Flutter / iOS / Android
UI

UI/UX Designer

Design & Prototyping
BE

Backend Engineer

APIs & Integrations
Consulting & Marketing
FC

Functional Consultant

Odoo ERP Expert
DM

Digital Marketer

SEO & Paid Ads
SM

Social Media

Content & Strategy
HR

HR Executive

People & Culture

Want to Work With Us?

Get in touch and let's discuss how we can help your business grow.

Our Clients

Trusted by 50+ Businesses

Companies across Sialkot and beyond rely on Oracubes Technologies to power their digital operations.

Client Portfolio
Brands We Work With
From manufacturers and schools to hospitals and eCommerce stores — we serve businesses across all sectors.
sialkotmade
addos
agile
allied
alnaeem
cmi
jm
detech
ekal
enika
finemed
garmor
humed
lombard
sobytek
solargear
technimen
Testimonials
What Our Clients Say
⭐⭐⭐⭐⭐

"Oracubes implemented Odoo ERP for our manufacturing unit in record time. The team was professional, knowledgeable and always available for support."

— Client, Manufacturing Sector, Sialkot
⭐⭐⭐⭐⭐

"Our website traffic tripled after Oracubes redesigned our website and implemented their SEO strategy. Highly recommended for any business in Sialkot."

— Client, Retail Sector, Sialkot
⭐⭐⭐⭐⭐

"The mobile app they built for us is exactly what we needed. Smooth, fast and our customers love it. Great team to work with."

— Client, eCommerce, Pakistan

Join Our Growing Client Family

Let's discuss how we can deliver results for your business too.

Portfolio

Our Portfolio

Explore our completed projects — from ERP implementations to custom web and mobile applications.

Featured Work
Projects That Deliver Results
Every project is tailored to the client's unique needs. Here's a sample of our best work.

Have a Project in Mind?

Let us bring your vision to life. Contact us for a free consultation and project estimate.

Products

Our 7 ERP Products

Since 2003, Oracubes has built seven industry-specific ERP systems for businesses in Pakistan and worldwide. Every product is modular — switch on only what you need.

CONIAC

Sportswear & Garments ERP

Quick answer: CONIAC is a complete ERP for apparel and sports-goods manufacturers, covering export documentation, production planning and shipment.

ExportProduction Planning (PPC)ProductionShipment
Request a CONIAC demo →

AUGER

Education ERP

Quick answer: AUGER is an education ERP for schools, colleges and universities with fee management, exams, admissions and HR.

FeesExamsInquiryHR
Request an AUGER demo →

SQUID

Surgical Industry ERP

Quick answer: SQUID is an ERP for surgical-instrument manufacturers with export, production and store control, and Urdu language support.

ExportProduction Planning (PPC)StoreShipment
Request a SQUID demo →

EAGLE EYE 7

Hotel Management ERP

Quick answer: EAGLE EYE 7 is a complete hotel management ERP with front office, POS, banquet and gym management.

Front OfficePOSBanquetGym
Request an EAGLE EYE 7 demo →

SMART SHOP

Retail POS & ERP

Quick answer: SMART SHOP is a retail POS and ERP for medical stores, general stores, shoe shops and wholesale agencies, with accounts, inventory and HR.

POSAccountsStoreHR
Request a SMART SHOP demo →

SMART RESTAURANT

Restaurant POS

Quick answer: SMART RESTAURANT is a touchscreen restaurant POS with inventory, procurement and staff management.

Touch POSInventoryStaff
Request a SMART RESTAURANT demo →

BANQUET MANAGEMENT

Banquet & Event Venue ERP

Quick answer: BANQUET MANAGEMENT handles reservations, catering, events, accounts, POS and administration for banquet halls and event venues.

ReservationsCateringEventsAccountsPOSAdministration
Request a BANQUET MANAGEMENT demo →

Not sure which ERP fits your business?

Tell us your industry and we'll recommend the right Oracubes ERP — with a free consultation and demo.

Blog

Insights & Resources

Stay updated with the latest in ERP, web technology, digital marketing and business growth.

Latest Articles
From Our Blog
ERP GUIDE
ERP

The Ultimate Guide to Odoo ERP for Pakistani Businesses

How Odoo's all-in-one platform can streamline operations and accelerate growth for businesses in Pakistan.

WEB DEV
Web Development

Why Your Business in Sialkot Needs a Professional Website in 2025

A professional website is no longer optional — it's the foundation of your digital presence and credibility.

MOBILE
App Development

Should You Build a Native or Cross-Platform Mobile App?

We break down the pros and cons of Flutter vs native development to help you make the right decision.

ODOO
ERP

Odoo vs SAP: Which ERP is Right for Your Business?

A detailed comparison of Odoo and SAP to help SMEs choose the right ERP platform for their needs.

SEO
Digital Marketing

Top SEO Strategies That Actually Work for Pakistani Businesses

Practical, proven SEO techniques to help your website rank higher on Google in Pakistan.

SHOPIFY
Integration

Odoo & Shopify Integration: A Complete Step-by-Step Guide

Learn how to connect Shopify with Odoo for seamless order and inventory management.

Want Us to Write About Your Topic?

Have a question or topic you'd like us to cover? Get in touch and we'll write about it.

Contact

Get In Touch

Reach out for a free consultation. Our team will get back to you within 24 hours.

Contact Info
We're Here to
Help You Grow

Whether you're looking for ERP, a new website, a mobile app or IT advice — we're ready to discuss your project.

📍
Office Address
Office No.28, Second Floor,
SBCC Building, Chamber of Commerce,
Sialkot, Punjab, Pakistan
🌐
🕐
Business Hours
Mon – Sat: 9:00 AM – 6:00 PM
Sunday: Closed

Send Us a Message

FAQ
Common Questions
How quickly will you respond to my inquiry?
+
We respond to all inquiries within 24 business hours. For urgent matters, WhatsApp us at +92 345 6822204 for immediate assistance.
Do you offer a free consultation?
+
Yes! We offer a free 30-minute consultation for all new clients. We'll discuss your needs and recommend the best solution for your business.
Do you work with international clients?
+
Absolutely. While headquartered in Sialkot, we serve clients across Pakistan, the Middle East, Europe and beyond. We are fully set up for remote collaboration.
What payment methods do you accept?
+
We accept bank transfers, JazzCash, EasyPaisa and PayPal for international clients. We require a 50% advance payment before starting any project.

School Management System — Free Trial

Quick answer: Oracubes proudly presents a powerful and intelligent School Management System designed to streamline and digitize every aspect of modern educational institutions. Our software provides complete coverage of daily school operations, ensuring efficiency, accuracy, and transparency across all departments. From student attendance tracking to advanced fee management, detailed reporting, academic testing, and comprehensive accounting, every function is seamlessly integrated into one unified platform.

Attendance Management

Oracubes School Management System offers a smart attendance management module that allows schools to track student and staff attendance in real time. It ensures accurate record-keeping, reduces manual effort, and provides instant access to attendance history whenever needed.

Fee Management

The fee management system simplifies the entire fee collection process by generating challans, tracking payments, and maintaining complete financial records. It helps schools manage dues efficiently while providing transparency in all transactions.

Reports & Analytics

Our software provides detailed reports and analytics that give valuable insights into student performance, attendance trends, and financial data. These reports help school administrators make informed decisions quickly and effectively.

Test & Examination System

The test and examination module allows schools to manage exams, grading, and results with ease. It supports structured evaluation systems and generates accurate report cards to track student academic progress.

Accounting System

Oracubes includes a fully integrated accounting system that manages income, expenses, and financial records. It ensures proper bookkeeping and helps schools maintain financial accuracy and control.

Dashboard & Home Panel

The centralized dashboard provides a complete overview of school operations in one place. Administrators, teachers, and staff can access important data, notifications, and updates instantly through an easy-to-use interface.

WhatsApp Integration & Parent Updates

With built-in WhatsApp integration, parents stay constantly informed about their children’s activities. They receive instant updates on attendance, fees, results, and announcements, ensuring strong communication between school and parents.

Start the free trial →

← All free trials

Free Trial
Choose Your Free Trial
Select the software you'd like to try. Pick a product to see the details and start your trial.
(end of ) has defined window.__SEO_ROUTES AND // before ftSeoMeta exists, so on a deep-linked load it can only fall back to the // generic /freetrial meta — clobbering the per-product render.php served. // By the time we get here both are available, so this puts the right one back. if(typeof updateSEO==='function'){const s=ftSlugForIndex(i);if(s)updateSEO('freetrial/'+s);} // Drop render.php's crawler <article> only now that the real view is on screen — // removing it from the router would leave a gap while config is still loading. if(typeof seoRemoveServerArticle==='function')seoRemoveServerArticle(); // instant on a deep link / refresh: showPage already jumped to the top, and a second // smooth scroll from there just jitters. if(!instant)window.scrollTo({top:0,behavior:'smooth'}); }; window.ftBackToSelect=function(){ const sel=document.getElementById('ft-select'),det=document.getElementById('ft-detail'); if(sel)sel.style.display=''; if(det)det.style.display='none'; ftCurrentIndex=-1; }; // The Back button is a real route change (so browser Back/Forward and the URL agree); // ftBackToSelect stays the pure view toggle that ftSyncFromRoute drives. window.ftBackToTrials=function(){navigate('freetrial');}; // ─── POPUP MANAGEMENT ─── window.renderPopup=function(){ if(!config.popup)config.popup={enabled:'yes',delay:800,frequency:'day',image:'',alt:'',heading:'',desc:'',btnText:'',btnLink:'',returning:{}}; const p=config.popup; document.getElementById('f_popupEnabled').value=p.enabled||'yes'; document.getElementById('f_popupDelay').value=p.delay||800; document.getElementById('f_popupFrequency').value=p.frequency||'day'; document.getElementById('f_popupImage').value=p.image||''; document.getElementById('f_popupAlt').value=p.alt||''; document.getElementById('f_popupHeading').value=p.heading||''; document.getElementById('f_popupDesc').value=p.desc||''; document.getElementById('f_popupBtnText').value=p.btnText||''; document.getElementById('f_popupBtnLink').value=p.btnLink||''; const pr=p.returning||{}; document.getElementById('f_popupRetHeading').value=pr.heading||''; document.getElementById('f_popupRetDesc').value=pr.desc||''; document.getElementById('f_popupRetBtnText').value=pr.btnText||''; document.getElementById('f_popupRetBtnLink').value=pr.btnLink||''; // Update preview admUpdatePopupPreview(); // Live preview: update preview as user types ['f_popupImage','f_popupHeading','f_popupDesc','f_popupBtnText'].forEach(function(id){ var el=document.getElementById(id); if(el)el.addEventListener('input',admUpdatePopupPreview); }); }; function admUpdatePopupPreview(){ const prev=document.getElementById('popupPreview');if(!prev)return; const img=document.getElementById('f_popupImage').value; const heading=document.getElementById('f_popupHeading').value; const desc=document.getElementById('f_popupDesc').value; const btnText=document.getElementById('f_popupBtnText').value; if(!img&&!heading&&!desc){prev.innerHTML='<span style="color:#64748b">Configure above, then save to see preview</span>';return;} let html=''; if(img)html+='<img alt="" src="'+escAttr(img)+'" style="max-width:100%;border-radius:8px;margin-bottom:.75rem" onerror="this.style.display=\'none\'"/>'; if(heading)html+='<h3 style="color:#e2e8f4;margin-bottom:.5rem">'+escHtml(heading)+'</h3>'; if(desc)html+='<p style="color:#94a3b8;font-size:14px;margin-bottom:.5rem">'+escHtml(desc)+'</p>'; if(btnText)html+='<div style="margin-top:.5rem"><span style="background:var(--grad);color:#fff;padding:6px 16px;border-radius:6px;font-size:13px;font-weight:600">'+escHtml(btnText)+'</span></div>'; prev.innerHTML=html; } window.admUploadPopupImg=function(evt){ const file=evt.target.files[0];if(!file)return; uploadMediaFile(file,'media',function(url){ document.getElementById('f_popupImage').value=url; admUpdatePopupPreview(); admToast('✓ Image uploaded'); }); }; window.admSavePopup=function(){ config.popup={ enabled:document.getElementById('f_popupEnabled').value, delay:parseInt(document.getElementById('f_popupDelay').value)||800, frequency:document.getElementById('f_popupFrequency').value||'day', image:document.getElementById('f_popupImage').value, alt:document.getElementById('f_popupAlt').value, heading:document.getElementById('f_popupHeading').value, desc:document.getElementById('f_popupDesc').value, btnText:document.getElementById('f_popupBtnText').value, btnLink:document.getElementById('f_popupBtnLink').value, returning:{ heading:document.getElementById('f_popupRetHeading').value, desc:document.getElementById('f_popupRetDesc').value, btnText:document.getElementById('f_popupRetBtnText').value, btnLink:document.getElementById('f_popupRetBtnLink').value } }; saveConfig();applyPopupLive(); admToast('✓ Popup settings saved!'); }; window.admPreviewPopup=function(){ admSavePopup(); const overlay=document.getElementById('popupOverlay'); if(overlay){ // Remove loading class from card if present const card=document.getElementById('popupCard'); if(card)card.classList.remove('loading'); overlay.classList.add('active'); document.body.style.overflow='hidden'; } }; function applyPopupLive(){ const p=config.popup;if(!p)return; const card=document.getElementById('popupCard');if(!card)return; let html='<div id="popupClose"><svg viewBox="0 0 24 24" fill="none"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></div>'; if(p.image)html+='<img src="'+escAttr(p.image)+'" alt="'+escAttr(p.alt||'')+'" draggable="false" onload="this.parentElement.classList.remove(\'loading\')" onerror="this.parentElement.classList.remove(\'loading\')"/>'; if(p.heading||p.desc||p.btnText){ html+='<div style="padding:1.5rem 2rem;background:var(--bg2)">'; if(p.heading)html+='<h3 style="color:#e2e8f4;font-size:20px;font-weight:700;margin-bottom:.5rem">'+escHtml(p.heading)+'</h3>'; if(p.desc)html+='<p style="color:#94a3b8;font-size:14px;line-height:1.6;margin-bottom:.75rem">'+escHtml(p.desc)+'</p>'; if(p.btnText)html+='<a href="'+escAttr(p.btnLink||'#')+'" style="display:inline-block;background:var(--popup-cta-bg,var(--grad));color:#fff;padding:10px 24px;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;cursor:pointer" onclick="document.getElementById(\'popupOverlay\').classList.remove(\'active\');document.body.style.overflow=\'\'">'+escHtml(p.btnText)+'</a>'; html+='</div>'; } html+='<div id="popupHint">Click anywhere to close</div>'; card.innerHTML=html; // Re-bind card stopPropagation (innerHTML wipes old listeners on children but card listener persists) // Re-bind close button const closeBtn=document.getElementById('popupClose'); if(closeBtn)closeBtn.addEventListener('click',function(e){e.stopPropagation();document.getElementById('popupOverlay').classList.remove('active');document.body.style.overflow='';}); } function applyAboutLive(){ const ap=config.aboutPage;if(!ap)return; const aboutPage=document.getElementById('page-about'); if(!aboutPage)return; // Update hero title and description const heroH1=aboutPage.querySelector('.page-hero h1'); if(heroH1){ const heroTitle=ap.heroTitle||'Oracubes Technologies'; heroH1.innerHTML='About <span class="grad-text" data-edit="aboutPage.heroTitle">'+veRichHtml(heroTitle)+'</span>'; } const heroP=aboutPage.querySelector('.page-hero p'); if(heroP){ const hd=ap.heroDesc||'An ERP software company based in Sialkot, Pakistan — delivering world-class digital solutions since 2003.'; heroP.innerHTML=veRichHtml(hd); } // Update paragraphs const paras=aboutPage.querySelectorAll('section:first-of-type p[style]'); if(paras.length>=3){ [ap.p1||'',ap.p2||'',ap.p3||''].forEach((v,idx)=>{ paras[idx].innerHTML=veRichHtml(v); }); } // Update stats const statBoxes=aboutPage.querySelectorAll('.stat-box'); (ap.stats||[]).forEach((s,i)=>{ if(statBoxes[i]){ const n=statBoxes[i].querySelector('.stat-n'); const l=statBoxes[i].querySelector('.stat-l'); if(n)n.innerHTML=veRichHtml(s.number); if(l)l.innerHTML=veRichHtml(s.label); } }); // Update why cards const whySection=aboutPage.querySelector('#why .cards-grid'); if(whySection&&ap.whyCards&&ap.whyCards.length){ whySection.innerHTML=''; const colorClasses=['ci-blue','ci-cyan','ci-purple','ci-green','ci-orange','ci-red']; ap.whyCards.forEach((wc,i)=>{ const card=document.createElement('div');card.className='card'; card.setAttribute('data-edit-item','aboutPage.whyCards.'+i); card.innerHTML='<div class="card-icon '+colorClasses[i%colorClasses.length]+'" data-edit="aboutPage.whyCards.'+i+'.icon">'+wc.icon+'</div><h3 data-edit="aboutPage.whyCards.'+i+'.title">'+veRichHtml(wc.title)+'</h3><p data-edit="aboutPage.whyCards.'+i+'.desc" data-edit-multiline>'+veRichHtml(wc.desc)+'</p>'; whySection.appendChild(card); }); } // Update team const teamGrids=aboutPage.querySelectorAll('.team-grid'); if(teamGrids.length&&ap.team&&ap.team.length){ const depts={Leadership:[],Technical:[],Consulting:[]}; ap.team.forEach(t=>{ const d=t.dept||'Technical'; if(!depts[d])depts[d]=[]; depts[d].push(t); }); const grids=[...teamGrids]; const deptKeys=['Leadership','Technical','Consulting']; deptKeys.forEach((key,idx)=>{ if(grids[idx]){ grids[idx].innerHTML=''; (depts[key]||[]).forEach(t=>{ // Keyed by the person's stable id, not their array position — a saved drag // position/resize/edit must follow the PERSON through Move up/down/Delete, // not silently transfer to whoever ends up at the same index afterward. const tid=t.id; const card=document.createElement('div');card.className='team-card'; card.setAttribute('data-edit-item','aboutPage.team.#'+tid); const avatarContent=t.image?'<img src="'+escAttr(t.image)+'" alt="'+escAttr(veStripTags(t.name))+'"/>'+escHtml(t.initials):escHtml(t.initials); card.innerHTML='<div class="avatar" data-mov="team-avatar-'+tid+'" style="background:linear-gradient(135deg,#c8202e,#8f1620)">'+avatarContent+'</div><h4 data-edit="aboutPage.team.#'+tid+'.name">'+veRichHtml(t.name)+'</h4><span data-edit="aboutPage.team.#'+tid+'.role">'+veRichHtml(t.role)+'</span>'; grids[idx].appendChild(card); }); } }); } } // ═══════════════════════════════════════════════════════════ // VISUAL EDITOR — CORE // Config path helpers, override/resize/ticker appliers, section // registry and the static-content auto-stamper. The UI shell // (toolbar, context menu, item editor) is in the VISUAL EDITOR UI // block below. Everything is inert until edit mode is entered. // ═══════════════════════════════════════════════════════════ // Expose the live in-memory config (read-only use) for scripts outside this // IIFE — the portfolio page IIFE uses it so unsaved edits preview correctly. window.getSiteConfig=function(){return config;}; // Dot-path resolver over `config`. Segments: plain keys, numeric indexes // ("services.2.title") and "#<id>" array lookups ("blogs.#3.title"). #id paths // are required for arrays rendered as sorted/filtered copies (blogs, portfolio) // where positional indexes don't match the DOM order. function _cfgSeg(parent,seg){ if(seg.charAt(0)==='#'&&Array.isArray(parent)){ const id=seg.slice(1); return parent.findIndex(x=>x&&String(x.id)===id); } return seg; } function cfgGet(path){ let cur=config; const parts=String(path).split('.'); for(let i=0;i<parts.length;i++){ if(cur==null)return undefined; const seg=_cfgSeg(cur,parts[i]); if(seg===-1)return undefined; cur=cur[seg]; } return cur; } function cfgSet(path,value){ const parts=String(path).split('.'); let cur=config; for(let i=0;i<parts.length-1;i++){ const seg=_cfgSeg(cur,parts[i]); if(seg===-1)return false; if(cur[seg]==null||typeof cur[seg]!=='object')cur[seg]=(/^\d+$/.test(parts[i+1])?[]:{}); cur=cur[seg]; } const last=_cfgSeg(cur,parts[parts.length-1]); if(last===-1)return false; cur[last]=value; return true; } function cfgDelete(path){ const parts=String(path).split('.'); let cur=config; for(let i=0;i<parts.length-1;i++){ const seg=_cfgSeg(cur,parts[i]); if(seg===-1||cur[seg]==null)return false; cur=cur[seg]; } const last=_cfgSeg(cur,parts[parts.length-1]); if(last===-1)return false; if(Array.isArray(cur))cur.splice(+last,1);else delete cur[last]; return true; } // Strip anything executable from override HTML. Runs at APPLY time (not only // when saved): save-config.php accepts unauthenticated writes, so a hostile // config must not become stored XSS through this innerHTML path. // style="" is whitelisted to ONLY color/font-size, ONLY on <span> — these are the // sole two properties the per-run text-formatting toolbar ever writes. Everything // else (expression(), url(), position:fixed, etc.) is dropped outright, not merely // escaped, since save-config.php performs zero schema validation — this is the only // gate between a hostile config and CSS injection. Keep VE_RUN_COLOR_RE/VE_RUN_SIZE_RE // in sync with the span-builder in the selection-formatting code (veRunApply) — the // write side and this read side must accept exactly the same grammar or a user's own // formatting silently vanishes the next time this function runs. const VE_RUN_COLOR_RE=/^#[0-9a-f]{6}$/i; const VE_RUN_SIZE_RE=/^([1-9][0-9]{0,2})px$/; // Bold/italic/underline normally arrive as <b>/<i>/<u> TAGS (veStartInlineEdit forces // execCommand's styleWithCSS off), which pass through untouched — this function strips // a fixed tag list, not unknown tags. These three properties are needed for the OTHER // direction: turning bold OFF inside text that is already bold from CSS (any heading) // makes the browser emit <span style="font-weight:normal">, and without whitelisting it // here that span was stripped and the un-bold silently reverted on the next render. // Enumerated keywords only — no url()/expression() can pass, so the CSS-injection gate // described above is unchanged. const VE_RUN_WEIGHT_RE=/^(normal|bold|bolder|lighter|[1-9]00)$/i; const VE_RUN_FSTYLE_RE=/^(normal|italic|oblique)$/i; const VE_RUN_DECOR_RE=/^(none|underline|line-through|overline|underline line-through)$/i; function veSanitizeHtml(html){ const tpl=document.createElement('template'); tpl.innerHTML=String(html==null?'':html); tpl.content.querySelectorAll('script,style,iframe,object,embed,link,meta,form').forEach(n=>n.remove()); tpl.content.querySelectorAll('*').forEach(el=>{ [...el.attributes].forEach(a=>{ const n=a.name.toLowerCase(),v=String(a.value||''); if(n.indexOf('on')===0||((n==='href'||n==='src'||n==='xlink:href')&&/^\s*javascript:/i.test(v))){ el.removeAttribute(a.name); }else if(n==='style'){ if(el.tagName!=='SPAN'){el.removeAttribute('style');return;} const kept=[]; v.split(';').forEach(decl=>{ const m=/^\s*([a-z-]+)\s*:\s*([^;]+?)\s*$/i.exec(decl); if(!m)return; const prop=m[1].toLowerCase(),val=m[2].trim(); if(prop==='color'&&VE_RUN_COLOR_RE.test(val))kept.push('color:'+val.toLowerCase()); else if(prop==='font-size'&&VE_RUN_SIZE_RE.test(val))kept.push('font-size:'+val); else if(prop==='font-weight'&&VE_RUN_WEIGHT_RE.test(val))kept.push('font-weight:'+val.toLowerCase()); else if(prop==='font-style'&&VE_RUN_FSTYLE_RE.test(val))kept.push('font-style:'+val.toLowerCase()); else if((prop==='text-decoration'||prop==='text-decoration-line')&&VE_RUN_DECOR_RE.test(val)) kept.push(prop+':'+val.toLowerCase()); }); if(kept.length)el.setAttribute('style',kept.join(';')); else el.removeAttribute('style'); } }); }); return tpl.innerHTML; } // Distinguishes "plain string, never touched by rich editing" from "contains real // markup" for path-bound fields that may hold either, depending on whether a user // has ever formatted a run in them. Used at render time to pick escape-and-print // (old behavior, byte-identical for untouched data) vs sanitize-and-inject-raw-HTML. function veHasMarkup(v){return /<[a-z][\s\S]*>/i.test(String(v||''));} // Render a config text value as an element's CONTENT. Every place that writes the // inner text of a [data-edit]/[data-edit-static] element must go through this, or a // styled run would print as literal <span …> tags. function veRichHtml(v){return veHasMarkup(v)?veSanitizeHtml(v):escHtml(v);} // The inverse, for every place a value is consumed as PLAIN text — attributes // (alt/title), document.title, URL slugs, the search index, outbound email payloads, // and plain <input> editors. Cheap no-op for the untouched-plain-string case. function veStripTags(v){ if(!veHasMarkup(v))return String(v==null?'':v); const t=document.createElement('template'); t.innerHTML=veSanitizeHtml(v); return (t.content.textContent||'').replace(/\s+/g,' ').trim(); } // Populate a plain <input>/<textarea> editor from a possibly-rich value. Deliberately // NOT veStripTags: that one collapses all whitespace (right for a <title>/alt=/slug, // wrong for a textarea) — a styled FAQ answer must keep its paragraph breaks, so // <br>/</div>/</p> become real newlines first, exactly as veReadText does on the way in. function veEdOut(v){ if(!veHasMarkup(v))return String(v==null?'':v); const t=document.createElement('div'); t.innerHTML=String(veSanitizeHtml(v)) .replace(/<br\s*\/?>/gi,'\n') .replace(/<\/div>\s*<div[^>]*>/gi,'\n') .replace(/<\/p>\s*<p[^>]*>/gi,'\n'); return t.textContent.replace(/\u00a0/g,' ').replace(/\n{3,}/g,'\n\n').trim(); } // …and read it back. Untouched box → keep the rich original; genuinely retyped → // plain text replaces it, which is the honest meaning of typing into a plain box. // Without this pairing, merely opening and saving an editor would wipe the styling. // MUST compare through veEdOut (not veStripTags) — the same transform that filled // the box, or "untouched" would never match for anything containing a line break. function veEdIn(prev,next){return veEdOut(prev)===next?prev:next;} // Hardcoded-text overrides. Originals are captured on first sight so "Reset to // original" works in-session without a reload. const VE_STATIC_DEFAULTS={}; function applyOverridesLive(){ const o=config.contentOverrides||{}; document.querySelectorAll('[data-edit-static]').forEach(el=>{ const k=el.getAttribute('data-edit-static'); if(!(k in VE_STATIC_DEFAULTS))VE_STATIC_DEFAULTS[k]=el.innerHTML; if(o[k]!=null){ el.innerHTML=veSanitizeHtml(o[k]); el.dataset.veOverridden='1'; }else if(el.dataset.veOverridden==='1'){ el.innerHTML=VE_STATIC_DEFAULTS[k]; delete el.dataset.veOverridden; } }); } // Resize layer: per-element font-size / width, keyed by data-edit / // data-edit-static stamps. Runs LAST after every rebuild. function applyElementStylesLive(){ const styles=config.elementStyles||{}; document.querySelectorAll('[data-ve-styled]').forEach(el=>{ const k=el.getAttribute('data-edit')||el.getAttribute('data-edit-static'); if(!k||!styles[k]){ el.style.fontSize='';el.style.width='';el.style.maxWidth=''; if(el.tagName==='IMG')el.style.height=''; el.removeAttribute('data-ve-styled'); } }); Object.keys(styles).forEach(k=>{ const s=styles[k]||{}; let els; try{ els=document.querySelectorAll('[data-edit="'+CSS.escape(k)+'"],[data-edit-static="'+CSS.escape(k)+'"]'); }catch(e){return;} els.forEach(el=>{ el.style.fontSize=s.fontSize||''; if(s.width){ el.style.width=s.width;el.style.maxWidth='100%'; if(el.tagName==='IMG')el.style.height='auto'; }else{ el.style.width='';el.style.maxWidth=''; if(el.tagName==='IMG')el.style.height=''; } if(s.fontSize||s.width)el.setAttribute('data-ve-styled','1'); }); }); } // Ticker marquee is config-driven: rebuild the track (items twice for the // seamless -50% loop; editing either copy re-renders both). function applyTickerLive(){ const track=document.querySelector('.ticker-track'); const items=config.ticker||[]; if(!track||!items.length)return; let html=''; for(let copy=0;copy<2;copy++){ items.forEach((t,i)=>{ html+='<div class="t-item"><div class="t-dot"></div><span class="t-txt" data-edit="ticker.'+i+'">'+veRichHtml(t)+'</span></div>'; }); } track.innerHTML=html; } // Re-render whatever section a config path belongs to, then re-apply the // resize layer (rebuilds create fresh elements). function veApplyFor(path){ const root=String(path).split('.')[0]; switch(root){ case 'services':applyServicesLive();break; case 'pills':applyPillsLive();break; case 'faqs':applyFaqsLive();break; case 'testimonials':applyTestimonialsLive();break; case 'blogs':applyBlogsLive();break; case 'clients':applyClientsLive();break; case 'navItems':case 'navCta':applyNavLive();break; case 'navDropdowns':applyDropdownsLive();break; case 'industries':applyIndustriesLive();break; case 'trialOptions':applyTrialOptionsLive();break; case 'aboutPage':applyAboutLive();break; case 'buttonLabels':applyButtonsLive();break; case 'portfolio':if(window.renderPortfolioPage)window.renderPortfolioPage('all');break; case 'ticker':applyTickerLive();break; case 'contentOverrides':applyOverridesLive();break; case 'elementStyles':break; case 'elementPositions':break; case 'elementPositionsMobile':break; case 'hiddenElements':break; // veApplyPositions() (below) regenerates the hide CSS case 'elementSizes':break; // ditto — the size layer is pure CSS, no re-render needed case 'elementSizesMobile':break; case 'elementTextColors':break; // pure CSS layer — veApplyPositions() (tail) regenerates it default:applyLive(); } applyElementStylesLive(); veApplyPositions(); } // Popup-editor field schemas, resolved from an item's config path. const VE_SCHEMAS=[ {re:/^services\.\d+$/,label:'Service',fields:[{f:'icon',l:'Icon (emoji)',t:'text'},{f:'title',l:'Title',t:'text'},{f:'desc',l:'Description',t:'textarea'},{f:'tags',l:'Tags (comma separated)',t:'tags'}]}, {re:/^pills\.items\.\d+$/,label:'App pill',fields:[{f:'label',l:'Label',t:'text'},{f:'bg',l:'Background color',t:'color'},{f:'fg',l:'Text color',t:'color'}]}, {re:/^clients\.\d+$/,label:'Client logo',fields:[{f:'name',l:'Client name',t:'text'},{f:'image',l:'Logo image',t:'image'},{f:'url',l:'Website link (optional)',t:'text'},{f:'scale',l:'Logo size (0.5 – 3)',t:'number'}]}, {re:/^faqs\.\d+$/,label:'FAQ',fields:[{f:'q',l:'Question',t:'text'},{f:'a',l:'Answer',t:'textarea'}]}, {re:/^testimonials\.\d+$/,label:'Testimonial',fields:[{f:'name',l:'Name',t:'text'},{f:'company',l:'Company',t:'text'},{f:'location',l:'Location',t:'text'},{f:'rating',l:'Rating (1–5)',t:'number'},{f:'text',l:'Testimonial text',t:'textarea'},{f:'approved',l:'Approved (visible on site)',t:'checkbox'}]}, {re:/^industries\.\d+$/,label:'Industry',fields:[{f:'icon',l:'Icon (emoji)',t:'text'},{f:'title',l:'Title',t:'text'},{f:'desc',l:'Description',t:'textarea'}]}, {re:/^trialOptions\.\d+$/,label:'Trial product',fields:[{f:'icon',l:'Icon (emoji)',t:'text'},{f:'title',l:'Title',t:'text'},{f:'desc',l:'Card description',t:'textarea'},{f:'detail',l:'Detail text (blank lines = paragraphs)',t:'textarea'},{f:'image',l:'Detail image',t:'image'},{f:'trialUrl',l:'Trial link URL',t:'text'}]}, {re:/^aboutPage\.team\.#?[\w-]+$/,label:'Team member',fields:[{f:'initials',l:'Initials',t:'text'},{f:'name',l:'Name',t:'text'},{f:'role',l:'Role',t:'text'},{f:'dept',l:'Department',t:'select',opts:['Leadership','Technical','Consulting']},{f:'image',l:'Photo (optional)',t:'image'}]}, {re:/^aboutPage\.whyCards\.\d+$/,label:'Why-choose card',fields:[{f:'icon',l:'Icon (emoji)',t:'text'},{f:'title',l:'Title',t:'text'},{f:'desc',l:'Description',t:'textarea'}]}, {re:/^portfolio\.#?[\w-]+$/,label:'Portfolio project',fields:[{f:'title',l:'Title',t:'text'},{f:'client',l:'Client',t:'text'},{f:'category',l:'Category',t:'text'},{f:'image',l:'Cover image',t:'image'},{f:'tags',l:'Tags (comma separated)',t:'tags'},{f:'desc',l:'Description',t:'textarea'},{f:'url',l:'Project link (optional)',t:'text'},{f:'featured',l:'Featured',t:'checkbox'}]}, {re:/^navItems\.\d+$/,label:'Menu item',fields:[{f:'label',l:'Menu label',t:'text'},{f:'visible',l:'Visible in menu',t:'checkbox'}]}, {re:/^navDropdowns\.[\w-]+\.\d+$/,label:'Dropdown item',fields:[{f:'icon',l:'Icon (emoji or image URL)',t:'text'},{f:'title',l:'Title',t:'text'},{f:'sub',l:'Subtitle',t:'text'},{f:'href',l:'Link',t:'text'}]}, {re:/^navCta$/,label:'Nav button',fields:[{f:'text',l:'Button text',t:'text'},{f:'link',l:'Button link',t:'text'}]}, {re:/^contact$/,label:'Contact details',fields:[{f:'phone1',l:'Phone 1',t:'text'},{f:'phone2',l:'Phone 2',t:'text'},{f:'phone3',l:'Phone 3',t:'text'},{f:'email1',l:'Email 1',t:'text'},{f:'email2',l:'Email 2',t:'text'},{f:'address',l:'Address',t:'textarea'}]}, {re:/^social$/,label:'Social links',fields:[{f:'facebook',l:'Facebook URL',t:'text'},{f:'instagram',l:'Instagram URL',t:'text'},{f:'youtube',l:'YouTube URL',t:'text'},{f:'linkedin',l:'LinkedIn URL',t:'text'},{f:'twitter',l:'Twitter/X URL',t:'text'},{f:'whatsapp',l:'WhatsApp number (digits only)',t:'text'}]} ]; function veSchemaFor(path){ for(let i=0;i<VE_SCHEMAS.length;i++)if(VE_SCHEMAS[i].re.test(path))return VE_SCHEMAS[i]; return null; } // Defaults for "Add ⟨item⟩" per array path (data-edit-add values). const VE_ADD_DEFAULTS={ 'services':function(){return{icon:'🛠️',title:'New Service',desc:'Describe this service…',tags:[]};}, 'faqs':function(){return{q:'New question?',a:'Answer goes here.'};}, 'testimonials':function(){return{name:'Client Name',company:'',location:'',rating:5,text:'Great service!',approved:true};}, 'industries':function(){return{icon:'🏢',title:'New Industry',desc:'Describe this industry…'};}, 'trialOptions':function(){return{icon:'⭐',title:'New Product',desc:'Short description',detail:'Full detail text',image:'',trialUrl:''};}, 'clients':function(){return{name:'New client',image:'',url:''};}, 'aboutPage.team':function(){return{id:Date.now(),initials:'NM',name:'New Member',role:'Role',dept:'Technical'};}, 'aboutPage.whyCards':function(){return{icon:'⭐',title:'New Card',desc:'Description'};}, 'portfolio':function(){return{id:Date.now(),title:'New Project',client:'',category:'Web Development',image:'',tags:[],desc:'',url:'',featured:false};}, 'pills.items':function(){return{label:'New App',bg:'#7a5af8',fg:'#ffffff'};} }; // ── Static-content auto-stamper ── // Marks purely hardcoded text (page heroes, section headers, detail-page cards, // CTA strips, footer…) as data-edit-static so it becomes right-click editable // via config.contentOverrides. Keys derive from page id + section anchor + // element role + index — stable across loads because the static DOM order is // fixed. Skips anything config-driven. const STATIC_EDIT_SELECTORS=[ {sel:'.page-hero .eyebrow',name:'eyebrow'}, {sel:'.page-hero h1',name:'h1',rich:true}, {sel:'.page-hero p',name:'p',multiline:true}, {sel:'.home-intro h2',name:'h2',rich:true}, {sel:'.home-intro p',name:'p',multiline:true}, {sel:'.eyebrow',name:'eyebrow'}, {sel:'.sec-title',name:'title',rich:true}, {sel:'.sec-sub',name:'sub',multiline:true}, {sel:'.cards-grid .card .card-icon',name:'icon'}, {sel:'.cards-grid .card h3',name:'h3'}, {sel:'.cards-grid .card p',name:'p',multiline:true}, {sel:'.int-grid .int-card .i-icon',name:'icon'}, {sel:'.int-grid .int-card h3',name:'h3'}, {sel:'.int-grid .int-card p',name:'p',multiline:true}, {sel:'.cta-strip h2',name:'h2',rich:true}, {sel:'.cta-strip p',name:'p',multiline:true}, {sel:'.team-cat',name:'cat'}, {sel:'footer .footer-grid h4',name:'h4'}, {sel:'footer .f-desc',name:'desc',multiline:true}, {sel:'footer .footer-bottom span',name:'legal'}, {sel:'.c-lbl',name:'lbl'}, {sel:'.c-val',name:'val',rich:true} ]; function veStampStatics(){ const counters={}; STATIC_EDIT_SELECTORS.forEach(rule=>{ document.querySelectorAll(rule.sel).forEach(el=>{ if(el.hasAttribute('data-edit')||el.hasAttribute('data-edit-static'))return; if(el.hasAttribute('data-contact')||el.hasAttribute('data-btn'))return; if(el.closest('[data-edit]'))return; // inside a config-driven leaf if(el.closest('[data-edit-add]'))return; // inside a rebuilt grid if(el.closest('#adminRoot,#nav,#mobMenu,.dropdown,.ticker'))return; if(el.querySelector('[data-edit],[data-btn]'))return; // wraps config-driven content if(!(el.textContent||'').trim())return; const pg=el.closest('.page'); const pgKey=pg?pg.id.replace(/^page-/,''):(el.closest('footer')?'footer':'site'); const secEl=el.closest('section[id],.cta-strip,.page-hero,.home-intro,.footer-grid,.footer-bottom,section.sec'); let secKey='x'; if(secEl){ secKey=secEl.id||(secEl.className||'sec').split(' ')[0]; if(!secEl.id&&pg){ // disambiguate repeated anonymous sections within a page const cls=secKey.replace(/[^\w-]/g,''); const same=[...pg.querySelectorAll('.'+cls)]; if(same.length>1)secKey+=same.indexOf(secEl); } } const base=pgKey+'/'+secKey+'/'+rule.name; counters[base]=(counters[base]||0); const key=base+(counters[base]?counters[base]:''); counters[base]++; el.setAttribute('data-edit-static',key); if(rule.multiline)el.setAttribute('data-edit-multiline','1'); if(rule.rich)el.setAttribute('data-edit-rich','1'); }); }); } // ── Movable-element stamper + position layer ── // Every draggable unit gets a UNIQUE data-ve-mov key. Existing keys (data-edit // etc.) repeat across elements (company.logo is on 3), so an @index suffix // disambiguates instances. Keys are recomputed from scratch each pass in // document order — deterministic, and stable across loads because static DOM is // fixed and rebuilt grids render in config order. // Card/tile classes unlocked for free-drag per the "move any element" request. Named // classes only (see the counter-collision warning below). Static tiles (.int-card, // .vert-card) match no other movable selector, so they MUST be listed here to become // draggable; config-driven tiles already carry data-edit-item and are covered either way. // .team-card is here too — the whole card (avatar+name+role) is now one drag unit, // not just its individual children. const VE_CARD_SELECTOR='.card,.svc-card,.blog-card,.int-card,.vert-card,.port-page-card,.client-cell,.ft-card,.faq-item,.team-card'; const VE_MOV_SELECTOR='[data-edit],[data-edit-static],[data-edit-item],[data-btn],[data-mov],.soc,.c-ico,.avatar,.i-icon,.vert-icon,.card-icon,'+VE_CARD_SELECTOR+',img,button,a'; // Structural containers get a data-ve-mov key (sizable/hideable/movable) but are never // grabbed by a PLAIN press — see VE_ESCONLY_SELECTOR below, which is what actually // decides drag-ok vs escalation-only now. Named classes only — a blanket `div` could // collide with an existing `x:…/div.soc@N` base and shift the @index counters of // already-saved position keys. const VE_SIZE_SELECTOR='section,.sec,.card,.svc-card,.page-hero,.cta-strip,.footer-grid,[data-ve-size]'; // Named group/band wrappers: whole card grids, form rows, footer columns-by-block — // containers a admin would want to move as a unit but that must never be grabbed by a // stray press (see VE_ESCONLY_SELECTOR). [data-ve-group] is an escape hatch for // anonymous <div>s that carry none of these classes — mirrors [data-ve-size] above. // .train-scene (not .clients-grid) — mountTrain() replaces .clients-grid with // .train-scene at boot whenever config.clients is non-empty, which it always is. const VE_GROUP_SELECTOR='.team-grid,.team-group,.cards-grid,.stats-row,.stat-box,.about-split,.train-scene,.blog-grid,.int-grid,.vert-grid,.port-page-grid,.contact-grid,.btns,.faq-list,.c-block,.social-row,.form-row,.home-intro,.footer-bottom,.oc-prod,.oc-prod-mods,[data-ve-group]'; const VE_STAMP_SELECTOR=VE_MOV_SELECTOR+','+VE_SIZE_SELECTOR+','+VE_GROUP_SELECTOR; // Replaced media pins EXACTLY — a logo dragged to 120px must BE 120px, and it has no // text that could spill. Every OTHER element treats a stored height as a floor, not a // cap (see veBuildSizeCss): `height` never clips, so a pinned text container just // paints its text outside the border box and onto whatever sits below it. const VE_RZ_EXACT='img,svg,video,canvas,iframe'; // Escalation-only: gets a data-ve-mov key and IS movable (see the stamp gate below), // but never by a plain press — only via Alt+drag, the right-click "Move" chain, or the // panel's ▲ selector. A plain press on a section's blank padding must not throw the // whole band across the page. `translate`/margin reserves no space, so an offset band // paints over its neighbours (Float) or reflows them (Push) — either way this is a // deliberate, escalated action, never an accident. const VE_ESCONLY_SELECTOR='section,.sec,.page-hero,.cta-strip,.footer-grid,.nav-sub,.nav-cta,'+VE_GROUP_SELECTOR; // Ancestors that clip their children with overflow:hidden — dropping/nudging a child // past one of these edges hides it for visitors even though it may still look fine // while editing (see the edit-mode overflow release). Used to warn on drop and to // offer the persistent "Allow overflow here" opt-out in the context menu. const VE_CLIP_SELECTOR='.page-hero,.cta-strip,.stats-row,.avatar,.ticker'; // FROZEN — do not extend. Entries saved against these keys before containers were // unlockable were never meant to render (the old build out-specified them rather than // migrating the data); un-suppressing them now would visibly shift the live site for // every visitor. See the `v` flag in veBuildPosCss: anything dragged/nudged since this // change carries v:2 and is honoured regardless of this list. const VE_LEGACY_NODRAG='section,.sec,.page-hero,.cta-strip,.footer-grid,.team-card,.nav-sub,.nav-cta'; const VE_LEGACY_NODRAG_LIST=VE_LEGACY_NODRAG.split(','); const VE_RZ_EXACT_LIST=VE_RZ_EXACT.split(','); // Prefix every selector in a list with an attribute match, e.g. `.card[data-ve-mov="k"]`. // Raises specificity above the plain `[data-ve-mov="k"]` base rule, which is how these // overrides win without depending on source order. function veQualify(list,k){ return list.map(function(s){return s+'[data-ve-mov="'+k+'"]';}).join(','); } // A locked element can never take a position offset by any route (drag, menu, panel, // nudge, resize edge-pin). Driven by the `data-ve-drag-any` stamp, which every stamped // element carries — so in practice this is only ever true for a key whose element isn't // resolvable right now (stamp missing or stale after a rebuild raced the caller). function veMovIsLocked(key){ const el=veMoveElByKey(key); return !!(el&&!el.hasAttribute('data-ve-drag-any')); } const VE_MOV_EXCLUDE='#veToolbar,#veMenu,#veItemEditor,#veMediaPick,#veMovePanel,#veResizeBox,#veSelToolbar,#adminRoot,'+ '#mobMenu,.dropdown,.ticker,#pillsStage,.train-viewport,#oracubes-widget,.wa-float,'+ '#popupOverlay,#subscribeOverlay,#subscribeMenu,#blogReaderOverlay,#ocIntro'; function veStampMovables(){ const counters={}; document.querySelectorAll(VE_STAMP_SELECTOR).forEach(el=>{ if(el.closest(VE_MOV_EXCLUDE)){el.removeAttribute('data-ve-mov');el.removeAttribute('data-ve-drag-ok');el.removeAttribute('data-ve-drag-any');return;} // Two drag tiers. data-ve-drag-ok = grabbable by a plain press (innermost wins via // closest()). data-ve-drag-any = movable at all — every stamped element gets it, // including escalation-only bands/groups, which are reached via Alt+drag, the // right-click "Move" chain, or the panel's ▲ selector (see veMovTargetOf). The // context menu resolves its target through [data-ve-mov] regardless of either flag. if(el.matches(VE_MOV_SELECTOR)&&!el.matches(VE_ESCONLY_SELECTOR))el.setAttribute('data-ve-drag-ok','1'); else el.removeAttribute('data-ve-drag-ok'); el.setAttribute('data-ve-drag-any','1'); const dm=el.getAttribute('data-mov'), de=el.getAttribute('data-edit'),ds=el.getAttribute('data-edit-static'), di=el.getAttribute('data-edit-item'),db=el.getAttribute('data-btn'); let base; // data-mov is an authored, document-order-independent key (rock stable); // attribute keys (edit/static/item/btn) are also stable. Only elements with // none of these fall back to the synthesized positional key below. if(dm)base='m:'+dm; else if(de)base='e:'+de; else if(ds)base='s:'+ds; else if(di)base='i:'+di; else if(db)base='b:'+db; else{ // auto key, derivation mirrors veStampStatics const pg=el.closest('.page'); const pgKey=pg?pg.id.replace(/^page-/,''):(el.closest('footer')?'footer':(el.closest('#nav')?'nav':'site')); const secEl=el.closest('section[id],.cta-strip,.page-hero,.home-intro,.footer-grid,section.sec'); const secKey=secEl?(secEl.id||(secEl.className||'sec').split(' ')[0]):'x'; // Qualify with the element's first class so roles that share a tag (e.g. a // footer nav <a> vs an <a class="soc">) don't share a counter and shift each // other's @index. className can be an SVGAnimatedString — guard for a string. const cls=(el.className&&typeof el.className==='string')?el.className.trim().split(/\s+/)[0]:''; base='x:'+pgKey+'/'+secKey+'/'+el.tagName.toLowerCase()+(cls?'.'+cls:''); } counters[base]=counters[base]||0; el.setAttribute('data-ve-mov',base+'@'+counters[base]++); }); } // Breakpoint tiers. Desktop = ≥1025px (config.elementPositions); mobile/tablet = // ≤1024px (config.elementPositionsMobile). matchMedia matches the CSS split below // exactly, so the "tier being edited" always agrees with what the visitor sees. const VE_POS_MQ=matchMedia('(min-width:1025px)'); // Which map a drag/nudge/reset writes to, based on the editor's current width. function veActivePosMap(){ return VE_POS_MQ.matches?'elementPositions':'elementPositionsMobile'; } // Build the CSS rules for one tier's map (shared whitelist + zero-skip logic). // A stored entry can carry: x,y (offset), z (stacking), mode ('push'|undefined), // mt0 (push-mode's captured baseline margin-top — see the writers), ov (allow this // container to overflow its clipping ancestor), rel (this key needs position:relative // for z-index to apply), v (2 = written after containers were unlocked). // // rel/mt0 are DECIDED ONCE, at write time (by zSet()/veWritePos()), and only ever READ // here — never measured live in this function. Doing otherwise is self-referential: // this function's own output changes computed style, so a live probe here would read // back what a PREVIOUS pass of itself wrote, flip-flopping every other call (z-index // silently stopping every other commit; a push offset compounding every other drag). function veBuildPosCss(map){ const pos=config[map]||{}; let css=''; Object.keys(pos).forEach(k=>{ if(!/^[\w .,#/@\[\]:-]+$/.test(k))return; // whitelist (config can arrive from the open save endpoint) const p=pos[k]||{},x=Math.round(+p.x||0),y=Math.round(+p.y||0),z=Math.round(+p.z||0); if(!x&&!y&&!z&&!p.ov)return; let d=''; // Push mode: Y only, additive from the captured baseline (p.mt0) — never an // absolute overwrite, which would replace an authored `margin:0 auto` centering // rule outright. X always stays translate: there is no safe additive form for a // horizontal push on a centred/auto-margin block, so we don't attempt one. if(p.mode==='push'&&p.v){ d+='margin-top:'+(Math.round(+p.mt0||0)+y)+'px!important;'; if(x)d+='translate:'+x+'px 0;'; }else if(x||y)d+='translate:'+x+'px '+y+'px;'; // z-index is a no-op on a static element UNLESS it's a flex/grid item (the CSS // Grid/Flexbox spec makes z-index apply to items even without position — that case // needs nothing extra). Everything else needs position:relative, decided once at // write time and stored as p.rel — see zSet() at the "Stacking" menu actions. if(z&&p.v){ d+='z-index:'+z+'!important;'; if(p.rel)d+='position:relative!important;'; } if(p.ov&&p.v)d+='overflow:visible!important;'; if(d)css+='[data-ve-mov="'+k+'"]{'+d+'}'; // key is whitelisted; no " or \ possible // LEGACY SUPPRESSION — do not remove. Entries saved before containers were // unlockable (no `v`) were never intended to apply: the old build out-specified // them instead of migrating the data, and un-suppressing them on upgrade would // visibly shift a live page for visitors. Anything dragged/nudged/re-stacked since // carries v:2 and is honoured above; touching a dormant entry revives it that way. if(!p.v)css+=veQualify(VE_LEGACY_NODRAG_LIST,k)+'{translate:none!important}'; }); return css; } // Single write path for the position maps. Every drag/nudge/resize commits an offset // through here so none of them can silently blow away a sibling field (z/ov/mode/rel) // set by a DIFFERENT action — the pre-existing per-callsite bare overwrites/deletes did // exactly that. Drops the entry entirely only once nothing is left to express. function veWritePos(map,key,x,y){ config[map]=config[map]||{}; const cur=Object.assign({},config[map][key]||{}); cur.x=Math.round(x||0); cur.y=Math.round(y||0); cur.v=2; // Push mode's Y baseline is captured ONCE, the first time an offset is written while // in push mode — never re-measured later (see veBuildPosCss for why that would drift). if(cur.mode==='push'&&cur.mt0==null){ const el=veMoveElByKey(key); cur.mt0=el?(parseFloat(getComputedStyle(el).marginTop)||0):0; } if(!cur.x&&!cur.y&&!cur.z&&!cur.ov&&!cur.mode)delete config[map][key]; else config[map][key]=cur; } // Build the "deleted element" rules (all screen sizes — no @media). Keyed by the same // unique data-ve-mov stamps as positions, so any movable element can be removed. // Visitors (no .edit-mode) get display:none — truly gone, layout closes up. In the // editor the element stays laid-out but ghosted (opacity + red dash) and forced // pointer-events so it can always be right-clicked to Restore. function veBuildHiddenCss(){ const h=config.hiddenElements||{}; let css=''; Object.keys(h).forEach(k=>{ if(!h[k])return; if(!/^[\w .,#/@\[\]:-]+$/.test(k))return; // whitelist (config can arrive from the open save endpoint) css+='body:not(.edit-mode) [data-ve-mov="'+k+'"]{display:none!important}'; css+='body.edit-mode [data-ve-mov="'+k+'"]{opacity:.28!important;outline:1.5px dashed var(--a1,#c8202e)!important;outline-offset:2px;pointer-events:auto!important}'; }); return css; } // Which size map a resize writes to — same breakpoint split as positions, so the // tier being edited always matches what the visitor at that width sees. function veActiveSizeMap(){ return VE_POS_MQ.matches?'elementSizes':'elementSizesMobile'; } // Build the CSS rules for one tier's size map (window-style resize layer). Only the // axes actually stored are emitted, so a width-only resize leaves height to reflow. // Emitted as CSS rather than inline styles (the way elementStyles did it) so sizes // survive innerHTML rebuilds and can be media-gated per tier. function veBuildSizeCss(map){ const sz=config[map]||{}; let css=''; Object.keys(sz).forEach(k=>{ if(!/^[\w .,#/@\[\]:-]+$/.test(k))return; // whitelist (config can arrive from the open save endpoint) const s=sz[k]||{}; const w=Math.round(+s.w||0), h=Math.round(+s.h||0), fs=+s.fs||0; let d=''; // !important beats both authored CSS and any leftover elementStyles INLINE style // (an important author rule outranks a non-important inline one), so the legacy // layer needs no migration — it is simply out-specified. // Width is CAPPED to the parent track: a grid/flex item wider than its column // bleeds sideways over its neighbour. min-width:0 still lets it shrink freely. if(w>0)d+='width:'+w+'px!important;min-width:0!important;max-width:100%!important;'; // Height is a FLOOR, not a cap. `height` does not clip — a box pinned shorter than // its text paints that text outside the border and onto the row below, which is // what made resized cards overlap. min-height honours the dragged size while the // content is short and grows the box the moment it isn't, so overlap is impossible. if(h>0)d+='height:auto!important;min-height:'+h+'px!important;max-height:none!important;'; if(fs>0)d+='font-size:'+(Math.round(fs*100)/100)+'px!important;'; if(!d)return; if(w>0||h>0){ d+='box-sizing:border-box!important;'; // dragged px == the visible box if(s.inline)d+='display:inline-block!important;'; // width/height are no-ops on inline elements } css+='[data-ve-mov="'+k+'"]{'+d+'}'; // key is whitelisted; no " or \ possible // Replaced media opts back into an EXACT box: a logo dragged to 120px must be // 120px, it can never overflow a text row, and pinning both axes without // object-fit would squash it. Higher specificity than the rule above (0,1,1 vs // 0,1,0), so it wins on every property it repeats regardless of source order. // flex:0 0 auto lives here too — forbidding a flex item to shrink is precisely // what makes a text container overflow, so only exact-sized media should get it. let r=''; if(w>0)r+='max-width:none!important;'; if(h>0)r+='height:'+h+'px!important;min-height:0!important;'; if(w>0||h>0)r+='flex:0 0 auto!important;'; if(w>0&&h>0)r+='object-fit:contain!important;'; if(r)css+=veQualify(VE_RZ_EXACT_LIST,k)+'{'+r+'}'; }); return css; } // Text-color override layer — deliberately UNTIERED (unlike elementSizes/positions), // same reasoning as veBuildHiddenCss: color isn't inherently responsive, so one value // applies at every screen width. _isHex still accepts 3-digit hex here even though the // panel UI always writes 6-digit via normHex, in case a config was written by other // tooling (save-config.php does zero schema validation, so this check is load-bearing). function veBuildColorCss(){ const cols=config.elementTextColors||{}; let css=''; Object.keys(cols).forEach(k=>{ if(!/^[\w .,#/@\[\]:-]+$/.test(k))return; // whitelist (config can arrive from the open save endpoint) const v=cols[k]; if(!_isHex(v))return; // -webkit-text-fill-color rides along for the same reason as the span rule beside // .grad-text in the head CSS: on a clipped-fill element (any .grad-text eyebrow or // accent word, .stat-n, .oc-word) `color` alone is never painted. It cannot clobber // a per-run span inside this element — that span gets the property applied DIRECTLY // (head CSS), and a direct declaration always beats an inherited one, !important or // not — nor a nested .grad-text accent word, which keeps its own `transparent`. css+='[data-ve-mov="'+k+'"]{color:'+v+'!important;-webkit-text-fill-color:'+v+'!important}'; }); return css; } // Regenerate the position stylesheet. The @media wrappers gate each tier natively // — the desktop block never affects mobile and vice-versa, and crossing the // breakpoint on resize needs no JS. Positions survive innerHTML rebuilds // automatically; only the data-ve-mov stamps need re-applying. function veApplyPositions(){ veStampMovables(); // ALWAYS stamp — drag needs targets even with 0 saved positions const dCss=veBuildPosCss('elementPositions'), mCss=veBuildPosCss('elementPositionsMobile'); const dSz=veBuildSizeCss('elementSizes'), mSz=veBuildSizeCss('elementSizesMobile'); let out=''; if(dCss||dSz)out+='@media(min-width:1025px){'+dSz+dCss+'}'; if(mCss||mSz)out+='@media(max-width:1024px){'+mSz+mCss+'}'; out+=veBuildHiddenCss(); // deleted-element rules — folded in BEFORE the early return below out+=veBuildColorCss(); // text-color overrides — untiered, same as hidden-element rules let st=document.getElementById('vePosCss'); if(!st){ if(!out)return; st=document.createElement('style'); st.id='vePosCss'; document.head.appendChild(st); } st.textContent=out; } window.veApplyPositions=veApplyPositions; // for rebuilds in the earlier (non-IIFE) script block // ═══════════════════════════════════════════════════════════ // VISUAL EDITOR — UI SHELL (edit mode, toolbar, dirty/undo state) // ═══════════════════════════════════════════════════════════ const VE_FLAG='oracubes_edit_mode'; let veBaseline=null; // JSON snapshot of the last saved state (Discard target) let veDirty=false; let veUndoStack=[]; let veUiReady=false; let veActiveEdit=null; // element currently inline-editing let veSelRange=null; // cloned Range currently backing the floating selection toolbar function veIsEditMode(){return document.body.classList.contains('edit-mode');} function veSetDirty(d){ veDirty=d; const dot=document.getElementById('veDirtyDot'); if(dot)dot.style.display=d?'':'none'; const sb=document.getElementById('veSaveBtn'); if(sb)sb.classList.toggle('ve-attn',d); } // Single write path for every visual edit: snapshot → mutate → re-render → dirty. function veCommit(path,mutator){ if(veUndoStack.length>=30)veUndoStack.shift(); veUndoStack.push(JSON.stringify(config)); try{mutator();}catch(e){veUndoStack.pop();throw e;} veApplyFor(path); veSetDirty(true); } function veUndo(){ if(!veUndoStack.length){admToast('Nothing to undo');return;} config=migrateDesign({...DEFAULT_CONFIG,...JSON.parse(veUndoStack.pop())}); applyAndInit(); veSetDirty(JSON.stringify(config)!==veBaseline); admToast('↶ Undone'); } function veDiscard(){ if(!veDirty){admToast('No unsaved changes');return;} if(!confirm('Discard all unsaved changes?'))return; config=migrateDesign({...DEFAULT_CONFIG,...JSON.parse(veBaseline)}); veUndoStack=[]; applyAndInit(); veSetDirty(false); admToast('Changes discarded'); } function veSave(){ saveConfig(); // veOnSaved hook (below) resets baseline + dirty admToast('✓ Saved — the live site will show your changes'); } // Called at the end of saveConfig() — ANY save path (visual Save button, // Design Studio auto-save, popup/password pages) resets the editor baseline // so Discard/dirty stay truthful. window.veOnSaved=function(){ veBaseline=JSON.stringify(config); veSetDirty(false); veUndoStack=[]; }; window.veEnterEditMode=function(){ try{sessionStorage.setItem(VE_FLAG,'1');}catch(e){} veInitUi(); document.body.classList.add('edit-mode'); document.getElementById('adminRoot').classList.remove('open'); // Dismiss the marketing popup if it opened before login (hash navigation // doesn't reload the page, so an already-open popup would block editing). const pov=document.getElementById('popupOverlay'); if(pov&&pov.classList.contains('active')){pov.classList.remove('active');document.body.style.overflow='';} if(location.hash==='#admin'||location.hash==='#/admin')navigate('home',null); veBaseline=JSON.stringify(config); veSetDirty(false); // Pills: force the static pile so pills are right-clickable, not physics bodies window.veStaticPills=true; if(window.pillsSection){window.pillsSection.destroy();window.pillsSection.onPageShow();} if(typeof applyCursorLive==='function')applyCursorLive(); // native cursor while editing veSyncToolbar(); }; window.veExitEditMode=function(){ if(veDirty&&!confirm('You have unsaved changes. Exit the editor and lose them?'))return; if(veDirty){ // user chose to exit: restore last saved state config=migrateDesign({...DEFAULT_CONFIG,...JSON.parse(veBaseline)}); applyAndInit(); } try{sessionStorage.removeItem(VE_FLAG);}catch(e){} document.body.classList.remove('edit-mode'); veSetDirty(false); veUndoStack=[]; veCloseMenu();veCloseItemEditor();veSelToolbarHide(); if(typeof veExitMoveMode==='function')veExitMoveMode(); // close nudge panel, clear selection veHideGuides(); const bb=document.getElementById('veMoveBadge'); if(bb)bb.style.display='none'; window.veStaticPills=false; if(window.pillsSection){window.pillsSection.destroy();window.pillsSection.onPageShow();} if(typeof applyCursorLive==='function')applyCursorLive(); // restore custom cursor veSyncToolbar(); }; // Toolbar/menus must vanish while the classic dashboard overlay is open // (their z-index beats #adminRoot's) and when edit mode is off. function veSyncToolbar(){ const tb=document.getElementById('veToolbar'); if(!tb)return; const adminOpen=document.getElementById('adminRoot').classList.contains('open'); const show=veIsEditMode()&&!adminOpen; tb.style.display=show?'':'none'; const mn=document.getElementById('veMobNote'); if(mn)mn.style.visibility=show?'':'hidden'; if(adminOpen){veCloseMenu();veCloseItemEditor();veSelToolbarHide();} } // Deferred a tick: this listener registers before checkHash's, and it must read // the #adminRoot open/closed state AFTER checkHash has updated it. window.addEventListener('hashchange',function(){setTimeout(veSyncToolbar,0);}); function veInitUi(){ if(veUiReady)return; veUiReady=true; // ── CSS (injected so the whole editor lives in one code region) ── const css=document.createElement('style'); css.id='veCss'; css.textContent= 'body.edit-mode [data-edit]:hover,body.edit-mode [data-edit-static]:hover{outline:1.5px dashed var(--a1,#c8202e);outline-offset:2px;cursor:context-menu}'+ 'body.edit-mode [data-edit-item]:hover{outline:1.5px solid rgba(200,32,46,.5);outline-offset:3px}'+ 'body.edit-mode [contenteditable]{outline:2px solid var(--a1,#c8202e)!important;outline-offset:2px;cursor:text;min-width:20px}'+ // Movable affordance: after the hover rules above so the grab cursor wins by // source order; :not([contenteditable]) so it never fights inline-edit's text cursor. 'body.edit-mode [data-ve-mov]:not([contenteditable]):hover{cursor:grab;outline:1.5px dashed rgba(var(--a1-rgb),.55);outline-offset:2px}'+ 'body.edit-mode.ve-dragging [data-ve-mov]:hover{outline:none}'+ 'body.edit-mode .ticker-track{animation-play-state:paused}'+ 'body.edit-mode .train,body.edit-mode .wheel,body.edit-mode .smoke,body.edit-mode .loco-rod{animation-play-state:paused}'+ 'body.edit-mode .mik-rotator{min-height:0;flex-direction:column;display:flex;gap:.35rem}'+ 'body.edit-mode .mik-text-slide{position:static;opacity:1;transform:none}'+ 'body.edit-mode .mik-hero-content{pointer-events:auto}'+ // hero text is pointer-events:none for visitors // Dragging a child past its clipping ancestor's edge makes it look deleted rather // than just cropped. Released ONLY where the clip exists purely to mask a // decorative ::before gradient (.page-hero/.cta-strip) — .stats-row and similar // rely on their own overflow:hidden to round grid-line corners, so releasing it // there would make the editor look visibly different from what visitors see; those // instead get the drop-time warning + the per-container "Allow overflow" opt-in. 'body.edit-mode .page-hero,body.edit-mode .cta-strip{overflow:visible!important}'+ 'body.edit-mode #oracubes-widget{display:none!important}'+ 'body.edit-mode{cursor:auto}'+ '#veToolbar{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:99990;display:flex;gap:6px;align-items:center;background:#10141f;border:1px solid rgba(255,255,255,.14);border-radius:999px;padding:8px 14px;box-shadow:0 12px 40px rgba(0,0,0,.45);font-family:var(--font,sans-serif)}'+ '#veToolbar button{background:transparent;border:0;color:#dbe3f0;font-size:13px;font-weight:600;padding:7px 12px;border-radius:999px;cursor:pointer;white-space:nowrap}'+ '#veToolbar button:hover{background:rgba(255,255,255,.08)}'+ '#veToolbar #veSaveBtn{background:var(--a1,#c8202e);color:#fff}'+ '#veToolbar #veSaveBtn.ve-attn{box-shadow:0 0 0 3px rgba(200,32,46,.35);animation:vePulse 1.6s ease infinite}'+ '@keyframes vePulse{50%{box-shadow:0 0 0 6px rgba(200,32,46,.15)}}'+ '#veDirtyDot{width:9px;height:9px;border-radius:50%;background:#fbbf24;box-shadow:0 0 8px #fbbf24}'+ '#veToolbar .ve-sep{width:1px;height:20px;background:rgba(255,255,255,.15)}'+ '#veMenu{position:fixed;z-index:99999;min-width:210px;background:#151a26;border:1px solid rgba(255,255,255,.14);border-radius:12px;padding:6px;box-shadow:0 16px 50px rgba(0,0,0,.5);display:none;font-family:var(--font,sans-serif)}'+ '#veMenu .ve-mi{display:flex;align-items:center;gap:9px;padding:8px 11px;border-radius:8px;color:#dbe3f0;font-size:13px;cursor:pointer;user-select:none}'+ '#veMenu .ve-mi:hover,#veMenu .ve-mi.focus{background:rgba(200,32,46,.18)}'+ '#veMenu .ve-mi.danger{color:#f87171}'+ '#veMenu .ve-mi .ve-ico{width:18px;text-align:center}'+ '#veMenu .ve-hd{padding:5px 11px 4px;font-size:10px;letter-spacing:1.4px;text-transform:uppercase;color:#7c8698}'+ '#veMenu .ve-div{height:1px;background:rgba(255,255,255,.09);margin:5px 4px}'+ '#veItemEditor{position:fixed;top:70px;right:18px;z-index:99995;width:380px;max-width:calc(100vw - 24px);max-height:calc(100vh - 100px);overflow:auto;background:#131826;border:1px solid rgba(255,255,255,.14);border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.55);display:none;font-family:var(--font,sans-serif);color:#dbe3f0}'+ '#veItemEditor .ve-ie-head{display:flex;align-items:center;justify-content:space-between;padding:13px 16px;border-bottom:1px solid rgba(255,255,255,.1);cursor:move;font-weight:700;font-size:14px}'+ '#veItemEditor .ve-ie-head button{background:transparent;border:0;color:#8a93a6;font-size:17px;cursor:pointer}'+ '#veItemEditor .ve-ie-body{padding:14px 16px;display:flex;flex-direction:column;gap:11px}'+ '#veItemEditor label{display:block;font-size:11px;letter-spacing:.6px;text-transform:uppercase;color:#8a93a6;margin-bottom:4px}'+ '#veItemEditor input[type=text],#veItemEditor input[type=number],#veItemEditor textarea,#veItemEditor select{width:100%;background:#0c101a;border:1px solid rgba(255,255,255,.12);border-radius:8px;color:#e7ecf5;padding:9px 11px;font-size:13px;font-family:inherit}'+ '#veItemEditor textarea{min-height:90px;resize:vertical}'+ '#veItemEditor input[type=color]{width:46px;height:32px;border:1px solid rgba(255,255,255,.12);border-radius:8px;background:#0c101a;padding:2px}'+ '#veItemEditor .ve-ie-foot{display:flex;gap:8px;justify-content:flex-end;padding:12px 16px;border-top:1px solid rgba(255,255,255,.1)}'+ '#veItemEditor .ve-ie-foot button{border:0;border-radius:8px;padding:9px 16px;font-size:13px;font-weight:600;cursor:pointer}'+ '#veItemEditor .ve-btn-save{background:var(--a1,#c8202e);color:#fff}'+ '#veItemEditor .ve-btn-cancel{background:rgba(255,255,255,.08);color:#dbe3f0}'+ '#veItemEditor .ve-img-row{display:flex;gap:10px;align-items:center}'+ '#veItemEditor .ve-img-prev{width:74px;height:52px;border-radius:8px;background:#0c101a center/cover no-repeat;border:1px solid rgba(255,255,255,.12);flex:none}'+ '#veItemEditor .ve-img-btns{display:flex;flex-direction:column;gap:6px;flex:1}'+ '#veItemEditor .ve-img-btns button{background:rgba(255,255,255,.08);border:0;border-radius:7px;color:#dbe3f0;font-size:12px;padding:7px;cursor:pointer}'+ '#veMediaPick{position:fixed;inset:0;z-index:99998;background:rgba(5,8,14,.72);display:none;align-items:center;justify-content:center}'+ '#veMediaPick .ve-mp-card{width:640px;max-width:92vw;max-height:80vh;overflow:auto;background:#131826;border:1px solid rgba(255,255,255,.14);border-radius:14px;padding:18px;color:#dbe3f0;font-family:var(--font,sans-serif)}'+ '#veMediaPick .ve-mp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px;margin-top:12px}'+ '#veMediaPick .ve-mp-tile{height:82px;border-radius:9px;background:#0c101a center/cover no-repeat;border:1px solid rgba(255,255,255,.12);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px;color:#8a93a6}'+ '#veMediaPick .ve-mp-tile:hover{border-color:var(--a1,#c8202e)}'+ '#veMobNote{display:none;position:fixed;left:50%;transform:translateX(-50%);bottom:74px;z-index:99989;background:rgba(16,20,31,.95);border:1px solid rgba(255,255,255,.14);border-radius:10px;color:#cbd5e1;font-size:12px;padding:7px 12px;font-family:var(--font,sans-serif);white-space:nowrap}'+ 'body.ve-dragging{user-select:none!important;cursor:grabbing!important}'+ 'body.ve-dragging *{cursor:grabbing!important}'+ 'body.ve-dragging [data-ve-drag]{outline:2px dashed var(--a1,#c8202e);outline-offset:3px;opacity:.85;will-change:transform}'+ // ── Window-style resize overlay ── // A FIXED overlay tracking the target's rect, never handles injected into the // element: the site rebuilds sections with innerHTML constantly, which would wipe // injected handles and pollute veReadText/textContent reads. // z-index sits above .ve-guide (99988) and below #veMoveBadge (99991). // The box itself is click-through; only the 8 handles take pointer events. // z-index sits ABOVE #veMovePanel (99996): a full-width element's E/NE/SE handles // land on top of the panel and there is no margin to move it into, so the handles // have to win. The box is click-through, so only the 8 small handles ever shadow // the panel's own controls. '#veResizeBox{position:fixed;z-index:99997;display:none;pointer-events:none;box-sizing:border-box;border:1.5px solid var(--a1,#c8202e);box-shadow:0 0 0 1px rgba(255,255,255,.35)}'+ '#veResizeBox .ve-rh{position:absolute;width:12px;height:12px;background:#fff;border:1.5px solid var(--a1,#c8202e);border-radius:2px;pointer-events:auto;box-shadow:0 1px 4px rgba(0,0,0,.45)}'+ '#veResizeBox .ve-rh:hover{background:var(--a1,#c8202e)}'+ // Corners '#veResizeBox .ve-rh-nw{left:-7px;top:-7px;cursor:nwse-resize}'+ '#veResizeBox .ve-rh-ne{right:-7px;top:-7px;cursor:nesw-resize}'+ '#veResizeBox .ve-rh-se{right:-7px;bottom:-7px;cursor:nwse-resize}'+ '#veResizeBox .ve-rh-sw{left:-7px;bottom:-7px;cursor:nesw-resize}'+ // Edges (centred, and wide/tall enough to grab like a real window border) '#veResizeBox .ve-rh-n{left:50%;top:-7px;margin-left:-6px;cursor:ns-resize}'+ '#veResizeBox .ve-rh-s{left:50%;bottom:-7px;margin-left:-6px;cursor:ns-resize}'+ '#veResizeBox .ve-rh-w{top:50%;left:-7px;margin-top:-6px;cursor:ew-resize}'+ '#veResizeBox .ve-rh-e{top:50%;right:-7px;margin-top:-6px;cursor:ew-resize}'+ // Size readout pinned under the box '#veResizeBox .ve-rh-dim{position:absolute;left:50%;transform:translateX(-50%);bottom:-27px;background:#10141f;color:#dbe3f0;border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:2px 7px;font:600 11px var(--font,sans-serif);white-space:nowrap;pointer-events:none}'+ // Resize drag uses its OWN body class — body.ve-dragging * forces cursor:grabbing // on everything and would otherwise stomp every resize cursor mid-drag. 'body.ve-resizing{user-select:none!important}'+ 'body.ve-resizing.ve-rz-ns,body.ve-resizing.ve-rz-ns *{cursor:ns-resize!important}'+ 'body.ve-resizing.ve-rz-ew,body.ve-resizing.ve-rz-ew *{cursor:ew-resize!important}'+ 'body.ve-resizing.ve-rz-nwse,body.ve-resizing.ve-rz-nwse *{cursor:nwse-resize!important}'+ 'body.ve-resizing.ve-rz-nesw,body.ve-resizing.ve-rz-nesw *{cursor:nesw-resize!important}'+ 'body.ve-resizing [data-ve-rz]{outline:2px dashed var(--a1,#c8202e);outline-offset:2px}'+ // Move-to-drag badge (display only, never intercepts pointer events) '#veMoveBadge{position:fixed;z-index:99991;display:none;pointer-events:none;background:#10141f;color:#dbe3f0;border:1px solid rgba(255,255,255,.16);border-radius:7px;padding:4px 9px;font:600 11px var(--font,sans-serif);white-space:nowrap;box-shadow:0 6px 20px rgba(0,0,0,.4)}'+ '#veMoveBadge b{color:var(--a1,#c8202e)}'+ // Alignment guide lines drawn while dragging (document-space, non-interactive) '.ve-guide{position:absolute;z-index:99988;display:none;pointer-events:none;background:var(--a1,#c8202e);box-shadow:0 0 0 .5px rgba(255,255,255,.35)}'+ '#veGuideV{width:1px}#veGuideH{height:1px}'+ // Selected-for-move highlight 'body.edit-mode [data-ve-movesel]{outline:2px solid var(--a1,#c8202e)!important;outline-offset:2px}'+ // Precise-move panel '#veMovePanel{position:fixed;top:76px;right:18px;z-index:99996;width:262px;background:#131826;border:1px solid rgba(255,255,255,.14);border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.55);display:none;font-family:var(--font,sans-serif);color:#dbe3f0}'+ '#veMovePanel .ve-mp-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:11px 13px;border-bottom:1px solid rgba(255,255,255,.1);cursor:move;font-weight:700;font-size:13px}'+ '#veMovePanel .ve-mp-head button{background:transparent;border:0;color:#8a93a6;font-size:16px;cursor:pointer;line-height:1}'+ '#veMovePanel .ve-mp-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'+ '#veMovePanel .ve-mp-nav{display:flex;gap:2px;margin-left:auto}'+ '#veMovePanel .ve-mp-nav button{width:24px;height:24px;border-radius:6px;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.12);color:#dbe3f0;font-size:11px;line-height:1;cursor:pointer}'+ '#veMovePanel .ve-mp-nav button:hover:not(:disabled){background:rgba(200,32,46,.25)}'+ '#veMovePanel .ve-mp-nav button:disabled{opacity:.3;cursor:default}'+ '#veMovePanel .ve-mp-mode{display:flex;gap:6px}'+ '#veMovePanel .ve-mp-mode button{flex:1;padding:7px 0;border-radius:8px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);color:#8a93a6;font-size:11px;font-weight:700;letter-spacing:.4px;cursor:pointer}'+ '#veMovePanel .ve-mp-mode button:disabled{opacity:.3;cursor:default}'+ '#veMovePanel .ve-mp-mode button.ve-mp-mode-on{background:var(--a1,#c8202e);border-color:var(--a1,#c8202e);color:#fff}'+ '#veMovePanel .ve-mp-body{padding:12px 13px;display:flex;flex-direction:column;gap:10px}'+ '#veMovePanel .ve-mp-tier{font-size:11px;letter-spacing:.4px;color:#8a93a6}'+ '#veMovePanel .ve-mp-tier b{color:var(--a1,#c8202e)}'+ '#veMovePanel .ve-mp-xy{display:flex;gap:8px}'+ '#veMovePanel .ve-mp-xy > div{flex:1}'+ '#veMovePanel label{display:block;font-size:10px;letter-spacing:.6px;text-transform:uppercase;color:#8a93a6;margin-bottom:3px}'+ '#veMovePanel .ve-mp-step{display:flex;align-items:center;gap:0}'+ '#veMovePanel .ve-mp-step button{width:26px;height:32px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);color:#dbe3f0;font-size:15px;cursor:pointer}'+ '#veMovePanel .ve-mp-step button:first-child{border-radius:8px 0 0 8px}'+ '#veMovePanel .ve-mp-step button:last-child{border-radius:0 8px 8px 0}'+ '#veMovePanel .ve-mp-step input{width:100%;min-width:0;text-align:center;background:#0c101a;border:1px solid rgba(255,255,255,.12);border-left:0;border-right:0;color:#e7ecf5;padding:8px 2px;font-size:13px;font-family:inherit}'+ // Text color row '#veMovePanel .ve-mp-color-row{display:flex;gap:6px}'+ '#veMovePanel #veColorPicker{width:32px;height:32px;padding:0;border:1px solid rgba(255,255,255,.12);border-radius:8px;background:#0c101a;cursor:pointer;flex:0 0 auto}'+ '#veMovePanel #veColorHex{flex:1;min-width:0;background:#0c101a;border:1px solid rgba(255,255,255,.12);border-radius:8px;color:#e7ecf5;padding:8px 8px;font-size:13px;font-family:inherit}'+ '#veMovePanel #veColorClear{width:32px;height:32px;border-radius:8px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);color:#dbe3f0;font-size:13px;cursor:pointer;flex:0 0 auto}'+ '#veMovePanel .ve-mp-swatches{display:flex;gap:6px;margin-top:7px}'+ '#veMovePanel .ve-mp-swatch{width:22px;height:22px;border-radius:50%;border:1px solid rgba(255,255,255,.25);cursor:pointer;padding:0}'+ '#veMovePanel .ve-mp-hint{font-size:10.5px;color:#7c8698;line-height:1.5}'+ // Aspect-ratio lock row '#veMovePanel .ve-mp-chk{display:flex;align-items:center;gap:7px;font-size:11px;letter-spacing:0;text-transform:none;color:#dbe3f0;margin:0;cursor:pointer}'+ '#veMovePanel .ve-mp-chk input{width:14px;height:14px;accent-color:var(--a1,#c8202e);cursor:pointer;margin:0}'+ '#veMovePanel .ve-mp-chk span{color:#7c8698}'+ '#veMovePanel .ve-mp-foot{display:flex;gap:6px;padding:0 13px 13px}'+ '#veMovePanel .ve-mp-foot button{flex:1;border:0;border-radius:8px;padding:9px 4px;font-size:11.5px;font-weight:600;cursor:pointer;white-space:nowrap}'+ '#veMovePanel .ve-mp-reset{background:rgba(255,255,255,.08);color:#dbe3f0}'+ '#veMovePanel .ve-mp-done{background:var(--a1,#c8202e);color:#fff}'+ // Floating per-selection formatting toolbar (Google Docs/Medium style) — a small // dark pill, separate from #veMovePanel's whole-element Style… panel. '#veSelToolbar{position:fixed;z-index:99998;display:none;align-items:center;gap:6px;background:#151a26;border:1px solid rgba(255,255,255,.14);border-radius:10px;padding:6px 8px;box-shadow:0 12px 40px rgba(0,0,0,.5)}'+ '#veSelToolbar input[type=color]{width:26px;height:26px;padding:0;border:1px solid rgba(255,255,255,.12);border-radius:6px;background:#0c101a;cursor:pointer;flex:0 0 auto}'+ '#veSelToolbar input[type=text]{width:76px;background:#0c101a;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#e7ecf5;padding:5px 6px;font-size:12px;font-family:inherit}'+ '#veSelToolbar button{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#dbe3f0;font-size:12px;font-weight:700;padding:5px 9px;cursor:pointer;flex:0 0 auto}'+ '#veSelToolbar input[type=number]{width:52px;background:#0c101a;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#e7ecf5;padding:5px 4px;font-size:12px;font-family:inherit;text-align:center;flex:0 0 auto}'+ '#veSelToolbar button:hover{background:rgba(200,32,46,.25)}'+ '#veSelToolbar button.ve-on{background:rgba(200,32,46,.55);border-color:rgba(255,255,255,.32);color:#fff}'+ '#veSelToolbar .ve-mp-swatches{display:flex;gap:5px}'+ '#veSelToolbar .ve-mp-swatch{width:18px;height:18px;border-radius:50%;border:1px solid rgba(255,255,255,.25);cursor:pointer;padding:0;flex:0 0 auto}'+ '#veSelToolbar .ve-sep{width:1px;height:20px;background:rgba(255,255,255,.15)}'+ '@media(max-width:900px){#veToolbar{bottom:10px;flex-wrap:wrap;justify-content:center;max-width:96vw;border-radius:16px}body.edit-mode #veMobNote{display:block}#veMovePanel{top:auto;bottom:74px;right:8px;left:8px;width:auto}}'; document.head.appendChild(css); // ── Toolbar ── const tb=document.createElement('div'); tb.id='veToolbar'; tb.innerHTML= '<span id="veDirtyDot" title="Unsaved changes" style="display:none"></span>'+ '<button id="veSaveBtn" type="button">💾 Save</button>'+ '<button type="button" id="veUndoBtn">↶ Undo</button>'+ '<button type="button" id="veDiscardBtn">✕ Discard</button>'+ '<span class="ve-sep"></span>'+ '<button type="button" id="veLayoutBtn" title="Undo layout moves">↺ Layout</button>'+ '<button type="button" id="veDesignBtn">🎨 Design</button>'+ '<button type="button" id="veDashBtn">📊 Dashboard</button>'+ '<span class="ve-sep"></span>'+ '<button type="button" id="veExitBtn">⏻ Exit editor</button>'; document.body.appendChild(tb); const mn=document.createElement('div'); mn.id='veMobNote'; mn.textContent='📱 Editing works best on a desktop. Long-press = right-click.'; document.body.appendChild(mn); document.getElementById('veSaveBtn').addEventListener('click',veSave); document.getElementById('veUndoBtn').addEventListener('click',veUndo); document.getElementById('veDiscardBtn').addEventListener('click',veDiscard); document.getElementById('veLayoutBtn').addEventListener('click',function(e){ const r=e.currentTarget.getBoundingClientRect(); veShowMenu(r.left,r.top,[ {hd:'Reset layout'}, {label:'This element'+(veMoveSel?'':' (select one first)'),ico:'✥',fn:function(){ if(veMoveSel)veResetLayout('el',veMoveSel); else admToast('Right-click an element → Move, then try again'); }}, {label:'This page (+ nav & footer)',ico:'📄',fn:function(){veResetLayout('page');}}, {label:'Everything, whole site',ico:'⚠️',fn:function(){veResetLayout('all');}}, {div:1}, {label:'Clean up unused entries…',ico:'🧹',fn:function(){veResetLayout('gc');}} ]); }); document.getElementById('veDesignBtn').addEventListener('click',function(){location.hash='#admin';setTimeout(function(){admGo('colors');},60);}); document.getElementById('veDashBtn').addEventListener('click',function(){location.hash='#admin';}); document.getElementById('veExitBtn').addEventListener('click',veExitEditMode); veBuildMenuDom(); veBuildItemEditorDom(); veBuildMoveUi(); veBuildSelToolbarUi(); veBindEditEvents(); veBindMoveEvents(); window.addEventListener('beforeunload',function(e){ if(veIsEditMode()&&veDirty){e.preventDefault();e.returnValue='';} }); } // ═══════════════════════════════════════════════════════════ // VISUAL EDITOR — CONTEXT MENU, INLINE EDITING, ITEM EDITOR // ═══════════════════════════════════════════════════════════ function veBuildMenuDom(){ const m=document.createElement('div'); m.id='veMenu'; document.body.appendChild(m); } function veCloseMenu(){ const m=document.getElementById('veMenu'); if(m)m.style.display='none'; } // items: {label,ico,fn,danger} | {hd:'Header'} | {div:1} function veShowMenu(x,y,items){ const m=document.getElementById('veMenu'); if(!m)return; m.innerHTML=''; items.forEach(it=>{ if(it.div){const d=document.createElement('div');d.className='ve-div';m.appendChild(d);return;} if(it.hd){const h=document.createElement('div');h.className='ve-hd';h.textContent=it.hd;m.appendChild(h);return;} const r=document.createElement('div'); r.className='ve-mi'+(it.danger?' danger':''); r.innerHTML='<span class="ve-ico">'+(it.ico||'')+'</span><span></span>'; r.lastChild.textContent=it.label; r.addEventListener('click',function(ev){ev.stopPropagation();veCloseMenu();it.fn();}); m.appendChild(r); }); veMenuOpenedAt=Date.now(); m.style.display='block'; // clamp inside the viewport m.style.left='0px';m.style.top='0px'; const r=m.getBoundingClientRect(); m.style.left=Math.max(6,Math.min(x,innerWidth-r.width-6))+'px'; m.style.top=Math.max(6,Math.min(y,innerHeight-r.height-6))+'px'; } function veItemCaps(path){ if(/^navItems\./.test(path))return{move:true,del:false,dup:false}; if(/^blogs\./.test(path))return{move:false,del:true,dup:true,blog:true}; if(/^trialOptions\.\d+$/.test(path))return{move:true,del:true,dup:true}; return{move:true,del:true,dup:true}; } function veArrayOp(itemPath,op){ const parts=itemPath.split('.'); const last=parts.pop(); const parentPath=parts.join('.'); const arr=cfgGet(parentPath); if(!Array.isArray(arr))return; const idx=last.charAt(0)==='#'?_cfgSeg(arr,last):+last; if(!(idx>=0&&idx<arr.length))return; veCommit(parentPath,function(){ if(op==='up'&&idx>0){const t=arr[idx-1];arr[idx-1]=arr[idx];arr[idx]=t;} else if(op==='down'&&idx<arr.length-1){const t=arr[idx+1];arr[idx+1]=arr[idx];arr[idx]=t;} else if(op==='del')arr.splice(idx,1); else if(op==='dup'){ const c=JSON.parse(JSON.stringify(arr[idx])); if(c&&typeof c==='object'&&('id' in c))c.id=Date.now(); // sorted-by-id renders need fresh ids arr.splice(idx+1,0,c); } }); } function veAddItem(arrPath){ if(arrPath==='blogs'){veOpenBlogEditor(null);return;} let make=VE_ADD_DEFAULTS[arrPath]; if(!make&&/^navDropdowns\./.test(arrPath))make=function(){return{icon:'🔗',title:'New item',sub:'Subtitle',href:'#home'};}; if(!make){admToast('Adding is not supported here',1);return;} veCommit(arrPath,function(){ let arr=cfgGet(arrPath); if(!Array.isArray(arr)){cfgSet(arrPath,[]);arr=cfgGet(arrPath);} arr.push(make()); }); const arr=cfgGet(arrPath); const p=arrPath+'.'+(arr.length-1); if(veSchemaFor(p))veOpenItemEditor(p); admToast('✓ Item added'); } function veOpenBlogEditor(id){ location.hash='#admin'; setTimeout(function(){ admGo('blogs'); if(id!=null)admEditBlog(id);else admNewBlog(); },80); } // ── Legacy resize-layer key ── // The preset font-size / image-width menus that used to live here are gone, replaced // by the universal drag-handle resize layer (config.elementSizes, keyed by the unique // data-ve-mov stamp). config.elementStyles is still READ and applied by // applyElementStylesLive() so sites saved before this change keep their sizes; this // helper resolves the old shared key so "Reset size" can clear that half too. function veKeyOf(el){return el.getAttribute('data-edit')||el.getAttribute('data-edit-static');} // ── Image replace ── function veReplaceImage(path,el){ const isLogo=path==='company.logo'; const inp=document.createElement('input'); inp.type='file';inp.accept='image/*'; inp.onchange=function(){ const file=inp.files[0];if(!file)return; admToast('Uploading…'); uploadMediaFileXHR(file,isLogo?'logo':'media',{ onDone:function(url){ const v=url.indexOf('data:')===0?url:url+(url.indexOf('?')>=0?'&':'?')+'v='+Date.now(); veCommit(path,function(){cfgSet(path,v);}); admToast('✓ Image updated'); }, onError:function(msg){admToast('⚠️ '+msg,1);} }); }; inp.click(); } function veMediaPicker(cb){ let mp=document.getElementById('veMediaPick'); if(!mp){ mp=document.createElement('div'); mp.id='veMediaPick'; mp.innerHTML='<div class="ve-mp-card"><div style="display:flex;justify-content:space-between;align-items:center"><strong>Media library</strong><button type="button" style="background:none;border:0;color:#8a93a6;font-size:18px;cursor:pointer" data-mp-close>✕</button></div><div class="ve-mp-grid"></div></div>'; mp.addEventListener('click',function(e){if(e.target===mp||e.target.hasAttribute('data-mp-close'))mp.style.display='none';}); document.body.appendChild(mp); } const grid=mp.querySelector('.ve-mp-grid'); grid.innerHTML=''; const up=document.createElement('div'); up.className='ve-mp-tile';up.textContent='📤 Upload new'; up.addEventListener('click',function(){ const inp=document.createElement('input'); inp.type='file';inp.accept='image/*'; inp.onchange=function(){ const f=inp.files[0];if(!f)return; admToast('Uploading…'); uploadMediaFileXHR(f,'media',{ onDone:function(url){ config.media=config.media||[]; config.media.push({url:url,name:f.name,date:Date.now()}); mp.style.display='none';cb(url); }, onError:function(msg){admToast('⚠️ '+msg,1);} }); }; inp.click(); }); grid.appendChild(up); (config.media||[]).slice().reverse().forEach(function(m){ const url=typeof m==='string'?m:(m&&m.url); if(!url)return; const t=document.createElement('div'); t.className='ve-mp-tile'; t.style.backgroundImage='url("'+url.replace(/"/g,'%22')+'")'; t.title=(m&&m.name)||url; t.addEventListener('click',function(){mp.style.display='none';cb(url);}); grid.appendChild(t); }); mp.style.display='flex'; } // ── Inline editing ── let veCancelFlag=false; let veMenuOpenedAt=0; function vePastePlain(e){ e.preventDefault(); let txt=(e.clipboardData||window.clipboardData).getData('text/plain'); // Single-line fields are contentEditable='true' now (see veStartInlineEdit), so a // multi-line paste would inject <div>s into a heading. Flatten it instead. const el=veActiveEdit; if(el&&!el.hasAttribute('data-edit-multiline'))txt=txt.replace(/\s*[\r\n]+\s*/g,' '); document.execCommand('insertText',false,txt); } function veEditKeydown(e){ const el=veActiveEdit;if(!el)return; const multiline=el.hasAttribute('data-edit-multiline'); if(e.key==='Escape'){e.preventDefault();veCancelFlag=true;el.blur();} else if(e.key==='Enter'&&(!multiline||e.ctrlKey||e.metaKey)){e.preventDefault();el.blur();} } function veEditBlur(e){ const el=veActiveEdit;if(!el)return; // Focus moving to the selection toolbar (a swatch/button click, or clicking into // the hex/color <input>s) must NOT end the edit session — relatedTarget is the // element gaining focus, well-supported on blur across browsers. Without this, // clicking any toolbar control would commit/end the edit out from under the user // before the control's own click handler ever runs. if(e&&e.relatedTarget&&e.relatedTarget.closest&&e.relatedTarget.closest('#veSelToolbar'))return; veFinishInline(el,veCancelFlag); veCancelFlag=false; } // Expand a collapsed caret to the word under it. Selection.modify is non-standard // but implemented in Chrome, Safari and Firefox; without it we just leave the caret. function veSelectWordAt(sel){ if(typeof sel.modify!=='function')return; try{ sel.modify('move','backward','word'); sel.modify('extend','forward','word'); }catch(e){} } // Caret range from viewport coords, across both APIs (caretRangeFromPoint is // Chrome/Safari, caretPositionFromPoint is the standard one Firefox ships). function veCaretRangeFromPoint(x,y){ if(document.caretRangeFromPoint)return document.caretRangeFromPoint(x,y); if(document.caretPositionFromPoint){ const p=document.caretPositionFromPoint(x,y); if(!p)return null; const r=document.createRange(); r.setStart(p.offsetNode,p.offset);r.collapse(true); return r; } return null; } // pt = {x,y} viewport coords of the double-click that started this edit, or null // for the right-click → "Edit text" menu action (a deliberate "rewrite this whole // field" gesture, which still gets select-all). function veStartInlineEdit(el,pt){ if(!el)return; if(veActiveEdit===el)return; if(veActiveEdit)veFinishInline(veActiveEdit,false); // commit any previous edit — never leave a stuck session if(el.tagName==='IMG'||el.getAttribute('data-edit-kind')==='bg')return; const path=el.getAttribute('data-edit'),staticKey=el.getAttribute('data-edit-static'); if(!path&&!staticKey)return; veCloseMenu(); // Capture the selection the browser already made BEFORE contentEditable is set — // a double-click selects a word on the second mousedown, and preventDefault() on // the dblclick event does not undo that. Setting contentEditable/focus() can drop // it, so we stash it here and put it back below. This is what makes "double-click // a word → that word is selected" work instead of the old select-everything. let keep=null; try{ const s0=getSelection(); if(s0&&s0.rangeCount){ const r0=s0.getRangeAt(0); if(!r0.collapsed&&el.contains(r0.commonAncestorContainer))keep=r0.cloneRange(); } }catch(e){} veActiveEdit=el; el.dataset.veOrig=el.innerHTML; // EVERY editable text field is now rich (allowed to hold color/size/bold runs from // the selection toolbar). This used to be limited to data-edit-rich + multiline // fields, with everything else opened as 'plaintext-only' — which silently threw // away any styling applied to a heading, title, label, button or nav item: it // looked applied until commit, then vanished. veFinishInline below still stores // PLAIN text for a single-line field until the user actually styles something, // so untouched values stay byte-identical to before. try{el.contentEditable='true';}catch(e){} // Always sanitize paste to plain text: the ONLY way a styled <span> should ever // enter the DOM is through the toolbar's own veRunApply/execCommand, never via // typing or pasting formatted content from Word/a webpage. el.addEventListener('paste',vePastePlain); el.addEventListener('keydown',veEditKeydown); el.addEventListener('blur',veEditBlur); el.focus(); // Tag markup (<b>/<i>/<u>), never CSS spans — the sanitizer's style whitelist is // narrow by design and styleWithCSS=true would emit properties it drops. try{document.execCommand('styleWithCSS',false,false);}catch(e){} try{ const s=getSelection(); if(keep){s.removeAllRanges();s.addRange(keep);} else if(pt){ const r=veCaretRangeFromPoint(pt.x,pt.y); if(r&&el.contains(r.startContainer)){s.removeAllRanges();s.addRange(r);veSelectWordAt(s);} }else{ const r=document.createRange();r.selectNodeContents(el); s.removeAllRanges();s.addRange(r); } }catch(e){} } function veFinishInline(el,cancel){ veSelToolbarHide(); el.removeEventListener('keydown',veEditKeydown); el.removeEventListener('blur',veEditBlur); el.removeEventListener('paste',vePastePlain); el.removeAttribute('contenteditable'); veActiveEdit=null; if(cancel){el.innerHTML=el.dataset.veOrig;delete el.dataset.veOrig;return;} const changed=el.innerHTML!==el.dataset.veOrig; delete el.dataset.veOrig; if(!changed)return; const html=veSanitizeHtml(el.innerHTML); // Plain vs HTML storage. The multiline/rich terms are load-bearing, not redundant: // those fields ALWAYS stored HTML (including the <div>/<br> line breaks a // contenteditable produces), so routing them through the same branch keeps them // byte-identical to before this change. veHasMarkup(html) is the new term — it is // what lets a single-line field (heading, title, label, nav item, stat number) // hold a styled run, while a field nobody has styled still stores plain text and // every existing plain-text consumer of it keeps working untouched. const keepHtml=el.hasAttribute('data-edit-multiline') ||el.hasAttribute('data-edit-rich') ||veHasMarkup(html); const path=el.getAttribute('data-edit'),staticKey=el.getAttribute('data-edit-static'); if(path){ const val=keepHtml?html:veReadText(el); veCommit(path,function(){cfgSet(path,val);}); }else if(staticKey){ const out=keepHtml?html:escHtml(veReadText(el)).replace(/\n/g,'<br/>'); veCommit('contentOverrides',function(){config.contentOverrides[staticKey]=out;}); } } // Read the RAW typed text of an edited element. Never innerText: it returns // the RENDERED text, so CSS text-transform:uppercase (hero subtitle, eyebrows, // section titles…) would silently store SHOUTING text in the config. // textContent keeps the real case; <br>/<div>/<p> breaks become \n first. function veReadText(el){ const tpl=document.createElement('div'); tpl.innerHTML=String(el.innerHTML) .replace(/<br\s*\/?>/gi,'\n') .replace(/<\/div>\s*<div[^>]*>/gi,'\n') .replace(/<\/p>\s*<p[^>]*>/gi,'\n'); return tpl.textContent.replace(/\u00a0/g,' ').replace(/\n{3,}/g,'\n\n').trim(); } // ── Selection formatting toolbar: per-run color/size within a rich inline-edit // session (separate from the whole-element "Style…" panel, which stays keyed by // data-ve-mov). Only ever engages while veActiveEdit is set — see veStartInlineEdit/ // veFinishInline above, which now treat every multiline/rich field as a candidate. function veOnSelChange(){ if(!veIsEditMode()||document.getElementById('adminRoot').classList.contains('open')){veSelToolbarHide();return;} if(!veActiveEdit){veSelToolbarHide();return;} // Focusing a plain <input> (the hex field) collapses/moves the document's main // Selection as a side effect — that's the browser separating "form control caret" // from "document selection," not the user deselecting the target text. Ignore // selectionchange entirely while focus is inside our own toolbar, so veSelRange // (and the visible toolbar) survive typing into the hex field. if(document.activeElement&&document.activeElement.closest&&document.activeElement.closest('#veSelToolbar'))return; const sel=getSelection(); if(!sel||!sel.rangeCount){veSelToolbarHide();return;} const r=sel.getRangeAt(0); if(r.collapsed){veSelToolbarHide();return;} if(!veActiveEdit.contains(r.commonAncestorContainer)){veSelToolbarHide();return;} veSelRange=r.cloneRange(); veSelToolbarShow(); } function veSelToolbarShow(){ const tb=document.getElementById('veSelToolbar'); if(!tb||!veSelRange)return; tb.style.display='flex'; veSelToolbarSync(); veSelToolbarPosition(); } function veSelToolbarHide(){ const tb=document.getElementById('veSelToolbar'); if(tb)tb.style.display='none'; veSelRange=null; } function veSelToolbarPosition(){ const tb=document.getElementById('veSelToolbar'); if(!tb||!veSelRange||tb.style.display==='none')return; const rect=veSelRange.getBoundingClientRect(); if(!rect||(!rect.width&&!rect.height)){veSelToolbarHide();return;} tb.style.left='0px';tb.style.top='0px'; const tr=tb.getBoundingClientRect(); let left=rect.left+rect.width/2-tr.width/2; left=Math.max(6,Math.min(left,innerWidth-tr.width-6)); let top=rect.top-tr.height-8; if(top<6)top=rect.bottom+8; // flip below when too close to the viewport top top=Math.max(6,Math.min(top,innerHeight-tr.height-6)); tb.style.left=left+'px'; tb.style.top=top+'px'; } // A span this feature recognizes as "its own": the ONLY attribute is style, and // every declaration in it is color/font-size. Anything else (a decorative class- // bearing span already in the markup, e.g. <h2>Text <span>word</span></h2>) is left // structurally untouched — new styling nests around/inside it instead of unwrapping it. // // ⚠ INVARIANT — this is the UNWRAP key. veUnwrapOwnStyleAncestors hoists nodes out of // any span that matches here, and veRunApply then REBUILDS that span from // veRunStyleString(color,fontSize), which can only regenerate those two properties. // So ONLY properties veRunStyleString can regenerate may ever be listed below. Adding // font-weight (as the B/I/U feature might tempt you to) would mean re-coloring a bold // run hoists it out of its weight span and rebuilds without the weight — the bold // would silently disappear. Bold/italic/underline markup is deliberately treated as // FOREIGN here: never unwrapped, so it survives, and color spans just nest inside it. function veIsOwnStyleSpan(el){ if(!el||el.nodeType!==1||el.tagName!=='SPAN')return false; if(el.attributes.length!==1||!el.hasAttribute('style'))return false; const v=el.getAttribute('style')||''; return v.split(';').every(function(decl){ if(!decl.trim())return true; const m=/^\s*([a-z-]+)\s*:/i.exec(decl); return !!m&&(m[1].toLowerCase()==='color'||m[1].toLowerCase()==='font-size'); }); } // Seed the picker/hex/swatches from the effective style at the selection's start — // mirrors veMovePanelSync's seeding pattern for the whole-element Style panel. function veSelToolbarSync(){ if(!veSelRange)return; // Seed from the first text node INSIDE the selection, not from startContainer — // that container is the edited element itself for any select-all/triple-click. const root=veActiveEdit; let node=null; if(root){ const off=veTextOffsetOf(root,veSelRange.startContainer,veSelRange.startOffset); node=veRunTextNodeAt(root,off)||veRunTextNodeAt(root,0); } if(node)node=node.parentElement; if(!node){node=veSelRange.startContainer;if(node.nodeType===3)node=node.parentElement;} let seedHex=null; let n=node; while(n&&n!==veActiveEdit&&n.nodeType===1){ if(veIsOwnStyleSpan(n)&&n.style.color){seedHex=rgbToHex(n.style.color);if(seedHex)break;} n=n.parentElement; } if(!seedHex)seedHex=(node&&veVisibleColorOf(node))||'#000000'; const cp=document.getElementById('veSelColorPicker'),ch=document.getElementById('veSelColorHex'); if(cp)cp.value=seedHex; if(ch)ch.value=seedHex; const dg=(config.design&&config.design.global)||{}; const swatchDefs={primary:'#c8202e',secondary:'#8f1620',accent:'#64748b',bodyText:'#141a24',white:'#ffffff'}; document.querySelectorAll('#veSelToolbar .ve-mp-swatch').forEach(function(btn){ const key=btn.getAttribute('data-swatch'); const hex=key==='white'?'#ffffff':(_isHex(dg[key])?dg[key]:swatchDefs[key]); btn.style.background=hex; btn.dataset.hex=hex; }); // Exact px box — don't fight the user while they're typing into it. const sz=document.getElementById('veSelSizeVal'); if(sz&&document.activeElement!==sz)sz.value=veRunCurrentSize(); // B/I/U pressed state. queryCommandState reflects the COMPUTED style, so B shows // "on" inside a heading that is bold from CSS — which is correct and is exactly // why un-bolding has to round-trip (see the font-weight entry in veSanitizeHtml). [['veSelBold','bold'],['veSelItalic','italic'],['veSelUnder','underline']].forEach(function(p){ const b=document.getElementById(p[0]);if(!b)return; let on=false; try{on=document.queryCommandState(p[1]);}catch(e){} b.classList.toggle('ve-on',!!on); }); } // Canonical style-string serialization — MUST stay byte-compatible with // veSanitizeHtml's VE_RUN_COLOR_RE/VE_RUN_SIZE_RE whitelist, or a user's own // formatting would be silently stripped the next time that sanitizer runs. function veRunStyleString(color,fontSize){ const parts=[]; if(color)parts.push('color:'+color); if(fontSize)parts.push('font-size:'+fontSize+'px'); return parts.join(';'); } // The merged color/font-size in effect at a node, from the nearest recognized // own-style-span ancestor (walks up to, but not past, root). function veRunEffectiveStyle(node,root){ let color=null,fontSize=null; let n=node&&node.nodeType===3?node.parentNode:node; while(n&&n.nodeType===1&&n!==root){ if(veIsOwnStyleSpan(n)){ if(color===null&&n.style.color){const hx=rgbToHex(n.style.color);if(hx)color=hx;} if(fontSize===null&&n.style.fontSize){const fs=parseInt(n.style.fontSize,10);if(fs)fontSize=fs;} } n=n.parentNode; } return {color:color,fontSize:fontSize}; } // The colour a node's text is actually PAINTED in, as #rrggbb — the seed both colour // pickers show. Normally that's computed `color`, but a clipped-fill element // (.grad-text, .stat-n, .oc-word) paints its text with a background showing through a // transparent -webkit-text-fill-color, leaving computed `color` at the inherited body // value. Seeding from that made both pickers open on #141a24 while the text on screen // was brand red. Walk up while the fill is transparent and take the first real // background instead. rgbToHex drops alpha, so transparency is tested on the raw // computed string, never on the hex. function veIsTransparentColor(v){ v=String(v||'').replace(/\s+/g,''); return !v||v==='transparent'||/^rgba\(.*,0(\.0+)?\)$/i.test(v); } function veVisibleColorOf(node){ let el=node&&node.nodeType===3?node.parentElement:node; if(!el||el.nodeType!==1)return null; let cs=getComputedStyle(el); if(veIsTransparentColor(cs.webkitTextFillColor||cs.color)){ let a=el; while(a&&a.nodeType===1&&a!==document.documentElement){ const acs=getComputedStyle(a); if(!veIsTransparentColor(acs.backgroundColor))return rgbToHex(acs.backgroundColor); if(!veIsTransparentColor(acs.webkitTextFillColor))return rgbToHex(acs.webkitTextFillColor); a=a.parentElement; } } return rgbToHex(cs.color); } // Total character offset of (node,offset) relative to root's text content — used // to reselect the just-formatted run by character position rather than by node // reference, since the apply algorithm below rebuilds/merges spans and a node // reference captured beforehand may no longer exist afterward. // // MUST handle ELEMENT containers, not just text nodes. A Range endpoint is an // element whenever the selection covers whole nodes — which is every Ctrl+A, // triple-click, drag-past-end, and Range.selectNodeContents(). The original // text-node-identity walk fell through to "total" for those, so start and end // both came back as the FULL text length, endOff<=startOff, and veRunApply // below returned without applying anything: the "A+ does nothing" bug. // // Range.toString() concatenates exactly the text nodes that the TreeWalker in // veRangeFromTextOffsets walks, so the write side and the re-select side agree // on one character numbering. Keep those two in sync if either ever changes. function veTextOffsetOf(root,node,offset){ if(!root||!node)return 0; const r=document.createRange(); try{ r.selectNodeContents(root); // start at root's beginning -> end can never precede it r.setEnd(node,offset); }catch(e){return 0;} // endpoint outside root (guarded by the caller anyway) return r.toString().length; } // First text node containing character `off` of root — the offset-based twin of // veTextOffsetOf. Deliberately NOT Range.intersectsNode: sharing one arithmetic // with everything else here is what keeps the offsets consistent. function veRunTextNodeAt(root,off){ const w=document.createTreeWalker(root,NodeFilter.SHOW_TEXT,null); let total=0,n; while((n=w.nextNode())){ if(total+n.nodeValue.length>off)return n; total+=n.nodeValue.length; } return null; } function veRangeFromTextOffsets(root,start,end){ const r=document.createRange(); let total=0,startSet=false; const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT,null); let n; while((n=walker.nextNode())){ const len=n.nodeValue.length; if(!startSet&&total+len>=start){r.setStart(n,Math.max(0,Math.min(len,start-total)));startSet=true;} if(total+len>=end){r.setEnd(n,Math.max(0,Math.min(len,end-total)));return r;} total+=len; } r.selectNodeContents(root);r.collapse(false); return r; } // Merge adjacent sibling own-style spans with byte-identical style so repeated // clicks don't grow the DOM — each pass leaves at most one span per contiguous // identically-styled run. function veRunNormalizeAdjacent(root){ root.normalize(); let changed=true; while(changed){ changed=false; const spans=[...root.querySelectorAll('span')].filter(veIsOwnStyleSpan); for(let i=0;i<spans.length;i++){ const a=spans[i]; if(!a.parentNode)continue; const b=a.nextSibling; if(b&&b.nodeType===1&&veIsOwnStyleSpan(b)&&a.getAttribute('style')===b.getAttribute('style')){ while(b.firstChild)a.appendChild(b.firstChild); b.parentNode.removeChild(b); a.normalize(); changed=true; } } } } // If node's immediate parent is a recognized own-style span, hoist `node` OUT // to be a direct sibling of that span (preserving document order), splitting // the span into a "before" remainder (left in place) and an "after" clone // (same style) when node had siblings on either side. Repeats up through // nested own-style-span ancestors, stopping at root. This — not Range. // extractContents(), which does NOT include the wrapping element when a // selection sits entirely inside one text node — is what makes case (b) // "re-select the exact same run" and case (c) "partial overlap" both resolve // to a clean, non-nested result instead of piling up nested spans. function veUnwrapOwnStyleAncestors(node,root){ while(node.parentNode&&node.parentNode!==root&&veIsOwnStyleSpan(node.parentNode)){ const parent=node.parentNode,gp=parent.parentNode; const styleAttr=parent.getAttribute('style'); const kids=[...parent.childNodes]; const idx=kids.indexOf(node); const beforeKids=kids.slice(0,idx),afterKids=kids.slice(idx+1); if(afterKids.length){ const afterSpan=document.createElement('span'); afterSpan.setAttribute('style',styleAttr); afterKids.forEach(function(k){afterSpan.appendChild(k);}); gp.insertBefore(afterSpan,parent.nextSibling); } gp.insertBefore(node,parent.nextSibling); if(!beforeKids.length)gp.removeChild(parent); // parent held nothing else — drop it } } // Core primitive: apply a color/font-size mutation to the current selection. // Manual DOM surgery via splitText — not Range.extractContents (see // veUnwrapOwnStyleAncestors above for why) and not document.execCommand // (foreColor/fontSize produce legacy <font> tags / bucketed sizes that the // strict sanitizer whitelist, VE_RUN_COLOR_RE/VE_RUN_SIZE_RE, can't accept). // prop: 'color' | 'fontSize'. mutateFn(currentEffectiveValue) returns the new value // (hex string for color, bare px number for fontSize) or null to clear that property. function veRunApply(prop,mutateFn){ // Never fail silently: every early return here used to look, to the user, like // a dead button. if(!veSelRange||veSelRange.collapsed){admToast('Select some text first');return;} const root=veActiveEdit; if(!root||!root.contains(veSelRange.commonAncestorContainer)){admToast('Select some text first');return;} const startOff=veTextOffsetOf(root,veSelRange.startContainer,veSelRange.startOffset); const endOff=veTextOffsetOf(root,veSelRange.endContainer,veSelRange.endOffset); if(endOff<=startOff){admToast('Select some text first');return;} // Snapshot every text node + its absolute offset BEFORE any mutation — // splitText below only ever affects the node being split, never renumbers // any other node, so this snapshot stays valid throughout the loop. const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT,null); const infos=[]; let total=0,tn; while((tn=walker.nextNode())){infos.push({node:tn,start:total,end:total+tn.nodeValue.length});total+=tn.nodeValue.length;} const targets=[]; infos.forEach(function(info){ const s=Math.max(info.start,startOff),e=Math.min(info.end,endOff); if(s>=e)return; // this text node isn't in the selected range at all let node=info.node; const localEnd=e-info.start; if(localEnd<node.nodeValue.length)node.splitText(localEnd); // trim trailing out-of-range tail const localStart=s-info.start; if(localStart>0)node=node.splitText(localStart); // drop leading out-of-range head; node = in-range part targets.push(node); }); targets.forEach(function(node){ if(!node.nodeValue)return; const eff=veRunEffectiveStyle(node,root); const newVal=mutateFn(prop==='color'?eff.color:eff.fontSize); const nextColor=prop==='color'?newVal:eff.color; const nextSize=prop==='fontSize'?newVal:eff.fontSize; const styleStr=veRunStyleString(nextColor,nextSize); veUnwrapOwnStyleAncestors(node,root); if(styleStr){ const span=document.createElement('span'); span.setAttribute('style',styleStr); node.parentNode.insertBefore(span,node); span.appendChild(node); } }); veRunNormalizeAdjacent(root); // Reselect by character offset (not node reference — normalize above may have // merged/removed the exact nodes we just built) so the next click immediately // re-targets the flattened result, and the user sees their selection persist. const newRange=veRangeFromTextOffsets(root,startOff,endOff); const sel=getSelection(); sel.removeAllRanges();sel.addRange(newRange); veSelRange=newRange.cloneRange(); veSelToolbarSync(); veSelToolbarPosition(); } function veRunSetColor(hex){ const norm=normHex(hex); if(!norm)return; veRunApply('color',function(){return norm;}); } // Fixed +/-2px step, applied UNIFORMLY across the whole selection from a single // base size computed once (the selection start's effective/computed size) — a // documented v1 simplification: a selection with mixed sizes normalizes to one // size per step rather than preserving relative per-run offsets, consistent with // the existing "untiered color" precedent for the whole-element Style panel. // The base size MUST come from the first text node INSIDE the selection, not from // veSelRange.startContainer: when that container is an element (any select-all / // triple-click), veRunEffectiveStyle's "walk up to but not past root" loop never // runs and getComputedStyle would measure the root element instead of the run. function veRunCurrentSize(){ const root=veActiveEdit; if(!root||!veSelRange)return 16; const startOff=veTextOffsetOf(root,veSelRange.startContainer,veSelRange.startOffset); const node=veRunTextNodeAt(root,startOff)||veRunTextNodeAt(root,0); const eff=veRunEffectiveStyle(node||root,root); if(eff.fontSize)return eff.fontSize; const box=node?node.parentElement:root; return Math.round(parseFloat(getComputedStyle(box||root).fontSize))||16; } function veRunStepSize(delta){ if(!veSelRange||veSelRange.collapsed){admToast('Select some text first');return;} const next=Math.max(8,Math.min(200,veRunCurrentSize()+delta)); veRunApply('fontSize',function(){return next;}); } // Exact px entry from the toolbar's size box. function veRunSetSize(px){ const n=Math.round(+px); if(!n||!isFinite(n))return; const next=Math.max(8,Math.min(200,n)); veRunApply('fontSize',function(){return next;}); } function veBuildSelToolbarUi(){ if(document.getElementById('veSelToolbar'))return; const tb=document.createElement('div'); tb.id='veSelToolbar'; tb.style.display='none'; tb.innerHTML= '<input type="color" id="veSelColorPicker">'+ '<input type="text" id="veSelColorHex" placeholder="#000000" maxlength="7">'+ '<span class="ve-mp-swatches">'+ '<button type="button" class="ve-mp-swatch" data-swatch="primary" title="Primary"></button>'+ '<button type="button" class="ve-mp-swatch" data-swatch="secondary" title="Secondary"></button>'+ '<button type="button" class="ve-mp-swatch" data-swatch="accent" title="Accent"></button>'+ '<button type="button" class="ve-mp-swatch" data-swatch="bodyText" title="Body text"></button>'+ '<button type="button" class="ve-mp-swatch" data-swatch="white" title="White"></button>'+ '</span>'+ '<button type="button" id="veSelColorClear" title="Clear color">✕</button>'+ '<span class="ve-sep"></span>'+ '<button type="button" id="veSelBold" title="Bold" style="font-weight:900">B</button>'+ '<button type="button" id="veSelItalic" title="Italic" style="font-style:italic">I</button>'+ '<button type="button" id="veSelUnder" title="Underline" style="text-decoration:underline">U</button>'+ '<span class="ve-sep"></span>'+ '<button type="button" id="veSelSizeDec" title="Smaller">A−</button>'+ '<input type="number" id="veSelSizeVal" min="8" max="200" step="1" title="Exact size (px)">'+ '<button type="button" id="veSelSizeInc" title="Larger">A+</button>'+ '<button type="button" id="veSelSizeClear" title="Clear size">✕</button>'; document.body.appendChild(tb); // Keep focus (and therefore the live blue selection) in the contenteditable when a // BUTTON is pressed. Without this, every click moved focus to the toolbar: the // highlight greyed out, and document.execCommand — which only ever acts on the // focused editable — could not run at all, so B/I/U would be dead. // Scoped to non-inputs on purpose: the old comment here was right that a blanket // preventDefault would make the hex/color <input>s untypable. Those two keep using // the veEditBlur relatedTarget guard (and the activeElement guard in veOnSelChange), // which also remains the fallback on touch, where preventDefault on a compatibility // mousedown is not reliable. tb.addEventListener('mousedown',function(e){ if(e.target.closest('input'))return; e.preventDefault(); }); tb.querySelector('#veSelColorPicker').addEventListener('change',function(){veRunSetColor(this.value);}); tb.querySelector('#veSelColorHex').addEventListener('change',function(){ let v=this.value.trim();if(v&&v[0]!=='#')v='#'+v; veRunSetColor(v); }); tb.querySelector('#veSelColorClear').addEventListener('click',function(){ veRunApply('color',function(){return null;}); }); tb.querySelectorAll('.ve-mp-swatch').forEach(function(btn){ btn.addEventListener('click',function(){if(this.dataset.hex)veRunSetColor(this.dataset.hex);}); }); tb.querySelector('#veSelSizeDec').addEventListener('click',function(){veRunStepSize(-2);}); tb.querySelector('#veSelSizeInc').addEventListener('click',function(){veRunStepSize(2);}); tb.querySelector('#veSelSizeClear').addEventListener('click',function(){ veRunApply('fontSize',function(){return null;}); }); const szBox=tb.querySelector('#veSelSizeVal'); szBox.addEventListener('change',function(){veRunSetSize(this.value);}); szBox.addEventListener('keydown',function(e){ if(e.key==='Enter'){e.preventDefault();e.stopPropagation();veRunSetSize(this.value);} }); [['#veSelBold','bold'],['#veSelItalic','italic'],['#veSelUnder','underline']].forEach(function(p){ tb.querySelector(p[0]).addEventListener('click',function(){veRunExec(p[1]);}); }); } // Bold/italic/underline. execCommand (not the manual span surgery veRunApply does) // because it already handles toggling OFF across partial/mixed selections, and with // styleWithCSS=false it emits <b>/<i>/<u> tags, which veIsOwnStyleSpan treats as // foreign markup and therefore never unwraps. function veRunExec(cmd){ if(!veSelRange||veSelRange.collapsed){admToast('Select some text first');return;} const root=veActiveEdit; if(!root||!root.contains(veSelRange.commonAncestorContainer)){admToast('Select some text first');return;} try{document.execCommand(cmd,false,null);}catch(e){return;} // The cached clone can now point at nodes execCommand detached — re-take it from // the live selection, which execCommand leaves over the affected text. try{ const s=getSelection(); if(s&&s.rangeCount)veSelRange=s.getRangeAt(0).cloneRange(); }catch(e){} veSelToolbarSync(); veSelToolbarPosition(); } // ── Context menu dispatch ── function veOnContextMenu(e){ if(!veIsEditMode())return; if(document.getElementById('adminRoot').classList.contains('open'))return; const t=e.target; // Checked BEFORE the veActiveEdit block below: a right-click on the selection // toolbar (e.g. to paste into the hex field) must never commit/end the inline // edit session — unlike the main #veToolbar, which deliberately does end it. if(t.closest('#veSelToolbar'))return; if(veActiveEdit){ // Inside the element being edited → native menu (spellcheck etc.). // Anywhere else → commit that edit first, then show our menu. if(t===veActiveEdit||veActiveEdit.contains(t))return; veFinishInline(veActiveEdit,false); } if(t.closest('#veToolbar,#veMenu,#veItemEditor,#veMediaPick,#veMovePanel,#veResizeBox,#veSelToolbar'))return; const leaf=t.closest('[data-edit],[data-edit-static]'); const item=t.closest('[data-edit-item]'); const cont=t.closest('[data-edit-add]'); const contact=t.closest('[data-edit-group="contact"]'); const social=t.closest('.soc,.wa-float'); const mov=t.closest('[data-ve-mov]'); const movKey=mov&&mov.getAttribute('data-ve-mov'); const posMap=veActivePosMap(); const hasPos=movKey&&config[posMap]&&config[posMap][movKey]; // `mov` alone is enough to show the menu (Move action) — a plain button/link may // have no data-edit/item/social and no saved position yet. if(!leaf&&!item&&!cont&&!contact&&!social&&!mov)return; e.preventDefault();e.stopPropagation(); const x=e.clientX,y=e.clientY; const items=[]; if(social){ items.push({hd:'Social links'}); items.push({label:'Edit social links…',ico:'🔗',fn:function(){veOpenItemEditor('social');}}); } if(contact){ items.push({hd:'Contact details'}); items.push({label:'Edit contact details…',ico:'📞',fn:function(){veOpenItemEditor('contact');}}); } if(leaf&&!social&&!contact){ const path=leaf.getAttribute('data-edit'),staticKey=leaf.getAttribute('data-edit-static'); const isImg=leaf.tagName==='IMG'||leaf.getAttribute('data-edit-kind')==='bg'; if(isImg){ items.push({hd:'Image'}); items.push({label:'Replace image (upload)…',ico:'🖼',fn:function(){veReplaceImage(path,leaf);}}); items.push({label:'Choose from media library…',ico:'🗂',fn:function(){ veMediaPicker(function(url){veCommit(path,function(){cfgSet(path,url);});admToast('✓ Image updated');}); }}); // Sizing now lives in the universal Resize… action in the `if(mov)` block below, // which works on every element and stores per-element, per-breakpoint values. }else{ items.push({hd:'Text'}); items.push({label:'Edit text',ico:'✏️',fn:function(){veStartInlineEdit(leaf);}}); if(path&&/^buttonLabels\./.test(path)){ const btnId=path.split('.')[1]; if(leaf.hasAttribute('href')&&!leaf.hasAttribute('data-btn-nolink')){ items.push({label:'Edit link…',ico:'🔗',fn:function(){ const cur=(config.buttonLabels[btnId]||{}).link||leaf.dataset.btnDefLink||''; const v=prompt('Button link (e.g. #contact or https://…):',cur); if(v===null)return; veCommit(path,function(){ config.buttonLabels[btnId]=config.buttonLabels[btnId]||{}; config.buttonLabels[btnId].link=v; }); }}); } items.push({label:'Reset to default',ico:'⟲',fn:function(){ veCommit(path,function(){delete config.buttonLabels[btnId];}); // applyButtonsLive only sets non-empty overrides — restore captured defaults document.querySelectorAll('[data-btn="'+btnId+'"]').forEach(function(b){ if(b.dataset.btnDef!==undefined)b.textContent=b.dataset.btnDef; if(b.dataset.btnDefLink!==undefined)b.setAttribute('href',b.dataset.btnDefLink); }); }}); } if(path==='navCta.text'){ items.push({label:'Edit button…',ico:'⚙️',fn:function(){veOpenItemEditor('navCta');}}); } if(staticKey&&config.contentOverrides[staticKey]!=null){ items.push({label:'Reset to original text',ico:'⟲',fn:function(){ veCommit('contentOverrides',function(){delete config.contentOverrides[staticKey];}); }}); } } } if(item){ const ipath=item.getAttribute('data-edit-item'); const caps=veItemCaps(ipath); const schema=veSchemaFor(ipath); items.push({div:1}); items.push({hd:(schema&&schema.label)||'Item'}); if(caps.blog){ items.push({label:'Edit post…',ico:'📝',fn:function(){ const seg=ipath.split('.').pop(); veOpenBlogEditor(seg.charAt(0)==='#'?seg.slice(1):seg); }}); }else if(schema){ items.push({label:'Edit details…',ico:'⚙️',fn:function(){veOpenItemEditor(ipath);}}); } if(caps.dup)items.push({label:'Duplicate',ico:'➕',fn:function(){veArrayOp(ipath,'dup');admToast('✓ Duplicated');}}); if(caps.move){ items.push({label:'Move up',ico:'⬆',fn:function(){veArrayOp(ipath,'up');}}); items.push({label:'Move down',ico:'⬇',fn:function(){veArrayOp(ipath,'down');}}); } if(caps.del)items.push({label:'Delete',ico:'🗑',danger:true,fn:function(){ if(confirm('Delete this item? You can still Discard before saving.')) {veArrayOp(ipath,'del');admToast('Item deleted');} }}); } if(cont){ const arrPath=cont.getAttribute('data-edit-add'); const nice={'services':'service','faqs':'FAQ','testimonials':'testimonial','industries':'industry','trialOptions':'trial product','clients':'client logo','aboutPage.team':'team member','aboutPage.whyCards':'card','portfolio':'project','blogs':'blog post','pills.items':'app pill'}[arrPath]||'item'; items.push({div:1}); items.push({label:'Add '+nice,ico:'➕',fn:function(){veAddItem(arrPath);}}); } if(mov){ if(items.length)items.push({div:1}); // Universal, works on every stamped element — text, button, image, logo, section. const hasSize=config[veActiveSizeMap()]&&config[veActiveSizeMap()][movKey]; items.push({label:'Style…',ico:'⤢',fn:function(){veEnterResizeMode(movKey);}}); if(hasSize){ items.push({label:'Reset size',ico:'⟲',fn:function(){veResizeReset(movKey);}}); } // Independent from hasSize — a color-only override (no size override) must stay // reachable/clearable from the menu too. See veColorReset. const hasColor=config.elementTextColors&&config.elementTextColors[movKey]; if(hasColor){ items.push({label:'Reset text color',ico:'⟲',fn:function(){veColorReset(movKey);}}); } // Ancestor breadcrumb, innermost first. Picking one arms sticky selection, so the // very next plain press ANYWHERE inside it drags that whole unit — this is how a // whole card/group/section is grabbed, not just a lucky pixel with no child under it. const chain=veMovChain(mov); if(chain.length){ items.push({div:1}); items.push({hd:'Move'}); chain.slice(0,6).forEach(function(cel,i){ const k=cel.getAttribute('data-ve-mov'); items.push({label:(i?'↑ ':'')+veMoveLabel(k,cel),ico:i?'⤴':'✥',fn:function(){veEnterMoveMode(k);}}); }); } if(hasPos){ items.push({label:'Reset position',ico:'⟲',fn:function(){ const m=veActivePosMap(); veCommit(m,function(){ const cur=config[m]&&config[m][movKey]; if(!cur)return; delete cur.x;delete cur.y;delete cur.mode;delete cur.mt0; if(!cur.z&&!cur.ov)delete config[m][movKey]; }); admToast('Position reset'); }}); } // Stacking. An offset element already paints in its own layer, but two offset // siblings still paint in DOM order — an earlier card dragged onto a later one // disappears underneath it without this. (function(){ const curPos=(config[veActivePosMap()]||{})[movKey]||{}; const zNow=Math.round(+curPos.z||0); const zSet=function(z){ const m=veActivePosMap(); veCommit(m,function(){ config[m]=config[m]||{}; const cur=Object.assign({},config[m][movKey]||{}); const el2=veMoveElByKey(movKey); if(z){ cur.z=z; // Decided ONCE, here — never re-measured inside veBuildPosCss, which would // be self-referential against its own previous output. if(cur.rel==null&&el2){ const cs=getComputedStyle(el2), pd=el2.parentElement?getComputedStyle(el2.parentElement).display:''; cur.rel=(cs.position==='static'&&!/flex|grid/.test(pd))?1:0; } }else{delete cur.z;delete cur.rel;} cur.v=2; if(!cur.x&&!cur.y&&!cur.z&&!cur.ov)delete config[m][movKey]; else config[m][movKey]=cur; const parentBg=el2&&el2.parentElement?getComputedStyle(el2.parentElement).backgroundColor:''; if(z<0&&parentBg&&parentBg!=='rgba(0, 0, 0, 0)'&&parentBg!=='transparent') admToast('Sent to back — if it disappeared, its container has a background; use Reset stacking',1); }); }; items.push({div:1}); items.push({hd:'Stacking'}); if(zNow!==9)items.push({label:'Bring to front',ico:'⬆',fn:function(){zSet(9);}}); if(zNow!==-1)items.push({label:'Send to back',ico:'⬇',fn:function(){zSet(-1);}}); if(zNow)items.push({label:'Reset stacking',ico:'⟲',fn:function(){zSet(0);}}); })(); // Overflow: a child dragged past a clipping ancestor's edge hides for visitors even // when it looks fine while editing. Persists an explicit opt-out for that ancestor. const clipEl=mov.parentElement&&mov.parentElement.closest(VE_CLIP_SELECTOR); const clipKey=clipEl&&clipEl.getAttribute('data-ve-mov'); if(clipKey){ const clipOn=!!((config[veActivePosMap()]||{})[clipKey]||{}).ov; items.push({label:(clipOn?'Re-crop ':'Allow overflow in ')+veMoveLabel(clipKey,clipEl),ico:clipOn?'✂️':'🔓',fn:function(){ const m=veActivePosMap(); veCommit(m,function(){ config[m]=config[m]||{}; const cur=Object.assign({},config[m][clipKey]||{}); if(clipOn)delete cur.ov; else{cur.ov=1;cur.v=2;} if(!cur.x&&!cur.y&&!cur.z&&!cur.ov)delete config[m][clipKey]; else config[m][clipKey]=cur; }); }}); } // Delete / Restore any movable element (text, button, image, emoji, logo). // Suppressed when the target IS an array-item container (mov===item) — that // block already offers a real config Delete, so we don't stack a second one. const isHidden=!!(config.hiddenElements&&config.hiddenElements[movKey]); const isItemSelf=item&&mov===item; if(isHidden){ items.push({label:'Restore element',ico:'↺',fn:function(){ veCommit('hiddenElements',function(){delete config.hiddenElements[movKey];}); admToast('Element restored'); }}); }else if(!isItemSelf){ items.push({label:(item?'Delete this element':'Delete element'),ico:'🗑',danger:true,fn:function(){ if(confirm('Remove this element from the site? You can restore it by right-clicking it in the editor (or Discard before saving).')){ veCommit('hiddenElements',function(){config.hiddenElements=config.hiddenElements||{};config.hiddenElements[movKey]=1;}); admToast('Element removed'); } }}); } } items.push({div:1}); items.push({label:'Reset this page\'s layout',ico:'↺',fn:function(){veResetLayout('page');}}); items.push({label:'Open Design Studio',ico:'🎨',fn:function(){ location.hash='#admin';setTimeout(function(){admGo('colors');},60); }}); veShowMenu(x,y,items); } // ── Click / dblclick handling in edit mode ── function veClickCapture(e){ if(!veIsEditMode())return; if(document.getElementById('adminRoot').classList.contains('open'))return; if(veMovSwallow&&Date.now()-veMovSwallow<500){veMovSwallow=0;e.preventDefault();e.stopPropagation();return;} // A mis-aimed escalation attempt (Alt/Ctrl+press that never crossed the drag // threshold) still fires a normal click with the modifier intact — on a real <a // href> that's Ctrl+click-opens-a-new-tab or Alt+click-downloads-the-link in Chrome. if(e.altKey||e.ctrlKey||e.metaKey){e.preventDefault();e.stopPropagation();return;} const t=e.target; if(t.closest('#veToolbar,#veMenu,#veItemEditor,#veMediaPick,#veMovePanel,#veResizeBox,#veSelToolbar'))return; if(!t.closest('#veMenu'))veCloseMenu(); // Block navigation/actions on the element being inline-edited; a click // anywhere else commits the edit (belt-and-braces for a swallowed blur). if(veActiveEdit){ if(t===veActiveEdit||veActiveEdit.contains(t)){e.preventDefault();return;} veFinishInline(veActiveEdit,false); } // Allowed interactions (needed to reach/edit content) if(t.closest('.faq-q,.faq-more-wrap,.hamburger,#ham'))return; if(t.closest('.ft-card'))return; // link to the editable detail page if(t.closest('[data-btn="ft-back"],[onclick^="ftBackToTrials"]'))return; // Suppressed interactions if(t.closest('[onclick^="toggleSubscribeMenu"],.sub-opt,.nav-sub')){e.preventDefault();e.stopPropagation();return;} if(t.closest('.blog-card,.port-page-card,.wa-float')){e.preventDefault();e.stopPropagation();return;} const a=t.closest('a[href]'); if(a){ const href=a.getAttribute('href')||''; const internal=href.charAt(0)==='#'&&href!=='#'&&href!=='#admin'&&href!=='#/admin'; if(internal&&!a.closest('.wagon'))return; // real navigation still works e.preventDefault();e.stopPropagation();return; // external / wagon / dead links } const submitBtn=t.closest('button[type="submit"]'); if(submitBtn){e.preventDefault();e.stopPropagation();return;} } function veDblClickCapture(e){ if(!veIsEditMode())return; if(document.getElementById('adminRoot').classList.contains('open'))return; if(veMovSwallow&&Date.now()-veMovSwallow<500){e.preventDefault();e.stopPropagation();return;} const t=e.target; // Checked BEFORE the veActiveEdit block below: double-clicking the selection // toolbar (e.g. to select-all text in the hex field) must never end the edit // session — see the identical guard/comment in veOnContextMenu above. if(t.closest('#veSelToolbar'))return; if(veActiveEdit){ if(t===veActiveEdit||veActiveEdit.contains(t))return; // double-click inside the edit = word select veFinishInline(veActiveEdit,false); } if(t.closest('#veToolbar,#veMenu,#veItemEditor,#veMediaPick,#veMovePanel,#veResizeBox,#veSelToolbar,#adminRoot'))return; const leaf=t.closest('[data-edit],[data-edit-static]'); if(!leaf)return; if(leaf.tagName==='IMG'||leaf.getAttribute('data-edit-kind')==='bg')return; e.preventDefault();e.stopPropagation(); veStartInlineEdit(leaf,{x:e.clientX,y:e.clientY}); // → select the word that was clicked } function veBindEditEvents(){ document.addEventListener('contextmenu',veOnContextMenu,true); document.addEventListener('click',veClickCapture,true); document.addEventListener('dblclick',veDblClickCapture,true); document.addEventListener('selectionchange',veOnSelChange); // #veSelToolbar is position:fixed and placed from getBoundingClientRect, so without // these it strands itself the moment the page (or any nested scroller — hence // capture) moves under the selection it belongs to. window.addEventListener('scroll',veSelToolbarPosition,{capture:true,passive:true}); window.addEventListener('resize',veSelToolbarPosition); // Grace period: the browser can emit settle-scroll events right after a // right-click scrolled the target into view — don't let those close the // menu the instant it opens. window.addEventListener('scroll',function(){if(Date.now()-veMenuOpenedAt>450)veCloseMenu();},true); window.addEventListener('resize',veCloseMenu); document.addEventListener('keydown',function(e){ if(e.key==='Escape'&&!veActiveEdit){veCloseMenu();} }); } // ── Move layer: hold-and-drag repositioning (edit mode; desktop + mobile tiers) ── let veDrag=null; // {el,key,px,py,x0,y0,moved,pid,snap,bx,by,bw,bh} let veMovSwallow=0; // timestamp: swallow the click/dblclick right after a real drag let veMoveSel=null; // data-ve-mov key currently selected in the nudge panel (or null) // True only when the current selection came from a deliberate Move action (the // breadcrumb menu, the panel's ▲/▼ steppers) — NOT from Resize…, which every stamped // element offers unconditionally and would otherwise silently arm whole-container // dragging on a totally unrelated press. See veMovTargetOf's sticky branch. let veMoveSticky=false; // A press on these, even inside a sticky-selected container, always resolves to the // plain per-element target instead — dragging the whole .contact-grid because the // admin tried to click-select text in its <textarea> would be actively hostile. const VE_STICKY_SKIP='input,textarea,select,[contenteditable]'; let vePanelMoved=false; // user dragged the panel by its header — stop auto-placing it let veNudgeAt=0; // timestamp of last arrow/stepper nudge — for undo coalescing // What a press at `t` would grab, in priority order: // 1. deep (Ctrl/Cmd) → always the innermost per-element target — the explicit escape // hatch back out of a sticky container selection. // 2. sticky selection armed, not deep → the selected container, from ANYWHERE inside // it (this is how a whole card/section is dragged from any point on it, not just // its padding) — unless the press is on an interactive/editable leaf. // 3. alt → one level above whatever a plain press would hit (reaches escalation-only // bands/groups that have no drag-ok of their own). // 4. plain → innermost [data-ve-drag-ok], unchanged from before this feature existed. function veMovTargetOf(t,mods){ if(!veIsEditMode())return null; // any width — drag writes to the active tier's map if(document.getElementById('adminRoot').classList.contains('open'))return null; if(veActiveEdit)return null; // never drag while inline-editing: the press commits the edit // via the existing click path (a rebuild there could detach a target) if(!t.closest||t.closest(VE_MOV_EXCLUDE))return null; const deep=!!(mods&&mods.deep), alt=!!(mods&&mods.alt); const direct=t.closest('[data-ve-mov][data-ve-drag-ok]'); if(deep)return direct; if(veMoveSticky&&veMoveSel&&!alt){ const sel=veMoveElByKey(veMoveSel); if(sel&&(sel===t||sel.contains(t))&&!t.closest(VE_STICKY_SKIP))return sel; } if(!alt)return direct; const from=direct||t.closest('[data-ve-mov][data-ve-drag-any]'); const up=from&&from.parentElement?from.parentElement.closest('[data-ve-mov][data-ve-drag-any]'):null; if(up&&!up.closest(VE_MOV_EXCLUDE))return up; return from||direct; // nothing further up — Alt still grabs SOMETHING rather than nothing } // Draggable ancestor ladder for an element, innermost → outermost. Powers the // right-click "Move" breadcrumb and the panel's ▲/▼ selection steppers. function veMovChain(el){ const out=[]; let cur=el&&el.closest?el.closest('[data-ve-mov]'):null; while(cur&&out.length<8){ if(cur.closest(VE_MOV_EXCLUDE))break; if(cur.hasAttribute('data-ve-drag-any'))out.push(cur); cur=cur.parentElement?cur.parentElement.closest('[data-ve-mov]'):null; } return out; } function veOnPointerDown(e){ if(veDrag)return; // reentrancy guard if(e.button!==0||(e.pointerType!=='mouse'&&e.pointerType!=='pen'))return; // A press outside the sticky selection (and outside editor chrome) releases it — the // only way in was a deliberate Move action, so a click elsewhere is the natural "I'm // done with this" signal. Without this, sticky mode would trap every later plain // press inside the selected container until Escape or the panel's Done button. if(veMoveSticky&&veMoveSel){ const sel=veMoveElByKey(veMoveSel); if(sel&&!sel.contains(e.target)&&!e.target.closest('#veMovePanel,#veResizeBox,#veMenu,#veToolbar'))veExitMoveMode(); } const el=veMovTargetOf(e.target,{alt:e.altKey,deep:e.ctrlKey||e.metaKey}); if(!el)return; const p=(config[veActivePosMap()]||{})[el.getAttribute('data-ve-mov')]||{}; veDrag={el:el,key:el.getAttribute('data-ve-mov'),px:e.pageX,py:e.pageY, // pageX/Y: doc-space, tracks through scroll x0:Math.round(+p.x||0),y0:Math.round(+p.y||0),moved:false,pid:e.pointerId,snap:null}; window.addEventListener('pointermove',veOnDragMove,true); window.addEventListener('pointerup',veOnDragUp,true); window.addEventListener('pointercancel',veOnDragCancel,true); window.addEventListener('keydown',veOnDragKey,true); // NO preventDefault: a plain press must still produce click/dblclick } function veOnDragMove(e){ const d=veDrag; if(!d)return; let dx=e.pageX-d.px,dy=e.pageY-d.py; if(!d.moved){ if(Math.abs(dx)<5&&Math.abs(dy)<5)return; // threshold keeps clicks/dblclicks alive d.moved=true; document.body.classList.add('ve-dragging'); try{d.el.setPointerCapture(d.pid);}catch(err){} // set only NOW, so plain clicks are untouched try{getSelection().removeAllRanges();}catch(err){} d.el.setAttribute('data-ve-drag','1'); veCloseMenu(); const bb=document.getElementById('veMoveBadge'); if(bb)bb.style.display='none'; veSnapInit(d); // cache snap candidates once (page space) } const snapped=veSnapAdjust(d,d.x0+dx,d.y0+dy); // {x,y} after alignment snapping + guide lines d.el.style.translate=snapped.x+'px '+snapped.y+'px'; // inline preview beats #vePosCss d.lastX=snapped.x; d.lastY=snapped.y; e.preventDefault(); } function veDragTeardown(){ const d=veDrag; veDrag=null; window.removeEventListener('pointermove',veOnDragMove,true); window.removeEventListener('pointerup',veOnDragUp,true); window.removeEventListener('pointercancel',veOnDragCancel,true); window.removeEventListener('keydown',veOnDragKey,true); document.body.classList.remove('ve-dragging'); veHideGuides(); if(d&&d.el){d.el.removeAttribute('data-ve-drag');try{d.el.releasePointerCapture(d.pid);}catch(err){}} return d; } function veOnDragUp(e){ const d=veDragTeardown(); if(!d||!d.moved)return; veMovSwallow=Date.now(); // Use the snapped preview values (fall back to raw delta if snapping produced none). const nx=Math.round(d.lastX!=null?d.lastX:d.x0+e.pageX-d.px), ny=Math.round(d.lastY!=null?d.lastY:d.y0+e.pageY-d.py); const map=veActivePosMap(); if(Math.abs(nx)<3&&Math.abs(ny)<3){ // dropped near origin -> clear the OFFSET only veCommit(map,function(){ // stacking (z) and overflow (ov) are independent const cur=config[map]&&config[map][d.key]; // settings — a snap-back must not erase them if(!cur)return; delete cur.x;delete cur.y;delete cur.mode;delete cur.mt0; if(!cur.z&&!cur.ov)delete config[map][d.key]; }); }else{ veCommit(map,function(){veWritePos(map,d.key,nx,ny);}); } d.el.style.translate=''; // AFTER commit: #vePosCss now holds it -> no flicker if(veMoveSel===d.key)veMovePanelSync(); // keep an open nudge panel in sync // A child dropped outside a clipping ancestor is cropped for visitors even though // the editor may still show it (see the edit-mode overflow release) — say so, since // the drag otherwise looks like it worked and the element only vanishes on save. const clip=d.el.parentElement&&d.el.parentElement.closest(VE_CLIP_SELECTOR); if(clip){ const cr=clip.getBoundingClientRect(), er=d.el.getBoundingClientRect(); if(er.left<cr.left-1||er.right>cr.right+1||er.top<cr.top-1||er.bottom>cr.bottom+1){ const clipKey=clip.getAttribute('data-ve-mov'); admToast('This now sticks out of “'+veMoveLabel(clipKey,clip)+'”, which crops its contents for visitors. Right-click it → “Allow overflow” to keep it visible.',1); } } } function veOnDragCancel(){ const d=veDragTeardown(); if(d&&d.moved)d.el.style.translate=''; } function veOnDragKey(e){ if(e.key!=='Escape'||!veDrag)return; e.preventDefault();e.stopPropagation(); veOnDragCancel(); } function veOnDragStartNative(e){ if(veDrag)e.preventDefault(); } // kill native <a>/<img> HTML5 drag // ── Snap & alignment guides (page-space so scrolling mid-drag stays correct) ── const VE_SNAP=6; // px tolerance function veGuideEl(id){ let g=document.getElementById(id); if(!g){ g=document.createElement('div'); g.id=id; g.className='ve-guide'; document.body.appendChild(g); } return g; } function veHideGuides(){ ['veGuideV','veGuideH'].forEach(function(id){const g=document.getElementById(id);if(g)g.style.display='none';}); } // Cache candidate alignment lines ONCE, in page coordinates, when a drag begins. function veSnapInit(d){ const r=d.el.getBoundingClientRect(); const sx=window.scrollX||window.pageXOffset||0, sy=window.scrollY||window.pageYOffset||0; d.bw=r.width; d.bh=r.height; d.bx=r.left+sx; d.by=r.top+sy; // page-space left/top at current offset (x0,y0) const xs={}, ys={}; const addX=v=>{ if(isFinite(v))xs[Math.round(v)]=1; }; const addY=v=>{ if(isFinite(v))ys[Math.round(v)]=1; }; // Page horizontal centre. addX(sx+ (window.innerWidth/2)); // The element's own ORIGIN (offset 0) edges, so it snaps cleanly back to layout. const ol=d.bx-d.x0, ot=d.by-d.y0; addX(ol); addX(ol+d.bw/2); addX(ol+d.bw); addY(ot); addY(ot+d.bh/2); addY(ot+d.bh); // Neighbour edges: other movable elements currently near the viewport. const vh=window.innerHeight, vw=window.innerWidth; let n=0; document.querySelectorAll('[data-ve-mov]').forEach(function(el){ if(el===d.el||n>300)return; const q=el.getBoundingClientRect(); if(!q.width||!q.height)return; if(q.bottom<-200||q.top>vh+200||q.right<-200||q.left>vw+200)return; // skip far-offscreen n++; const l=q.left+sx, t=q.top+sy; addX(l); addX(l+q.width/2); addX(l+q.width); addY(t); addY(t+q.height/2); addY(t+q.height); }); d.snap={xs:Object.keys(xs).map(Number),ys:Object.keys(ys).map(Number)}; } // Given tentative translate (tx,ty), snap to the nearest candidate line and draw guides. function veSnapAdjust(d,tx,ty){ if(!d.snap)return {x:tx,y:ty}; const shiftX=tx-d.x0, shiftY=ty-d.y0; // movement from base (which is at x0,y0) const projL=d.bx+shiftX, projT=d.by+shiftY; const edgesX=[projL,projL+d.bw/2,projL+d.bw], edgesY=[projT,projT+d.bh/2,projT+d.bh]; let bestX=null,bestXdelta=VE_SNAP+1,lineX=null; d.snap.xs.forEach(function(c){ edgesX.forEach(function(e){ const dd=Math.abs(c-e); if(dd<bestXdelta){bestXdelta=dd;bestX=c-e;lineX=c;} }); }); let bestY=null,bestYdelta=VE_SNAP+1,lineY=null; d.snap.ys.forEach(function(c){ edgesY.forEach(function(e){ const dd=Math.abs(c-e); if(dd<bestYdelta){bestYdelta=dd;bestY=c-e;lineY=c;} }); }); if(bestX!=null){ tx+=bestX; } if(bestY!=null){ ty+=bestY; } // Draw / hide guides (page coords → absolute in document). const gv=veGuideEl('veGuideV'), gh=veGuideEl('veGuideH'); if(lineX!=null){ gv.style.display='block'; gv.style.left=lineX+'px'; gv.style.top='0'; gv.style.height=document.documentElement.scrollHeight+'px'; } else gv.style.display='none'; if(lineY!=null){ gh.style.display='block'; gh.style.top=lineY+'px'; gh.style.left='0'; gh.style.width=document.documentElement.scrollWidth+'px'; } else gh.style.display='none'; return {x:tx,y:ty}; } // ── Precise move mode: selection, floating nudge panel, arrow-key nudging ── function veMoveElByKey(key){ if(!key)return null; try{ return document.querySelector('[data-ve-mov="'+(window.CSS&&CSS.escape?CSS.escape(key):key.replace(/"/g,'\\"'))+'"]'); } catch(e){ return null; } } // Container names beat text: "Team card" is useful, the first 20 characters of a // whole section's prose is not. Checked in order — .team-card before the generic // .card, section/.sec last so a more specific class always wins. const VE_MOV_NAMES=[ ['.team-card','Team card'],['.team-group','Team group'],['.team-grid','Team group'], ['.cards-grid','Card grid'],['.stats-row','Stats row'],['.stat-box','Stat tile'], ['.about-split','About columns'],['.train-scene','Client logos'], ['.blog-grid','Blog grid'],['.int-grid','Industry grid'],['.vert-grid','Vertical grid'], ['.port-page-grid','Portfolio grid'],['.contact-grid','Contact columns'], ['.btns','Button row'],['.faq-list','FAQ list'],['.c-block','Contact detail'], ['.social-row','Social links'],['.form-row','Form row'],['.home-intro','Intro band'], ['.footer-bottom','Footer bottom'],['.oc-prod','Product'],['.oc-prod-mods','Product modules'], ['.page-hero','Page hero'],['.cta-strip','CTA strip'],['.footer-grid','Footer columns'], ['.svc-card','Service card'],['.blog-card','Blog card'],['.port-page-card','Project card'], ['.client-cell','Client logo'],['.faq-item','FAQ row'],['.card','Card'], ['.avatar','Avatar'],['section','Section'],['.sec','Section'] ]; function veMoveLabel(key,el){ if(!el)return key; const dm=el.getAttribute('data-mov'); if(dm==='sub-nav'||dm==='sub-foot')return 'Subscribe button'; if(el.tagName==='IMG')return 'Image'; for(let i=0;i<VE_MOV_NAMES.length;i++){ if(!el.matches(VE_MOV_NAMES[i][0]))continue; const nm=VE_MOV_NAMES[i][1]; const h=el.querySelector('.sec-title,.team-cat,h1,h2,h3,h4'); const ht=h?(h.textContent||'').trim().replace(/\s+/g,' '):''; return ht?(nm+' — “'+ht.slice(0,18)+(ht.length>18?'…':'')+'”'):nm; } const txt=(el.textContent||'').trim().replace(/\s+/g,' '); return txt?('“'+txt.slice(0,20)+(txt.length>20?'…':'')+'”'):el.tagName.toLowerCase(); } function veEnterMoveMode(key){ veExitMoveMode(); // clear any previous selection marker veMoveSel=key; veMoveSticky=true; // a deliberate Move action — arms whole-container dragging; see veEnterResizeMode const el=veMoveElByKey(key); if(el)el.setAttribute('data-ve-movesel','1'); const p=document.getElementById('veMovePanel'); if(p)p.style.display='block'; veMovePanelSync(); } function veExitMoveMode(){ if(typeof veExitResizeMode==='function')veExitResizeMode(); // handles + rAF loop die with the selection if(veMoveSel){ const el=veMoveElByKey(veMoveSel); if(el)el.removeAttribute('data-ve-movesel'); } veMoveSel=null; veMoveSticky=false; const p=document.getElementById('veMovePanel'); if(p)p.style.display='none'; } function veMovePanelSync(){ const p=document.getElementById('veMovePanel'); if(!p||!veMoveSel)return; const m=veActivePosMap(); const el=veMoveElByKey(veMoveSel); if(el&&!el.hasAttribute('data-ve-movesel'))el.setAttribute('data-ve-movesel','1'); // re-mark after a rebuild const pos=(config[m]||{})[veMoveSel]||{x:0,y:0}; p.querySelector('.ve-mp-title').textContent=veMoveLabel(veMoveSel,el); p.querySelector('.ve-mp-tier').innerHTML='Editing: <b>'+(m==='elementPositions'?'Desktop':'Mobile')+'</b> tier'+ (veMoveSticky?' · <b>Moving</b> — Esc to release':''); p.querySelector('#veMoveX').value=Math.round(+pos.x||0); p.querySelector('#veMoveY').value=Math.round(+pos.y||0); const hint=p.querySelector('.ve-mp-hint'); if(hint)hint.innerHTML='Press anywhere inside this element to drag it whole · <b>Ctrl</b>+drag a child · <b>Alt</b>+drag its parent · arrows nudge, Shift = 10px. A locked text color overrides hover/focus color changes on that element.'; // Ancestor ladder — recomputed from the LIVE element every sync (never cached), so a // rebuild or a fresh menu selection can't leave a stepper pointing at the wrong step. const chain=el?veMovChain(el):[]; const ci=el?chain.indexOf(el):-1; const up=p.querySelector('#veSelUp'), dn=p.querySelector('#veSelDown'); if(up)up.disabled=!(ci>=0&&ci<chain.length-1); if(dn)dn.disabled=!(ci>0); // Float/Push — Push is only offered on elements whose parent isn't flex/grid // (see veBuildPosCss's own gate); a grid/flex child can only ever float. const modeRow=p.querySelector('#veMpMode'); if(modeRow){ const parentDisplay=el&&el.parentElement?getComputedStyle(el.parentElement).display:''; const pushEligible=!/flex|grid/.test(parentDisplay); const isPush=pos.mode==='push'&&pushEligible; modeRow.querySelectorAll('button').forEach(function(b){ const on=(b.getAttribute('data-mode')==='push')===isPush; b.classList.toggle('ve-mp-mode-on',on); if(b.getAttribute('data-mode')==='push')b.disabled=!pushEligible; }); } // Size half — blank means "not overridden, flowing naturally". const s=(config[veActiveSizeMap()]||{})[veMoveSel]||{}; const w=p.querySelector('#veSizeW'), hh=p.querySelector('#veSizeH'), f=p.querySelector('#veSizeF'), lk=p.querySelector('#veSizeLock'); if(w)w.value=s.w?Math.round(s.w):''; if(hh)hh.value=s.h?Math.round(s.h):''; if(f)f.value=s.fs?Math.round(s.fs):''; if(lk)lk.checked=!!s.ar; // Text color — el can be null (stale/rebuilt-away key), so never dereference it // without a truthiness check first; a crash here would abort the sync partway // through and break the X/Y/size fields above too, not just color. const colorRow=p.querySelector('#veMpColor'); if(colorRow)colorRow.style.display=(el&&veRzIsText(el))?'':'none'; const storedCol=config.elementTextColors&&config.elementTextColors[veMoveSel]; const seedCol=normHex(storedCol)||(el?veVisibleColorOf(el):null)||'#000000'; const cp=p.querySelector('#veColorPicker'), ch=p.querySelector('#veColorHex'); if(cp)cp.value=seedCol; if(ch)ch.value=seedCol; // Swatches: always fall back to the DESIGN_GROUPS default per key — migrateDesign // only backfills design.global.primary/secondary/accent from legacy config.colors, // never bodyText, so that key reads undefined on most real configs (the common case, // not an edge case) and a raw lookup would render a broken background:undefined swatch. const dg=(config.design&&config.design.global)||{}; const swatchDefs={primary:'#c8202e',secondary:'#8f1620',accent:'#64748b',bodyText:'#141a24',white:'#ffffff'}; p.querySelectorAll('.ve-mp-swatch').forEach(function(btn){ const key=btn.getAttribute('data-swatch'); const hex=key==='white'?'#ffffff':(_isHex(dg[key])?dg[key]:swatchDefs[key]); btn.style.background=hex; btn.dataset.hex=hex; }); } // Coalesced nudge: one undo snapshot per burst of rapid nudges (key-repeat/steppers). function veMoveNudge(dx,dy){ if(!veMoveSel)return; if(veMovIsLocked(veMoveSel))return; // stamp is stale/missing (rebuild raced this call) — nothing to nudge const m=veActivePosMap(); const fresh=Date.now()-veNudgeAt>600; veNudgeAt=Date.now(); if(fresh){ if(veUndoStack.length>=30)veUndoStack.shift(); veUndoStack.push(JSON.stringify(config)); } config[m]=config[m]||{}; const cur=config[m][veMoveSel]||{x:0,y:0}; const x=Math.round((+cur.x||0)+dx), y=Math.round((+cur.y||0)+dy); veWritePos(m,veMoveSel,x,y); veApplyPositions(); veSetDirty(true); veMovePanelSync(); } function veMoveSetXY(x,y){ if(!veMoveSel||veMovIsLocked(veMoveSel))return; const m=veActivePosMap(); x=Math.round(x||0); y=Math.round(y||0); veCommit(m,function(){veWritePos(m,veMoveSel,x,y);}); veMovePanelSync(); } function veMoveReset(){ if(!veMoveSel)return; const m=veActivePosMap(); veCommit(m,function(){ // clears the OFFSET only — stacking/overflow are const cur=config[m]&&config[m][veMoveSel]; // separate, deliberate settings with their own resets if(!cur)return; delete cur.x;delete cur.y;delete cur.mode;delete cur.mt0; if(!cur.z&&!cur.ov)delete config[m][veMoveSel]; }); veMovePanelSync(); admToast('Position reset'); } // Walk the ancestor ladder from the LIVE current selection — never a cached chain, // which would desync the moment a rebuild replaces the element or the admin selects // something else via the menu without going through this stepper. function veSelectStep(dir){ if(!veMoveSel)return; const el=veMoveElByKey(veMoveSel); if(!el){admToast('That element is no longer on the page');return;} const chain=veMovChain(el); let i=chain.indexOf(el); if(i<0)i=0; const nx=chain[i+(dir>0?1:-1)]; if(!nx){admToast(dir>0?'Outermost element':'Innermost element');return;} const k=nx.getAttribute('data-ve-mov'); if(veRzSel)veEnterResizeMode(k); else veEnterMoveMode(k); } // Switch the selected element between Float (overlap, today's model) and Push // (reflow — see veBuildPosCss for exactly which elements this can apply to). Only // flips the flag; the offset itself is written by the usual drag/nudge path, which // captures the push baseline (mt0) the first time an offset lands while mode:'push'. function veMoveSetMode(mode){ if(!veMoveSel)return; const m=veActivePosMap(); veCommit(m,function(){ config[m]=config[m]||{}; const cur=Object.assign({},config[m][veMoveSel]||{}); if(mode==='push'){cur.mode='push';delete cur.mt0;} // re-baseline on every switch INTO push else delete cur.mode; cur.v=2; if(!cur.x&&!cur.y&&!cur.z&&!cur.ov&&!cur.mode)delete config[m][veMoveSel]; else config[m][veMoveSel]=cur; }); veMovePanelSync(); } // Safety net now that every container is unlockable: reset saved layout offsets // without touching sizes, hidden elements or anything else. All scopes route through // veCommit so a single Undo reverts them. // 'el' — just the given key, both tiers. // 'page' — every key on the active page, PLUS #nav and <footer> (both live outside // any .page, so a page-scoped reset that skipped them would under-deliver). // 'all' — every saved position on the whole site, both tiers. // 'gc' — MANUAL only: drop entries whose element resolves to nothing right now. // That can mean "genuinely deleted" OR "hidden by a filter/approval state // this instant" (an unapproved testimonial, a non-active portfolio tab) — // the confirm lists the actual keys so a familiar one can be caught first. function veResetLayout(scope,key){ const maps=['elementPositions','elementPositionsMobile']; // If the visual editor's own Save button isn't in the DOM, this call came from // outside its UI (the traditional Design Studio) — nothing else will persist the // change, so do it here. Inside the visual editor, behave like every other action: // mark dirty and let the admin's own Save/Discard decide. const persist=function(){ if(!document.getElementById('veSaveBtn'))saveConfig(); }; if(scope==='el'){ if(!key)return; veCommit('elementPositions',function(){ maps.forEach(function(m){ if(config[m])delete config[m][key]; }); }); admToast('Position reset'); if(veMoveSel===key)veMovePanelSync(); persist(); return; } if(scope==='page'){ const pg=document.querySelector('.page.active'); const roots=[pg,document.getElementById('nav'),document.querySelector('footer.site-footer')].filter(Boolean); const keep={}; roots.forEach(function(r){ r.querySelectorAll('[data-ve-mov]').forEach(function(el){keep[el.getAttribute('data-ve-mov')]=1;}); }); const n=Object.keys(keep).length; if(!n){admToast('Nothing on this page has a saved position');return;} if(!confirm('Reset the position of every element on this page, plus the nav and footer ('+n+' candidates)? Sizes and text are not touched.'))return; veCommit('elementPositions',function(){ maps.forEach(function(m){ if(!config[m])return; Object.keys(config[m]).forEach(function(k){ if(keep[k])delete config[m][k]; }); }); }); admToast('Layout reset'); persist(); return; } if(scope==='gc'){ const dead=[]; maps.forEach(function(m){ if(!config[m])return; Object.keys(config[m]).forEach(function(k){ if(dead.indexOf(k)<0&&!veMoveElByKey(k))dead.push(k); }); }); if(!dead.length){admToast('Nothing to clean up');return;} const preview=dead.slice(0,12).join(', ')+(dead.length>12?', …':''); if(!confirm('Remove '+dead.length+' saved position(s) whose element is not on the page right now?\n\nThis can include items only hidden by a filter or approval state (an unapproved testimonial, a portfolio category tab that is not active), not just genuinely deleted ones — check the list below before continuing:\n\n'+preview))return; veCommit('elementPositions',function(){ maps.forEach(function(m){ if(!config[m])return; dead.forEach(function(k){ delete config[m][k]; }); }); }); admToast('Removed '+dead.length+' unused position entr'+(dead.length===1?'y':'ies')); persist(); return; } if(!confirm('Reset EVERY element position on the whole site, on both desktop and mobile? Sizes, text and colours are not touched. You can still Undo, or Discard before saving.'))return; veCommit('elementPositions',function(){ config.elementPositions={}; config.elementPositionsMobile={}; }); admToast('All positions reset'); persist(); } window.veResetLayout=veResetLayout; function veOnMoveKey(e){ if(!veMoveSel||!veIsEditMode()||veActiveEdit)return; // Escape must win even while focus sits in the panel's own X/Y/W/H number inputs — // otherwise there is no way out of a selection right after typing a value. if(e.key==='Escape'){ e.preventDefault(); if(document.activeElement)document.activeElement.blur(); veExitMoveMode(); return; } const ae=document.activeElement; if(ae&&(ae.tagName==='INPUT'||ae.tagName==='TEXTAREA'||ae.isContentEditable))return; // let fields keep native arrows let dx=0,dy=0; const s=e.shiftKey?10:1; if(e.key==='ArrowLeft')dx=-s; else if(e.key==='ArrowRight')dx=s; else if(e.key==='ArrowUp')dy=-s; else if(e.key==='ArrowDown')dy=s; else return; e.preventDefault(); veMoveNudge(dx,dy); } let veHoverLast=null; // last hovered [data-ve-mov] — skip the rebuild when it hasn't changed // (mouseover fires per descendant crossed, hundreds/sec in a dense grid) function veOnMoveHover(e){ const badge=document.getElementById('veMoveBadge'); if(!badge)return; if(!veIsEditMode()||veDrag||veActiveEdit){badge.style.display='none';veHoverLast=null;return;} const el=e.target&&e.target.closest&&e.target.closest('[data-ve-mov]'); if(!el||el.closest(VE_MOV_EXCLUDE)){badge.style.display='none';veHoverLast=null;return;} if(el===veHoverLast){badge.style.display='block';return;} veHoverLast=el; // Anchor on the element actually under the pointer (so the badge never jumps to a // sticky-selected ancestor's corner) — use veMovTargetOf only to NAME the action a // press would take, which is what tells the admin whether they're about to drag a // sub-element, the whole sticky selection, or nothing without a modifier. const tgt=veMovTargetOf(e.target,{}), up=veMovTargetOf(e.target,{alt:true}); let html; if(tgt){ html='✥ Drag <b>'+escHtml(veMoveLabel(tgt.getAttribute('data-ve-mov'),tgt))+'</b>'; if(up&&up!==tgt)html+=' · <b>Alt</b>+drag <b>'+escHtml(veMoveLabel(up.getAttribute('data-ve-mov'),up))+'</b>'; html+=' · right-click for more'; }else{ html='⤢ <b>Right-click</b> to move or resize'; } badge.innerHTML=html; badge.style.display='block'; const r=el.getBoundingClientRect(); let top=r.top-badge.offsetHeight-6; if(top<4)top=r.bottom+6; let left=r.left; if(left<4)left=4; const maxL=window.innerWidth-badge.offsetWidth-4; if(left>maxL)left=Math.max(4,maxL); badge.style.top=top+'px'; badge.style.left=left+'px'; } function veOnMoveOut(e){ const badge=document.getElementById('veMoveBadge'); if(!badge)return; const to=e.relatedTarget; if(to&&to.closest&&to.closest('[data-ve-mov]'))return; badge.style.display='none'; veHoverLast=null; } function veBuildMoveUi(){ if(document.getElementById('veMovePanel'))return; const b=document.createElement('div'); b.id='veMoveBadge'; document.body.appendChild(b); const p=document.createElement('div'); p.id='veMovePanel'; p.innerHTML= '<div class="ve-mp-head"><span class="ve-mp-title">Position & style</span>'+ '<span class="ve-mp-nav">'+ '<button type="button" id="veSelUp" title="Select parent">⬆</button>'+ '<button type="button" id="veSelDown" title="Select child">⬇</button>'+ '</span>'+ '<button type="button" id="veMoveClose" title="Done">✕</button></div>'+ '<div class="ve-mp-body">'+ '<div class="ve-mp-tier"></div>'+ '<div class="ve-mp-xy" id="veMpXY">'+ '<div><label>X</label><div class="ve-mp-step"><button type="button" data-nudge="x-1">−</button><input id="veMoveX" type="number" step="1"><button type="button" data-nudge="x1">+</button></div></div>'+ '<div><label>Y</label><div class="ve-mp-step"><button type="button" data-nudge="y-1">−</button><input id="veMoveY" type="number" step="1"><button type="button" data-nudge="y1">+</button></div></div>'+ '</div>'+ '<div class="ve-mp-mode" id="veMpMode" title="Float overlaps neighbours; Push reflows them (whole bands only — grid/flex children always float)">'+ '<button type="button" data-mode="float">Float</button><button type="button" data-mode="push">Push</button>'+ '</div>'+ '<div class="ve-mp-xy">'+ '<div><label>Width</label><div class="ve-mp-step"><button type="button" data-rznudge="w-1">−</button><input id="veSizeW" type="number" step="1" min="8" placeholder="auto"><button type="button" data-rznudge="w1">+</button></div></div>'+ '<div><label>Height</label><div class="ve-mp-step"><button type="button" data-rznudge="h-1">−</button><input id="veSizeH" type="number" step="1" min="8" placeholder="auto"><button type="button" data-rznudge="h1">+</button></div></div>'+ '</div>'+ '<div class="ve-mp-xy"><div><label>Text size</label><div class="ve-mp-step"><button type="button" data-rznudge="f-1">−</button><input id="veSizeF" type="number" step="1" min="8" placeholder="auto"><button type="button" data-rznudge="f1">+</button></div></div></div>'+ '<div id="veMpColor">'+ '<label>Text color</label>'+ '<div class="ve-mp-color-row">'+ '<input type="color" id="veColorPicker">'+ '<input type="text" id="veColorHex" placeholder="#000000" maxlength="7">'+ '<button type="button" id="veColorClear" title="Clear color">✕</button>'+ '</div>'+ '<div class="ve-mp-swatches">'+ '<button type="button" class="ve-mp-swatch" data-swatch="primary" title="Primary"></button>'+ '<button type="button" class="ve-mp-swatch" data-swatch="secondary" title="Secondary"></button>'+ '<button type="button" class="ve-mp-swatch" data-swatch="accent" title="Accent"></button>'+ '<button type="button" class="ve-mp-swatch" data-swatch="bodyText" title="Body text"></button>'+ '<button type="button" class="ve-mp-swatch" data-swatch="white" title="White"></button>'+ '</div>'+ '</div>'+ '<label class="ve-mp-chk"><input type="checkbox" id="veSizeLock"> Lock aspect ratio <span>(or hold Shift)</span></label>'+ '<div class="ve-mp-hint">Drag the <b>handles</b> on the element to resize from any edge or corner. Arrow keys nudge position · Shift = 10px.</div>'+ '</div>'+ '<div class="ve-mp-foot"><button type="button" class="ve-mp-reset" id="veMoveReset">⟲ Position</button><button type="button" class="ve-mp-reset" id="veSizeReset">⟲ Size</button><button type="button" class="ve-mp-done" id="veMoveDone">Done</button></div>'; document.body.appendChild(p); p.querySelector('#veMoveClose').addEventListener('click',veExitMoveMode); p.querySelector('#veMoveDone').addEventListener('click',veExitMoveMode); p.querySelector('#veMoveReset').addEventListener('click',veMoveReset); p.querySelector('#veSelUp').addEventListener('click',function(){veSelectStep(1);}); p.querySelector('#veSelDown').addEventListener('click',function(){veSelectStep(-1);}); p.querySelectorAll('#veMpMode button').forEach(function(btn){ btn.addEventListener('click',function(){veMoveSetMode(btn.getAttribute('data-mode'));}); }); p.querySelectorAll('[data-nudge]').forEach(function(btn){ btn.addEventListener('click',function(){ const c=btn.getAttribute('data-nudge'); veMoveNudge(c==='x-1'?-1:c==='x1'?1:0, c==='y-1'?-1:c==='y1'?1:0); }); }); const setFromInputs=function(){ veMoveSetXY(+p.querySelector('#veMoveX').value||0,+p.querySelector('#veMoveY').value||0); }; p.querySelector('#veMoveX').addEventListener('change',setFromInputs); p.querySelector('#veMoveY').addEventListener('change',setFromInputs); // Size half of the panel p.querySelector('#veSizeReset').addEventListener('click',function(){ if(veMoveSel)veResizeReset(veMoveSel); }); p.querySelectorAll('[data-rznudge]').forEach(function(btn){ btn.addEventListener('click',function(){ const c=btn.getAttribute('data-rznudge'); veSizeNudge(c.charAt(0), c.slice(1)==='-1'?-1:1); }); }); ['#veSizeW','#veSizeH','#veSizeF'].forEach(function(sel){ p.querySelector(sel).addEventListener('change',veSizeSetFromInputs); }); p.querySelector('#veSizeLock').addEventListener('change',veSizeSetFromInputs); // Text color — commit only on `change` (not `input`): a native color-picker dialog // has no reliable cancel signal, so live-preview-on-input risks a stuck, uncommitted // preview if the user cancels; the OS dialog already shows its own live swatch. p.querySelector('#veColorPicker').addEventListener('change',function(){ veColorSetFromHex(this.value); }); p.querySelector('#veColorHex').addEventListener('change',function(){ let v=this.value.trim(); if(v&&v[0]!=='#')v='#'+v; veColorSetFromHex(v); }); p.querySelector('#veColorClear').addEventListener('click',function(){ if(veMoveSel)veColorReset(veMoveSel); }); p.querySelectorAll('.ve-mp-swatch').forEach(function(btn){ btn.addEventListener('click',function(){ if(this.dataset.hex)veColorSetFromHex(this.dataset.hex); }); }); // Drag the panel by its header (mirrors the item-editor pattern). const head=p.querySelector('.ve-mp-head'); let pdrag=null; head.addEventListener('mousedown',function(e){ if(e.target.tagName==='BUTTON')return; pdrag={dx:e.clientX-p.offsetLeft,dy:e.clientY-p.offsetTop}; vePanelMoved=true; e.preventDefault(); }); document.addEventListener('mousemove',function(e){ if(!pdrag)return; p.style.left=Math.max(0,e.clientX-pdrag.dx)+'px'; p.style.top=Math.max(0,e.clientY-pdrag.dy)+'px'; p.style.right='auto'; p.style.bottom=''; }); document.addEventListener('mouseup',function(){pdrag=null;}); // Global hover badge, arrow-nudge, and tier-label refresh on breakpoint change. document.addEventListener('mouseover',veOnMoveHover,false); document.addEventListener('mouseout',veOnMoveOut,false); document.addEventListener('keydown',veOnMoveKey,true); window.addEventListener('scroll',function(){const bb=document.getElementById('veMoveBadge');if(bb)bb.style.display='none';},true); if(VE_POS_MQ.addEventListener)VE_POS_MQ.addEventListener('change',function(){ if(veMoveSel)veMovePanelSync(); }); } function veBindMoveEvents(){ document.addEventListener('pointerdown',veOnPointerDown,true); // capture: runs before target handlers document.addEventListener('dragstart',veOnDragStartNative,true); } // ── Resize layer: window-style 8-handle sizing (edit mode; desktop + mobile tiers) ── // Handles live in a FIXED overlay that tracks the target's rect via rAF, never inside // the element: the site rebuilds sections with innerHTML constantly, which would wipe // injected handles and pollute veReadText/textContent reads. The element itself is // only touched by the inline preview during a drag, cleared once the CSS layer holds // the committed value. const VE_RZ_HANDLES=[ {d:'nw',c:'nwse'},{d:'n',c:'ns'},{d:'ne',c:'nesw'}, {d:'w', c:'ew'}, {d:'e', c:'ew'}, {d:'sw',c:'nesw'},{d:'s',c:'ns'},{d:'se',c:'nwse'} ]; const VE_RZ_MIN=8; // px floor, so an element can never be dragged to nothing let veRzSel=null; // data-ve-mov key currently selected for resizing let veRz=null; // active resize-drag state let veRzRaf=0; // rAF handle for the overlay tracking loop let veRzAvoidAt=0; // throttle for the panel-avoidance pass // Text-ish element: corner drags scale its font-size instead of pinning a height // (pinning would clip the very text being enlarged). Inline children are fine — // headings are routinely written as `<h2>Text <span>word</span></h2>` — so only // replaced/interactive descendants or a known layout container disqualify it. // Split in two: the SELF test decides what THIS element is; the INNER test decides // whether a DESCENDANT disqualifies it. Widening the self list (whole cards/groups, // now that they're draggable) must not also reclassify every ANCESTOR that happens to // contain one of them, which sharing one constant between both uses would do. const VE_RZ_NOTTEXT='section,.sec,.card,.svc-card,.page-hero,.cta-strip,.footer-grid,.team-card,.team-group,'+VE_GROUP_SELECTOR; const VE_RZ_NOTTEXT_INNER='section,.sec,.card,.svc-card,.page-hero,.cta-strip,.footer-grid'; function veRzIsText(el){ if(!el||el.tagName==='IMG')return false; if(el.matches(VE_RZ_NOTTEXT))return false; if(el.querySelector('img,svg,video,canvas,input,textarea,select,button,'+VE_RZ_NOTTEXT_INNER))return false; return (el.textContent||'').trim().length>0; } function veRzBox(){ let b=document.getElementById('veResizeBox'); if(!b){ b=document.createElement('div'); b.id='veResizeBox'; let h=''; VE_RZ_HANDLES.forEach(function(x){ h+='<div class="ve-rh ve-rh-'+x.d+'" data-rz="'+x.d+'" data-rzc="'+x.c+'"></div>'; }); b.innerHTML=h+'<div class="ve-rh-dim"></div>'; document.body.appendChild(b); b.addEventListener('pointerdown',veRzOnPointerDown,true); } return b; } function veEnterResizeMode(key){ veEnterMoveMode(key); // shared selection marker + the Position & size panel veMoveSticky=false; // Resize… is NOT a Move action — must not arm whole-container dragging veRzSel=key; // set AFTER: veEnterMoveMode calls veExitMoveMode internally if(!veMoveElByKey(key)){veExitResizeMode();return;} veRzBox().style.display='block'; if(!veRzRaf)veRzRaf=requestAnimationFrame(veRzTrack); veMovePanelSync(); } function veExitResizeMode(){ veRzSel=null; if(veRzRaf){cancelAnimationFrame(veRzRaf);veRzRaf=0;} const b=document.getElementById('veResizeBox'); if(b)b.style.display='none'; } // Follow the target through scroll, reflow and innerHTML rebuilds. veMoveElByKey // re-queries the DOM every frame, so a replaced element is picked up automatically. function veRzTrack(){ veRzRaf=0; if(!veRzSel||!veIsEditMode()){veExitResizeMode();return;} const b=veRzBox(), el=veMoveElByKey(veRzSel); const r=el&&el.isConnected?el.getBoundingClientRect():null; if(!r||(!r.width&&!r.height)){ b.style.display='none'; }else{ b.style.display='block'; b.style.left=r.left+'px'; b.style.top=r.top+'px'; b.style.width=r.width+'px'; b.style.height=r.height+'px'; const dim=b.querySelector('.ve-rh-dim'); if(dim)dim.textContent=Math.round(r.width)+' × '+Math.round(r.height); veRzAvoidPanel(r); } veRzRaf=requestAnimationFrame(veRzTrack); } // Keep the floating panel off the selection's handles. Parked top-right by default it // sits exactly on the E/NE/SE handles of any full-width element, making them // impossible to grab. Only runs between drags (mid-drag the pointer is already // captured, and a moving panel would just be noise) and never fights a panel the // user has dragged somewhere deliberately. function veRzAvoidPanel(r){ if(veRz||vePanelMoved)return; if(Date.now()-veRzAvoidAt<200)return; const p=document.getElementById('veMovePanel'); if(!p||p.style.display==='none')return; const pr=p.getBoundingClientRect(); if(!pr.width)return; const pad=16; if(r.right+pad<pr.left||r.left-pad>pr.right||r.bottom+pad<pr.top||r.top-pad>pr.bottom)return; // Only move into a margin that genuinely has room. Never force a corner: shoving // the panel left to clear the E handle would just bury the W handle instead, and // the z-index above already guarantees every handle stays grabbable. if(window.innerWidth-r.right-pad>=pr.width+pad){ veRzAvoidAt=Date.now();p.style.left='auto';p.style.right=pad+'px';p.style.bottom='';return; } if(r.left-pad>=pr.width+pad){ veRzAvoidAt=Date.now();p.style.left=pad+'px';p.style.right='auto';p.style.bottom='';return; } if(window.innerHeight-r.bottom-pad>=pr.height+74){ veRzAvoidAt=Date.now();p.style.top=Math.round(r.bottom+pad)+'px';p.style.bottom='';return; } if(r.top-pad>=pr.height+pad){ veRzAvoidAt=Date.now();p.style.top=Math.max(pad,Math.round(r.top-pr.height-pad))+'px';p.style.bottom=''; } } // Snap one moving edge to the nearest cached candidate line (page space) and draw the // matching guide. veSnapAdjust can't be reused: it shifts the WHOLE box, whereas a // resize must move a single edge and leave the opposite one alone. function veRzSnapEdge(d,val,axis){ const gv=veGuideEl('veGuideV'), gh=veGuideEl('veGuideH'); let best=null,bd=VE_SNAP+1; if(d.snap)(axis==='x'?d.snap.xs:d.snap.ys).forEach(function(c){ const dd=Math.abs(c-val); if(dd<bd){bd=dd;best=c;} }); if(best==null){ (axis==='x'?gv:gh).style.display='none'; return null; } if(axis==='x'){ gv.style.display='block';gv.style.left=best+'px';gv.style.top='0';gv.style.height=document.documentElement.scrollHeight+'px'; } else{ gh.style.display='block';gh.style.top=best+'px';gh.style.left='0';gh.style.width=document.documentElement.scrollWidth+'px'; } return best; } function veRzOnPointerDown(e){ if(veRz||e.button!==0||!veRzSel)return; const h=e.target&&e.target.closest&&e.target.closest('[data-rz]'); if(!h)return; const el=veMoveElByKey(veRzSel); if(!el)return; // Stop here: the document-capture veOnPointerDown already declined (VE_MOV_EXCLUDE // contains #veResizeBox), and this keeps the press off the page underneath. e.preventDefault(); e.stopPropagation(); const dir=h.getAttribute('data-rz'), cur=h.getAttribute('data-rzc'); const r=el.getBoundingClientRect(), cs=getComputedStyle(el); const pm=veActivePosMap(), sm=veActiveSizeMap(); const pos=(config[pm]||{})[veRzSel]||{}, sz=(config[sm]||{})[veRzSel]||{}; const exact=el.matches(VE_RZ_EXACT); // The committed height is a min-height for everything except replaced media, so the // box physically cannot go below its content. Measure that floor once, here, and // clamp the drag to it — otherwise the handle keeps travelling while the box refuses // to follow, and the resize reads as broken. Inline !important is required to // out-rank our own #vePosCss rule. One forced reflow per drag start. let natH=0; if(!exact){ const pv=el.style.getPropertyValue('height'), pm2=el.style.getPropertyValue('min-height'); el.style.setProperty('height','auto','important'); el.style.setProperty('min-height','0','important'); natH=el.offsetHeight; el.style.removeProperty('height'); el.style.removeProperty('min-height'); if(pv)el.style.setProperty('height',pv); // restore any live inline preview if(pm2)el.style.setProperty('min-height',pm2); } veRz={ el:el,key:veRzSel,dir:dir,pid:e.pointerId,hEl:h, px:e.pageX,py:e.pageY, w0:r.width,h0:r.height, exact:exact,natH:natH,locked:el.matches(VE_ESCONLY_SELECTOR), x0:Math.round(+pos.x||0),y0:Math.round(+pos.y||0), fs0:parseFloat(cs.fontSize)||0, // Once our own rule has applied display:inline-block, computed display no longer // reads "inline" — so keep any previously stored flag rather than losing it. inline:cs.display==='inline'||!!sz.inline, ar:r.height>0?r.width/r.height:1, lock:!!sz.ar, scaleText:veRzIsText(el), moved:false }; document.body.classList.add('ve-resizing','ve-rz-'+cur); el.setAttribute('data-ve-rz','1'); try{h.setPointerCapture(e.pointerId);}catch(err){} window.addEventListener('pointermove',veRzOnMove,true); window.addEventListener('pointerup',veRzOnUp,true); window.addEventListener('pointercancel',veRzOnCancel,true); window.addEventListener('keydown',veRzOnKey,true); veCloseMenu(); veSnapInit(veRz); // reuse the move layer's page-space candidate cache } function veRzOnMove(e){ const d=veRz; if(!d)return; const dx=e.pageX-d.px, dy=e.pageY-d.py; if(!d.moved){ if(Math.abs(dx)<2&&Math.abs(dy)<2)return; d.moved=true; try{getSelection().removeAllRanges();}catch(err){} } const dir=d.dir; const west=dir.indexOf('w')>=0, east=dir.indexOf('e')>=0; const north=dir.charAt(0)==='n', south=dir.charAt(0)==='s'; const corner=(north||south)&&(east||west); let w=d.w0, h=d.h0; if(east)w=d.w0+dx; else if(west)w=d.w0-dx; if(south)h=d.h0+dy; else if(north)h=d.h0-dy; // Edge snapping (skipped while Shift is held — Shift means "keep the ratio"). if(!e.shiftKey){ if(east){ const s=veRzSnapEdge(d,d.bx+w,'x'); if(s!=null)w=s-d.bx; } else if(west){ const s=veRzSnapEdge(d,d.bx+(d.w0-w),'x'); if(s!=null)w=d.w0-(s-d.bx); } if(south){ const s=veRzSnapEdge(d,d.by+h,'y'); if(s!=null)h=s-d.by; } else if(north){ const s=veRzSnapEdge(d,d.by+(d.h0-h),'y'); if(s!=null)h=d.h0-(s-d.by); } }else veHideGuides(); if((e.shiftKey||d.lock)&&corner&&d.ar>0)h=w/d.ar; w=Math.max(VE_RZ_MIN,Math.min(w,window.innerWidth*3)); h=Math.max(VE_RZ_MIN,Math.min(h,window.innerHeight*3)); let setW=east||west, setH=north||south; // Stop the handle at the content floor for anything whose height is a minimum, so // the box and the pointer never disagree about where the edge is. if(!d.exact&&setH&&d.natH>0){ d.clamped=h<d.natH; h=Math.max(h,d.natH); } // Text corners scale the type instead of pinning a height — pinning it would clip // the very text the user is trying to enlarge. let fs=0; if(d.scaleText&&corner&&d.fs0>0&&d.h0>0){ fs=Math.max(8,Math.min(200,d.fs0*(h/d.h0))); setH=false; } // N/W handles must pin the OPPOSITE edge: grow leftward, then shift left by the // same amount. Uses the standalone `translate` property (the position layer's), so // it never clashes with the site's `transform` animations. // A locked element never takes an offset — writing one here would reintroduce the // out-of-flow overlap through the resize path instead of the drag path. Its N and W // handles therefore grow the box downward/rightward rather than pinning the far edge. const tx=(west&&!d.locked)?d.x0-(w-d.w0):d.x0, ty=(north&&setH&&!d.locked)?d.y0-(h-d.h0):d.y0; const st=d.el.style; // Mirror what veBuildSizeCss will commit, so the preview doesn't snap on release. if(setW){st.width=Math.round(w)+'px';st.maxWidth=d.exact?'none':'100%';st.boxSizing='border-box';} if(setH){ if(d.exact){st.height=Math.round(h)+'px';} else{st.height='auto';st.minHeight=Math.round(h)+'px';} st.boxSizing='border-box'; } if(fs)st.fontSize=(Math.round(fs*100)/100)+'px'; if(d.inline&&(setW||setH))st.display='inline-block'; if(tx!==d.x0||ty!==d.y0)st.translate=Math.round(tx)+'px '+Math.round(ty)+'px'; d.lw=w;d.lh=h;d.lfs=fs;d.ltx=tx;d.lty=ty;d.setW=setW;d.setH=setH; e.preventDefault(); } function veRzTeardown(){ const d=veRz; veRz=null; window.removeEventListener('pointermove',veRzOnMove,true); window.removeEventListener('pointerup',veRzOnUp,true); window.removeEventListener('pointercancel',veRzOnCancel,true); window.removeEventListener('keydown',veRzOnKey,true); document.body.classList.remove('ve-resizing','ve-rz-ns','ve-rz-ew','ve-rz-nwse','ve-rz-nesw'); veHideGuides(); if(d&&d.el)d.el.removeAttribute('data-ve-rz'); // Release explicitly (mirrors the move layer): on an Escape-cancel the button is // still down, and leaving capture on the handle would retarget stray pointer events. if(d&&d.hEl)try{d.hEl.releasePointerCapture(d.pid);}catch(err){} return d; } // Drop the inline preview. Safe to wipe these properties outright: veApplyFor's tail // re-runs applyElementStylesLive() + veApplyPositions(), which restore any legacy // inline size and the committed translate. function veRzClearPreview(d){ if(!d||!d.el)return; const st=d.el.style; st.width='';st.height='';st.minHeight='';st.maxWidth='';st.boxSizing='';st.fontSize='';st.translate=''; if(d.inline)st.display=''; } function veRzOnUp(){ const d=veRzTeardown(); if(!d)return; if(!d.moved){veRzClearPreview(d);return;} veMovSwallow=Date.now(); // swallow the click that follows a real drag const sm=veActiveSizeMap(), pm=veActivePosMap(); const tx=Math.round(d.ltx), ty=Math.round(d.lty); veCommit(sm,function(){ config[sm]=config[sm]||{}; const cur=Object.assign({},config[sm][d.key]||{}); if(d.setW)cur.w=Math.round(d.lw); if(d.setH)cur.h=Math.round(d.lh); if(d.lfs)cur.fs=Math.round(d.lfs*100)/100; if(d.inline)cur.inline=1; config[sm][d.key]=cur; // Same commit as the size, so one undo restores both halves of a W/N drag. The // edge-pin is only ever reachable on a non-locked (non-ESCONLY) element, which by // construction never carries mode:'push' (push is only offered for escalation-only // bands) — so a plain merge here is enough; there's no push baseline to preserve. if(tx!==d.x0||ty!==d.y0)veWritePos(pm,d.key,tx,ty); }); veRzClearPreview(d); // AFTER the commit — #vePosCss now holds it, no flicker if(veMoveSel===d.key)veMovePanelSync(); // A stored height is a minimum, so content can no longer overflow the box — but the // drag WILL appear to stall at the content floor, and that needs explaining. if(d.setH&&!d.exact&&d.clamped)admToast('Height is a minimum — this box is already as short as its content allows',1); } function veRzOnCancel(){ const d=veRzTeardown(); if(d)veRzClearPreview(d); } function veRzOnKey(e){ if(e.key!=='Escape'||!veRz)return; e.preventDefault();e.stopPropagation(); veRzOnCancel(); } // Clear this element's size in the active tier AND any legacy elementStyles entry — // without the second half an old inline width/fontSize survives and Reset looks broken. function veResizeReset(key){ const sm=veActiveSizeMap(); const el=veMoveElByKey(key), lk=el?veKeyOf(el):null; veCommit(sm,function(){ if(config[sm])delete config[sm][key]; if(lk&&config.elementStyles&&config.elementStyles[lk])delete config.elementStyles[lk]; }); admToast('Size reset'); } // Independent from veResizeReset — a color-only override must stay reachable/clearable // even when no size override exists for the same key (see veOnContextMenu's hasColor gate). function veColorReset(key){ veCommit('elementTextColors',function(){ if(config.elementTextColors)delete config.elementTextColors[key]; }); if(veMoveSel===key)veMovePanelSync(); // picker/hex field must revert to the computed-color seed immediately admToast('Color reset'); } // Single commit path for the hex field, swatches, and the native picker's change event. function veColorSetFromHex(hex){ const norm=normHex(hex); if(!veMoveSel||!norm)return; veCommit('elementTextColors',function(){ config.elementTextColors=config.elementTextColors||{}; config.elementTextColors[veMoveSel]=norm; }); veMovePanelSync(); // re-seed with the NORMALIZED value (e.g. "fff" → "#ffffff") } // Panel: exact W/H/text-size entry. function veSizeSetFromInputs(){ const p=document.getElementById('veMovePanel'); if(!p||!veMoveSel)return; const sm=veActiveSizeMap(); const w=+p.querySelector('#veSizeW').value||0, h=+p.querySelector('#veSizeH').value||0, f=+p.querySelector('#veSizeF').value||0, lock=p.querySelector('#veSizeLock').checked; const el=veMoveElByKey(veMoveSel); const prev=(config[sm]||{})[veMoveSel]||{}; const inline=prev.inline||(el&&getComputedStyle(el).display==='inline'); veCommit(sm,function(){ config[sm]=config[sm]||{}; const cur={}; if(w>0)cur.w=Math.max(VE_RZ_MIN,w); if(h>0)cur.h=Math.max(VE_RZ_MIN,h); if(f>0)cur.fs=Math.max(8,Math.min(200,f)); if(lock)cur.ar=1; if(inline&&(w>0||h>0))cur.inline=1; if(!Object.keys(cur).length)delete config[sm][veMoveSel]; else config[sm][veMoveSel]=cur; }); } // Coalesced stepper nudge — one undo snapshot per burst, mirroring veMoveNudge. function veSizeNudge(which,delta){ if(!veMoveSel)return; const el=veMoveElByKey(veMoveSel); if(!el)return; const sm=veActiveSizeMap(); const fresh=Date.now()-veNudgeAt>600; veNudgeAt=Date.now(); if(fresh){ if(veUndoStack.length>=30)veUndoStack.shift(); veUndoStack.push(JSON.stringify(config)); } config[sm]=config[sm]||{}; const cur=Object.assign({},config[sm][veMoveSel]||{}); const r=el.getBoundingClientRect(); const ratio=r.height>0?r.width/r.height:0; if(which==='f'){ const base=+cur.fs||parseFloat(getComputedStyle(el).fontSize)||16; cur.fs=Math.max(8,Math.min(200,base+delta)); }else if(which==='w'){ cur.w=Math.max(VE_RZ_MIN,Math.round((+cur.w||r.width)+delta)); if(cur.ar&&ratio>0)cur.h=Math.max(VE_RZ_MIN,Math.round(cur.w/ratio)); }else{ cur.h=Math.max(VE_RZ_MIN,Math.round((+cur.h||r.height)+delta)); if(cur.ar&&ratio>0)cur.w=Math.max(VE_RZ_MIN,Math.round(cur.h*ratio)); } if((cur.w>0||cur.h>0)&&getComputedStyle(el).display==='inline')cur.inline=1; config[sm][veMoveSel]=cur; veApplyPositions(); veSetDirty(true); veMovePanelSync(); } // ── Schema-driven item editor (floating panel) ── function veBuildItemEditorDom(){ const p=document.createElement('div'); p.id='veItemEditor'; p.innerHTML='<div class="ve-ie-head"><span id="veIeTitle">Edit</span><button type="button" id="veIeClose">✕</button></div>'+ '<div class="ve-ie-body" id="veIeBody"></div>'+ '<div class="ve-ie-foot"><button type="button" class="ve-btn-cancel" id="veIeCancel">Cancel</button><button type="button" class="ve-btn-save" id="veIeSave">Save changes</button></div>'; document.body.appendChild(p); p.querySelector('#veIeClose').addEventListener('click',veCloseItemEditor); p.querySelector('#veIeCancel').addEventListener('click',veCloseItemEditor); // drag by header const head=p.querySelector('.ve-ie-head'); let drag=null; head.addEventListener('mousedown',function(e){ if(e.target.tagName==='BUTTON')return; drag={dx:e.clientX-p.offsetLeft,dy:e.clientY-p.offsetTop}; e.preventDefault(); }); document.addEventListener('mousemove',function(e){ if(!drag)return; p.style.left=Math.max(0,e.clientX-drag.dx)+'px'; p.style.top=Math.max(0,e.clientY-drag.dy)+'px'; p.style.right='auto'; }); document.addEventListener('mouseup',function(){drag=null;}); } function veCloseItemEditor(){ const p=document.getElementById('veItemEditor'); if(p)p.style.display='none'; } function veOpenItemEditor(path){ const schema=veSchemaFor(path); if(!schema){admToast('No editor available for this item',1);return;} const p=document.getElementById('veItemEditor'); const body=p.querySelector('#veIeBody'); p.querySelector('#veIeTitle').textContent='Edit '+schema.label; const obj=cfgGet(path)||{}; body.innerHTML=''; const inputs={}; schema.fields.forEach(function(fd){ const wrap=document.createElement('div'); const lab=document.createElement('label');lab.textContent=fd.l;wrap.appendChild(lab); let inp; const cur=obj[fd.f]; // veEdOut/veEdIn (here and in the save handler below) keep this plain-text editor // from eating run styling: the box shows the text without its <span>/<b> markup, // and an untouched box writes the ORIGINAL rich value back. Without the pair, // merely opening this panel and pressing Save would strip every styled run. if(fd.t==='textarea'){inp=document.createElement('textarea');inp.value=veEdOut(cur);} else if(fd.t==='select'){ inp=document.createElement('select'); (fd.opts||[]).forEach(function(o){ const op=document.createElement('option');op.value=o;op.textContent=o;inp.appendChild(op); }); inp.value=cur==null?(fd.opts&&fd.opts[0])||'':cur; } else if(fd.t==='checkbox'){ inp=document.createElement('input');inp.type='checkbox'; inp.checked=(cur===undefined)?true:!!cur; // absent flags (approved/visible) mean "on" inp.style.width='auto'; } else if(fd.t==='color'){inp=document.createElement('input');inp.type='color';inp.value=cur||'#7a5af8';} else if(fd.t==='number'){inp=document.createElement('input');inp.type='number';inp.step='any';inp.value=cur==null?'':cur;} else if(fd.t==='image'){ inp=document.createElement('input');inp.type='hidden';inp.value=cur||''; const row=document.createElement('div');row.className='ve-img-row'; const prev=document.createElement('div');prev.className='ve-img-prev'; if(cur)prev.style.backgroundImage='url("'+String(cur).replace(/"/g,'%22')+'")'; const btns=document.createElement('div');btns.className='ve-img-btns'; const setUrl=function(url){ inp.value=url; prev.style.backgroundImage=url?'url("'+String(url).replace(/"/g,'%22')+'")':''; }; const b1=document.createElement('button');b1.type='button';b1.textContent='📤 Upload…'; b1.addEventListener('click',function(){ const f=document.createElement('input');f.type='file';f.accept='image/*'; f.onchange=function(){ const file=f.files[0];if(!file)return; admToast('Uploading…'); uploadMediaFileXHR(file,'media',{ onDone:function(url){setUrl(url);admToast('✓ Uploaded');}, onError:function(msg){admToast('⚠️ '+msg,1);} }); }; f.click(); }); const b2=document.createElement('button');b2.type='button';b2.textContent='🗂 Media library…'; b2.addEventListener('click',function(){veMediaPicker(setUrl);}); const b3=document.createElement('button');b3.type='button';b3.textContent='✕ Remove image'; b3.addEventListener('click',function(){setUrl('');}); btns.appendChild(b1);btns.appendChild(b2);btns.appendChild(b3); row.appendChild(prev);row.appendChild(btns); wrap.appendChild(row); } else if(fd.t==='tags'){inp=document.createElement('input');inp.type='text';inp.value=Array.isArray(cur)?cur.join(', '):(cur||'');} else{inp=document.createElement('input');inp.type='text';inp.value=veEdOut(cur);} inputs[fd.f]={el:inp,t:fd.t}; wrap.appendChild(inp); body.appendChild(wrap); }); const save=p.querySelector('#veIeSave'); save.onclick=function(){ veCommit(path,function(){ const target=cfgGet(path); if(!target||typeof target!=='object')return; schema.fields.forEach(function(fd){ const rec=inputs[fd.f]; let v; if(fd.t==='checkbox')v=rec.el.checked; else if(fd.t==='number')v=rec.el.value===''?undefined:parseFloat(rec.el.value); else if(fd.t==='tags')v=rec.el.value.split(',').map(function(s){return s.trim();}).filter(Boolean); // Everything the populate loop above rendered as a plain text box or textarea // (i.e. anything but select/color/image) goes through the veEdIn guard — the // two sides must cover the same set of field types or the guard has a hole. else if(fd.t!=='select'&&fd.t!=='color'&&fd.t!=='image')v=veEdIn(target[fd.f],rec.el.value); else v=rec.el.value; if(v===undefined)delete target[fd.f];else target[fd.f]=v; }); }); veCloseItemEditor(); admToast('✓ Updated'); }; p.style.display='block'; } // ─── STARTUP ────────────────────────────────────────────── function checkHash(){ // Canonical-host self-heal: the site forces the www host (see .htaccess), so any // backend POST from the bare apex domain gets 301'd cross-origin and the browser // drops it ("Network error during upload" + silent save failures). Jump to www once // so the page and all .php endpoints share the same origin. if(location.protocol!=='file:'&&location.hostname==='oracubes.com'){ location.replace('https://www.oracubes.com'+location.pathname+location.search+location.hash); return; } const isAdmin=location.hash==='#admin'||location.hash==='#/admin'|| (!USE_HASH&&(location.pathname==='/admin'||location.pathname==='/admin/')); if(isAdmin){ document.getElementById('adminRoot').classList.add('open'); if(sessionStorage.getItem(LS_AUTH)==='1'){ localStorage.setItem(LS_OWNER,'1'); // already-authenticated browser: keep the owner tag set showDashboard(); }else{ document.getElementById('admLogin').style.display='flex'; document.getElementById('admShell').style.display='none'; setTimeout(()=>document.getElementById('admPassInput').focus(),100); } }else{ document.getElementById('adminRoot').classList.remove('open'); } } window.addEventListener('hashchange',checkHash); window.addEventListener('popstate',checkHash); checkHash(); // Load embedded config — timestamp decides embedded vs localStorage // Rule: use whichever is newer (_savedAt). localStorage wins on tie (admin's live edits). const emb=document.getElementById('ORACUBES_EMBEDDED_CONFIG'); if(emb){ try{ const embData=JSON.parse(emb.textContent); const embTime=embData._savedAt||0; const localTime=config._savedAt||0; // already loaded from localStorage by loadConfig() if(embTime>localTime){ // Embedded config is newer — use it, but only update localStorage (not MySQL) config={...DEFAULT_CONFIG,...embData}; migrateDesign(config); // fold legacy colors/mode into config.design try{localStorage.setItem(LS_KEY,JSON.stringify(config));}catch(e){} } // else: localStorage config is newer (admin made changes since last download) — keep it }catch(e){} } // On a real server, also fetch oracubes-config.json (written by save-config.php after online edits) // This ensures all visitors see the latest config even if they have stale localStorage. // veStampStatics runs FIRST: applyOverridesLive (at applyLive's tail) needs the // data-edit-static stamps in place. The stamper is idempotent. function applyAndInit(){veStampStatics();seedClientsFromStatic();applyLive();applyDesignLive();} // Apply cached/embedded config synchronously — before the first paint, so content and // theme colours never flash from the hard-coded defaults to the real values on refresh. // (Every applyXxxLive() helper applyLive() calls is already defined above, so this is safe.) // ─── ONE-TIME REBRAND MIGRATION ─────────────────────────── // Configs saved before the rebrand hold the old default blue/dark palette in // MySQL/localStorage and would override the new brand CSS. If (and only if) // the saved colors are exactly the old defaults — i.e. never customized — // swap them to the new red/silver light brand palette. function migrateBrandColors(){ const g=(config.design&&config.design.global)||{}; if(g.primary==='#2563eb'&&g.secondary==='#7c3aed'&&g.accent==='#06b6d4'){ g.primary='#c8202e';g.secondary='#8f1620';g.accent='#64748b'; } } migrateDesign(config); // ensure config.design exists before brand-color check migrateBrandColors(); applyAndInit(); // Visual editor survives a refresh: re-enter edit mode when this tab was editing. if(sessionStorage.getItem(LS_AUTH)==='1'&&sessionStorage.getItem(VE_FLAG)==='1' &&location.hash!=='#admin'&&location.hash!=='#/admin'){ veEnterEditMode(); } // On server: MySQL is the source of truth. // Always apply it — this means uploading a new index.html NEVER resets your content. if(location.protocol!=='file:'){ fetch('/get-config.php?_='+Date.now(),{cache:'no-store'}) .then(r=>r.ok?r.json():null) .then(serverData=>{ // Only re-render when the server copy is genuinely NEWER than what we already // applied. On a normal refresh the cached config already matches the server, so // this skips the redundant second render that caused the content/colour flash. if(serverData&&serverData._savedAt&&serverData._savedAt>(config._savedAt||0)){ // Editor race guard: this fetch can resolve AFTER edit mode re-entered on // refresh. Unsaved edits must not be silently clobbered by a newer server // copy (e.g. saved from another device) — keep them and warn instead. if(veIsEditMode()&&veDirty){ admToast('⚠️ A newer saved version exists on the server. Saving now will overwrite it.',true); return; } // MySQL always wins — it is the authoritative store config={...DEFAULT_CONFIG,...serverData}; migrateDesign(config); // fold legacy colors/mode into config.design migrateBrandColors(); // server copy may still hold the pre-rebrand blue palette // Update localStorage as a fast-load cache for next visit try{localStorage.setItem(LS_KEY,JSON.stringify(config));}catch(e){} applyAndInit(); // Refresh the editor's Discard/dirty reference to the new authoritative copy if(veIsEditMode())window.veOnSaved(); } }) .catch(()=>{}) .finally(()=>{ // Mark DB as ready — only NOW can saveConfig() write to MySQL. // This prevents early auto-saves (analytics etc.) from overwriting real content. window._dbReady=true; // Flush any save that was queued while the DB was still loading. if(window._pendingSave){window._pendingSave=false;saveConfig();} }); }else{ window._dbReady=true; // local file mode — always ready } // Login on Enter document.getElementById('admPassInput').addEventListener('keydown',e=>{ if(e.key==='Enter')admLogin(); }); })(); </script> <script> (function(){ // Translation dictionary — extended with ~80 common phrases (Urdu) const TRANSLATIONS={ en:{}, // English is the source — no overrides needed ur:{ 'Home':'ہوم','Services':'خدمات','Verticals':'شعبے','About':'بارے میں','Clients':'کلائنٹس','Blog':'بلاگ','FAQ':'سوالات','Contact':'رابطہ', 'Get Started →':'شروع کریں ←', 'Send Message →':'پیغام بھیجیں ←','Send Us a Message':'ہمیں پیغام بھیجیں', 'Your Full Name':'آپ کا پورا نام','Email Address':'ای میل ایڈریس','Phone Number':'فون نمبر', 'Business Type':'کاروبار کی قسم','Service Required':'مطلوبہ خدمت','Tell us about your project and requirements...':'اپنے پروجیکٹ کے بارے میں بتائیں...', 'Web Design':'ویب ڈیزائن','Web Design & Development':'ویب ڈیزائن اور ڈویلپمنٹ','App Development':'ایپ ڈویلپمنٹ', 'ERP Software':'ای آر پی سافٹ ویئر','ERP Software (Odoo)':'ای آر پی سافٹ ویئر (اوڈو)','Consultancy':'مشاورت','IT Consultancy':'آئی ٹی مشاورت', 'Digital Marketing':'ڈیجیٹل مارکیٹنگ','All Services':'تمام خدمات', 'Quick Links':'فوری لنکس','Our Clients':'ہمارے کلائنٹس','Our Services':'ہماری خدمات', 'Common Questions':'عام سوالات','Frequently Asked Questions':'اکثر پوچھے جانے والے سوالات', 'Trusted by Leading Businesses':'معروف کاروباروں کا اعتماد', 'Get In Touch':'رابطہ کریں','Contact Us':'ہم سے رابطہ کریں','Address':'پتہ','Phone':'فون','Email':'ای میل', 'About Us':'ہمارے بارے میں','Our Story':'ہماری کہانی','Our Mission':'ہمارا مقصد','Our Vision':'ہمارا وژن','Our Values':'ہماری اقدار', 'Why Choose Us':'ہمیں کیوں منتخب کریں','Our Team':'ہماری ٹیم','Leadership':'قیادت', 'Read More':'مزید پڑھیں','Learn More':'مزید جانیں','Get Started':'شروع کریں','Book a Demo':'ڈیمو بک کریں', 'Free Consultation':'مفت مشاورت','Get a Quote':'قیمت معلوم کریں', 'Sialkot, Pakistan':'سیالکوٹ، پاکستان','Pakistan':'پاکستان', 'Type your message...':'اپنا پیغام لکھیں...','Online — Ready to help':'آن لائن — مدد کے لیے تیار', 'Oracubes Assistant':'اوراکیوبز اسسٹنٹ', 'Retail':'خوردہ','Wholesale':'تھوک','Manufacturing':'مینوفیکچرنگ','Trading':'تجارت','Healthcare':'صحت','Education':'تعلیم', 'Restaurant':'ریستوران','eCommerce':'ای کامرس','Logistics':'لاجسٹکس','Other':'دیگر', 'Verticals We Serve':'ہم جن شعبوں میں خدمات دیتے ہیں','Industries We Serve':'ہم جن صنعتوں میں خدمات دیتے ہیں', 'Latest Blog Posts':'تازہ ترین بلاگ پوسٹس','Latest from the Blog':'بلاگ سے تازہ ترین', 'Our Portfolio':'ہمارا پورٹ فولیو','Featured Projects':'منتخب پروجیکٹس', 'What Our Clients Say':'ہمارے کلائنٹس کیا کہتے ہیں','Testimonials':'تعریفیں' } }; let currentLang = localStorage.getItem('oracubes_lang') || 'en'; let originalTexts = new WeakMap(); function captureOriginal(el){ if(!originalTexts.has(el))originalTexts.set(el, el.textContent); return originalTexts.get(el); } function translateText(text, lang){ if(lang === 'en') return text; const dict = TRANSLATIONS[lang] || {}; const trimmed = text.trim(); if(dict[trimmed]) return text.replace(trimmed, dict[trimmed]); return text; } function applyLang(lang){ currentLang = lang; localStorage.setItem('oracubes_lang', lang); // Set RTL for Urdu document.documentElement.dir = (lang === 'ur') ? 'rtl' : 'ltr'; document.documentElement.lang = lang; // Selectors of text-containing elements (avoid scripts/styles) const selectors = 'h1, h2, h3, h4, h5, h6, p, span, button, a, label, li, option, .eyebrow, .sec-title, .sec-sub, .card h3, .card p, .nav-cta, .btn-grad, .btn-ghost, .f-link, input[placeholder]'; document.querySelectorAll(selectors).forEach(el => { // Skip our own toggle, chatbot internals, and admin if(el.closest('#langToggle') || el.closest('#oracubes-widget') || el.closest('#adminRoot')) return; // For inputs with placeholder, translate placeholder if(el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'){ if(el.placeholder){ if(!el.dataset.origPh) el.dataset.origPh = el.placeholder; const dict = TRANSLATIONS[lang] || {}; el.placeholder = (lang === 'en') ? el.dataset.origPh : (dict[el.dataset.origPh] || el.dataset.origPh); } return; } // Only translate elements with simple text (no nested elements with text) if(el.children.length === 0 || (el.children.length <= 2 && el.textContent.trim().length < 80)){ const orig = captureOriginal(el); if(lang === 'en'){ el.textContent = orig; } else { const trimmed = orig.trim(); const dict = TRANSLATIONS[lang] || {}; if(dict[trimmed]){ el.textContent = orig.replace(trimmed, dict[trimmed]); } } } }); // Update toggle label const lbl = document.getElementById('langLabel'); if(lbl) lbl.textContent = (lang === 'en') ? 'EN' : 'اردو'; } // Apply on load (after a brief delay so DOM is fully ready) // setTimeout(() => applyLang(currentLang), 200); window.OracubesLang = { apply: applyLang, current: () => currentLang }; })(); </script> <!-- ═══════════════════════════════════════════════════════════════ POPUP MODAL — Auto-shows on page load, click anywhere to close ═══════════════════════════════════════════════════════════════ --> <style> /* ---------- Popup Overlay ---------- */ #popupOverlay{ position:fixed;inset:0;z-index:99999; display:flex;align-items:center;justify-content:center; background:rgba(0,0,0,0); backdrop-filter:blur(0px); -webkit-backdrop-filter:blur(0px); cursor:pointer; opacity:0;visibility:hidden; transition:opacity .45s cubic-bezier(.4,0,.2,1), background .45s cubic-bezier(.4,0,.2,1), backdrop-filter .45s cubic-bezier(.4,0,.2,1), visibility 0s .45s; padding:1.25rem; } #popupOverlay.active{ opacity:1;visibility:visible; background:var(--popup-backdrop,rgba(0,0,0,.72)); backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px); transition:opacity .45s cubic-bezier(.4,0,.2,1), background .45s cubic-bezier(.4,0,.2,1), backdrop-filter .45s cubic-bezier(.4,0,.2,1), visibility 0s 0s; } /* ---------- Popup Card ---------- */ #popupCard{ position:relative; max-width:920px;width:100%; border-radius:18px; overflow:hidden; box-shadow:0 30px 80px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.08); transform:scale(.88) translateY(30px); transition:transform .5s cubic-bezier(.175,.885,.32,1.275), opacity .4s ease; opacity:0; cursor:default; } #popupOverlay.active #popupCard{ transform:scale(1) translateY(0); opacity:1; } /* ---------- Close Hint ---------- */ #popupClose{ position:absolute;top:14px;right:14px;z-index:2; width:36px;height:36px;border-radius:50%; background:var(--popup-close-bg,rgba(0,0,0,.55));border:1px solid rgba(255,255,255,.15); display:flex;align-items:center;justify-content:center; cursor:pointer; transition:background .25s,transform .25s; } #popupClose:hover{background:var(--popup-close-hover,rgba(220,38,38,.85));transform:scale(1.12)} #popupClose svg{width:16px;height:16px;stroke:#fff;stroke-width:2.5;stroke-linecap:round} /* ---------- Popup Image ---------- */ #popupCard img{ display:block;width:100%;height:auto; object-fit:contain; } /* ---------- Click-anywhere hint ---------- */ #popupHint{ position:absolute;bottom:0;left:0;right:0; text-align:center;padding:10px; font-family:'Outfit',sans-serif;font-size:12px; color:rgba(255,255,255,.55);letter-spacing:.4px; background:linear-gradient(transparent,rgba(0,0,0,.6)); pointer-events:none; } /* ---------- Shimmer loading effect on image ---------- */ #popupCard.loading{ background:linear-gradient(110deg,#0c1322 8%,#141e33 18%,#0c1322 33%); background-size:200% 100%; animation:popupShimmer 1.5s linear infinite; min-height:300px; } @keyframes popupShimmer{to{background-position:-200% 0}} /* ---------- Responsive ---------- */ @media(max-width:640px){ #popupOverlay{padding:.75rem} #popupCard{border-radius:14px} #popupClose{top:10px;right:10px;width:32px;height:32px} #popupHint{font-size:11px;padding:8px} } @media(max-width:380px){ #popupCard{border-radius:12px} } </style> <!-- ═══════════════════════════════════════════════════════════════ SUBSCRIBE — YouTube-style level dropdown + contact modal ═══════════════════════════════════════════════════════════════ --> <style> #subscribeMenu{position:fixed;z-index:100000;min-width:280px;background:var(--dropdown-bg,var(--card));backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid var(--border);border-radius:14px;padding:.5rem;box-shadow:0 20px 60px rgba(0,0,0,.5);display:none;flex-direction:column;gap:.15rem} #subscribeMenu.open{display:flex} #subscribeMenu .sub-opt{display:flex;align-items:center;gap:12px;padding:.7rem .85rem;border-radius:10px;cursor:pointer;background:none;border:none;text-align:left;width:100%;font-family:var(--font)} #subscribeMenu .sub-opt:hover{background:var(--dd-hover-bg,rgba(var(--a1-rgb),.1))} #subscribeMenu .so-ico{width:36px;height:36px;border-radius:9px;background:rgba(var(--a1-rgb),.12);border:1px solid rgba(var(--a1-rgb),.2);display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0} #subscribeMenu .so-txt{min-width:0} #subscribeMenu .so-txt strong{display:block;font-size:13.5px;font-weight:600;color:var(--light)} #subscribeMenu .so-txt small{font-size:12px;color:var(--muted)} #subscribeOverlay{position:fixed;inset:0;z-index:100001;display:flex;align-items:center;justify-content:center;padding:1.25rem;background:rgba(0,0,0,0);backdrop-filter:blur(0);opacity:0;visibility:hidden;transition:opacity .3s ease,background .3s ease,visibility 0s .3s} #subscribeOverlay.open{opacity:1;visibility:visible;background:var(--popup-backdrop,rgba(3,7,18,.72));backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);transition:opacity .3s ease,background .3s ease,visibility 0s 0s} #subscribeCard{position:relative;width:100%;max-width:440px;background:var(--card,#0c1322);border:1px solid var(--border);border-radius:18px;padding:2rem;box-shadow:0 30px 80px rgba(0,0,0,.55);transform:translateY(20px) scale(.98);opacity:0;transition:transform .35s cubic-bezier(.175,.885,.32,1.275),opacity .3s ease;max-height:92vh;overflow-y:auto} #subscribeOverlay.open #subscribeCard{transform:translateY(0) scale(1);opacity:1} #subscribeCard .sub-close{position:absolute;top:14px;right:14px;width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:var(--popup-close-bg,rgba(255,255,255,.05));color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:15px;line-height:1;transition:all .2s} #subscribeCard .sub-close:hover{background:var(--popup-close-hover,rgba(255,255,255,.12));color:var(--light)} #subscribeCard h3{font-size:20px;font-weight:800;letter-spacing:-.4px;margin-bottom:.35rem;color:var(--light)} #subscribeCard .sub-lead{font-size:13.5px;color:var(--muted);line-height:1.6;margin-bottom:1.25rem} #subscribeForm{display:flex;flex-direction:column;gap:.8rem} #subscribeForm .sf-lbl{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;font-family:var(--mono);margin-bottom:-.45rem} #subscribeForm input,#subscribeForm select{padding:12px 15px;border:1px solid var(--input-border,var(--border));border-radius:10px;background:var(--input-bg,rgba(255,255,255,.03));color:var(--input-fg,var(--light));font-size:14px;font-family:var(--font);outline:none;transition:border-color .2s;width:100%} #subscribeForm input::placeholder{color:var(--muted)} #subscribeForm input:focus,#subscribeForm select:focus{border-color:var(--input-focus-border,rgba(var(--a1-rgb),.5))} #subscribeForm select option{background:var(--bg2)} #subscribeForm .sf-row{display:grid;grid-template-columns:1fr 1fr;gap:.8rem} #subscribeForm .sf-hp{position:absolute!important;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none} #subscribeForm .btn-grad{margin-top:.35rem;text-align:center;width:100%} #subscribeStatus{font-size:13px;padding:0;min-height:0;border-radius:10px;overflow:hidden;transition:all .2s} #subscribeStatus.active{padding:11px 14px;min-height:18px} #subscribeStatus.success{background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.3);color:#4ade80} #subscribeStatus.error{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);color:#f87171} @media(max-width:520px){#subscribeForm .sf-row{grid-template-columns:1fr}} </style> <div id="subscribeMenu" role="menu" aria-label="Subscription options"> <button class="sub-opt" type="button" role="menuitem" onclick="openSubscribe('blog')"><span class="so-ico">📝</span><span class="so-txt"><strong>Blog updates only</strong><small>New blog posts</small></span></button> <button class="sub-opt" type="button" role="menuitem" onclick="openSubscribe('updates')"><span class="so-ico">📣</span><span class="so-txt"><strong>All website updates</strong><small>Announcements & site changes</small></span></button> <button class="sub-opt" type="button" role="menuitem" onclick="openSubscribe('all')"><span class="so-ico">🔔</span><span class="so-txt"><strong>Everything</strong><small>Blog posts + all updates</small></span></button> </div> <div id="subscribeOverlay" role="dialog" aria-modal="true" aria-labelledby="subscribeTitle"> <div id="subscribeCard"> <button class="sub-close" type="button" onclick="closeSubscribe()" aria-label="Close">✕</button> <h3 id="subscribeTitle">Subscribe to Oracubes</h3> <p class="sub-lead">Stay in the loop — tell us where to reach you. Every field is optional (add an email if you'd like to actually receive updates).</p> <form id="subscribeForm" onsubmit="return submitSubscribe(event)"> <div class="sf-lbl">I want to receive</div> <select id="sfLevel" aria-label="Subscription level"> <option value="blog">📝 Blog updates only</option> <option value="updates">📣 All website updates</option> <option value="all">🔔 Everything</option> </select> <input id="sfName" type="text" placeholder="Your name" autocomplete="name"/> <input id="sfEmail" type="email" placeholder="Email address" autocomplete="email"/> <div class="sf-row"> <input id="sfPhone" type="tel" placeholder="Phone" autocomplete="tel"/> <input id="sfCompany" type="text" placeholder="Company" autocomplete="organization"/> </div> <input class="sf-hp" type="text" id="sfHp" name="website" tabindex="-1" autocomplete="off" aria-hidden="true"/> <div id="subscribeStatus"></div> <button class="btn-grad" type="submit">Subscribe</button> </form> </div> </div> <script> (function(){ var menu=document.getElementById('subscribeMenu'); var overlay=document.getElementById('subscribeOverlay'); var form=document.getElementById('subscribeForm'); var statusEl=document.getElementById('subscribeStatus'); if(!menu||!overlay) return; var menuOpen=false, currentTrigger=null; function positionMenu(trigger){ var r=trigger.getBoundingClientRect(); menu.style.visibility='hidden'; menu.classList.add('open'); var mw=menu.offsetWidth, mh=menu.offsetHeight, gap=8; var left=r.left; if(left+mw>window.innerWidth-8) left=window.innerWidth-mw-8; if(left<8) left=8; var top=r.bottom+gap; if(top+mh>window.innerHeight-8 && r.top-gap-mh>8) top=r.top-gap-mh; menu.style.left=left+'px'; menu.style.top=top+'px'; menu.style.visibility=''; } function closeMenu(){ menu.classList.remove('open'); menuOpen=false; currentTrigger=null; document.removeEventListener('click',outsideClick,true); } function outsideClick(e){ if(menu.contains(e.target)) return; if(currentTrigger && currentTrigger.contains(e.target)) return; // let the trigger toggle handle it closeMenu(); } function toggleSubscribeMenu(trigger){ if(menuOpen){ closeMenu(); return; } var mob=document.getElementById('mobMenu'); if(mob) mob.classList.remove('open'); var ham=document.getElementById('ham'); if(ham) ham.classList.remove('active'); currentTrigger=trigger; positionMenu(trigger); menuOpen=true; setTimeout(function(){ document.addEventListener('click',outsideClick,true); },0); } function openSubscribe(level){ closeMenu(); var sel=document.getElementById('sfLevel'); if(sel && level) sel.value=level; if(statusEl){ statusEl.className=''; statusEl.textContent=''; } overlay.classList.add('open'); document.body.style.overflow='hidden'; var name=document.getElementById('sfName'); if(name) setTimeout(function(){name.focus();},350); } function closeSubscribe(){ overlay.classList.remove('open'); var pop=document.getElementById('popupOverlay'); if(!(pop && pop.classList.contains('active'))) document.body.style.overflow=''; // don't steal the marketing popup's scroll-lock } window.toggleSubscribeMenu=toggleSubscribeMenu; window.openSubscribe=openSubscribe; window.closeSubscribe=closeSubscribe; overlay.addEventListener('click',function(e){ if(e.target===overlay) closeSubscribe(); }); document.addEventListener('keydown',function(e){ if(e.key==='Escape'){ if(menuOpen) closeMenu(); if(overlay.classList.contains('open')) closeSubscribe(); } }); window.addEventListener('scroll',function(){ if(menuOpen) closeMenu(); },true); window.addEventListener('resize',function(){ if(menuOpen) closeMenu(); }); function submitSubscribe(e){ e.preventDefault(); var level=(document.getElementById('sfLevel')||{}).value||'all'; var payload={ action:'subscribe', name:((document.getElementById('sfName')||{}).value||'').trim(), email:((document.getElementById('sfEmail')||{}).value||'').trim(), phone:((document.getElementById('sfPhone')||{}).value||'').trim(), company:((document.getElementById('sfCompany')||{}).value||'').trim(), website:(document.getElementById('sfHp')||{}).value||'', wantsBlog: (level==='blog'||level==='all'), wantsUpdates: (level==='updates'||level==='all') }; function showOk(){ statusEl.className='active success'; statusEl.textContent="🎉 You're subscribed! Thanks for joining."; if(form) form.reset(); setTimeout(closeSubscribe,1600); } function showErr(){ statusEl.className='active error'; statusEl.textContent='Sorry, something went wrong. Please try again.'; } if(location.protocol==='file:'){ showOk(); return false; } fetch('/subscribe.php',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}) .then(function(r){ return r.json().catch(function(){return {};}); }) .then(function(res){ if(res && res.ok) showOk(); else showErr(); }) .catch(showErr); return false; } window.submitSubscribe=submitSubscribe; })(); </script> <div id="popupOverlay"> <div id="popupCard" class="loading"> <div id="popupClose"> <svg viewBox="0 0 24 24" fill="none"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg> </div> <img src="/uploads/embedded-6.png" alt="Oracubes Technologies — Smart Digital Invoicing & ERP Solutions" draggable="false" onload="this.parentElement.classList.remove('loading')" onerror="this.parentElement.classList.remove('loading')"/> <div id="popupHint">Click anywhere to close</div> </div> </div> <!-- ═══════════════════════════════════════════════════════════════ BLOG READER — opens a full post (with its images & videos) ═══════════════════════════════════════════════════════════════ --> <style> #blogReaderOverlay{ position:fixed;inset:0;z-index:99999; display:flex;align-items:flex-start;justify-content:center; background:rgba(0,0,0,0);backdrop-filter:blur(0);-webkit-backdrop-filter:blur(0); opacity:0;visibility:hidden;cursor:pointer; padding:5vh 1.25rem;overflow-y:auto; transition:opacity .35s ease,background .35s ease,backdrop-filter .35s ease,visibility 0s .35s; } #blogReaderOverlay.active{ opacity:1;visibility:visible; background:var(--blog-reader-backdrop,rgba(0,0,0,.72));backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px); transition:opacity .35s ease,background .35s ease,backdrop-filter .35s ease,visibility 0s 0s; } #blogReaderCard{ position:relative;max-width:820px;width:100%;margin:auto;cursor:default; background:var(--card,#0c1322);border:1px solid var(--border,rgba(255,255,255,.08)); border-radius:18px;padding:2.5rem;color:var(--light,#e2e8f4); box-shadow:0 30px 80px rgba(0,0,0,.55); transform:translateY(30px);opacity:0; transition:transform .45s cubic-bezier(.175,.885,.32,1.275),opacity .35s ease; } #blogReaderOverlay.active #blogReaderCard{transform:translateY(0);opacity:1} #blogReaderClose{ position:absolute;top:14px;right:14px;z-index:2; width:36px;height:36px;border-radius:50%; background:var(--popup-close-bg,rgba(0,0,0,.55));border:1px solid rgba(255,255,255,.15); display:flex;align-items:center;justify-content:center;cursor:pointer; transition:background .25s,transform .25s; } #blogReaderClose:hover{background:var(--popup-close-hover,rgba(220,38,38,.85));transform:scale(1.12)} #blogReaderClose svg{width:16px;height:16px;stroke:#fff;stroke-width:2.5;stroke-linecap:round} #blogReaderCover{width:100%;max-height:360px;object-fit:cover;border-radius:12px;margin-bottom:1.5rem;display:none} #blogReaderCat{display:inline-block;background:var(--blog-reader-cat-bg,rgba(var(--a1-rgb),.15));color:var(--accent2,#93c5fd);font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;letter-spacing:.5px;text-transform:uppercase;margin-bottom:.75rem} #blogReaderTitle{font-size:28px;font-weight:800;line-height:1.25;letter-spacing:-.5px;margin-bottom:.6rem;color:var(--blog-reader-title,#fff)} #blogReaderMeta{font-size:13px;color:#64748b;font-family:var(--mono);margin-bottom:.6rem;display:flex;gap:14px;flex-wrap:wrap} #blogReaderViews{font-size:13px;color:#64748b;font-family:var(--mono);margin-bottom:1.5rem;letter-spacing:.5px} #blogReaderBody{font-size:15.5px;line-height:1.8;color:var(--blog-reader-body,#cbd5e1)} #blogReaderBody h2{font-size:22px;font-weight:700;color:#fff;margin:1.4em 0 .5em} #blogReaderBody h3{font-size:18px;font-weight:600;color:#fff;margin:1.2em 0 .4em} #blogReaderBody p{margin:.8em 0} #blogReaderBody ul,#blogReaderBody ol{margin:.8em 0 .8em 1.5em} #blogReaderBody a{color:var(--accent2,#93c5fd);text-decoration:underline} #blogReaderBody blockquote{border-left:3px solid var(--a1);padding-left:1em;margin:1em 0;color:var(--accent2,#93c5fd);font-style:italic} #blogReaderBody img,#blogReaderBody video{max-width:100%;height:auto;border-radius:10px;margin:1em 0;display:block} #blogReaderBody video{background:#000;width:100%} @media(max-width:640px){ #blogReaderCard{padding:1.5rem;border-radius:14px} #blogReaderTitle{font-size:22px} } </style> <div id="blogReaderOverlay" onclick="if(event.target===this)closeBlogPost()"> <article id="blogReaderCard"> <div id="blogReaderClose" onclick="closeBlogPost()" title="Close"> <svg viewBox="0 0 24 24" fill="none"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg> </div> <img id="blogReaderCover" alt=""/> <span id="blogReaderCat"></span> <h1 id="blogReaderTitle"></h1> <div id="blogReaderMeta"></div> <div id="blogReaderViews"></div> <div id="blogReaderBody"></div> </article> </div> <script> (function(){ const overlay = document.getElementById('popupOverlay'); const card = document.getElementById('popupCard'); if(!overlay||!card) return; // Read config for popup settings const LS_KEY='oracubes_site_config'; let popupCfg={enabled:'yes',delay:800}; try{ const stored=localStorage.getItem(LS_KEY); if(stored){const cfg=JSON.parse(stored);if(cfg.popup)popupCfg=cfg.popup;} }catch(e){} const DELAY = popupCfg.delay||800; // Returning-visitor detection — read the flag BEFORE setting it, so a first-ever // visit is correctly treated as new. Persistent across sessions (localStorage). var isReturning=false; try{ isReturning=!!localStorage.getItem('oracubes_returning'); localStorage.setItem('oracubes_returning','1'); }catch(e){} // Effective content: returning visitors can get override copy/CTA when configured. var rv=(isReturning&&popupCfg.returning)?popupCfg.returning:{}; var pHeading=rv.heading||popupCfg.heading, pDesc=rv.desc||popupCfg.desc, pBtnText=rv.btnText||popupCfg.btnText, pBtnLink=rv.btnLink||popupCfg.btnLink; // Apply dynamic popup content from config if(popupCfg.image||pHeading||pDesc){ let html='<div id="popupClose"><svg viewBox="0 0 24 24" fill="none"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></div>'; if(popupCfg.image)html+='<img src="'+popupCfg.image+'" alt="'+(popupCfg.alt||'')+'" draggable="false" onload="this.parentElement.classList.remove(\'loading\')" onerror="this.parentElement.classList.remove(\'loading\')"/>'; if(pHeading||pDesc||pBtnText){ html+='<div style="padding:1.5rem 2rem;background:var(--bg2)">'; if(pHeading)html+='<h3 style="color:#e2e8f4;font-size:20px;font-weight:700;margin-bottom:.5rem">'+pHeading+'</h3>'; if(pDesc)html+='<p style="color:#94a3b8;font-size:14px;line-height:1.6;margin-bottom:.75rem">'+pDesc+'</p>'; if(pBtnText)html+='<a href="'+(pBtnLink||'#')+'" style="display:inline-block;background:var(--popup-cta-bg,var(--grad));color:#fff;padding:10px 24px;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;cursor:pointer" onclick="document.getElementById(\'popupOverlay\').classList.remove(\'active\');document.body.style.overflow=\'\'">'+pBtnText+'</a>'; html+='</div>'; } html+='<div id="popupHint">Click anywhere to close</div>'; card.innerHTML=html; card.classList.remove('loading'); } // Frequency cap so the popup doesn't nag on every refresh/repeat visit. // every — no cap (legacy behaviour) session — once per tab (sessionStorage) // day — once per 24h week — once per 7 days (localStorage ts) function popupAllowed(freq){ try{ if(freq==='session') return !sessionStorage.getItem('oracubes_popup_shown'); var last=parseInt(localStorage.getItem('oracubes_popup_last')||'0',10)||0; var win=(freq==='week')?7*24*3600*1000:24*3600*1000; return (Date.now()-last)>=win; }catch(e){ return true; } } function popupStamp(freq){ try{ if(freq==='session') sessionStorage.setItem('oracubes_popup_shown','1'); else localStorage.setItem('oracubes_popup_last',String(Date.now())); }catch(e){} } function showPopup(){ if(popupCfg.enabled==='no')return; // Don't show popup while admin panel is open var adminRoot=document.getElementById('adminRoot'); if(adminRoot && adminRoot.classList.contains('open')) return; // …or while the visual editor is active if(document.body.classList.contains('edit-mode')) return; // Respect the configured show frequency (default: once per day). Old configs // saved before this feature have no `frequency` → treated as 'day'. var freq=popupCfg.frequency||'day'; if(freq!=='every' && !popupAllowed(freq)) return; overlay.classList.add('active'); document.body.style.overflow = 'hidden'; if(freq!=='every') popupStamp(freq); } function hidePopup(){ if(!overlay.classList.contains('active')) return; overlay.classList.remove('active'); document.body.style.overflow = ''; } // Stop clicks inside popup card from closing the overlay card.addEventListener('click', function(e){ e.stopPropagation(); }); overlay.addEventListener('click', hidePopup); document.addEventListener('keydown', function(e){ if(e.key === 'Escape') hidePopup(); }); // Bind close button const closeBtn=document.getElementById('popupClose'); if(closeBtn)closeBtn.addEventListener('click',function(e){e.stopPropagation();hidePopup();}); function schedulePopup(){ if(document.readyState === 'complete'){ setTimeout(showPopup, DELAY); } else { window.addEventListener('load', function(){ setTimeout(showPopup, DELAY); }); } } // Wait for the intro "Logo Unfold" reveal to finish so the popup never appears over it. // Race-free: oracubes:intro-done is only ever dispatched asynchronously, and every intro // exit path fires it, so the popup is guaranteed to schedule. if(window.__ocIntroDone){ schedulePopup(); } else { window.addEventListener('oracubes:intro-done', schedulePopup, {once:true}); } })(); </script> <!-- Oracubes Assistant chat widget (assets in /chatbot/). Absolute paths are required: the SPA fallback serves this page for deep routes like /services. --> <script src="/chatbot/widget/oracubes-widget.js" data-base="/chatbot/" data-position="bottom-left" data-accent="#c8202e" data-title="Oracubes Assistant" defer></script> <script>window.__SEO_ROUTES={"home":{"t":"Oracubes Technologies | ERP Software Products, Odoo, Web & Apps — Sialkot, Pakistan","d":"ERP software company in Sialkot, Pakistan since 2003. 7 proprietary ERP products — CONIAC, AUGER, SQUID, EAGLE EYE 7, SMART SHOP, SMART RESTAURANT, BANQUET MANAGEMENT — plus Odoo implementation, web design, mobile apps & IT consultancy.","c":"/"},"services":{"t":"Services — ERP Products, Odoo, Web & App Development | Oracubes","d":"7 proprietary ERP products (CONIAC, AUGER, SQUID, EAGLE EYE 7, SMART SHOP, SMART RESTAURANT, BANQUET MANAGEMENT), Odoo ERP implementation, UI/UX & web development, eCommerce, iOS/Android apps, AI chatbots and IT consultancy.","c":"/services"},"integrations":{"t":"Integrations — Shopify, SAP, Oracle, Stripe & more | Oracubes","d":"Oracubes connects your ERP and store: Shopify, WooCommerce, Amazon, Magento, Microsoft, Salesforce, SAP, Oracle, Stripe, PayPal and ZATCA e-invoicing.","c":"/integrations"},"industries":{"t":"Industries — Sportswear, Education, Surgical, Retail & More | Oracubes","d":"Purpose-built ERP for sportswear & garments, education, surgical, retail, hotels and restaurants — plus web and app solutions for logistics, wholesale, eCommerce, healthcare and more.","c":"/industries"},"verticals":{"t":"Industries — Sportswear, Education, Surgical, Retail & More | Oracubes","d":"Purpose-built ERP for sportswear & garments, education, surgical, retail, hotels and restaurants — plus web and app solutions for logistics, wholesale, eCommerce, healthcare and more.","c":"/industries"},"portfolio":{"t":"Portfolio — Our Work | Oracubes Technologies","d":"See ERP, web and app projects delivered by Oracubes Technologies for clients in Pakistan and worldwide.","c":"/portfolio"},"clients":{"t":"Our Clients | Oracubes Technologies","d":"Businesses that trust Oracubes across sportswear, surgical, education, retail and manufacturing — including Capmount Impex, FineMed Surgical, Nation Sports and Allied Schools.","c":"/clients"},"about":{"t":"About Oracubes Technologies | ERP Software Company Since 2003","d":"Founded in 2003 by Aziz ur Rehman, Oracubes Technologies builds 7 proprietary ERP products and delivers Odoo, web and app solutions from Sialkot, Pakistan.","c":"/about"},"blog":{"t":"Blog & Insights | Oracubes Technologies","d":"Articles and insights on ERP, web, apps and digital transformation from the Oracubes team.","c":"/blog"},"contact":{"t":"Contact Oracubes Technologies | Sialkot, Pakistan","d":"Get in touch with Oracubes Technologies. Call/WhatsApp +92 345 6822204, call +92 300 9495640, or email info@oracubes.com.","c":"/contact"},"freetrial":{"t":"Start a Free Trial | Oracubes Technologies","d":"Try Oracubes software free — AUGER School Management, SMART SHOP POS and more. Start your free trial today.","c":"/freetrial"},"products":{"t":"ERP Products — 7 Industry-Specific ERP Systems | Oracubes","d":"Explore Oracubes' 7 proprietary ERP products: CONIAC (sportswear & garments), AUGER (education), SQUID (surgical), EAGLE EYE 7 (hotels), SMART SHOP (retail), SMART RESTAURANT and BANQUET MANAGEMENT.","c":"/products"},"products/coniac":{"t":"CONIAC — Sportswear & Garments ERP | Oracubes","d":"CONIAC is a complete ERP for apparel and sports-goods manufacturers, covering export documentation, production planning and shipment. Built by Oracubes Technologies in Sialkot, Pakistan.","c":"/products/coniac"},"products/auger":{"t":"AUGER — Education ERP | Oracubes","d":"AUGER is an education ERP for schools, colleges and universities, with fee management, exams, admissions/inquiry and HR. Built by Oracubes Technologies in Sialkot, Pakistan.","c":"/products/auger"},"products/squid":{"t":"SQUID — Surgical Industry ERP | Oracubes","d":"SQUID is an ERP for surgical-instrument manufacturers with export, production and store control, and Urdu language support. Built by Oracubes Technologies in Sialkot, Pakistan.","c":"/products/squid"},"products/eagle-eye-7":{"t":"EAGLE EYE 7 — Hotel Management ERP | Oracubes","d":"EAGLE EYE 7 is a complete hotel management ERP with front office, POS, banquet and gym management. Built by Oracubes Technologies in Sialkot, Pakistan.","c":"/products/eagle-eye-7"},"products/smart-shop":{"t":"SMART SHOP — Retail POS & ERP | Oracubes","d":"SMART SHOP is a retail POS and ERP for medical stores, general stores, shoe shops and wholesale agencies, with accounts, inventory and HR. Built by Oracubes Technologies in Sialkot, Pakistan.","c":"/products/smart-shop"},"products/smart-restaurant":{"t":"SMART RESTAURANT — Restaurant POS | Oracubes","d":"SMART RESTAURANT is a touchscreen restaurant POS with inventory, procurement and staff management. Built by Oracubes Technologies in Sialkot, Pakistan.","c":"/products/smart-restaurant"},"products/banquet-management":{"t":"BANQUET MANAGEMENT — Banquet & Event Venue ERP | Oracubes","d":"BANQUET MANAGEMENT handles reservations, catering, events, accounts, POS and administration for banquet halls and event venues. Built by Oracubes Technologies in Sialkot, Pakistan.","c":"/products/banquet-management"},"freetrial/point-fo-sale":{"t":"Point fo sale Free Trial | Oracubes Technologies","d":"Oracubes Technologies presents a powerful and intelligent Retail Business Management Software, designed as a complete Point of Sale (POS) solution. This system is built to streamline and automate all operations of modern retail businesses, ensuring efficiency, accuracy, and real-time control over…","c":"/freetrial/point-fo-sale"},"freetrial/school-management-system":{"t":"School Management System Free Trial | Oracubes Technologies","d":"Oracubes proudly presents a powerful and intelligent School Management System designed to streamline and digitize every aspect of modern educational institutions. Our software provides complete coverage of daily school operations, ensuring efficiency, accuracy, and transparency across all departm…","c":"/freetrial/school-management-system"}};window.__DEEPLINK={"type":"trial","slug":"school-management-system"};</script><!-- render.php injects window.__SEO_ROUTES + window.__DEEPLINK here --> </body> </html>