From 156adbe5db7d8a5f0c085d1f52fe8329b411a259 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 11 Jan 2026 01:28:40 +0000 Subject: [PATCH] Review N8N V.2 --- ai-call-logs.php | 178 +++++++------------ api/brightlocal-sync.php | 54 ++++++ api/calendar-events.php | 82 +++++++++ api/call-tracking.php | 112 ++++++------ api/chat-logs.php | 84 +++++++++ api/config.php | 3 + api/reviews-webhook.php | 92 ++++++++++ api/reviews.php | 102 +++++------ api/view-webhook-log.php | 1 + api/weather-fetch.php | 82 +++++++++ api/weather.php | 70 ++++++++ api/webhook_debug.log | 97 +++++++++++ assets/css/custom.css | 143 ++++++++++++++-- assets/js/main.js | 87 +++++++++- assets/js/map.js | 38 ++++ bookings.php | 69 +++----- calendar.php | 125 ++++++++++++++ call-tracking.php | 202 ++++++++++++++++++++++ chat-logs.php | 225 ++++++++++++++++++++++++ customers.php | 64 +++---- db/schema.sql | 90 +++++----- index.php | 362 ++++++++++++++++++++++++--------------- reviews.php | 238 +++++++++++++++++++++++++ 23 files changed, 2097 insertions(+), 503 deletions(-) create mode 100644 api/brightlocal-sync.php create mode 100644 api/calendar-events.php create mode 100644 api/chat-logs.php create mode 100644 api/reviews-webhook.php create mode 100644 api/view-webhook-log.php create mode 100644 api/weather-fetch.php create mode 100644 api/weather.php create mode 100644 api/webhook_debug.log create mode 100644 assets/js/map.js create mode 100644 calendar.php create mode 100644 call-tracking.php create mode 100644 chat-logs.php create mode 100644 reviews.php diff --git a/ai-call-logs.php b/ai-call-logs.php index 7e22b08..ad2be3b 100644 --- a/ai-call-logs.php +++ b/ai-call-logs.php @@ -46,123 +46,91 @@ $page_title = "AI Call Logs"; - + - - - -
-
-

+
+ + - +
+
+

+
-
- -
- -
+
-
+
-
-
- +
+
-
Total Calls
-

+
Total Calls
+

+
-
-
- +
+
-
Avg. Call Duration
-

s

+
Avg. Call Duration
+

s

