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;
}
}