Jake Vanderwerf
2025-09-30 12e5205559c8def89659f9922a91b8b628289664
Square update and initial Referral System started
8 files modified
3 files added
2891 ■■■■ changed files
JVBase.php 6 ●●●●● patch | view | raw | blame | history
assets/js/dash/SquareCheckout.js 17 ●●●● patch | view | raw | blame | history
assets/js/min/square.min.js 2 ●●● patch | view | raw | blame | history
inc/integrations/Square.php 428 ●●●● patch | view | raw | blame | history
inc/managers/EmailManager.php 4 ●●● patch | view | raw | blame | history
inc/managers/ReferralManager.php 1036 ●●●●● patch | view | raw | blame | history
inc/managers/ReferralManager2.php 1006 ●●●●● patch | view | raw | blame | history
inc/managers/_setup.php 4 ●●●● patch | view | raw | blame | history
inc/registry/CheckCustomTables.php 70 ●●●●● patch | view | raw | blame | history
inc/rest/_setup.php 3 ●●●●● patch | view | raw | blame | history
inc/rest/routes/ReferralRoutes.php 315 ●●●●● patch | view | raw | blame | history
JVBase.php
@@ -5,6 +5,7 @@
use JVBase\managers\ErrorHandler;
use JVBase\managers\OperationQueue;
use JVBase\managers\DashboardManager;
use JVBase\managers\ReferralManager;
use JVBase\managers\RoleManager;
use JVBase\managers\SchemaManager;
use JVBase\managers\AdminPages;
@@ -81,6 +82,11 @@
            'userTerms'     => new UserTermsManager(),
        ];
        if (Features::forSite()->has('referrals')) {
            $this->managers['referral'] = new ReferralManager();
            $this->routes['referral'] = new ReferralRoutes();
        }
        if (Features::forSite()->has('dashboard')) {
            $this->managers['dash'] = new DashboardManager();
        }
assets/js/dash/SquareCheckout.js
@@ -507,29 +507,30 @@
        if (squareConfig.isOpen !== '1') {
            return;
        }
        const response = await fetch(this.config.api_url + 'checkout', {
        // Square Web Payments SDK handles EVERYTHING
        // We just need to track the order for status updates
        const response = await fetch(this.config.api_url + 'save-order', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-WP-Nonce': this.config.nonce
            },
            body: JSON.stringify({
                source_id: token,
                amount: orderData.total,
                items: orderData.items,
                order_id: token.orderId,  // From Square SDK response
                payment_id: token.paymentId,  // From Square SDK response
                customer: orderData.customer,
                note: orderData.note,
                currency: this.config.currency,
                items: orderData.items,
                action: 'jvb_integration_action',
                service: 'square',
                integration_action: 'process_order'
                integration_action: 'save_order'
            })
        });
        const result = await response.json();
        if (!response.ok) {
            throw new Error(result.message || 'Payment failed');
            throw new Error(result.message || 'Failed to save order');
        }
        return result;