+
-
+
-
-
-
Call Intent Distribution
-
-
- -
+
+
Call Intent Distribution
+
-
-
-
Call Outcome Distribution
-
-
- -
+
+
Call Outcome Distribution
+
- -
-
-
All AI Call Logs
-
+
+
All AI Call Logs
- - - - - - - - - + + @@ -170,15 +138,13 @@ $page_title = "AI Call Logs"; - - - + + + - - - +
Call IDStart TimeEnd TimeIntentOutcomeSummary
Call IDStart TimeEnd TimeIntentOutcomeSummary
No call logs found.
No call logs found.
@@ -186,17 +152,19 @@ $page_title = "AI Call Logs";
-
- -
- Powered by Flatlogic -
+
+ - \ No newline at end of file + diff --git a/api/brightlocal-sync.php b/api/brightlocal-sync.php new file mode 100644 index 0000000..2f151ab --- /dev/null +++ b/api/brightlocal-sync.php @@ -0,0 +1,54 @@ +query("SELECT * FROM review_snapshot WHERE location_id = 'charlotte-heating' LIMIT 1"); + $snapshot = $stmt->fetch(PDO::FETCH_ASSOC); + + // Get recent reviews + $stmt = $pdo->query("SELECT * FROM reviews WHERE location_id = 'charlotte-heating' ORDER BY review_date DESC LIMIT 10"); + $recentReviews = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if ($snapshot) { + echo json_encode([ + 'success' => true, + 'data' => [ + 'total_reviews' => (int)$snapshot['total_reviews'], + 'avg_rating' => (float)$snapshot['avg_rating'], + 'reviews_this_week' => (int)$snapshot['reviews_this_week'], + 'reviews_this_month' => (int)$snapshot['reviews_this_month'], + 'sources' => [ + 'google' => ['count' => (int)$snapshot['google_reviews'], 'avg' => (float)$snapshot['google_avg']], + 'yelp' => ['count' => (int)$snapshot['yelp_reviews'], 'avg' => (float)$snapshot['yelp_avg']], + 'facebook' => ['count' => (int)$snapshot['facebook_reviews'], 'avg' => (float)$snapshot['facebook_avg']] + ], + 'recent_reviews' => $recentReviews, + 'last_synced' => $snapshot['last_synced'] + ] + ]); + } else { + // Return default data if no snapshot exists yet + echo json_encode([ + 'success' => true, + 'data' => [ + 'total_reviews' => 504, + 'avg_rating' => 4.93, + 'reviews_this_week' => 0, + 'reviews_this_month' => 0, + 'sources' => [ + 'google' => ['count' => 500, 'avg' => 4.95], + 'yelp' => ['count' => 2, 'avg' => 3.0], + 'facebook' => ['count' => 2, 'avg' => 3.0] + ], + 'recent_reviews' => [], + 'last_synced' => null, + 'message' => 'Using default data - N8N sync not yet configured' + ] + ]); + } +} catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/api/calendar-events.php b/api/calendar-events.php new file mode 100644 index 0000000..49a35cd --- /dev/null +++ b/api/calendar-events.php @@ -0,0 +1,82 @@ + 'API key is not configured.']); + exit; +} + +$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; +if (strpos($authHeader, 'Bearer ') !== 0) { + http_response_code(401); + echo json_encode(['error' => 'Authorization header missing or invalid.']); + exit; +} +$token = substr($authHeader, 7); +if ($token !== $apiKey) { + http_response_code(401); + echo json_encode(['error' => 'Invalid API key.']); + exit; +} + +log_api_request('calendar-events'); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $data = json_decode(file_get_contents('php://input'), true); + + // Basic validation + $required_fields = ['google_event_id', 'start_datetime', 'end_datetime']; + foreach ($required_fields as $field) { + if (empty($data[$field])) { + http_response_code(400); + echo json_encode(['error' => "Missing required field: $field"]); + exit; + } + } + + try { + $pdo = db(); + $stmt = $pdo->prepare( + "INSERT INTO calendar_events (google_event_id, google_calendar_id, event_title, event_description, event_location, start_datetime, end_datetime, customer_name, customer_phone, service_type, assigned_technician, event_status, booking_id) + VALUES (:google_event_id, :google_calendar_id, :event_title, :event_description, :event_location, :start_datetime, :end_datetime, :customer_name, :customer_phone, :service_type, :assigned_technician, :event_status, :booking_id)" + ); + + $stmt->execute([ + ':google_event_id' => $data['google_event_id'], + ':google_calendar_id' => $data['google_calendar_id'] ?? null, + ':event_title' => $data['event_title'] ?? null, + ':event_description' => $data['event_description'] ?? null, + ':event_location' => $data['event_location'] ?? null, + ':start_datetime' => $data['start_datetime'], + ':end_datetime' => $data['end_datetime'], + ':customer_name' => $data['customer_name'] ?? null, + ':customer_phone' => $data['customer_phone'] ?? null, + ':service_type' => $data['service_type'] ?? null, + ':assigned_technician' => $data['assigned_technician'] ?? null, + ':event_status' => $data['event_status'] ?? null, + ':booking_id' => $data['booking_id'] ?? null, + ]); + + http_response_code(201); + echo json_encode(['success' => true, 'message' => 'Calendar event created successfully.']); + + } catch (PDOException $e) { + if ($e->getCode() == 23000) { // Duplicate entry + http_response_code(409); + echo json_encode(['error' => 'Duplicate google_event_id.']); + } else { + error_log("DB Error: " . $e->getMessage()); + http_response_code(500); + echo json_encode(['error' => 'Database error.']); + } + } +} else { + http_response_code(405); // Method Not Allowed + echo json_encode(['error' => 'Only POST method is accepted.']); +} diff --git a/api/call-tracking.php b/api/call-tracking.php index 3b1eff4..6f1bc1c 100644 --- a/api/call-tracking.php +++ b/api/call-tracking.php @@ -1,64 +1,68 @@ 'Invalid request method'], 405); - exit; -} +check_api_key(); -if (!validateApiKey()) { - logWebhook('call-tracking', file_get_contents('php://input'), 401); - sendJsonResponse(['error' => 'Unauthorized'], 401); - exit; -} +$request_method = $_SERVER['REQUEST_METHOD']; -$request_body = file_get_contents('php://input'); -$data = json_decode($request_body, true); +if ($request_method === 'POST') { + $data = json_decode(file_get_contents('php://input'), true); -if (json_last_error() !== JSON_ERROR_NONE) { - logWebhook('call-tracking', $request_body, 400); - sendJsonResponse(['error' => 'Invalid JSON'], 400); - exit; -} + // --- Validation --- + $required_fields = ['external_call_id', 'call_start_time']; + foreach ($required_fields as $field) { + if (empty($data[$field])) { + log_and_exit(400, "Missing required field: {$field}"); + } + } -$errors = []; -if (empty($data['external_call_id'])) { - $errors[] = 'external_call_id is required'; -} -if (empty($data['tracking_platform'])) { - $errors[] = 'tracking_platform is required'; -} -if (empty($data['call_start_time'])) { - $errors[] = 'call_start_time is required'; -} -if (empty($data['call_status'])) { - $errors[] = 'call_status is required'; -} -if (empty($data['traffic_source'])) { - $errors[] = 'traffic_source is required'; -} + try { + $pdo = db(); + $stmt = $pdo->prepare(" + INSERT INTO call_tracking ( + external_call_id, tracking_platform, caller_number, tracking_number, + call_start_time, call_end_time, call_duration_seconds, call_status, + answered_by, traffic_source, campaign_name, recording_url, + was_ai_rescue, attributed_revenue, caller_name, caller_city, caller_state + ) VALUES ( + :external_call_id, :tracking_platform, :caller_number, :tracking_number, + :call_start_time, :call_end_time, :call_duration_seconds, :call_status, + :answered_by, :traffic_source, :campaign_name, :recording_url, + :was_ai_rescue, :attributed_revenue, :caller_name, :caller_city, :caller_state + ) + "); + $stmt->execute([ + ':external_call_id' => $data['external_call_id'], + ':tracking_platform' => $data['tracking_platform'] ?? null, + ':caller_number' => $data['caller_number'] ?? null, + ':tracking_number' => $data['tracking_number'] ?? null, + ':call_start_time' => $data['call_start_time'], + ':call_end_time' => $data['call_end_time'] ?? null, + ':call_duration_seconds' => $data['call_duration_seconds'] ?? null, + ':call_status' => $data['call_status'] ?? null, + ':answered_by' => $data['answered_by'] ?? null, + ':traffic_source' => $data['traffic_source'] ?? null, + ':campaign_name' => $data['campaign_name'] ?? null, + ':recording_url' => $data['recording_url'] ?? null, + ':was_ai_rescue' => $data['was_ai_rescue'] ?? 0, + ':attributed_revenue' => $data['attributed_revenue'] ?? null, + ':caller_name' => $data['caller_name'] ?? null, + ':caller_city' => $data['caller_city'] ?? null, + ':caller_state' => $data['caller_state'] ?? null, + ]); -if (!empty($errors)) { - logWebhook('call-tracking', $request_body, 422); - sendJsonResponse(['errors' => $errors], 422); - exit; + header('Content-Type: application/json'); + echo json_encode(['success' => true, 'message' => 'Call tracked successfully.', 'id' => $pdo->lastInsertId()]); + + } catch (PDOException $e) { + if ($e->errorInfo[1] == 1062) { // Duplicate entry + log_and_exit(409, "Conflict: A call with the same external_call_id already exists."); + } else { + log_and_exit(500, "Database error: " . $e->getMessage()); + } + } + +} else { + log_and_exit(405, "Method Not Allowed"); } - -try { - $stmt = db()->prepare("INSERT INTO call_tracking (external_call_id, tracking_platform, call_start_time, call_status, traffic_source) VALUES (?, ?, ?, ?, ?)"); - $stmt->execute([ - $data['external_call_id'], - $data['tracking_platform'], - $data['call_start_time'], - $data['call_status'], - $data['traffic_source'] - ]); - $new_id = db()->lastInsertId(); - logWebhook('call-tracking', $request_body, 201); - sendJsonResponse(['success' => true, 'id' => $new_id, 'message' => 'Call tracking created'], 201); -} catch (PDOException $e) { - error_log($e->getMessage()); - logWebhook('call-tracking', $request_body, 500); - sendJsonResponse(['error' => 'Database error'], 500); -} \ No newline at end of file diff --git a/api/chat-logs.php b/api/chat-logs.php new file mode 100644 index 0000000..053c134 --- /dev/null +++ b/api/chat-logs.php @@ -0,0 +1,84 @@ + 'Method not allowed'])); +} + +// Get request body +$body = file_get_contents('php://input'); + +// Always log to file first (this works) +$logFile = __DIR__ . '/webhook_debug.log'; +file_put_contents($logFile, date('Y-m-d H:i:s') . " - " . $body . " +", FILE_APPEND); + +// Try database, but don't fail if it doesn't work +$dbSuccess = false; +$dbError = null; +$insertId = null; + +try { + // Check if config exists + $configPath = __DIR__ . '/config.php'; + if (!file_exists($configPath)) { + throw new Exception("config.php not found at: " . $configPath); + } + require_once $configPath; + + // Check if $pdo exists + if (!isset($pdo)) { + throw new Exception("PDO connection not established in config.php"); + } + + // Parse Tiny Talk payload + $data = json_decode($body, true); + $eventType = $data['type'] ?? 'unknown'; + $payload = $data['payload'] ?? []; + + // Extract fields + $externalChatId = $payload['id'] ?? null; + + // Get customer name + $firstName = $payload['name']['first'] ?? ''; + $lastName = $payload['name']['last'] ?? ''; + $customerName = trim($firstName . ' ' . $lastName); + if (empty($customerName)) $customerName = 'Unknown'; + + // Get email and phone + $customerEmail = $payload['email']['address'] ?? $payload['email']['pendingAddress'] ?? null; + $customerPhone = $payload['phone']['number'] ?? null; + + // Parse the ISO date properly + $createdAt = $payload['createdAt'] ?? null; + if ($createdAt) { + $createdAt = date('Y-m-d H:i:s', strtotime($createdAt)); + } else { + $createdAt = date('Y-m-d H:i:s'); + } + + // Insert with all fields + $stmt = $pdo->prepare("INSERT INTO chat_logs (external_chat_id, event_type, customer_name, customer_email, customer_phone, raw_payload, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"); + $stmt->execute([$externalChatId, $eventType, $customerName, $customerEmail, $customerPhone, $body, $createdAt]); + $insertId = $pdo->lastInsertId(); + $dbSuccess = true; + +} catch (Exception $e) { + $dbError = $e->getMessage(); +} + +// Always return 200 +http_response_code(200); +echo json_encode([ + 'success' => true, + 'logged_to_file' => true, + 'db_success' => $dbSuccess, + 'db_error' => $dbError, + 'insert_id' => $insertId +]); \ No newline at end of file diff --git a/api/config.php b/api/config.php index cf0a1c1..98fe2a0 100644 --- a/api/config.php +++ b/api/config.php @@ -46,3 +46,6 @@ function sendJsonResponse($data, $statusCode = 200) { echo json_encode($data); exit; } + +// Create the $pdo variable for scripts that need it. +$pdo = db(); \ No newline at end of file diff --git a/api/reviews-webhook.php b/api/reviews-webhook.php new file mode 100644 index 0000000..4932914 --- /dev/null +++ b/api/reviews-webhook.php @@ -0,0 +1,92 @@ + false, 'error' => 'No data received or invalid JSON.']); + exit; +} + +try { + $pdo = db(); + + // Handle snapshot update + if (isset($data['type']) && $data['type'] === 'snapshot') { + $stmt = $pdo->prepare(" + INSERT INTO review_snapshot (location_id, total_reviews, avg_rating, reviews_this_week, reviews_this_month, google_reviews, google_avg, yelp_reviews, yelp_avg, facebook_reviews, facebook_avg, last_synced) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + total_reviews = VALUES(total_reviews), + avg_rating = VALUES(avg_rating), + reviews_this_week = VALUES(reviews_this_week), + reviews_this_month = VALUES(reviews_this_month), + google_reviews = VALUES(google_reviews), + google_avg = VALUES(google_avg), + yelp_reviews = VALUES(yelp_reviews), + yelp_avg = VALUES(yelp_avg), + facebook_reviews = VALUES(facebook_reviews), + facebook_avg = VALUES(facebook_avg), + last_synced = NOW() + "); + $stmt->execute([ + $data['location_id'] ?? 'charlotte-heating', + $data['total_reviews'] ?? 0, + $data['avg_rating'] ?? 0, + $data['reviews_this_week'] ?? 0, + $data['reviews_this_month'] ?? 0, + $data['google_reviews'] ?? 0, + $data['google_avg'] ?? 0, + $data['yelp_reviews'] ?? 0, + $data['yelp_avg'] ?? 0, + $data['facebook_reviews'] ?? 0, + $data['facebook_avg'] ?? 0 + ]); + echo json_encode(['success' => true, 'message' => 'Snapshot updated']); + exit; + } + + // Handle individual review insert + if (isset($data['type']) && $data['type'] === 'review') { + $stmt = $pdo->prepare(" + INSERT IGNORE INTO reviews (location_id, site, review_id, reviewer_name, rating, review_text, review_date) + VALUES (?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $data['location_id'] ?? 'charlotte-heating', + $data['site'] ?? 'google', + $data['review_id'] ?? uniqid(), + $data['reviewer_name'] ?? 'Anonymous', + $data['rating'] ?? null, + $data['review_text'] ?? '', + $data['review_date'] ?? null + ]); + + if ($stmt->rowCount() > 0) { + echo json_encode(['success' => true, 'message' => 'Review inserted']); + } else { + echo json_encode(['success' => true, 'message' => 'Review skipped (already exists)']); + } + exit; + } + + // Fallback for unknown type + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid data type specified.']); + +} catch (PDOException $e) { + http_response_code(500); + // In a real app, you'd log this securely. + error_log('Webhook DB Error: ' . $e->getMessage()); + echo json_encode(['success' => false, 'error' => 'A database error occurred.']); +} catch (Exception $e) { + http_response_code(500); + error_log('Webhook General Error: ' . $e->getMessage()); + echo json_encode(['success' => false, 'error' => 'An internal server error occurred.']); +} + +?> \ No newline at end of file diff --git a/api/reviews.php b/api/reviews.php index 6947d5d..560afee 100644 --- a/api/reviews.php +++ b/api/reviews.php @@ -1,55 +1,55 @@ 'Invalid request method'], 405); - exit; +check_api_key(); + +$request_method = $_SERVER['REQUEST_METHOD']; + +if ($request_method === 'POST') { + $data = json_decode(file_get_contents('php://input'), true); + + // Basic validation + if (empty($data['platform_source']) || empty($data['star_rating'])) { + log_and_exit(400, "Missing required fields: platform_source and star_rating are required."); + } + + try { + $pdo = db(); + $stmt = $pdo->prepare(" + INSERT INTO reviews ( + external_review_id, platform_source, star_rating, review_text, reviewer_name, + review_date, review_url, sentiment_score, was_ai_booked, is_negative_alert, + response_needed, response_sent, response_text + ) VALUES ( + :external_review_id, :platform_source, :star_rating, :review_text, :reviewer_name, + :review_date, :review_url, :sentiment_score, :was_ai_booked, :is_negative_alert, + :response_needed, :response_sent, :response_text + ) + "); + + $stmt->execute([ + ':external_review_id' => $data['external_review_id'] ?? null, + ':platform_source' => $data['platform_source'], + ':star_rating' => $data['star_rating'], + ':review_text' => $data['review_text'] ?? null, + ':reviewer_name' => $data['reviewer_name'] ?? null, + ':review_date' => $data['review_date'] ?? null, + ':review_url' => $data['review_url'] ?? null, + ':sentiment_score' => $data['sentiment_score'] ?? null, + ':was_ai_booked' => $data['was_ai_booked'] ?? 0, + ':is_negative_alert' => ($data['star_rating'] <= 2) ? 1 : 0, + ':response_needed' => $data['response_needed'] ?? 0, + ':response_sent' => $data['response_sent'] ?? 0, + ':response_text' => $data['response_text'] ?? null, + ]); + + header('Content-Type: application/json'); + echo json_encode(['success' => true, 'message' => 'Review logged successfully.', 'id' => $pdo->lastInsertId()]); + + } catch (PDOException $e) { + log_and_exit(500, "Database error: " . $e->getMessage()); + } + +} else { + log_and_exit(405, "Method Not Allowed"); } - -if (!validateApiKey()) { - logWebhook('reviews', file_get_contents('php://input'), 401); - sendJsonResponse(['error' => 'Unauthorized'], 401); - exit; -} - -$request_body = file_get_contents('php://input'); -$data = json_decode($request_body, true); - -if (json_last_error() !== JSON_ERROR_NONE) { - logWebhook('reviews', $request_body, 400); - sendJsonResponse(['error' => 'Invalid JSON'], 400); - exit; -} - -$errors = []; -if (empty($data['platform_source'])) { - $errors[] = 'platform_source is required'; -} -if (empty($data['star_rating'])) { - $errors[] = 'star_rating is required'; -} - - -if (!empty($errors)) { - logWebhook('reviews', $request_body, 422); - sendJsonResponse(['errors' => $errors], 422); - exit; -} - -try { - $stmt = db()->prepare("INSERT INTO reviews (platform_source, star_rating, review_text, reviewer_name, review_date) VALUES (?, ?, ?, ?, ?)"); - $stmt->execute([ - $data['platform_source'], - $data['star_rating'], - $data['review_text'] ?? null, - $data['reviewer_name'] ?? null, - $data['review_date'] ?? null - ]); - $new_id = db()->lastInsertId(); - logWebhook('reviews', $request_body, 201); - sendJsonResponse(['success' => true, 'id' => $new_id, 'message' => 'Review created'], 201); -} catch (PDOException $e) { - error_log($e->getMessage()); - logWebhook('reviews', $request_body, 500); - sendJsonResponse(['error' => 'Database error'], 500); -} \ No newline at end of file diff --git a/api/view-webhook-log.php b/api/view-webhook-log.php new file mode 100644 index 0000000..39f7ea8 --- /dev/null +++ b/api/view-webhook-log.php @@ -0,0 +1 @@ + 'Failed to fetch data from OpenWeatherMap API.']); + exit; +} + +$weather_data = json_decode($response, true); + +if ($weather_data === null || !isset($weather_data['current'])) { + http_response_code(500); + echo json_encode(['error' => 'Invalid response from OpenWeatherMap API.', 'details' => $weather_data]); + exit; +} + +// --- Data Transformation & Storage --- +$current_weather = $weather_data['current']; +$temperature_f = $current_weather['temp']; +$is_extreme_heat = ($temperature_f > 95); +$is_extreme_cold = ($temperature_f < 32); + +$weather_record = [ + ':location_name' => "Charlotte, NC", + ':zip_code' => "28202", + ':observation_time' => date('Y-m-d H:i:s', $current_weather['dt'] ?? time()), + ':weather_condition' => $current_weather['weather'][0]['main'] ?? null, + ':weather_description' => $current_weather['weather'][0]['description'] ?? null, + ':weather_icon' => $current_weather['weather'][0]['icon'] ?? null, + ':temperature_f' => $temperature_f, + ':feels_like_f' => $current_weather['feels_like'] ?? null, + ':temp_min_f' => $weather_data['daily'][0]['temp']['min'] ?? null, // Approximating from daily + ':temp_max_f' => $weather_data['daily'][0]['temp']['max'] ?? null, // Approximating from daily + ':humidity_pct' => $current_weather['humidity'] ?? null, + ':wind_speed_mph' => $current_weather['wind_speed'] ?? null, + ':is_extreme_heat' => $is_extreme_heat ? 1 : 0, + ':is_extreme_cold' => $is_extreme_cold ? 1 : 0, + ':is_severe_weather' => isset($weather_data['alerts']) ? 1 : 0, + ':weather_alerts' => isset($weather_data['alerts']) ? json_encode($weather_data['alerts']) : null, +]; + +try { + $pdo = db(); + $stmt = $pdo->prepare(" + INSERT INTO weather (zip_code, location_name, observation_time, weather_condition, weather_description, weather_icon, temperature_f, feels_like_f, temp_min_f, temp_max_f, humidity_pct, wind_speed_mph, is_extreme_heat, is_extreme_cold, is_severe_weather, weather_alerts) + VALUES (:zip_code, :location_name, :observation_time, :weather_condition, :weather_description, :weather_icon, :temperature_f, :feels_like_f, :temp_min_f, :temp_max_f, :humidity_pct, :wind_speed_mph, :is_extreme_heat, :is_extreme_cold, :is_severe_weather, :weather_alerts) + ON DUPLICATE KEY UPDATE + location_name = VALUES(location_name), + observation_time = VALUES(observation_time), + weather_condition = VALUES(weather_condition), + weather_description = VALUES(weather_description), + weather_icon = VALUES(weather_icon), + temperature_f = VALUES(temperature_f), + feels_like_f = VALUES(feels_like_f), + temp_min_f = VALUES(temp_min_f), + temp_max_f = VALUES(temp_max_f), + humidity_pct = VALUES(humidity_pct), + wind_speed_mph = VALUES(wind_speed_mph), + is_extreme_heat = VALUES(is_extreme_heat), + is_extreme_cold = VALUES(is_extreme_cold), + is_severe_weather = VALUES(is_severe_weather), + weather_alerts = VALUES(weather_alerts) + "); + + $stmt->execute($weather_record); + + // Return the fresh data + echo json_encode($weather_record); + +} catch (PDOException $e) { + error_log("DB Error: " . $e->getMessage()); + http_response_code(500); + echo json_encode(['error' => 'Database error while saving weather data.']); +} \ No newline at end of file diff --git a/api/weather.php b/api/weather.php new file mode 100644 index 0000000..baa7c2a --- /dev/null +++ b/api/weather.php @@ -0,0 +1,70 @@ + 'API key is not configured.']); + exit; +} + +$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; +if (strpos($authHeader, 'Bearer ') !== 0) { + http_response_code(401); + echo json_encode(['error' => 'Authorization header missing or invalid.']); + exit; +} +$token = substr($authHeader, 7); +if ($token !== $apiKey) { + http_response_code(401); + echo json_encode(['error' => 'Invalid API key.']); + exit; +} + +log_api_request('weather'); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $data = json_decode(file_get_contents('php://input'), true); + + try { + $pdo = db(); + $stmt = $pdo->prepare( + "INSERT INTO weather (location_name, zip_code, observation_time, weather_condition, weather_description, weather_icon, temperature_f, feels_like_f, temp_min_f, temp_max_f, humidity_pct, wind_speed_mph, is_extreme_heat, is_extreme_cold, is_severe_weather, weather_alerts) + VALUES (:location_name, :zip_code, :observation_time, :weather_condition, :weather_description, :weather_icon, :temperature_f, :feels_like_f, :temp_min_f, :temp_max_f, :humidity_pct, :wind_speed_mph, :is_extreme_heat, :is_extreme_cold, :is_severe_weather, :weather_alerts)" + ); + + $stmt->execute([ + ':location_name' => $data['location_name'] ?? null, + ':zip_code' => $data['zip_code'] ?? null, + ':observation_time' => $data['observation_time'] ?? null, + ':weather_condition' => $data['weather_condition'] ?? null, + ':weather_description' => $data['weather_description'] ?? null, + ':weather_icon' => $data['weather_icon'] ?? null, + ':temperature_f' => $data['temperature_f'] ?? null, + ':feels_like_f' => $data['feels_like_f'] ?? null, + ':temp_min_f' => $data['temp_min_f'] ?? null, + ':temp_max_f' => $data['temp_max_f'] ?? null, + ':humidity_pct' => $data['humidity_pct'] ?? null, + ':wind_speed_mph' => $data['wind_speed_mph'] ?? null, + ':is_extreme_heat' => $data['is_extreme_heat'] ?? 0, + ':is_extreme_cold' => $data['is_extreme_cold'] ?? 0, + ':is_severe_weather' => $data['is_severe_weather'] ?? 0, + ':weather_alerts' => isset($data['weather_alerts']) ? json_encode($data['weather_alerts']) : null, + ]); + + http_response_code(201); + echo json_encode(['success' => true, 'message' => 'Weather data created successfully.']); + + } catch (PDOException $e) { + error_log("DB Error: " . $e->getMessage()); + http_response_code(500); + echo json_encode(['error' => 'Database error.']); + } +} else { + http_response_code(405); // Method Not Allowed + echo json_encode(['error' => 'Only POST method is accepted.']); +} diff --git a/api/webhook_debug.log b/api/webhook_debug.log new file mode 100644 index 0000000..bdb2ed8 --- /dev/null +++ b/api/webhook_debug.log @@ -0,0 +1,97 @@ +2026-01-06 16:53:54 - {"event":"test","data":{"name":"Test"}} +2026-01-06 16:55:09 - {"type":"contact.created","payload":{"id":"4c4533ac-d463-4bb2-8d7f-7c392ff82732","userId":null,"botId":"eadd690b-fcc8-409e-8037-8fee3c2572d9","externalId":null,"name":{"first":null,"last":null},"email":{"address":null,"pendingAddress":"test@example.com"},"phone":{"number":null,"verified":null},"metaPublic":{},"metaPrivate":{"geoIp":{"ip":"192.168.1.100","hostname":"192-168-1-100.example.net","city":"Amsterdam","region":"North Holland","country":"Netherlands","loc":"52.3740,4.8897","org":"AS1136 KPN B.V.","postal":"1012","timezone":"Europe/Amsterdam","countryCode":"NL","countryFlag":{"emoji":"🇳🇱","unicode":"U+1F1F3 U+1F1F1"},"countryFlagURL":"https://cdn.ipinfo.io/static/images/countries-flags/NL.svg","countryCurrency":{"code":"EUR","symbol":"€"},"continent":{"code":"EU","name":"Europe"},"isEU":true},"ua":{"ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","browser":{"name":"Chrome","version":"127.0.0.0","major":"127"},"engine":{"name":"Blink","version":"127.0.0.0"},"os":{"name":"Mac OS","version":"10.15.7"},"device":{"vendor":"Apple","model":"Macintosh"},"cpu":{}},"languages":[{"code":"en","script":null,"region":"US","quality":1},{"code":"en","script":null,"quality":0.9}]},"createdAt":"2026-01-06T16:55:08.937Z","updatedAt":"2026-01-06T16:55:08.937Z"}} +2026-01-06 16:55:28 - {"type":"contact.created","payload":{"id":"4c4533ac-d463-4bb2-8d7f-7c392ff82732","userId":null,"botId":"eadd690b-fcc8-409e-8037-8fee3c2572d9","externalId":null,"name":{"first":null,"last":null},"email":{"address":null,"pendingAddress":"test@example.com"},"phone":{"number":null,"verified":null},"metaPublic":{},"metaPrivate":{"geoIp":{"ip":"192.168.1.100","hostname":"192-168-1-100.example.net","city":"Amsterdam","region":"North Holland","country":"Netherlands","loc":"52.3740,4.8897","org":"AS1136 KPN B.V.","postal":"1012","timezone":"Europe/Amsterdam","countryCode":"NL","countryFlag":{"emoji":"🇳🇱","unicode":"U+1F1F3 U+1F1F1"},"countryFlagURL":"https://cdn.ipinfo.io/static/images/countries-flags/NL.svg","countryCurrency":{"code":"EUR","symbol":"€"},"continent":{"code":"EU","name":"Europe"},"isEU":true},"ua":{"ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","browser":{"name":"Chrome","version":"127.0.0.0","major":"127"},"engine":{"name":"Blink","version":"127.0.0.0"},"os":{"name":"Mac OS","version":"10.15.7"},"device":{"vendor":"Apple","model":"Macintosh"},"cpu":{}},"languages":[{"code":"en","script":null,"region":"US","quality":1},{"code":"en","script":null,"quality":0.9}]},"createdAt":"2026-01-06T16:55:28.102Z","updatedAt":"2026-01-06T16:55:28.102Z"}} +2026-01-06 17:06:46 - { + "type": "contact.created", + "payload": { + "id": "test-123", + "name": {"first": "John", "last": "Doe"}, + "email": {"address": "john@example.com"}, + "phone": {"number": "704-555-1234"}, + "createdAt": "2026-01-06T12:00:00.000Z" + } +} +2026-01-06 17:06:58 - { + "type": "contact.created", + "payload": { + "id": "test-123", + "name": {"first": "John", "last": "Doe"}, + "email": {"address": "john@example.com"}, + "phone": {"number": "704-555-1234"}, + "createdAt": "2026-01-06T12:00:00.000Z" + } +} +2026-01-06 17:12:03 - { + "type": "contact.created", + "payload": { + "id": "test-123", + "name": {"first": "John", "last": "Doe"}, + "email": {"address": "john@example.com"}, + "phone": {"number": "704-555-1234"}, + "createdAt": "2026-01-06T12:00:00.000Z" + } +} +2026-01-06 17:16:42 - { + "type": "contact.created", + "payload": { + "id": "test-123", + "name": {"first": "John", "last": "Doe"}, + "email": {"address": "john@example.com"}, + "phone": {"number": "704-555-1234"}, + "createdAt": "2026-01-06T12:00:00.000Z" + } +} +2026-01-07 02:09:06 - { + "type": "contact.created", + "payload": { + "id": "test-456", + "name": {"first": "Jane", "last": "Smith"}, + "email": {"address": "jane@example.com"}, + "phone": {"number": "704-555-5678"}, + "createdAt": "2026-01-06T12:00:00.000Z" + } +} +2026-01-07 02:17:54 - { + "type": "contact.created", + "payload": { + "id": "test-789", + "name": {"first": "Mike", "last": "Johnson"}, + "email": {"address": "mike@example.com"}, + "phone": {"number": "704-555-9999"}, + "createdAt": "2026-01-06T12:00:00.000Z" + } +} +2026-01-07 02:24:17 - { + "type": "contact.created", + "payload": { + "id": "test-final", + "name": {"first": "Sarah", "last": "Wilson"}, + "email": {"address": "sarah@example.com"}, + "phone": {"number": "704-555-1111"}, + "createdAt": "2026-01-06T12:00:00.000Z" + } +} +2026-01-07 02:27:05 - {"type":"contact.created","payload":{"id":"4c4533ac-d463-4bb2-8d7f-7c392ff82732","userId":null,"botId":"eadd690b-fcc8-409e-8037-8fee3c2572d9","externalId":null,"name":{"first":null,"last":null},"email":{"address":null,"pendingAddress":"test@example.com"},"phone":{"number":null,"verified":null},"metaPublic":{},"metaPrivate":{"geoIp":{"ip":"192.168.1.100","hostname":"192-168-1-100.example.net","city":"Amsterdam","region":"North Holland","country":"Netherlands","loc":"52.3740,4.8897","org":"AS1136 KPN B.V.","postal":"1012","timezone":"Europe/Amsterdam","countryCode":"NL","countryFlag":{"emoji":"🇳🇱","unicode":"U+1F1F3 U+1F1F1"},"countryFlagURL":"https://cdn.ipinfo.io/static/images/countries-flags/NL.svg","countryCurrency":{"code":"EUR","symbol":"€"},"continent":{"code":"EU","name":"Europe"},"isEU":true},"ua":{"ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","browser":{"name":"Chrome","version":"127.0.0.0","major":"127"},"engine":{"name":"Blink","version":"127.0.0.0"},"os":{"name":"Mac OS","version":"10.15.7"},"device":{"vendor":"Apple","model":"Macintosh"},"cpu":{}},"languages":[{"code":"en","script":null,"region":"US","quality":1},{"code":"en","script":null,"quality":0.9}]},"createdAt":"2026-01-07T02:27:05.059Z","updatedAt":"2026-01-07T02:27:05.059Z"}} +2026-01-07 15:37:54 - {"type":"contact.created","payload":{"id":"4c4533ac-d463-4bb2-8d7f-7c392ff82732","userId":null,"botId":"eadd690b-fcc8-409e-8037-8fee3c2572d9","externalId":null,"name":{"first":null,"last":null},"email":{"address":null,"pendingAddress":"test@example.com"},"phone":{"number":null,"verified":null},"metaPublic":{},"metaPrivate":{"geoIp":{"ip":"192.168.1.100","hostname":"192-168-1-100.example.net","city":"Amsterdam","region":"North Holland","country":"Netherlands","loc":"52.3740,4.8897","org":"AS1136 KPN B.V.","postal":"1012","timezone":"Europe/Amsterdam","countryCode":"NL","countryFlag":{"emoji":"🇳🇱","unicode":"U+1F1F3 U+1F1F1"},"countryFlagURL":"https://cdn.ipinfo.io/static/images/countries-flags/NL.svg","countryCurrency":{"code":"EUR","symbol":"€"},"continent":{"code":"EU","name":"Europe"},"isEU":true},"ua":{"ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","browser":{"name":"Chrome","version":"127.0.0.0","major":"127"},"engine":{"name":"Blink","version":"127.0.0.0"},"os":{"name":"Mac OS","version":"10.15.7"},"device":{"vendor":"Apple","model":"Macintosh"},"cpu":{}},"languages":[{"code":"en","script":null,"region":"US","quality":1},{"code":"en","script":null,"quality":0.9}]},"createdAt":"2026-01-07T15:37:53.921Z","updatedAt":"2026-01-07T15:37:53.921Z"}} +2026-01-07 15:38:28 - {"type":"contact.created","payload":{"id":"4c4533ac-d463-4bb2-8d7f-7c392ff82732","userId":null,"botId":"eadd690b-fcc8-409e-8037-8fee3c2572d9","externalId":null,"name":{"first":null,"last":null},"email":{"address":null,"pendingAddress":"test@example.com"},"phone":{"number":null,"verified":null},"metaPublic":{},"metaPrivate":{"geoIp":{"ip":"192.168.1.100","hostname":"192-168-1-100.example.net","city":"Amsterdam","region":"North Holland","country":"Netherlands","loc":"52.3740,4.8897","org":"AS1136 KPN B.V.","postal":"1012","timezone":"Europe/Amsterdam","countryCode":"NL","countryFlag":{"emoji":"🇳🇱","unicode":"U+1F1F3 U+1F1F1"},"countryFlagURL":"https://cdn.ipinfo.io/static/images/countries-flags/NL.svg","countryCurrency":{"code":"EUR","symbol":"€"},"continent":{"code":"EU","name":"Europe"},"isEU":true},"ua":{"ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","browser":{"name":"Chrome","version":"127.0.0.0","major":"127"},"engine":{"name":"Blink","version":"127.0.0.0"},"os":{"name":"Mac OS","version":"10.15.7"},"device":{"vendor":"Apple","model":"Macintosh"},"cpu":{}},"languages":[{"code":"en","script":null,"region":"US","quality":1},{"code":"en","script":null,"quality":0.9}]},"createdAt":"2026-01-07T15:38:28.142Z","updatedAt":"2026-01-07T15:38:28.142Z"}} +2026-01-07 15:43:08 - { + "type": "contact.created", + "payload": { + "id": "test-display-check", + "name": {"first": "David", "last": "Thompson"}, + "email": {"address": "david@example.com"}, + "phone": {"number": "704-555-2222"}, + "createdAt": "2026-01-06T21:30:00.000Z" + } +} +2026-01-08 05:03:27 - { + "type": "contact.created", + "payload": { + "id": "test-display-check", + "name": {"first": "David", "last": "Thompson"}, + "email": {"address": "david@example.com"}, + "phone": {"number": "704-555-2222"}, + "createdAt": "2026-01-06T21:30:00.000Z" + } +} +2026-01-08 05:03:47 - {"type":"contact.created","payload":{"id":"4c4533ac-d463-4bb2-8d7f-7c392ff82732","userId":null,"botId":"eadd690b-fcc8-409e-8037-8fee3c2572d9","externalId":null,"name":{"first":null,"last":null},"email":{"address":null,"pendingAddress":"test@example.com"},"phone":{"number":null,"verified":null},"metaPublic":{},"metaPrivate":{"geoIp":{"ip":"192.168.1.100","hostname":"192-168-1-100.example.net","city":"Amsterdam","region":"North Holland","country":"Netherlands","loc":"52.3740,4.8897","org":"AS1136 KPN B.V.","postal":"1012","timezone":"Europe/Amsterdam","countryCode":"NL","countryFlag":{"emoji":"🇳🇱","unicode":"U+1F1F3 U+1F1F1"},"countryFlagURL":"https://cdn.ipinfo.io/static/images/countries-flags/NL.svg","countryCurrency":{"code":"EUR","symbol":"€"},"continent":{"code":"EU","name":"Europe"},"isEU":true},"ua":{"ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36","browser":{"name":"Chrome","version":"127.0.0.0","major":"127"},"engine":{"name":"Blink","version":"127.0.0.0"},"os":{"name":"Mac OS","version":"10.15.7"},"device":{"vendor":"Apple","model":"Macintosh"},"cpu":{}},"languages":[{"code":"en","script":null,"region":"US","quality":1},{"code":"en","script":null,"quality":0.9}]},"createdAt":"2026-01-08T05:03:47.257Z","updatedAt":"2026-01-08T05:03:47.257Z"}} diff --git a/assets/css/custom.css b/assets/css/custom.css index 0ff610b..b0b28e2 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,47 +1,156 @@ -/* HVAC Command Center Custom Styles */ +/* HVAC Command Center - Dark Theme */ + +:root { + --dark-bg: #1a1f2b; + --card-bg: #2d3446; + --text-light: #ffffff; + --text-muted: #aab0bb; + --accent-color: #4dc9ff; + --border-color: #3d4455; +} body { - background-color: #f8f9fa; + background-color: var(--dark-bg); + color: var(--text-light); font-family: 'Roboto', sans-serif; } h1, h2, h3, h4, h5, h6 { font-family: 'Montserrat', sans-serif; + color: var(--text-light); } .header { - background: linear-gradient(90deg, #0d6efd, #17a2b8); - padding: 1.5rem 2rem; + background: var(--card-bg); + padding: 1rem 2rem; + border-bottom: 1px solid var(--border-color); } .card { - border: none; + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; } .card:hover { transform: translateY(-5px); - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; + box-shadow: 0 8px 24px rgba(77, 201, 255, 0.15); } .card-header { - background-color: #fff; - border-bottom: 1px solid #e9ecef; + background: linear-gradient(90deg, #364057, #2d3446); + border-bottom: 1px solid var(--border-color); + border-radius: 12px 12px 0 0; + padding: 1rem 1.5rem; } -.table-hover tbody tr:hover { - background-color: #f1f3f5; +.kpi-card-header { + background: linear-gradient(135deg, rgba(77, 201, 255, 0.15), rgba(77, 201, 255, 0.05)); } -.badge.bg-emergency { - background-color: #dc3545 !important; +.table { + --bs-table-bg: var(--card-bg); + --bs-table-color: var(--text-light); + --bs-table-border-color: var(--border-color); + --bs-table-hover-bg: #364057; + --bs-table-hover-color: var(--text-light); } -.badge.bg-urgent { - background-color: #ffc107 !important; - color: #000 !important; +.table-striped>tbody>tr:nth-of-type(odd)>* { + --bs-table-accent-bg: #262c3a; } -.badge.bg-routine { - background-color: #6c757d !important; +.badge.bg-success { background-color: #28a745 !important; } +.badge.bg-warning { background-color: #ffc107 !important; color: #000 !important; } +.badge.bg-danger { background-color: #dc3545 !important; } +.badge.bg-info { background-color: var(--accent-color) !important; color: #000 !important;} + + +/* Sidebar Navigation */ +.sidebar { + position: fixed; + top: 0; + left: 0; + width: 250px; + height: 100vh; + background-color: var(--card-bg); + padding: 1rem; + z-index: 1030; + border-right: 1px solid var(--border-color); + box-shadow: 4px 0 10px rgba(0,0,0,0.1); } + +.sidebar .nav-link { + color: var(--text-muted); + font-size: 1rem; + padding: 0.75rem 1rem; + border-radius: 8px; + transition: background-color 0.2s, color 0.2s; +} + +.sidebar .nav-link:hover, +.sidebar .nav-link.active { + background-color: rgba(77, 201, 255, 0.1); + color: var(--accent-color); +} + +.sidebar .nav-link i { + margin-right: 10px; +} + +.sidebar-header { + text-align: center; + margin-bottom: 1rem; +} + +.main-content { + margin-left: 250px; + padding: 2rem; +} + +@media (max-width: 768px) { + .sidebar { + width: 100%; + height: auto; + position: relative; + z-index: 1031; + } + .main-content { + margin-left: 0; + } +} + +/* Stat Cards */ +.stat-card .icon { + font-size: 2.5rem; + color: var(--accent-color); + opacity: 0.7; +} + +.stat-card .change-indicator { + font-size: 1rem; +} +.stat-card .change-indicator.positive { color: #28a745; } +.stat-card .change-indicator.negative { color: #dc3545; } + +/* Weather Widget */ +#weather-widget { + background: linear-gradient(135deg, #2d3446, #1a1f2b); +} + +/* Map Widget */ +#weather-map { + height: 400px; + border-radius: 8px; +} + +.leaflet-tile { + filter: brightness(0.8) contrast(1.2); +} + +.leaflet-popup-content-wrapper, .leaflet-popup-tip { + background-color: var(--card-bg); + color: var(--text-light); +} \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index 4bfa124..c984f7d 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1 +1,86 @@ -// Future javascript for the HVAC Command Center \ No newline at end of file + +document.addEventListener('DOMContentLoaded', function () { + const refreshButton = document.getElementById('refresh-weather-btn'); + const weatherContent = document.getElementById('weather-content'); + const weatherLoading = document.getElementById('weather-loading'); + const weatherError = document.getElementById('weather-error'); + + const weatherIcon = document.getElementById('weather-icon'); + const weatherTemp = document.getElementById('weather-temp'); + const weatherFeelsLike = document.getElementById('weather-feels-like'); + const weatherDesc = document.getElementById('weather-desc'); + const weatherLastUpdated = document.getElementById('weather-last-updated'); + const weatherAlerts = document.getElementById('weather-alerts'); + const weatherPlaceholder = document.getElementById('weather-placeholder'); + + async function fetchWeather() { + // Show loading state + weatherContent.style.display = 'none'; + weatherError.style.display = 'none'; + weatherLoading.style.display = 'block'; + refreshButton.disabled = true; + + try { + const response = await fetch('api/weather-fetch.php'); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + // Update UI + if (weatherPlaceholder) { + weatherPlaceholder.style.display = 'none'; + } + weatherIcon.src = `https://openweathermap.org/img/wn/${data[':weather_icon']}@2x.png`; + weatherTemp.innerHTML = `${Math.round(data[':temperature_f'])}°F`; + weatherFeelsLike.innerHTML = `Feels like ${Math.round(data[':feels_like_f'])}°F`; + weatherDesc.textContent = data[':weather_description'].charAt(0).toUpperCase() + data[':weather_description'].slice(1); + + const observationDate = new Date(data[':observation_time'].replace(/-/g, '/')); + weatherLastUpdated.textContent = observationDate.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + + // Clear and build alerts + weatherAlerts.innerHTML = ''; + if (data[':is_extreme_heat'] == 1) { + weatherAlerts.innerHTML += ` + `; + } + if (data[':is_extreme_cold'] == 1) { + weatherAlerts.innerHTML += ` + `; + } + + + weatherContent.style.display = 'block'; + + } catch (error) { + console.error("Error fetching weather:", error); + weatherError.textContent = 'Failed to fetch weather data. Please try again.'; + weatherError.style.display = 'block'; + } finally { + // Hide loading state + weatherLoading.style.display = 'none'; + refreshButton.disabled = false; + } + } + + // --- Event Listeners --- + if(refreshButton) { + refreshButton.addEventListener('click', fetchWeather); + } + + + // --- Auto-refresh Timer --- + // Auto-refresh every 30 minutes (1800000 milliseconds) + setInterval(fetchWeather, 1800000); +}); diff --git a/assets/js/map.js b/assets/js/map.js new file mode 100644 index 0000000..afa3689 --- /dev/null +++ b/assets/js/map.js @@ -0,0 +1,38 @@ + +var map; +var layers; +var currentLayer = null; +var owmApiKey = 'ff101be91e4bbe53d6ffbbec1868dfc0'; + +function showLayer(layerName) { + if (currentLayer) { + map.removeLayer(currentLayer); + } + if (layers[layerName]) { + currentLayer = layers[layerName]; + currentLayer.addTo(map); + } + document.querySelectorAll('.map-btn').forEach(btn => btn.classList.remove('active')); + document.querySelector(`.map-btn[data-layer="${layerName}"]`)?.classList.add('active'); +} + +document.addEventListener('DOMContentLoaded', function() { + var mapContainer = document.getElementById('weather-map'); + if (!mapContainer) return; + + map = L.map('weather-map').setView([35.2271, -80.8431], 8); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(map); + + layers = { + temp: L.tileLayer('https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=' + owmApiKey, { opacity: 0.85 }), + precip: L.tileLayer('https://tile.openweathermap.org/map/precipitation_new/{z}/{x}/{y}.png?appid=' + owmApiKey, { opacity: 0.85 }), + clouds: L.tileLayer('https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=' + owmApiKey, { opacity: 0.85 }), + wind: L.tileLayer('https://tile.openweathermap.org/map/wind_speed/{z}/{x}/{y}.png?appid=' + owmApiKey, { opacity: 0.85 }) + }; + + L.marker([35.2271, -80.8431]).addTo(map).bindPopup('Charlotte, NC').openPopup(); + + showLayer('temp'); +}); diff --git a/bookings.php b/bookings.php index 6078fbf..684519d 100644 --- a/bookings.php +++ b/bookings.php @@ -60,7 +60,7 @@ $page_title = "Bookings"; - + @@ -70,49 +70,40 @@ $page_title = "Bookings"; -
-
-

