trustbio_table = $wpdb->prefix . 'trustbio_author_credentials'; } // ========================================================= // Render // ========================================================= public function render(): void { require_once TRUSTBIO_PLUGIN_DIR . 'admin/views/credentials.php'; } // ========================================================= // AJAX: Save credential // ========================================================= public function ajax_save(): void { check_ajax_referer( 'trustbio_save_credential', 'nonce' ); trustbio_verify_ajax_request( 'trustbio_save_credential' ); global $wpdb; $trustbio_id = isset( $_POST['cred_id'] ) ? absint( $_POST['cred_id'] ) : ( isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0 ); $trustbio_author_id = absint( $_POST['author_id'] ?? 0 ); if ( ! $trustbio_author_id ) { wp_send_json_error( array( 'message' => __( 'Author ID is required.', 'trustbio' ) ) ); } // Validate badge type. $trustbio_valid_types = array_keys( self::get_badge_types() ); $trustbio_badge_type = sanitize_key( wp_unslash( $_POST['badge_type'] ?? 'custom' ) ); if ( ! in_array( $trustbio_badge_type, $trustbio_valid_types, true ) ) { $trustbio_badge_type = 'custom'; } $trustbio_data = array( 'author_id' => $trustbio_author_id, 'badge_type' => $trustbio_badge_type, 'credential_name' => sanitize_text_field( wp_unslash( $_POST['credential_name'] ?? '' ) ), 'issuing_body' => sanitize_text_field( wp_unslash( $_POST['issuing_body'] ?? '' ) ), 'license_number' => sanitize_text_field( wp_unslash( $_POST['license_number'] ?? '' ) ), 'issue_date' => sanitize_text_field( wp_unslash( $_POST['issue_date'] ?? '' ) ), 'expiry_date' => sanitize_text_field( wp_unslash( $_POST['expiry_date'] ?? '' ) ) ?: null, 'verification_url' => esc_url_raw( wp_unslash( $_POST['verification_url'] ?? '' ) ), 'show_badge' => isset( $_POST['show_badge'] ) ? 1 : 0, 'show_in_schema' => isset( $_POST['show_in_schema'] ) ? 1 : 0, 'sort_order' => absint( $_POST['sort_order'] ?? 0 ), ); if ( empty( $trustbio_data['credential_name'] ) ) { wp_send_json_error( array( 'message' => __( 'Credential name is required.', 'trustbio' ) ) ); } // Auto-build NPI verification URL from license number when not manually supplied. if ( 'npi' === $trustbio_badge_type && ! empty( $trustbio_data['license_number'] ) && empty( $trustbio_data['verification_url'] ) ) { $trustbio_npi_num = preg_replace( '/\D/', '', $trustbio_data['license_number'] ); if ( $trustbio_npi_num ) { $trustbio_data['verification_url'] = 'https://npiregistry.cms.hhs.gov/provider-view/' . $trustbio_npi_num; } } // Validate dates. if ( ! empty( $trustbio_data['issue_date'] ) && ! $this->is_valid_date( $trustbio_data['issue_date'] ) ) { $trustbio_data['issue_date'] = ''; } if ( ! empty( $trustbio_data['expiry_date'] ) && ! $this->is_valid_date( $trustbio_data['expiry_date'] ) ) { $trustbio_data['expiry_date'] = null; } if ( $trustbio_id > 0 ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $this->trustbio_table, $trustbio_data, array( 'id' => $trustbio_id ), null, array( '%d' ) ); wp_send_json_success( array( 'message' => __( 'Credential updated.', 'trustbio' ), 'id' => $trustbio_id ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $this->trustbio_table, $trustbio_data ); $trustbio_new_id = $wpdb->insert_id; if ( ! $trustbio_new_id ) { wp_send_json_error( array( 'message' => __( 'Failed to save credential.', 'trustbio' ) ) ); } wp_send_json_success( array( 'message' => __( 'Credential added.', 'trustbio' ), 'id' => $trustbio_new_id ) ); } // ========================================================= // AJAX: Delete credential // ========================================================= public function ajax_delete(): void { check_ajax_referer( 'trustbio_delete_credential', 'nonce' ); trustbio_verify_ajax_request( 'trustbio_delete_credential' ); global $wpdb; $trustbio_id = absint( $_POST['id'] ?? 0 ); if ( ! $trustbio_id ) { wp_send_json_error( array( 'message' => __( 'Invalid credential ID.', 'trustbio' ) ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->delete( $this->trustbio_table, array( 'id' => $trustbio_id ), array( '%d' ) ); wp_send_json_success( array( 'message' => __( 'Credential deleted.', 'trustbio' ) ) ); } // ========================================================= // AJAX: Reorder credentials // ========================================================= public function ajax_reorder(): void { check_ajax_referer( 'trustbio_reorder_credentials', 'nonce' ); trustbio_verify_ajax_request( 'trustbio_reorder_credentials' ); global $wpdb; $trustbio_order = isset( $_POST['order'] ) && is_array( $_POST['order'] ) ? array_map( 'absint', wp_unslash( $_POST['order'] ) ) : array(); foreach ( $trustbio_order as $trustbio_pos => $trustbio_cred_id ) { if ( ! $trustbio_cred_id ) { continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $this->trustbio_table, array( 'sort_order' => $trustbio_pos ), array( 'id' => $trustbio_cred_id ), array( '%d' ), array( '%d' ) ); } wp_send_json_success( array( 'message' => __( 'Order saved.', 'trustbio' ) ) ); } // ========================================================= // Public: get credentials for an author // ========================================================= /** * Get all credentials for an author, ordered by sort_order. * * @param int $trustbio_author_id Author ID. * @param bool $trustbio_badge_only Return only credentials with show_badge = 1. * @return array */ public static function get_for_author( int $trustbio_author_id, bool $trustbio_badge_only = false ): array { global $wpdb; $trustbio_t = $wpdb->prefix . 'trustbio_author_credentials'; /* * Two whole queries rather than one with a WHERE fragment spliced * into it. The fragment was a literal either way, so nothing could be * injected through it — but a query built by concatenation has to be * read to know that, and these two can be read at a glance. */ if ( $trustbio_badge_only ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE author_id = %d AND show_badge = 1 ORDER BY sort_order ASC, id ASC', $trustbio_t, $trustbio_author_id ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE author_id = %d ORDER BY sort_order ASC, id ASC', $trustbio_t, $trustbio_author_id ) ); } /** * Get credentials for schema output (show_in_schema = 1). * * @param int $trustbio_author_id Author ID. * @return array */ public static function get_for_schema( int $trustbio_author_id ): array { global $wpdb; $trustbio_t = $wpdb->prefix . 'trustbio_author_credentials'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE author_id = %d AND show_in_schema = 1 ORDER BY sort_order ASC', $trustbio_t, $trustbio_author_id ) ); } // ========================================================= // Public: badge rendering // ========================================================= /** * Render inline HTML badges for an author (used in author box frontend). * * @param int $trustbio_author_id Author ID. * @return string HTML. */ public static function render_badges( int $trustbio_author_id ): string { $trustbio_creds = self::get_for_author( $trustbio_author_id, true ); if ( empty( $trustbio_creds ) ) { return ''; } $trustbio_badge_types = self::get_badge_types(); $trustbio_html = ''; foreach ( $trustbio_creds as $trustbio_cred ) { // Check expiry. if ( ! empty( $trustbio_cred->expiry_date ) && strtotime( $trustbio_cred->expiry_date ) < time() ) { continue; // Skip expired credentials silently on frontend. } $trustbio_badge_info = $trustbio_badge_types[ $trustbio_cred->badge_type ] ?? $trustbio_badge_types['custom']; $trustbio_label = esc_html( self::badge_label( $trustbio_cred ) ); $trustbio_tooltip = esc_attr( $trustbio_cred->credential_name ); if ( ! empty( $trustbio_cred->issuing_body ) ) { $trustbio_tooltip .= ' — ' . esc_attr( $trustbio_cred->issuing_body ); } $trustbio_color = esc_attr( $trustbio_badge_info['color'] ); $trustbio_text_color = esc_attr( $trustbio_badge_info['text'] ); $trustbio_icon_svg = $trustbio_badge_info['icon']; $trustbio_badge_html = ''; if ( $trustbio_icon_svg ) { $trustbio_badge_html .= ''; } $trustbio_badge_html .= $trustbio_label; // Close the badge span before anything is wrapped around it. This // used to happen only in the else branch below, so every badge that // had a verification URL emitted an unclosed . $trustbio_badge_html .= ''; if ( ! empty( $trustbio_cred->verification_url ) ) { // Wrap in a link if verification URL is set. $trustbio_badge_html = '' . $trustbio_badge_html . ''; } $trustbio_html .= $trustbio_badge_html; } return $trustbio_html; } /** * Get credential labels for an author as a plain-text comma-separated string. * Mirrors the label logic in render_badges(), skipping expired credentials. * * @param int $trustbio_author_id Author ID. * @return string e.g. "MBA, PHD" */ public static function get_text_for_author( int $trustbio_author_id ): string { $trustbio_creds = self::get_for_author( $trustbio_author_id, true ); if ( empty( $trustbio_creds ) ) { return ''; } $trustbio_labels = array(); foreach ( $trustbio_creds as $trustbio_cred ) { if ( ! empty( $trustbio_cred->expiry_date ) && strtotime( $trustbio_cred->expiry_date ) < time() ) { continue; } $trustbio_labels[] = self::badge_label( $trustbio_cred ); } return implode( ', ', $trustbio_labels ); } /** * The short label shown on a credential badge. * * Shared by render_badges() and get_text_for_author() so the badge and the * plain-text list can never disagree — they previously held two copies of * this logic with a comment asking that they be kept in step. * * For a known badge type this is the type's own label ("MD"), not the raw * slug, which was being printed lowercase and only looked right because of * a CSS text-transform. * * For a custom credential it is the leading word rather than a fixed byte * count. The old `substr( $name, 0, 6 )` cut "FRCP (Cardiology)" to the * visibly broken "FRCP (", and being a BYTE cut it truncated non-Latin * scripts far harder than Latin ones and could slice a UTF-8 character in * half — "Dré循環器" came out as invalid bytes. * * @param object $trustbio_cred Credential row. * @return string */ private static function badge_label( object $trustbio_cred ): string { $trustbio_type = (string) ( $trustbio_cred->badge_type ?? 'custom' ); if ( 'custom' !== $trustbio_type ) { $trustbio_types = self::get_badge_types(); return isset( $trustbio_types[ $trustbio_type ] ) ? (string) $trustbio_types[ $trustbio_type ]['label'] : strtoupper( $trustbio_type ); } $trustbio_name = trim( (string) $trustbio_cred->credential_name ); if ( '' === $trustbio_name ) { return ''; } // The leading word, so "FRCP (Cardiology)" badges as "FRCP". $trustbio_parts = preg_split( '/[\s(\[,;\/–—-]+/u', $trustbio_name, 2 ); $trustbio_first = is_array( $trustbio_parts ) ? (string) $trustbio_parts[0] : ''; if ( '' !== $trustbio_first && mb_strlen( $trustbio_first ) <= 10 ) { return $trustbio_first; } return mb_strlen( $trustbio_name ) > 8 ? mb_substr( $trustbio_name, 0, 8 ) . '…' : $trustbio_name; } /** * Build the hasCredential schema array for JSON-LD output. * * @param int $trustbio_author_id Author ID. * @return array */ public static function get_schema_credentials( int $trustbio_author_id ): array { $trustbio_creds = self::get_for_schema( $trustbio_author_id ); $trustbio_result = array(); foreach ( $trustbio_creds as $trustbio_cred ) { // Never emit a lapsed credential into the structured data. The badge and // text renderers drop expired credentials too, so the page and the schema // always agree: the site cannot claim a qualification that has expired. if ( ! empty( $trustbio_cred->expiry_date ) && strtotime( $trustbio_cred->expiry_date ) < time() ) { continue; } $trustbio_is_npi = ( 'npi' === ( $trustbio_cred->badge_type ?? '' ) ); if ( $trustbio_is_npi ) { // NPI gets its own structured block per the spec: GovernmentOrganization recognizedBy, // credentialCategory, and the NPI number surfaced as the name. $trustbio_npi_num = preg_replace( '/\D/', '', (string) $trustbio_cred->license_number ); $trustbio_npi_url = ! empty( $trustbio_cred->verification_url ) ? $trustbio_cred->verification_url : ( $trustbio_npi_num ? 'https://npiregistry.cms.hhs.gov/provider-view/' . $trustbio_npi_num : '' ); $trustbio_schema_cred = array( '@type' => 'EducationalOccupationalCredential', 'credentialCategory' => 'NPI', 'name' => $trustbio_npi_num ? 'NPI #' . $trustbio_npi_num : $trustbio_cred->credential_name, 'recognizedBy' => array( '@type' => 'GovernmentOrganization', 'name' => 'CMS National Plan & Provider Enumeration System', ), ); if ( $trustbio_npi_url ) { $trustbio_schema_cred['url'] = $trustbio_npi_url; } if ( ! empty( $trustbio_cred->issue_date ) ) { $trustbio_schema_cred['dateCreated'] = $trustbio_cred->issue_date; } } else { $trustbio_schema_cred = array( '@type' => 'EducationalOccupationalCredential', 'name' => $trustbio_cred->credential_name, ); if ( ! empty( $trustbio_cred->issuing_body ) ) { $trustbio_schema_cred['recognizedBy'] = array( '@type' => 'Organization', 'name' => $trustbio_cred->issuing_body, ); } if ( ! empty( $trustbio_cred->issue_date ) ) { $trustbio_schema_cred['dateCreated'] = $trustbio_cred->issue_date; } if ( ! empty( $trustbio_cred->expiry_date ) ) { $trustbio_schema_cred['expires'] = $trustbio_cred->expiry_date; } if ( ! empty( $trustbio_cred->verification_url ) ) { $trustbio_schema_cred['url'] = $trustbio_cred->verification_url; } } $trustbio_result[] = $trustbio_schema_cred; } return $trustbio_result; } // ========================================================= // Public: badge types registry // ========================================================= /** * All supported credential badge types with display config. * * @return array */ public static function get_badge_types(): array { return array( // Medical. 'md' => array( 'label' => 'MD', 'description' => __( 'Medical Doctor', 'trustbio' ), 'color' => '#1e40af', 'text' => '#ffffff', 'icon' => '', ), 'do' => array( 'label' => 'DO', 'description' => __( 'Doctor of Osteopathic Medicine', 'trustbio' ), 'color' => '#1e40af', 'text' => '#ffffff', 'icon' => '', ), 'rn' => array( 'label' => 'RN', 'description' => __( 'Registered Nurse', 'trustbio' ), 'color' => '#0891b2', 'text' => '#ffffff', 'icon' => '', ), 'phd' => array( 'label' => 'PhD', 'description' => __( 'Doctor of Philosophy', 'trustbio' ), 'color' => '#6d28d9', 'text' => '#ffffff', 'icon' => '', ), // Legal. 'jd' => array( 'label' => 'JD', 'description' => __( 'Juris Doctor (Attorney)', 'trustbio' ), 'color' => '#92400e', 'text' => '#ffffff', 'icon' => '', ), 'esq' => array( 'label' => 'ESQ', 'description' => __( 'Esquire (Licensed Attorney)', 'trustbio' ), 'color' => '#92400e', 'text' => '#ffffff', 'icon' => '', ), // Financial. 'cpa' => array( 'label' => 'CPA', 'description' => __( 'Certified Public Accountant', 'trustbio' ), 'color' => '#065f46', 'text' => '#ffffff', 'icon' => '', ), 'cfp' => array( 'label' => 'CFP', 'description' => __( 'Certified Financial Planner', 'trustbio' ), 'color' => '#065f46', 'text' => '#ffffff', 'icon' => '', ), // Mental health. 'lcsw' => array( 'label' => 'LCSW', 'description' => __( 'Licensed Clinical Social Worker', 'trustbio' ), 'color' => '#0f766e', 'text' => '#ffffff', 'icon' => '', ), 'lmft' => array( 'label' => 'LMFT', 'description' => __( 'Licensed Marriage and Family Therapist', 'trustbio' ), 'color' => '#0f766e', 'text' => '#ffffff', 'icon' => '', ), 'lpcc' => array( 'label' => 'LPCC', 'description' => __( 'Licensed Professional Clinical Counselor', 'trustbio' ), 'color' => '#0f766e', 'text' => '#ffffff', 'icon' => '', ), // Academic. 'ms' => array( 'label' => 'MS', 'description' => __( 'Master of Science', 'trustbio' ), 'color' => '#4338ca', 'text' => '#ffffff', 'icon' => '', ), 'mba' => array( 'label' => 'MBA', 'description' => __( 'Master of Business Administration', 'trustbio' ), 'color' => '#4338ca', 'text' => '#ffffff', 'icon' => '', ), // Healthcare identity verification. 'npi' => array( 'label' => 'NPI', 'description' => __( 'National Provider Identifier (US healthcare)', 'trustbio' ), 'color' => '#0369a1', 'text' => '#ffffff', 'icon' => '', ), // General / custom. 'verified' => array( 'label' => 'Verified', 'description' => __( 'Verified Expert', 'trustbio' ), 'color' => '#16a34a', 'text' => '#ffffff', 'icon' => '', ), 'custom' => array( 'label' => 'Custom', 'description' => __( 'Custom credential — label auto-set from credential name', 'trustbio' ), 'color' => '#374151', 'text' => '#ffffff', 'icon' => '', ), ); } // ========================================================= // Private helpers // ========================================================= /** * Validate a date string (YYYY-MM-DD). * * @param string $trustbio_date Date string. * @return bool */ private function is_valid_date( string $trustbio_date ): bool { if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $trustbio_date ) ) { return false; } $trustbio_ts = strtotime( $trustbio_date ); return false !== $trustbio_ts && $trustbio_ts > 0; } }