assets/js/min/square.min.js
@@ -1 +1 @@
(()=>{class t{constructor(t={}){this.checkout=document.querySelector("aside#cart"),this.checkout&&(this.config=Object.assign({application_id:squareConfig.application_id,location_id:squareConfig.location_id,api_url:squareConfig.api_url,nonce:squareConfig.nonce,currency:squareConfig.currency||"CAD"},t),this.stepMultiplier=1,this.cache=new window.jvbCache("cart",{TTL:864e5}),this.a11y=window.jvbA11y,this.initCart(),this.payments=null,this.card=null,this.isInitialized=!1,this.clickHandler=this.handleClick.bind(this),this.keyHandler=this.handleEscape.bind(this),this.changeHandler=this.handleChange.bind(this),this.initElements(),this.bindEvents(),this.init(),this.toggle.hidden=!1)}async initCart(){this.cartItems=await this.cache.get("cart")??new Map,console.log("cart",this.cartItems),this.cartItems.size>0&&this.notifyRestoredCart()}handleClick(t){if(window.targetCheck(t,".toggle-cart")){window.targetCheck(t,".toggle-cart");console.log("Toggle found. Toggling cart"),this.toggleCart()}else if(window.targetCheck(t,"button")&&window.targetCheck(t,"div.quantity")){let e=window.targetCheck(t,"div.quantity");this.handleNumberClick(t,e)}else if(window.targetCheck(t,"[data-add-to-cart]")){let e=window.targetCheck(t,"[data-add-to-cart]");this.handleAddToCart(e)}else if(window.targetCheck(t,"[data-remove-from-cart]")){let e=window.targetCheck(t,"[data-remove-from-cart]");this.handleRemoveFromCart(e)}else window.targetCheck(t,"[data-clear-cart]")?this.clearCart():this.checkout.classList.contains("expanded")&&!this.checkout.contains(t.target)&&t.target!==this.toggle&&this.closeCart()}handleChange(t,e){console.log("Checkout change");let a=window.targetCheck(t,".quantity-input");if(a){let e=t.target.closest(".quantity"),i=a.value;if(window.targetCheck(t,".cart-items")){let t=document.querySelector(`.menu-section [data-id="${e.dataset.id}"] input`);t&&(t.value=a.value)}i>0?this.handleAddToCart(e):this.handleRemoveFromCart(e)}}handleNumberClick(t,e){console.log(e),t.preventDefault();let a=0;if(t.target.closest(".increase")?a+=1:t.target.closest(".decrease")&&(a-=1),0!==a){let[t,i]=[parseInt(e.dataset.step),e.querySelector("input")],r=""===i.value?0:parseInt(i.value);i.value=r+t*a*this.stepMultiplier,i.dispatchEvent(new Event("change",{bubbles:!0})),this.handleNumberLimits(e)}}handleNumberLimits(t){let[e,a,i,r,s]=[t.dataset.min,t.dataset.max,t.querySelector("input"),t.querySelector(".increase"),t.querySelector(".decrease")],o=parseInt(i.value);o<e?(i.value=e,s.disabled=!0):o>a?(i.value=a,r.disabled=!1):r.disabled?r.disabled=!1:s.disabled&&(s.disabled=!1)}toggleCart(){this.checkout.classList.contains("expanded")?this.closeCart():this.openCart()}openCart(t="Opened Cart"){this.checkout.classList.add("expanded"),this.toggle.title="Hide cart",this.toggle.ariaExpanded=!0,this.toggle.querySelector("span").textContent="Close Cart",this.a11y.announce(t),this.maybeAddEmptyState(),document.addEventListener("keydown",this.keyHandler)}maybeAddEmptyState(){let t=this.itemsList.querySelector(".empty");if(t&&t.remove(),0===this.cartItems.size){this.checkoutPanel.disabled=!0,this.checkoutPanel.title="Add some things to your cart first!";let t=window.getTemplate("emptyCart");this.itemsList.append(t),this.table.closest("table").hidden=!0,this.total.hidden=!0,this.a11y.announce("Nothing in Cart")}else this.checkoutPanel.disabled=!1,this.table.closest("table").hidden=!1,this.total.hidden=!1,this.checkoutPanel.title="Checkout"}closeCart(t="Closed Cart"){this.checkout.classList.remove("expanded"),this.toggle.title="Show Cart",this.toggle.ariaExpanded=!1,this.toggle.querySelector("span").textContent="",this.a11y.announce(t),document.removeEventListener("keydown",this.keyHandler)}handleEscape(t){"Escape"===t.key?(this.closeCart("Closed Cart with escape key"),this.stepMultiplier=1):t.ctrlKey&&t.shiftKey?this.stepMultiplier=Math.max(100*parseInt(this.stepMultiplier),1e3):t.shiftKey&&(this.stepMultiplier=Math.max(10*parseInt(this.stepMultiplier),1e3))}handleAddToCart(t){let e=t.dataset.id;this.createItemElement(t);let a=parseFloat(t.dataset.price),i=parseInt(t.querySelector(".quantity-input")?.value)??1,r=parseFloat(a*i);this.cartItems.set(e,{post_id:e,name:t.dataset.name,price:a,quantity:i,total:r,square_catalog_id:t.dataset.squareCatalogId}),this.saveCart()}notifyRestoredCart(){let t=window.getTemplate("restoredCart");this.checkout.querySelector(".tab-content[data-tab=cartItems]").insertBefore(t,this.itemsList),this.cartItems.forEach((t=>{console.log(t);let e=window.getTemplate("cartItem"),a=e.querySelector(".quantity"),i=t.price,r=t.quantity;[a.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,a.dataset.price,a.dataset.squareCatalogId,e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[t.post_id,t.name,window.formatPrice(i),i,t.square_catalog_id,r,window.formatPrice(r*i)],this.table.append(e)})),this.updateTotal()}handleRemoveFromCart(t){if(confirm("This will remove this item from the cart. Continue?")){t.querySelector("[data-id]")||(t=t.closest(".item")?.querySelector(".quantity.field"));let e=t.dataset.id;this.cartItems.delete(e),this.table.querySelector(`[data-id="${e}"]`)?.closest("tr").remove();let a=document.querySelector(`[data-id="${e}"] input`);a&&(a.value=0),this.maybeAddEmptyState(),this.saveCart()}}clearCart(){this.cartItems.clear(),window.removeChildren(this.table),this.saveCart()}saveCart(){this.updateTotal(),this.cache.set("cart",this.cartItems)}updateTotal(){let t=0;this.cartItems.forEach((e=>{console.log(e),t+=e.total}));let e=.05*t;t=window.formatPrice(t+e),e=window.formatPrice(e),window.eraseText(this.totalTax),window.eraseText(this.grandTotal),window.typeText(this.totalTax,e),window.typeText(this.grandTotal,t),this.totalTax.classList.remove("typeText")}createItemElement(t){let e=this.itemsList.querySelector(`[data-id="${t.dataset.id}"]`),a=!1,i=t.dataset.price,r=t.querySelector('[name="quantity"]')?.value??1;if(e)e=e.closest("tr");else{a=!0,e=window.getTemplate("cartItem");let r=e.querySelector(".quantity");[r.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,r.dataset.price,r.dataset.squareCatalogId]=[t.dataset.id,t.dataset.name,window.formatPrice(i),i,t.dataset.squareCatalogId]}[e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[r,window.formatPrice(r*i)],a&&(e.classList.add("adding"),this.table.append(e),setTimeout((()=>{e.classList.remove("adding")}),500))}async init(){if(window.Square)try{this.payments=window.Square.payments(this.config.application_id,this.config.location_id),await this.initializePaymentMethods(),this.isInitialized=!0,document.dispatchEvent(new CustomEvent("squareCheckoutReady",{detail:{checkout:this}}))}catch(t){console.error("Failed to initialize Square payments:",t),this.handleError(t)}else console.error("Square Web Payments SDK not loaded")}initElements(){this.toggle=document.querySelector(".toggle-cart"),"1"!==squareConfig.isOpen&&(this.toggle.disabled=!0,this.toggle.title="Currently closed for online ordering"),this.checkoutPanel=this.checkout.querySelector('button[data-tab="checkout"]'),this.itemsList=this.checkout.querySelector(".cart-items"),this.table=this.checkout.querySelector(".cart-items tbody"),this.total=this.checkout.querySelector(".cart-total"),this.totalTax=this.total.querySelector(".tax span"),this.grandTotal=this.total.querySelector(".total span"),this.checkoutForm=this.checkout.querySelector("form"),this.tabs=new window.jvbTabs(this.checkoutForm,{updateURL:!1}),console.log("Initialized Checkout")}bindEvents(){this.checkoutForm.addEventListener("submit",(t=>this.handleFormSubmit(t))),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}async initializePaymentMethods(){if(document.getElementById("square-card-container"))try{this.card=await this.payments.card({style:this.getCardStyle()}),await this.card.attach("#square-card-container")}catch(t){throw console.error("Failed to initialize card:",t),t}}getCardStyle(){return{input:{fontSize:"16px",fontFamily:"inherit",color:"#333",backgroundColor:"#fff"},".input-container":{borderColor:"#ccc",borderRadius:"4px"},".input-container.is-focus":{borderColor:"#007cba"},".input-container.is-error":{borderColor:"#d63638"}}}async handleFormSubmit(t){if("1"!==squareConfig.isOpen)return;if(t.preventDefault(),!this.isInitialized)return void this.handleError("Checkout not initialized");const e=t.target,a=this.extractOrderData(e);try{window.jvbLoading.showLoading("Processing payment...");const t=await this.processPayment(a);this.handleSuccess(t,e)}catch(t){this.handleError(t)}finally{window.jvbLoading.hideLoading()}}extractOrderData(t){const e=Array.from(this.cartItems.values()).map((t=>({post_id:t.post_id,quantity:t.quantity,price:t.price,name:t.name})));return{total:100*e.reduce(((t,e)=>t+e.price*e.quantity),0),items:e,customer:{email:t.querySelector('[name="email"]')?.value||"",name:t.querySelector('[name="name"]')?.value||"",phone:t.querySelector('[name="phone"]')?.value||""},note:t.querySelector('[name="special_instructions"]')?.value||"",pickup_time:t.querySelector('[name="pickup_time"]')?.value||""}}async processPayment(t){try{const e=await this.card.tokenize();if("OK"===e.status)return await this.submitToServer(e.token,t);throw new Error("Card tokenization failed: "+(e.errors?.join(", ")||"Unknown error"))}catch(t){throw console.error("Payment processing failed:",t),t}}async submitToServer(t,e){if("1"!==squareConfig.isOpen)return;const a=await fetch(this.config.api_url+"checkout",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({source_id:t,amount:e.total,items:e.items,customer:e.customer,note:e.note,currency:this.config.currency,action:"jvb_integration_action",service:"square",integration_action:"process_order"})}),i=await a.json();if(!a.ok)throw new Error(i.message||"Payment failed");return i}trackOrder(t){this.orderId=t,this.scheduleOrderCheck(),this.checkout.querySelector("button[data-tab=order]").hidden=!1}scheduleOrderCheck(){window.debouncer.schedule("order",(()=>{this.checkOrderStatus()}),3e4)}async checkOrderStatus(){const t=await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`),e=await t.json();"ready"!==e.status&&this.scheduleOrderCheck(),this.updateOrderStatus(e)}updateOrderStatus(t){this.checkout.querySelectorAll(".status-item").forEach((e=>{e.dataset.status===t.status&&e.classList.add("active")})),this.checkout.querySelector("#eta").textContent=t.eta||"In progress"}async loadCustomerProfile(t){const e=await fetch("/wp-json/jvb/v1/square/customer",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({email:t})}),a=await e.json();a&&(this.displaySavedCards(a.cards),this.fillCustomerInfo(a.customer))}displaySavedCards(t){const e=document.getElementById("saved-cards");t.length&&(e.innerHTML=`\n            <h3>Saved Payment Methods</h3>\n            ${t.map((t=>`\n                <label>\n                    <input type="radio" name="payment_method" value="${t.id}">\n                    •••• ${t.last_4} (${t.card_brand})\n                </label>\n            `)).join("")}\n            <label>\n                <input type="radio" name="payment_method" value="new" checked>\n                Use new card\n            </label>\n        `)}handleSuccess(t,e){document.dispatchEvent(new CustomEvent("squareCheckoutSuccess",{detail:{result:t,form:e}}));const a=e.dataset.successUrl||`/order-confirmation/?order=${t.wp_order_id}`;window.location.href=a}handleError(t){console.error("Square checkout error:",t),document.dispatchEvent(new CustomEvent("squareCheckoutError",{detail:{error:t}})),window.jvbNotifications?.show?.(t.message||"Payment failed","error")}}document.addEventListener("DOMContentLoaded",(()=>{window.squareCheckout=new t}))})();
(()=>{class t{constructor(t={}){this.checkout=document.querySelector("aside#cart"),this.checkout&&(this.config=Object.assign({application_id:squareConfig.application_id,location_id:squareConfig.location_id,api_url:squareConfig.api_url,nonce:squareConfig.nonce,currency:squareConfig.currency||"CAD"},t),this.stepMultiplier=1,this.cache=new window.jvbCache("cart",{TTL:864e5}),this.a11y=window.jvbA11y,this.initCart(),this.payments=null,this.card=null,this.isInitialized=!1,this.clickHandler=this.handleClick.bind(this),this.keyHandler=this.handleEscape.bind(this),this.changeHandler=this.handleChange.bind(this),this.initElements(),this.bindEvents(),this.init(),this.toggle.hidden=!1)}async initCart(){this.cartItems=await this.cache.get("cart")??new Map,console.log("cart",this.cartItems),this.cartItems.size>0&&this.notifyRestoredCart()}handleClick(t){if(window.targetCheck(t,".toggle-cart")){window.targetCheck(t,".toggle-cart");console.log("Toggle found. Toggling cart"),this.toggleCart()}else if(window.targetCheck(t,"button")&&window.targetCheck(t,"div.quantity")){let e=window.targetCheck(t,"div.quantity");this.handleNumberClick(t,e)}else if(window.targetCheck(t,"[data-add-to-cart]")){let e=window.targetCheck(t,"[data-add-to-cart]");this.handleAddToCart(e)}else if(window.targetCheck(t,"[data-remove-from-cart]")){let e=window.targetCheck(t,"[data-remove-from-cart]");this.handleRemoveFromCart(e)}else window.targetCheck(t,"[data-clear-cart]")?this.clearCart():this.checkout.classList.contains("expanded")&&!this.checkout.contains(t.target)&&t.target!==this.toggle&&this.closeCart()}handleChange(t,e){console.log("Checkout change");let a=window.targetCheck(t,".quantity-input");if(a){let e=t.target.closest(".quantity"),i=a.value;if(window.targetCheck(t,".cart-items")){let t=document.querySelector(`.menu-section [data-id="${e.dataset.id}"] input`);t&&(t.value=a.value)}i>0?this.handleAddToCart(e):this.handleRemoveFromCart(e)}}handleNumberClick(t,e){console.log(e),t.preventDefault();let a=0;if(t.target.closest(".increase")?a+=1:t.target.closest(".decrease")&&(a-=1),0!==a){let[t,i]=[parseInt(e.dataset.step),e.querySelector("input")],r=""===i.value?0:parseInt(i.value);i.value=r+t*a*this.stepMultiplier,i.dispatchEvent(new Event("change",{bubbles:!0})),this.handleNumberLimits(e)}}handleNumberLimits(t){let[e,a,i,r,s]=[t.dataset.min,t.dataset.max,t.querySelector("input"),t.querySelector(".increase"),t.querySelector(".decrease")],o=parseInt(i.value);o<e?(i.value=e,s.disabled=!0):o>a?(i.value=a,r.disabled=!1):r.disabled?r.disabled=!1:s.disabled&&(s.disabled=!1)}toggleCart(){this.checkout.classList.contains("expanded")?this.closeCart():this.openCart()}openCart(t="Opened Cart"){this.checkout.classList.add("expanded"),this.toggle.title="Hide cart",this.toggle.ariaExpanded=!0,this.toggle.querySelector("span").textContent="Close Cart",this.a11y.announce(t),this.maybeAddEmptyState(),document.addEventListener("keydown",this.keyHandler)}maybeAddEmptyState(){let t=this.itemsList.querySelector(".empty");if(t&&t.remove(),0===this.cartItems.size){this.checkoutPanel.disabled=!0,this.checkoutPanel.title="Add some things to your cart first!";let t=window.getTemplate("emptyCart");this.itemsList.append(t),this.table.closest("table").hidden=!0,this.total.hidden=!0,this.a11y.announce("Nothing in Cart")}else this.checkoutPanel.disabled=!1,this.table.closest("table").hidden=!1,this.total.hidden=!1,this.checkoutPanel.title="Checkout"}closeCart(t="Closed Cart"){this.checkout.classList.remove("expanded"),this.toggle.title="Show Cart",this.toggle.ariaExpanded=!1,this.toggle.querySelector("span").textContent="",this.a11y.announce(t),document.removeEventListener("keydown",this.keyHandler)}handleEscape(t){"Escape"===t.key?(this.closeCart("Closed Cart with escape key"),this.stepMultiplier=1):t.ctrlKey&&t.shiftKey?this.stepMultiplier=Math.max(100*parseInt(this.stepMultiplier),1e3):t.shiftKey&&(this.stepMultiplier=Math.max(10*parseInt(this.stepMultiplier),1e3))}handleAddToCart(t){let e=t.dataset.id;this.createItemElement(t);let a=parseFloat(t.dataset.price),i=parseInt(t.querySelector(".quantity-input")?.value)??1,r=parseFloat(a*i);this.cartItems.set(e,{post_id:e,name:t.dataset.name,price:a,quantity:i,total:r,square_catalog_id:t.dataset.squareCatalogId}),this.saveCart()}notifyRestoredCart(){let t=window.getTemplate("restoredCart");this.checkout.querySelector(".tab-content[data-tab=cartItems]").insertBefore(t,this.itemsList),this.cartItems.forEach((t=>{console.log(t);let e=window.getTemplate("cartItem"),a=e.querySelector(".quantity"),i=t.price,r=t.quantity;[a.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,a.dataset.price,a.dataset.squareCatalogId,e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[t.post_id,t.name,window.formatPrice(i),i,t.square_catalog_id,r,window.formatPrice(r*i)],this.table.append(e)})),this.updateTotal()}handleRemoveFromCart(t){if(confirm("This will remove this item from the cart. Continue?")){t.querySelector("[data-id]")||(t=t.closest(".item")?.querySelector(".quantity.field"));let e=t.dataset.id;this.cartItems.delete(e),this.table.querySelector(`[data-id="${e}"]`)?.closest("tr").remove();let a=document.querySelector(`[data-id="${e}"] input`);a&&(a.value=0),this.maybeAddEmptyState(),this.saveCart()}}clearCart(){this.cartItems.clear(),window.removeChildren(this.table),this.saveCart()}saveCart(){this.updateTotal(),this.cache.set("cart",this.cartItems)}updateTotal(){let t=0;this.cartItems.forEach((e=>{console.log(e),t+=e.total}));let e=.05*t;t=window.formatPrice(t+e),e=window.formatPrice(e),window.eraseText(this.totalTax),window.eraseText(this.grandTotal),window.typeText(this.totalTax,e),window.typeText(this.grandTotal,t),this.totalTax.classList.remove("typeText")}createItemElement(t){let e=this.itemsList.querySelector(`[data-id="${t.dataset.id}"]`),a=!1,i=t.dataset.price,r=t.querySelector('[name="quantity"]')?.value??1;if(e)e=e.closest("tr");else{a=!0,e=window.getTemplate("cartItem");let r=e.querySelector(".quantity");[r.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,r.dataset.price,r.dataset.squareCatalogId]=[t.dataset.id,t.dataset.name,window.formatPrice(i),i,t.dataset.squareCatalogId]}[e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[r,window.formatPrice(r*i)],a&&(e.classList.add("adding"),this.table.append(e),setTimeout((()=>{e.classList.remove("adding")}),500))}async init(){if(window.Square)try{this.payments=window.Square.payments(this.config.application_id,this.config.location_id),await this.initializePaymentMethods(),this.isInitialized=!0,document.dispatchEvent(new CustomEvent("squareCheckoutReady",{detail:{checkout:this}}))}catch(t){console.error("Failed to initialize Square payments:",t),this.handleError(t)}else console.error("Square Web Payments SDK not loaded")}initElements(){this.toggle=document.querySelector(".toggle-cart"),"1"!==squareConfig.isOpen&&(this.toggle.disabled=!0,this.toggle.title="Currently closed for online ordering"),this.checkoutPanel=this.checkout.querySelector('button[data-tab="checkout"]'),this.itemsList=this.checkout.querySelector(".cart-items"),this.table=this.checkout.querySelector(".cart-items tbody"),this.total=this.checkout.querySelector(".cart-total"),this.totalTax=this.total.querySelector(".tax span"),this.grandTotal=this.total.querySelector(".total span"),this.checkoutForm=this.checkout.querySelector("form"),this.tabs=new window.jvbTabs(this.checkoutForm,{updateURL:!1}),console.log("Initialized Checkout")}bindEvents(){this.checkoutForm.addEventListener("submit",(t=>this.handleFormSubmit(t))),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}async initializePaymentMethods(){if(document.getElementById("square-card-container"))try{this.card=await this.payments.card({style:this.getCardStyle()}),await this.card.attach("#square-card-container")}catch(t){throw console.error("Failed to initialize card:",t),t}}getCardStyle(){return{input:{fontSize:"16px",fontFamily:"inherit",color:"#333",backgroundColor:"#fff"},".input-container":{borderColor:"#ccc",borderRadius:"4px"},".input-container.is-focus":{borderColor:"#007cba"},".input-container.is-error":{borderColor:"#d63638"}}}async handleFormSubmit(t){if("1"!==squareConfig.isOpen)return;if(t.preventDefault(),!this.isInitialized)return void this.handleError("Checkout not initialized");const e=t.target,a=this.extractOrderData(e);try{window.jvbLoading.showLoading("Processing payment...");const t=await this.processPayment(a);this.handleSuccess(t,e)}catch(t){this.handleError(t)}finally{window.jvbLoading.hideLoading()}}extractOrderData(t){const e=Array.from(this.cartItems.values()).map((t=>({post_id:t.post_id,quantity:t.quantity,price:t.price,name:t.name})));return{total:100*e.reduce(((t,e)=>t+e.price*e.quantity),0),items:e,customer:{email:t.querySelector('[name="email"]')?.value||"",name:t.querySelector('[name="name"]')?.value||"",phone:t.querySelector('[name="phone"]')?.value||""},note:t.querySelector('[name="special_instructions"]')?.value||"",pickup_time:t.querySelector('[name="pickup_time"]')?.value||""}}async processPayment(t){try{const e=await this.card.tokenize();if("OK"===e.status)return await this.submitToServer(e.token,t);throw new Error("Card tokenization failed: "+(e.errors?.join(", ")||"Unknown error"))}catch(t){throw console.error("Payment processing failed:",t),t}}async submitToServer(t,e){if("1"!==squareConfig.isOpen)return;const a=await fetch(this.config.api_url+"save-order",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({order_id:t.orderId,payment_id:t.paymentId,customer:e.customer,items:e.items,action:"jvb_integration_action",service:"square",integration_action:"save_order"})}),i=await a.json();if(!a.ok)throw new Error(i.message||"Failed to save order");return i}trackOrder(t){this.orderId=t,this.scheduleOrderCheck(),this.checkout.querySelector("button[data-tab=order]").hidden=!1}scheduleOrderCheck(){window.debouncer.schedule("order",(()=>{this.checkOrderStatus()}),3e4)}async checkOrderStatus(){const t=await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`),e=await t.json();"ready"!==e.status&&this.scheduleOrderCheck(),this.updateOrderStatus(e)}updateOrderStatus(t){this.checkout.querySelectorAll(".status-item").forEach((e=>{e.dataset.status===t.status&&e.classList.add("active")})),this.checkout.querySelector("#eta").textContent=t.eta||"In progress"}async loadCustomerProfile(t){const e=await fetch("/wp-json/jvb/v1/square/customer",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({email:t})}),a=await e.json();a&&(this.displaySavedCards(a.cards),this.fillCustomerInfo(a.customer))}displaySavedCards(t){const e=document.getElementById("saved-cards");t.length&&(e.innerHTML=`\n            <h3>Saved Payment Methods</h3>\n            ${t.map((t=>`\n                <label>\n                    <input type="radio" name="payment_method" value="${t.id}">\n                    •••• ${t.last_4} (${t.card_brand})\n                </label>\n            `)).join("")}\n            <label>\n                <input type="radio" name="payment_method" value="new" checked>\n                Use new card\n            </label>\n        `)}handleSuccess(t,e){document.dispatchEvent(new CustomEvent("squareCheckoutSuccess",{detail:{result:t,form:e}}));const a=e.dataset.successUrl||`/order-confirmation/?order=${t.wp_order_id}`;window.location.href=a}handleError(t){console.error("Square checkout error:",t),document.dispatchEvent(new CustomEvent("squareCheckoutError",{detail:{error:t}})),window.jvbNotifications?.show?.(t.message||"Payment failed","error")}}document.addEventListener("DOMContentLoaded",(()=>{window.squareCheckout=new t}))})();
inc/integrations/Square.php
@@ -1195,7 +1195,7 @@
            $price = floatval($meta->getValue('price') ?: 0);
            $catalog_object['item_data']['variations'][] = [
                'type' => 'ITEM_VARIATION',
                'id' => $existing_square_id ? null : '#neb_menu_item_' . $postID . '_var_default',
                'id' => $existing_square_id ? null : '#'.BASE.'menu_item_' . $postID . '_var_default',
                'item_variation_data' => [
                    'name' => 'Regular',
                    'ordinal' => 0,
@@ -1213,7 +1213,7 @@
                $existing_var_id = get_post_meta($postID, BASE . '_square_variation_' . $index . '_id', true);
                $catalog_object['item_data']['variations'][] = [
                    'type' => 'ITEM_VARIATION',
                    'id' => $existing_var_id ?: '#neb_menu_item_' . $postID . '_var_' . $index,
                    'id' => $existing_var_id ?: '#'.BASE.'menu_item_' . $postID . '_var_' . $index,
                    'item_variation_data' => [
                        'name' => $variation['name'] ?? 'Variation ' . ($index + 1),
                        'ordinal' => $index,
@@ -1755,172 +1755,6 @@
    }
    /******************************************************************
     * ORDER PROCESSING
     ******************************************************************/
    /**
     * Process checkout order
     */
    public function processOrder($data):WP_Error|array
    {
        check_ajax_referer('square_checkout_nonce', 'nonce');
        $cart_items = json_decode(stripslashes($data['cart'] ?? '[]'), true);
        $customer_info = [
            'name' => sanitize_text_field($data['name'] ?? ''),
            'email' => sanitize_email($data['email'] ?? ''),
            'phone' => sanitize_text_field($data['phone'] ?? ''),
        ];
        $payment_token = sanitize_text_field($data['payment_token'] ?? '');
        if (empty($cart_items) || empty($payment_token)) {
            return new WP_Error('error', 'Invalid order data');
        }
        // Calculate order total
        $order_total = $this->calculateOrderTotal($cart_items);
        // Create Square order
        $order_response = $this->createSquareOrder($cart_items, $customer_info, $order_total);
        if (is_wp_error($order_response)) {
            return new WP_Error('error', $order_response->get_error_message());
        }
        // Process payment
        $payment_response = $this->processSquarePayment($payment_token, $order_response['order']['id'], $order_total);
        if (is_wp_error($payment_response)) {
            return new WP_Error('error', $order_response->get_error_message());
        }
        // Save order to user if logged in
        if (is_user_logged_in()) {
            $this->saveOrderToUser(get_current_user_id(), $order_response['order']['id']);
        }
        return [
            'success'   => true,
            'order_id' => $order_response['order']['id'],
            'receipt_url' => $payment_response['payment']['receipt_url'] ?? '',
            'message' => 'Order placed successfully!'
        ];
    }
    /**
     * Calculate order total from cart items
     */
    private function calculateOrderTotal(array $cart_items): int
    {
        $total = 0;
        foreach ($cart_items as $item) {
            $post_id = intval($item['id'] ?? 0);
            if (!$post_id) continue;
            $meta = new MetaManager($post_id, 'post');
            $price = floatval($meta->getValue('price'));
            $quantity = intval($item['quantity'] ?? 1);
            $total += ($price * $quantity * 100); // Convert to cents
        }
        // Add tax (simplified - you'd want more complex tax calculation)
        $tax_rate = floatval(get_option(BASE . 'square_tax_rate', 0.05));
        $tax = intval($total * $tax_rate);
        return $total + $tax;
    }
    /**
     * Create Square order
     */
    private function createSquareOrder(array $cart_items, array $customer_info, int $total): array|WP_Error
    {
        $line_items = [];
        foreach ($cart_items as $item) {
            $post_id = intval($item['id'] ?? 0);
            $variation_index = $item['variation'] ?? null;
            if (!$post_id) continue;
            $post = get_post($post_id);
            $square_catalog_id = get_post_meta($post_id, BASE . '_square_catalog_id', true);
            $line_item = [
                'quantity' => strval($item['quantity'] ?? 1),
                'name' => $post->post_title
            ];
            // If variation specified, get variation ID
            if ($variation_index !== null && $square_catalog_id) {
                $variation_id = get_post_meta($post_id, BASE . '_square_variation_' . $variation_index . '_id', true);
                if ($variation_id) {
                    $line_item['catalog_object_id'] = $variation_id;
                    // Add variation name to line item
                    $meta = new MetaManager($post_id, 'post');
                    $variations = $meta->getValue('product_variations');
                    if (!empty($variations[$variation_index]['name'])) {
                        $line_item['name'] .= ' - ' . $variations[$variation_index]['name'];
                    }
                }
            } elseif ($square_catalog_id) {
                // Use default variation if no specific variation
                $default_variation_id = get_post_meta($post_id, BASE . '_square_variation_0_id', true);
                if ($default_variation_id) {
                    $line_item['catalog_object_id'] = $default_variation_id;
                }
            }
            // If no catalog ID, create ad-hoc line item
            if (empty($line_item['catalog_object_id'])) {
                $meta = new MetaManager($post_id, 'post');
                $variations = $meta->getValue('product_variations');
                if ($variation_index !== null && !empty($variations[$variation_index]['price'])) {
                    $price = floatval($variations[$variation_index]['price']);
                } else {
                    $price = floatval($meta->getValue('price'));
                }
                $line_item['base_price_money'] = [
                    'amount' => intval($price * 100),
                    'currency' => 'CAD'
                ];
            }
            $line_items[] = $line_item;
        }
        return $this->postRequest('orders', [
            'order' => [
                'location_id' => $this->locationId,
                'line_items' => $line_items,
                'customer_id' => $this->getOrCreateSquareCustomer($customer_info)
            ]
        ]);
    }
    /**
     * Process Square payment
     */
    private function processSquarePayment(string $payment_token, string $order_id, int $amount): array|WP_Error
    {
        return $this->postRequest('payments', [
            'source_id' => $payment_token,
            'idempotency_key' => wp_generate_uuid4(),
            'amount_money' => [
                'amount' => $amount,
                'currency' => 'CAD'
            ],
            'order_id' => $order_id,
            'location_id' => $this->locationId
        ]);
    }
    /******************************************************************
     * WEBHOOK HANDLING
     ******************************************************************/
@@ -2121,7 +1955,12 @@
            'squareConfig',
            [
                'isOpen'        => jvbIsOpen(),
//              'currency' => get_option('jvb_currency', 'CAD')
                'application_id' => $this->credentials['client_id'] ?? '',
                'location_id' => $this->locationId,
                'environment' => $this->environment,
                'api_url' => rest_url('jvb/v1/square/'),
                'nonce' => wp_create_nonce('wp_rest'),
                'currency' => get_option(BASE . 'currency', 'CAD')
            ]
        );
    }
@@ -2167,6 +2006,43 @@
    }
    /**
     * Save order reference for status tracking
     */
    public function saveOrderReference($data): array
    {
        $order_id = sanitize_text_field($data['order_id'] ?? '');
        $payment_id = sanitize_text_field($data['payment_id'] ?? '');
        if (!$order_id) {
            return ['success' => false, 'message' => 'Invalid order data'];
        }
        // Save to user if logged in
        if (is_user_logged_in()) {
            $user_id = get_current_user_id();
            $orders = get_user_meta($user_id, BASE . '_square_orders', true) ?: [];
            $orders[] = [
                'order_id' => $order_id,
                'payment_id' => $payment_id,
                'date' => current_time('mysql'),
                'customer' => $data['customer'] ?? []
            ];
            // Keep last 50 orders
            if (count($orders) > 50) {
                $orders = array_slice($orders, -50);
            }
            update_user_meta($user_id, BASE . '_square_orders', $orders);
        }
        return [
            'success' => true,
            'order_id' => $order_id,
            'message' => 'Order saved'
        ];
    }
    /**
     * Save order to user meta
     */
    private function saveOrderToUser(int $user_id, string $order_id): void
