*/ public static function business_types(): array { return Business::business_types(); } /** @return array */ public static function defaults(): array { return Business::defaults(); } public function register( Container $container ): void {} public function boot( Container $container ): void { add_action( 'rest_api_init', array( $this, 'routes' ) ); add_filter( 'airanksuite/schema/graph', array( $this, 'schema_graph' ), 10, 2 ); add_shortcode( 'airanksuite_business_address', array( $this, 'shortcode_address' ) ); add_shortcode( 'airanksuite_business_hours', array( $this, 'shortcode_hours' ) ); add_shortcode( 'airanksuite_business_phone', array( $this, 'shortcode_phone' ) ); add_shortcode( 'airanksuite_locations', array( $this, 'shortcode_locations' ) ); add_shortcode( 'airanksuite_open_now', array( $this, 'shortcode_open_now' ) ); add_shortcode( 'airanksuite_business_map', array( $this, 'shortcode_map' ) ); add_shortcode( 'airanksuite_services', array( $this, 'shortcode_services' ) ); // A weekly re-read of the Google listing, so the rating and review // count become a trend rather than a number. One Places call a week on // the customer's own key; nothing runs until a place is chosen. add_action( 'airanksuite_local_listing_refresh', array( $this, 'refresh_listing' ) ); \AiRankSuite\Support\Cron::ensure( 'airanksuite_local_listing_refresh', 'weekly', time() + DAY_IN_SECONDS ); } /** Cron: refresh the stored Google listing when a place is configured. */ public function refresh_listing(): void { $settings = $this->settings(); $place_id = (string) ( $settings['google_place_id'] ?? '' ); if ( '' === $place_id || ! Listings::has_key() ) { return; } Listings::fetch( $place_id ); } /** * Add LocalBusiness node(s) to the JSON-LD graph on the home page. * * @param array> $graph Existing nodes. * @param \WP_Post|null $post Current post. * @return array> */ public function schema_graph( array $graph, $post = null ): array { $settings = $this->settings(); if ( empty( $settings['enabled'] ) || ! ( is_front_page() || is_home() ) ) { return $graph; } return array_merge( $graph, Business::graph( $settings, home_url( '/' ) ) ); } /** * @param array|string $atts Attributes. */ public function shortcode_address( $atts = array() ): string { $s = $this->settings(); if ( empty( $s['enabled'] ) ) { return ''; } $parts = array_filter( array( (string) $s['name'], trim( (string) $s['street'] . ' ' . (string) ( $s['street2'] ?? '' ) ), trim( (string) $s['postal_code'] . ' ' . (string) $s['city'] ), (string) $s['country'], ) ); if ( empty( $parts ) ) { return ''; } return '
' . implode( '
', array_map( 'esc_html', $parts ) ) . '
'; } /** * @param array|string $atts Attributes. */ public function shortcode_phone( $atts = array() ): string { $s = $this->settings(); if ( empty( $s['enabled'] ) || '' === trim( (string) $s['phone'] ) ) { return ''; } $phone = (string) $s['phone']; return '' . esc_html( $phone ) . ''; } /** * @param array|string $atts Attributes. */ public function shortcode_hours( $atts = array() ): string { $s = $this->settings(); if ( empty( $s['enabled'] ) ) { return ''; } $atts = shortcode_atts( array( 'special' => 'yes' ), (array) $atts, 'airanksuite_business_hours' ); $labels = $this->day_labels(); $rows = ''; foreach ( Business::DAYS as $day ) { $slot = (array) ( $s['hours'][ $day ] ?? array() ); $value = ! empty( $slot['closed'] ) ? __( 'Closed', 'ai-rank-suite' ) : $this->slot_label( $slot ); $rows .= '' . esc_html( $labels[ $day ] ) . '' . esc_html( $value ) . ''; } // Upcoming closures belong next to the week, not on a page nobody // visits. Only future ones, and only a handful. if ( 'yes' === $atts['special'] ) { $today = current_time( 'Y-m-d' ); $shown = 0; foreach ( (array) ( $s['special_hours'] ?? array() ) as $entry ) { $entry = (array) $entry; $end = '' !== (string) ( $entry['until'] ?? '' ) ? (string) $entry['until'] : (string) $entry['date']; if ( $end < $today || $shown >= 5 ) { continue; } ++$shown; $when = (string) $entry['date'] === $end ? $this->date_label( (string) $entry['date'] ) : $this->date_label( (string) $entry['date'] ) . ' – ' . $this->date_label( $end ); $value = ! empty( $entry['closed'] ) ? __( 'Closed', 'ai-rank-suite' ) : $this->slot_label( $entry ); $label = '' !== (string) ( $entry['label'] ?? '' ) ? (string) $entry['label'] : $when; $rows .= '' . esc_html( $label ) . '' . esc_html( $value ) . ''; } } return '' . $rows . '
'; } /** * A live open/closed badge. Not cached: the whole point is that it is right * at the moment it renders. * * @param array|string $atts Attributes. */ public function shortcode_open_now( $atts = array() ): string { $s = $this->settings(); if ( empty( $s['enabled'] ) ) { return ''; } $state = Business::open_now( $s ); $text = $state['label']; if ( '' !== $state['until'] ) { $text .= $state['open'] /* translators: %s: closing time. */ ? ' · ' . sprintf( __( 'until %s', 'ai-rank-suite' ), $state['until'] ) /* translators: %s: opening time. */ : ' · ' . sprintf( __( 'opens %s', 'ai-rank-suite' ), $state['until'] ); } if ( '' !== $state['reason'] ) { $text .= ' (' . $state['reason'] . ')'; } return '' . esc_html( $text ) . ''; } /** * @param array|string $atts Attributes. */ public function shortcode_map( $atts = array() ): string { $s = $this->settings(); if ( empty( $s['enabled'] ) ) { return ''; } $atts = shortcode_atts( array( 'height' => '320' ), (array) $atts, 'airanksuite_business_map' ); $url = (string) ( $s['map_url'] ?? '' ); if ( '' === $url ) { $query = trim( implode( ' ', array_filter( array( (string) $s['name'], (string) $s['street'], (string) $s['postal_code'], (string) $s['city'] ) ) ) ); if ( '' === $query ) { return ''; } $url = 'https://www.google.com/maps/search/?api=1&query=' . rawurlencode( $query ); } // An iframe to a third party on every page view is a consent problem in // Europe, so this stays a link with the address as its text. return '' . esc_html( trim( (string) $s['street'] . ', ' . (string) $s['postal_code'] . ' ' . (string) $s['city'], ' ,' ) ) . ''; } /** * @param array|string $atts Attributes. */ public function shortcode_services( $atts = array() ): string { $s = $this->settings(); if ( empty( $s['enabled'] ) || empty( $s['services'] ) ) { return ''; } $html = '
    '; foreach ( (array) $s['services'] as $service ) { $service = (array) $service; $name = esc_html( (string) ( $service['name'] ?? '' ) ); $url = (string) ( $service['url'] ?? '' ); $label = '' !== $url ? '' . $name . '' : $name; $price = (string) ( $service['price'] ?? '' ); $html .= '
  • ' . $label . ''; if ( '' !== $price ) { $html .= ' ' . esc_html( trim( $price . ' ' . (string) ( $service['currency'] ?? '' ) ) ) . ''; } if ( '' !== (string) ( $service['description'] ?? '' ) ) { $html .= '' . esc_html( (string) $service['description'] ) . ''; } $html .= '
  • '; } return $html . '
'; } /** * @param array|string $atts Attributes. */ public function shortcode_locations( $atts = array() ): string { $s = $this->settings(); if ( empty( $s['enabled'] ) || empty( $s['locations'] ) ) { return $this->shortcode_address(); } $html = '
'; foreach ( array_merge( array( array() ), (array) $s['locations'] ) as $location ) { $location = array_merge( $s, array_filter( (array) $location, static fn( $v ) => '' !== $v && array() !== $v ) ); $parts = array_filter( array( (string) $location['name'], (string) $location['street'], trim( (string) $location['postal_code'] . ' ' . (string) $location['city'] ), (string) $location['phone'], ) ); if ( empty( $parts ) ) { continue; } $html .= '
' . implode( '
', array_map( 'esc_html', $parts ) ) . '
'; } return $html . '
'; } public function routes(): void { register_rest_route( self::NAMESPACE, '/settings/local-seo', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_settings' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ), array( 'methods' => 'PUT', 'callback' => array( $this, 'update_settings' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ), ) ); register_rest_route( self::NAMESPACE, '/local-seo/preview', array( 'methods' => 'GET', 'callback' => array( $this, 'preview' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); // The directories. Key and Place ID are settings; search, fetch and // import are actions that talk to Google and are rate-limited like // geocoding — a Places call costs money on the customer's own key. register_rest_route( self::NAMESPACE, '/local-seo/listings', array( array( 'methods' => 'GET', 'callback' => array( $this, 'listings' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'listings_key' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'key' => array( 'type' => 'string' ) ), ), ) ); register_rest_route( self::NAMESPACE, '/local-seo/listings/search', array( 'methods' => 'POST', 'callback' => array( $this, 'listings_search' ), 'permission_callback' => static function () { if ( ! current_user_can( 'manage_options' ) ) { return self::forbidden(); } return self::spend( 'places', 30 ); }, 'args' => array( 'query' => array( 'type' => 'string', 'required' => true ) ), ) ); register_rest_route( self::NAMESPACE, '/local-seo/listings/fetch', array( 'methods' => 'POST', 'callback' => array( $this, 'listings_fetch' ), 'permission_callback' => static function () { if ( ! current_user_can( 'manage_options' ) ) { return self::forbidden(); } return self::spend( 'places', 30 ); }, 'args' => array( 'place_id' => array( 'type' => 'string' ) ), ) ); register_rest_route( self::NAMESPACE, '/local-seo/listings/import', array( 'methods' => 'POST', 'callback' => array( $this, 'listings_import' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'overwrite' => array( 'type' => 'boolean', 'default' => false ) ), ) ); register_rest_route( self::NAMESPACE, '/local-seo/listings/google', array( 'methods' => 'DELETE', 'callback' => array( $this, 'listings_forget' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/local-seo/geocode', array( 'methods' => 'POST', 'callback' => array( $this, 'geocode' ), 'permission_callback' => static function () { if ( ! current_user_can( 'manage_options' ) ) { return self::forbidden(); } return self::spend( 'geocode', 30 ); }, ) ); } public function get_settings(): WP_REST_Response { $settings = $this->settings(); return new WP_REST_Response( array( 'settings' => $settings, 'business_types' => Business::business_types(), 'type_groups' => Business::type_groups(), 'payment_methods' => Business::payment_methods(), 'amenities' => Business::amenities(), 'days' => Business::DAYS, 'completeness' => Business::completeness( $settings ), 'open_now' => Business::open_now( $settings ), 'timezone' => wp_timezone_string(), ) ); } public function update_settings( WP_REST_Request $request ): WP_REST_Response { $input = (array) $request->get_json_params() ?: $request->get_params(); $values = Business::sanitize( $input, $this->settings() ); \AiRankSuite\Kernel::service( Options::class )->set( 'local_seo', $values, Business::defaults() ); return $this->get_settings(); } /** Everything the Listings tab needs, in one payload. */ public function listings(): WP_REST_Response { $settings = $this->settings(); $snapshot = Listings::snapshot(); return new WP_REST_Response( array( 'has_key' => Listings::has_key(), 'key_hint' => Listings::key_hint(), 'place_id' => (string) ( $settings['google_place_id'] ?? '' ), 'google' => $snapshot, 'compare' => $snapshot ? Listings::compare( $settings, $snapshot ) : array(), 'history' => Listings::history(), 'bing_url' => (string) ( $settings['bing_url'] ?? '' ), 'apple_url' => (string) ( $settings['apple_url'] ?? '' ), 'home' => home_url( '/' ), ) ); } public function listings_key( WP_REST_Request $request ): WP_REST_Response { Listings::save_key( (string) $request->get_param( 'key' ) ); return $this->listings(); } public function listings_search( WP_REST_Request $request ) { $found = Listings::search( (string) $request->get_param( 'query' ) ); return $found instanceof \WP_Error ? $found : new WP_REST_Response( array( 'places' => $found ) ); } public function listings_fetch( WP_REST_Request $request ) { $settings = $this->settings(); $place_id = (string) ( $request->get_param( 'place_id' ) ?: ( $settings['google_place_id'] ?? '' ) ); $snapshot = Listings::fetch( $place_id ); if ( $snapshot instanceof \WP_Error ) { return $snapshot; } // Remember the chosen place so the next fetch — and the weekly cron — // needs no argument. if ( $place_id !== (string) ( $settings['google_place_id'] ?? '' ) ) { \AiRankSuite\Kernel::service( Options::class )->set( 'local_seo', array( 'google_place_id' => $place_id ), Business::defaults() ); } return $this->listings(); } public function listings_import( WP_REST_Request $request ) { $snapshot = Listings::snapshot(); if ( ! $snapshot ) { return new \WP_Error( 'airanksuite_places_none', __( 'Fetch the Google listing first.', 'ai-rank-suite' ), array( 'status' => 400 ) ); } $settings = $this->settings(); $values = Listings::import_values( $snapshot, (bool) $request->get_param( 'overwrite' ), $settings ); if ( ! empty( $values ) ) { $clean = Business::sanitize( $values, $settings ); \AiRankSuite\Kernel::service( Options::class )->set( 'local_seo', array_intersect_key( $clean, $values ), Business::defaults() ); } $out = $this->listings()->get_data(); $out['imported'] = array_keys( $values ); return new WP_REST_Response( $out ); } public function listings_forget(): WP_REST_Response { Listings::forget(); \AiRankSuite\Kernel::service( Options::class )->set( 'local_seo', array( 'google_place_id' => '' ), Business::defaults() ); return $this->listings(); } /** * The exact JSON-LD that will be printed on the home page, so nobody has to * view-source to check their work. */ public function preview(): WP_REST_Response { $settings = $this->settings(); $graph = Business::graph( $settings, home_url( '/' ) ); return new WP_REST_Response( array( 'json' => wp_json_encode( array( '@context' => 'https://schema.org', '@graph' => $graph, ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), 'nodes' => count( $graph ), 'active' => ! empty( $settings['enabled'] ), 'test' => 'https://search.google.com/test/rich-results?url=' . rawurlencode( home_url( '/' ) ), ) ); } /** * Look up coordinates for the stored address. * * OpenStreetMap's Nominatim needs no key, which keeps the feature usable on * a fresh install. Its usage policy asks for an identifying User-Agent and * one request per second, and the rate limit on this route respects that. */ public function geocode(): WP_REST_Response|\WP_Error { $s = $this->settings(); $parts = array_filter( array( (string) $s['street'], (string) $s['postal_code'], (string) $s['city'], (string) $s['region'], (string) $s['country'], ) ); if ( count( $parts ) < 2 ) { return new \WP_Error( 'airanksuite_local_address', __( 'Fill in at least a street and a city first.', 'ai-rank-suite' ), array( 'status' => 400 ) ); } $response = wp_remote_get( add_query_arg( array( 'q' => rawurlencode( implode( ', ', $parts ) ), 'format' => 'json', 'limit' => 1, ), 'https://nominatim.openstreetmap.org/search' ), array( 'timeout' => 12, 'headers' => array( 'User-Agent' => 'AI Rank Suite/' . AIRANKSUITE_VERSION . ' (' . home_url( '/' ) . ')', ), ) ); if ( is_wp_error( $response ) ) { return $response; } $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || empty( $body[0]['lat'] ) ) { return new \WP_Error( 'airanksuite_local_geocode', __( 'No coordinates found for that address. Check the spelling, or enter them by hand.', 'ai-rank-suite' ), array( 'status' => 404 ) ); } return new WP_REST_Response( array( 'latitude' => (string) $body[0]['lat'], 'longitude' => (string) $body[0]['lon'], 'matched' => (string) ( $body[0]['display_name'] ?? '' ), ) ); } /** @return array */ private function day_labels(): array { return array( 'monday' => __( 'Monday', 'ai-rank-suite' ), 'tuesday' => __( 'Tuesday', 'ai-rank-suite' ), 'wednesday' => __( 'Wednesday', 'ai-rank-suite' ), 'thursday' => __( 'Thursday', 'ai-rank-suite' ), 'friday' => __( 'Friday', 'ai-rank-suite' ), 'saturday' => __( 'Saturday', 'ai-rank-suite' ), 'sunday' => __( 'Sunday', 'ai-rank-suite' ), ); } /** * @param array $slot Hours slot. */ private function slot_label( array $slot ): string { $open = (string) ( $slot['open'] ?? '' ); $close = (string) ( $slot['close'] ?? '' ); if ( '' === $open ) { return __( 'Closed', 'ai-rank-suite' ); } $start = (string) ( $slot['break_start'] ?? '' ); $end = (string) ( $slot['break_end'] ?? '' ); if ( '' !== $start && '' !== $end ) { return sprintf( '%s – %s, %s – %s', $open, $start, $end, $close ); } return sprintf( '%s – %s', $open, $close ); } private function date_label( string $date ): string { $time = strtotime( $date ); return false === $time ? $date : wp_date( (string) get_option( 'date_format' ), $time ); } /** @return array */ private function settings(): array { return \AiRankSuite\Kernel::service( Options::class )->get( 'local_seo', Business::defaults() ); } }