Compare commits

...

2 Commits

Author SHA1 Message Date
Flatlogic Bot
156adbe5db Review N8N V.2 2026-01-11 01:28:40 +00:00
Flatlogic Bot
26d8efd021 N8N API Testing 2026-01-02 22:45:22 +00:00
30 changed files with 3150 additions and 154 deletions

View File

@ -1,18 +1,12 @@
DirectoryIndex index.php index.html
Options -Indexes
Options -MultiViews
RewriteEngine On
# 0) Serve existing files/directories as-is
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Route API calls to the correct script
RewriteRule ^api/bookings$ api/bookings.php [L]
RewriteRule ^api/ai-call-logs$ api/ai-call-logs.php [L]
RewriteRule ^api/call-tracking$ api/call-tracking.php [L]
RewriteRule ^api/reviews$ api/reviews.php [L]
# 1) Internal map: /page or /page/ -> /page.php (if such PHP file exists)
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+?)/?$ $1.php [L]
# 2) Optional: strip trailing slash for non-directories (keeps .php links working)
# Standard rule to pass requests to index.php if they are not files or directories
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.+)/$ $1 [R=301,L]
RewriteRule ^.*$ index.php [L]

209
ai-call-logs.php Normal file
View File

@ -0,0 +1,209 @@
<?php
require_once 'db/config.php';
try {
$pdo = db();
// Fetch all AI call logs
$stmt_call_logs = $pdo->query('SELECT * FROM ai_call_logs ORDER BY call_start_time DESC');
$call_logs = $stmt_call_logs->fetchAll();
// Calculate performance metrics
$total_calls = count($call_logs);
$total_duration_seconds = 0;
foreach ($call_logs as $log) {
$start = new DateTime($log['call_start_time']);
$end = new DateTime($log['call_end_time']);
$duration = $end->getTimestamp() - $start->getTimestamp();
$total_duration_seconds += $duration;
}
$avg_call_duration = $total_calls > 0 ? $total_duration_seconds / $total_calls : 0;
// Data for charts
$intent_distribution = [];
$outcome_distribution = [];
foreach ($call_logs as $log) {
$intent_distribution[$log['call_intent']] = ($intent_distribution[$log['call_intent']] ?? 0) + 1;
$outcome_distribution[$log['call_outcome']] = ($outcome_distribution[$log['call_outcome']] ?? 0) + 1;
}
} catch (PDOException $e) {
$error = "Database error: " . $e->getMessage();
}
$project_name = "HVAC Command Center";
$page_title = "AI Call Logs";
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($page_title) ?> | <?= htmlspecialchars($project_name) ?></title>
<!-- Styles -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
</div>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
<li class="nav-item"><a class="nav-link active" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
</ul>
</div>
<div class="main-content">
<header class="d-flex justify-content-between align-items-center mb-4">
<h1><?= htmlspecialchars($page_title) ?></h1>
</header>
<?php if (isset($error)): ?>
<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i><?= htmlspecialchars($error) ?></div>
<?php else: ?>
<!-- Performance Metrics -->
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card stat-card h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted">Total Calls</h6>
<h4 class="fw-bold mb-0"><?= htmlspecialchars($total_calls) ?></h4>
</div>
<div class="icon"><i class="fas fa-phone-volume"></i></div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card stat-card h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted">Avg. Call Duration</h6>
<h4 class="fw-bold mb-0"><?= htmlspecialchars(round($avg_call_duration)) ?>s</h4>
</div>
<div class="icon"><i class="fas fa-clock"></i></div>
</div>
</div>
</div>
</div>
<!-- Charts -->
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header"><h5 class="m-0">Call Intent Distribution</h5></div>
<div class="card-body"><canvas id="intentChart"></canvas></div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header"><h5 class="m-0">Call Outcome Distribution</h5></div>
<div class="card-body"><canvas id="outcomeChart"></canvas></div>
</div>
</div>
</div>
<!-- Call Logs Table -->
<div class="card">
<div class="card-header"><h5 class="m-0"><i class="fas fa-list-ul me-2"></i>All AI Call Logs</h5></div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr><th>Call ID</th><th>Start Time</th><th>End Time</th><th>Intent</th><th>Outcome</th><th>Summary</th></tr>
</thead>
<tbody>
<?php foreach ($call_logs as $log): ?>
<tr>
<td><?= htmlspecialchars($log['call_id']) ?></td>
<td><?= htmlspecialchars(date("M d, Y H:i:s", strtotime($log['call_start_time']))) ?></td>
<td><?= htmlspecialchars(date("M d, Y H:i:s", strtotime($log['call_end_time']))) ?></td>
<td><span class="badge bg-info"><?= htmlspecialchars($log['call_intent']) ?></span></td>
<td><span class="badge bg-primary"><?= htmlspecialchars($log['call_outcome']) ?></span></td>
<td><small><?= htmlspecialchars($log['call_summary']) ?></small></td>
</tr>
<?php endforeach; ?>
<?php if (empty($call_logs)): ?>
<tr><td colspan="6" class="text-center text-muted">No call logs found.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
Chart.defaults.color = '#aab0bb';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
const chartBackgroundColor = ['#4dc9ff', '#ff6384', '#ffce56', '#36a2eb', '#9966ff', '#ff9f40'];
// Intent Chart
const intentCtx = document.getElementById('intentChart').getContext('2d');
new Chart(intentCtx, {
type: 'doughnut',
data: {
labels: <?= json_encode(array_keys($intent_distribution)) ?>,
datasets: [{
data: <?= json_encode(array_values($intent_distribution)) ?>,
backgroundColor: chartBackgroundColor,
borderColor: '#2d3446',
borderWidth: 3
}]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom', labels: { color: '#aab0bb' } } }
}
});
// Outcome Chart
const outcomeCtx = document.getElementById('outcomeChart').getContext('2d');
new Chart(outcomeCtx, {
type: 'pie',
data: {
labels: <?= json_encode(array_keys($outcome_distribution)) ?>,
datasets: [{
data: <?= json_encode(array_values($outcome_distribution)) ?>,
backgroundColor: chartBackgroundColor,
borderColor: '#2d3446',
borderWidth: 3
}]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom', labels: { color: '#aab0bb' } } }
}
});
});
</script>
</body>
</html>

200
api-keys.php Normal file
View File

