Data passed to directory.js via wp_localize_script. */ private static $directory_js_data = []; /** @var array Data passed to location-directory.js via wp_localize_script. */ private static $location_js_data = []; /** @var array Per-request memo of API lookups (see load_provider()). */ private static $entity_cache = []; public static function init(): void { add_shortcode('powr_providers', [self::class, 'render_providers']); add_shortcode('powr_provider_directory', [self::class, 'render_directory']); add_shortcode('powr_provider_detail', [self::class, 'render_detail']); add_shortcode('powr_location_directory', [self::class, 'render_location_directory']); add_shortcode('powr_location_detail', [self::class, 'render_location_detail']); add_shortcode('powr_locations', [self::class, 'render_locations']); add_shortcode('powr_provider_card', [self::class, 'render_provider_card']); add_action('wp_enqueue_scripts', [self::class, 'register_assets']); // Headers must fire before body output; shortcode render alone is often too late. add_action('template_redirect', [self::class, 'maybe_send_nocache_headers']); } /** * Prevent Cloudflare (incl. APO) and browsers from caching directory pages. * Safe to call more than once per request. */ public static function send_nocache_headers(): void { if (headers_sent()) { return; } nocache_headers(); // Cloudflare CDN / APO honor this even when origin Cache-Control is rewritten. header('CDN-Cache-Control: no-store'); header('Cloudflare-CDN-Cache-Control: no-store'); } /** * Early hook: no-cache on provider/location listing + detail pages. */ public static function maybe_send_nocache_headers(): void { $provider_page_id = (int) get_option('powr_detail_page_id', 0); $location_page_id = (int) get_option('powr_location_listing_page_id', 0); if ($provider_page_id > 0 && is_page($provider_page_id)) { self::send_nocache_headers(); return; } if ($location_page_id > 0 && is_page($location_page_id)) { self::send_nocache_headers(); } } /** * Combined listing + detail on one page (e.g. /providers/ and /providers/{slug}/). * * @param array|string $atts */ public static function render_providers($atts = []): string { $slug = sanitize_title((string) get_query_var(Powr_Dir_Rewrites::QUERY_VAR_PROVIDER)); if ($slug !== '') { return self::render_detail($atts); } return self::render_directory($atts); } /** * Combined listing + detail on one page (e.g. /locations/ and /locations/{slug}/). * * @param array|string $atts */ public static function render_locations($atts = []): string { $slug = sanitize_title((string) get_query_var(Powr_Dir_Rewrites::QUERY_VAR_LOCATION)); if ($slug !== '') { return self::render_location_detail($atts); } return self::render_location_directory($atts); } public static function register_assets(): void { // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- external Google Fonts URL; intentionally no ?ver=. wp_register_style( 'powr-dir-fonts', 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap', [], null ); wp_register_style( 'powr-dir-host', POWR_DIR_URL . 'assets/css/pd-host.css', [], powr_dir_asset_version('assets/css/pd-host.css') ); // Registered for URL helpers / versioning; injected into Shadow DOM, not the document. wp_register_style( 'powr-dir-global', POWR_DIR_URL . 'assets/css/pd-global.css', [], powr_dir_asset_version('assets/css/pd-global.css') ); wp_register_style( 'powr-dir-directory', POWR_DIR_URL . 'assets/css/pd-directory.css', [], powr_dir_asset_version('assets/css/pd-directory.css') ); wp_register_style( 'powr-dir-detail', POWR_DIR_URL . 'assets/css/pd-detail.css', [], powr_dir_asset_version('assets/css/pd-detail.css') ); wp_register_style( 'powr-dir-location', POWR_DIR_URL . 'assets/css/pd-location.css', [], powr_dir_asset_version('assets/css/pd-location.css') ); wp_register_style( 'powr-dir-location-detail', POWR_DIR_URL . 'assets/css/pd-location-detail.css', [], powr_dir_asset_version('assets/css/pd-location-detail.css') ); wp_register_script( 'powr-dir-shadow', POWR_DIR_URL . 'assets/js/shadow-boot.js', [], powr_dir_asset_version('assets/js/shadow-boot.js'), true ); wp_register_script( 'powr-dir', POWR_DIR_URL . 'assets/js/directory.js', ['powr-dir-shadow'], powr_dir_asset_version('assets/js/directory.js'), true ); wp_register_script( 'powr-dir-location-js', POWR_DIR_URL . 'assets/js/location-directory.js', ['powr-dir-shadow'], powr_dir_asset_version('assets/js/location-directory.js'), true ); wp_register_script( 'powr-dir-detail-js', POWR_DIR_URL . 'assets/js/detail.js', ['powr-dir-shadow'], powr_dir_asset_version('assets/js/detail.js'), true ); } /** * @param array|string $atts */ public static function render_directory($atts = []): string { // On a shared /providers/{slug}/ page, skip the listing shell. if (sanitize_title((string) get_query_var(Powr_Dir_Rewrites::QUERY_VAR_PROVIDER)) !== '') { return ''; } try { self::send_nocache_headers(); self::$asset_context = 'directory'; self::enqueue_front_assets(); $client = new Powr_Dir_Api_Client(); if (! $client->is_configured()) { return self::error_box(__('Sondy API is not configured. Set the base URL and token under Settings → Sondy.', 'sondy')); } $providers_result = $client->get_providers(); if (is_wp_error($providers_result)) { return self::error_box($providers_result->get_error_message()); } $filter_options = $client->get_filters(); if (is_wp_error($filter_options)) { $filter_options = [ 'specialties' => [], 'languages' => [], 'genders' => [], 'insurances' => [], ]; } $detail_page_id = (int) get_option('powr_detail_page_id', 0); $detail_url = $detail_page_id > 0 ? (string) get_permalink($detail_page_id) : ''; $normalized = array_map( static function (array $row) use ($detail_url): array { return self::normalize_directory_provider($row, $detail_url); }, $providers_result['data'] ); $providerGenders = array_values(array_unique(array_filter(array_map( static function (array $p): string { return (string) ($p['gender'] ?? ''); }, $normalized )))); $filterLocations = []; foreach ($normalized as $provider) { foreach ($provider['locations'] ?? [] as $name) { if ($name !== '') { $filterLocations[] = $name; } } if (empty($provider['locations']) && ($provider['location'] ?? '') !== '') { $filterLocations[] = $provider['location']; } } $filterLocations = array_values(array_unique($filterLocations)); sort($filterLocations); $simplified = self::provider_directory_is_simplified(count($normalized)); self::$directory_js_data = [ 'providers' => $normalized, 'detailUrl' => $detail_url, 'simplified' => $simplified, 'placeholders' => [ 'male' => POWR_DIR_URL . 'assets/images/placeholder-male.png', 'female' => POWR_DIR_URL . 'assets/images/placeholder-female.png', ], 'filters' => [ 'locations' => $filterLocations, 'specialties' => array_values(array_map('strval', $filter_options['specialties'] ?? [])), 'genders' => $providerGenders !== [] ? $providerGenders : array_values(array_unique(array_filter(array_map( [self::class, 'format_gender_label'], array_map('strval', $filter_options['genders'] ?? []) )))), ], ]; wp_localize_script('powr-dir', 'powrDirData', self::$directory_js_data); $theme = self::load_theme($client); ob_start(); include POWR_DIR_PATH . 'templates/provider-directory.php'; return self::page_loader_html(false) . self::wrap_shadow((string) ob_get_clean(), ['pd-global.css', 'pd-directory.css'], $theme); } catch (Throwable $e) { return self::error_box(__('Unable to render the provider directory.', 'sondy')); } } /** * [powr_provider_card slug="jane-doe-dc"]: a linked provider card for * service pages and blog posts (internal links to the profile). * * @param array|string $atts */ public static function render_provider_card($atts = []): string { try { $atts = shortcode_atts([ 'slug' => '', ], is_array($atts) ? $atts : [], 'powr_provider_card'); $slug = sanitize_title((string) $atts['slug']); $client = new Powr_Dir_Api_Client(); if ($slug === '' || ! $client->is_configured()) { return ''; } $result = $client->get_providers(); if (is_wp_error($result)) { return ''; } $row = null; foreach ((array) $result['data'] as $candidate) { if (is_array($candidate) && sanitize_title((string) ($candidate['slug'] ?? '')) === $slug) { $row = $candidate; break; } } if ($row === null) { return ''; } // Only the shared host/shadow assets; directory.js is not needed for a static card. wp_enqueue_style('powr-dir-fonts'); wp_enqueue_style('powr-dir-host'); wp_enqueue_script('powr-dir-shadow'); $detail_page_id = (int) get_option('powr_detail_page_id', 0); $detail_url = $detail_page_id > 0 ? (string) get_permalink($detail_page_id) : ''; $card = Powr_Dir_Directory_Html::embed_card(self::normalize_directory_provider($row, $detail_url), [ 'male' => POWR_DIR_URL . 'assets/images/placeholder-male.png', 'female' => POWR_DIR_URL . 'assets/images/placeholder-female.png', ]); return self::wrap_shadow( '
' . $card . '
', ['pd-global.css', 'pd-directory.css'], self::load_theme($client) ); } catch (Throwable $e) { return ''; } } /** * Small-practice mode: a few providers shown without filters, in API order. */ public static function provider_directory_is_simplified(int $provider_count): bool { return (int) get_option('powr_small_practice_mode', 0) === 1 && $provider_count > 1 && $provider_count <= (int) get_option('powr_provider_simplify_max', 3); } /** * @param array|string $atts */ public static function render_detail($atts = []): string { try { self::send_nocache_headers(); self::$asset_context = 'detail'; self::enqueue_front_assets(); $atts = shortcode_atts([ 'slug' => '', ], is_array($atts) ? $atts : [], 'powr_provider_detail'); $slug = self::resolve_provider_slug($atts['slug']); if ($slug === '') { return self::error_box(__('No provider specified. Open /provider-detail/{slug}/ or use [powr_provider_detail slug="…"].', 'sondy')); } $client = new Powr_Dir_Api_Client(); if (! $client->is_configured()) { return self::error_box(__('Sondy API is not configured. Set the base URL and token under Settings → Sondy.', 'sondy')); } $provider = self::load_provider($client, $slug); if (is_wp_error($provider)) { return self::error_box($provider->get_error_message()); } $theme = self::load_theme($client); $related_providers = self::related_providers($client, $provider, $slug); ob_start(); include POWR_DIR_PATH . 'templates/provider-detail.php'; return self::page_loader_html(true) . self::wrap_shadow((string) ob_get_clean(), ['pd-global.css', 'pd-detail.css'], $theme); } catch (Throwable $e) { return self::error_box(__('Unable to render the provider profile.', 'sondy')); } } /** * @param array|string $atts */ public static function render_location_directory($atts = []): string { // On a shared /locations/{slug}/ page, skip the listing shell. if (sanitize_title((string) get_query_var(Powr_Dir_Rewrites::QUERY_VAR_LOCATION)) !== '') { return ''; } try { self::send_nocache_headers(); self::$asset_context = 'location_directory'; self::enqueue_front_assets(); $client = new Powr_Dir_Api_Client(); if (! $client->is_configured()) { return self::error_box(__('Sondy API is not configured. Set the base URL and token under Settings → Sondy.', 'sondy')); } $locations = $client->get_locations(); if (is_wp_error($locations)) { return self::error_box($locations->get_error_message()); } $filter_options = $client->get_filters(); if (is_wp_error($filter_options)) { $filter_options = [ 'locationTypes' => [], 'cities' => [], 'locationServices' => [], ]; } $detail_page_id = (int) get_option('powr_location_listing_page_id', 0); $detail_url = $detail_page_id > 0 ? (string) get_permalink($detail_page_id) : ''; $locationRows = is_array($locations['data'] ?? null) ? $locations['data'] : []; $normalized = array_map( static function (array $row) use ($detail_url): array { return self::normalize_directory_location($row, $detail_url); }, $locationRows ); $location_count = count($normalized); $location_simplify_max = (int) get_option('powr_location_simplify_max', 3); $simplified = (int) get_option('powr_small_practice_mode', 0) === 1 && $location_count > 1 && $location_count <= $location_simplify_max; self::$location_js_data = [ 'locations' => $normalized, 'detailUrl' => $detail_url, 'simplified' => $simplified, 'placeholders' => [ 'location' => POWR_DIR_URL . 'assets/images/placeholder-location.png', ], 'filters' => [ 'types' => array_values(array_map('strval', $filter_options['locationTypes'] ?? [])), 'cities' => array_values(array_map('strval', $filter_options['cities'] ?? [])), 'services' => array_values(array_map('strval', $filter_options['locationServices'] ?? [])), ], ]; wp_localize_script('powr-dir-location-js', 'powrLocData', self::$location_js_data); $theme = self::load_theme($client); ob_start(); include POWR_DIR_PATH . 'templates/location-directory.php'; return self::wrap_shadow((string) ob_get_clean(), ['pd-global.css', 'pd-location.css'], $theme); } catch (Throwable $e) { return self::error_box(__('Unable to render the location directory.', 'sondy')); } } /** * @param array|string $atts */ public static function render_location_detail($atts = []): string { try { self::send_nocache_headers(); self::$asset_context = 'location_detail'; self::enqueue_front_assets(); $atts = shortcode_atts([ 'slug' => '', ], is_array($atts) ? $atts : [], 'powr_location_detail'); $slug = self::resolve_location_slug($atts['slug']); if ($slug === '') { return self::error_box(__('No location specified. Open /locations/{slug}/ or use [powr_location_detail slug="…"].', 'sondy')); } $client = new Powr_Dir_Api_Client(); if (! $client->is_configured()) { return self::error_box(__('Sondy API is not configured. Set the base URL and token under Settings → Sondy.', 'sondy')); } $location = self::load_location($client, $slug); if (is_wp_error($location)) { return self::error_box($location->get_error_message()); } $theme = self::load_theme($client); ob_start(); include POWR_DIR_PATH . 'templates/location-detail.php'; return self::wrap_shadow((string) ob_get_clean(), ['pd-global.css', 'pd-location-detail.css'], $theme); } catch (Throwable $e) { return self::error_box(__('Unable to render the location profile.', 'sondy')); } } /** * Up to three other providers to link from a profile, preferring those at * the same location, then with the same specialty, then directory order. * * @param array $provider * @return list */ private static function related_providers(Powr_Dir_Api_Client $client, array $provider, string $slug): array { $detail_page_id = (int) get_option('powr_detail_page_id', 0); $detail_url = $detail_page_id > 0 ? (string) get_permalink($detail_page_id) : ''; $result = $detail_url !== '' ? $client->get_providers() : null; if ($result === null || is_wp_error($result)) { return []; } $own_locations = []; foreach ((array) ($provider['locations'] ?? []) as $loc) { $own_locations[] = strtolower(trim((string) (is_array($loc) ? ($loc['name'] ?? '') : $loc))); } $own_specialties = array_map('strtolower', self::to_string_list($provider['specialties'] ?? [])); $candidates = []; foreach (array_values((array) $result['data']) as $order => $row) { if (! is_array($row)) { continue; } $row_slug = sanitize_title((string) ($row['slug'] ?? '')); $name = trim((string) ($row['name'] ?? '')); if ($row_slug === '' || $row_slug === $slug || $name === '') { continue; } $row_locations = []; foreach ((array) ($row['locations'] ?? []) as $loc) { $row_locations[] = strtolower(trim((string) (is_array($loc) ? ($loc['name'] ?? '') : $loc))); } $specialties = self::to_string_list($row['specialties'] ?? []); $score = (array_intersect(array_filter($own_locations), array_filter($row_locations)) !== [] ? 2 : 0) + (array_intersect($own_specialties, array_map('strtolower', $specialties)) !== [] ? 1 : 0); $credentials = trim((string) ($row['credentials'] ?? '')); $candidates[] = [ 'score' => $score, 'order' => $order, 'name' => $credentials !== '' ? $name . ', ' . $credentials : $name, 'url' => Powr_Dir_Rewrites::build_profile_url($detail_url, $row_slug), 'specialty' => (string) ($specialties[0] ?? ''), ]; } usort($candidates, static function (array $a, array $b): int { return $b['score'] <=> $a['score'] ?: $a['order'] <=> $b['order']; }); $related = []; foreach (array_slice($candidates, 0, 3) as $c) { if ($c['url'] !== '#') { $related[] = ['name' => $c['name'], 'url' => $c['url'], 'specialty' => $c['specialty']]; } } return $related; } /** * Normalized provider profile, memoized per request so SEO head output and * the shortcode render share one lookup. * * @return array|WP_Error */ public static function load_provider(Powr_Dir_Api_Client $client, string $slug) { $key = 'provider:' . $slug; if (array_key_exists($key, self::$entity_cache)) { return self::$entity_cache[$key]; } $raw = $client->get_provider($slug); if (is_wp_error($raw)) { return self::$entity_cache[$key] = $raw; } $locations_list = $client->get_locations(); $location_slug_map = []; if (! is_wp_error($locations_list)) { foreach ((array) $locations_list as $row) { if (! is_array($row)) { continue; } $location_slug = (string) ($row['slug'] ?? ''); if ($location_slug === '') { continue; } $phone_key = Powr_Dir_Phone::tel((string) ($row['phone'] ?? '')); if ($phone_key !== '') { $location_slug_map['phone:' . $phone_key] = $location_slug; } $name_key = strtolower(trim((string) ($row['name'] ?? ''))); if ($name_key !== '') { $location_slug_map['name:' . $name_key] = $location_slug; } } } return self::$entity_cache[$key] = self::normalize_detail_provider(is_array($raw) ? $raw : [], $location_slug_map); } /** * Normalized location profile, memoized per request. * * @return array|WP_Error */ public static function load_location(Powr_Dir_Api_Client $client, string $slug) { $key = 'location:' . $slug; if (array_key_exists($key, self::$entity_cache)) { return self::$entity_cache[$key]; } $raw = $client->get_location($slug); if (is_wp_error($raw)) { return self::$entity_cache[$key] = $raw; } $provider_detail_page_id = (int) get_option('powr_detail_page_id', 0); $provider_detail_url = $provider_detail_page_id > 0 ? (string) get_permalink($provider_detail_page_id) : ''; return self::$entity_cache[$key] = self::normalize_detail_location(is_array($raw) ? $raw : [], $provider_detail_url); } /** * Brand theme + organization schema, memoized per request. * * @return array|null */ public static function load_theme(Powr_Dir_Api_Client $client): ?array { if (! array_key_exists('theme', self::$entity_cache)) { self::$entity_cache['theme'] = self::fetch_theme($client); } return self::$entity_cache['theme']; } private static function enqueue_front_assets(): void { wp_enqueue_style('powr-dir-fonts'); wp_enqueue_style('powr-dir-host'); wp_enqueue_script('powr-dir-shadow'); if (self::$asset_context === 'directory') { wp_enqueue_script('powr-dir'); } if (self::$asset_context === 'location_directory') { wp_enqueue_script('powr-dir-location-js'); } if (self::$asset_context === 'detail' || self::$asset_context === 'location_detail') { wp_enqueue_script('powr-dir-detail-js'); } } /** * The loader is never visible in the server HTML, so crawlers and no-JS * visitors are not shown an overlay. When $visible, a script right after it * shows it straight away; detail.js hides it once the profile is styled. */ private static function page_loader_html(bool $visible): string { $html = ''; if ($visible) { $html .= wp_get_inline_script_tag( "(function(l){if(l){l.classList.add('is-visible');l.setAttribute('aria-hidden','false');}})(document.getElementById('pdPageLoader'));" ); } return $html; } /** * @param list $css_files Filenames under assets/css/ * @param array{name?: string, colors?: array{primary?: ?string, secondary?: ?string, accent?: ?string}}|null $theme */ private static function wrap_shadow(string $inner_html, array $css_files, ?array $theme = null): string { $urls = []; foreach ($css_files as $file) { $urls[] = self::css_asset_url($file); } $attrs = 'class="powr-dir-host" data-powr-shadow data-css="' . esc_attr(implode(',', $urls)) . '"'; if (is_array($theme) && $theme !== []) { $json = wp_json_encode($theme); if (is_string($json) && $json !== '') { $attrs .= ' data-theme="' . esc_attr($json) . '"'; } } // pd-host.css only hides the host until the shadow root is styled when // this flag is set, so without JavaScript the content is never hidden. return wp_get_inline_script_tag("document.documentElement.classList.add('pd-js');") . '
' . $inner_html . '
'; } /** * @return array{name: string, colors: array{primary: ?string, secondary: ?string, accent: ?string}}|null */ private static function fetch_theme(Powr_Dir_Api_Client $client): ?array { $brand = $client->get_brand(); if (is_wp_error($brand) || ! is_array($brand)) { return null; } $theme = [ 'name' => (string) ($brand['name'] ?? ''), 'colors' => [ 'primary' => isset($brand['colors']['primary']) ? $brand['colors']['primary'] : null, 'secondary' => isset($brand['colors']['secondary']) ? $brand['colors']['secondary'] : null, 'accent' => isset($brand['colors']['accent']) ? $brand['colors']['accent'] : null, ], ]; foreach ([ 'id', 'schemaId', 'url', 'website', 'siteRoot', 'organizationNpi', 'schema', 'graph', 'schemaEntity', 'schemaOrganization', 'schemaWebSite', 'websiteId', 'organizationId', 'ownership', 'publishable', 'publishErrors', 'organizationCategory', ] as $key) { if (array_key_exists($key, $brand)) { $theme[$key] = $brand[$key]; } } return $theme; } private static function css_asset_url(string $filename): string { $relative = 'assets/css/' . ltrim($filename, '/'); return add_query_arg('ver', powr_dir_asset_version($relative), POWR_DIR_URL . $relative); } /** * Prefer pretty path query var, then shortcode attribute, then legacy ?provider=. */ private static function resolve_provider_slug(string $shortcode_slug): string { $from_path = sanitize_title((string) get_query_var(Powr_Dir_Rewrites::QUERY_VAR_PROVIDER)); if ($from_path !== '') { return $from_path; } if ($shortcode_slug !== '') { return sanitize_title($shortcode_slug); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only query param, not a form submission. if (isset($_GET['provider'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only query param, not a form submission. return sanitize_title(wp_unslash((string) $_GET['provider'])); } return ''; } /** * Prefer pretty path query var, then shortcode attribute, then legacy ?location=. */ private static function resolve_location_slug(string $shortcode_slug): string { $from_path = sanitize_title((string) get_query_var(Powr_Dir_Rewrites::QUERY_VAR_LOCATION)); if ($from_path !== '') { return $from_path; } if ($shortcode_slug !== '') { return sanitize_title($shortcode_slug); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only query param, not a form submission. if (isset($_GET['location'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only query param, not a form submission. return sanitize_title(wp_unslash((string) $_GET['location'])); } return ''; } /** * @param array $row * @return array */ private static function normalize_directory_provider(array $row, string $detail_url): array { $slug = (string) ($row['slug'] ?? ''); $profile_url = Powr_Dir_Rewrites::build_profile_url($detail_url, $slug); $gender_raw = (string) ($row['gender'] ?? ''); $custom_tabs = []; foreach ((array) ($row['customTabs'] ?? []) as $item) { if (! is_array($item)) { continue; } $title = trim((string) ($item['title'] ?? '')); if ($title === '') { continue; } $custom_tabs[] = $title; } $locations_raw = is_array($row['locations'] ?? null) ? $row['locations'] : []; $locations_list = []; foreach ($locations_raw as $loc) { if (! is_array($loc)) { continue; } $loc_name = (string) ($loc['name'] ?? ''); if ($loc_name === '') { continue; } $locations_list[] = $loc_name; } return [ 'id' => $row['id'] ?? null, 'schemaId' => (string) ($row['schemaId'] ?? ''), 'slug' => $slug, 'url' => $profile_url, 'name' => (string) ($row['name'] ?? ''), 'credentials' => (string) ($row['credentials'] ?? ''), 'specialties' => self::to_string_list($row['specialties'] ?? []), 'gender' => self::format_gender_label($gender_raw), 'location' => (string) ($row['location'] ?? ($locations_list[0] ?? '')), 'locations' => $locations_list, 'languages' => self::to_string_list($row['languages'] ?? []), 'phone' => (string) ($row['phone'] ?? ''), 'accepting' => ! empty($row['newPatients']) || ! empty($row['acceptingNewPatients']) || ! empty($row['accepting']), 'acceptingCondition' => trim((string) ($row['acceptingNewPatientsCondition'] ?? $row['acceptingCondition'] ?? '')), 'video' => ! empty($row['videoVisits']) || ! empty($row['video']), 'photo' => self::resolve_provider_photo((string) ($row['photo'] ?? ''), $gender_raw), 'bookVisit' => ! empty($row['bookVisit']) || trim((string) ($row['bookingUrl'] ?? '')) !== '', 'bookingUrl' => (string) ($row['bookingUrl'] ?? ''), 'profileUrl' => $profile_url, 'customTabs' => $custom_tabs, 'conditionsTreated' => wp_strip_all_tags((string) ($row['conditionsTreated'] ?? '')), 'servicesOffered' => wp_strip_all_tags((string) ($row['servicesOffered'] ?? '')), 'procedures' => wp_strip_all_tags((string) ($row['procedures'] ?? '')), ]; } /** * @param array $row * @return array */ private static function normalize_directory_location(array $row, string $detail_url): array { $slug = (string) ($row['slug'] ?? ''); $detail_page_url = Powr_Dir_Rewrites::build_location_url($detail_url, $slug); $city = (string) ($row['cityLabel'] ?? ''); if ($city === '') { $city = trim(implode(', ', array_filter([ (string) ($row['city'] ?? ''), (string) ($row['state'] ?? ''), ]))); } $building = (string) ($row['building'] ?? $row['address'] ?? ''); if ($building === '' && isset($row['address']) && is_array($row['address'])) { $building = (string) ($row['address'][0] ?? ''); } $street = (string) ($row['building'] ?? ''); if ($street === '') { if (isset($row['address']) && is_array($row['address'])) { $street = (string) ($row['address'][0] ?? ''); } else { $street = (string) ($row['address'] ?? ''); } } $zip = (string) ($row['zip'] ?? $row['postalCode'] ?? ''); $location_part = $city; if ($zip !== '') { $location_part = trim($location_part . ' ' . $zip); } $full_address = trim(implode(', ', array_filter([$street, $location_part]))); $maps_q = trim($building . ' ' . $city); $maps_url = $maps_q !== '' ? 'https://maps.google.com/?q=' . rawurlencode($maps_q) : '#'; return [ 'id' => $row['id'] ?? null, 'schemaId' => (string) ($row['schemaId'] ?? ''), 'slug' => $slug, 'url' => $detail_page_url, 'name' => (string) ($row['name'] ?? ''), 'type' => (string) ($row['type'] ?? ''), 'city' => $city, 'building' => $building, 'fullAddress' => $full_address, 'services' => self::to_string_list($row['services'] ?? []), 'languages' => self::to_string_list($row['languages'] ?? []), 'phone' => (string) ($row['phone'] ?? ''), 'accessible' => ! empty($row['accessible']), 'photo' => self::resolve_location_photo((string) ($row['photo'] ?? '')), 'detailUrl' => $detail_page_url, 'directionsUrl' => $maps_url, ]; } /** * @param array $row * @param array $location_slug_map * @return array */ private static function normalize_detail_provider(array $row, array $location_slug_map = []): array { $education = []; foreach ((array) ($row['education'] ?? []) as $item) { if (! is_array($item)) { continue; } $education[] = [ 'label' => (string) ($item['label'] ?? $item['type'] ?? 'Education'), 'school' => (string) ($item['school'] ?? $item['institution'] ?? ''), 'meta' => (string) ($item['meta'] ?? $item['detail'] ?? ''), ]; } $location_listing_page_id = (int) get_option('powr_location_listing_page_id', 0); $location_detail_url = $location_listing_page_id > 0 ? (string) get_permalink($location_listing_page_id) : ''; $locations = []; foreach ((array) ($row['locations'] ?? []) as $loc) { if (! is_array($loc)) { continue; } $address_lines = []; if (! empty($loc['address'])) { $address_lines[] = (string) $loc['address']; } if (! empty($loc['cityState'])) { $address_lines[] = (string) $loc['cityState']; } if (isset($loc['address']) && is_array($loc['address'])) { $address_lines = array_map('strval', $loc['address']); } $phone_key = Powr_Dir_Phone::tel((string) ($loc['phone'] ?? '')); $name_key = strtolower(trim((string) ($loc['name'] ?? ''))); $loc_slug = (string) ($loc['slug'] ?? $location_slug_map['phone:' . $phone_key] ?? $location_slug_map['name:' . $name_key] ?? ''); $locations[] = [ 'name' => (string) ($loc['name'] ?? ''), 'slug' => $loc_slug, 'detailUrl' => $loc_slug !== '' ? Powr_Dir_Rewrites::build_location_url($location_detail_url, $loc_slug) : '', 'address' => $address_lines, 'street' => is_string($loc['address'] ?? null) ? (string) $loc['address'] : '', 'city' => (string) ($loc['city'] ?? ''), 'state' => (string) ($loc['state'] ?? ''), 'zip' => (string) ($loc['zip'] ?? $loc['postalCode'] ?? ''), 'phone' => (string) ($loc['phone'] ?? ''), 'phones' => Powr_Dir_Phone::normalize_list($loc['phones'] ?? null, (string) ($loc['phone'] ?? '')), 'addressNote' => trim((string) ($loc['addressNote'] ?? '')), ]; } $training = []; foreach ((array) ($row['training'] ?? []) as $item) { if (! is_array($item)) { continue; } $training[] = [ 'label' => (string) ($item['label'] ?? $item['trainingType'] ?? 'Training'), 'school' => (string) ($item['school'] ?? $item['institution'] ?? ''), 'meta' => (string) ($item['meta'] ?? $item['specialty'] ?? ''), ]; } $certifications = []; foreach ((array) ($row['certifications'] ?? []) as $item) { if (! is_array($item)) { continue; } $year = trim((string) ($item['year'] ?? '')); $year_expires = trim((string) ($item['year_expires'] ?? '')); $meta_parts = array_filter([ $year !== '' ? "Certified {$year}" : '', $year_expires !== '' ? "Expires {$year_expires}" : '', ]); $certifications[] = [ 'label' => (string) ($item['label'] ?? $item['name'] ?? 'Certification'), 'school' => (string) ($item['school'] ?? $item['issuer'] ?? ''), 'meta' => (string) ($item['meta'] ?? implode(' · ', $meta_parts)), ]; } $specialties = self::to_string_list($row['specialties'] ?? []); $associations = []; foreach ((array) ($row['associations'] ?? []) as $item) { if (! is_array($item)) { continue; } $associations[] = [ 'label' => (string) ($item['label'] ?? $item['name'] ?? 'Association'), 'school' => (string) ($item['school'] ?? $item['role'] ?? ''), 'meta' => (string) ($item['meta'] ?? ( trim((string) ($item['memberSince'] ?? $item['member_since'] ?? '')) !== '' ? 'Member since ' . trim((string) ($item['memberSince'] ?? $item['member_since'] ?? '')) : '' )), ]; } $awards = []; foreach ((array) ($row['awards'] ?? []) as $item) { if (! is_array($item)) { continue; } $year = trim((string) ($item['year'] ?? '')); $awards[] = [ 'label' => (string) ($item['label'] ?? $item['name'] ?? 'Award'), 'school' => (string) ($item['school'] ?? $item['organization'] ?? ''), 'meta' => (string) ($item['meta'] ?? ($year !== '' ? $year : '')), ]; } $custom_tabs = []; foreach ((array) ($row['customTabs'] ?? []) as $item) { if (! is_array($item)) { continue; } $title = trim((string) ($item['title'] ?? '')); $content = self::to_html_string($item['content'] ?? ''); if ($title === '' && $content === '') { continue; } $custom_tabs[] = [ 'title' => $title !== '' ? $title : 'Tab', 'content' => $content, ]; } $gender_raw = (string) ($row['gender'] ?? ''); return [ 'id' => (string) ($row['id'] ?? ''), 'schemaId' => (string) ($row['schemaId'] ?? ''), 'url' => (string) ($row['url'] ?? ''), 'jobTitle' => (string) ($row['jobTitle'] ?? ''), 'providerType' => (string) ($row['providerType'] ?? ''), 'providerTypeLabel' => (string) ($row['providerTypeLabel'] ?? ''), 'schema' => is_array($row['schema'] ?? null) ? $row['schema'] : null, 'graph' => is_array($row['graph'] ?? null) ? $row['graph'] : null, 'schemaEntity' => is_array($row['schemaEntity'] ?? null) ? $row['schemaEntity'] : null, 'schemaOrganization' => is_array($row['schemaOrganization'] ?? null) ? $row['schemaOrganization'] : null, 'schemaWebSite' => is_array($row['schemaWebSite'] ?? null) ? $row['schemaWebSite'] : null, 'websiteId' => (string) ($row['websiteId'] ?? ''), 'organizationId' => (string) ($row['organizationId'] ?? ''), 'ownership' => is_array($row['ownership'] ?? null) ? $row['ownership'] : null, 'publishable' => array_key_exists('publishable', $row) ? (bool) $row['publishable'] : true, 'publishErrors' => is_array($row['publishErrors'] ?? null) ? $row['publishErrors'] : [], 'name' => (string) ($row['name'] ?? ''), 'credentials' => (string) ($row['credentials'] ?? ''), 'npi' => (string) ($row['npi'] ?? ''), 'accepting' => ! empty($row['acceptingNewPatients']) || ! empty($row['accepting']), 'acceptingCondition' => trim((string) ($row['acceptingNewPatientsCondition'] ?? $row['acceptingCondition'] ?? '')), 'photo' => self::resolve_provider_photo((string) ($row['photo'] ?? ''), $gender_raw), 'specialties' => $specialties, 'gender' => self::format_gender_label($gender_raw), 'videoVisits' => ! empty($row['videoVisits']) || ! empty($row['video']), 'videoUrl' => (string) ($row['videoUrl'] ?? ''), 'languages' => self::to_string_list($row['languages'] ?? []), 'schedulePhone' => (string) ($row['phone'] ?? $row['schedulePhone'] ?? ''), 'phone' => (string) ($row['phone'] ?? $row['schedulePhone'] ?? ''), 'bookingUrl' => (string) ($row['bookingUrl'] ?? ''), 'about' => self::to_html_string($row['about'] ?? ''), 'interests' => self::to_html_string($row['interests'] ?? ''), 'conditionsTreated' => self::to_html_string($row['conditionsTreated'] ?? ''), 'servicesOffered' => self::to_html_string($row['servicesOffered'] ?? ''), 'procedures' => self::to_html_string($row['procedures'] ?? ''), 'education' => $education, 'training' => $training, 'certifications' => $certifications, 'associations' => $associations, 'awards' => $awards, 'customTabs' => $custom_tabs, 'locations' => $locations, 'hours' => self::normalize_hours($row['hours'] ?? []), 'holidayHours' => trim((string) ($row['holidayHours'] ?? '')), 'insurance' => self::to_string_list($row['insurance'] ?? []), 'metaTitle' => trim((string) ($row['metaTitle'] ?? '')), 'metaDescription' => trim((string) ($row['metaDescription'] ?? '')), ]; } /** * @param array $row * @return array */ private static function normalize_detail_location(array $row, string $provider_detail_url = ''): array { $providers = []; foreach ((array) ($row['providers'] ?? []) as $p) { if (! is_array($p)) { continue; } $slug = (string) ($p['slug'] ?? ''); $profile_url = $slug !== '' && $provider_detail_url !== '' ? Powr_Dir_Rewrites::build_profile_url($provider_detail_url, $slug) : '#'; $providers[] = [ 'name' => (string) ($p['name'] ?? ''), 'slug' => $slug, 'specialty' => (string) ($p['specialty'] ?? ''), 'accepting' => ! empty($p['accepting']), 'acceptingCondition' => trim((string) ($p['acceptingNewPatientsCondition'] ?? $p['acceptingCondition'] ?? '')), 'photo' => self::resolve_provider_photo((string) ($p['photo'] ?? ''), (string) ($p['gender'] ?? '')), 'profileUrl' => $profile_url, ]; } $address = []; if (isset($row['address']) && is_array($row['address'])) { $address = array_values(array_filter(array_map('strval', $row['address']))); } $photos = self::to_string_list($row['photos'] ?? []); $photo = self::resolve_location_photo((string) ($row['photo'] ?? ($photos[0] ?? ''))); if ($photos === []) { if (false === strpos($photo, 'placeholder-location.png')) { $photos = [$photo]; } } else { $photos = array_values(array_filter(array_map( static fn (string $src): string => self::absolutize_api_asset_url($src), $photos ))); } $maps_q = implode(' ', $address); $directions_url = $maps_q !== '' ? 'https://maps.google.com/?q=' . rawurlencode($maps_q) : '#'; return [ 'id' => (string) ($row['id'] ?? ''), 'schemaId' => (string) ($row['schemaId'] ?? ''), 'url' => (string) ($row['url'] ?? ''), 'practiceType' => (string) ($row['practiceType'] ?? ''), 'practiceTypeLabel' => (string) ($row['practiceTypeLabel'] ?? ''), 'schema' => is_array($row['schema'] ?? null) ? $row['schema'] : null, 'graph' => is_array($row['graph'] ?? null) ? $row['graph'] : null, 'schemaEntity' => is_array($row['schemaEntity'] ?? null) ? $row['schemaEntity'] : null, 'schemaOrganization' => is_array($row['schemaOrganization'] ?? null) ? $row['schemaOrganization'] : null, 'schemaWebSite' => is_array($row['schemaWebSite'] ?? null) ? $row['schemaWebSite'] : null, 'websiteId' => (string) ($row['websiteId'] ?? ''), 'organizationId' => (string) ($row['organizationId'] ?? ''), 'ownership' => is_array($row['ownership'] ?? null) ? $row['ownership'] : null, 'publishable' => array_key_exists('publishable', $row) ? (bool) $row['publishable'] : true, 'publishErrors' => is_array($row['publishErrors'] ?? null) ? $row['publishErrors'] : [], 'name' => (string) ($row['name'] ?? ''), 'photo' => $photo, 'services' => self::to_string_list($row['services'] ?? []), 'videoVisits' => ! empty($row['videoVisits']), 'providerCount' => (int) ($row['providerCount'] ?? count($providers)), 'schedulePhone' => (string) ($row['schedulePhone'] ?? $row['phone'] ?? ''), 'about' => array_values(array_filter(array_map('strval', (array) ($row['about'] ?? [])))), 'providers' => $providers, 'photos' => $photos, 'hours' => self::normalize_hours($row['hours'] ?? []), 'holidayHours' => trim((string) ($row['holidayHours'] ?? '')), 'address' => $address, 'addressNote' => trim((string) ($row['addressNote'] ?? '')), 'phone' => (string) ($row['phone'] ?? ''), 'phones' => Powr_Dir_Phone::normalize_list($row['phones'] ?? null, (string) ($row['phone'] ?? $row['schedulePhone'] ?? '')), 'fax' => (string) ($row['fax'] ?? ''), 'email' => (string) ($row['email'] ?? ''), 'mapEmbedUrl' => (string) ($row['mapEmbedUrl'] ?? ''), 'directionsUrl' => $directions_url, 'bookingUrl' => (string) ($row['bookingUrl'] ?? ''), 'insurance' => self::to_string_list($row['insurance'] ?? []), 'paymentMethods' => self::normalize_payment_methods($row['paymentMethods'] ?? []), 'metaTitle' => trim((string) ($row['metaTitle'] ?? '')), 'metaDescription' => trim((string) ($row['metaDescription'] ?? '')), ]; } /** * Normalize weekly hours. Returns [] when no day has open times so * unspecified API day-stubs do not render as all-Closed. * * @param mixed $raw * @return list|null}> */ private static function normalize_hours($raw): array { $hours = []; $has_open = false; foreach ((array) $raw as $h) { if (! is_array($h)) { continue; } $times = $h['times'] ?? null; $normalized_times = null; if (is_array($times)) { $normalized_times = array_values(array_filter( array_map(static fn ($t): string => trim((string) $t), $times), static fn (string $t): bool => $t !== '' )); if ($normalized_times !== []) { $has_open = true; } else { $normalized_times = null; } } $hours[] = [ 'day' => (string) ($h['day'] ?? ''), 'times' => $normalized_times, ]; } return $has_open ? $hours : []; } /** * @param mixed $value */ private static function to_html_string($value): string { if (is_string($value)) { return trim($value); } if (is_array($value)) { $parts = array_values(array_filter(array_map('strval', $value))); return trim(implode('', $parts)); } return ''; } private static function to_string_list($value): array { if (is_string($value)) { $parts = preg_split('/\s*,\s*/', $value) ?: []; return array_values(array_filter(array_map('trim', $parts))); } if (! is_array($value)) { return []; } return array_values(array_filter(array_map( static function ($item) { return trim((string) $item); }, $value ))); } /** * @param mixed $value * @return list}> */ private static function normalize_payment_methods($value): array { if (! is_array($value)) { return []; } $rows = []; foreach ($value as $row) { if (! is_array($row)) { continue; } $label = trim((string) ($row['label'] ?? '')); $answer = trim((string) ($row['answer'] ?? '')); if ($label === '' || ($answer !== 'Yes' && $answer !== 'No')) { continue; } $credit_cards = []; if (isset($row['creditCards']) && is_array($row['creditCards'])) { $credit_cards = array_values(array_filter(array_map( static fn ($item): string => trim((string) $item), $row['creditCards'] ))); } $rows[] = [ 'label' => $label, 'answer' => $answer, 'creditCards' => $credit_cards, ]; } return $rows; } private static function format_gender_label(string $gender): string { $map = [ 'male' => 'Male', 'female' => 'Female', 'non_binary' => 'Non-binary', 'prefer_not' => 'Prefer not to say', ]; $key = strtolower(str_replace(' ', '_', $gender)); return $map[$key] ?? ($gender !== '' ? $gender : ''); } private static function resolve_provider_photo(string $apiPhoto, string $genderRaw): string { $apiPhoto = trim($apiPhoto); if ($apiPhoto !== '' && ! self::is_laravel_placeholder_photo($apiPhoto)) { return $apiPhoto; } $genderKey = strtolower(str_replace(' ', '_', $genderRaw)); $filename = $genderKey === 'female' ? 'placeholder-female.png' : 'placeholder-male.png'; return POWR_DIR_URL . 'assets/images/' . $filename; } private static function resolve_location_photo(string $apiPhoto): string { $apiPhoto = trim($apiPhoto); if ($apiPhoto === '') { return POWR_DIR_URL . 'assets/images/placeholder-location.png'; } return self::absolutize_api_asset_url($apiPhoto); } private static function absolutize_api_asset_url(string $url): string { $url = trim($url); if ($url === '') { return ''; } if (preg_match('#^https?://#i', $url)) { return $url; } $base = rtrim((string) get_option('powr_api_base_url', ''), '/'); if ($base === '') { return $url; } return $base . '/' . ltrim($url, '/'); } private static function is_laravel_placeholder_photo(string $url): bool { return false !== strpos($url, '/images/providers/placeholder-'); } private static function error_box(string $message): string { wp_enqueue_style('powr-dir-fonts'); wp_enqueue_style('powr-dir-host'); wp_enqueue_script('powr-dir-shadow'); $inner = '

' . esc_html__('Something went wrong', 'sondy') . '

' . esc_html($message) . '

'; return self::wrap_shadow($inner, ['pd-global.css']); } }