@@ -2186,9 +2062,9 @@
    }
    /**
     * Get order status
     * Get order status (for customer feedback)
     */
    public function getOrderStatus($data):WP_Error|array
    public function getOrderStatus($data): WP_Error|array
    {
        $order_id = sanitize_text_field($data['order_id'] ?? '');
@@ -2196,26 +2072,24 @@
            return new WP_Error('error', 'Order ID required');
        }
        // Check cache first
        $cached_status = get_transient(BASE . 'square_order_' . $order_id);
        if ($cached_status !== false) {
            return $cached_status;
        }
        // Fetch from Square
        $response = $this->getRequest('orders/' . $order_id);
        $response = $this->getRequest('v2/orders/' . $order_id);
        if (is_wp_error($response)) {
            return new WP_Error('error', 'Could not fetch order status');
        }
        $status = $response['order']['state'] ?? 'UNKNOWN';
        set_transient(BASE . 'square_order_' . $order_id, $status, 5 * MINUTE_IN_SECONDS);
        $order = $response['order'] ?? [];
        $status_data = [
            'state' => $order['state'] ?? 'UNKNOWN',
            'fulfillment_eta' => $order['fulfillments'][0]['pickup_details']['pickup_at'] ?? null
        ];
        return array_merge([
            'success'   => true,
        ], $status);
        return [
            'success' => true,
            'status' => $status_data['state'],
            'eta' => $status_data['fulfillment_eta']
        ];
    }
    /**
@@ -3026,137 +2900,60 @@
    IMAGE PROCESSING
     *********************************************************/
    /**
     * Upload featured image to Square catalog
     *
     * @param int $postID WordPress post ID
     * @return array|WP_Error Result of the upload operation
     */
    public function uploadImageToSquare(int $imgID): array|WP_Error
    {
        if ($imgID === 0) {
            return new WP_Error('no_image', 'No image found for post');
        }
        try {
            // Get the supported image (converts WebP if needed)
            $supported_image_id = $this->getSupportedImage($imgID);
            // Check if we've already uploaded this image
            $existing_square_image_id = $this->getSquareImageId($supported_image_id);
            if ($existing_square_image_id) {
                return [
                    'success' => true,
                    'image_id' => $existing_square_image_id,
                    'message' => 'Image already exists in Square catalog'
                ];
            }
            // Step 1: Create image object in catalog
            $image_object = $this->createSquareImageObject($supported_image_id);
            if (is_wp_error($image_object)) {
                return $image_object;
            }
            // Step 2: Upload the actual image file
            $upload_result = $this->uploadImageFileToSquare(
                $supported_image_id,
                $image_object['id']
            );
            if (is_wp_error($upload_result)) {
                return $upload_result;
            }
            // Store the Square image ID for future reference
            $this->setSquareImageId($supported_image_id, $image_object['id']);
            return [
                'success' => true,
                'image_id' => $image_object['id'],
                'message' => 'Image successfully uploaded to Square'
            ];
        } catch (\Exception $e) {
            $this->logError('Failed to upload image to Square', [
                'method' => 'uploadImageToSquare',
                'error' => $e->getMessage()
            ]);
            return new WP_Error('upload_failed', $e->getMessage());
        }
    }
    /**
     * Create image object in Square catalog
     *
     * @param int $attachment_id WordPress attachment ID
     * @return array|WP_Error Square image object or error
     */
    protected function createSquareImageObject(int $attachment_id): array|WP_Error
    {
        $image_title = get_the_title($attachment_id);
        $alt_text = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
        $image_object = [
            'type' => 'IMAGE',
            'id' => '#IMAGE_' . $attachment_id . '_' . time(),
            'image_data' => [
                'name' => $image_title ?: 'Image',
                'caption' => $alt_text ?: ''
            ]
        ];
        $response = $this->postRequest('catalog/batch-upsert', [
            'idempotency_key' => wp_generate_uuid4(),
            'batches' => [[
                'objects' => [$image_object]
            ]]
        ]);
        if (is_wp_error($response)) {
            return $response;
        }
        if (!empty($response['objects'][0])) {
            return $response['objects'][0];
        }
        return new WP_Error('creation_failed', 'Failed to create image object in Square');
    }
    /**
     * Upload image file to Square
     *
     * @param int $attachment_id WordPress attachment ID
     * @param string $square_image_id Square catalog image ID
     * @param int $imgID WordPress attachment ID
     * @return array|WP_Error Upload result or error
     */
    protected function uploadImageFileToSquare(int $attachment_id, string $square_image_id): array|WP_Error
    protected function uploadImageToSquare(int $imgID): array|WP_Error
    {
        $file_path = get_attached_file($attachment_id);
        $supported_image_id = $this->getSupportedImage($imgID);
        // Check if already uploaded
        $existing_square_image_id = $this->getSquareImageId($supported_image_id);
        if ($existing_square_image_id) {
            return [
                'success' => true,
                'image_id' => $existing_square_image_id
            ];
        }
        $file_path = get_attached_file($supported_image_id);
        if (!file_exists($file_path)) {
            return new WP_Error('file_not_found', 'Image file not found');
        }
        // Verify file type
        $mime_type = get_post_mime_type($attachment_id);
        $allowed_types = ['image/jpeg', 'image/png', 'image/gif'];
        if (!in_array($mime_type, $allowed_types)) {
        $mime_type = get_post_mime_type($supported_image_id);
        if (!in_array($mime_type, ['image/jpeg', 'image/png', 'image/gif'])) {
            return new WP_Error('invalid_type', 'Square only supports JPEG, PNG, and GIF images');
        }
        // Prepare the multipart form data
        $image_title = get_the_title($supported_image_id);
        $alt_text = get_post_meta($supported_image_id, '_wp_attachment_image_alt', true);
        // Build multipart request - SINGLE STEP
        $boundary = wp_generate_password(24);
        $headers = $this->getRequestHeaders();
        $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
        $body = $this->buildMultipartBody($file_path, $square_image_id, $boundary);
        // Request JSON part
        $request_json = [
            'idempotency_key' => wp_generate_uuid4(),
            'image' => [
                'type' => 'IMAGE',
                'id' => '#IMAGE_' . $supported_image_id . '_' . time(),
                'image_data' => [
                    'name' => $image_title ?: 'Image',
                    'caption' => $alt_text ?: ''
                ]
            ]
        ];
        $body = $this->buildMultipartBody($file_path, $request_json, $boundary);
        $response = wp_remote_post(
            $this->getApiUrl('catalog/images'),
            $this->getApiUrl('v2/catalog/images'),
            [
                'headers' => $headers,
                'body' => $body,
@@ -3168,56 +2965,43 @@
            return $response;
        }
        $body = wp_remote_retrieve_body($response);
        $data = json_decode($body, true);
        $data = json_decode(wp_remote_retrieve_body($response), true);
        if (!empty($data['errors'])) {
            $error_message = $data['errors'][0]['detail'] ?? 'Unknown error';
            return new WP_Error('upload_error', $error_message);
            return new WP_Error('upload_error', $data['errors'][0]['detail'] ?? 'Unknown error');
        }
        if (!empty($data['image'])) {
            return $data;
        if (!empty($data['image']['id'])) {
            $this->setSquareImageId($supported_image_id, $data['image']['id']);
            return [
                'success' => true,
                'image_id' => $data['image']['id']
            ];
        }
        return new WP_Error('upload_failed', 'Failed to upload image to Square');
        return new WP_Error('upload_failed', 'Failed to upload image');
    }
    /**
     * Build multipart form data for image upload
     *
     * @param string $file_path Path to image file
     * @param string $square_image_id Square catalog image ID
     * @param string $boundary Multipart boundary
     * @return string Multipart body
     */
    protected function buildMultipartBody(string $file_path, string $square_image_id, string $boundary): string
    protected function buildMultipartBody(string $file_path, array $request_json, string $boundary): string
    {
        $eol = "\r\n";
        $body = '';
        // Add request JSON
        $request_data = [
            'idempotency_key' => wp_generate_uuid4(),
            'object_id' => $square_image_id
        ];
        // Add request JSON part
        $body .= '--' . $boundary . $eol;
        $body .= 'Content-Disposition: form-data; name="request"' . $eol;
        $body .= 'Content-Type: application/json' . $eol . $eol;
        $body .= json_encode($request_data) . $eol;
        $body .= json_encode($request_json) . $eol;
        // Add image file
        // Add image file part
        $filename = basename($file_path);
        $file_contents = file_get_contents($file_path);
        $mime_type = mime_content_type($file_path);
        $body .= '--' . $boundary . $eol;
        $body .= 'Content-Disposition: form-data; name="image"; filename="' . $filename . '"' . $eol;
        $body .= 'Content-Disposition: form-data; name="file"; filename="' . $filename . '"' . $eol;
        $body .= 'Content-Type: ' . $mime_type . $eol . $eol;
        $body .= $file_contents . $eol;
        // End boundary
        $body .= '--' . $boundary . '--' . $eol;
        return $body;
inc/managers/EmailManager.php
@@ -369,7 +369,9 @@
                    <p><strong>Note:</strong>Even after approval by admin, your ability to publish depends on your karmic standing by artists. Artists each have a vote they can cast (UP or DOWN) - if your karmic score dips too far in the negative, you account is subject to reconsideration or even a ban.</p>';
            }
            $message .= '<div class="divider"></div>';
            $message .= '<p>If you didn\'t create this account, please ignore this email and the link will expire.</p>';
            $message = apply_filters(BASE.'new_user_email_content', $message, $user);
            $message .= '<p>If you didn\'t create this account, please ignore this email and the link will expire.</p>';
            $message .= sprintf('<p>Ink on, %s</p>', $user->first_name);
            $message .= $this->signature;
            $wp_new_user_notification_email['message'] = $this->getEmailTemplate($message, 'Welcome to edmonton.ink');
inc/managers/ReferralManager.php
New file
@@ -0,0 +1,1036 @@
<?php
namespace JVBase\managers;
use WP_User;
use WP_Error;
if (!defined('ABSPATH')) {
    exit;
}
/**
 * Referral Tracking Manager
 *
 * Handles user referral codes, tracking, rewards, and reporting.
 * Keeps referrals separate from invitations system.
 */
class ReferralManager
{
    protected $wpdb;
    protected CacheManager $cache;
    protected string $referrals_table;
    protected string $rewards_table;
    // Default reward settings
    protected array $default_settings = [
        'referrer_reward_applies_to' => 'per_user',  // 'per_user' or 'flat_total'
        'referrer_reward_amount' => 25.00,
        'referrer_reward_type'  => 'fixed',
        'referee_reward_type' => 'percentage',  // 'percentage' or 'fixed'
        'referee_reward_amount' => 20,  // 20% or $20
        'referee_reward_applies_to' => 'first_order'  // 'first_order' or 'all_orders'
    ];
    public function __construct()
    {
        global $wpdb;
        $this->wpdb = $wpdb;
        $this->cache = new CacheManager('referrals');
        $this->referrals_table = BASE . 'referrals';
        $this->rewards_table = BASE . 'referral_rewards';
        // Hook into user registration to track referrals
        add_action('user_register', [$this, 'processReferral'], 10, 1);
        // Add meta boxes for admin to manage referrals
        add_action('show_user_profile', [$this, 'displayUserReferralInfo']);
        add_action('edit_user_profile', [$this, 'displayUserReferralInfo']);
        // Save referral code changes
        add_action('personal_options_update', [$this, 'saveUserReferralCode']);
        add_action('edit_user_profile_update', [$this, 'saveUserReferralCode']);
        add_filter(BASE.'new_user_email_content', [$this, 'addReferralToWelcomeEmail'], 99, 2);
        if (is_user_logged_in()) {
            add_action('wp_footer', [$this, 'outputShareWidget']);
        }
        add_action('template_redirect', [$this, 'trackReferralCode']);;
        // Schedule cron jobs for reports
        $this->registerCronJobs();
    }
    /**
     * Register cron jobs for automated reporting
     */
    protected function registerCronJobs(): void
    {
        add_action(BASE . 'referral_daily_report', [$this, 'sendDailyReport']);
        add_action(BASE . 'referral_weekly_report', [$this, 'sendWeeklyReport']);
        // Schedule if not already scheduled
        if (!wp_next_scheduled(BASE . 'referral_daily_report')) {
            wp_schedule_event(strtotime('tomorrow 9:00'), 'daily', BASE . 'referral_daily_report');
        }
        if (!wp_next_scheduled(BASE . 'referral_weekly_report')) {
            wp_schedule_event(strtotime('next Monday 9:00'), 'weekly', BASE . 'referral_weekly_report');
        }
    }
    /**
     * Generate or get existing referral code for a user
     *
     * @param int $user_id
     * @param string|null $custom_code Optional custom code
     * @return string|WP_Error
     */
    public function getUserReferralCode(int $user_id, ?string $custom_code = null)
    {
        $user = get_user_by('ID', $user_id);
        if (!$user) {
            return new WP_Error('invalid_user', 'User not found');
        }
        // Check if user already has a code
        $existing_code = get_user_meta($user_id, BASE . 'referral_code', true);
        if ($existing_code && !$custom_code) {
            return $existing_code;
        }
        // Generate new code if custom provided or none exists
        $code = $custom_code ?: $this->generateReferralCode($user);
        // Validate uniqueness
        if ($this->isCodeTaken($code, $user_id)) {
            return new WP_Error('code_taken', 'This referral code is already in use');
        }
        // Save the code
        update_user_meta($user_id, BASE . 'referral_code', $code);
        return $code;
    }
    /**
     * Generate a referral code based on user info
     *
     * @param WP_User $user
     * @return string
     */
    protected function generateReferralCode(WP_User $user): string
    {
        // Create code from first and last name
        $first = sanitize_title($user->first_name ?: 'user');
        $last = sanitize_title($user->last_name ?: wp_generate_password(4, false));
        $base_code = strtoupper(substr($first, 0, 4) . substr($last, 0, 4));
        // Ensure uniqueness
        $code = $base_code;
        $suffix = 1;
        while ($this->isCodeTaken($code)) {
            $code = $base_code . $suffix;
            $suffix++;
        }
        return $code;
    }
    /**
     * Check if a referral code is already taken
     *
     * @param string $code
     * @param int|null $exclude_user_id
     * @return bool
     */
    protected function isCodeTaken(string $code, ?int $exclude_user_id = null): bool
    {
        $args = [
            'meta_key' => BASE . 'referral_code',
            'meta_value' => $code,
            'fields' => 'ID',
            'number' => 1
        ];
        if ($exclude_user_id) {
            $args['exclude'] = [$exclude_user_id];
        }
        $users = get_users($args);
        return !empty($users);
    }
    /**
     * Track a new referral when user registers
     *
     * @param int $user_id
     */
    public function processReferral(int $user_id): void
    {
        // Check if there's a referral code in the session/cookie
        $referral_code = $this->getReferralCodeFromSession();
        if (!$referral_code) {
            return;
        }
        // Find the referrer
        $referrer = $this->getUserByReferralCode($referral_code);
        if (!$referrer) {
            return;
        }
        // Check if this user was already referred (prevent duplicates)
        $existing = $this->getReferralByReferee($user_id);
        if ($existing) {
            return;
        }
        // Create referral record
        $this->createReferral($referrer->ID, $user_id, $referral_code);
        // Clear the session
        $this->clearReferralSession();
    }
    /**
     * Create a referral record in the database
     *
     * @param int $referrer_id
     * @param int $referee_id
     * @param string $code
     * @return int|false
     */
    protected function createReferral(int $referrer_id, int $referee_id, string $code)
    {
        $user = get_user_by('ID', $referee_id);
        return $this->wpdb->insert(
            $this->referrals_table,
            [
                'referrer_id' => $referrer_id,
                'referee_id' => $referee_id,
                'referee_name' => $user->display_name,
                'referee_email' => $user->user_email,
                'referee_phone' => get_user_meta($referee_id, BASE . 'phone', true) ?: '',
                'referral_code' => $code,
                'status' => 'pending',
                'referred_at' => current_time('mysql')
            ],
            ['%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s']
        );
    }
    /**
     * Get user by referral code
     *
     * @param string $code
     * @return WP_User|null
     */
    protected function getUserByReferralCode(string $code): ?WP_User
    {
        $users = get_users([
            'meta_key' => BASE . 'referral_code',
            'meta_value' => $code,
            'number' => 1
        ]);
        return $users[0] ?? null;
    }
    /**
     * Get referral record by referee ID
     *
     * @param int $referee_id
     * @return object|null
     */
    public function getReferralByReferee(int $referee_id): ?object
    {
        $result = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->referrals_table} WHERE referee_id = %d",
            $referee_id
        ));
        return $result ?: null;
    }
    /**
     * Mark a referral as treated/rewarded
     *
     * @param int $referral_id
     * @param bool $treated
     * @return bool
     */
    public function markAsTreated(int $referral_id, bool $treated = true): bool
    {
        $status = $treated ? 'treated' : 'pending';
        $result = $this->wpdb->update(
            $this->referrals_table,
            [
                'status' => $status,
                'treated_at' => $treated ? current_time('mysql') : null
            ],
            ['id' => $referral_id],
            ['%s', '%s'],
            ['%d']
        );
        if ($result && $treated) {
            // Create reward records when marking as treated
            $this->createRewardRecords($referral_id);
        }
        return $result !== false;
    }
    /**
     * Create reward records for both referrer and referee
     *
     * @param int $referral_id
     */
    protected function createRewardRecords(int $referral_id): void
    {
        $referral = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->referrals_table} WHERE id = %d",
            $referral_id
        ));
        if (!$referral) {
            return;
        }
        $settings = $this->getRewardSettings();
        // Create referrer reward
        $this->wpdb->insert(
            $this->rewards_table,
            [
                'referral_id' => $referral_id,
                'user_id' => $referral->referrer_id,
                'reward_type' => 'referrer',
                'amount' => $settings['referrer_reward_amount'],
                'status' => 'available',
                'created_at' => current_time('mysql')
            ],
            ['%d', '%d', '%s', '%f', '%s', '%s']
        );
        // Create referee reward
        $referee_amount = $settings['referee_reward_type'] === 'percentage'
            ? $settings['referee_reward_amount']  // Store as percentage
            : $settings['referee_reward_amount'];  // Store as fixed amount
        $this->wpdb->insert(
            $this->rewards_table,
            [
                'referral_id' => $referral_id,
                'user_id' => $referral->referee_id,
                'reward_type' => 'referee',
                'amount' => $referee_amount,
                'reward_calculation' => $settings['referee_reward_type'],
                'status' => 'available',
                'created_at' => current_time('mysql')
            ],
            ['%d', '%d', '%s', '%f', '%s', '%s', '%s']
        );
    }
    /**
     * Get referrals for a user
     *
     * @param int $user_id
     * @param array $args
     * @return array
     */
    public function getUserReferrals(int $user_id, array $args = []): array
    {
        $defaults = [
            'status' => 'all',
            'limit' => 100,
            'offset' => 0,
            'orderby' => 'referred_at',
            'order' => 'DESC'
        ];
        $args = wp_parse_args($args, $defaults);
        $where = $this->wpdb->prepare("WHERE referrer_id = %d", $user_id);
        if ($args['status'] !== 'all') {
            $where .= $this->wpdb->prepare(" AND status = %s", $args['status']);
        }
        $query = "SELECT * FROM {$this->referrals_table}
                  {$where}
                  ORDER BY {$args['orderby']} {$args['order']}
                  LIMIT {$args['limit']} OFFSET {$args['offset']}";
        return $this->wpdb->get_results($query);
    }
    /**
     * Get referral statistics for a user
     *
     * @param int $user_id
     * @return array
     */
    public function getUserStats(int $user_id): array
    {
        $cache_key = 'stats_' . $user_id;
        $cached = $this->cache->get($cache_key);
        if ($cached !== false) {
            return $cached;
        }
        $stats = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT
                COUNT(*) as total_referrals,
                SUM(CASE WHEN status = 'treated' THEN 1 ELSE 0 END) as treated_count,
                SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count
            FROM {$this->referrals_table}
            WHERE referrer_id = %d",
            $user_id
        ), ARRAY_A);
        // Get total rewards
        $rewards = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT
                SUM(CASE WHEN status = 'available' THEN amount ELSE 0 END) as available_rewards,
                SUM(CASE WHEN status = 'redeemed' THEN amount ELSE 0 END) as redeemed_rewards
            FROM {$this->rewards_table}
            WHERE user_id = %d AND reward_type = 'referrer'",
            $user_id
        ), ARRAY_A);
        $stats = array_merge($stats, $rewards);
        $this->cache->set($cache_key, $stats, HOUR_IN_SECONDS);
        return $stats;
    }
    /**
     * Get top referrers for a time period
     *
     * @param int $limit
     * @param string $period 'day'|'week'|'month'|'all'
     * @return array
     */
    public function getTopReferrers(int $limit = 10, string $period = 'all'): array
    {
        $where = '';
        if ($period !== 'all') {
            $date_where = match($period) {
                'day' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)",
                'week' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)",
                'month' => "referred_at >= DATE_SUB(NOW(), INTERVAL 1 MONTH)",
                default => "1=1"
            };
            $where = "WHERE {$date_where}";
        }
        $query = "SELECT
                    referrer_id,
                    COUNT(*) as referral_count,
                    SUM(CASE WHEN status = 'treated' THEN 1 ELSE 0 END) as treated_count
                  FROM {$this->referrals_table}
                  {$where}
                  GROUP BY referrer_id
                  ORDER BY referral_count DESC
                  LIMIT {$limit}";
        $results = $this->wpdb->get_results($query);
        // Enrich with user data
        foreach ($results as &$result) {
            $user = get_user_by('ID', $result->referrer_id);
            $result->user_name = $user ? $user->display_name : 'Unknown';
            $result->user_email = $user ? $user->user_email : '';
        }
        return $results;
    }
    /**
     * Send daily report if there are new referrals
     */
    public function sendDailyReport(): void
    {
        // Get referrals from the last 24 hours
        $referrals = $this->wpdb->get_results(
            "SELECT r.*, u.display_name as referrer_name, u.user_email as referrer_email
             FROM {$this->referrals_table} r
             LEFT JOIN {$this->wpdb->users} u ON r.referrer_id = u.ID
             WHERE r.referred_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)
             ORDER BY r.referred_at DESC"
        );
        if (empty($referrals)) {
            return;  // No referrals, no email
        }
        // Generate CSV
        $csv_content = $this->generateCSV($referrals);
        $csv_filename = 'referrals-' . date('Y-m-d') . '.csv';
        // Save CSV temporarily
        $upload_dir = wp_upload_dir();
        $csv_path = $upload_dir['basedir'] . '/' . $csv_filename;
        file_put_contents($csv_path, $csv_content);
        // Send email with attachment
        $to = get_option('admin_email');
        $subject = '[' . get_bloginfo('name') . '] Daily Referral Report - ' . date('F j, Y');
        $message = $this->generateReportEmail($referrals, 'daily');
        $attachments = [$csv_path];
        wp_mail($to, $subject, $message, ['Content-Type: text/html; charset=UTF-8'], $attachments);
        // Clean up temporary file
        unlink($csv_path);
    }
    /**
     * Send weekly report with top referrers
     */
    public function sendWeeklyReport(): void
    {
        $top_referrers = $this->getTopReferrers(10, 'week');
        $total_referrals = $this->wpdb->get_var(
            "SELECT COUNT(*) FROM {$this->referrals_table}
             WHERE referred_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)"
        );
        if ($total_referrals == 0) {
            return;
        }
        $to = get_option('admin_email');
        $subject = '[' . get_bloginfo('name') . '] Weekly Referral Summary - ' . date('F j, Y');
        $message = $this->generateWeeklyReportEmail($top_referrers, $total_referrals);
        wp_mail($to, $subject, $message, ['Content-Type: text/html; charset=UTF-8']);
    }
    /**
     * Generate CSV content from referrals
     *
     * @param array $referrals
     * @return string
     */
    protected function generateCSV(array $referrals): string
    {
        $csv = "Referred By,Referee Name,Referee Email,Referee Phone,Referral Code,Status,Referred At,Treated At\n";
        foreach ($referrals as $referral) {
            $csv .= sprintf(
                '"%s","%s","%s","%s","%s","%s","%s","%s"' . "\n",
                $referral->referrer_name ?? 'Unknown',
                $referral->referee_name,
                $referral->referee_email,
                $referral->referee_phone,
                $referral->referral_code,
                $referral->status,
                $referral->referred_at,
                $referral->treated_at ?? 'Not yet'
            );
        }
        return $csv;
    }
    /**
     * Generate HTML email for daily report
     *
     * @param array $referrals
     * @param string $period
     * @return string
     */
    protected function generateReportEmail(array $referrals, string $period): string
    {
        $count = count($referrals);
        $content = sprintf('<p>You have <strong>%d new referral%s</strong> today.</p>',
            $count,
            $count !== 1 ? 's' : ''
        );
        $content .= '<table style="width:100%; border-collapse: collapse; margin: 20px 0;">';
        $content .= '<thead><tr style="background: #f5f5f5; text-align: left;">';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Referred By</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">New User</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Email</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Status</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Time</th>';
        $content .= '</tr></thead><tbody>';
        foreach ($referrals as $referral) {
            $content .= '<tr>';
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html($referral->referrer_name ?? 'Unknown'));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html($referral->referee_name));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html($referral->referee_email));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html(ucfirst($referral->status)));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html(date('g:i A', strtotime($referral->referred_at))));
            $content .= '</tr>';
        }
        $content .= '</tbody></table>';
        $content .= '<p><small>See attached CSV for full details.</small></p>';
        return jvbGetEmailTemplate($content, 'Daily Referral Report');
    }
    /**
     * Generate HTML email for weekly report
     *
     * @param array $top_referrers
     * @param int $total_referrals
     * @return string
     */
    protected function generateWeeklyReportEmail(array $top_referrers, int $total_referrals): string
    {
        $content = sprintf(
            '<p>This week you had <strong>%d total referral%s</strong>.</p>',
            $total_referrals,
            $total_referrals !== 1 ? 's' : ''
        );
        $content .= '<h3>Top 10 Referrers This Week</h3>';
        $content .= '<table style="width:100%; border-collapse: collapse; margin: 20px 0;">';
        $content .= '<thead><tr style="background: #f5f5f5; text-align: left;">';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Rank</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">User</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Total Referrals</th>';
        $content .= '<th style="padding: 10px; border: 1px solid #ddd;">Treated</th>';
        $content .= '</tr></thead><tbody>';
        $rank = 1;
        foreach ($top_referrers as $referrer) {
            $content .= '<tr>';
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%d</td>', $rank++);
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%s</td>',
                esc_html($referrer->user_name));
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%d</td>',
                $referrer->referral_count);
            $content .= sprintf('<td style="padding: 10px; border: 1px solid #ddd;">%d</td>',
                $referrer->treated_count);
            $content .= '</tr>';
        }
        $content .= '</tbody></table>';
        return jvbGetEmailTemplate($content, 'Weekly Referral Summary');
    }
    /**
     * Get reward settings
     *
     * @return array
     */
    protected function getRewardSettings(): array
    {
        $saved = get_option(BASE . 'referral_settings', []);
        return wp_parse_args($saved, $this->default_settings);
    }
    /**
     * Session/Cookie handling for referral codes
     */
    protected function getReferralCodeFromSession(): ?string
    {
        if (session_status() === PHP_SESSION_NONE) {
            session_start();
        }
        return $_SESSION[BASE . 'referral_code'] ?? $_COOKIE[BASE . 'referral_code'] ?? null;
    }
    protected function clearReferralSession(): void
    {
        if (session_status() === PHP_SESSION_NONE) {
            session_start();
        }
        unset($_SESSION[BASE . 'referral_code']);
        setcookie(BASE . 'referral_code', '', time() - 3600, '/');
    }
    /**
     * Display referral info in user profile
     *
     * @param WP_User $user
     */
    public function displayUserReferralInfo(WP_User $user): void
    {
        if (!current_user_can('edit_user', $user->ID)) {
            return;
        }
        $referral_code = get_user_meta($user->ID, BASE . 'referral_code', true);
        $stats = $this->getUserStats($user->ID);
        $referrals = $this->getUserReferrals($user->ID, ['limit' => 10]);
        ?>
        <h2>Referral Information</h2>
        <table class="form-table">
            <tr>
                <th><label for="referral_code">Referral Code</label></th>
                <td>
                    <input type="text"
                           name="referral_code"
                           id="referral_code"
                           value="<?php echo esc_attr($referral_code); ?>"
                           class="regular-text" />
                    <p class="description">
                        Users can sign up with this code.
                        <?php if ($referral_code): ?>
                            Share link: <?php echo home_url('/?ref=' . $referral_code); ?>
                        <?php endif; ?>
                    </p>
                </td>
            </tr>
        </table>
        <h3>Referral Statistics</h3>
        <table class="form-table">
            <tr>
                <th>Total Referrals:</th>
                <td><?php echo $stats['total_referrals'] ?? 0; ?></td>
            </tr>
            <tr>
                <th>Treated:</th>
                <td><?php echo $stats['treated_count'] ?? 0; ?></td>
            </tr>
            <tr>
                <th>Pending:</th>
                <td><?php echo $stats['pending_count'] ?? 0; ?></td>
            </tr>
            <tr>
                <th>Available Rewards:</th>
                <td>$<?php echo number_format($stats['available_rewards'] ?? 0, 2); ?></td>
            </tr>
            <tr>
                <th>Redeemed Rewards:</th>
                <td>$<?php echo number_format($stats['redeemed_rewards'] ?? 0, 2); ?></td>
            </tr>
        </table>
        <?php if (!empty($referrals)): ?>
        <h3>Recent Referrals</h3>
        <table class="widefat">
            <thead>
            <tr>
                <th>Name</th>
                <th>Email</th>
                <th>Status</th>
                <th>Referred At</th>
                <th>Actions</th>
            </tr>
            </thead>
            <tbody>
            <?php foreach ($referrals as $referral): ?>
                <tr>
                    <td><?php echo esc_html($referral->referee_name); ?></td>
                    <td><?php echo esc_html($referral->referee_email); ?></td>
                    <td><?php echo esc_html(ucfirst($referral->status)); ?></td>
                    <td><?php echo esc_html($referral->referred_at); ?></td>
                    <td>
                        <?php if ($referral->status === 'pending'): ?>
                            <button type="button"
                                    onclick="markReferralTreated(<?php echo $referral->id; ?>)">
                                Mark as Treated
                            </button>
                        <?php endif; ?>
                    </td>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>
    <?php endif; ?>
        <script>
            function markReferralTreated(referralId) {
                if (!confirm('Mark this referral as treated? This will create reward records.')) {
                    return;
                }
                fetch('<?php echo rest_url(BASE . '/v1/referrals/' ); ?>' + referralId + '/treat', {
                    method: 'POST',
                    headers: {
                        'X-WP-Nonce': '<?php echo wp_create_nonce('wp_rest'); ?>'
                    }
                })
                    .then(response => response.json())
                    .then(data => {
                        if (data.success) {
                            alert('Referral marked as treated!');
                            location.reload();
                        } else {
                            alert('Error: ' + (data.message || 'Unknown error'));
                        }
                    });
            }
        </script>
        <?php
    }
    /**
     * Save custom referral code
     *
     * @param int $user_id
     */
    public function saveUserReferralCode(int $user_id): void
    {
        if (!current_user_can('edit_user', $user_id)) {
            return;
        }
        if (!isset($_POST['referral_code'])) {
            return;
        }
        $code = sanitize_text_field($_POST['referral_code']);
        if (empty($code)) {
            delete_user_meta($user_id, BASE . 'referral_code');
            return;
        }
        // Validate code format (alphanumeric only)
        if (!preg_match('/^[A-Z0-9]+$/i', $code)) {
            add_action('user_profile_update_errors', function($errors) {
                $errors->add('invalid_referral_code',
                    'Referral code can only contain letters and numbers.');
            });
            return;
        }
        // Check if code is unique
        if ($this->isCodeTaken($code, $user_id)) {
            add_action('user_profile_update_errors', function($errors) {
                $errors->add('referral_code_taken',
                    'This referral code is already in use.');
            });
            return;
        }
        update_user_meta($user_id, BASE . 'referral_code', strtoupper($code));
    }
    public function trackReferralCode(): void
    {
        if (!isset($_GET['ref'])) {
            return;
        }
        $referral_code = strtoupper(sanitize_text_field($_GET['ref']));
        // Start session if not already started
        if (session_status() === PHP_SESSION_NONE) {
            session_start();
        }
        // Store in both session and cookie (30 day expiry)
        $_SESSION[BASE . 'referral_code'] = $referral_code;
        setcookie(BASE . 'referral_code', $referral_code, time() + (30 * DAY_IN_SECONDS), '/');
        // Optional: Redirect to clean URL (removes ?ref= from address bar)
        $clean_url = remove_query_arg('ref');
        wp_safe_redirect($clean_url);
        exit;
    }
    /**
     * Display user's referral code and share options
     * Use in templates or dashboard with: echo jvbReferralShareWidget();
     *
     * @return string HTML output
     */
    public function outputShareWidget(): string
    {
        $user_id = get_current_user_id();
        if (!$user_id) {
            return '';
        }
        $referral_code = get_user_meta($user_id, BASE . 'referral_code', true);
        // Generate code if user doesn't have one
        if (empty($referral_code)) {
            $manager = new \JVBase\managers\ReferralManager();
            $referral_code = $manager->getUserReferralCode($user_id);
        }
        $share_url = home_url('/?ref=' . $referral_code);
        $encoded_url = urlencode($share_url);
        $site_name = get_bloginfo('name');
        ob_start();
        ?>
        <div class="jvb-referral-widget" style="background: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
            <h3 style="margin-top: 0;">Share & Earn Rewards</h3>
            <p>Share your unique referral code with friends and earn rewards when they book!</p>
            <div class="referral-code-display" style="background: white; padding: 15px; border-radius: 4px; margin: 15px 0; text-align: center;">
                <label style="display: block; font-size: 12px; color: #666; margin-bottom: 5px;">Your Referral Code</label>
                <div style="font-size: 24px; font-weight: bold; letter-spacing: 2px; color: #2271b1;">
                    <?php echo esc_html($referral_code); ?>
                </div>
            </div>
            <div class="referral-url" style="margin: 15px 0;">
                <label style="display: block; font-size: 12px; color: #666; margin-bottom: 5px;">Share Link</label>
                <div style="display: flex; gap: 10px;">
                    <input type="text"
                           readonly
                           value="<?php echo esc_url($share_url); ?>"
                           id="referral-url-<?php echo $user_id; ?>"
                           style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-family: monospace; font-size: 14px;">
                    <button type="button"
                            onclick="jvbCopyReferralUrl('referral-url-<?php echo $user_id; ?>')"
                            style="padding: 8px 16px; background: #2271b1; color: white; border: none; border-radius: 4px; cursor: pointer;">
                        Copy
                    </button>
                </div>
            </div>
            <div class="referral-share-buttons" style="display: flex; gap: 10px; margin-top: 15px; flex-wrap: wrap;">
                <a href="mailto:?subject=Check out <?php echo esc_attr($site_name); ?>&body=I thought you might like <?php echo esc_url($share_url); ?>"
                   class="share-button"
                   style="padding: 10px 20px; background: #666; color: white; text-decoration: none; border-radius: 4px; display: inline-flex; align-items: center; gap: 8px;">
                    📧 Email
                </a>
                <a href="sms:?&body=Check out <?php echo esc_attr($site_name); ?>: <?php echo esc_url($share_url); ?>"
                   class="share-button"
                   style="padding: 10px 20px; background: #25D366; color: white; text-decoration: none; border-radius: 4px; display: inline-flex; align-items: center; gap: 8px;">
                    💬 Text
                </a>
                <a href="https://www.facebook.com/sharer/sharer.php?u=<?php echo $encoded_url; ?>"
                   target="_blank"
                   class="share-button"
                   style="padding: 10px 20px; background: #1877f2; color: white; text-decoration: none; border-radius: 4px; display: inline-flex; align-items: center; gap: 8px;">
                    f Facebook
                </a>
                <a href="https://twitter.com/intent/tweet?url=<?php echo $encoded_url; ?>&text=Check out <?php echo esc_attr($site_name); ?>"
                   target="_blank"
                   class="share-button"
                   style="padding: 10px 20px; background: #1da1f2; color: white; text-decoration: none; border-radius: 4px; display: inline-flex; align-items: center; gap: 8px;">
                    𝕏 Twitter
                </a>
            </div>
            <div id="referral-stats-<?php echo $user_id; ?>" class="referral-stats" style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd;">
                <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 15px; text-align: center;">
                    <div>
                        <div style="font-size: 24px; font-weight: bold; color: #2271b1;" data-stat="total">-</div>
                        <div style="font-size: 12px; color: #666;">Total Referrals</div>
                    </div>
                    <div>
                        <div style="font-size: 24px; font-weight: bold; color: #00a32a;" data-stat="treated">-</div>
                        <div style="font-size: 12px; color: #666;">Completed</div>
                    </div>
                    <div>
                        <div style="font-size: 24px; font-weight: bold; color: #dba617;" data-stat="pending">-</div>
                        <div style="font-size: 12px; color: #666;">Pending</div>
                    </div>
                    <div>
                        <div style="font-size: 24px; font-weight: bold; color: #2271b1;" data-stat="rewards">$0</div>
                        <div style="font-size: 12px; color: #666;">Earned</div>
                    </div>
                </div>
            </div>
        </div>
        <script>
            function jvbCopyReferralUrl(elementId) {
                const input = document.getElementById(elementId);
                input.select();
                document.execCommand('copy');
                // Visual feedback
                const button = input.nextElementSibling;
                const originalText = button.textContent;
                button.textContent = 'Copied!';
                button.style.background = '#00a32a';
                setTimeout(() => {
                    button.textContent = originalText;
                    button.style.background = '#2271b1';
                }, 2000);
            }
            // Load stats via AJAX
            (function() {
                fetch('<?php echo rest_url(BASE . '/v1/referrals/stats'); ?>', {
                    headers: {
                        'X-WP-Nonce': '<?php echo wp_create_nonce('wp_rest'); ?>'
                    }
                })
                    .then(response => response.json())
                    .then(data => {
                        if (data.success && data.stats) {
                            const container = document.getElementById('referral-stats-<?php echo $user_id; ?>');
                            container.querySelector('[data-stat="total"]').textContent = data.stats.total_referrals || 0;
                            container.querySelector('[data-stat="treated"]').textContent = data.stats.treated_count || 0;
                            container.querySelector('[data-stat="pending"]').textContent = data.stats.pending_count || 0;
                            container.querySelector('[data-stat="rewards"]').textContent =
                                '$' + parseFloat(data.stats.available_rewards || 0).toFixed(2);
                        }
                    })
                    .catch(error => console.error('Error loading referral stats:', error));
            })();
        </script>
        <?php
        return ob_get_clean();
    }
    function addReferralToWelcomeEmail(string $content, WP_User $user): string
    {
        $referral = $this->getReferralByReferee($user->ID);
        if (!$referral) {
            return $content;
        }
        $settings = get_option(BASE . 'referral_settings', []);
        $reward_amount = $settings['referee_reward_amount'] ?? 20;
        $reward_type = $settings['referee_reward_type'] ?? 'percentage';
        $reward_text = $reward_type === 'percentage'
            ? $reward_amount . '% off'
            : '$' . number_format($reward_amount, 2) . ' off';
        $bonus_content = '<div style="background: #e7f5ff; padding: 20px; border-radius: 8px; margin: 20px 0;">';
        $bonus_content .= '<h3 style="margin-top: 0; color: #2271b1;">🎉 Welcome Bonus!</h3>';
        $bonus_content .= '<p>Since you were referred by a friend, you\'ve earned <strong>' . $reward_text . '</strong> your first booking!</p>';
        $bonus_content .= '<p>Your reward will be automatically applied when you book.</p>';
        $bonus_content .= '</div>';
        // Insert bonus content after the first paragraph
        $parts = explode('</p>', $content, 2);
        if (count($parts) === 2) {
            return $parts[0] . '</p>' . $bonus_content . $parts[1];
        }
        return $content . $bonus_content;
    }
}
inc/managers/ReferralManager2.php
New file
@@ -0,0 +1,1006 @@
<?php
namespace JVBase\managers;
use JVBase\JVB;
use WP_Error;
use WP_REST_Response;
use Exception;
if (!defined('ABSPATH')) {
    exit;
}
/**
 * Referral Tracking System
 *
 * Manages user referral codes, tracking, and rewards
 * Uses existing infrastructure: MetaManager, CacheManager, NotificationManager
 */