+
+ + - +
+
+

+
-
-
- +
-
+
-
All Bookings
+
All Bookings
- @@ -120,7 +111,7 @@ $page_title = "Bookings";
- @@ -128,7 +119,7 @@ $page_title = "Bookings";
- @@ -143,7 +134,7 @@ $page_title = "Bookings";
- + @@ -177,13 +168,9 @@ $page_title = "Bookings"; - - -
- Powered by Flatlogic -
+ - \ No newline at end of file + diff --git a/calendar.php b/calendar.php new file mode 100644 index 0000000..de5fe85 --- /dev/null +++ b/calendar.php @@ -0,0 +1,125 @@ +query("SELECT title, start_date as start, end_date as end, event_type FROM calendar_events"); + $events = $stmt->fetchAll(PDO::FETCH_ASSOC); + $calendar_events_json = json_encode($events); + + // Stat cards data + $stmt_upcoming = $pdo->prepare("SELECT COUNT(*) FROM calendar_events WHERE start_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)"); + $stmt_upcoming->execute(); + $upcoming_appointments_count = $stmt_upcoming->fetchColumn(); + +} catch (PDOException $e) { + $error = "Database error: " . $e->getMessage(); +} + +$project_name = "HVAC Command Center"; +$page_title = "Calendar"; + +?> + + + + + + + <?= htmlspecialchars($page_title) ?> - <?= htmlspecialchars($project_name) ?> + + + + + + + + + + + + + + +
+
+

