|null */ private static $cache = null; /** * Hook into WordPress. * * @return void */ public function register() { add_action( 'admin_menu', array( $this, 'add_page' ) ); add_action( 'admin_init', array( $this, 'register_setting' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_filter( 'plugin_action_links_' . ADVANCED_TESTIMONIAL_BASENAME, array( $this, 'action_links' ) ); self::register_cache(); } /** * Attach the cache-flush hooks. * * Separate from register() (which is admin-only): settings are read on * the frontend too, so the flush must be armed everywhere. * * @return void */ public static function register_cache() { // The settings array is cached per request (see get_settings()) — drop // it whenever the underlying option could have changed, so a single // request never serves a mix of old and new values. add_action( 'update_option_' . self::OPTION, array( __CLASS__, 'flush_cache' ) ); add_action( 'deleted_option', array( __CLASS__, 'flush_on_delete' ) ); add_action( 'switch_blog', array( __CLASS__, 'flush_cache' ) ); } /** * Add a "Settings" link to the plugin's row on the Plugins screen. * * @param array $links Existing action links. * @return array */ public function action_links( $links ) { $url = admin_url( 'edit.php?post_type=' . CPT::POST_TYPE . '&page=' . self::PAGE ); $settings = '' . esc_html__( 'Settings', 'advanced-testimonial' ) . ''; array_unshift( $links, $settings ); return $links; } /** * Settings schema grouped by tab. * * @return array> */ public static function schema() { return array( 'general' => array( 'title' => __( 'General', 'advanced-testimonial' ), 'fields' => array( 'image_size' => array( 'type' => 'select', 'label' => __( 'Client Image Size', 'advanced-testimonial' ), 'default' => 'medium', 'options' => array( 'thumbnail' => __( 'Thumbnail', 'advanced-testimonial' ), 'medium' => __( 'Medium', 'advanced-testimonial' ), 'large' => __( 'Large', 'advanced-testimonial' ), 'full' => __( 'Full', 'advanced-testimonial' ), ), ), 'lazy_load' => array( 'type' => 'checkbox', 'label' => __( 'Lazy Load Images', 'advanced-testimonial' ), 'default' => 1, ), 'enable_css' => array( 'type' => 'checkbox', 'label' => __( 'Enable Frontend CSS', 'advanced-testimonial' ), 'default' => 1, ), 'enable_js' => array( 'type' => 'checkbox', 'label' => __( 'Enable Frontend JS', 'advanced-testimonial' ), 'default' => 1, ), 'enable_schema' => array( 'type' => 'checkbox', 'label' => __( 'Enable Schema.org Markup', 'advanced-testimonial' ), 'default' => 1, 'description' => __( 'Adds Review/Rating structured data for richer search results.', 'advanced-testimonial' ), ), 'reviewed_type' => array( 'type' => 'select', 'label' => __( 'What is being reviewed', 'advanced-testimonial' ), 'default' => 'organization', 'options' => array( 'organization' => __( 'My own business / organization', 'advanced-testimonial' ), 'product' => __( 'A product', 'advanced-testimonial' ), 'software' => __( 'Software / app', 'advanced-testimonial' ), 'course' => __( 'A course', 'advanced-testimonial' ), 'event' => __( 'An event', 'advanced-testimonial' ), ), 'description' => __( 'Google only shows star ratings for reviews of products, apps, courses and similar — never for a business reviewing itself. Pick what your testimonials praise.', 'advanced-testimonial' ), ), 'reviewed_name' => array( 'type' => 'text', 'label' => __( 'Reviewed name', 'advanced-testimonial' ), 'default' => '', ), 'reviewed_url' => array( 'type' => 'text', 'label' => __( 'Reviewed URL', 'advanced-testimonial' ), 'default' => '', ), 'reviewed_image' => array( 'type' => 'text', 'label' => __( 'Reviewed image URL', 'advanced-testimonial' ), 'default' => '', ), 'enable_rtl' => array( 'type' => 'checkbox', 'label' => __( 'Enable RTL Styles', 'advanced-testimonial' ), 'default' => 0, ), 'single_pages' => array( 'type' => 'checkbox', 'label' => __( 'Give testimonials their own public pages', 'advanced-testimonial' ), 'default' => 0, 'description' => __( 'Off (recommended): testimonials appear only where you place them, never as thin standalone pages or in site search. Existing sites keep their current URLs — this is on for them.', 'advanced-testimonial' ), ), ), ), 'styles' => array( 'title' => __( 'Styles', 'advanced-testimonial' ), 'fields' => array( 'card_style' => array( 'type' => 'select', 'label' => __( 'Card Style', 'advanced-testimonial' ), 'default' => 'classic', 'options' => \AdvancedTestimonial\Frontend\Renderer::card_styles(), 'description' => __( 'Default look of the testimonial cards. Each block or shortcode can override it with the style attribute.', 'advanced-testimonial' ), ), 'primary_color' => array( 'type' => 'color', 'label' => __( 'Primary Color', 'advanced-testimonial' ), 'default' => '#2563eb', ), 'accent_color' => array( 'type' => 'color', 'label' => __( 'Secondary / Accent Color', 'advanced-testimonial' ), 'default' => '#f59e0b', 'description' => __( 'Used for rating stars and accents.', 'advanced-testimonial' ), ), 'text_color' => array( 'type' => 'color', 'label' => __( 'Text Color', 'advanced-testimonial' ), 'default' => '#1f2937', ), 'border_radius' => array( 'type' => 'number', 'label' => __( 'Border Radius (px)', 'advanced-testimonial' ), 'default' => 12, 'min' => 0, 'max' => 50, 'step' => 1, ), 'spacing' => array( 'type' => 'number', 'label' => __( 'Spacing / Gap (px)', 'advanced-testimonial' ), 'default' => 24, 'min' => 0, 'max' => 80, 'step' => 1, ), 'card_shadow' => array( 'type' => 'select', 'label' => __( 'Card Shadow', 'advanced-testimonial' ), 'default' => 'soft', 'options' => array( 'none' => __( 'None', 'advanced-testimonial' ), 'soft' => __( 'Soft', 'advanced-testimonial' ), 'medium' => __( 'Medium', 'advanced-testimonial' ), 'strong' => __( 'Strong', 'advanced-testimonial' ), ), ), 'button_style' => array( 'type' => 'select', 'label' => __( 'Button Style', 'advanced-testimonial' ), 'default' => 'filled', 'options' => array( 'filled' => __( 'Filled', 'advanced-testimonial' ), 'outline' => __( 'Outline', 'advanced-testimonial' ), ), ), 'avatar_shape' => array( 'type' => 'select', 'label' => __( 'Avatar Shape', 'advanced-testimonial' ), 'default' => 'circle', 'options' => array( 'circle' => __( 'Circle', 'advanced-testimonial' ), 'rounded' => __( 'Rounded', 'advanced-testimonial' ), 'square' => __( 'Square', 'advanced-testimonial' ), ), ), 'star_icon' => array( 'type' => 'select', 'label' => __( 'Rating Icon', 'advanced-testimonial' ), 'default' => 'star', 'options' => array( 'star' => __( 'Star', 'advanced-testimonial' ), 'heart' => __( 'Heart', 'advanced-testimonial' ), ), ), 'default_rating' => array( 'type' => 'number', 'label' => __( 'Default Rating', 'advanced-testimonial' ), 'default' => 0, 'min' => 0, 'max' => 5, 'step' => 1, 'description' => __( 'Used when a testimonial has no rating set (0 = none).', 'advanced-testimonial' ), ), 'marquee_speed' => array( 'type' => 'select', 'label' => __( 'Marquee Scroll Speed', 'advanced-testimonial' ), 'default' => 'normal', 'options' => array( 'slow' => __( 'Slow', 'advanced-testimonial' ), 'normal' => __( 'Normal', 'advanced-testimonial' ), 'fast' => __( 'Fast', 'advanced-testimonial' ), ), 'description' => __( 'Default speed for the Marquee layout. Each block can override this.', 'advanced-testimonial' ), ), 'marquee_width' => array( 'type' => 'number', 'label' => __( 'Marquee Card Width (px)', 'advanced-testimonial' ), 'default' => 340, 'min' => 200, 'max' => 600, 'step' => 10, 'description' => __( 'Default card width for the Marquee layout. Each block can override this.', 'advanced-testimonial' ), ), 'marquee_direction' => array( 'type' => 'select', 'label' => __( 'Marquee Scroll Direction', 'advanced-testimonial' ), 'default' => 'left', 'options' => array( 'left' => __( 'Right to left', 'advanced-testimonial' ), 'right' => __( 'Left to right', 'advanced-testimonial' ), ), 'description' => __( 'Default scroll direction for the Marquee layout. Each block can override this.', 'advanced-testimonial' ), ), 'readmore_lines' => array( 'type' => 'number', 'label' => __( 'Read More — Lines Shown', 'advanced-testimonial' ), 'default' => 4, 'min' => 2, 'max' => 12, 'step' => 1, 'description' => __( 'When "Read more" is enabled on a block, reviews are trimmed to this many lines before the toggle appears.', 'advanced-testimonial' ), ), 'load_more_count' => array( 'type' => 'number', 'label' => __( 'Load More — Batch Size', 'advanced-testimonial' ), 'default' => 6, 'min' => 1, 'max' => 24, 'step' => 1, 'description' => __( 'When "Load more" is enabled on a block, this many testimonials are shown at first and revealed per click.', 'advanced-testimonial' ), ), ), ), 'performance' => array( 'title' => __( 'Performance', 'advanced-testimonial' ), 'fields' => array( 'conditional_assets' => array( 'type' => 'checkbox', 'label' => __( 'Load Assets Only When Needed', 'advanced-testimonial' ), 'default' => 1, 'description' => __( 'Only enqueue CSS/JS on pages that actually render testimonials.', 'advanced-testimonial' ), ), 'cache_queries' => array( 'type' => 'checkbox', 'label' => __( 'Cache Queries', 'advanced-testimonial' ), 'default' => 1, 'description' => __( 'Cache testimonial queries for faster repeat loads.', 'advanced-testimonial' ), ), ), ), 'submission' => array( 'title' => __( 'Submission Form', 'advanced-testimonial' ), 'fields' => array( 'form_enabled' => array( 'type' => 'checkbox', 'label' => __( 'Enable Submission Form', 'advanced-testimonial' ), 'default' => 1, 'description' => __( 'Enable the [at_form] shortcode. Visitors can submit testimonials from the front end.', 'advanced-testimonial' ), ), 'form_auto_publish' => array( 'type' => 'checkbox', 'label' => __( 'Auto-Publish Submissions', 'advanced-testimonial' ), 'default' => 0, 'description' => __( 'Publish testimonials immediately. When off, they are saved as "Pending" for you to review first (recommended).', 'advanced-testimonial' ), ), 'form_require_rating' => array( 'type' => 'checkbox', 'label' => __( 'Require a Rating', 'advanced-testimonial' ), 'default' => 0, ), 'form_show_headline' => array( 'type' => 'checkbox', 'label' => __( 'Show "Review Title" Field', 'advanced-testimonial' ), 'default' => 1, ), 'form_show_company' => array( 'type' => 'checkbox', 'label' => __( 'Show "Company" Field', 'advanced-testimonial' ), 'default' => 1, ), 'form_show_designation' => array( 'type' => 'checkbox', 'label' => __( 'Show "Job Title" Field', 'advanced-testimonial' ), 'default' => 1, ), 'form_show_location' => array( 'type' => 'checkbox', 'label' => __( 'Show "Location" Field', 'advanced-testimonial' ), 'default' => 1, ), 'form_show_email' => array( 'type' => 'checkbox', 'label' => __( 'Show "Email Address" Field', 'advanced-testimonial' ), 'default' => 1, 'description' => __( 'Email is never displayed publicly. Used for admin reference only.', 'advanced-testimonial' ), ), 'form_show_website' => array( 'type' => 'checkbox', 'label' => __( 'Show "Website" Field', 'advanced-testimonial' ), 'default' => 0, ), 'form_notify_email' => array( 'type' => 'email', 'label' => __( 'Notification Email', 'advanced-testimonial' ), 'default' => '', 'description' => __( 'Send a notification to this address on each new submission. Leave blank to use the site admin email.', 'advanced-testimonial' ), ), 'form_success_message' => array( 'type' => 'text', 'label' => __( 'Success Message', 'advanced-testimonial' ), 'default' => '', 'description' => __( 'Message shown after a successful submission. Shortcode attribute "success" overrides this.', 'advanced-testimonial' ), ), ), ), 'advanced' => array( 'title' => __( 'Advanced', 'advanced-testimonial' ), 'fields' => array( 'custom_css' => array( 'type' => 'textarea', 'label' => __( 'Custom CSS', 'advanced-testimonial' ), 'default' => '', 'description' => __( 'Output on the frontend after the plugin styles.', 'advanced-testimonial' ), ), 'delete_data' => array( 'type' => 'checkbox', 'label' => __( 'Remove all plugin data when deleting the plugin', 'advanced-testimonial' ), 'default' => 0, 'description' => __( 'When the plugin is deleted, permanently remove all testimonials, groups, meta, settings and transients. Deactivating never deletes anything. Default: off.', 'advanced-testimonial' ), ), ), ), ); } /** * Default values derived from the schema. * * @return array */ public static function defaults() { $defaults = array(); foreach ( self::schema() as $tab ) { foreach ( $tab['fields'] as $key => $field ) { $defaults[ $key ] = $field['default']; } } return $defaults; } /** * Get all settings merged with defaults. * * The merged array is built once per request: a 9-card grid calls this * ~59 times, and rebuilding the schema (dozens of translation lookups * plus the card-style list) on every call is pure waste. * * @return array */ public static function get_settings() { if ( null === self::$cache ) { $saved = get_option( self::OPTION, array() ); if ( ! is_array( $saved ) ) { $saved = array(); } self::$cache = wp_parse_args( $saved, self::defaults() ); } return self::$cache; } /** * Drop the cached settings array. * * @return void */ public static function flush_cache() { self::$cache = null; } /** * Flush the cache when our own option is deleted (e.g. Tools → reset). * * @param string $option Deleted option name. * @return void */ public static function flush_on_delete( $option ) { if ( self::OPTION === $option ) { self::flush_cache(); } } /** * Get a single setting value. * * @param string $key Setting key. * @param mixed $fallback Value returned when the setting is not set. * @return mixed */ public static function get( $key, $fallback = null ) { $settings = self::get_settings(); return array_key_exists( $key, $settings ) ? $settings[ $key ] : $fallback; } /** * Register the submenu page. * * @return void */ public function add_page() { $this->hook_suffix = (string) add_submenu_page( 'edit.php?post_type=' . CPT::POST_TYPE, __( 'Advanced Testimonial Settings', 'advanced-testimonial' ), __( 'Settings', 'advanced-testimonial' ), 'manage_options', self::PAGE, array( $this, 'render' ) ); } /** * Register the option and its sanitizer. * * @return void */ public function register_setting() { register_setting( self::GROUP, self::OPTION, array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize' ), 'default' => self::defaults(), ) ); } /** * Enqueue settings page assets. * * @param string $hook Current admin page hook. * @return void */ public function enqueue( $hook ) { if ( $hook !== $this->hook_suffix ) { return; } wp_enqueue_style( 'advanced-testimonial-admin', ADVANCED_TESTIMONIAL_URL . 'assets/css/admin.css', array(), Helpers::asset_version( 'assets/css/admin.css' ) ); wp_enqueue_script( 'advanced-testimonial-settings', ADVANCED_TESTIMONIAL_URL . 'assets/js/settings.js', array(), Helpers::asset_version( 'assets/js/settings.js' ), true ); } /** * Render the settings page. * * @return void */ public function render() { if ( ! current_user_can( 'manage_options' ) ) { return; } $schema = self::schema(); $settings = self::get_settings(); $tab_keys = array_keys( $schema ); $first = reset( $tab_keys ); echo '
'; echo '

' . esc_html__( 'Advanced Testimonial Settings', 'advanced-testimonial' ) . '

'; Tools::maybe_notice(); echo '
'; settings_fields( self::GROUP ); // Tab navigation. echo ''; // Tab panels. foreach ( $schema as $tab_key => $tab ) { $hidden = ( $tab_key === $first ) ? '' : ' style="display:none"'; echo '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static markup above is safe. echo ''; foreach ( $tab['fields'] as $key => $field ) { $value = isset( $settings[ $key ] ) ? $settings[ $key ] : $field['default']; echo ''; echo ''; echo ''; echo ''; } echo ''; echo '
'; } echo '
'; submit_button(); echo '
'; echo '
'; // Tools panel — rendered outside the settings form (it has its own forms). echo ''; echo '
'; } /** * Render a single settings field. * * @param string $key Field key. * @param array $field Field definition. * @param mixed $value Current value. * @return void */ private function render_field( $key, $field, $value ) { $name = self::OPTION . '[' . $key . ']'; $id = 'at-set-' . $key; switch ( $field['type'] ) { case 'checkbox': echo ''; break; case 'select': echo ''; break; case 'color': echo ''; echo ''; break; case 'number': $min = isset( $field['min'] ) ? ' min="' . esc_attr( $field['min'] ) . '"' : ''; $max = isset( $field['max'] ) ? ' max="' . esc_attr( $field['max'] ) . '"' : ''; $step = isset( $field['step'] ) ? ' step="' . esc_attr( $field['step'] ) . '"' : ''; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- attributes escaped above. break; case 'textarea': echo ''; break; case 'email': echo ''; break; default: echo ''; break; } } /** * Sanitize submitted settings. * * @param mixed $input Raw input. * @return array */ public function sanitize( $input ) { if ( ! is_array( $input ) ) { $input = array(); } $clean = array(); foreach ( self::schema() as $tab ) { foreach ( $tab['fields'] as $key => $field ) { $raw = isset( $input[ $key ] ) ? $input[ $key ] : null; switch ( $field['type'] ) { case 'checkbox': $clean[ $key ] = ( '1' === (string) $raw ) ? 1 : 0; break; case 'select': $clean[ $key ] = array_key_exists( (string) $raw, $field['options'] ) ? (string) $raw : $field['default']; break; case 'color': $color = sanitize_hex_color( (string) $raw ); $clean[ $key ] = $color ? $color : $field['default']; break; case 'number': $num = is_numeric( $raw ) ? (int) $raw : (int) $field['default']; if ( isset( $field['min'] ) && $num < $field['min'] ) { $num = (int) $field['min']; } if ( isset( $field['max'] ) && $num > $field['max'] ) { $num = (int) $field['max']; } $clean[ $key ] = $num; break; case 'textarea': $clean[ $key ] = wp_strip_all_tags( (string) $raw ); break; case 'email': $mail = sanitize_email( (string) $raw ); $clean[ $key ] = is_email( $mail ) ? $mail : $field['default']; break; default: $clean[ $key ] = sanitize_text_field( (string) $raw ); break; } } } return $clean; } }