Compare commits

..

3 commits

Author SHA1 Message Date
ben
cb68c9a5bf docs: Add AI assistant tools and multi-model workflow docs to CLAUDE.md
Some checks failed
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Has been cancelled
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Has been cancelled
Security Monitoring & Compliance / Secrets & Credential Scan (push) Has been cancelled
Security Monitoring & Compliance / WordPress Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Static Code Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Security Compliance Validation (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Notification (push) Has been cancelled
Security Monitoring & Compliance / Security Summary Report (push) Has been cancelled
Security Monitoring & Compliance / Security Team Notification (push) Has been cancelled
Add Zen MCP tools usage guide and Clink multi-model workflow
documentation (GPT-5, Gemini 3, Kimi K2.5) with security warnings,
synthesis format, and quick usage examples. Update local settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:50:57 -04:00
ben
ca928bfffb feat: Staging email filter, venue geocoding, MapGeo improvements, trainers overview
Accumulated changes from previous sessions (Feb 9-20):
- Staging email filter to prevent test emails reaching real users
- Version bump to 2.2.0 in plugin header
- Venue geocoding enhancements and batch processing
- Find Training page improvements (tab content, map data)
- MapGeo integration hardening for Find a Trainer
- Master trainers overview table improvements
- AJAX handler additions for venue categories
- SVG marker icon tweaks (trainer, venue)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:50:51 -04:00
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
16 changed files with 1541 additions and 83 deletions

View file

@ -19,7 +19,67 @@
"Bash(curl:*)", "Bash(curl:*)",
"Bash(yes:*)", "Bash(yes:*)",
"WebFetch(domain:json.schemastore.org)", "WebFetch(domain:json.schemastore.org)",
"WebFetch(domain:www.schemastore.org)" "WebFetch(domain:www.schemastore.org)",
"Bash(scripts/pre-deployment-check.sh)",
"Bash(ssh roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp eval ''wp_set_password\\(\"\"TestPass123\"\", 25\\);'' 2>&1\")",
"Bash(wc:*)",
"Bash(php -l:*)",
"Bash(php:*)",
"Bash(./scripts/pre-deployment-check.sh:*)",
"Bash(HEADLESS=true BASE_URL=https://upskill-staging.measurequick.com node test-comprehensive-validation.js:*)",
"Bash(ssh roodev@146.190.76.204 'cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user get test_master --field=ID 2>&1')",
"Bash(ssh:*)",
"mcp__playwright__browser_wait_for",
"mcp__playwright__browser_console_messages",
"mcp__playwright__browser_snapshot",
"mcp__playwright__browser_navigate_back",
"mcp__playwright__browser_press_key",
"mcp__playwright__browser_network_requests",
"Bash(source:*)",
"Bash(sshpass -p \"$UPSKILL_STAGING_PASS\" ssh -o StrictHostKeyChecking=no \"$UPSKILL_STAGING_SSH_USER@$UPSKILL_STAGING_IP\" \"cd $UPSKILL_STAGING_PATH && wp eval ''HVAC_Page_Manager::create_pages\\(\\); echo \"\"Pages created/verified\"\";'' 2>&1\")",
"mcp__playwright__browser_select_option",
"mcp__playwright__browser_hover",
"mcp__playwright__browser_resize",
"Bash(grep:*)",
"Bash(sshpass -p \"$UPSKILL_STAGING_PASS\" ssh -o StrictHostKeyChecking=no \"roodev@146.190.76.204\" \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp eval \"\"HVAC_Secure_Storage::store_credential\\(''hvac_google_maps_api_key'', ''AIzaSyD_W0fPmA3wtsQ_o3mdeoaUpxX_cdBvi5g''\\); echo ''API key stored successfully'';\"\" && wp cache flush\")",
"Bash(sshpass -p \"$UPSKILL_PROD_SSH_PASS\" ssh -o StrictHostKeyChecking=no \"$UPSKILL_PROD_SSH_USER@$UPSKILL_PROD_IP\" \"cd $UPSKILL_PROD_PATH && wp eval \"\"HVAC_Secure_Storage::save_credential\\(''hvac_google_maps_api_key'', ''AIzaSyD_W0fPmA3wtsQ_o3mdeoaUpxX_cdBvi5g''\\); echo ''API key saved successfully'';\"\"\")",
"Bash(sshpass -p \"$UPSKILL_PROD_SSH_PASS\" ssh -o StrictHostKeyChecking=no \"$UPSKILL_PROD_SSH_USER@$UPSKILL_PROD_IP\" \"cd $UPSKILL_PROD_PATH && wp eval \"\"HVAC_Secure_Storage::store_credential\\(''hvac_google_maps_api_key'', ''AIzaSyD_W0fPmA3wtsQ_o3mdeoaUpxX_cdBvi5g''\\); echo ''API key saved successfully'';\"\"\")",
"Bash(sshpass -p \"$UPSKILL_PROD_SSH_PASS\" ssh -o StrictHostKeyChecking=no \"$UPSKILL_PROD_SSH_USER@$UPSKILL_PROD_IP\" \"cd $UPSKILL_PROD_PATH && wp cache flush\")",
"Bash(sshpass -f ~/.ssh/cloudways_pass rsync:*)",
"Bash(rsync:*)",
"Bash(if [ -f /home/ben/dev/upskill-event-manager/.env ])",
"Bash(then echo \".env exists\")",
"Bash(else echo \".env not found\")",
"Bash(fi)",
"mcp__wordpress-production__wp_cpt_search",
"mcp__wordpress-production__wp_get_cpt",
"mcp__wordpress-production__wp_update_cpt",
"Bash(find:*)",
"mcp__wordpress-production__wp_users_search",
"mcp__wordpress-production__wp_add_user",
"Bash(node --check:*)",
"mcp__playwright__browser_tab_list",
"Bash(scp:*)",
"Bash(sshpass -p 'uSCO6f1y' scp -o StrictHostKeyChecking=no scripts/setup-approved-labs.php scripts/check-venue-coordinates.php scripts/geocode-approved-labs.php roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/scripts/)",
"Bash(sshpass -p 'uSCO6f1y' ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"ls -la /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/includes/class-hvac-venue-categories.php 2>/dev/null || echo ''File not found''\")",
"Bash(sshpass -p 'uSCO6f1y' ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"grep -n ''venue-categories\\\\|Venue_Categories'' /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/includes/class-hvac-plugin.php\")",
"Bash(sshpass -p 'uSCO6f1y' ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp eval ''if \\(class_exists\\(\"\"HVAC_Venue_Categories\"\"\\)\\) { echo \"\"Class exists\\\\n\"\"; HVAC_Venue_Categories::instance\\(\\)->register_taxonomies\\(\\); echo \"\"Registered\\\\n\"\"; } else { echo \"\"Class not found\\\\n\"\"; } echo \"\"venue_type exists: \"\" . \\(taxonomy_exists\\(\"\"venue_type\"\"\\) ? \"\"yes\"\" : \"\"no\"\"\\) . \"\"\\\\n\"\";''\")",
"Bash(sshpass -p 'uSCO6f1y' scp -o StrictHostKeyChecking=no scripts/check-venue-coordinates.php roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/scripts/)",
"Bash(sshpass -p 'uSCO6f1y' ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp eval-file wp-content/plugins/hvac-community-events/scripts/check-venue-coordinates.php\")",
"Bash(sshpass -p 'uSCO6f1y' ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp eval ''\n// Simulate AJAX context - just check what happens without forcing registration\necho \"\"TEC active: \"\" . \\(function_exists\\(\"\"tribe_get_venue\"\"\\) ? \"\"yes\"\" : \"\"no\"\"\\) . \"\"\\\\n\"\";\necho \"\"venue_type taxonomy: \"\" . \\(taxonomy_exists\\(\"\"venue_type\"\"\\) ? \"\"yes\"\" : \"\"no\"\"\\) . \"\"\\\\n\"\";\n\n// Now test the actual method\nif \\(class_exists\\(\"\"HVAC_Training_Map_Data\"\"\\)\\) {\n $data = HVAC_Training_Map_Data::get_instance\\(\\);\n $venues = $data->get_venue_markers\\(\\);\n echo \"\"get_venue_markers\\(\\) returned: \"\" . count\\($venues\\) . \"\" venues\\\\n\"\";\n}\n''\")",
"Bash(sshpass -p 'uSCO6f1y' scp:*)",
"Bash(sshpass -p 'uSCO6f1y' ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp eval ''if \\(class_exists\\(\"\"HVAC_Training_Map_Data\"\"\\)\\) { $d = HVAC_Training_Map_Data::get_instance\\(\\); $v = $d->get_venue_markers\\(\\); foreach \\($v as $x\\) { echo $x[\"\"name\"\"] . \"\"\\\\n\"\"; } }''\")",
"Bash(sshpass:*)",
"WebFetch(domain:upskillhvac.com)",
"Bash(pkill:*)",
"Bash(echo:*)",
"WebFetch(domain:upskill-staging.measurequick.com)",
"Bash(bash:*)",
"mcp__playwright__browser_handle_dialog",
"Bash(/home/ben/dev/upskill-event-manager/scripts/pre-deployment-check.sh:*)",
"Bash(/home/ben/dev/upskill-event-manager/scripts/deploy.sh:*)",
"Bash(printf:*)",
"WebFetch(domain:issuetracker.google.com)"
], ],
"deny": [], "deny": [],
"ask": [], "ask": [],