+
+ + +
+ + +
+
+
+
+
Upcoming Appointments (Next 7 Days)
+

+
+
+
+
+
+
+
Technician Workload
+

Workload summary coming soon.

+
+
+
+
+ + +
+
+
+
+
+ +
+ + + + + diff --git a/call-tracking.php b/call-tracking.php new file mode 100644 index 0000000..82c911d --- /dev/null +++ b/call-tracking.php @@ -0,0 +1,202 @@ +prepare("SELECT COUNT(*) as total FROM call_tracking WHERE DATE(call_start_time) = ?"); + $stmt_calls_today->execute([$today]); + $total_calls_today = $stmt_calls_today->fetch()['total']; + + $stmt_calls_week = $pdo->prepare("SELECT COUNT(*) as total FROM call_tracking WHERE call_start_time >= ?"); + $stmt_calls_week->execute([$this_week_start]); + $total_calls_week = $stmt_calls_week->fetch()['total']; + + $stmt_calls_month = $pdo->prepare("SELECT COUNT(*) as total FROM call_tracking WHERE call_start_time >= ?"); + $stmt_calls_month->execute([$this_month_start]); + $total_calls_month = $stmt_calls_month->fetch()['total']; + + // Calls by source (for pie chart) + $stmt_calls_by_source = $pdo->query(" + SELECT traffic_source, COUNT(*) as count + FROM call_tracking + GROUP BY traffic_source + ORDER BY count DESC + "); + $calls_by_source = $stmt_calls_by_source->fetchAll(); + $chart_labels_source = json_encode(array_column($calls_by_source, 'traffic_source')); + $chart_data_source = json_encode(array_column($calls_by_source, 'count')); + + // AI Rescue Rate + $stmt_ai_rescue = $pdo->query(" + SELECT + (SUM(CASE WHEN was_ai_rescue = 1 THEN 1 ELSE 0 END) / COUNT(*)) * 100 as rescue_rate + FROM call_tracking + WHERE call_status = 'missed' OR answered_by != 'human' + "); + $ai_rescue_rate = $stmt_ai_rescue->fetchColumn() ?? 0; + + // Recent calls + $stmt_recent_calls = $pdo->query('SELECT * FROM call_tracking ORDER BY call_start_time DESC LIMIT 10'); + $recent_calls = $stmt_recent_calls->fetchAll(); + +} catch (PDOException $e) { + $error = "Database error: " . $e->getMessage(); +} + +$project_name = "HVAC Command Center"; +$page_title = "Call Tracking"; + +?> + + + + + + <?= htmlspecialchars($page_title) ?> | <?= htmlspecialchars($project_name) ?> + + + + + + + + +
+
+

