fields = new FieldRenderer(); } /** * Registration hook. * * Settings are saved by this class's own handler rather than `options.php`, * because each module owns one array option and the Settings API's * one-option-per-field model would put us back to option sprawl. */ public function registerSettings(): void { // Intentionally empty for now — kept as the extension point for modules // that need to register something on `admin_init` specifically. } /** * Render the screen. */ public function render(): void { if ( ! current_user_can( Menu::CAPABILITY ) ) { wp_die( esc_html__( 'شما اجازه دسترسی به این صفحه را ندارید.', 'rahkar-toolkit' ) ); } $outcome = $this->handleSave(); $modules = $this->configurableModules(); Ui::pageOpen( __( 'تنظیمات راهکار وردپرس', 'rahkar-toolkit' ), '', Help::link( 'settings', __( 'تنظیمات', 'rahkar-toolkit' ) ) ); if ( 'saved' === $outcome ) { echo wp_kses( Ui::notice( 'success', __( 'تنظیمات ذخیره شد.', 'rahkar-toolkit' ) ), Ui::allowedHtml() ); } if ( 'truncated' === $outcome ) { echo wp_kses( Ui::notice( 'error', __( 'تعداد فیلدهای ارسالی از سقف سرور (max_input_vars) گذشت، پس بخشی از فرم به سرور نرسید. برای اینکه نیمی از تنظیمات ذخیره نشود، هیچ‌چیز ذخیره نشد. مقدار max_input_vars را بالا ببرید یا تغییرها را در چند مرحله ذخیره کنید.', 'rahkar-toolkit' ), __( 'تنظیمات ذخیره نشد', 'rahkar-toolkit' ) ), Ui::allowedHtml() ); } if ( [] === $modules ) { echo wp_kses( Ui::emptyState( __( 'هیچ ماژول روشنی وجود ندارد.', 'rahkar-toolkit' ), __( 'برای دیدن تنظیمات، اول یک ماژول را روشن کنید.', 'rahkar-toolkit' ), 'settings', Ui::button( __( 'رفتن به ماژول‌ها', 'rahkar-toolkit' ), 'primary', [], admin_url( 'admin.php?page=' . Menu::SLUG ) ) ), Ui::allowedHtml() ); Ui::pageClose(); return; } $tree = $this->tabTree( $modules ); $active = $this->activeTab( $tree ); Ui::vtabsLayoutOpen(); echo wp_kses( Ui::vtabsNav( $tree, $active, __( 'بخش‌های تنظیمات', 'rahkar-toolkit' ), self::SEARCH_THRESHOLD <= $this->countTabs( $tree ) ), Ui::allowedHtml() ); echo '
'; Ui::formOpen(); // `data-rk-keep` is the guard's opt-out. Without it the nonce is just // another unchanged field, and a form that submits only what changed would // drop it — turning every save into an expired-link screen. echo wp_kses( str_replace( '', esc_attr( self::SUBMIT_FIELD ) ); // Which tab was open when submit was pressed, so the reload comes back to // it rather than dropping the admin on the first module after every save. printf( '', esc_attr( self::TAB_FIELD ), esc_attr( $active ) ); foreach ( $modules as $slug => $module ) { $this->renderModulePanels( $slug, $module, $active ); // The delivery test posts on its own and must not nest inside the // settings form: a form inside a form is invalid, and browsers resolve // it by dropping the inner one. It is closed out of the form below. if ( NotificationsModule::SLUG === $slug ) { $this->pendingTest = true; } } // Last field, deliberately. PHP truncates from the end, so this is the // first thing to disappear when the cap is hit — which is exactly what // makes its absence a reliable signal. Rendered server-side rather than // only by script, so the check still works with JavaScript switched off. printf( '', esc_attr( self::SENTINEL_FIELD ) ); Ui::formClose(); if ( $this->pendingTest ) { $this->renderDeliveryTest( $active ); } echo '
'; Ui::vtabsLayoutClose(); Ui::pageClose(); $this->enqueueTabScript(); } /** * The navigation tree, one entry per module. * * A module with more than one section becomes a group and its sections become * the tabs; a module with a single section stays one flat tab. Wrapping a lone * section in a group would add a click that reveals exactly one thing. * * @param array $modules Enabled configurable modules. * * @return array> */ private function tabTree( array $modules ): array { $manager = Plugin::instance()->modules(); $tree = []; foreach ( $modules as $slug => $module ) { $children = []; foreach ( $this->sectionsOf( $module ) as $sectionId => $label ) { $children[ self::tabKey( $slug, $sectionId ) ] = [ 'label' => $label ]; } // The delivery test is a section of the notifications module in every // sense except that it cannot live inside the settings form. Giving it a // tab keeps the nav a description of everything the screen can show. if ( NotificationsModule::SLUG === $slug ) { $children[ self::TEST_TAB ] = [ 'label' => __( 'ارسال پیام آزمایشی', 'rahkar-toolkit' ) ]; } // A module whose schema is empty has no panel to show. Listing it would // be a tab that opens onto nothing. if ( [] === $children ) { continue; } $entry = [ 'label' => $module->name(), 'icon' => $manager->iconFor( $slug ), ]; // One child means one tab, keyed by that child — never by the slug. The // nav reads a flat entry's array key as its tab id, and a module that // gains or loses a section must not change the id of a panel that is // still rendered from the same section. if ( 1 === count( $children ) ) { $tree[ (string) array_key_first( $children ) ] = $entry; continue; } $entry['items'] = $children; $tree[ $slug ] = $entry; } return $tree; } /** * A module's sections in render order, keyed by section id. * * Driven by the schema rather than `settingsSections()` alone: a module may * name a section it has no fields in, and a tab that opens onto nothing is * worse than no tab. A field with no section lands in `general`, which is what * the field renderer already assumes. * * @param HasSettings&Module $module Module instance. * * @return array */ private function sectionsOf( HasSettings $module ): array { $named = $module->settingsSections(); $result = []; foreach ( $module->settingsSchema() as $field ) { $id = (string) ( $field['section'] ?? 'general' ); $result[ $id ] = $named[ $id ] ?? $module->name(); } return $result; } /** * Render one module as one tab panel per section. * * @param string $slug Module slug. * @param HasSettings&Module $module Module instance. * @param string $active Selected tab id. */ private function renderModulePanels( string $slug, HasSettings $module, string $active ): void { $sections = $this->sectionsOf( $module ); $values = Settings::all( $slug ); $multi = 1 < count( $sections ); $grouped = []; foreach ( $module->settingsSchema() as $key => $field ) { $grouped[ (string) ( $field['section'] ?? 'general' ) ][ $key ] = $field; } foreach ( $grouped as $sectionId => $fields ) { // Always `slug:section`, matching what `tabTree()` puts in the nav. The // two used to be derived separately, which meant a module changing its // section count could give a tab one id and its panel another — and the // symptom of that is a tab that opens onto a blank column. $key = self::tabKey( $slug, (string) $sectionId ); Ui::vtabsPanelOpen( $key, $key, $key === $active ); // One guide per module: every section of it links to the same page. Ui::panelOpen( $multi ? $sections[ $sectionId ] : $module->name(), $multi ? $module->name() : '', '', Help::link( $slug, $module->name() ), $key ); foreach ( $fields as $fieldKey => $field ) { $this->renderField( $slug, (string) $fieldKey, $field, $values ); } Ui::panelClose( Ui::button( __( 'ذخیره تنظیمات', 'rahkar-toolkit' ), 'primary', [ 'type' => 'submit' ], '', 'check' ) ); Ui::vtabsPanelClose(); } } /** * The tab to open, preferring the one the admin last had open. * * Falls back to the first tab, which is also what a browser running no * JavaScript gets: every panel is rendered server-side and the right one is * already visible before any script has run. * * @param array> $tree Navigation tree. */ private function activeTab( array $tree ): string { $keys = $this->tabKeys( $tree ); if ( [] === $keys ) { return ''; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Picks which panel is visible and nothing else; the save path checks its own nonce. $raw = isset( $_REQUEST[ self::TAB_FIELD ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- As above. ? sanitize_text_field( wp_unslash( (string) $_REQUEST[ self::TAB_FIELD ] ) ) : ''; if ( in_array( $raw, $keys, true ) ) { return $raw; } // A bare module slug, as the modules screen and the Rahkar Foroosh panel // link it, opens that module's first tab: tab keys are `slug:section`. foreach ( $keys as $key ) { if ( '' !== $raw && str_starts_with( $key, $raw . ':' ) ) { return $key; } } return $keys[0]; } /** * Every selectable tab id, group parents excluded. * * @param array> $tree Navigation tree. * * @return list */ private function tabKeys( array $tree ): array { $keys = []; foreach ( $tree as $key => $item ) { $children = $item['items'] ?? []; if ( is_array( $children ) && [] !== $children ) { foreach ( array_keys( $children ) as $childKey ) { $keys[] = (string) $childKey; } continue; } $keys[] = (string) $key; } return $keys; } /** * How many selectable tabs the nav holds. * * @param array> $tree Navigation tree. */ private function countTabs( array $tree ): int { return count( $this->tabKeys( $tree ) ); } /** * Tab id for one section of one module. * * @param string $slug Module slug. * @param string $section Section id. */ private static function tabKey( string $slug, string $section ): string { return $slug . ':' . $section; } /** * Load the panel-switching behaviour. * * Small and dependency-free on purpose. The design system ships a React * `VerticalTabs`, but reaching for it here would pull `wp-element` and * `wp-components` onto a screen that renders entirely on the server — roughly * 1.15MB of WordPress runtime to move a `hidden` attribute between sections. * * The submit guard comes from the design system rather than from here, as its * own dependency-free file. Declared as a dependency of this script so the * global it defines exists by the time this one looks for it — and declared as * a dependency of *that* handle only, never of `rahkar-ui`, which would drag * the whole runtime back in through the side door. */ private function enqueueTabScript(): void { $guard = Ui::enqueueFormGuard(); wp_enqueue_script( 'rahkar-wp-admin-settings', RAHKAR_WP_URL . 'assets/dist/admin-settings.js', '' === $guard ? [] : [ $guard ], RAHKAR_WP_VERSION, true ); wp_add_inline_script( 'rahkar-wp-admin-settings', 'window.rahkarSettingsL10n=' . wp_json_encode( [ 'unsaved' => __( 'تغییر ذخیره‌نشده', 'rahkar-toolkit' ) ], JSON_UNESCAPED_UNICODE ) . ';', 'before' ); } /** * Render one field, locked or not. * * @param string $slug Module slug. * @param string $key Setting key. * @param array $field Schema entry. * @param array $values Stored values. */ private function renderField( string $slug, string $key, array $field, array $values ): void { $name = sprintf( 'rahkar[%s][%s]', $slug, $key ); $id = sanitize_html_class( str_replace( [ '[', ']' ], '-', $name ) ); $locked = Tier::PRO === $this->tierOf( $slug, $key, $field ) && ! Tier::unlocked(); $help = (string) ( $field['help'] ?? '' ); if ( $locked ) { // The shipped default, not what is stored. The stored value is kept — a // licence that lapses and is renewed should bring the configuration // back — but showing it here would describe behaviour the site is not // currently getting. $value = $field['default'] ?? ''; $help = __( 'این تنظیم بخشی از نسخه حرفه‌ای است. مقدار ذخیره‌شده‌ی شما حفظ می‌شود و با فعال‌سازی نسخه حرفه‌ای برمی‌گردد.', 'rahkar-toolkit' ); } else { $value = $values[ $key ] ?? ( $field['default'] ?? '' ); } Ui::rowOpen( (string) ( $field['label'] ?? $key ), $id, $help, $locked ? 'is-locked' : '' ); $this->fields->render( $name, $field, $value, $locked ); if ( $locked ) { echo wp_kses( Ui::badge( Tier::label( Tier::PRO ), 'brand' ), Ui::allowedHtml() ); } Ui::rowClose(); } /** * Render the "send a real test message" control. * * Every field above it can look correct while the configuration is still * broken — a sender line the operator has not approved, an API key with no * credit, a pattern code that does not exist. This is the only way to find * that out before a customer does. * * @param string $active Selected tab id, so this panel knows whether to show. */ private function renderDeliveryTest( string $active ): void { Ui::vtabsPanelOpen( self::TEST_TAB, '', self::TEST_TAB === $active ); Ui::panelOpen( __( 'ارسال پیام آزمایشی', 'rahkar-toolkit' ), __( 'ابتدا تنظیمات را ذخیره کنید، سپس یک شماره موبایل یا نشانی ایمیل وارد کنید. پیام واقعی ارسال می‌شود و پاسخ سرویس‌دهنده بدون تغییر نمایش داده می‌شود.', 'rahkar-toolkit' ), 'plug', Help::link( 'notifications', __( 'اطلاع‌رسانی', 'rahkar-toolkit' ) ) ); Ui::toolbarOpen( false ); echo wp_kses( Ui::input( 'text', '', '', [ 'id' => 'rahkar-test-recipient', 'placeholder' => __( '۰۹۱۲۳۴۵۶۷۸۹ یا you@example.com', 'rahkar-toolkit' ), ] ), Ui::allowedHtml() ); echo wp_kses( Ui::button( __( 'ارسال آزمایشی', 'rahkar-toolkit' ), 'secondary', [ 'type' => 'button', 'id' => 'rahkar-test-send', ], '', 'refresh' ), Ui::allowedHtml() ); Ui::toolbarClose(); echo '

'; Ui::panelClose(); Ui::vtabsPanelClose(); wp_enqueue_script( 'rahkar-wp-admin-test', RAHKAR_WP_URL . 'assets/dist/admin-test.js', [], RAHKAR_WP_VERSION, true ); wp_add_inline_script( 'rahkar-wp-admin-test', 'window.rahkarTest=' . wp_json_encode( [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'rahkar_test_notification' ), 'sending' => __( 'در حال ارسال…', 'rahkar-toolkit' ), 'failed' => __( 'ارتباط با سرور برقرار نشد.', 'rahkar-toolkit' ), ], JSON_UNESCAPED_UNICODE ) . ';', 'before' ); } /** * Persist a submission. * * Values are sanitised against the schema and unknown keys are dropped, so a * crafted POST cannot write arbitrary data into a module's option. * * @return string `saved`, `truncated`, or an empty string when this request * was not a submission of this form. */ private function handleSave(): string { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Only decides whether this is our form at all; the nonce is checked immediately below. if ( ! isset( $_POST[ self::SUBMIT_FIELD ] ) ) { return ''; } check_admin_referer( self::NONCE ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked on the line above. if ( ! isset( $_POST[ self::SENTINEL_FIELD ] ) ) { // The tail of the form never arrived. Saving what did would write a // subset and leave the rest at whatever it was, which is worse than // refusing: the admin would see a success notice over half-applied // settings and have no way to tell which half. return 'truncated'; } // Nothing changed, so the browser sent no fields. That is a successful // save of nothing, not a failure — and not a reason to stay silent. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked above. if ( ! isset( $_POST['rahkar'] ) ) { return 'saved'; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing -- Nonce checked above; each value is sanitised against its schema entry below. $submitted = wp_unslash( $_POST['rahkar'] ); if ( ! is_array( $submitted ) ) { return ''; } foreach ( $this->configurableModules() as $slug => $module ) { if ( ! isset( $submitted[ $slug ] ) || ! is_array( $submitted[ $slug ] ) ) { continue; } $schema = $module->settingsSchema(); $clean = []; foreach ( $schema as $key => $field ) { // A locked field is left out of the payload entirely. The form submits // nothing for it, but a hand-made POST would, so dropping it here is // what stops a licence check from being bypassed by hand. Leaving it // out no longer loses the stored value: `Settings::save()` merges, so // a key absent from the payload keeps whatever it had. if ( Tier::PRO === $this->tierOf( $slug, $key, $field ) && ! Tier::unlocked() ) { continue; } if ( ! array_key_exists( $key, $submitted[ $slug ] ) ) { continue; } $clean[ $key ] = $this->fields->sanitize( $field, $submitted[ $slug ][ $key ] ); } Settings::save( $slug, $clean ); } return 'saved'; } /** * Which tier a single field belongs to. * * A field can say so itself with `'tier' => Tier::PRO` in the schema. When it * does not, and the key happens to be one of the module's boot switches, the * catalogue is asked instead — that is where a switch's tier is already * declared, and repeating it in the schema would be a second place to forget. * * @param string $slug Module slug. * @param string $key Setting key. * @param array $field Schema entry. */ private function tierOf( string $slug, string $key, array $field ): string { if ( isset( $field['tier'] ) ) { return Tier::normalise( $field['tier'] ); } $switch = ModuleManager::bootSwitches()[ $slug ][ $key ] ?? null; return null === $switch ? Tier::FREE : $switch['tier']; } /** * Enabled modules that expose settings. * * @return array */ private function configurableModules(): array { $manager = Plugin::instance()->modules(); // Appearance comes first and is not a module. Its tokens are what every // module's front end below it — and the add-on's — inherits from, so it // reads as the thing the rest sit inside. It is absent from the catalogue // because there is no "no appearance" to switch to. $result = [ Tokens::SLUG => Plugin::make( AppearanceSettings::class ) ]; foreach ( Manifest::enabledModules() as $slug ) { $module = $manager->get( $slug ); if ( $module instanceof HasSettings && $module instanceof Module && [] === $module->unmetRequirements() ) { $result[ $slug ] = $module; } } return $result; } }