namespace, '/settings', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'getSettings' ], 'permission_callback' => [ $this, 'checkPermission' ], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'saveSettings' ], 'permission_callback' => [ $this, 'checkPermission' ], 'args' => [ 'settings' => [ 'required' => false, 'validate_callback' => function ( $value ) { return is_array( $value ) || is_object( $value ) || null === $value; }, ], ], ], ] ); register_rest_route( $this->namespace, '/settings/meta', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'getSettingsMeta' ], 'permission_callback' => [ $this, 'checkPermission' ], ], ] ); // Product search for order-bump selectors. register_rest_route( $this->namespace, '/products/search', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'searchProducts' ], 'permission_callback' => [ $this, 'checkPermission' ], 'args' => [ 'q' => [ 'required' => true, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function ( $value ) { return '' !== trim( (string) $value ); }, ], ], ], ] ); } public function getSettings(): \WP_REST_Response { $settings = []; foreach ( $this->allowedKeys as $key ) { // Existing stores displayed order bumps before the module switch existed. $settings[ $key ] = 'arrayfunnel_order_bumps_enabled' === $key ? get_option( $key, 'yes' ) : get_option( $key ); } return $this->success( $settings ); } public function getSettingsMeta(): \WP_REST_Response { return $this->success( $this->settingsMeta() ); } public function saveSettings( \WP_REST_Request $request ): \WP_REST_Response { $params = $request->get_json_params() ?? $request->get_params(); if ( ! is_array( $params ) ) { return $this->error( __( 'Invalid settings payload.', 'arrayfunnel' ), 400, 'arrayfunnel_invalid_payload' ); } $saved = []; foreach ( $this->allowedKeys as $key ) { if ( ! array_key_exists( $key, $params ) ) { continue; } $value = $this->sanitizeSetting( $key, $params[ $key ] ); if ( false === update_option( $key, $value ) ) { // WordPress returns false for unchanged values as well as write // failures. A failed write must not advance the saved timestamp. $missing = new \stdClass(); $stored = get_option( $key, $missing ); $matches = is_array( $value ) ? $stored === $value : is_scalar( $stored ) && (string) $stored === (string) $value; if ( $stored === $missing || ! $matches ) { return $this->error( __( 'Could not save settings. Please try again.', 'arrayfunnel' ), 500, 'arrayfunnel_settings_save_failed' ); } } $saved[ $key ] = $value; } if ( ! empty( $saved ) ) { // UTC Unix time is stored separately from editable settings. WordPress // applies the site's timezone and display formats when it is read. update_option( 'arrayfunnel_settings_last_saved_at', time(), false ); } $this->clearAnalyticsCache(); return $this->success( [ 'saved' => $saved, 'meta' => $this->settingsMeta() ] ); } /** Saved-time metadata is read-only and never inferred from activation dates. */ private function settingsMeta(): array { $timestamp = max( 0, (int) get_option( 'arrayfunnel_settings_last_saved_at', 0 ) ); $format = (string) get_option( 'date_format', 'F j, Y' ) . ' ' . (string) get_option( 'time_format', 'g:i a' ); return [ 'last_saved_at' => $timestamp, 'last_saved_at_formatted' => $timestamp ? wp_date( $format, $timestamp ) : '', ]; } public function searchProducts( \WP_REST_Request $request ): \WP_REST_Response { $query = (string) $request->get_param( 'q' ); $needle = strtolower( trim( $query ) ); $results = wc_get_products( [ 'status' => 'publish', 'limit' => 20, 'orderby' => 'relevance', 'search' => $query, 'return' => 'objects', ] ); $products = array_map( function ( \WC_Product $p ) { $price_value = (float) $p->get_price(); $image_id = absint( $p->get_image_id() ); return [ 'id' => $p->get_id(), 'name' => $p->get_name(), 'price_value' => $price_value, 'price_formatted' => wc_price( $price_value ), 'image' => wp_get_attachment_image_url( $image_id, 'thumbnail' ) ?: wc_placeholder_img_src(), 'sku' => $p->get_sku(), 'stock' => $p->get_stock_status(), ]; }, $results ); if ( $needle !== '' && count( $products ) > 1 ) { $starts_with = static function ( string $haystack, string $prefix ): bool { return $prefix !== '' && 0 === strpos( $haystack, $prefix ); }; $contains = static function ( string $haystack, string $fragment ): bool { return $fragment !== '' && false !== strpos( $haystack, $fragment ); }; $score_for = static function ( array $product ) use ( $needle, $starts_with, $contains ): int { $name = strtolower( (string) ( $product['name'] ?? '' ) ); $sku = strtolower( (string) ( $product['sku'] ?? '' ) ); if ( $name === $needle || $sku === $needle ) { return 0; } if ( $starts_with( $name, $needle ) ) { return 1; } if ( $contains( $name, $needle ) ) { return 2; } if ( $starts_with( $sku, $needle ) ) { return 3; } if ( $contains( $sku, $needle ) ) { return 4; } return 9; }; foreach ( $products as $i => $product ) { $products[ $i ]['_score'] = $score_for( $product ); } usort( $products, static function ( array $a, array $b ) { $scoreA = (int) ( $a['_score'] ?? 9 ); $scoreB = (int) ( $b['_score'] ?? 9 ); if ( $scoreA !== $scoreB ) { return $scoreA <=> $scoreB; } return strcasecmp( (string) ( $a['name'] ?? '' ), (string) ( $b['name'] ?? '' ) ); } ); foreach ( $products as $i => $product ) { unset( $products[ $i ]['_score'] ); } } return $this->success( $products ); } /** * Sanitize a single setting according to the value it is allowed to hold. * * @param string $key Option key, already checked against the allow list. * @param mixed $value Raw value from the request. * * @return mixed */ private function sanitizeSetting( string $key, $value ) { $yes_no_keys = [ 'arrayfunnel_order_bumps_enabled', 'arrayfunnel_side_cart_enabled', 'arrayfunnel_checkout_optimized', 'arrayfunnel_checkout_countdown', 'arrayfunnel_checkout_trust_badges', ]; $int_keys = [ 'arrayfunnel_analytics_retention_days', 'arrayfunnel_checkout_countdown_mins', 'arrayfunnel_abandon_threshold', 'arrayfunnel_abandon_email_1_delay', 'arrayfunnel_abandon_email_2_delay', 'arrayfunnel_abandon_email_3_delay', ]; if ( in_array( $key, $yes_no_keys, true ) ) { return $value === 'yes' || $value === true || $value === 1 ? 'yes' : 'no'; } if ( $key === 'arrayfunnel_side_cart_theme' ) { $allowed = [ 'light', 'graphite' ]; $theme = sanitize_key( (string) $value ); return in_array( $theme, $allowed, true ) ? $theme : 'light'; } if ( $key === 'arrayfunnel_side_cart_radius' ) { $allowed = [ 'soft', 'balanced', 'expressive' ]; $radius = sanitize_key( (string) $value ); return in_array( $radius, $allowed, true ) ? $radius : 'balanced'; } if ( $key === 'arrayfunnel_side_cart_accent' ) { $allowed = [ 'emerald', 'black', 'sky' ]; $accent = sanitize_key( (string) $value ); return in_array( $accent, $allowed, true ) ? $accent : 'emerald'; } if ( $key === 'arrayfunnel_side_cart_open_mode' ) { $allowed = [ 'always', 'smart' ]; $mode = sanitize_key( (string) $value ); return in_array( $mode, $allowed, true ) ? $mode : 'always'; } if ( in_array( $key, $int_keys, true ) ) { return absint( $value ); } if ( $key === 'arrayfunnel_field_settings' ) { return is_array( $value ) ? $this->sanitizeFieldSettings( $value ) : []; } if ( $key === 'arrayfunnel_abandon_email_from_email' ) { return sanitize_email( (string) $value ); } if ( $key === 'arrayfunnel_side_cart_free_shipping' ) { return '' === trim( (string) $value ) ? '' : (string) max( 0, (float) $value ); } return sanitize_text_field( (string) $value ); } /** * Sanitize the checkout field overrides. * * The shape is fixed: a group name, then a field key, then a scalar override. * * @param array $settings Raw field settings. * * @return array> */ private function sanitizeFieldSettings( array $settings ): array { $clean = []; foreach ( [ 'hidden', 'required', 'labels', 'priority' ] as $group ) { if ( ! isset( $settings[ $group ] ) || ! is_array( $settings[ $group ] ) ) { continue; } foreach ( $settings[ $group ] as $field_key => $field_value ) { if ( ! is_scalar( $field_value ) && null !== $field_value ) { continue; } $field_key = sanitize_key( (string) $field_key ); if ( '' === $field_key ) { continue; } if ( 'labels' === $group ) { $clean[ $group ][ $field_key ] = sanitize_text_field( (string) $field_value ); } elseif ( 'priority' === $group ) { $clean[ $group ][ $field_key ] = absint( $field_value ); } else { $clean[ $group ][ $field_key ] = (bool) $field_value; } } } return $clean; } private function clearAnalyticsCache(): void { $windows = [ 7, 30, 90, 365 ]; foreach ( $windows as $days ) { delete_transient( "arrayfunnel_overview_{$days}" ); delete_transient( "arrayfunnel_revenue_chart_{$days}" ); delete_transient( "arrayfunnel_events_log_{$days}_50_0" ); } } }