+
+ + +
+ + +
+
+
+
+
Calls Today
+

+
+
+
+
+
+
+
This Week
+

+
+
+
+
+
+
+
This Month
+

+
+
+
+
+
+
+
AI Rescue Rate
+

%

+
+
+
+
+ + +
+
+
+
Calls by Source
+
+
+
+
+ + +
+
Recent Calls
+
+
+
Date Customer
+ + + + + + + + + + + + + + + + + + +
CallerSourceStatusAnswered ByDurationDate
No calls recorded yet.
+
+
+
+ +
+ + + + + + \ No newline at end of file diff --git a/chat-logs.php b/chat-logs.php new file mode 100644 index 0000000..862579b --- /dev/null +++ b/chat-logs.php @@ -0,0 +1,225 @@ + +prepare("SELECT COUNT(*) FROM chat_logs WHERE created_at >= ?"); + $stmt_chats_today->execute([$today_start]); + $chats_today = $stmt_chats_today->fetchColumn(); + + $stmt_chats_week = $pdo->prepare("SELECT COUNT(*) FROM chat_logs WHERE created_at >= ?"); + $stmt_chats_week->execute([$this_week_start]); + $chats_this_week = $stmt_chats_week->fetchColumn(); + + $stmt_chats_month = $pdo->prepare("SELECT COUNT(*) FROM chat_logs WHERE created_at >= ?"); + $stmt_chats_month->execute([$this_month_start]); + $chats_this_month = $stmt_chats_month->fetchColumn(); + + // Conversion Rate + $stmt_total_chats = $pdo->query("SELECT COUNT(*) FROM chat_logs"); + $total_chats = $stmt_total_chats->fetchColumn(); + $stmt_converted_chats = $pdo->query("SELECT COUNT(*) FROM chat_logs WHERE was_converted = 1"); + $converted_chats = $stmt_converted_chats->fetchColumn(); + $conversion_rate = ($total_chats > 0) ? ($converted_chats / $total_chats) * 100 : 0; + + // Average Duration + $stmt_avg_duration = $pdo->query("SELECT AVG(chat_duration_seconds) FROM chat_logs"); + $avg_duration_seconds = $stmt_avg_duration->fetchColumn(); + $avg_duration = ($avg_duration_seconds) ? gmdate("i:s", $avg_duration_seconds) : 'N/A'; + + // Chat Outcomes (for Pie Chart) + $stmt_outcomes = $pdo->query("SELECT chat_outcome, COUNT(*) as count FROM chat_logs GROUP BY chat_outcome"); + $chat_outcomes = $stmt_outcomes->fetchAll(PDO::FETCH_KEY_PAIR); + + // Recent Chats + $stmt_recent_chats = $pdo->query("SELECT * FROM chat_logs ORDER BY created_at DESC LIMIT 10"); + $recent_chats = $stmt_recent_chats->fetchAll(); + +} catch (PDOException $e) { + $error = "Database error: " . $e->getMessage(); +} + +$project_name = "HVAC Command Center"; +$page_title = "Chat Logs"; + +?> + + + + + + <?= htmlspecialchars($page_title) ?> | <?= htmlspecialchars($project_name) ?> + + + + + + + + + + + + +
+
+

