Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
156adbe5db | ||
|
|
26d8efd021 |
22
.htaccess
22
.htaccess
@ -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
209
ai-call-logs.php
Normal 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
200
api-keys.php
Normal 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
64
api/ai-call-logs.php
Normal 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
68
api/bookings.php
Normal 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
54
api/brightlocal-sync.php
Normal 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
82
api/calendar-events.php
Normal 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
68
api/call-tracking.php
Normal 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
84
api/chat-logs.php
Normal 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
51
api/config.php
Normal 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
84
api/keys.php
Normal 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
92
api/reviews-webhook.php
Normal 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
55
api/reviews.php
Normal 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
1
api/view-webhook-log.php
Normal 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
82
api/weather-fetch.php
Normal 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
70
api/weather.php
Normal 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
97
api/webhook_debug.log
Normal 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
156
assets/css/custom.css
Normal 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
86
assets/js/main.js
Normal 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'])}°F`;
|
||||
weatherFeelsLike.innerHTML = `Feels like ${Math.round(data[':feels_like_f'])}°F`;
|
||||
weatherDesc.textContent = data[':weather_description'].charAt(0).toUpperCase() + data[':weather_description'].slice(1);
|
||||
|
||||
const observationDate = new Date(data[':observation_time'].replace(/-/g, '/'));
|
||||
weatherLastUpdated.textContent = observationDate.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
|
||||
// Clear and build alerts
|
||||
weatherAlerts.innerHTML = '';
|
||||
if (data[':is_extreme_heat'] == 1) {
|
||||
weatherAlerts.innerHTML += `
|
||||
<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°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°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
38
assets/js/map.js
Normal 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
176
bookings.php
Normal 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
125
calendar.php
Normal 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
202
call-tracking.php
Normal 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
225
chat-logs.php
Normal 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
107
customers.php
Normal 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
48
db/schema.sql
Normal 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
54
db/schema_api.sql
Normal 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
17
db/seed.sql
Normal 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
449
index.php
@ -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']) ?>°F</span>
|
||||
<p id="weather-feels-like" class="mb-0 text-muted small">Feels like <?= round($current_weather['feels_like_f']) ?>°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
238
reviews.php
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user