handleSubmission(); $provinces = LocationRepository::provinces(); $selected = $this->selectedProvince( $provinces ); $base = LocationRepository::baseCities( $selected ); $custom = LocationRepository::customCities( $selected ); Ui::pageOpen( __( 'شهرهای ایران', 'rahkar-toolkit' ), sprintf( /* translators: 1: number of provinces, 2: number of cities */ __( 'پایگاه داده شامل %1$s استان و %2$s شهر است. اگر شهری از قلم افتاده، از این صفحه اضافه‌اش کنید — با به‌روزرسانی افزونه پاک نمی‌شود.', 'rahkar-toolkit' ), number_format_i18n( count( $provinces ) ), number_format_i18n( $this->totalCities( $provinces ) ) ), Help::link( IranLocationsModule::SLUG, __( 'استان و شهرهای ایران', 'rahkar-toolkit' ) ) ); if ( '' !== $notice['message'] ) { echo wp_kses( Ui::notice( $notice['type'], $notice['message'] ), Ui::allowedHtml() ); } $this->renderPicker( $provinces, $selected ); $this->renderAddForm( $provinces, $selected ); $this->renderCities( $provinces, $selected, $base, $custom ); Ui::confirmModal(); Ui::enqueueConfirm(); Ui::pageClose(); } /** * The province selector. * * @param array $provinces Province code to name. * @param string $selected Selected province code. */ private function renderPicker( array $provinces, string $selected ): void { Ui::formOpen( 'get' ); printf( '', esc_attr( self::SLUG ) ); Ui::toolbarOpen( false ); echo wp_kses( Ui::toolbarLabel( __( 'استان', 'rahkar-toolkit' ) ), Ui::allowedHtml() ); // Submitting on change is the whole interaction; the noscript button is // what keeps the screen usable when that never fires. echo wp_kses( Ui::select( 'province', $provinces, $selected, [ 'id' => 'rahkar-province', 'data-rahkar-autosubmit' => '', ], true ), Ui::allowedHtml() ); echo ''; Ui::toolbarClose(); Ui::formClose(); } /** * The add-a-city form. * * @param array $provinces Province code to name. * @param string $selected Selected province code. */ private function renderAddForm( array $provinces, string $selected ): void { Ui::panelOpen( __( 'افزودن شهر', 'rahkar-toolkit' ), '', 'plus' ); Ui::formOpen(); wp_nonce_field( self::NONCE ); printf( '', esc_attr( $selected ) ); Ui::toolbarOpen( false ); echo wp_kses( Ui::input( 'text', 'city_name', '', [ 'required' => '', 'placeholder' => __( 'نام شهر جدید', 'rahkar-toolkit' ), ] ), Ui::allowedHtml() ); echo wp_kses( Ui::button( sprintf( /* translators: %s: province name */ __( 'افزودن به %s', 'rahkar-toolkit' ), wp_kses( $provinces[ $selected ] ?? '', Ui::allowedHtml() ) ), 'primary', [ 'type' => 'submit' ], '', 'plus' ), Ui::allowedHtml() ); Ui::toolbarClose(); Ui::formClose(); Ui::panelClose(); } /** * Every city of the province, in one alphabetical list. * * The two lists this replaces — the plugin's own and the site's additions — * put the same kind of thing in two places, and a province with a hundred * and fifty names as loose chips is a wall to read rather than a list to * scan. One list, in columns, alphabetical, with a search box: the ones this * site added carry a badge and their remove button, so nothing is lost by * merging them in. * * @param array $provinces Province code to name. * @param string $selected Selected province code. * @param list $base Cities that ship with the plugin. * @param list $custom Cities this site added. */ private function renderCities( array $provinces, string $selected, array $base, array $custom ): void { $all = self::sorted( array_merge( $base, $custom ) ); Ui::panelOpen( sprintf( /* translators: 1: province name, 2: number of cities */ __( 'شهرهای %1$s (%2$s)', 'rahkar-toolkit' ), $provinces[ $selected ] ?? '', number_format_i18n( count( $all ) ) ), [] === $custom ? __( 'این فهرست بخشی از افزونه است و با هر به‌روزرسانی دوباره ساخته می‌شود. فقط شهرهایی که خودتان اضافه کرده‌اید قابل حذف‌اند.', 'rahkar-toolkit' ) : sprintf( /* translators: %s: number of cities added by this site */ __( 'فهرست افزونه با هر به‌روزرسانی دوباره ساخته می‌شود. %s شهر را خودتان اضافه کرده‌اید؛ همان‌ها نشان دارند و قابل حذف‌اند.', 'rahkar-toolkit' ), number_format_i18n( count( $custom ) ) ), 'truck' ); if ( [] === $all ) { echo wp_kses( Ui::emptyState( __( 'شهری برای این استان ثبت نشده است.', 'rahkar-toolkit' ), '', 'truck' ), Ui::allowedHtml() ); Ui::panelClose(); return; } Ui::toolbarOpen( false ); echo wp_kses( Ui::input( 'search', '', '', [ 'placeholder' => __( 'جستجوی شهر…', 'rahkar-toolkit' ), 'data-rahkar-city-search' => '', 'aria-label' => __( 'جستجوی شهر', 'rahkar-toolkit' ), ] ), Ui::allowedHtml() ); printf( '%s', esc_html( sprintf( /* translators: %s: number of cities */ __( '%s شهر', 'rahkar-toolkit' ), number_format_i18n( count( $all ) ) ) ) ); Ui::toolbarClose(); echo '
    '; foreach ( $all as $city ) { $this->renderCity( (string) $city, in_array( $city, $custom, true ), $selected ); } echo '
'; printf( '', esc_html__( 'شهری با این نام در این استان نیست. می‌توانید از بالا اضافه‌اش کنید.', 'rahkar-toolkit' ) ); Ui::panelClose(); wp_enqueue_script( 'rahkar-wp-cities', RAHKAR_WP_URL . 'assets/dist/admin-cities.js', [], RAHKAR_WP_VERSION, true ); } /** * One city: a name, and for one this site added, a badge and a way to remove it. * * @param string $city City name. * @param bool $isCustom Whether this site added it. * @param string $selected Selected province code. */ private function renderCity( string $city, bool $isCustom, string $selected ): void { $remove = ''; if ( $isCustom ) { $remove = Ui::badge( __( 'افزوده‌شده', 'rahkar-toolkit' ), 'brand' ) . sprintf( '
%s%s
', wp_kses( Ui::confirmAttrs( __( 'حذف شهر', 'rahkar-toolkit' ), sprintf( /* translators: %s: city name */ __( '«%s» از فهرست شهرهای این استان برداشته می‌شود. می‌توانید دوباره اضافه‌اش کنید.', 'rahkar-toolkit' ), $city ), __( 'حذف', 'rahkar-toolkit' ), 'warning' ), Ui::allowedHtml() ), wp_kses( wp_nonce_field( self::NONCE, '_wpnonce', true, false ), Ui::allowedHtml() ), esc_attr( $selected ), esc_attr( $city ), wp_kses( Ui::button( '', 'secondary', [ 'type' => 'submit', 'aria-label' => __( 'حذف', 'rahkar-toolkit' ) ], '', 'close' ), Ui::allowedHtml() ) ); } printf( '
  • %s%s
  • ', $isCustom ? ' is-custom' : '', esc_attr( self::searchKey( $city ) ), esc_html( $city ), wp_kses( $remove, Ui::allowedHtml() ) ); } /** * City names in Persian alphabetical order. * * `Collator` knows that پ follows ب and گ follows ک; a plain string sort * puts them where their code points fall, which is neither alphabetical nor * what anyone scanning the list expects. Hosts without the intl extension * fall back to that plain sort, which is still stable and close enough. * * @param list $cities City names. * * @return list */ private static function sorted( array $cities ): array { $cities = array_values( array_unique( $cities ) ); if ( class_exists( 'Collator' ) ) { $collator = new \Collator( 'fa_IR' ); if ( $collator->sort( $cities ) ) { return $cities; } } sort( $cities, SORT_STRING ); return $cities; } /** * A city's name reduced to what a search should match: Arabic letters folded * to their Persian counterparts, and the characters a typist leaves out — * the zero-width non-joiner, spaces, half-spaces — removed. "خواجه ده" then * matches "خواجهده" and "شهرك" matches "شهرک". * * @param string $city City name. */ private static function searchKey( string $city ): string { return str_replace( [ 'ي', 'ك', 'ة', 'أ', 'إ', 'آ', 'ؤ', "\u{200c}", ' ', '‌', '_', '-', '.' ], [ 'ی', 'ک', 'ه', 'ا', 'ا', 'ا', 'و', '', '', '', '', '', '' ], $city ); } /** * Apply an add or remove submission. * * @return array{type:string, message:string} */ private function handleSubmission(): array { if ( ! isset( $_POST['rahkar_city_action'] ) ) { return [ 'type' => '', 'message' => '', ]; } check_admin_referer( self::NONCE ); $action = sanitize_key( wp_unslash( $_POST['rahkar_city_action'] ) ); $province = isset( $_POST['province'] ) ? sanitize_text_field( wp_unslash( $_POST['province'] ) ) : ''; $city = isset( $_POST['city_name'] ) ? sanitize_text_field( wp_unslash( $_POST['city_name'] ) ) : ''; if ( 'add' === $action ) { return LocationRepository::addCity( $province, $city ) ? [ 'type' => 'success', 'message' => sprintf( /* translators: %s: city name */ __( 'شهر «%s» اضافه شد.', 'rahkar-toolkit' ), Normaliser::place( $city ) ), ] : [ 'type' => 'warning', 'message' => __( 'این شهر اضافه نشد — یا از قبل در فهرست بود یا نام آن معتبر نیست.', 'rahkar-toolkit' ), ]; } if ( 'remove' === $action ) { return LocationRepository::removeCity( $province, $city ) ? [ 'type' => 'success', 'message' => __( 'شهر حذف شد.', 'rahkar-toolkit' ), ] : [ 'type' => 'warning', 'message' => __( 'حذف انجام نشد. فقط شهرهایی که خودتان اضافه کرده‌اید قابل حذف هستند.', 'rahkar-toolkit' ), ]; } return [ 'type' => '', 'message' => '', ]; } /** * Which province is being viewed. * * @param array $provinces Known provinces. */ private function selectedProvince( array $provinces ): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only navigation, validated against the known province list. $requested = isset( $_GET['province'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_GET['province'] ) ) ) : ''; if ( isset( $provinces[ $requested ] ) ) { return $requested; } return (string) array_key_first( $provinces ); } /** * Total city count across every province. * * Only reached on this screen, so loading all 31 files here is fine — it is * exactly the sort of thing that must never happen on a checkout request. * * @param array $provinces Known provinces. */ private function totalCities( array $provinces ): int { $total = 0; foreach ( array_keys( $provinces ) as $code ) { $total += count( LocationRepository::cities( $code ) ); } return $total; } }