Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdb6be6798 | ||
|
|
fe7ebfad4d | ||
|
|
bcdbd5300c | ||
|
|
4ad7fde6c2 | ||
|
|
3413390d5e | ||
|
|
b357806d05 | ||
|
|
d1b9211822 | ||
|
|
75f62724d5 | ||
|
|
f2a44e1184 | ||
|
|
8e948b66c3 | ||
|
|
1a71dc39b4 | ||
|
|
01edc11ccd | ||
|
|
281e356fda | ||
|
|
1a06383009 | ||
|
|
7e5d56a6cf | ||
|
|
a6a9d9d626 | ||
|
|
b12f985e57 | ||
|
|
5bb874b992 | ||
|
|
ce9f0dfe67 | ||
|
|
883324698c | ||
|
|
487c883d7f | ||
|
|
551fb8d177 | ||
|
|
d553a4a914 | ||
|
|
c733efa8ca | ||
|
|
84ff927075 | ||
|
|
1b68b62fde | ||
|
|
d05f2381f7 | ||
|
|
8f1d678d64 | ||
|
|
5cc3f02c65 | ||
|
|
a2ff9876ac | ||
|
|
94ddfaeca6 | ||
|
|
2c7ebcace0 | ||
|
|
2c65f7a5df | ||
|
|
a1880c468c | ||
|
|
bd0c257d11 | ||
|
|
2e98c61f0c | ||
|
|
7b84a1fc13 | ||
|
|
ac0f115fb6 | ||
|
|
f400adcf7c | ||
|
|
a2b2e8ae3b | ||
|
|
f8e0cdfba4 | ||
|
|
0dc1ca6104 | ||
|
|
16c40428e5 | ||
|
|
b9f0c9ea5a | ||
|
|
d762bc8263 | ||
|
|
4ba12356e0 | ||
|
|
9a7532b7f7 | ||
|
|
b7c23d9356 | ||
|
|
dbf75ca780 |
0
.perm_test_apache
Normal file
0
.perm_test_exec
Normal file
642
admin.php
Normal file
@ -0,0 +1,642 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once 'db/config.php';
|
||||||
|
require_once 'includes/admin_auth.php';
|
||||||
|
require_once 'includes/webinar_email.php';
|
||||||
|
|
||||||
|
admin_require_login();
|
||||||
|
|
||||||
|
function format_admin_datetime(?string $value): string {
|
||||||
|
if (!$value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (new DateTime($value))->format('M j, Y • g:i A');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind_named_values(PDOStatement $stmt, array $params): void {
|
||||||
|
foreach ($params as $name => $value) {
|
||||||
|
$stmt->bindValue($name, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = admin_get_flash();
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$records_per_page = 15;
|
||||||
|
$page = isset($_GET['page']) && is_numeric($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
|
||||||
|
$selected_webinar_id = isset($_GET['webinar_id']) && is_numeric($_GET['webinar_id']) ? max(0, (int) $_GET['webinar_id']) : 0;
|
||||||
|
|
||||||
|
$webinars = $pdo->query('SELECT id, title, description, presenter, scheduled_at FROM webinars ORDER BY scheduled_at DESC, id DESC')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$where_parts = ['a.deleted_at IS NULL'];
|
||||||
|
$params = [];
|
||||||
|
if ($selected_webinar_id > 0) {
|
||||||
|
$where_parts[] = 'a.webinar_id = :webinar_id';
|
||||||
|
$params[':webinar_id'] = $selected_webinar_id;
|
||||||
|
}
|
||||||
|
$where_sql = 'WHERE ' . implode(' AND ', $where_parts);
|
||||||
|
|
||||||
|
$count_stmt = $pdo->prepare("SELECT COUNT(*) FROM attendees a {$where_sql}");
|
||||||
|
bind_named_values($count_stmt, $params);
|
||||||
|
$count_stmt->execute();
|
||||||
|
$total_records = (int) $count_stmt->fetchColumn();
|
||||||
|
|
||||||
|
$total_pages = max(1, (int) ceil($total_records / $records_per_page));
|
||||||
|
if ($page > $total_pages) {
|
||||||
|
$page = $total_pages;
|
||||||
|
}
|
||||||
|
$offset = ($page - 1) * $records_per_page;
|
||||||
|
|
||||||
|
$today_stmt = $pdo->prepare("SELECT COUNT(*) FROM attendees a {$where_sql} AND DATE(a.created_at) = CURDATE()");
|
||||||
|
bind_named_values($today_stmt, $params);
|
||||||
|
$today_stmt->execute();
|
||||||
|
$today_count = (int) $today_stmt->fetchColumn();
|
||||||
|
|
||||||
|
$last7_stmt = $pdo->prepare("SELECT COUNT(*) FROM attendees a {$where_sql} AND a.created_at >= (NOW() - INTERVAL 7 DAY)");
|
||||||
|
bind_named_values($last7_stmt, $params);
|
||||||
|
$last7_stmt->execute();
|
||||||
|
$last_7_days_count = (int) $last7_stmt->fetchColumn();
|
||||||
|
|
||||||
|
$latest_stmt = $pdo->prepare("SELECT MAX(a.created_at) FROM attendees a {$where_sql}");
|
||||||
|
bind_named_values($latest_stmt, $params);
|
||||||
|
$latest_stmt->execute();
|
||||||
|
$latest_registration_at = $latest_stmt->fetchColumn();
|
||||||
|
|
||||||
|
$company_stmt = $pdo->prepare("SELECT COUNT(DISTINCT NULLIF(TRIM(a.company), '')) FROM attendees a {$where_sql}");
|
||||||
|
bind_named_values($company_stmt, $params);
|
||||||
|
$company_stmt->execute();
|
||||||
|
$unique_companies = (int) $company_stmt->fetchColumn();
|
||||||
|
|
||||||
|
$attendees_sql = "SELECT
|
||||||
|
a.id,
|
||||||
|
a.webinar_id,
|
||||||
|
a.first_name,
|
||||||
|
a.last_name,
|
||||||
|
a.email,
|
||||||
|
a.company,
|
||||||
|
a.how_did_you_hear,
|
||||||
|
a.timezone,
|
||||||
|
a.consented,
|
||||||
|
a.created_at,
|
||||||
|
w.title AS webinar_title,
|
||||||
|
w.scheduled_at AS webinar_scheduled_at
|
||||||
|
FROM attendees a
|
||||||
|
LEFT JOIN webinars w ON w.id = a.webinar_id
|
||||||
|
{$where_sql}
|
||||||
|
ORDER BY a.created_at DESC, a.id DESC
|
||||||
|
LIMIT :limit OFFSET :offset";
|
||||||
|
$attendees_stmt = $pdo->prepare($attendees_sql);
|
||||||
|
bind_named_values($attendees_stmt, $params);
|
||||||
|
$attendees_stmt->bindValue(':limit', $records_per_page, PDO::PARAM_INT);
|
||||||
|
$attendees_stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||||
|
$attendees_stmt->execute();
|
||||||
|
$attendees = $attendees_stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$chart_stmt = $pdo->prepare("SELECT DATE(a.created_at) AS registration_day, COUNT(*) AS user_count
|
||||||
|
FROM attendees a
|
||||||
|
{$where_sql}
|
||||||
|
GROUP BY DATE(a.created_at)
|
||||||
|
ORDER BY DATE(a.created_at)");
|
||||||
|
bind_named_values($chart_stmt, $params);
|
||||||
|
$chart_stmt->execute();
|
||||||
|
$chart_data = $chart_stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$selected_webinar = null;
|
||||||
|
foreach ($webinars as $webinar) {
|
||||||
|
if ((int) $webinar['id'] === $selected_webinar_id) {
|
||||||
|
$selected_webinar = $webinar;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$preview_webinar = $selected_webinar ?? ($webinars[0] ?? null);
|
||||||
|
$preview_email_payload = null;
|
||||||
|
$preview_scope_note = null;
|
||||||
|
|
||||||
|
if ($preview_webinar) {
|
||||||
|
$preview_email_payload = webinar_build_email_payload('there', $preview_webinar, true);
|
||||||
|
$preview_scope_note = $selected_webinar
|
||||||
|
? 'This preview matches the correction email for the selected webinar.'
|
||||||
|
: 'Preview shows the correction email template for the most recent webinar. When "All webinars" is selected, each attendee still receives the version for their own webinar.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$chart_labels = json_encode(array_column($chart_data, 'registration_day'));
|
||||||
|
$chart_values = json_encode(array_column($chart_data, 'user_count'));
|
||||||
|
$export_link = 'export_csv.php' . ($selected_webinar_id > 0 ? '?webinar_id=' . urlencode((string) $selected_webinar_id) : '');
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Dashboard | Webinar Registrations</title>
|
||||||
|
<meta name="description" content="Admin dashboard for webinar registrations, attendee management, CSV exports, and daily registration charts.">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<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;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-start: #0b2083;
|
||||||
|
--bg-end: #1029a0;
|
||||||
|
--surface: rgba(11, 32, 131, 0.40);
|
||||||
|
--surface-soft: rgba(16, 41, 160, 0.32);
|
||||||
|
--line: rgba(221, 226, 253, 0.18);
|
||||||
|
--text-main: #f3f9ff;
|
||||||
|
--text-soft: #dde2fd;
|
||||||
|
--text-muted: #97acd0;
|
||||||
|
--accent: #2c4bd1;
|
||||||
|
--accent-strong: #1029a0;
|
||||||
|
--success: #18d1b3;
|
||||||
|
--danger: #ff6b81;
|
||||||
|
--font-body: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-display: "Space Grotesk", "Inter", system-ui, sans-serif;
|
||||||
|
--button-shadow: 0 16px 30px rgba(11, 32, 131, 0.26);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(221, 226, 253, 0.22), transparent 28%),
|
||||||
|
linear-gradient(135deg, var(--bg-start) 0%, #1029a0 55%, var(--bg-end) 100%);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.admin-shell {
|
||||||
|
max-width: 1380px;
|
||||||
|
padding-top: 40px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
h1, h2, h3, .h5, .btn, .page-link, th, label {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.lead-copy,
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
.panel-card,
|
||||||
|
.table-shell,
|
||||||
|
.summary-card,
|
||||||
|
.toolbar-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 25px 60px rgba(3, 11, 25, 0.38);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
.panel-card,
|
||||||
|
.summary-card,
|
||||||
|
.toolbar-card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.table-shell {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.summary-label {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.summary-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.summary-meta {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.toolbar-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.filter-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.form-select,
|
||||||
|
.form-control {
|
||||||
|
min-width: 260px;
|
||||||
|
background: rgba(221, 226, 253, 0.08);
|
||||||
|
border-color: rgba(221, 226, 253, 0.18);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.form-select:focus,
|
||||||
|
.form-control:focus {
|
||||||
|
background: rgba(221, 226, 253, 0.10);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-color: rgba(44, 75, 209, 0.65);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(187, 200, 251, 0.18);
|
||||||
|
}
|
||||||
|
.table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
--bs-table-color: var(--text-main);
|
||||||
|
--bs-table-striped-bg: rgba(255, 255, 255, 0.03);
|
||||||
|
--bs-table-striped-color: var(--text-main);
|
||||||
|
--bs-table-border-color: rgba(221, 226, 253, 0.12);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.32rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(34, 199, 255, 0.14);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
border: 1px solid rgba(34, 199, 255, 0.18);
|
||||||
|
}
|
||||||
|
.attendee-name {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.attendee-meta,
|
||||||
|
.webinar-meta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
border-radius: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.14);
|
||||||
|
box-shadow: var(--button-shadow);
|
||||||
|
}
|
||||||
|
.btn-success {
|
||||||
|
background: linear-gradient(135deg, #18d1b3 0%, #0b8f7a 100%);
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 16px 30px rgba(24, 209, 179, 0.18);
|
||||||
|
}
|
||||||
|
.btn-outline-light {
|
||||||
|
border-color: rgba(221, 226, 253, 0.28);
|
||||||
|
}
|
||||||
|
.pagination .page-link {
|
||||||
|
color: var(--text-main);
|
||||||
|
background: rgba(221, 226, 253, 0.05);
|
||||||
|
border-color: rgba(221, 226, 253, 0.14);
|
||||||
|
}
|
||||||
|
.pagination .page-item.active .page-link {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
}
|
||||||
|
.email-preview-card {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.email-preview-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.email-preview-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.email-preview-frame-wrap {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.email-preview-frame {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 920px;
|
||||||
|
border: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.email-text-details {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.email-text-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.email-text-preview {
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(221, 226, 253, 0.08);
|
||||||
|
color: var(--text-soft);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
color: var(--accent-soft, #bbc8fb);
|
||||||
|
}
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.admin-shell {
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
.summary-value {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
}
|
||||||
|
.form-select,
|
||||||
|
.form-control {
|
||||||
|
min-width: 210px;
|
||||||
|
}
|
||||||
|
.email-preview-frame {
|
||||||
|
min-height: 760px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container admin-shell">
|
||||||
|
<header>
|
||||||
|
<h1>Webinar registrations dashboard</h1>
|
||||||
|
<p class="lead-copy mb-0">Welcome, <?php echo htmlspecialchars(admin_current_name()); ?>. Review attendee data, edit registrations, export CSV, and monitor daily signup volume.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<?php if ($message): ?>
|
||||||
|
<div class="alert alert-info"><?php echo htmlspecialchars($message); ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<section class="toolbar-card" aria-label="Dashboard filters and actions">
|
||||||
|
<form class="filter-form" method="GET" action="admin.php">
|
||||||
|
<div>
|
||||||
|
<label for="webinar_id" class="form-label mb-2">Webinar filter</label>
|
||||||
|
<select class="form-select" id="webinar_id" name="webinar_id">
|
||||||
|
<option value="0">All webinars</option>
|
||||||
|
<?php foreach ($webinars as $webinar): ?>
|
||||||
|
<option value="<?php echo (int) $webinar['id']; ?>" <?php echo (int) $webinar['id'] === $selected_webinar_id ? 'selected' : ''; ?>>
|
||||||
|
<?php echo htmlspecialchars($webinar['title']); ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button type="submit" class="btn btn-primary">Apply filter</button>
|
||||||
|
<a href="admin.php" class="btn btn-outline-light">Reset</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="d-flex gap-2 flex-wrap align-items-start">
|
||||||
|
<a href="<?php echo htmlspecialchars($export_link); ?>" class="btn btn-success">Download CSV</a>
|
||||||
|
<form action="resend_webinar_email.php" method="POST" class="m-0" onsubmit="return confirm('Send the updated webinar email with the corrected time and Google Meet link to all active attendees in the current filter?');">
|
||||||
|
<input type="hidden" name="webinar_id" value="<?php echo (int) $selected_webinar_id; ?>">
|
||||||
|
<button type="submit" class="btn btn-primary">Send correction email</button>
|
||||||
|
</form>
|
||||||
|
<a href="index.php" class="btn btn-outline-light">View site</a>
|
||||||
|
<a href="login.php?logout=1" class="btn btn-outline-light">Log out</a>
|
||||||
|
<div class="w-100 text-muted small mt-1">Sends the corrected webinar time, Google Meet link, Google Calendar button, and .ics file to all active attendees<?php echo $selected_webinar ? ' in the selected webinar' : ''; ?>.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if ($preview_email_payload): ?>
|
||||||
|
<section class="panel-card email-preview-card" aria-labelledby="email-preview-title">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="email-preview-title" class="h5 mb-1">Correction email preview</h2>
|
||||||
|
<p class="text-muted mb-0"><?php echo htmlspecialchars($preview_scope_note ?? 'Preview unavailable.'); ?></p>
|
||||||
|
</div>
|
||||||
|
<div class="email-preview-badges">
|
||||||
|
<span class="tag">Recipients: <?php echo number_format($total_records); ?></span>
|
||||||
|
<span class="tag">Preview webinar: <?php echo htmlspecialchars($preview_webinar['title']); ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="email-preview-meta">
|
||||||
|
<div><strong>Subject:</strong> <?php echo htmlspecialchars($preview_email_payload['subject']); ?></div>
|
||||||
|
<div><strong>Send scope:</strong> <?php echo $selected_webinar ? 'Selected webinar only' : 'All webinars (preview shown for the most recent webinar)'; ?></div>
|
||||||
|
<div><strong>What will be sent:</strong> Correction note, corrected webinar time, Google Meet link, Google Calendar button, and <code>.ics</code> calendar file.</div>
|
||||||
|
</div>
|
||||||
|
<div class="email-preview-frame-wrap">
|
||||||
|
<iframe
|
||||||
|
class="email-preview-frame"
|
||||||
|
title="Correction email HTML preview"
|
||||||
|
srcdoc="<?php echo htmlspecialchars($preview_email_payload['html'], ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
<details class="email-text-details">
|
||||||
|
<summary>Show plain-text version</summary>
|
||||||
|
<pre class="email-text-preview"><?php echo htmlspecialchars($preview_email_payload['text']); ?></pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<section class="summary-grid" aria-label="Registration summary">
|
||||||
|
<article class="summary-card">
|
||||||
|
<div class="summary-label">Total registrations</div>
|
||||||
|
<div class="summary-value"><?php echo number_format($total_records); ?></div>
|
||||||
|
<div class="summary-meta">Active attendees<?php echo $selected_webinar ? ' for the selected webinar' : ' across all webinars'; ?>.</div>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card">
|
||||||
|
<div class="summary-label">Registered today</div>
|
||||||
|
<div class="summary-value"><?php echo number_format($today_count); ?></div>
|
||||||
|
<div class="summary-meta">New signups recorded on <?php echo (new DateTime())->format('M j, Y'); ?>.</div>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card">
|
||||||
|
<div class="summary-label">Last 7 days</div>
|
||||||
|
<div class="summary-value"><?php echo number_format($last_7_days_count); ?></div>
|
||||||
|
<div class="summary-meta">Rolling weekly registrations based on <code>created_at</code>.</div>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card">
|
||||||
|
<div class="summary-label">Companies represented</div>
|
||||||
|
<div class="summary-value"><?php echo number_format($unique_companies); ?></div>
|
||||||
|
<div class="summary-meta">Unique non-empty company names among active attendees.</div>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card">
|
||||||
|
<div class="summary-label">Latest registration</div>
|
||||||
|
<div class="summary-value" style="font-size: 1.15rem; line-height: 1.35;"><?php echo htmlspecialchars(format_admin_datetime($latest_registration_at)); ?></div>
|
||||||
|
<div class="summary-meta">Most recent active attendee creation timestamp.</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel-card" aria-labelledby="daily-registrations-title">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 id="daily-registrations-title" class="h5 mb-1">Daily registrations</h2>
|
||||||
|
<p class="text-muted mb-0">Chart based on attendee <code>created_at</code>, grouped by registration day<?php echo $selected_webinar ? ' for the selected webinar' : ''; ?>.</p>
|
||||||
|
</div>
|
||||||
|
<button id="downloadChartBtn" class="btn btn-sm btn-primary" type="button">Save as Image</button>
|
||||||
|
</div>
|
||||||
|
<div style="max-width: 840px; margin: auto;">
|
||||||
|
<canvas id="registrationsChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="toolbar-card" aria-labelledby="registered-attendees-title">
|
||||||
|
<div>
|
||||||
|
<h2 id="registered-attendees-title" class="h5 mb-1">Registered attendees</h2>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
<?php if ($selected_webinar): ?>
|
||||||
|
Showing active records for <strong><?php echo htmlspecialchars($selected_webinar['title']); ?></strong>.
|
||||||
|
<?php else: ?>
|
||||||
|
Showing active records for all webinars, newest registrations first.
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="table-responsive table-shell">
|
||||||
|
<table class="table table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Attendee</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Company</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Timezone</th>
|
||||||
|
<th>Webinar</th>
|
||||||
|
<th>Registered at</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($attendees)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="text-center py-4">No attendees found for this filter yet.</td>
|
||||||
|
</tr>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($attendees as $attendee): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo (int) $attendee['id']; ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="attendee-name"><?php echo htmlspecialchars(trim(($attendee['first_name'] ?? '') . ' ' . ($attendee['last_name'] ?? '')) ?: '—'); ?></span>
|
||||||
|
<span class="attendee-meta">Consent: <?php echo !empty($attendee['consented']) ? 'Yes' : 'No'; ?></span>
|
||||||
|
</td>
|
||||||
|
<td><?php echo htmlspecialchars($attendee['email'] ?? ''); ?></td>
|
||||||
|
<td><?php echo htmlspecialchars(($attendee['company'] ?? '') !== '' ? $attendee['company'] : '—'); ?></td>
|
||||||
|
<td><span class="tag"><?php echo htmlspecialchars(($attendee['how_did_you_hear'] ?? '') !== '' ? $attendee['how_did_you_hear'] : '—'); ?></span></td>
|
||||||
|
<td><?php echo htmlspecialchars(($attendee['timezone'] ?? '') !== '' ? $attendee['timezone'] : '—'); ?></td>
|
||||||
|
<td>
|
||||||
|
<strong><?php echo htmlspecialchars(($attendee['webinar_title'] ?? '') !== '' ? $attendee['webinar_title'] : '—'); ?></strong>
|
||||||
|
<span class="webinar-meta"><?php echo htmlspecialchars(format_admin_datetime($attendee['webinar_scheduled_at'] ?? null)); ?></span>
|
||||||
|
</td>
|
||||||
|
<td><?php echo htmlspecialchars(format_admin_datetime($attendee['created_at'] ?? null)); ?></td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<a href="edit_attendee.php?id=<?php echo (int) $attendee['id']; ?>" class="btn btn-sm btn-primary">Edit</a>
|
||||||
|
<form action="delete_attendee.php" method="POST" class="m-0" onsubmit="return confirm('Soft-delete attendee #<?php echo (int) $attendee['id']; ?>?');">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int) $attendee['id']; ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-light">Archive</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($total_pages > 1): ?>
|
||||||
|
<nav class="mt-4" aria-label="Attendee pagination">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
<?php for ($i = 1; $i <= $total_pages; $i++): ?>
|
||||||
|
<li class="page-item <?php echo $i === $page ? 'active' : ''; ?>">
|
||||||
|
<a class="page-link" href="admin.php?page=<?php echo $i; ?><?php echo $selected_webinar_id > 0 ? '&webinar_id=' . urlencode((string) $selected_webinar_id) : ''; ?>"><?php echo $i; ?></a>
|
||||||
|
</li>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<?php endif; ?>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const chartLabels = <?php echo $chart_labels ?: '[]'; ?>;
|
||||||
|
const chartValues = <?php echo $chart_values ?: '[]'; ?>;
|
||||||
|
const chartCtx = document.getElementById('registrationsChart');
|
||||||
|
|
||||||
|
const registrationsChart = new Chart(chartCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: chartLabels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Registrations per day',
|
||||||
|
data: chartValues,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: '#22c7ff',
|
||||||
|
backgroundColor: 'rgba(34, 199, 255, 0.15)',
|
||||||
|
pointBackgroundColor: '#f3f9ff',
|
||||||
|
pointBorderColor: '#22c7ff',
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: '#dde2fd'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: '#dde2fd' },
|
||||||
|
grid: { color: 'rgba(221, 226, 253, 0.08)' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
color: '#dde2fd',
|
||||||
|
precision: 0
|
||||||
|
},
|
||||||
|
grid: { color: 'rgba(221, 226, 253, 0.08)' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('downloadChartBtn').addEventListener('click', function () {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = registrationsChart.toBase64Image('image/png', 1);
|
||||||
|
link.download = 'registrations-per-day.png';
|
||||||
|
link.click();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
493
ai/LocalAIApi.php
Normal file
@ -0,0 +1,493 @@
|
|||||||
|
<?php
|
||||||
|
// LocalAIApi — proxy client for the Responses API.
|
||||||
|
// Usage (async: auto-polls status until ready):
|
||||||
|
// require_once __DIR__ . '/ai/LocalAIApi.php';
|
||||||
|
// $response = LocalAIApi::createResponse([
|
||||||
|
// 'input' => [
|
||||||
|
// ['role' => 'system', 'content' => 'You are a helpful assistant.'],
|
||||||
|
// ['role' => 'user', 'content' => 'Tell me a bedtime story.'],
|
||||||
|
// ],
|
||||||
|
// ]);
|
||||||
|
// if (!empty($response['success'])) {
|
||||||
|
// // response['data'] contains full payload, e.g.:
|
||||||
|
// // {
|
||||||
|
// // "id": "resp_xxx",
|
||||||
|
// // "status": "completed",
|
||||||
|
// // "output": [
|
||||||
|
// // {"type": "reasoning", "summary": []},
|
||||||
|
// // {"type": "message", "content": [{"type": "output_text", "text": "Your final answer here."}]}
|
||||||
|
// // ]
|
||||||
|
// // }
|
||||||
|
// $decoded = LocalAIApi::decodeJsonFromResponse($response); // or inspect $response['data'] / extractText(...)
|
||||||
|
// }
|
||||||
|
// Poll settings override:
|
||||||
|
// LocalAIApi::createResponse($payload, ['poll_interval' => 5, 'poll_timeout' => 300]);
|
||||||
|
|
||||||
|
class LocalAIApi
|
||||||
|
{
|
||||||
|
/** @var array<string,mixed>|null */
|
||||||
|
private static ?array $configCache = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signature compatible with the OpenAI Responses API.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Request body (model, input, text, reasoning, metadata, etc.).
|
||||||
|
* @param array<string,mixed> $options Extra options (timeout, verify_tls, headers, path, project_uuid).
|
||||||
|
* @return array{
|
||||||
|
* success:bool,
|
||||||
|
* status?:int,
|
||||||
|
* data?:mixed,
|
||||||
|
* error?:string,
|
||||||
|
* response?:mixed,
|
||||||
|
* message?:string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function createResponse(array $params, array $options = []): array
|
||||||
|
{
|
||||||
|
$cfg = self::config();
|
||||||
|
$payload = $params;
|
||||||
|
|
||||||
|
if (empty($payload['input']) || !is_array($payload['input'])) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'input_missing',
|
||||||
|
'message' => 'Parameter "input" is required and must be an array.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($payload['model']) || $payload['model'] === '') {
|
||||||
|
$payload['model'] = $cfg['default_model'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$initial = self::request($options['path'] ?? null, $payload, $options);
|
||||||
|
if (empty($initial['success'])) {
|
||||||
|
return $initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async flow: if backend returns ai_request_id, poll status until ready
|
||||||
|
$data = $initial['data'] ?? null;
|
||||||
|
if (is_array($data) && isset($data['ai_request_id'])) {
|
||||||
|
$aiRequestId = $data['ai_request_id'];
|
||||||
|
$pollTimeout = isset($options['poll_timeout']) ? (int) $options['poll_timeout'] : 300; // seconds
|
||||||
|
$pollInterval = isset($options['poll_interval']) ? (int) $options['poll_interval'] : 5; // seconds
|
||||||
|
return self::awaitResponse($aiRequestId, [
|
||||||
|
'timeout' => $pollTimeout,
|
||||||
|
'interval' => $pollInterval,
|
||||||
|
'headers' => $options['headers'] ?? [],
|
||||||
|
'timeout_per_call' => $options['timeout'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snake_case alias for createResponse (matches the provided example).
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params
|
||||||
|
* @param array<string,mixed> $options
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function create_response(array $params, array $options = []): array
|
||||||
|
{
|
||||||
|
return self::createResponse($params, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a raw request to the AI proxy.
|
||||||
|
*
|
||||||
|
* @param string $path Endpoint (may be an absolute URL).
|
||||||
|
* @param array<string,mixed> $payload JSON payload.
|
||||||
|
* @param array<string,mixed> $options Additional request options.
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function request(?string $path = null, array $payload = [], array $options = []): array
|
||||||
|
{
|
||||||
|
$cfg = self::config();
|
||||||
|
|
||||||
|
$projectUuid = $cfg['project_uuid'];
|
||||||
|
if (empty($projectUuid)) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'project_uuid_missing',
|
||||||
|
'message' => 'PROJECT_UUID is not defined; aborting AI request.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaultPath = $cfg['responses_path'] ?? null;
|
||||||
|
$resolvedPath = $path ?? ($options['path'] ?? $defaultPath);
|
||||||
|
if (empty($resolvedPath)) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'project_id_missing',
|
||||||
|
'message' => 'PROJECT_ID is not defined; cannot resolve AI proxy endpoint.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = self::buildUrl($resolvedPath, $cfg['base_url']);
|
||||||
|
$baseTimeout = isset($cfg['timeout']) ? (int) $cfg['timeout'] : 30;
|
||||||
|
$timeout = isset($options['timeout']) ? (int) $options['timeout'] : $baseTimeout;
|
||||||
|
if ($timeout <= 0) {
|
||||||
|
$timeout = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseVerifyTls = array_key_exists('verify_tls', $cfg) ? (bool) $cfg['verify_tls'] : true;
|
||||||
|
$verifyTls = array_key_exists('verify_tls', $options)
|
||||||
|
? (bool) $options['verify_tls']
|
||||||
|
: $baseVerifyTls;
|
||||||
|
|
||||||
|
$projectHeader = $cfg['project_header'];
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Accept: application/json',
|
||||||
|
];
|
||||||
|
$headers[] = $projectHeader . ': ' . $projectUuid;
|
||||||
|
if (!empty($options['headers']) && is_array($options['headers'])) {
|
||||||
|
foreach ($options['headers'] as $header) {
|
||||||
|
if (is_string($header) && $header !== '') {
|
||||||
|
$headers[] = $header;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($projectUuid) && !array_key_exists('project_uuid', $payload)) {
|
||||||
|
$payload['project_uuid'] = $projectUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($body === false) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'json_encode_failed',
|
||||||
|
'message' => 'Failed to encode request body to JSON.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::sendCurl($url, 'POST', $body, $headers, $timeout, $verifyTls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll AI request status until ready or timeout.
|
||||||
|
*
|
||||||
|
* @param int|string $aiRequestId
|
||||||
|
* @param array<string,mixed> $options
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function awaitResponse($aiRequestId, array $options = []): array
|
||||||
|
{
|
||||||
|
$cfg = self::config();
|
||||||
|
|
||||||
|
$timeout = isset($options['timeout']) ? (int) $options['timeout'] : 300; // seconds
|
||||||
|
$interval = isset($options['interval']) ? (int) $options['interval'] : 5; // seconds
|
||||||
|
if ($interval <= 0) {
|
||||||
|
$interval = 5;
|
||||||
|
}
|
||||||
|
$perCallTimeout = isset($options['timeout_per_call']) ? (int) $options['timeout_per_call'] : null;
|
||||||
|
|
||||||
|
$deadline = time() + max($timeout, $interval);
|
||||||
|
$headers = $options['headers'] ?? [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
$statusResp = self::fetchStatus($aiRequestId, [
|
||||||
|
'headers' => $headers,
|
||||||
|
'timeout' => $perCallTimeout,
|
||||||
|
]);
|
||||||
|
if (!empty($statusResp['success'])) {
|
||||||
|
$data = $statusResp['data'] ?? [];
|
||||||
|
if (is_array($data)) {
|
||||||
|
$statusValue = $data['status'] ?? null;
|
||||||
|
if ($statusValue === 'success') {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'status' => 200,
|
||||||
|
'data' => $data['response'] ?? $data,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($statusValue === 'failed') {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'status' => 500,
|
||||||
|
'error' => isset($data['error']) ? (string)$data['error'] : 'AI request failed',
|
||||||
|
'data' => $data,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return $statusResp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time() >= $deadline) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'timeout',
|
||||||
|
'message' => 'Timed out waiting for AI response.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
sleep($interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch status for queued AI request.
|
||||||
|
*
|
||||||
|
* @param int|string $aiRequestId
|
||||||
|
* @param array<string,mixed> $options
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function fetchStatus($aiRequestId, array $options = []): array
|
||||||
|
{
|
||||||
|
$cfg = self::config();
|
||||||
|
$projectUuid = $cfg['project_uuid'];
|
||||||
|
if (empty($projectUuid)) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'project_uuid_missing',
|
||||||
|
'message' => 'PROJECT_UUID is not defined; aborting status check.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusPath = self::resolveStatusPath($aiRequestId, $cfg);
|
||||||
|
$url = self::buildUrl($statusPath, $cfg['base_url']);
|
||||||
|
|
||||||
|
$baseTimeout = isset($cfg['timeout']) ? (int) $cfg['timeout'] : 30;
|
||||||
|
$timeout = isset($options['timeout']) ? (int) $options['timeout'] : $baseTimeout;
|
||||||
|
if ($timeout <= 0) {
|
||||||
|
$timeout = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseVerifyTls = array_key_exists('verify_tls', $cfg) ? (bool) $cfg['verify_tls'] : true;
|
||||||
|
$verifyTls = array_key_exists('verify_tls', $options)
|
||||||
|
? (bool) $options['verify_tls']
|
||||||
|
: $baseVerifyTls;
|
||||||
|
|
||||||
|
$projectHeader = $cfg['project_header'];
|
||||||
|
$headers = [
|
||||||
|
'Accept: application/json',
|
||||||
|
$projectHeader . ': ' . $projectUuid,
|
||||||
|
];
|
||||||
|
if (!empty($options['headers']) && is_array($options['headers'])) {
|
||||||
|
foreach ($options['headers'] as $header) {
|
||||||
|
if (is_string($header) && $header !== '') {
|
||||||
|
$headers[] = $header;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::sendCurl($url, 'GET', null, $headers, $timeout, $verifyTls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract plain text from a Responses API payload.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $response Result of LocalAIApi::createResponse|request.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function extractText(array $response): string
|
||||||
|
{
|
||||||
|
$payload = $response['data'] ?? $response;
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($payload['output']) && is_array($payload['output'])) {
|
||||||
|
$combined = '';
|
||||||
|
foreach ($payload['output'] as $item) {
|
||||||
|
if (!isset($item['content']) || !is_array($item['content'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($item['content'] as $block) {
|
||||||
|
if (is_array($block) && ($block['type'] ?? '') === 'output_text' && !empty($block['text'])) {
|
||||||
|
$combined .= $block['text'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($combined !== '') {
|
||||||
|
return $combined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($payload['choices'][0]['message']['content'])) {
|
||||||
|
return (string) $payload['choices'][0]['message']['content'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to decode JSON emitted by the model (handles markdown fences).
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $response
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
public static function decodeJsonFromResponse(array $response): ?array
|
||||||
|
{
|
||||||
|
$text = self::extractText($response);
|
||||||
|
if ($text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($text, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stripped = preg_replace('/^```json|```$/m', '', trim($text));
|
||||||
|
if ($stripped !== null && $stripped !== $text) {
|
||||||
|
$decoded = json_decode($stripped, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration from ai/config.php.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function config(): array
|
||||||
|
{
|
||||||
|
if (self::$configCache === null) {
|
||||||
|
$configPath = __DIR__ . '/config.php';
|
||||||
|
if (!file_exists($configPath)) {
|
||||||
|
throw new RuntimeException('AI config file not found: ai/config.php');
|
||||||
|
}
|
||||||
|
$cfg = require $configPath;
|
||||||
|
if (!is_array($cfg)) {
|
||||||
|
throw new RuntimeException('Invalid AI config format: expected array');
|
||||||
|
}
|
||||||
|
self::$configCache = $cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$configCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an absolute URL from base_url and a path.
|
||||||
|
*/
|
||||||
|
private static function buildUrl(string $path, string $baseUrl): string
|
||||||
|
{
|
||||||
|
$trimmed = trim($path);
|
||||||
|
if ($trimmed === '') {
|
||||||
|
return $baseUrl;
|
||||||
|
}
|
||||||
|
if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) {
|
||||||
|
return $trimmed;
|
||||||
|
}
|
||||||
|
if ($trimmed[0] === '/') {
|
||||||
|
return $baseUrl . $trimmed;
|
||||||
|
}
|
||||||
|
return $baseUrl . '/' . $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve status path based on configured responses_path and ai_request_id.
|
||||||
|
*
|
||||||
|
* @param int|string $aiRequestId
|
||||||
|
* @param array<string,mixed> $cfg
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function resolveStatusPath($aiRequestId, array $cfg): string
|
||||||
|
{
|
||||||
|
$basePath = $cfg['responses_path'] ?? '';
|
||||||
|
$trimmed = rtrim($basePath, '/');
|
||||||
|
if ($trimmed === '') {
|
||||||
|
return '/ai-request/' . rawurlencode((string)$aiRequestId) . '/status';
|
||||||
|
}
|
||||||
|
if (substr($trimmed, -11) !== '/ai-request') {
|
||||||
|
$trimmed .= '/ai-request';
|
||||||
|
}
|
||||||
|
return $trimmed . '/' . rawurlencode((string)$aiRequestId) . '/status';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared CURL sender for GET/POST requests.
|
||||||
|
*
|
||||||
|
* @param string $url
|
||||||
|
* @param string $method
|
||||||
|
* @param string|null $body
|
||||||
|
* @param array<int,string> $headers
|
||||||
|
* @param int $timeout
|
||||||
|
* @param bool $verifyTls
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function sendCurl(string $url, string $method, ?string $body, array $headers, int $timeout, bool $verifyTls): array
|
||||||
|
{
|
||||||
|
if (!function_exists('curl_init')) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'curl_missing',
|
||||||
|
'message' => 'PHP cURL extension is missing. Install or enable it on the VM.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||||
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifyTls);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifyTls ? 2 : 0);
|
||||||
|
curl_setopt($ch, CURLOPT_FAILONERROR, false);
|
||||||
|
|
||||||
|
$upper = strtoupper($method);
|
||||||
|
if ($upper === 'POST') {
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? '');
|
||||||
|
} else {
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPGET, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$responseBody = curl_exec($ch);
|
||||||
|
if ($responseBody === false) {
|
||||||
|
$error = curl_error($ch) ?: 'Unknown cURL error';
|
||||||
|
curl_close($ch);
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'curl_error',
|
||||||
|
'message' => $error,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$decoded = null;
|
||||||
|
if ($responseBody !== '' && $responseBody !== null) {
|
||||||
|
$decoded = json_decode($responseBody, true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
$decoded = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status >= 200 && $status < 300) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'status' => $status,
|
||||||
|
'data' => $decoded ?? $responseBody,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$errorMessage = 'AI proxy request failed';
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$errorMessage = $decoded['error'] ?? $decoded['message'] ?? $errorMessage;
|
||||||
|
} elseif (is_string($responseBody) && $responseBody !== '') {
|
||||||
|
$errorMessage = $responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'status' => $status,
|
||||||
|
'error' => $errorMessage,
|
||||||
|
'response' => $decoded ?? $responseBody,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy alias for backward compatibility with the previous class name.
|
||||||
|
if (!class_exists('OpenAIService')) {
|
||||||
|
class_alias(LocalAIApi::class, 'OpenAIService');
|
||||||
|
}
|
||||||
52
ai/config.php
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
// OpenAI proxy configuration (workspace scope).
|
||||||
|
// Reads values from environment variables or executor/.env.
|
||||||
|
|
||||||
|
$projectUuid = getenv('PROJECT_UUID');
|
||||||
|
$projectId = getenv('PROJECT_ID');
|
||||||
|
|
||||||
|
if (
|
||||||
|
($projectUuid === false || $projectUuid === null || $projectUuid === '') ||
|
||||||
|
($projectId === false || $projectId === null || $projectId === '')
|
||||||
|
) {
|
||||||
|
$envPath = realpath(__DIR__ . '/../../.env'); // executor/.env
|
||||||
|
if ($envPath && is_readable($envPath)) {
|
||||||
|
$lines = @file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '' || $line[0] === '#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!str_contains($line, '=')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[$key, $value] = array_map('trim', explode('=', $line, 2));
|
||||||
|
if ($key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = trim($value, "\"' ");
|
||||||
|
if (getenv($key) === false || getenv($key) === '') {
|
||||||
|
putenv("{$key}={$value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$projectUuid = getenv('PROJECT_UUID');
|
||||||
|
$projectId = getenv('PROJECT_ID');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectUuid = ($projectUuid === false) ? null : $projectUuid;
|
||||||
|
$projectId = ($projectId === false) ? null : $projectId;
|
||||||
|
|
||||||
|
$baseUrl = 'https://flatlogic.com';
|
||||||
|
$responsesPath = $projectId ? "/projects/{$projectId}/ai-request" : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'base_url' => $baseUrl,
|
||||||
|
'responses_path' => $responsesPath,
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'project_uuid' => $projectUuid,
|
||||||
|
'project_header' => 'project-uuid',
|
||||||
|
'default_model' => 'gpt-5-mini',
|
||||||
|
'timeout' => 30,
|
||||||
|
'verify_tls' => true,
|
||||||
|
];
|
||||||
64
api/chat.php
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__ . '/../db/config.php';
|
||||||
|
require_once __DIR__ . '/../ai/LocalAIApi.php';
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$message = $input['message'] ?? '';
|
||||||
|
|
||||||
|
if (empty($message)) {
|
||||||
|
echo json_encode(['reply' => "I didn't catch that. Could you repeat?"]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch Knowledge Base (FAQs)
|
||||||
|
$stmt = db()->query("SELECT keywords, answer FROM faqs");
|
||||||
|
$faqs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$knowledgeBase = "Here is the knowledge base for this website:\n\n";
|
||||||
|
foreach ($faqs as $faq) {
|
||||||
|
$knowledgeBase .= "Q: " . $faq['keywords'] . "\nA: " . $faq['answer'] . "\n---\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Construct Prompt for AI
|
||||||
|
$systemPrompt = "You are a helpful, friendly AI assistant for this website. " .
|
||||||
|
"Use the provided Knowledge Base to answer user questions accurately. " .
|
||||||
|
"If the answer is found in the Knowledge Base, rephrase it naturally. " .
|
||||||
|
"If the answer is NOT in the Knowledge Base, use your general knowledge to help, " .
|
||||||
|
"but politely mention that you don't have specific information about that if it seems like a site-specific question. " .
|
||||||
|
"Keep answers concise and professional.\n\n" .
|
||||||
|
$knowledgeBase;
|
||||||
|
|
||||||
|
// 3. Call AI API
|
||||||
|
$response = LocalAIApi::createResponse([
|
||||||
|
'model' => 'gpt-4o-mini',
|
||||||
|
'input' => [
|
||||||
|
['role' => 'system', 'content' => $systemPrompt],
|
||||||
|
['role' => 'user', 'content' => $message],
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($response['success'])) {
|
||||||
|
$aiReply = LocalAIApi::extractText($response);
|
||||||
|
|
||||||
|
// 4. Save to Database
|
||||||
|
try {
|
||||||
|
$stmt = db()->prepare("INSERT INTO messages (user_message, ai_response) VALUES (?, ?)");
|
||||||
|
$stmt->execute([$message, $aiReply]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("DB Save Error: " . $e->getMessage());
|
||||||
|
// Continue even if save fails, so the user still gets a reply
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['reply' => $aiReply]);
|
||||||
|
} else {
|
||||||
|
// Fallback if AI fails
|
||||||
|
error_log("AI Error: " . ($response['error'] ?? 'Unknown'));
|
||||||
|
echo json_encode(['reply' => "I'm having trouble connecting to my brain right now. Please try again later."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("Chat Error: " . $e->getMessage());
|
||||||
|
echo json_encode(['reply' => "An internal error occurred."]);
|
||||||
|
}
|
||||||
29
api/pexels.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once __DIR__.'/../includes/pexels.php';
|
||||||
|
$qs = isset($_GET['queries']) ? explode(',', $_GET['queries']) : ['professional portrait','man smiling','woman smiling'];
|
||||||
|
$out = [];
|
||||||
|
foreach ($qs as $q) {
|
||||||
|
$u = 'https://api.pexels.com/v1/search?query=' . urlencode(trim($q)) . '&orientation=square&per_page=1&page=1';
|
||||||
|
$d = pexels_get($u);
|
||||||
|
if ($d && !empty($d['photos'])) {
|
||||||
|
$p = $d['photos'][0];
|
||||||
|
$src = $p['src']['original'] ?? null;
|
||||||
|
$dest = __DIR__.'/../assets/images/pexels/'.$p['id'].'.jpg';
|
||||||
|
if ($src) download_to($src, $dest);
|
||||||
|
$out[] = [
|
||||||
|
'src' => 'assets/images/pexels/'.$p['id'].'.jpg',
|
||||||
|
'photographer' => $p['photographer'] ?? 'Unknown',
|
||||||
|
'photographer_url' => $p['photographer_url'] ?? '',
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Fallback: Picsum
|
||||||
|
$out[] = [
|
||||||
|
'src' => 'https://picsum.photos/600',
|
||||||
|
'photographer' => 'Random Picsum',
|
||||||
|
'photographer_url' => 'https://picsum.photos/'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo json_encode($out);
|
||||||
|
?>
|
||||||
91
api/telegram_webhook.php
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../db/config.php';
|
||||||
|
require_once __DIR__ . '/../ai/LocalAIApi.php';
|
||||||
|
|
||||||
|
// Get Telegram Update
|
||||||
|
$content = file_get_contents("php://input");
|
||||||
|
$update = json_decode($content, true);
|
||||||
|
|
||||||
|
if (!$update || !isset($update['message'])) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $update['message'];
|
||||||
|
$chatId = $message['chat']['id'];
|
||||||
|
$text = $message['text'] ?? '';
|
||||||
|
|
||||||
|
if (empty($text)) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Telegram Token from DB
|
||||||
|
$stmt = db()->query("SELECT setting_value FROM settings WHERE setting_key = 'telegram_token'");
|
||||||
|
$token = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
if (!$token) {
|
||||||
|
error_log("Telegram Error: No bot token found in settings.");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendTelegramMessage($chatId, $text, $token) {
|
||||||
|
$url = "https://api.telegram.org/bot$token/sendMessage";
|
||||||
|
$data = [
|
||||||
|
'chat_id' => $chatId,
|
||||||
|
'text' => $text,
|
||||||
|
'parse_mode' => 'Markdown'
|
||||||
|
];
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'http' => [
|
||||||
|
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
|
||||||
|
'method' => 'POST',
|
||||||
|
'content' => http_build_query($data),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$context = stream_context_create($options);
|
||||||
|
return file_get_contents($url, false, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process with AI (Similar logic to api/chat.php)
|
||||||
|
try {
|
||||||
|
// 1. Fetch Knowledge Base
|
||||||
|
$stmt = db()->query("SELECT keywords, answer FROM faqs");
|
||||||
|
$faqs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$knowledgeBase = "Here is the knowledge base for this website:\n\n";
|
||||||
|
foreach ($faqs as $faq) {
|
||||||
|
$knowledgeBase .= "Q: " . $faq['keywords'] . "\nA: " . $faq['answer'] . "\n---\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$systemPrompt = "You are a helpful AI assistant integrated with Telegram. " .
|
||||||
|
"Use the provided Knowledge Base to answer user questions. " .
|
||||||
|
"Keep answers concise for mobile reading. Use Markdown for formatting.\n\n" .
|
||||||
|
$knowledgeBase;
|
||||||
|
|
||||||
|
// 2. Call AI
|
||||||
|
$response = LocalAIApi::createResponse([
|
||||||
|
'model' => 'gpt-4o-mini',
|
||||||
|
'input' => [
|
||||||
|
['role' => 'system', 'content' => $systemPrompt],
|
||||||
|
['role' => 'user', 'content' => $text],
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($response['success'])) {
|
||||||
|
$aiReply = LocalAIApi::extractText($response);
|
||||||
|
|
||||||
|
// 3. Save History
|
||||||
|
try {
|
||||||
|
$stmt = db()->prepare("INSERT INTO messages (user_message, ai_response) VALUES (?, ?)");
|
||||||
|
$stmt->execute(["[Telegram] " . $text, $aiReply]);
|
||||||
|
} catch (Exception $e) {}
|
||||||
|
|
||||||
|
// 4. Send back to Telegram
|
||||||
|
sendTelegramMessage($chatId, $aiReply, $token);
|
||||||
|
} else {
|
||||||
|
sendTelegramMessage($chatId, "I'm sorry, I encountered an error processing your request.", $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("Telegram Webhook Error: " . $e->getMessage());
|
||||||
|
}
|
||||||
90
assets/css/custom.css
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
:root {
|
||||||
|
--primary: #2c4bd1;
|
||||||
|
--primary-dark: #1029a0;
|
||||||
|
--accent: #18d1b3;
|
||||||
|
--dark-bg: #0b2083;
|
||||||
|
--card-bg: rgba(11, 32, 131, 0.40);
|
||||||
|
--text-main: #f3f9ff;
|
||||||
|
--text-muted: #bbc8fb;
|
||||||
|
--gradient: linear-gradient(135deg, #2c4bd1 0%, #1029a0 100%);
|
||||||
|
--bg-gradient: linear-gradient(-45deg, #0b2083, #1029a0, #2c4bd1);
|
||||||
|
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
--font-display: "Space Grotesk", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
--button-shadow: 0 18px 32px rgba(11, 32, 131, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-gradient);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: gradient 15s ease infinite;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Styling (AppWizzy style) */
|
||||||
|
.chat-container, .admin-container {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button {
|
||||||
|
background: var(--gradient);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.14);
|
||||||
|
padding: 0.82rem 1.55rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
box-shadow: var(--button-shadow);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 22px 36px rgba(11, 32, 131, 0.34);
|
||||||
|
filter: brightness(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
input, .form-control {
|
||||||
|
background: rgba(221, 226, 253, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
0
assets/images/flatlogic_logo.png
Normal file
BIN
assets/images/pexels/22601971.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
assets/images/pexels/34238049.jpg
Normal file
|
After Width: | Height: | Size: 470 KiB |
BIN
assets/images/pexels/34285016.jpg
Normal file
|
After Width: | Height: | Size: 762 KiB |
39
assets/js/main.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const chatForm = document.getElementById('chat-form');
|
||||||
|
const chatInput = document.getElementById('chat-input');
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
|
||||||
|
const appendMessage = (text, sender) => {
|
||||||
|
const msgDiv = document.createElement('div');
|
||||||
|
msgDiv.classList.add('message', sender);
|
||||||
|
msgDiv.textContent = text;
|
||||||
|
chatMessages.appendChild(msgDiv);
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
chatForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const message = chatInput.value.trim();
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
appendMessage(message, 'visitor');
|
||||||
|
chatInput.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('api/chat.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Artificial delay for realism
|
||||||
|
setTimeout(() => {
|
||||||
|
appendMessage(data.reply, 'bot');
|
||||||
|
}, 500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
assets/pasted-20251016-141336-dce056a4.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
assets/pasted-20251016-141746-e994507c.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
assets/pasted-20251016-142345-48170932.png
Normal file
|
After Width: | Height: | Size: 501 KiB |
BIN
assets/pasted-20251016-160256-cf9071d4.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/pasted-20251016-161033-a327c220.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
assets/pasted-20251017-092558-6823fffc.png
Normal file
|
After Width: | Height: | Size: 601 KiB |
BIN
assets/pasted-20251017-093140-48a8b5ba.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
assets/pasted-20251017-093539-e74373a7.png
Normal file
|
After Width: | Height: | Size: 511 KiB |
BIN
assets/pasted-20251017-094703-ce814c6a.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/pasted-20251017-095855-e8ae3c6d.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
assets/pasted-20251017-100534-44f1cec7.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
assets/pasted-20251017-105229-1478f697.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
assets/pasted-20251017-112741-3509bc32.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
assets/pasted-20251017-113238-94b9c787.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
16
assets/pasted-20251017-113531-8f1dec11.jpg
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<svg width="755" height="113" viewBox="0 0 755 113" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M79.2858 44.5879C79.2858 41.8567 81.4998 39.6427 84.2309 39.6427H106.055C108.786 39.6427 111 41.8567 111 44.5879V58.4832C111 61.2143 108.786 63.4284 106.055 63.4284H84.2309C81.4998 63.4284 79.2858 61.2143 79.2858 58.4832V44.5879Z" fill="#02004E"/>
|
||||||
|
<path d="M0 4.94518C0 2.21404 2.21403 0 4.94518 0H106.055C108.786 0 111 2.21403 111 4.94518V22.8048C111 25.5359 108.786 27.75 106.055 27.75H4.94518C2.21404 27.75 0 25.5359 0 22.8048V4.94518Z" fill="#5C7EF1"/>
|
||||||
|
<path d="M0 80.2665C0 77.5353 2.21403 75.3213 4.94518 75.3213H22.8048C25.5359 75.3213 27.75 77.5353 27.75 80.2665V94.1618C27.75 96.8929 25.5359 99.107 22.8048 99.107H4.94518C2.21404 99.107 0 96.8929 0 94.1618V80.2665Z" fill="#8C9DFF"/>
|
||||||
|
<path d="M43.6071 80.2665C43.6071 77.5353 45.8212 75.3213 48.5523 75.3213H106.055C108.786 75.3213 111 77.5353 111 80.2665V94.1618C111 96.8929 108.786 99.107 106.055 99.107H48.5523C45.8212 99.107 43.6071 96.8929 43.6071 94.1618V80.2665Z" fill="#02004E"/>
|
||||||
|
<path d="M0 44.5879C0 41.8567 2.21403 39.6427 4.94518 39.6427H58.4833C61.2144 39.6427 63.4285 41.8567 63.4285 44.5879V58.4832C63.4285 61.2143 61.2144 63.4284 58.4833 63.4284H4.94518C2.21403 63.4284 0 61.2143 0 58.4832V44.5879Z" fill="#FFA70B"/>
|
||||||
|
<path d="M147.724 10.7838H198.315V25.0124H162.63V43.0804H197.637V57.3089H162.63V89.8314H147.724V10.7838Z" fill="#02004E"/>
|
||||||
|
<path d="M215.401 76.2804H235.05V24.3348H216.078V10.7838H249.278V76.2804H267.572V89.8314H215.401V76.2804Z" fill="#02004E"/>
|
||||||
|
<path d="M321.133 81.7008C319.627 85.2391 317.519 87.7611 314.809 89.2667C312.099 90.6971 308.937 91.4123 305.323 91.4123C301.936 91.4123 298.736 90.7724 295.725 89.4926C292.789 88.2128 290.191 86.3307 287.933 83.8463C285.674 81.362 283.868 78.3506 282.512 74.8123C281.233 71.274 280.593 67.2463 280.593 62.7293V60.9225C280.593 56.4808 281.233 52.4908 282.512 48.9525C283.792 45.4142 285.524 42.4028 287.707 39.9185C289.89 37.4341 292.412 35.5521 295.273 34.2722C298.209 32.9171 301.333 32.2396 304.646 32.2396C308.636 32.2396 311.835 32.9171 314.244 34.2722C316.729 35.6273 318.686 37.7353 320.117 40.596H322.149V33.8205H336.378V72.8926C336.378 75.1511 337.394 76.2804 339.427 76.2804H341.572V89.8314H331.861C329.301 89.8314 327.193 89.0785 325.537 87.5729C323.956 86.0672 323.166 84.1098 323.166 81.7008H321.133ZM308.485 77.8613C312.626 77.8613 315.938 76.5062 318.423 73.796C320.907 71.0105 322.149 67.2464 322.149 62.5035V61.1484C322.149 56.4055 320.907 52.679 318.423 49.9688C315.938 47.1833 312.626 45.7906 308.485 45.7906C304.345 45.7906 301.032 47.1833 298.548 49.9688C296.064 52.679 294.821 56.4055 294.821 61.1484V62.5035C294.821 67.2464 296.064 71.0105 298.548 73.796C301.032 76.5062 304.345 77.8613 308.485 77.8613Z" fill="#02004E"/>
|
||||||
|
<path d="M352.448 33.8205H369.047V10.7838H383.276V33.8205H403.49V47.3715H383.276V72.8926C383.276 75.1511 384.292 76.2804 386.325 76.2804H401.457V89.8314H377.743C375.183 89.8314 373.075 89.0032 371.419 87.347C369.838 85.6908 369.047 83.5828 369.047 81.0232V47.3715H352.448V33.8205Z" fill="#02004E"/>
|
||||||
|
<path d="M422.834 76.2804H442.483V24.3348H423.512V10.7838H456.712V76.2804H475.006V89.8314H422.834V76.2804Z" fill="#02004E"/>
|
||||||
|
<path d="M547.199 62.5035C547.199 67.1711 546.409 71.3117 544.828 74.9253C543.247 78.4636 541.101 81.4749 538.391 83.9593C535.681 86.3683 532.556 88.2128 529.018 89.4926C525.555 90.7724 521.904 91.4123 518.064 91.4123C514.225 91.4123 510.536 90.7724 506.998 89.4926C503.535 88.2128 500.448 86.3683 497.738 83.9593C495.028 81.4749 492.882 78.4636 491.301 74.9253C489.72 71.3117 488.93 67.1711 488.93 62.5035V61.1484C488.93 56.5561 489.72 52.4908 491.301 48.9525C492.882 45.3389 495.028 42.2899 497.738 39.8056C500.448 37.3212 503.535 35.4391 506.998 34.1593C510.536 32.8795 514.225 32.2396 518.064 32.2396C521.904 32.2396 525.555 32.8795 529.018 34.1593C532.556 35.4391 535.681 37.3212 538.391 39.8056C541.101 42.2899 543.247 45.3389 544.828 48.9525C546.409 52.4908 547.199 56.5561 547.199 61.1484V62.5035ZM518.064 77.8613C520.097 77.8613 522.017 77.5225 523.824 76.845C525.63 76.1674 527.211 75.1887 528.566 73.9089C529.922 72.6291 530.976 71.0858 531.728 69.279C532.556 67.3969 532.971 65.289 532.971 62.9552V60.6967C532.971 58.3629 532.556 56.2926 531.728 54.4858C530.976 52.6037 529.922 51.0228 528.566 49.743C527.211 48.4632 525.63 47.4845 523.824 46.8069C522.017 46.1294 520.097 45.7906 518.064 45.7906C516.032 45.7906 514.112 46.1294 512.305 46.8069C510.498 47.4845 508.918 48.4632 507.562 49.743C506.207 51.0228 505.116 52.6037 504.288 54.4858C503.535 56.2926 503.158 58.3629 503.158 60.6967V62.9552C503.158 65.289 503.535 67.3969 504.288 69.279C505.116 71.0858 506.207 72.6291 507.562 73.9089C508.918 75.1887 510.498 76.1674 512.305 76.845C514.112 77.5225 516.032 77.8613 518.064 77.8613Z" fill="#02004E"/>
|
||||||
|
<path d="M601.438 81.7008H599.405C598.803 82.9806 598.05 84.2227 597.146 85.4273C596.318 86.5565 595.189 87.5729 593.759 88.4763C592.404 89.3797 590.747 90.0948 588.79 90.6218C586.908 91.1488 584.612 91.4123 581.901 91.4123C578.438 91.4123 575.201 90.81 572.19 89.6055C569.254 88.3257 566.657 86.4812 564.398 84.0722C562.215 81.5878 560.483 78.5765 559.204 75.0382C557.999 71.4999 557.397 67.4346 557.397 62.8423V60.8096C557.397 56.2926 558.037 52.265 559.316 48.7266C560.672 45.1883 562.478 42.2146 564.737 39.8056C567.071 37.3212 569.743 35.4391 572.755 34.1593C575.841 32.8795 579.078 32.2396 582.466 32.2396C586.983 32.2396 590.559 33.1053 593.194 34.8369C595.829 36.4931 597.899 38.8645 599.405 41.9511H601.438V33.8205H615.666V103.608C615.666 106.168 614.838 108.276 613.182 109.932C611.601 111.588 609.53 112.416 606.971 112.416H569.254V98.8654H598.389C600.421 98.8654 601.438 97.7361 601.438 95.4776V81.7008ZM586.531 77.8613C590.973 77.8613 594.549 76.4686 597.259 73.6831C600.045 70.8223 601.438 67.0958 601.438 62.5035V61.1484C601.438 56.5561 600.045 52.8672 597.259 50.0817C594.549 47.221 590.973 45.7906 586.531 45.7906C582.09 45.7906 578.476 47.1833 575.691 49.9688C572.98 52.679 571.625 56.4055 571.625 61.1484V62.5035C571.625 67.2464 572.98 71.0105 575.691 73.796C578.476 76.5062 582.09 77.8613 586.531 77.8613Z" fill="#02004E"/>
|
||||||
|
<path d="M631.623 76.2804H650.594V47.3715H632.978V33.8205H664.823V76.2804H681.084V89.8314H631.623V76.2804ZM668.324 17.2206C668.324 18.7262 668.022 20.1566 667.42 21.5117C666.893 22.7915 666.14 23.9208 665.162 24.8995C664.183 25.8029 663.016 26.5557 661.661 27.158C660.381 27.6849 659.026 27.9484 657.596 27.9484C656.09 27.9484 654.697 27.6849 653.417 27.158C652.138 26.5557 651.008 25.8029 650.03 24.8995C649.051 23.9208 648.261 22.7915 647.658 21.5117C647.131 20.1566 646.868 18.7262 646.868 17.2206C646.868 15.7149 647.131 14.3221 647.658 13.0423C648.261 11.6872 649.051 10.558 650.03 9.65458C651.008 8.67589 652.138 7.92306 653.417 7.39608C654.697 6.79381 656.09 6.49268 657.596 6.49268C659.026 6.49268 660.381 6.79381 661.661 7.39608C663.016 7.92306 664.183 8.67589 665.162 9.65458C666.14 10.558 666.893 11.6872 667.42 13.0423C668.022 14.3221 668.324 15.7149 668.324 17.2206Z" fill="#02004E"/>
|
||||||
|
<path d="M754.407 69.279C753.127 75.9039 750.078 81.2491 745.26 85.3144C740.517 89.3797 734.156 91.4123 726.176 91.4123C722.035 91.4123 718.158 90.7724 714.544 89.4926C711.006 88.2128 707.919 86.3683 705.284 83.9593C702.65 81.5502 700.579 78.5765 699.074 75.0382C697.568 71.4999 696.815 67.4722 696.815 62.9552V61.6001C696.815 57.0831 697.568 53.0178 699.074 49.4042C700.579 45.7906 702.65 42.704 705.284 40.1443C707.995 37.5847 711.119 35.6273 714.657 34.2722C718.271 32.9171 722.11 32.2396 726.176 32.2396C734.005 32.2396 740.329 34.2722 745.147 38.3375C750.04 42.4028 753.127 47.748 754.407 54.3729L740.404 57.9865C739.952 54.674 738.522 51.8133 736.113 49.4042C733.704 46.9951 730.316 45.7906 725.95 45.7906C723.917 45.7906 721.997 46.167 720.191 46.9198C718.384 47.6727 716.803 48.7643 715.448 50.1947C714.093 51.5498 713.001 53.2436 712.173 55.2763C711.42 57.2337 711.044 59.4545 711.044 61.9389V62.6164C711.044 65.1008 711.42 67.3216 712.173 69.279C713.001 71.1611 714.093 72.742 715.448 74.0219C716.803 75.3017 718.384 76.2803 720.191 76.9579C721.997 77.5602 723.917 77.8613 725.95 77.8613C730.316 77.8613 733.629 76.7697 735.887 74.5865C738.221 72.4033 739.727 69.4672 740.404 65.7783L754.407 69.279Z" fill="#02004E"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
16
assets/pasted-20251017-113610-1935da0a.jpg
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<svg width="755" height="113" viewBox="0 0 755 113" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M79.2858 44.5879C79.2858 41.8567 81.4998 39.6427 84.2309 39.6427H106.055C108.786 39.6427 111 41.8567 111 44.5879V58.4832C111 61.2143 108.786 63.4284 106.055 63.4284H84.2309C81.4998 63.4284 79.2858 61.2143 79.2858 58.4832V44.5879Z" fill="#02004E"/>
|
||||||
|
<path d="M0 4.94518C0 2.21404 2.21403 0 4.94518 0H106.055C108.786 0 111 2.21403 111 4.94518V22.8048C111 25.5359 108.786 27.75 106.055 27.75H4.94518C2.21404 27.75 0 25.5359 0 22.8048V4.94518Z" fill="#5C7EF1"/>
|
||||||
|
<path d="M0 80.2665C0 77.5353 2.21403 75.3213 4.94518 75.3213H22.8048C25.5359 75.3213 27.75 77.5353 27.75 80.2665V94.1618C27.75 96.8929 25.5359 99.107 22.8048 99.107H4.94518C2.21404 99.107 0 96.8929 0 94.1618V80.2665Z" fill="#8C9DFF"/>
|
||||||
|
<path d="M43.6071 80.2665C43.6071 77.5353 45.8212 75.3213 48.5523 75.3213H106.055C108.786 75.3213 111 77.5353 111 80.2665V94.1618C111 96.8929 108.786 99.107 106.055 99.107H48.5523C45.8212 99.107 43.6071 96.8929 43.6071 94.1618V80.2665Z" fill="#02004E"/>
|
||||||
|
<path d="M0 44.5879C0 41.8567 2.21403 39.6427 4.94518 39.6427H58.4833C61.2144 39.6427 63.4285 41.8567 63.4285 44.5879V58.4832C63.4285 61.2143 61.2144 63.4284 58.4833 63.4284H4.94518C2.21403 63.4284 0 61.2143 0 58.4832V44.5879Z" fill="#FFA70B"/>
|
||||||
|
<path d="M147.724 10.7838H198.315V25.0124H162.63V43.0804H197.637V57.3089H162.63V89.8314H147.724V10.7838Z" fill="#02004E"/>
|
||||||
|
<path d="M215.401 76.2804H235.05V24.3348H216.078V10.7838H249.278V76.2804H267.572V89.8314H215.401V76.2804Z" fill="#02004E"/>
|
||||||
|
<path d="M321.133 81.7008C319.627 85.2391 317.519 87.7611 314.809 89.2667C312.099 90.6971 308.937 91.4123 305.323 91.4123C301.936 91.4123 298.736 90.7724 295.725 89.4926C292.789 88.2128 290.191 86.3307 287.933 83.8463C285.674 81.362 283.868 78.3506 282.512 74.8123C281.233 71.274 280.593 67.2463 280.593 62.7293V60.9225C280.593 56.4808 281.233 52.4908 282.512 48.9525C283.792 45.4142 285.524 42.4028 287.707 39.9185C289.89 37.4341 292.412 35.5521 295.273 34.2722C298.209 32.9171 301.333 32.2396 304.646 32.2396C308.636 32.2396 311.835 32.9171 314.244 34.2722C316.729 35.6273 318.686 37.7353 320.117 40.596H322.149V33.8205H336.378V72.8926C336.378 75.1511 337.394 76.2804 339.427 76.2804H341.572V89.8314H331.861C329.301 89.8314 327.193 89.0785 325.537 87.5729C323.956 86.0672 323.166 84.1098 323.166 81.7008H321.133ZM308.485 77.8613C312.626 77.8613 315.938 76.5062 318.423 73.796C320.907 71.0105 322.149 67.2464 322.149 62.5035V61.1484C322.149 56.4055 320.907 52.679 318.423 49.9688C315.938 47.1833 312.626 45.7906 308.485 45.7906C304.345 45.7906 301.032 47.1833 298.548 49.9688C296.064 52.679 294.821 56.4055 294.821 61.1484V62.5035C294.821 67.2464 296.064 71.0105 298.548 73.796C301.032 76.5062 304.345 77.8613 308.485 77.8613Z" fill="#02004E"/>
|
||||||
|
<path d="M352.448 33.8205H369.047V10.7838H383.276V33.8205H403.49V47.3715H383.276V72.8926C383.276 75.1511 384.292 76.2804 386.325 76.2804H401.457V89.8314H377.743C375.183 89.8314 373.075 89.0032 371.419 87.347C369.838 85.6908 369.047 83.5828 369.047 81.0232V47.3715H352.448V33.8205Z" fill="#02004E"/>
|
||||||
|
<path d="M422.834 76.2804H442.483V24.3348H423.512V10.7838H456.712V76.2804H475.006V89.8314H422.834V76.2804Z" fill="#02004E"/>
|
||||||
|
<path d="M547.199 62.5035C547.199 67.1711 546.409 71.3117 544.828 74.9253C543.247 78.4636 541.101 81.4749 538.391 83.9593C535.681 86.3683 532.556 88.2128 529.018 89.4926C525.555 90.7724 521.904 91.4123 518.064 91.4123C514.225 91.4123 510.536 90.7724 506.998 89.4926C503.535 88.2128 500.448 86.3683 497.738 83.9593C495.028 81.4749 492.882 78.4636 491.301 74.9253C489.72 71.3117 488.93 67.1711 488.93 62.5035V61.1484C488.93 56.5561 489.72 52.4908 491.301 48.9525C492.882 45.3389 495.028 42.2899 497.738 39.8056C500.448 37.3212 503.535 35.4391 506.998 34.1593C510.536 32.8795 514.225 32.2396 518.064 32.2396C521.904 32.2396 525.555 32.8795 529.018 34.1593C532.556 35.4391 535.681 37.3212 538.391 39.8056C541.101 42.2899 543.247 45.3389 544.828 48.9525C546.409 52.4908 547.199 56.5561 547.199 61.1484V62.5035ZM518.064 77.8613C520.097 77.8613 522.017 77.5225 523.824 76.845C525.63 76.1674 527.211 75.1887 528.566 73.9089C529.922 72.6291 530.976 71.0858 531.728 69.279C532.556 67.3969 532.971 65.289 532.971 62.9552V60.6967C532.971 58.3629 532.556 56.2926 531.728 54.4858C530.976 52.6037 529.922 51.0228 528.566 49.743C527.211 48.4632 525.63 47.4845 523.824 46.8069C522.017 46.1294 520.097 45.7906 518.064 45.7906C516.032 45.7906 514.112 46.1294 512.305 46.8069C510.498 47.4845 508.918 48.4632 507.562 49.743C506.207 51.0228 505.116 52.6037 504.288 54.4858C503.535 56.2926 503.158 58.3629 503.158 60.6967V62.9552C503.158 65.289 503.535 67.3969 504.288 69.279C505.116 71.0858 506.207 72.6291 507.562 73.9089C508.918 75.1887 510.498 76.1674 512.305 76.845C514.112 77.5225 516.032 77.8613 518.064 77.8613Z" fill="#02004E"/>
|
||||||
|
<path d="M601.438 81.7008H599.405C598.803 82.9806 598.05 84.2227 597.146 85.4273C596.318 86.5565 595.189 87.5729 593.759 88.4763C592.404 89.3797 590.747 90.0948 588.79 90.6218C586.908 91.1488 584.612 91.4123 581.901 91.4123C578.438 91.4123 575.201 90.81 572.19 89.6055C569.254 88.3257 566.657 86.4812 564.398 84.0722C562.215 81.5878 560.483 78.5765 559.204 75.0382C557.999 71.4999 557.397 67.4346 557.397 62.8423V60.8096C557.397 56.2926 558.037 52.265 559.316 48.7266C560.672 45.1883 562.478 42.2146 564.737 39.8056C567.071 37.3212 569.743 35.4391 572.755 34.1593C575.841 32.8795 579.078 32.2396 582.466 32.2396C586.983 32.2396 590.559 33.1053 593.194 34.8369C595.829 36.4931 597.899 38.8645 599.405 41.9511H601.438V33.8205H615.666V103.608C615.666 106.168 614.838 108.276 613.182 109.932C611.601 111.588 609.53 112.416 606.971 112.416H569.254V98.8654H598.389C600.421 98.8654 601.438 97.7361 601.438 95.4776V81.7008ZM586.531 77.8613C590.973 77.8613 594.549 76.4686 597.259 73.6831C600.045 70.8223 601.438 67.0958 601.438 62.5035V61.1484C601.438 56.5561 600.045 52.8672 597.259 50.0817C594.549 47.221 590.973 45.7906 586.531 45.7906C582.09 45.7906 578.476 47.1833 575.691 49.9688C572.98 52.679 571.625 56.4055 571.625 61.1484V62.5035C571.625 67.2464 572.98 71.0105 575.691 73.796C578.476 76.5062 582.09 77.8613 586.531 77.8613Z" fill="#02004E"/>
|
||||||
|
<path d="M631.623 76.2804H650.594V47.3715H632.978V33.8205H664.823V76.2804H681.084V89.8314H631.623V76.2804ZM668.324 17.2206C668.324 18.7262 668.022 20.1566 667.42 21.5117C666.893 22.7915 666.14 23.9208 665.162 24.8995C664.183 25.8029 663.016 26.5557 661.661 27.158C660.381 27.6849 659.026 27.9484 657.596 27.9484C656.09 27.9484 654.697 27.6849 653.417 27.158C652.138 26.5557 651.008 25.8029 650.03 24.8995C649.051 23.9208 648.261 22.7915 647.658 21.5117C647.131 20.1566 646.868 18.7262 646.868 17.2206C646.868 15.7149 647.131 14.3221 647.658 13.0423C648.261 11.6872 649.051 10.558 650.03 9.65458C651.008 8.67589 652.138 7.92306 653.417 7.39608C654.697 6.79381 656.09 6.49268 657.596 6.49268C659.026 6.49268 660.381 6.79381 661.661 7.39608C663.016 7.92306 664.183 8.67589 665.162 9.65458C666.14 10.558 666.893 11.6872 667.42 13.0423C668.022 14.3221 668.324 15.7149 668.324 17.2206Z" fill="#02004E"/>
|
||||||
|
<path d="M754.407 69.279C753.127 75.9039 750.078 81.2491 745.26 85.3144C740.517 89.3797 734.156 91.4123 726.176 91.4123C722.035 91.4123 718.158 90.7724 714.544 89.4926C711.006 88.2128 707.919 86.3683 705.284 83.9593C702.65 81.5502 700.579 78.5765 699.074 75.0382C697.568 71.4999 696.815 67.4722 696.815 62.9552V61.6001C696.815 57.0831 697.568 53.0178 699.074 49.4042C700.579 45.7906 702.65 42.704 705.284 40.1443C707.995 37.5847 711.119 35.6273 714.657 34.2722C718.271 32.9171 722.11 32.2396 726.176 32.2396C734.005 32.2396 740.329 34.2722 745.147 38.3375C750.04 42.4028 753.127 47.748 754.407 54.3729L740.404 57.9865C739.952 54.674 738.522 51.8133 736.113 49.4042C733.704 46.9951 730.316 45.7906 725.95 45.7906C723.917 45.7906 721.997 46.167 720.191 46.9198C718.384 47.6727 716.803 48.7643 715.448 50.1947C714.093 51.5498 713.001 53.2436 712.173 55.2763C711.42 57.2337 711.044 59.4545 711.044 61.9389V62.6164C711.044 65.1008 711.42 67.3216 712.173 69.279C713.001 71.1611 714.093 72.742 715.448 74.0219C716.803 75.3017 718.384 76.2803 720.191 76.9579C721.997 77.5602 723.917 77.8613 725.95 77.8613C730.316 77.8613 733.629 76.7697 735.887 74.5865C738.221 72.4033 739.727 69.4672 740.404 65.7783L754.407 69.279Z" fill="#02004E"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
16
assets/pasted-20251017-113710-b292ccf6.jpg
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<svg width="755" height="113" viewBox="0 0 755 113" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M79.2858 44.5879C79.2858 41.8567 81.4998 39.6427 84.2309 39.6427H106.055C108.786 39.6427 111 41.8567 111 44.5879V58.4832C111 61.2143 108.786 63.4284 106.055 63.4284H84.2309C81.4998 63.4284 79.2858 61.2143 79.2858 58.4832V44.5879Z" fill="#02004E"/>
|
||||||
|
<path d="M0 4.94518C0 2.21404 2.21403 0 4.94518 0H106.055C108.786 0 111 2.21403 111 4.94518V22.8048C111 25.5359 108.786 27.75 106.055 27.75H4.94518C2.21404 27.75 0 25.5359 0 22.8048V4.94518Z" fill="#5C7EF1"/>
|
||||||
|
<path d="M0 80.2665C0 77.5353 2.21403 75.3213 4.94518 75.3213H22.8048C25.5359 75.3213 27.75 77.5353 27.75 80.2665V94.1618C27.75 96.8929 25.5359 99.107 22.8048 99.107H4.94518C2.21404 99.107 0 96.8929 0 94.1618V80.2665Z" fill="#8C9DFF"/>
|
||||||
|
<path d="M43.6071 80.2665C43.6071 77.5353 45.8212 75.3213 48.5523 75.3213H106.055C108.786 75.3213 111 77.5353 111 80.2665V94.1618C111 96.8929 108.786 99.107 106.055 99.107H48.5523C45.8212 99.107 43.6071 96.8929 43.6071 94.1618V80.2665Z" fill="#02004E"/>
|
||||||
|
<path d="M0 44.5879C0 41.8567 2.21403 39.6427 4.94518 39.6427H58.4833C61.2144 39.6427 63.4285 41.8567 63.4285 44.5879V58.4832C63.4285 61.2143 61.2144 63.4284 58.4833 63.4284H4.94518C2.21403 63.4284 0 61.2143 0 58.4832V44.5879Z" fill="#FFA70B"/>
|
||||||
|
<path d="M147.724 10.7838H198.315V25.0124H162.63V43.0804H197.637V57.3089H162.63V89.8314H147.724V10.7838Z" fill="#02004E"/>
|
||||||
|
<path d="M215.401 76.2804H235.05V24.3348H216.078V10.7838H249.278V76.2804H267.572V89.8314H215.401V76.2804Z" fill="#02004E"/>
|
||||||
|
<path d="M321.133 81.7008C319.627 85.2391 317.519 87.7611 314.809 89.2667C312.099 90.6971 308.937 91.4123 305.323 91.4123C301.936 91.4123 298.736 90.7724 295.725 89.4926C292.789 88.2128 290.191 86.3307 287.933 83.8463C285.674 81.362 283.868 78.3506 282.512 74.8123C281.233 71.274 280.593 67.2463 280.593 62.7293V60.9225C280.593 56.4808 281.233 52.4908 282.512 48.9525C283.792 45.4142 285.524 42.4028 287.707 39.9185C289.89 37.4341 292.412 35.5521 295.273 34.2722C298.209 32.9171 301.333 32.2396 304.646 32.2396C308.636 32.2396 311.835 32.9171 314.244 34.2722C316.729 35.6273 318.686 37.7353 320.117 40.596H322.149V33.8205H336.378V72.8926C336.378 75.1511 337.394 76.2804 339.427 76.2804H341.572V89.8314H331.861C329.301 89.8314 327.193 89.0785 325.537 87.5729C323.956 86.0672 323.166 84.1098 323.166 81.7008H321.133ZM308.485 77.8613C312.626 77.8613 315.938 76.5062 318.423 73.796C320.907 71.0105 322.149 67.2464 322.149 62.5035V61.1484C322.149 56.4055 320.907 52.679 318.423 49.9688C315.938 47.1833 312.626 45.7906 308.485 45.7906C304.345 45.7906 301.032 47.1833 298.548 49.9688C296.064 52.679 294.821 56.4055 294.821 61.1484V62.5035C294.821 67.2464 296.064 71.0105 298.548 73.796C301.032 76.5062 304.345 77.8613 308.485 77.8613Z" fill="#02004E"/>
|
||||||
|
<path d="M352.448 33.8205H369.047V10.7838H383.276V33.8205H403.49V47.3715H383.276V72.8926C383.276 75.1511 384.292 76.2804 386.325 76.2804H401.457V89.8314H377.743C375.183 89.8314 373.075 89.0032 371.419 87.347C369.838 85.6908 369.047 83.5828 369.047 81.0232V47.3715H352.448V33.8205Z" fill="#02004E"/>
|
||||||
|
<path d="M422.834 76.2804H442.483V24.3348H423.512V10.7838H456.712V76.2804H475.006V89.8314H422.834V76.2804Z" fill="#02004E"/>
|
||||||
|
<path d="M547.199 62.5035C547.199 67.1711 546.409 71.3117 544.828 74.9253C543.247 78.4636 541.101 81.4749 538.391 83.9593C535.681 86.3683 532.556 88.2128 529.018 89.4926C525.555 90.7724 521.904 91.4123 518.064 91.4123C514.225 91.4123 510.536 90.7724 506.998 89.4926C503.535 88.2128 500.448 86.3683 497.738 83.9593C495.028 81.4749 492.882 78.4636 491.301 74.9253C489.72 71.3117 488.93 67.1711 488.93 62.5035V61.1484C488.93 56.5561 489.72 52.4908 491.301 48.9525C492.882 45.3389 495.028 42.2899 497.738 39.8056C500.448 37.3212 503.535 35.4391 506.998 34.1593C510.536 32.8795 514.225 32.2396 518.064 32.2396C521.904 32.2396 525.555 32.8795 529.018 34.1593C532.556 35.4391 535.681 37.3212 538.391 39.8056C541.101 42.2899 543.247 45.3389 544.828 48.9525C546.409 52.4908 547.199 56.5561 547.199 61.1484V62.5035ZM518.064 77.8613C520.097 77.8613 522.017 77.5225 523.824 76.845C525.63 76.1674 527.211 75.1887 528.566 73.9089C529.922 72.6291 530.976 71.0858 531.728 69.279C532.556 67.3969 532.971 65.289 532.971 62.9552V60.6967C532.971 58.3629 532.556 56.2926 531.728 54.4858C530.976 52.6037 529.922 51.0228 528.566 49.743C527.211 48.4632 525.63 47.4845 523.824 46.8069C522.017 46.1294 520.097 45.7906 518.064 45.7906C516.032 45.7906 514.112 46.1294 512.305 46.8069C510.498 47.4845 508.918 48.4632 507.562 49.743C506.207 51.0228 505.116 52.6037 504.288 54.4858C503.535 56.2926 503.158 58.3629 503.158 60.6967V62.9552C503.158 65.289 503.535 67.3969 504.288 69.279C505.116 71.0858 506.207 72.6291 507.562 73.9089C508.918 75.1887 510.498 76.1674 512.305 76.845C514.112 77.5225 516.032 77.8613 518.064 77.8613Z" fill="#02004E"/>
|
||||||
|
<path d="M601.438 81.7008H599.405C598.803 82.9806 598.05 84.2227 597.146 85.4273C596.318 86.5565 595.189 87.5729 593.759 88.4763C592.404 89.3797 590.747 90.0948 588.79 90.6218C586.908 91.1488 584.612 91.4123 581.901 91.4123C578.438 91.4123 575.201 90.81 572.19 89.6055C569.254 88.3257 566.657 86.4812 564.398 84.0722C562.215 81.5878 560.483 78.5765 559.204 75.0382C557.999 71.4999 557.397 67.4346 557.397 62.8423V60.8096C557.397 56.2926 558.037 52.265 559.316 48.7266C560.672 45.1883 562.478 42.2146 564.737 39.8056C567.071 37.3212 569.743 35.4391 572.755 34.1593C575.841 32.8795 579.078 32.2396 582.466 32.2396C586.983 32.2396 590.559 33.1053 593.194 34.8369C595.829 36.4931 597.899 38.8645 599.405 41.9511H601.438V33.8205H615.666V103.608C615.666 106.168 614.838 108.276 613.182 109.932C611.601 111.588 609.53 112.416 606.971 112.416H569.254V98.8654H598.389C600.421 98.8654 601.438 97.7361 601.438 95.4776V81.7008ZM586.531 77.8613C590.973 77.8613 594.549 76.4686 597.259 73.6831C600.045 70.8223 601.438 67.0958 601.438 62.5035V61.1484C601.438 56.5561 600.045 52.8672 597.259 50.0817C594.549 47.221 590.973 45.7906 586.531 45.7906C582.09 45.7906 578.476 47.1833 575.691 49.9688C572.98 52.679 571.625 56.4055 571.625 61.1484V62.5035C571.625 67.2464 572.98 71.0105 575.691 73.796C578.476 76.5062 582.09 77.8613 586.531 77.8613Z" fill="#02004E"/>
|
||||||
|
<path d="M631.623 76.2804H650.594V47.3715H632.978V33.8205H664.823V76.2804H681.084V89.8314H631.623V76.2804ZM668.324 17.2206C668.324 18.7262 668.022 20.1566 667.42 21.5117C666.893 22.7915 666.14 23.9208 665.162 24.8995C664.183 25.8029 663.016 26.5557 661.661 27.158C660.381 27.6849 659.026 27.9484 657.596 27.9484C656.09 27.9484 654.697 27.6849 653.417 27.158C652.138 26.5557 651.008 25.8029 650.03 24.8995C649.051 23.9208 648.261 22.7915 647.658 21.5117C647.131 20.1566 646.868 18.7262 646.868 17.2206C646.868 15.7149 647.131 14.3221 647.658 13.0423C648.261 11.6872 649.051 10.558 650.03 9.65458C651.008 8.67589 652.138 7.92306 653.417 7.39608C654.697 6.79381 656.09 6.49268 657.596 6.49268C659.026 6.49268 660.381 6.79381 661.661 7.39608C663.016 7.92306 664.183 8.67589 665.162 9.65458C666.14 10.558 666.893 11.6872 667.42 13.0423C668.022 14.3221 668.324 15.7149 668.324 17.2206Z" fill="#02004E"/>
|
||||||
|
<path d="M754.407 69.279C753.127 75.9039 750.078 81.2491 745.26 85.3144C740.517 89.3797 734.156 91.4123 726.176 91.4123C722.035 91.4123 718.158 90.7724 714.544 89.4926C711.006 88.2128 707.919 86.3683 705.284 83.9593C702.65 81.5502 700.579 78.5765 699.074 75.0382C697.568 71.4999 696.815 67.4722 696.815 62.9552V61.6001C696.815 57.0831 697.568 53.0178 699.074 49.4042C700.579 45.7906 702.65 42.704 705.284 40.1443C707.995 37.5847 711.119 35.6273 714.657 34.2722C718.271 32.9171 722.11 32.2396 726.176 32.2396C734.005 32.2396 740.329 34.2722 745.147 38.3375C750.04 42.4028 753.127 47.748 754.407 54.3729L740.404 57.9865C739.952 54.674 738.522 51.8133 736.113 49.4042C733.704 46.9951 730.316 45.7906 725.95 45.7906C723.917 45.7906 721.997 46.167 720.191 46.9198C718.384 47.6727 716.803 48.7643 715.448 50.1947C714.093 51.5498 713.001 53.2436 712.173 55.2763C711.42 57.2337 711.044 59.4545 711.044 61.9389V62.6164C711.044 65.1008 711.42 67.3216 712.173 69.279C713.001 71.1611 714.093 72.742 715.448 74.0219C716.803 75.3017 718.384 76.2803 720.191 76.9579C721.997 77.5602 723.917 77.8613 725.95 77.8613C730.316 77.8613 733.629 76.7697 735.887 74.5865C738.221 72.4033 739.727 69.4672 740.404 65.7783L754.407 69.279Z" fill="#02004E"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
16
assets/pasted-20251017-113909-97a4d5fe.jpg
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<svg width="755" height="113" viewBox="0 0 755 113" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M79.2858 44.5879C79.2858 41.8567 81.4998 39.6427 84.2309 39.6427H106.055C108.786 39.6427 111 41.8567 111 44.5879V58.4832C111 61.2143 108.786 63.4284 106.055 63.4284H84.2309C81.4998 63.4284 79.2858 61.2143 79.2858 58.4832V44.5879Z" fill="#02004E"/>
|
||||||
|
<path d="M0 4.94518C0 2.21404 2.21403 0 4.94518 0H106.055C108.786 0 111 2.21403 111 4.94518V22.8048C111 25.5359 108.786 27.75 106.055 27.75H4.94518C2.21404 27.75 0 25.5359 0 22.8048V4.94518Z" fill="#5C7EF1"/>
|
||||||
|
<path d="M0 80.2665C0 77.5353 2.21403 75.3213 4.94518 75.3213H22.8048C25.5359 75.3213 27.75 77.5353 27.75 80.2665V94.1618C27.75 96.8929 25.5359 99.107 22.8048 99.107H4.94518C2.21404 99.107 0 96.8929 0 94.1618V80.2665Z" fill="#8C9DFF"/>
|
||||||
|
<path d="M43.6071 80.2665C43.6071 77.5353 45.8212 75.3213 48.5523 75.3213H106.055C108.786 75.3213 111 77.5353 111 80.2665V94.1618C111 96.8929 108.786 99.107 106.055 99.107H48.5523C45.8212 99.107 43.6071 96.8929 43.6071 94.1618V80.2665Z" fill="#02004E"/>
|
||||||
|
<path d="M0 44.5879C0 41.8567 2.21403 39.6427 4.94518 39.6427H58.4833C61.2144 39.6427 63.4285 41.8567 63.4285 44.5879V58.4832C63.4285 61.2143 61.2144 63.4284 58.4833 63.4284H4.94518C2.21403 63.4284 0 61.2143 0 58.4832V44.5879Z" fill="#FFA70B"/>
|
||||||
|
<path d="M147.724 10.7838H198.315V25.0124H162.63V43.0804H197.637V57.3089H162.63V89.8314H147.724V10.7838Z" fill="#02004E"/>
|
||||||
|
<path d="M215.401 76.2804H235.05V24.3348H216.078V10.7838H249.278V76.2804H267.572V89.8314H215.401V76.2804Z" fill="#02004E"/>
|
||||||
|
<path d="M321.133 81.7008C319.627 85.2391 317.519 87.7611 314.809 89.2667C312.099 90.6971 308.937 91.4123 305.323 91.4123C301.936 91.4123 298.736 90.7724 295.725 89.4926C292.789 88.2128 290.191 86.3307 287.933 83.8463C285.674 81.362 283.868 78.3506 282.512 74.8123C281.233 71.274 280.593 67.2463 280.593 62.7293V60.9225C280.593 56.4808 281.233 52.4908 282.512 48.9525C283.792 45.4142 285.524 42.4028 287.707 39.9185C289.89 37.4341 292.412 35.5521 295.273 34.2722C298.209 32.9171 301.333 32.2396 304.646 32.2396C308.636 32.2396 311.835 32.9171 314.244 34.2722C316.729 35.6273 318.686 37.7353 320.117 40.596H322.149V33.8205H336.378V72.8926C336.378 75.1511 337.394 76.2804 339.427 76.2804H341.572V89.8314H331.861C329.301 89.8314 327.193 89.0785 325.537 87.5729C323.956 86.0672 323.166 84.1098 323.166 81.7008H321.133ZM308.485 77.8613C312.626 77.8613 315.938 76.5062 318.423 73.796C320.907 71.0105 322.149 67.2464 322.149 62.5035V61.1484C322.149 56.4055 320.907 52.679 318.423 49.9688C315.938 47.1833 312.626 45.7906 308.485 45.7906C304.345 45.7906 301.032 47.1833 298.548 49.9688C296.064 52.679 294.821 56.4055 294.821 61.1484V62.5035C294.821 67.2464 296.064 71.0105 298.548 73.796C301.032 76.5062 304.345 77.8613 308.485 77.8613Z" fill="#02004E"/>
|
||||||
|
<path d="M352.448 33.8205H369.047V10.7838H383.276V33.8205H403.49V47.3715H383.276V72.8926C383.276 75.1511 384.292 76.2804 386.325 76.2804H401.457V89.8314H377.743C375.183 89.8314 373.075 89.0032 371.419 87.347C369.838 85.6908 369.047 83.5828 369.047 81.0232V47.3715H352.448V33.8205Z" fill="#02004E"/>
|
||||||
|
<path d="M422.834 76.2804H442.483V24.3348H423.512V10.7838H456.712V76.2804H475.006V89.8314H422.834V76.2804Z" fill="#02004E"/>
|
||||||
|
<path d="M547.199 62.5035C547.199 67.1711 546.409 71.3117 544.828 74.9253C543.247 78.4636 541.101 81.4749 538.391 83.9593C535.681 86.3683 532.556 88.2128 529.018 89.4926C525.555 90.7724 521.904 91.4123 518.064 91.4123C514.225 91.4123 510.536 90.7724 506.998 89.4926C503.535 88.2128 500.448 86.3683 497.738 83.9593C495.028 81.4749 492.882 78.4636 491.301 74.9253C489.72 71.3117 488.93 67.1711 488.93 62.5035V61.1484C488.93 56.5561 489.72 52.4908 491.301 48.9525C492.882 45.3389 495.028 42.2899 497.738 39.8056C500.448 37.3212 503.535 35.4391 506.998 34.1593C510.536 32.8795 514.225 32.2396 518.064 32.2396C521.904 32.2396 525.555 32.8795 529.018 34.1593C532.556 35.4391 535.681 37.3212 538.391 39.8056C541.101 42.2899 543.247 45.3389 544.828 48.9525C546.409 52.4908 547.199 56.5561 547.199 61.1484V62.5035ZM518.064 77.8613C520.097 77.8613 522.017 77.5225 523.824 76.845C525.63 76.1674 527.211 75.1887 528.566 73.9089C529.922 72.6291 530.976 71.0858 531.728 69.279C532.556 67.3969 532.971 65.289 532.971 62.9552V60.6967C532.971 58.3629 532.556 56.2926 531.728 54.4858C530.976 52.6037 529.922 51.0228 528.566 49.743C527.211 48.4632 525.63 47.4845 523.824 46.8069C522.017 46.1294 520.097 45.7906 518.064 45.7906C516.032 45.7906 514.112 46.1294 512.305 46.8069C510.498 47.4845 508.918 48.4632 507.562 49.743C506.207 51.0228 505.116 52.6037 504.288 54.4858C503.535 56.2926 503.158 58.3629 503.158 60.6967V62.9552C503.158 65.289 503.535 67.3969 504.288 69.279C505.116 71.0858 506.207 72.6291 507.562 73.9089C508.918 75.1887 510.498 76.1674 512.305 76.845C514.112 77.5225 516.032 77.8613 518.064 77.8613Z" fill="#02004E"/>
|
||||||
|
<path d="M601.438 81.7008H599.405C598.803 82.9806 598.05 84.2227 597.146 85.4273C596.318 86.5565 595.189 87.5729 593.759 88.4763C592.404 89.3797 590.747 90.0948 588.79 90.6218C586.908 91.1488 584.612 91.4123 581.901 91.4123C578.438 91.4123 575.201 90.81 572.19 89.6055C569.254 88.3257 566.657 86.4812 564.398 84.0722C562.215 81.5878 560.483 78.5765 559.204 75.0382C557.999 71.4999 557.397 67.4346 557.397 62.8423V60.8096C557.397 56.2926 558.037 52.265 559.316 48.7266C560.672 45.1883 562.478 42.2146 564.737 39.8056C567.071 37.3212 569.743 35.4391 572.755 34.1593C575.841 32.8795 579.078 32.2396 582.466 32.2396C586.983 32.2396 590.559 33.1053 593.194 34.8369C595.829 36.4931 597.899 38.8645 599.405 41.9511H601.438V33.8205H615.666V103.608C615.666 106.168 614.838 108.276 613.182 109.932C611.601 111.588 609.53 112.416 606.971 112.416H569.254V98.8654H598.389C600.421 98.8654 601.438 97.7361 601.438 95.4776V81.7008ZM586.531 77.8613C590.973 77.8613 594.549 76.4686 597.259 73.6831C600.045 70.8223 601.438 67.0958 601.438 62.5035V61.1484C601.438 56.5561 600.045 52.8672 597.259 50.0817C594.549 47.221 590.973 45.7906 586.531 45.7906C582.09 45.7906 578.476 47.1833 575.691 49.9688C572.98 52.679 571.625 56.4055 571.625 61.1484V62.5035C571.625 67.2464 572.98 71.0105 575.691 73.796C578.476 76.5062 582.09 77.8613 586.531 77.8613Z" fill="#02004E"/>
|
||||||
|
<path d="M631.623 76.2804H650.594V47.3715H632.978V33.8205H664.823V76.2804H681.084V89.8314H631.623V76.2804ZM668.324 17.2206C668.324 18.7262 668.022 20.1566 667.42 21.5117C666.893 22.7915 666.14 23.9208 665.162 24.8995C664.183 25.8029 663.016 26.5557 661.661 27.158C660.381 27.6849 659.026 27.9484 657.596 27.9484C656.09 27.9484 654.697 27.6849 653.417 27.158C652.138 26.5557 651.008 25.8029 650.03 24.8995C649.051 23.9208 648.261 22.7915 647.658 21.5117C647.131 20.1566 646.868 18.7262 646.868 17.2206C646.868 15.7149 647.131 14.3221 647.658 13.0423C648.261 11.6872 649.051 10.558 650.03 9.65458C651.008 8.67589 652.138 7.92306 653.417 7.39608C654.697 6.79381 656.09 6.49268 657.596 6.49268C659.026 6.49268 660.381 6.79381 661.661 7.39608C663.016 7.92306 664.183 8.67589 665.162 9.65458C666.14 10.558 666.893 11.6872 667.42 13.0423C668.022 14.3221 668.324 15.7149 668.324 17.2206Z" fill="#02004E"/>
|
||||||
|
<path d="M754.407 69.279C753.127 75.9039 750.078 81.2491 745.26 85.3144C740.517 89.3797 734.156 91.4123 726.176 91.4123C722.035 91.4123 718.158 90.7724 714.544 89.4926C711.006 88.2128 707.919 86.3683 705.284 83.9593C702.65 81.5502 700.579 78.5765 699.074 75.0382C697.568 71.4999 696.815 67.4722 696.815 62.9552V61.6001C696.815 57.0831 697.568 53.0178 699.074 49.4042C700.579 45.7906 702.65 42.704 705.284 40.1443C707.995 37.5847 711.119 35.6273 714.657 34.2722C718.271 32.9171 722.11 32.2396 726.176 32.2396C734.005 32.2396 740.329 34.2722 745.147 38.3375C750.04 42.4028 753.127 47.748 754.407 54.3729L740.404 57.9865C739.952 54.674 738.522 51.8133 736.113 49.4042C733.704 46.9951 730.316 45.7906 725.95 45.7906C723.917 45.7906 721.997 46.167 720.191 46.9198C718.384 47.6727 716.803 48.7643 715.448 50.1947C714.093 51.5498 713.001 53.2436 712.173 55.2763C711.42 57.2337 711.044 59.4545 711.044 61.9389V62.6164C711.044 65.1008 711.42 67.3216 712.173 69.279C713.001 71.1611 714.093 72.742 715.448 74.0219C716.803 75.3017 718.384 76.2803 720.191 76.9579C721.997 77.5602 723.917 77.8613 725.95 77.8613C730.316 77.8613 733.629 76.7697 735.887 74.5865C738.221 72.4033 739.727 69.4672 740.404 65.7783L754.407 69.279Z" fill="#02004E"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
BIN
assets/pasted-20251017-114417-852523c9.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
assets/pasted-20251017-114555-f353c4d9.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
assets/pasted-20251017-115404-4e52d477.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
assets/pasted-20251017-115640-64b19aff.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
assets/pasted-20251017-115952-0d605af0.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
assets/pasted-20251017-120438-de869d16.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/pasted-20251025-190102-dd19def2.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
assets/pasted-20251025-190534-b34b4e03.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
assets/pasted-20251025-190634-62543fd4.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
assets/pasted-20251025-190711-b09e530a.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
assets/pasted-20251025-190857-c1b4b325.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
assets/pasted-20251025-191303-2c7c1987.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/pasted-20251030-095539-d98e7dee.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
assets/pasted-20251030-095744-1b7c02ab.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
assets/pasted-20260310-052744-6ae52ecd.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/pasted-20260310-052911-0f126184.jpg
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
assets/pasted-20260310-054917-348e02e8.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
assets/pasted-20260310-065120-46ee3070.png
Normal file
|
After Width: | Height: | Size: 622 KiB |
BIN
assets/pasted-20260317-090209-091a2a66.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/pasted-20260325-163454-1eca4df8.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/vm-shot-2025-10-16T14-15-36-087Z.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
assets/vm-shot-2025-10-16T16-02-18-334Z.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/vm-shot-2025-10-17T09-58-48-783Z.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
assets/vm-shot-2025-10-17T10-52-25-776Z.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
assets/vm-shot-2025-10-17T11-08-35-321Z.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
assets/vm-shot-2025-10-25T19-08-54-931Z.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
assets/vm-shot-2025-10-30T09-55-34-105Z.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
assets/vm-shot-2026-03-10T05-29-08-831Z.jpg
Normal file
|
After Width: | Height: | Size: 99 KiB |
294
dashboard.php
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once 'db/config.php';
|
||||||
|
|
||||||
|
if (isset($_GET['logout']) && $_GET['logout'] === '1') {
|
||||||
|
unset($_SESSION['user_id']);
|
||||||
|
session_regenerate_id(true);
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($_SESSION['user_id'])) {
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function format_dashboard_datetime(?string $scheduledAt, ?string $timezone): string {
|
||||||
|
if (!$scheduledAt) {
|
||||||
|
return 'Schedule to be announced';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$date = new DateTime($scheduledAt, new DateTimeZone('UTC'));
|
||||||
|
if (!empty($timezone) && in_array($timezone, timezone_identifiers_list(), true)) {
|
||||||
|
$date->setTimezone(new DateTimeZone($timezone));
|
||||||
|
return $date->format('l, F j, Y \a\t g:i A T');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $date->format('l, F j, Y \a\t g:i A T');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return $scheduledAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$attendee = null;
|
||||||
|
$webinar = null;
|
||||||
|
$error = '';
|
||||||
|
$user_id = (int) $_SESSION['user_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = db()->prepare('SELECT * FROM attendees WHERE id = ? AND deleted_at IS NULL');
|
||||||
|
$stmt->execute([$user_id]);
|
||||||
|
$attendee = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($attendee) {
|
||||||
|
$stmt = db()->prepare('SELECT * FROM webinars WHERE id = ?');
|
||||||
|
$stmt->execute([(int) $attendee['webinar_id']]);
|
||||||
|
$webinar = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
} else {
|
||||||
|
$error = 'Could not find your active registration details.';
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Dashboard load failed: ' . $e->getMessage());
|
||||||
|
$error = 'Dashboard is temporarily unavailable. Please try again later.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$participantName = trim((string) (($attendee['first_name'] ?? '') . ' ' . ($attendee['last_name'] ?? '')));
|
||||||
|
$participantName = $participantName !== '' ? $participantName : ((string) ($attendee['email'] ?? 'Guest'));
|
||||||
|
$joinLink = $attendee ? 'join.php?id=' . rawurlencode(base64_encode((string) $attendee['id'])) : 'login.php';
|
||||||
|
$webinarDate = $webinar ? format_dashboard_datetime($webinar['scheduled_at'] ?? null, $attendee['timezone'] ?? null) : 'Schedule to be announced';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Your Webinar Dashboard | AppWizzy</title>
|
||||||
|
<meta name="description" content="Access your webinar registration details and join link for the AppWizzy event.">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<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;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-start: #071659;
|
||||||
|
--bg-end: #1029a0;
|
||||||
|
--surface: rgba(9, 24, 47, 0.82);
|
||||||
|
--surface-soft: rgba(221, 226, 253, 0.08);
|
||||||
|
--line: rgba(221, 226, 253, 0.16);
|
||||||
|
--text-main: #f3f9ff;
|
||||||
|
--text-soft: #dde2fd;
|
||||||
|
--text-muted: #bbc8fb;
|
||||||
|
--accent: #6ed4ff;
|
||||||
|
--accent-strong: #2c4bd1;
|
||||||
|
--success: #18d1b3;
|
||||||
|
--danger: #ff99a9;
|
||||||
|
--shadow: 0 30px 80px rgba(2, 10, 24, 0.48);
|
||||||
|
--font-body: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-display: "Space Grotesk", "Inter", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--text-main);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(110, 212, 255, 0.18), transparent 28%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(44, 75, 209, 0.20), transparent 32%),
|
||||||
|
linear-gradient(145deg, var(--bg-start) 0%, var(--bg-end) 100%);
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 960px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.15fr 0.85fr;
|
||||||
|
}
|
||||||
|
.main, .aside { padding: 40px; }
|
||||||
|
.aside {
|
||||||
|
background: linear-gradient(180deg, rgba(11, 32, 131, 0.62), rgba(16, 41, 160, 0.82));
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(110, 212, 255, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
h1, h2 { font-family: var(--font-display); margin: 0 0 14px; }
|
||||||
|
h1 { font-size: clamp(2.1rem, 4vw, 3.2rem); line-height: 1.02; }
|
||||||
|
h2 { font-size: 1.2rem; }
|
||||||
|
p { color: var(--text-soft); line-height: 1.65; }
|
||||||
|
.lead { font-size: 1.05rem; max-width: 44ch; }
|
||||||
|
.notice {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(255, 153, 169, 0.2);
|
||||||
|
background: rgba(255, 153, 169, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.12);
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { transform: translateY(-1px); }
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #6ed4ff 0%, #2c4bd1 100%);
|
||||||
|
color: #071659;
|
||||||
|
box-shadow: 0 18px 36px rgba(44, 75, 209, 0.28);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(221, 226, 253, 0.10);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.18);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.aside-card {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(221, 226, 253, 0.08);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.12);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.aside-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.aside small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.footer-link {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--text-soft);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.shell { grid-template-columns: 1fr; }
|
||||||
|
.aside { border-left: 0; border-top: 1px solid var(--line); }
|
||||||
|
.main, .aside { padding: 28px; }
|
||||||
|
.stats { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<section class="card">
|
||||||
|
<div class="shell">
|
||||||
|
<div class="main">
|
||||||
|
<span class="eyebrow">Registered attendee</span>
|
||||||
|
<h1>Your webinar dashboard</h1>
|
||||||
|
<?php if ($error !== ''): ?>
|
||||||
|
<div class="notice"><?php echo htmlspecialchars($error); ?></div>
|
||||||
|
<?php elseif ($attendee && $webinar): ?>
|
||||||
|
<p class="lead">Hi <strong><?php echo htmlspecialchars($participantName); ?></strong> — your seat is confirmed. Use the join button below when it is time to enter the webinar room.</p>
|
||||||
|
|
||||||
|
<div class="stats" aria-label="Registration details">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Webinar</div>
|
||||||
|
<div class="stat-value"><?php echo htmlspecialchars((string) ($webinar['title'] ?? 'Upcoming webinar')); ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Scheduled time</div>
|
||||||
|
<div class="stat-value"><?php echo htmlspecialchars($webinarDate); ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Email</div>
|
||||||
|
<div class="stat-value"><?php echo htmlspecialchars((string) ($attendee['email'] ?? '—')); ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Timezone</div>
|
||||||
|
<div class="stat-value"><?php echo htmlspecialchars((string) (($attendee['timezone'] ?? '') !== '' ? $attendee['timezone'] : 'UTC')); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a href="<?php echo htmlspecialchars($joinLink); ?>" class="btn btn-primary">Join webinar</a>
|
||||||
|
<a href="index.php" class="btn btn-secondary">Back to site</a>
|
||||||
|
<a href="dashboard.php?logout=1" class="btn btn-secondary">Log out</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="notice">Could not find your registration details.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<aside class="aside">
|
||||||
|
<h2>What to expect</h2>
|
||||||
|
<div class="aside-card">
|
||||||
|
<strong>Live product session</strong>
|
||||||
|
<small>See how AppWizzy goes from idea to a working app with a real database and a real deployment flow.</small>
|
||||||
|
</div>
|
||||||
|
<div class="aside-card">
|
||||||
|
<strong>Practical takeaways</strong>
|
||||||
|
<small>Architecture, ownership, scaling, and why generated apps should stay editable.</small>
|
||||||
|
</div>
|
||||||
|
<div class="aside-card">
|
||||||
|
<strong>Need help?</strong>
|
||||||
|
<small>If your details look wrong, return to the main page and submit the form again, or contact the organizer.</small>
|
||||||
|
</div>
|
||||||
|
<a class="footer-link" href="login.php">← Back to login</a>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -7,11 +7,33 @@ define('DB_PASS', 'e45f2778-db1f-450c-99c6-29efb4601472');
|
|||||||
|
|
||||||
function db() {
|
function db() {
|
||||||
static $pdo;
|
static $pdo;
|
||||||
|
|
||||||
if (!$pdo) {
|
if (!$pdo) {
|
||||||
$pdo = new PDO('mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', DB_USER, DB_PASS, [
|
try {
|
||||||
|
$pdo = new PDO('mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4', DB_USER, DB_PASS, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if ((int) $e->getCode() !== 1049 && stripos($e->getMessage(), 'Unknown database') === false) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bootstrap = new PDO('mysql:host=' . DB_HOST . ';charset=utf8mb4', DB_USER, DB_PASS, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
$bootstrap->exec('CREATE DATABASE IF NOT EXISTS `' . DB_NAME . '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
|
||||||
|
|
||||||
|
$pdo = new PDO('mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4', DB_USER, DB_PASS, [
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/ensure_schema.php';
|
||||||
|
ensure_app_schema($pdo);
|
||||||
|
}
|
||||||
|
|
||||||
return $pdo;
|
return $pdo;
|
||||||
}
|
}
|
||||||
|
|||||||
130
db/ensure_schema.php
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
function schema_table_exists(PDO $pdo, string $table): bool {
|
||||||
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?');
|
||||||
|
$stmt->execute([$table]);
|
||||||
|
return (int) $stmt->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schema_column_exists(PDO $pdo, string $table, string $column): bool {
|
||||||
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?');
|
||||||
|
$stmt->execute([$table, $column]);
|
||||||
|
return (int) $stmt->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure_app_schema(PDO $pdo): void {
|
||||||
|
static $hasRun = false;
|
||||||
|
if ($hasRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$hasRun = true;
|
||||||
|
|
||||||
|
$pdo->exec('CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
migration VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||||||
|
|
||||||
|
$pdo->exec('CREATE TABLE IF NOT EXISTS webinars (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
presenter VARCHAR(255),
|
||||||
|
scheduled_at DATETIME NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||||||
|
|
||||||
|
$pdo->exec('CREATE TABLE IF NOT EXISTS admin_users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
display_name VARCHAR(255) NOT NULL DEFAULT "Admin",
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_admin_users_email (email)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||||||
|
|
||||||
|
$pdo->exec('CREATE TABLE IF NOT EXISTS attendees (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
webinar_id INT NOT NULL,
|
||||||
|
first_name VARCHAR(255) NOT NULL DEFAULT "",
|
||||||
|
last_name VARCHAR(255) NOT NULL DEFAULT "",
|
||||||
|
name VARCHAR(255) NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
company VARCHAR(255) DEFAULT NULL,
|
||||||
|
timezone VARCHAR(255) DEFAULT NULL,
|
||||||
|
how_did_you_hear VARCHAR(255) DEFAULT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL DEFAULT "",
|
||||||
|
consented TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_attendees_webinar FOREIGN KEY (webinar_id) REFERENCES webinars(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||||||
|
|
||||||
|
if (schema_table_exists($pdo, 'attendees')) {
|
||||||
|
if (schema_column_exists($pdo, 'attendees', 'name')) {
|
||||||
|
$pdo->exec('ALTER TABLE attendees MODIFY COLUMN name VARCHAR(255) NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema_column_exists($pdo, 'attendees', 'first_name')) {
|
||||||
|
$pdo->exec('ALTER TABLE attendees ADD COLUMN first_name VARCHAR(255) NOT NULL DEFAULT "" AFTER webinar_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema_column_exists($pdo, 'attendees', 'last_name')) {
|
||||||
|
$pdo->exec('ALTER TABLE attendees ADD COLUMN last_name VARCHAR(255) NOT NULL DEFAULT "" AFTER first_name');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema_column_exists($pdo, 'attendees', 'company')) {
|
||||||
|
$pdo->exec('ALTER TABLE attendees ADD COLUMN company VARCHAR(255) DEFAULT NULL AFTER email');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema_column_exists($pdo, 'attendees', 'timezone')) {
|
||||||
|
$pdo->exec('ALTER TABLE attendees ADD COLUMN timezone VARCHAR(255) DEFAULT NULL AFTER company');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema_column_exists($pdo, 'attendees', 'how_did_you_hear')) {
|
||||||
|
$pdo->exec('ALTER TABLE attendees ADD COLUMN how_did_you_hear VARCHAR(255) DEFAULT NULL AFTER timezone');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema_column_exists($pdo, 'attendees', 'password')) {
|
||||||
|
$pdo->exec('ALTER TABLE attendees ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT "" AFTER how_did_you_hear');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema_column_exists($pdo, 'attendees', 'consented')) {
|
||||||
|
$pdo->exec('ALTER TABLE attendees ADD COLUMN consented TINYINT(1) NOT NULL DEFAULT 0 AFTER password');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema_column_exists($pdo, 'attendees', 'deleted_at')) {
|
||||||
|
$pdo->exec('ALTER TABLE attendees ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL AFTER consented');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema_column_exists($pdo, 'attendees', 'created_at')) {
|
||||||
|
$pdo->exec('ALTER TABLE attendees ADD COLUMN created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP AFTER company');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema_column_exists($pdo, 'attendees', 'registered_at')) {
|
||||||
|
$pdo->exec('UPDATE attendees SET created_at = COALESCE(created_at, registered_at)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema_column_exists($pdo, 'attendees', 'name')) {
|
||||||
|
$pdo->exec("UPDATE attendees SET first_name = TRIM(SUBSTRING_INDEX(name, ' ', 1)) WHERE (first_name = '' OR first_name IS NULL) AND name IS NOT NULL AND name <> ''");
|
||||||
|
$pdo->exec("UPDATE attendees SET last_name = TRIM(SUBSTRING(name, CHAR_LENGTH(SUBSTRING_INDEX(name, ' ', 1)) + 1)) WHERE (last_name = '' OR last_name IS NULL) AND name IS NOT NULL AND name LIKE '% %'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$desiredTitle = 'Building Scalable Apps with AppWizzy';
|
||||||
|
$desiredDescription = 'The fastest way to go from an idea to a working app you own, running on your server, with your database, using real frameworks.';
|
||||||
|
$desiredPresenter = 'AppWizzy Team';
|
||||||
|
$desiredScheduledAt = '2026-03-25 18:00:00';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM webinars WHERE id = 1');
|
||||||
|
$stmt->execute();
|
||||||
|
$hasPrimaryWebinar = (int) $stmt->fetchColumn() > 0;
|
||||||
|
|
||||||
|
if ($hasPrimaryWebinar) {
|
||||||
|
$update = $pdo->prepare('UPDATE webinars SET title = ?, description = ?, presenter = ?, scheduled_at = ? WHERE id = 1');
|
||||||
|
$update->execute([$desiredTitle, $desiredDescription, $desiredPresenter, $desiredScheduledAt]);
|
||||||
|
} else {
|
||||||
|
$insert = $pdo->prepare('INSERT INTO webinars (id, title, description, presenter, scheduled_at) VALUES (1, ?, ?, ?, ?)');
|
||||||
|
$insert->execute([$desiredTitle, $desiredDescription, $desiredPresenter, $desiredScheduledAt]);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
db/migrate.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'config.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connect without specifying a database to ensure the DB exists
|
||||||
|
$pdo = new PDO('mysql:host='.DB_HOST, DB_USER, DB_PASS, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
]);
|
||||||
|
$pdo->exec('CREATE DATABASE IF NOT EXISTS '.DB_NAME);
|
||||||
|
|
||||||
|
// Now connect to the specific database
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
// 1. Create migrations table if it doesn't exist
|
||||||
|
$pdo->exec('CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
migration VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)');
|
||||||
|
|
||||||
|
// 2. Get all migration files
|
||||||
|
$migration_files = glob(__DIR__ . '/migrations/*.sql');
|
||||||
|
sort($migration_files);
|
||||||
|
|
||||||
|
// 3. Get already executed migrations
|
||||||
|
$executed_migrations_stmt = $pdo->query('SELECT migration FROM migrations');
|
||||||
|
$executed_migrations = $executed_migrations_stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
foreach ($migration_files as $file) {
|
||||||
|
$migration_name = basename($file);
|
||||||
|
|
||||||
|
// 4. If migration has not been run, execute it
|
||||||
|
if (!in_array($migration_name, $executed_migrations)) {
|
||||||
|
$sql = file_get_contents($file);
|
||||||
|
$pdo->exec($sql);
|
||||||
|
|
||||||
|
// 5. Record the migration
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO migrations (migration) VALUES (?)');
|
||||||
|
$stmt->execute([$migration_name]);
|
||||||
|
|
||||||
|
echo "Executed migration: $migration_name" . PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "All migrations have been run." . PHP_EOL;
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
die("Database migration failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
9
db/migrations/001_create_webinars_table.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webinars (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
presenter VARCHAR(255),
|
||||||
|
scheduled_at DATETIME NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
10
db/migrations/002_create_attendees_table.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attendees (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
webinar_id INT NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
company VARCHAR(255),
|
||||||
|
registered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (webinar_id) REFERENCES webinars(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
5
db/migrations/003_seed_webinars_table.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
INSERT INTO webinars (title, description, presenter, scheduled_at) VALUES
|
||||||
|
('Getting Started with Docker', 'Learn the fundamentals of Docker and containerization.', 'John Doe', '2025-11-15 10:00:00'),
|
||||||
|
('Mastering Kubernetes', 'A deep dive into Kubernetes for container orchestration.', 'Jane Smith', '2025-11-20 14:00:00'),
|
||||||
|
('CI/CD with Jenkins', 'Automate your build, test, and deployment pipeline with Jenkins.', 'Peter Jones', '2025-12-01 11:30:00');
|
||||||
10
db/migrations/004_add_tracking_to_attendees.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE attendees
|
||||||
|
ADD COLUMN timezone VARCHAR(255) DEFAULT NULL,
|
||||||
|
ADD COLUMN utm_source VARCHAR(255) DEFAULT NULL,
|
||||||
|
ADD COLUMN utm_medium VARCHAR(255) DEFAULT NULL,
|
||||||
|
ADD COLUMN utm_campaign VARCHAR(255) DEFAULT NULL,
|
||||||
|
ADD COLUMN utm_term VARCHAR(255) DEFAULT NULL,
|
||||||
|
ADD COLUMN utm_content VARCHAR(255) DEFAULT NULL,
|
||||||
|
ADD COLUMN referrer VARCHAR(255) DEFAULT NULL,
|
||||||
|
ADD COLUMN gclid VARCHAR(255) DEFAULT NULL,
|
||||||
|
ADD COLUMN fbclid VARCHAR(255) DEFAULT NULL;
|
||||||
1
db/migrations/005_add_password_to_attendees.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `attendees` ADD `password` VARCHAR(255) NOT NULL;
|
||||||
1
db/migrations/006_add_form_fields.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `attendees` ADD `consented` TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
1
db/migrations/007_add_consented_column.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE attendees ADD COLUMN IF NOT EXISTS consented TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
1
db/migrations/008_rename_column.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE attendees CHANGE COLUMN registered_at created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
1
db/migrations/009_add_deleted_at_to_attendees.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE attendees ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL;
|
||||||
1
db/migrations/010_add_how_did_you_hear_to_attendees.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE attendees ADD COLUMN how_did_you_hear VARCHAR(255) DEFAULT NULL;
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
ALTER TABLE attendees MODIFY COLUMN name VARCHAR(255) NULL;
|
||||||
|
ALTER TABLE attendees ADD COLUMN IF NOT EXISTS first_name VARCHAR(255) NOT NULL DEFAULT '' AFTER webinar_id;
|
||||||
|
ALTER TABLE attendees ADD COLUMN IF NOT EXISTS last_name VARCHAR(255) NOT NULL DEFAULT '' AFTER first_name;
|
||||||
|
ALTER TABLE attendees ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP AFTER company;
|
||||||
|
ALTER TABLE attendees ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP NULL DEFAULT NULL AFTER consented;
|
||||||
|
ALTER TABLE attendees ADD COLUMN IF NOT EXISTS how_did_you_hear VARCHAR(255) DEFAULT NULL AFTER timezone;
|
||||||
|
UPDATE attendees SET created_at = COALESCE(created_at, registered_at);
|
||||||
|
UPDATE attendees
|
||||||
|
SET first_name = TRIM(SUBSTRING_INDEX(name, ' ', 1))
|
||||||
|
WHERE (first_name = '' OR first_name IS NULL)
|
||||||
|
AND name IS NOT NULL
|
||||||
|
AND name <> '';
|
||||||
|
UPDATE attendees
|
||||||
|
SET last_name = TRIM(SUBSTRING(name, CHAR_LENGTH(SUBSTRING_INDEX(name, ' ', 1)) + 1))
|
||||||
|
WHERE (last_name = '' OR last_name IS NULL)
|
||||||
|
AND name IS NOT NULL
|
||||||
|
AND name LIKE '% %';
|
||||||
|
INSERT INTO webinars (id, title, description, presenter, scheduled_at)
|
||||||
|
SELECT 1,
|
||||||
|
'Building Scalable Apps with AppWizzy',
|
||||||
|
'The fastest way to go from an idea to a working app you own, running on your server, with your database, using real frameworks.',
|
||||||
|
'AppWizzy Team',
|
||||||
|
'2026-03-25 18:00:00'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM webinars WHERE id = 1);
|
||||||
|
UPDATE webinars
|
||||||
|
SET title = 'Building Scalable Apps with AppWizzy',
|
||||||
|
description = 'The fastest way to go from an idea to a working app you own, running on your server, with your database, using real frameworks.',
|
||||||
|
presenter = 'AppWizzy Team',
|
||||||
|
scheduled_at = '2026-03-25 18:00:00'
|
||||||
|
WHERE id = 1;
|
||||||
8
db/migrations/012_create_admin_users_table.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS admin_users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
display_name VARCHAR(255) NOT NULL DEFAULT 'Admin',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_admin_users_email (email)
|
||||||
|
);
|
||||||
27
delete_attendee.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once 'db/config.php';
|
||||||
|
require_once 'includes/admin_auth.php';
|
||||||
|
|
||||||
|
if (!admin_is_logged_in()) {
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['id']) && is_numeric($_POST['id'])) {
|
||||||
|
$id = (int) $_POST['id'];
|
||||||
|
$pdo = db();
|
||||||
|
$stmt = $pdo->prepare('UPDATE attendees SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
if ($stmt->rowCount() > 0) {
|
||||||
|
admin_set_flash("Attendee #{$id} has been archived successfully.");
|
||||||
|
} else {
|
||||||
|
admin_set_flash("No active attendee found for ID #{$id}.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
admin_set_flash('Error: Invalid archive request.');
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Location: admin.php');
|
||||||
|
exit;
|
||||||
247
edit_attendee.php
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once 'db/config.php';
|
||||||
|
require_once 'includes/admin_auth.php';
|
||||||
|
|
||||||
|
admin_require_login();
|
||||||
|
|
||||||
|
$attendee = null;
|
||||||
|
$webinars = [];
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : (int) ($_POST['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($id <= 0) {
|
||||||
|
admin_set_flash('Select a valid attendee to edit.');
|
||||||
|
header('Location: admin.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$webinars = $pdo->query('SELECT id, title FROM webinars ORDER BY scheduled_at DESC, id DESC')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$webinarId = max(1, (int) ($_POST['webinar_id'] ?? 1));
|
||||||
|
$firstName = trim((string) ($_POST['first_name'] ?? ''));
|
||||||
|
$lastName = trim((string) ($_POST['last_name'] ?? ''));
|
||||||
|
$email = strtolower(trim((string) ($_POST['email'] ?? '')));
|
||||||
|
$company = trim((string) ($_POST['company'] ?? ''));
|
||||||
|
$timezone = trim((string) ($_POST['timezone'] ?? ''));
|
||||||
|
$howDidYouHear = trim((string) ($_POST['how_did_you_hear'] ?? ''));
|
||||||
|
$consented = !empty($_POST['consented']) ? 1 : 0;
|
||||||
|
|
||||||
|
if ($firstName === '' || $lastName === '') {
|
||||||
|
throw new RuntimeException('First name and last name are required.');
|
||||||
|
}
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new RuntimeException('Enter a valid email address.');
|
||||||
|
}
|
||||||
|
if ($timezone !== '' && !in_array($timezone, timezone_identifiers_list(), true)) {
|
||||||
|
throw new RuntimeException('Timezone must be a valid IANA timezone, for example Europe/Berlin or America/New_York.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$webinarCheck = $pdo->prepare('SELECT COUNT(*) FROM webinars WHERE id = ?');
|
||||||
|
$webinarCheck->execute([$webinarId]);
|
||||||
|
if ((int) $webinarCheck->fetchColumn() === 0) {
|
||||||
|
throw new RuntimeException('Selected webinar was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$update = $pdo->prepare('UPDATE attendees SET webinar_id = ?, first_name = ?, last_name = ?, email = ?, company = ?, timezone = ?, how_did_you_hear = ?, consented = ? WHERE id = ?');
|
||||||
|
$update->execute([$webinarId, $firstName, $lastName, $email, $company !== '' ? $company : null, $timezone !== '' ? $timezone : null, $howDidYouHear !== '' ? $howDidYouHear : null, $consented, $id]);
|
||||||
|
|
||||||
|
admin_set_flash('Attendee #' . $id . ' was updated successfully.');
|
||||||
|
header('Location: admin.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM attendees WHERE id = ? LIMIT 1');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$attendee = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$attendee) {
|
||||||
|
admin_set_flash('Attendee not found.');
|
||||||
|
header('Location: admin.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
admin_set_flash($e->getMessage());
|
||||||
|
header('Location: edit_attendee.php?id=' . urlencode((string) $id));
|
||||||
|
exit;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Edit attendee error: ' . $e->getMessage());
|
||||||
|
admin_set_flash('Unable to load or save attendee changes right now.');
|
||||||
|
header('Location: admin.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = admin_get_flash();
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Edit attendee | Webinar admin</title>
|
||||||
|
<meta name="description" content="Edit webinar attendee details including webinar assignment, consent, timezone, and lead source.">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<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;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-start: #071659;
|
||||||
|
--bg-end: #1029a0;
|
||||||
|
--surface: rgba(11, 32, 131, 0.4);
|
||||||
|
--line: rgba(221, 226, 253, 0.18);
|
||||||
|
--text-main: #f3f9ff;
|
||||||
|
--text-soft: #dde2fd;
|
||||||
|
--accent: #2c4bd1;
|
||||||
|
--accent-strong: #1029a0;
|
||||||
|
--font-body: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-display: "Space Grotesk", "Inter", system-ui, sans-serif;
|
||||||
|
--button-shadow: 0 16px 30px rgba(11, 32, 131, 0.28);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background: radial-gradient(circle at top left, rgba(221, 226, 253, 0.22), transparent 28%), linear-gradient(135deg, var(--bg-start) 0%, #1029a0 55%, var(--bg-end) 100%);
|
||||||
|
color: var(--text-main);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.container { max-width: 840px; padding-top: 3rem; padding-bottom: 3rem; }
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 1.9rem;
|
||||||
|
box-shadow: 0 25px 60px rgba(3, 11, 25, 0.4);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
h1, h2, .btn, .form-label { font-family: var(--font-display); }
|
||||||
|
h1 { font-size: 2rem; letter-spacing: -0.04em; margin-bottom: 0.5rem; }
|
||||||
|
.lead-copy { color: var(--text-soft); margin-bottom: 1.5rem; }
|
||||||
|
.form-control, .form-select {
|
||||||
|
background: rgba(221, 226, 253, 0.08);
|
||||||
|
border-color: rgba(221, 226, 253, 0.18);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.form-control:focus, .form-select:focus {
|
||||||
|
background: rgba(221, 226, 253, 0.1);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-color: rgba(44, 75, 209, 0.65);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(187, 200, 251, 0.18);
|
||||||
|
}
|
||||||
|
.form-select option { color: #111827; }
|
||||||
|
.btn {
|
||||||
|
border-radius: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { transform: translateY(-1px); }
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.14);
|
||||||
|
box-shadow: var(--button-shadow);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(221, 226, 253, 0.12);
|
||||||
|
border-color: rgba(221, 226, 253, 0.22);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.mini-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.meta-card {
|
||||||
|
background: rgba(221, 226, 253, 0.08);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.12);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
}
|
||||||
|
.meta-label { color: var(--text-soft); font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
.meta-value { font-weight: 700; margin-top: 0.35rem; }
|
||||||
|
@media (max-width: 768px) { .mini-meta { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<section class="panel">
|
||||||
|
<h1>Edit attendee #<?php echo (int) $attendee['id']; ?></h1>
|
||||||
|
<p class="lead-copy">Update registration details safely. Changes here affect the admin table, CSV export, and registration analytics.</p>
|
||||||
|
|
||||||
|
<?php if ($message !== ''): ?>
|
||||||
|
<div class="alert alert-info"><?php echo htmlspecialchars($message); ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="mini-meta" aria-label="Attendee summary">
|
||||||
|
<div class="meta-card">
|
||||||
|
<div class="meta-label">Registered at</div>
|
||||||
|
<div class="meta-value"><?php echo htmlspecialchars((string) ($attendee['created_at'] ?? '—')); ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-card">
|
||||||
|
<div class="meta-label">Current timezone</div>
|
||||||
|
<div class="meta-value"><?php echo htmlspecialchars((string) ($attendee['timezone'] ?? '—')); ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-card">
|
||||||
|
<div class="meta-label">Lead source</div>
|
||||||
|
<div class="meta-value"><?php echo htmlspecialchars((string) ($attendee['how_did_you_hear'] ?? '—')); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="edit_attendee.php">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int) $attendee['id']; ?>">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="first_name" class="form-label">First name</label>
|
||||||
|
<input type="text" class="form-control" id="first_name" name="first_name" value="<?php echo htmlspecialchars((string) $attendee['first_name']); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="last_name" class="form-label">Last name</label>
|
||||||
|
<input type="text" class="form-control" id="last_name" name="last_name" value="<?php echo htmlspecialchars((string) $attendee['last_name']); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="email" class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" value="<?php echo htmlspecialchars((string) $attendee['email']); ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="company" class="form-label">Company</label>
|
||||||
|
<input type="text" class="form-control" id="company" name="company" value="<?php echo htmlspecialchars((string) ($attendee['company'] ?? '')); ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="webinar_id" class="form-label">Webinar</label>
|
||||||
|
<select class="form-select" id="webinar_id" name="webinar_id" required>
|
||||||
|
<?php foreach ($webinars as $webinar): ?>
|
||||||
|
<option value="<?php echo (int) $webinar['id']; ?>" <?php echo (int) $webinar['id'] === (int) $attendee['webinar_id'] ? 'selected' : ''; ?>>
|
||||||
|
<?php echo htmlspecialchars((string) $webinar['title']); ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="timezone" class="form-label">Timezone</label>
|
||||||
|
<input type="text" class="form-control" id="timezone" name="timezone" value="<?php echo htmlspecialchars((string) ($attendee['timezone'] ?? '')); ?>" placeholder="Europe/Berlin">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="how_did_you_hear" class="form-label">How did you hear about the webinar?</label>
|
||||||
|
<input type="text" class="form-control" id="how_did_you_hear" name="how_did_you_hear" value="<?php echo htmlspecialchars((string) ($attendee['how_did_you_hear'] ?? '')); ?>" placeholder="LinkedIn, newsletter, referral...">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input type="hidden" name="consented" value="0">
|
||||||
|
<input type="checkbox" class="form-check-input" id="consented" name="consented" value="1" <?php echo !empty($attendee['consented']) ? 'checked' : ''; ?>>
|
||||||
|
<label class="form-check-label" for="consented">Marketing consent received</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||||
|
<a href="admin.php" class="btn btn-secondary">Back to admin</a>
|
||||||
|
<a href="login.php?logout=1" class="btn btn-secondary">Log out</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
88
export_csv.php
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once 'db/config.php';
|
||||||
|
require_once 'includes/admin_auth.php';
|
||||||
|
|
||||||
|
if (!admin_is_logged_in()) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo 'Forbidden';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$selected_webinar_id = isset($_GET['webinar_id']) && is_numeric($_GET['webinar_id']) ? max(0, (int) $_GET['webinar_id']) : 0;
|
||||||
|
|
||||||
|
$where_sql = 'WHERE a.deleted_at IS NULL';
|
||||||
|
$params = [];
|
||||||
|
if ($selected_webinar_id > 0) {
|
||||||
|
$where_sql .= ' AND a.webinar_id = :webinar_id';
|
||||||
|
$params[':webinar_id'] = $selected_webinar_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT
|
||||||
|
a.id,
|
||||||
|
a.webinar_id,
|
||||||
|
w.title AS webinar_title,
|
||||||
|
a.first_name,
|
||||||
|
a.last_name,
|
||||||
|
a.email,
|
||||||
|
a.company,
|
||||||
|
a.how_did_you_hear,
|
||||||
|
a.timezone,
|
||||||
|
a.consented,
|
||||||
|
a.created_at
|
||||||
|
FROM attendees a
|
||||||
|
LEFT JOIN webinars w ON w.id = a.webinar_id
|
||||||
|
{$where_sql}
|
||||||
|
ORDER BY a.created_at DESC, a.id DESC";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
foreach ($params as $name => $value) {
|
||||||
|
$stmt->bindValue($name, $value, PDO::PARAM_INT);
|
||||||
|
}
|
||||||
|
$stmt->execute();
|
||||||
|
$attendees = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
header('Content-Type: text/csv; charset=utf-8');
|
||||||
|
|
||||||
|
$filename = 'attendees';
|
||||||
|
if ($selected_webinar_id > 0) {
|
||||||
|
$filename .= '-webinar-' . $selected_webinar_id;
|
||||||
|
}
|
||||||
|
$filename .= '-' . date('Y-m-d') . '.csv';
|
||||||
|
header('Content-Disposition: attachment; filename=' . $filename);
|
||||||
|
|
||||||
|
$output = fopen('php://output', 'w');
|
||||||
|
fputs($output, "\xEF\xBB\xBF");
|
||||||
|
|
||||||
|
fputcsv($output, [
|
||||||
|
'ID',
|
||||||
|
'Webinar ID',
|
||||||
|
'Webinar Title',
|
||||||
|
'First Name',
|
||||||
|
'Last Name',
|
||||||
|
'Email',
|
||||||
|
'Company',
|
||||||
|
'Source',
|
||||||
|
'Timezone',
|
||||||
|
'Consented',
|
||||||
|
'Registered At',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($attendees as $attendee) {
|
||||||
|
fputcsv($output, [
|
||||||
|
$attendee['id'],
|
||||||
|
$attendee['webinar_id'],
|
||||||
|
$attendee['webinar_title'] ?? '',
|
||||||
|
$attendee['first_name'],
|
||||||
|
$attendee['last_name'],
|
||||||
|
$attendee['email'],
|
||||||
|
$attendee['company'] ?? '',
|
||||||
|
$attendee['how_did_you_hear'] ?? '',
|
||||||
|
$attendee['timezone'] ?? '',
|
||||||
|
!empty($attendee['consented']) ? 'Yes' : 'No',
|
||||||
|
$attendee['created_at'] ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($output);
|
||||||
|
exit;
|
||||||
70
includes/admin_auth.php
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../db/config.php';
|
||||||
|
|
||||||
|
function admin_session_start_if_needed(): void {
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function admin_is_logged_in(): bool {
|
||||||
|
admin_session_start_if_needed();
|
||||||
|
return isset($_SESSION['admin_user_id']) && (int) $_SESSION['admin_user_id'] > 0 && (($_SESSION['user'] ?? null) === 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
function admin_require_login(): void {
|
||||||
|
if (!admin_is_logged_in()) {
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function admin_logout(): void {
|
||||||
|
admin_session_start_if_needed();
|
||||||
|
unset($_SESSION['admin_user_id'], $_SESSION['admin_email'], $_SESSION['admin_name'], $_SESSION['user']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function admin_set_flash(string $message): void {
|
||||||
|
admin_session_start_if_needed();
|
||||||
|
$_SESSION['message'] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function admin_get_flash(): string {
|
||||||
|
admin_session_start_if_needed();
|
||||||
|
$message = isset($_SESSION['message']) ? (string) $_SESSION['message'] : '';
|
||||||
|
unset($_SESSION['message']);
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function admin_count_users(): int {
|
||||||
|
$stmt = db()->query('SELECT COUNT(*) FROM admin_users');
|
||||||
|
return (int) $stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function admin_get_by_email(string $email): ?array {
|
||||||
|
$stmt = db()->prepare('SELECT id, email, password_hash, display_name, created_at FROM admin_users WHERE email = ? LIMIT 1');
|
||||||
|
$stmt->execute([$email]);
|
||||||
|
$admin = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
return $admin ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function admin_create_user(string $email, string $password, string $displayName = 'Admin'): int {
|
||||||
|
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
|
||||||
|
$stmt = db()->prepare('INSERT INTO admin_users (email, password_hash, display_name) VALUES (?, ?, ?)');
|
||||||
|
$stmt->execute([$email, $passwordHash, $displayName]);
|
||||||
|
return (int) db()->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
function admin_login_user(array $admin): void {
|
||||||
|
admin_session_start_if_needed();
|
||||||
|
$_SESSION['user'] = 'admin';
|
||||||
|
$_SESSION['admin_user_id'] = (int) $admin['id'];
|
||||||
|
$_SESSION['admin_email'] = (string) $admin['email'];
|
||||||
|
$_SESSION['admin_name'] = (string) ($admin['display_name'] ?? 'Admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
function admin_current_name(): string {
|
||||||
|
admin_session_start_if_needed();
|
||||||
|
return (string) ($_SESSION['admin_name'] ?? 'Admin');
|
||||||
|
}
|
||||||
26
includes/pexels.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
function pexels_key() {
|
||||||
|
$k = getenv('PEXELS_KEY');
|
||||||
|
return $k && strlen($k) > 0 ? $k : 'Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18';
|
||||||
|
}
|
||||||
|
function pexels_get($url) {
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $url,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [ 'Authorization: '. pexels_key() ],
|
||||||
|
CURLOPT_TIMEOUT => 15,
|
||||||
|
]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($code >= 200 && $code < 300 && $resp) return json_decode($resp, true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function download_to($srcUrl, $destPath) {
|
||||||
|
$data = file_get_contents($srcUrl);
|
||||||
|
if ($data === false) return false;
|
||||||
|
if (!is_dir(dirname($destPath))) mkdir(dirname($destPath), 0775, true);
|
||||||
|
return file_put_contents($destPath, $data) !== false;
|
||||||
|
}
|
||||||
|
?>
|
||||||
176
includes/webinar_email.php
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/webinar_schedule.php';
|
||||||
|
|
||||||
|
function webinar_join_url(): string {
|
||||||
|
return 'https://meet.google.com/ohs-ayvx-dqg';
|
||||||
|
}
|
||||||
|
|
||||||
|
function webinar_base_url(): string {
|
||||||
|
$isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||||
|
$protocol = $isHttps ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
|
||||||
|
return $protocol . '://' . $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webinar_logo_url(): string {
|
||||||
|
return webinar_base_url() . '/assets/pasted-20251030-095744-1b7c02ab.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
function webinar_calendar_description(array $schedule): string {
|
||||||
|
return implode("
|
||||||
|
|
||||||
|
", [
|
||||||
|
'Professional Vibe-Coding Webinar',
|
||||||
|
'Join us on ' . $schedule['date_long'] . ' at ' . $schedule['timezone_line'] . '.',
|
||||||
|
'Google Meet link: ' . webinar_join_url(),
|
||||||
|
'The fastest way to go from an idea to a working app you own, running on your server, with your database, using real frameworks.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function webinar_google_calendar_link(array $schedule): string {
|
||||||
|
$eventTitle = $schedule['title'];
|
||||||
|
$eventDescription = webinar_calendar_description($schedule);
|
||||||
|
$startTimeUtc = webinar_utc_stamp($schedule['start']);
|
||||||
|
$endTimeUtc = webinar_utc_stamp($schedule['end']);
|
||||||
|
|
||||||
|
return 'https://www.google.com/calendar/render?action=TEMPLATE'
|
||||||
|
. '&text=' . urlencode($eventTitle)
|
||||||
|
. '&dates=' . $startTimeUtc . '/' . $endTimeUtc
|
||||||
|
. '&details=' . urlencode($eventDescription)
|
||||||
|
. '&location=' . urlencode(webinar_join_url())
|
||||||
|
. '&ctz=UTC';
|
||||||
|
}
|
||||||
|
|
||||||
|
function webinar_outlook_calendar_link(array $schedule): string {
|
||||||
|
$eventTitle = $schedule['title'];
|
||||||
|
$eventDescription = webinar_calendar_description($schedule);
|
||||||
|
$icsContent = implode("
|
||||||
|
", [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Flatlogic//' . $eventTitle . '//EN',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
'URL:' . webinar_base_url(),
|
||||||
|
'DTSTART:' . webinar_utc_stamp($schedule['start']),
|
||||||
|
'DTEND:' . webinar_utc_stamp($schedule['end']),
|
||||||
|
'SUMMARY:' . $eventTitle,
|
||||||
|
'DESCRIPTION:' . str_replace(PHP_EOL, '\n', $eventDescription),
|
||||||
|
'LOCATION:' . webinar_join_url(),
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return 'data:text/calendar;charset=utf-8,' . rawurlencode($icsContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function webinar_build_email_payload(string $firstName, array $webinar, bool $isCorrection = false): array {
|
||||||
|
$schedule = webinar_schedule_data($webinar);
|
||||||
|
$eventTitle = $schedule['title'];
|
||||||
|
$googleLink = webinar_google_calendar_link($schedule);
|
||||||
|
$outlookLink = webinar_outlook_calendar_link($schedule);
|
||||||
|
$joinUrl = webinar_join_url();
|
||||||
|
$baseUrl = webinar_base_url();
|
||||||
|
$logoUrl = webinar_logo_url();
|
||||||
|
|
||||||
|
$safeFirstName = htmlspecialchars(trim($firstName) !== '' ? $firstName : 'there', ENT_QUOTES, 'UTF-8');
|
||||||
|
$safeTitle = htmlspecialchars($eventTitle, ENT_QUOTES, 'UTF-8');
|
||||||
|
$safeDateLong = htmlspecialchars($schedule['date_long'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$safeBerlinLabel = htmlspecialchars($schedule['berlin_label'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$safeNewYorkLabel = htmlspecialchars($schedule['new_york_label'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$safeLosAngelesLabel = htmlspecialchars($schedule['los_angeles_label'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$safeJoinUrl = htmlspecialchars($joinUrl, ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
|
if ($isCorrection) {
|
||||||
|
$subject = 'Updated webinar time and join link for ' . $eventTitle;
|
||||||
|
$introHtml = '<p style="font-size: 16px; line-height: 1.6;">We updated the webinar timing to reflect U.S. daylight saving time. Please use the corrected schedule and links below.</p>';
|
||||||
|
$noteHtml = '<p style="font-size: 16px; line-height: 1.6;"><strong>If you already saved the older calendar event, please remove it and add the updated one from this email.</strong></p>';
|
||||||
|
$headline = 'Updated Webinar Details';
|
||||||
|
$introText = 'We updated the webinar timing to reflect U.S. daylight saving time. Please use the corrected schedule and links below.';
|
||||||
|
$noteText = 'If you already saved the older calendar event, please remove it and add the updated one from this email.';
|
||||||
|
} else {
|
||||||
|
$subject = "Confirmation: You're Registered for {$eventTitle}";
|
||||||
|
$introHtml = '<p style="font-size: 16px; line-height: 1.6;">Thank you for registering for the <strong>' . $safeTitle . '</strong> webinar.</p><p style="font-size: 16px; line-height: 1.6;">We are excited to have you join us for this professional vibe-coding session.</p>';
|
||||||
|
$noteHtml = '<p style="font-size: 16px; line-height: 1.6;"><strong>Your webinar link is below, and you can add the corrected event to your calendar right now.</strong></p>';
|
||||||
|
$headline = "You're Registered!";
|
||||||
|
$introText = 'Thank you for registering for the ' . $eventTitle . ' webinar. We are excited to have you join us for this professional vibe-coding session.';
|
||||||
|
$noteText = 'Your webinar link is below, and you can add the corrected event to your calendar right now.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$bodyHtml = <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{$safeTitle}</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f4f4f4; color: #333; margin: 0; padding: 0;">
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0" style="background-color: #f4f4f4;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" border="0" cellspacing="0" cellpadding="0" style="background-color: #ffffff; margin: 20px auto; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); overflow: hidden;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px; background: linear-gradient(135deg, #1a237e 0%, #0f9fff 100%);">
|
||||||
|
<img src="{$logoUrl}" alt="AppWizzy Logo" style="height: 60px; margin-bottom: 20px;">
|
||||||
|
<h1 style="color: #ffffff; font-size: 28px; margin: 0; line-height: 1.2;">{$headline}</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 30px;">
|
||||||
|
<h2 style="font-size: 22px; color: #1a237e; margin-top: 0;">Hello {$safeFirstName},</h2>
|
||||||
|
{$introHtml}
|
||||||
|
<div style="background-color: #f9f9f9; border-left: 4px solid #0f9fff; padding: 15px 20px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; font-size: 16px;"><strong>Webinar details</strong></p>
|
||||||
|
<p style="margin: 10px 0 0; font-size: 16px;">{$safeDateLong} | <strong>{$safeBerlinLabel}</strong> | {$safeNewYorkLabel} | {$safeLosAngelesLabel}</p>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 16px; line-height: 1.6;">Join the live session here:</p>
|
||||||
|
<p style="font-size: 16px; line-height: 1.8; word-break: break-word;"><a href="{$safeJoinUrl}" style="color: #0f62fe;">{$safeJoinUrl}</a></p>
|
||||||
|
{$noteHtml}
|
||||||
|
<p style="text-align: center; margin: 30px 0 12px;">
|
||||||
|
<a href="{$safeJoinUrl}" style="display: inline-block; background: linear-gradient(135deg, #0f62fe 0%, #0f9fff 100%); color: #ffffff; padding: 12px 25px; text-decoration: none; border-radius: 8px; font-weight: 700; margin: 0 8px 12px;">Join Webinar</a>
|
||||||
|
<a href="{$googleLink}" style="display: inline-block; background: linear-gradient(135deg, #1a237e 0%, #4b5bdc 100%); color: #ffffff; padding: 12px 25px; text-decoration: none; border-radius: 8px; font-weight: 700; margin: 0 8px 12px;">Add to Google Calendar</a>
|
||||||
|
</p>
|
||||||
|
<p style="text-align: center; margin: 0 0 12px;">
|
||||||
|
<a href="{$outlookLink}" style="color: #1a237e; font-weight: 600; text-decoration: none;">Download calendar file (.ics)</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 16px; line-height: 1.6;">You'll learn the fastest way to go from an idea to a working app you own, running on your server, with your database, using real frameworks.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 20px; background-color: #f4f4f4; border-top: 1px solid #dddddd;">
|
||||||
|
<p style="margin: 0; color: #777;">© 2026 AppWizzy. All rights reserved.</p>
|
||||||
|
<p style="margin: 5px 0 0; color: #777;">You can <a href="{$baseUrl}" style="color: #0f62fe;">visit our website</a> for more information.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
$textBody = implode("
|
||||||
|
|
||||||
|
", [
|
||||||
|
'Hello ' . (trim($firstName) !== '' ? trim($firstName) : 'there') . ',',
|
||||||
|
$introText,
|
||||||
|
'Webinar details: ' . $schedule['date_long'] . ' | ' . $schedule['berlin_label'] . ' | ' . $schedule['new_york_label'] . ' | ' . $schedule['los_angeles_label'],
|
||||||
|
'Join webinar: ' . $joinUrl,
|
||||||
|
$noteText,
|
||||||
|
'Add to Google Calendar: ' . $googleLink,
|
||||||
|
'Download calendar file (.ics): ' . $outlookLink,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subject' => $subject,
|
||||||
|
'html' => $bodyHtml,
|
||||||
|
'text' => $textBody,
|
||||||
|
'schedule' => $schedule,
|
||||||
|
'google_link' => $googleLink,
|
||||||
|
'outlook_link' => $outlookLink,
|
||||||
|
'join_url' => $joinUrl,
|
||||||
|
'event_title' => $eventTitle,
|
||||||
|
];
|
||||||
|
}
|
||||||
72
includes/webinar_schedule.php
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
function webinar_schedule_base_timezone(): DateTimeZone {
|
||||||
|
static $timezone = null;
|
||||||
|
|
||||||
|
if ($timezone === null) {
|
||||||
|
$timezone = new DateTimeZone('Europe/Berlin');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webinar_default_title(): string {
|
||||||
|
return 'Building Scalable Apps with AppWizzy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function webinar_start_datetime(array $webinar): DateTimeImmutable {
|
||||||
|
$scheduledAt = trim((string) ($webinar['scheduled_at'] ?? ''));
|
||||||
|
|
||||||
|
if ($scheduledAt === '') {
|
||||||
|
$scheduledAt = '2026-03-25 18:00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DateTimeImmutable($scheduledAt, webinar_schedule_base_timezone());
|
||||||
|
}
|
||||||
|
|
||||||
|
function webinar_end_datetime(DateTimeImmutable $start, int $durationMinutes = 60): DateTimeImmutable {
|
||||||
|
$durationMinutes = max(1, $durationMinutes);
|
||||||
|
|
||||||
|
return $start->add(new DateInterval('PT' . $durationMinutes . 'M'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function webinar_time_label(DateTimeInterface $start, string $timezone): string {
|
||||||
|
$localStart = DateTimeImmutable::createFromInterface($start)->setTimezone(new DateTimeZone($timezone));
|
||||||
|
|
||||||
|
return strtoupper($localStart->format('gA T'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function webinar_utc_stamp(DateTimeInterface $dateTime): string {
|
||||||
|
return DateTimeImmutable::createFromInterface($dateTime)
|
||||||
|
->setTimezone(new DateTimeZone('UTC'))
|
||||||
|
->format('Ymd\THis\Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
function webinar_schedule_data(array $webinar, int $durationMinutes = 60): array {
|
||||||
|
$title = trim((string) ($webinar['title'] ?? ''));
|
||||||
|
if ($title === '') {
|
||||||
|
$title = webinar_default_title();
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = webinar_start_datetime($webinar);
|
||||||
|
$end = webinar_end_datetime($start, $durationMinutes);
|
||||||
|
|
||||||
|
$berlin = webinar_time_label($start, 'Europe/Berlin');
|
||||||
|
$newYork = webinar_time_label($start, 'America/New_York');
|
||||||
|
$losAngeles = webinar_time_label($start, 'America/Los_Angeles');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $title,
|
||||||
|
'start' => $start,
|
||||||
|
'end' => $end,
|
||||||
|
'date_long' => $start->format('l, F j, Y'),
|
||||||
|
'berlin_label' => $berlin,
|
||||||
|
'new_york_label' => $newYork,
|
||||||
|
'los_angeles_label' => $losAngeles,
|
||||||
|
'timezone_line' => $berlin . ' | ' . $newYork . ' | ' . $losAngeles,
|
||||||
|
'hero_line' => strtoupper($start->format('l, F j') . ' | ' . $newYork . ' | ' . $losAngeles . ' | ' . $berlin),
|
||||||
|
'calendar_sentence' => 'Professional Vibe-Coding Webinar. Join us on ' . $start->format('F jS') . ' at ' . $berlin . ' | ' . $newYork . ' | ' . $losAngeles . ' to learn the fastest way to go from an idea to a working app you own.',
|
||||||
|
'calendar_description' => "Professional Vibe-Coding Webinar
|
||||||
|
|
||||||
|
Join us for this webinar on " . $start->format('F jS') . ' at ' . $berlin . ' | ' . $newYork . ' | ' . $losAngeles . '. The fastest way to go from an idea to a working app you own, running on your server, with your database, using real frameworks.',
|
||||||
|
];
|
||||||
|
}
|
||||||
560
index.php
@ -1,150 +1,468 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
require_once 'db/config.php';
|
||||||
@ini_set('display_errors', '1');
|
require_once 'includes/webinar_schedule.php';
|
||||||
@error_reporting(E_ALL);
|
|
||||||
@date_default_timezone_set('UTC');
|
|
||||||
|
|
||||||
$phpVersion = PHP_VERSION;
|
$heroSchedule = webinar_schedule_data([]);
|
||||||
$now = date('Y-m-d H:i:s');
|
|
||||||
|
try {
|
||||||
|
$stmt = db()->prepare('SELECT title, scheduled_at FROM webinars WHERE id = ? LIMIT 1');
|
||||||
|
$stmt->execute([1]);
|
||||||
|
$heroWebinar = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($heroWebinar) {
|
||||||
|
$heroSchedule = webinar_schedule_data($heroWebinar);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Keep fallback schedule if the database is temporarily unavailable.
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>New Style</title>
|
<meta property="og:image" content="https://webinar-registration-march-2026.flatlogic.app/assets/pasted-20260317-090209-091a2a66.png">
|
||||||
<?php
|
<meta name="twitter:image" content="https://webinar-registration-march-2026.flatlogic.app/assets/pasted-20260317-090209-091a2a66.png">
|
||||||
// Read project preview data from environment
|
<title>AppWizzy Webinar Registration</title>
|
||||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
<meta name="description" content="Register for the AppWizzy webinar about building scalable apps with real frameworks, your own server, and your own database.">
|
||||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
<meta property="og:type" content="website">
|
||||||
?>
|
<meta property="og:title" content="AppWizzy Webinar Registration">
|
||||||
<?php if ($projectDescription): ?>
|
<meta property="og:description" content="Register for the AppWizzy webinar and learn about the product features and benefits.">
|
||||||
<!-- Meta description -->
|
<meta property="og:url" content="https://webinar-registration-march-2026.flatlogic.app/">
|
||||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
<meta property="og:image" content="https://webinar-registration-march-2026.flatlogic.app/assets/pasted-20260317-090209-091a2a66.png">
|
||||||
<!-- Open Graph meta tags -->
|
|
||||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<!-- Twitter meta tags -->
|
<meta name="twitter:title" content="AppWizzy Webinar Registration">
|
||||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
<meta name="twitter:description" content="Register for the AppWizzy webinar and learn about the product features and benefits.">
|
||||||
<?php endif; ?>
|
<meta name="twitter:image" content="https://webinar-registration-march-2026.flatlogic.app/assets/pasted-20260317-090209-091a2a66.png">
|
||||||
<?php if ($projectImageUrl): ?>
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<!-- 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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-color-start: #6a11cb;
|
--bg-start: #0b2083;
|
||||||
--bg-color-end: #2575fc;
|
--bg-mid: #1029a0;
|
||||||
--text-color: #ffffff;
|
--bg-end: #2c4bd1;
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
--surface: rgba(11, 32, 131, 0.34);
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
--surface-strong: rgba(16, 41, 160, 0.38);
|
||||||
|
--line: rgba(221, 226, 253, 0.22);
|
||||||
|
--text-main: #f3f9ff;
|
||||||
|
--text-soft: #dde2fd;
|
||||||
|
--text-muted: #bbc8fb;
|
||||||
|
--accent: #2c4bd1;
|
||||||
|
--accent-strong: #1029a0;
|
||||||
|
--accent-soft: #bbc8fb;
|
||||||
|
--success: #18d1b3;
|
||||||
|
--font-body: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-display: "Space Grotesk", "Inter", system-ui, sans-serif;
|
||||||
|
--button-shadow: 0 18px 36px rgba(11, 32, 131, 0.34);
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(221, 226, 253, 0.24), transparent 30%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(44, 75, 209, 0.24), transparent 35%),
|
||||||
|
linear-gradient(135deg, var(--bg-start) 0%, var(--bg-mid) 52%, var(--bg-end) 100%);
|
||||||
|
color: var(--text-main);
|
||||||
margin: 0;
|
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;
|
min-height: 100vh;
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
body::before {
|
.webinar-container {
|
||||||
content: '';
|
min-height: 100vh;
|
||||||
position: absolute;
|
display: flex;
|
||||||
top: 0;
|
align-items: center;
|
||||||
left: 0;
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.webinar-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
max-width: 1200px;
|
||||||
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>');
|
box-shadow: 0 30px 80px rgba(2, 10, 24, 0.55);
|
||||||
animation: bg-pan 20s linear infinite;
|
border-radius: 28px;
|
||||||
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;
|
overflow: hidden;
|
||||||
clip: rect(0, 0, 0, 0);
|
background: var(--surface);
|
||||||
white-space: nowrap; border: 0;
|
border: 1px solid var(--line);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
}
|
}
|
||||||
h1 {
|
.left-column {
|
||||||
font-size: 3rem;
|
padding: 60px;
|
||||||
|
}
|
||||||
|
.right-column {
|
||||||
|
background: linear-gradient(180deg, rgba(11, 32, 131, 0.78), rgba(16, 41, 160, 0.86));
|
||||||
|
padding: 60px;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.brand-logo {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0 0 1rem;
|
font-size: 1.5rem;
|
||||||
letter-spacing: -1px;
|
margin-bottom: 40px;
|
||||||
|
color: var(--accent-soft);
|
||||||
}
|
}
|
||||||
p {
|
.brand-logo,
|
||||||
margin: 0.5rem 0;
|
.webinar-title,
|
||||||
font-size: 1.1rem;
|
.webinar-subtitle,
|
||||||
|
.speakers-section h3,
|
||||||
|
.what-you-learn h4,
|
||||||
|
.right-column h2,
|
||||||
|
.btn-register,
|
||||||
|
.btn-light,
|
||||||
|
.btn-outline-light {
|
||||||
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
code {
|
.webinar-title {
|
||||||
background: rgba(0,0,0,0.2);
|
font-size: 2.8rem;
|
||||||
padding: 2px 6px;
|
font-weight: 800;
|
||||||
border-radius: 4px;
|
margin-top: 10px;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
margin-bottom: 12px;
|
||||||
|
color: var(--text-main);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
.webinar-subtitle {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.webinar-time {
|
||||||
|
color: var(--accent);
|
||||||
|
margin-top: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.webinar-description {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 1.08rem;
|
||||||
|
color: var(--text-soft);
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
.speakers-section {
|
||||||
|
margin-top: 42px;
|
||||||
|
}
|
||||||
|
.speakers-section h3 {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.speaker-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.speaker-card img {
|
||||||
|
width: 92px;
|
||||||
|
height: 92px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 3px solid rgba(187, 200, 251, 0.82);
|
||||||
|
box-shadow: 0 12px 25px rgba(8, 24, 47, 0.45);
|
||||||
|
}
|
||||||
|
.speaker-card h5 {
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.speaker-card .text-muted {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
background-color: rgba(221, 226, 253, 0.08);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.18);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 13px 15px;
|
||||||
|
color: var(--text-main);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.form-control::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
color: var(--text-main);
|
||||||
|
background-color: rgba(221, 226, 253, 0.12);
|
||||||
|
border-color: rgba(44, 75, 209, 0.65);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(221, 226, 253, 0.18);
|
||||||
|
}
|
||||||
|
.form-select option {
|
||||||
|
background: #1029a0;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.form-check-label {
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.btn-register {
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.18);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 15px 18px;
|
||||||
|
width: 100%;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: transform 0.25s ease, box-shadow 0.25s ease, filter 0.25s ease;
|
||||||
|
box-shadow: var(--button-shadow);
|
||||||
|
}
|
||||||
|
.btn-register:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 24px 40px rgba(11, 32, 131, 0.42);
|
||||||
|
filter: brightness(1.04);
|
||||||
|
}
|
||||||
|
.btn-light,
|
||||||
|
.btn-outline-light {
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
.btn-light {
|
||||||
|
background: linear-gradient(135deg, #dde2fd 0%, #bbc8fb 100%);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.55);
|
||||||
|
color: #0b2083;
|
||||||
|
box-shadow: 0 14px 28px rgba(11, 32, 131, 0.2);
|
||||||
|
}
|
||||||
|
.btn-outline-light {
|
||||||
|
background: rgba(221, 226, 253, 0.08);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.22);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.btn-light:hover,
|
||||||
|
.btn-outline-light:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 18px 30px rgba(11, 32, 131, 0.24);
|
||||||
|
}
|
||||||
|
.what-you-learn {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
.what-you-learn h4 {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-main);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.what-you-learn ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.what-you-learn li {
|
||||||
|
color: var(--text-soft);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.what-you-learn li::before {
|
||||||
|
content: "✓";
|
||||||
|
margin-right: 10px;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
#right-column-content h2,
|
||||||
|
#right-column-content p {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
#form-message {
|
||||||
|
color: var(--accent-soft) !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.left-column,
|
||||||
|
.right-column {
|
||||||
|
padding: 36px 28px;
|
||||||
|
}
|
||||||
|
.right-column {
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.webinar-title {
|
||||||
|
font-size: 2.2rem;
|
||||||
}
|
}
|
||||||
footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<div class="webinar-container">
|
||||||
<div class="card">
|
<div class="webinar-card">
|
||||||
<h1>Analyzing your requirements and generating your website…</h1>
|
<div class="row g-0">
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<div class="col-lg-7">
|
||||||
<span class="sr-only">Loading…</span>
|
<div class="left-column">
|
||||||
|
<img src="assets/pasted-20251030-095744-1b7c02ab.png" alt="AppWizzy Logo" style="height: 60px; margin-bottom: 40px;">
|
||||||
|
|
||||||
|
<h1 class="webinar-title" style="line-height: 1.2;">Building Scalable Apps with AppWizzy</h1>
|
||||||
|
<h2 class="webinar-subtitle">Professional Vibe-Coding Webinar</h2>
|
||||||
|
<div class="webinar-time"><?php echo htmlspecialchars($heroSchedule['hero_line'], ENT_QUOTES, 'UTF-8'); ?></div>
|
||||||
|
<p class="webinar-description">The fastest way to go from an idea to a working app you own, running on your server, with your database, using real frameworks.</p>
|
||||||
|
<div class="speakers-section">
|
||||||
|
<h3>Speakers</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="speaker-card">
|
||||||
|
<img src="assets/pasted-20251025-190534-b34b4e03.png" alt="Philip Daineka">
|
||||||
|
<h5>Philip Daineka</h5>
|
||||||
|
<p class="text-muted">CEO, AppWizzy</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</main>
|
<div class="col-md-4">
|
||||||
<footer>
|
<div class="speaker-card">
|
||||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
<img src="assets/pasted-20251025-190634-62543fd4.png" alt="Alexandr Rubanau">
|
||||||
</footer>
|
<h5>Alexandr Rubanau</h5>
|
||||||
|
<p class="text-muted">Lead Engineer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="speaker-card">
|
||||||
|
<img src="assets/pasted-20251025-190711-b09e530a.png" alt="Alexey Vertel">
|
||||||
|
<h5>Alexey Vertel</h5>
|
||||||
|
<p class="text-muted">Lead Engineer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="right-column" id="right-column-content">
|
||||||
|
<h2>Register Form</h2>
|
||||||
|
<p>Don't miss this chance to learn and get your questions answered</p>
|
||||||
|
<div id="form-message" class="mb-3" style="color: #ffc107;"></div>
|
||||||
|
<form id="registration-form" method="POST" action="register.php">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<input type="text" class="form-control" name="first_name" placeholder="First name" required>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<input type="text" class="form-control" name="last_name" placeholder="Last name" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="email" class="form-control" name="email" placeholder="email@website.com" autocomplete="email" inputmode="email" autocapitalize="off" spellcheck="false" maxlength="190" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" class="form-control" name="company" placeholder="Company">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<select class="form-select form-control" name="how_did_you_hear" required>
|
||||||
|
<option value="" selected disabled hidden>How did you hear about this webinar?</option>
|
||||||
|
<option value="Social Media">Social Media (X, Instargram, Facebook, etc.)</option>
|
||||||
|
<option value="LinkedIn">LinkedIn</option>
|
||||||
|
<option value="Reddit">Reddit</option>
|
||||||
|
<option value="Threads">Threads</option>
|
||||||
|
<option value="Advertisement">Advertisement</option>
|
||||||
|
<option value="ChatGPT">ChatGPT</option>
|
||||||
|
<option value="Flatlogic Community Discord">Flatlogic Community Discord</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="webinar_id" value="1">
|
||||||
|
<input type="hidden" name="timezone" id="timezone-field" value="">
|
||||||
|
<button type="submit" class="btn-register">Register Now</button>
|
||||||
|
</form>
|
||||||
|
<div class="what-you-learn">
|
||||||
|
<div style="display: flex; align-items: center; margin-bottom: 20px;">
|
||||||
|
<img src="assets/pasted-20251017-115404-4e52d477.png" alt="Bot" style="width: 50px; margin-right: 15px;">
|
||||||
|
<h4>SCHEDULE</h4>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li>Intro ~10 min</li>
|
||||||
|
<li>Creating Apps ~40-50 min</li>
|
||||||
|
<li>Q&A</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('registration-form').addEventListener('submit', function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const form = event.target;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const messageDiv = document.getElementById('form-message');
|
||||||
|
const rightColumn = document.getElementById('right-column-content');
|
||||||
|
const emailInput = form.querySelector('input[name="email"]');
|
||||||
|
const timezoneField = form.querySelector('#timezone-field');
|
||||||
|
const emailValue = (emailInput.value || '').trim().toLowerCase();
|
||||||
|
const blockedDomains = new Set([
|
||||||
|
'10minutemail.com',
|
||||||
|
'dispostable.com',
|
||||||
|
'emailondeck.com',
|
||||||
|
'fakeinbox.com',
|
||||||
|
'guerrillamail.com',
|
||||||
|
'maildrop.cc',
|
||||||
|
'mailinator.com',
|
||||||
|
'mailnesia.com',
|
||||||
|
'mintemail.com',
|
||||||
|
'sharklasers.com',
|
||||||
|
'tempmail.com',
|
||||||
|
'temp-mail.org',
|
||||||
|
'trashmail.com',
|
||||||
|
'yopmail.com',
|
||||||
|
'example.com',
|
||||||
|
'example.net',
|
||||||
|
'example.org'
|
||||||
|
]);
|
||||||
|
|
||||||
|
messageDiv.textContent = ''; // Clear previous messages
|
||||||
|
|
||||||
|
if (timezoneField) {
|
||||||
|
try {
|
||||||
|
timezoneField.value = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||||
|
} catch (error) {
|
||||||
|
timezoneField.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailValue && emailValue.includes('@')) {
|
||||||
|
const emailDomain = emailValue.split('@').pop();
|
||||||
|
if (blockedDomains.has(emailDomain)) {
|
||||||
|
messageDiv.textContent = 'Please use your real email address. Temporary or disposable inboxes are not allowed.';
|
||||||
|
emailInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('register.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
const successHtml = `
|
||||||
|
<h2>You're Registered!</h2>
|
||||||
|
<p>Thank you for registering for the webinar. An email confirmation has been sent to you.</p>
|
||||||
|
<p>Add this event to your calendar:</p>
|
||||||
|
<div class="d-grid gap-2 mt-4">
|
||||||
|
<a href="${data.google_link}" target="_blank" class="btn btn-light">Add to Google Calendar</a>
|
||||||
|
<a href="${data.outlook_link}" download="webinar.ics" class="btn btn-outline-light">Add to Outlook/iCal</a>
|
||||||
|
</div>
|
||||||
|
<div class="what-you-learn" style="margin-top: 60px;">
|
||||||
|
<div style="display: flex; align-items: center; margin-bottom: 20px;">
|
||||||
|
<img src="assets/pasted-20251017-115404-4e52d477.png" alt="Bot" style="width: 50px; margin-right: 15px;">
|
||||||
|
<h4>SCHEDULE</h4>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li>Intro ~10 min</li>
|
||||||
|
<li>Creating Apps ~40-50 min</li>
|
||||||
|
<li>Q&A</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
rightColumn.innerHTML = successHtml;
|
||||||
|
} else {
|
||||||
|
messageDiv.textContent = data.error || 'An unknown error occurred.';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
messageDiv.textContent = 'A network error occurred. Please try again.';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
233
join.php
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once 'db/config.php';
|
||||||
|
|
||||||
|
function resolve_attendee_id(): ?int {
|
||||||
|
if (isset($_SESSION['user_id']) && is_numeric($_SESSION['user_id'])) {
|
||||||
|
return (int) $_SESSION['user_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = isset($_GET['id']) ? trim((string) $_GET['id']) : '';
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctype_digit($raw)) {
|
||||||
|
return (int) $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode(strtr($raw, ' ', '+'), true);
|
||||||
|
if ($decoded !== false && ctype_digit($decoded)) {
|
||||||
|
return (int) $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function format_join_datetime(?string $scheduledAt, ?string $timezone): string {
|
||||||
|
if (!$scheduledAt) {
|
||||||
|
return 'Schedule to be announced';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$date = new DateTime($scheduledAt, new DateTimeZone('UTC'));
|
||||||
|
if (!empty($timezone) && in_array($timezone, timezone_identifiers_list(), true)) {
|
||||||
|
$date->setTimezone(new DateTimeZone($timezone));
|
||||||
|
}
|
||||||
|
return $date->format('l, F j, Y \a\t g:i A T');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return $scheduledAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$attendeeId = resolve_attendee_id();
|
||||||
|
if ($attendeeId === null) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo 'Invalid webinar access link.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attendee = null;
|
||||||
|
$webinar = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = db()->prepare('SELECT * FROM attendees WHERE id = ? AND deleted_at IS NULL');
|
||||||
|
$stmt->execute([$attendeeId]);
|
||||||
|
$attendee = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($attendee) {
|
||||||
|
$stmt = db()->prepare('SELECT * FROM webinars WHERE id = ?');
|
||||||
|
$stmt->execute([(int) $attendee['webinar_id']]);
|
||||||
|
$webinar = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Join page lookup failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$attendee || !$webinar) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo 'Registration not found.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$participantName = trim((string) (($attendee['first_name'] ?? '') . ' ' . ($attendee['last_name'] ?? '')));
|
||||||
|
$participantName = $participantName !== '' ? $participantName : ((string) ($attendee['email'] ?? 'Guest'));
|
||||||
|
$scheduledLabel = format_join_datetime($webinar['scheduled_at'] ?? null, $attendee['timezone'] ?? null);
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Join Webinar | AppWizzy</title>
|
||||||
|
<meta name="description" content="Join the AppWizzy webinar room and review your registration details.">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<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;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-start: #071659;
|
||||||
|
--bg-end: #1029a0;
|
||||||
|
--surface: rgba(9, 24, 47, 0.84);
|
||||||
|
--surface-soft: rgba(221, 226, 253, 0.08);
|
||||||
|
--line: rgba(221, 226, 253, 0.16);
|
||||||
|
--text-main: #f3f9ff;
|
||||||
|
--text-soft: #dde2fd;
|
||||||
|
--text-muted: #bbc8fb;
|
||||||
|
--accent: #6ed4ff;
|
||||||
|
--accent-strong: #2c4bd1;
|
||||||
|
--success: #18d1b3;
|
||||||
|
--font-body: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-display: "Space Grotesk", "Inter", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--text-main);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(110, 212, 255, 0.18), transparent 28%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(44, 75, 209, 0.20), transparent 32%),
|
||||||
|
linear-gradient(145deg, var(--bg-start) 0%, var(--bg-end) 100%);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 840px;
|
||||||
|
padding: 36px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: 0 30px 80px rgba(2, 10, 24, 0.48);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(24, 209, 179, 0.14);
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(2.1rem, 4vw, 3rem);
|
||||||
|
margin: 16px 0 14px;
|
||||||
|
line-height: 1.02;
|
||||||
|
}
|
||||||
|
p { color: var(--text-soft); line-height: 1.65; }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin: 28px 0;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.12);
|
||||||
|
}
|
||||||
|
.item-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.item-value {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { transform: translateY(-1px); }
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #6ed4ff 0%, #2c4bd1 100%);
|
||||||
|
color: #071659;
|
||||||
|
box-shadow: 0 18px 36px rgba(44, 75, 209, 0.28);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(221, 226, 253, 0.10);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.18);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.card { padding: 28px; }
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<span class="badge">Access confirmed</span>
|
||||||
|
<h1>Welcome, <?php echo htmlspecialchars($participantName); ?>!</h1>
|
||||||
|
<p>Your registration is active. When the live room is available, this page is where you can connect attendees to the webinar experience.</p>
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Registration summary">
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-label">Webinar</div>
|
||||||
|
<div class="item-value"><?php echo htmlspecialchars((string) ($webinar['title'] ?? 'Upcoming webinar')); ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-label">Scheduled time</div>
|
||||||
|
<div class="item-value"><?php echo htmlspecialchars($scheduledLabel); ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-label">Registered email</div>
|
||||||
|
<div class="item-value"><?php echo htmlspecialchars((string) ($attendee['email'] ?? '—')); ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<div class="item-label">Timezone</div>
|
||||||
|
<div class="item-value"><?php echo htmlspecialchars((string) (($attendee['timezone'] ?? '') !== '' ? $attendee['timezone'] : 'UTC')); ?></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a href="dashboard.php" class="btn btn-primary">Back to dashboard</a>
|
||||||
|
<a href="index.php" class="btn btn-secondary">Open main site</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
354
login.php
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once 'db/config.php';
|
||||||
|
require_once 'includes/admin_auth.php';
|
||||||
|
|
||||||
|
if (isset($_GET['logout']) && $_GET['logout'] === '1') {
|
||||||
|
admin_logout();
|
||||||
|
unset($_SESSION['user_id']);
|
||||||
|
session_regenerate_id(true);
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminError = '';
|
||||||
|
$adminSuccess = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$adminCount = admin_count_users();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('Admin bootstrap error: ' . $e->getMessage());
|
||||||
|
$adminCount = 0;
|
||||||
|
$adminError = 'Unable to load admin access right now. Please try again in a moment.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$loginType = $_POST['login_type'] ?? '';
|
||||||
|
|
||||||
|
if ($loginType === 'admin_setup' && $adminCount === 0) {
|
||||||
|
$displayName = trim((string) ($_POST['display_name'] ?? ''));
|
||||||
|
$email = strtolower(trim((string) ($_POST['email'] ?? '')));
|
||||||
|
$password = (string) ($_POST['password'] ?? '');
|
||||||
|
$passwordConfirm = (string) ($_POST['password_confirm'] ?? '');
|
||||||
|
|
||||||
|
if ($displayName === '') {
|
||||||
|
$adminError = 'Enter an admin name to finish setup.';
|
||||||
|
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$adminError = 'Enter a valid admin email address.';
|
||||||
|
} elseif (strlen($password) < 10) {
|
||||||
|
$adminError = 'Admin password must be at least 10 characters.';
|
||||||
|
} elseif ($password !== $passwordConfirm) {
|
||||||
|
$adminError = 'Admin passwords do not match.';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$newAdminId = admin_create_user($email, $password, $displayName);
|
||||||
|
admin_login_user([
|
||||||
|
'id' => $newAdminId,
|
||||||
|
'email' => $email,
|
||||||
|
'display_name' => $displayName,
|
||||||
|
]);
|
||||||
|
admin_set_flash('Admin access has been created successfully.');
|
||||||
|
header('Location: admin.php');
|
||||||
|
exit;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Admin setup failed: ' . $e->getMessage());
|
||||||
|
$adminError = 'Could not create the admin user. Please try a different email.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($loginType === 'admin_login' && $adminCount > 0) {
|
||||||
|
$email = strtolower(trim((string) ($_POST['email'] ?? '')));
|
||||||
|
$password = (string) ($_POST['password'] ?? '');
|
||||||
|
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
|
||||||
|
$adminError = 'Enter your admin email and password.';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$admin = admin_get_by_email($email);
|
||||||
|
if ($admin && password_verify($password, $admin['password_hash'])) {
|
||||||
|
admin_login_user($admin);
|
||||||
|
header('Location: admin.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$adminError = 'Invalid admin email or password.';
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Admin login failed: ' . $e->getMessage());
|
||||||
|
$adminError = 'Admin login is temporarily unavailable.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Login | Webinar Platform</title>
|
||||||
|
<meta name="description" content="Secure admin login for the webinar registration platform.">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<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;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #2c4bd1;
|
||||||
|
--primary-hover: #1029a0;
|
||||||
|
--background-start: #071659;
|
||||||
|
--background-end: #1029a0;
|
||||||
|
--card-background: rgba(9, 24, 47, 0.8);
|
||||||
|
--card-soft: rgba(221, 226, 253, 0.08);
|
||||||
|
--text-color: #f3f9ff;
|
||||||
|
--muted-text: #bbc8fb;
|
||||||
|
--input-border: rgba(221, 226, 253, 0.18);
|
||||||
|
--input-focus-border: #6ed4ff;
|
||||||
|
--error-color: #ff99a9;
|
||||||
|
--success-color: #18d1b3;
|
||||||
|
--font-body: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-display: "Space Grotesk", "Inter", system-ui, sans-serif;
|
||||||
|
--button-shadow: 0 18px 36px rgba(11, 32, 131, 0.34);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(221, 226, 253, 0.22), transparent 24%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(110, 212, 255, 0.12), transparent 28%),
|
||||||
|
linear-gradient(135deg, var(--background-start) 0%, #1029a0 55%, var(--background-end) 100%);
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.brand-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.brand-copy {
|
||||||
|
color: var(--muted-text);
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--card-background);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
box-shadow: 0 24px 60px rgba(3, 11, 25, 0.35);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(110, 212, 255, 0.12);
|
||||||
|
color: #d9f6ff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
h1, h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
h1 { font-size: 40px; margin-bottom: 0.85rem; }
|
||||||
|
h2 { font-size: 26px; margin-bottom: 0.6rem; }
|
||||||
|
.subtitle, .helper, .notice {
|
||||||
|
color: var(--muted-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
background: var(--card-soft);
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.12);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.status.error {
|
||||||
|
background: rgba(255, 153, 169, 0.12);
|
||||||
|
border: 1px solid rgba(255, 153, 169, 0.25);
|
||||||
|
color: #ffd7de;
|
||||||
|
}
|
||||||
|
.status.success {
|
||||||
|
background: rgba(24, 209, 179, 0.12);
|
||||||
|
border: 1px solid rgba(24, 209, 179, 0.24);
|
||||||
|
color: #d7fff6;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.form-group.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
background: rgba(221, 226, 253, 0.08);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
input::placeholder { color: var(--muted-text); }
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--input-focus-border);
|
||||||
|
box-shadow: 0 0 0 3px rgba(110, 212, 255, 0.14);
|
||||||
|
background: rgba(221, 226, 253, 0.12);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(221, 226, 253, 0.16);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-hover) 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--button-shadow);
|
||||||
|
}
|
||||||
|
.btn:hover { transform: translateY(-1px); }
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.text-link {
|
||||||
|
color: #d7ecff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.text-link:hover { text-decoration: underline; }
|
||||||
|
ul.feature-list {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
ul.feature-list li { margin-bottom: 0.55rem; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
h1 { font-size: 32px; }
|
||||||
|
.card { padding: 22px; }
|
||||||
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<header class="brand">
|
||||||
|
<div class="eyebrow">Admin access</div>
|
||||||
|
<h1 class="brand-name">Secure admin login for your webinar workspace</h1>
|
||||||
|
<p class="brand-copy">Use the admin area to manage registrations, edit attendee data, export CSV, and track daily signup trends.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Login options">
|
||||||
|
<article class="card">
|
||||||
|
<div class="eyebrow">Admin</div>
|
||||||
|
<h2><?php echo $adminCount === 0 ? 'Create the first admin account' : 'Admin login'; ?></h2>
|
||||||
|
<p class="subtitle">
|
||||||
|
<?php echo $adminCount === 0
|
||||||
|
? 'The previous hardcoded admin password has been removed. Create a real admin account to unlock the dashboard.'
|
||||||
|
: 'Sign in with your saved admin credentials to access registrations and analytics.'; ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php if ($adminError !== ''): ?>
|
||||||
|
<div class="status error"><?php echo htmlspecialchars($adminError); ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($adminSuccess !== ''): ?>
|
||||||
|
<div class="status success"><?php echo htmlspecialchars($adminSuccess); ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($adminCount === 0): ?>
|
||||||
|
<div class="notice">First-run setup is only shown until one admin account exists. After that, access is password-hashed and database-backed.</div>
|
||||||
|
<form method="POST" action="login.php" novalidate>
|
||||||
|
<input type="hidden" name="login_type" value="admin_setup">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group full">
|
||||||
|
<label for="display_name">Admin name</label>
|
||||||
|
<input type="text" id="display_name" name="display_name" placeholder="Jane Admin" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group full">
|
||||||
|
<label for="admin_email">Admin email</label>
|
||||||
|
<input type="email" id="admin_email" name="email" placeholder="admin@yourdomain.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="admin_password">Password</label>
|
||||||
|
<input type="password" id="admin_password" name="password" minlength="10" placeholder="At least 10 characters" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="admin_password_confirm">Confirm password</label>
|
||||||
|
<input type="password" id="admin_password_confirm" name="password_confirm" minlength="10" placeholder="Repeat password" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Create admin account</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="POST" action="login.php" novalidate>
|
||||||
|
<input type="hidden" name="login_type" value="admin_login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="admin_login_email">Admin email</label>
|
||||||
|
<input type="email" id="admin_login_email" name="email" placeholder="admin@yourdomain.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="admin_login_password">Password</label>
|
||||||
|
<input type="password" id="admin_login_password" name="password" placeholder="Your admin password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Open admin dashboard</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>See all webinar registrations in one place.</li>
|
||||||
|
<li>Edit attendee details and archive old records safely.</li>
|
||||||
|
<li>Export filtered data to CSV and watch the daily chart update automatically.</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -14,35 +14,24 @@ class MailService
|
|||||||
{
|
{
|
||||||
$cfg = self::loadConfig();
|
$cfg = self::loadConfig();
|
||||||
|
|
||||||
$autoload = __DIR__ . '/../vendor/autoload.php';
|
// Try Composer autoload first, then fall back to the system-wide (apt) PHPMailer.
|
||||||
if (file_exists($autoload)) {
|
$composerAutoload = __DIR__ . '/../vendor/autoload.php';
|
||||||
require_once $autoload;
|
if (file_exists($composerAutoload)) {
|
||||||
}
|
require_once $composerAutoload;
|
||||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
} elseif (file_exists('/usr/share/php/libphp-phpmailer/autoload.php')) {
|
||||||
@require_once 'libphp-phpmailer/autoload.php';
|
require_once '/usr/share/php/libphp-phpmailer/autoload.php';
|
||||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
|
||||||
@require_once 'libphp-phpmailer/src/Exception.php';
|
|
||||||
@require_once 'libphp-phpmailer/src/SMTP.php';
|
|
||||||
@require_once 'libphp-phpmailer/src/PHPMailer.php';
|
|
||||||
}
|
|
||||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
|
||||||
@require_once 'PHPMailer/src/Exception.php';
|
|
||||||
@require_once 'PHPMailer/src/SMTP.php';
|
|
||||||
@require_once 'PHPMailer/src/PHPMailer.php';
|
|
||||||
}
|
|
||||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
|
||||||
@require_once 'PHPMailer/Exception.php';
|
|
||||||
@require_once 'PHPMailer/SMTP.php';
|
|
||||||
@require_once 'PHPMailer/PHPMailer.php';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {
|
||||||
return [ 'success' => false, 'error' => 'PHPMailer not available' ];
|
return [ 'success' => false, 'error' => 'PHPMailer not available' ];
|
||||||
}
|
}
|
||||||
|
|
||||||
$mail = new PHPMailer\PHPMailer\PHPMailer(true);
|
$mail = new PHPMailer\PHPMailer\PHPMailer(true);
|
||||||
try {
|
try {
|
||||||
|
$mail->SMTPDebug = 2;
|
||||||
|
$mail->Debugoutput = function($str, $level) {
|
||||||
|
file_put_contents(__DIR__ . '/mail.log', $str, FILE_APPEND);
|
||||||
|
};
|
||||||
$mail->isSMTP();
|
$mail->isSMTP();
|
||||||
$mail->Host = $cfg['smtp_host'] ?? '';
|
$mail->Host = $cfg['smtp_host'] ?? '';
|
||||||
$mail->Port = (int)($cfg['smtp_port'] ?? 587);
|
$mail->Port = (int)($cfg['smtp_port'] ?? 587);
|
||||||
@ -54,7 +43,7 @@ class MailService
|
|||||||
$mail->Username = $cfg['smtp_user'] ?? '';
|
$mail->Username = $cfg['smtp_user'] ?? '';
|
||||||
$mail->Password = $cfg['smtp_pass'] ?? '';
|
$mail->Password = $cfg['smtp_pass'] ?? '';
|
||||||
|
|
||||||
$fromEmail = $opts['from_email'] ?? ($cfg['from_email'] ?? 'no-reply@localhost');
|
$fromEmail = $opts['from_email'] ?? ($cfg['from_email'] ?? 'support@flatlogic.com');
|
||||||
$fromName = $opts['from_name'] ?? ($cfg['from_name'] ?? 'App');
|
$fromName = $opts['from_name'] ?? ($cfg['from_name'] ?? 'App');
|
||||||
$mail->setFrom($fromEmail, $fromName);
|
$mail->setFrom($fromEmail, $fromName);
|
||||||
if (!empty($opts['reply_to']) && filter_var($opts['reply_to'], FILTER_VALIDATE_EMAIL)) {
|
if (!empty($opts['reply_to']) && filter_var($opts['reply_to'], FILTER_VALIDATE_EMAIL)) {
|
||||||
@ -101,6 +90,11 @@ class MailService
|
|||||||
}
|
}
|
||||||
private static function loadConfig(): array
|
private static function loadConfig(): array
|
||||||
{
|
{
|
||||||
|
static $cachedConfig = null;
|
||||||
|
if (is_array($cachedConfig)) {
|
||||||
|
return $cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
$configPath = __DIR__ . '/config.php';
|
$configPath = __DIR__ . '/config.php';
|
||||||
if (!file_exists($configPath)) {
|
if (!file_exists($configPath)) {
|
||||||
throw new \RuntimeException('Mail config not found. Copy mail/config.sample.php to mail/config.php and fill in credentials.');
|
throw new \RuntimeException('Mail config not found. Copy mail/config.sample.php to mail/config.php and fill in credentials.');
|
||||||
@ -109,7 +103,9 @@ class MailService
|
|||||||
if (!is_array($cfg)) {
|
if (!is_array($cfg)) {
|
||||||
throw new \RuntimeException('Invalid mail config format: expected array');
|
throw new \RuntimeException('Invalid mail config format: expected array');
|
||||||
}
|
}
|
||||||
return $cfg;
|
|
||||||
|
$cachedConfig = $cfg;
|
||||||
|
return $cachedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send a contact message
|
// Send a contact message
|
||||||
@ -118,31 +114,12 @@ class MailService
|
|||||||
{
|
{
|
||||||
$cfg = self::loadConfig();
|
$cfg = self::loadConfig();
|
||||||
|
|
||||||
// Try Composer autoload if available (for PHPMailer)
|
// Try Composer autoload first, then fall back to the system-wide (apt) PHPMailer.
|
||||||
$autoload = __DIR__ . '/../vendor/autoload.php';
|
$composerAutoload = __DIR__ . '/../vendor/autoload.php';
|
||||||
if (file_exists($autoload)) {
|
if (file_exists($composerAutoload)) {
|
||||||
require_once $autoload;
|
require_once $composerAutoload;
|
||||||
}
|
} elseif (file_exists('/usr/share/php/libphp-phpmailer/autoload.php')) {
|
||||||
// Fallback to system-wide PHPMailer (installed via apt: libphp-phpmailer)
|
require_once '/usr/share/php/libphp-phpmailer/autoload.php';
|
||||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
|
||||||
// Debian/Ubuntu package layout (libphp-phpmailer)
|
|
||||||
@require_once 'libphp-phpmailer/autoload.php';
|
|
||||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
|
||||||
@require_once 'libphp-phpmailer/src/Exception.php';
|
|
||||||
@require_once 'libphp-phpmailer/src/SMTP.php';
|
|
||||||
@require_once 'libphp-phpmailer/src/PHPMailer.php';
|
|
||||||
}
|
|
||||||
// Alternative layout (older PHPMailer package names)
|
|
||||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
|
||||||
@require_once 'PHPMailer/src/Exception.php';
|
|
||||||
@require_once 'PHPMailer/src/SMTP.php';
|
|
||||||
@require_once 'PHPMailer/src/PHPMailer.php';
|
|
||||||
}
|
|
||||||
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
|
|
||||||
@require_once 'PHPMailer/Exception.php';
|
|
||||||
@require_once 'PHPMailer/SMTP.php';
|
|
||||||
@require_once 'PHPMailer/PHPMailer.php';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$transport = $cfg['transport'] ?? 'smtp';
|
$transport = $cfg['transport'] ?? 'smtp';
|
||||||
@ -158,6 +135,10 @@ class MailService
|
|||||||
{
|
{
|
||||||
$mail = new PHPMailer\PHPMailer\PHPMailer(true);
|
$mail = new PHPMailer\PHPMailer\PHPMailer(true);
|
||||||
try {
|
try {
|
||||||
|
$mail->SMTPDebug = 2;
|
||||||
|
$mail->Debugoutput = function($str, $level) {
|
||||||
|
file_put_contents(__DIR__ . '/mail.log', $str, FILE_APPEND);
|
||||||
|
};
|
||||||
$mail->isSMTP();
|
$mail->isSMTP();
|
||||||
$mail->Host = $cfg['smtp_host'] ?? '';
|
$mail->Host = $cfg['smtp_host'] ?? '';
|
||||||
$mail->Port = (int)($cfg['smtp_port'] ?? 587);
|
$mail->Port = (int)($cfg['smtp_port'] ?? 587);
|
||||||
@ -169,7 +150,7 @@ class MailService
|
|||||||
$mail->Username = $cfg['smtp_user'] ?? '';
|
$mail->Username = $cfg['smtp_user'] ?? '';
|
||||||
$mail->Password = $cfg['smtp_pass'] ?? '';
|
$mail->Password = $cfg['smtp_pass'] ?? '';
|
||||||
|
|
||||||
$fromEmail = $cfg['from_email'] ?? 'no-reply@localhost';
|
$fromEmail = $cfg['from_email'] ?? 'support@flatlogic.com';
|
||||||
$fromName = $cfg['from_name'] ?? 'App';
|
$fromName = $cfg['from_name'] ?? 'App';
|
||||||
$mail->setFrom($fromEmail, $fromName);
|
$mail->setFrom($fromEmail, $fromName);
|
||||||
|
|
||||||
|
|||||||
@ -2,15 +2,18 @@
|
|||||||
// Mail configuration sourced from environment variables.
|
// Mail configuration sourced from environment variables.
|
||||||
// No secrets are stored here; the file just maps env -> config array for MailService.
|
// No secrets are stored here; the file just maps env -> config array for MailService.
|
||||||
|
|
||||||
function env_val(string $key, $default = null) {
|
if (!function_exists('env_val')) {
|
||||||
|
function env_val(string $key, $default = null) {
|
||||||
$v = getenv($key);
|
$v = getenv($key);
|
||||||
return ($v === false || $v === null || $v === '') ? $default : $v;
|
return ($v === false || $v === null || $v === '') ? $default : $v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: if critical vars are missing from process env, try to parse executor/.env
|
// Fallback: if critical vars are missing from process env, try to parse executor/.env
|
||||||
// This helps in web/Apache contexts where .env is not exported.
|
// This helps in web/Apache contexts where .env is not exported.
|
||||||
// Supports simple KEY=VALUE lines; ignores quotes and comments.
|
// Supports simple KEY=VALUE lines; ignores quotes and comments.
|
||||||
function load_dotenv_if_needed(array $keys): void {
|
if (!function_exists('load_dotenv_if_needed')) {
|
||||||
|
function load_dotenv_if_needed(array $keys): void {
|
||||||
$missing = array_filter($keys, fn($k) => getenv($k) === false || getenv($k) === '');
|
$missing = array_filter($keys, fn($k) => getenv($k) === false || getenv($k) === '');
|
||||||
if (empty($missing)) return;
|
if (empty($missing)) return;
|
||||||
static $loaded = false;
|
static $loaded = false;
|
||||||
@ -23,7 +26,7 @@ function load_dotenv_if_needed(array $keys): void {
|
|||||||
if (!str_contains($line, '=')) continue;
|
if (!str_contains($line, '=')) continue;
|
||||||
[$k, $v] = array_map('trim', explode('=', $line, 2));
|
[$k, $v] = array_map('trim', explode('=', $line, 2));
|
||||||
// Strip potential surrounding quotes
|
// Strip potential surrounding quotes
|
||||||
$v = trim($v, "\"' ");
|
$v = trim($v, "\"' " );
|
||||||
// Do not override existing env
|
// Do not override existing env
|
||||||
if ($k !== '' && (getenv($k) === false || getenv($k) === '')) {
|
if ($k !== '' && (getenv($k) === false || getenv($k) === '')) {
|
||||||
putenv("{$k}={$v}");
|
putenv("{$k}={$v}");
|
||||||
@ -31,6 +34,7 @@ function load_dotenv_if_needed(array $keys): void {
|
|||||||
}
|
}
|
||||||
$loaded = true;
|
$loaded = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
load_dotenv_if_needed([
|
load_dotenv_if_needed([
|
||||||
@ -40,15 +44,15 @@ load_dotenv_if_needed([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$transport = env_val('MAIL_TRANSPORT', 'smtp');
|
$transport = env_val('MAIL_TRANSPORT', 'smtp');
|
||||||
$smtp_host = env_val('SMTP_HOST');
|
$smtp_host = env_val('SMTP_HOST', 'email-smtp.us-east-1.amazonaws.com');
|
||||||
$smtp_port = (int) env_val('SMTP_PORT', 587);
|
$smtp_port = (int) env_val('SMTP_PORT', 587);
|
||||||
$smtp_secure = env_val('SMTP_SECURE', 'tls'); // tls | ssl | null
|
$smtp_secure = env_val('SMTP_SECURE', 'tls'); // tls | ssl | null
|
||||||
$smtp_user = env_val('SMTP_USER');
|
$smtp_user = env_val('SMTP_USER', 'AKIAVEW7G4PQUBGM52OF');
|
||||||
$smtp_pass = env_val('SMTP_PASS');
|
$smtp_pass = env_val('SMTP_PASS', 'BLnD4hKGb6YkSz3gaQrf8fnyLi3C3/EdjOOsLEDTDPTz');
|
||||||
|
|
||||||
$from_email = env_val('MAIL_FROM', 'no-reply@localhost');
|
$from_email = env_val('MAIL_FROM', 'app@flatlogic.app');
|
||||||
$from_name = env_val('MAIL_FROM_NAME', 'App');
|
$from_name = env_val('MAIL_FROM_NAME', 'Flatlogic App');
|
||||||
$reply_to = env_val('MAIL_REPLY_TO');
|
$reply_to = env_val('MAIL_REPLY_TO', 'app@flatlogic.app');
|
||||||
|
|
||||||
$dkim_domain = env_val('DKIM_DOMAIN');
|
$dkim_domain = env_val('DKIM_DOMAIN');
|
||||||
$dkim_selector = env_val('DKIM_SELECTOR');
|
$dkim_selector = env_val('DKIM_SELECTOR');
|
||||||
|
|||||||
40582
mail/mail.log
Normal file
BIN
register.php
Normal file
73
resend_webinar_email.php
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once 'db/config.php';
|
||||||
|
require_once 'mail/MailService.php';
|
||||||
|
require_once 'includes/admin_auth.php';
|
||||||
|
require_once 'includes/webinar_schedule.php';
|
||||||
|
require_once 'includes/webinar_email.php';
|
||||||
|
|
||||||
|
admin_require_login();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
header('Location: admin.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedWebinarId = filter_input(INPUT_POST, 'webinar_id', FILTER_VALIDATE_INT);
|
||||||
|
$selectedWebinarId = $selectedWebinarId ? max(0, (int) $selectedWebinarId) : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$sql = "SELECT a.id, a.first_name, a.last_name, a.email, a.webinar_id, w.title, w.description, w.scheduled_at, w.presenter
|
||||||
|
FROM attendees a
|
||||||
|
INNER JOIN webinars w ON w.id = a.webinar_id
|
||||||
|
WHERE a.deleted_at IS NULL";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($selectedWebinarId > 0) {
|
||||||
|
$sql .= ' AND a.webinar_id = ?';
|
||||||
|
$params[] = $selectedWebinarId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY a.created_at DESC, a.id DESC';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$attendees = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$attendees) {
|
||||||
|
admin_set_flash('No active attendees were found for the selected webinar.');
|
||||||
|
header('Location: admin.php' . ($selectedWebinarId > 0 ? '?webinar_id=' . urlencode((string) $selectedWebinarId) : ''));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sentCount = 0;
|
||||||
|
$failedCount = 0;
|
||||||
|
|
||||||
|
foreach ($attendees as $attendee) {
|
||||||
|
$payload = webinar_build_email_payload((string) ($attendee['first_name'] ?? ''), $attendee, true);
|
||||||
|
$result = MailService::sendMail((string) $attendee['email'], $payload['subject'], $payload['html'], $payload['text']);
|
||||||
|
|
||||||
|
if (!empty($result['success'])) {
|
||||||
|
$sentCount++;
|
||||||
|
} else {
|
||||||
|
$failedCount++;
|
||||||
|
error_log('Correction email failed for attendee #' . (int) $attendee['id'] . ': ' . ($result['error'] ?? 'unknown error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope = $selectedWebinarId > 0 ? 'selected webinar' : 'all active webinars';
|
||||||
|
$message = "Correction email sent to {$sentCount} attendee(s) for the {$scope}.";
|
||||||
|
if ($failedCount > 0) {
|
||||||
|
$message .= " {$failedCount} email(s) failed to send. Check the mail log for details.";
|
||||||
|
}
|
||||||
|
|
||||||
|
admin_set_flash($message);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('Failed to send correction emails: ' . $e->getMessage());
|
||||||
|
admin_set_flash('Could not send the correction email right now. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Location: admin.php' . ($selectedWebinarId > 0 ? '?webinar_id=' . urlencode((string) $selectedWebinarId) : ''));
|
||||||
|
exit;
|
||||||