diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 773aea33..e5e75dbf 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -2,44 +2,46 @@
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -100 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log | grep -i -E ''(fatal|error|warning|dashboard|manage)''\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp plugin list | grep -E ''(events-calendar|tribe)''\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp plugin deactivate the-events-calendar-community-events\")",
- "Bash(printf:*)",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no benr@146.190.76.204 \"tail -100 /home/974670.cloudwaysapps.com/ncjzsayvsk/public_html/wp-content/debug.log | grep -i -E ''(security|nonce|edit|6288)''\")",
- "Bash(scripts/deploy.sh:*)",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp post get 5346 --fields=ID,post_title,post_name\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -30 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log | grep -i ''hvac announcements admin''\")",
"mcp__playwright__browser_navigate",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user list --role=hvac_master_trainer --fields=user_login,user_email --format=table\")",
- "mcp__playwright__browser_type",
- "mcp__playwright__browser_click",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user update joe@upskillhvac.com --user_pass=''JoeTest123!'' --skip-email\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user list --role=hvac_trainer --fields=user_login,user_email --format=table | head -5\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -50 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log | grep -i -E ''(master-trainer|trainers|javascript|enqueue)''\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user get test_master --field=roles\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user create test_master_new test.master.new@example.com --role=hvac_master_trainer --user_pass=''TestNew123!'' --skip-email 2>&1 || wp user update test_master_new --user_pass=''TestNew123!'' --skip-email\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user list --field=user_login | grep test\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && which composer && composer --version\")",
- "Bash(unzip:*)",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp plugin list | grep -E ''(wordpress-mcp|Name)''\")",
- "WebFetch(domain:github.com)",
- "WebFetch(domain:json.schemastore.org)",
- "WebFetch(domain:www.schemastore.org)",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass:*)",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e scp -o StrictHostKeyChecking=no /tmp/wordpress-mcp.zip roodev@146.190.76.204:/tmp/wordpress-mcp-prod.zip)",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/ncjzsayvsk/public_html && wp plugin install /tmp/wordpress-mcp-prod.zip --activate\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no benr@146.190.76.204 \"ls -la /home/974670.cloudwaysapps.com/ | head -20\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cat /home/974670.cloudwaysapps.com/ncjzsayvsk/public_html/wp-config.php 2>&1 | grep -i ''site.*url'' | head -5 || echo ''Checking domain...''\")",
- "Bash(SSHPASS=\"uSCO6f1y@1oVkz0M\" sshpass -e ssh -o StrictHostKeyChecking=no benr@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/ncjzsayvsk/public_html && wp plugin install /tmp/wordpress-mcp-prod.zip --activate\")",
- "Bash(SSHPASS=\"uSCO6f1y@1oVkz0M\" sshpass -e ssh -o StrictHostKeyChecking=no benr@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/ncjzsayvsk/public_html && wp plugin list | grep -E ''(wordpress-mcp|Name)''\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user update master_trainer --user_pass=''MasterTest123!'' --skip-email 2>&1 || echo ''User does not exist, creating...'' && wp user create master_trainer master_trainer@example.com --role=hvac_master_trainer --user_pass=''MasterTest123!'' --skip-email\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp plugin list | grep -E ''(security|login|limit)''\")",
- "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp post list --post_type=page --s=''announcements'' --fields=ID,post_title,post_name,post_status --format=table\")",
- "mcp__zen__codereview",
- "mcp__playwright__browser_console_messages",
- "mcp__zen__debug",
"mcp__playwright__browser_evaluate",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -200 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log | grep -i ''enqueue''\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -300 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log | grep -i -E ''(wp_enqueue_scripts|init_hooks)''\")",
+ "Bash(scripts/deploy.sh:*)",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -100 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log | grep -i ''HVAC_Announcements_Admin''\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -150 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log | grep -i ''HVAC_Announcements_Admin''\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -200 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp cache flush\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -50 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log | grep -i ''HVAC''\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -200 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log | grep -A5 ''HVAC_Announcements_Admin.*enqueue_admin_assets called''\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -500 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log | grep -i ''enqueue.*admin.*assets\\|ENQUEUING SCRIPTS''\")",
+ "mcp__zen__debug",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user get 25 --field=roles\")",
+ "WebSearch",
+ "mcp__zen__chat",
+ "mcp__playwright__browser_click",
"mcp__playwright__browser_take_screenshot",
- "mcp__playwright__browser_wait_for"
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"grep -A5 ''wp_enqueue_script.*hvac-announcements-admin'' /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/includes/class-hvac-announcements-admin.php | head -20\")",
+ "mcp__zen__analyze",
+ "mcp__playwright__browser_type",
+ "mcp__playwright__browser_close",
+ "mcp__playwright__browser_install",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp option get hvac_zoho_client_id 2>/dev/null || echo ''NOT SET''\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp option get hvac_zoho_refresh_token 2>/dev/null || echo ''NOT SET''\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp eval ''\nrequire_once ABSPATH . \"\"wp-content/plugins/hvac-community-events/includes/zoho/class-zoho-crm-auth.php\"\";\n$auth = new HVAC_Zoho_CRM_Auth();\n\necho \"\"=== ZOHO CRM READ-ONLY TEST ===\"\";\necho \"\"\\n\\n1. ORGANIZATION INFO:\\n\"\";\n$org = $auth->make_api_request(\"\"/org\"\", \"\"GET\"\");\nif (isset($org[\"\"org\"\"][0])) {\n $o = $org[\"\"org\"\"][0];\n echo \"\" Company: \"\" . $o[\"\"company_name\"\"] . \"\"\\n\"\";\n echo \"\" Country: \"\" . ($o[\"\"country\"\"] ?? \"\"N/A\"\") . \"\"\\n\"\";\n echo \"\" Time Zone: \"\" . ($o[\"\"time_zone\"\"] ?? \"\"N/A\"\") . \"\"\\n\"\";\n} else {\n echo \"\" Error: \"\" . print_r($org, true);\n}\n''\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp eval ''\nrequire_once ABSPATH . \"\"wp-content/plugins/hvac-community-events/includes/zoho/class-zoho-crm-auth.php\"\";\n$auth = new HVAC_Zoho_CRM_Auth();\n\necho \"\"2. CONTACTS (first 5):\\n\"\";\n$contacts = $auth->make_api_request(\"\"/Contacts?per_page=5\"\", \"\"GET\"\");\nif (isset($contacts[\"\"data\"\"])) {\n foreach ($contacts[\"\"data\"\"] as $c) {\n echo \"\" - \"\" . ($c[\"\"Full_Name\"\"] ?? $c[\"\"Email\"\"] ?? \"\"Unknown\"\") . \"\"\\n\"\";\n }\n echo \"\" Total in response: \"\" . count($contacts[\"\"data\"\"]) . \"\"\\n\"\";\n} elseif (isset($contacts[\"\"info\"\"])) {\n echo \"\" No contacts found (empty CRM)\\n\"\";\n} else {\n echo \"\" Response: \"\" . print_r($contacts, true);\n}\n''\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp eval ''\nrequire_once ABSPATH . \"\"wp-content/plugins/hvac-community-events/includes/zoho/class-zoho-crm-auth.php\"\";\n$auth = new HVAC_Zoho_CRM_Auth();\n\necho \"\"3. CAMPAIGNS (first 5):\\n\"\";\n$campaigns = $auth->make_api_request(\"\"/Campaigns?per_page=5\"\", \"\"GET\"\");\nif (isset($campaigns[\"\"data\"\"])) {\n foreach ($campaigns[\"\"data\"\"] as $c) {\n echo \"\" - \"\" . ($c[\"\"Campaign_Name\"\"] ?? \"\"Unnamed\"\") . \"\"\\n\"\";\n }\n} elseif (isset($campaigns[\"\"code\"\"]) && $campaigns[\"\"code\"\"] == \"\"INVALID_MODULE\"\") {\n echo \"\" Campaigns module not enabled\\n\"\";\n} else {\n echo \"\" Response: \"\" . json_encode($campaigns) . \"\"\\n\"\";\n}\n\necho \"\"\\n4. USERS (CRM users):\\n\"\";\n$users = $auth->make_api_request(\"\"/users?type=AllUsers\"\", \"\"GET\"\");\nif (isset($users[\"\"users\"\"])) {\n foreach (array_slice($users[\"\"users\"\"], 0, 5) as $u) {\n echo \"\" - \"\" . $u[\"\"full_name\"\"] . \"\" (\"\" . $u[\"\"role\"\"][\"\"name\"\"] . \"\")\\n\"\";\n }\n} else {\n echo \"\" Response: \"\" . json_encode($users) . \"\"\\n\"\";\n}\n''\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp eval ''\necho \"\"=== EVENT TICKETS DATA STRUCTURE ===\"\";\necho \"\"\\n\"\";\n\n// Check what ticket-related post types exist\n$post_types = get_post_types(array(), \"\"names\"\");\n$ticket_types = array_filter($post_types, function($pt) {\n return strpos($pt, \"\"ticket\"\") !== false || strpos($pt, \"\"tec\"\") !== false || strpos($pt, \"\"tribe\"\") !== false || strpos($pt, \"\"attendee\"\") !== false || strpos($pt, \"\"order\"\") !== false;\n});\necho \"\"Ticket-related post types:\\n\"\";\nforeach ($ticket_types as $pt) {\n $count = wp_count_posts($pt);\n $total = isset($count->publish) ? $count->publish : 0;\n echo \"\" - \"\" . $pt . \"\" (\"\" . $total . \"\" published)\\n\"\";\n}\n\necho \"\"\\n\"\";\n\n// Check for Tickets Commerce orders\nif (class_exists(\"\"TEC\\Tickets\\Commerce\\Order\"\")) {\n echo \"\"Tickets Commerce Order class exists\\n\"\";\n}\n\n// Check for attendees\n$attendees = get_posts(array(\n \"\"post_type\"\" => \"\"tribe_tpp_attendees\"\",\n \"\"posts_per_page\"\" => 5,\n \"\"post_status\"\" => \"\"any\"\"\n));\necho \"\"\\nAttendees (tribe_tpp_attendees): \"\" . count($attendees) . \"\"\\n\"\";\n\n// Try tribe_rsvp_attendees\n$rsvp = get_posts(array(\n \"\"post_type\"\" => \"\"tribe_rsvp_attendees\"\", \n \"\"posts_per_page\"\" => 5,\n \"\"post_status\"\" => \"\"any\"\"\n));\necho \"\"RSVPs (tribe_rsvp_attendees): \"\" . count($rsvp) . \"\"\\n\"\";\n\n// Try tec_tc_attendee (Tickets Commerce)\n$tc_attendees = get_posts(array(\n \"\"post_type\"\" => \"\"tec_tc_attendee\"\",\n \"\"posts_per_page\"\" => 5, \n \"\"post_status\"\" => \"\"any\"\"\n));\necho \"\"TC Attendees (tec_tc_attendee): \"\" . count($tc_attendees) . \"\"\\n\"\";\n\n// Try tec_tc_order\n$tc_orders = get_posts(array(\n \"\"post_type\"\" => \"\"tec_tc_order\"\",\n \"\"posts_per_page\"\" => 5,\n \"\"post_status\"\" => \"\"any\"\"\n));\necho \"\"TC Orders (tec_tc_order): \"\" . count($tc_orders) . \"\"\\n\"\";\n''\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp eval ''\necho \"\"=== EVENT TICKETS DATA STRUCTURE ===\\n\\n\"\";\n\n// Check ticket-related post types\n$post_types = get_post_types(array(), \"\"names\"\");\necho \"\"Ticket-related post types:\\n\"\";\nforeach ($post_types as $pt) {\n if (strpos($pt, \"\"ticket\"\") !== false || strpos($pt, \"\"attendee\"\") !== false || strpos($pt, \"\"rsvp\"\") !== false || strpos($pt, \"\"tec_tc\"\") !== false) {\n $count = wp_count_posts($pt);\n $total = isset($count->publish) ? $count->publish : 0;\n echo \"\" - \"\" . $pt . \"\" (\"\" . $total . \"\" published)\\n\"\";\n }\n}\n''\")",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 'cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && cat > /tmp/check_orders.php << ''''PHPCODE''''\n \"\"tec_tc_order\"\",\n \"\"posts_per_page\"\" => 3,\n \"\"post_status\"\" => \"\"any\"\"\n));\n\necho \"\"Found \"\" . count($orders) . \"\" sample orders\\n\\n\"\";\n\nforeach ($orders as $order) {\n echo \"\"--- Order #\"\" . $order->ID . \"\" ---\\n\"\";\n echo \"\"Status: \"\" . $order->post_status . \"\"\\n\"\";\n echo \"\"Date: \"\" . $order->post_date . \"\"\\n\"\";\n \n $meta = get_post_meta($order->ID);\n \n // Key fields\n $fields = array(\n \"\"_tec_tc_order_purchaser_name\"\",\n \"\"_tec_tc_order_purchaser_email\"\", \n \"\"_tec_tc_order_total\"\",\n \"\"_tec_tc_order_gateway\"\",\n \"\"_tec_tc_order_gateway_order_id\"\",\n \"\"_tec_tc_order_items\"\"\n );\n \n foreach ($fields as $f) {\n if (isset($meta[$f])) {\n $val = $meta[$f][0];\n if (is_serialized($val)) {\n $val = \"\"SERIALIZED: \"\" . substr($val, 0, 100) . \"\"...\"\";\n }\n echo \"\"$f: $val\\n\"\";\n }\n }\n echo \"\"\\n\"\";\n}\n\n// Also check attendees linked to orders\necho \"\"=== SAMPLE ATTENDEE WITH ORDER LINK ===\\n\"\";\n$attendee = get_posts(array(\n \"\"post_type\"\" => \"\"tec_tc_attendee\"\",\n \"\"posts_per_page\"\" => 1,\n \"\"post_status\"\" => \"\"any\"\"\n));\nif ($attendee) {\n $a = $attendee[0];\n $meta = get_post_meta($a->ID);\n echo \"\"Attendee ID: \"\" . $a->ID . \"\"\\n\"\";\n echo \"\"Event ID: \"\" . ($meta[\"\"_tec_tc_attendee_event\"\"][0] ?? \"\"N/A\"\") . \"\"\\n\"\";\n echo \"\"Order ID: \"\" . ($meta[\"\"_tec_tc_order\"\"][0] ?? $meta[\"\"_tribe_tpp_order\"\"][0] ?? \"\"N/A\"\") . \"\"\\n\"\";\n echo \"\"Ticket ID: \"\" . ($meta[\"\"_tec_tc_attendee_ticket\"\"][0] ?? \"\"N/A\"\") . \"\"\\n\"\";\n echo \"\"Name: \"\" . ($meta[\"\"_tec_tc_attendee_name\"\"][0] ?? $meta[\"\"_tribe_tickets_full_name\"\"][0] ?? \"\"N/A\"\") . \"\"\\n\"\";\n echo \"\"Email: \"\" . ($meta[\"\"_tec_tc_attendee_email\"\"][0] ?? $meta[\"\"_tribe_tickets_email\"\"][0] ?? \"\"N/A\"\") . \"\"\\n\"\";\n}\nPHPCODE\nphp /tmp/check_orders.php')",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 'cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && cat > /tmp/test_sync.php << ''''PHPCODE''''\nsync_events();\necho \"\" Total: \"\" . $result[\"\"total\"\"] . \"\", Would Sync: \"\" . $result[\"\"synced\"\"] . \"\"\\n\"\";\nif (!empty($result[\"\"test_data\"\"])) {\n $sample = $result[\"\"test_data\"\"][0];\n echo \"\" Sample: \"\" . substr($sample[\"\"event_title\"\"], 0, 40) . \"\"...\\n\"\";\n}\necho \"\"\\n\"\";\n\n// 2. Users (Trainers)\necho \"\"2. TRAINERS → CONTACTS\\n\"\";\n$result = $sync->sync_users();\necho \"\" Total: \"\" . $result[\"\"total\"\"] . \"\", Would Sync: \"\" . $result[\"\"synced\"\"] . \"\"\\n\"\";\nif (!empty($result[\"\"test_data\"\"])) {\n $sample = $result[\"\"test_data\"\"][0];\n echo \"\" Sample: \"\" . $sample[\"\"user_email\"\"] . \"\" (\"\" . $sample[\"\"user_role\"\"] . \"\")\\n\"\";\n}\necho \"\"\\n\"\";\n\n// 3. Attendees\necho \"\"3. ATTENDEES → CONTACTS + CAMPAIGN MEMBERS\\n\"\";\n$result = $sync->sync_attendees();\necho \"\" Total: \"\" . $result[\"\"total\"\"] . \"\", Would Sync: \"\" . $result[\"\"synced\"\"] . \"\"\\n\"\";\nif (!empty($result[\"\"test_data\"\"])) {\n $sample = $result[\"\"test_data\"\"][0];\n echo \"\" Sample: \"\" . ($sample[\"\"full_name\"\"] ?: \"\"Unknown\"\") . \"\" <\"\" . ($sample[\"\"email\"\"] ?: \"\"no-email\"\") . \"\">\\n\"\";\n echo \"\" Event: \"\" . ($sample[\"\"event_title\"\"] ?: \"\"N/A\"\") . \"\"\\n\"\";\n}\necho \"\"\\n\"\";\n\n// 4. RSVPs\necho \"\"4. RSVPs → LEADS + CAMPAIGN MEMBERS\\n\"\";\n$result = $sync->sync_rsvps();\necho \"\" Total: \"\" . $result[\"\"total\"\"] . \"\", Would Sync: \"\" . $result[\"\"synced\"\"] . \"\"\\n\"\";\nif (!empty($result[\"\"test_data\"\"])) {\n $sample = $result[\"\"test_data\"\"][0];\n echo \"\" Sample: \"\" . ($sample[\"\"full_name\"\"] ?: \"\"Unknown\"\") . \"\" <\"\" . ($sample[\"\"email\"\"] ?: \"\"no-email\"\") . \"\">\\n\"\";\n echo \"\" RSVP Status: \"\" . ($sample[\"\"rsvp_status\"\"] ?: \"\"N/A\"\") . \"\"\\n\"\";\n}\necho \"\"\\n\"\";\n\n// 5. Purchases/Orders\necho \"\"5. TICKET ORDERS → INVOICES\\n\"\";\n$result = $sync->sync_purchases();\necho \"\" Total: \"\" . $result[\"\"total\"\"] . \"\", Would Sync: \"\" . $result[\"\"synced\"\"] . \"\"\\n\"\";\nif (!empty($result[\"\"test_data\"\"])) {\n $sample = $result[\"\"test_data\"\"][0];\n echo \"\" Sample: Order #\"\" . $sample[\"\"order_id\"\"] . \"\" - \"\" . $sample[\"\"purchaser_email\"\"] . \"\"\\n\"\";\n echo \"\" Gateway: \"\" . $sample[\"\"gateway\"\"] . \"\", Date: \"\" . $sample[\"\"date\"\"] . \"\"\\n\"\";\n}\necho \"\"\\n\"\";\n\necho \"\"=== ALL SYNCS IN STAGING MODE (NO DATA SENT TO ZOHO) ===\\n\"\";\nPHPCODE\nphp /tmp/test_sync.php')",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 'tail -50 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log 2>/dev/null | grep -i \"\"zoho\\|fatal\\|error\"\" | tail -20')",
+ "Bash(./scripts/deploy.sh:*)",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 'ls -la /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/assets/css/zoho-admin.css 2>/dev/null || echo \"\"File does not exist on staging\"\"')",
+ "Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 'curl -s \"\"https://upskill-staging.measurequick.com/master-trainer/master-dashboard/\"\" 2>/dev/null | sed -n \"\"220,235p\"\"')",
+ "Bash(curl:*)",
+ "Bash(yes:*)"
],
"deny": [],
"ask": [],
diff --git a/Status.md b/Status.md
index 0f13fe9b..b60f8e49 100644
--- a/Status.md
+++ b/Status.md
@@ -39,6 +39,29 @@
---
+## 🎯 CURRENT SESSION - PUBLIC MAP & DIRECTORY FIX (Dec 20, 2025)
+
+### Status: 🚧 **IN PROGRESS (Debugging)**
+
+**Problem:** "Find a Trainer" map loads briefly then disappears, replaced by "Map Temporarily Unavailable". Directory filters working but map unstable.
+
+**Findings:**
+1. **Safari Blocker Bug:** Identified and fixed `HVAC_Find_Trainer_Assets` correctly blocking assets on Safari.
+2. **Safety Script Race Condition:** `mapgeo-safety.js` has a hard 6-second timeout loop that conflicts with slower resource loading.
+3. **Stale Cache Issue:** Browser continues to serve old `mapgeo-safety.js` (6s timeout) despite file update (30s timeout) and version bump (`.fix1`).
+4. **Environment Constraints:** `wp cache flush` failed (command not found), indicating potential restriction on direct WP-CLI use.
+
+**Fixes Applied (Pending Verification):**
+1. ✅ **Removed Safari Blocker:** Corrected logic in `class-hvac-find-trainer-assets.php`.
+2. ✅ **Increased Timeouts:** Updated `assets/js/mapgeo-safety.js` to 30s global timeout.
+3. ✅ **Version Bump:** Added `.fix1` suffix to enqueue version in `class-hvac-mapgeo-safety.php`.
+
+**Next Steps:**
+- Resolve caching issue to ensure updated `mapgeo-safety.js` is served.
+- Verify map stability with 30s timeout.
+
+---
+
## 📋 PREVIOUS SESSION - SCHEDULED ZOHO SYNC (Dec 19, 2025)
### Status: ✅ **COMPLETE - WP-Cron Scheduled Sync Implemented**
diff --git a/assets/js/mapgeo-safety.js b/assets/js/mapgeo-safety.js
index c6e6e3d6..6760c303 100644
--- a/assets/js/mapgeo-safety.js
+++ b/assets/js/mapgeo-safety.js
@@ -7,23 +7,23 @@
* @since 2.0.0
*/
-(function() {
+(function () {
'use strict';
-
+
// Safety configuration
const config = window.HVAC_MapGeo_Config || {
maxRetries: 3,
retryDelay: 2000,
- timeout: 10000,
+ timeout: 30000, // Increased to 30s
fallbackEnabled: true,
debugMode: false
};
-
- const log = config.debugMode ? console.log.bind(console) : () => {};
- const error = config.debugMode ? console.error.bind(console) : () => {};
-
+
+ const log = config.debugMode ? console.log.bind(console) : () => { };
+ const error = config.debugMode ? console.error.bind(console) : () => { };
+
log('[MapGeo Safety] Initializing protection system');
-
+
/**
* Resource Load Monitor
* Tracks and manages external script loading
@@ -39,45 +39,45 @@
];
this.setupMonitoring();
}
-
+
setupMonitoring() {
// Monitor script loading
const originalAppendChild = Element.prototype.appendChild;
const self = this;
-
- Element.prototype.appendChild = function(element) {
+
+ Element.prototype.appendChild = function (element) {
if (element.tagName === 'SCRIPT' && element.src) {
self.monitorScript(element);
}
return originalAppendChild.call(this, element);
};
-
+
// Monitor existing scripts
document.querySelectorAll('script[src]').forEach(script => {
this.monitorScript(script);
});
}
-
+
monitorScript(script) {
const src = script.src;
- const isCritical = this.criticalResources.some(resource =>
+ const isCritical = this.criticalResources.some(resource =>
src.toLowerCase().includes(resource)
);
-
+
if (isCritical) {
log('[MapGeo Safety] Monitoring critical resource:', src);
-
+
const timeoutId = setTimeout(() => {
error('[MapGeo Safety] Resource timeout:', src);
this.handleResourceFailure(src);
}, config.timeout);
-
+
script.addEventListener('load', () => {
clearTimeout(timeoutId);
log('[MapGeo Safety] Resource loaded:', src);
this.resources.set(src, 'loaded');
});
-
+
script.addEventListener('error', () => {
clearTimeout(timeoutId);
error('[MapGeo Safety] Resource failed:', src);
@@ -85,40 +85,40 @@
});
}
}
-
+
handleResourceFailure(src) {
this.resources.set(src, 'failed');
-
+
// Check if this is a MapGeo CDN resource
if (src.includes('cdn') || src.includes('amcharts')) {
log('[MapGeo Safety] CDN resource failed, activating fallback');
this.activateFallback();
}
}
-
+
activateFallback() {
// Hide map container
const mapContainers = document.querySelectorAll(
'.igm-map-container, [class*="mapgeo"], [id*="map-"], .map-widget-container'
);
-
+
mapContainers.forEach(container => {
container.style.display = 'none';
});
-
+
// Show fallback content
const fallback = document.getElementById('hvac-map-fallback');
if (fallback) {
fallback.style.display = 'block';
}
-
+
// Dispatch custom event
window.dispatchEvent(new CustomEvent('hvac:mapgeo:fallback', {
detail: { reason: 'resource_failure' }
}));
}
}
-
+
/**
* MapGeo API Wrapper
* Safely wraps MapGeo API calls
@@ -127,7 +127,7 @@
constructor() {
this.wrapAPIs();
}
-
+
wrapAPIs() {
// Wrap potential MapGeo global functions
const mapGeoAPIs = [
@@ -136,18 +136,18 @@
'IGM',
'mapWidget'
];
-
+
mapGeoAPIs.forEach(api => {
if (typeof window[api] !== 'undefined') {
this.wrapAPI(api);
}
-
+
// Set up getter to wrap when loaded
Object.defineProperty(window, `_original_${api}`, {
value: window[api],
writable: true
});
-
+
Object.defineProperty(window, api, {
get() {
return window[`_wrapped_${api}`] || window[`_original_${api}`];
@@ -176,10 +176,10 @@
});
});
}
-
+
wrapAPI(apiName) {
const original = window[apiName];
-
+
window[apiName] = new Proxy(original, {
construct(target, args) {
try {
@@ -202,7 +202,7 @@
});
}
}
-
+
/**
* DOM Ready Safety
* Ensures MapGeo only runs when DOM is safe
@@ -211,20 +211,20 @@
constructor() {
this.setupSafety();
}
-
+
setupSafety() {
// Intercept jQuery ready calls that might contain MapGeo code
if (typeof jQuery !== 'undefined') {
const originalReady = jQuery.fn.ready;
-
- jQuery.fn.ready = function(callback) {
- const wrappedCallback = function() {
+
+ jQuery.fn.ready = function (callback) {
+ const wrappedCallback = function () {
try {
// Check if MapGeo elements exist before running
const hasMapElements = document.querySelector(
'.igm-map-container, [class*="mapgeo"], [id*="map-"]'
);
-
+
if (hasMapElements || !callback.toString().includes('map')) {
return callback.apply(this, arguments);
} else {
@@ -234,13 +234,13 @@
error('[MapGeo Safety] Error in ready callback:', e);
}
};
-
+
return originalReady.call(this, wrappedCallback);
};
}
}
}
-
+
/**
* CDN Health Checker
* Proactively checks AmCharts CDN availability before MapGeo initialization
@@ -252,49 +252,49 @@
'https://cdn.amcharts.com/lib/version/4.10.29/maps.js',
'https://cdn.amcharts.com/lib/4/geodata/usaLow.js'
];
- this.timeout = 5000; // 5 second timeout
+ this.timeout = 15000; // Increased to 15s
this.cacheKey = 'hvac_cdn_health';
this.cacheExpiry = 10 * 60 * 1000; // 10 minutes
}
-
+
async checkCDNHealth() {
log('[MapGeo Safety] Checking AmCharts CDN health...');
-
+
// Check cached result first
const cached = this.getCachedResult();
if (cached !== null) {
log('[MapGeo Safety] Using cached CDN status:', cached);
return cached;
}
-
+
// Test primary CDN endpoints
const results = await Promise.allSettled(
this.criticalCDNs.map(url => this.testCDNEndpoint(url))
);
-
+
// Consider CDN healthy if at least 2 out of 3 endpoints work
const successCount = results.filter(r => r.status === 'fulfilled' && r.value).length;
const isHealthy = successCount >= 2;
-
+
log(`[MapGeo Safety] CDN health check: ${successCount}/${this.criticalCDNs.length} endpoints available`);
-
+
// Cache result
this.setCachedResult(isHealthy);
-
+
return isHealthy;
}
-
+
async testCDNEndpoint(url) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
-
+
const response = await fetch(url, {
method: 'HEAD',
mode: 'no-cors', // Allow cross-origin requests
signal: controller.signal
});
-
+
clearTimeout(timeoutId);
return true; // If we get here, endpoint is reachable
} catch (e) {
@@ -302,7 +302,7 @@
return false;
}
}
-
+
getCachedResult() {
try {
const cached = sessionStorage.getItem(this.cacheKey);
@@ -317,7 +317,7 @@
}
return null;
}
-
+
setCachedResult(healthy) {
try {
const data = {
@@ -330,7 +330,7 @@
}
}
}
-
+
/**
* Initialize all safety systems with proactive CDN checking
*/
@@ -340,52 +340,52 @@
log('[MapGeo Safety] No map elements detected, skipping initialization');
return;
}
-
+
// CRITICAL: Check CDN health before allowing MapGeo to initialize
const cdnChecker = new CDNHealthChecker();
const cdnHealthy = await cdnChecker.checkCDNHealth();
-
+
if (!cdnHealthy) {
error('[MapGeo Safety] AmCharts CDN unavailable - activating immediate fallback');
-
+
// Show fallback state
UIManager.showFallbackState();
-
+
// Dispatch event to notify other systems
window.dispatchEvent(new CustomEvent('hvac:mapgeo:cdn_unavailable', {
detail: { reason: 'amcharts_cdn_timeout' }
}));
-
+
log('[MapGeo Safety] Immediate fallback activated due to CDN unavailability');
return;
}
-
+
log('[MapGeo Safety] AmCharts CDN healthy - proceeding with MapGeo initialization');
-
+
// Show map state since CDN is healthy
UIManager.showMapState();
-
+
// Initialize monitors
new ResourceLoadMonitor();
new MapGeoAPIWrapper();
new DOMReadySafety();
-
+
// Set up periodic health check with shorter timeout now that we pre-checked CDN
let healthCheckCount = 0;
const healthCheckInterval = setInterval(() => {
healthCheckCount++;
-
+
// Check if map loaded successfully
const mapLoaded = document.querySelector('.igm-map-loaded, .mapgeo-loaded, .map-initialized');
-
+
if (mapLoaded) {
log('[MapGeo Safety] Map loaded successfully');
clearInterval(healthCheckInterval);
- } else if (healthCheckCount >= 6) {
- // Reduced to 6 seconds since we already verified CDN
- error('[MapGeo Safety] Map failed to load after 6 seconds (CDN was healthy)');
+ } else if (healthCheckCount >= 30) {
+ // Increased to 30 seconds to match global timeout
+ error('[MapGeo Safety] Map failed to load after 30 seconds');
clearInterval(healthCheckInterval);
-
+
// Activate fallback if configured
if (config.fallbackEnabled) {
const monitor = new ResourceLoadMonitor();
@@ -393,10 +393,10 @@
}
}
}, 1000);
-
+
log('[MapGeo Safety] All safety systems initialized with CDN pre-check');
}
-
+
/**
* Enhanced UI Management for CDN fallbacks
*/
@@ -405,55 +405,55 @@
const loading = document.getElementById('hvac-map-loading');
const fallback = document.getElementById('hvac-map-fallback');
const mapWrapper = document.querySelector('.hvac-mapgeo-wrapper');
-
+
if (loading) loading.style.display = 'block';
if (fallback) fallback.style.display = 'none';
if (mapWrapper) mapWrapper.style.display = 'none';
-
+
log('[MapGeo Safety] Loading state activated');
}
-
+
static showFallbackState() {
const loading = document.getElementById('hvac-map-loading');
const fallback = document.getElementById('hvac-map-fallback');
const mapWrapper = document.querySelector('.hvac-mapgeo-wrapper');
-
+
if (loading) loading.style.display = 'none';
if (fallback) fallback.style.display = 'block';
if (mapWrapper) mapWrapper.style.display = 'none';
-
+
log('[MapGeo Safety] Fallback state activated');
}
-
+
static showMapState() {
const loading = document.getElementById('hvac-map-loading');
const fallback = document.getElementById('hvac-map-fallback');
const mapWrapper = document.querySelector('.hvac-mapgeo-wrapper');
-
+
if (loading) loading.style.display = 'none';
if (fallback) fallback.style.display = 'none';
if (mapWrapper) mapWrapper.style.display = 'block';
-
+
log('[MapGeo Safety] Map state activated');
}
-
+
static setupRetryButton() {
const retryButton = document.querySelector('.hvac-retry-map');
if (retryButton) {
retryButton.addEventListener('click', async () => {
retryButton.disabled = true;
retryButton.textContent = 'Checking...';
-
+
try {
UIManager.showLoadingState();
-
+
// Clear CDN health cache
const cdnChecker = new CDNHealthChecker();
sessionStorage.removeItem(cdnChecker.cacheKey);
-
+
// Re-check CDN health
const isHealthy = await cdnChecker.checkCDNHealth();
-
+
if (isHealthy) {
log('[MapGeo Safety] CDN healthy on retry - reloading page');
window.location.reload();
@@ -475,7 +475,7 @@
}
}
}
-
+
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
@@ -489,7 +489,7 @@
UIManager.setupRetryButton();
initializeSafetySystems();
}
-
+
// Enhanced safety API for debugging and manual control
window.HVACMapGeoSafety = {
config: config,
@@ -509,5 +509,5 @@
log('[MapGeo Safety] CDN cache cleared');
}
};
-
+
})();
\ No newline at end of file
diff --git a/assets/js/zoho-admin.js b/assets/js/zoho-admin.js
index 05082f0f..c8c25707 100644
--- a/assets/js/zoho-admin.js
+++ b/assets/js/zoho-admin.js
@@ -3,12 +3,12 @@
*
* @package HVACCommunityEvents
*/
-jQuery(document).ready(function($) {
+jQuery(document).ready(function ($) {
// =====================================================
// Password visibility toggle
// =====================================================
- $('#toggle-secret').on('click', function() {
+ $('#toggle-secret').on('click', function () {
var passwordField = $('#zoho_client_secret');
var toggleBtn = $(this);
@@ -24,19 +24,19 @@ jQuery(document).ready(function($) {
// =====================================================
// Copy redirect URI to clipboard
// =====================================================
- $('#copy-redirect-uri').on('click', function() {
+ $('#copy-redirect-uri').on('click', function () {
var redirectUri = hvacZoho.redirectUri || '';
if (!redirectUri) {
alert('Redirect URI not available');
return;
}
- navigator.clipboard.writeText(redirectUri).then(function() {
+ navigator.clipboard.writeText(redirectUri).then(function () {
$('#copy-redirect-uri').text('Copied!').prop('disabled', true);
- setTimeout(function() {
+ setTimeout(function () {
$('#copy-redirect-uri').text('Copy').prop('disabled', false);
}, 2000);
- }).catch(function() {
+ }).catch(function () {
// Fallback for older browsers
var tempInput = $(' ');
$('body').append(tempInput);
@@ -44,7 +44,7 @@ jQuery(document).ready(function($) {
document.execCommand('copy');
tempInput.remove();
$('#copy-redirect-uri').text('Copied!').prop('disabled', true);
- setTimeout(function() {
+ setTimeout(function () {
$('#copy-redirect-uri').text('Copy').prop('disabled', false);
}, 2000);
});
@@ -53,21 +53,21 @@ jQuery(document).ready(function($) {
// =====================================================
// Flush rewrite rules
// =====================================================
- $('#flush-rewrite-rules').on('click', function() {
+ $('#flush-rewrite-rules').on('click', function () {
var button = $(this);
button.prop('disabled', true).text('Flushing...');
$.post(hvacZoho.ajaxUrl, {
action: 'hvac_zoho_flush_rewrite_rules'
- }, function(response) {
+ }, function (response) {
if (response.success) {
button.text('Flushed!').css('color', '#46b450');
- setTimeout(function() {
+ setTimeout(function () {
location.reload();
}, 1000);
} else {
button.text('Error').css('color', '#dc3232');
- setTimeout(function() {
+ setTimeout(function () {
button.prop('disabled', false).text('Flush Rules').css('color', '');
}, 2000);
}
@@ -77,7 +77,10 @@ jQuery(document).ready(function($) {
// =====================================================
// Credentials form submission
// =====================================================
- $('#zoho-credentials-form').on('submit', function(e) {
+ // =====================================================
+ // Credentials form submission
+ // =====================================================
+ $('#zoho-credentials-form').on('submit', function (e) {
e.preventDefault();
var formData = {
@@ -87,14 +90,73 @@ jQuery(document).ready(function($) {
nonce: $('input[name="hvac_zoho_nonce"]').val()
};
- $('#save-credentials').prop('disabled', true).text('Saving...');
+ var $saveBtn = $('#save-credentials');
+ $saveBtn.prop('disabled', true).text('Saving...');
- $.post(hvacZoho.ajaxUrl, formData, function(response) {
- if (response.success) {
- window.location.href = window.location.href.split('?')[0] + '?page=hvac-zoho-sync&credentials_saved=1';
- } else {
- alert('Error saving credentials: ' + response.data.message);
- $('#save-credentials').prop('disabled', false).text('Save Credentials');
+ // Clear any previous messages
+ $('.notice').remove();
+
+ $.ajax({
+ url: hvacZoho.ajaxUrl,
+ method: 'POST',
+ data: formData,
+ success: function (response) {
+ if (response.success) {
+ window.location.href = window.location.href.split('?')[0] + '?page=hvac-zoho-sync&credentials_saved=1';
+ } else {
+ // Create error notice
+ var errorHtml = '
' +
+ 'Error saving credentials: ' + response.data.message +
+ '
';
+ $('h1').after(errorHtml);
+
+ $saveBtn.prop('disabled', false).text('Save Credentials');
+ }
+ },
+ error: function (xhr, status, error) {
+ console.error('Zoho Save Error:', xhr);
+
+ var errorMessage = 'Unknown error occurred';
+ var errorDetails = '';
+
+ if (xhr.status === 400 || xhr.status === 403) {
+ errorMessage = 'Request blocked (' + xhr.status + ')';
+ errorDetails = 'This is likely due to a security plugin or Web Application Firewall (WAF) blocking the request. ' +
+ 'The content (e.g. Client Secret) might be triggering a false positive security rule.';
+ } else if (xhr.status === 500) {
+ errorMessage = 'Server Error (500)';
+ errorDetails = 'Check the server error logs for more information.';
+ } else if (status === 'timeout') {
+ errorMessage = 'Request timed out';
+ } else if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
+ errorMessage = xhr.responseJSON.data.message;
+ } else {
+ errorMessage = error || status;
+ }
+
+ var errorHtml = '' +
+ '
❌ Save Failed: ' + errorMessage + '
';
+
+ if (errorDetails) {
+ errorHtml += '
' + errorDetails + '
';
+ }
+
+ // Add debug details
+ errorHtml += '
' +
+ 'Technical Details ' +
+ '' +
+ 'Status: ' + xhr.status + ' ' + xhr.statusText + ' ' +
+ 'Response: ' + (xhr.responseText ? xhr.responseText.substring(0, 100) + '...' : '(empty)') + ' ' +
+ ' ' +
+ ' ';
+
+ $('h1').after(errorHtml);
+
+ // Re-enable button
+ $saveBtn.prop('disabled', false).text('Save Credentials');
+
+ // Scroll to error
+ $('html, body').animate({ scrollTop: 0 }, 'slow');
}
});
});
@@ -102,7 +164,7 @@ jQuery(document).ready(function($) {
// =====================================================
// OAuth authorization handler
// =====================================================
- $('#start-oauth').on('click', function() {
+ $('#start-oauth').on('click', function () {
var clientId = $('#zoho_client_id').val();
var clientSecret = $('#zoho_client_secret').val();
@@ -126,13 +188,13 @@ jQuery(document).ready(function($) {
// =====================================================
// Test connection
// =====================================================
- $('#test-connection').on('click', function() {
+ $('#test-connection').on('click', function () {
var $button = $(this);
var $status = $('#connection-status');
-
+
$button.prop('disabled', true).text('Testing...');
$status.html('');
-
+
$.ajax({
url: hvacZoho.ajaxUrl,
method: 'POST',
@@ -140,11 +202,11 @@ jQuery(document).ready(function($) {
action: 'hvac_zoho_test_connection',
nonce: hvacZoho.nonce
},
- success: function(response) {
+ success: function (response) {
if (response.success) {
var successHtml = '';
successHtml += '
' + response.data.message + '
';
-
+
// Show credential details
if (response.data.client_id) {
successHtml += '
Client ID: ' + response.data.client_id + '
';
@@ -157,7 +219,7 @@ jQuery(document).ready(function($) {
} else {
successHtml += '
Refresh Token: ❌ Missing (OAuth required)
';
}
-
+
// Show debug info if available
if (response.data.debug) {
successHtml += '
';
@@ -166,7 +228,7 @@ jQuery(document).ready(function($) {
successHtml += JSON.stringify(response.data.debug, null, 2);
successHtml += ' ';
}
-
+
successHtml += '
';
$status.html(successHtml);
} else {
@@ -174,23 +236,23 @@ jQuery(document).ready(function($) {
console.log('Error response:', response);
console.log('Message:', response.data.message);
console.log('Has auth_url:', !!response.data.auth_url);
-
+
// Handle OAuth authorization case specially
if (response.data.message === 'OAuth Authorization Required' && response.data.auth_url) {
var authHtml = '';
authHtml += '
🔐 OAuth Authorization Required ';
authHtml += '
' + response.data.details + '
';
-
+
if (response.data.next_steps) {
authHtml += '
';
- response.data.next_steps.forEach(function(step) {
+ response.data.next_steps.forEach(function (step) {
authHtml += '' + step + ' ';
});
authHtml += ' ';
}
-
+
authHtml += '
🚀 Authorize with Zoho CRM
';
-
+
// Show credential status
if (response.data.credentials_status) {
authHtml += '
Current Status:
';
@@ -200,7 +262,7 @@ jQuery(document).ready(function($) {
authHtml += '
Refresh Token: ' + (response.data.credentials_status.refresh_token_exists ? '✓ Found' : '❌ Missing') + ' ';
authHtml += '';
}
-
+
authHtml += '
After authorization, come back and test the connection again.
';
authHtml += '
';
$status.html(authHtml);
@@ -210,141 +272,260 @@ jQuery(document).ready(function($) {
console.log('Message matches:', response.data.message === 'OAuth Authorization Required');
console.log('Auth URL exists:', !!response.data.auth_url);
}
-
+
// Create detailed error display for other errors
var errorHtml = '';
errorHtml += '
' + response.data.message + ': ' + response.data.error + '
';
-
+
// Add error code if available
if (response.data.code) {
errorHtml += '
Error Code: ' + response.data.code + '
';
}
-
+
// Add details if available
if (response.data.details) {
errorHtml += '
Details: ' + response.data.details + '
';
}
-
+
// Add debugging info
errorHtml += '
';
errorHtml += '
Debug Information:
';
errorHtml += '
Check the PHP error log for more details.
';
errorHtml += '
Log location: wp-content/plugins/hvac-community-events/logs/zoho-debug.log
';
-
+
// Add raw response data if available
if (response.data.raw) {
errorHtml += '
';
errorHtml += 'Raw Response Data (click to expand) ';
- errorHtml += '' +
- JSON.stringify(JSON.parse(response.data.raw), null, 2) +
- ' ';
+ errorHtml += '' +
+ JSON.stringify(JSON.parse(response.data.raw), null, 2) +
+ ' ';
errorHtml += ' ';
}
-
+
// Add file/line info if available (for exceptions)
if (response.data.file) {
errorHtml += '
File: ' + response.data.file + '
';
}
-
+
// Add trace if available
if (response.data.trace) {
errorHtml += '
';
errorHtml += 'Stack Trace (click to expand) ';
- errorHtml += '' +
- response.data.trace +
- ' ';
+ errorHtml += '' +
+ response.data.trace +
+ ' ';
errorHtml += ' ';
}
-
+
errorHtml += '
'; // Close debug info
errorHtml += '
'; // Close notice
-
+
$status.html(errorHtml);
}
},
- error: function(xhr, status, error) {
+ error: function (xhr, status, error) {
var errorHtml = '';
errorHtml += '
AJAX Error: Connection test failed
';
errorHtml += '
Status: ' + status + '
';
errorHtml += '
Error: ' + error + '
';
errorHtml += '
';
-
+
$status.html(errorHtml);
},
- complete: function() {
+ complete: function () {
$button.prop('disabled', false).text('Test Connection');
}
});
});
-
- // Sync data
- $('.sync-button').on('click', function() {
- var $button = $(this);
- var type = $button.data('type');
- var $status = $('#' + type + '-status');
-
- $button.prop('disabled', true).text('Syncing...');
- $status.html('Syncing ' + type + '...
');
-
+
+ // =====================================================
+ // Sync data with batch progress
+ // =====================================================
+
+ /**
+ * Sync with progress - auto-continues through all batches
+ * @param {jQuery} $button - The sync button
+ * @param {string} type - Sync type (events, users, attendees, rsvps, purchases)
+ * @param {jQuery} $status - Status container element
+ * @param {number} offset - Current offset
+ * @param {object} accumulated - Accumulated results across batches
+ */
+ function syncWithProgress($button, type, $status, offset, accumulated) {
+ accumulated = accumulated || {
+ synced: 0,
+ failed: 0,
+ errors: [],
+ total: 0,
+ staging_mode: false,
+ responses: [],
+ test_data: []
+ };
+
$.ajax({
url: hvacZoho.ajaxUrl,
method: 'POST',
data: {
action: 'hvac_zoho_sync_data',
type: type,
+ offset: offset,
nonce: hvacZoho.nonce
},
- success: function(response) {
+ success: function (response) {
if (response.success) {
var result = response.data;
- var html = '';
-
- if (result.staging_mode) {
- html += '
🔧 STAGING MODE - Simulation Results ';
- html += '
' + result.message + '
';
- } else {
- html += '
Sync completed successfully!
';
+
+ // Update accumulated totals
+ accumulated.total = result.total; // Total is consistent across batches
+ accumulated.synced += result.synced;
+ accumulated.failed += result.failed;
+ accumulated.staging_mode = result.staging_mode;
+
+ // Merge arrays
+ if (result.errors && result.errors.length > 0) {
+ accumulated.errors = accumulated.errors.concat(result.errors);
+ }
+ if (result.responses && result.responses.length > 0) {
+ accumulated.responses = accumulated.responses.concat(result.responses);
}
-
- html += '
' +
- 'Total records: ' + result.total + ' ' +
- 'Synced: ' + result.synced + ' ' +
- 'Failed: ' + result.failed + ' ' +
- ' ';
-
if (result.test_data && result.test_data.length > 0) {
- html += '
' +
- 'View test data (first 5 records) ' +
- '' +
- JSON.stringify(result.test_data.slice(0, 5), null, 2) +
- ' ' +
- ' ';
+ accumulated.test_data = accumulated.test_data.concat(result.test_data);
+ }
+
+ // Calculate progress
+ var processed = accumulated.synced + accumulated.failed;
+ var percent = accumulated.total > 0 ? Math.round((processed / accumulated.total) * 100) : 0;
+
+ // Update progress bar
+ var progressHtml = '
' +
+ '
' +
+ '
' +
+ '' + processed + ' of ' + accumulated.total + ' processed (' + percent + '%)' +
+ '
';
+ $status.html(progressHtml);
+
+ // Check if there are more batches
+ if (result.has_more && result.next_offset > offset) {
+ // Continue with next batch
+ syncWithProgress($button, type, $status, result.next_offset, accumulated);
+ } else {
+ // All done! Show final results
+ displaySyncResults($button, type, $status, accumulated, result);
}
-
- html += '
';
- $status.html(html);
} else {
$status.html('' + response.data.message + ': ' + response.data.error + '
');
+ $button.prop('disabled', false).text('Sync ' + type.charAt(0).toUpperCase() + type.slice(1));
}
},
- error: function() {
- $status.html('');
- },
- complete: function() {
+ error: function () {
+ $status.html('Sync failed - network or server error
');
$button.prop('disabled', false).text('Sync ' + type.charAt(0).toUpperCase() + type.slice(1));
}
});
+ }
+
+ /**
+ * Display final sync results
+ */
+ function displaySyncResults($button, type, $status, accumulated, lastResult) {
+ var html = '';
+
+ if (accumulated.staging_mode) {
+ html += '
🔧 STAGING MODE - Simulation Complete ';
+ html += '
No data was sent to Zoho CRM. This is a dry-run showing what would sync.
';
+ } else {
+ html += '
✅ Sync completed successfully!
';
+ }
+
+ if (lastResult.version) {
+ html += '
Server Code Version: ' + lastResult.version + '
';
+ }
+
+ html += '
' +
+ 'Total records: ' + accumulated.total + ' ' +
+ 'Synced: ' + accumulated.synced + ' ' +
+ 'Failed: ' + accumulated.failed + ' ' +
+ ' ';
+
+ // Show test data for staging
+ if (accumulated.test_data && accumulated.test_data.length > 0) {
+ html += '
' +
+ 'View test data (first 5 records) ' +
+ '' +
+ JSON.stringify(accumulated.test_data.slice(0, 5), null, 2) +
+ ' ' +
+ ' ';
+ }
+
+ // Debug info
+ if (lastResult.debug_info) {
+ html += '
' +
+ '🔍 Debug: Mode Detection Info ' +
+ '';
+
+ if (typeof lastResult.debug_info.is_staging !== 'undefined') {
+ html += '
Is Staging: ' + (lastResult.debug_info.is_staging ? '✅ YES' : '❌ NO') + '
';
+ }
+ if (lastResult.debug_info.site_url) {
+ html += '
Site URL: ' + lastResult.debug_info.site_url + '
';
+ }
+
+ html += '
' +
+ JSON.stringify(lastResult.debug_info, null, 2) +
+ ' ' +
+ '
' +
+ ' ';
+ }
+
+ // Show errors if any
+ if (accumulated.errors && accumulated.errors.length > 0) {
+ html += '
' +
+ '❌ Errors (' + accumulated.errors.length + ') ' +
+ '' +
+ JSON.stringify(accumulated.errors.slice(0, 20), null, 2) +
+ ' ' +
+ ' ';
+ }
+
+ // Show API responses preview
+ if (accumulated.responses && accumulated.responses.length > 0) {
+ html += '
' +
+ '📡 Raw API Responses (first 10) ' +
+ '' +
+ JSON.stringify(accumulated.responses.slice(0, 10), null, 2) +
+ ' ' +
+ ' ';
+ }
+
+ html += '
';
+ $status.html(html);
+ $button.prop('disabled', false).text('Sync ' + type.charAt(0).toUpperCase() + type.slice(1));
+ }
+
+ // Sync button click handler
+ $('.sync-button').on('click', function () {
+ var $button = $(this);
+ var type = $button.data('type');
+ var $status = $('#' + type + '-status');
+
+ $button.prop('disabled', true).text('Syncing...');
+ $status.html('Starting sync for ' + type + '...
');
+
+ // Start sync with batch progress
+ syncWithProgress($button, type, $status, 0, null);
});
-
+
// Save settings
- $('#zoho-settings-form').on('submit', function(e) {
+ $('#zoho-settings-form').on('submit', function (e) {
e.preventDefault();
-
+
var $form = $(this);
var $button = $form.find('button[type="submit"]');
-
+
$button.prop('disabled', true).text('Saving...');
-
+
$.ajax({
url: hvacZoho.ajaxUrl,
method: 'POST',
@@ -354,14 +535,10 @@ jQuery(document).ready(function($) {
auto_sync: $form.find('input[name="auto_sync"]').is(':checked') ? '1' : '0',
sync_frequency: $form.find('select[name="sync_frequency"]').val()
},
- success: function(response) {
+ success: function (response) {
if (response.success) {
- // Use toast notification instead of alert
- if (window.HVACToast) {
- HVACToast.success('Settings saved successfully!');
- } else {
- alert('Settings saved successfully!');
- }
+ // Reload page to show updated status
+ window.location.reload();
} else {
// Use toast notification instead of alert
if (window.HVACToast) {
@@ -369,19 +546,161 @@ jQuery(document).ready(function($) {
} else {
alert('Error saving settings: ' + response.data.message);
}
+ $button.prop('disabled', false).text('Save Settings');
}
},
- error: function() {
+ error: function () {
// Use toast notification instead of alert
if (window.HVACToast) {
HVACToast.error('Error saving settings');
} else {
alert('Error saving settings');
}
- },
- complete: function() {
$button.prop('disabled', false).text('Save Settings');
}
});
});
+
+ // =====================================================
+ // Run Scheduled Sync Now Handler
+ // =====================================================
+ $('#run-scheduled-sync-now').on('click', function () {
+ var $button = $(this);
+ var $status = $('#scheduled-sync-status');
+
+ $button.prop('disabled', true).text('Running...');
+ $status.html('Starting scheduled sync...
');
+
+ $.ajax({
+ url: hvacZoho.ajaxUrl,
+ method: 'POST',
+ data: {
+ action: 'hvac_zoho_run_scheduled_sync',
+ nonce: hvacZoho.nonce
+ },
+ success: function (response) {
+ if (response.success) {
+ var result = response.data.result;
+ var html = '';
+
+ if (result.events && result.events.staging_mode) {
+ html += '
🔧 STAGING MODE - Simulation Complete ';
+ html += '
No data was sent to Zoho CRM.
';
+ } else {
+ html += '
✅ Scheduled sync completed!
';
+ }
+
+ html += '
';
+ html += 'Total synced: ' + (result.total_synced || 0) + ' ';
+ html += 'Total failed: ' + (result.total_failed || 0) + ' ';
+ html += 'Duration: ' + (result.duration_seconds || 0) + ' seconds ';
+ html += ' ';
+
+ // Show details per type
+ html += '
Details by type ';
+ ['events', 'users', 'attendees', 'rsvps', 'purchases'].forEach(function (type) {
+ if (result[type]) {
+ html += '' + type + ': ' +
+ (result[type].synced || 0) + ' synced, ' +
+ (result[type].failed || 0) + ' failed ';
+ }
+ });
+ html += ' ';
+
+ html += '
';
+ $status.html(html);
+ } else {
+ $status.html('Sync failed: ' + response.data.message + '
');
+ }
+ },
+ error: function () {
+ $status.html('Error running scheduled sync
');
+ },
+ complete: function () {
+ $button.prop('disabled', false).text('🔄 Run Sync Now');
+ }
+ });
+ });
+
+ // =====================================================
+ // Diagnostic Test Handler
+ // =====================================================
+ $('#diagnostic-test').on('click', function () {
+ var $button = $(this);
+ $button.prop('disabled', true).text('Testing...');
+
+ // Remove existing notices
+ $('.notice').remove();
+
+ // Test 1: Simple GET
+ var runSimpleTest = function () {
+ return $.ajax({
+ url: hvacZoho.ajaxUrl,
+ method: 'POST',
+ data: {
+ action: 'hvac_zoho_simple_test'
+ }
+ });
+ };
+
+ // Test 2: Payload Test (simulates credentials)
+ var runPayloadTest = function () {
+ var fakeId = '1000.' + new Array(20).join('a');
+ var fakeSecret = new Array(30).join('b');
+
+ return $.ajax({
+ url: hvacZoho.ajaxUrl,
+ method: 'POST',
+ data: {
+ action: 'hvac_zoho_simple_test',
+ test_payload: 'SIMULATED_CREDENTIALS',
+ zoho_client_id: fakeId,
+ zoho_client_secret: fakeSecret
+ }
+ });
+ };
+
+ // Execute tests sequence
+ runSimpleTest()
+ .then(function (response) {
+ if (response.success) {
+ console.log('Simple test passed');
+ return runPayloadTest();
+ } else {
+ return $.Deferred().reject({ status: 200, statusText: 'OK', responseJSON: response });
+ }
+ })
+ .then(function (response) {
+ if (response.success) {
+ // Success!
+ var successHtml = '' +
+ '
✅ Diagnostic Test Passed
' +
+ '
AJAX requests are working correctly. No WAF blocking detected for credential-like data.
' +
+ '
';
+ $('h1').after(successHtml);
+ } else {
+ return $.Deferred().reject({ status: 200, statusText: 'OK', responseJSON: response });
+ }
+ })
+ .fail(function (xhr) {
+ var errorHtml = '' +
+ '
❌ Diagnostic Test Failed
';
+
+ if (xhr.status === 400 || xhr.status === 403) {
+ errorHtml += '
WAF Blocking Detected!
';
+ errorHtml += '
The server returned ' + xhr.status + ' when sending data.
';
+ } else {
+ errorHtml += '
Status: ' + (xhr.status || 'Unknown') + '
';
+ if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
+ errorHtml += '
Message: ' + xhr.responseJSON.data.message + '
';
+ }
+ }
+
+ errorHtml += '
';
+ $('h1').after(errorHtml);
+ })
+ .always(function () {
+ $button.prop('disabled', false).text('🏥 Run Diagnostic Test');
+ });
+ });
});
\ No newline at end of file
diff --git a/includes/class-hvac-find-trainer-assets.php b/includes/class-hvac-find-trainer-assets.php
index 8cfdfd17..77b58ffe 100644
--- a/includes/class-hvac-find-trainer-assets.php
+++ b/includes/class-hvac-find-trainer-assets.php
@@ -54,19 +54,11 @@ class HVAC_Find_Trainer_Assets {
* Initialize WordPress hooks
*/
private function init_hooks() {
- // CRITICAL: Don't add asset loading hooks for Safari browsers
- // Let HVAC_Scripts_Styles handle Safari minimal loading
- if ($this->browser_detection->is_safari_browser()) {
- error_log('[HVAC Find Trainer Assets] Safari detected - skipping asset hooks to prevent resource cascade');
- // Only add footer scripts for MapGeo integration
- add_action('wp_footer', [$this, 'add_find_trainer_inline_scripts']);
- return;
- }
-
- // Use proper WordPress hook system for non-Safari browsers
+ // Use proper WordPress hook system
add_action('wp_enqueue_scripts', [$this, 'enqueue_find_trainer_assets']);
add_action('wp_footer', [$this, 'add_find_trainer_inline_scripts']);
}
+ }
/**
* Check if current page is find-a-trainer
diff --git a/includes/class-hvac-mapgeo-safety.php b/includes/class-hvac-mapgeo-safety.php
index f24d05da..9e54e145 100644
--- a/includes/class-hvac-mapgeo-safety.php
+++ b/includes/class-hvac-mapgeo-safety.php
@@ -66,7 +66,7 @@ class HVAC_MapGeo_Safety {
'hvac-mapgeo-safety',
HVAC_PLUGIN_URL . 'assets/js/mapgeo-safety.js',
array(),
- HVAC_PLUGIN_VERSION,
+ HVAC_PLUGIN_VERSION . '.fix1',
false // Load in head to catch errors early
);
diff --git a/includes/zoho/class-zoho-admin.php b/includes/zoho/class-zoho-admin.php
deleted file mode 100644
index 2f21675b..00000000
--- a/includes/zoho/class-zoho-admin.php
+++ /dev/null
@@ -1,211 +0,0 @@
-exchange_code_for_tokens($_GET['code'])) {
- add_settings_error(
- 'hvac_zoho_messages',
- 'hvac_zoho_auth_success',
- 'Successfully connected to Zoho CRM!',
- 'success'
- );
- } else {
- add_settings_error(
- 'hvac_zoho_messages',
- 'hvac_zoho_auth_error',
- 'Failed to connect to Zoho CRM. Please check your credentials.',
- 'error'
- );
- }
-
- // Redirect to remove code from URL
- wp_redirect(admin_url('edit.php?post_type=tribe_events&page=hvac-zoho-crm'));
- exit;
- }
- }
-
- /**
- * Display admin page
- */
- public function admin_page() {
- ?>
-
-
Zoho CRM Integration
-
-
-
-
-
-
Zoho CRM configuration file not found. Please follow the setup instructions below.
-
-
-
Setup Instructions
-
-
- Register your application in Zoho:
- Go to Zoho API Console
-
- Create a new Server-based Application
- Set redirect URI to:
- Copy your Client ID and Client Secret
- Run the setup helper script from command line:
- cd zoho
-php setup-helper.php
-
-
-
-
-
- make_api_request('/crm/v2/org');
- $connected = !is_wp_error($org_info) && isset($org_info['org']);
- ?>
-
-
-
-
✓ Connected to Zoho CRM
-
-
-
Organization Information
-
-
-
Integration Status
- display_integration_status(); ?>
-
-
Actions
-
- Test Sync
- Create Custom Fields
-
-
-
-
-
✗ Not connected to Zoho CRM
-
-
-
Reconnect to Zoho
-
Click the button below to authorize this application with Zoho CRM:
-
- Connect to Zoho CRM
-
-
-
-
-
-
-
-
-
- Module
- Fields Configured
- Last Sync
- Status
-
-
-
-
- Campaigns (Events)
- check_custom_fields('Campaigns'); ?>
-
-
-
-
- Contacts (Users)
- check_custom_fields('Contacts'); ?>
-
-
-
-
- Invoices (Orders)
- check_custom_fields('Invoices'); ?>
-
-
-
-
-
- Pending';
- }
-}
-
-// Initialize admin interface
-if (is_admin()) {
- new HVAC_Zoho_Admin();
-}
\ No newline at end of file
diff --git a/includes/zoho/class-zoho-crm-auth.php b/includes/zoho/class-zoho-crm-auth.php
index 81f5926b..f8fa0377 100644
--- a/includes/zoho/class-zoho-crm-auth.php
+++ b/includes/zoho/class-zoho-crm-auth.php
@@ -203,13 +203,83 @@ class HVAC_Zoho_CRM_Auth {
return false;
}
+ /**
+ * Make authenticated API request
+ */
+ /**
+ * Check if we are in staging mode
+ *
+ * @return bool True if in staging mode
+ */
+ public static function is_staging_mode() {
+ // 1. Allow forcing production mode via constant
+ if (defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE) {
+ return false;
+ }
+
+ // 2. Allow forcing staging mode via constant
+ if (defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE) {
+ return true;
+ }
+
+ $site_url = get_site_url();
+
+ // 3. Check for specific staging domains or keywords
+ if (strpos($site_url, 'staging') !== false ||
+ strpos($site_url, 'dev') !== false ||
+ strpos($site_url, 'test') !== false ||
+ strpos($site_url, 'cloudwaysapps.com') !== false) {
+ return true;
+ }
+
+ // 4. Default check: Production only on upskillhvac.com
+ return strpos($site_url, 'upskillhvac.com') === false;
+ }
+
+ /**
+ * Get details about how the mode was determined (for debugging)
+ *
+ * @return array Debug information
+ */
+ public static function get_debug_mode_info() {
+ $info = array(
+ 'site_url' => get_site_url(),
+ 'is_staging' => self::is_staging_mode(),
+ 'forced_production' => defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE,
+ 'forced_staging' => defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE,
+ 'detection_logic' => array()
+ );
+
+ // Replicate logic to show which rule matched
+ if ($info['forced_production']) {
+ $info['detection_logic'][] = 'Forced PRODUCTION via HVAC_ZOHO_PRODUCTION_MODE constant';
+ } elseif ($info['forced_staging']) {
+ $info['detection_logic'][] = 'Forced STAGING via HVAC_ZOHO_STAGING_MODE constant';
+ } else {
+ $site_url = $info['site_url'];
+ if (strpos($site_url, 'staging') !== false) $info['detection_logic'][] = 'Matched "staging" in URL';
+ if (strpos($site_url, 'dev') !== false) $info['detection_logic'][] = 'Matched "dev" in URL';
+ if (strpos($site_url, 'test') !== false) $info['detection_logic'][] = 'Matched "test" in URL';
+ if (strpos($site_url, 'cloudwaysapps.com') !== false) $info['detection_logic'][] = 'Matched "cloudwaysapps.com" in URL';
+
+ if (empty($info['detection_logic'])) {
+ if (strpos($site_url, 'upskillhvac.com') === false) {
+ $info['detection_logic'][] = 'Default STAGING: URL does not contain "upskillhvac.com"';
+ } else {
+ $info['detection_logic'][] = 'Default PRODUCTION: URL contains "upskillhvac.com"';
+ }
+ }
+ }
+
+ return $info;
+ }
+
/**
* Make authenticated API request
*/
public function make_api_request($endpoint, $method = 'GET', $data = null) {
// Check if we're in staging mode
- $site_url = get_site_url();
- $is_staging = strpos($site_url, 'upskillhvac.com') === false;
+ $is_staging = self::is_staging_mode();
// In staging mode, only allow read operations, no writes
if ($is_staging && in_array($method, array('POST', 'PUT', 'DELETE', 'PATCH'))) {
@@ -258,7 +328,8 @@ class HVAC_Zoho_CRM_Auth {
return new WP_Error('no_token', $error_message);
}
- $url = 'https://www.zohoapis.com/crm/v2' . $endpoint;
+ // Update to v6 API (v2 is deprecated)
+ $url = 'https://www.zohoapis.com/crm/v6' . $endpoint;
// Log the request details
$this->log_debug('Making ' . $method . ' request to: ' . $url);
@@ -327,7 +398,8 @@ class HVAC_Zoho_CRM_Auth {
return array(
'error' => $error_message,
'code' => 'JSON_PARSE_ERROR',
- 'details' => 'Raw response: ' . substr($body, 0, 255) . (strlen($body) > 255 ? '...' : '')
+ 'details' => 'Raw response: ' . substr($body, 0, 500),
+ 'request_payload' => isset($args['body']) ? $args['body'] : 'No body'
);
}
@@ -344,6 +416,7 @@ class HVAC_Zoho_CRM_Auth {
// Add HTTP error information to the response
$data['http_status'] = $status_code;
$data['error'] = $error_message;
+ $data['request_payload'] = isset($args['body']) ? $args['body'] : 'No body';
// Extract more detailed error information if available
if (isset($data['code'])) {
@@ -406,7 +479,7 @@ class HVAC_Zoho_CRM_Auth {
/**
* Log debug messages
*/
- private function log_debug($message) {
+ public function log_debug($message) {
// Sanitize message to remove sensitive data
$sanitized = $this->sanitize_log_message($message);
diff --git a/includes/zoho/class-zoho-sync.php b/includes/zoho/class-zoho-sync.php
index 9766d338..e714bf81 100644
--- a/includes/zoho/class-zoho-sync.php
+++ b/includes/zoho/class-zoho-sync.php
@@ -28,6 +28,13 @@ class HVAC_Zoho_Sync {
*/
private $is_staging;
+ /**
+ * Last contact creation error for debugging
+ *
+ * @var string
+ */
+ private $last_contact_error = '';
+
/**
* Constructor
*/
@@ -36,8 +43,7 @@ class HVAC_Zoho_Sync {
$this->auth = new HVAC_Zoho_CRM_Auth();
// Determine if we're in staging mode
- $site_url = get_site_url();
- $this->is_staging = strpos($site_url, 'upskillhvac.com') === false;
+ $this->is_staging = HVAC_Zoho_CRM_Auth::is_staging_mode();
}
/**
@@ -46,33 +52,88 @@ class HVAC_Zoho_Sync {
* @return bool
*/
private function is_sync_allowed() {
- // Only allow sync on production (upskillhvac.com)
- $site_url = get_site_url();
- return strpos($site_url, 'upskillhvac.com') !== false;
+ // Use consistent logic from Auth class
+ return !HVAC_Zoho_CRM_Auth::is_staging_mode();
}
/**
* Sync events to Zoho Campaigns
*
+ * @param int $offset Starting offset for pagination
+ * @param int $limit Number of records per batch
+ * @param int|null $since_timestamp Optional timestamp to filter modified records
* @return array Sync results
*/
- public function sync_events() {
+ public function sync_events($offset = 0, $limit = 50, $since_timestamp = null) {
$results = array(
'total' => 0,
'synced' => 0,
'failed' => 0,
'errors' => array(),
- 'staging_mode' => $this->is_staging
+ 'staging_mode' => $this->is_staging,
+ 'debug_info' => HVAC_Zoho_CRM_Auth::get_debug_mode_info(),
+ 'has_more' => false,
+ 'next_offset' => 0,
+ 'batch_offset' => $offset,
+ 'incremental_sync' => !is_null($since_timestamp)
);
- // Get all published events (past and future - 'custom' bypasses date filtering)
- $events = tribe_get_events(array(
+ // Build base query args
+ $base_event_args = array(
'posts_per_page' => -1,
'eventDisplay' => 'custom',
- 'start_date' => '2020-01-01', // Include all historical events
- ));
+ 'start_date' => '2020-01-01',
+ );
- $results['total'] = count($events);
+ $base_series_args = array(
+ 'post_type' => 'tribe_event_series',
+ 'posts_per_page' => -1,
+ 'post_status' => 'publish',
+ );
+
+ // Add date filter for incremental sync
+ if ($since_timestamp) {
+ $since_date = date('Y-m-d H:i:s', $since_timestamp);
+ $date_query = array(
+ array(
+ 'column' => 'post_modified',
+ 'after' => $since_date,
+ 'inclusive' => true,
+ )
+ );
+ $base_event_args['date_query'] = $date_query;
+ $base_series_args['date_query'] = $date_query;
+ }
+
+ // First, get total count
+ $all_events = tribe_get_events($base_event_args);
+ $event_series = get_posts($base_series_args);
+ $total_count = count($all_events) + count($event_series);
+ $results['total'] = $total_count;
+
+ // Get paginated events
+ $paginated_args = array_merge($base_event_args, array(
+ 'posts_per_page' => $limit,
+ 'offset' => $offset,
+ ));
+ $events = tribe_get_events($paginated_args);
+
+ // Also get event series (paginated based on remaining limit)
+ $remaining = max(0, $limit - count($events));
+ $series_offset = max(0, $offset - count($all_events));
+ if ($remaining > 0 && $series_offset >= 0) {
+ $event_series_batch = get_posts(array(
+ 'post_type' => 'tribe_event_series',
+ 'posts_per_page' => $remaining,
+ 'offset' => $series_offset,
+ 'post_status' => 'publish',
+ ));
+ $events = array_merge($events, $event_series_batch);
+ }
+
+ // Calculate pagination
+ $results['has_more'] = ($offset + count($events)) < $total_count;
+ $results['next_offset'] = $offset + count($events);
// If staging mode, simulate the sync
if ($this->is_staging) {
@@ -101,29 +162,49 @@ class HVAC_Zoho_Sync {
foreach ($events as $event) {
try {
$campaign_data = $this->prepare_campaign_data($event);
+ $campaign_id = null;
- // Check if campaign already exists in Zoho
- $search_response = $this->auth->make_api_request('GET', '/crm/v2/Campaigns/search', array(
- 'criteria' => "(Campaign_Name:equals:{$campaign_data['Campaign_Name']})"
- ));
+ // FIRST: Check if we already have a stored Zoho Campaign ID
+ $stored_campaign_id = get_post_meta($event->ID, '_zoho_campaign_id', true);
- if (!empty($search_response['data'])) {
- // Update existing campaign
- $campaign_id = $search_response['data'][0]['id'];
- $update_response = $this->auth->make_api_request('PUT', "/crm/v2/Campaigns/{$campaign_id}", array(
+ if (!empty($stored_campaign_id)) {
+ // We have a stored ID - try to update
+ $update_response = $this->auth->make_api_request("/Campaigns/{$stored_campaign_id}", 'PUT', array(
'data' => array($campaign_data)
));
+
+ // Check if update failed due to invalid ID (e.g. campaign deleted in Zoho)
+ if (isset($update_response['code']) && $update_response['code'] === 'INVALID_DATA') {
+ // Fallback: Create new campaign
+ $create_response = $this->auth->make_api_request('/Campaigns', 'POST', array(
+ 'data' => array($campaign_data)
+ ));
+ $results['responses'][] = array('type' => 'create_fallback', 'id' => $event->ID, 'response' => $create_response);
+
+ if (!empty($create_response['data'][0]['details']['id'])) {
+ $campaign_id = $create_response['data'][0]['details']['id'];
+ }
+ } else {
+ $campaign_id = $stored_campaign_id;
+ $results['responses'][] = array('type' => 'update', 'id' => $event->ID, 'response' => $update_response);
+ }
} else {
- // Create new campaign
- $create_response = $this->auth->make_api_request('POST', '/crm/v2/Campaigns', array(
+ // No stored ID - create new campaign
+ $create_response = $this->auth->make_api_request('/Campaigns', 'POST', array(
'data' => array($campaign_data)
));
+ $results['responses'][] = array('type' => 'create', 'id' => $event->ID, 'response' => $create_response);
+
+ // Extract campaign ID from create response
+ if (!empty($create_response['data'][0]['details']['id'])) {
+ $campaign_id = $create_response['data'][0]['details']['id'];
+ }
}
$results['synced']++;
- // Update event meta with Zoho ID
- if (isset($campaign_id)) {
+ // Update event meta with Zoho Campaign ID
+ if (!empty($campaign_id)) {
update_post_meta($event->ID, '_zoho_campaign_id', $campaign_id);
}
@@ -139,19 +220,26 @@ class HVAC_Zoho_Sync {
/**
* Sync users to Zoho Contacts
*
+ * @param int $offset Starting offset for pagination
+ * @param int $limit Number of records per batch
+ * @param int|null $since_timestamp Optional timestamp to filter modified records
* @return array Sync results
*/
- public function sync_users() {
+ public function sync_users($offset = 0, $limit = 50, $since_timestamp = null) {
$results = array(
'total' => 0,
'synced' => 0,
'failed' => 0,
'errors' => array(),
- 'staging_mode' => $this->is_staging
+ 'staging_mode' => $this->is_staging,
+ 'has_more' => false,
+ 'next_offset' => 0,
+ 'batch_offset' => $offset,
+ 'incremental_sync' => !is_null($since_timestamp)
);
- // Get trainers (hvac_trainer and hvac_master_trainer roles)
- $users = get_users(array(
+ // Base query args
+ $base_args = array(
'role__in' => array('hvac_trainer', 'hvac_master_trainer'),
'meta_query' => array(
'relation' => 'OR',
@@ -165,9 +253,26 @@ class HVAC_Zoho_Sync {
'compare' => 'NOT EXISTS'
)
)
- ));
+ );
- $results['total'] = count($users);
+ // Note: WP_User_Query doesn't support date_query for user_registered
+ // For users, we'll sync all and let Zoho handle updates
+ // Future enhancement: track _zoho_last_synced per user
+
+ // Get total count first
+ $total_query = new WP_User_Query(array_merge($base_args, array('count_total' => true, 'number' => -1)));
+ $total_count = $total_query->get_total();
+ $results['total'] = $total_count;
+
+ // Get paginated users
+ $users = get_users(array_merge($base_args, array(
+ 'number' => $limit,
+ 'offset' => $offset
+ )));
+
+ // Calculate pagination
+ $results['has_more'] = ($offset + count($users)) < $total_count;
+ $results['next_offset'] = $offset + count($users);
// If staging mode, simulate the sync
if ($this->is_staging) {
@@ -199,19 +304,19 @@ class HVAC_Zoho_Sync {
$contact_data = $this->prepare_contact_data($user);
// Check if contact already exists in Zoho
- $search_response = $this->auth->make_api_request('GET', '/crm/v2/Contacts/search', array(
+ $search_response = $this->auth->make_api_request('/Contacts/search', 'GET', array(
'criteria' => "(Email:equals:{$contact_data['Email']})"
));
if (!empty($search_response['data'])) {
// Update existing contact
$contact_id = $search_response['data'][0]['id'];
- $update_response = $this->auth->make_api_request('PUT', "/crm/v2/Contacts/{$contact_id}", array(
+ $update_response = $this->auth->make_api_request("/Contacts/{$contact_id}", 'PUT', array(
'data' => array($contact_data)
));
} else {
// Create new contact
- $create_response = $this->auth->make_api_request('POST', '/crm/v2/Contacts', array(
+ $create_response = $this->auth->make_api_request('/Contacts', 'POST', array(
'data' => array($contact_data)
));
@@ -239,31 +344,65 @@ class HVAC_Zoho_Sync {
/**
* Sync Event Tickets orders to Zoho Invoices
*
+ * @param int $offset Starting offset for pagination
+ * @param int $limit Number of records per batch
+ * @param int|null $since_timestamp Optional timestamp to filter modified records
* @return array Sync results
*/
- public function sync_purchases() {
+ public function sync_purchases($offset = 0, $limit = 50, $since_timestamp = null) {
$results = array(
'total' => 0,
'synced' => 0,
'failed' => 0,
'errors' => array(),
- 'staging_mode' => $this->is_staging
+ 'staging_mode' => $this->is_staging,
+ 'has_more' => false,
+ 'next_offset' => 0,
+ 'batch_offset' => $offset,
+ 'incremental_sync' => !is_null($since_timestamp)
);
- // Get Tickets Commerce orders (tec_tc_order post type)
- $orders = get_posts(array(
+ // Build query args
+ $query_args = array(
'post_type' => 'tec_tc_order',
- 'posts_per_page' => -1,
'post_status' => 'tec-tc-completed',
- ));
+ 'posts_per_page' => -1,
+ 'fields' => 'ids'
+ );
+
+ // Add date filter for incremental sync
+ if ($since_timestamp) {
+ $query_args['date_query'] = array(
+ array(
+ 'column' => 'post_modified',
+ 'after' => date('Y-m-d H:i:s', $since_timestamp),
+ 'inclusive' => true,
+ )
+ );
+ }
- $results['total'] = count($orders);
+ // Get total count first
+ $count_query = new WP_Query($query_args);
+ $total_count = $count_query->found_posts;
+ $results['total'] = $total_count;
- if ($results['total'] === 0) {
+ if ($total_count === 0) {
$results['message'] = 'No completed ticket orders found.';
return $results;
}
+ // Get paginated orders
+ $paginated_args = array_merge($query_args, array(
+ 'posts_per_page' => $limit,
+ 'offset' => $offset,
+ 'fields' => 'all',
+ ));
+ $orders = get_posts($paginated_args);
+
+ // Calculate pagination
+ $results['has_more'] = ($offset + count($orders)) < $total_count;
+ $results['next_offset'] = $offset + count($orders);
+
// If staging mode, simulate the sync
if ($this->is_staging) {
$results['message'] = 'STAGING MODE: Sync simulation only. No data sent to Zoho.';
@@ -336,9 +475,12 @@ class HVAC_Zoho_Sync {
/**
* Sync ticket attendees to Zoho Contacts + Campaign Members
*
+ * @param int $offset Starting offset for pagination
+ * @param int $limit Number of records per batch
+ * @param int|null $since_timestamp Optional timestamp to filter modified records
* @return array Sync results
*/
- public function sync_attendees() {
+ public function sync_attendees($offset = 0, $limit = 50, $since_timestamp = null) {
$results = array(
'total' => 0,
'synced' => 0,
@@ -346,27 +488,82 @@ class HVAC_Zoho_Sync {
'errors' => array(),
'staging_mode' => $this->is_staging,
'contacts_created' => 0,
- 'campaign_members_created' => 0
+ 'campaign_members_created' => 0,
+ 'responses' => array(),
+ 'debug_info' => HVAC_Zoho_CRM_Auth::get_debug_mode_info(),
+ 'version' => 'BATCH_V1',
+ 'has_more' => false,
+ 'next_offset' => 0,
+ 'batch_offset' => $offset,
+ 'incremental_sync' => !is_null($since_timestamp)
);
- // Get all ticket attendees (Tickets Commerce)
- $attendees = get_posts(array(
+ // Build base query args
+ $base_tc_args = array(
'post_type' => 'tec_tc_attendee',
- 'posts_per_page' => -1,
'post_status' => 'any',
- ));
-
- // Also get PayPal attendees if any
- $tpp_attendees = get_posts(array(
+ 'posts_per_page' => -1,
+ 'fields' => 'ids'
+ );
+ $base_tpp_args = array(
'post_type' => 'tribe_tpp_attendees',
- 'posts_per_page' => -1,
'post_status' => 'any',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids'
+ );
+
+ // Add date filter for incremental sync
+ if ($since_timestamp) {
+ $date_query = array(
+ array(
+ 'column' => 'post_date',
+ 'after' => date('Y-m-d H:i:s', $since_timestamp),
+ 'inclusive' => true,
+ )
+ );
+ $base_tc_args['date_query'] = $date_query;
+ $base_tpp_args['date_query'] = $date_query;
+ }
+
+ // Get total count for all attendee types
+ $tc_count_query = new WP_Query($base_tc_args);
+ $tpp_count_query = new WP_Query($base_tpp_args);
+ $total_count = $tc_count_query->found_posts + $tpp_count_query->found_posts;
+ $results['total'] = $total_count;
+
+ if ($total_count === 0) {
+ $results['message'] = 'No ticket attendees found.';
+ return $results;
+ }
+
+ // Get paginated attendees - first from tec_tc_attendee
+ $tc_paginated_args = array_merge($base_tc_args, array(
+ 'posts_per_page' => $limit,
+ 'offset' => $offset,
+ 'fields' => 'all',
));
+ $tc_attendees = get_posts($tc_paginated_args);
- $all_attendees = array_merge($attendees, $tpp_attendees);
- $results['total'] = count($all_attendees);
+ // If we need more to fill the batch, get from PayPal attendees
+ $remaining = max(0, $limit - count($tc_attendees));
+ $tpp_offset = max(0, $offset - $tc_count_query->found_posts);
+ $tpp_attendees = array();
+ if ($remaining > 0 && $tpp_offset >= 0) {
+ $tpp_paginated_args = array_merge($base_tpp_args, array(
+ 'posts_per_page' => $remaining,
+ 'offset' => $tpp_offset,
+ 'fields' => 'all',
+ ));
+ $tpp_attendees = get_posts($tpp_paginated_args);
+ }
- if ($results['total'] === 0) {
+ $all_attendees = array_merge($tc_attendees, $tpp_attendees);
+
+ // Calculate pagination
+ $results['has_more'] = ($offset + count($all_attendees)) < $total_count;
+ $results['next_offset'] = $offset + count($all_attendees);
+
+ if (count($all_attendees) === 0) {
$results['message'] = 'No ticket attendees found.';
return $results;
}
@@ -391,40 +588,104 @@ class HVAC_Zoho_Sync {
return $results;
}
- foreach ($all_attendees as $attendee) {
- try {
- $attendee_data = $this->prepare_attendee_data($attendee);
+ $results['version'] = 'PRODUCTION_BATCH_V1';
- if (empty($attendee_data['email'])) {
- $results['failed']++;
- $results['errors'][] = sprintf('Attendee %s: No email address', $attendee->ID);
- continue;
- }
+ // Wrap entire loop in try-catch for safety (catches PHP 7+ errors)
+ try {
+ foreach ($all_attendees as $attendee) {
+
+ try {
+ $attendee_data = $this->prepare_attendee_data($attendee);
- // Step 1: Create/Update Contact
- $contact_id = $this->ensure_contact_exists($attendee_data);
- if ($contact_id) {
- $results['contacts_created']++;
- }
-
- // Step 2: Create Campaign Member (link Contact to Campaign)
- if ($contact_id && !empty($attendee_data['event_id'])) {
- $campaign_id = get_post_meta($attendee_data['event_id'], '_zoho_campaign_id', true);
- if ($campaign_id) {
- $this->create_campaign_member($contact_id, $campaign_id, 'Attended');
- $results['campaign_members_created']++;
+ // Check for any usable email (fallback to mq_email if email is empty)
+ $has_email = !empty($attendee_data['email']) || !empty($attendee_data['mq_email']);
+ if (!$has_email) {
+ $results['failed']++;
+ $results['errors'][] = sprintf('Attendee %s: No email address', $attendee->ID);
+ $processed++;
+ continue;
}
+
+ // Step 1: Create/Update Contact
+ $contact_id = $this->ensure_contact_exists($attendee_data);
+ if ($contact_id) {
+ $results['contacts_created']++;
+ }
+
+ if (count($results['responses']) < 5) {
+ $cid_debug = $contact_id ? $contact_id : 'NULL';
+ $results['responses'][] = array('type' => 'debug', 'msg' => "Debug: Attendee {$attendee->ID} found Contact ID: " . $cid_debug);
+ }
+
+ // Step 2: Create Campaign Member (link Contact to Campaign)
+ if ($contact_id && !empty($attendee_data['event_id'])) {
+ $campaign_id = get_post_meta($attendee_data['event_id'], '_zoho_campaign_id', true);
+
+ // Debug: Log event_id and campaign_id for troubleshooting
+ if (count($results['responses']) < 10) {
+ $results['responses'][] = array(
+ 'type' => 'campaign_lookup',
+ 'attendee_id' => $attendee->ID,
+ 'event_id' => $attendee_data['event_id'],
+ 'campaign_id' => $campaign_id ?: 'NOT_SET'
+ );
+ }
+
+ if ($campaign_id) {
+ $assoc_response = $this->create_campaign_member($contact_id, $campaign_id, 'Attended');
+
+ // ALWAYS capture the first link attempt for debugging
+ if (!isset($results['first_link_attempt'])) {
+ $results['first_link_attempt'] = array(
+ 'attendee_id' => $attendee->ID,
+ 'contact_id' => $contact_id,
+ 'campaign_id' => $campaign_id,
+ 'response' => $assoc_response
+ );
+ }
+
+ // Debug: Add responses (increased limit to 20)
+ if (count($results['responses']) < 20) {
+ $results['responses'][] = array('type' => 'link_attempt', 'id' => $attendee->ID, 'response' => $assoc_response);
+ }
+
+ if (isset($assoc_response['status']) && $assoc_response['status'] === 'success') {
+ $results['campaign_members_created']++;
+ } elseif (isset($assoc_response['data'][0]['status']) && $assoc_response['data'][0]['status'] === 'success') {
+ $results['campaign_members_created']++;
+ } else {
+ $results['errors'][] = sprintf('Attendee %s: Failed to link to campaign. Response: %s', $attendee->ID, json_encode($assoc_response));
+ if (isset($assoc_response['data'])) {
+ $results['responses'][] = array('type' => 'link_error', 'id' => $attendee->ID, 'response' => $assoc_response);
+ }
+ }
+ } else {
+ $results['errors'][] = sprintf('Attendee %s: Event %s has no Zoho Campaign ID', $attendee->ID, $attendee_data['event_id']);
+ }
+ } elseif (!$contact_id) {
+ $error_detail = $this->last_contact_error ?: 'Unknown error';
+ $results['errors'][] = sprintf('Attendee %s: No contact_id created. %s', $attendee->ID, $error_detail);
+ } elseif (empty($attendee_data['event_id'])) {
+ // Debug: Log when event_id is missing
+ if (count($results['responses']) < 10) {
+ $results['responses'][] = array('type' => 'missing_event_id', 'attendee_id' => $attendee->ID);
+ }
+ }
+
+ $results['synced']++;
+
+ // Update attendee meta with Zoho Contact ID
+ update_post_meta($attendee->ID, '_zoho_contact_id', $contact_id);
+
+ } catch (\Throwable $e) {
+ // Catch both Exception and Error (PHP 7+)
+ $results['failed']++;
+ $results['errors'][] = sprintf('Attendee %s: [%s] %s', $attendee->ID, get_class($e), $e->getMessage());
}
-
- $results['synced']++;
-
- // Update attendee meta with Zoho Contact ID
- update_post_meta($attendee->ID, '_zoho_contact_id', $contact_id);
-
- } catch (Exception $e) {
- $results['failed']++;
- $results['errors'][] = sprintf('Attendee %s: %s', $attendee->ID, $e->getMessage());
}
+ } catch (\Throwable $e) {
+ // Outer catch for any loop-level errors
+ $results['errors'][] = 'Loop error: [' . get_class($e) . '] ' . $e->getMessage();
}
return $results;
@@ -433,9 +694,12 @@ class HVAC_Zoho_Sync {
/**
* Sync RSVPs to Zoho Leads + Campaign Members
*
+ * @param int $offset Starting offset for pagination
+ * @param int $limit Number of records per batch
+ * @param int|null $since_timestamp Optional timestamp to filter modified records
* @return array Sync results
*/
- public function sync_rsvps() {
+ public function sync_rsvps($offset = 0, $limit = 50, $since_timestamp = null) {
$results = array(
'total' => 0,
'synced' => 0,
@@ -443,27 +707,59 @@ class HVAC_Zoho_Sync {
'errors' => array(),
'staging_mode' => $this->is_staging,
'leads_created' => 0,
- 'campaign_members_created' => 0
+ 'campaign_members_created' => 0,
+ 'debug_info' => HVAC_Zoho_CRM_Auth::get_debug_mode_info(),
+ 'has_more' => false,
+ 'next_offset' => 0,
+ 'batch_offset' => $offset,
+ 'incremental_sync' => !is_null($since_timestamp)
);
- // Get RSVP attendees
- $rsvps = get_posts(array(
+ // Build query args
+ $query_args = array(
'post_type' => 'tribe_rsvp_attendees',
- 'posts_per_page' => -1,
'post_status' => 'any',
- ));
+ 'posts_per_page' => -1,
+ 'fields' => 'ids'
+ );
+
+ // Add date filter for incremental sync
+ if ($since_timestamp) {
+ $query_args['date_query'] = array(
+ array(
+ 'column' => 'post_date',
+ 'after' => date('Y-m-d H:i:s', $since_timestamp),
+ 'inclusive' => true,
+ )
+ );
+ }
- $results['total'] = count($rsvps);
+ // Get total count first
+ $count_query = new WP_Query($query_args);
+ $total_count = $count_query->found_posts;
+ $results['total'] = $total_count;
- if ($results['total'] === 0) {
+ if ($total_count === 0) {
$results['message'] = 'No RSVP attendees found.';
return $results;
}
+ // Get paginated RSVPs
+ $paginated_args = array_merge($query_args, array(
+ 'posts_per_page' => $limit,
+ 'offset' => $offset,
+ 'fields' => 'all',
+ ));
+ $rsvps = get_posts($paginated_args);
+
+ // Calculate pagination
+ $results['has_more'] = ($offset + count($rsvps)) < $total_count;
+ $results['next_offset'] = $offset + count($rsvps);
+
// If staging mode, simulate the sync
if ($this->is_staging) {
$results['message'] = 'STAGING MODE: Sync simulation only. No data sent to Zoho.';
- $results['synced'] = $results['total'];
+ $results['synced'] = count($rsvps);
$results['test_data'] = array();
foreach ($rsvps as $rsvp) {
@@ -500,8 +796,19 @@ class HVAC_Zoho_Sync {
if ($lead_id && !empty($rsvp_data['event_id'])) {
$campaign_id = get_post_meta($rsvp_data['event_id'], '_zoho_campaign_id', true);
if ($campaign_id) {
- $this->create_campaign_member($lead_id, $campaign_id, 'Responded', 'Leads');
- $results['campaign_members_created']++;
+ $assoc_response = $this->create_campaign_member($lead_id, $campaign_id, 'Responded', 'Leads');
+ if (isset($assoc_response['status']) && $assoc_response['status'] === 'success') {
+ $results['campaign_members_created']++;
+ } elseif (isset($assoc_response['data'][0]['status']) && $assoc_response['data'][0]['status'] === 'success') {
+ $results['campaign_members_created']++;
+ } else {
+ $results['errors'][] = sprintf('RSVP %s: Failed to link to campaign. Response: %s', $rsvp->ID, json_encode($assoc_response));
+ if (isset($assoc_response['data'])) {
+ $results['responses'][] = array('type' => 'link_error', 'id' => $rsvp->ID, 'response' => $assoc_response);
+ }
+ }
+ } else {
+ $results['errors'][] = sprintf('RSVP %s: Event %s has no Zoho Campaign ID', $rsvp->ID, $rsvp_data['event_id']);
}
}
@@ -520,32 +827,70 @@ class HVAC_Zoho_Sync {
}
/**
- * Ensure a Contact exists in Zoho (create or get existing)
+ * Ensure a Contact exists in Zoho (create or get existing, update fields)
*
* @param array $data Attendee data
* @return string|null Zoho Contact ID
*/
private function ensure_contact_exists($data) {
- // Search for existing contact by email
- $search_response = $this->auth->make_api_request(
- '/Contacts/search?criteria=(Email:equals:' . urlencode($data['email']) . ')',
- 'GET'
- );
-
- if (!empty($search_response['data'])) {
- return $search_response['data'][0]['id'];
+ // Determine primary email for lookup
+ // Prefer measureQuick email, fallback to attendee email if mq_email is empty
+ // If both are empty, we can't create a contact
+ $attendee_email = !empty($data['email']) ? $data['email'] : '';
+ $mq_email = !empty($data['mq_email']) ? $data['mq_email'] : '';
+
+ // Use mq_email as primary if available, otherwise use attendee_email
+ $primary_email = !empty($mq_email) ? $mq_email : $attendee_email;
+
+ // Set secondary email (only if different from primary and not empty)
+ $other_email = '';
+ if (!empty($mq_email) && !empty($attendee_email) && $mq_email !== $attendee_email) {
+ $other_email = $attendee_email;
}
-
- // Create new contact
+
+ // Build contact data for create or update
$contact_data = array(
'First_Name' => $data['first_name'] ?: 'Unknown',
'Last_Name' => $data['last_name'] ?: 'Attendee',
- 'Email' => $data['email'],
+ 'Email' => $primary_email,
'Lead_Source' => 'Event Tickets',
'Contact_Type' => 'Attendee',
'WordPress_Attendee_ID' => $data['attendee_id'],
);
+
+ // Add Other Email if we have both emails
+ if (!empty($other_email)) {
+ $contact_data['Secondary_Email'] = $other_email;
+ }
+
+ // Add Mobile phone if available
+ if (!empty($data['phone'])) {
+ $contact_data['Mobile'] = $data['phone'];
+ }
+
+ // Add Primary Role if available
+ if (!empty($data['company_role'])) {
+ $contact_data['Primary_Role'] = $data['company_role'];
+ }
+
+ // Search for existing contact by primary email
+ $search_response = $this->auth->make_api_request(
+ '/Contacts/search?criteria=(Email:equals:' . urlencode($primary_email) . ')',
+ 'GET'
+ );
+ if (!empty($search_response['data'])) {
+ $contact_id = $search_response['data'][0]['id'];
+
+ // UPDATE existing contact with new field data
+ $this->auth->make_api_request("/Contacts/{$contact_id}", 'PUT', array(
+ 'data' => array($contact_data)
+ ));
+
+ return $contact_id;
+ }
+
+ // Create new contact
$create_response = $this->auth->make_api_request('/Contacts', 'POST', array(
'data' => array($contact_data)
));
@@ -553,6 +898,26 @@ class HVAC_Zoho_Sync {
if (!empty($create_response['data'][0]['details']['id'])) {
return $create_response['data'][0]['details']['id'];
}
+
+ // Handle DUPLICATE_DATA: Contact exists but with different email
+ // Extract existing contact ID from the error response and use it
+ if (!empty($create_response['data'][0]['code']) &&
+ $create_response['data'][0]['code'] === 'DUPLICATE_DATA' &&
+ !empty($create_response['data'][0]['details']['duplicate_record']['id'])) {
+
+ $existing_id = $create_response['data'][0]['details']['duplicate_record']['id'];
+
+ // Update the existing contact with new data
+ $this->auth->make_api_request("/Contacts/{$existing_id}", 'PUT', array(
+ 'data' => array($contact_data)
+ ));
+
+ return $existing_id;
+ }
+
+ // Store error for debugging and log it
+ $this->last_contact_error = "Email: {$primary_email}, Response: " . json_encode($create_response);
+ $this->auth->log_debug("Failed to create contact: " . $this->last_contact_error);
return null;
}
@@ -604,13 +969,26 @@ class HVAC_Zoho_Sync {
* @param string $type 'Contacts' or 'Leads'
*/
private function create_campaign_member($record_id, $campaign_id, $status = 'Attended', $type = 'Contacts') {
- $endpoint = "/Campaigns/{$campaign_id}/{$type}/{$record_id}";
+ // v6 API: Associate Contact/Lead with Campaign via related list
+ // PUT /{Contacts|Leads}/{record_id}/Campaigns
+ // The Contact/Lead is the parent, Campaigns is the related list
+ // Campaign ID goes in the request body, not the URL
+
+ $endpoint = "/{$type}/{$record_id}/Campaigns";
+
+ // Debug: Log the association attempt
+ $this->auth->log_debug("Attempting to associate {$type} {$record_id} with Campaign {$campaign_id} via {$endpoint}");
- $this->auth->make_api_request($endpoint, 'PUT', array(
+ $response = $this->auth->make_api_request($endpoint, 'PUT', array(
'data' => array(
- array('Member_Status' => $status)
+ array(
+ 'id' => $campaign_id,
+ 'Member_Status' => $status
+ )
)
));
+
+ return $response;
}
/**
@@ -622,22 +1000,112 @@ class HVAC_Zoho_Sync {
private function prepare_campaign_data($event) {
$trainer_id = get_post_meta($event->ID, '_trainer_id', true);
$trainer = get_user_by('id', $trainer_id);
- $venue = tribe_get_venue($event->ID);
+ $venue_id = tribe_get_venue_id($event->ID);
- return array(
- 'Campaign_Name' => get_the_title($event->ID),
+ // Get venue details
+ $venue_name = '';
+ $venue_city = '';
+ $venue_state = '';
+ $venue_details = '';
+
+ if ($venue_id) {
+ $venue_name = html_entity_decode(get_the_title($venue_id), ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ $venue_city = tribe_get_city($event->ID);
+ $venue_state = tribe_get_state($event->ID);
+ $venue_address = tribe_get_address($event->ID);
+
+ // Build venue details string
+ $venue_parts = array_filter(array($venue_name, $venue_address, $venue_city, $venue_state));
+ $venue_details = implode(', ', $venue_parts);
+ }
+
+ // Sanitize description: strip tags and limit length, then decode entities
+ $description = wp_strip_all_tags(get_the_content(null, false, $event));
+ $description = html_entity_decode($description, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+ if (strlen($description) > 30000) {
+ $description = substr($description, 0, 30000) . '...';
+ }
+
+ $campaign_name = html_entity_decode(get_the_title($event->ID), ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ $trainer_name = $trainer ? html_entity_decode($trainer->display_name, ENT_QUOTES | ENT_HTML5, 'UTF-8') : '';
+
+ // Get trainer's Zoho Contact ID (if they've been synced)
+ $instructor_zoho_id = '';
+ if ($trainer) {
+ $instructor_zoho_id = get_user_meta($trainer->ID, '_zoho_contact_id', true);
+ }
+
+ // Calculate ticket counts
+ $total_capacity = intval(get_post_meta($event->ID, '_stock', true));
+ $tickets_sold = $this->get_attendee_count($event->ID);
+ $tickets_available = max(0, $total_capacity - $tickets_sold);
+
+ // Build campaign data array
+ $data = array(
+ 'Campaign_Name' => substr($campaign_name, 0, 200),
'Start_Date' => tribe_get_start_date($event->ID, false, 'Y-m-d'),
'End_Date' => tribe_get_end_date($event->ID, false, 'Y-m-d'),
'Status' => (tribe_get_end_date($event->ID, false, 'U') < time()) ? 'Completed' : 'Active',
- 'Description' => get_the_content(null, false, $event),
+ 'Description' => $description,
'Type' => 'Training Event',
'Expected_Revenue' => floatval(get_post_meta($event->ID, '_price', true)),
- 'Total_Capacity' => intval(get_post_meta($event->ID, '_stock', true)),
- 'Venue' => $venue ? get_the_title($venue) : '',
- 'Trainer_Name' => $trainer ? $trainer->display_name : '',
- 'Trainer_Email' => $trainer ? $trainer->user_email : '',
- 'WordPress_Event_ID' => $event->ID
+ 'Total_Capacity' => $total_capacity,
+ 'Tickets_Sold' => $tickets_sold,
+ 'Tickets_Available' => $tickets_available,
+ 'Event_City' => substr($venue_city, 0, 100),
+ 'Event_State' => substr($venue_state, 0, 100),
+ 'Event_URL' => get_permalink($event->ID),
+ 'Venue_Details' => substr($venue_details, 0, 250),
+ 'Instructor_Email' => $trainer ? $trainer->user_email : '',
);
+
+ // Add Instructor lookup only if we have a valid Zoho Contact ID
+ if (!empty($instructor_zoho_id)) {
+ $data['Instructor'] = $instructor_zoho_id;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get count of attendees for an event
+ *
+ * @param int $event_id Event post ID
+ * @return int Attendee count
+ */
+ private function get_attendee_count($event_id) {
+ // Check Tickets Commerce attendees
+ $tc_attendees = get_posts(array(
+ 'post_type' => 'tec_tc_attendee',
+ 'posts_per_page' => -1,
+ 'post_status' => 'any',
+ 'meta_query' => array(
+ array(
+ 'key' => '_tec_tickets_commerce_event',
+ 'value' => $event_id,
+ 'compare' => '='
+ )
+ ),
+ 'fields' => 'ids'
+ ));
+
+ // Also check PayPal attendees
+ $tpp_attendees = get_posts(array(
+ 'post_type' => 'tribe_tpp_attendees',
+ 'posts_per_page' => -1,
+ 'post_status' => 'any',
+ 'meta_query' => array(
+ array(
+ 'key' => '_tribe_tpp_event',
+ 'value' => $event_id,
+ 'compare' => '='
+ )
+ ),
+ 'fields' => 'ids'
+ ));
+
+ return count($tc_attendees) + count($tpp_attendees);
}
/**
@@ -657,12 +1125,12 @@ class HVAC_Zoho_Sync {
}
return array(
- 'First_Name' => get_user_meta($user->ID, 'first_name', true),
- 'Last_Name' => get_user_meta($user->ID, 'last_name', true),
+ 'First_Name' => html_entity_decode(get_user_meta($user->ID, 'first_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
+ 'Last_Name' => html_entity_decode(get_user_meta($user->ID, 'last_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'Email' => $user->user_email,
'Phone' => get_user_meta($user->ID, 'phone_number', true),
- 'Title' => get_user_meta($user->ID, 'hvac_professional_title', true),
- 'Company' => get_user_meta($user->ID, 'hvac_company_name', true),
+ 'Title' => html_entity_decode(get_user_meta($user->ID, 'hvac_professional_title', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
+ 'Company' => html_entity_decode(get_user_meta($user->ID, 'hvac_company_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'Lead_Source' => 'HVAC Community Events',
'Contact_Type' => $role,
'WordPress_User_ID' => $user->ID,
@@ -746,6 +1214,21 @@ class HVAC_Zoho_Sync {
$full_name = get_post_meta($attendee->ID, '_tec_tickets_commerce_full_name', true);
$email = get_post_meta($attendee->ID, '_tec_tickets_commerce_email', true);
$checkin = get_post_meta($attendee->ID, '_tec_tickets_commerce_checked_in', true);
+
+ // Custom attendee fields stored in _tec_tickets_commerce_attendee_fields (serialized array)
+ // Field names use hyphens: attendee-cell-phone, company-role, measurequick-email
+ $attendee_fields = get_post_meta($attendee->ID, '_tec_tickets_commerce_attendee_fields', true);
+
+ $phone = '';
+ $company_role = '';
+ $mq_email = '';
+
+ if (is_array($attendee_fields)) {
+ $phone = isset($attendee_fields['attendee-cell-phone']) ? $attendee_fields['attendee-cell-phone'] : '';
+ $company_role = isset($attendee_fields['company-role']) ? $attendee_fields['company-role'] : '';
+ $mq_email = isset($attendee_fields['measurequick-email']) ? $attendee_fields['measurequick-email'] : '';
+ }
+
} else {
// PayPal or other attendee types (tribe_tpp_attendees)
$event_id = get_post_meta($attendee->ID, '_tribe_tpp_event', true);
@@ -754,6 +1237,12 @@ class HVAC_Zoho_Sync {
$full_name = get_post_meta($attendee->ID, '_tribe_tickets_full_name', true);
$email = get_post_meta($attendee->ID, '_tribe_tickets_email', true);
$checkin = get_post_meta($attendee->ID, '_tribe_tpp_checkin', true);
+
+ // Legacy attendee meta for custom fields (hyphenated keys)
+ $attendee_meta = get_post_meta($attendee->ID, '_tribe_tickets_meta', true);
+ $phone = is_array($attendee_meta) && isset($attendee_meta['attendee-cell-phone']) ? $attendee_meta['attendee-cell-phone'] : '';
+ $company_role = is_array($attendee_meta) && isset($attendee_meta['company-role']) ? $attendee_meta['company-role'] : '';
+ $mq_email = is_array($attendee_meta) && isset($attendee_meta['measurequick-email']) ? $attendee_meta['measurequick-email'] : '';
}
// Parse full name into first/last
@@ -776,6 +1265,9 @@ class HVAC_Zoho_Sync {
'first_name' => $first_name,
'last_name' => $last_name,
'email' => $email,
+ 'phone' => $phone,
+ 'company_role' => $company_role,
+ 'mq_email' => $mq_email,
'checked_in' => !empty($checkin),
'zoho_contact' => array(
'First_Name' => $first_name ?: 'Unknown',