@ -0,0 +1,200 @@
<?php
require_once __DIR__ . '/api/keys.php';
$generated_key = null;
$error = null;
$success = null;
// Handle POST requests for key management
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['generate_api_key'])) {
$keyName = trim($_POST['key_name']);
$integrationType = trim($_POST['integration_type']);
$result = generateApiKey($keyName, $integrationType);
if ($result['success']) {
$generated_key = $result;
$success = "Successfully generated new API key. Please copy it now, it won't be shown again.";
} else {
$error = $result['message'];
}
} elseif (isset($_POST['deactivate_api_key'])) {
$keyId = $_POST['key_id'];
$result = deactivateApiKey($keyId);
if ($result['success']) {
$success = $result['message'];
} else {
$error = $result['message'];
}
} elseif (isset($_POST['delete_api_key'])) {
$keyId = $_POST['key_id'];
$result = deleteApiKey($keyId);
if ($result['success']) {
$success = $result['message'];
} else {
$error = $result['message'];
}
}
}
// Fetch all existing API keys to display
$api_keys = listApiKeys();
$project_name = "HVAC Command Center";
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Key Management - <?= htmlspecialchars($project_name) ?></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
</head>
<body>
<header class="header text-white">
<div class="container-fluid">
<h1 class="display-6 m-0"><?= htmlspecialchars($project_name) ?></h1>
</div>
</header>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="index.php">Dashboard</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="customers.php">Customers</a></li>
<li class="nav-item"><a class="nav-link" href="bookings.php">Bookings</a></li>
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php">AI Call Logs</a></li>
<li class="nav-item"><a class="nav-link active" href="api-keys.php">API Keys</a></li>
</ul>
</div>
</div>
</nav>
<main class="container-fluid mt-4">
<h2 class="mb-4"><i class="bi bi-key-fill me-2"></i>API Key Management</h2>
<?php if ($error): ?>
<div class="alert alert-danger"><i class="bi bi-exclamation-triangle-fill me-2"></i><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success"><i class="bi bi-check-circle-fill me-2"></i><?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<?php if ($generated_key): ?>
<div class="alert alert-warning" id="generated-key-alert">
<h5 class="alert-heading"><i class="bi bi-shield-lock-fill me-2"></i>Your New API Key</h5>
<p>Please copy it now. It will not be shown again.</p>
<div class="input-group">
<input type="text" id="new-api-key" class="form-control form-control-lg bg-white" value="<?= htmlspecialchars($generated_key['api_key']) ?>" readonly style="font-family: monospace;">
<button class="btn btn-dark" type="button" onclick="copyToClipboard()">
<i class="bi bi-clipboard-check-fill me-2"></i>Copy
</button>
</div>
<hr>
<p class="mb-0">Use this key in the <code>Authorization</code> header as a Bearer token: <code>Bearer <?= htmlspecialchars($generated_key['api_key']) ?></code></p>
</div>
<?php endif; ?>
<div class="card shadow-sm mb-4">
<div class="card-header">
<h5 class="m-0">Create New API Key</h5>
</div>
<div class="card-body">
<form method="POST" action="api-keys.php">
<div class="row g-3 align-items-end">
<div class="col-md-5">
<label for="key_name" class="form-label">Key Name</label>
<input type="text" class="form-control" id="key_name" name="key_name" placeholder="e.g., N8N Production" required>
</div>
<div class="col-md-5">
<label for="integration_type" class="form-label">Integration Type</label>
<select class="form-select" id="integration_type" name="integration_type" required>
<option value="n8n">n8n</option>
<option value="callrail">CallRail</option>
<option value="twilio">Twilio</option>
<option value="giveme5">Giveme5.ai</option>
<option value="google">Google</option>
<option value="other">Other</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" name="generate_api_key" class="btn btn-primary w-100">Generate Key</button>
</div>
</div>
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header">
<h5 class="m-0">Existing API Keys</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Integration</th>
<th>Status</th>
<th>Created</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($api_keys)): ?>
<tr><td colspan="6" class="text-center text-muted">No API keys found.</td></tr>
<?php else: ?>
<?php foreach ($api_keys as $key): ?>
<tr>
<td><?= htmlspecialchars($key['key_name']) ?></td>
<td><span class="badge bg-secondary"><?= htmlspecialchars($key['integration_type']) ?></span></td>
<td><span class="badge bg-<?= $key['is_active'] ? 'success' : 'secondary' ?>"><?= $key['is_active'] ? 'Active' : 'Inactive' ?></span></td>
<td><?= htmlspecialchars(date("M d, Y", strtotime($key['created_at']))) ?></td>
<td><?= $key['last_used_at'] ? htmlspecialchars(date("M d, Y H:i", strtotime($key['last_used_at']))) : 'Never' ?></td>
<td>
<form method="POST" action="api-keys.php" onsubmit="return confirm('Are you sure?');" class="d-inline">
<input type="hidden" name="key_id" value="<?= $key['id'] ?>">
<?php if ($key['is_active']): ?>
<button type="submit" name="deactivate_api_key" class="btn btn-sm btn-warning" title="Deactivate"><i class="bi bi-pause-circle-fill"></i></button>
<?php endif; ?>
<button type="submit" name="delete_api_key" class="btn btn-sm btn-danger" title="Delete"><i class="bi bi-trash-fill"></i></button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</main>
<footer class="container-fluid text-center text-muted py-3 mt-4">
<small>Powered by Flatlogic</small>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
function copyToClipboard() {
const keyInput = document.getElementById('new-api-key');
keyInput.select();
keyInput.setSelectionRange(0, 99999); // For mobile devices
document.execCommand('copy');
// Optional: Provide feedback to the user
const originalBtn = document.querySelector('#generated-key-alert button');
originalBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Copied!';
setTimeout(() => {
originalBtn.innerHTML = '<i class="bi bi-clipboard-check-fill me-2"></i>Copy';
}, 2000);
}
</script>
</body>
</html>

64
api/ai-call-logs.php Normal file
View File

@ -0,0 +1,64 @@
<?php
require_once __DIR__ . '/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJsonResponse(['error' => 'Invalid request method'], 405);
exit;
}
if (!validateApiKey()) {
logWebhook('ai-call-logs', 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('ai-call-logs', $request_body, 400);
sendJsonResponse(['error' => 'Invalid JSON'], 400);
exit;
}
$errors = [];
if (empty($data['call_id'])) {
$errors[] = 'call_id is required';
}
if (empty($data['call_start_time'])) {
$errors[] = 'call_start_time is required';
}
if (empty($data['call_intent'])) {
$errors[] = 'call_intent is required';
}
if (empty($data['call_outcome'])) {
$errors[] = 'call_outcome is required';
}
if (!empty($errors)) {
logWebhook('ai-call-logs', $request_body, 422);
sendJsonResponse(['errors' => $errors], 422);
exit;
}
try {
$stmt = db()->prepare("INSERT INTO ai_call_logs (call_id, conversation_id, call_start_time, call_end_time, call_duration_seconds, call_intent, call_outcome, ai_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([
$data['call_id'],
$data['conversation_id'] ?? null,
$data['call_start_time'],
$data['call_end_time'] ?? null,
$data['call_duration_seconds'] ?? null,
$data['call_intent'],
$data['call_outcome'],
$data['ai_summary'] ?? null
]);
$new_id = db()->lastInsertId();
logWebhook('ai-call-logs', $request_body, 201);
sendJsonResponse(['success' => true, 'id' => $new_id, 'message' => 'Call log created'], 201);
} catch (PDOException $e) {
error_log($e->getMessage());
logWebhook('ai-call-logs', $request_body, 500);
sendJsonResponse(['error' => 'Database error'], 500);
}

68
api/bookings.php Normal file
View File

@ -0,0 +1,68 @@
<?php
require_once __DIR__ . '/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJsonResponse(['error' => 'Invalid request method'], 405);
exit;
}
if (!validateApiKey()) {
logWebhook('bookings', 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('bookings', $request_body, 400);
sendJsonResponse(['error' => 'Invalid JSON'], 400);
exit;
}
$errors = [];
if (empty($data['record_type'])) {
$errors[] = 'record_type is required';
}
if (empty($data['customer_name'])) {
$errors[] = 'customer_name is required';
}
if (empty($data['customer_phone'])) {
$errors[] = 'customer_phone is required';
}
if (!empty($errors)) {
logWebhook('bookings', $request_body, 422);
sendJsonResponse(['errors' => $errors], 422);
exit;
}
try {
$stmt = db()->prepare("INSERT INTO bookings (record_type, customer_name, customer_phone, service_address, service_category, service_type, system_type, urgency_level, issue_description, appointment_date, appointment_time, status, estimated_revenue, actual_revenue, booked_by, customer_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([
$data['record_type'],
$data['customer_name'],
$data['customer_phone'],
$data['service_address'] ?? null,
$data['service_category'] ?? null,
$data['service_type'] ?? null,
$data['system_type'] ?? null,
$data['urgency_level'] ?? null,
$data['issue_description'] ?? null,
$data['appointment_date'] ?? null,
$data['appointment_time'] ?? null,
$data['status'] ?? 'new',
$data['estimated_revenue'] ?? null,
$data['actual_revenue'] ?? null,
$data['booked_by'] ?? 'online',
$data['customer_id'] ?? null
]);
$new_id = db()->lastInsertId();
logWebhook('bookings', $request_body, 201);
sendJsonResponse(['success' => true, 'id' => $new_id, 'message' => 'Booking created'], 201);
} catch (PDOException $e) {
error_log($e->getMessage());
logWebhook('bookings', $request_body, 500);
sendJsonResponse(['error' => 'Database error'], 500);
}

