*/ private const GROUPS = [ OrderEvents::GROUP => 'سفارش‌ها', 'stock' => 'موجودی کالا', 'reviews' => 'نظرات', 'cart' => 'سبد خرید', ]; /** * Render the screen. */ public function render(): void { if ( ! current_user_can( Menu::CAPABILITY ) ) { wp_die( esc_html__( 'شما اجازه دسترسی به این صفحه را ندارید.', 'rahkar-toolkit' ) ); } $notice = $this->handleSubmission(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing; the id is validated against the catalogue. $editing = isset( $_GET['event'] ) ? sanitize_text_field( wp_unslash( $_GET['event'] ) ) : ''; $event = $this->catalogue()[ $editing ] ?? null; if ( null !== $event ) { Ui::pageOpen( $event->label, '', Help::link( NotificationsModule::SLUG, __( 'اطلاع‌رسانی', 'rahkar-toolkit' ) ) ); } else { Ui::pageOpen( __( 'اطلاع‌رسانی‌ها', 'rahkar-toolkit' ), __( 'هر ردیف یک پیام است که فروشگاه می‌تواند بفرستد. همه در ابتدا خاموش‌اند — تا وقتی روشنشان نکنید هیچ پیامکی ارسال نمی‌شود و اعتباری مصرف نمی‌شود.', 'rahkar-toolkit' ), Help::link( NotificationsModule::SLUG, __( 'اطلاع‌رسانی', 'rahkar-toolkit' ) ) ); } if ( '' !== $notice['message'] ) { echo wp_kses( Ui::notice( $notice['type'], $notice['message'] ), Ui::allowedHtml() ); } if ( null !== $event ) { $this->renderEditor( $event ); } else { $this->renderList(); } Ui::pageClose(); } /** * The overview: one tab for messages to customers, one for messages to the * store's staff. * * Split by who receives them because that is the first thing a store owner * decides — "what do my customers get" and "what do I get" are separate * questions — and one long mixed table made both hard to answer. */ private function renderList(): void { $catalogue = $this->catalogue(); if ( [] === $catalogue ) { echo wp_kses( Ui::emptyState( __( 'رویدادی برای نمایش نیست.', 'rahkar-toolkit' ), __( 'ووکامرس فعال نیست، پس رویداد سفارشی وجود ندارد.', 'rahkar-toolkit' ), 'plug' ), Ui::allowedHtml() ); return; } $audience = $this->currentAudience(); $tabs = []; foreach ( [ EventDefinition::AUDIENCE_CUSTOMER, EventDefinition::AUDIENCE_ADMIN ] as $key ) { $events = array_filter( $catalogue, static fn( EventDefinition $event ): bool => $event->audience === $key ); $active = array_filter( $events, static fn( EventDefinition $event ): bool => EventSettings::isEnabled( $event ) ); $tabs[ $key ] = [ 'label' => sprintf( /* translators: 1: audience, 2: switched-on notifications, 3: all of them */ __( '%1$s — %2$s فعال از %3$s', 'rahkar-toolkit' ), EventDefinition::AUDIENCE_ADMIN === $key ? __( 'به مدیران', 'rahkar-toolkit' ) : __( 'به مشتری', 'rahkar-toolkit' ), number_format_i18n( count( $active ) ), number_format_i18n( count( $events ) ) ), 'href' => add_query_arg( 'audience', $key, menu_page_url( self::SLUG, false ) ), ]; } echo wp_kses( Ui::tabs( $tabs, $audience, __( 'گیرندگان', 'rahkar-toolkit' ) ), Ui::allowedHtml() ); if ( EventDefinition::AUDIENCE_ADMIN === $audience ) { $this->renderAdminPhones(); } $grouped = []; foreach ( $catalogue as $event ) { if ( $event->audience === $audience ) { $grouped[ $event->group ][] = $event; } } foreach ( $grouped as $group => $events ) { Ui::panelOpen( self::GROUPS[ $group ] ?? $group, EventDefinition::AUDIENCE_ADMIN === $audience ? __( 'وقتی سفارشی به این وضعیت‌ها می‌رسد، به شماره‌های مدیران پیام می‌رود.', 'rahkar-toolkit' ) : __( 'وقتی وضعیت سفارش مشتری عوض می‌شود، به موبایل خود او پیام می‌رود.', 'rahkar-toolkit' ), 'plug' ); Ui::tableOpen( [ __( 'فعال', 'rahkar-toolkit' ), __( 'وقتی سفارش…', 'rahkar-toolkit' ), __( 'کانال', 'rahkar-toolkit' ), __( 'الگو', 'rahkar-toolkit' ), '', ] ); foreach ( $events as $event ) { $this->renderListRow( $event ); } Ui::tableClose(); Ui::panelClose(); } wp_enqueue_script( 'rahkar-wp-events', RAHKAR_WP_URL . 'assets/dist/admin-events.js', [], RAHKAR_WP_VERSION, true ); } /** * The staff numbers, editable right where they are used. * * Every notification on this tab goes nowhere without them, so an empty list * is said out loud rather than left for the store owner to discover on the * settings screen. */ private function renderAdminPhones(): void { $phones = trim( (string) Settings::get( NotificationsModule::SLUG . '.admin_phones', '' ) ); if ( '' === $phones ) { echo wp_kses( Ui::notice( 'warning', __( 'هنوز شماره مدیری ثبت نشده؛ اطلاع‌رسانی‌های این بخش به جایی نمی‌روند، مگر رویدادی شماره اختصاصی خودش را داشته باشد.', 'rahkar-toolkit' ) ), Ui::allowedHtml() ); } Ui::formOpen(); wp_nonce_field( self::NONCE ); echo ''; Ui::panelOpen( __( 'شماره موبایل مدیران', 'rahkar-toolkit' ), __( 'چند شماره را با ویرگول جدا کنید. هر رویداد می‌تواند در ویرایش خودش شماره اختصاصی داشته باشد.', 'rahkar-toolkit' ), 'settings' ); Ui::rowOpen( __( 'شماره‌ها', 'rahkar-toolkit' ), 'rahkar-admin-phones' ); echo wp_kses( Ui::input( 'text', 'admin_phones', $phones, [ 'id' => 'rahkar-admin-phones', 'dir' => 'ltr', 'placeholder' => '09121234567, 09351234567' ], true ), Ui::allowedHtml() ); Ui::rowClose(); Ui::panelClose( Ui::button( __( 'ذخیره', 'rahkar-toolkit' ), 'primary', [ 'type' => 'submit' ], '', 'check' ) ); Ui::formClose(); } /** * The tab being shown. */ private function currentAudience(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Navigation only. $audience = isset( $_GET['audience'] ) ? sanitize_key( wp_unslash( $_GET['audience'] ) ) : ''; return EventDefinition::AUDIENCE_ADMIN === $audience ? EventDefinition::AUDIENCE_ADMIN : EventDefinition::AUDIENCE_CUSTOMER; } /** * One row of the overview. * * @param EventDefinition $event The notification. */ private function renderListRow( EventDefinition $event ): void { $config = EventSettings::for( $event ); $edit = add_query_arg( 'event', $event->id, menu_page_url( self::SLUG, false ) ); // A switch that submits its own small form: toggling is a write, and a // write belongs behind a POST with a nonce. The checkbox is keyed by the // event so every row's switch has its own id. $key = $event->settingsKey(); $switch = sprintf( '
%s%s
', wp_nonce_field( self::NONCE, '_wpnonce', true, false ), esc_attr( $event->id ), esc_attr( $key ), Ui::toggle( 'enabled[' . $key . ']', $config['enabled'], [ 'aria-label' => $config['enabled'] ? __( 'خاموش کردن', 'rahkar-toolkit' ) : __( 'روشن کردن', 'rahkar-toolkit' ) ] ), Ui::button( __( 'اعمال', 'rahkar-toolkit' ), 'secondary', [ 'type' => 'submit' ] ) ); if ( '' !== $config['pattern'] ) { $pattern = Ui::badge( $config['pattern'], 'quiet', true ); } elseif ( $config['enabled'] && 'email' !== $config['channel'] ) { // Only worth flagging once the notification is armed and texts; an // unconfigured pattern on a switched-off event is not a problem yet. $pattern = Ui::badge( __( 'بدون الگو', 'rahkar-toolkit' ), 'warning' ); } else { $pattern = '—'; } printf( '%s%s%s%s%s', $config['enabled'] ? '' : ' is-off', wp_kses( $switch, Ui::allowedHtml() ), esc_url( $edit ), esc_html( '' !== $event->short ? $event->short : $event->label ), esc_html( $this->channelLabel( $config['channel'] ) ), wp_kses( $pattern, Ui::allowedHtml() ), wp_kses( Ui::button( __( 'ویرایش', 'rahkar-toolkit' ), 'secondary', [], $edit, 'settings' ), Ui::allowedHtml() ) ); } /** * The editor for one notification. * * @param EventDefinition $event The notification. */ private function renderEditor( EventDefinition $event ): void { $config = EventSettings::for( $event ); $unknown = TemplateRenderer::unknownTokens( $config['text'], $event->variables ); if ( [] !== $unknown ) { echo wp_kses( Ui::notice( 'warning', sprintf( /* translators: %s: comma-separated list of unknown placeholders */ __( 'این متغیرها در این رویداد وجود ندارند و هنگام ارسال حذف می‌شوند: %s', 'rahkar-toolkit' ), wp_kses( implode( '، ', $unknown ), Ui::allowedHtml() ) ) ), Ui::allowedHtml() ); } Ui::panelOpen( __( 'تنظیمات این اطلاع‌رسانی', 'rahkar-toolkit' ), '', 'settings', Ui::button( __( 'بازگشت به فهرست', 'rahkar-toolkit' ), 'secondary', [], menu_page_url( self::SLUG, false ), 'chevron-right' ) ); Ui::formOpen(); wp_nonce_field( self::NONCE ); printf( '', esc_attr( $event->id ) ); Ui::rowOpen( __( 'فعال', 'rahkar-toolkit' ), 'rahkar-enabled' ); echo ''; echo wp_kses( Ui::toggle( 'enabled', (bool) $config['enabled'], [ 'id' => 'rahkar-enabled' ] ), Ui::allowedHtml() ); Ui::rowClose(); Ui::rowOpen( __( 'کانال', 'rahkar-toolkit' ), 'rahkar-channel' ); echo wp_kses( Ui::select( 'channel', [ 'sms' => $this->channelLabel( 'sms' ), 'email' => $this->channelLabel( 'email' ), 'both' => $this->channelLabel( 'both' ), ], (string) $config['channel'], [ 'id' => 'rahkar-channel' ] ), Ui::allowedHtml() ); Ui::rowClose(); if ( $event->isForAdmin() ) { Ui::rowOpen( __( 'شماره گیرندگان', 'rahkar-toolkit' ), 'rahkar-recipients', __( 'با ویرگول جدا کنید. خالی بگذارید تا شماره‌های عمومی مدیران استفاده شود.', 'rahkar-toolkit' ) ); echo wp_kses( Ui::input( 'text', 'recipients', (string) $config['recipients'], [ 'id' => 'rahkar-recipients' ], true ), Ui::allowedHtml() ); Ui::rowClose(); } Ui::rowOpen( __( 'کد الگو', 'rahkar-toolkit' ), 'rahkar-pattern', __( 'کد الگوی تأییدشده در پنل پیامکی، مخصوص همین پیام. اپراتور برای هر متن الگوی جداگانه تأیید می‌کند. اگر خالی بماند متن به‌صورت آزاد ارسال می‌شود که برای شماره‌های عضو لیست سیاه تحویل نمی‌شود.', 'rahkar-toolkit' ) ); echo wp_kses( Ui::input( 'text', 'pattern', (string) $config['pattern'], [ 'id' => 'rahkar-pattern' ], true ), Ui::allowedHtml() ); // One pattern code per event, whichever gateway sends it, so the picker // reads the first enabled gateway that can list patterns. echo wp_kses( PatternPicker::controls( PatternPicker::defaultGateway(), 'rahkar-pattern', '', '' !== $config['pattern'] ), Ui::allowedHtml() ); Ui::rowClose(); $this->renderPatternMap( $event, $config['pattern_map'] ); Ui::rowOpen( __( 'متن پیام', 'rahkar-toolkit' ), 'rahkar-text', __( 'برای درج متغیر روی آن کلیک کنید.', 'rahkar-toolkit' ) ); echo wp_kses( Ui::textarea( 'text', (string) $config['text'], 6, [ 'id' => 'rahkar-text' ] ), Ui::allowedHtml() ); $this->renderVariables( $event ); Ui::rowClose(); echo wp_kses( Ui::button( __( 'ذخیره', 'rahkar-toolkit' ), 'primary', [ 'type' => 'submit' ], '', 'check' ), Ui::allowedHtml() ); Ui::formClose(); Ui::panelClose(); wp_enqueue_script( 'rahkar-wp-events', RAHKAR_WP_URL . 'assets/dist/admin-events.js', [], RAHKAR_WP_VERSION, true ); wp_add_inline_script( 'rahkar-wp-events', 'window.rahkarPatternMap=' . wp_json_encode( [ 'variables' => $event->variables, 'synonyms' => OrderEvents::synonyms(), 'i18n' => [ 'auto' => __( 'خودکار', 'rahkar-toolkit' ), 'missing' => __( 'بدون مقدار — سرویس‌دهنده ارسال را رد می‌کند', 'rahkar-toolkit' ), 'filled' => __( 'متن پیام از روی الگو پر شد؛ برای ایمیل و ارسال بدون الگو همین استفاده می‌شود.', 'rahkar-toolkit' ), 'changed' => __( 'کد الگو عوض شد. «بررسی کد» را بزنید تا متغیرهای الگوی تازه اینجا بیاید.', 'rahkar-toolkit' ), /* translators: %d: number of pattern variables without a value */ 'confirm' => __( '%d متغیر الگو مقدار ندارد و سرویس‌دهنده ارسال را رد می‌کند. با این حال ذخیره شود؟', 'rahkar-toolkit' ), ], ], JSON_UNESCAPED_UNICODE ) . ';', 'before' ); } /** * Which order value goes into each of the pattern's variables. * * Rows for the saved mapping are rendered here, so the form shows what was * saved even before the pattern is read; the script then reads the pattern, * adds a row for any variable the mapping lacks — matched by exact name, then * by {@see OrderEvents::synonyms()} — and drops rows the pattern no longer * has. Each value is stored as a small template: `{first_name}`, a fixed * `aramahome.com`, or a mix of the two. * * @param EventDefinition $event The notification. * @param array $map Saved mapping. */ private function renderPatternMap( EventDefinition $event, array $map ): void { // The whole row, label included, stays out of the way until a pattern has // variables to show. printf( '
', [] === $map ? ' hidden' : '' ); Ui::rowOpen( __( 'متغیرهای الگو', 'rahkar-toolkit' ), '', __( 'هر متغیر الگو چه مقداری از سفارش بگیرد. هم‌نام‌ها و نام‌های رایج خودکار وصل می‌شوند؛ برای بقیه از فهرست انتخاب کنید یا «متن دلخواه» بنویسید.', 'rahkar-toolkit' ) ); echo '
'; echo ''; echo '
'; foreach ( $map as $name => $template ) { echo wp_kses( $this->mapRow( $event, $name, $template ), Ui::allowedHtml() ); } echo '
'; printf( '', wp_kses( $this->mapRow( $event, '__VAR__', '' ), Ui::allowedHtml() ) ); echo '
'; Ui::rowClose(); echo '
'; } /** * One mapping row. * * @param EventDefinition $event The notification. * @param string $name Pattern variable. * @param string $template Saved value. */ private function mapRow( EventDefinition $event, string $name, string $template ): string { $options = [ '' => __( '— انتخاب کنید —', 'rahkar-toolkit' ) ]; foreach ( $event->variables as $variable => $label ) { $options[ $variable ] = $label . ' {' . $variable . '}'; } $options['__text'] = __( 'متن دلخواه…', 'rahkar-toolkit' ); $selected = ''; $text = ''; if ( 1 === preg_match( '/^\{([a-z0-9_]+)\}$/', $template, $match ) && isset( $event->variables[ $match[1] ] ) ) { $selected = $match[1]; } elseif ( '' !== $template ) { $selected = '__text'; $text = $template; } return sprintf( '
%%%2$s%%%3$s%5$s
', esc_attr( $name ), esc_html( $name ), Ui::select( 'pattern_map_src[' . $name . ']', $options, $selected, [ 'data-rahkar-pmap-src' => '' ] ), '__text' === $selected ? '' : ' hidden', Ui::input( 'text', 'pattern_map_text[' . $name . ']', $text, [ 'data-rahkar-pmap-text' => '', 'placeholder' => __( 'مثلاً aramahome.com یا {first_name} {last_name}', 'rahkar-toolkit' ), ] ) ); } /** * The mapping from the submitted rows. * * @return array */ private function postedMap(): array { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in handleSubmission(), the only caller. $sources = isset( $_POST['pattern_map_src'] ) && is_array( $_POST['pattern_map_src'] ) ? map_deep( wp_unslash( $_POST['pattern_map_src'] ), 'sanitize_text_field' ) : []; $texts = isset( $_POST['pattern_map_text'] ) && is_array( $_POST['pattern_map_text'] ) ? map_deep( wp_unslash( $_POST['pattern_map_text'] ), 'sanitize_text_field' ) : []; // phpcs:enable WordPress.Security.NonceVerification.Missing $map = []; foreach ( $sources as $name => $source ) { if ( '__VAR__' === $name ) { continue; } $map[ (string) $name ] = '__text' === $source ? trim( (string) ( $texts[ $name ] ?? '' ) ) : ( '' !== $source ? '{' . sanitize_key( (string) $source ) . '}' : '' ); } return $map; } /** * The clickable placeholder list under the message box. * * @param EventDefinition $event The notification. */ private function renderVariables( EventDefinition $event ): void { echo '
'; foreach ( $event->variables as $name => $label ) { printf( '', esc_attr( '{' . $name . '}' ), esc_html( (string) $name ), esc_html( (string) $label ) ); } echo '
'; } /** * Apply a toggle or a save. * * @return array{type:string, message:string} */ private function handleSubmission(): array { $none = [ 'type' => '', 'message' => '', ]; if ( ! isset( $_POST['rahkar_event'], $_POST['rahkar_action'] ) ) { return $none; } check_admin_referer( self::NONCE ); $action = sanitize_key( wp_unslash( $_POST['rahkar_action'] ) ); // The staff numbers belong to no single notification. if ( 'phones' === $action ) { $phones = isset( $_POST['admin_phones'] ) ? sanitize_text_field( wp_unslash( $_POST['admin_phones'] ) ) : ''; Settings::save( NotificationsModule::SLUG, [ 'admin_phones' => $phones ] ); return [ 'type' => 'success', 'message' => __( 'شماره‌های مدیران ذخیره شد.', 'rahkar-toolkit' ), ]; } $id = sanitize_text_field( wp_unslash( $_POST['rahkar_event'] ) ); $event = $this->catalogue()[ $id ] ?? null; if ( null === $event ) { return $none; } if ( 'toggle' === $action ) { // Keyed by event on the overview, so each row's switch has its own id; // the hidden `0` before the checkbox is what arrives when it is off. $posted = isset( $_POST['enabled'] ) && is_array( $_POST['enabled'] ) ? map_deep( wp_unslash( $_POST['enabled'] ), 'sanitize_text_field' ) : []; $enabled = ! empty( $posted[ $event->settingsKey() ] ); EventSettings::setEnabled( $event, $enabled ); return [ 'type' => 'success', 'message' => $enabled ? __( 'اطلاع‌رسانی روشن شد.', 'rahkar-toolkit' ) : __( 'اطلاع‌رسانی خاموش شد.', 'rahkar-toolkit' ), ]; } if ( 'save' !== $action ) { return $none; } EventSettings::save( $event, [ 'enabled' => ! empty( $_POST['enabled'] ), 'channel' => isset( $_POST['channel'] ) ? sanitize_key( wp_unslash( $_POST['channel'] ) ) : 'sms', 'pattern' => isset( $_POST['pattern'] ) ? sanitize_text_field( wp_unslash( $_POST['pattern'] ) ) : '', 'pattern_map' => $this->postedMap(), 'text' => isset( $_POST['text'] ) ? sanitize_textarea_field( wp_unslash( $_POST['text'] ) ) : '', 'recipients' => isset( $_POST['recipients'] ) ? sanitize_text_field( wp_unslash( $_POST['recipients'] ) ) : '', ] ); return [ 'type' => 'success', 'message' => __( 'ذخیره شد.', 'rahkar-toolkit' ), ]; } /** * Every notification this install can send. * * @return array */ private function catalogue(): array { /** * Filters the notifications shown on the events screen. * * Future modules — stock alerts, review invitations, abandoned carts — * add their own definitions here rather than building a second screen, so * a shop owner has one place that answers "what does this store send?". * * @param array $events Notifications by id. */ return (array) apply_filters( 'rahkar_wp_notification_events', OrderEvents::all() ); } /** * Human label for a channel. * * @param string $channel Channel key. */ private function channelLabel( string $channel ): string { return [ 'sms' => __( 'پیامک', 'rahkar-toolkit' ), 'email' => __( 'ایمیل', 'rahkar-toolkit' ), 'both' => __( 'پیامک و ایمیل', 'rahkar-toolkit' ), ][ $channel ] ?? $channel; } }