Square update and initial Referral System started
3 files added
8 files modified
| | |
| | | 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; |
| | |
| | | '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(); |
| | | } |
| | |
| | | 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; |
| | |
| | | (()=>{class t{constructor(t={}){this.checkout=document.querySelector("aside#cart"),this.checkout&&(this.config=Object.assign({application_id:squareConfig.application_id,location_id:squareConfig.location_id,api_url:squareConfig.api_url,nonce:squareConfig.nonce,currency:squareConfig.currency||"CAD"},t),this.stepMultiplier=1,this.cache=new window.jvbCache("cart",{TTL:864e5}),this.a11y=window.jvbA11y,this.initCart(),this.payments=null,this.card=null,this.isInitialized=!1,this.clickHandler=this.handleClick.bind(this),this.keyHandler=this.handleEscape.bind(this),this.changeHandler=this.handleChange.bind(this),this.initElements(),this.bindEvents(),this.init(),this.toggle.hidden=!1)}async initCart(){this.cartItems=await this.cache.get("cart")??new Map,console.log("cart",this.cartItems),this.cartItems.size>0&&this.notifyRestoredCart()}handleClick(t){if(window.targetCheck(t,".toggle-cart")){window.targetCheck(t,".toggle-cart");console.log("Toggle found. Toggling cart"),this.toggleCart()}else if(window.targetCheck(t,"button")&&window.targetCheck(t,"div.quantity")){let e=window.targetCheck(t,"div.quantity");this.handleNumberClick(t,e)}else if(window.targetCheck(t,"[data-add-to-cart]")){let e=window.targetCheck(t,"[data-add-to-cart]");this.handleAddToCart(e)}else if(window.targetCheck(t,"[data-remove-from-cart]")){let e=window.targetCheck(t,"[data-remove-from-cart]");this.handleRemoveFromCart(e)}else window.targetCheck(t,"[data-clear-cart]")?this.clearCart():this.checkout.classList.contains("expanded")&&!this.checkout.contains(t.target)&&t.target!==this.toggle&&this.closeCart()}handleChange(t,e){console.log("Checkout change");let a=window.targetCheck(t,".quantity-input");if(a){let e=t.target.closest(".quantity"),i=a.value;if(window.targetCheck(t,".cart-items")){let t=document.querySelector(`.menu-section [data-id="${e.dataset.id}"] input`);t&&(t.value=a.value)}i>0?this.handleAddToCart(e):this.handleRemoveFromCart(e)}}handleNumberClick(t,e){console.log(e),t.preventDefault();let a=0;if(t.target.closest(".increase")?a+=1:t.target.closest(".decrease")&&(a-=1),0!==a){let[t,i]=[parseInt(e.dataset.step),e.querySelector("input")],r=""===i.value?0:parseInt(i.value);i.value=r+t*a*this.stepMultiplier,i.dispatchEvent(new Event("change",{bubbles:!0})),this.handleNumberLimits(e)}}handleNumberLimits(t){let[e,a,i,r,s]=[t.dataset.min,t.dataset.max,t.querySelector("input"),t.querySelector(".increase"),t.querySelector(".decrease")],o=parseInt(i.value);o<e?(i.value=e,s.disabled=!0):o>a?(i.value=a,r.disabled=!1):r.disabled?r.disabled=!1:s.disabled&&(s.disabled=!1)}toggleCart(){this.checkout.classList.contains("expanded")?this.closeCart():this.openCart()}openCart(t="Opened Cart"){this.checkout.classList.add("expanded"),this.toggle.title="Hide cart",this.toggle.ariaExpanded=!0,this.toggle.querySelector("span").textContent="Close Cart",this.a11y.announce(t),this.maybeAddEmptyState(),document.addEventListener("keydown",this.keyHandler)}maybeAddEmptyState(){let t=this.itemsList.querySelector(".empty");if(t&&t.remove(),0===this.cartItems.size){this.checkoutPanel.disabled=!0,this.checkoutPanel.title="Add some things to your cart first!";let t=window.getTemplate("emptyCart");this.itemsList.append(t),this.table.closest("table").hidden=!0,this.total.hidden=!0,this.a11y.announce("Nothing in Cart")}else this.checkoutPanel.disabled=!1,this.table.closest("table").hidden=!1,this.total.hidden=!1,this.checkoutPanel.title="Checkout"}closeCart(t="Closed Cart"){this.checkout.classList.remove("expanded"),this.toggle.title="Show Cart",this.toggle.ariaExpanded=!1,this.toggle.querySelector("span").textContent="",this.a11y.announce(t),document.removeEventListener("keydown",this.keyHandler)}handleEscape(t){"Escape"===t.key?(this.closeCart("Closed Cart with escape key"),this.stepMultiplier=1):t.ctrlKey&&t.shiftKey?this.stepMultiplier=Math.max(100*parseInt(this.stepMultiplier),1e3):t.shiftKey&&(this.stepMultiplier=Math.max(10*parseInt(this.stepMultiplier),1e3))}handleAddToCart(t){let e=t.dataset.id;this.createItemElement(t);let a=parseFloat(t.dataset.price),i=parseInt(t.querySelector(".quantity-input")?.value)??1,r=parseFloat(a*i);this.cartItems.set(e,{post_id:e,name:t.dataset.name,price:a,quantity:i,total:r,square_catalog_id:t.dataset.squareCatalogId}),this.saveCart()}notifyRestoredCart(){let t=window.getTemplate("restoredCart");this.checkout.querySelector(".tab-content[data-tab=cartItems]").insertBefore(t,this.itemsList),this.cartItems.forEach((t=>{console.log(t);let e=window.getTemplate("cartItem"),a=e.querySelector(".quantity"),i=t.price,r=t.quantity;[a.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,a.dataset.price,a.dataset.squareCatalogId,e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[t.post_id,t.name,window.formatPrice(i),i,t.square_catalog_id,r,window.formatPrice(r*i)],this.table.append(e)})),this.updateTotal()}handleRemoveFromCart(t){if(confirm("This will remove this item from the cart. Continue?")){t.querySelector("[data-id]")||(t=t.closest(".item")?.querySelector(".quantity.field"));let e=t.dataset.id;this.cartItems.delete(e),this.table.querySelector(`[data-id="${e}"]`)?.closest("tr").remove();let a=document.querySelector(`[data-id="${e}"] input`);a&&(a.value=0),this.maybeAddEmptyState(),this.saveCart()}}clearCart(){this.cartItems.clear(),window.removeChildren(this.table),this.saveCart()}saveCart(){this.updateTotal(),this.cache.set("cart",this.cartItems)}updateTotal(){let t=0;this.cartItems.forEach((e=>{console.log(e),t+=e.total}));let e=.05*t;t=window.formatPrice(t+e),e=window.formatPrice(e),window.eraseText(this.totalTax),window.eraseText(this.grandTotal),window.typeText(this.totalTax,e),window.typeText(this.grandTotal,t),this.totalTax.classList.remove("typeText")}createItemElement(t){let e=this.itemsList.querySelector(`[data-id="${t.dataset.id}"]`),a=!1,i=t.dataset.price,r=t.querySelector('[name="quantity"]')?.value??1;if(e)e=e.closest("tr");else{a=!0,e=window.getTemplate("cartItem");let r=e.querySelector(".quantity");[r.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,r.dataset.price,r.dataset.squareCatalogId]=[t.dataset.id,t.dataset.name,window.formatPrice(i),i,t.dataset.squareCatalogId]}[e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[r,window.formatPrice(r*i)],a&&(e.classList.add("adding"),this.table.append(e),setTimeout((()=>{e.classList.remove("adding")}),500))}async init(){if(window.Square)try{this.payments=window.Square.payments(this.config.application_id,this.config.location_id),await this.initializePaymentMethods(),this.isInitialized=!0,document.dispatchEvent(new CustomEvent("squareCheckoutReady",{detail:{checkout:this}}))}catch(t){console.error("Failed to initialize Square payments:",t),this.handleError(t)}else console.error("Square Web Payments SDK not loaded")}initElements(){this.toggle=document.querySelector(".toggle-cart"),"1"!==squareConfig.isOpen&&(this.toggle.disabled=!0,this.toggle.title="Currently closed for online ordering"),this.checkoutPanel=this.checkout.querySelector('button[data-tab="checkout"]'),this.itemsList=this.checkout.querySelector(".cart-items"),this.table=this.checkout.querySelector(".cart-items tbody"),this.total=this.checkout.querySelector(".cart-total"),this.totalTax=this.total.querySelector(".tax span"),this.grandTotal=this.total.querySelector(".total span"),this.checkoutForm=this.checkout.querySelector("form"),this.tabs=new window.jvbTabs(this.checkoutForm,{updateURL:!1}),console.log("Initialized Checkout")}bindEvents(){this.checkoutForm.addEventListener("submit",(t=>this.handleFormSubmit(t))),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}async initializePaymentMethods(){if(document.getElementById("square-card-container"))try{this.card=await this.payments.card({style:this.getCardStyle()}),await this.card.attach("#square-card-container")}catch(t){throw console.error("Failed to initialize card:",t),t}}getCardStyle(){return{input:{fontSize:"16px",fontFamily:"inherit",color:"#333",backgroundColor:"#fff"},".input-container":{borderColor:"#ccc",borderRadius:"4px"},".input-container.is-focus":{borderColor:"#007cba"},".input-container.is-error":{borderColor:"#d63638"}}}async handleFormSubmit(t){if("1"!==squareConfig.isOpen)return;if(t.preventDefault(),!this.isInitialized)return void this.handleError("Checkout not initialized");const e=t.target,a=this.extractOrderData(e);try{window.jvbLoading.showLoading("Processing payment...");const t=await this.processPayment(a);this.handleSuccess(t,e)}catch(t){this.handleError(t)}finally{window.jvbLoading.hideLoading()}}extractOrderData(t){const e=Array.from(this.cartItems.values()).map((t=>({post_id:t.post_id,quantity:t.quantity,price:t.price,name:t.name})));return{total:100*e.reduce(((t,e)=>t+e.price*e.quantity),0),items:e,customer:{email:t.querySelector('[name="email"]')?.value||"",name:t.querySelector('[name="name"]')?.value||"",phone:t.querySelector('[name="phone"]')?.value||""},note:t.querySelector('[name="special_instructions"]')?.value||"",pickup_time:t.querySelector('[name="pickup_time"]')?.value||""}}async processPayment(t){try{const e=await this.card.tokenize();if("OK"===e.status)return await this.submitToServer(e.token,t);throw new Error("Card tokenization failed: "+(e.errors?.join(", ")||"Unknown error"))}catch(t){throw console.error("Payment processing failed:",t),t}}async submitToServer(t,e){if("1"!==squareConfig.isOpen)return;const a=await fetch(this.config.api_url+"checkout",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({source_id:t,amount:e.total,items:e.items,customer:e.customer,note:e.note,currency:this.config.currency,action:"jvb_integration_action",service:"square",integration_action:"process_order"})}),i=await a.json();if(!a.ok)throw new Error(i.message||"Payment failed");return i}trackOrder(t){this.orderId=t,this.scheduleOrderCheck(),this.checkout.querySelector("button[data-tab=order]").hidden=!1}scheduleOrderCheck(){window.debouncer.schedule("order",(()=>{this.checkOrderStatus()}),3e4)}async checkOrderStatus(){const t=await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`),e=await t.json();"ready"!==e.status&&this.scheduleOrderCheck(),this.updateOrderStatus(e)}updateOrderStatus(t){this.checkout.querySelectorAll(".status-item").forEach((e=>{e.dataset.status===t.status&&e.classList.add("active")})),this.checkout.querySelector("#eta").textContent=t.eta||"In progress"}async loadCustomerProfile(t){const e=await fetch("/wp-json/jvb/v1/square/customer",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({email:t})}),a=await e.json();a&&(this.displaySavedCards(a.cards),this.fillCustomerInfo(a.customer))}displaySavedCards(t){const e=document.getElementById("saved-cards");t.length&&(e.innerHTML=`\n <h3>Saved Payment Methods</h3>\n ${t.map((t=>`\n <label>\n <input type="radio" name="payment_method" value="${t.id}">\n •••• ${t.last_4} (${t.card_brand})\n </label>\n `)).join("")}\n <label>\n <input type="radio" name="payment_method" value="new" checked>\n Use new card\n </label>\n `)}handleSuccess(t,e){document.dispatchEvent(new CustomEvent("squareCheckoutSuccess",{detail:{result:t,form:e}}));const a=e.dataset.successUrl||`/order-confirmation/?order=${t.wp_order_id}`;window.location.href=a}handleError(t){console.error("Square checkout error:",t),document.dispatchEvent(new CustomEvent("squareCheckoutError",{detail:{error:t}})),window.jvbNotifications?.show?.(t.message||"Payment failed","error")}}document.addEventListener("DOMContentLoaded",(()=>{window.squareCheckout=new t}))})(); |
| | | (()=>{class t{constructor(t={}){this.checkout=document.querySelector("aside#cart"),this.checkout&&(this.config=Object.assign({application_id:squareConfig.application_id,location_id:squareConfig.location_id,api_url:squareConfig.api_url,nonce:squareConfig.nonce,currency:squareConfig.currency||"CAD"},t),this.stepMultiplier=1,this.cache=new window.jvbCache("cart",{TTL:864e5}),this.a11y=window.jvbA11y,this.initCart(),this.payments=null,this.card=null,this.isInitialized=!1,this.clickHandler=this.handleClick.bind(this),this.keyHandler=this.handleEscape.bind(this),this.changeHandler=this.handleChange.bind(this),this.initElements(),this.bindEvents(),this.init(),this.toggle.hidden=!1)}async initCart(){this.cartItems=await this.cache.get("cart")??new Map,console.log("cart",this.cartItems),this.cartItems.size>0&&this.notifyRestoredCart()}handleClick(t){if(window.targetCheck(t,".toggle-cart")){window.targetCheck(t,".toggle-cart");console.log("Toggle found. Toggling cart"),this.toggleCart()}else if(window.targetCheck(t,"button")&&window.targetCheck(t,"div.quantity")){let e=window.targetCheck(t,"div.quantity");this.handleNumberClick(t,e)}else if(window.targetCheck(t,"[data-add-to-cart]")){let e=window.targetCheck(t,"[data-add-to-cart]");this.handleAddToCart(e)}else if(window.targetCheck(t,"[data-remove-from-cart]")){let e=window.targetCheck(t,"[data-remove-from-cart]");this.handleRemoveFromCart(e)}else window.targetCheck(t,"[data-clear-cart]")?this.clearCart():this.checkout.classList.contains("expanded")&&!this.checkout.contains(t.target)&&t.target!==this.toggle&&this.closeCart()}handleChange(t,e){console.log("Checkout change");let a=window.targetCheck(t,".quantity-input");if(a){let e=t.target.closest(".quantity"),i=a.value;if(window.targetCheck(t,".cart-items")){let t=document.querySelector(`.menu-section [data-id="${e.dataset.id}"] input`);t&&(t.value=a.value)}i>0?this.handleAddToCart(e):this.handleRemoveFromCart(e)}}handleNumberClick(t,e){console.log(e),t.preventDefault();let a=0;if(t.target.closest(".increase")?a+=1:t.target.closest(".decrease")&&(a-=1),0!==a){let[t,i]=[parseInt(e.dataset.step),e.querySelector("input")],r=""===i.value?0:parseInt(i.value);i.value=r+t*a*this.stepMultiplier,i.dispatchEvent(new Event("change",{bubbles:!0})),this.handleNumberLimits(e)}}handleNumberLimits(t){let[e,a,i,r,s]=[t.dataset.min,t.dataset.max,t.querySelector("input"),t.querySelector(".increase"),t.querySelector(".decrease")],o=parseInt(i.value);o<e?(i.value=e,s.disabled=!0):o>a?(i.value=a,r.disabled=!1):r.disabled?r.disabled=!1:s.disabled&&(s.disabled=!1)}toggleCart(){this.checkout.classList.contains("expanded")?this.closeCart():this.openCart()}openCart(t="Opened Cart"){this.checkout.classList.add("expanded"),this.toggle.title="Hide cart",this.toggle.ariaExpanded=!0,this.toggle.querySelector("span").textContent="Close Cart",this.a11y.announce(t),this.maybeAddEmptyState(),document.addEventListener("keydown",this.keyHandler)}maybeAddEmptyState(){let t=this.itemsList.querySelector(".empty");if(t&&t.remove(),0===this.cartItems.size){this.checkoutPanel.disabled=!0,this.checkoutPanel.title="Add some things to your cart first!";let t=window.getTemplate("emptyCart");this.itemsList.append(t),this.table.closest("table").hidden=!0,this.total.hidden=!0,this.a11y.announce("Nothing in Cart")}else this.checkoutPanel.disabled=!1,this.table.closest("table").hidden=!1,this.total.hidden=!1,this.checkoutPanel.title="Checkout"}closeCart(t="Closed Cart"){this.checkout.classList.remove("expanded"),this.toggle.title="Show Cart",this.toggle.ariaExpanded=!1,this.toggle.querySelector("span").textContent="",this.a11y.announce(t),document.removeEventListener("keydown",this.keyHandler)}handleEscape(t){"Escape"===t.key?(this.closeCart("Closed Cart with escape key"),this.stepMultiplier=1):t.ctrlKey&&t.shiftKey?this.stepMultiplier=Math.max(100*parseInt(this.stepMultiplier),1e3):t.shiftKey&&(this.stepMultiplier=Math.max(10*parseInt(this.stepMultiplier),1e3))}handleAddToCart(t){let e=t.dataset.id;this.createItemElement(t);let a=parseFloat(t.dataset.price),i=parseInt(t.querySelector(".quantity-input")?.value)??1,r=parseFloat(a*i);this.cartItems.set(e,{post_id:e,name:t.dataset.name,price:a,quantity:i,total:r,square_catalog_id:t.dataset.squareCatalogId}),this.saveCart()}notifyRestoredCart(){let t=window.getTemplate("restoredCart");this.checkout.querySelector(".tab-content[data-tab=cartItems]").insertBefore(t,this.itemsList),this.cartItems.forEach((t=>{console.log(t);let e=window.getTemplate("cartItem"),a=e.querySelector(".quantity"),i=t.price,r=t.quantity;[a.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,a.dataset.price,a.dataset.squareCatalogId,e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[t.post_id,t.name,window.formatPrice(i),i,t.square_catalog_id,r,window.formatPrice(r*i)],this.table.append(e)})),this.updateTotal()}handleRemoveFromCart(t){if(confirm("This will remove this item from the cart. Continue?")){t.querySelector("[data-id]")||(t=t.closest(".item")?.querySelector(".quantity.field"));let e=t.dataset.id;this.cartItems.delete(e),this.table.querySelector(`[data-id="${e}"]`)?.closest("tr").remove();let a=document.querySelector(`[data-id="${e}"] input`);a&&(a.value=0),this.maybeAddEmptyState(),this.saveCart()}}clearCart(){this.cartItems.clear(),window.removeChildren(this.table),this.saveCart()}saveCart(){this.updateTotal(),this.cache.set("cart",this.cartItems)}updateTotal(){let t=0;this.cartItems.forEach((e=>{console.log(e),t+=e.total}));let e=.05*t;t=window.formatPrice(t+e),e=window.formatPrice(e),window.eraseText(this.totalTax),window.eraseText(this.grandTotal),window.typeText(this.totalTax,e),window.typeText(this.grandTotal,t),this.totalTax.classList.remove("typeText")}createItemElement(t){let e=this.itemsList.querySelector(`[data-id="${t.dataset.id}"]`),a=!1,i=t.dataset.price,r=t.querySelector('[name="quantity"]')?.value??1;if(e)e=e.closest("tr");else{a=!0,e=window.getTemplate("cartItem");let r=e.querySelector(".quantity");[r.dataset.id,e.querySelector("label").textContent,e.querySelector(".price").textContent,r.dataset.price,r.dataset.squareCatalogId]=[t.dataset.id,t.dataset.name,window.formatPrice(i),i,t.dataset.squareCatalogId]}[e.querySelector('[name="quantity"]').value,e.querySelector(".total").textContent]=[r,window.formatPrice(r*i)],a&&(e.classList.add("adding"),this.table.append(e),setTimeout((()=>{e.classList.remove("adding")}),500))}async init(){if(window.Square)try{this.payments=window.Square.payments(this.config.application_id,this.config.location_id),await this.initializePaymentMethods(),this.isInitialized=!0,document.dispatchEvent(new CustomEvent("squareCheckoutReady",{detail:{checkout:this}}))}catch(t){console.error("Failed to initialize Square payments:",t),this.handleError(t)}else console.error("Square Web Payments SDK not loaded")}initElements(){this.toggle=document.querySelector(".toggle-cart"),"1"!==squareConfig.isOpen&&(this.toggle.disabled=!0,this.toggle.title="Currently closed for online ordering"),this.checkoutPanel=this.checkout.querySelector('button[data-tab="checkout"]'),this.itemsList=this.checkout.querySelector(".cart-items"),this.table=this.checkout.querySelector(".cart-items tbody"),this.total=this.checkout.querySelector(".cart-total"),this.totalTax=this.total.querySelector(".tax span"),this.grandTotal=this.total.querySelector(".total span"),this.checkoutForm=this.checkout.querySelector("form"),this.tabs=new window.jvbTabs(this.checkoutForm,{updateURL:!1}),console.log("Initialized Checkout")}bindEvents(){this.checkoutForm.addEventListener("submit",(t=>this.handleFormSubmit(t))),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler)}async initializePaymentMethods(){if(document.getElementById("square-card-container"))try{this.card=await this.payments.card({style:this.getCardStyle()}),await this.card.attach("#square-card-container")}catch(t){throw console.error("Failed to initialize card:",t),t}}getCardStyle(){return{input:{fontSize:"16px",fontFamily:"inherit",color:"#333",backgroundColor:"#fff"},".input-container":{borderColor:"#ccc",borderRadius:"4px"},".input-container.is-focus":{borderColor:"#007cba"},".input-container.is-error":{borderColor:"#d63638"}}}async handleFormSubmit(t){if("1"!==squareConfig.isOpen)return;if(t.preventDefault(),!this.isInitialized)return void this.handleError("Checkout not initialized");const e=t.target,a=this.extractOrderData(e);try{window.jvbLoading.showLoading("Processing payment...");const t=await this.processPayment(a);this.handleSuccess(t,e)}catch(t){this.handleError(t)}finally{window.jvbLoading.hideLoading()}}extractOrderData(t){const e=Array.from(this.cartItems.values()).map((t=>({post_id:t.post_id,quantity:t.quantity,price:t.price,name:t.name})));return{total:100*e.reduce(((t,e)=>t+e.price*e.quantity),0),items:e,customer:{email:t.querySelector('[name="email"]')?.value||"",name:t.querySelector('[name="name"]')?.value||"",phone:t.querySelector('[name="phone"]')?.value||""},note:t.querySelector('[name="special_instructions"]')?.value||"",pickup_time:t.querySelector('[name="pickup_time"]')?.value||""}}async processPayment(t){try{const e=await this.card.tokenize();if("OK"===e.status)return await this.submitToServer(e.token,t);throw new Error("Card tokenization failed: "+(e.errors?.join(", ")||"Unknown error"))}catch(t){throw console.error("Payment processing failed:",t),t}}async submitToServer(t,e){if("1"!==squareConfig.isOpen)return;const a=await fetch(this.config.api_url+"save-order",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({order_id:t.orderId,payment_id:t.paymentId,customer:e.customer,items:e.items,action:"jvb_integration_action",service:"square",integration_action:"save_order"})}),i=await a.json();if(!a.ok)throw new Error(i.message||"Failed to save order");return i}trackOrder(t){this.orderId=t,this.scheduleOrderCheck(),this.checkout.querySelector("button[data-tab=order]").hidden=!1}scheduleOrderCheck(){window.debouncer.schedule("order",(()=>{this.checkOrderStatus()}),3e4)}async checkOrderStatus(){const t=await fetch(`/wp-json/jvb/v1/square/order-status/${this.orderId}`),e=await t.json();"ready"!==e.status&&this.scheduleOrderCheck(),this.updateOrderStatus(e)}updateOrderStatus(t){this.checkout.querySelectorAll(".status-item").forEach((e=>{e.dataset.status===t.status&&e.classList.add("active")})),this.checkout.querySelector("#eta").textContent=t.eta||"In progress"}async loadCustomerProfile(t){const e=await fetch("/wp-json/jvb/v1/square/customer",{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":this.config.nonce},body:JSON.stringify({email:t})}),a=await e.json();a&&(this.displaySavedCards(a.cards),this.fillCustomerInfo(a.customer))}displaySavedCards(t){const e=document.getElementById("saved-cards");t.length&&(e.innerHTML=`\n <h3>Saved Payment Methods</h3>\n ${t.map((t=>`\n <label>\n <input type="radio" name="payment_method" value="${t.id}">\n •••• ${t.last_4} (${t.card_brand})\n </label>\n `)).join("")}\n <label>\n <input type="radio" name="payment_method" value="new" checked>\n Use new card\n </label>\n `)}handleSuccess(t,e){document.dispatchEvent(new CustomEvent("squareCheckoutSuccess",{detail:{result:t,form:e}}));const a=e.dataset.successUrl||`/order-confirmation/?order=${t.wp_order_id}`;window.location.href=a}handleError(t){console.error("Square checkout error:",t),document.dispatchEvent(new CustomEvent("squareCheckoutError",{detail:{error:t}})),window.jvbNotifications?.show?.(t.message||"Payment failed","error")}}document.addEventListener("DOMContentLoaded",(()=>{window.squareCheckout=new t}))})(); |
| | |
| | | $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, |
| | |
| | | $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, |
| | |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * 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 |
| | | ******************************************************************/ |
| | | |
| | |
| | | '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') |
| | | ] |
| | | ); |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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 |
| | |
| | | } |
| | | |
| | | /** |
| | | * Get order status |
| | | * Get order status (for customer feedback) |
| | | */ |
| | | public function getOrderStatus($data):WP_Error|array |
| | | { |
| | |
| | | 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([ |
| | | return [ |
| | | 'success' => true, |
| | | ], $status); |
| | | 'status' => $status_data['state'], |
| | | 'eta' => $status_data['fulfillment_eta'] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | |
| | | IMAGE PROCESSING |
| | | *********************************************************/ |
| | | /** |
| | | * Upload featured image to Square catalog |
| | | * Upload image file to Square |
| | | * |
| | | * @param int $postID WordPress post ID |
| | | * @return array|WP_Error Result of the upload operation |
| | | * @param int $imgID WordPress attachment ID |
| | | * @return array|WP_Error Upload result or error |
| | | */ |
| | | public function uploadImageToSquare(int $imgID): array|WP_Error |
| | | protected 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 |
| | | // 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, |
| | | 'message' => 'Image already exists in Square catalog' |
| | | 'image_id' => $existing_square_image_id |
| | | ]; |
| | | } |
| | | |
| | | // 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 |
| | | * @return array|WP_Error Upload result or error |
| | | */ |
| | | protected function uploadImageFileToSquare(int $attachment_id, string $square_image_id): array|WP_Error |
| | | { |
| | | $file_path = get_attached_file($attachment_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, |
| | |
| | | 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; |
| | |
| | | <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 = 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; |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| | |
| | | if (Features::forSite()->has('dashboard')) { |
| | | require(JVB_DIR . '/inc/managers/DashboardManager.php'); |
| | | } |
| | | |
| | | if (Features::forSite()->has('referrals')) { |
| | | require(JVB_DIR . '/inc/managers/ReferralManager.php'); |
| | | } |
| | |
| | | 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']) { |
| | |
| | | } |
| | | |
| | | |
| | | /** |
| | | * 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 |
| | |
| | | |
| | | return "(\n " . implode(",\n ", $allDefinitions) . "\n)"; |
| | | } |
| | | |
| | | } |
| | |
| | | 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'); |
| | | } |
| New file |
| | |
| | | <?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 |
| | | ]); |
| | | } |
| | | } |