54
api/brightlocal-sync.php Normal file
View File

@ -0,0 +1,54 @@
<?php header('Content-Type: application/json');
require_once 'config.php';
try {
$pdo = db();
// Get the latest snapshot
$stmt = $pdo->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()]);
}

82
api/calendar-events.php Normal file
View File

@ -0,0 +1,82 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/../db/config.php';
header('Content-Type: application/json');
$apiKey = get_api_key();
if (!$apiKey) {
error_log('API key is missing or not configured.');
http_response_code(500);
echo json_encode(['error' => '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.']);
}

68
api/call-tracking.php Normal file
View File

@ -0,0 +1,68 @@
<?php
require_once __DIR__ . '/config.php';
check_api_key();
$request_method = $_SERVER['REQUEST_METHOD'];
if ($request_method === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
// --- 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}");
}
}
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,
]);
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");
}

84
api/chat-logs.php Normal file
View File

@ -0,0 +1,84 @@
<?php
// Prevent any output before headers
error_reporting(0);
ini_set('display_errors', 0);
header('Content-Type: application/json');
// Handle non-POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
die(json_encode(['error' => '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
]);

51
api/config.php Normal file
View File

@ -0,0 +1,51 @@
<?php
require_once __DIR__ . '/../db/config.php';
function validateApiKey() {
$headers = getallheaders();
if (!isset($headers['Authorization'])) {
return false;
}
$auth_header = $headers['Authorization'];
if (preg_match('/Bearer\s(\S+)/', $auth_header, $matches)) {
$api_key = $matches[1];
$stmt = db()->prepare("SELECT * FROM api_keys WHERE api_key = ? AND is_active = TRUE");
$stmt->execute([$api_key]);
$key_data = $stmt->fetch();
if ($key_data) {
return true;
}
}
return false;
}
function logWebhook($endpoint, $body, $status_code) {
$headers = getallheaders();
$ip_address = $_SERVER['REMOTE_ADDR'] ?? null;
try {
$stmt = db()->prepare("INSERT INTO webhook_logs (endpoint, request_headers, request_body, response_status, ip_address) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([
$endpoint,
json_encode($headers),
$body,
$status_code,
$ip_address
]);
} catch (PDOException $e) {
error_log("Failed to log webhook request: " . $e->getMessage());
}
}
function sendJsonResponse($data, $statusCode = 200) {
header('Content-Type: application/json');
http_response_code($statusCode);
echo json_encode($data);
exit;
}
// Create the $pdo variable for scripts that need it.
$pdo = db();

84
api/keys.php Normal file
View File

