| @@ -39,10 +39,11 @@ | ||
| 39 | 39 | ]; |
| 40 | 40 | |
| 41 | 41 | public static function getCodeByName(string $persianName): ?string |
| 42 | 42 | { |
| 43 | + $needle = self::normalizePersian($persianName); | |
| 43 | 44 | foreach (self::$provinces as $code => $name) { |
| 44 | - if (trim($name) === trim($persianName)) return $code; | |
| 45 | + if (self::normalizePersian($name) === $needle) return $code; | |
| 45 | 46 | } |
| 46 | 47 | |
| 47 | 48 | return null; |
| 48 | 49 | } |
| @@ -63,12 +64,74 @@ | ||
| 63 | 64 | ); |
| 64 | 65 | } |
| 65 | 66 | |
| 66 | 67 | /** |
| 67 | - * Set order address with PWS compatibility | |
| 68 | - * If PWS is active, sets Tapin state/city IDs as meta data | |
| 69 | - * Otherwise, uses normal WooCommerce state/city fields. | |
| 68 | + * Register IR states with WooCommerce when no other plugin has done so. | |
| 69 | + * WooCommerce won't persist `billing_state` for a country that has registered | |
| 70 | + * states unless the value matches a registered key — and it ships zero states | |
| 71 | + * for IR. Without this filter, set_billing_state('یزد') is silently dropped | |
| 72 | + * on stores that have no Persian shipping/state plugin active. | |
| 73 | + * | |
| 74 | + * Safe to call multiple times; if another plugin already provides IR states | |
| 75 | + * we leave them alone. | |
| 70 | 76 | */ |
| 77 | + public static function registerIranStatesFilter(): void | |
| 78 | + { | |
| 79 | + static $registered = false; | |
| 80 | + if ($registered) return; | |
| 81 | + $registered = true; | |
| 82 | + | |
| 83 | + add_filter('woocommerce_states', function ($states) { | |
| 84 | + if (!empty($states['IR'])) return $states; | |
| 85 | + | |
| 86 | + $states['IR'] = self::$provinces; | |
| 87 | + | |
| 88 | + return $states; | |
| 89 | + }, 5); | |
| 90 | + } | |
| 91 | + | |
| 92 | + /** | |
| 93 | + * Whether PWS is running in Tapin mode (state IDs come from tapin.json, 1..31). | |
| 94 | + * Otherwise PWS falls back to the `state_city` taxonomy term IDs. | |
| 95 | + */ | |
| 96 | + private static function isTapinEnabled(): bool | |
| 97 | + { | |
| 98 | + if (!class_exists('\PWS_Tapin')) return false; | |
| 99 | + if (!method_exists('\PWS_Tapin', 'is_enable')) return false; | |
| 100 | + | |
| 101 | + return (bool) call_user_func(['\PWS_Tapin', 'is_enable']); | |
| 102 | + } | |
| 103 | + | |
| 104 | + /** | |
| 105 | + * Normalize Persian/Arabic text so name comparisons survive small variations | |
| 106 | + * (Arabic Yeh/Kaf, ZWNJ / half-space, repeated whitespace, BOM, etc.). | |
| 107 | + */ | |
| 108 | + private static function normalizePersian(?string $value): string | |
| 109 | + { | |
| 110 | + if ($value === null) return ''; | |
| 111 | + | |
| 112 | + $value = str_replace( | |
| 113 | + ["\xEF\xBB\xBF", "\xE2\x80\x8C", "\xC2\xA0", 'ي', 'ك', 'ﮐ', 'ﮏ', 'ﯼ', 'ﯽ'], | |
| 114 | + ['', ' ', ' ', 'ی', 'ک', 'ک', 'ک', 'ی', 'ی'], | |
| 115 | + $value | |
| 116 | + ); | |
| 117 | + | |
| 118 | + $value = preg_replace('/\s+/u', ' ', $value); | |
| 119 | + | |
| 120 | + return trim((string) $value); | |
| 121 | + } | |
| 122 | + | |
| 123 | + /** | |
| 124 | + * Set order address with PWS compatibility. | |
| 125 | + * | |
| 126 | + * - When PWS+Tapin is active: store Tapin numeric state/city IDs in both the | |
| 127 | + * WC billing_state/billing_city fields (so WC's state lookup resolves the | |
| 128 | + * Persian name correctly) AND in PWS's `_billing_state_id`/`_billing_city_id` | |
| 129 | + * meta (which Tapin label submission reads). | |
| 130 | + * - When PWS is active without Tapin: resolve the `state_city` taxonomy | |
| 131 | + * term IDs and use those. | |
| 132 | + * - Otherwise fall back to plain Persian names. | |
| 133 | + */ | |
| 71 | 134 | public static function setOrderAddress($order, $addressData, $type = 'billing') |
| 72 | 135 | { |
| 73 | 136 | if (!$order || !isset($addressData['province']) || !isset($addressData['city'])) { |
| 74 | 137 | return; |
| @@ -73,45 +136,59 @@ | ||
| 73 | 136 | if (!$order || !isset($addressData['province']) || !isset($addressData['city'])) { |
| 74 | 137 | return; |
| 75 | 138 | } |
| 76 | 139 | |
| 77 | - $province = trim($addressData['province']); | |
| 78 | - $city = trim($addressData['city']); | |
| 140 | + $province = trim((string) $addressData['province']); | |
| 141 | + $city = trim((string) $addressData['city']); | |
| 79 | 142 | |
| 143 | + if ($province === '' && $city === '') { | |
| 144 | + return; | |
| 145 | + } | |
| 146 | + | |
| 147 | + // WooCommerce validates billing_state against the registered keys of | |
| 148 | + // states['IR'], so the state value must be a valid KEY (numeric ID in | |
| 149 | + // PWS, 3-letter code without PWS). The city field has no such | |
| 150 | + // validation, so we always keep the human-readable Persian name there — | |
| 151 | + // otherwise the admin order screen would render the raw term ID. | |
| 152 | + $stateValue = $province; | |
| 153 | + $cityValue = $city; | |
| 154 | + $stateMetaId = null; | |
| 155 | + $cityMetaId = null; | |
| 156 | + | |
| 80 | 157 | if (self::isPWSActive()) { |
| 81 | - // PWS is active - set Tapin IDs | |
| 82 | - $state_id = self::getTapinStateIdByName($province); | |
| 83 | - $city_id = self::getTapinCityIdByName($city, $state_id); | |
| 158 | + if (self::isTapinEnabled()) { | |
| 159 | + $stateMetaId = self::getTapinStateIdByName($province); | |
| 160 | + $cityMetaId = self::getTapinCityIdByName($city, $stateMetaId); | |
| 161 | + } else { | |
| 162 | + $stateMetaId = self::getPwsStateTermIdByName($province); | |
| 163 | + $cityMetaId = self::getPwsCityTermIdByName($city, $stateMetaId); | |
| 164 | + } | |
| 84 | 165 | |
| 85 | - if ($state_id) { | |
| 86 | - $order->update_meta_data("_{$type}_state_id", $state_id); | |
| 87 | - // Also set the state name for WooCommerce | |
| 88 | - if ($type === 'billing') { | |
| 89 | - $order->set_billing_state($province); | |
| 90 | - } else { | |
| 91 | - $order->set_shipping_state($province); | |
| 92 | - } | |
| 166 | + if ($stateMetaId) $stateValue = (string) $stateMetaId; | |
| 167 | + } else { | |
| 168 | + // No PWS — make sure WC has IR states registered so it doesn't drop | |
| 169 | + // the billing_state on save, then store the code instead of the name. | |
| 170 | + self::registerIranStatesFilter(); | |
| 171 | + $code = self::getCodeByName($province); | |
| 172 | + if ($code) { | |
| 173 | + $stateValue = $code; | |
| 93 | 174 | } |
| 175 | + } | |
| 94 | 176 | |
| 95 | - if ($city_id) { | |
| 96 | - $order->update_meta_data("_{$type}_city_id", $city_id); | |
| 97 | - // Also set the city name for WooCommerce | |
| 98 | - if ($type === 'billing') { | |
| 99 | - $order->set_billing_city($city); | |
| 100 | - } else { | |
| 101 | - $order->set_shipping_city($city); | |
| 102 | - } | |
| 103 | - } | |
| 177 | + if ($type === 'billing') { | |
| 178 | + $order->set_billing_state($stateValue); | |
| 179 | + $order->set_billing_city($cityValue); | |
| 104 | 180 | } else { |
| 105 | - // PWS is not active - use normal WooCommerce fields | |
| 106 | - if ($type === 'billing') { | |
| 107 | - $order->set_billing_state($province); | |
| 108 | - $order->set_billing_city($city); | |
| 109 | - } else { | |
| 110 | - $order->set_shipping_state($province); | |
| 111 | - $order->set_shipping_city($city); | |
| 112 | - } | |
| 181 | + $order->set_shipping_state($stateValue); | |
| 182 | + $order->set_shipping_city($cityValue); | |
| 113 | 183 | } |
| 184 | + | |
| 185 | + if ($stateMetaId) { | |
| 186 | + $order->update_meta_data("_{$type}_state_id", $stateMetaId); | |
| 187 | + } | |
| 188 | + if ($cityMetaId) { | |
| 189 | + $order->update_meta_data("_{$type}_city_id", $cityMetaId); | |
| 190 | + } | |
| 114 | 191 | } |
| 115 | 192 | |
| 116 | 193 | /** |
| 117 | 194 | * Get Tapin state ID by Persian name. |
| @@ -122,11 +199,15 @@ | ||
| 122 | 199 | |
| 123 | 200 | $states = call_user_func(['\PWS_Tapin', 'states']); |
| 124 | 201 | if (!$states) return null; |
| 125 | 202 | |
| 126 | - $stateName = trim($stateName); | |
| 203 | + $needle = self::normalizePersian($stateName); | |
| 204 | + if ($needle === '') return null; | |
| 205 | + | |
| 127 | 206 | foreach ($states as $state_id => $state_title) { |
| 128 | - if (trim($state_title) === $stateName) return (int) $state_id; | |
| 207 | + if (self::normalizePersian((string) $state_title) === $needle) { | |
| 208 | + return (int) $state_id; | |
| 209 | + } | |
| 129 | 210 | } |
| 130 | 211 | |
| 131 | 212 | return null; |
| 132 | 213 | } |
| @@ -132,19 +213,90 @@ | ||
| 132 | 213 | } |
| 133 | 214 | |
| 134 | 215 | /** |
| 135 | 216 | * Get Tapin city ID by Persian name. |
| 217 | + * If $stateId is provided the search is constrained to that state — this | |
| 218 | + * avoids the failure mode where a null state_id lets PWS_Tapin::cities() | |
| 219 | + * search across ALL cities and return the wrong match. | |
| 136 | 220 | */ |
| 137 | 221 | private static function getTapinCityIdByName($cityName, $stateId = null): ?int |
| 138 | 222 | { |
| 139 | 223 | if (!class_exists('\PWS_Tapin')) return null; |
| 140 | 224 | |
| 141 | - $cities = call_user_func(['\PWS_Tapin', 'cities'], $stateId); | |
| 142 | - if (!$cities) return null; | |
| 225 | + $needle = self::normalizePersian($cityName); | |
| 226 | + if ($needle === '') return null; | |
| 143 | 227 | |
| 144 | - $cityName = trim($cityName); | |
| 145 | - foreach ($cities as $city_id => $city_title) { | |
| 146 | - if (trim($city_title) === $cityName) return (int) $city_id; | |
| 228 | + if ($stateId) { | |
| 229 | + $cities = call_user_func(['\PWS_Tapin', 'cities'], $stateId); | |
| 230 | + if (!$cities) return null; | |
| 231 | + | |
| 232 | + foreach ($cities as $city_id => $city_title) { | |
| 233 | + if (self::normalizePersian((string) $city_title) === $needle) { | |
| 234 | + return (int) $city_id; | |
| 235 | + } | |
| 236 | + } | |
| 237 | + | |
| 238 | + return null; | |
| 239 | + } | |
| 240 | + | |
| 241 | + return null; | |
| 242 | + } | |
| 243 | + | |
| 244 | + /** | |
| 245 | + * Resolve a `state_city` taxonomy term ID for the given province name | |
| 246 | + * (used when PWS is active but Tapin is not). | |
| 247 | + */ | |
| 248 | + private static function getPwsStateTermIdByName($stateName): ?int | |
| 249 | + { | |
| 250 | + if (!taxonomy_exists('state_city')) return null; | |
| 251 | + | |
| 252 | + $needle = self::normalizePersian($stateName); | |
| 253 | + if ($needle === '') return null; | |
| 254 | + | |
| 255 | + $terms = get_terms([ | |
| 256 | + 'taxonomy' => 'state_city', | |
| 257 | + 'hide_empty' => false, | |
| 258 | + 'parent' => 0, | |
| 259 | + ]); | |
| 260 | + | |
| 261 | + if (is_wp_error($terms) || empty($terms)) return null; | |
| 262 | + | |
| 263 | + foreach ($terms as $term) { | |
| 264 | + if (self::normalizePersian((string) $term->name) === $needle) { | |
| 265 | + return (int) $term->term_id; | |
| 266 | + } | |
| 267 | + } | |
| 268 | + | |
| 269 | + return null; | |
| 270 | + } | |
| 271 | + | |
| 272 | + /** | |
| 273 | + * Resolve a `state_city` taxonomy term ID for the given city name. | |
| 274 | + * Scoped to the province term when available. | |
| 275 | + */ | |
| 276 | + private static function getPwsCityTermIdByName($cityName, $stateTermId = null): ?int | |
| 277 | + { | |
| 278 | + if (!taxonomy_exists('state_city')) return null; | |
| 279 | + | |
| 280 | + $needle = self::normalizePersian($cityName); | |
| 281 | + if ($needle === '') return null; | |
| 282 | + | |
| 283 | + $args = [ | |
| 284 | + 'taxonomy' => 'state_city', | |
| 285 | + 'hide_empty' => false, | |
| 286 | + ]; | |
| 287 | + | |
| 288 | + if ($stateTermId) { | |
| 289 | + $args['parent'] = $stateTermId; | |
| 290 | + } | |
| 291 | + | |
| 292 | + $terms = get_terms($args); | |
| 293 | + if (is_wp_error($terms) || empty($terms)) return null; | |
| 294 | + | |
| 295 | + foreach ($terms as $term) { | |
| 296 | + if (self::normalizePersian((string) $term->name) === $needle) { | |
| 297 | + return (int) $term->term_id; | |
| 298 | + } | |
| 147 | 299 | } |
| 148 | 300 | |
| 149 | 301 | return null; |
| 150 | 302 | } |