Changeset 3705845
- Timestamp:
- 09/21/2026 03:16:35 PM (4 weeks ago)
- Location:
- thai-nexus-logistics
- Files:
-
- 38 added
- 38 edited
- 1 copied
-
tags/1.5.15 (copied) (copied from thai-nexus-logistics/trunk )
-
tags/1.5.15/admin/package.json (modified) (1 diff)
-
tags/1.5.15/admin/src/App.jsx (modified) (2 diffs)
-
tags/1.5.15/admin/src/components/CountryMultiSelect.tsx (added)
-
tags/1.5.15/admin/src/components/ProductSearchSelect.jsx (modified) (1 diff)
-
tags/1.5.15/admin/src/components/fees (added)
-
tags/1.5.15/admin/src/components/fees/AdvancedRuleWizard.tsx (added)
-
tags/1.5.15/admin/src/components/fees/ConditionPicker.tsx (added)
-
tags/1.5.15/admin/src/components/fees/PricingFormulaEditor.tsx (added)
-
tags/1.5.15/admin/src/components/fees/PricingModeToggle.tsx (added)
-
tags/1.5.15/admin/src/lib (added)
-
tags/1.5.15/admin/src/lib/api.ts (added)
-
tags/1.5.15/admin/src/lib/countries.ts (added)
-
tags/1.5.15/admin/src/lib/pricingFormula.ts (added)
-
tags/1.5.15/admin/src/lib/types.ts (added)
-
tags/1.5.15/admin/src/pages/FeesPage.tsx (added)
-
tags/1.5.15/admin/src/pages/PrivacyPage.jsx (added)
-
tags/1.5.15/admin/src/pages/ProductsPage.jsx (added)
-
tags/1.5.15/admin/src/pages/SettingsPage.jsx (modified) (14 diffs)
-
tags/1.5.15/admin/src/pages/ShipmentsPage.jsx (modified) (12 diffs)
-
tags/1.5.15/admin/tailwind.config.js (modified) (1 diff)
-
tags/1.5.15/dist/.vite/manifest.json (modified) (1 diff)
-
tags/1.5.15/dist/assets/index-BBMv6f4n.js (added)
-
tags/1.5.15/dist/assets/index-CrlahNyv.css (added)
-
tags/1.5.15/dist/index.html (modified) (1 diff)
-
tags/1.5.15/includes/class-tnxl-box-packer.php (modified) (4 diffs)
-
tags/1.5.15/includes/class-tnxl-commission.php (modified) (1 diff)
-
tags/1.5.15/includes/class-tnxl-order.php (modified) (1 diff)
-
tags/1.5.15/includes/class-tnxl-product.php (modified) (1 diff)
-
tags/1.5.15/includes/class-tnxl-rest-api.php (modified) (4 diffs)
-
tags/1.5.15/includes/class-tnxl-service-coverage.php (added)
-
tags/1.5.15/includes/class-tnxl-settings.php (modified) (2 diffs)
-
tags/1.5.15/includes/class-tnxl-shipping-method.php (modified) (4 diffs)
-
tags/1.5.15/readme.txt (modified) (2 diffs)
-
tags/1.5.15/tests (added)
-
tags/1.5.15/tests/test-pricing-rules.php (added)
-
tags/1.5.15/thai-nexus-logistics.php (modified) (6 diffs)
-
tags/1.5.15/vendor/composer/installed.json (modified) (1 diff)
-
tags/1.5.15/vendor/composer/installed.php (modified) (2 diffs)
-
trunk/admin/package.json (modified) (1 diff)
-
trunk/admin/src/App.jsx (modified) (2 diffs)
-
trunk/admin/src/components/CountryMultiSelect.tsx (added)
-
trunk/admin/src/components/ProductSearchSelect.jsx (modified) (1 diff)
-
trunk/admin/src/components/fees (added)
-
trunk/admin/src/components/fees/AdvancedRuleWizard.tsx (added)
-
trunk/admin/src/components/fees/ConditionPicker.tsx (added)
-
trunk/admin/src/components/fees/PricingFormulaEditor.tsx (added)
-
trunk/admin/src/components/fees/PricingModeToggle.tsx (added)
-
trunk/admin/src/lib (added)
-
trunk/admin/src/lib/api.ts (added)
-
trunk/admin/src/lib/countries.ts (added)
-
trunk/admin/src/lib/pricingFormula.ts (added)
-
trunk/admin/src/lib/types.ts (added)
-
trunk/admin/src/pages/FeesPage.tsx (added)
-
trunk/admin/src/pages/PrivacyPage.jsx (added)
-
trunk/admin/src/pages/ProductsPage.jsx (added)
-
trunk/admin/src/pages/SettingsPage.jsx (modified) (14 diffs)
-
trunk/admin/src/pages/ShipmentsPage.jsx (modified) (12 diffs)
-
trunk/admin/tailwind.config.js (modified) (1 diff)
-
trunk/dist/.vite/manifest.json (modified) (1 diff)
-
trunk/dist/assets/index-BBMv6f4n.js (added)
-
trunk/dist/assets/index-CrlahNyv.css (added)
-
trunk/dist/index.html (modified) (1 diff)
-
trunk/includes/class-tnxl-box-packer.php (modified) (4 diffs)
-
trunk/includes/class-tnxl-commission.php (modified) (1 diff)
-
trunk/includes/class-tnxl-order.php (modified) (1 diff)
-
trunk/includes/class-tnxl-product.php (modified) (1 diff)
-
trunk/includes/class-tnxl-rest-api.php (modified) (4 diffs)
-
trunk/includes/class-tnxl-service-coverage.php (added)
-
trunk/includes/class-tnxl-settings.php (modified) (2 diffs)
-
trunk/includes/class-tnxl-shipping-method.php (modified) (4 diffs)
-
trunk/readme.txt (modified) (2 diffs)
-
trunk/tests (added)
-
trunk/tests/test-pricing-rules.php (added)
-
trunk/thai-nexus-logistics.php (modified) (6 diffs)
-
trunk/vendor/composer/installed.json (modified) (1 diff)
-
trunk/vendor/composer/installed.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
thai-nexus-logistics/tags/1.5.15/admin/package.json
r3678280 r3705845 2 2 "name": "admin", 3 3 "private": true, 4 "version": "1.5.1 4",4 "version": "1.5.15", 5 5 "type": "module", 6 6 "scripts": { -
thai-nexus-logistics/tags/1.5.15/admin/src/App.jsx
r3559867 r3705845 1 1 import React, { useState, useEffect } from 'react'; 2 import { Settings, Package, LayoutDashboard, Save, ExternalLink, RefreshCw, DollarSign, Terminal} from 'lucide-react';2 import { Settings, Package, LayoutDashboard, ExternalLink, DollarSign, Terminal, Shield, Box } from 'lucide-react'; 3 3 import SettingsPage from './pages/SettingsPage'; 4 4 import ShipmentsPage from './pages/ShipmentsPage'; 5 import CommissionRulesPage from './pages/CommissionRulesPage';5 import FeesPage from './pages/FeesPage'; 6 6 import BoxDefinitionsPage from './pages/BoxDefinitionsPage'; 7 7 import DebugLogPage from './pages/DebugLogPage'; 8 import PrivacyPage from './pages/PrivacyPage'; 9 import ProductsPage from './pages/ProductsPage'; 10 import { fetchSettings } from './lib/api'; 8 11 import { clsx } from 'clsx'; 9 12 import { twMerge } from 'tailwind-merge'; … … 14 17 15 18 const App = () => { 16 // @ts-ignore17 19 const debugEnabled = window.tnxlData?.debugEnabled || false; 18 const [activeTab, setActiveTab] = useState('shipments'); 19 const [loading, setLoading] = useState(false); 20 const [activeTab, setActiveTab] = useState('settings'); 21 const [chargeActualWeightOnly, setChargeActualWeightOnly] = useState(false); 22 23 useEffect(() => { 24 void fetchSettings() 25 .then((data) => setChargeActualWeightOnly(Boolean(data?.charge_actual_weight_only))) 26 .catch(() => {}); 27 }, []); 28 29 useEffect(() => { 30 if (chargeActualWeightOnly && activeTab === 'boxes') { 31 setActiveTab('settings'); 32 } 33 }, [chargeActualWeightOnly, activeTab]); 34 35 const tabs = [ 36 { id: 'shipments', label: 'Shipments', icon: LayoutDashboard }, 37 { id: 'settings', label: 'Settings', icon: Settings }, 38 { id: 'products', label: 'Products', icon: Box }, 39 { id: 'fees', label: 'Fees', icon: DollarSign }, 40 ...(!chargeActualWeightOnly ? [{ id: 'boxes', label: 'Boxes', icon: Package }] : []), 41 { id: 'privacy', label: 'Privacy', icon: Shield }, 42 ...(debugEnabled ? [{ id: 'debug', label: 'Debug', icon: Terminal }] : []), 43 ]; 20 44 21 45 return ( 22 46 <div className="min-h-screen p-6 md:p-10 max-w-7xl mx-auto font-sans"> 23 {/* Header */}24 47 <header className="flex flex-col md:flex-row md:items-center justify-between mb-10 gap-4"> 25 48 <div> 26 <h1 className="text-3xl font-bold text- secondary flex items-center gap-3">27 <Package className="text- primary w-8 h-8" />49 <h1 className="text-3xl font-bold text-primary flex items-center gap-3"> 50 <Package className="text-secondary w-8 h-8" /> 28 51 Thai Nexus Logistics 29 52 </h1> 30 53 <p className="text-gray-500 mt-1">Manage your shipping operations and API configurations.</p> 31 54 </div> 32 33 <div className="flex items-center gap-4 bg-white p-1 rounded-xl shadow-sm border border-gray-100 flex-wrap"> 34 <button 35 onClick={() => setActiveTab('shipments')} 36 className={cn( 37 "flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium transition-all", 38 activeTab === 'shipments' 39 ? "bg-secondary text-white shadow-md" 40 : "text-gray-600 hover:bg-gray-50" 41 )} 42 > 43 <LayoutDashboard size={18} /> 44 Shipments 45 </button> 46 <button 47 onClick={() => setActiveTab('settings')} 48 className={cn( 49 "flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium transition-all", 50 activeTab === 'settings' 51 ? "bg-secondary text-white shadow-md" 52 : "text-gray-600 hover:bg-gray-50" 53 )} 54 > 55 <Settings size={18} /> 56 Settings 57 </button> 58 <button 59 onClick={() => setActiveTab('commission')} 60 className={cn( 61 "flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium transition-all", 62 activeTab === 'commission' 63 ? "bg-secondary text-white shadow-md" 64 : "text-gray-600 hover:bg-gray-50" 65 )} 66 > 67 <DollarSign size={18} /> 68 Fees 69 </button> 70 <button 71 onClick={() => setActiveTab('boxes')} 72 className={cn( 73 "flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium transition-all", 74 activeTab === 'boxes' 75 ? "bg-secondary text-white shadow-md" 76 : "text-gray-600 hover:bg-gray-50" 77 )} 78 > 79 <Package size={18} /> 80 Boxes 81 </button> 82 83 {debugEnabled && ( 55 56 <div className="flex items-center gap-2 bg-white p-1 rounded-xl shadow-sm border border-gray-100 flex-wrap"> 57 {tabs.map(({ id, label, icon: Icon }) => ( 84 58 <button 85 onClick={() => setActiveTab('debug')} 59 key={id} 60 type="button" 61 onClick={() => setActiveTab(id)} 86 62 className={cn( 87 "flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium transition-all border border-transparent", 88 activeTab === 'debug' 89 ? "bg-amber-500 text-white shadow-md border-amber-600" 90 : "text-amber-600 hover:bg-amber-50 border-amber-100/50" 63 'flex items-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-all text-sm', 64 activeTab === id 65 ? id === 'debug' 66 ? 'bg-amber-500 text-white shadow-md' 67 : 'bg-primary text-white shadow-md' 68 : id === 'debug' 69 ? 'text-amber-600 hover:bg-amber-50' 70 : 'text-gray-600 hover:bg-gray-50' 91 71 )} 92 72 > 93 < Terminal size={18} />94 Debug73 <Icon size={16} /> 74 {label} 95 75 </button> 96 ) }76 ))} 97 77 </div> 98 78 </header> 99 79 100 {/* Main Content */}101 80 <main className="animate-in fade-in slide-in-from-bottom-4 duration-500"> 102 81 {activeTab === 'shipments' && <ShipmentsPage />} 103 {activeTab === 'settings' && <SettingsPage />} 104 {activeTab === 'commission' && <CommissionRulesPage />} 105 {activeTab === 'boxes' && <BoxDefinitionsPage />} 82 {activeTab === 'settings' && ( 83 <SettingsPage onSaved={(data) => setChargeActualWeightOnly(Boolean(data?.charge_actual_weight_only))} /> 84 )} 85 {activeTab === 'products' && <ProductsPage />} 86 {activeTab === 'fees' && <FeesPage />} 87 {activeTab === 'boxes' && !chargeActualWeightOnly && <BoxDefinitionsPage />} 88 {activeTab === 'privacy' && <PrivacyPage />} 106 89 {activeTab === 'debug' && debugEnabled && <DebugLogPage />} 107 90 </main> 108 91 109 {/* Footer */}110 92 <footer className="mt-12 py-6 border-t border-gray-200 flex justify-between items-center text-sm text-gray-500"> 111 93 <p>© 2026 Thai Nexus Point Co., Ltd. All rights reserved.</p> 112 <a 113 href="https://app.thainexus.co.th/" 114 target="_blank" 94 <a 95 href="https://app.thainexus.co.th/" 96 target="_blank" 115 97 rel="noopener noreferrer" 116 98 className="flex items-center gap-1 hover:text-primary transition-colors" -
thai-nexus-logistics/tags/1.5.15/admin/src/components/ProductSearchSelect.jsx
r3617587 r3705845 26 26 27 27 if (ids.length === 0) { 28 setSelectedDetails([]); 28 29 return () => { 29 30 cancelled = true; -
thai-nexus-logistics/tags/1.5.15/admin/src/pages/SettingsPage.jsx
r3678280 r3705845 3 3 import axios from 'axios'; 4 4 import ProductSearchSelect from '../components/ProductSearchSelect'; 5 import CountryMultiSelect from '../components/CountryMultiSelect'; 5 6 6 7 const normalizeServiceId = (value) => String(value || '') … … 10 11 .replace(/[^a-z0-9_-]/g, ''); 11 12 12 const SettingsPage = () => { 13 const serviceKeys = (service) => { 14 const seen = new Set(); 15 const keys = []; 16 for (const raw of [service.id, service.service_name, service.name]) { 17 const id = normalizeServiceId(raw); 18 if (!id || seen.has(id)) continue; 19 seen.add(id); 20 keys.push(id); 21 } 22 return keys; 23 }; 24 25 const SettingsPage = ({ onSaved }) => { 13 26 const [loading, setLoading] = useState(false); 14 27 const [saving, setSaving] = useState(false); … … 22 35 disabled_service_ids: [], 23 36 shipping_ineligible_product_ids: [], 37 product_weight_unit: 'kg', 38 charge_actual_weight_only: false, 39 service_coverage: {}, 24 40 shipper: { 25 41 name: '', … … 81 97 disabled_service_ids: response.data?.disabled_service_ids || [], 82 98 shipping_ineligible_product_ids: response.data?.shipping_ineligible_product_ids || [], 99 product_weight_unit: response.data?.product_weight_unit === 'g' ? 'g' : 'kg', 100 charge_actual_weight_only: Boolean(response.data?.charge_actual_weight_only), 101 service_coverage: response.data?.service_coverage || {}, 83 102 }); 84 103 if (response.data?.api_token) { … … 119 138 }; 120 139 121 const toggleService = (service Id, enabled) => {122 const id = normalizeServiceId(serviceId);140 const toggleService = (service, enabled) => { 141 const ids = serviceKeys(service); 123 142 setSettings((previous) => ({ 124 143 ...previous, 125 144 disabled_service_ids: enabled 126 ? previous.disabled_service_ids.filter((item) => item !== id)127 : [...new Set([...previous.disabled_service_ids, id])],145 ? previous.disabled_service_ids.filter((item) => !ids.includes(item)) 146 : [...new Set([...previous.disabled_service_ids, ...ids])], 128 147 })); 148 }; 149 150 const coverageFor = (service) => { 151 for (const id of serviceKeys(service)) { 152 if (settings.service_coverage?.[id]) return settings.service_coverage[id]; 153 } 154 return { worldwide: true, countries: [] }; 155 }; 156 157 const setCoverage = (service, next) => { 158 setSettings((prev) => { 159 const merged = { ...(prev.service_coverage || {}) }; 160 for (const id of serviceKeys(service)) { 161 merged[id] = next; 162 } 163 return { ...prev, service_coverage: merged }; 164 }); 129 165 }; 130 166 … … 142 178 }); 143 179 setMessage({ type: 'success', text: 'Settings saved successfully!' }); 180 if (typeof onSaved === 'function') { 181 onSaved(settings); 182 } 144 183 if (settings.api_token) { 145 184 void fetchShippingServices(); … … 168 207 {/* Feature toggles */} 169 208 <div className="tnxl-card"> 170 <div className="bg- secondary p-5 flex items-center gap-3">171 <Zap className="text- primaryw-6 h-6" />209 <div className="bg-primary p-5 flex items-center gap-3"> 210 <Zap className="text-white w-6 h-6" /> 172 211 <h2 className="text-lg font-bold text-white">Automation</h2> 173 212 </div> … … 227 266 {/* API Authentication */} 228 267 <div className="tnxl-card"> 229 <div className="bg- secondary p-5 flex items-center gap-3">230 <Key className="text- primaryw-6 h-6" />268 <div className="bg-primary p-5 flex items-center gap-3"> 269 <Key className="text-white w-6 h-6" /> 231 270 <h2 className="text-lg font-bold text-white">API Authentication</h2> 232 271 </div> … … 271 310 </div> 272 311 312 <div className="tnxl-card"> 313 <div className="bg-primary p-5 flex items-center gap-3"> 314 <Package className="text-white w-6 h-6" /> 315 <h2 className="text-lg font-bold text-white">Product weight and billing</h2> 316 </div> 317 <div className="p-8 space-y-6"> 318 <div> 319 <p className="text-sm font-semibold text-gray-700 mb-2">Product weight unit</p> 320 <p className="text-sm text-gray-600 mb-3"> 321 How catalog product weights are read. Box inventory stays in kg. Thai Nexus quotes always use kg. 322 </p> 323 <div className="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-1"> 324 <button 325 type="button" 326 onClick={() => setSettings((s) => ({ ...s, product_weight_unit: 'kg' }))} 327 className={`px-4 py-2 text-sm font-medium rounded-md ${ 328 settings.product_weight_unit === 'kg' ? 'bg-white text-primary shadow-sm' : 'text-gray-600' 329 }`} 330 > 331 KG 332 </button> 333 <button 334 type="button" 335 onClick={() => setSettings((s) => ({ ...s, product_weight_unit: 'g' }))} 336 className={`px-4 py-2 text-sm font-medium rounded-md ${ 337 settings.product_weight_unit === 'g' ? 'bg-white text-primary shadow-sm' : 'text-gray-600' 338 }`} 339 > 340 Gram 341 </button> 342 </div> 343 </div> 344 <label className="flex items-start gap-3 cursor-pointer"> 345 <input 346 type="checkbox" 347 className="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" 348 checked={Boolean(settings.charge_actual_weight_only)} 349 onChange={(e) => setSettings((s) => ({ ...s, charge_actual_weight_only: e.target.checked }))} 350 /> 351 <span> 352 <span className="block text-sm font-semibold text-gray-800">Charge actual product weight only</span> 353 <span className="block text-sm text-gray-600 mt-1"> 354 Box packing is skipped. Quotes send filler dimensions so volumetric weight stays below product weight. 355 </span> 356 </span> 357 </label> 358 {settings.charge_actual_weight_only ? ( 359 <div className="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950"> 360 <Info size={18} className="shrink-0 mt-0.5" /> 361 <p>The Boxes tab is hidden. Filler parcel size keeps volumetric weight below actual weight.</p> 362 </div> 363 ) : null} 364 </div> 365 </div> 366 273 367 {/* Shipping services */} 274 368 <div className="tnxl-card"> 275 <div className="bg- secondary p-5 flex items-center justify-between gap-4">369 <div className="bg-primary p-5 flex items-center justify-between gap-4"> 276 370 <div className="flex items-center gap-3"> 277 <Truck className="text- primaryw-6 h-6" />371 <Truck className="text-white w-6 h-6" /> 278 372 <h2 className="text-lg font-bold text-white">Shipping Services</h2> 279 373 </div> … … 303 397 ) : ( 304 398 <div className="space-y-4"> 305 <p className="text-sm text-gray-600">Unchecked services will not appear as Thai Nexus checkout options.</p> 399 <p className="text-sm text-gray-600"> 400 Unchecked services will not appear as Thai Nexus checkout options. Choose All Available, selected countries, exclude, or rest of world per service. 401 </p> 306 402 <div className="flex gap-4 text-sm"> 307 403 <button … … 324 420 </div> 325 421 <div className="divide-y divide-gray-100 border border-gray-100 rounded-xl"> 422 <ul> 326 423 {services.map((service) => { 327 424 const id = normalizeServiceId(service.id); 328 const enabled = !settings.disabled_service_ids.includes(id); 425 const keys = serviceKeys(service); 426 const enabled = !keys.some((key) => settings.disabled_service_ids.includes(key)); 427 const coverage = coverageFor(service); 428 const isExclude = Boolean(coverage.excludeCountries); 429 const isRestOfWorld = Boolean(coverage.restOfWorld) && !isExclude; 430 const isWorldwide = coverage.worldwide && !isRestOfWorld && !isExclude; 431 const isSelected = !isWorldwide && !isRestOfWorld && !isExclude; 329 432 return ( 330 <label key={id} className="flex items-center gap-4 px-4 py-3 cursor-pointer hover:bg-gray-50"> 331 <input 332 type="checkbox" 333 checked={enabled} 334 onChange={(event) => toggleService(service.id, event.target.checked)} 335 className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" 336 /> 337 {service.logo 338 ? <img src={service.logo} alt="" className="w-8 h-8 object-contain" /> 339 : <Truck size={20} className="text-gray-400" />} 340 <span className="text-sm font-medium text-gray-800"> 341 {service.service_name || service.name || service.id} 342 </span> 343 </label> 433 <li key={id} className="px-4 py-3 hover:bg-gray-50"> 434 <label className="flex items-center gap-4 cursor-pointer"> 435 <input 436 type="checkbox" 437 checked={enabled} 438 onChange={(event) => toggleService(service, event.target.checked)} 439 className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" 440 /> 441 {service.logo 442 ? <img src={service.logo} alt="" className="w-8 h-8 object-contain" /> 443 : <Truck size={20} className="text-gray-400" />} 444 <span className="text-sm font-medium text-gray-800"> 445 {service.service_name || service.name || service.id} 446 </span> 447 </label> 448 {enabled && ( 449 <div className="mt-3 ml-8 space-y-3"> 450 <div className="flex flex-wrap gap-4 text-sm"> 451 <label className="inline-flex items-center gap-2 cursor-pointer"> 452 <input 453 type="radio" 454 name={`coverage-${id}`} 455 checked={isWorldwide} 456 onChange={() => setCoverage(service, { worldwide: true, countries: [] })} 457 /> 458 All Available 459 </label> 460 <label className="inline-flex items-center gap-2 cursor-pointer"> 461 <input 462 type="radio" 463 name={`coverage-${id}`} 464 checked={isSelected} 465 onChange={() => setCoverage(service, { worldwide: false, countries: coverage.countries || [] })} 466 /> 467 Selected countries 468 </label> 469 <label className="inline-flex items-center gap-2 cursor-pointer"> 470 <input 471 type="radio" 472 name={`coverage-${id}`} 473 checked={isExclude} 474 onChange={() => setCoverage(service, { worldwide: true, excludeCountries: true, countries: coverage.countries || [] })} 475 /> 476 Exclude countries 477 </label> 478 <label className="inline-flex items-center gap-2 cursor-pointer"> 479 <input 480 type="radio" 481 name={`coverage-${id}`} 482 checked={isRestOfWorld} 483 onChange={() => setCoverage(service, { worldwide: false, restOfWorld: true, countries: [] })} 484 /> 485 Rest of world 486 </label> 487 </div> 488 {isSelected && ( 489 <CountryMultiSelect 490 selected={coverage.countries || []} 491 onChange={(countries) => setCoverage(service, { worldwide: false, countries })} 492 /> 493 )} 494 {isExclude && ( 495 <CountryMultiSelect 496 selected={coverage.countries || []} 497 placeholder="Search countries to exclude" 498 onChange={(countries) => setCoverage(service, { worldwide: true, excludeCountries: true, countries })} 499 /> 500 )} 501 {isRestOfWorld && ( 502 <p className="text-xs text-gray-500">Shown only when no other enabled service is offered for the destination.</p> 503 )} 504 </div> 505 )} 506 </li> 344 507 ); 345 508 })} 509 </ul> 346 510 </div> 347 511 </div> … … 352 516 {/* Product eligibility */} 353 517 <div className="tnxl-card"> 354 <div className="bg- secondary p-5 flex items-center gap-3">355 <Package className="text- primaryw-6 h-6" />518 <div className="bg-primary p-5 flex items-center gap-3"> 519 <Package className="text-white w-6 h-6" /> 356 520 <h2 className="text-lg font-bold text-white">Product Shipping Eligibility</h2> 357 521 </div> … … 380 544 {/* Shipper Address */} 381 545 <div className="tnxl-card"> 382 <div className="bg- secondary p-5 flex items-center gap-3">383 <MapPin className="text- primaryw-6 h-6" />546 <div className="bg-primary p-5 flex items-center gap-3"> 547 <MapPin className="text-white w-6 h-6" /> 384 548 <h2 className="text-lg font-bold text-white">Store Origin Address</h2> 385 549 </div> … … 454 618 onChange={(e) => setSettings({ ...settings, shipper: { ...settings.shipper, country: e.target.value } })} 455 619 > 456 <option value="TH">Thailand</option> 457 {/* Add more countries if needed */} 620 <option value="TH">Thailand (TH)</option> 621 <option value="US">United States (US)</option> 622 <option value="GB">United Kingdom (GB)</option> 623 <option value="AU">Australia (AU)</option> 624 <option value="SG">Singapore (SG)</option> 458 625 </select> 459 626 </div> 460 627 </div> 461 </div>462 </div>463 464 {/* Privacy & Data Disclosure */}465 <div className="tnxl-card bg-secondary border-secondary">466 <div className="bg-secondary border-secondary p-5 flex items-center gap-3 ">467 <AlertCircle className="text-blue-600 w-6 h-6" />468 <h2 className="text-lg font-bold text-white">Privacy & Data Disclosure</h2>469 </div>470 <div className="p-8">471 <p className="text-black text-sm leading-relaxed">472 To provide real-time shipping quotations, this plugin securely transmits package dimensions,473 weights, and the <strong>customer's shipping address</strong> (Country, City, State, and Postal Code)474 to the <a href="https://app.thainexus.co.th/" target="_blank" className="text-primary hover:underline font-medium"> Thai Nexus API</a>.475 </p>476 <p className="text-black text-sm mt-4 leading-relaxed">477 Additionally, it communicates with the <a href="https://frankfurter.dev/" target="_blank" rel="noreferrer" className="text-primary hover:underline font-medium">Frankfurter API</a> to fetch current exchange478 rates, and falls back to <a href="https://www.exchangerate-api.com" target="_blank" rel="noreferrer" className="text-primary hover:underline font-medium">Exchange Rate API</a> for currencies Frankfurter does not cover.479 No customer PII (Name, Email, Phone) is sent during the quotation phase.480 </p>481 628 </div> 482 629 </div> -
thai-nexus-logistics/tags/1.5.15/admin/src/pages/ShipmentsPage.jsx
r3678280 r3705845 61 61 const [syncing, setSyncing] = useState(false); 62 62 const [syncMessage, setSyncMessage] = useState(''); 63 const [errorType, setErrorType] = useState(null); // 'auth' | 'general' 63 const [searchQuery, setSearchQuery] = useState(''); 64 const [errorType, setErrorType] = useState(null); 64 65 65 66 useEffect(() => { … … 181 182 </span> 182 183 </h2> 183 184 184 185 <div className="relative group"> 185 186 <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-3.5 h-3.5 z-10 group-focus-within:text-primary transition-colors" /> 186 <input 187 type="text" 188 placeholder="Search request number..." 187 <input 188 type="text" 189 placeholder="Search request number or status..." 190 value={searchQuery} 191 onChange={(e) => setSearchQuery(e.target.value)} 189 192 className="tnxl-input !pl-9 py-1.5 text-xs w-full md:w-56 relative border-gray-100 bg-gray-50/50 focus:bg-white" 190 193 /> … … 221 224 </td> 222 225 </tr> 223 ) : shipments.length === 0 ? ( 226 ) : shipments.filter((shipment) => { 227 const q = searchQuery.trim().toLowerCase(); 228 if (!q) return true; 229 const request = String(shipment.request_number || '').toLowerCase(); 230 const status = String(shipment.status || '').toLowerCase(); 231 return request.includes(q) || status.includes(q); 232 }).length === 0 ? ( 224 233 <tr> 225 234 <td colSpan={5} className="py-16 text-center animate-in fade-in zoom-in duration-300"> … … 230 239 </tr> 231 240 ) : ( 232 shipments.map((shipment, index) => ( 233 <tr 234 key={shipment.id} 241 shipments.filter((shipment) => { 242 const q = searchQuery.trim().toLowerCase(); 243 if (!q) return true; 244 const request = String(shipment.request_number || '').toLowerCase(); 245 const status = String(shipment.status || '').toLowerCase(); 246 return request.includes(q) || status.includes(q); 247 }).map((shipment, index) => ( 248 <tr 249 key={shipment.id} 235 250 style={{ animationDelay: `${index * 30}ms` }} 236 251 className="hover:bg-blue-50/20 transition-all group animate-in fade-in slide-in-from-left-1 duration-200" … … 256 271 </td> 257 272 <td className="px-5 py-3 text-right"> 258 <button 273 <button 259 274 onClick={() => fetchShipmentDetails(shipment.request_number)} 260 275 className="text-gray-300 hover:text-primary p-1.5 hover:bg-white rounded-md transition-all shadow-none hover:shadow-sm" … … 277 292 </p> 278 293 <div className="flex gap-1.5"> 279 <button 294 <button 280 295 disabled={page === 1 || loading} 281 296 onClick={() => setPage(p => p - 1)} … … 284 299 <ChevronLeft size={16} /> 285 300 </button> 286 <button 301 <button 287 302 disabled={loading || (total > 0 && page * 10 >= total)} 288 303 onClick={() => setPage(p => p + 1)} … … 341 356 </div> 342 357 </div> 343 <button 358 <button 344 359 onClick={() => setSelectedShipment(null)} 345 360 className="p-2 bg-white/5 hover:bg-white/15 text-white rounded-xl transition-all border border-white/10 group shadow-sm" … … 371 386 </p> 372 387 <p className="text-xs text-gray-600 leading-relaxed flex items-start gap-2"> 373 <MapPin size={12} className="text-primary mt-0.5 shrink-0" /> 388 <MapPin size={12} className="text-primary mt-0.5 shrink-0" /> 374 389 <span>{getAddress(selectedShipment, 'shipper_address').address_line1 || getAddress(selectedShipment, 'shipper_address').address}, <span className="font-bold text-gray-400">{getAddress(selectedShipment, 'shipper_address').city}, {getAddress(selectedShipment, 'shipper_address').country}</span></span> 375 390 </p> … … 390 405 </p> 391 406 <p className="text-xs text-gray-600 leading-relaxed flex items-start gap-2"> 392 <MapPin size={12} className="text-blue-500 mt-0.5 shrink-0" /> 407 <MapPin size={12} className="text-blue-500 mt-0.5 shrink-0" /> 393 408 <span>{getAddress(selectedShipment, 'consignee_address').address_line1 || getAddress(selectedShipment, 'consignee_address').address}, <span className="font-bold text-gray-400">{getAddress(selectedShipment, 'consignee_address').city}, {getAddress(selectedShipment, 'consignee_address').country}</span></span> 394 409 </p> … … 416 431 ))} 417 432 </div> 418 433 419 434 <div className="bg-gray-50 p-4 rounded-xl border border-gray-100 flex items-start gap-3"> 420 435 <Tag className="text-secondary/20 w-4 h-4 mt-0.5 shrink-0" /> … … 446 461 Sync tracking 447 462 </button> 448 <button 463 <button 449 464 type="button" 450 465 onClick={() => { -
thai-nexus-logistics/tags/1.5.15/admin/tailwind.config.js
r3559867 r3705845 9 9 colors: { 10 10 primary: { 11 DEFAULT: '# dc2626', // Red12 hover: '# b91c1c',11 DEFAULT: '#272262', 12 hover: '#1e1a4d', 13 13 }, 14 14 secondary: { 15 DEFAULT: '# 272262', // Dark Blue/Purple16 hover: '# 1e1a4d',15 DEFAULT: '#bf1d2d', 16 hover: '#9f1824', 17 17 }, 18 18 } -
thai-nexus-logistics/tags/1.5.15/dist/.vite/manifest.json
r3678280 r3705845 1 1 { 2 2 "index.html": { 3 "file": "assets/index-B lRndIDa.js",3 "file": "assets/index-BBMv6f4n.js", 4 4 "name": "index", 5 5 "src": "index.html", 6 6 "isEntry": true, 7 7 "css": [ 8 "assets/index- DFHBTHlP.css"8 "assets/index-CrlahNyv.css" 9 9 ] 10 10 } -
thai-nexus-logistics/tags/1.5.15/dist/index.html
r3678280 r3705845 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <title>admin</title> 8 <script type="module" crossorigin src="/assets/index-B lRndIDa.js"></script>9 <link rel="stylesheet" crossorigin href="/assets/index- DFHBTHlP.css">8 <script type="module" crossorigin src="/assets/index-BBMv6f4n.js"></script> 9 <link rel="stylesheet" crossorigin href="/assets/index-CrlahNyv.css"> 10 10 </head> 11 11 <body> -
thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-box-packer.php
r3678280 r3705845 162 162 */ 163 163 public function pack_items($items) { 164 if (class_exists('TNXL_Settings') && TNXL_Settings::is_actual_weight_only()) { 165 return $this->pack_actual_weight_only($items); 166 } 167 164 168 $result = new TNXL_Packing_Result(); 165 169 $box_definitions = get_option('tnxl_box_definitions', []); … … 307 311 308 312 /** 313 * One parcel from product weights. Filler cube so LWH/5000 stays below actual kg. 314 */ 315 private function pack_actual_weight_only($items) { 316 $result = new TNXL_Packing_Result(); 317 $total_weight = 0.0; 318 $labels = array(); 319 $all_document = true; 320 $has_shipping = false; 321 322 foreach ($items as $item_values) { 323 $product = $item_values['data'] ?? null; 324 if (!$product instanceof WC_Product || !$product->needs_shipping()) { 325 continue; 326 } 327 $has_shipping = true; 328 $qty = max(1, absint($item_values['quantity'] ?? 1)); 329 $measurements = TNXL_Product::get_shipping_measurements($product); 330 $wt = $measurements['weight']; 331 if ($wt <= 0) { 332 $result->add_error(sprintf( 333 __('Product "%s" is missing required shipping weight.', 'thai-nexus-logistics'), 334 $product->get_name() 335 )); 336 continue; 337 } 338 $total_weight += $wt * $qty; 339 $labels[] = self::box_item_record($product->get_name(), $qty, (int) $product->get_id()); 340 if (!TNXL_Product::is_document($product)) { 341 $all_document = false; 342 } 343 } 344 345 if ($result->has_errors()) { 346 return $result; 347 } 348 if (!$has_shipping || $total_weight <= 0) { 349 $result->add_error(__('No shippable items', 'thai-nexus-logistics')); 350 return $result; 351 } 352 353 $dims = self::filler_dims_below_actual_weight($total_weight); 354 $result->add_box(array( 355 'name' => __('Actual weight', 'thai-nexus-logistics'), 356 'length' => $dims['length'], 357 'width' => $dims['width'], 358 'height' => $dims['height'], 359 'weight' => $total_weight, 360 'items' => $labels, 361 'is_document' => $all_document, 362 )); 363 364 return $result; 365 } 366 367 /** 368 * @return array{length:float,width:float,height:float} 369 */ 370 public static function filler_dims_below_actual_weight(float $actual_kg): array { 371 $weight = max(0.001, $actual_kg); 372 $max_volume = $weight * 5000 * 0.5; 373 $side = max(1, (int) floor(pow($max_volume, 1 / 3))); 374 while ($side > 1 && (($side * $side * $side) / 5000) >= $weight) { 375 $side -= 1; 376 } 377 if (($side * $side * $side) / 5000 >= $weight) { 378 $side = 1; 379 } 380 return array('length' => (float) $side, 'width' => (float) $side, 'height' => (float) $side); 381 } 382 383 /** 309 384 * Validate all items have dimensions and weight 310 385 */ … … 312 387 $result = new TNXL_Packing_Result(); 313 388 foreach ($items as $item_values) { 314 $product = $item_values['data'] ;315 if (!$product ->needs_shipping()) continue;389 $product = $item_values['data'] ?? null; 390 if (!$product instanceof WC_Product || !$product->needs_shipping()) continue; 316 391 317 392 $measurements = TNXL_Product::get_shipping_measurements($product); … … 343 418 344 419 foreach ($items as $item_values) { 345 $product = $item_values['data'] ;420 $product = $item_values['data'] ?? null; 346 421 $qty = $item_values['quantity']; 347 422 348 if (!$product ->needs_shipping()) continue;423 if (!$product instanceof WC_Product || !$product->needs_shipping()) continue; 349 424 350 425 $measurements = TNXL_Product::get_shipping_measurements($product); -
thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-commission.php
r3560480 r3705845 17 17 } 18 18 19 private function __construct() { 20 // No longer hooking into woocommerce_cart_calculate_fees 21 // Commission will be added as a buffer to shipping rates instead 22 } 19 private function __construct() {} 23 20 24 21 /** 25 * Calculate total commission based on current cart 26 * 27 * @return float Total commission amount 22 * Additive commission only (quote markup is applied in apply_checkout_pricing). 28 23 */ 29 24 public function get_total_commission() { 30 if (!TNXL_Settings::can_fetch_checkout_rates() || !function_exists('WC') || !WC()->cart) { 25 return self::apply_checkout_pricing(0, self::cart_pricing_context()); 26 } 27 28 /** 29 * checkout = (quote + matching_fixed) x Π(1 + quote_% / 100) + cart/product_% 30 */ 31 public static function apply_checkout_pricing(float $quote_thb, array $context = array()): float { 32 if (!TNXL_Settings::can_fetch_checkout_rates()) { 33 return max(0, round($quote_thb, 2)); 34 } 35 36 $rules = get_option('tnxl_commission_rules', array()); 37 if (!is_array($rules) || empty($rules)) { 38 return max(0, round($quote_thb, 2)); 39 } 40 41 $quote = $quote_thb > 0 ? $quote_thb : 0; 42 $mode = TNXL_Settings::get_pricing_mode(); 43 $active = $mode === 'basic' 44 ? array_values(array_filter($rules, array(__CLASS__, 'rule_is_always_on'))) 45 : $rules; 46 47 $fixed = 0.0; 48 $markup_multiplier = 1.0; 49 $additive = 0.0; 50 51 foreach ($active as $rule) { 52 if (!is_array($rule)) { 53 continue; 54 } 55 $match = self::rule_matches($rule, $context); 56 if (!$match['matched']) { 57 continue; 58 } 59 60 $pickup = self::pickup_from_rule($rule); 61 $markup = self::markup_percent_from_rule($rule); 62 $cart_percent = self::cart_percent_from_rule($rule); 63 if ($pickup <= 0 && $markup <= 0 && $cart_percent <= 0) { 64 if (!empty($rule['stopProcessing']) || !empty($rule['stop_processing'])) { 65 break; 66 } 67 continue; 68 } 69 70 if ($pickup > 0) { 71 $fixed += self::scaled_pickup( 72 $pickup, 73 self::pickup_unit_from_rule($rule), 74 $match['matchingQty'], 75 (float) ($context['cartWeightKg'] ?? $context['cart_weight_kg'] ?? 0) 76 ); 77 } 78 if ($markup > 0) { 79 $markup_multiplier *= 1 + ($markup / 100); 80 } 81 if ($cart_percent > 0) { 82 $has_products = false; 83 foreach (self::effective_conditions($rule) as $condition) { 84 if (($condition['type'] ?? '') === 'specific_products') { 85 $has_products = true; 86 break; 87 } 88 } 89 $basis = $has_products 90 ? $match['productSubtotal'] 91 : (float) ($context['cartSubtotal'] ?? $context['cart_subtotal'] ?? 0); 92 $additive += ($basis * $cart_percent) / 100; 93 } 94 95 if (!empty($rule['stopProcessing']) || !empty($rule['stop_processing'])) { 96 break; 97 } 98 } 99 100 $total = ($quote + $fixed) * $markup_multiplier + $additive; 101 if (!is_finite($total) || $total < 0) { 31 102 return 0; 32 103 } 33 104 34 $rules = get_option('tnxl_commission_rules', array()); 35 if (empty($rules) || !is_array($rules)) { 105 return round($total, 2); 106 } 107 108 /** 109 * Convert a THB quote to store currency, then apply pickup/markup/cart % 110 * in that currency (admin fees UI uses the store symbol). 111 * 112 * @return array{quote_store:float,priced:float,commission:float} 113 */ 114 public static function price_converted_quote(float $quote_thb, $exchange_rate, array $context = array()): array { 115 $quote_store = $quote_thb > 0 ? $quote_thb : 0.0; 116 if (is_numeric($exchange_rate) && (float) $exchange_rate > 0) { 117 $quote_store = $quote_store * (float) $exchange_rate; 118 } 119 $priced = self::apply_checkout_pricing($quote_store, $context); 120 return array( 121 'quote_store' => round($quote_store, 2), 122 'priced' => $priced, 123 'commission' => max(0, round($priced - $quote_store, 2)), 124 ); 125 } 126 127 public static function cart_pricing_context(array $extra = array()): array { 128 $items = array(); 129 $subtotal = 0.0; 130 $qty = 0; 131 $weight = 0.0; 132 $dest = ''; 133 134 if (function_exists('WC') && WC()->cart) { 135 $subtotal = (float) WC()->cart->get_subtotal(); 136 $customer = WC()->customer; 137 if ($customer) { 138 $dest = (string) $customer->get_shipping_country(); 139 } 140 foreach (WC()->cart->get_cart() as $cart_item) { 141 $product = $cart_item['data'] ?? null; 142 $line_qty = max(1, (int) ($cart_item['quantity'] ?? 1)); 143 $qty += $line_qty; 144 $items[] = array( 145 'product_id' => (string) ($cart_item['product_id'] ?? ''), 146 'variation_id' => (string) ($cart_item['variation_id'] ?? ''), 147 'quantity' => $line_qty, 148 'line_total' => (float) ($cart_item['line_total'] ?? 0), 149 ); 150 if ($product instanceof WC_Product && class_exists('TNXL_Product')) { 151 $weight += TNXL_Product::get_shipping_measurements($product)['weight'] * $line_qty; 152 } 153 } 154 } 155 156 return array_merge(array( 157 'items' => $items, 158 'cartSubtotal' => $subtotal, 159 'destinationCountry' => $dest, 160 'cartWeightKg' => $weight, 161 'itemQuantity' => $qty, 162 'serviceIds' => self::chosen_service_ids(), 163 ), $extra); 164 } 165 166 private static function chosen_service_ids(): array { 167 if (!function_exists('WC') || !WC()->session || !class_exists('TNXL_Service_Coverage')) { 168 return array(); 169 } 170 $chosen = WC()->session->get('chosen_shipping_methods'); 171 if (!is_array($chosen) || empty($chosen)) { 172 return array(); 173 } 174 $ids = array(); 175 $packages = WC()->shipping() ? WC()->shipping()->get_packages() : array(); 176 foreach ($packages as $index => $package) { 177 $rate_id = $chosen[$index] ?? ''; 178 $rate = $package['rates'][$rate_id] ?? null; 179 if (!$rate || !is_object($rate) || !method_exists($rate, 'get_meta')) { 180 continue; 181 } 182 $courier = (string) $rate->get_meta('tnxl_courier'); 183 $display = (string) $rate->get_meta('tnxl_courier_display'); 184 if ($courier === '' && $display === '') { 185 continue; 186 } 187 foreach (TNXL_Service_Coverage::coverage_ids(array($courier, $display)) as $id) { 188 $ids[] = $id; 189 } 190 } 191 return array_values(array_unique($ids)); 192 } 193 194 private static function pickup_from_rule(array $rule): float { 195 $type = $rule['feeType'] ?? $rule['fee_type'] ?? 'fixed'; 196 if ($type === 'fixed' || $type === 'mixed') { 197 $value = (float) ($rule['feeValue'] ?? $rule['fee_value'] ?? 0); 198 return $value > 0 ? $value : 0; 199 } 200 return 0; 201 } 202 203 private static function markup_percent_from_rule(array $rule): float { 204 $type = $rule['feeType'] ?? $rule['fee_type'] ?? 'fixed'; 205 if ($type === 'mixed') { 206 $value = (float) ($rule['markupPercent'] ?? $rule['markup_percent'] ?? 0); 207 return $value > 0 ? $value : 0; 208 } 209 if ($type === 'quote_percentage') { 210 $value = (float) ($rule['feeValue'] ?? $rule['fee_value'] ?? 0); 211 return $value > 0 ? $value : 0; 212 } 213 return 0; 214 } 215 216 private static function cart_percent_from_rule(array $rule): float { 217 $type = $rule['feeType'] ?? $rule['fee_type'] ?? 'fixed'; 218 if ($type === 'percentage') { 219 $value = (float) ($rule['feeValue'] ?? $rule['fee_value'] ?? 0); 220 return $value > 0 ? $value : 0; 221 } 222 $extra = (float) ($rule['cartPercent'] ?? $rule['cart_percent'] ?? 0); 223 return $extra > 0 ? $extra : 0; 224 } 225 226 private static function pickup_unit_from_rule(array $rule): string { 227 $unit = $rule['pickupUnit'] ?? $rule['pickup_unit'] ?? 'once'; 228 return in_array($unit, array('per_item', 'per_kg'), true) ? $unit : 'once'; 229 } 230 231 private static function effective_conditions(array $rule): array { 232 if (!empty($rule['conditions']) && is_array($rule['conditions'])) { 233 return array_values(array_filter($rule['conditions'], array(__CLASS__, 'condition_is_active'))); 234 } 235 236 $type = $rule['conditionType'] ?? $rule['condition_type'] ?? ''; 237 if ($type === 'specific_products') { 238 $ids = array_values(array_filter(array_map('intval', (array) ($rule['specificProducts'] ?? $rule['specific_products'] ?? array())))); 239 return array(array('type' => 'specific_products', 'specificProducts' => $ids)); 240 } 241 242 $min = (float) ($rule['minRange'] ?? $rule['min_range'] ?? 0); 243 $max = (float) ($rule['maxRange'] ?? $rule['max_range'] ?? 0); 244 if ($min > 0 || $max > 0) { 245 return array(array('type' => 'subtotal_range', 'minRange' => $min, 'maxRange' => $max)); 246 } 247 248 return array(); 249 } 250 251 public static function rule_has_effect($rule): bool { 252 if (!is_array($rule)) { 253 return false; 254 } 255 256 return self::pickup_from_rule($rule) > 0 257 || self::markup_percent_from_rule($rule) > 0 258 || self::cart_percent_from_rule($rule) > 0; 259 } 260 261 public static function rule_is_always_on($rule): bool { 262 if (!is_array($rule)) { 263 return false; 264 } 265 $conditions = self::effective_conditions($rule); 266 if (empty($conditions)) { 267 return true; 268 } 269 foreach ($conditions as $condition) { 270 if (($condition['type'] ?? '') !== 'subtotal_range') { 271 return false; 272 } 273 if ((float) ($condition['minRange'] ?? $condition['min_range'] ?? 0) > 0 274 || (float) ($condition['maxRange'] ?? $condition['max_range'] ?? 0) > 0) { 275 return false; 276 } 277 } 278 return true; 279 } 280 281 private static function condition_is_active($condition): bool { 282 if (!is_array($condition)) { 283 return false; 284 } 285 $type = $condition['type'] ?? ''; 286 if ($type === 'subtotal_range') { 287 return (float) ($condition['minRange'] ?? 0) > 0 || (float) ($condition['maxRange'] ?? 0) > 0; 288 } 289 if ($type === 'specific_products') { 290 return !empty($condition['specificProducts']) || !empty($condition['specific_products']); 291 } 292 if ($type === 'destination_country') { 293 return !empty($condition['countries']); 294 } 295 if ($type === 'weight_range') { 296 return (float) ($condition['minKg'] ?? 0) > 0 || (float) ($condition['maxKg'] ?? 0) > 0; 297 } 298 if ($type === 'item_quantity') { 299 return (float) ($condition['minQuantity'] ?? 0) > 0 || (float) ($condition['maxQuantity'] ?? 0) > 0; 300 } 301 return !empty($condition['serviceIds']) || !empty($condition['service_ids']); 302 } 303 304 /** 305 * @return array{matched:bool,productSubtotal:float,matchingQty:int} 306 */ 307 private static function rule_matches(array $rule, array $context): array { 308 $items = $context['items'] ?? array(); 309 $conditions = self::effective_conditions($rule); 310 $product_subtotal = 0.0; 311 $matching_qty = 0; 312 $has_product_filter = false; 313 314 foreach ($conditions as $condition) { 315 if (!self::condition_matches($condition, $context, $items)) { 316 return array('matched' => false, 'productSubtotal' => 0.0, 'matchingQty' => 0); 317 } 318 if (($condition['type'] ?? '') === 'specific_products') { 319 $has_product_filter = true; 320 $match = self::matching_products($items, $condition['specificProducts'] ?? $condition['specific_products'] ?? array()); 321 $product_subtotal += $match['productSubtotal']; 322 $matching_qty += $match['matchingQty']; 323 } 324 } 325 326 if (!$has_product_filter) { 327 $matching_qty = (int) ($context['itemQuantity'] ?? $context['item_quantity'] ?? 0); 328 if ($matching_qty <= 0) { 329 foreach ($items as $item) { 330 $matching_qty += max(1, (int) ($item['quantity'] ?? 1)); 331 } 332 } 333 } 334 335 return array('matched' => true, 'productSubtotal' => $product_subtotal, 'matchingQty' => $matching_qty); 336 } 337 338 private static function condition_matches(array $condition, array $context, array $items): bool { 339 $type = $condition['type'] ?? ''; 340 if ($type === 'subtotal_range') { 341 return self::in_min_max( 342 (float) ($context['cartSubtotal'] ?? $context['cart_subtotal'] ?? 0), 343 (float) ($condition['minRange'] ?? 0), 344 (float) ($condition['maxRange'] ?? 0) 345 ); 346 } 347 if ($type === 'specific_products') { 348 $ids = $condition['specificProducts'] ?? $condition['specific_products'] ?? array(); 349 return self::matching_products($items, $ids)['inCart']; 350 } 351 if ($type === 'destination_country') { 352 $dest = strtoupper(trim((string) ($context['destinationCountry'] ?? $context['destination_country'] ?? ''))); 353 if ($dest === '') { 354 return false; 355 } 356 $codes = array(); 357 foreach ((array) ($condition['countries'] ?? array()) as $code) { 358 $iso = strtoupper(trim((string) $code)); 359 if ($iso !== '') { 360 $codes[$iso] = true; 361 } 362 } 363 if (empty($codes)) { 364 return false; 365 } 366 $has = isset($codes[$dest]); 367 return !empty($condition['excludeCountries']) || !empty($condition['exclude_countries']) ? !$has : $has; 368 } 369 if ($type === 'weight_range') { 370 return self::in_min_max( 371 (float) ($context['cartWeightKg'] ?? $context['cart_weight_kg'] ?? 0), 372 (float) ($condition['minKg'] ?? 0), 373 (float) ($condition['maxKg'] ?? 0) 374 ); 375 } 376 if ($type === 'item_quantity') { 377 $qty = (int) ($context['itemQuantity'] ?? $context['item_quantity'] ?? 0); 378 if ($qty <= 0) { 379 foreach ($items as $item) { 380 $qty += max(1, (int) ($item['quantity'] ?? 1)); 381 } 382 } 383 return self::in_min_max($qty, (float) ($condition['minQuantity'] ?? 0), (float) ($condition['maxQuantity'] ?? 0)); 384 } 385 386 $stored = $condition['serviceIds'] ?? $condition['service_ids'] ?? array(); 387 $quoted = $context['serviceIds'] ?? $context['service_ids'] ?? array(); 388 $want = array_fill_keys(TNXL_Service_Coverage::coverage_ids($stored), true); 389 if (empty($want)) { 390 return false; 391 } 392 foreach (TNXL_Service_Coverage::coverage_ids($quoted) as $id) { 393 if (isset($want[$id])) { 394 return true; 395 } 396 } 397 return false; 398 } 399 400 /** 401 * @param mixed[] $items 402 * @param mixed[] $product_ids 403 * @return array{inCart:bool,productSubtotal:float,matchingQty:int} 404 */ 405 private static function matching_products(array $items, array $product_ids): array { 406 $ids = array(); 407 foreach ($product_ids as $id) { 408 $key = (string) $id; 409 if ($key !== '') { 410 $ids[$key] = true; 411 } 412 } 413 $subtotal = 0.0; 414 $qty = 0; 415 $in_cart = false; 416 foreach ($items as $item) { 417 $candidates = array( 418 (string) ($item['product_id'] ?? ''), 419 (string) ($item['variation_id'] ?? ''), 420 ); 421 $hit = false; 422 foreach ($candidates as $pid) { 423 if ($pid !== '' && isset($ids[$pid])) { 424 $hit = true; 425 break; 426 } 427 } 428 if (!$hit) { 429 continue; 430 } 431 $in_cart = true; 432 $line_qty = max(1, (int) ($item['quantity'] ?? 1)); 433 $qty += $line_qty; 434 $subtotal += (float) ($item['line_total'] ?? 0); 435 } 436 return array('inCart' => $in_cart, 'productSubtotal' => $subtotal, 'matchingQty' => $qty); 437 } 438 439 private static function scaled_pickup(float $pickup, string $unit, int $matching_qty, float $cart_weight_kg): float { 440 if ($pickup <= 0) { 36 441 return 0; 37 442 } 38 39 $cart = WC()->cart; 40 $cart_subtotal = $cart->get_subtotal(); 41 $total_commission = 0; 42 43 foreach ($rules as $rule) { 44 $condition_type = $rule['condition_type'] ?? ''; 45 $fee_type = $rule['fee_type'] ?? 'fixed'; 46 $fee_value = floatval($rule['fee_value'] ?? 0); 47 48 if ($fee_value <= 0) continue; 49 50 $fee_amount = 0; 51 $apply_fee = false; 52 53 if ($condition_type === 'subtotal_range') { 54 $min_range = floatval($rule['min_range'] ?? 0); 55 $max_range = floatval($rule['max_range'] ?? 0); 56 57 if ($cart_subtotal >= $min_range && ($max_range == 0 || $cart_subtotal <= $max_range)) { 58 $apply_fee = true; 59 if ($fee_type === 'percentage') { 60 $fee_amount = ($cart_subtotal * $fee_value) / 100; 61 } else { 62 $fee_amount = $fee_value; 63 } 64 } 65 } elseif ($condition_type === 'specific_products') { 66 $specific_products = $rule['specific_products'] ?? array(); 67 if (empty($specific_products)) continue; 68 69 $matching_products_total = 0; 70 $found_product = false; 71 72 foreach ($cart->get_cart() as $cart_item) { 73 $product_id = $cart_item['product_id']; 74 $variation_id = $cart_item['variation_id']; 75 76 if (in_array($product_id, $specific_products) || in_array($variation_id, $specific_products)) { 77 $found_product = true; 78 $matching_products_total += $cart_item['line_total']; 79 } 80 } 81 82 if ($found_product) { 83 $apply_fee = true; 84 if ($fee_type === 'percentage') { 85 $fee_amount = ($matching_products_total * $fee_value) / 100; 86 } else { 87 $fee_amount = $fee_value; 88 } 89 } 90 } 91 92 if ($apply_fee && $fee_amount > 0) { 93 $total_commission += $fee_amount; 94 } 95 } 96 97 return $total_commission; 443 if ($unit === 'per_item') { 444 return $pickup * $matching_qty; 445 } 446 if ($unit === 'per_kg') { 447 return $pickup * ($cart_weight_kg > 0 ? $cart_weight_kg : 0); 448 } 449 return $pickup; 450 } 451 452 private static function in_min_max(float $value, float $min, float $max): bool { 453 $lo = $min > 0 ? $min : 0; 454 $hi = $max > 0 ? $max : 0; 455 if ($hi > 0 && $lo > $hi) { 456 return $value >= $hi && $value <= $lo; 457 } 458 return $value >= $lo && ($hi <= 0 || $value <= $hi); 98 459 } 99 460 } 100 -
thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-order.php
r3678280 r3705845 323 323 'width_cm' => $box['width'], 324 324 'height_cm' => $box['height'], 325 'shipment_type' => 'parcel', 325 'is_document' => !empty($box['is_document']), 326 'shipment_type' => !empty($box['is_document']) ? 'document' : 'parcel', 326 327 'service_type' => $service['name'] !== '' ? $service['name'] : $service['id'], 327 328 'courier_name' => $service['id'], -
thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-product.php
r3678280 r3705845 156 156 } 157 157 158 $weight = (float) wc_get_weight($values['weight'], 'kg'); 159 158 160 return array( 159 161 'length' => (float) wc_get_dimension($values['length'], 'cm'), 160 162 'width' => (float) wc_get_dimension($values['width'], 'cm'), 161 163 'height' => (float) wc_get_dimension($values['height'], 'cm'), 162 'weight' => (float) wc_get_weight($values['weight'], 'kg'),164 'weight' => $weight, 163 165 ); 164 166 } -
thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-rest-api.php
r3678280 r3705845 54 54 )); 55 55 56 register_rest_route('tnxl/v1', '/product-catalog', array( 57 'methods' => 'GET', 58 'callback' => array($this, 'get_product_catalog'), 59 'permission_callback' => array($this, 'check_permission'), 60 )); 61 62 register_rest_route('tnxl/v1', '/products/(?P<id>\d+)', array( 63 'methods' => 'PUT', 64 'callback' => array($this, 'update_product'), 65 'permission_callback' => array($this, 'check_permission'), 66 )); 67 56 68 register_rest_route('tnxl/v1', '/search-products', array( 57 69 'methods' => 'GET', … … 125 137 'api_token' => TNXL_Settings::get_api_token(), 126 138 'features' => TNXL_Settings::get_features(), 127 'commission_rules' => get_option('tnxl_commission_rules', array()),139 'commission_rules' => $this->sanitize_commission_rules(get_option('tnxl_commission_rules', array())), 128 140 'disabled_service_ids' => TNXL_Settings::get_disabled_service_ids(), 129 141 'shipping_ineligible_product_ids' => TNXL_Settings::get_ineligible_product_ids(), 142 'product_weight_unit' => TNXL_Settings::get_product_weight_unit(), 143 'charge_actual_weight_only' => TNXL_Settings::is_actual_weight_only(), 144 'service_coverage' => TNXL_Settings::get_service_coverage(), 145 'pricing_mode' => TNXL_Settings::get_pricing_mode(), 130 146 'currency_symbol' => $currency_symbol, 131 147 'shipper' => array( … … 180 196 } 181 197 198 if (array_key_exists('product_weight_unit', $params)) { 199 TNXL_Settings::save_product_weight_unit($params['product_weight_unit']); 200 } 201 if (array_key_exists('charge_actual_weight_only', $params)) { 202 TNXL_Settings::save_actual_weight_only($params['charge_actual_weight_only']); 203 } 204 if (isset($params['service_coverage']) && is_array($params['service_coverage'])) { 205 TNXL_Settings::save_service_coverage($params['service_coverage']); 206 } 207 if (array_key_exists('pricing_mode', $params)) { 208 TNXL_Settings::save_pricing_mode($params['pricing_mode']); 209 } 210 182 211 if (isset($params['commission_rules'])) { 183 // Need custom sanitization for an array of rules 184 $rules = array(); 185 if (is_array($params['commission_rules'])) { 186 foreach ($params['commission_rules'] as $rule) { 187 $rules[] = array( 188 'condition_type' => sanitize_text_field($rule['condition_type'] ?? ''), 189 'min_range' => floatval($rule['min_range'] ?? 0), 190 'max_range' => floatval($rule['max_range'] ?? 0), 191 'specific_products' => array_map('intval', (array)($rule['specific_products'] ?? array())), 192 'fee_type' => sanitize_text_field($rule['fee_type'] ?? 'fixed'), 193 'fee_value' => floatval($rule['fee_value'] ?? 0), 194 'fee_label' => sanitize_text_field($rule['fee_label'] ?? ''), 195 ); 196 } 197 } 198 update_option('tnxl_commission_rules', $rules); 212 update_option('tnxl_commission_rules', $this->sanitize_commission_rules($params['commission_rules'])); 199 213 } 200 214 … … 320 334 321 335 return rest_ensure_response($result); 336 } 337 338 public function get_product_catalog($request) { 339 $search = sanitize_text_field((string) $request->get_param('search')); 340 $args = array( 341 'post_type' => 'product', 342 'post_status' => 'publish', 343 'posts_per_page' => 25, 344 's' => $search, 345 'orderby' => 'title', 346 'order' => 'ASC', 347 ); 348 $query = new WP_Query($args); 349 $products = array(); 350 foreach ($query->posts as $post) { 351 $product = wc_get_product($post->ID); 352 if (!$product) { 353 continue; 354 } 355 $m = TNXL_Product::get_shipping_measurements($product); 356 $customs = TNXL_Product::get_customs_details($product); 357 $products[] = array( 358 'id' => $product->get_id(), 359 'name' => $product->get_name(), 360 'sku' => $product->get_sku(), 361 'length' => $m['length'], 362 'width' => $m['width'], 363 'height' => $m['height'], 364 'weight' => $m['weight'], 365 'hs_code' => $customs['hs_code'], 366 'country_of_origin' => $customs['country_of_origin'], 367 'is_document' => TNXL_Product::is_document($product), 368 'is_boxed_product' => TNXL_Product::is_boxed_product($product), 369 'shipping_eligible' => TNXL_Product::is_shipping_eligible($product), 370 'edit_url' => get_edit_post_link($product->get_id(), 'raw'), 371 ); 372 } 373 374 return rest_ensure_response($products); 375 } 376 377 public function update_product($request) { 378 $id = (int) $request['id']; 379 $product = wc_get_product($id); 380 if (!$product) { 381 return new WP_Error('tnxl_product_missing', 'Product not found.', array('status' => 404)); 382 } 383 384 $params = $request->get_json_params(); 385 if (!is_array($params)) { 386 $params = array(); 387 } 388 389 if (isset($params['length']) || isset($params['width']) || isset($params['height'])) { 390 $dim_unit = (string) get_option('woocommerce_dimension_unit', 'cm'); 391 if (isset($params['length'])) { 392 $product->set_length(wc_get_dimension((float) $params['length'], $dim_unit, 'cm')); 393 } 394 if (isset($params['width'])) { 395 $product->set_width(wc_get_dimension((float) $params['width'], $dim_unit, 'cm')); 396 } 397 if (isset($params['height'])) { 398 $product->set_height(wc_get_dimension((float) $params['height'], $dim_unit, 'cm')); 399 } 400 } 401 if (isset($params['weight'])) { 402 $weight_unit = (string) get_option('woocommerce_weight_unit', 'kg'); 403 $product->set_weight(wc_get_weight((float) $params['weight'], $weight_unit, 'kg')); 404 } 405 $product->save(); 406 407 if (array_key_exists('is_document', $params)) { 408 update_post_meta($id, '_tnxl_is_document', !empty($params['is_document']) ? 'yes' : 'no'); 409 } 410 if (array_key_exists('is_boxed_product', $params)) { 411 update_post_meta($id, '_tnxl_is_boxed_product', !empty($params['is_boxed_product']) ? 'yes' : 'no'); 412 } 413 if (array_key_exists('shipping_eligible', $params)) { 414 update_post_meta( 415 $id, 416 '_tnxl_shipping_eligible', 417 $params['shipping_eligible'] === false ? 'no' : 'yes' 418 ); 419 } 420 if (array_key_exists('hs_code', $params)) { 421 update_post_meta($id, '_tnxl_hs_code', TNXL_Product::normalize_hs_code($params['hs_code'])); 422 } 423 if (array_key_exists('country_of_origin', $params)) { 424 $origin = strtoupper(preg_replace('/[^A-Za-z]/', '', sanitize_text_field((string) $params['country_of_origin']))); 425 update_post_meta($id, '_tnxl_country_of_origin', substr($origin, 0, 2)); 426 } 427 428 $fresh = wc_get_product($id); 429 $m = TNXL_Product::get_shipping_measurements($fresh); 430 $customs = TNXL_Product::get_customs_details($fresh); 431 return rest_ensure_response(array( 432 'id' => $fresh->get_id(), 433 'name' => $fresh->get_name(), 434 'sku' => $fresh->get_sku(), 435 'length' => $m['length'], 436 'width' => $m['width'], 437 'height' => $m['height'], 438 'weight' => $m['weight'], 439 'hs_code' => $customs['hs_code'], 440 'country_of_origin' => $customs['country_of_origin'], 441 'is_document' => TNXL_Product::is_document($fresh), 442 'is_boxed_product' => TNXL_Product::is_boxed_product($fresh), 443 'shipping_eligible' => TNXL_Product::is_shipping_eligible($fresh), 444 'edit_url' => get_edit_post_link($fresh->get_id(), 'raw'), 445 )); 446 } 447 448 /** 449 * @param mixed $rules 450 * @return array<int, array<string, mixed>> 451 */ 452 private function sanitize_commission_rules($rules): array { 453 if (!is_array($rules)) { 454 return array(); 455 } 456 $out = array(); 457 foreach ($rules as $index => $rule) { 458 if (!is_array($rule)) { 459 continue; 460 } 461 $conditions = array(); 462 foreach ((array) ($rule['conditions'] ?? array()) as $condition) { 463 if (!is_array($condition)) { 464 continue; 465 } 466 $type = sanitize_key((string) ($condition['type'] ?? '')); 467 $row = array('type' => $type); 468 if ($type === 'subtotal_range') { 469 $row['minRange'] = floatval($condition['minRange'] ?? $condition['min_range'] ?? 0); 470 $row['maxRange'] = floatval($condition['maxRange'] ?? $condition['max_range'] ?? 0); 471 } elseif ($type === 'specific_products') { 472 $row['specificProducts'] = array_values(array_filter(array_map('absint', (array) ($condition['specificProducts'] ?? $condition['specific_products'] ?? array())))); 473 } elseif ($type === 'destination_country') { 474 $codes = array(); 475 foreach ((array) ($condition['countries'] ?? array()) as $code) { 476 $iso = strtoupper(preg_replace('/[^A-Za-z]/', '', (string) $code)); 477 if (strlen($iso) === 2) { 478 $codes[] = $iso; 479 } 480 } 481 $row['countries'] = array_values(array_unique($codes)); 482 $row['excludeCountries'] = !empty($condition['excludeCountries']) || !empty($condition['exclude_countries']); 483 } elseif ($type === 'weight_range') { 484 $row['minKg'] = floatval($condition['minKg'] ?? 0); 485 $row['maxKg'] = floatval($condition['maxKg'] ?? 0); 486 } elseif ($type === 'item_quantity') { 487 $row['minQuantity'] = absint($condition['minQuantity'] ?? 0); 488 $row['maxQuantity'] = absint($condition['maxQuantity'] ?? 0); 489 } elseif ($type === 'shipping_service') { 490 $ids = array(); 491 foreach ((array) ($condition['serviceIds'] ?? $condition['service_ids'] ?? array()) as $id) { 492 $norm = TNXL_Settings::normalize_service_id($id); 493 if ($norm !== '') { 494 $ids[] = $norm; 495 } 496 } 497 $row['serviceIds'] = array_values(array_unique($ids)); 498 } else { 499 continue; 500 } 501 $conditions[] = $row; 502 } 503 504 $id = sanitize_text_field((string) ($rule['id'] ?? ('rule_' . ($index + 1)))); 505 $out[] = array( 506 'id' => $id !== '' ? $id : ('rule_' . ($index + 1)), 507 'conditionType' => sanitize_text_field((string) ($rule['conditionType'] ?? $rule['condition_type'] ?? 'subtotal_range')), 508 'minRange' => floatval($rule['minRange'] ?? $rule['min_range'] ?? 0), 509 'maxRange' => floatval($rule['maxRange'] ?? $rule['max_range'] ?? 0), 510 'specificProducts' => array_values(array_filter(array_map('absint', (array) ($rule['specificProducts'] ?? $rule['specific_products'] ?? array())))), 511 'conditions' => $conditions, 512 'feeType' => sanitize_text_field((string) ($rule['feeType'] ?? $rule['fee_type'] ?? 'fixed')), 513 'feeValue' => floatval($rule['feeValue'] ?? $rule['fee_value'] ?? 0), 514 'markupPercent' => floatval($rule['markupPercent'] ?? $rule['markup_percent'] ?? 0), 515 'cartPercent' => floatval($rule['cartPercent'] ?? $rule['cart_percent'] ?? 0), 516 'pickupUnit' => sanitize_text_field((string) ($rule['pickupUnit'] ?? $rule['pickup_unit'] ?? 'once')), 517 'stopProcessing' => !empty($rule['stopProcessing']) || !empty($rule['stop_processing']), 518 'feeLabel' => sanitize_text_field((string) ($rule['feeLabel'] ?? $rule['fee_label'] ?? '')), 519 ); 520 } 521 522 return $out; 322 523 } 323 524 -
thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-settings.php
r3617587 r3705845 16 16 public const OPTION_DISABLED_SERVICES = 'tnxl_disabled_service_ids'; 17 17 public const OPTION_INELIGIBLE_PRODUCTS = 'tnxl_shipping_ineligible_product_ids'; 18 public const OPTION_PRODUCT_WEIGHT_UNIT = 'tnxl_product_weight_unit'; 19 public const OPTION_ACTUAL_WEIGHT_ONLY = 'tnxl_charge_actual_weight_only'; 20 public const OPTION_SERVICE_COVERAGE = 'tnxl_service_coverage'; 21 public const OPTION_PRICING_MODE = 'tnxl_pricing_mode'; 18 22 19 23 public static function maybe_register_defaults(): void { … … 134 138 } 135 139 140 public static function get_product_weight_unit(): string { 141 $saved = (string) get_option(self::OPTION_PRODUCT_WEIGHT_UNIT, ''); 142 if ($saved === 'g' || $saved === 'kg') { 143 return $saved; 144 } 145 return get_option('woocommerce_weight_unit') === 'g' ? 'g' : 'kg'; 146 } 147 148 public static function save_product_weight_unit($value): void { 149 $unit = strtolower(trim((string) $value)); 150 update_option(self::OPTION_PRODUCT_WEIGHT_UNIT, $unit === 'g' ? 'g' : 'kg', false); 151 } 152 153 public static function is_actual_weight_only(): bool { 154 return self::option_is_yes(self::OPTION_ACTUAL_WEIGHT_ONLY, 'no'); 155 } 156 157 public static function save_actual_weight_only($value): void { 158 update_option(self::OPTION_ACTUAL_WEIGHT_ONLY, self::bool_to_yes_no($value), false); 159 } 160 161 /** 162 * @return array<string, mixed> 163 */ 164 public static function get_service_coverage(): array { 165 $coverage = get_option(self::OPTION_SERVICE_COVERAGE, array()); 166 return is_array($coverage) ? $coverage : array(); 167 } 168 169 /** 170 * @param array<mixed> $coverage 171 */ 172 public static function save_service_coverage(array $coverage): void { 173 $clean = array(); 174 foreach ($coverage as $key => $rule) { 175 $id = self::normalize_service_id($key); 176 if ($id === '' || !is_array($rule)) { 177 continue; 178 } 179 $rest = !empty($rule['restOfWorld']) || !empty($rule['rest_of_world']); 180 $exclude = !$rest && (!empty($rule['excludeCountries']) || !empty($rule['exclude_countries'])); 181 $worldwide = !$rest && !$exclude && (!isset($rule['worldwide']) || $rule['worldwide']); 182 $countries = array(); 183 foreach ((array) ($rule['countries'] ?? array()) as $code) { 184 $iso = strtoupper(preg_replace('/[^A-Za-z]/', '', (string) $code)); 185 if (strlen($iso) === 2) { 186 $countries[] = $iso; 187 } 188 } 189 $countries = array_values(array_unique($countries)); 190 if ($rest) { 191 $clean[$id] = array('worldwide' => false, 'restOfWorld' => true, 'countries' => array()); 192 continue; 193 } 194 if ($exclude) { 195 $clean[$id] = array('worldwide' => true, 'excludeCountries' => true, 'countries' => $countries); 196 continue; 197 } 198 $clean[$id] = $worldwide 199 ? array('worldwide' => true, 'countries' => array()) 200 : array('worldwide' => false, 'countries' => $countries); 201 } 202 update_option(self::OPTION_SERVICE_COVERAGE, $clean, false); 203 } 204 205 public static function get_pricing_mode(): string { 206 $mode = (string) get_option(self::OPTION_PRICING_MODE, ''); 207 if ($mode === 'advanced' || $mode === 'basic') { 208 return $mode; 209 } 210 211 $rules = get_option('tnxl_commission_rules', array()); 212 if (!is_array($rules) || !class_exists('TNXL_Commission')) { 213 return 'basic'; 214 } 215 216 $always_on_with_effect = 0; 217 foreach ($rules as $rule) { 218 if (!is_array($rule)) { 219 continue; 220 } 221 if (!TNXL_Commission::rule_is_always_on($rule)) { 222 return 'advanced'; 223 } 224 if (TNXL_Commission::rule_has_effect($rule)) { 225 $always_on_with_effect++; 226 if ($always_on_with_effect > 1) { 227 return 'advanced'; 228 } 229 } 230 } 231 232 return 'basic'; 233 } 234 235 public static function save_pricing_mode($value): void { 236 $mode = (string) $value; 237 update_option(self::OPTION_PRICING_MODE, $mode === 'advanced' ? 'advanced' : 'basic', false); 238 } 239 136 240 /** 137 241 * Feature flags for REST/admin UI. -
thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-shipping-method.php
r3678280 r3705845 122 122 123 123 // 3D Box Packing Implementation 124 if (!class_exists('TNXL_Box_Packer')) { 125 return; 126 } 124 127 $packer = TNXL_Box_Packer::get_instance(); 125 128 $packing_result = $packer->pack_items($tnxl_items); … … 153 156 154 157 $commission = 0; 155 if (class_exists('TNXL_Commission')) { 156 $commission = TNXL_Commission::get_instance()->get_total_commission(); 157 } 158 159 // Quote each unique parcel shape once. Repeated retail boxes would 160 // otherwise trigger one sequential HTTP request per cart quantity. 158 $commission_class = class_exists('TNXL_Commission'); 159 161 160 $quote_groups = array(); 162 161 foreach ($packed_boxes as $box) { … … 231 230 } 232 231 233 // Only show couriers that could quote ALL boxes234 232 $box_count = count($packed_boxes); 235 233 $final_quotes_debug = array(); 236 234 $disabled_service_ids = TNXL_Settings::get_disabled_service_ids(); 237 235 $coverage = TNXL_Settings::get_service_coverage(); 236 $packed_weight = 0; 237 foreach ($packed_boxes as $box) { 238 $packed_weight += (float) ($box['weight'] ?? 0); 239 } 240 $pricing_context = $commission_class 241 ? TNXL_Commission::cart_pricing_context(array( 242 'destinationCountry' => (string) ($dest['country'] ?? ''), 243 'cartWeightKg' => $packed_weight, 244 )) 245 : array(); 246 247 $offered = array(); 238 248 foreach ($all_quotes as $courier => $data) { 239 249 if ($data['count'] < $box_count) continue; 240 if (in_array(TNXL_Settings::normalize_service_id($courier), $disabled_service_ids, true)) { 241 continue; 242 } 243 244 $cost = $data['total_price']; 250 $offered[] = array( 251 'courier' => $courier, 252 'display_name' => $data['display_name'], 253 'estimated_days' => $data['estimated_days'], 254 'total_price' => $data['total_price'], 255 ); 256 } 257 258 $offered = TNXL_Service_Coverage::filter_checkout_quotes( 259 $offered, 260 static function ($quote) { 261 return array($quote['courier'], $quote['display_name']); 262 }, 263 (string) ($dest['country'] ?? ''), 264 $disabled_service_ids, 265 $coverage 266 ); 267 268 foreach ($offered as $row) { 269 $cost_thb = (float) $row['total_price']; 270 $quote_store = $cost_thb; 245 271 if ($has_rate) { 246 $cost = $cost * (float) $rate; 247 } 248 249 // Add hidden commission buffer 250 $cost += $commission; 251 252 // Add destination hash to ID to force WooCommerce to refresh rates when address changes 253 $rate_id = 'tnxl_' . sanitize_title($courier) . '_' . substr(md5($dest['country'] . $dest['postcode']), 0, 6); 272 $quote_store = $cost_thb * (float) $rate; 273 } 274 $priced = $quote_store; 275 $commission = 0.0; 276 if ($commission_class) { 277 $service_ids = TNXL_Service_Coverage::coverage_ids(array($row['courier'], $row['display_name'])); 278 $priced_row = TNXL_Commission::price_converted_quote( 279 $cost_thb, 280 $has_rate ? $rate : null, 281 array_merge($pricing_context, array('serviceIds' => $service_ids)) 282 ); 283 $quote_store = $priced_row['quote_store']; 284 $priced = $priced_row['priced']; 285 $commission = $priced_row['commission']; 286 } 287 $cost = $priced; 288 289 $rate_id = 'tnxl_' . sanitize_title($row['courier']) . '_' . substr(md5($dest['country'] . $dest['postcode']), 0, 6); 254 290 255 291 $this->add_rate(array( 256 292 'id' => $rate_id, 257 'label' => $ data['display_name'] . ' (' . ($data['estimated_days'] ?: 'TBA') . ' days)',293 'label' => $row['display_name'] . ' (' . ($row['estimated_days'] ?: 'TBA') . ' days)', 258 294 'cost' => $cost, 259 295 'meta_data' => array( 260 'tnxl_courier' => $ courier,261 'tnxl_courier_display' => $ data['display_name'],296 'tnxl_courier' => $row['courier'], 297 'tnxl_courier_display' => $row['display_name'], 262 298 'tnxl_boxes' => $packed_boxes, 263 299 'tnxl_breakdown' => array( 264 'base_price' => $ cost - $commission,300 'base_price' => $quote_store, 265 301 'commission' => $commission, 266 302 'total' => $cost … … 271 307 if ($debug_enabled) { 272 308 $final_quotes_debug[] = array( 273 'courier' => $ data['display_name'],274 'price_thb' => $ data['total_price'],309 'courier' => $row['display_name'], 310 'price_thb' => $row['total_price'], 275 311 'final_cost' => $cost, 276 'days' => $ data['estimated_days'] ?: 'TBA',312 'days' => $row['estimated_days'] ?: 'TBA', 277 313 ); 278 314 } -
thai-nexus-logistics/tags/1.5.15/readme.txt
r3678280 r3705845 4 4 Requires at least: 5.8 5 5 Tested up to: 7.1 6 Stable tag: 1.5.1 46 Stable tag: 1.5.15 7 7 Requires PHP: 8.2 8 8 License: GPLv2 or later … … 84 84 == Changelog == 85 85 86 = 1.5.15 = 87 * Feature: Fees tab with basic/advanced pricing formulas (replaces the old commission rules page). 88 * Feature: Per-service destination coverage (include, exclude, rest of world). 89 * Feature: Products tab for catalog shipping fields (dimensions, HS code, boxed/document flags). 90 * Feature: Privacy tab in the admin dashboard. 91 * Fix: Checkout rates honor service coverage and the updated fee engine. 92 86 93 = 1.5.14 = 87 94 * Feature: Show TNX tracking numbers and Track links on My Account, thank you, Processing/Completed emails, and customer notes when the code is generated. -
thai-nexus-logistics/tags/1.5.15/thai-nexus-logistics.php
r3678280 r3705845 3 3 * Plugin Name: Thai Nexus Logistics - International Shipping Rates & Currency Converter for WooCommerce 4 4 * Description: Real-time WooCommerce shipping rates, automated shipments, and multi-currency conversion for Thailand and international orders via the Thai Nexus API. 5 * Version: 1.5.1 45 * Version: 1.5.15 6 6 * Author: Thai Nexus 7 7 * Author URI: https://app.thainexus.co.th … … 17 17 18 18 // Release line: stay on 1.5.x (patch) until explicitly approved for 1.6+. 19 define('TNXL_VERSION', '1.5.1 4');19 define('TNXL_VERSION', '1.5.15'); 20 20 define('TNXL_PLUGIN_DIR', plugin_dir_path(__FILE__)); 21 21 define('TNXL_PLUGIN_URL', plugin_dir_url(__FILE__)); … … 119 119 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-settings.php'; 120 120 TNXL_Settings::init(); 121 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-service-coverage.php'; 121 122 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-migration.php'; 122 123 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-api.php'; … … 141 142 if (!class_exists('DVDoug\BoxPacker\Packer')) { 142 143 add_action('admin_notices', array($this, 'boxpacker_missing_notice')); 143 return;144 144 } 145 145 146 146 // Load WooCommerce dependent files 147 147 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-product.php'; 148 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-box-packer.php'; 148 if (class_exists('DVDoug\BoxPacker\Packer')) { 149 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-box-packer.php'; 150 } 149 151 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-shipping-method.php'; 150 152 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-order.php'; … … 164 166 } 165 167 166 TNXL_Box_Packer::get_instance(); 168 if (class_exists('TNXL_Box_Packer')) { 169 TNXL_Box_Packer::get_instance(); 170 } 167 171 168 172 $needs_shipping_method = TNXL_Settings::can_fetch_checkout_rates() … … 219 223 if ($this->maybe_hide_shipping_on_cart(true) === false) { 220 224 return $rates; 225 } 226 227 foreach ($rates as $rate) { 228 if ($rate instanceof WC_Shipping_Rate && $rate->get_method_id() === 'tnxl_shipping') { 229 return $rates; 230 } 221 231 } 222 232 -
thai-nexus-logistics/tags/1.5.15/vendor/composer/installed.json
r3559867 r3705845 136 136 } 137 137 ], 138 "dev": true,138 "dev": false, 139 139 "dev-package-names": [] 140 140 } -
thai-nexus-logistics/tags/1.5.15/vendor/composer/installed.php
r3559867 r3705845 2 2 'root' => array( 3 3 'name' => 'thainexus/thai-nexus-logistics', 4 'pretty_version' => 'dev-ma ster',5 'version' => 'dev-ma ster',6 'reference' => ' c3684859ee067328625aa7437ca24af1d6e4df02',4 'pretty_version' => 'dev-main', 5 'version' => 'dev-main', 6 'reference' => '4bce3740c4d1d0078e7eb7b82844685a8aa60a97', 7 7 'type' => 'library', 8 8 'install_path' => __DIR__ . '/../../', 9 9 'aliases' => array(), 10 'dev' => true,10 'dev' => false, 11 11 ), 12 12 'versions' => array( … … 30 30 ), 31 31 'thainexus/thai-nexus-logistics' => array( 32 'pretty_version' => 'dev-ma ster',33 'version' => 'dev-ma ster',34 'reference' => ' c3684859ee067328625aa7437ca24af1d6e4df02',32 'pretty_version' => 'dev-main', 33 'version' => 'dev-main', 34 'reference' => '4bce3740c4d1d0078e7eb7b82844685a8aa60a97', 35 35 'type' => 'library', 36 36 'install_path' => __DIR__ . '/../../', -
thai-nexus-logistics/trunk/admin/package.json
r3678280 r3705845 2 2 "name": "admin", 3 3 "private": true, 4 "version": "1.5.1 4",4 "version": "1.5.15", 5 5 "type": "module", 6 6 "scripts": { -
thai-nexus-logistics/trunk/admin/src/App.jsx
r3559867 r3705845 1 1 import React, { useState, useEffect } from 'react'; 2 import { Settings, Package, LayoutDashboard, Save, ExternalLink, RefreshCw, DollarSign, Terminal} from 'lucide-react';2 import { Settings, Package, LayoutDashboard, ExternalLink, DollarSign, Terminal, Shield, Box } from 'lucide-react'; 3 3 import SettingsPage from './pages/SettingsPage'; 4 4 import ShipmentsPage from './pages/ShipmentsPage'; 5 import CommissionRulesPage from './pages/CommissionRulesPage';5 import FeesPage from './pages/FeesPage'; 6 6 import BoxDefinitionsPage from './pages/BoxDefinitionsPage'; 7 7 import DebugLogPage from './pages/DebugLogPage'; 8 import PrivacyPage from './pages/PrivacyPage'; 9 import ProductsPage from './pages/ProductsPage'; 10 import { fetchSettings } from './lib/api'; 8 11 import { clsx } from 'clsx'; 9 12 import { twMerge } from 'tailwind-merge'; … … 14 17 15 18 const App = () => { 16 // @ts-ignore17 19 const debugEnabled = window.tnxlData?.debugEnabled || false; 18 const [activeTab, setActiveTab] = useState('shipments'); 19 const [loading, setLoading] = useState(false); 20 const [activeTab, setActiveTab] = useState('settings'); 21 const [chargeActualWeightOnly, setChargeActualWeightOnly] = useState(false); 22 23 useEffect(() => { 24 void fetchSettings() 25 .then((data) => setChargeActualWeightOnly(Boolean(data?.charge_actual_weight_only))) 26 .catch(() => {}); 27 }, []); 28 29 useEffect(() => { 30 if (chargeActualWeightOnly && activeTab === 'boxes') { 31 setActiveTab('settings'); 32 } 33 }, [chargeActualWeightOnly, activeTab]); 34 35 const tabs = [ 36 { id: 'shipments', label: 'Shipments', icon: LayoutDashboard }, 37 { id: 'settings', label: 'Settings', icon: Settings }, 38 { id: 'products', label: 'Products', icon: Box }, 39 { id: 'fees', label: 'Fees', icon: DollarSign }, 40 ...(!chargeActualWeightOnly ? [{ id: 'boxes', label: 'Boxes', icon: Package }] : []), 41 { id: 'privacy', label: 'Privacy', icon: Shield }, 42 ...(debugEnabled ? [{ id: 'debug', label: 'Debug', icon: Terminal }] : []), 43 ]; 20 44 21 45 return ( 22 46 <div className="min-h-screen p-6 md:p-10 max-w-7xl mx-auto font-sans"> 23 {/* Header */}24 47 <header className="flex flex-col md:flex-row md:items-center justify-between mb-10 gap-4"> 25 48 <div> 26 <h1 className="text-3xl font-bold text- secondary flex items-center gap-3">27 <Package className="text- primary w-8 h-8" />49 <h1 className="text-3xl font-bold text-primary flex items-center gap-3"> 50 <Package className="text-secondary w-8 h-8" /> 28 51 Thai Nexus Logistics 29 52 </h1> 30 53 <p className="text-gray-500 mt-1">Manage your shipping operations and API configurations.</p> 31 54 </div> 32 33 <div className="flex items-center gap-4 bg-white p-1 rounded-xl shadow-sm border border-gray-100 flex-wrap"> 34 <button 35 onClick={() => setActiveTab('shipments')} 36 className={cn( 37 "flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium transition-all", 38 activeTab === 'shipments' 39 ? "bg-secondary text-white shadow-md" 40 : "text-gray-600 hover:bg-gray-50" 41 )} 42 > 43 <LayoutDashboard size={18} /> 44 Shipments 45 </button> 46 <button 47 onClick={() => setActiveTab('settings')} 48 className={cn( 49 "flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium transition-all", 50 activeTab === 'settings' 51 ? "bg-secondary text-white shadow-md" 52 : "text-gray-600 hover:bg-gray-50" 53 )} 54 > 55 <Settings size={18} /> 56 Settings 57 </button> 58 <button 59 onClick={() => setActiveTab('commission')} 60 className={cn( 61 "flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium transition-all", 62 activeTab === 'commission' 63 ? "bg-secondary text-white shadow-md" 64 : "text-gray-600 hover:bg-gray-50" 65 )} 66 > 67 <DollarSign size={18} /> 68 Fees 69 </button> 70 <button 71 onClick={() => setActiveTab('boxes')} 72 className={cn( 73 "flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium transition-all", 74 activeTab === 'boxes' 75 ? "bg-secondary text-white shadow-md" 76 : "text-gray-600 hover:bg-gray-50" 77 )} 78 > 79 <Package size={18} /> 80 Boxes 81 </button> 82 83 {debugEnabled && ( 55 56 <div className="flex items-center gap-2 bg-white p-1 rounded-xl shadow-sm border border-gray-100 flex-wrap"> 57 {tabs.map(({ id, label, icon: Icon }) => ( 84 58 <button 85 onClick={() => setActiveTab('debug')} 59 key={id} 60 type="button" 61 onClick={() => setActiveTab(id)} 86 62 className={cn( 87 "flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium transition-all border border-transparent", 88 activeTab === 'debug' 89 ? "bg-amber-500 text-white shadow-md border-amber-600" 90 : "text-amber-600 hover:bg-amber-50 border-amber-100/50" 63 'flex items-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-all text-sm', 64 activeTab === id 65 ? id === 'debug' 66 ? 'bg-amber-500 text-white shadow-md' 67 : 'bg-primary text-white shadow-md' 68 : id === 'debug' 69 ? 'text-amber-600 hover:bg-amber-50' 70 : 'text-gray-600 hover:bg-gray-50' 91 71 )} 92 72 > 93 < Terminal size={18} />94 Debug73 <Icon size={16} /> 74 {label} 95 75 </button> 96 ) }76 ))} 97 77 </div> 98 78 </header> 99 79 100 {/* Main Content */}101 80 <main className="animate-in fade-in slide-in-from-bottom-4 duration-500"> 102 81 {activeTab === 'shipments' && <ShipmentsPage />} 103 {activeTab === 'settings' && <SettingsPage />} 104 {activeTab === 'commission' && <CommissionRulesPage />} 105 {activeTab === 'boxes' && <BoxDefinitionsPage />} 82 {activeTab === 'settings' && ( 83 <SettingsPage onSaved={(data) => setChargeActualWeightOnly(Boolean(data?.charge_actual_weight_only))} /> 84 )} 85 {activeTab === 'products' && <ProductsPage />} 86 {activeTab === 'fees' && <FeesPage />} 87 {activeTab === 'boxes' && !chargeActualWeightOnly && <BoxDefinitionsPage />} 88 {activeTab === 'privacy' && <PrivacyPage />} 106 89 {activeTab === 'debug' && debugEnabled && <DebugLogPage />} 107 90 </main> 108 91 109 {/* Footer */}110 92 <footer className="mt-12 py-6 border-t border-gray-200 flex justify-between items-center text-sm text-gray-500"> 111 93 <p>© 2026 Thai Nexus Point Co., Ltd. All rights reserved.</p> 112 <a 113 href="https://app.thainexus.co.th/" 114 target="_blank" 94 <a 95 href="https://app.thainexus.co.th/" 96 target="_blank" 115 97 rel="noopener noreferrer" 116 98 className="flex items-center gap-1 hover:text-primary transition-colors" -
thai-nexus-logistics/trunk/admin/src/components/ProductSearchSelect.jsx
r3617587 r3705845 26 26 27 27 if (ids.length === 0) { 28 setSelectedDetails([]); 28 29 return () => { 29 30 cancelled = true; -
thai-nexus-logistics/trunk/admin/src/pages/SettingsPage.jsx
r3678280 r3705845 3 3 import axios from 'axios'; 4 4 import ProductSearchSelect from '../components/ProductSearchSelect'; 5 import CountryMultiSelect from '../components/CountryMultiSelect'; 5 6 6 7 const normalizeServiceId = (value) => String(value || '') … … 10 11 .replace(/[^a-z0-9_-]/g, ''); 11 12 12 const SettingsPage = () => { 13 const serviceKeys = (service) => { 14 const seen = new Set(); 15 const keys = []; 16 for (const raw of [service.id, service.service_name, service.name]) { 17 const id = normalizeServiceId(raw); 18 if (!id || seen.has(id)) continue; 19 seen.add(id); 20 keys.push(id); 21 } 22 return keys; 23 }; 24 25 const SettingsPage = ({ onSaved }) => { 13 26 const [loading, setLoading] = useState(false); 14 27 const [saving, setSaving] = useState(false); … … 22 35 disabled_service_ids: [], 23 36 shipping_ineligible_product_ids: [], 37 product_weight_unit: 'kg', 38 charge_actual_weight_only: false, 39 service_coverage: {}, 24 40 shipper: { 25 41 name: '', … … 81 97 disabled_service_ids: response.data?.disabled_service_ids || [], 82 98 shipping_ineligible_product_ids: response.data?.shipping_ineligible_product_ids || [], 99 product_weight_unit: response.data?.product_weight_unit === 'g' ? 'g' : 'kg', 100 charge_actual_weight_only: Boolean(response.data?.charge_actual_weight_only), 101 service_coverage: response.data?.service_coverage || {}, 83 102 }); 84 103 if (response.data?.api_token) { … … 119 138 }; 120 139 121 const toggleService = (service Id, enabled) => {122 const id = normalizeServiceId(serviceId);140 const toggleService = (service, enabled) => { 141 const ids = serviceKeys(service); 123 142 setSettings((previous) => ({ 124 143 ...previous, 125 144 disabled_service_ids: enabled 126 ? previous.disabled_service_ids.filter((item) => item !== id)127 : [...new Set([...previous.disabled_service_ids, id])],145 ? previous.disabled_service_ids.filter((item) => !ids.includes(item)) 146 : [...new Set([...previous.disabled_service_ids, ...ids])], 128 147 })); 148 }; 149 150 const coverageFor = (service) => { 151 for (const id of serviceKeys(service)) { 152 if (settings.service_coverage?.[id]) return settings.service_coverage[id]; 153 } 154 return { worldwide: true, countries: [] }; 155 }; 156 157 const setCoverage = (service, next) => { 158 setSettings((prev) => { 159 const merged = { ...(prev.service_coverage || {}) }; 160 for (const id of serviceKeys(service)) { 161 merged[id] = next; 162 } 163 return { ...prev, service_coverage: merged }; 164 }); 129 165 }; 130 166 … … 142 178 }); 143 179 setMessage({ type: 'success', text: 'Settings saved successfully!' }); 180 if (typeof onSaved === 'function') { 181 onSaved(settings); 182 } 144 183 if (settings.api_token) { 145 184 void fetchShippingServices(); … … 168 207 {/* Feature toggles */} 169 208 <div className="tnxl-card"> 170 <div className="bg- secondary p-5 flex items-center gap-3">171 <Zap className="text- primaryw-6 h-6" />209 <div className="bg-primary p-5 flex items-center gap-3"> 210 <Zap className="text-white w-6 h-6" /> 172 211 <h2 className="text-lg font-bold text-white">Automation</h2> 173 212 </div> … … 227 266 {/* API Authentication */} 228 267 <div className="tnxl-card"> 229 <div className="bg- secondary p-5 flex items-center gap-3">230 <Key className="text- primaryw-6 h-6" />268 <div className="bg-primary p-5 flex items-center gap-3"> 269 <Key className="text-white w-6 h-6" /> 231 270 <h2 className="text-lg font-bold text-white">API Authentication</h2> 232 271 </div> … … 271 310 </div> 272 311 312 <div className="tnxl-card"> 313 <div className="bg-primary p-5 flex items-center gap-3"> 314 <Package className="text-white w-6 h-6" /> 315 <h2 className="text-lg font-bold text-white">Product weight and billing</h2> 316 </div> 317 <div className="p-8 space-y-6"> 318 <div> 319 <p className="text-sm font-semibold text-gray-700 mb-2">Product weight unit</p> 320 <p className="text-sm text-gray-600 mb-3"> 321 How catalog product weights are read. Box inventory stays in kg. Thai Nexus quotes always use kg. 322 </p> 323 <div className="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-1"> 324 <button 325 type="button" 326 onClick={() => setSettings((s) => ({ ...s, product_weight_unit: 'kg' }))} 327 className={`px-4 py-2 text-sm font-medium rounded-md ${ 328 settings.product_weight_unit === 'kg' ? 'bg-white text-primary shadow-sm' : 'text-gray-600' 329 }`} 330 > 331 KG 332 </button> 333 <button 334 type="button" 335 onClick={() => setSettings((s) => ({ ...s, product_weight_unit: 'g' }))} 336 className={`px-4 py-2 text-sm font-medium rounded-md ${ 337 settings.product_weight_unit === 'g' ? 'bg-white text-primary shadow-sm' : 'text-gray-600' 338 }`} 339 > 340 Gram 341 </button> 342 </div> 343 </div> 344 <label className="flex items-start gap-3 cursor-pointer"> 345 <input 346 type="checkbox" 347 className="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" 348 checked={Boolean(settings.charge_actual_weight_only)} 349 onChange={(e) => setSettings((s) => ({ ...s, charge_actual_weight_only: e.target.checked }))} 350 /> 351 <span> 352 <span className="block text-sm font-semibold text-gray-800">Charge actual product weight only</span> 353 <span className="block text-sm text-gray-600 mt-1"> 354 Box packing is skipped. Quotes send filler dimensions so volumetric weight stays below product weight. 355 </span> 356 </span> 357 </label> 358 {settings.charge_actual_weight_only ? ( 359 <div className="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950"> 360 <Info size={18} className="shrink-0 mt-0.5" /> 361 <p>The Boxes tab is hidden. Filler parcel size keeps volumetric weight below actual weight.</p> 362 </div> 363 ) : null} 364 </div> 365 </div> 366 273 367 {/* Shipping services */} 274 368 <div className="tnxl-card"> 275 <div className="bg- secondary p-5 flex items-center justify-between gap-4">369 <div className="bg-primary p-5 flex items-center justify-between gap-4"> 276 370 <div className="flex items-center gap-3"> 277 <Truck className="text- primaryw-6 h-6" />371 <Truck className="text-white w-6 h-6" /> 278 372 <h2 className="text-lg font-bold text-white">Shipping Services</h2> 279 373 </div> … … 303 397 ) : ( 304 398 <div className="space-y-4"> 305 <p className="text-sm text-gray-600">Unchecked services will not appear as Thai Nexus checkout options.</p> 399 <p className="text-sm text-gray-600"> 400 Unchecked services will not appear as Thai Nexus checkout options. Choose All Available, selected countries, exclude, or rest of world per service. 401 </p> 306 402 <div className="flex gap-4 text-sm"> 307 403 <button … … 324 420 </div> 325 421 <div className="divide-y divide-gray-100 border border-gray-100 rounded-xl"> 422 <ul> 326 423 {services.map((service) => { 327 424 const id = normalizeServiceId(service.id); 328 const enabled = !settings.disabled_service_ids.includes(id); 425 const keys = serviceKeys(service); 426 const enabled = !keys.some((key) => settings.disabled_service_ids.includes(key)); 427 const coverage = coverageFor(service); 428 const isExclude = Boolean(coverage.excludeCountries); 429 const isRestOfWorld = Boolean(coverage.restOfWorld) && !isExclude; 430 const isWorldwide = coverage.worldwide && !isRestOfWorld && !isExclude; 431 const isSelected = !isWorldwide && !isRestOfWorld && !isExclude; 329 432 return ( 330 <label key={id} className="flex items-center gap-4 px-4 py-3 cursor-pointer hover:bg-gray-50"> 331 <input 332 type="checkbox" 333 checked={enabled} 334 onChange={(event) => toggleService(service.id, event.target.checked)} 335 className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" 336 /> 337 {service.logo 338 ? <img src={service.logo} alt="" className="w-8 h-8 object-contain" /> 339 : <Truck size={20} className="text-gray-400" />} 340 <span className="text-sm font-medium text-gray-800"> 341 {service.service_name || service.name || service.id} 342 </span> 343 </label> 433 <li key={id} className="px-4 py-3 hover:bg-gray-50"> 434 <label className="flex items-center gap-4 cursor-pointer"> 435 <input 436 type="checkbox" 437 checked={enabled} 438 onChange={(event) => toggleService(service, event.target.checked)} 439 className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" 440 /> 441 {service.logo 442 ? <img src={service.logo} alt="" className="w-8 h-8 object-contain" /> 443 : <Truck size={20} className="text-gray-400" />} 444 <span className="text-sm font-medium text-gray-800"> 445 {service.service_name || service.name || service.id} 446 </span> 447 </label> 448 {enabled && ( 449 <div className="mt-3 ml-8 space-y-3"> 450 <div className="flex flex-wrap gap-4 text-sm"> 451 <label className="inline-flex items-center gap-2 cursor-pointer"> 452 <input 453 type="radio" 454 name={`coverage-${id}`} 455 checked={isWorldwide} 456 onChange={() => setCoverage(service, { worldwide: true, countries: [] })} 457 /> 458 All Available 459 </label> 460 <label className="inline-flex items-center gap-2 cursor-pointer"> 461 <input 462 type="radio" 463 name={`coverage-${id}`} 464 checked={isSelected} 465 onChange={() => setCoverage(service, { worldwide: false, countries: coverage.countries || [] })} 466 /> 467 Selected countries 468 </label> 469 <label className="inline-flex items-center gap-2 cursor-pointer"> 470 <input 471 type="radio" 472 name={`coverage-${id}`} 473 checked={isExclude} 474 onChange={() => setCoverage(service, { worldwide: true, excludeCountries: true, countries: coverage.countries || [] })} 475 /> 476 Exclude countries 477 </label> 478 <label className="inline-flex items-center gap-2 cursor-pointer"> 479 <input 480 type="radio" 481 name={`coverage-${id}`} 482 checked={isRestOfWorld} 483 onChange={() => setCoverage(service, { worldwide: false, restOfWorld: true, countries: [] })} 484 /> 485 Rest of world 486 </label> 487 </div> 488 {isSelected && ( 489 <CountryMultiSelect 490 selected={coverage.countries || []} 491 onChange={(countries) => setCoverage(service, { worldwide: false, countries })} 492 /> 493 )} 494 {isExclude && ( 495 <CountryMultiSelect 496 selected={coverage.countries || []} 497 placeholder="Search countries to exclude" 498 onChange={(countries) => setCoverage(service, { worldwide: true, excludeCountries: true, countries })} 499 /> 500 )} 501 {isRestOfWorld && ( 502 <p className="text-xs text-gray-500">Shown only when no other enabled service is offered for the destination.</p> 503 )} 504 </div> 505 )} 506 </li> 344 507 ); 345 508 })} 509 </ul> 346 510 </div> 347 511 </div> … … 352 516 {/* Product eligibility */} 353 517 <div className="tnxl-card"> 354 <div className="bg- secondary p-5 flex items-center gap-3">355 <Package className="text- primaryw-6 h-6" />518 <div className="bg-primary p-5 flex items-center gap-3"> 519 <Package className="text-white w-6 h-6" /> 356 520 <h2 className="text-lg font-bold text-white">Product Shipping Eligibility</h2> 357 521 </div> … … 380 544 {/* Shipper Address */} 381 545 <div className="tnxl-card"> 382 <div className="bg- secondary p-5 flex items-center gap-3">383 <MapPin className="text- primaryw-6 h-6" />546 <div className="bg-primary p-5 flex items-center gap-3"> 547 <MapPin className="text-white w-6 h-6" /> 384 548 <h2 className="text-lg font-bold text-white">Store Origin Address</h2> 385 549 </div> … … 454 618 onChange={(e) => setSettings({ ...settings, shipper: { ...settings.shipper, country: e.target.value } })} 455 619 > 456 <option value="TH">Thailand</option> 457 {/* Add more countries if needed */} 620 <option value="TH">Thailand (TH)</option> 621 <option value="US">United States (US)</option> 622 <option value="GB">United Kingdom (GB)</option> 623 <option value="AU">Australia (AU)</option> 624 <option value="SG">Singapore (SG)</option> 458 625 </select> 459 626 </div> 460 627 </div> 461 </div>462 </div>463 464 {/* Privacy & Data Disclosure */}465 <div className="tnxl-card bg-secondary border-secondary">466 <div className="bg-secondary border-secondary p-5 flex items-center gap-3 ">467 <AlertCircle className="text-blue-600 w-6 h-6" />468 <h2 className="text-lg font-bold text-white">Privacy & Data Disclosure</h2>469 </div>470 <div className="p-8">471 <p className="text-black text-sm leading-relaxed">472 To provide real-time shipping quotations, this plugin securely transmits package dimensions,473 weights, and the <strong>customer's shipping address</strong> (Country, City, State, and Postal Code)474 to the <a href="https://app.thainexus.co.th/" target="_blank" className="text-primary hover:underline font-medium"> Thai Nexus API</a>.475 </p>476 <p className="text-black text-sm mt-4 leading-relaxed">477 Additionally, it communicates with the <a href="https://frankfurter.dev/" target="_blank" rel="noreferrer" className="text-primary hover:underline font-medium">Frankfurter API</a> to fetch current exchange478 rates, and falls back to <a href="https://www.exchangerate-api.com" target="_blank" rel="noreferrer" className="text-primary hover:underline font-medium">Exchange Rate API</a> for currencies Frankfurter does not cover.479 No customer PII (Name, Email, Phone) is sent during the quotation phase.480 </p>481 628 </div> 482 629 </div> -
thai-nexus-logistics/trunk/admin/src/pages/ShipmentsPage.jsx
r3678280 r3705845 61 61 const [syncing, setSyncing] = useState(false); 62 62 const [syncMessage, setSyncMessage] = useState(''); 63 const [errorType, setErrorType] = useState(null); // 'auth' | 'general' 63 const [searchQuery, setSearchQuery] = useState(''); 64 const [errorType, setErrorType] = useState(null); 64 65 65 66 useEffect(() => { … … 181 182 </span> 182 183 </h2> 183 184 184 185 <div className="relative group"> 185 186 <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-3.5 h-3.5 z-10 group-focus-within:text-primary transition-colors" /> 186 <input 187 type="text" 188 placeholder="Search request number..." 187 <input 188 type="text" 189 placeholder="Search request number or status..." 190 value={searchQuery} 191 onChange={(e) => setSearchQuery(e.target.value)} 189 192 className="tnxl-input !pl-9 py-1.5 text-xs w-full md:w-56 relative border-gray-100 bg-gray-50/50 focus:bg-white" 190 193 /> … … 221 224 </td> 222 225 </tr> 223 ) : shipments.length === 0 ? ( 226 ) : shipments.filter((shipment) => { 227 const q = searchQuery.trim().toLowerCase(); 228 if (!q) return true; 229 const request = String(shipment.request_number || '').toLowerCase(); 230 const status = String(shipment.status || '').toLowerCase(); 231 return request.includes(q) || status.includes(q); 232 }).length === 0 ? ( 224 233 <tr> 225 234 <td colSpan={5} className="py-16 text-center animate-in fade-in zoom-in duration-300"> … … 230 239 </tr> 231 240 ) : ( 232 shipments.map((shipment, index) => ( 233 <tr 234 key={shipment.id} 241 shipments.filter((shipment) => { 242 const q = searchQuery.trim().toLowerCase(); 243 if (!q) return true; 244 const request = String(shipment.request_number || '').toLowerCase(); 245 const status = String(shipment.status || '').toLowerCase(); 246 return request.includes(q) || status.includes(q); 247 }).map((shipment, index) => ( 248 <tr 249 key={shipment.id} 235 250 style={{ animationDelay: `${index * 30}ms` }} 236 251 className="hover:bg-blue-50/20 transition-all group animate-in fade-in slide-in-from-left-1 duration-200" … … 256 271 </td> 257 272 <td className="px-5 py-3 text-right"> 258 <button 273 <button 259 274 onClick={() => fetchShipmentDetails(shipment.request_number)} 260 275 className="text-gray-300 hover:text-primary p-1.5 hover:bg-white rounded-md transition-all shadow-none hover:shadow-sm" … … 277 292 </p> 278 293 <div className="flex gap-1.5"> 279 <button 294 <button 280 295 disabled={page === 1 || loading} 281 296 onClick={() => setPage(p => p - 1)} … … 284 299 <ChevronLeft size={16} /> 285 300 </button> 286 <button 301 <button 287 302 disabled={loading || (total > 0 && page * 10 >= total)} 288 303 onClick={() => setPage(p => p + 1)} … … 341 356 </div> 342 357 </div> 343 <button 358 <button 344 359 onClick={() => setSelectedShipment(null)} 345 360 className="p-2 bg-white/5 hover:bg-white/15 text-white rounded-xl transition-all border border-white/10 group shadow-sm" … … 371 386 </p> 372 387 <p className="text-xs text-gray-600 leading-relaxed flex items-start gap-2"> 373 <MapPin size={12} className="text-primary mt-0.5 shrink-0" /> 388 <MapPin size={12} className="text-primary mt-0.5 shrink-0" /> 374 389 <span>{getAddress(selectedShipment, 'shipper_address').address_line1 || getAddress(selectedShipment, 'shipper_address').address}, <span className="font-bold text-gray-400">{getAddress(selectedShipment, 'shipper_address').city}, {getAddress(selectedShipment, 'shipper_address').country}</span></span> 375 390 </p> … … 390 405 </p> 391 406 <p className="text-xs text-gray-600 leading-relaxed flex items-start gap-2"> 392 <MapPin size={12} className="text-blue-500 mt-0.5 shrink-0" /> 407 <MapPin size={12} className="text-blue-500 mt-0.5 shrink-0" /> 393 408 <span>{getAddress(selectedShipment, 'consignee_address').address_line1 || getAddress(selectedShipment, 'consignee_address').address}, <span className="font-bold text-gray-400">{getAddress(selectedShipment, 'consignee_address').city}, {getAddress(selectedShipment, 'consignee_address').country}</span></span> 394 409 </p> … … 416 431 ))} 417 432 </div> 418 433 419 434 <div className="bg-gray-50 p-4 rounded-xl border border-gray-100 flex items-start gap-3"> 420 435 <Tag className="text-secondary/20 w-4 h-4 mt-0.5 shrink-0" /> … … 446 461 Sync tracking 447 462 </button> 448 <button 463 <button 449 464 type="button" 450 465 onClick={() => { -
thai-nexus-logistics/trunk/admin/tailwind.config.js
r3559867 r3705845 9 9 colors: { 10 10 primary: { 11 DEFAULT: '# dc2626', // Red12 hover: '# b91c1c',11 DEFAULT: '#272262', 12 hover: '#1e1a4d', 13 13 }, 14 14 secondary: { 15 DEFAULT: '# 272262', // Dark Blue/Purple16 hover: '# 1e1a4d',15 DEFAULT: '#bf1d2d', 16 hover: '#9f1824', 17 17 }, 18 18 } -
thai-nexus-logistics/trunk/dist/.vite/manifest.json
r3678280 r3705845 1 1 { 2 2 "index.html": { 3 "file": "assets/index-B lRndIDa.js",3 "file": "assets/index-BBMv6f4n.js", 4 4 "name": "index", 5 5 "src": "index.html", 6 6 "isEntry": true, 7 7 "css": [ 8 "assets/index- DFHBTHlP.css"8 "assets/index-CrlahNyv.css" 9 9 ] 10 10 } -
thai-nexus-logistics/trunk/dist/index.html
r3678280 r3705845 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <title>admin</title> 8 <script type="module" crossorigin src="/assets/index-B lRndIDa.js"></script>9 <link rel="stylesheet" crossorigin href="/assets/index- DFHBTHlP.css">8 <script type="module" crossorigin src="/assets/index-BBMv6f4n.js"></script> 9 <link rel="stylesheet" crossorigin href="/assets/index-CrlahNyv.css"> 10 10 </head> 11 11 <body> -
thai-nexus-logistics/trunk/includes/class-tnxl-box-packer.php
r3678280 r3705845 162 162 */ 163 163 public function pack_items($items) { 164 if (class_exists('TNXL_Settings') && TNXL_Settings::is_actual_weight_only()) { 165 return $this->pack_actual_weight_only($items); 166 } 167 164 168 $result = new TNXL_Packing_Result(); 165 169 $box_definitions = get_option('tnxl_box_definitions', []); … … 307 311 308 312 /** 313 * One parcel from product weights. Filler cube so LWH/5000 stays below actual kg. 314 */ 315 private function pack_actual_weight_only($items) { 316 $result = new TNXL_Packing_Result(); 317 $total_weight = 0.0; 318 $labels = array(); 319 $all_document = true; 320 $has_shipping = false; 321 322 foreach ($items as $item_values) { 323 $product = $item_values['data'] ?? null; 324 if (!$product instanceof WC_Product || !$product->needs_shipping()) { 325 continue; 326 } 327 $has_shipping = true; 328 $qty = max(1, absint($item_values['quantity'] ?? 1)); 329 $measurements = TNXL_Product::get_shipping_measurements($product); 330 $wt = $measurements['weight']; 331 if ($wt <= 0) { 332 $result->add_error(sprintf( 333 __('Product "%s" is missing required shipping weight.', 'thai-nexus-logistics'), 334 $product->get_name() 335 )); 336 continue; 337 } 338 $total_weight += $wt * $qty; 339 $labels[] = self::box_item_record($product->get_name(), $qty, (int) $product->get_id()); 340 if (!TNXL_Product::is_document($product)) { 341 $all_document = false; 342 } 343 } 344 345 if ($result->has_errors()) { 346 return $result; 347 } 348 if (!$has_shipping || $total_weight <= 0) { 349 $result->add_error(__('No shippable items', 'thai-nexus-logistics')); 350 return $result; 351 } 352 353 $dims = self::filler_dims_below_actual_weight($total_weight); 354 $result->add_box(array( 355 'name' => __('Actual weight', 'thai-nexus-logistics'), 356 'length' => $dims['length'], 357 'width' => $dims['width'], 358 'height' => $dims['height'], 359 'weight' => $total_weight, 360 'items' => $labels, 361 'is_document' => $all_document, 362 )); 363 364 return $result; 365 } 366 367 /** 368 * @return array{length:float,width:float,height:float} 369 */ 370 public static function filler_dims_below_actual_weight(float $actual_kg): array { 371 $weight = max(0.001, $actual_kg); 372 $max_volume = $weight * 5000 * 0.5; 373 $side = max(1, (int) floor(pow($max_volume, 1 / 3))); 374 while ($side > 1 && (($side * $side * $side) / 5000) >= $weight) { 375 $side -= 1; 376 } 377 if (($side * $side * $side) / 5000 >= $weight) { 378 $side = 1; 379 } 380 return array('length' => (float) $side, 'width' => (float) $side, 'height' => (float) $side); 381 } 382 383 /** 309 384 * Validate all items have dimensions and weight 310 385 */ … … 312 387 $result = new TNXL_Packing_Result(); 313 388 foreach ($items as $item_values) { 314 $product = $item_values['data'] ;315 if (!$product ->needs_shipping()) continue;389 $product = $item_values['data'] ?? null; 390 if (!$product instanceof WC_Product || !$product->needs_shipping()) continue; 316 391 317 392 $measurements = TNXL_Product::get_shipping_measurements($product); … … 343 418 344 419 foreach ($items as $item_values) { 345 $product = $item_values['data'] ;420 $product = $item_values['data'] ?? null; 346 421 $qty = $item_values['quantity']; 347 422 348 if (!$product ->needs_shipping()) continue;423 if (!$product instanceof WC_Product || !$product->needs_shipping()) continue; 349 424 350 425 $measurements = TNXL_Product::get_shipping_measurements($product); -
thai-nexus-logistics/trunk/includes/class-tnxl-commission.php
r3560480 r3705845 17 17 } 18 18 19 private function __construct() { 20 // No longer hooking into woocommerce_cart_calculate_fees 21 // Commission will be added as a buffer to shipping rates instead 22 } 19 private function __construct() {} 23 20 24 21 /** 25 * Calculate total commission based on current cart 26 * 27 * @return float Total commission amount 22 * Additive commission only (quote markup is applied in apply_checkout_pricing). 28 23 */ 29 24 public function get_total_commission() { 30 if (!TNXL_Settings::can_fetch_checkout_rates() || !function_exists('WC') || !WC()->cart) { 25 return self::apply_checkout_pricing(0, self::cart_pricing_context()); 26 } 27 28 /** 29 * checkout = (quote + matching_fixed) x Π(1 + quote_% / 100) + cart/product_% 30 */ 31 public static function apply_checkout_pricing(float $quote_thb, array $context = array()): float { 32 if (!TNXL_Settings::can_fetch_checkout_rates()) { 33 return max(0, round($quote_thb, 2)); 34 } 35 36 $rules = get_option('tnxl_commission_rules', array()); 37 if (!is_array($rules) || empty($rules)) { 38 return max(0, round($quote_thb, 2)); 39 } 40 41 $quote = $quote_thb > 0 ? $quote_thb : 0; 42 $mode = TNXL_Settings::get_pricing_mode(); 43 $active = $mode === 'basic' 44 ? array_values(array_filter($rules, array(__CLASS__, 'rule_is_always_on'))) 45 : $rules; 46 47 $fixed = 0.0; 48 $markup_multiplier = 1.0; 49 $additive = 0.0; 50 51 foreach ($active as $rule) { 52 if (!is_array($rule)) { 53 continue; 54 } 55 $match = self::rule_matches($rule, $context); 56 if (!$match['matched']) { 57 continue; 58 } 59 60 $pickup = self::pickup_from_rule($rule); 61 $markup = self::markup_percent_from_rule($rule); 62 $cart_percent = self::cart_percent_from_rule($rule); 63 if ($pickup <= 0 && $markup <= 0 && $cart_percent <= 0) { 64 if (!empty($rule['stopProcessing']) || !empty($rule['stop_processing'])) { 65 break; 66 } 67 continue; 68 } 69 70 if ($pickup > 0) { 71 $fixed += self::scaled_pickup( 72 $pickup, 73 self::pickup_unit_from_rule($rule), 74 $match['matchingQty'], 75 (float) ($context['cartWeightKg'] ?? $context['cart_weight_kg'] ?? 0) 76 ); 77 } 78 if ($markup > 0) { 79 $markup_multiplier *= 1 + ($markup / 100); 80 } 81 if ($cart_percent > 0) { 82 $has_products = false; 83 foreach (self::effective_conditions($rule) as $condition) { 84 if (($condition['type'] ?? '') === 'specific_products') { 85 $has_products = true; 86 break; 87 } 88 } 89 $basis = $has_products 90 ? $match['productSubtotal'] 91 : (float) ($context['cartSubtotal'] ?? $context['cart_subtotal'] ?? 0); 92 $additive += ($basis * $cart_percent) / 100; 93 } 94 95 if (!empty($rule['stopProcessing']) || !empty($rule['stop_processing'])) { 96 break; 97 } 98 } 99 100 $total = ($quote + $fixed) * $markup_multiplier + $additive; 101 if (!is_finite($total) || $total < 0) { 31 102 return 0; 32 103 } 33 104 34 $rules = get_option('tnxl_commission_rules', array()); 35 if (empty($rules) || !is_array($rules)) { 105 return round($total, 2); 106 } 107 108 /** 109 * Convert a THB quote to store currency, then apply pickup/markup/cart % 110 * in that currency (admin fees UI uses the store symbol). 111 * 112 * @return array{quote_store:float,priced:float,commission:float} 113 */ 114 public static function price_converted_quote(float $quote_thb, $exchange_rate, array $context = array()): array { 115 $quote_store = $quote_thb > 0 ? $quote_thb : 0.0; 116 if (is_numeric($exchange_rate) && (float) $exchange_rate > 0) { 117 $quote_store = $quote_store * (float) $exchange_rate; 118 } 119 $priced = self::apply_checkout_pricing($quote_store, $context); 120 return array( 121 'quote_store' => round($quote_store, 2), 122 'priced' => $priced, 123 'commission' => max(0, round($priced - $quote_store, 2)), 124 ); 125 } 126 127 public static function cart_pricing_context(array $extra = array()): array { 128 $items = array(); 129 $subtotal = 0.0; 130 $qty = 0; 131 $weight = 0.0; 132 $dest = ''; 133 134 if (function_exists('WC') && WC()->cart) { 135 $subtotal = (float) WC()->cart->get_subtotal(); 136 $customer = WC()->customer; 137 if ($customer) { 138 $dest = (string) $customer->get_shipping_country(); 139 } 140 foreach (WC()->cart->get_cart() as $cart_item) { 141 $product = $cart_item['data'] ?? null; 142 $line_qty = max(1, (int) ($cart_item['quantity'] ?? 1)); 143 $qty += $line_qty; 144 $items[] = array( 145 'product_id' => (string) ($cart_item['product_id'] ?? ''), 146 'variation_id' => (string) ($cart_item['variation_id'] ?? ''), 147 'quantity' => $line_qty, 148 'line_total' => (float) ($cart_item['line_total'] ?? 0), 149 ); 150 if ($product instanceof WC_Product && class_exists('TNXL_Product')) { 151 $weight += TNXL_Product::get_shipping_measurements($product)['weight'] * $line_qty; 152 } 153 } 154 } 155 156 return array_merge(array( 157 'items' => $items, 158 'cartSubtotal' => $subtotal, 159 'destinationCountry' => $dest, 160 'cartWeightKg' => $weight, 161 'itemQuantity' => $qty, 162 'serviceIds' => self::chosen_service_ids(), 163 ), $extra); 164 } 165 166 private static function chosen_service_ids(): array { 167 if (!function_exists('WC') || !WC()->session || !class_exists('TNXL_Service_Coverage')) { 168 return array(); 169 } 170 $chosen = WC()->session->get('chosen_shipping_methods'); 171 if (!is_array($chosen) || empty($chosen)) { 172 return array(); 173 } 174 $ids = array(); 175 $packages = WC()->shipping() ? WC()->shipping()->get_packages() : array(); 176 foreach ($packages as $index => $package) { 177 $rate_id = $chosen[$index] ?? ''; 178 $rate = $package['rates'][$rate_id] ?? null; 179 if (!$rate || !is_object($rate) || !method_exists($rate, 'get_meta')) { 180 continue; 181 } 182 $courier = (string) $rate->get_meta('tnxl_courier'); 183 $display = (string) $rate->get_meta('tnxl_courier_display'); 184 if ($courier === '' && $display === '') { 185 continue; 186 } 187 foreach (TNXL_Service_Coverage::coverage_ids(array($courier, $display)) as $id) { 188 $ids[] = $id; 189 } 190 } 191 return array_values(array_unique($ids)); 192 } 193 194 private static function pickup_from_rule(array $rule): float { 195 $type = $rule['feeType'] ?? $rule['fee_type'] ?? 'fixed'; 196 if ($type === 'fixed' || $type === 'mixed') { 197 $value = (float) ($rule['feeValue'] ?? $rule['fee_value'] ?? 0); 198 return $value > 0 ? $value : 0; 199 } 200 return 0; 201 } 202 203 private static function markup_percent_from_rule(array $rule): float { 204 $type = $rule['feeType'] ?? $rule['fee_type'] ?? 'fixed'; 205 if ($type === 'mixed') { 206 $value = (float) ($rule['markupPercent'] ?? $rule['markup_percent'] ?? 0); 207 return $value > 0 ? $value : 0; 208 } 209 if ($type === 'quote_percentage') { 210 $value = (float) ($rule['feeValue'] ?? $rule['fee_value'] ?? 0); 211 return $value > 0 ? $value : 0; 212 } 213 return 0; 214 } 215 216 private static function cart_percent_from_rule(array $rule): float { 217 $type = $rule['feeType'] ?? $rule['fee_type'] ?? 'fixed'; 218 if ($type === 'percentage') { 219 $value = (float) ($rule['feeValue'] ?? $rule['fee_value'] ?? 0); 220 return $value > 0 ? $value : 0; 221 } 222 $extra = (float) ($rule['cartPercent'] ?? $rule['cart_percent'] ?? 0); 223 return $extra > 0 ? $extra : 0; 224 } 225 226 private static function pickup_unit_from_rule(array $rule): string { 227 $unit = $rule['pickupUnit'] ?? $rule['pickup_unit'] ?? 'once'; 228 return in_array($unit, array('per_item', 'per_kg'), true) ? $unit : 'once'; 229 } 230 231 private static function effective_conditions(array $rule): array { 232 if (!empty($rule['conditions']) && is_array($rule['conditions'])) { 233 return array_values(array_filter($rule['conditions'], array(__CLASS__, 'condition_is_active'))); 234 } 235 236 $type = $rule['conditionType'] ?? $rule['condition_type'] ?? ''; 237 if ($type === 'specific_products') { 238 $ids = array_values(array_filter(array_map('intval', (array) ($rule['specificProducts'] ?? $rule['specific_products'] ?? array())))); 239 return array(array('type' => 'specific_products', 'specificProducts' => $ids)); 240 } 241 242 $min = (float) ($rule['minRange'] ?? $rule['min_range'] ?? 0); 243 $max = (float) ($rule['maxRange'] ?? $rule['max_range'] ?? 0); 244 if ($min > 0 || $max > 0) { 245 return array(array('type' => 'subtotal_range', 'minRange' => $min, 'maxRange' => $max)); 246 } 247 248 return array(); 249 } 250 251 public static function rule_has_effect($rule): bool { 252 if (!is_array($rule)) { 253 return false; 254 } 255 256 return self::pickup_from_rule($rule) > 0 257 || self::markup_percent_from_rule($rule) > 0 258 || self::cart_percent_from_rule($rule) > 0; 259 } 260 261 public static function rule_is_always_on($rule): bool { 262 if (!is_array($rule)) { 263 return false; 264 } 265 $conditions = self::effective_conditions($rule); 266 if (empty($conditions)) { 267 return true; 268 } 269 foreach ($conditions as $condition) { 270 if (($condition['type'] ?? '') !== 'subtotal_range') { 271 return false; 272 } 273 if ((float) ($condition['minRange'] ?? $condition['min_range'] ?? 0) > 0 274 || (float) ($condition['maxRange'] ?? $condition['max_range'] ?? 0) > 0) { 275 return false; 276 } 277 } 278 return true; 279 } 280 281 private static function condition_is_active($condition): bool { 282 if (!is_array($condition)) { 283 return false; 284 } 285 $type = $condition['type'] ?? ''; 286 if ($type === 'subtotal_range') { 287 return (float) ($condition['minRange'] ?? 0) > 0 || (float) ($condition['maxRange'] ?? 0) > 0; 288 } 289 if ($type === 'specific_products') { 290 return !empty($condition['specificProducts']) || !empty($condition['specific_products']); 291 } 292 if ($type === 'destination_country') { 293 return !empty($condition['countries']); 294 } 295 if ($type === 'weight_range') { 296 return (float) ($condition['minKg'] ?? 0) > 0 || (float) ($condition['maxKg'] ?? 0) > 0; 297 } 298 if ($type === 'item_quantity') { 299 return (float) ($condition['minQuantity'] ?? 0) > 0 || (float) ($condition['maxQuantity'] ?? 0) > 0; 300 } 301 return !empty($condition['serviceIds']) || !empty($condition['service_ids']); 302 } 303 304 /** 305 * @return array{matched:bool,productSubtotal:float,matchingQty:int} 306 */ 307 private static function rule_matches(array $rule, array $context): array { 308 $items = $context['items'] ?? array(); 309 $conditions = self::effective_conditions($rule); 310 $product_subtotal = 0.0; 311 $matching_qty = 0; 312 $has_product_filter = false; 313 314 foreach ($conditions as $condition) { 315 if (!self::condition_matches($condition, $context, $items)) { 316 return array('matched' => false, 'productSubtotal' => 0.0, 'matchingQty' => 0); 317 } 318 if (($condition['type'] ?? '') === 'specific_products') { 319 $has_product_filter = true; 320 $match = self::matching_products($items, $condition['specificProducts'] ?? $condition['specific_products'] ?? array()); 321 $product_subtotal += $match['productSubtotal']; 322 $matching_qty += $match['matchingQty']; 323 } 324 } 325 326 if (!$has_product_filter) { 327 $matching_qty = (int) ($context['itemQuantity'] ?? $context['item_quantity'] ?? 0); 328 if ($matching_qty <= 0) { 329 foreach ($items as $item) { 330 $matching_qty += max(1, (int) ($item['quantity'] ?? 1)); 331 } 332 } 333 } 334 335 return array('matched' => true, 'productSubtotal' => $product_subtotal, 'matchingQty' => $matching_qty); 336 } 337 338 private static function condition_matches(array $condition, array $context, array $items): bool { 339 $type = $condition['type'] ?? ''; 340 if ($type === 'subtotal_range') { 341 return self::in_min_max( 342 (float) ($context['cartSubtotal'] ?? $context['cart_subtotal'] ?? 0), 343 (float) ($condition['minRange'] ?? 0), 344 (float) ($condition['maxRange'] ?? 0) 345 ); 346 } 347 if ($type === 'specific_products') { 348 $ids = $condition['specificProducts'] ?? $condition['specific_products'] ?? array(); 349 return self::matching_products($items, $ids)['inCart']; 350 } 351 if ($type === 'destination_country') { 352 $dest = strtoupper(trim((string) ($context['destinationCountry'] ?? $context['destination_country'] ?? ''))); 353 if ($dest === '') { 354 return false; 355 } 356 $codes = array(); 357 foreach ((array) ($condition['countries'] ?? array()) as $code) { 358 $iso = strtoupper(trim((string) $code)); 359 if ($iso !== '') { 360 $codes[$iso] = true; 361 } 362 } 363 if (empty($codes)) { 364 return false; 365 } 366 $has = isset($codes[$dest]); 367 return !empty($condition['excludeCountries']) || !empty($condition['exclude_countries']) ? !$has : $has; 368 } 369 if ($type === 'weight_range') { 370 return self::in_min_max( 371 (float) ($context['cartWeightKg'] ?? $context['cart_weight_kg'] ?? 0), 372 (float) ($condition['minKg'] ?? 0), 373 (float) ($condition['maxKg'] ?? 0) 374 ); 375 } 376 if ($type === 'item_quantity') { 377 $qty = (int) ($context['itemQuantity'] ?? $context['item_quantity'] ?? 0); 378 if ($qty <= 0) { 379 foreach ($items as $item) { 380 $qty += max(1, (int) ($item['quantity'] ?? 1)); 381 } 382 } 383 return self::in_min_max($qty, (float) ($condition['minQuantity'] ?? 0), (float) ($condition['maxQuantity'] ?? 0)); 384 } 385 386 $stored = $condition['serviceIds'] ?? $condition['service_ids'] ?? array(); 387 $quoted = $context['serviceIds'] ?? $context['service_ids'] ?? array(); 388 $want = array_fill_keys(TNXL_Service_Coverage::coverage_ids($stored), true); 389 if (empty($want)) { 390 return false; 391 } 392 foreach (TNXL_Service_Coverage::coverage_ids($quoted) as $id) { 393 if (isset($want[$id])) { 394 return true; 395 } 396 } 397 return false; 398 } 399 400 /** 401 * @param mixed[] $items 402 * @param mixed[] $product_ids 403 * @return array{inCart:bool,productSubtotal:float,matchingQty:int} 404 */ 405 private static function matching_products(array $items, array $product_ids): array { 406 $ids = array(); 407 foreach ($product_ids as $id) { 408 $key = (string) $id; 409 if ($key !== '') { 410 $ids[$key] = true; 411 } 412 } 413 $subtotal = 0.0; 414 $qty = 0; 415 $in_cart = false; 416 foreach ($items as $item) { 417 $candidates = array( 418 (string) ($item['product_id'] ?? ''), 419 (string) ($item['variation_id'] ?? ''), 420 ); 421 $hit = false; 422 foreach ($candidates as $pid) { 423 if ($pid !== '' && isset($ids[$pid])) { 424 $hit = true; 425 break; 426 } 427 } 428 if (!$hit) { 429 continue; 430 } 431 $in_cart = true; 432 $line_qty = max(1, (int) ($item['quantity'] ?? 1)); 433 $qty += $line_qty; 434 $subtotal += (float) ($item['line_total'] ?? 0); 435 } 436 return array('inCart' => $in_cart, 'productSubtotal' => $subtotal, 'matchingQty' => $qty); 437 } 438 439 private static function scaled_pickup(float $pickup, string $unit, int $matching_qty, float $cart_weight_kg): float { 440 if ($pickup <= 0) { 36 441 return 0; 37 442 } 38 39 $cart = WC()->cart; 40 $cart_subtotal = $cart->get_subtotal(); 41 $total_commission = 0; 42 43 foreach ($rules as $rule) { 44 $condition_type = $rule['condition_type'] ?? ''; 45 $fee_type = $rule['fee_type'] ?? 'fixed'; 46 $fee_value = floatval($rule['fee_value'] ?? 0); 47 48 if ($fee_value <= 0) continue; 49 50 $fee_amount = 0; 51 $apply_fee = false; 52 53 if ($condition_type === 'subtotal_range') { 54 $min_range = floatval($rule['min_range'] ?? 0); 55 $max_range = floatval($rule['max_range'] ?? 0); 56 57 if ($cart_subtotal >= $min_range && ($max_range == 0 || $cart_subtotal <= $max_range)) { 58 $apply_fee = true; 59 if ($fee_type === 'percentage') { 60 $fee_amount = ($cart_subtotal * $fee_value) / 100; 61 } else { 62 $fee_amount = $fee_value; 63 } 64 } 65 } elseif ($condition_type === 'specific_products') { 66 $specific_products = $rule['specific_products'] ?? array(); 67 if (empty($specific_products)) continue; 68 69 $matching_products_total = 0; 70 $found_product = false; 71 72 foreach ($cart->get_cart() as $cart_item) { 73 $product_id = $cart_item['product_id']; 74 $variation_id = $cart_item['variation_id']; 75 76 if (in_array($product_id, $specific_products) || in_array($variation_id, $specific_products)) { 77 $found_product = true; 78 $matching_products_total += $cart_item['line_total']; 79 } 80 } 81 82 if ($found_product) { 83 $apply_fee = true; 84 if ($fee_type === 'percentage') { 85 $fee_amount = ($matching_products_total * $fee_value) / 100; 86 } else { 87 $fee_amount = $fee_value; 88 } 89 } 90 } 91 92 if ($apply_fee && $fee_amount > 0) { 93 $total_commission += $fee_amount; 94 } 95 } 96 97 return $total_commission; 443 if ($unit === 'per_item') { 444 return $pickup * $matching_qty; 445 } 446 if ($unit === 'per_kg') { 447 return $pickup * ($cart_weight_kg > 0 ? $cart_weight_kg : 0); 448 } 449 return $pickup; 450 } 451 452 private static function in_min_max(float $value, float $min, float $max): bool { 453 $lo = $min > 0 ? $min : 0; 454 $hi = $max > 0 ? $max : 0; 455 if ($hi > 0 && $lo > $hi) { 456 return $value >= $hi && $value <= $lo; 457 } 458 return $value >= $lo && ($hi <= 0 || $value <= $hi); 98 459 } 99 460 } 100 -
thai-nexus-logistics/trunk/includes/class-tnxl-order.php
r3678280 r3705845 323 323 'width_cm' => $box['width'], 324 324 'height_cm' => $box['height'], 325 'shipment_type' => 'parcel', 325 'is_document' => !empty($box['is_document']), 326 'shipment_type' => !empty($box['is_document']) ? 'document' : 'parcel', 326 327 'service_type' => $service['name'] !== '' ? $service['name'] : $service['id'], 327 328 'courier_name' => $service['id'], -
thai-nexus-logistics/trunk/includes/class-tnxl-product.php
r3678280 r3705845 156 156 } 157 157 158 $weight = (float) wc_get_weight($values['weight'], 'kg'); 159 158 160 return array( 159 161 'length' => (float) wc_get_dimension($values['length'], 'cm'), 160 162 'width' => (float) wc_get_dimension($values['width'], 'cm'), 161 163 'height' => (float) wc_get_dimension($values['height'], 'cm'), 162 'weight' => (float) wc_get_weight($values['weight'], 'kg'),164 'weight' => $weight, 163 165 ); 164 166 } -
thai-nexus-logistics/trunk/includes/class-tnxl-rest-api.php
r3678280 r3705845 54 54 )); 55 55 56 register_rest_route('tnxl/v1', '/product-catalog', array( 57 'methods' => 'GET', 58 'callback' => array($this, 'get_product_catalog'), 59 'permission_callback' => array($this, 'check_permission'), 60 )); 61 62 register_rest_route('tnxl/v1', '/products/(?P<id>\d+)', array( 63 'methods' => 'PUT', 64 'callback' => array($this, 'update_product'), 65 'permission_callback' => array($this, 'check_permission'), 66 )); 67 56 68 register_rest_route('tnxl/v1', '/search-products', array( 57 69 'methods' => 'GET', … … 125 137 'api_token' => TNXL_Settings::get_api_token(), 126 138 'features' => TNXL_Settings::get_features(), 127 'commission_rules' => get_option('tnxl_commission_rules', array()),139 'commission_rules' => $this->sanitize_commission_rules(get_option('tnxl_commission_rules', array())), 128 140 'disabled_service_ids' => TNXL_Settings::get_disabled_service_ids(), 129 141 'shipping_ineligible_product_ids' => TNXL_Settings::get_ineligible_product_ids(), 142 'product_weight_unit' => TNXL_Settings::get_product_weight_unit(), 143 'charge_actual_weight_only' => TNXL_Settings::is_actual_weight_only(), 144 'service_coverage' => TNXL_Settings::get_service_coverage(), 145 'pricing_mode' => TNXL_Settings::get_pricing_mode(), 130 146 'currency_symbol' => $currency_symbol, 131 147 'shipper' => array( … … 180 196 } 181 197 198 if (array_key_exists('product_weight_unit', $params)) { 199 TNXL_Settings::save_product_weight_unit($params['product_weight_unit']); 200 } 201 if (array_key_exists('charge_actual_weight_only', $params)) { 202 TNXL_Settings::save_actual_weight_only($params['charge_actual_weight_only']); 203 } 204 if (isset($params['service_coverage']) && is_array($params['service_coverage'])) { 205 TNXL_Settings::save_service_coverage($params['service_coverage']); 206 } 207 if (array_key_exists('pricing_mode', $params)) { 208 TNXL_Settings::save_pricing_mode($params['pricing_mode']); 209 } 210 182 211 if (isset($params['commission_rules'])) { 183 // Need custom sanitization for an array of rules 184 $rules = array(); 185 if (is_array($params['commission_rules'])) { 186 foreach ($params['commission_rules'] as $rule) { 187 $rules[] = array( 188 'condition_type' => sanitize_text_field($rule['condition_type'] ?? ''), 189 'min_range' => floatval($rule['min_range'] ?? 0), 190 'max_range' => floatval($rule['max_range'] ?? 0), 191 'specific_products' => array_map('intval', (array)($rule['specific_products'] ?? array())), 192 'fee_type' => sanitize_text_field($rule['fee_type'] ?? 'fixed'), 193 'fee_value' => floatval($rule['fee_value'] ?? 0), 194 'fee_label' => sanitize_text_field($rule['fee_label'] ?? ''), 195 ); 196 } 197 } 198 update_option('tnxl_commission_rules', $rules); 212 update_option('tnxl_commission_rules', $this->sanitize_commission_rules($params['commission_rules'])); 199 213 } 200 214 … … 320 334 321 335 return rest_ensure_response($result); 336 } 337 338 public function get_product_catalog($request) { 339 $search = sanitize_text_field((string) $request->get_param('search')); 340 $args = array( 341 'post_type' => 'product', 342 'post_status' => 'publish', 343 'posts_per_page' => 25, 344 's' => $search, 345 'orderby' => 'title', 346 'order' => 'ASC', 347 ); 348 $query = new WP_Query($args); 349 $products = array(); 350 foreach ($query->posts as $post) { 351 $product = wc_get_product($post->ID); 352 if (!$product) { 353 continue; 354 } 355 $m = TNXL_Product::get_shipping_measurements($product); 356 $customs = TNXL_Product::get_customs_details($product); 357 $products[] = array( 358 'id' => $product->get_id(), 359 'name' => $product->get_name(), 360 'sku' => $product->get_sku(), 361 'length' => $m['length'], 362 'width' => $m['width'], 363 'height' => $m['height'], 364 'weight' => $m['weight'], 365 'hs_code' => $customs['hs_code'], 366 'country_of_origin' => $customs['country_of_origin'], 367 'is_document' => TNXL_Product::is_document($product), 368 'is_boxed_product' => TNXL_Product::is_boxed_product($product), 369 'shipping_eligible' => TNXL_Product::is_shipping_eligible($product), 370 'edit_url' => get_edit_post_link($product->get_id(), 'raw'), 371 ); 372 } 373 374 return rest_ensure_response($products); 375 } 376 377 public function update_product($request) { 378 $id = (int) $request['id']; 379 $product = wc_get_product($id); 380 if (!$product) { 381 return new WP_Error('tnxl_product_missing', 'Product not found.', array('status' => 404)); 382 } 383 384 $params = $request->get_json_params(); 385 if (!is_array($params)) { 386 $params = array(); 387 } 388 389 if (isset($params['length']) || isset($params['width']) || isset($params['height'])) { 390 $dim_unit = (string) get_option('woocommerce_dimension_unit', 'cm'); 391 if (isset($params['length'])) { 392 $product->set_length(wc_get_dimension((float) $params['length'], $dim_unit, 'cm')); 393 } 394 if (isset($params['width'])) { 395 $product->set_width(wc_get_dimension((float) $params['width'], $dim_unit, 'cm')); 396 } 397 if (isset($params['height'])) { 398 $product->set_height(wc_get_dimension((float) $params['height'], $dim_unit, 'cm')); 399 } 400 } 401 if (isset($params['weight'])) { 402 $weight_unit = (string) get_option('woocommerce_weight_unit', 'kg'); 403 $product->set_weight(wc_get_weight((float) $params['weight'], $weight_unit, 'kg')); 404 } 405 $product->save(); 406 407 if (array_key_exists('is_document', $params)) { 408 update_post_meta($id, '_tnxl_is_document', !empty($params['is_document']) ? 'yes' : 'no'); 409 } 410 if (array_key_exists('is_boxed_product', $params)) { 411 update_post_meta($id, '_tnxl_is_boxed_product', !empty($params['is_boxed_product']) ? 'yes' : 'no'); 412 } 413 if (array_key_exists('shipping_eligible', $params)) { 414 update_post_meta( 415 $id, 416 '_tnxl_shipping_eligible', 417 $params['shipping_eligible'] === false ? 'no' : 'yes' 418 ); 419 } 420 if (array_key_exists('hs_code', $params)) { 421 update_post_meta($id, '_tnxl_hs_code', TNXL_Product::normalize_hs_code($params['hs_code'])); 422 } 423 if (array_key_exists('country_of_origin', $params)) { 424 $origin = strtoupper(preg_replace('/[^A-Za-z]/', '', sanitize_text_field((string) $params['country_of_origin']))); 425 update_post_meta($id, '_tnxl_country_of_origin', substr($origin, 0, 2)); 426 } 427 428 $fresh = wc_get_product($id); 429 $m = TNXL_Product::get_shipping_measurements($fresh); 430 $customs = TNXL_Product::get_customs_details($fresh); 431 return rest_ensure_response(array( 432 'id' => $fresh->get_id(), 433 'name' => $fresh->get_name(), 434 'sku' => $fresh->get_sku(), 435 'length' => $m['length'], 436 'width' => $m['width'], 437 'height' => $m['height'], 438 'weight' => $m['weight'], 439 'hs_code' => $customs['hs_code'], 440 'country_of_origin' => $customs['country_of_origin'], 441 'is_document' => TNXL_Product::is_document($fresh), 442 'is_boxed_product' => TNXL_Product::is_boxed_product($fresh), 443 'shipping_eligible' => TNXL_Product::is_shipping_eligible($fresh), 444 'edit_url' => get_edit_post_link($fresh->get_id(), 'raw'), 445 )); 446 } 447 448 /** 449 * @param mixed $rules 450 * @return array<int, array<string, mixed>> 451 */ 452 private function sanitize_commission_rules($rules): array { 453 if (!is_array($rules)) { 454 return array(); 455 } 456 $out = array(); 457 foreach ($rules as $index => $rule) { 458 if (!is_array($rule)) { 459 continue; 460 } 461 $conditions = array(); 462 foreach ((array) ($rule['conditions'] ?? array()) as $condition) { 463 if (!is_array($condition)) { 464 continue; 465 } 466 $type = sanitize_key((string) ($condition['type'] ?? '')); 467 $row = array('type' => $type); 468 if ($type === 'subtotal_range') { 469 $row['minRange'] = floatval($condition['minRange'] ?? $condition['min_range'] ?? 0); 470 $row['maxRange'] = floatval($condition['maxRange'] ?? $condition['max_range'] ?? 0); 471 } elseif ($type === 'specific_products') { 472 $row['specificProducts'] = array_values(array_filter(array_map('absint', (array) ($condition['specificProducts'] ?? $condition['specific_products'] ?? array())))); 473 } elseif ($type === 'destination_country') { 474 $codes = array(); 475 foreach ((array) ($condition['countries'] ?? array()) as $code) { 476 $iso = strtoupper(preg_replace('/[^A-Za-z]/', '', (string) $code)); 477 if (strlen($iso) === 2) { 478 $codes[] = $iso; 479 } 480 } 481 $row['countries'] = array_values(array_unique($codes)); 482 $row['excludeCountries'] = !empty($condition['excludeCountries']) || !empty($condition['exclude_countries']); 483 } elseif ($type === 'weight_range') { 484 $row['minKg'] = floatval($condition['minKg'] ?? 0); 485 $row['maxKg'] = floatval($condition['maxKg'] ?? 0); 486 } elseif ($type === 'item_quantity') { 487 $row['minQuantity'] = absint($condition['minQuantity'] ?? 0); 488 $row['maxQuantity'] = absint($condition['maxQuantity'] ?? 0); 489 } elseif ($type === 'shipping_service') { 490 $ids = array(); 491 foreach ((array) ($condition['serviceIds'] ?? $condition['service_ids'] ?? array()) as $id) { 492 $norm = TNXL_Settings::normalize_service_id($id); 493 if ($norm !== '') { 494 $ids[] = $norm; 495 } 496 } 497 $row['serviceIds'] = array_values(array_unique($ids)); 498 } else { 499 continue; 500 } 501 $conditions[] = $row; 502 } 503 504 $id = sanitize_text_field((string) ($rule['id'] ?? ('rule_' . ($index + 1)))); 505 $out[] = array( 506 'id' => $id !== '' ? $id : ('rule_' . ($index + 1)), 507 'conditionType' => sanitize_text_field((string) ($rule['conditionType'] ?? $rule['condition_type'] ?? 'subtotal_range')), 508 'minRange' => floatval($rule['minRange'] ?? $rule['min_range'] ?? 0), 509 'maxRange' => floatval($rule['maxRange'] ?? $rule['max_range'] ?? 0), 510 'specificProducts' => array_values(array_filter(array_map('absint', (array) ($rule['specificProducts'] ?? $rule['specific_products'] ?? array())))), 511 'conditions' => $conditions, 512 'feeType' => sanitize_text_field((string) ($rule['feeType'] ?? $rule['fee_type'] ?? 'fixed')), 513 'feeValue' => floatval($rule['feeValue'] ?? $rule['fee_value'] ?? 0), 514 'markupPercent' => floatval($rule['markupPercent'] ?? $rule['markup_percent'] ?? 0), 515 'cartPercent' => floatval($rule['cartPercent'] ?? $rule['cart_percent'] ?? 0), 516 'pickupUnit' => sanitize_text_field((string) ($rule['pickupUnit'] ?? $rule['pickup_unit'] ?? 'once')), 517 'stopProcessing' => !empty($rule['stopProcessing']) || !empty($rule['stop_processing']), 518 'feeLabel' => sanitize_text_field((string) ($rule['feeLabel'] ?? $rule['fee_label'] ?? '')), 519 ); 520 } 521 522 return $out; 322 523 } 323 524 -
thai-nexus-logistics/trunk/includes/class-tnxl-settings.php
r3617587 r3705845 16 16 public const OPTION_DISABLED_SERVICES = 'tnxl_disabled_service_ids'; 17 17 public const OPTION_INELIGIBLE_PRODUCTS = 'tnxl_shipping_ineligible_product_ids'; 18 public const OPTION_PRODUCT_WEIGHT_UNIT = 'tnxl_product_weight_unit'; 19 public const OPTION_ACTUAL_WEIGHT_ONLY = 'tnxl_charge_actual_weight_only'; 20 public const OPTION_SERVICE_COVERAGE = 'tnxl_service_coverage'; 21 public const OPTION_PRICING_MODE = 'tnxl_pricing_mode'; 18 22 19 23 public static function maybe_register_defaults(): void { … … 134 138 } 135 139 140 public static function get_product_weight_unit(): string { 141 $saved = (string) get_option(self::OPTION_PRODUCT_WEIGHT_UNIT, ''); 142 if ($saved === 'g' || $saved === 'kg') { 143 return $saved; 144 } 145 return get_option('woocommerce_weight_unit') === 'g' ? 'g' : 'kg'; 146 } 147 148 public static function save_product_weight_unit($value): void { 149 $unit = strtolower(trim((string) $value)); 150 update_option(self::OPTION_PRODUCT_WEIGHT_UNIT, $unit === 'g' ? 'g' : 'kg', false); 151 } 152 153 public static function is_actual_weight_only(): bool { 154 return self::option_is_yes(self::OPTION_ACTUAL_WEIGHT_ONLY, 'no'); 155 } 156 157 public static function save_actual_weight_only($value): void { 158 update_option(self::OPTION_ACTUAL_WEIGHT_ONLY, self::bool_to_yes_no($value), false); 159 } 160 161 /** 162 * @return array<string, mixed> 163 */ 164 public static function get_service_coverage(): array { 165 $coverage = get_option(self::OPTION_SERVICE_COVERAGE, array()); 166 return is_array($coverage) ? $coverage : array(); 167 } 168 169 /** 170 * @param array<mixed> $coverage 171 */ 172 public static function save_service_coverage(array $coverage): void { 173 $clean = array(); 174 foreach ($coverage as $key => $rule) { 175 $id = self::normalize_service_id($key); 176 if ($id === '' || !is_array($rule)) { 177 continue; 178 } 179 $rest = !empty($rule['restOfWorld']) || !empty($rule['rest_of_world']); 180 $exclude = !$rest && (!empty($rule['excludeCountries']) || !empty($rule['exclude_countries'])); 181 $worldwide = !$rest && !$exclude && (!isset($rule['worldwide']) || $rule['worldwide']); 182 $countries = array(); 183 foreach ((array) ($rule['countries'] ?? array()) as $code) { 184 $iso = strtoupper(preg_replace('/[^A-Za-z]/', '', (string) $code)); 185 if (strlen($iso) === 2) { 186 $countries[] = $iso; 187 } 188 } 189 $countries = array_values(array_unique($countries)); 190 if ($rest) { 191 $clean[$id] = array('worldwide' => false, 'restOfWorld' => true, 'countries' => array()); 192 continue; 193 } 194 if ($exclude) { 195 $clean[$id] = array('worldwide' => true, 'excludeCountries' => true, 'countries' => $countries); 196 continue; 197 } 198 $clean[$id] = $worldwide 199 ? array('worldwide' => true, 'countries' => array()) 200 : array('worldwide' => false, 'countries' => $countries); 201 } 202 update_option(self::OPTION_SERVICE_COVERAGE, $clean, false); 203 } 204 205 public static function get_pricing_mode(): string { 206 $mode = (string) get_option(self::OPTION_PRICING_MODE, ''); 207 if ($mode === 'advanced' || $mode === 'basic') { 208 return $mode; 209 } 210 211 $rules = get_option('tnxl_commission_rules', array()); 212 if (!is_array($rules) || !class_exists('TNXL_Commission')) { 213 return 'basic'; 214 } 215 216 $always_on_with_effect = 0; 217 foreach ($rules as $rule) { 218 if (!is_array($rule)) { 219 continue; 220 } 221 if (!TNXL_Commission::rule_is_always_on($rule)) { 222 return 'advanced'; 223 } 224 if (TNXL_Commission::rule_has_effect($rule)) { 225 $always_on_with_effect++; 226 if ($always_on_with_effect > 1) { 227 return 'advanced'; 228 } 229 } 230 } 231 232 return 'basic'; 233 } 234 235 public static function save_pricing_mode($value): void { 236 $mode = (string) $value; 237 update_option(self::OPTION_PRICING_MODE, $mode === 'advanced' ? 'advanced' : 'basic', false); 238 } 239 136 240 /** 137 241 * Feature flags for REST/admin UI. -
thai-nexus-logistics/trunk/includes/class-tnxl-shipping-method.php
r3678280 r3705845 122 122 123 123 // 3D Box Packing Implementation 124 if (!class_exists('TNXL_Box_Packer')) { 125 return; 126 } 124 127 $packer = TNXL_Box_Packer::get_instance(); 125 128 $packing_result = $packer->pack_items($tnxl_items); … … 153 156 154 157 $commission = 0; 155 if (class_exists('TNXL_Commission')) { 156 $commission = TNXL_Commission::get_instance()->get_total_commission(); 157 } 158 159 // Quote each unique parcel shape once. Repeated retail boxes would 160 // otherwise trigger one sequential HTTP request per cart quantity. 158 $commission_class = class_exists('TNXL_Commission'); 159 161 160 $quote_groups = array(); 162 161 foreach ($packed_boxes as $box) { … … 231 230 } 232 231 233 // Only show couriers that could quote ALL boxes234 232 $box_count = count($packed_boxes); 235 233 $final_quotes_debug = array(); 236 234 $disabled_service_ids = TNXL_Settings::get_disabled_service_ids(); 237 235 $coverage = TNXL_Settings::get_service_coverage(); 236 $packed_weight = 0; 237 foreach ($packed_boxes as $box) { 238 $packed_weight += (float) ($box['weight'] ?? 0); 239 } 240 $pricing_context = $commission_class 241 ? TNXL_Commission::cart_pricing_context(array( 242 'destinationCountry' => (string) ($dest['country'] ?? ''), 243 'cartWeightKg' => $packed_weight, 244 )) 245 : array(); 246 247 $offered = array(); 238 248 foreach ($all_quotes as $courier => $data) { 239 249 if ($data['count'] < $box_count) continue; 240 if (in_array(TNXL_Settings::normalize_service_id($courier), $disabled_service_ids, true)) { 241 continue; 242 } 243 244 $cost = $data['total_price']; 250 $offered[] = array( 251 'courier' => $courier, 252 'display_name' => $data['display_name'], 253 'estimated_days' => $data['estimated_days'], 254 'total_price' => $data['total_price'], 255 ); 256 } 257 258 $offered = TNXL_Service_Coverage::filter_checkout_quotes( 259 $offered, 260 static function ($quote) { 261 return array($quote['courier'], $quote['display_name']); 262 }, 263 (string) ($dest['country'] ?? ''), 264 $disabled_service_ids, 265 $coverage 266 ); 267 268 foreach ($offered as $row) { 269 $cost_thb = (float) $row['total_price']; 270 $quote_store = $cost_thb; 245 271 if ($has_rate) { 246 $cost = $cost * (float) $rate; 247 } 248 249 // Add hidden commission buffer 250 $cost += $commission; 251 252 // Add destination hash to ID to force WooCommerce to refresh rates when address changes 253 $rate_id = 'tnxl_' . sanitize_title($courier) . '_' . substr(md5($dest['country'] . $dest['postcode']), 0, 6); 272 $quote_store = $cost_thb * (float) $rate; 273 } 274 $priced = $quote_store; 275 $commission = 0.0; 276 if ($commission_class) { 277 $service_ids = TNXL_Service_Coverage::coverage_ids(array($row['courier'], $row['display_name'])); 278 $priced_row = TNXL_Commission::price_converted_quote( 279 $cost_thb, 280 $has_rate ? $rate : null, 281 array_merge($pricing_context, array('serviceIds' => $service_ids)) 282 ); 283 $quote_store = $priced_row['quote_store']; 284 $priced = $priced_row['priced']; 285 $commission = $priced_row['commission']; 286 } 287 $cost = $priced; 288 289 $rate_id = 'tnxl_' . sanitize_title($row['courier']) . '_' . substr(md5($dest['country'] . $dest['postcode']), 0, 6); 254 290 255 291 $this->add_rate(array( 256 292 'id' => $rate_id, 257 'label' => $ data['display_name'] . ' (' . ($data['estimated_days'] ?: 'TBA') . ' days)',293 'label' => $row['display_name'] . ' (' . ($row['estimated_days'] ?: 'TBA') . ' days)', 258 294 'cost' => $cost, 259 295 'meta_data' => array( 260 'tnxl_courier' => $ courier,261 'tnxl_courier_display' => $ data['display_name'],296 'tnxl_courier' => $row['courier'], 297 'tnxl_courier_display' => $row['display_name'], 262 298 'tnxl_boxes' => $packed_boxes, 263 299 'tnxl_breakdown' => array( 264 'base_price' => $ cost - $commission,300 'base_price' => $quote_store, 265 301 'commission' => $commission, 266 302 'total' => $cost … … 271 307 if ($debug_enabled) { 272 308 $final_quotes_debug[] = array( 273 'courier' => $ data['display_name'],274 'price_thb' => $ data['total_price'],309 'courier' => $row['display_name'], 310 'price_thb' => $row['total_price'], 275 311 'final_cost' => $cost, 276 'days' => $ data['estimated_days'] ?: 'TBA',312 'days' => $row['estimated_days'] ?: 'TBA', 277 313 ); 278 314 } -
thai-nexus-logistics/trunk/readme.txt
r3678280 r3705845 4 4 Requires at least: 5.8 5 5 Tested up to: 7.1 6 Stable tag: 1.5.1 46 Stable tag: 1.5.15 7 7 Requires PHP: 8.2 8 8 License: GPLv2 or later … … 84 84 == Changelog == 85 85 86 = 1.5.15 = 87 * Feature: Fees tab with basic/advanced pricing formulas (replaces the old commission rules page). 88 * Feature: Per-service destination coverage (include, exclude, rest of world). 89 * Feature: Products tab for catalog shipping fields (dimensions, HS code, boxed/document flags). 90 * Feature: Privacy tab in the admin dashboard. 91 * Fix: Checkout rates honor service coverage and the updated fee engine. 92 86 93 = 1.5.14 = 87 94 * Feature: Show TNX tracking numbers and Track links on My Account, thank you, Processing/Completed emails, and customer notes when the code is generated. -
thai-nexus-logistics/trunk/thai-nexus-logistics.php
r3678280 r3705845 3 3 * Plugin Name: Thai Nexus Logistics - International Shipping Rates & Currency Converter for WooCommerce 4 4 * Description: Real-time WooCommerce shipping rates, automated shipments, and multi-currency conversion for Thailand and international orders via the Thai Nexus API. 5 * Version: 1.5.1 45 * Version: 1.5.15 6 6 * Author: Thai Nexus 7 7 * Author URI: https://app.thainexus.co.th … … 17 17 18 18 // Release line: stay on 1.5.x (patch) until explicitly approved for 1.6+. 19 define('TNXL_VERSION', '1.5.1 4');19 define('TNXL_VERSION', '1.5.15'); 20 20 define('TNXL_PLUGIN_DIR', plugin_dir_path(__FILE__)); 21 21 define('TNXL_PLUGIN_URL', plugin_dir_url(__FILE__)); … … 119 119 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-settings.php'; 120 120 TNXL_Settings::init(); 121 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-service-coverage.php'; 121 122 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-migration.php'; 122 123 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-api.php'; … … 141 142 if (!class_exists('DVDoug\BoxPacker\Packer')) { 142 143 add_action('admin_notices', array($this, 'boxpacker_missing_notice')); 143 return;144 144 } 145 145 146 146 // Load WooCommerce dependent files 147 147 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-product.php'; 148 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-box-packer.php'; 148 if (class_exists('DVDoug\BoxPacker\Packer')) { 149 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-box-packer.php'; 150 } 149 151 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-shipping-method.php'; 150 152 require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-order.php'; … … 164 166 } 165 167 166 TNXL_Box_Packer::get_instance(); 168 if (class_exists('TNXL_Box_Packer')) { 169 TNXL_Box_Packer::get_instance(); 170 } 167 171 168 172 $needs_shipping_method = TNXL_Settings::can_fetch_checkout_rates() … … 219 223 if ($this->maybe_hide_shipping_on_cart(true) === false) { 220 224 return $rates; 225 } 226 227 foreach ($rates as $rate) { 228 if ($rate instanceof WC_Shipping_Rate && $rate->get_method_id() === 'tnxl_shipping') { 229 return $rates; 230 } 221 231 } 222 232 -
thai-nexus-logistics/trunk/vendor/composer/installed.json
r3559867 r3705845 136 136 } 137 137 ], 138 "dev": true,138 "dev": false, 139 139 "dev-package-names": [] 140 140 } -
thai-nexus-logistics/trunk/vendor/composer/installed.php
r3559867 r3705845 2 2 'root' => array( 3 3 'name' => 'thainexus/thai-nexus-logistics', 4 'pretty_version' => 'dev-ma ster',5 'version' => 'dev-ma ster',6 'reference' => ' c3684859ee067328625aa7437ca24af1d6e4df02',4 'pretty_version' => 'dev-main', 5 'version' => 'dev-main', 6 'reference' => '4bce3740c4d1d0078e7eb7b82844685a8aa60a97', 7 7 'type' => 'library', 8 8 'install_path' => __DIR__ . '/../../', 9 9 'aliases' => array(), 10 'dev' => true,10 'dev' => false, 11 11 ), 12 12 'versions' => array( … … 30 30 ), 31 31 'thainexus/thai-nexus-logistics' => array( 32 'pretty_version' => 'dev-ma ster',33 'version' => 'dev-ma ster',34 'reference' => ' c3684859ee067328625aa7437ca24af1d6e4df02',32 'pretty_version' => 'dev-main', 33 'version' => 'dev-main', 34 'reference' => '4bce3740c4d1d0078e7eb7b82844685a8aa60a97', 35 35 'type' => 'library', 36 36 'install_path' => __DIR__ . '/../../',
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)