View file

@ -180,3 +180,53 @@ The plugin still relies on "The Events Calendar: Community" (TEC CE) for event c
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** - System architecture details - **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** - System architecture details
- **[docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)** - Common issues and solutions - **[docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)** - Common issues and solutions
- **[docs/TESTING-GUIDE.md](docs/TESTING-GUIDE.md)** - Testing procedures - **[docs/TESTING-GUIDE.md](docs/TESTING-GUIDE.md)** - Testing procedures
## AI Assistant Tools
### Zen MCP Tools (Use Throughout Every Session)
- **Zen Debug**: Use whenever you encounter an error or unexpected behavior
- **Zen Code Review**: Use whenever you finish a significant piece of code or feature
- **Zen Analyze**: Use before adding new features or making significant changes to existing code
- **Zen Deepthink**: Use when you have a complex problem to solve or need to brainstorm ideas
- **Zen Chat**: Use for general questions, clarifications, or when you need assistance with a task
**Important Context Reminder**: Remind the Zen tools that this is a small local environment with a single user, so security and scalability are not primary concerns.
### Clink Multi-Model Workflows (GPT-5, Gemini 3, Kimi K2.5)
Use `mcp__zen__clink` to get diverse AI perspectives via external CLIs. Available CLIs:
- **codex**: GPT-5 - Fast, precise code understanding
- **gemini**: Gemini 3 - Large context (1M tokens), thorough analysis
- **Kimi K2.5** (via `mcp__zen__chat` with OpenRouter) - Strong reasoning, excellent at complex logic
**Security Warning:** Files are sent to OpenAI, Google, and Moonshot AI. NEVER use on `.env`, credentials, or regulated data.
**When to Use Multi-Model:**
- Code reviews (catch different types of issues)
- Complex debugging (multiple hypotheses)
- Architecture decisions (diverse perspectives)
- Planning (compare approaches)
**Available Skills:**
- `/multi-review <files>` - Code review from all 3 models
- `/multi-plan <task>` - Planning perspectives from all 3 models
- `/multi-debug <issue>` - Parallel bug investigation
- `/multi-analyze <files>` - Comprehensive analysis
**Quick Usage** (all three calls in one message = parallel execution):
```python
review_prompt = "Review for bugs and security issues"
mcp__zen__clink(prompt=review_prompt, cli_name="codex", role="codereviewer", absolute_file_paths=["/absolute/path/file.py"])
mcp__zen__clink(prompt=review_prompt, cli_name="gemini", role="codereviewer", absolute_file_paths=["/absolute/path/file.py"])
mcp__zen__chat(prompt=review_prompt + " Use Kimi K2.5 via OpenRouter for this analysis.", absolute_file_paths=["/absolute/path/file.py"])
```
**Important:** Use absolute paths (not globs). Resolve patterns first: `find src -name "*.py" | xargs realpath`
**Synthesis Format:** After multi-model analysis, report:
1. **Consensus** - All models agree
2. **Majority** - 2/3 agree (note dissent)
3. **Unique insights** - Model-specific findings
4. **Recommended actions** - Prioritized by consensus
**Roles:** Each CLI supports `default`, `codereviewer`, `planner`

View file

@ -1,6 +1,6 @@
# HVAC Community Events - Project Status # HVAC Community Events - Project Status
**Last Updated:** February 9, 2026 **Last Updated:** February 20, 2026
**Version:** 2.2.18 (Deployed to Production) **Version:** 2.2.18 (Deployed to Production)
--- ---
@ -19,7 +19,39 @@
--- ---
## CURRENT SESSION - MARKER VISIBILITY TOGGLE CHECKBOX REFACTOR (Feb 9, 2026) ## CURRENT SESSION - SLACK NOTIFICATIONS (Feb 20, 2026)
### Status: COMPLETE - Deployed to Production
**Objective:** Add Slack notifications for trainer registrations, ticket purchases, and event submissions/publishes via Incoming Webhook with Block Kit rich formatting.
### Features
- **New Trainer Registration** — name, role, organization, business type, profile photo, "View in WordPress" button
- **Ticket Purchase** — purchaser, email, event, ticket count, total, payment gateway, "View Order" button
- **Event Submitted by Trainer** — event title, trainer, date, venue, "View Event" button
- **Event Published by Admin** — same fields, "View Event" + "Edit in WP" buttons
- **Settings UI** — Webhook URL field (password type), "Send Test Notification" button with AJAX feedback
- **Test message** includes environment (Staging/Production) and site URL
### Design Decisions
- Non-blocking `wp_remote_post` for all real notifications; blocking only for test button
- Atomic `add_post_meta(..., true)` for idempotency (prevents races and duplicate sends)
- Webhook URL validated at save-time and send-time: `https` + `hooks.slack.com` + `/services/` path
- Graceful degradation: empty webhook = disabled, missing meta = "N/A" fields, no exceptions surface to users
- Code reviewed by GPT-5 (Codex) — all 5 findings fixed before production deploy
### Files Modified
| File | Change |
|------|--------|
| `includes/class-hvac-slack-notifications.php` | **NEW** — static utility class with 4 notification types + test handler |
| `includes/class-hvac-settings.php` | Slack webhook URL setting + validation + test button |
| `includes/class-hvac-plugin.php` | Include file + `init()` call in `initializeSecondaryComponents()` |
| `includes/class-hvac-registration.php` | One-line call to `notify_new_registration()` after admin email |
---
## PREVIOUS SESSION - MARKER VISIBILITY TOGGLE CHECKBOX REFACTOR (Feb 9, 2026)
### Status: COMPLETE - Deployed to Production (v2.2.18) ### Status: COMPLETE - Deployed to Production (v2.2.18)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 B

After

Width:  |  Height:  |  Size: 362 B

View file

@ -3,7 +3,7 @@
* Plugin Name: HVAC Community Events * Plugin Name: HVAC Community Events
* Plugin URI: https://upskillhvac.com * Plugin URI: https://upskillhvac.com
* Description: Custom plugin for HVAC trainer event management system * Description: Custom plugin for HVAC trainer event management system
* Version: 2.1.7 * Version: 2.2.0
* Author: Upskill HVAC * Author: Upskill HVAC
* Author URI: https://upskillhvac.com * Author URI: https://upskillhvac.com
* License: GPL-2.0+ * License: GPL-2.0+
@ -17,6 +17,50 @@ if (!defined('ABSPATH')) {
exit; exit;
} }
/**
* Staging Email Filter
* Prevents emails from being sent to anyone except allowed addresses on staging.
* This protects real users from receiving test emails during development.
*/
function hvac_is_staging_environment() {
$host = isset( $_SERVER['HTTP_HOST'] ) ? $_SERVER['HTTP_HOST'] : '';
$staging_indicators = array( 'staging', 'upskill-staging', 'localhost', '127.0.0.1' );
foreach ( $staging_indicators as $indicator ) {
if ( stripos( $host, $indicator ) !== false ) {
return true;
}
}
return false;
}
function hvac_staging_email_filter( $args ) {
if ( ! hvac_is_staging_environment() ) {
return $args;
}
$allowed_emails = array( 'ben@tealmaker.com', 'ben@measurequick.com' );
$to = $args['to'];
// Extract email address
$email_address = is_array( $to ) ? ( isset( $to[0] ) ? $to[0] : '' ) : $to;
if ( preg_match( '/<([^>]+)>/', $email_address, $matches ) ) {
$email_address = $matches[1];
}
$email_address = trim( strtolower( $email_address ) );
if ( ! in_array( $email_address, $allowed_emails, true ) ) {
error_log( sprintf( '[HVAC Staging] Blocked email to: %s | Subject: %s',
is_array( $to ) ? implode( ', ', $to ) : $to, $args['subject'] ) );
$args['to'] = '';
return $args;
}
$args['subject'] = '[STAGING] ' . $args['subject'];
return $args;
}
add_filter( 'wp_mail', 'hvac_staging_email_filter', 1 );
// Load the main plugin class // Load the main plugin class
require_once plugin_dir_path(__FILE__) . 'includes/class-hvac-plugin.php'; require_once plugin_dir_path(__FILE__) . 'includes/class-hvac-plugin.php';

