'; echo '

'; echo ''; echo ''; echo esc_html__( '4WP Booking', '4wp-booking' ); echo ''; echo '

'; echo '
'; echo ''; } /** * Allowed tags for the heading SVG. * * @return array> */ private static function heading_svg_allowed_html(): array { return array( 'svg' => array( 'xmlns' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'fill' => true, 'focusable' => true, 'aria-hidden' => true, ), 'path' => array( 'd' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, ), 'rect' => array( 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'fill' => true, 'stroke' => true, ), ); } /** * Calendar icon for the settings heading. * * @return string */ private static function heading_svg(): string { return ''; } /** * Scripts and styles only on our screen. * * @param string $hook_suffix Current admin page. * @return void */ public function enqueue_admin_assets( string $hook_suffix ): void { if ( 'toplevel_page_forwp-booking' !== $hook_suffix ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } $asset_file = FORWP_BOOKING_PATH . 'build/admin/index.asset.php'; if ( ! is_readable( $asset_file ) ) { return; } $asset = include $asset_file; wp_enqueue_style( 'wp-components' ); wp_enqueue_style( 'dashicons' ); $style_path = FORWP_BOOKING_PATH . 'build/admin/style-index.css'; if ( is_readable( $style_path ) ) { wp_enqueue_style( 'forwp-booking-admin', FORWP_BOOKING_URL . 'build/admin/style-index.css', array( 'wp-components' ), $asset['version'] ); } wp_enqueue_script( 'forwp-booking-admin', FORWP_BOOKING_URL . 'build/admin/index.js', $asset['dependencies'], $asset['version'], true ); wp_set_script_translations( 'forwp-booking-admin', '4wp-booking', FORWP_BOOKING_PATH . 'languages' ); wp_localize_script( 'forwp-booking-admin', 'forwpBookingAdmin', array( 'restRoot' => esc_url_raw( rest_url() ), 'nonce' => wp_create_nonce( 'wp_rest' ), ) ); } /** * Sanitize API key input. * * @param mixed $value Raw value. * @return string */ public function sanitize_api_key( $value ): string { $value = is_string( $value ) ? $value : ''; return sanitize_text_field( trim( $value ) ); } /** * Stored options. * * @return array */ public function get_options(): array { $stored = get_option( self::OPTION_KEY, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } $defaults = array( 'provider' => 'cliniccards', 'tokens' => array(), 'default_template' => 'advanced', 'booking_flow' => 'staff', 'copy' => array(), 'notify_emails' => '', 'channel_tokens' => array(), 'channel_targets' => array(), 'form_telegram' => array(), 'appearance' => array(), ); return array_merge( $defaults, $stored ); } /** * Persist options array. * * @param array $options Options. * @return void */ public function save_options( array $options ): void { update_option( self::OPTION_KEY, $options, false ); } /** * Active provider slug. * * @return string */ public function get_provider_slug(): string { $options = $this->get_options(); return isset( $options['provider'] ) ? sanitize_key( (string) $options['provider'] ) : 'cliniccards'; } /** * Set credential provider slug. * * @param string $slug Provider slug. * @return void */ public function set_provider_slug( string $slug ): void { $options = $this->get_options(); $options['provider'] = sanitize_key( $slug ); $this->save_options( $options ); } /** * API token for a provider. * * @param string $slug Provider slug. * @return string */ public function get_provider_token( string $slug ): string { $options = $this->get_options(); $tokens = isset( $options['tokens'] ) && is_array( $options['tokens'] ) ? $options['tokens'] : array(); return isset( $tokens[ $slug ] ) ? (string) $tokens[ $slug ] : ''; } /** * Store or clear a provider token. * * @param string $slug Provider slug. * @param string $token Token (empty clears). * @return void */ public function set_provider_token( string $slug, string $token ): void { $options = $this->get_options(); if ( ! isset( $options['tokens'] ) || ! is_array( $options['tokens'] ) ) { $options['tokens'] = array(); } $slug = sanitize_key( $slug ); if ( '' === $token ) { unset( $options['tokens'][ $slug ] ); } else { $options['tokens'][ $slug ] = $token; } $this->save_options( $options ); } /** * Default form template slug. * * @return string */ public function get_default_template(): string { $options = $this->get_options(); $slug = isset( $options['default_template'] ) ? sanitize_key( (string) $options['default_template'] ) : 'advanced'; if ( '' === $slug || ! Template_Registry::has( $slug ) ) { return Advanced_Template::SLUG; } return $slug; } /** * Persist default template. * * @param string $slug Template slug. * @return void */ public function set_default_template( string $slug ): void { $options = $this->get_options(); $options['default_template'] = sanitize_key( $slug ); $this->save_options( $options ); } /** * Allowed booking flows: staff, date, or service. * * @param string $flow Raw flow. * @return string Empty if invalid (caller may fall back). */ public static function sanitize_booking_flow( string $flow ): string { $flow = sanitize_key( $flow ); if ( in_array( $flow, array( 'staff', 'date', 'service' ), true ) ) { return $flow; } return ''; } /** * Active booking flow. * * @return string staff|date|service */ public function get_booking_flow(): string { $options = $this->get_options(); $flow = isset( $options['booking_flow'] ) ? self::sanitize_booking_flow( (string) $options['booking_flow'] ) : ''; return '' !== $flow ? $flow : 'staff'; } /** * Persist booking flow. * * @param string $flow Flow slug. * @return void */ public function set_booking_flow( string $flow ): void { $clean = self::sanitize_booking_flow( $flow ); if ( '' === $clean ) { $clean = 'staff'; } $options = $this->get_options(); $options['booking_flow'] = $clean; $this->save_options( $options ); } /** * Default front-end copy (translatable). * * @return array */ public static function default_copy(): array { return array( 'offerings_title' => __( 'Select a doctor', '4wp-booking' ), 'offerings_description' => '', 'tab_service' => __( 'By service', '4wp-booking' ), 'tab_date' => __( 'By date', '4wp-booking' ), 'tab_staff' => __( 'By doctor', '4wp-booking' ), 'select_service' => __( 'Select a service', '4wp-booking' ), 'select_date' => __( 'Select a date', '4wp-booking' ), 'select_time' => __( 'Select a time', '4wp-booking' ), 'your_details' => __( 'Your details', '4wp-booking' ), 'first_name' => __( 'First name', '4wp-booking' ), 'last_name' => __( 'Last name', '4wp-booking' ), 'phone' => __( 'Phone', '4wp-booking' ), 'email' => __( 'Email', '4wp-booking' ), 'comment' => __( 'Comment', '4wp-booking' ), 'submit' => __( 'Confirm booking', '4wp-booking' ), 'success' => __( 'Your appointment request has been sent.', '4wp-booking' ), 'done_title' => __( 'Request sent', '4wp-booking' ), 'back' => __( 'Back', '4wp-booking' ), 'error_required' => __( 'This field is required.', '4wp-booking' ), 'error_name' => __( 'Enter at least 2 characters.', '4wp-booking' ), 'error_phone' => __( 'Enter a valid phone number.', '4wp-booking' ), 'error_email' => __( 'Enter a valid email address.', '4wp-booking' ), 'no_offerings' => __( 'No doctors are available to book.', '4wp-booking' ), 'no_services' => __( 'No services are available to book.', '4wp-booking' ), 'no_slots' => __( 'No times on this day.', '4wp-booking' ), 'no_month_slots' => __( 'No free times this month.', '4wp-booking' ), ); } /** * Stored copy overrides (empty string = use default). * * @return array */ public function get_copy_overrides(): array { $options = $this->get_options(); $stored = isset( $options['copy'] ) && is_array( $options['copy'] ) ? $options['copy'] : array(); $out = array(); foreach ( array_keys( self::default_copy() ) as $key ) { $out[ $key ] = isset( $stored[ $key ] ) ? (string) $stored[ $key ] : ''; } return $out; } /** * Front-end copy with defaults, overrides, and gettext filter. * * @return array */ public function get_copy(): array { $overrides = $this->get_copy_overrides(); $defaults = self::default_copy(); $out = array(); foreach ( $defaults as $key => $default ) { $override = isset( $overrides[ $key ] ) ? trim( $overrides[ $key ] ) : ''; $value = '' !== $override ? $override : $default; $out[ $key ] = (string) apply_filters( 'forwp_booking_copy_string', $value, $key ); } return $out; } /** * Persist copy overrides. * * @param array $copy Posted copy. * @return void */ public function set_copy( array $copy ): void { $clean = array(); foreach ( array_keys( self::default_copy() ) as $key ) { if ( ! array_key_exists( $key, $copy ) ) { continue; } $raw = is_string( $copy[ $key ] ) ? $copy[ $key ] : ''; if ( 'offerings_description' === $key ) { $clean[ $key ] = sanitize_textarea_field( $raw ); } else { $clean[ $key ] = sanitize_text_field( $raw ); } } $options = $this->get_options(); $options['copy'] = $clean; $this->save_options( $options ); } /** * Split a comma-separated email field. Empty field → fallback (admin email). * * @param string $raw Posted or stored value. * @param string $fallback Used when $raw is empty. * @return string[] */ public static function parse_email_list( string $raw, string $fallback = '' ): array { $source = trim( $raw ); if ( '' === $source ) { $source = trim( $fallback ); } if ( '' === $source ) { return array(); } $parts = preg_split( '/[,\s;]+/', $source ); if ( ! is_array( $parts ) ) { $parts = array(); } $out = array(); foreach ( $parts as $part ) { $email = sanitize_email( (string) $part ); if ( '' !== $email && is_email( $email ) && ! in_array( $email, $out, true ) ) { $out[] = $email; } } return $out; } /** * Split comma-separated chat/target ids. * * @param string $raw Stored value. * @return string[] */ public static function parse_id_list( string $raw ): array { $parts = preg_split( '/[,\s;]+/', trim( $raw ) ); if ( ! is_array( $parts ) ) { $parts = array(); } $out = array(); foreach ( $parts as $part ) { $id = sanitize_text_field( (string) $part ); if ( '' !== $id && ! in_array( $id, $out, true ) ) { $out[] = $id; } } return $out; } /** * Notification recipients. Blank setting uses the WordPress admin email. * * @return string[] */ public function get_notify_emails(): array { $options = $this->get_options(); $stored = isset( $options['notify_emails'] ) ? (string) $options['notify_emails'] : ''; $fallback = (string) get_option( 'admin_email', '' ); return self::parse_email_list( $stored, $fallback ); } /** * Raw notify_emails field (empty = use admin email). * * @return string */ public function get_notify_emails_raw(): string { $options = $this->get_options(); return isset( $options['notify_emails'] ) ? (string) $options['notify_emails'] : ''; } /** * Persist notify emails field. * * @param string $raw Comma-separated emails or empty. * @return void */ public function set_notify_emails( string $raw ): void { $options = $this->get_options(); $options['notify_emails'] = sanitize_text_field( $raw ); $this->save_options( $options ); } /** * Channel secret (bot token). * * @param string $slug Channel slug. * @return string */ public function get_channel_token( string $slug ): string { $options = $this->get_options(); $tokens = isset( $options['channel_tokens'] ) && is_array( $options['channel_tokens'] ) ? $options['channel_tokens'] : array(); $slug = sanitize_key( $slug ); return isset( $tokens[ $slug ] ) ? (string) $tokens[ $slug ] : ''; } /** * Store or clear a channel token. * * @param string $slug Channel slug. * @param string $token Token (empty clears). * @return void */ public function set_channel_token( string $slug, string $token ): void { $options = $this->get_options(); if ( ! isset( $options['channel_tokens'] ) || ! is_array( $options['channel_tokens'] ) ) { $options['channel_tokens'] = array(); } $slug = sanitize_key( $slug ); if ( '' === $token ) { unset( $options['channel_tokens'][ $slug ] ); } else { $options['channel_tokens'][ $slug ] = $this->sanitize_api_key( $token ); } $this->save_options( $options ); } /** * Channel targets (chat ids). * * @param string $slug Channel slug. * @return string[] */ public function get_channel_targets( string $slug ): array { $options = $this->get_options(); $stored = isset( $options['channel_targets'] ) && is_array( $options['channel_targets'] ) ? $options['channel_targets'] : array(); $slug = sanitize_key( $slug ); $raw = isset( $stored[ $slug ] ) ? (string) $stored[ $slug ] : ''; return self::parse_id_list( $raw ); } /** * Raw channel targets field. * * @param string $slug Channel slug. * @return string */ public function get_channel_targets_raw( string $slug ): string { $options = $this->get_options(); $stored = isset( $options['channel_targets'] ) && is_array( $options['channel_targets'] ) ? $options['channel_targets'] : array(); $slug = sanitize_key( $slug ); return isset( $stored[ $slug ] ) ? (string) $stored[ $slug ] : ''; } /** * Persist channel targets. * * @param string $slug Channel slug. * @param string $raw Comma-separated ids. * @return void */ public function set_channel_targets( string $slug, string $raw ): void { $options = $this->get_options(); if ( ! isset( $options['channel_targets'] ) || ! is_array( $options['channel_targets'] ) ) { $options['channel_targets'] = array(); } $slug = sanitize_key( $slug ); $raw = sanitize_text_field( $raw ); if ( '' === $raw ) { unset( $options['channel_targets'][ $slug ] ); } else { $options['channel_targets'][ $slug ] = $raw; } $this->save_options( $options ); } /** * Form → Telegram settings (enabled, chat ids, per-source toggles). * * @return array */ public function get_form_telegram_settings(): array { $options = $this->get_options(); $stored = isset( $options['form_telegram'] ) && is_array( $options['form_telegram'] ) ? $options['form_telegram'] : array(); $sources = isset( $stored['sources'] ) && is_array( $stored['sources'] ) ? $stored['sources'] : array(); $normalized_sources = array(); foreach ( array( 'contact-form-7', 'wpforms', 'gravityforms' ) as $slug ) { $row = isset( $sources[ $slug ] ) && is_array( $sources[ $slug ] ) ? $sources[ $slug ] : array(); $normalized_sources[ $slug ] = array( 'enabled' => ! array_key_exists( 'enabled', $row ) || ! empty( $row['enabled'] ), 'exclude_ids' => isset( $row['exclude_ids'] ) ? sanitize_text_field( (string) $row['exclude_ids'] ) : '', ); } return array( 'enabled' => ! empty( $stored['enabled'] ), 'chat_ids' => isset( $stored['chat_ids'] ) ? (string) $stored['chat_ids'] : '', 'sources' => $normalized_sources, ); } /** * Persist form Telegram settings from REST. * * @param array $input Payload. * @return void */ public function set_form_telegram_settings( array $input ): void { $current = $this->get_form_telegram_settings(); $sources = $current['sources']; if ( isset( $input['sources'] ) && is_array( $input['sources'] ) ) { foreach ( $input['sources'] as $slug => $row ) { $slug = sanitize_key( (string) $slug ); if ( '' === $slug || ! isset( $sources[ $slug ] ) || ! is_array( $row ) ) { continue; } $sources[ $slug ] = array( 'enabled' => ! empty( $row['enabled'] ), 'exclude_ids' => isset( $row['exclude_ids'] ) ? sanitize_text_field( (string) $row['exclude_ids'] ) : '', ); } } $options = $this->get_options(); $options['form_telegram'] = array( 'enabled' => ! empty( $input['enabled'] ), 'chat_ids' => isset( $input['chat_ids'] ) ? sanitize_text_field( (string) $input['chat_ids'] ) : '', 'sources' => $sources, ); $this->save_options( $options ); } /** * Whether form → Telegram is enabled. */ public function is_form_telegram_enabled(): bool { return ! empty( $this->get_form_telegram_settings()['enabled'] ); } /** * Whether a form source is enabled. * * @param string $slug Source slug. */ public function is_form_source_enabled( string $slug ): bool { $slug = sanitize_key( $slug ); $settings = $this->get_form_telegram_settings(); $sources = $settings['sources']; return isset( $sources[ $slug ] ) && ! empty( $sources[ $slug ]['enabled'] ); } /** * Whether a form id is excluded for a source. * * @param string $slug Source slug. * @param int $form_id Form id. */ public function is_form_excluded( string $slug, int $form_id ): bool { if ( $form_id <= 0 ) { return false; } $slug = sanitize_key( $slug ); $settings = $this->get_form_telegram_settings(); $sources = $settings['sources']; $raw = isset( $sources[ $slug ]['exclude_ids'] ) ? (string) $sources[ $slug ]['exclude_ids'] : ''; $ids = self::parse_id_list( $raw ); return in_array( (string) $form_id, $ids, true ); } /** * Chat ids for form notifications. * Empty form list falls back to booking Telegram chat ids. * * @return string[] */ public function get_form_telegram_chat_ids(): array { $settings = $this->get_form_telegram_settings(); $form_ids = self::parse_id_list( (string) $settings['chat_ids'] ); if ( array() !== $form_ids ) { return $form_ids; } return $this->get_channel_targets( 'telegram' ); } /** * Raw form chat ids field (empty means “use booking chats”). */ public function get_form_telegram_chat_ids_raw(): string { return (string) $this->get_form_telegram_settings()['chat_ids']; } }