Plugin Directory

Changeset 3705845


Ignore:
Timestamp:
09/21/2026 03:16:35 PM (4 weeks ago)
Author:
thainexus
Message:

Release 1.5.15: fees formulas, service coverage, products and privacy tabs.

Location:
thai-nexus-logistics
Files:
38 added
38 edited
1 copied

Legend:

Unmodified
Added
Removed
  • thai-nexus-logistics/tags/1.5.15/admin/package.json

    r3678280 r3705845  
    22  "name": "admin",
    33  "private": true,
    4   "version": "1.5.14",
     4  "version": "1.5.15",
    55  "type": "module",
    66  "scripts": {
  • thai-nexus-logistics/tags/1.5.15/admin/src/App.jsx

    r3559867 r3705845  
    11import React, { useState, useEffect } from 'react';
    2 import { Settings, Package, LayoutDashboard, Save, ExternalLink, RefreshCw, DollarSign, Terminal } from 'lucide-react';
     2import { Settings, Package, LayoutDashboard, ExternalLink, DollarSign, Terminal, Shield, Box } from 'lucide-react';
    33import SettingsPage from './pages/SettingsPage';
    44import ShipmentsPage from './pages/ShipmentsPage';
    5 import CommissionRulesPage from './pages/CommissionRulesPage';
     5import FeesPage from './pages/FeesPage';
    66import BoxDefinitionsPage from './pages/BoxDefinitionsPage';
    77import DebugLogPage from './pages/DebugLogPage';
     8import PrivacyPage from './pages/PrivacyPage';
     9import ProductsPage from './pages/ProductsPage';
     10import { fetchSettings } from './lib/api';
    811import { clsx } from 'clsx';
    912import { twMerge } from 'tailwind-merge';
    … …  
    1417
    1518const App = () => {
    16   // @ts-ignore
    1719  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  ];
    2044
    2145  return (
    2246    <div className="min-h-screen p-6 md:p-10 max-w-7xl mx-auto font-sans">
    23       {/* Header */}
    2447      <header className="flex flex-col md:flex-row md:items-center justify-between mb-10 gap-4">
    2548        <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" />
    2851            Thai Nexus Logistics
    2952          </h1>
    3053          <p className="text-gray-500 mt-1">Manage your shipping operations and API configurations.</p>
    3154        </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 }) => (
    8458            <button
    85               onClick={() => setActiveTab('debug')}
     59              key={id}
     60              type="button"
     61              onClick={() => setActiveTab(id)}
    8662              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'
    9171              )}
    9272            >
    93               <Terminal size={18} />
    94               Debug
     73              <Icon size={16} />
     74              {label}
    9575            </button>
    96           )}
     76          ))}
    9777        </div>
    9878      </header>
    9979
    100       {/* Main Content */}
    10180      <main className="animate-in fade-in slide-in-from-bottom-4 duration-500">
    10281        {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 />}
    10689        {activeTab === 'debug' && debugEnabled && <DebugLogPage />}
    10790      </main>
    10891
    109       {/* Footer */}
    11092      <footer className="mt-12 py-6 border-t border-gray-200 flex justify-between items-center text-sm text-gray-500">
    11193        <p>&copy; 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"
    11597          rel="noopener noreferrer"
    11698          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  
    2626
    2727    if (ids.length === 0) {
     28      setSelectedDetails([]);
    2829      return () => {
    2930        cancelled = true;
  • thai-nexus-logistics/tags/1.5.15/admin/src/pages/SettingsPage.jsx

    r3678280 r3705845  
    33import axios from 'axios';
    44import ProductSearchSelect from '../components/ProductSearchSelect';
     5import CountryMultiSelect from '../components/CountryMultiSelect';
    56
    67const normalizeServiceId = (value) => String(value || '')
    … …  
    1011  .replace(/[^a-z0-9_-]/g, '');
    1112
    12 const SettingsPage = () => {
     13const 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
     25const SettingsPage = ({ onSaved }) => {
    1326  const [loading, setLoading] = useState(false);
    1427  const [saving, setSaving] = useState(false);
    … …  
    2235    disabled_service_ids: [],
    2336    shipping_ineligible_product_ids: [],
     37    product_weight_unit: 'kg',
     38    charge_actual_weight_only: false,
     39    service_coverage: {},
    2440    shipper: {
    2541      name: '',
    … …  
    8197        disabled_service_ids: response.data?.disabled_service_ids || [],
    8298        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 || {},
    83102      });
    84103      if (response.data?.api_token) {
    … …  
    119138  };
    120139
    121   const toggleService = (serviceId, enabled) => {
    122     const id = normalizeServiceId(serviceId);
     140  const toggleService = (service, enabled) => {
     141    const ids = serviceKeys(service);
    123142    setSettings((previous) => ({
    124143      ...previous,
    125144      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])],
    128147    }));
     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    });
    129165  };
    130166
    … …  
    142178      });
    143179      setMessage({ type: 'success', text: 'Settings saved successfully!' });
     180      if (typeof onSaved === 'function') {
     181        onSaved(settings);
     182      }
    144183      if (settings.api_token) {
    145184        void fetchShippingServices();
    … …  
    168207      {/* Feature toggles */}
    169208      <div className="tnxl-card">
    170         <div className="bg-secondary p-5 flex items-center gap-3">
    171           <Zap className="text-primary w-6 h-6" />
     209        <div className="bg-primary p-5 flex items-center gap-3">
     210          <Zap className="text-white w-6 h-6" />
    172211          <h2 className="text-lg font-bold text-white">Automation</h2>
    173212        </div>
    … …  
    227266      {/* API Authentication */}
    228267      <div className="tnxl-card">
    229         <div className="bg-secondary p-5 flex items-center gap-3">
    230           <Key className="text-primary w-6 h-6" />
     268        <div className="bg-primary p-5 flex items-center gap-3">
     269          <Key className="text-white w-6 h-6" />
    231270          <h2 className="text-lg font-bold text-white">API Authentication</h2>
    232271        </div>
    … …  
    271310      </div>
    272311
     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
    273367      {/* Shipping services */}
    274368      <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">
    276370          <div className="flex items-center gap-3">
    277             <Truck className="text-primary w-6 h-6" />
     371            <Truck className="text-white w-6 h-6" />
    278372            <h2 className="text-lg font-bold text-white">Shipping Services</h2>
    279373          </div>
    … …  
    303397          ) : (
    304398            <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>
    306402              <div className="flex gap-4 text-sm">
    307403                <button
    … …  
    324420              </div>
    325421              <div className="divide-y divide-gray-100 border border-gray-100 rounded-xl">
     422                <ul>
    326423                {services.map((service) => {
    327424                  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;
    329432                  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>
    344507                  );
    345508                })}
     509                </ul>
    346510              </div>
    347511            </div>
    … …  
    352516      {/* Product eligibility */}
    353517      <div className="tnxl-card">
    354         <div className="bg-secondary p-5 flex items-center gap-3">
    355           <Package className="text-primary w-6 h-6" />
     518        <div className="bg-primary p-5 flex items-center gap-3">
     519          <Package className="text-white w-6 h-6" />
    356520          <h2 className="text-lg font-bold text-white">Product Shipping Eligibility</h2>
    357521        </div>
    … …  
    380544      {/* Shipper Address */}
    381545      <div className="tnxl-card">
    382         <div className="bg-secondary p-5 flex items-center gap-3">
    383           <MapPin className="text-primary w-6 h-6" />
     546        <div className="bg-primary p-5 flex items-center gap-3">
     547          <MapPin className="text-white w-6 h-6" />
    384548          <h2 className="text-lg font-bold text-white">Store Origin Address</h2>
    385549        </div>
    … …  
    454618                onChange={(e) => setSettings({ ...settings, shipper: { ...settings.shipper, country: e.target.value } })}
    455619              >
    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>
    458625              </select>
    459626            </div>
    460627          </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 exchange
    478             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>
    481628        </div>
    482629      </div>
  • thai-nexus-logistics/tags/1.5.15/admin/src/pages/ShipmentsPage.jsx

    r3678280 r3705845  
    6161  const [syncing, setSyncing] = useState(false);
    6262  const [syncMessage, setSyncMessage] = useState('');
    63   const [errorType, setErrorType] = useState(null); // 'auth' | 'general'
     63  const [searchQuery, setSearchQuery] = useState('');
     64  const [errorType, setErrorType] = useState(null);
    6465
    6566  useEffect(() => {
    … …  
    181182            </span>
    182183          </h2>
    183          
     184
    184185          <div className="relative group">
    185186            <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)}
    189192              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"
    190193            />
    … …  
    221224                  </td>
    222225                </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 ? (
    224233                <tr>
    225234                  <td colSpan={5} className="py-16 text-center animate-in fade-in zoom-in duration-300">
    … …  
    230239                </tr>
    231240              ) : (
    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}
    235250                    style={{ animationDelay: `${index * 30}ms` }}
    236251                    className="hover:bg-blue-50/20 transition-all group animate-in fade-in slide-in-from-left-1 duration-200"
    … …  
    256271                    </td>
    257272                    <td className="px-5 py-3 text-right">
    258                       <button 
     273                      <button
    259274                        onClick={() => fetchShipmentDetails(shipment.request_number)}
    260275                        className="text-gray-300 hover:text-primary p-1.5 hover:bg-white rounded-md transition-all shadow-none hover:shadow-sm"
    … …  
    277292            </p>
    278293            <div className="flex gap-1.5">
    279               <button 
     294              <button
    280295                disabled={page === 1 || loading}
    281296                onClick={() => setPage(p => p - 1)}
    … …  
    284299                <ChevronLeft size={16} />
    285300              </button>
    286               <button 
     301              <button
    287302                disabled={loading || (total > 0 && page * 10 >= total)}
    288303                onClick={() => setPage(p => p + 1)}
    … …  
    341356                </div>
    342357              </div>
    343               <button 
     358              <button
    344359                onClick={() => setSelectedShipment(null)}
    345360                className="p-2 bg-white/5 hover:bg-white/15 text-white rounded-xl transition-all border border-white/10 group shadow-sm"
    … …  
    371386                          </p>
    372387                          <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" />
    374389                            <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>
    375390                          </p>
    … …  
    390405                          </p>
    391406                          <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" />
    393408                            <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>
    394409                          </p>
    … …  
    416431                      ))}
    417432                    </div>
    418                    
     433
    419434                    <div className="bg-gray-50 p-4 rounded-xl border border-gray-100 flex items-start gap-3">
    420435                       <Tag className="text-secondary/20 w-4 h-4 mt-0.5 shrink-0" />
    … …  
    446461                  Sync tracking
    447462                </button>
    448                 <button 
     463                <button
    449464                  type="button"
    450465                  onClick={() => {
  • thai-nexus-logistics/tags/1.5.15/admin/tailwind.config.js

    r3559867 r3705845  
    99      colors: {
    1010        primary: {
    11           DEFAULT: '#dc2626', // Red
    12           hover: '#b91c1c',
     11          DEFAULT: '#272262',
     12          hover: '#1e1a4d',
    1313        },
    1414        secondary: {
    15           DEFAULT: '#272262', // Dark Blue/Purple
    16           hover: '#1e1a4d',
     15          DEFAULT: '#bf1d2d',
     16          hover: '#9f1824',
    1717        },
    1818      }
  • thai-nexus-logistics/tags/1.5.15/dist/.vite/manifest.json

    r3678280 r3705845  
    11{
    22  "index.html": {
    3     "file": "assets/index-BlRndIDa.js",
     3    "file": "assets/index-BBMv6f4n.js",
    44    "name": "index",
    55    "src": "index.html",
    66    "isEntry": true,
    77    "css": [
    8       "assets/index-DFHBTHlP.css"
     8      "assets/index-CrlahNyv.css"
    99    ]
    1010  }
  • thai-nexus-logistics/tags/1.5.15/dist/index.html

    r3678280 r3705845  
    66    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    77    <title>admin</title>
    8     <script type="module" crossorigin src="/assets/index-BlRndIDa.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">
    1010  </head>
    1111  <body>
  • thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-box-packer.php

    r3678280 r3705845  
    162162     */
    163163    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
    164168        $result = new TNXL_Packing_Result();
    165169        $box_definitions = get_option('tnxl_box_definitions', []);
    … …  
    307311
    308312    /**
     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    /**
    309384     * Validate all items have dimensions and weight
    310385     */
    … …  
    312387        $result = new TNXL_Packing_Result();
    313388        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;
    316391
    317392            $measurements = TNXL_Product::get_shipping_measurements($product);
    … …  
    343418
    344419        foreach ($items as $item_values) {
    345             $product = $item_values['data'];
     420            $product = $item_values['data'] ?? null;
    346421            $qty = $item_values['quantity'];
    347422
    348             if (!$product->needs_shipping()) continue;
     423            if (!$product instanceof WC_Product || !$product->needs_shipping()) continue;
    349424
    350425            $measurements = TNXL_Product::get_shipping_measurements($product);
  • thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-commission.php

    r3560480 r3705845  
    1717    }
    1818
    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() {}
    2320
    2421    /**
    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).
    2823     */
    2924    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) {
    31102            return 0;
    32103        }
    33104
    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) {
    36441            return 0;
    37442        }
    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);
    98459    }
    99460}
    100 
  • thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-order.php

    r3678280 r3705845  
    323323                    'width_cm'          => $box['width'],
    324324                    'height_cm'         => $box['height'],
    325                     'shipment_type'     => 'parcel',
     325                    'is_document'       => !empty($box['is_document']),
     326                    'shipment_type'     => !empty($box['is_document']) ? 'document' : 'parcel',
    326327                    'service_type'      => $service['name'] !== '' ? $service['name'] : $service['id'],
    327328                    'courier_name'      => $service['id'],
  • thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-product.php

    r3678280 r3705845  
    156156        }
    157157
     158        $weight = (float) wc_get_weight($values['weight'], 'kg');
     159
    158160        return array(
    159161            'length' => (float) wc_get_dimension($values['length'], 'cm'),
    160162            'width'  => (float) wc_get_dimension($values['width'], 'cm'),
    161163            'height' => (float) wc_get_dimension($values['height'], 'cm'),
    162             'weight' => (float) wc_get_weight($values['weight'], 'kg'),
     164            'weight' => $weight,
    163165        );
    164166    }
  • thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-rest-api.php

    r3678280 r3705845  
    5454        ));
    5555
     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
    5668        register_rest_route('tnxl/v1', '/search-products', array(
    5769            'methods'             => 'GET',
    … …  
    125137            'api_token' => TNXL_Settings::get_api_token(),
    126138            '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())),
    128140            'disabled_service_ids' => TNXL_Settings::get_disabled_service_ids(),
    129141            '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(),
    130146            'currency_symbol' => $currency_symbol,
    131147            'shipper'   => array(
    … …  
    180196        }
    181197
     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
    182211        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']));
    199213        }
    200214
    … …  
    320334
    321335        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;
    322523    }
    323524
  • thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-settings.php

    r3617587 r3705845  
    1616    public const OPTION_DISABLED_SERVICES = 'tnxl_disabled_service_ids';
    1717    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';
    1822
    1923    public static function maybe_register_defaults(): void {
    … …  
    134138    }
    135139
     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
    136240    /**
    137241     * Feature flags for REST/admin UI.
  • thai-nexus-logistics/tags/1.5.15/includes/class-tnxl-shipping-method.php

    r3678280 r3705845  
    122122
    123123        // 3D Box Packing Implementation
     124        if (!class_exists('TNXL_Box_Packer')) {
     125            return;
     126        }
    124127        $packer = TNXL_Box_Packer::get_instance();
    125128        $packing_result = $packer->pack_items($tnxl_items);
    … …  
    153156
    154157        $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
    161160        $quote_groups = array();
    162161        foreach ($packed_boxes as $box) {
    … …  
    231230        }
    232231
    233         // Only show couriers that could quote ALL boxes
    234232        $box_count = count($packed_boxes);
    235233        $final_quotes_debug = array();
    236234        $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();
    238248        foreach ($all_quotes as $courier => $data) {
    239249            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;
    245271            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);
    254290
    255291            $this->add_rate(array(
    256292                'id'    => $rate_id,
    257                 'label' => $data['display_name'] . ' (' . ($data['estimated_days'] ?: 'TBA') . ' days)',
     293                'label' => $row['display_name'] . ' (' . ($row['estimated_days'] ?: 'TBA') . ' days)',
    258294                'cost'  => $cost,
    259295                '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'],
    262298                    'tnxl_boxes'   => $packed_boxes,
    263299                    'tnxl_breakdown' => array(
    264                         'base_price' => $cost - $commission,
     300                        'base_price' => $quote_store,
    265301                        'commission' => $commission,
    266302                        'total'      => $cost
    … …  
    271307            if ($debug_enabled) {
    272308                $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'],
    275311                    'final_cost' => $cost,
    276                     'days' => $data['estimated_days'] ?: 'TBA',
     312                    'days' => $row['estimated_days'] ?: 'TBA',
    277313                );
    278314            }
  • thai-nexus-logistics/tags/1.5.15/readme.txt

    r3678280 r3705845  
    44Requires at least: 5.8
    55Tested up to: 7.1
    6 Stable tag: 1.5.14
     6Stable tag: 1.5.15
    77Requires PHP: 8.2
    88License: GPLv2 or later
    … …  
    8484== Changelog ==
    8585
     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
    8693= 1.5.14 =
    8794* 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  
    33 * Plugin Name: Thai Nexus Logistics - International Shipping Rates & Currency Converter for WooCommerce
    44 * 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.14
     5 * Version: 1.5.15
    66 * Author: Thai Nexus
    77 * Author URI: https://app.thainexus.co.th
    … …  
    1717
    1818// Release line: stay on 1.5.x (patch) until explicitly approved for 1.6+.
    19 define('TNXL_VERSION', '1.5.14');
     19define('TNXL_VERSION', '1.5.15');
    2020define('TNXL_PLUGIN_DIR', plugin_dir_path(__FILE__));
    2121define('TNXL_PLUGIN_URL', plugin_dir_url(__FILE__));
    … …  
    119119        require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-settings.php';
    120120        TNXL_Settings::init();
     121        require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-service-coverage.php';
    121122        require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-migration.php';
    122123        require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-api.php';
    … …  
    141142        if (!class_exists('DVDoug\BoxPacker\Packer')) {
    142143            add_action('admin_notices', array($this, 'boxpacker_missing_notice'));
    143             return;
    144144        }
    145145
    146146        // Load WooCommerce dependent files
    147147        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        }
    149151        require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-shipping-method.php';
    150152        require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-order.php';
    … …  
    164166        }
    165167
    166         TNXL_Box_Packer::get_instance();
     168        if (class_exists('TNXL_Box_Packer')) {
     169            TNXL_Box_Packer::get_instance();
     170        }
    167171
    168172        $needs_shipping_method = TNXL_Settings::can_fetch_checkout_rates()
    … …  
    219223        if ($this->maybe_hide_shipping_on_cart(true) === false) {
    220224            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            }
    221231        }
    222232
  • thai-nexus-logistics/tags/1.5.15/vendor/composer/installed.json

    r3559867 r3705845  
    136136        }
    137137    ],
    138     "dev": true,
     138    "dev": false,
    139139    "dev-package-names": []
    140140}
  • thai-nexus-logistics/tags/1.5.15/vendor/composer/installed.php

    r3559867 r3705845  
    22    'root' => array(
    33        'name' => 'thainexus/thai-nexus-logistics',
    4         'pretty_version' => 'dev-master',
    5         'version' => 'dev-master',
    6         'reference' => 'c3684859ee067328625aa7437ca24af1d6e4df02',
     4        'pretty_version' => 'dev-main',
     5        'version' => 'dev-main',
     6        'reference' => '4bce3740c4d1d0078e7eb7b82844685a8aa60a97',
    77        'type' => 'library',
    88        'install_path' => __DIR__ . '/../../',
    99        'aliases' => array(),
    10         'dev' => true,
     10        'dev' => false,
    1111    ),
    1212    'versions' => array(
    … …  
    3030        ),
    3131        'thainexus/thai-nexus-logistics' => array(
    32             'pretty_version' => 'dev-master',
    33             'version' => 'dev-master',
    34             'reference' => 'c3684859ee067328625aa7437ca24af1d6e4df02',
     32            'pretty_version' => 'dev-main',
     33            'version' => 'dev-main',
     34            'reference' => '4bce3740c4d1d0078e7eb7b82844685a8aa60a97',
    3535            'type' => 'library',
    3636            'install_path' => __DIR__ . '/../../',
  • thai-nexus-logistics/trunk/admin/package.json

    r3678280 r3705845  
    22  "name": "admin",
    33  "private": true,
    4   "version": "1.5.14",
     4  "version": "1.5.15",
    55  "type": "module",
    66  "scripts": {
  • thai-nexus-logistics/trunk/admin/src/App.jsx

    r3559867 r3705845  
    11import React, { useState, useEffect } from 'react';
    2 import { Settings, Package, LayoutDashboard, Save, ExternalLink, RefreshCw, DollarSign, Terminal } from 'lucide-react';
     2import { Settings, Package, LayoutDashboard, ExternalLink, DollarSign, Terminal, Shield, Box } from 'lucide-react';
    33import SettingsPage from './pages/SettingsPage';
    44import ShipmentsPage from './pages/ShipmentsPage';
    5 import CommissionRulesPage from './pages/CommissionRulesPage';
     5import FeesPage from './pages/FeesPage';
    66import BoxDefinitionsPage from './pages/BoxDefinitionsPage';
    77import DebugLogPage from './pages/DebugLogPage';
     8import PrivacyPage from './pages/PrivacyPage';
     9import ProductsPage from './pages/ProductsPage';
     10import { fetchSettings } from './lib/api';
    811import { clsx } from 'clsx';
    912import { twMerge } from 'tailwind-merge';
    … …  
    1417
    1518const App = () => {
    16   // @ts-ignore
    1719  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  ];
    2044
    2145  return (
    2246    <div className="min-h-screen p-6 md:p-10 max-w-7xl mx-auto font-sans">
    23       {/* Header */}
    2447      <header className="flex flex-col md:flex-row md:items-center justify-between mb-10 gap-4">
    2548        <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" />
    2851            Thai Nexus Logistics
    2952          </h1>
    3053          <p className="text-gray-500 mt-1">Manage your shipping operations and API configurations.</p>
    3154        </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 }) => (
    8458            <button
    85               onClick={() => setActiveTab('debug')}
     59              key={id}
     60              type="button"
     61              onClick={() => setActiveTab(id)}
    8662              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'
    9171              )}
    9272            >
    93               <Terminal size={18} />
    94               Debug
     73              <Icon size={16} />
     74              {label}
    9575            </button>
    96           )}
     76          ))}
    9777        </div>
    9878      </header>
    9979
    100       {/* Main Content */}
    10180      <main className="animate-in fade-in slide-in-from-bottom-4 duration-500">
    10281        {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 />}
    10689        {activeTab === 'debug' && debugEnabled && <DebugLogPage />}
    10790      </main>
    10891
    109       {/* Footer */}
    11092      <footer className="mt-12 py-6 border-t border-gray-200 flex justify-between items-center text-sm text-gray-500">
    11193        <p>&copy; 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"
    11597          rel="noopener noreferrer"
    11698          className="flex items-center gap-1 hover:text-primary transition-colors"
  • thai-nexus-logistics/trunk/admin/src/components/ProductSearchSelect.jsx

    r3617587 r3705845  
    2626
    2727    if (ids.length === 0) {
     28      setSelectedDetails([]);
    2829      return () => {
    2930        cancelled = true;
  • thai-nexus-logistics/trunk/admin/src/pages/SettingsPage.jsx

    r3678280 r3705845  
    33import axios from 'axios';
    44import ProductSearchSelect from '../components/ProductSearchSelect';
     5import CountryMultiSelect from '../components/CountryMultiSelect';
    56
    67const normalizeServiceId = (value) => String(value || '')
    … …  
    1011  .replace(/[^a-z0-9_-]/g, '');
    1112
    12 const SettingsPage = () => {
     13const 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
     25const SettingsPage = ({ onSaved }) => {
    1326  const [loading, setLoading] = useState(false);
    1427  const [saving, setSaving] = useState(false);
    … …  
    2235    disabled_service_ids: [],
    2336    shipping_ineligible_product_ids: [],
     37    product_weight_unit: 'kg',
     38    charge_actual_weight_only: false,
     39    service_coverage: {},
    2440    shipper: {
    2541      name: '',
    … …  
    8197        disabled_service_ids: response.data?.disabled_service_ids || [],
    8298        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 || {},
    83102      });
    84103      if (response.data?.api_token) {
    … …  
    119138  };
    120139
    121   const toggleService = (serviceId, enabled) => {
    122     const id = normalizeServiceId(serviceId);
     140  const toggleService = (service, enabled) => {
     141    const ids = serviceKeys(service);
    123142    setSettings((previous) => ({
    124143      ...previous,
    125144      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])],
    128147    }));
     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    });
    129165  };
    130166
    … …  
    142178      });
    143179      setMessage({ type: 'success', text: 'Settings saved successfully!' });
     180      if (typeof onSaved === 'function') {
     181        onSaved(settings);
     182      }
    144183      if (settings.api_token) {
    145184        void fetchShippingServices();
    … …  
    168207      {/* Feature toggles */}
    169208      <div className="tnxl-card">
    170         <div className="bg-secondary p-5 flex items-center gap-3">
    171           <Zap className="text-primary w-6 h-6" />
     209        <div className="bg-primary p-5 flex items-center gap-3">
     210          <Zap className="text-white w-6 h-6" />
    172211          <h2 className="text-lg font-bold text-white">Automation</h2>
    173212        </div>
    … …  
    227266      {/* API Authentication */}
    228267      <div className="tnxl-card">
    229         <div className="bg-secondary p-5 flex items-center gap-3">
    230           <Key className="text-primary w-6 h-6" />
     268        <div className="bg-primary p-5 flex items-center gap-3">
     269          <Key className="text-white w-6 h-6" />
    231270          <h2 className="text-lg font-bold text-white">API Authentication</h2>
    232271        </div>
    … …  
    271310      </div>
    272311
     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
    273367      {/* Shipping services */}
    274368      <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">
    276370          <div className="flex items-center gap-3">
    277             <Truck className="text-primary w-6 h-6" />
     371            <Truck className="text-white w-6 h-6" />
    278372            <h2 className="text-lg font-bold text-white">Shipping Services</h2>
    279373          </div>
    … …  
    303397          ) : (
    304398            <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>
    306402              <div className="flex gap-4 text-sm">
    307403                <button
    … …  
    324420              </div>
    325421              <div className="divide-y divide-gray-100 border border-gray-100 rounded-xl">
     422                <ul>
    326423                {services.map((service) => {
    327424                  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;
    329432                  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>
    344507                  );
    345508                })}
     509                </ul>
    346510              </div>
    347511            </div>
    … …  
    352516      {/* Product eligibility */}
    353517      <div className="tnxl-card">
    354         <div className="bg-secondary p-5 flex items-center gap-3">
    355           <Package className="text-primary w-6 h-6" />
     518        <div className="bg-primary p-5 flex items-center gap-3">
     519          <Package className="text-white w-6 h-6" />
    356520          <h2 className="text-lg font-bold text-white">Product Shipping Eligibility</h2>
    357521        </div>
    … …  
    380544      {/* Shipper Address */}
    381545      <div className="tnxl-card">
    382         <div className="bg-secondary p-5 flex items-center gap-3">
    383           <MapPin className="text-primary w-6 h-6" />
     546        <div className="bg-primary p-5 flex items-center gap-3">
     547          <MapPin className="text-white w-6 h-6" />
    384548          <h2 className="text-lg font-bold text-white">Store Origin Address</h2>
    385549        </div>
    … …  
    454618                onChange={(e) => setSettings({ ...settings, shipper: { ...settings.shipper, country: e.target.value } })}
    455619              >
    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>
    458625              </select>
    459626            </div>
    460627          </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 exchange
    478             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>
    481628        </div>
    482629      </div>
  • thai-nexus-logistics/trunk/admin/src/pages/ShipmentsPage.jsx

    r3678280 r3705845  
    6161  const [syncing, setSyncing] = useState(false);
    6262  const [syncMessage, setSyncMessage] = useState('');
    63   const [errorType, setErrorType] = useState(null); // 'auth' | 'general'
     63  const [searchQuery, setSearchQuery] = useState('');
     64  const [errorType, setErrorType] = useState(null);
    6465
    6566  useEffect(() => {
    … …  
    181182            </span>
    182183          </h2>
    183          
     184
    184185          <div className="relative group">
    185186            <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)}
    189192              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"
    190193            />
    … …  
    221224                  </td>
    222225                </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 ? (
    224233                <tr>
    225234                  <td colSpan={5} className="py-16 text-center animate-in fade-in zoom-in duration-300">
    … …  
    230239                </tr>
    231240              ) : (
    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}
    235250                    style={{ animationDelay: `${index * 30}ms` }}
    236251                    className="hover:bg-blue-50/20 transition-all group animate-in fade-in slide-in-from-left-1 duration-200"
    … …  
    256271                    </td>
    257272                    <td className="px-5 py-3 text-right">
    258                       <button 
     273                      <button
    259274                        onClick={() => fetchShipmentDetails(shipment.request_number)}
    260275                        className="text-gray-300 hover:text-primary p-1.5 hover:bg-white rounded-md transition-all shadow-none hover:shadow-sm"
    … …  
    277292            </p>
    278293            <div className="flex gap-1.5">
    279               <button 
     294              <button
    280295                disabled={page === 1 || loading}
    281296                onClick={() => setPage(p => p - 1)}
    … …  
    284299                <ChevronLeft size={16} />
    285300              </button>
    286               <button 
     301              <button
    287302                disabled={loading || (total > 0 && page * 10 >= total)}
    288303                onClick={() => setPage(p => p + 1)}
    … …  
    341356                </div>
    342357              </div>
    343               <button 
     358              <button
    344359                onClick={() => setSelectedShipment(null)}
    345360                className="p-2 bg-white/5 hover:bg-white/15 text-white rounded-xl transition-all border border-white/10 group shadow-sm"
    … …  
    371386                          </p>
    372387                          <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" />
    374389                            <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>
    375390                          </p>
    … …  
    390405                          </p>
    391406                          <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" />
    393408                            <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>
    394409                          </p>
    … …  
    416431                      ))}
    417432                    </div>
    418                    
     433
    419434                    <div className="bg-gray-50 p-4 rounded-xl border border-gray-100 flex items-start gap-3">
    420435                       <Tag className="text-secondary/20 w-4 h-4 mt-0.5 shrink-0" />
    … …  
    446461                  Sync tracking
    447462                </button>
    448                 <button 
     463                <button
    449464                  type="button"
    450465                  onClick={() => {
  • thai-nexus-logistics/trunk/admin/tailwind.config.js

    r3559867 r3705845  
    99      colors: {
    1010        primary: {
    11           DEFAULT: '#dc2626', // Red
    12           hover: '#b91c1c',
     11          DEFAULT: '#272262',
     12          hover: '#1e1a4d',
    1313        },
    1414        secondary: {
    15           DEFAULT: '#272262', // Dark Blue/Purple
    16           hover: '#1e1a4d',
     15          DEFAULT: '#bf1d2d',
     16          hover: '#9f1824',
    1717        },
    1818      }
  • thai-nexus-logistics/trunk/dist/.vite/manifest.json

    r3678280 r3705845  
    11{
    22  "index.html": {
    3     "file": "assets/index-BlRndIDa.js",
     3    "file": "assets/index-BBMv6f4n.js",
    44    "name": "index",
    55    "src": "index.html",
    66    "isEntry": true,
    77    "css": [
    8       "assets/index-DFHBTHlP.css"
     8      "assets/index-CrlahNyv.css"
    99    ]
    1010  }
  • thai-nexus-logistics/trunk/dist/index.html

    r3678280 r3705845  
    66    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    77    <title>admin</title>
    8     <script type="module" crossorigin src="/assets/index-BlRndIDa.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">
    1010  </head>
    1111  <body>
  • thai-nexus-logistics/trunk/includes/class-tnxl-box-packer.php

    r3678280 r3705845  
    162162     */
    163163    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
    164168        $result = new TNXL_Packing_Result();
    165169        $box_definitions = get_option('tnxl_box_definitions', []);
    … …  
    307311
    308312    /**
     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    /**
    309384     * Validate all items have dimensions and weight
    310385     */
    … …  
    312387        $result = new TNXL_Packing_Result();
    313388        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;
    316391
    317392            $measurements = TNXL_Product::get_shipping_measurements($product);
    … …  
    343418
    344419        foreach ($items as $item_values) {
    345             $product = $item_values['data'];
     420            $product = $item_values['data'] ?? null;
    346421            $qty = $item_values['quantity'];
    347422
    348             if (!$product->needs_shipping()) continue;
     423            if (!$product instanceof WC_Product || !$product->needs_shipping()) continue;
    349424
    350425            $measurements = TNXL_Product::get_shipping_measurements($product);
  • thai-nexus-logistics/trunk/includes/class-tnxl-commission.php

    r3560480 r3705845  
    1717    }
    1818
    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() {}
    2320
    2421    /**
    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).
    2823     */
    2924    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) {
    31102            return 0;
    32103        }
    33104
    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) {
    36441            return 0;
    37442        }
    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);
    98459    }
    99460}
    100 
  • thai-nexus-logistics/trunk/includes/class-tnxl-order.php

    r3678280 r3705845  
    323323                    'width_cm'          => $box['width'],
    324324                    'height_cm'         => $box['height'],
    325                     'shipment_type'     => 'parcel',
     325                    'is_document'       => !empty($box['is_document']),
     326                    'shipment_type'     => !empty($box['is_document']) ? 'document' : 'parcel',
    326327                    'service_type'      => $service['name'] !== '' ? $service['name'] : $service['id'],
    327328                    'courier_name'      => $service['id'],
  • thai-nexus-logistics/trunk/includes/class-tnxl-product.php

    r3678280 r3705845  
    156156        }
    157157
     158        $weight = (float) wc_get_weight($values['weight'], 'kg');
     159
    158160        return array(
    159161            'length' => (float) wc_get_dimension($values['length'], 'cm'),
    160162            'width'  => (float) wc_get_dimension($values['width'], 'cm'),
    161163            'height' => (float) wc_get_dimension($values['height'], 'cm'),
    162             'weight' => (float) wc_get_weight($values['weight'], 'kg'),
     164            'weight' => $weight,
    163165        );
    164166    }
  • thai-nexus-logistics/trunk/includes/class-tnxl-rest-api.php

    r3678280 r3705845  
    5454        ));
    5555
     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
    5668        register_rest_route('tnxl/v1', '/search-products', array(
    5769            'methods'             => 'GET',
    … …  
    125137            'api_token' => TNXL_Settings::get_api_token(),
    126138            '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())),
    128140            'disabled_service_ids' => TNXL_Settings::get_disabled_service_ids(),
    129141            '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(),
    130146            'currency_symbol' => $currency_symbol,
    131147            'shipper'   => array(
    … …  
    180196        }
    181197
     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
    182211        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']));
    199213        }
    200214
    … …  
    320334
    321335        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;
    322523    }
    323524
  • thai-nexus-logistics/trunk/includes/class-tnxl-settings.php

    r3617587 r3705845  
    1616    public const OPTION_DISABLED_SERVICES = 'tnxl_disabled_service_ids';
    1717    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';
    1822
    1923    public static function maybe_register_defaults(): void {
    … …  
    134138    }
    135139
     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
    136240    /**
    137241     * Feature flags for REST/admin UI.
  • thai-nexus-logistics/trunk/includes/class-tnxl-shipping-method.php

    r3678280 r3705845  
    122122
    123123        // 3D Box Packing Implementation
     124        if (!class_exists('TNXL_Box_Packer')) {
     125            return;
     126        }
    124127        $packer = TNXL_Box_Packer::get_instance();
    125128        $packing_result = $packer->pack_items($tnxl_items);
    … …  
    153156
    154157        $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
    161160        $quote_groups = array();
    162161        foreach ($packed_boxes as $box) {
    … …  
    231230        }
    232231
    233         // Only show couriers that could quote ALL boxes
    234232        $box_count = count($packed_boxes);
    235233        $final_quotes_debug = array();
    236234        $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();
    238248        foreach ($all_quotes as $courier => $data) {
    239249            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;
    245271            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);
    254290
    255291            $this->add_rate(array(
    256292                'id'    => $rate_id,
    257                 'label' => $data['display_name'] . ' (' . ($data['estimated_days'] ?: 'TBA') . ' days)',
     293                'label' => $row['display_name'] . ' (' . ($row['estimated_days'] ?: 'TBA') . ' days)',
    258294                'cost'  => $cost,
    259295                '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'],
    262298                    'tnxl_boxes'   => $packed_boxes,
    263299                    'tnxl_breakdown' => array(
    264                         'base_price' => $cost - $commission,
     300                        'base_price' => $quote_store,
    265301                        'commission' => $commission,
    266302                        'total'      => $cost
    … …  
    271307            if ($debug_enabled) {
    272308                $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'],
    275311                    'final_cost' => $cost,
    276                     'days' => $data['estimated_days'] ?: 'TBA',
     312                    'days' => $row['estimated_days'] ?: 'TBA',
    277313                );
    278314            }
  • thai-nexus-logistics/trunk/readme.txt

    r3678280 r3705845  
    44Requires at least: 5.8
    55Tested up to: 7.1
    6 Stable tag: 1.5.14
     6Stable tag: 1.5.15
    77Requires PHP: 8.2
    88License: GPLv2 or later
    … …  
    8484== Changelog ==
    8585
     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
    8693= 1.5.14 =
    8794* 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  
    33 * Plugin Name: Thai Nexus Logistics - International Shipping Rates & Currency Converter for WooCommerce
    44 * 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.14
     5 * Version: 1.5.15
    66 * Author: Thai Nexus
    77 * Author URI: https://app.thainexus.co.th
    … …  
    1717
    1818// Release line: stay on 1.5.x (patch) until explicitly approved for 1.6+.
    19 define('TNXL_VERSION', '1.5.14');
     19define('TNXL_VERSION', '1.5.15');
    2020define('TNXL_PLUGIN_DIR', plugin_dir_path(__FILE__));
    2121define('TNXL_PLUGIN_URL', plugin_dir_url(__FILE__));
    … …  
    119119        require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-settings.php';
    120120        TNXL_Settings::init();
     121        require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-service-coverage.php';
    121122        require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-migration.php';
    122123        require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-api.php';
    … …  
    141142        if (!class_exists('DVDoug\BoxPacker\Packer')) {
    142143            add_action('admin_notices', array($this, 'boxpacker_missing_notice'));
    143             return;
    144144        }
    145145
    146146        // Load WooCommerce dependent files
    147147        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        }
    149151        require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-shipping-method.php';
    150152        require_once TNXL_PLUGIN_DIR . 'includes/class-tnxl-order.php';
    … …  
    164166        }
    165167
    166         TNXL_Box_Packer::get_instance();
     168        if (class_exists('TNXL_Box_Packer')) {
     169            TNXL_Box_Packer::get_instance();
     170        }
    167171
    168172        $needs_shipping_method = TNXL_Settings::can_fetch_checkout_rates()
    … …  
    219223        if ($this->maybe_hide_shipping_on_cart(true) === false) {
    220224            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            }
    221231        }
    222232
  • thai-nexus-logistics/trunk/vendor/composer/installed.json

    r3559867 r3705845  
    136136        }
    137137    ],
    138     "dev": true,
     138    "dev": false,
    139139    "dev-package-names": []
    140140}
  • thai-nexus-logistics/trunk/vendor/composer/installed.php

    r3559867 r3705845  
    22    'root' => array(
    33        'name' => 'thainexus/thai-nexus-logistics',
    4         'pretty_version' => 'dev-master',
    5         'version' => 'dev-master',
    6         'reference' => 'c3684859ee067328625aa7437ca24af1d6e4df02',
     4        'pretty_version' => 'dev-main',
     5        'version' => 'dev-main',
     6        'reference' => '4bce3740c4d1d0078e7eb7b82844685a8aa60a97',
    77        'type' => 'library',
    88        'install_path' => __DIR__ . '/../../',
    99        'aliases' => array(),
    10         'dev' => true,
     10        'dev' => false,
    1111    ),
    1212    'versions' => array(
    … …  
    3030        ),
    3131        'thainexus/thai-nexus-logistics' => array(
    32             'pretty_version' => 'dev-master',
    33             'version' => 'dev-master',
    34             'reference' => 'c3684859ee067328625aa7437ca24af1d6e4df02',
     32            'pretty_version' => 'dev-main',
     33            'version' => 'dev-main',
     34            'reference' => '4bce3740c4d1d0078e7eb7b82844685a8aa60a97',
    3535            'type' => 'library',
    3636            'install_path' => __DIR__ . '/../../',
Note: See TracChangeset for help on using the changeset viewer.