View file

@ -1046,6 +1046,15 @@ class HVAC_Ajax_Handlers {
return; return;
} }
// Verify reCAPTCHA
if (class_exists('HVAC_Recaptcha')) {
$recaptcha_response = sanitize_text_field($_POST['g-recaptcha-response'] ?? '');
if (!HVAC_Recaptcha::instance()->verify_response($recaptcha_response)) {
wp_send_json_error(['message' => 'CAPTCHA verification failed. Please try again.'], 400);
return;
}
}
// Rate limiting - max 5 submissions per IP per hour // Rate limiting - max 5 submissions per IP per hour
$ip = $this->get_client_ip(); $ip = $this->get_client_ip();
$rate_key = 'hvac_contact_rate_' . md5($ip); $rate_key = 'hvac_contact_rate_' . md5($ip);
@ -1202,6 +1211,15 @@ class HVAC_Ajax_Handlers {
return; return;
} }
// Verify reCAPTCHA
if (class_exists('HVAC_Recaptcha')) {
$recaptcha_response = sanitize_text_field($_POST['g-recaptcha-response'] ?? '');
if (!HVAC_Recaptcha::instance()->verify_response($recaptcha_response)) {
wp_send_json_error(['message' => 'CAPTCHA verification failed. Please try again.'], 400);
return;
}
}
// Rate limiting - max 5 submissions per IP per hour // Rate limiting - max 5 submissions per IP per hour
$ip = $this->get_client_ip(); $ip = $this->get_client_ip();
$rate_key = 'hvac_venue_contact_rate_' . md5($ip); $rate_key = 'hvac_venue_contact_rate_' . md5($ip);

View file

