> $fields Address fields. * * @return array> * * @internal Hooked callback. */ public function addressFields( array $fields ): array { if ( ! isset( $fields['city'] ) ) { return $fields; } self::$needsAssets = true; $fields['city']['type'] = 'select'; $fields['city']['class'] = array_merge( (array) ( $fields['city']['class'] ?? [] ), [ 'rahkar-city-field' ] ); $fields['city']['options'] = [ '' => __( 'ابتدا استان را انتخاب کنید', 'rahkar-toolkit' ) ]; $fields['city']['required'] = (bool) Settings::get( IranLocationsModule::SLUG . '.city_required', true ); // The city list depends on the selected state, so the state field has to // come first for the form to make sense when read top to bottom. if ( isset( $fields['state']['priority'], $fields['city']['priority'] ) && $fields['state']['priority'] > $fields['city']['priority'] ) { [ $fields['state']['priority'], $fields['city']['priority'] ] = [ $fields['city']['priority'], $fields['state']['priority'] ]; } return $fields; } /** * Seed the city select with the saved value. * * A select can only show an option it contains. Without this, returning to a * saved address renders an empty city box and the customer has to re-pick a * value they already chose. * * @param array> $fields Checkout fields. * * @return array> * * @internal Hooked callback. */ public function seedSavedCity( array $fields ): array { foreach ( [ 'billing', 'shipping' ] as $group ) { $cityKey = "{$group}_city"; $stateKey = "{$group}_state"; if ( ! isset( $fields[ $group ][ $cityKey ] ) ) { continue; } // The shipping block is hidden until the customer opts into a separate // delivery address. Seeding it regardless would put a second province's // worth of options — around 8KB — into every checkout page for a form // most customers never open. When they do open it, the script fills it. if ( 'shipping' === $group && ! $this->shippingAddressInUse() ) { continue; } $state = $this->currentValue( $stateKey ); $city = $this->currentValue( $cityKey ); if ( '' === $state || ! LocationRepository::isProvince( $state ) ) { continue; } $options = [ '' => __( 'انتخاب شهر', 'rahkar-toolkit' ) ]; foreach ( LocationRepository::cities( $state ) as $name ) { $options[ $name ] = $name; } // Keep an unrecognised saved value selectable rather than silently // discarding an address the customer entered before this module ran. if ( '' !== $city && ! isset( $options[ $city ] ) ) { $options[ $city ] = $city; } $fields[ $group ][ $cityKey ]['options'] = $options; // Tell the script which province these options belong to, so it can // skip re-fetching a list the page already carries. $fields[ $group ][ $cityKey ]['custom_attributes']['data-rahkar-province'] = $state; } return $fields; } /** * Is the customer actually using a separate shipping address? */ private function shippingAddressInUse(): bool { if ( ! function_exists( 'WC' ) || ! WC()->cart ) { return false; } if ( ! WC()->cart->needs_shipping_address() ) { return false; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only hint for rendering; the value is re-validated on submit. if ( isset( $_POST['ship_to_different_address'] ) ) { return true; } return (bool) get_user_meta( get_current_user_id(), 'shipping_city', true ); } /** * Reject a city that does not belong to the selected province. * * The browser's selection is a suggestion; this is the check that counts. * * @param array $data Posted checkout data. * @param \WP_Error $errors Error collector. * * @internal Hooked callback. */ public function validate( array $data, \WP_Error $errors ): void { if ( ! Settings::get( IranLocationsModule::SLUG . '.city_required', true ) ) { return; } $group = ! empty( $data['ship_to_different_address'] ) ? 'shipping' : 'billing'; $country = (string) ( $data[ "{$group}_country" ] ?? '' ); $state = (string) ( $data[ "{$group}_state" ] ?? '' ); $city = (string) ( $data[ "{$group}_city" ] ?? '' ); if ( 'IR' !== $country || '' === $state ) { return; } if ( '' === trim( $city ) ) { $errors->add( 'rahkar_city_required', __( 'لطفاً شهر را انتخاب کنید.', 'rahkar-toolkit' ) ); return; } if ( ! LocationRepository::isValidCity( $state, $city ) ) { $errors->add( 'rahkar_city_invalid', sprintf( /* translators: 1: city name, 2: province name */ __( 'شهر «%1$s» در استان %2$s یافت نشد. لطفاً یکی از شهرهای فهرست را انتخاب کنید.', 'rahkar-toolkit' ), $city, LocationRepository::provinceName( $state ) ) ); } } /** * Load the city selector assets, on pages that render an address form only. * * @internal Hooked callback. */ public function enqueue(): void { if ( ! self::$needsAssets && ! $this->isAddressPage() ) { return; } $version = LocationRepository::datasetVersion(); // `selectWoo` is WooCommerce's own select2 build, already enqueued on any // checkout page for the country and state fields. Declaring it as a // dependency downloads nothing extra — it only guarantees the library is // parsed before this script tries to enhance the city field with it, so // the two dropdowns end up looking and behaving identically. wp_enqueue_script( 'rahkar-iran-cities', RAHKAR_WP_URL . 'assets/dist/iran-cities.js', [ 'jquery', 'selectWoo' ], RAHKAR_WP_VERSION, true ); wp_add_inline_script( 'rahkar-iran-cities', 'window.rahkarCities=' . wp_json_encode( [ // Static JSON served straight from disk — no PHP runs to // answer it, and the browser caches it across page views. 'baseUrl' => RAHKAR_WP_URL . 'assets/data/ir/', 'version' => $version, // Admin additions ride along inline. There are only ever a // handful, so this costs bytes measured in tens rather than // invalidating the static files. 'custom' => LocationRepository::allCustomCities(), 'placeholder' => __( 'انتخاب شهر', 'rahkar-toolkit' ), 'empty' => __( 'ابتدا استان را انتخاب کنید', 'rahkar-toolkit' ), ], JSON_UNESCAPED_UNICODE ) . ';', 'before' ); } /** * Does this page render an address form? */ private function isAddressPage(): bool { return function_exists( 'is_checkout' ) && ( is_checkout() || is_account_page() ); } /** * Read a field's current value from the customer session. * * @param string $key Field key, e.g. `billing_state`. */ private function currentValue( string $key ): string { if ( ! function_exists( 'WC' ) || ! WC()->checkout() ) { return ''; } return (string) WC()->checkout()->get_value( $key ); } }