class ReferralManager
{
    protected $wpdb;
    protected $cache;
    protected $table_codes;
    protected $table_usage;
    protected $table_rewards;
    // Default reward settings
    const DEFAULT_REFERRER_REWARD_TYPE = 'per_user'; // or 'flat_total'
    const DEFAULT_REFERRER_REWARD_AMOUNT = 25.00;
    const DEFAULT_REFERRED_REWARD_TYPE = 'percentage'; // or 'fixed'
    const DEFAULT_REFERRED_REWARD_AMOUNT = 20; // 20% or $20
    public function __construct()
    {
        global $wpdb;
        $this->wpdb = $wpdb;
        $this->cache = JVB()->cache();
        $this->table_codes = $wpdb->prefix . BASE . 'referral_codes';
        $this->table_usage = $wpdb->prefix . BASE . 'referral_usage';
        $this->table_rewards = $wpdb->prefix . BASE . 'referral_rewards';
        $this->registerHooks();
    }
    /**
     * Register WordPress hooks
     */
    protected function registerHooks(): void
    {
        // Track new user registrations with referral codes
        add_action('user_register', [$this, 'trackReferralRegistration'], 10, 2);
        // Monthly report cron
        add_action(BASE . 'referral_monthly_report', [$this, 'generateMonthlyReports']);
        // Cleanup expired codes
        add_action(BASE . 'cleanup_referrals', [$this, 'cleanupExpiredCodes']);
        // Handle bulk operations
        add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
    }
    /************************************************************
     * Referral Code Management
     ************************************************************/
    /**
     * Create or update a user's referral code
     *
     * @param int $user_id User ID
     * @param string|null $custom_code Optional custom code (must be unique)
     * @return array|WP_Error
     */
    public function createReferralCode(int $user_id, ?string $custom_code = null): array|WP_Error
    {
        // Validate user
        if (!$this->validateUser($user_id)) {
            return new WP_Error('invalid_user', 'Invalid user ID');
        }
        // Check if user already has a code
        $existing = $this->getUserReferralCode($user_id);
        if ($existing && !$custom_code) {
            return $existing; // Return existing code if no custom code requested
        }
        // Generate or validate custom code
        $code = $custom_code ? $this->sanitizeCode($custom_code) : $this->generateUniqueCode($user_id);
        // Check if code is already taken
        if ($this->isCodeTaken($code, $user_id)) {
            return new WP_Error('code_taken', 'This referral code is already in use');
        }
        // Validate code format
        if (!$this->validateCodeFormat($code)) {
            return new WP_Error('invalid_format', 'Code must be 4-20 alphanumeric characters');
        }
        $data = [
            'user_id' => $user_id,
            'code' => $code,
            'is_active' => 1,
            'created_at' => current_time('mysql'),
            'updated_at' => current_time('mysql')
        ];
        if ($existing) {
            // Update existing code
            $result = $this->wpdb->update(
                $this->table_codes,
                ['code' => $code, 'updated_at' => current_time('mysql')],
                ['user_id' => $user_id]
            );
        } else {
            // Insert new code
            $result = $this->wpdb->insert($this->table_codes, $data);
        }
        if ($result === false) {
            return new WP_Error('db_error', 'Failed to save referral code');
        }
        // Clear cache
        $this->cache->delete('referral_code_' . $user_id);
        $this->cache->delete('referral_user_' . $code);
        return [
            'success' => true,
            'code' => $code,
            'url' => $this->getReferralUrl($code)
        ];
    }
    /**
     * Get user's referral code
     *
     * @param int $user_id User ID
     * @return array|null
     */
    public function getUserReferralCode(int $user_id): ?array
    {
        $cache_key = 'referral_code_' . $user_id;
        $cached = $this->cache->get($cache_key);
        if ($cached !== false) {
            return $cached;
        }
        $result = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->table_codes} WHERE user_id = %d",
            $user_id
        ), ARRAY_A);
        if ($result) {
            $result['url'] = $this->getReferralUrl($result['code']);
            $result['stats'] = $this->getCodeStats($result['code']);
            $this->cache->set($cache_key, $result, 3600);
        }
        return $result;
    }
    /**
     * Get referral code statistics
     *
     * @param string $code Referral code
     * @return array
     */
    public function getCodeStats(string $code): array
    {
        $cache_key = 'referral_stats_' . $code;
        $cached = $this->cache->get($cache_key);
        if ($cached !== false) {
            return $cached;
        }
        $stats = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT
                COUNT(*) as total_uses,
                COUNT(CASE WHEN registered_at IS NOT NULL THEN 1 END) as completed_registrations,
                COUNT(CASE WHEN first_order_at IS NOT NULL THEN 1 END) as converted_orders
             FROM {$this->table_usage}
             WHERE referral_code = %s",
            $code
        ), ARRAY_A);
        $this->cache->set($cache_key, $stats, 1800);
        return $stats ?: ['total_uses' => 0, 'completed_registrations' => 0, 'converted_orders' => 0];
    }
    /**
     * Get user ID from referral code
     *
     * @param string $code Referral code
     * @return int|null User ID or null
     */
    public function getUserFromCode(string $code): ?int
    {
        $cache_key = 'referral_user_' . $code;
        $cached = $this->cache->get($cache_key);
        if ($cached !== false) {
            return $cached;
        }
        $user_id = $this->wpdb->get_var($this->wpdb->prepare(
            "SELECT user_id FROM {$this->table_codes}
             WHERE code = %s AND is_active = 1",
            $code
        ));
        if ($user_id) {
            $this->cache->set($cache_key, (int)$user_id, 3600);
            return (int)$user_id;
        }
        return null;
    }
    /************************************************************
     * Referral Tracking
     ************************************************************/
    /**
     * Track when someone clicks a referral link
     *
     * @param string $code Referral code
     * @param string|null $email Optional email if user provides it
     * @return array|WP_Error
     */
    public function trackReferralClick(string $code, ?string $email = null): array|WP_Error
    {
        $user_id = $this->getUserFromCode($code);
        if (!$user_id) {
            return new WP_Error('invalid_code', 'Invalid referral code');
        }
        // Check if this email/IP already used this code recently (prevent duplicate tracking)
        $ip_address = $this->getClientIp();
        $existing = $this->wpdb->get_var($this->wpdb->prepare(
            "SELECT id FROM {$this->table_usage}
             WHERE referral_code = %s
             AND (email = %s OR ip_address = %s)
             AND clicked_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)",
            $code,
            $email ?: '',
            $ip_address
        ));
        if ($existing) {
            return ['success' => true, 'message' => 'Already tracked'];
        }
        // Track the click
        $data = [
            'referral_code' => $code,
            'referrer_user_id' => $user_id,
            'email' => $email,
            'ip_address' => $ip_address,
            'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
            'clicked_at' => current_time('mysql')
        ];
        $result = $this->wpdb->insert($this->table_usage, $data);
        if ($result === false) {
            return new WP_Error('db_error', 'Failed to track referral');
        }
        // Store in cookie for 30 days
        setcookie('jvb_referral', $code, time() + (86400 * 30), '/');
        return [
            'success' => true,
            'tracking_id' => $this->wpdb->insert_id
        ];
    }
    /**
     * Track referral when user registers
     *
     * @param int $new_user_id Newly registered user ID
     * @param array $userdata User data
     * @return void
     */
    public function trackReferralRegistration(int $new_user_id, array $userdata = []): void
    {
        // Check for referral code in cookie or GET parameter
        $code = $_COOKIE['jvb_referral'] ?? $_GET['ref'] ?? null;
        if (!$code) {
            return;
        }
        $user = get_userdata($new_user_id);
        if (!$user) {
            return;
        }
        // Update or create usage record
        $usage = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->table_usage}
             WHERE referral_code = %s
             AND (email = %s OR ip_address = %s)
             ORDER BY clicked_at DESC LIMIT 1",
            $code,
            $user->user_email,
            $this->getClientIp()
        ), ARRAY_A);
        if ($usage) {
            // Update existing record
            $this->wpdb->update(
                $this->table_usage,
                [
                    'referred_user_id' => $new_user_id,
                    'email' => $user->user_email,
                    'registered_at' => current_time('mysql')
                ],
                ['id' => $usage['id']]
            );
        } else {
            // Create new record (direct registration with code)
            $referrer_id = $this->getUserFromCode($code);
            if ($referrer_id) {
                $this->wpdb->insert($this->table_usage, [
                    'referral_code' => $code,
                    'referrer_user_id' => $referrer_id,
                    'referred_user_id' => $new_user_id,
                    'email' => $user->user_email,
                    'ip_address' => $this->getClientIp(),
                    'clicked_at' => current_time('mysql'),
                    'registered_at' => current_time('mysql')
                ]);
            }
        }
        // Clear cache
        $this->cache->delete('referral_stats_' . $code);
        // Notify referrer
        if (isset($referrer_id) && $referrer_id) {
            $this->notifyReferrer($referrer_id, $new_user_id);
        }
    }
    /**
     * Track when referred user makes first order
     *
     * @param int $user_id User who made order
     * @param float $order_amount Order amount
     * @return void
     */
    public function trackFirstOrder(int $user_id, float $order_amount): void
    {
        $usage = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->table_usage}
             WHERE referred_user_id = %d
             AND first_order_at IS NULL",
            $user_id
        ), ARRAY_A);
        if (!$usage) {
            return;
        }
        // Update usage record
        $this->wpdb->update(
            $this->table_usage,
            [
                'first_order_at' => current_time('mysql'),
                'first_order_amount' => $order_amount
            ],
            ['id' => $usage['id']]
        );
        // Process rewards
        $this->processRewards($usage['referrer_user_id'], $user_id, $order_amount);
        // Clear cache
        $this->cache->delete('referral_stats_' . $usage['referral_code']);
    }
    /************************************************************
     * Reward Management
     ************************************************************/
    /**
     * Process referral rewards
     *
     * @param int $referrer_id User who referred
     * @param int $referred_id User who was referred
     * @param float $order_amount First order amount
     * @return void
     */
    protected function processRewards(int $referrer_id, int $referred_id, float $order_amount): void
    {
        // Get reward settings
        $settings = $this->getRewardSettings();
        // Calculate referrer reward
        $referrer_amount = $this->calculateReferrerReward($referrer_id, $settings);
        if ($referrer_amount > 0) {
            $this->addReward($referrer_id, 'referrer', $referrer_amount, $referred_id);
        }
        // Calculate referred user reward (already applied at checkout)
        $referred_amount = $this->calculateReferredReward($order_amount, $settings);
        if ($referred_amount > 0) {
            $this->addReward($referred_id, 'referred', $referred_amount, $referrer_id);
        }
    }
    /**
     * Calculate referrer reward amount
     *
     * @param int $referrer_id Referrer user ID
     * @param array $settings Reward settings
     * @return float Reward amount
     */
    protected function calculateReferrerReward(int $referrer_id, array $settings): float
    {
        $type = $settings['referrer_reward_type'];
        $amount = floatval($settings['referrer_reward_amount']);
        if ($type === 'per_user') {
            return $amount;
        }
        // For 'flat_total', check if total reward cap reached
        $total_earned = $this->getTotalRewardsEarned($referrer_id, 'referrer');
        if ($total_earned >= $amount) {
            return 0; // Cap reached
        }
        return min($settings['referrer_reward_per_user'] ?? 25.00, $amount - $total_earned);
    }
    /**
     * Calculate referred user reward
     *
     * @param float $order_amount Order amount
     * @param array $settings Reward settings
     * @return float Discount amount
     */
    protected function calculateReferredReward(float $order_amount, array $settings): float
    {
        $type = $settings['referred_reward_type'];
        $amount = floatval($settings['referred_reward_amount']);
        if ($type === 'percentage') {
            return $order_amount * ($amount / 100);
        }
        return min($amount, $order_amount); // Fixed amount, but not more than order
    }
    /**
     * Add reward to user's account
     *
     * @param int $user_id User receiving reward
     * @param string $type 'referrer' or 'referred'
     * @param float $amount Reward amount
     * @param int $related_user_id Related user ID
     * @return bool
     */
    protected function addReward(int $user_id, string $type, float $amount, int $related_user_id): bool
    {
        $data = [
            'user_id' => $user_id,
            'reward_type' => $type,
            'amount' => $amount,
            'related_user_id' => $related_user_id,
            'status' => 'pending',
            'created_at' => current_time('mysql')
        ];
        $result = $this->wpdb->insert($this->table_rewards, $data);
        if ($result) {
            // Notify user
            $notification_type = $type === 'referrer' ? 'referral_reward_earned' : 'referral_reward_received';
            JVB()->notification()->addNotification(
                $user_id,
                $notification_type,
                null,
                sprintf('You earned $%.2f in referral rewards!', $amount)
            );
            return true;
        }
        return false;
    }
    /**
     * Get total rewards earned by user
     *
     * @param int $user_id User ID
     * @param string|null $type Optional reward type filter
     * @return float Total amount
     */
    public function getTotalRewardsEarned(int $user_id, ?string $type = null): float
    {
        $sql = "SELECT SUM(amount) FROM {$this->table_rewards} WHERE user_id = %d";
        $params = [$user_id];
        if ($type) {
            $sql .= " AND reward_type = %s";
            $params[] = $type;
        }
        $total = $this->wpdb->get_var($this->wpdb->prepare($sql, $params));
        return floatval($total);
    }
    /**
     * Get user's available reward balance
     *
     * @param int $user_id User ID
     * @return float Available balance
     */
    public function getAvailableBalance(int $user_id): float
    {
        $total = $this->wpdb->get_var($this->wpdb->prepare(
            "SELECT SUM(amount) FROM {$this->table_rewards}
             WHERE user_id = %d AND status IN ('pending', 'available')",
            $user_id
        ));
        return floatval($total);
    }
    /************************************************************
     * Monthly Reports
     ************************************************************/
    /**
     * Generate monthly reports for all users with referrals
     *
     * @return void
     */
    public function generateMonthlyReports(): void
    {
        $first_day = date('Y-m-01', strtotime('last month'));
        $last_day = date('Y-m-t', strtotime('last month'));
        // Get all users who had referral activity last month
        $users = $this->wpdb->get_col($this->wpdb->prepare(
            "SELECT DISTINCT referrer_user_id
             FROM {$this->table_usage}
             WHERE registered_at BETWEEN %s AND %s
             OR first_order_at BETWEEN %s AND %s",
            $first_day, $last_day, $first_day, $last_day
        ));
        if (empty($users)) {
            return;
        }
        // Queue report generation
        $queue = JVB()->queue();
        $queue->queueOperation(
            'generate_referral_report',
            0,
            [
                'users' => $users,
                'period_start' => $first_day,
                'period_end' => $last_day
            ],
            [
                'count' => count($users),
                'chunk_key' => 'users',
                'chunk_size' => 10,
                'priority' => 'low'
            ]
        );
    }
    /**
     * Generate report for a single user
     *
     * @param int $user_id User ID
     * @param string $period_start Start date
     * @param string $period_end End date
     * @return array|WP_Error
     */
    public function generateUserReport(int $user_id, string $period_start, string $period_end): array|WP_Error
    {
        $user = get_userdata($user_id);
        if (!$user) {
            return new WP_Error('invalid_user', 'Invalid user');
        }
        $code = $this->getUserReferralCode($user_id);
        if (!$code) {
            return new WP_Error('no_code', 'User has no referral code');
        }
        // Get activity for period
        $activity = $this->wpdb->get_results($this->wpdb->prepare(
            "SELECT * FROM {$this->table_usage}
             WHERE referrer_user_id = %d
             AND (
                (registered_at BETWEEN %s AND %s)
                OR (first_order_at BETWEEN %s AND %s)
             )
             ORDER BY registered_at DESC",
            $user_id, $period_start, $period_end, $period_start, $period_end
        ), ARRAY_A);
        // Generate CSV
        $csv_path = $this->generateActivityCSV($user_id, $activity, $period_start, $period_end);
        // Send email with CSV attachment
        $this->sendMonthlyReportEmail($user, $activity, $csv_path, $period_start, $period_end);
        return [
            'success' => true,
            'user_id' => $user_id,
            'activity_count' => count($activity)
        ];
    }
    /**
     * Generate CSV file for activity
     *
     * @param int $user_id User ID
     * @param array $activity Activity records
     * @param string $period_start Start date
     * @param string $period_end End date
     * @return string File path
     */
    protected function generateActivityCSV(int $user_id, array $activity, string $period_start, string $period_end): string
    {
        $upload_dir = wp_upload_dir();
        $filename = sprintf(
            'referral-report-%d-%s-to-%s.csv',
            $user_id,
            $period_start,
            $period_end
        );
        $filepath = $upload_dir['basedir'] . '/referral-reports/' . $filename;
        // Create directory if needed
        wp_mkdir_p(dirname($filepath));
        $fp = fopen($filepath, 'w');
        // Headers
        fputcsv($fp, [
            'Date',
            'Type',
            'Email',
            'User ID',
            'Status',
            'Order Amount',
            'Reward Earned'
        ]);
        // Data rows
        foreach ($activity as $record) {
            $type = $record['registered_at'] ? 'Registration' : 'Click';
            if ($record['first_order_at']) {
                $type = 'First Order';
            }
            fputcsv($fp, [
                $record['registered_at'] ?? $record['clicked_at'],
                $type,
                $record['email'],
                $record['referred_user_id'] ?? 'N/A',
                $record['first_order_at'] ? 'Converted' : ($record['registered_at'] ? 'Registered' : 'Pending'),
                $record['first_order_amount'] ?? 'N/A',
                $this->getRewardForUsage($record['id'])
            ]);
        }
        fclose($fp);
        return $filepath;
    }
    /**
     * Get reward amount for usage record
     *
     * @param int $usage_id Usage ID
     * @return string Formatted amount or N/A
     */
    protected function getRewardForUsage(int $usage_id): string
    {
        $usage = $this->wpdb->get_row($this->wpdb->prepare(
            "SELECT * FROM {$this->table_usage} WHERE id = %d",
            $usage_id
        ), ARRAY_A);
        if (!$usage || !$usage['referred_user_id']) {
            return 'N/A';
        }
        $reward = $this->wpdb->get_var($this->wpdb->prepare(
            "SELECT amount FROM {$this->table_rewards}
             WHERE user_id = %d AND related_user_id = %d
             AND reward_type = 'referrer'",
            $usage['referrer_user_id'],
            $usage['referred_user_id']
        ));
        return $reward ? '$' . number_format($reward, 2) : 'N/A';
    }
    /**
     * Send monthly report email
     *
     * @param object $user User object
     * @param array $activity Activity records
     * @param string $csv_path Path to CSV file
     * @param string $period_start Start date
     * @param string $period_end End date
     * @return bool
     */
    protected function sendMonthlyReportEmail($user, array $activity, string $csv_path, string $period_start, string $period_end): bool
    {
        $total_clicks = count(array_filter($activity, fn($a) => !empty($a['clicked_at'])));
        $total_registrations = count(array_filter($activity, fn($a) => !empty($a['registered_at'])));
        $total_orders = count(array_filter($activity, fn($a) => !empty($a['first_order_at'])));
        $total_earned = $this->wpdb->get_var($this->wpdb->prepare(
            "SELECT SUM(r.amount) FROM {$this->table_rewards} r
             INNER JOIN {$this->table_usage} u ON r.related_user_id = u.referred_user_id
             WHERE r.user_id = %d
             AND r.reward_type = 'referrer'
             AND r.created_at BETWEEN %s AND %s",
            $user->ID, $period_start, $period_end
        ));
        $subject = sprintf(
            'Your Referral Report for %s',
            date('F Y', strtotime($period_start))
        );
        $message = sprintf(
            "Hi %s,\n\n" .
            "Here's your referral activity summary for %s:\n\n" .
            "📊 Activity Overview:\n" .
            "- Clicks: %d\n" .
            "- New Registrations: %d\n" .
            "- First Orders: %d\n" .
            "- Total Earned: $%.2f\n\n" .
            "Your current reward balance: $%.2f\n\n" .
            "Detailed activity is attached as a CSV file.\n\n" .
            "Keep sharing your referral link to earn more rewards!\n" .
            "Your link: %s\n\n" .
            "Thanks,\n%s",
            $user->display_name,
            date('F Y', strtotime($period_start)),
            $total_clicks,
            $total_registrations,
            $total_orders,
            floatval($total_earned),
            $this->getAvailableBalance($user->ID),
            $this->getReferralUrl($this->getUserReferralCode($user->ID)['code']),
            get_bloginfo('name')
        );
        return wp_mail(
            $user->user_email,
            $subject,
            $message,
            ['Content-Type: text/plain; charset=UTF-8'],
            [$csv_path]
        );
    }
    /************************************************************
     * Settings & Configuration
     ************************************************************/
    /**
     * Get referral reward settings
     *
     * @return array Settings
     */
    public function getRewardSettings(): array
    {
        $defaults = [
            'referrer_reward_type' => self::DEFAULT_REFERRER_REWARD_TYPE,
            'referrer_reward_amount' => self::DEFAULT_REFERRER_REWARD_AMOUNT,
            'referrer_reward_per_user' => self::DEFAULT_REFERRER_REWARD_AMOUNT,
            'referred_reward_type' => self::DEFAULT_REFERRED_REWARD_TYPE,
            'referred_reward_amount' => self::DEFAULT_REFERRED_REWARD_AMOUNT
        ];
        // Get from options (can be customized in admin settings)
        $saved = get_option(BASE . 'referral_settings', []);
        return array_merge($defaults, $saved);
    }
    /**
     * Update referral reward settings
     *
     * @param array $settings New settings
     * @return bool
     */
    public function updateRewardSettings(array $settings): bool
    {
        $valid_settings = [];
        if (isset($settings['referrer_reward_type'])) {
            $valid_settings['referrer_reward_type'] = in_array($settings['referrer_reward_type'], ['per_user', 'flat_total'])
                ? $settings['referrer_reward_type']
                : self::DEFAULT_REFERRER_REWARD_TYPE;
        }
        if (isset($settings['referrer_reward_amount'])) {
            $valid_settings['referrer_reward_amount'] = max(0, floatval($settings['referrer_reward_amount']));
        }
        if (isset($settings['referrer_reward_per_user'])) {
            $valid_settings['referrer_reward_per_user'] = max(0, floatval($settings['referrer_reward_per_user']));
        }
        if (isset($settings['referred_reward_type'])) {
            $valid_settings['referred_reward_type'] = in_array($settings['referred_reward_type'], ['percentage', 'fixed'])
                ? $settings['referred_reward_type']
                : self::DEFAULT_REFERRED_REWARD_TYPE;
        }
        if (isset($settings['referred_reward_amount'])) {
            $valid_settings['referred_reward_amount'] = max(0, floatval($settings['referred_reward_amount']));
        }
        return update_option(BASE . 'referral_settings', $valid_settings);
    }
    /************************************************************
     * Helper Methods
     ************************************************************/
    /**
     * Generate unique referral code
     *
     * @param int $user_id User ID
     * @return string Unique code
     */
    protected function generateUniqueCode(int $user_id): string
    {
        $user = get_userdata($user_id);
        $base = strtoupper(substr($user->user_login, 0, 6));
        $code = $base . rand(1000, 9999);
        // Ensure uniqueness
        while ($this->isCodeTaken($code)) {
            $code = $base . rand(1000, 9999);
        }
        return $code;
    }
    /**
     * Sanitize referral code
     *
     * @param string $code Raw code
     * @return string Sanitized code
     */
    protected function sanitizeCode(string $code): string
    {
        return strtoupper(preg_replace('/[^A-Z0-9]/', '', strtoupper($code)));
    }
    /**
     * Check if code is already taken
     *
     * @param string $code Code to check
     * @param int|null $exclude_user_id User ID to exclude
     * @return bool
     */
    protected function isCodeTaken(string $code, ?int $exclude_user_id = null): bool
    {
        $sql = "SELECT COUNT(*) FROM {$this->table_codes} WHERE code = %s";
        $params = [$code];
        if ($exclude_user_id) {
            $sql .= " AND user_id != %d";
            $params[] = $exclude_user_id;
        }
        $count = $this->wpdb->get_var($this->wpdb->prepare($sql, $params));
        return $count > 0;
    }
    /**
     * Validate code format
     *
     * @param string $code Code to validate
     * @return bool
     */
    protected function validateCodeFormat(string $code): bool
    {
        return preg_match('/^[A-Z0-9]{4,20}$/', $code);
    }
    /**
     * Validate user ID
     *
     * @param int $user_id User ID
     * @return bool
     */
    protected function validateUser(int $user_id): bool
    {
        return get_userdata($user_id) !== false;
    }
    /**
     * Get referral URL for code
     *
     * @param string $code Referral code
     * @return string Full URL
     */
    protected function getReferralUrl(string $code): string
    {
        return add_query_arg('ref', $code, home_url('/register'));
    }
    /**
     * Get client IP address
     *
     * @return string IP address
     */
    protected function getClientIp(): string
    {
        $ip = $_SERVER['HTTP_CF_CONNECTING_IP'] ??
            $_SERVER['HTTP_X_FORWARDED_FOR'] ??
            $_SERVER['REMOTE_ADDR'] ??
            '0.0.0.0';
        return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '0.0.0.0';
    }
    /**
     * Notify referrer of new registration
     *
     * @param int $referrer_id Referrer user ID
     * @param int $referred_id Referred user ID
     * @return void
     */
    protected function notifyReferrer(int $referrer_id, int $referred_id): void
    {
        JVB()->notification()->addNotification(
            $referrer_id,
            'referral_signup',
            $referred_id,
            'Someone signed up using your referral code!'
        );
    }
    /**
     * Cleanup expired/old records
     *
     * @return void
     */
    public function cleanupExpiredCodes(): void
    {
        // Delete clicks older than 90 days with no registration
        $this->wpdb->query(
            "DELETE FROM {$this->table_usage}
             WHERE clicked_at < DATE_SUB(NOW(), INTERVAL 90 DAY)
             AND registered_at IS NULL"
        );
    }
    /**
     * Handle bulk operations
     *
     * @param mixed $result Default result
     * @param object $operation Operation object
     * @param array $data Operation data
     * @return mixed
     */
    public function processOperation($result, object $operation, array $data)
    {
        if ($operation->type === 'generate_referral_report') {
            $user_id = $data['users'][$operation->progress_count] ?? null;
            if ($user_id) {
                return $this->generateUserReport(
                    $user_id,
                    $data['period_start'],
                    $data['period_end']
                );
            }
        }
        return $result;
    }
}
inc/managers/_setup.php
@@ -50,3 +50,7 @@
if (Features::forSite()->has('dashboard')) {
    require(JVB_DIR . '/inc/managers/DashboardManager.php');
}
if (Features::forSite()->has('referrals')) {
    require(JVB_DIR . '/inc/managers/ReferralManager.php');
}
inc/registry/CheckCustomTables.php
@@ -72,6 +72,13 @@
                error_log("JVB: Error in dashboard section: " . $e->getMessage());
            }
            try {
                if (array_key_exists('referrals', $this->JVB_SITE) && $this->JVB_SITE['referrals']) {
                    $tables = array_merge($tables, $this->referralTables());
                }
            } catch (Exception $e) {
                error_log("JVB: Error in referrals section: " . $e->getMessage());
            }
            // RE-ENABLE favourites tables
            try {
                if ($this->JVB_SITE['favourites']) {
@@ -1146,6 +1153,68 @@
    }
    /**
     * Create referral tracking tables
     *
     * Call this from the main table creation method in CheckCustomTables.php:
     * $tables = array_merge($tables, $this->referralTables());
     */
    protected function referralTables(): array
    {
        $tables = [];
        // Main referrals table
        $tables[BASE . 'referrals'] = "(
        `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        `referrer_id` bigint(20) unsigned NOT NULL,
        `referee_id` bigint(20) unsigned NOT NULL,
        `referee_name` varchar(255) NOT NULL,
        `referee_email` varchar(255) NOT NULL,
        `referee_phone` varchar(50) DEFAULT NULL,
        `referral_code` varchar(50) NOT NULL,
        `status` enum('pending', 'treated', 'cancelled') DEFAULT 'pending',
        `referred_at` datetime NOT NULL,
        `treated_at` datetime DEFAULT NULL,
        `notes` text DEFAULT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `referee_unique` (`referee_id`),
        KEY `referrer_idx` (`referrer_id`),
        KEY `status_idx` (`status`),
        KEY `code_idx` (`referral_code`),
        KEY `date_idx` (`referred_at`),
        CONSTRAINT `{$this->base}referral_referrer_fk` FOREIGN KEY (`referrer_id`)
            REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE,
        CONSTRAINT `{$this->base}referral_referee_fk` FOREIGN KEY (`referee_id`)
            REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
    )";
        // Rewards table
        $tables[BASE . 'referral_rewards'] = "(
        `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        `referral_id` bigint(20) unsigned NOT NULL,
        `user_id` bigint(20) unsigned NOT NULL,
        `reward_type` enum('referrer', 'referee') NOT NULL,
        `amount` decimal(10,2) NOT NULL,
        `reward_calculation` varchar(20) DEFAULT NULL COMMENT 'percentage or fixed',
        `status` enum('available', 'redeemed', 'expired', 'cancelled') DEFAULT 'available',
        `created_at` datetime NOT NULL,
        `redeemed_at` datetime DEFAULT NULL,
        `expires_at` datetime DEFAULT NULL,
        `notes` text DEFAULT NULL,
        PRIMARY KEY (`id`),
        KEY `referral_idx` (`referral_id`),
        KEY `user_idx` (`user_id`),
        KEY `status_idx` (`status`),
        KEY `type_idx` (`reward_type`),
        CONSTRAINT `{$this->base}reward_referral_fk` FOREIGN KEY (`referral_id`)
            REFERENCES {$this->wpdb->prefix}" . BASE . "referrals (`id`) ON DELETE CASCADE,
        CONSTRAINT `{$this->base}reward_user_fk` FOREIGN KEY (`user_id`)
            REFERENCES {$this->wpdb->users} (`ID`) ON DELETE CASCADE
    )";
        return $tables;
    }
    /*******************************************************************************************
     * These methods help create a content-type taxonomy, like the tattoo shops in edmonton.ink
     *     To set up, ensure that some fields in the registered taxonomy include 'content_table' => true
@@ -1538,4 +1607,5 @@
        return "(\n    " . implode(",\n    ", $allDefinitions) . "\n)";
    }
}
inc/rest/_setup.php
@@ -33,6 +33,9 @@
if (Features::forMembership()->has('forum')) {
    require(JVB_DIR . '/inc/rest/routes/NewsRoutes.php');
}
if (Features::forSite()->has('referrals')) {
    require(JVB_DIR . '/inc/rest/routes/ReferralRoutes.php');
}
if (Features::anyContentHas('response') || Features::anyTaxonomyHas('response')) {
    require(JVB_DIR . '/inc/rest/routes/ResponseRoutes.php');
}
inc/rest/routes/ReferralRoutes.php
New file
@@ -0,0 +1,315 @@
<?php
namespace JVBase\rest;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use JVBase\managers\ReferralManager;
if (!defined('ABSPATH')) {
    exit;
}
/**
 * REST API routes for referral system
 */