@ -291,6 +291,8 @@ class HVAC_Master_Trainers_Overview {
/** /**
* AJAX handler for filtering trainers * AJAX handler for filtering trainers
*
* Uses get_trainers_table_data() from dashboard data class for reliable trainer listing
*/ */
public function ajax_filter_trainers() { public function ajax_filter_trainers() {
// Verify nonce // Verify nonce
@ -306,7 +308,6 @@ class HVAC_Master_Trainers_Overview {
// Get filter parameters // Get filter parameters
$args = array( $args = array(
'status' => isset( $_POST['status'] ) ? sanitize_text_field( $_POST['status'] ) : 'all', 'status' => isset( $_POST['status'] ) ? sanitize_text_field( $_POST['status'] ) : 'all',
'region' => isset( $_POST['region'] ) ? sanitize_text_field( $_POST['region'] ) : '',
'search' => isset( $_POST['search'] ) ? sanitize_text_field( $_POST['search'] ) : '', 'search' => isset( $_POST['search'] ) ? sanitize_text_field( $_POST['search'] ) : '',
'page' => isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1, 'page' => isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1,
'per_page' => isset( $_POST['per_page'] ) ? absint( $_POST['per_page'] ) : 20, 'per_page' => isset( $_POST['per_page'] ) ? absint( $_POST['per_page'] ) : 20,
@ -314,12 +315,21 @@ class HVAC_Master_Trainers_Overview {
'order' => isset( $_POST['order'] ) ? sanitize_text_field( $_POST['order'] ) : 'ASC', 'order' => isset( $_POST['order'] ) ? sanitize_text_field( $_POST['order'] ) : 'ASC',
); );
// Get trainers data // Use the reliable get_trainers_table_data method (same as Master Dashboard)
if ( $this->dashboard_data ) { if ( $this->dashboard_data ) {
$trainer_stats = $this->dashboard_data->get_trainer_statistics(); $trainer_data = $this->dashboard_data->get_trainers_table_data( $args );
$trainers = $trainer_data['trainers'];
// Format trainers for display // Apply region filter if specified (not handled by get_trainers_table_data)
$formatted_trainers = $this->format_trainers_for_display( $trainer_stats['trainer_data'], $args ); $region_filter = isset( $_POST['region'] ) ? sanitize_text_field( $_POST['region'] ) : '';
if ( ! empty( $region_filter ) ) {
$trainers = array_filter( $trainers, function( $trainer ) use ( $region_filter ) {
$user_meta = get_user_meta( $trainer['id'] );
$user_state = isset( $user_meta['billing_state'][0] ) ? $user_meta['billing_state'][0] : '';
return $user_state === $region_filter;
} );
$trainers = array_values( $trainers ); // Re-index array
}
// Generate HTML table // Generate HTML table
ob_start(); ob_start();
@ -329,20 +339,20 @@ class HVAC_Master_Trainers_Overview {
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Email</th> <th>Email</th>
<th>Location</th>
<th>Status</th> <th>Status</th>
<th>Total Events</th> <th>Total Events</th>
<th>Revenue</th>
<th>Registered</th> <th>Registered</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if ( empty( $formatted_trainers ) ) : ?> <?php if ( empty( $trainers ) ) : ?>
<tr> <tr>
<td colspan="7" class="hvac-no-results">No trainers found matching your criteria.</td> <td colspan="7" class="hvac-no-results">No trainers found matching your criteria.</td>
</tr> </tr>
<?php else : ?> <?php else : ?>
<?php foreach ( $formatted_trainers as $trainer ) : ?> <?php foreach ( $trainers as $trainer ) : ?>
<tr> <tr>
<td class="hvac-trainer-name"> <td class="hvac-trainer-name">
<strong><?php echo esc_html( $trainer['name'] ); ?></strong> <strong><?php echo esc_html( $trainer['name'] ); ?></strong>
@ -352,22 +362,22 @@ class HVAC_Master_Trainers_Overview {
<?php echo esc_html( $trainer['email'] ); ?> <?php echo esc_html( $trainer['email'] ); ?>
</a> </a>
</td> </td>
<td class="hvac-trainer-location">
<?php echo esc_html( $trainer['location'] ); ?>
</td>
<td class="hvac-trainer-status"> <td class="hvac-trainer-status">
<span class="hvac-status-badge <?php echo esc_attr( $trainer['status_class'] ); ?>"> <span class="hvac-status-badge hvac-status-<?php echo esc_attr( $trainer['status'] ); ?>">
<?php echo esc_html( $trainer['status'] ); ?> <?php echo esc_html( $trainer['status_label'] ); ?>
</span> </span>
</td> </td>
<td class="hvac-trainer-events"> <td class="hvac-trainer-events">
<?php echo esc_html( $trainer['total_events'] ); ?> <?php echo esc_html( $trainer['total_events'] ); ?>
</td> </td>
<td class="hvac-trainer-revenue">
$<?php echo esc_html( number_format( $trainer['revenue'], 2 ) ); ?>
</td>
<td class="hvac-trainer-registered"> <td class="hvac-trainer-registered">
<?php echo esc_html( $trainer['registered'] ); ?> <?php echo esc_html( $trainer['registration_date'] ); ?>
</td> </td>
<td class="hvac-trainer-actions"> <td class="hvac-trainer-actions">
<a href="<?php echo esc_url( $trainer['profile_link'] ); ?>" <a href="<?php echo esc_url( home_url( '/master-trainer/trainer-profile/' . $trainer['id'] . '/' ) ); ?>"
class="hvac-btn hvac-btn-sm hvac-btn-primary hvac-view-trainer" class="hvac-btn hvac-btn-sm hvac-btn-primary hvac-view-trainer"
data-trainer-id="<?php echo esc_attr( $trainer['id'] ); ?>"> data-trainer-id="<?php echo esc_attr( $trainer['id'] ); ?>">
View Profile View Profile
@ -379,14 +389,18 @@ class HVAC_Master_Trainers_Overview {
</tbody> </tbody>
</table> </table>
<div class="hvac-trainers-count"> <div class="hvac-trainers-count">
Showing <?php echo esc_html( count( $formatted_trainers ) ); ?> trainer(s) Showing <?php echo esc_html( count( $trainers ) ); ?> trainer(s)
<?php if ( ! empty( $trainer_data['pagination'] ) ) : ?>
of <?php echo esc_html( $trainer_data['pagination']['total_items'] ); ?> total
<?php endif; ?>
</div> </div>
<?php <?php
$html = ob_get_clean(); $html = ob_get_clean();
wp_send_json_success( array( wp_send_json_success( array(
'html' => $html, 'html' => $html,
'total_found' => count( $formatted_trainers ) 'total_found' => count( $trainers ),
'pagination' => $trainer_data['pagination']
) ); ) );
} }
@ -514,16 +528,35 @@ class HVAC_Master_Trainers_Overview {
/** /**
* Count trainers by status * Count trainers by status
*
* Uses HVAC_Trainer_Status for dynamic status calculation (active/inactive based on event activity)
*/ */
private function count_trainers_by_status( $status ) { private function count_trainers_by_status( $status ) {
// Load trainer status class for dynamic status calculation
if ( ! class_exists( 'HVAC_Trainer_Status' ) ) {
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-trainer-status.php';
}
// Get all trainers (both roles)
$users = get_users( array( $users = get_users( array(
'role' => 'hvac_trainer', 'role__in' => array( 'hvac_trainer', 'hvac_master_trainer' ),
'meta_key' => 'hvac_trainer_status', 'fields' => 'ID'
'meta_value' => $status,
'count_total' => true
) ); ) );
return is_array( $users ) ? count( $users ) : 0; if ( empty( $users ) ) {
return 0;
}
// Count by dynamic status
$count = 0;
foreach ( $users as $user_id ) {
$user_status = HVAC_Trainer_Status::get_trainer_status( $user_id );
if ( $user_status === $status ) {
$count++;
}
}
return $count;
} }
/** /**

View file

@ -255,6 +255,7 @@ final class HVAC_Plugin {
'class-attendee-profile.php', 'class-attendee-profile.php',
'class-hvac-page-content-fixer.php', 'class-hvac-page-content-fixer.php',
'class-hvac-page-content-manager.php', 'class-hvac-page-content-manager.php',
'class-hvac-slack-notifications.php',
]; ];
// Find a Trainer feature files // Find a Trainer feature files
@ -759,6 +760,11 @@ final class HVAC_Plugin {
if (class_exists('HVAC_Announcements_Display')) { if (class_exists('HVAC_Announcements_Display')) {
HVAC_Announcements_Display::get_instance(); HVAC_Announcements_Display::get_instance();
} }
// Initialize Slack notifications (registration + ticket purchase hooks)
if (class_exists('HVAC_Slack_Notifications')) {
HVAC_Slack_Notifications::init();
}
error_log('HVAC Plugin: Checking if HVAC_Announcements_Admin class exists: ' . (class_exists('HVAC_Announcements_Admin') ? 'YES' : 'NO')); error_log('HVAC Plugin: Checking if HVAC_Announcements_Admin class exists: ' . (class_exists('HVAC_Announcements_Admin') ? 'YES' : 'NO'));
if (class_exists('HVAC_Announcements_Admin')) { if (class_exists('HVAC_Announcements_Admin')) {
error_log('HVAC Plugin: Instantiating HVAC_Announcements_Admin...'); error_log('HVAC Plugin: Instantiating HVAC_Announcements_Admin...');

View file

@ -162,6 +162,11 @@ class HVAC_Registration {
$this->send_admin_notification($user_id, $submitted_data); $this->send_admin_notification($user_id, $submitted_data);
} }
// Slack notification (non-blocking, fire-and-forget)
if (class_exists('HVAC_Slack_Notifications')) {
HVAC_Slack_Notifications::notify_new_registration($user_id, $submitted_data);
}
// --- Success Redirect --- // --- Success Redirect ---
$success_redirect_url = home_url('/registration-pending/'); // URL from E2E test $success_redirect_url = home_url('/registration-pending/'); // URL from E2E test

View file

@ -63,7 +63,15 @@ class HVAC_Settings {
public function register_settings() { public function register_settings() {
register_setting('hvac_ce_options', 'hvac_ce_options'); 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( add_settings_section(
'hvac_ce_main', 'hvac_ce_main',
__('HVAC Community Events Settings', 'hvac-ce'), __('HVAC Community Events Settings', 'hvac-ce'),
@ -78,6 +86,136 @@ class HVAC_Settings {
'hvac-ce', 'hvac-ce',
'hvac_ce_main' '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() { public function settings_section_callback() {
@ -86,12 +224,318 @@ class HVAC_Settings {
public function notification_emails_callback() { public function notification_emails_callback() {
$options = get_option('hvac_ce_options'); $options = get_option('hvac_ce_options');
echo '<input type="text" name="hvac_ce_options[notification_emails]" value="' . echo '<input type="text" name="hvac_ce_options[notification_emails]" value="' .
esc_attr($options['notification_emails'] ?? '') . '" class="regular-text">'; esc_attr($options['notification_emails'] ?? '') . '" class="regular-text">';
echo '<p class="description">' . echo '<p class="description">' .
__('Comma-separated list of emails to notify when new trainers register', 'hvac-ce') . '</p>'; __('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() { public function options_page() {
$active_tab = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : 'general'; $active_tab = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : 'general';

View file

@ -0,0 +1,485 @@
<?php
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
/**
* Slack notification utility for trainer registrations and ticket purchases.
*
* Uses Slack Incoming Webhooks with Block Kit rich formatting.
* All sends are non-blocking (fire-and-forget) so Slack failures
* never affect registration or checkout flows.
*/
class HVAC_Slack_Notifications {
/** @var bool Prevents duplicate hook registration */
private static bool $initialized = false;
/**
* Register hooks. Called once from HVAC_Plugin::initializeSecondaryComponents().
*/
public static function init(): void {
if (self::$initialized) {
return;
}
self::$initialized = true;
// Ticket purchase: fires on any post status transition
add_action('transition_post_status', [__CLASS__, 'on_order_status_change'], 10, 3);
// Event published by admin (any tribe_events post transitioning to publish)
add_action('transition_post_status', [__CLASS__, 'on_event_status_change'], 10, 3);
// Event submitted by trainer via TEC Community Events form
add_action('hvac_tec_event_saved', [__CLASS__, 'notify_event_submitted'], 10, 1);
// AJAX: test webhook from settings page
add_action('wp_ajax_hvac_test_slack_webhook', [__CLASS__, 'send_test_notification']);
}
/**
* Send a message to Slack via Incoming Webhook.
*
* @param string $text Plain-text fallback (required by Slack).
* @param array $blocks Block Kit blocks for rich layout.
* @param bool $blocking Whether to wait for the response.
* @return bool|null True on success, false on failure, null if disabled.
*/
private static function send(string $text, array $blocks, bool $blocking = false): ?bool {
$webhook_url = get_option('hvac_slack_webhook_url', '');
if (empty($webhook_url)) {
return null; // Disabled — no webhook configured
}
// Validate URL: must be https://hooks.slack.com/services/...
$scheme = parse_url($webhook_url, PHP_URL_SCHEME);
$host = parse_url($webhook_url, PHP_URL_HOST);
$path = parse_url($webhook_url, PHP_URL_PATH) ?: '';
if ($scheme !== 'https' || $host !== 'hooks.slack.com' || !str_starts_with($path, '/services/')) {
error_log('[HVAC Slack] Rejected webhook URL: ' . $webhook_url);
return false;
}
$payload = wp_json_encode([
'text' => $text,
'blocks' => $blocks,
]);
$response = wp_remote_post($webhook_url, [
'body' => $payload,
'headers' => ['Content-Type' => 'application/json'],
'timeout' => 5,
'blocking' => $blocking,
]);
if ($blocking) {
if (is_wp_error($response)) {
error_log('[HVAC Slack] Send failed: ' . $response->get_error_message());
return false;
}
$code = wp_remote_retrieve_response_code($response);
if ($code !== 200) {
$body = wp_remote_retrieve_body($response);
error_log("[HVAC Slack] Slack returned HTTP {$code}: {$body}");
return false;
}
return true;
}
// Non-blocking — optimistically assume success
if (is_wp_error($response)) {
error_log('[HVAC Slack] Non-blocking send error: ' . $response->get_error_message());
}
return true;
}
// ------------------------------------------------------------------
// Registration notification
// ------------------------------------------------------------------
/**
* Send a Slack notification for a new trainer registration.
*
* @param int $user_id Newly created user ID.
* @param array $submitted_data Raw form data from registration.
*/
public static function notify_new_registration(int $user_id, array $submitted_data): void {
$first_name = sanitize_text_field($submitted_data['first_name'] ?? 'N/A');
$last_name = sanitize_text_field($submitted_data['last_name'] ?? '');
$full_name = trim("{$first_name} {$last_name}") ?: 'N/A';
$role = sanitize_text_field($submitted_data['role'] ?? $submitted_data['trainer_type'] ?? 'Trainer');
$business_name = sanitize_text_field($submitted_data['business_name'] ?? $submitted_data['company'] ?? 'N/A');
$business_type = sanitize_text_field($submitted_data['business_type'] ?? 'N/A');
$admin_url = admin_url("user-edit.php?user_id={$user_id}");
$text = "New Trainer Registration: {$full_name} ({$role})";
// Profile image accessory (optional)
$accessory = null;
$image_id = get_user_meta($user_id, 'profile_image_id', true);
if ($image_id) {
$image_url = wp_get_attachment_image_url((int) $image_id, 'thumbnail');
if ($image_url) {
$accessory = [
'type' => 'image',
'image_url' => $image_url,
'alt_text' => $full_name,
];
}
}
$fields_section = [
'type' => 'section',
'fields' => [
['type' => 'mrkdwn', 'text' => "*Name:*\n{$full_name}"],
['type' => 'mrkdwn', 'text' => "*Role:*\n{$role}"],
['type' => 'mrkdwn', 'text' => "*Organization:*\n{$business_name}"],
['type' => 'mrkdwn', 'text' => "*Business Type:*\n{$business_type}"],
],
];
if ($accessory) {
$fields_section['accessory'] = $accessory;
}
$blocks = [
[
'type' => 'header',
'text' => ['type' => 'plain_text', 'text' => "\xF0\x9F\x86\x95 New Trainer Registration", 'emoji' => true],
],
$fields_section,
[
'type' => 'actions',
'elements' => [
[
'type' => 'button',
'text' => ['type' => 'plain_text', 'text' => 'View in WordPress'],
'url' => $admin_url,
'style' => 'primary',
],
],
],
];
self::send($text, $blocks);
}
// ------------------------------------------------------------------
// Ticket purchase notification
// ------------------------------------------------------------------
/**
* Hook callback for transition_post_status.
*
* @param string $new_status New post status.
* @param string $old_status Previous post status.
* @param WP_Post $post Post object.
*/
public static function on_order_status_change(string $new_status, string $old_status, \WP_Post $post): void {
// Guard 1: correct post type
if ($post->post_type !== 'tec_tc_order') {
return;
}
// Guard 2: transitioning INTO completed
if ($new_status !== 'tec-tc-completed') {
return;
}
// Guard 3: not already completed (prevents re-saves)
if ($old_status === 'tec-tc-completed') {
return;
}
// Atomic idempotency: add_post_meta with $unique=true prevents races
if (!add_post_meta($post->ID, '_hvac_slack_ticket_notified', '1', true)) {
return;
}
self::notify_ticket_purchase($post->ID);
}
/**
* Send a Slack notification for a completed ticket purchase.
*
* @param int $order_id TEC Tickets Commerce order post ID.
*/
public static function notify_ticket_purchase(int $order_id): void {
$purchaser_name = get_post_meta($order_id, '_tec_tc_order_purchaser_name', true) ?: 'N/A';
$purchaser_email = get_post_meta($order_id, '_tec_tc_order_purchaser_email', true) ?: 'N/A';
$order_items = get_post_meta($order_id, '_tec_tc_order_items', true);
$gateway = get_post_meta($order_id, '_tec_tc_order_gateway', true) ?: 'Unknown';
$total_qty = 0;
$total_price = 0.0;
$event_title = 'N/A';
if (is_array($order_items)) {
foreach ($order_items as $item) {
$qty = (int) ($item['quantity'] ?? 1);
$total_qty += $qty;
$total_price += (float) ($item['sub_total'] ?? $item['price'] ?? 0) * $qty;
if (!empty($item['event_id']) && $event_title === 'N/A') {
$title = get_the_title((int) $item['event_id']);
if ($title) {
$event_title = $title;
}
}
}
}
$total_formatted = '$' . number_format($total_price, 2);
$admin_url = admin_url("post.php?post={$order_id}&action=edit");
$text = "New Ticket Purchase: {$purchaser_name}{$total_qty} ticket(s) for {$event_title} ({$total_formatted})";
$blocks = [
[
'type' => 'header',
'text' => ['type' => 'plain_text', 'text' => "\xF0\x9F\x8E\x9F\xEF\xB8\x8F New Ticket Purchase", 'emoji' => true],
],
[
'type' => 'section',
'fields' => [
['type' => 'mrkdwn', 'text' => "*Purchaser:*\n{$purchaser_name}"],
['type' => 'mrkdwn', 'text' => "*Email:*\n{$purchaser_email}"],
['type' => 'mrkdwn', 'text' => "*Event:*\n{$event_title}"],
['type' => 'mrkdwn', 'text' => "*Tickets:*\n{$total_qty}"],
],
],
[
'type' => 'section',
'fields' => [
['type' => 'mrkdwn', 'text' => "*Total:*\n{$total_formatted}"],
],
],
[
'type' => 'context',
'elements' => [
['type' => 'mrkdwn', 'text' => "via {$gateway}"],
],
],
[
'type' => 'actions',
'elements' => [
[
'type' => 'button',
'text' => ['type' => 'plain_text', 'text' => 'View Order'],
'url' => $admin_url,
'style' => 'primary',
],
],
],
];
self::send($text, $blocks);
}
// ------------------------------------------------------------------
// Event notifications
// ------------------------------------------------------------------
/**
* Build common event Block Kit fields from an event post ID.
*
* @param int $event_id The tribe_events post ID.
* @return array{text: string, fields: array, admin_url: string, event_title: string}
*/
private static function build_event_fields(int $event_id): array {
$event = get_post($event_id);
$event_title = $event ? $event->post_title : 'N/A';
$start_date = get_post_meta($event_id, '_EventStartDate', true);
$end_date = get_post_meta($event_id, '_EventEndDate', true);
$venue_id = get_post_meta($event_id, '_EventVenueID', true);
$venue_name = $venue_id ? get_the_title((int) $venue_id) : 'N/A';
$date_display = 'N/A';
if ($start_date) {
$date_display = wp_date('M j, Y g:ia', strtotime($start_date));
if ($end_date) {
$date_display .= ' — ' . wp_date('g:ia', strtotime($end_date));
}
}
// Get trainer/author info
$author_id = $event ? (int) $event->post_author : 0;
$author_name = $author_id ? get_the_author_meta('display_name', $author_id) : 'N/A';
$admin_url = admin_url("post.php?post={$event_id}&action=edit");
$front_url = get_permalink($event_id) ?: $admin_url;
$fields = [
['type' => 'mrkdwn', 'text' => "*Event:*\n{$event_title}"],
['type' => 'mrkdwn', 'text' => "*Trainer:*\n{$author_name}"],
['type' => 'mrkdwn', 'text' => "*Date:*\n{$date_display}"],
['type' => 'mrkdwn', 'text' => "*Venue:*\n{$venue_name}"],
];
return [
'event_title' => $event_title,
'author_name' => $author_name,
'fields' => $fields,
'admin_url' => $admin_url,
'front_url' => $front_url,
];
}
/**
* Called when a trainer submits an event via TEC Community Events form.
* Hooked to `hvac_tec_event_saved`.
*
* @param int $event_id The saved event post ID.
*/
public static function notify_event_submitted(int $event_id): void {
// Atomic lock: add_post_meta with $unique=true returns false if key already exists.
// Prevents duplicate sends on edits and concurrent requests.
if (!add_post_meta($event_id, '_hvac_slack_event_notified', '1', true)) {
return;
}
$data = self::build_event_fields($event_id);
$text = "New Event Submitted: {$data['event_title']} by {$data['author_name']}";
$blocks = [
[
'type' => 'header',
'text' => ['type' => 'plain_text', 'text' => "\xF0\x9F\x93\x9D Event Submitted by Trainer", 'emoji' => true],
],
[
'type' => 'section',
'fields' => $data['fields'],
],
[
'type' => 'actions',
'elements' => [
[
'type' => 'button',
'text' => ['type' => 'plain_text', 'text' => 'View Event'],
'url' => $data['admin_url'],
'style' => 'primary',
],
],
],
];
self::send($text, $blocks);
}
/**
* Hook callback for transition_post_status on tribe_events.
* Fires when an admin publishes an event (including from draft/pending).
*
* @param string $new_status New post status.
* @param string $old_status Previous post status.
* @param WP_Post $post Post object.
*/
public static function on_event_status_change(string $new_status, string $old_status, \WP_Post $post): void {
if ($post->post_type !== 'tribe_events') {
return;
}
// Only notify when transitioning INTO publish
if ($new_status !== 'publish' || $old_status === 'publish') {
return;
}
// Atomic lock: prevents double-notify from trainer submission hook or concurrent requests
if (!add_post_meta($post->ID, '_hvac_slack_event_notified', '1', true)) {
return;
}
$data = self::build_event_fields($post->ID);
$text = "Event Published: {$data['event_title']}";
$blocks = [
[
'type' => 'header',
'text' => ['type' => 'plain_text', 'text' => "\xE2\x9C\x85 Event Published", 'emoji' => true],
],
[
'type' => 'section',
'fields' => $data['fields'],
],
[
'type' => 'actions',
'elements' => [
[
'type' => 'button',
'text' => ['type' => 'plain_text', 'text' => 'View Event'],
'url' => $data['front_url'],
],
[
'type' => 'button',
'text' => ['type' => 'plain_text', 'text' => 'Edit in WP'],
'url' => $data['admin_url'],
],
],
],
];
self::send($text, $blocks);
}
// ------------------------------------------------------------------
// Test notification (AJAX)
// ------------------------------------------------------------------
/**
* AJAX handler: send a test notification to verify webhook config.
* Uses blocking mode so the admin UI gets real success/failure feedback.
*/
public static function send_test_notification(): void {
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => 'Unauthorized'], 403);
}
check_ajax_referer('hvac_test_slack_webhook', 'nonce');
$env = function_exists('hvac_is_staging_environment') && hvac_is_staging_environment() ? 'Staging' : 'Production';
$site_url = home_url('/');
$text = "Test notification from HVAC Community Events ({$env})";
$blocks = [
[
'type' => 'header',
'text' => ['type' => 'plain_text', 'text' => "\xE2\x9C\x85 Slack Integration Test", 'emoji' => true],
],
[
'type' => 'section',
'text' => [
'type' => 'mrkdwn',
'text' => "This is a test notification from *HVAC Community Events*.\nIf you see this, your webhook is working correctly.",
],
],
[
'type' => 'section',
'fields' => [
['type' => 'mrkdwn', 'text' => "*Environment:*\n{$env}"],
['type' => 'mrkdwn', 'text' => "*Site:*\n{$site_url}"],
],
],
[
'type' => 'context',
'elements' => [
['type' => 'mrkdwn', 'text' => 'Sent at ' . current_time('Y-m-d H:i:s')],
],
],
];
$result = self::send($text, $blocks, blocking: true);
if ($result === null) {
wp_send_json_error(['message' => 'No webhook URL configured.']);
} elseif ($result === false) {
wp_send_json_error(['message' => 'Slack returned an error. Check the webhook URL and try again.']);
} else {
wp_send_json_success(['message' => 'Test notification sent successfully!']);
}
}
}

View file

@ -45,8 +45,15 @@ class HVAC_Venue_Categories {
* Constructor * Constructor
*/ */
private function __construct() { private function __construct() {
add_action('init', [$this, 'register_taxonomies'], 5); // If init has already fired (we're being initialized late), register immediately
add_action('init', [$this, 'create_default_terms'], 10); // This handles the case where the class is instantiated during 'init' priority >= 5
if (did_action('init')) {
$this->register_taxonomies();
$this->create_default_terms();
} else {
add_action('init', [$this, 'register_taxonomies'], 5);
add_action('init', [$this, 'create_default_terms'], 10);
}
} }
/** /**

View file

@ -99,7 +99,11 @@ class HVAC_MapGeo_Integration {
add_action('wp_ajax_hvac_search_trainers', [$this, 'ajax_search_trainers']); add_action('wp_ajax_hvac_search_trainers', [$this, 'ajax_search_trainers']);
add_action('wp_ajax_nopriv_hvac_search_trainers', [$this, 'ajax_search_trainers']); add_action('wp_ajax_nopriv_hvac_search_trainers', [$this, 'ajax_search_trainers']);
// Add JavaScript to handle MapGeo marker clicks - Priority 0 to ensure interceptor runs before localization // Add JavaScript to handle MapGeo marker clicks - MUST run in wp_head BEFORE shortcode renders
// The IGM plugin outputs iMapsData during shortcode execution, so interceptor must be installed first
add_action('wp_head', [$this, 'add_mapgeo_interceptor'], 1);
// Add click handlers in footer (these run after map initializes)
add_action('wp_footer', [$this, 'add_mapgeo_click_handlers'], 0); add_action('wp_footer', [$this, 'add_mapgeo_click_handlers'], 0);
} }
@ -400,8 +404,84 @@ class HVAC_MapGeo_Integration {
/** /**
* Add JavaScript to handle MapGeo custom click actions * Add JavaScript interceptor in wp_head BEFORE shortcode renders
* This MUST run before IGM plugin outputs iMapsData
*/ */
public function add_mapgeo_interceptor() {
// Only add on find trainer page
if (!is_page() || get_post_field('post_name') !== 'find-a-trainer') {
return;
}
?>
<script type="text/javascript">
// Strategy H (v2): Multi-stage healing for IGM coordinate corruption
// The IGM plugin corrupts longitude AFTER initial assignment, so we need multiple healing passes
(function() {
// Prevent double-installation
if (window._hvacMapInterceptorInstalled) {
return;
}
window._hvacMapInterceptorInstalled = true;
// Healing function that can be called multiple times
window._hvacHealMapMarkers = function(source) {
if (typeof window.iMapsData === 'undefined' || !window.iMapsData) return 0;
var data = window.iMapsData;
if (!data.data || !data.data[0]) return 0;
var markerTypes = ['roundMarkers', 'iconMarkers', 'markers', 'customMarkers'];
var totalHealed = 0;
markerTypes.forEach(function(markerType) {
if (data.data[0][markerType] && Array.isArray(data.data[0][markerType])) {
data.data[0][markerType].forEach(function(m) {
// Detect corruption: latitude === longitude but backup values differ
if (m.lat && m.lng && m.latitude == m.longitude && parseFloat(m.lat) != parseFloat(m.lng)) {
m.latitude = parseFloat(m.lat);
m.longitude = parseFloat(m.lng);
totalHealed++;
}
});
}
});
if (totalHealed > 0) {
console.log('✅ HVAC MapGeo Healer [' + source + ']: Fixed ' + totalHealed + ' corrupted coordinates.');
}
return totalHealed;
};
// Schedule multiple healing passes to catch post-assignment corruption
var healingAttempts = 0;
var maxAttempts = 10;
var healingInterval = setInterval(function() {
healingAttempts++;
var healed = window._hvacHealMapMarkers('interval-' + healingAttempts);
// Stop after max attempts or if we've healed and no more corruption
if (healingAttempts >= maxAttempts) {
clearInterval(healingInterval);
console.log('🛡️ HVAC MapGeo Healer: Completed ' + healingAttempts + ' healing passes.');
}
}, 100); // Check every 100ms
// Also heal on DOMContentLoaded and window load
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() { window._hvacHealMapMarkers('DOMContentLoaded'); }, 50);
setTimeout(function() { window._hvacHealMapMarkers('DOMContentLoaded+500'); }, 500);
});
window.addEventListener('load', function() {
setTimeout(function() { window._hvacHealMapMarkers('window.load'); }, 100);
setTimeout(function() { window._hvacHealMapMarkers('window.load+1000'); }, 1000);
});
console.log('🛡️ HVAC MapGeo Healer installed with multi-stage healing');
})();
</script>
<?php
}
/** /**
* Add JavaScript to handle MapGeo custom click actions * Add JavaScript to handle MapGeo custom click actions
@ -411,43 +491,9 @@ class HVAC_MapGeo_Integration {
if (!is_page() || get_post_field('post_name') !== 'find-a-trainer') { if (!is_page() || get_post_field('post_name') !== 'find-a-trainer') {
return; return;
} }
?> ?>
<script type="text/javascript"> <script type="text/javascript">
// Strategy H: Intercept iMapsData assignment to fix corruption
(function() {
var _data = undefined;
// Only intercept if not defined yet
if (typeof iMapsData === 'undefined') {
Object.defineProperty(window, 'iMapsData', {
get: function() { return _data; },
set: function(val) {
// Fix corruption immediately upon assignment
if(val && val.data && val.data[0]) {
// Handle roundMarkers
if (val.data[0].roundMarkers) {
var healedCount = 0;
val.data[0].roundMarkers.forEach(function(m) {
// Restore from safe lat/lng keys if corruption detected
if(m.lat && m.lng && m.latitude == m.longitude) {
m.latitude = m.lat;
m.longitude = m.lng;
healedCount++;
}
});
if(healedCount > 0) {
console.log('✅ HVAC MapGeo Interceptor: Healed ' + healedCount + ' corrupted markers instantly.');
}
}
}
_data = val;
},
configurable: true
});
}
})();
jQuery(document).ready(function($) { jQuery(document).ready(function($) {
// Disable console logging in production // Disable console logging in production
var isProduction = window.location.hostname === 'upskillhvac.com'; var isProduction = window.location.hostname === 'upskillhvac.com';

View file

@ -147,7 +147,21 @@ class HVAC_Find_Training_Page {
* @return bool * @return bool
*/ */
public function is_find_training_page(): bool { public function is_find_training_page(): bool {
return is_page($this->page_slug) || is_page(get_option('hvac_find_training_page_id')); if (is_page($this->page_slug)) {
return true;
}
$page_id = get_option('hvac_find_training_page_id');
return $page_id && is_page($page_id);
}
/**
* Check if Google Maps API key is configured
*
* @return bool
*/
public function is_api_key_configured(): bool {
return !empty($this->api_key);
} }
/** /**
@ -158,6 +172,8 @@ class HVAC_Find_Training_Page {
return; return;
} }
$api_key_configured = !empty($this->api_key);
// Enqueue CSS // Enqueue CSS
wp_enqueue_style( wp_enqueue_style(
'hvac-find-training', 'hvac-find-training',
@ -166,8 +182,16 @@ class HVAC_Find_Training_Page {
HVAC_VERSION HVAC_VERSION
); );
// Enqueue Google Maps API with MarkerClusterer // Enqueue Google reCAPTCHA for contact forms
if (!empty($this->api_key)) { if (class_exists('HVAC_Recaptcha')) {
HVAC_Recaptcha::instance()->enqueue_script();
}
// Build script dependencies
$map_script_deps = ['jquery'];
// Enqueue Google Maps API with MarkerClusterer only if API key is configured
if ($api_key_configured) {
wp_enqueue_script( wp_enqueue_script(
'google-maps-api', 'google-maps-api',
'https://maps.googleapis.com/maps/api/js?key=' . esc_attr($this->api_key) . '&libraries=places&callback=Function.prototype', 'https://maps.googleapis.com/maps/api/js?key=' . esc_attr($this->api_key) . '&libraries=places&callback=Function.prototype',
@ -184,13 +208,16 @@ class HVAC_Find_Training_Page {
'2.5.3', '2.5.3',
true true
); );
$map_script_deps[] = 'google-maps-api';
$map_script_deps[] = 'google-maps-markerclusterer';
} }
// Enqueue main map JavaScript // Enqueue main map JavaScript (always load for directory functionality)
wp_enqueue_script( wp_enqueue_script(
'hvac-find-training-map', 'hvac-find-training-map',
HVAC_PLUGIN_URL . 'assets/js/find-training-map.js', HVAC_PLUGIN_URL . 'assets/js/find-training-map.js',
['jquery', 'google-maps-api', 'google-maps-markerclusterer'], $map_script_deps,
HVAC_VERSION, HVAC_VERSION,
true true
); );
@ -204,27 +231,38 @@ class HVAC_Find_Training_Page {
true true
); );
// Get reCAPTCHA site key if available
$recaptcha_site_key = '';
if (class_exists('HVAC_Recaptcha')) {
$recaptcha_site_key = HVAC_Recaptcha::instance()->get_site_key();
}
// Localize script with data // Localize script with data
wp_localize_script('hvac-find-training-map', 'hvacFindTraining', [ wp_localize_script('hvac-find-training-map', 'hvacFindTraining', [
'ajax_url' => admin_url('admin-ajax.php'), 'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('hvac_find_training'), 'nonce' => wp_create_nonce('hvac_find_training'),
'api_key' => !empty($this->api_key) ? 'configured' : '', // Don't expose actual key 'api_key_configured' => $api_key_configured,
'map_center' => [ 'map_center' => [
'lat' => 39.8283, // US center 'lat' => 39.8283, // US center
'lng' => -98.5795 'lng' => -98.5795
], ],
'default_zoom' => 4, 'default_zoom' => 4,
'cluster_zoom' => 8, 'cluster_zoom' => 8,
'recaptcha_site_key' => $recaptcha_site_key,
'messages' => [ 'messages' => [
'loading' => __('Loading...', 'hvac-community-events'), 'loading' => __('Loading...', 'hvac-community-events'),
'error' => __('An error occurred. Please try again.', 'hvac-community-events'), 'error' => __('An error occurred. Please try again.', 'hvac-community-events'),
'no_results' => __('No trainers or venues found matching your criteria.', 'hvac-community-events'), 'no_results' => __('No trainers or venues found matching your criteria.', 'hvac-community-events'),
'geolocation_error' => __('Unable to get your location. Please check your browser settings.', 'hvac-community-events'), 'geolocation_error' => __('Unable to get your location. Please check your browser settings.', 'hvac-community-events'),
'geolocation_unsupported' => __('Geolocation is not supported by your browser.', 'hvac-community-events') 'geolocation_unsupported' => __('Geolocation is not supported by your browser.', 'hvac-community-events'),
'api_key_missing' => __('Google Maps API key is not configured.', 'hvac-community-events'),
'captcha_required' => __('Please complete the CAPTCHA verification.', 'hvac-community-events'),
'captcha_failed' => __('CAPTCHA verification failed. Please try again.', 'hvac-community-events')
], ],
'marker_icons' => [ 'marker_icons' => [
'trainer' => HVAC_PLUGIN_URL . 'assets/images/marker-trainer.svg', 'trainer' => HVAC_PLUGIN_URL . 'assets/images/marker-trainer.svg',
'venue' => HVAC_PLUGIN_URL . 'assets/images/marker-venue.svg' 'venue' => HVAC_PLUGIN_URL . 'assets/images/marker-venue.svg',
'event' => HVAC_PLUGIN_URL . 'assets/images/marker-event.svg'
] ]
]); ]);
} }
@ -255,7 +293,7 @@ class HVAC_Find_Training_Page {
} }
/** /**
* AJAX: Get all map data (trainers and venues) * AJAX: Get all map data (trainers, venues, and events)
*/ */
public function ajax_get_map_data(): void { public function ajax_get_map_data(): void {
// Verify nonce // Verify nonce
@ -268,12 +306,15 @@ class HVAC_Find_Training_Page {
$trainers = $data_provider->get_trainer_markers(); $trainers = $data_provider->get_trainer_markers();
$venues = $data_provider->get_venue_markers(); $venues = $data_provider->get_venue_markers();
$events = $data_provider->get_event_markers();
wp_send_json_success([ wp_send_json_success([
'trainers' => $trainers, 'trainers' => $trainers,
'venues' => $venues, 'venues' => $venues,
'events' => $events,
'total_trainers' => count($trainers), 'total_trainers' => count($trainers),
'total_venues' => count($venues) 'total_venues' => count($venues),
'total_events' => count($events)
]); ]);
} }
@ -294,6 +335,8 @@ class HVAC_Find_Training_Page {
'search' => sanitize_text_field($_POST['search'] ?? ''), 'search' => sanitize_text_field($_POST['search'] ?? ''),
'show_trainers' => filter_var($_POST['show_trainers'] ?? true, FILTER_VALIDATE_BOOLEAN), 'show_trainers' => filter_var($_POST['show_trainers'] ?? true, FILTER_VALIDATE_BOOLEAN),
'show_venues' => filter_var($_POST['show_venues'] ?? true, FILTER_VALIDATE_BOOLEAN), 'show_venues' => filter_var($_POST['show_venues'] ?? true, FILTER_VALIDATE_BOOLEAN),
'show_events' => filter_var($_POST['show_events'] ?? true, FILTER_VALIDATE_BOOLEAN),
'include_past' => filter_var($_POST['include_past'] ?? false, FILTER_VALIDATE_BOOLEAN),
'lat' => isset($_POST['lat']) ? floatval($_POST['lat']) : null, 'lat' => isset($_POST['lat']) ? floatval($_POST['lat']) : null,
'lng' => isset($_POST['lng']) ? floatval($_POST['lng']) : null, 'lng' => isset($_POST['lng']) ? floatval($_POST['lng']) : null,
'radius' => isset($_POST['radius']) ? intval($_POST['radius']) : 100 // km 'radius' => isset($_POST['radius']) ? intval($_POST['radius']) : 100 // km
@ -303,7 +346,8 @@ class HVAC_Find_Training_Page {
$result = [ $result = [
'trainers' => [], 'trainers' => [],
'venues' => [] 'venues' => [],
'events' => []
]; ];
if ($filters['show_trainers']) { if ($filters['show_trainers']) {
@ -314,8 +358,13 @@ class HVAC_Find_Training_Page {
$result['venues'] = $data_provider->get_venue_markers($filters); $result['venues'] = $data_provider->get_venue_markers($filters);
} }
if ($filters['show_events']) {
$result['events'] = $data_provider->get_event_markers($filters);
}
$result['total_trainers'] = count($result['trainers']); $result['total_trainers'] = count($result['trainers']);
$result['total_venues'] = count($result['venues']); $result['total_venues'] = count($result['venues']);
$result['total_events'] = count($result['events']);
$result['filters_applied'] = array_filter($filters, function($v) { $result['filters_applied'] = array_filter($filters, function($v) {
return !empty($v) && $v !== true; return !empty($v) && $v !== true;
}); });
@ -477,6 +526,11 @@ class HVAC_Find_Training_Page {
<div class="hvac-form-field"> <div class="hvac-form-field">
<textarea name="message" placeholder="Message" rows="3"></textarea> <textarea name="message" placeholder="Message" rows="3"></textarea>
</div> </div>
<div class="hvac-form-field hvac-recaptcha-wrapper">
<?php if (class_exists('HVAC_Recaptcha')): ?>
<?php HVAC_Recaptcha::instance()->echo_widget('trainer-contact'); ?>
<?php endif; ?>
</div>
<button type="submit" class="hvac-btn-primary">Send Message</button> <button type="submit" class="hvac-btn-primary">Send Message</button>
</form> </form>

View file

@ -69,11 +69,27 @@ class HVAC_Venue_Geocoding {
} }
/** /**
* Load API key from secure storage * Load API key from secure storage or plain option
* Uses dedicated geocoding key if available, falls back to maps key
*/ */
private function load_api_key(): void { private function load_api_key(): void {
if (class_exists('HVAC_Secure_Storage')) { // First try dedicated geocoding API key (IP-restricted for server-side use)
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', ''); // Check plain option first (simpler setup)
$this->api_key = get_option('hvac_google_geocoding_api_key', '');
// Try secure storage if plain option not set
if (empty($this->api_key) && class_exists('HVAC_Secure_Storage')) {
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_geocoding_api_key', '');
}
// Fall back to maps API key if geocoding key not set
if (empty($this->api_key)) {
if (class_exists('HVAC_Secure_Storage')) {
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', '');
}
if (empty($this->api_key)) {
$this->api_key = get_option('hvac_google_maps_api_key', '');
}
} }
} }
@ -90,6 +106,12 @@ class HVAC_Venue_Geocoding {
// Admin action for batch geocoding // Admin action for batch geocoding
add_action('wp_ajax_hvac_batch_geocode_venues', [$this, 'ajax_batch_geocode']); add_action('wp_ajax_hvac_batch_geocode_venues', [$this, 'ajax_batch_geocode']);
// Admin action for marking venues as approved labs (legacy)
add_action('wp_ajax_hvac_mark_venues_approved', [$this, 'ajax_mark_venues_approved']);
// Admin action for updating approved labs list
add_action('wp_ajax_hvac_update_approved_labs', [$this, 'ajax_update_approved_labs']);
// Clear venue coordinates when address changes // Clear venue coordinates when address changes
add_action('updated_post_meta', [$this, 'on_venue_meta_update'], 10, 4); add_action('updated_post_meta', [$this, 'on_venue_meta_update'], 10, 4);
} }
@ -412,6 +434,158 @@ class HVAC_Venue_Geocoding {
wp_send_json_success($result); wp_send_json_success($result);
} }
/**
* AJAX handler for marking geocoded venues as approved training labs
*/
public function ajax_mark_venues_approved(): void {
// Check permissions
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => 'Permission denied']);
return;
}
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_mark_venues_approved')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$result = $this->mark_geocoded_venues_as_approved();
wp_send_json_success($result);
}
/**
* Mark all geocoded venues as approved training labs
*
* @return array Results with count of venues marked
*/
public function mark_geocoded_venues_as_approved(): array {
// Get all venues that have coordinates but are NOT already approved labs
$venues = get_posts([
'post_type' => 'tribe_venue',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_query' => [
'relation' => 'OR',
[
'key' => 'venue_latitude',
'compare' => 'EXISTS'
],
[
'key' => '_VenueLat',
'compare' => 'EXISTS'
]
],
'tax_query' => [
[
'taxonomy' => 'venue_type',
'field' => 'slug',
'terms' => 'mq-approved-lab',
'operator' => 'NOT IN'
]
]
]);
$results = [
'marked' => 0,
'failed' => 0,
'total_approved' => 0
];
// Load venue categories class
if (!class_exists('HVAC_Venue_Categories')) {
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-venue-categories.php';
}
$venue_categories = HVAC_Venue_Categories::instance();
foreach ($venues as $venue) {
$result = $venue_categories->set_as_approved_lab($venue->ID);
if (is_wp_error($result)) {
$results['failed']++;
} else {
$results['marked']++;
}
}
// Count total approved labs
$approved_query = new WP_Query([
'post_type' => 'tribe_venue',
'posts_per_page' => 1,
'post_status' => 'publish',
'fields' => 'ids',
'tax_query' => [
[
'taxonomy' => 'venue_type',
'field' => 'slug',
'terms' => 'mq-approved-lab',
]
]
]);
$results['total_approved'] = $approved_query->found_posts;
return $results;
}
/**
* AJAX handler for updating approved labs list with specific venue IDs
*/
public function ajax_update_approved_labs(): void {
// Check permissions
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => 'Permission denied']);
return;
}
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_mark_venues_approved')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
// Get venue IDs from request (these are the ones that should be approved)
$selected_ids = isset($_POST['venue_ids']) ? array_map('absint', (array)$_POST['venue_ids']) : [];
// Load venue categories class
if (!class_exists('HVAC_Venue_Categories')) {
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-venue-categories.php';
}
$venue_categories = HVAC_Venue_Categories::instance();
// Get all venues
$all_venues = get_posts([
'post_type' => 'tribe_venue',
'posts_per_page' => -1,
'post_status' => 'publish',
'fields' => 'ids'
]);
$added = 0;
$removed = 0;
foreach ($all_venues as $venue_id) {
$is_currently_approved = has_term('mq-approved-lab', 'venue_type', $venue_id);
$should_be_approved = in_array($venue_id, $selected_ids);
if ($should_be_approved && !$is_currently_approved) {
// Add the term
wp_set_post_terms($venue_id, ['mq-approved-lab'], 'venue_type', false);
$added++;
} elseif (!$should_be_approved && $is_currently_approved) {
// Remove the term
wp_remove_object_terms($venue_id, 'mq-approved-lab', 'venue_type');
$removed++;
}
}
wp_send_json_success([
'approved_count' => count($selected_ids),
'added' => $added,
'removed' => $removed
]);
}
/** /**
* Batch geocode venues without coordinates * Batch geocode venues without coordinates
* *