upskill-event-manager/includes/class-hvac-settings.php
ben 25bf5d98e1 feat(slack): Add Slack notifications for registrations, tickets, and events
Instant Slack alerts via Incoming Webhook with Block Kit rich formatting:
- New trainer registrations (name, role, org, business type, photo)
- Ticket purchases (purchaser, event, count, total, gateway)
- Events submitted by trainers via TEC Community Events form
- Events published by admins (draft/pending → publish)

Settings UI with webhook URL field, validation, and test button.
Non-blocking sends so Slack failures never affect user flows.
Atomic add_post_meta idempotency guards prevent duplicate sends.
Code reviewed by GPT-5 (Codex) — all 5 findings addressed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:50:08 -04:00

605 lines
No EOL
26 KiB
PHP

<?php
declare(strict_types=1);
/**
* Handles plugin settings and options
*/
if (!defined('ABSPATH')) {
exit;
}
class HVAC_Settings {
public function __construct() {
add_action('admin_menu', [$this, 'add_admin_menu']);
add_action('admin_init', [$this, 'register_settings']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
}
public function add_admin_menu() {
// Add main menu page
add_menu_page(
__('HVAC Community Events', 'hvac-ce'),
__('HVAC Community Events', 'hvac-ce'),
'manage_options',
'hvac-community-events',
[$this, 'options_page'],
'dashicons-calendar-alt',
30
);
// Add settings submenu
add_submenu_page(
'hvac-community-events',
__('Settings', 'hvac-ce'),
__('Settings', 'hvac-ce'),
'manage_options',
'hvac-community-events',
[$this, 'options_page']
);
// Add dashboard submenu
add_submenu_page(
'hvac-community-events',
__('Dashboard', 'hvac-ce'),
__('Dashboard', 'hvac-ce'),
'manage_options',
'hvac-ce-dashboard',
[$this, 'dashboard_page']
);
// Add trainer login link submenu
$training_login_url = home_url('training-login');
add_submenu_page(
'hvac-community-events',
__('Trainer Login', 'hvac-ce'),
__('Trainer Login', 'hvac-ce'),
'manage_options',
$training_login_url,
null
);
}
public function register_settings() {
register_setting('hvac_ce_options', 'hvac_ce_options');
register_setting('hvac_ce_options', 'hvac_google_maps_api_key', [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
]);
register_setting('hvac_ce_options', 'hvac_google_geocoding_api_key', [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
]);
add_settings_section(
'hvac_ce_main',
__('HVAC Community Events Settings', 'hvac-ce'),
[$this, 'settings_section_callback'],
'hvac-ce'
);
add_settings_field(
'notification_emails',
__('Trainer Notification Emails', 'hvac-ce'),
[$this, 'notification_emails_callback'],
'hvac-ce',
'hvac_ce_main'
);
// Google Maps API Settings Section
add_settings_section(
'hvac_ce_google_maps',
__('Google Maps API Settings', 'hvac-ce'),
[$this, 'google_maps_section_callback'],
'hvac-ce'
);
add_settings_field(
'hvac_google_maps_api_key',
__('Maps JavaScript API Key', 'hvac-ce'),
[$this, 'maps_api_key_callback'],
'hvac-ce',
'hvac_ce_google_maps'
);
add_settings_field(
'hvac_google_geocoding_api_key',
__('Geocoding API Key', 'hvac-ce'),
[$this, 'geocoding_api_key_callback'],
'hvac-ce',
'hvac_ce_google_maps'
);
// Slack Integration Section
register_setting('hvac_ce_options', 'hvac_slack_webhook_url', [
'type' => 'string',
'sanitize_callback' => [$this, 'sanitize_slack_webhook_url'],
]);
add_settings_section(
'hvac_ce_slack',
__('Slack Integration', 'hvac-ce'),
[$this, 'slack_section_callback'],
'hvac-ce'
);
add_settings_field(
'hvac_slack_webhook_url',
__('Webhook URL', 'hvac-ce'),
[$this, 'slack_webhook_url_callback'],
'hvac-ce',
'hvac_ce_slack'
);
}
/**
* Sanitize Slack webhook URL — only allow hooks.slack.com
*/
public function sanitize_slack_webhook_url(mixed $value): string {
if (!is_string($value)) {
return get_option('hvac_slack_webhook_url', '');
}
$value = trim($value);
if ($value === '') {
return '';
}
$value = esc_url_raw($value);
$scheme = parse_url($value, PHP_URL_SCHEME);
$host = parse_url($value, PHP_URL_HOST);
$path = parse_url($value, PHP_URL_PATH) ?: '';
if ($scheme !== 'https' || $host !== 'hooks.slack.com' || !str_starts_with($path, '/services/')) {
add_settings_error(
'hvac_slack_webhook_url',
'invalid_url',
__('Slack webhook URL must be a valid https://hooks.slack.com/services/... URL.', 'hvac-ce'),
'error'
);
return get_option('hvac_slack_webhook_url', ''); // keep old value
}
return $value;
}
public function slack_section_callback() {
echo '<p>' . __('Sends notifications for new trainer registrations and ticket purchases to a Slack channel.', 'hvac-ce') . '</p>';
}
public function slack_webhook_url_callback() {
$value = get_option('hvac_slack_webhook_url', '');
echo '<input type="password" name="hvac_slack_webhook_url" value="' . esc_attr($value) . '" class="regular-text" placeholder="https://hooks.slack.com/services/...">';
echo '<p class="description">' . __('Create an Incoming Webhook in your Slack workspace and paste the URL here. Leave empty to disable.', 'hvac-ce') . '</p>';
if (!empty($value)) {
$nonce = wp_create_nonce('hvac_test_slack_webhook');
echo '<div style="margin-top: 10px;">';
echo '<button type="button" id="hvac-test-slack-btn" class="button button-secondary" data-nonce="' . esc_attr($nonce) . '">';
echo __('Send Test Notification', 'hvac-ce');
echo '</button>';
echo '<span id="hvac-slack-test-status" style="margin-left: 10px;"></span>';
echo '</div>';
?>
<script type="text/javascript">
jQuery(document).ready(function($) {
$('#hvac-test-slack-btn').on('click', function() {
var $btn = $(this);
var $status = $('#hvac-slack-test-status');
var nonce = $btn.data('nonce');
$btn.prop('disabled', true).text('<?php echo esc_js(__('Sending...', 'hvac-ce')); ?>');
$status.html('');
$.ajax({
url: ajaxurl,
type: 'POST',
data: {
action: 'hvac_test_slack_webhook',
nonce: nonce
},
success: function(response) {
if (response.success) {
$status.html('<span style="color: green;">\u2713 ' + response.data.message + '</span>');
} else {
$status.html('<span style="color: red;">\u2717 ' + (response.data?.message || 'Unknown error') + '</span>');
}
$btn.prop('disabled', false).text('<?php echo esc_js(__('Send Test Notification', 'hvac-ce')); ?>');
},
error: function() {
$status.html('<span style="color: red;">\u2717 <?php echo esc_js(__('Network error. Please try again.', 'hvac-ce')); ?></span>');
$btn.prop('disabled', false).text('<?php echo esc_js(__('Send Test Notification', 'hvac-ce')); ?>');
}
});
});
});
</script>
<?php
}
}
public function settings_section_callback() {
echo '<p>' . __('Configure settings for HVAC Community Events', 'hvac-ce') . '</p>';
}
public function notification_emails_callback() {
$options = get_option('hvac_ce_options');
echo '<input type="text" name="hvac_ce_options[notification_emails]" value="' .
esc_attr($options['notification_emails'] ?? '') . '" class="regular-text">';
echo '<p class="description">' .
__('Comma-separated list of emails to notify when new trainers register', 'hvac-ce') . '</p>';
}
public function google_maps_section_callback() {
echo '<p>' . __('Configure Google Maps API keys for maps and geocoding functionality.', 'hvac-ce') . '</p>';
echo '<p class="description">' . __('You need two API keys: one for browser-side Maps JavaScript API (HTTP referrer restricted) and one for server-side Geocoding API (IP restricted).', 'hvac-ce') . '</p>';
}
public function maps_api_key_callback() {
$value = get_option('hvac_google_maps_api_key', '');
echo '<input type="text" name="hvac_google_maps_api_key" value="' . esc_attr($value) . '" class="regular-text" placeholder="AIza...">';
echo '<p class="description">' . __('Browser-side API key for Google Maps JavaScript API. Should be HTTP referrer restricted to your domain.', 'hvac-ce') . '</p>';
}
public function geocoding_api_key_callback() {
$value = get_option('hvac_google_geocoding_api_key', '');
echo '<input type="text" name="hvac_google_geocoding_api_key" value="' . esc_attr($value) . '" class="regular-text" placeholder="AIza...">';
echo '<p class="description">' . __('Server-side API key for Google Geocoding API. Should be IP restricted to your server IP address.', 'hvac-ce') . '</p>';
// Show current status
if (!empty($value)) {
echo '<p style="color: green;">✓ ' . __('Geocoding API key is configured.', 'hvac-ce') . '</p>';
// Show batch geocode button
$this->render_batch_geocode_button();
} else {
echo '<p style="color: orange;">⚠ ' . __('Geocoding API key not set. Venue addresses will not be geocoded for map display.', 'hvac-ce') . '</p>';
}
}
/**
* Render batch geocode button and status
*/
private function render_batch_geocode_button() {
// Count venues without coordinates
$venues_without_coords = get_posts([
'post_type' => 'tribe_venue',
'posts_per_page' => -1,
'post_status' => 'publish',
'fields' => 'ids',
'meta_query' => [
'relation' => 'AND',
[
'key' => 'venue_latitude',
'compare' => 'NOT EXISTS'
],
[
'key' => '_VenueLat',
'compare' => 'NOT EXISTS'
]
]
]);
$count = count($venues_without_coords);
$nonce = wp_create_nonce('hvac_batch_geocode_venues');
echo '<div style="margin-top: 15px; padding: 15px; background: #f5f5f5; border-radius: 4px;">';
echo '<strong>' . __('Venue Geocoding Status', 'hvac-ce') . '</strong><br>';
if ($count > 0) {
echo '<p style="color: orange;">' . sprintf(__('%d venues need geocoding.', 'hvac-ce'), $count) . '</p>';
echo '<button type="button" id="hvac-batch-geocode-btn" class="button button-secondary" data-nonce="' . esc_attr($nonce) . '">';
echo __('Geocode All Venues', 'hvac-ce');
echo '</button>';
echo '<span id="hvac-geocode-status" style="margin-left: 10px;"></span>';
} else {
echo '<p style="color: green;">✓ ' . __('All venues are geocoded.', 'hvac-ce') . '</p>';
}
echo '</div>';
// Render the mark as approved labs button
$this->render_mark_approved_button();
// Add inline JavaScript for the batch geocode button
?>
<script type="text/javascript">
jQuery(document).ready(function($) {
$('#hvac-batch-geocode-btn').on('click', function() {
var $btn = $(this);
var $status = $('#hvac-geocode-status');
var nonce = $btn.data('nonce');
$btn.prop('disabled', true).text('<?php echo esc_js(__('Processing...', 'hvac-ce')); ?>');
$status.html('<span style="color: blue;"><?php echo esc_js(__('Geocoding venues...', 'hvac-ce')); ?></span>');
function processNextBatch() {
$.ajax({
url: ajaxurl,
type: 'POST',
data: {
action: 'hvac_batch_geocode_venues',
nonce: nonce,
limit: 10
},
success: function(response) {
if (response.success) {
var data = response.data;
var msg = '<?php echo esc_js(__('Processed', 'hvac-ce')); ?>: ' + data.processed +
', <?php echo esc_js(__('Success', 'hvac-ce')); ?>: ' + data.success +
', <?php echo esc_js(__('Remaining', 'hvac-ce')); ?>: ' + data.remaining;
$status.html('<span style="color: blue;">' + msg + '</span>');
if (data.remaining > 0) {
// Continue with next batch
setTimeout(processNextBatch, 1000);
} else {
$status.html('<span style="color: green;">✓ <?php echo esc_js(__('All venues geocoded!', 'hvac-ce')); ?></span>');
$btn.text('<?php echo esc_js(__('Done!', 'hvac-ce')); ?>');
}
} else {
$status.html('<span style="color: red;"><?php echo esc_js(__('Error', 'hvac-ce')); ?>: ' + (response.data?.message || 'Unknown error') + '</span>');
$btn.prop('disabled', false).text('<?php echo esc_js(__('Retry', 'hvac-ce')); ?>');
}
},
error: function() {
$status.html('<span style="color: red;"><?php echo esc_js(__('Network error. Please try again.', 'hvac-ce')); ?></span>');
$btn.prop('disabled', false).text('<?php echo esc_js(__('Retry', 'hvac-ce')); ?>');
}
});
}
processNextBatch();
});
});
</script>
<?php
}
/**
* Render scrollable list of venues with checkboxes for approved training labs
*/
private function render_mark_approved_button() {
// Get all venues
$all_venues = get_posts([
'post_type' => 'tribe_venue',
'posts_per_page' => -1,
'post_status' => 'publish',
'orderby' => 'title',
'order' => 'ASC'
]);
$nonce = wp_create_nonce('hvac_mark_venues_approved');
echo '<div style="margin-top: 15px; padding: 15px; background: #f0f7ff; border-radius: 4px; border-left: 4px solid #0073aa;">';
echo '<strong>' . __('measureQuick Approved Training Labs', 'hvac-ce') . '</strong><br>';
echo '<p class="description">' . __('Select which venues should appear on the Find Training map as approved training labs.', 'hvac-ce') . '</p>';
if (empty($all_venues)) {
echo '<p style="color: gray;">' . __('No venues found.', 'hvac-ce') . '</p>';
echo '</div>';
return;
}
// Build venue data
$venue_data = [];
$approved_count = 0;
$geocoded_count = 0;
foreach ($all_venues as $venue) {
$is_approved = has_term('mq-approved-lab', 'venue_type', $venue->ID);
$lat = get_post_meta($venue->ID, 'venue_latitude', true) ?: get_post_meta($venue->ID, '_VenueLat', true);
$lng = get_post_meta($venue->ID, 'venue_longitude', true) ?: get_post_meta($venue->ID, '_VenueLng', true);
$has_coords = !empty($lat) && !empty($lng);
$city = get_post_meta($venue->ID, '_VenueCity', true);
$state = get_post_meta($venue->ID, '_VenueState', true);
$location = trim($city . ($city && $state ? ', ' : '') . $state);
if ($is_approved) $approved_count++;
if ($has_coords) $geocoded_count++;
$venue_data[] = [
'id' => $venue->ID,
'title' => $venue->post_title,
'location' => $location,
'is_approved' => $is_approved,
'has_coords' => $has_coords
];
}
// Summary
echo '<p style="margin: 10px 0;">';
echo sprintf(__('<strong>%d</strong> venues total, <strong>%d</strong> approved, <strong>%d</strong> geocoded', 'hvac-ce'),
count($all_venues), $approved_count, $geocoded_count);
echo '</p>';
// Scrollable list
echo '<div style="max-height: 300px; overflow-y: auto; border: 1px solid #ddd; background: #fff; padding: 10px; margin: 10px 0;">';
echo '<table style="width: 100%; border-collapse: collapse;">';
echo '<thead style="position: sticky; top: 0; background: #f5f5f5;">';
echo '<tr>';
echo '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #ddd; width: 30px;">';
echo '<input type="checkbox" id="hvac-select-all-venues" title="' . esc_attr__('Select/Deselect All', 'hvac-ce') . '">';
echo '</th>';
echo '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #ddd;">' . __('Venue', 'hvac-ce') . '</th>';
echo '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #ddd;">' . __('Location', 'hvac-ce') . '</th>';
echo '<th style="padding: 8px; text-align: center; border-bottom: 2px solid #ddd;">' . __('Geocoded', 'hvac-ce') . '</th>';
echo '</tr>';
echo '</thead>';
echo '<tbody>';
foreach ($venue_data as $venue) {
$row_style = $venue['is_approved'] ? 'background: #e7f5e7;' : '';
echo '<tr style="' . $row_style . '">';
echo '<td style="padding: 6px 8px; border-bottom: 1px solid #eee;">';
echo '<input type="checkbox" class="hvac-venue-checkbox" value="' . esc_attr($venue['id']) . '"' .
($venue['is_approved'] ? ' checked' : '') . '>';
echo '</td>';
echo '<td style="padding: 6px 8px; border-bottom: 1px solid #eee;">' . esc_html($venue['title']) . '</td>';
echo '<td style="padding: 6px 8px; border-bottom: 1px solid #eee; color: #666;">' . esc_html($venue['location'] ?: '—') . '</td>';
echo '<td style="padding: 6px 8px; border-bottom: 1px solid #eee; text-align: center;">';
echo $venue['has_coords'] ? '<span style="color: green;">✓</span>' : '<span style="color: #ccc;">—</span>';
echo '</td>';
echo '</tr>';
}
echo '</tbody>';
echo '</table>';
echo '</div>';
// Save button
echo '<button type="button" id="hvac-save-approved-labs" class="button button-primary" data-nonce="' . esc_attr($nonce) . '">';
echo __('Save Approved Labs', 'hvac-ce');
echo '</button>';
echo '<span id="hvac-approved-status" style="margin-left: 10px;"></span>';
echo '</div>';
// JavaScript
?>
<script type="text/javascript">
jQuery(document).ready(function($) {
// Select all checkbox
$('#hvac-select-all-venues').on('change', function() {
$('.hvac-venue-checkbox').prop('checked', $(this).is(':checked'));
});
// Update select-all state when individual checkboxes change
$('.hvac-venue-checkbox').on('change', function() {
var total = $('.hvac-venue-checkbox').length;
var checked = $('.hvac-venue-checkbox:checked').length;
$('#hvac-select-all-venues').prop('checked', total === checked);
$('#hvac-select-all-venues').prop('indeterminate', checked > 0 && checked < total);
});
// Initialize select-all state
(function() {
var total = $('.hvac-venue-checkbox').length;
var checked = $('.hvac-venue-checkbox:checked').length;
$('#hvac-select-all-venues').prop('checked', total === checked);
$('#hvac-select-all-venues').prop('indeterminate', checked > 0 && checked < total);
})();
// Save button
$('#hvac-save-approved-labs').on('click', function() {
var $btn = $(this);
var $status = $('#hvac-approved-status');
var nonce = $btn.data('nonce');
// Collect selected venue IDs
var selectedIds = [];
$('.hvac-venue-checkbox:checked').each(function() {
selectedIds.push($(this).val());
});
$btn.prop('disabled', true).text('<?php echo esc_js(__('Saving...', 'hvac-ce')); ?>');
$status.html('<span style="color: blue;"><?php echo esc_js(__('Updating venues...', 'hvac-ce')); ?></span>');
$.ajax({
url: ajaxurl,
type: 'POST',
data: {
action: 'hvac_update_approved_labs',
nonce: nonce,
venue_ids: selectedIds
},
success: function(response) {
if (response.success) {
var data = response.data;
var msg = '<?php echo esc_js(__('Saved!', 'hvac-ce')); ?> ' + data.approved_count + ' <?php echo esc_js(__('approved labs', 'hvac-ce')); ?>';
$status.html('<span style="color: green;">✓ ' + msg + '</span>');
$btn.prop('disabled', false).text('<?php echo esc_js(__('Save Approved Labs', 'hvac-ce')); ?>');
// Update row highlighting
$('.hvac-venue-checkbox').each(function() {
var $row = $(this).closest('tr');
if ($(this).is(':checked')) {
$row.css('background', '#e7f5e7');
} else {
$row.css('background', '');
}
});
} else {
$status.html('<span style="color: red;"><?php echo esc_js(__('Error', 'hvac-ce')); ?>: ' + (response.data?.message || 'Unknown error') + '</span>');
$btn.prop('disabled', false).text('<?php echo esc_js(__('Save Approved Labs', 'hvac-ce')); ?>');
}
},
error: function() {
$status.html('<span style="color: red;"><?php echo esc_js(__('Network error. Please try again.', 'hvac-ce')); ?></span>');
$btn.prop('disabled', false).text('<?php echo esc_js(__('Save Approved Labs', 'hvac-ce')); ?>');
}
});
});
});
</script>
<?php
}
public function options_page() {
$active_tab = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : 'general';
$tabs = [
'general' => __( 'General Settings', 'hvac-ce' ),
];
// Allow other classes to add tabs
$tabs = apply_filters( 'hvac_ce_settings_tabs', $tabs );
?>
<div class="wrap">
<h1><?php esc_html_e('HVAC Community Events Settings', 'hvac-ce'); ?></h1>
<h2 class="nav-tab-wrapper">
<?php foreach ( $tabs as $tab_key => $tab_label ): ?>
<a href="?page=hvac-community-events&tab=<?php echo esc_attr( $tab_key ); ?>"
class="nav-tab <?php echo $active_tab === $tab_key ? 'nav-tab-active' : ''; ?>">
<?php echo esc_html( $tab_label ); ?>
</a>
<?php endforeach; ?>
</h2>
<?php if ( $active_tab === 'general' ): ?>
<form method="post" action="options.php">
<?php
settings_fields('hvac_ce_options');
do_settings_sections('hvac-ce');
submit_button();
?>
</form>
<?php else: ?>
<?php do_action( 'hvac_ce_settings_content', $active_tab ); ?>
<?php endif; ?>
</div>
<?php
}
/**
* Dashboard page callback
*/
public function dashboard_page() {
// Load the admin dashboard class
if (!class_exists('HVAC_Admin_Dashboard')) {
require_once HVAC_PLUGIN_DIR . 'includes/admin/class-admin-dashboard.php';
}
$dashboard = new HVAC_Admin_Dashboard();
$dashboard->render_page();
}
/**
* Enqueue admin scripts and styles
*/
public function enqueue_admin_scripts($hook) {
// Only load on HVAC admin pages
if (strpos($hook, 'hvac-community-events') !== false) {
// Add inline script to make trainer login link open in new tab
$script = "
jQuery(document).ready(function($) {
// Find the trainer login menu link and make it open in new tab
$('a[href*=\"training-login\"]').attr('target', '_blank');
});
";
wp_add_inline_script('jquery', $script);
}
}
}