+
+ + +
+ +
+ + +
+
+
+
+
Chats Today
+

+
+
+
+
+
+
+
This Week
+

+
+
+
+
+
+
+
Conversion Rate
+

%

+
+
+
+
+
+
+
Avg. Duration
+

+
+
+
+
+ +
+ +
+
+
+
Chat Outcomes
+
+
+ +
+
+
+ + +
+
+
+
Recent Chats
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
DateCustomerDurationOutcomeConverted
No chats found.
+ + Yes + + No + +
+
+
+
+
+
+ + +
+ + + + + diff --git a/customers.php b/customers.php index 15dbabf..9c5dea2 100644 --- a/customers.php +++ b/customers.php @@ -9,7 +9,6 @@ try { $customers = $stmt_customers->fetchAll(); } catch (PDOException $e) { - // For production, you would log this error and show a user-friendly message. $error = "Database error: " . $e->getMessage(); } @@ -27,7 +26,7 @@ $page_title = "Customers"; - + @@ -37,49 +36,40 @@ $page_title = "Customers"; -
-
-

+
+ +
- +
+
+

+
-
-
- +
-
+
-
All Customers
+
All Customers
- + @@ -109,13 +99,9 @@ $page_title = "Customers"; - - -
- Powered by Flatlogic -
+ - \ No newline at end of file + diff --git a/db/schema.sql b/db/schema.sql index 3859b17..524f902 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,46 +1,48 @@ --- Adapted from user requirements for MySQL/MariaDB +-- Raw reviews storage +CREATE TABLE IF NOT EXISTS reviews ( + id INT AUTO_INCREMENT PRIMARY KEY, + location_id VARCHAR(100), + site VARCHAR(50), + review_id VARCHAR(255), + reviewer_name VARCHAR(255), + rating DECIMAL(2,1), + review_text TEXT, + review_date DATE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_review (site, review_id) +); -CREATE TABLE IF NOT EXISTS `customers` ( - `id` INT AUTO_INCREMENT PRIMARY KEY, - `first_name` VARCHAR(100), - `last_name` VARCHAR(100), - `email` VARCHAR(255) UNIQUE, - `phone` VARCHAR(20), - `phone_normalized` VARCHAR(15), - `service_address` TEXT, - `city` VARCHAR(100), - `state` VARCHAR(50), - `zip_code` VARCHAR(20), - `lifetime_value` DECIMAL(10, 2) DEFAULT 0, - `total_bookings` INT DEFAULT 0, - `total_quotes` INT DEFAULT 0, - `acquisition_source` ENUM('google_ads', 'google_lsa', 'organic', 'referral', 'facebook', 'yelp', 'direct', 'other'), - `acquisition_campaign` VARCHAR(255), - `first_contact_date` DATETIME DEFAULT CURRENT_TIMESTAMP, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Daily aggregates for fast dashboard queries +CREATE TABLE IF NOT EXISTS review_aggregates ( + id INT AUTO_INCREMENT PRIMARY KEY, + location_id VARCHAR(100), + site VARCHAR(50), + date DATE, + total_reviews INT, + avg_rating DECIMAL(3,2), + five_star INT DEFAULT 0, + four_star INT DEFAULT 0, + three_star INT DEFAULT 0, + two_star INT DEFAULT 0, + one_star INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_aggregate (location_id, site, date) +); -CREATE TABLE IF NOT EXISTS `bookings` ( - `id` INT AUTO_INCREMENT PRIMARY KEY, - `record_type` ENUM('appointment', 'quote_request') NOT NULL, - `customer_name` VARCHAR(255), - `customer_phone` VARCHAR(20), - `customer_email` VARCHAR(255), - `service_address` TEXT, - `service_category` ENUM('repair', 'maintenance', 'installation', 'inspection', 'emergency'), - `service_type` VARCHAR(100), - `system_type` ENUM('central_air', 'heat_pump', 'furnace', 'mini_split', 'boiler', 'other'), - `urgency_level` ENUM('routine', 'urgent', 'emergency'), - `issue_description` TEXT, - `appointment_date` DATE, - `appointment_time` VARCHAR(20), - `status` ENUM('new', 'confirmed', 'dispatched', 'in_progress', 'completed', 'cancelled', 'no_show') NOT NULL DEFAULT 'new', - `estimated_revenue` DECIMAL(10, 2), - `actual_revenue` DECIMAL(10, 2), - `booked_by` ENUM('ai_agent', 'human_agent', 'online', 'walk_in'), - `customer_id` INT, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Latest snapshot for quick dashboard display +CREATE TABLE IF NOT EXISTS review_snapshot ( + id INT AUTO_INCREMENT PRIMARY KEY, + location_id VARCHAR(100), + total_reviews INT, + avg_rating DECIMAL(3,2), + reviews_this_week INT, + reviews_this_month INT, + google_reviews INT, + google_avg DECIMAL(3,2), + yelp_reviews INT, + yelp_avg DECIMAL(3,2), + facebook_reviews INT, + facebook_avg DECIMAL(3,2), + last_synced TIMESTAMP, + UNIQUE KEY unique_snapshot (location_id) +); diff --git a/index.php b/index.php index 3e76913..3486756 100644 --- a/index.php +++ b/index.php @@ -20,14 +20,11 @@ try { $stmt_recent_bookings = $pdo->query('SELECT * FROM bookings ORDER BY created_at DESC LIMIT 5'); $recent_bookings = $stmt_recent_bookings->fetchAll(); - // --- API Key Management --- - // Handle API key generation if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['generate_api_key'])) { $new_key = 'hvac_' . bin2hex(random_bytes(16)); $stmt_insert_key = $pdo->prepare("INSERT INTO api_keys (api_key) VALUES (?)"); $stmt_insert_key->execute([$new_key]); - // Redirect to avoid form resubmission header("Location: " . $_SERVER['PHP_SELF']); exit; } @@ -36,13 +33,31 @@ try { $stmt_api_keys = $pdo->query('SELECT * FROM api_keys ORDER BY created_at DESC'); $api_keys = $stmt_api_keys->fetchAll(); + // Fetch latest weather data + $default_zip_code = '28202'; + $stmt_weather = $pdo->prepare('SELECT * FROM weather WHERE zip_code = ? ORDER BY observation_time DESC LIMIT 1'); + $stmt_weather->execute([$default_zip_code]); + $current_weather = $stmt_weather->fetch(); + + // Fetch chat stats + $today_start = date('Y-m-d 00:00:00'); + $stmt_chats_today = $pdo->prepare("SELECT COUNT(*) FROM chat_logs WHERE chat_start_time >= ?"); + $stmt_chats_today->execute([$today_start]); + $chats_today_count = $stmt_chats_today->fetchColumn(); + + $stmt_total_chats = $pdo->query("SELECT COUNT(*) FROM chat_logs"); + $total_chats = $stmt_total_chats->fetchColumn(); + $stmt_converted_chats = $pdo->query("SELECT COUNT(*) FROM chat_logs WHERE was_converted = 1"); + $converted_chats = $stmt_converted_chats->fetchColumn(); + $chat_conversion_rate = ($total_chats > 0) ? ($converted_chats / $total_chats) * 100 : 0; + } catch (PDOException $e) { - // For production, you would log this error and show a user-friendly message. $error = "Database error: " . $e->getMessage(); } $project_name = "HVAC Command Center"; $project_description = "Central dashboard for managing your HVAC business operations."; +$page_title = "Dashboard"; ?> @@ -51,7 +66,7 @@ $project_description = "Central dashboard for managing your HVAC business operat - <?= htmlspecialchars($project_name) ?> + <?= htmlspecialchars($page_title . ' | ' . $project_name) ?> @@ -62,8 +77,9 @@ $project_description = "Central dashboard for managing your HVAC business operat - + + @@ -71,167 +87,233 @@ $project_description = "Central dashboard for managing your HVAC business operat - -
-
-

+
+ + - + -
- -
- -
+
+
+
+
+
+
+
Total Customers
+

+ 5% this month +
+
+
+
+
+
+
+
+
+
Total Bookings
+

+ 12% this month +
+
+
+
+
+
+
+
+
+
Chats Today
+

+ % conv. +
+
+
+
+
+
+
+
+
+
Completed Revenue
+

$

+ 8% this month +
+
+
+
+
+
+
-
-
-
- -
-
Total Customers
-

- View All +
+ +
+
+
+
Current Weather
+
+
+ +
+
+
+

Last updated:

+
+
+ Weather icon + °F +

Feels like °F

+
+
+

+
+ +

Weather data not available. Click Refresh.

+ +
+ + +
+
+ + +
+
Service Area Weather Map
+
+
-
-
-
- -
-
Total Bookings
-

- View All +
+ +
+
Recent Bookings
+
+
+
Name Phone
+ + + + + + + + + + + + + + + + +
DateCustomerServiceUrgencyStatusEst. Revenue
$
No recent bookings found.
-
-
-
-
- -
-
Completed Revenue
-

$

+ +
+
+
API Keys
+ +
+
+
+ + + + + + + + + + + + + + + +
API KeyStatusCreated OnActions
No API keys found.
- -
-
-
API Keys
-
- -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - -
API KeyStatusCreated OnActions
No API keys found.
+
+
+
+
Booking Trends
+
- -
-
-
Recent Bookings
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
DateCustomerServiceUrgencyStatusEst. Revenue
$
No recent bookings found.
-
-
-
-
- -
- Powered by Flatlogic -
+
+ + + + - \ No newline at end of file + diff --git a/reviews.php b/reviews.php new file mode 100644 index 0000000..9dd0080 --- /dev/null +++ b/reviews.php @@ -0,0 +1,238 @@ + + + + + + + <?= htmlspecialchars($page_title) ?> | <?= htmlspecialchars($project_name) ?> + + + + + + + + +
+
+

+ +
+ + + +
+ +
+
+
+
+
Average Rating
+

+
+
+
+
+
+
+
Total Reviews
+

+
+
+
+
+ + +
+
+
+
Rating Breakdown
+
+
+
+
+
+
Reviews by Source
+
+
+
+
+ + +
+
Recent Reviews
+
+
+ + + + + +
ReviewerSourceRatingDateComment
Click "Sync Reviews" to load data.
+
+
+
+
+
+ + + + + +