' ); // 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 '' . esc_html( $text ) . '';
}
// 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' ) . '
' . 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() ) ) . '
' . 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'] ) . '
';
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 '
';
}
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' ) . '