@ -0,0 +1,84 @@
<?php
require_once __DIR__ . '/config.php';
/**
* Generate a new API key, hash it, store it, and return the plain text key.
*
* @param string $keyName User-defined name for the key.
* @param string $integrationType The type of integration (e.g., 'n8n', 'callrail').
* @return array Result array with success status, plain text API key, and key name.
*/
function generateApiKey($keyName, $integrationType) {
if (empty($keyName) || empty($integrationType)) {
return ["success" => false, "message" => "Key name and integration type are required."];
}
$plainKey = bin2hex(random_bytes(16));
$hashedKey = password_hash($plainKey, PASSWORD_DEFAULT);
$pdo = db();
$stmt = $pdo->prepare(
"INSERT INTO api_keys (key_name, api_key_hash, integration_type, is_active, rate_limit_per_minute, created_at)
VALUES (:key_name, :api_key_hash, :integration_type, true, 60, NOW())"
);
$stmt->bindParam(':key_name', $keyName);
$stmt->bindParam(':api_key_hash', $hashedKey);
$stmt->bindParam(':integration_type', $integrationType);
if ($stmt->execute()) {
return ["success" => true, "api_key" => $plainKey, "key_name" => $keyName, "id" => $pdo->lastInsertId()];
} else {
return ["success" => false, "message" => "Failed to generate API key."];
}
}
/**
* List all API keys from the database (excluding the hash).
*
* @return array An array of API key records.
*/
function listApiKeys() {
$pdo = db();
$stmt = $pdo->query(
"SELECT id, key_name, integration_type, is_active, last_used_at, created_at, expires_at
FROM api_keys ORDER BY created_at DESC"
);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Deactivate an API key.
*
* @param int $keyId The ID of the key to deactivate.
* @return array Result array with success status and a message.
*/
function deactivateApiKey($keyId) {
$pdo = db();
$stmt = $pdo->prepare("UPDATE api_keys SET is_active = false WHERE id = :id");
$stmt->bindParam(':id', $keyId, PDO::PARAM_INT);
if ($stmt->execute()) {
return ["success" => true, "message" => "API key deactivated."];
} else {
return ["success" => false, "message" => "Failed to deactivate API key."];
}
}
/**
* Delete an API key.
*
* @param int $keyId The ID of the key to delete.
* @return array Result array with success status and a message.
*/
function deleteApiKey($keyId) {
$pdo = db();
$stmt = $pdo->prepare("DELETE FROM api_keys WHERE id = :id");
$stmt->bindParam(':id', $keyId, PDO::PARAM_INT);
if ($stmt->execute()) {
return ["success" => true, "message" => "API key deleted."];
} else {
return ["success" => false, "message" => "Failed to delete API key."];
}
}

92
api/reviews-webhook.php Normal file
View File

@ -0,0 +1,92 @@
<?php
header('Content-Type: application/json');
require_once '../db/config.php';
// Get the incoming data
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if (!$data) {
http_response_code(400);
echo json_encode(['success' => 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.']);
}
?>

55
api/reviews.php Normal file
View File

@ -0,0 +1,55 @@
<?php
require_once __DIR__ . '/config.php';
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");
}

1
api/view-webhook-log.php Normal file
View File

@ -0,0 +1 @@
<?php header('Content-Type: text/plain'); $logFile = __DIR__ . '/webhook_debug.log'; if (file_exists($logFile)) { echo file_get_contents($logFile); } else { echo "No log file found"; }

82
api/weather-fetch.php Normal file
View File

@ -0,0 +1,82 @@
<?php
require_once __DIR__ . '/../db/config.php';
header('Content-Type: application/json');
// --- OpenWeatherMap API Call ---
$openweathermap_api_key = 'ff101be91e4bbe53d6ffbbec1868dfc0';
$api_url = "https://api.openweathermap.org/data/3.0/onecall?lat=35.2271&lon=-80.8431&units=imperial&exclude=minutely,hourly&appid={$openweathermap_api_key}";
$response = @file_get_contents($api_url);
if ($response === false) {
http_response_code(502); // Bad Gateway
echo json_encode(['error' => '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.']);
}

70
api/weather.php Normal file
View File

@ -0,0 +1,70 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/../db/config.php';
header('Content-Type: application/json');
$apiKey = get_api_key();
if (!$apiKey) {
error_log('API key is missing or not configured.');
http_response_code(500);
echo json_encode(['error' => '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.']);
}

97
api/webhook_debug.log Normal file
View File

@ -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"}}

156
assets/css/custom.css Normal file
View File

@ -0,0 +1,156 @@
/* 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: 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: var(--card-bg);
padding: 1rem 2rem;
border-bottom: 1px solid var(--border-color);
}
.card {
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 8px 24px rgba(77, 201, 255, 0.15);
}
.card-header {
background: linear-gradient(90deg, #364057, #2d3446);
border-bottom: 1px solid var(--border-color);
border-radius: 12px 12px 0 0;
padding: 1rem 1.5rem;
}
.kpi-card-header {
background: linear-gradient(135deg, rgba(77, 201, 255, 0.15), rgba(77, 201, 255, 0.05));
}
.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);
}
.table-striped>tbody>tr:nth-of-type(odd)>* {
--bs-table-accent-bg: #262c3a;
}
.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);
}

86
assets/js/main.js Normal file
View File

@ -0,0 +1,86 @@
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'])}&deg;F`;
weatherFeelsLike.innerHTML = `Feels like ${Math.round(data[':feels_like_f'])}&deg;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 += `
<div class="alert alert-danger mt-3 mb-0" role="alert">
<i class="bi bi-fire"></i> <strong>High AC Demand Expected:</strong> Temp > 95&deg;F.
</div>`;
}
if (data[':is_extreme_cold'] == 1) {
weatherAlerts.innerHTML += `
<div class="alert alert-info mt-3 mb-0" role="alert">
<i class="bi bi-snow"></i> <strong>High Heating Demand Expected:</strong> Temp < 32&deg;F.
</div>`;
}
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);
});

38
assets/js/map.js Normal file
View File

@ -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');
});

176
bookings.php Normal file
View File

@ -0,0 +1,176 @@
<?php
require_once 'db/config.php';
try {
$pdo = db();
// Filtering logic
$where_clauses = [];
$params = [];
if (!empty($_GET['service_type'])) {
$where_clauses[] = 'service_type = :service_type';
$params[':service_type'] = $_GET['service_type'];
}
if (!empty($_GET['urgency_level'])) {
$where_clauses[] = 'urgency_level = :urgency_level';
$params[':urgency_level'] = $_GET['urgency_level'];
}
if (!empty($_GET['status'])) {
$where_clauses[] = 'status = :status';
$params[':status'] = $_GET['status'];
}
$sql = 'SELECT * FROM bookings';
if (!empty($where_clauses)) {
$sql .= ' WHERE ' . implode(' AND ', $where_clauses);
}
$sql .= ' ORDER BY appointment_date DESC';
$stmt_bookings = $pdo->prepare($sql);
$stmt_bookings->execute($params);
$bookings = $stmt_bookings->fetchAll();
// Fetch distinct values for filters
$stmt_service_types = $pdo->query('SELECT DISTINCT service_type FROM bookings');
$service_types = $stmt_service_types->fetchAll(PDO::FETCH_COLUMN);
$stmt_urgency_levels = $pdo->query('SELECT DISTINCT urgency_level FROM bookings');
$urgency_levels = $stmt_urgency_levels->fetchAll(PDO::FETCH_COLUMN);
$stmt_statuses = $pdo->query('SELECT DISTINCT status FROM bookings');
$statuses = $stmt_statuses->fetchAll(PDO::FETCH_COLUMN);
} catch (PDOException $e) {
$error = "Database error: " . $e->getMessage();
}
$project_name = "HVAC Command Center";
$page_title = "Bookings";
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($page_title) ?> | <?= htmlspecialchars($project_name) ?></title>
<!-- Styles -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
</div>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
<li class="nav-item"><a class="nav-link active" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
</ul>
</div>
<div class="main-content">
<header class="d-flex justify-content-between align-items-center mb-4">
<h1><?= htmlspecialchars($page_title) ?></h1>
</header>
<?php if (isset($error)): ?>
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i> <?= htmlspecialchars($error) ?>
</div>
<?php else: ?>
<div class="card">
<div class="card-header">
<h5 class="m-0"><i class="fas fa-calendar-check me-2"></i>All Bookings</h5>
</div>
<div class="card-body">
<form method="GET" action="" class="row g-3 mb-4">
<div class="col-md-3">
<select name="service_type" class="form-select bg-dark text-white">
<option value="">All Services</option>
<?php foreach ($service_types as $type): ?>
<option value="<?= htmlspecialchars($type) ?>" <?= (isset($_GET['service_type']) && $_GET['service_type'] == $type) ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst($type)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-3">
<select name="urgency_level" class="form-select bg-dark text-white">
<option value="">All Urgencies</option>
<?php foreach ($urgency_levels as $level): ?>
<option value="<?= htmlspecialchars($level) ?>" <?= (isset($_GET['urgency_level']) && $_GET['urgency_level'] == $level) ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst($level)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-3">
<select name="status" class="form-select bg-dark text-white">
<option value="">All Statuses</option>
<?php foreach ($statuses as $status): ?>
<option value="<?= htmlspecialchars($status) ?>" <?= (isset($_GET['status']) && $_GET['status'] == $status) ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst($status)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary">Filter</button>
<a href="bookings.php" class="btn btn-secondary">Reset</a>
</div>
</form>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Date</th>
<th>Customer</th>
<th>Service</th>
<th>Urgency</th>
<th>Status</th>
<th class="text-end">Est. Revenue</th>
<th class="text-end">Actual Revenue</th>
</tr>
</thead>
<tbody>
<?php foreach ($bookings as $booking): ?>
<tr>
<td><?= htmlspecialchars(date("M d, Y", strtotime($booking['appointment_date']))) ?></td>
<td><?= htmlspecialchars($booking['customer_name']) ?></td>
<td><?= htmlspecialchars($booking['service_type']) ?></td>
<td><span class="badge bg-<?= strtolower(htmlspecialchars($booking['urgency_level'])) == 'emergency' ? 'danger' : (strtolower(htmlspecialchars($booking['urgency_level'])) == 'urgent' ? 'warning' : 'secondary') ?>"><?= htmlspecialchars(ucfirst($booking['urgency_level'])) ?></span></td>
<td><span class="badge bg-light text-dark border"><?= htmlspecialchars(ucfirst($booking['status'])) ?></span></td>
<td class="text-end fw-bold">$<?= number_format($booking['estimated_revenue'], 2) ?></td>
<td class="text-end fw-bold">$<?= number_format($booking['actual_revenue'], 2) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($bookings)): ?>
<tr>
<td colspan="7" class="text-center text-muted">No bookings found.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
</body>
</html>

125
calendar.php Normal file
View File

@ -0,0 +1,125 @@
<?php
require_once 'db/config.php';
try {
$pdo = db();
// Fetch calendar events
$stmt = $pdo->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";
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($page_title) ?> - <?= htmlspecialchars($project_name) ?></title>
<!-- Styles -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<!-- FullCalendar -->
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js'></script>
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
</div>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
<li class="nav-item"><a class="nav-link active" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
</ul>
</div>
<div class="main-content">
<header class="d-flex justify-content-between align-items-center mb-4">
<h1><?= htmlspecialchars($page_title) ?></h1>
</header>
<?php if (isset($error)): ?>
<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i> <?= htmlspecialchars($error) ?></div>
<?php else: ?>
<!-- Stat Cards -->
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card stat-card h-100">
<div class="card-body">
<h6 class="text-muted">Upcoming Appointments (Next 7 Days)</h6>
<h4 class="fw-bold mb-0"><?= htmlspecialchars($upcoming_appointments_count) ?></h4>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card stat-card h-100">
<div class="card-body">
<h6 class="text-muted">Technician Workload</h6>
<p class="text-muted small">Workload summary coming soon.</p>
</div>
</div>
</div>
</div>
<!-- Calendar -->
<div class="card">
<div class="card-body">
<div id="calendar"></div>
</div>
</div>
<?php endif; ?>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
events: <?= $calendar_events_json ?? '[]' ?>,
eventDidMount: function(info) {
// Apply custom styles for dark theme
info.el.style.borderColor = '#4dc9ff';
if (info.event.extendedProps.event_type === 'booking') {
info.el.style.backgroundColor = '#0d6efd';
} else if (info.event.extendedProps.event_type === 'maintenance') {
info.el.style.backgroundColor = '#198754';
} else {
info.el.style.backgroundColor = '#6c757d';
}
}
});
calendar.render();
});
</script>
</body>
</html>

202
call-tracking.php Normal file
View File

@ -0,0 +1,202 @@
<?php
require_once 'db/config.php';
try {
$pdo = db();
// Timeframe filters
$today = date('Y-m-d');
$this_week_start = date('Y-m-d', strtotime('monday this week'));
$this_month_start = date('Y-m-01');
// Total calls
$stmt_calls_today = $pdo->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";
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($page_title) ?> | <?= htmlspecialchars($project_name) ?></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
</div>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
<li class="nav-item"><a class="nav-link active" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
</ul>
</div>
<div class="main-content">
<header class="d-flex justify-content-between align-items-center mb-4">
<h1><?= htmlspecialchars($page_title) ?></h1>
</header>
<?php if (isset($error)): ?>
<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i> <?= htmlspecialchars($error) ?></div>
<?php else: ?>
<!-- Stat Cards -->
<div class="row g-4 mb-4">
<div class="col-lg-3 col-md-6">
<div class="card stat-card h-100">
<div class="card-body text-center">
<h6 class="text-muted">Calls Today</h6>
<h4 class="fw-bold mb-0"><?= $total_calls_today ?></h4>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card stat-card h-100">
<div class="card-body text-center">
<h6 class="text-muted">This Week</h6>
<h4 class="fw-bold mb-0"><?= $total_calls_week ?></h4>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card stat-card h-100">
<div class="card-body text-center">
<h6 class="text-muted">This Month</h6>
<h4 class="fw-bold mb-0"><?= $total_calls_month ?></h4>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card stat-card h-100 bg-success text-white">
<div class="card-body text-center">
<h6 class="text-white">AI Rescue Rate</h6>
<h4 class="fw-bold mb-0"><?= number_format($ai_rescue_rate, 1) ?>%</h4>
</div>
</div>
</div>
</div>
<!-- Charts -->
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header"><h5 class="m-0">Calls by Source</h5></div>
<div class="card-body"><canvas id="callsBySourceChart"></canvas></div>
</div>
</div>
</div>
<!-- Recent Calls Table -->
<div class="card">
<div class="card-header"><h5 class="m-0">Recent Calls</h5></div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr><th>Caller</th><th>Source</th><th>Status</th><th>Answered By</th><th>Duration</th><th>Date</th></tr>
</thead>
<tbody>
<?php foreach ($recent_calls as $call): ?>
<tr>
<td><?= htmlspecialchars($call['caller_name'] ?? $call['caller_number']) ?></td>
<td><span class="badge bg-info"><?= htmlspecialchars(ucwords(str_replace('_', ' ', $call['traffic_source']))) ?></span></td>
<td><?= htmlspecialchars(ucfirst($call['call_status'])) ?></td>
<td><?= htmlspecialchars(ucwords(str_replace('_', ' ', $call['answered_by']))) ?></td>
<td><?= $call['call_duration_seconds'] ? gmdate("i:s", $call['call_duration_seconds']) : 'N/A' ?></td>
<td><?= date("M d, Y h:i A", strtotime($call['call_start_time'])) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recent_calls)): ?>
<tr><td colspan="6" class="text-center text-muted">No calls recorded yet.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
Chart.defaults.color = '#aab0bb';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
const chartBackgroundColor = ['#4dc9ff', '#ff6384', '#ffce56', '#36a2eb', '#9966ff', '#ff9f40'];
// Chart for Calls by Source
const ctxSource = document.getElementById('callsBySourceChart');
if (ctxSource) {
new Chart(ctxSource, {
type: 'pie',
data: {
labels: <?= $chart_labels_source ?>,
datasets: [{
label: 'Calls',
data: <?= $chart_data_source ?>,
backgroundColor: chartBackgroundColor,
borderColor: '#2d3446',
borderWidth: 3
}]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom', labels: { color: '#aab0bb' } } }
}
});
}
});
</script>
</body>
</html>

225
chat-logs.php Normal file
View File

@ -0,0 +1,225 @@
<?php
require_once 'db/config.php';
try {
$pdo = db();
// Timeframe for stats
$today_start = date('Y-m-d 00:00:00');
$this_week_start = date('Y-m-d 00:00:00', strtotime('monday this week'));
$this_month_start = date('Y-m-01 00:00:00');
// Total chats
$stmt_chats_today = $pdo->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";
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($page_title) ?> | <?= htmlspecialchars($project_name) ?></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
</div>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
<li class="nav-item"><a class="nav-link active" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
</ul>
</div>
<div class="main-content">
<header class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="fas fa-comments me-2"></i><?= htmlspecialchars($page_title) ?></h1>
</header>
<?php if (isset($error)): ?>
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i> <?= htmlspecialchars($error) ?>
</div>
<?php else: ?>
<!-- Stat Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card stat-card">
<div class="card-body">
<h5 class="card-title">Chats Today</h5>
<p class="card-text display-4"><?= $chats_today ?></p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="card-body">
<h5 class="card-title">This Week</h5>
<p class="card-text display-4"><?= $chats_this_week ?></p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="card-body">
<h5 class="card-title">Conversion Rate</h5>
<p class="card-text display-4"><?= number_format($conversion_rate, 1) ?>%</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="card-body">
<h5 class="card-title">Avg. Duration</h5>
<p class="card-text display-4"><?= $avg_duration ?></p>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Chat Outcomes Pie Chart -->
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5>Chat Outcomes</h5>
</div>
<div class="card-body">
<canvas id="chatOutcomesChart"></canvas>
</div>
</div>
</div>
<!-- Recent Chats Table -->
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5>Recent Chats</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Date</th>
<th>Customer</th>
<th>Duration</th>
<th>Outcome</th>
<th>Converted</th>
</tr>
</thead>
<tbody>
<?php if (empty($recent_chats)): ?>
<tr><td colspan="5" class="text-center">No chats found.</td></tr>
<?php else: ?>
<?php foreach ($recent_chats as $chat): ?>
<tr>
<td><?= date('M d, Y H:i', strtotime($chat['created_at'])) ?></td>
<td><?= htmlspecialchars($chat['customer_name']) ?></td>
<td><?= gmdate("i:s", $chat['chat_duration_seconds']) ?></td>
<td><span class="badge bg-secondary"><?= htmlspecialchars($chat['chat_outcome']) ?></span></td>
<td>
<?php if ($chat['was_converted']): ?>
<span class="badge bg-success">Yes</span>
<?php else: ?>
<span class="badge bg-danger">No</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<?php endif; ?>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Chart.js: Chat Outcomes Pie Chart
const outcomeLabels = <?= json_encode(array_keys($chat_outcomes)) ?>;
const outcomeData = <?= json_encode(array_values($chat_outcomes)) ?>;
const outcomesChartCtx = document.getElementById('chatOutcomesChart').getContext('2d');
new Chart(outcomesChartCtx, {
type: 'pie',
data: {
labels: outcomeLabels,
datasets: [{
label: 'Chat Outcomes',
data: outcomeData,
backgroundColor: ['#4dc9ff', '#f672a7', '#ffc107', '#28a745', '#dc3545', '#6c757d'],
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#fff'
}
}
}
}
});
</script>
</body>
</html>

107
customers.php Normal file
View File

@ -0,0 +1,107 @@
<?php
require_once 'db/config.php';
try {
$pdo = db();
// Fetch all customers
$stmt_customers = $pdo->query('SELECT * FROM customers ORDER BY created_at DESC');
$customers = $stmt_customers->fetchAll();
} catch (PDOException $e) {
$error = "Database error: " . $e->getMessage();
}
$project_name = "HVAC Command Center";
$page_title = "Customers";
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($page_title) ?> | <?= htmlspecialchars($project_name) ?></title>
<!-- Styles -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
</div>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link active" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
</ul>
</div>
<div class="main-content">
<header class="d-flex justify-content-between align-items-center mb-4">
<h1><?= htmlspecialchars($page_title) ?></h1>
</header>
<?php if (isset($error)): ?>
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i> <?= htmlspecialchars($error) ?>
</div>
<?php else: ?>
<div class="card">
<div class="card-header">
<h5 class="m-0"><i class="fas fa-users me-2"></i>All Customers</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Name</th>
<th>Phone</th>
<th>Email</th>
<th>Address</th>
<th>Member Since</th>
</tr>
</thead>
<tbody>
<?php foreach ($customers as $customer): ?>
<tr>
<td><?= htmlspecialchars($customer['name']) ?></td>
<td><?= htmlspecialchars($customer['phone']) ?></td>
<td><?= htmlspecialchars($customer['email']) ?></td>
<td><?= htmlspecialchars($customer['address']) ?></td>
<td><?= htmlspecialchars(date("M d, Y", strtotime($customer['created_at']))) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($customers)): ?>
<tr>
<td colspan="5" class="text-center text-muted">No customers found.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
</body>
</html>

48
db/schema.sql Normal file
View File

@ -0,0 +1,48 @@
-- 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)
);
-- 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)
);
-- 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)
);

54
db/schema_api.sql Normal file
View File

@ -0,0 +1,54 @@
-- API Keys for authentication
CREATE TABLE IF NOT EXISTS `api_keys` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`api_key` VARCHAR(255) NOT NULL UNIQUE,
`is_active` BOOLEAN NOT NULL DEFAULT TRUE,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Webhook request logs
CREATE TABLE IF NOT EXISTS `webhook_logs` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`endpoint` VARCHAR(255) NOT NULL,
`request_headers` TEXT,
`request_body` LONGTEXT,
`response_status` INT,
`ip_address` VARCHAR(45),
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- AI Call Logs
CREATE TABLE IF NOT EXISTS `ai_call_logs` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`call_id` VARCHAR(255) NOT NULL UNIQUE,
`conversation_id` VARCHAR(255) DEFAULT NULL,
`call_start_time` DATETIME NOT NULL,
`call_end_time` DATETIME DEFAULT NULL,
`call_duration_seconds` INT DEFAULT NULL,
`call_intent` VARCHAR(100) DEFAULT NULL,
`call_outcome` VARCHAR(100) DEFAULT NULL,
`ai_summary` TEXT,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Call Tracking Logs
CREATE TABLE IF NOT EXISTS `call_tracking` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`external_call_id` VARCHAR(255) NOT NULL,
`tracking_platform` VARCHAR(100) NOT NULL,
`call_start_time` DATETIME NOT NULL,
`call_status` VARCHAR(50) NOT NULL,
`traffic_source` VARCHAR(100),
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Reviews
CREATE TABLE IF NOT EXISTS `reviews` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`platform_source` VARCHAR(100) NOT NULL,
`star_rating` DECIMAL(3, 2) NOT NULL,
`review_text` TEXT,
`reviewer_name` VARCHAR(255),
`review_date` DATETIME,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

17
db/seed.sql Normal file
View File

@ -0,0 +1,17 @@
INSERT INTO `customers` (`first_name`, `last_name`, `email`, `phone`, `service_address`, `city`, `state`, `zip_code`, `acquisition_source`) VALUES
('John', 'Smith', 'john.smith@example.com', '555-1234', '123 Main St', 'Anytown', 'CA', '12345', 'organic'),
('Jane', 'Doe', 'jane.doe@example.com', '555-5678', '456 Oak Ave', 'Someville', 'NY', '54321', 'referral'),
('Peter', 'Jones', 'peter.jones@example.com', '555-9876', '789 Pine Ln', 'Metropolis', 'FL', '67890', 'google_ads');
INSERT INTO `bookings` (`record_type`, `customer_name`, `customer_phone`, `service_address`, `service_category`, `service_type`, `system_type`, `urgency_level`, `appointment_date`, `status`, `estimated_revenue`, `customer_id`) VALUES
('appointment', 'Bob Johnson', '555-1111', '101 Maple Dr', 'repair', 'AC Unit Repair', 'central_air', 'urgent', '2026-01-05', 'new', 350.00, NULL),
('appointment', 'Samantha Williams', '555-2222', '212 Birch Rd', 'maintenance', 'Furnace Tune-up', 'furnace', 'routine', '2026-01-06', 'confirmed', 150.00, NULL),
('quote_request', 'Michael Brown', '555-3333', '333 Cedar Ct', 'installation', 'New Heat Pump', 'heat_pump', 'routine', '2026-01-07', 'new', 8500.00, NULL),
('appointment', 'Jessica Davis', '555-4444', '444 Spruce Way', 'emergency', 'Boiler Emergency', 'boiler', 'emergency', '2026-01-08', 'dispatched', 700.00, NULL),
('appointment', 'David Miller', '555-5555', '555 Willow Bend', 'inspection', 'Pre-purchase Inspection', 'central_air', 'routine', '2026-01-09', 'in_progress', 200.00, NULL),
('appointment', 'Emily Wilson', '555-6666', '666 Aspen Pl', 'repair', 'Mini-split not cooling', 'mini_split', 'urgent', '2026-01-10', 'completed', 450.00, NULL);
-- Link some bookings to the customers we created
UPDATE `bookings` SET `customer_id` = 1 WHERE `id` IN (1, 2);
UPDATE `bookings` SET `customer_id` = 2 WHERE `id` = 3;
UPDATE `bookings` SET `customer_id` = 3 WHERE `id` = 4;

449
index.php
View File

@ -1,150 +1,319 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
require_once 'db/config.php';
try {
$pdo = db();
// Fetch total customers
$stmt_customers = $pdo->query('SELECT COUNT(*) as total FROM customers');
$total_customers = $stmt_customers->fetch()['total'];
// Fetch total bookings
$stmt_bookings = $pdo->query('SELECT COUNT(*) as total FROM bookings');
$total_bookings = $stmt_bookings->fetch()['total'];
// Fetch total revenue
$stmt_revenue = $pdo->query("SELECT SUM(actual_revenue) as total FROM bookings WHERE status = 'completed'");
$total_revenue = $stmt_revenue->fetch()['total'] ?? 0;
// Fetch recent bookings
$stmt_recent_bookings = $pdo->query('SELECT * FROM bookings ORDER BY created_at DESC LIMIT 5');
$recent_bookings = $stmt_recent_bookings->fetchAll();
// 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]);
header("Location: " . $_SERVER['PHP_SELF']);
exit;
}
// Fetch all API keys
$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) {
$error = "Database error: " . $e->getMessage();
}
$project_name = "HVAC Command Center";
$project_description = "Central dashboard for managing your HVAC business operations.";
$page_title = "Dashboard";
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
?>
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($page_title . ' | ' . $project_name) ?></title>
<meta name="description" content="<?= htmlspecialchars($project_description) ?>">
<!-- Open Graph / Twitter -->
<meta property="og:title" content="<?= htmlspecialchars($project_name) ?>">
<meta property="og:description" content="<?= htmlspecialchars($project_description) ?>">
<meta property="og:image" content="<?= htmlspecialchars($_SERVER['PROJECT_IMAGE_URL'] ?? '') ?>">
<meta name="twitter:card" content="summary_large_image">
<!-- Styles -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
<div class="sidebar">
<div class="sidebar-header">
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
</div>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link active" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
<li class="nav-item"><a class="nav-link" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
</ul>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
<div class="main-content">
<header class="d-flex justify-content-between align-items-center mb-4">
<h1><?= htmlspecialchars($page_title) ?></h1>
<button class="btn btn-primary d-md-none" type="button" data-bs-toggle="collapse" data-bs-target=".sidebar">
<i class="fas fa-bars"></i>
</button>
</header>
<?php if (isset($error)): ?>
<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i><?= htmlspecialchars($error) ?></div>
<?php else: ?>
<!-- Stat Cards -->
<div class="row g-4 mb-4">
<div class="col-lg-3 col-md-6">
<div class="card stat-card h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted">Total Customers</h6>
<h4 class="fw-bold mb-0"><?= htmlspecialchars($total_customers) ?></h4>
<small class="change-indicator positive"><i class="fas fa-arrow-up"></i> 5% this month</small>
</div>
<div class="icon"><i class="fas fa-users"></i></div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card stat-card h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted">Total Bookings</h6>
<h4 class="fw-bold mb-0"><?= htmlspecialchars($total_bookings) ?></h4>
<small class="change-indicator positive"><i class="fas fa-arrow-up"></i> 12% this month</small>
</div>
<div class="icon"><i class="fas fa-calendar-check"></i></div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card stat-card h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted">Chats Today</h6>
<h4 class="fw-bold mb-0"><?= htmlspecialchars($chats_today_count) ?></h4>
<small class="change-indicator positive"><i class="fas fa-arrow-up"></i> <?= number_format($chat_conversion_rate, 0) ?>% conv.</small>
</div>
<div class="icon"><i class="fas fa-comments"></i></div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card stat-card h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted">Completed Revenue</h6>
<h4 class="fw-bold mb-0">$<?= number_format($total_revenue, 2) ?></h4>
<small class="change-indicator positive"><i class="fas fa-arrow-up"></i> 8% this month</small>
</div>
<div class="icon"><i class="fas fa-dollar-sign"></i></div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-lg-5">
<!-- Weather Widget -->
<div class="card mb-4" id="weather-widget">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="card-title mb-0"><i class="fas fa-cloud-sun me-2"></i>Current Weather</h5>
<button id="refresh-weather-btn" class="btn btn-sm btn-outline-info"><i class="fas fa-sync-alt"></i></button>
</div>
<div id="weather-content">
<?php if ($current_weather): ?>
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0"><?= htmlspecialchars($current_weather['location_name']) ?></h6>
<p class="text-muted mb-0 small">Last updated: <span id="weather-last-updated"><?= htmlspecialchars(date("g:i A", strtotime($current_weather['observation_time']))) ?></span></p>
</div>
<div class="text-end">
<img id="weather-icon" src="https://openweathermap.org/img/wn/<?= htmlspecialchars($current_weather['weather_icon']) ?>@2x.png" alt="Weather icon" style="width: 50px; height: 50px;">
<span id="weather-temp" class="fs-4 fw-bold"><?= round($current_weather['temperature_f']) ?>&deg;F</span>
<p id="weather-feels-like" class="mb-0 text-muted small">Feels like <?= round($current_weather['feels_like_f']) ?>&deg;F</p>
</div>
</div>
<p id="weather-desc" class="mb-0 fst-italic text-center mt-2"><?= htmlspecialchars(ucwords($current_weather['weather_description'])) ?></p>
<div id="weather-alerts"></div>
<?php else: ?>
<p id="weather-placeholder" class="text-muted text-center mb-0">Weather data not available. Click Refresh.</p>
<?php endif; ?>
</div>
<div id="weather-loading" class="text-center" style="display: none;">
<div class="spinner-border text-info" role="status"><span class="visually-hidden">Loading...</span></div>
<p class="mt-2">Fetching latest weather...</p>
</div>
<div id="weather-error" class="alert alert-warning mt-2" style="display: none;"></div>
</div>
</div>
<!-- Weather Map Widget -->
<div class="card mb-4">
<div class="card-header"><h5 class="card-title mb-0"><i class="fas fa-map-marked-alt me-2"></i>Service Area Weather Map</h5></div>
<div class="card-body p-0"><div id="weather-map"></div></div>
<div class="card-footer text-center map-buttons">
<button class="map-btn active" data-layer="temp" onclick="showLayer('temp')">Temp</button>
<button class="map-btn" data-layer="precip" onclick="showLayer('precip')">Precip</button>
<button class="map-btn" data-layer="clouds" onclick="showLayer('clouds')">Clouds</button>
<button class="map-btn" data-layer="wind" onclick="showLayer('wind')">Wind</button>
</div>
</div>
</div>
<div class="col-lg-7">
<!-- Recent Bookings Table -->
<div class="card mb-4">
<div class="card-header"><h5 class="m-0"><i class="fas fa-clock-rotate-left me-2"></i>Recent Bookings</h5></div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead><tr><th>Date</th><th>Customer</th><th>Service</th><th>Urgency</th><th>Status</th><th class="text-end">Est. Revenue</th></tr></thead>
<tbody>
<?php foreach ($recent_bookings as $booking): ?>
<tr>
<td><?= htmlspecialchars(date("M d, Y", strtotime($booking['appointment_date']))) ?></td>
<td><?= htmlspecialchars($booking['customer_name']) ?></td>
<td><?= htmlspecialchars($booking['service_type']) ?></td>
<td><span class="badge bg-<?= strtolower(htmlspecialchars($booking['urgency_level'])) == 'emergency' ? 'danger' : (strtolower(htmlspecialchars($booking['urgency_level'])) == 'urgent' ? 'warning' : 'secondary') ?>"><?= htmlspecialchars(ucfirst($booking['urgency_level'])) ?></span></td>
<td><span class="badge bg-light text-dark border"><?= htmlspecialchars(ucfirst($booking['status'])) ?></span></td>
<td class="text-end fw-bold">$<?= number_format($booking['estimated_revenue'], 2) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recent_bookings)): ?>
<tr><td colspan="6" class="text-center text-muted">No recent bookings found.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- API Key Management -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="m-0"><i class="fas fa-key me-2"></i>API Keys</h5>
<form method="POST" action=""><button type="submit" name="generate_api_key" class="btn btn-primary btn-sm">Generate New Key</button></form>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead><tr><th>API Key</th><th>Status</th><th>Created On</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($api_keys as $key): ?>
<tr>
<td><input type="text" readonly class="form-control-plaintext" value="<?= htmlspecialchars($key['api_key']) ?>"></td>
<td><span class="badge bg-<?= $key['is_active'] ? 'success' : 'danger' ?>"><?= $key['is_active'] ? 'Active' : 'Inactive' ?></span></td>
<td><?= htmlspecialchars(date("M d, Y", strtotime($key['created_at']))) ?></td>
<td></td>
</tr>
<?php endforeach; ?>
<?php if (empty($api_keys)): ?>
<tr><td colspan="4" class="text-center text-muted">No API keys found.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4 mt-1">
<div class="col-12">
<div class="card">
<div class="card-header"><h5 class="card-title mb-0"><i class="fas fa-chart-line me-2"></i>Booking Trends</h5></div>
<div class="card-body"><canvas id="booking-trends-chart"></canvas></div>
</div>
</div>
</div>
<?php endif; ?>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
<script src="assets/js/map.js?v=<?php echo time(); ?>"></script>
<script>
// Basic Chart.js Example
const ctx = document.getElementById('booking-trends-chart').getContext('2d');
const bookingChart = new Chart(ctx, {
type: 'line',
data: {
labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
datasets: [{
label: 'Bookings',
data: [65, 59, 80, 81, 56, 55, 40],
fill: true,
backgroundColor: 'rgba(77, 201, 255, 0.2)',
borderColor: '#4dc9ff',
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { grid: { color: 'rgba(255,255,255,0.1)' } },
y: { grid: { color: 'rgba(255,255,255,0.1)' } }
},
plugins: { legend: { display: false } }
}
});
</script>
</body>
</html>

238
reviews.php Normal file
View File

@ -0,0 +1,238 @@
<?php
require_once 'db/config.php';
// Notice: This page now uses hardcoded data and is set up for BrightLocal integration.
// The previous database queries have been removed.
// Placeholder data as per the request
$avg_rating = 4.93;
$total_reviews = 504;
// Placeholder for chart data - this will be populated by JavaScript
$chart_labels_rating = json_encode([]);
$chart_data_rating = json_encode([]);
$chart_labels_platform = json_encode([]);
$chart_data_platform = json_encode([]);
// Placeholder for recent reviews - this will be populated by JavaScript
$recent_reviews = [];
$project_name = "HVAC Command Center";
$page_title = "BrightLocal Reviews";
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($page_title) ?> | <?= htmlspecialchars($project_name) ?></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
<h2 class="h5"><i class="fas fa-tachometer-alt me-2"></i><?= htmlspecialchars($project_name) ?></h2>
</div>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link" href="index.php"><i class="fas fa-home"></i>Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="customers.php"><i class="fas fa-users"></i>Customers</a></li>
<li class="nav-item"><a class="nav-link" href="bookings.php"><i class="fas fa-calendar-check"></i>Bookings</a></li>
<li class="nav-item"><a class="nav-link" href="ai-call-logs.php"><i class="fas fa-robot"></i>AI Call Logs</a></li>
<li class="nav-item"><a class="nav-link" href="chat-logs.php"><i class="fas fa-comments"></i>Chat Logs</a></li>
<li class="nav-item"><a class="nav-link" href="call-tracking.php"><i class="fas fa-phone-alt"></i>Call Tracking</a></li>
<li class="nav-item"><a class="nav-link active" href="reviews.php"><i class="fas fa-star"></i>Reviews</a></li>
<li class="nav-item"><a class="nav-link" href="calendar.php"><i class="fas fa-calendar-alt"></i>Calendar</a></li>
</ul>
</div>
<div class="main-content">
<header class="d-flex justify-content-between align-items-center mb-4">
<h1><?= htmlspecialchars($page_title) ?></h1>
<button id="sync-reviews-btn" class="btn btn-primary"><i class="fas fa-sync-alt me-2"></i>Sync Reviews</button>
</header>
<div id="loading-spinner" class="text-center" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Fetching latest reviews from BrightLocal...</p>
</div>
<div id="reviews-content">
<!-- Stat Cards -->
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card stat-card h-100">
<div class="card-body text-center">
<h6 class="text-muted">Average Rating</h6>
<h4 id="avg-rating" class="fw-bold mb-0 text-warning"><?= number_format($avg_rating, 2) ?> <i class="fas fa-star"></i></h4>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card stat-card h-100">
<div class="card-body text-center">
<h6 class="text-muted">Total Reviews</h6>
<h4 id="total-reviews" class="fw-bold mb-0"><?= $total_reviews ?></h4>
</div>
</div>
</div>
</div>
<!-- Charts -->
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header"><h5 class="m-0">Rating Breakdown</h5></div>
<div class="card-body"><canvas id="ratingDistributionChart"></canvas></div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header"><h5 class="m-0">Reviews by Source</h5></div>
<div class="card-body"><canvas id="platformDistributionChart"></canvas></div>
</div>
</div>
</div>
<!-- Recent Reviews Table -->
<div class="card">
<div class="card-header"><h5 class="m-0">Recent Reviews</h5></div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead><tr><th>Reviewer</th><th>Source</th><th>Rating</th><th>Date</th><th>Comment</th></tr></thead>
<tbody id="recent-reviews-body">
<tr><td colspan="5" class="text-center text-muted">Click "Sync Reviews" to load data.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const syncButton = document.getElementById('sync-reviews-btn');
const loadingSpinner = document.getElementById('loading-spinner');
const reviewsContent = document.getElementById('reviews-content');
let ratingChart, platformChart;
syncButton.addEventListener('click', function() {
loadingSpinner.style.display = 'block';
reviewsContent.style.display = 'none';
fetch('/api/brightlocal-sync.php')
.then(response => response.json())
.then(result => {
if (result.success && result.data.success) {
const reviewsData = result.data.data;
updateDashboard(reviewsData);
} else {
alert('Error fetching reviews: ' + (result.error || result.data.error));
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while fetching data.');
})
.finally(() => {
loadingSpinner.style.display = 'none';
reviewsContent.style.display = 'block';
});
});
function updateDashboard(data) {
// Update stats
document.getElementById('avg-rating').innerHTML = `${parseFloat(data['summary']['average-rating']).toFixed(2)} <i class="fas fa-star"></i>`;
document.getElementById('total-reviews').textContent = data['summary']['total-reviews'];
// Update charts
updateRatingChart(data['summary']['rating-breakdown']);
updatePlatformChart(data['summary']['source-breakdown']);
// Update recent reviews table
updateRecentReviews(data['reviews']);
}
function updateRatingChart(ratingData) {
const ctx = document.getElementById('ratingDistributionChart').getContext('2d');
const labels = Object.keys(ratingData).sort((a,b) => b-a);
const chartData = labels.map(label => ratingData[label]);
if(ratingChart) ratingChart.destroy();
ratingChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels.map(l => l + ' Star'),
datasets: [{
label: 'Number of Reviews',
data: chartData,
backgroundColor: 'rgba(77, 201, 255, 0.5)',
borderColor: '#4dc9ff',
borderWidth: 1
}]
},
options: {
indexAxis: 'y',
scales: { x: { beginAtZero: true } },
plugins: { legend: { display: false } }
}
});
}
function updatePlatformChart(platformData) {
const ctx = document.getElementById('platformDistributionChart').getContext('2d');
const labels = Object.keys(platformData);
const chartData = Object.values(platformData).map(p => p.count);
const chartBackgroundColor = ['#4dc9ff', '#ff6384', '#ffce56', '#36a2eb', '#9966ff', '#ff9f40'];
if(platformChart) platformChart.destroy();
platformChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: chartData,
backgroundColor: chartBackgroundColor,
}]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom'} }
}
});
}
function updateRecentReviews(reviews) {
const tbody = document.getElementById('recent-reviews-body');
tbody.innerHTML = '';
if(reviews.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No reviews found.</td></tr>';
return;
}
reviews.slice(0, 10).forEach(review => {
const row = `
<tr>
<td>${review.customer_name || 'Anonymous'}</td>
<td>${review.source}</td>
<td><span class="text-warning">${ '★'.repeat(review.rating) + '☆'.repeat(5 - review.rating) }</span></td>
<td>${new Date(review.date).toLocaleDateString()}</td>
<td class="text-truncate" style="max-width: 300px;">${review.comment}</td>
</tr>
`;
tbody.innerHTML += row;
});
}
});
</script>
</body>
</html>