' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- a fixed menu icon $GLOBALS['spotfind_hook'] = add_menu_page( 'SpotFind', 'SpotFind', 'manage_options', 'spotfind', 'spotfind_settings_page', $icon, 58 ); add_submenu_page( 'spotfind', 'SpotFind', 'Overview', 'manage_options', 'spotfind', 'spotfind_settings_page' ); } ); add_filter( 'plugin_action_links_' . plugin_basename( SPOTFIND_DIR . 'spotfind.php' ), static function ( $links ) { array_unshift( $links, '' . esc_html__( 'Settings', 'spotfind' ) . '' ); return $links; } ); const SPOTFIND_THEMES = array( 'glass' => 'Glass (Apple-style, follows light or dark)', 'match' => 'Match my theme', 'light' => 'Light', 'dark' => 'Dark', 'auto' => 'Follow the visitor\'s device (light or dark)', 'contrast' => 'High contrast', ); add_action( 'admin_init', static function () { register_setting( 'spotfind', 'spotfind_options', array( 'sanitize_callback' => static function ( $in ) { $d = spotfind_defaults(); $in = is_array( $in ) ? $in : array(); $out = array(); foreach ( array( 'voice', 'images', 'typo', 'recent', 'popular', 'termrows', 'takeover', 'mrp' ) as $k ) { $out[ $k ] = empty( $in[ $k ] ) ? 0 : 1; } $out['rows'] = max( 3, min( 12, (int) ( $in['rows'] ?? $d['rows'] ) ) ); $out['min'] = max( 1, min( 4, (int) ( $in['min'] ?? $d['min'] ) ) ); $out['rebuild'] = in_array( $in['rebuild'] ?? '', array( 'auto', 'hourly', 'manual' ), true ) ? $in['rebuild'] : 'auto'; $out['theme'] = isset( SPOTFIND_THEMES[ $in['theme'] ?? '' ] ) ? $in['theme'] : 'match'; $out['accent'] = (string) sanitize_hex_color( $in['accent'] ?? '' ); $out['radius'] = ( '' === ( $in['radius'] ?? '' ) ) ? '' : max( 0, min( 28, (int) $in['radius'] ) ); $out['oos'] = in_array( $in['oos'] ?? '', array( 'show', 'lower', 'hide' ), true ) ? $in['oos'] : 'lower'; $out['density'] = 'compact' === ( $in['density'] ?? '' ) ? 'compact' : 'comfortable'; $out['synonyms'] = sanitize_textarea_field( $in['synonyms'] ?? '' ); // extra CSS selectors for search boxes the theme hides from us: up to 5, plain selector characters only $sel = array(); foreach ( array_slice( preg_split( '/\R/', (string) ( $in['selectors'] ?? '' ) ), 0, 5 ) as $line ) { $line = trim( sanitize_text_field( $line ) ); if ( '' !== $line && strlen( $line ) <= 120 && preg_match( '/^[A-Za-z0-9_\-\s.#\[\]="\'>~+,:*()^$|]+$/', $line ) ) { $sel[] = $line; } } $out['selectors'] = implode( "\n", $sel ); return $out; }, ) ); } ); // Maintenance buttons: each is a nonce-checked admin-post action that ends back on the settings page. foreach ( array( 'rebuild' => 'spotfind_rebuild', 'resync' => 'spotfind_full', 'optimize' => 'spotfind_optimize' ) as $spotfind_act => $spotfind_fn ) { add_action( "admin_post_spotfind_$spotfind_act", static function () use ( $spotfind_act, $spotfind_fn ) { check_admin_referer( "spotfind_$spotfind_act" ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Not allowed.', 'spotfind' ), 403 ); } $ok = $spotfind_fn(); wp_safe_redirect( add_query_arg( array( 'page' => 'spotfind', 'fs_done' => $spotfind_act . ( false === $ok ? '_failed' : '' ) ), admin_url( 'admin.php' ) ) ); exit; } ); } add_action( 'admin_enqueue_scripts', static function ( $hook ) { if ( ( $GLOBALS['spotfind_hook'] ?? '' ) === $hook ) { // only this screen loads our files wp_enqueue_style( 'spotfind-admin', SPOTFIND_URL . 'assets/admin.css', array(), SPOTFIND_VERSION ); wp_enqueue_script( 'spotfind-admin', SPOTFIND_URL . 'assets/admin.js', array(), SPOTFIND_VERSION, true ); } } ); function spotfind_kb( $bytes ): string { return null === $bytes ? 'n/a' : number_format_i18n( $bytes / 1024, 0 ); } // A small "?" that opens a plain-language explanation under the option. Works with the keyboard and screen readers. function spotfind_tip( string $text, string $p = 'lda' ): string { static $n = 0; $id = $p . '-tip-' . ( ++$n ); return ''; } // The guide text for each option, by option key. function spotfind_guide( string $key ): string { $g = array( 'theme' => __( 'How the dropdown looks. "Match my theme" copies your search box so it looks native, and is the safest choice. Glass, Light, Dark, Auto and High contrast are fixed looks you can pick instead.', 'spotfind' ), 'accent' => __( 'The highlight colour for the percent-off tag and the selected row. Leave it empty to copy the colour of your theme\'s buttons.', 'spotfind' ), 'radius' => __( 'How round the dropdown corners are. Leave it empty to follow your search box.', 'spotfind' ), 'oos' => __( 'Most stores keep out of stock products visible, so shoppers can still find them and ask. Show treats them like any other product. Show lower puts in-stock products first. Hide leaves them out of search until they are back.', 'spotfind' ), 'density' => __( 'Comfortable gives each result more room. Compact fits more results on a small screen.', 'spotfind' ), 'rows' => __( 'How many products show in the dropdown, from 3 to 12. More results make a taller dropdown, they do not slow the search.', 'spotfind' ), 'min' => __( 'How many letters a shopper types before results appear. 1 feels fastest. Use 2 or 3 if you want fewer, calmer suggestions.', 'spotfind' ), 'images' => __( 'Shows a small picture beside each result. Turn it off for smaller files and a text-only list. The pictures load only for the results on screen.', 'spotfind' ), 'mrp' => __( 'On sale products show the sale price with the regular price struck through and the percent off.', 'spotfind' ), 'termrows' => __( 'Shows matching categories and brands as small chips above the products, so shoppers can jump to them.', 'spotfind' ), 'recent' => __( 'When the search box is empty, shows what this shopper searched last. It is saved only in their own browser.', 'spotfind' ), 'popular' => __( 'When the search box is empty, shows your best sellers. It needs sales data from WooCommerce.', 'spotfind' ), 'typo' => __( 'Fixes spelling slips, for example "cannon" finds Canon. It runs in the browser and only when a search finds nothing.', 'spotfind' ), 'voice' => __( 'Adds a microphone button. The shopper\'s browser listens and turns speech into text. Turn it off if you prefer no audio at all.', 'spotfind' ), 'takeover' => __( 'When a shopper presses Enter, the results page lists the same products in the same order as the dropdown. Off means WooCommerce\'s normal search page.', 'spotfind' ), 'synonyms' => __( 'Words that mean the same thing. Write one group per line, for example "tv, television". Searching either word finds both. The index rebuilds in the background to apply them.', 'spotfind' ), 'selectors' => __( 'SpotFind attaches to your theme\'s search box on its own. If a search box somewhere else is not picked up, add its CSS selector here, one per line, for example .my-header input.search-field', 'spotfind' ), 'rebuild' => __( 'How soon the search file is rebuilt after a product changes. The product row is always updated at once. "A minute" suits most stores. Choose "An hour" for busy stores or big imports.', 'spotfind' ), 'search_file' => __( 'The compressed word list the browser downloads the first time someone searches. It is how the search works with no server call.', 'spotfind' ), 'cards' => __( 'Product cards hold each product\'s name, price, link and small picture. They are saved in files of 64 products, and a shopper\'s browser downloads only the files needed for the results on screen. Pictures appear when "Product images" is on.', 'spotfind' ), 'build' => __( 'How long the last background rebuild took. It runs outside your visitors\' page loads.', 'spotfind' ), ); return $g[ $key ] ?? ''; } // iOS-style switch with a title, a line of help and a "?" guide. function spotfind_switch( string $key, array $o, string $title, string $help = '' ): string { $tip = spotfind_guide( $key ); return ''; // phpcs:ignore WordPress.Security.EscapeOutput -- built from escaped parts } // A label, a help line, a "?" guide and a control on one row. The guide is found from the control's option name. // A card heading with a "?" guide; the explanation opens under the heading. function spotfind_h2( string $title, string $key, string $p = 'lda' ): string { $t = spotfind_tip( spotfind_guide( $key ), $p ); $cut = strpos( $t, '' . esc_html( $title ) . substr( $t, 0, $cut ) . '' . substr( $t, $cut ); // phpcs:ignore WordPress.Security.EscapeOutput -- built from escaped parts } /** A banner when WordPress already knows a newer version of this plugin. It reads the update list WordPress keeps; it makes no request of its own. */ function spotfind_update_banner( string $file, string $cls ): string { $t = get_site_transient( 'update_plugins' ); $u = is_object( $t ) && isset( $t->response[ $file ] ) ? $t->response[ $file ] : null; if ( ! $u || ! current_user_can( 'update_plugins' ) ) { return ''; } $v = (string) ( $u->new_version ?? $u->version ?? '' ); $url = wp_nonce_url( self_admin_url( 'update.php?action=upgrade-plugin&plugin=' . rawurlencode( $file ) ), 'upgrade-plugin_' . $file ); return '
' . esc_html( $v ? sprintf( /* translators: %s: version number */ __( 'Version %s is ready.', 'spotfind' ), $v ) : __( 'An update is ready.', 'spotfind' ) ) . ' ' . esc_html__( 'Update to get the latest fixes and features.', 'spotfind' ) . '' . esc_html__( 'Update now', 'spotfind' ) . '
'; } function spotfind_field( string $title, string $control, string $help = '' ): string { $tip = preg_match( '/name="spotfind_options\[(\w+)\]/', $control, $m ) ? spotfind_guide( $m[1] ) : ''; return '
' . esc_html( $title ) . '' . ( $tip ? spotfind_tip( $tip ) : '' ) . ( $help ? '' . esc_html( $help ) . '' : '' ) . '
' . $control . '
'; // phpcs:ignore WordPress.Security.EscapeOutput -- controls are built from escaped parts by the caller } function spotfind_settings_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $o = spotfind_options(); $meta = get_option( 'spotfind_meta' ); $st = spotfind_table_stats(); $next = wp_next_scheduled( 'spotfind_rebuild' ); $done = isset( $_GET['fs_done'] ) ? sanitize_key( wp_unslash( $_GET['fs_done'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification -- display only $notes = array( 'rebuild' => array( '', __( 'Index rebuilt.', 'spotfind' ) ), 'resync' => array( '', __( 'Table resynced from your products and index rebuilt.', 'spotfind' ) ), 'optimize' => array( '', __( 'Table optimised.', 'spotfind' ) ), 'optimize_failed' => array( 'warn', __( 'This database does not support OPTIMIZE TABLE. Nothing was changed.', 'spotfind' ) ), ); $count = $meta ? (int) $meta['count'] : 0; $late = $next && $next < time() - 900; echo '
'; if ( isset( $notes[ $done ] ) ) { echo '
' . esc_html( $notes[ $done ][1] ) . '
'; } if ( isset( $_GET['settings-updated'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification -- display only echo '
' . esc_html__( 'Saved.', 'spotfind' ) . '
'; } echo spotfind_update_banner( 'spotfind/spotfind.php', 'lda' ); // phpcs:ignore WordPress.Security.EscapeOutput echo '

SpotFind

' . esc_html__( 'Instant product search that never slows your store.', 'spotfind' ) . '
'; echo '' . esc_html( $meta ? __( 'Index ready', 'spotfind' ) : __( 'No index yet', 'spotfind' ) ) . '
'; echo ''; // ---- Overview echo '
'; printf( '
%s
0
%s%s
', esc_html__( 'Products in your search', 'spotfind' ), absint( $count ), esc_html( $meta ? sprintf( /* translators: %s: time */ __( 'Built %s ago', 'spotfind' ), human_time_diff( $meta['time'] ) ) : __( 'Not built yet', 'spotfind' ) ), esc_html( $next ? sprintf( /* translators: %s: time */ __( 'Next rebuild in about %s', 'spotfind' ), human_time_diff( time(), $next ) ) : __( 'No rebuild queued', 'spotfind' ) ) ); echo '
'; printf( '
%s0KB
', esc_html__( 'Search file', 'spotfind' ) . spotfind_tip( spotfind_guide( 'search_file' ) ), (int) round( ( $meta ? $meta['bytes'] : 0 ) / 1024 ), absint( $meta ? min( 100, (int) ( $meta['bytes'] / 10240 ) ) : 0 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts printf( '
%s0KB
', esc_html__( 'Product cards', 'spotfind' ) . spotfind_tip( spotfind_guide( 'cards' ) ), (int) round( ( $meta ? ( $meta['card_bytes'] ?? 0 ) : 0 ) / 1024 ), absint( $meta ? min( 100, (int) ( ( $meta['card_bytes'] ?? 0 ) / 10240 ) ) : 0 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts printf( '
%s0ms
', esc_html__( 'Last build time', 'spotfind' ) . spotfind_tip( spotfind_guide( 'build' ) ), absint( $meta ? (int) round( ( $meta['secs'] ?? 0 ) * 1000 ) : 0 ), 100 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts echo '
'; if ( $late ) { echo '

' . esc_html__( 'A rebuild is overdue', 'spotfind' ) . '

' . esc_html__( 'WP-Cron may be disabled on this site. Use "Rebuild index now" below or run a real server cron for wp-cron.php.', 'spotfind' ) . '

'; } do_action( 'spotfind_overview_extra' ); // SpotFind Pro puts its search numbers here, so both plugins show one set of figures echo '

' . esc_html__( 'Where it works', 'spotfind' ) . '

' . esc_html__( 'SpotFind attaches to your theme\'s own search box by itself: in the header, a sidebar, a mobile menu, a pop-up, the WooCommerce product search widget and the block editor Search block. Nothing to install in your theme.', 'spotfind' ) . '

'; echo '
1' . esc_html__( 'Check the status above says Index ready.', 'spotfind' ) . '
2' . esc_html__( 'Leave the look on Match my theme, or pick one under Appearance.', 'spotfind' ) . '
3' . esc_html__( 'Open your shop and click the search box.', 'spotfind' ) . '
'; echo '

' . wp_kses( __( 'Want a search box somewhere else, such as a page or a widget? Use the shortcode [spotfind_search]. A box that is not picked up? Add its CSS selector under Dropdown, Other search boxes.', 'spotfind' ), array( 'code' => array() ) ) . '

'; echo '

' . esc_html__( 'How it stays fresh', 'spotfind' ) . '

' . esc_html__( 'When a product is added, edited, trashed or its stock changes, only that product’s row in the SpotFind table is updated. The index files are then rebuilt from that table. Visitors never trigger a rebuild. The page loads nothing from SpotFind until a visitor clicks or focuses a search box, and the search runs in a background thread of their browser.', 'spotfind' ) . '

'; echo '

' . esc_html__( 'Maintenance', 'spotfind' ) . '

' . esc_html__( 'This is the plugin’s own table. Your WordPress posts tables are never searched or changed.', 'spotfind' ) . ' ' . esc_html( $st['name'] ) . '

'; echo '
'; foreach ( array( __( 'Rows', 'spotfind' ) => (string) (int) $st['rows'], __( 'Data', 'spotfind' ) => spotfind_kb( $st['data'] ) . ' KB', __( 'Indexes', 'spotfind' ) => spotfind_kb( $st['index'] ) . ' KB', __( 'Reclaimable', 'spotfind' ) => spotfind_kb( $st['free'] ) . ' KB' ) as $k => $v ) { echo '
' . esc_html( $k ) . '' . esc_html( $v ) . '
'; } echo '
'; foreach ( array( 'rebuild' => array( __( 'Rebuild index now', 'spotfind' ), __( 'Rebuilds the search files from the table.', 'spotfind' ), 'pri' ), 'resync' => array( __( 'Resync table from products', 'spotfind' ), __( 'Reads every product again, fixes the table, then rebuilds. Use after bulk edits made outside WordPress or a database import.', 'spotfind' ), '' ), 'optimize' => array( __( 'Optimise table', 'spotfind' ), __( 'Defragments the table and frees unused space. Safe to run any time.', 'spotfind' ), '' ), ) as $act => $txt ) { echo '
' . esc_html( $txt[0] ) . '' . esc_html( $txt[1] ) . '
'; wp_nonce_field( "spotfind_$act" ); echo '
'; } if ( ! defined( 'SPOTFIND_PRO_VERSION' ) ) { // one quiet card; the free plugin is complete without it echo '

' . esc_html__( 'Go further with SpotFind Pro', 'spotfind' ) . '

' . esc_html__( 'See what shoppers search for and what they cannot find, pin products to the top, add redirects, and control what is indexed. A separate plugin from Flying Star.', 'spotfind' ) . '

' . esc_html__( 'See Pro', 'spotfind' ) . '
'; } echo '
'; // ---- Settings form echo '
'; settings_fields( 'spotfind' ); $themes = array( 'match' => array( __( 'Match my theme', 'spotfind' ), __( 'Default. Copies the search box’s font and colours', 'spotfind' ) ), 'glass' => array( __( 'Glass', 'spotfind' ), __( 'Frosted, follows light or dark', 'spotfind' ) ), 'light' => array( __( 'Light', 'spotfind' ), '' ), 'dark' => array( __( 'Dark', 'spotfind' ), '' ), 'auto' => array( __( 'Auto', 'spotfind' ), __( 'Follows the visitor’s device', 'spotfind' ) ), 'contrast' => array( __( 'High contrast', 'spotfind' ), '' ), ); echo '
' . spotfind_h2( __( 'Style', 'spotfind' ), 'theme' ) . '

' . esc_html__( 'Pick how the dropdown looks. By default it matches your theme: it reads the search box’s own font, corners and colours so it looks native. Glass is a frosted, translucent panel.', 'spotfind' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts foreach ( $themes as $k => $t ) { echo ''; } echo '

' . esc_html__( 'Details', 'spotfind' ) . '

'; $acc = (string) ( $o['accent'] ?? '' ); echo spotfind_field( __( 'Accent colour', 'spotfind' ), '', __( 'Leave empty to take it from your theme’s buttons.', 'spotfind' ) ); // phpcs:ignore WordPress.Security.EscapeOutput echo spotfind_field( __( 'Corner radius', 'spotfind' ), ' px', __( 'Leave empty to follow the search box.', 'spotfind' ) ); // phpcs:ignore WordPress.Security.EscapeOutput echo spotfind_field( __( 'Out of stock products', 'spotfind' ), '
' ); // phpcs:ignore WordPress.Security.EscapeOutput echo spotfind_field( __( 'Density', 'spotfind' ), '
' ); // phpcs:ignore WordPress.Security.EscapeOutput echo '
'; echo '

' . esc_html__( 'Results', 'spotfind' ) . '

'; echo spotfind_field( __( 'Results shown', 'spotfind' ), '' ); // phpcs:ignore WordPress.Security.EscapeOutput echo spotfind_field( __( 'Start after', 'spotfind' ), ' ' . esc_html__( 'characters', 'spotfind' ) ); // phpcs:ignore WordPress.Security.EscapeOutput echo '

' . esc_html__( 'What it shows', 'spotfind' ) . '

'; echo spotfind_switch( 'images', $o, __( 'Product images', 'spotfind' ), __( 'Card files are smaller when off.', 'spotfind' ) ) . spotfind_switch( 'mrp', $o, __( 'Sale price with regular price struck through', 'spotfind' ), __( 'Also shows the percent off.', 'spotfind' ) ) . spotfind_switch( 'termrows', $o, __( 'Category and brand rows', 'spotfind' ) ) . spotfind_switch( 'recent', $o, __( 'Recent searches on an empty box', 'spotfind' ) ) . spotfind_switch( 'popular', $o, __( 'Popular products on an empty box', 'spotfind' ), __( 'Needs sales data.', 'spotfind' ) ) . spotfind_switch( 'typo', $o, __( 'Typo correction', 'spotfind' ) ); // phpcs:ignore WordPress.Security.EscapeOutput echo '

' . esc_html__( 'Voice and results page', 'spotfind' ) . '

'; echo spotfind_switch( 'voice', $o, __( 'Microphone button', 'spotfind' ), __( 'Uses the visitor’s browser speech recognition. Chrome sends the spoken audio to Google, Safari to Apple. Search itself never leaves your site. Hidden on browsers without support.', 'spotfind' ) ) . spotfind_switch( 'takeover', $o, __( 'Rank the full results page with this search', 'spotfind' ), __( 'Off: the results page uses WooCommerce’s normal search.', 'spotfind' ) ); // phpcs:ignore WordPress.Security.EscapeOutput echo '
' . spotfind_h2( __( 'Synonyms', 'spotfind' ), 'synonyms' ) . '

' . esc_html__( 'One group per line, single words separated by commas. Applied the next time the index rebuilds.', 'spotfind' ) . '

' . spotfind_h2( __( 'Other search boxes', 'spotfind' ), 'selectors' ) . '

' . esc_html__( 'Optional. Only needed if a search box is not picked up automatically.', 'spotfind' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts $rb = array( 'auto' => array( __( 'A minute after a change', 'spotfind' ), __( 'Default. Best for most stores.', 'spotfind' ) ), 'hourly' => array( __( 'An hour after a change', 'spotfind' ), __( 'Busy stores and big imports.', 'spotfind' ) ), 'manual' => array( __( 'Only daily and when I press the button', 'spotfind' ), __( 'Full control.', 'spotfind' ) ), ); echo '
' . spotfind_h2( __( 'Rebuild the index', 'spotfind' ), 'rebuild' ) . '

' . esc_html__( 'Rebuilds run in the background, never during a visitor’s page load. The table row for a product is always updated straight away.', 'spotfind' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts foreach ( $rb as $k => $t ) { echo ''; } echo '
'; echo '
' . esc_html__( 'Changes apply to the next index build.', 'spotfind' ) . '
' . '
'; }