class ReferralRoutes extends RestRouteManager
{
    protected ReferralManager $manager;
    public function __construct()
    {
        $this->route = 'referrals';
        $this->manager = new ReferralManager();
        parent::__construct();
    }
    public function registerRoutes(): void
    {
        // Get user's referrals
        register_rest_route($this->namespace, "/{$this->route}", [
            'methods' => 'GET',
            'callback' => [$this, 'getUserReferrals'],
            'permission_callback' => [$this, 'checkPermission']
        ]);
        // Get or create referral code
        register_rest_route($this->namespace, "/{$this->route}/code", [
            'methods' => 'GET',
            'callback' => [$this, 'getReferralCode'],
            'permission_callback' => [$this, 'checkPermission']
        ]);
        // Update referral code
        register_rest_route($this->namespace, "/{$this->route}/code", [
            'methods' => 'POST',
            'callback' => [$this, 'updateReferralCode'],
            'permission_callback' => [$this, 'checkPermission'],
            'args' => [
                'code' => [
                    'required' => true,
                    'type' => 'string',
                    'validate_callback' => function($param) {
                        return preg_match('/^[A-Z0-9]+$/i', $param);
                    }
                ]
            ]
        ]);
        // Track referral click (public endpoint)
        register_rest_route($this->namespace, "/{$this->route}/track", [
            'methods' => 'POST',
            'callback' => [$this, 'trackReferralClick'],
            'permission_callback' => '__return_true',
            'args' => [
                'code' => [
                    'required' => true,
                    'type' => 'string'
                ]
            ]
        ]);
        // Mark referral as treated
        register_rest_route($this->namespace, "/{$this->route}/(?P<id>\d+)/treat", [
            'methods' => 'POST',
            'callback' => [$this, 'markAsTreated'],
            'permission_callback' => function() {
                return current_user_can('manage_options');
            },
            'args' => [
                'id' => [
                    'required' => true,
                    'validate_callback' => function($param) {
                        return is_numeric($param);
                    }
                ]
            ]
        ]);
        // Get user stats
        register_rest_route($this->namespace, "/{$this->route}/stats", [
            'methods' => 'GET',
            'callback' => [$this, 'getUserStats'],
            'permission_callback' => [$this, 'checkPermission']
        ]);
        // Get top referrers (admin only)
        register_rest_route($this->namespace, "/{$this->route}/leaderboard", [
            'methods' => 'GET',
            'callback' => [$this, 'getTopReferrers'],
            'permission_callback' => function() {
                return current_user_can('manage_options');
            },
            'args' => [
                'period' => [
                    'default' => 'week',
                    'enum' => ['day', 'week', 'month', 'all']
                ],
                'limit' => [
                    'default' => 10,
                    'type' => 'integer'
                ]
            ]
        ]);
        // Get/Update referral settings (admin only)
        register_rest_route($this->namespace, "/{$this->route}/settings", [
            [
                'methods' => 'GET',
                'callback' => [$this, 'getSettings'],
                'permission_callback' => function() {
                    return current_user_can('manage_options');
                }
            ],
            [
                'methods' => 'POST',
                'callback' => [$this, 'updateSettings'],
                'permission_callback' => function() {
                    return current_user_can('manage_options');
                }
            ]
        ]);
    }
    public function checkPermission(WP_REST_Request $request): bool
    {
        return is_user_logged_in();
    }
    /**
     * Get user's referrals
     */
    public function getUserReferrals(WP_REST_Request $request): WP_REST_Response
    {
        $user_id = get_current_user_id();
        $args = [
            'status' => $request->get_param('status') ?? 'all',
            'limit' => $request->get_param('limit') ?? 50,
            'offset' => $request->get_param('offset') ?? 0
        ];
        $referrals = $this->manager->getUserReferrals($user_id, $args);
        return new WP_REST_Response([
            'success' => true,
            'referrals' => $referrals
        ]);
    }
    /**
     * Get user's referral code
     */
    public function getReferralCode(WP_REST_Request $request): WP_REST_Response
    {
        $user_id = get_current_user_id();
        $code = $this->manager->getUserReferralCode($user_id);
        if (is_wp_error($code)) {
            return new WP_REST_Response([
                'success' => false,
                'message' => $code->get_error_message()
            ], 400);
        }
        return new WP_REST_Response([
            'success' => true,
            'code' => $code,
            'share_url' => home_url('/?ref=' . $code)
        ]);
    }
    /**
     * Update user's referral code
     */
    public function updateReferralCode(WP_REST_Request $request): WP_REST_Response
    {
        $user_id = get_current_user_id();
        $new_code = strtoupper(sanitize_text_field($request->get_param('code')));
        $result = $this->manager->getUserReferralCode($user_id, $new_code);
        if (is_wp_error($result)) {
            return new WP_REST_Response([
                'success' => false,
                'message' => $result->get_error_message()
            ], 400);
        }
        return new WP_REST_Response([
            'success' => true,
            'code' => $result,
            'message' => 'Referral code updated successfully'
        ]);
    }
    /**
     * Track referral click and store in session
     */
    public function trackReferralClick(WP_REST_Request $request): WP_REST_Response
    {
        $code = strtoupper(sanitize_text_field($request->get_param('code')));
        // Start session if not already started
        if (session_status() === PHP_SESSION_NONE) {
            session_start();
        }
        // Store referral code in both session and cookie (30 day expiry)
        $_SESSION[BASE . 'referral_code'] = $code;
        setcookie(BASE . 'referral_code', $code, time() + (30 * DAY_IN_SECONDS), '/');
        return new WP_REST_Response([
            'success' => true,
            'message' => 'Referral tracked'
        ]);
    }
    /**
     * Mark referral as treated
     */
    public function markAsTreated(WP_REST_Request $request): WP_REST_Response
    {
        $referral_id = intval($request->get_param('id'));
        $result = $this->manager->markAsTreated($referral_id, true);
        if (!$result) {
            return new WP_REST_Response([
                'success' => false,
                'message' => 'Failed to update referral'
            ], 400);
        }
        return new WP_REST_Response([
            'success' => true,
            'message' => 'Referral marked as treated and rewards created'
        ]);
    }
    /**
     * Get user stats
     */
    public function getUserStats(WP_REST_Request $request): WP_REST_Response
    {
        $user_id = get_current_user_id();
        $stats = $this->manager->getUserStats($user_id);
        return new WP_REST_Response([
            'success' => true,
            'stats' => $stats
        ]);
    }
    /**
     * Get top referrers
     */
    public function getTopReferrers(WP_REST_Request $request): WP_REST_Response
    {
        $period = $request->get_param('period') ?? 'week';
        $limit = $request->get_param('limit') ?? 10;
        $top_referrers = $this->manager->getTopReferrers($limit, $period);
        return new WP_REST_Response([
            'success' => true,
            'period' => $period,
            'referrers' => $top_referrers
        ]);
    }
    /**
     * Get referral settings
     */
    public function getSettings(WP_REST_Request $request): WP_REST_Response
    {
        $settings = get_option(BASE . 'referral_settings', []);
        return new WP_REST_Response([
            'success' => true,
            'settings' => $settings
        ]);
    }
    /**
     * Update referral settings
     */
    public function updateSettings(WP_REST_Request $request): WP_REST_Response
    {
        $settings = [
            'referrer_reward_type' => $request->get_param('referrer_reward_type') ?? 'per_user',
            'referrer_reward_amount' => floatval($request->get_param('referrer_reward_amount') ?? 25),
            'referee_reward_type' => $request->get_param('referee_reward_type') ?? 'percentage',
            'referee_reward_amount' => floatval($request->get_param('referee_reward_amount') ?? 20),
            'referee_reward_applies_to' => $request->get_param('referee_reward_applies_to') ?? 'first_order'
        ];
        update_option(BASE . 'referral_settings', $settings);
        return new WP_REST_Response([
            'success' => true,
            'message' => 'Settings updated successfully',
            'settings' => $settings
        ]);
    }
}