register_cpt(); flush_rewrite_rules(); } ); final class Speakers_For_TEC { const CPT = 'tec_speaker'; const META_KEY = '_tec_event_speakers'; const PICKER_LIMIT = 200; // ------------------------------------------------------------------------- // Boot // ------------------------------------------------------------------------- public function __construct() { add_action( 'init', [ $this, 'register_cpt' ] ); add_action( 'add_meta_boxes', [ $this, 'add_meta_boxes' ] ); add_action( 'save_post_' . self::CPT, [ $this, 'save_speaker_meta' ], 10, 2 ); add_action( 'save_post_tribe_events', [ $this, 'save_event_meta' ], 10, 2 ); add_action( 'admin_menu', [ $this, 'add_admin_menu' ] ); add_action( 'admin_post_sftec_import', [ $this, 'handle_csv_import' ] ); add_action( 'admin_notices', [ $this, 'admin_notices' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ] ); add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_styles' ] ); add_shortcode( 'tec_speakers', [ $this, 'speakers_shortcode' ] ); if ( class_exists( 'Tribe__Events__Main' ) ) { add_action( 'tribe_events_single_event_after_the_meta', [ $this, 'display_event_speakers' ] ); } else { add_action( 'admin_notices', [ $this, 'notice_tec_missing' ] ); } } // ------------------------------------------------------------------------- // Custom post type // ------------------------------------------------------------------------- public function register_cpt(): void { register_post_type( self::CPT, [ 'labels' => [ 'name' => __( 'Speakers', 'speakers-for-tec' ), 'singular_name' => __( 'Speaker', 'speakers-for-tec' ), 'add_new_item' => __( 'Add New Speaker', 'speakers-for-tec' ), 'edit_item' => __( 'Edit Speaker', 'speakers-for-tec' ), 'new_item' => __( 'New Speaker', 'speakers-for-tec' ), 'view_item' => __( 'View Speaker', 'speakers-for-tec' ), 'search_items' => __( 'Search Speakers', 'speakers-for-tec' ), 'not_found' => __( 'No speakers found', 'speakers-for-tec' ), 'not_found_in_trash' => __( 'No speakers in trash', 'speakers-for-tec' ), 'all_items' => __( 'All Speakers', 'speakers-for-tec' ), 'menu_name' => __( 'Speakers', 'speakers-for-tec' ), ], 'public' => true, 'publicly_queryable' => true, 'show_ui' => true, 'show_in_menu' => true, 'query_var' => true, 'rewrite' => [ 'slug' => 'speakers' ], 'capability_type' => 'post', 'has_archive' => true, 'hierarchical' => false, 'menu_position' => 6, 'menu_icon' => 'dashicons-groups', 'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt' ], 'show_in_rest' => true, ] ); } // ------------------------------------------------------------------------- // Dependency notice // ------------------------------------------------------------------------- public function notice_tec_missing(): void { echo '

' . esc_html__( 'Speakers for The Events Calendar requires The Events Calendar plugin to be installed and active.', 'speakers-for-tec' ) . '

'; } // ------------------------------------------------------------------------- // Meta boxes // ------------------------------------------------------------------------- public function add_meta_boxes(): void { add_meta_box( 'sftec_speaker_details', __( 'Speaker Details', 'speakers-for-tec' ), [ $this, 'render_speaker_details_meta' ], self::CPT, 'side', 'default' ); if ( post_type_exists( 'tribe_events' ) ) { add_meta_box( 'sftec_event_speakers', __( 'Speakers', 'speakers-for-tec' ), [ $this, 'render_event_speakers_meta' ], 'tribe_events', 'normal', 'default' ); } } public function render_speaker_details_meta( \WP_Post $post ): void { wp_nonce_field( 'sftec_speaker_details_save', '_sftec_speaker_nonce' ); $website = get_post_meta( $post->ID, '_tec_speaker_website', true ); $email = get_post_meta( $post->ID, '_tec_speaker_email', true ); ?>



ID, self::META_KEY, true ) ?: [] ); $speakers = get_posts( [ 'post_type' => self::CPT, 'posts_per_page' => self::PICKER_LIMIT, 'orderby' => 'title', 'order' => 'ASC', 'post_status' => 'publish', ] ); if ( empty( $speakers ) ) { printf( '

%s %s

', esc_html__( 'No speakers yet.', 'speakers-for-tec' ), esc_url( admin_url( 'post-new.php?post_type=' . self::CPT ) ), esc_html__( 'Add speakers first, then return here to attach them.', 'speakers-for-tec' ) ); return; } echo ''; echo '

' . esc_html__( 'Type to search. Click a name to add, click again to remove.', 'speakers-for-tec' ) . '

'; } // ------------------------------------------------------------------------- // Saving meta // ------------------------------------------------------------------------- public function save_speaker_meta( int $post_id, \WP_Post $post ): void { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( ! isset( $_POST['_sftec_speaker_nonce'] ) || ! wp_verify_nonce( $_POST['_sftec_speaker_nonce'], 'sftec_speaker_details_save' ) || ! current_user_can( 'edit_post', $post_id ) ) { return; } update_post_meta( $post_id, '_tec_speaker_website', esc_url_raw( $_POST['sftec_speaker_website'] ?? '' ) ); update_post_meta( $post_id, '_tec_speaker_email', sanitize_email( $_POST['sftec_speaker_email'] ?? '' ) ); } public function save_event_meta( int $post_id, \WP_Post $post ): void { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( ! isset( $_POST['_sftec_event_speakers_nonce'] ) || ! wp_verify_nonce( $_POST['_sftec_event_speakers_nonce'], 'sftec_event_speakers_save' ) || ! current_user_can( 'edit_post', $post_id ) ) { return; } $ids = array_map( 'intval', (array) ( $_POST['sftec_event_speakers'] ?? [] ) ); update_post_meta( $post_id, self::META_KEY, array_filter( $ids ) ); } // ------------------------------------------------------------------------- // Front-end: speakers on event pages // ------------------------------------------------------------------------- public function display_event_speakers(): void { $post_id = get_the_ID(); $speaker_ids = (array) ( get_post_meta( $post_id, self::META_KEY, true ) ?: [] ); if ( empty( $speaker_ids ) ) { return; } $speakers = get_posts( [ 'post_type' => self::CPT, 'post__in' => $speaker_ids, 'posts_per_page' => -1, 'orderby' => 'post__in', 'post_status' => 'publish', ] ); if ( empty( $speakers ) ) { return; } $heading = count( $speakers ) > 1 ? __( 'Speakers', 'speakers-for-tec' ) : __( 'Speaker', 'speakers-for-tec' ); echo '
'; echo '

' . esc_html( $heading ) . '

'; echo '
'; foreach ( $speakers as $speaker ) { $this->render_speaker_card( $speaker, 'event' ); } echo '
'; } // ------------------------------------------------------------------------- // Shortcode: [tec_speakers limit="20" search=""] // ------------------------------------------------------------------------- public function speakers_shortcode( array $atts ): string { $atts = shortcode_atts( [ 'limit' => 50, 'search' => '' ], $atts, 'tec_speakers' ); $args = [ 'post_type' => self::CPT, 'posts_per_page' => (int) $atts['limit'], 'orderby' => 'title', 'order' => 'ASC', 'post_status' => 'publish', ]; if ( $atts['search'] ) { $args['s'] = sanitize_text_field( $atts['search'] ); } $speakers = get_posts( $args ); if ( empty( $speakers ) ) { return '

' . esc_html__( 'No speakers found.', 'speakers-for-tec' ) . '

'; } ob_start(); echo '
'; foreach ( $speakers as $speaker ) { $this->render_speaker_card( $speaker, 'archive' ); } echo '
'; return ob_get_clean(); } // ------------------------------------------------------------------------- // Shared card renderer // ------------------------------------------------------------------------- private function render_speaker_card( \WP_Post $speaker, string $context = 'event' ): void { $permalink = get_permalink( $speaker->ID ); $website = get_post_meta( $speaker->ID, '_tec_speaker_website', true ); echo '
'; if ( has_post_thumbnail( $speaker->ID ) ) { echo '
'; echo ''; echo get_the_post_thumbnail( $speaker->ID, [ 80, 80 ], [ 'class' => 'sftec-speaker-img' ] ); echo ''; echo '
'; } echo '
'; echo '

' . esc_html( $speaker->post_title ) . '

'; $excerpt = $speaker->post_excerpt ?: wp_trim_words( wp_strip_all_tags( $speaker->post_content ), 30 ); if ( $excerpt ) { echo '

' . esc_html( $excerpt ) . '

'; } if ( $website ) { echo '' . esc_html__( 'Website', 'speakers-for-tec' ) . ' →'; } echo '
'; echo '
'; } // ------------------------------------------------------------------------- // Styles — registered through WordPress, not echoed inline // ------------------------------------------------------------------------- public function enqueue_styles(): void { wp_register_style( 'sftec-speakers', false, [], '1.0.0' ); wp_enqueue_style( 'sftec-speakers' ); wp_add_inline_style( 'sftec-speakers', $this->get_css() ); } private function get_css(): string { return ' .sftec-event-speakers { margin: 2em 0; border-top: 1px solid #e0e0e0; padding-top: 1.5em; } .sftec-speakers-heading { font-size: 1.15em; font-weight: 600; margin: 0 0 1em; } .sftec-speakers-list, .sftec-speakers-archive { display: flex; flex-wrap: wrap; gap: 1.25em; } .sftec-speaker-card { display: flex; gap: 0.85em; align-items: flex-start; flex: 1 1 260px; max-width: 420px; } .sftec-speaker-photo img { border-radius: 50%; width: 64px; height: 64px; object-fit: cover; display: block; } .sftec-speaker-info { flex: 1; min-width: 0; } .sftec-speaker-name { margin: 0 0 0.3em; font-size: 1em; font-weight: 600; } .sftec-speaker-name a { text-decoration: none; color: inherit; } .sftec-speaker-name a:hover { text-decoration: underline; } .sftec-speaker-bio { margin: 0 0 0.4em; font-size: 0.875em; color: #555; line-height: 1.4; } .sftec-speaker-link { font-size: 0.825em; } '; } // ------------------------------------------------------------------------- // Admin: Select2 on event edit screens // ------------------------------------------------------------------------- public function admin_enqueue_scripts( string $hook ): void { $screen = get_current_screen(); if ( ! $screen || $screen->post_type !== 'tribe_events' || ! in_array( $hook, [ 'post.php', 'post-new.php' ], true ) ) { return; } // TEC ships SelectWoo as 'tribe-select2'; fall back to a plain 'select2' // registration from another plugin, then finally our own CDN copy. if ( wp_script_is( 'tribe-select2', 'registered' ) ) { wp_enqueue_script( 'tribe-select2' ); wp_enqueue_style( 'tribe-select2-css' ); $handle = 'tribe-select2'; } elseif ( wp_script_is( 'select2', 'registered' ) ) { wp_enqueue_script( 'select2' ); wp_enqueue_style( 'select2' ); $handle = 'select2'; } else { wp_enqueue_script( 'sftec-select2', 'https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js', [ 'jquery' ], '4.1.0', true ); wp_enqueue_style( 'sftec-select2', 'https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css', [], '4.1.0' ); $handle = 'sftec-select2'; } wp_add_inline_script( $handle, "jQuery(function($){ $('#sftec-speaker-select').select2({ placeholder: '" . esc_js( __( 'Search for a speaker…', 'speakers-for-tec' ) ) . "', allowClear: true, width: '100%' }); });" ); } // ------------------------------------------------------------------------- // Admin: CSV import page // ------------------------------------------------------------------------- public function add_admin_menu(): void { add_submenu_page( 'edit.php?post_type=' . self::CPT, __( 'Import Speakers from CSV', 'speakers-for-tec' ), __( 'Import CSV', 'speakers-for-tec' ), 'manage_options', 'sftec-import-speakers', [ $this, 'render_import_page' ] ); } public function render_import_page(): void { ?>

name, bio, email, website, image

'no_file' ], 60 ); wp_redirect( $redirect_url ); exit; } // Validate MIME type server-side — browser accept=".csv" is not a security control $finfo = new \finfo( FILEINFO_MIME_TYPE ); $mime = $finfo->file( $_FILES['speakers_csv']['tmp_name'] ); if ( ! in_array( $mime, [ 'text/plain', 'text/csv', 'application/csv', 'application/vnd.ms-excel' ], true ) ) { set_transient( $transient_key, [ 'err' => 'invalid_type' ], 60 ); wp_redirect( $redirect_url ); exit; } $handle = fopen( $_FILES['speakers_csv']['tmp_name'], 'r' ); if ( ! $handle ) { set_transient( $transient_key, [ 'err' => 'read_fail' ], 60 ); wp_redirect( $redirect_url ); exit; } $skip_header = ! empty( $_POST['skip_header'] ); // Build name index to prevent duplicates $existing_ids = get_posts( [ 'post_type' => self::CPT, 'posts_per_page' => -1, 'fields' => 'ids', 'post_status' => 'any', ] ); $existing_names = []; foreach ( $existing_ids as $id ) { $existing_names[ $this->normalise_name( get_the_title( $id ) ) ] = true; } $created = $skipped = $errors = 0; $row_num = 0; while ( ( $row = fgetcsv( $handle ) ) !== false ) { $row_num++; if ( $skip_header && $row_num === 1 ) { continue; } if ( empty( array_filter( $row ) ) ) { continue; } [ $name, $bio, $email, $website ] = array_pad( $row, 4, '' ); $name = trim( $name ); if ( ! $name ) { continue; } $key = $this->normalise_name( $name ); if ( isset( $existing_names[ $key ] ) ) { $skipped++; continue; } $bio = sanitize_textarea_field( $bio ); $post_id = wp_insert_post( [ 'post_type' => self::CPT, 'post_title' => sanitize_text_field( $name ), 'post_content' => $bio, 'post_excerpt' => wp_trim_words( $bio, 30 ), 'post_status' => 'publish', ] ); if ( is_wp_error( $post_id ) ) { $errors++; continue; } if ( $email ) { update_post_meta( $post_id, '_tec_speaker_email', sanitize_email( trim( $email ) ) ); } if ( $website ) { update_post_meta( $post_id, '_tec_speaker_website', esc_url_raw( trim( $website ) ) ); } $existing_names[ $key ] = true; $created++; } fclose( $handle ); set_transient( $transient_key, [ 'created' => $created, 'skipped' => $skipped, 'errors' => $errors, ], 60 ); wp_redirect( $redirect_url ); exit; } private function normalise_name( string $name ): string { $name = strtolower( trim( $name ) ); $name = preg_replace( '/^(dr\.?|prof\.?|mr\.?|mrs\.?|ms\.?|miss\.?)\s+/u', '', $name ) ?? $name; return preg_replace( '/\s+/', ' ', $name ) ?? $name; } // ------------------------------------------------------------------------- // Admin notices: import result banner // ------------------------------------------------------------------------- public function admin_notices(): void { $transient_key = 'sftec_import_result_' . get_current_user_id(); $result = get_transient( $transient_key ); if ( ! is_array( $result ) ) { return; } delete_transient( $transient_key ); if ( isset( $result['err'] ) ) { $msgs = [ 'no_file' => __( 'No file was uploaded.', 'speakers-for-tec' ), 'read_fail' => __( 'Could not read the uploaded file.', 'speakers-for-tec' ), 'invalid_type' => __( 'The uploaded file is not a valid CSV.', 'speakers-for-tec' ), ]; $msg = $msgs[ $result['err'] ] ?? __( 'An unknown error occurred.', 'speakers-for-tec' ); printf( '

%s %s

', esc_html__( 'Import failed:', 'speakers-for-tec' ), esc_html( $msg ) ); return; } printf( '

%s

', wp_kses( sprintf( /* translators: 1: number of speakers created, 2: number skipped, 3: number of errors */ __( 'Import complete — %1$d created, %2$d skipped (already exist), %3$d errors.', 'speakers-for-tec' ), (int) $result['created'], (int) $result['skipped'], (int) $result['errors'] ), [ 'strong' => [] ] ) ); } } add_action( 'plugins_loaded', fn() => new Speakers_For_TEC() );