From 12e5205559c8def89659f9922a91b8b628289664 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 30 Sep 2025 20:18:30 +0000
Subject: [PATCH] Square update and initial Referral System started
---
assets/js/dash/SquareCheckout.js | 17
assets/js/min/square.min.js | 2
JVBase.php | 6
inc/managers/ReferralManager.php | 1036 +++++++++++++++++++++
inc/integrations/Square.php | 428 ++------
inc/managers/ReferralManager2.php | 1006 ++++++++++++++++++++
inc/rest/_setup.php | 3
inc/managers/EmailManager.php | 4
inc/managers/_setup.php | 4
inc/registry/CheckCustomTables.php | 70 +
inc/rest/routes/ReferralRoutes.php | 315 ++++++
11 files changed, 2,559 insertions(+), 332 deletions(-)
diff --git a/JVBase.php b/JVBase.php
index 6dd39df..f88c372 100644
--- a/JVBase.php
+++ b/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();
}
diff --git a/assets/js/dash/SquareCheckout.js b/assets/js/dash/SquareCheckout.js
index d7adab8..1a1dda3 100644
--- a/assets/js/dash/SquareCheckout.js
+++ b/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;
diff --git a/assets/js/min/square.min.js b/assets/js/min/square.min.js
index 531aaa4..550b94e 100644
--- a/assets/js/min/square.min.js
+++ b/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}))})();
\ No newline at end of file
+(()=>{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}))})();
\ No newline at end of file
diff --git a/inc/integrations/Square.php b/inc/integrations/Square.php
index d82ea73..a7b5451 100644
--- a/inc/integrations/Square.php
+++ b/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;
diff --git a/inc/managers/EmailManager.php b/inc/managers/EmailManager.php
index d7d0368..e351a99 100644
--- a/inc/managers/EmailManager.php
+++ b/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');
diff --git a/inc/managers/ReferralManager.php b/inc/managers/ReferralManager.php
new file mode 100644
index 0000000..90671f2
--- /dev/null
+++ b/inc/managers/ReferralManager.php
@@ -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;
+ }
+}
+
diff --git a/inc/managers/ReferralManager2.php b/inc/managers/ReferralManager2.php
new file mode 100644
index 0000000..3f299fc
--- /dev/null
+++ b/inc/managers/ReferralManager2.php
@@ -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;
+ }
+}
diff --git a/inc/managers/_setup.php b/inc/managers/_setup.php
index 2d32697..77dd5f4 100644
--- a/inc/managers/_setup.php
+++ b/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');
+}
diff --git a/inc/registry/CheckCustomTables.php b/inc/registry/CheckCustomTables.php
index 1d4bcaa..1dc7235 100644
--- a/inc/registry/CheckCustomTables.php
+++ b/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)";
}
+
}
diff --git a/inc/rest/_setup.php b/inc/rest/_setup.php
index 9f554cc..c9cf18a 100644
--- a/inc/rest/_setup.php
+++ b/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');
}
diff --git a/inc/rest/routes/ReferralRoutes.php b/inc/rest/routes/ReferralRoutes.php
new file mode 100644
index 0000000..a447a16
--- /dev/null
+++ b/inc/rest/routes/ReferralRoutes.php
@@ -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
+ ]);
+ }
+}
--
Gitblit v1.10.0