Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
7e76255737 0 2026-05-29 14:48:07 +00:00
23 changed files with 1182 additions and 554 deletions

142
admin.php Normal file
View File

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
session_start();
require_once __DIR__ . '/includes/leads.php';
$projectName = $_SERVER['PROJECT_NAME'] ?? 'Northline Studio';
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Admin dashboard for reviewing agency quote requests.';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$accessKey = getenv('ADMIN_ACCESS_KEY') ?: '';
$demoMode = $accessKey === '';
$loginError = '';
if (isset($_GET['logout'])) {
unset($_SESSION['admin_ok']);
header('Location: admin.php');
exit;
}
if (!$demoMode && $_SERVER['REQUEST_METHOD'] === 'POST') {
$submitted = (string)($_POST['access_key'] ?? '');
if (hash_equals($accessKey, $submitted)) {
$_SESSION['admin_ok'] = true;
header('Location: admin.php');
exit;
}
$loginError = 'Invalid access key.';
}
$authenticated = $demoMode || !empty($_SESSION['admin_ok']);
$status = isset($_GET['status']) ? (string)$_GET['status'] : '';
$statuses = lead_statuses();
$leads = [];
$counts = array_fill_keys(array_keys($statuses), 0);
$error = null;
if ($authenticated) {
try {
$leads = list_leads($status ?: null);
$counts = lead_counts();
} catch (Throwable $e) {
error_log('Admin lead load failed: ' . $e->getMessage());
$error = 'Lead data is unavailable. Check database configuration.';
}
}
function h(?string $value): string { return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function status_label(array $statuses, string $status): string { return $statuses[$status] ?? ucfirst($status); }
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lead Dashboard | <?= h($projectName) ?></title>
<?php if ($projectDescription): ?>
<meta name="description" content="<?= h($projectDescription) ?>">
<meta property="og:description" content="<?= h($projectDescription) ?>">
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
<?php endif; ?>
<meta name="robots" content="noindex, nofollow">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=2026052901">
</head>
<body class="admin-shell">
<header class="site-header">
<nav class="navbar">
<div class="container">
<a class="navbar-brand" href="/"><span class="brand-mark" aria-hidden="true">N</span><?= h($projectName) ?></a>
<div class="d-flex gap-2"><a class="btn btn-outline-dark btn-sm" href="/">Home</a><?php if ($authenticated && !$demoMode): ?><a class="btn btn-dark btn-sm" href="admin.php?logout=1">Logout</a><?php endif; ?></div>
</div>
</nav>
</header>
<main>
<section class="admin-hero">
<div class="container">
<p class="eyebrow">Admin dashboard</p>
<h1 class="h2 mb-2">Quote requests and lead follow-up.</h1>
<p class="text-secondary mb-0">Review incoming submissions, filter by status, and open each lead for notes.</p>
</div>
</section>
<div class="container pb-5">
<?php if (!$authenticated): ?>
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<form class="admin-card" method="post" action="admin.php">
<h2 class="h4">Enter admin access key</h2>
<p class="text-secondary">Set ADMIN_ACCESS_KEY in the environment to protect this dashboard.</p>
<?php if ($loginError): ?><div class="alert alert-danger"><?= h($loginError) ?></div><?php endif; ?>
<label class="form-label" for="access_key">Access key</label>
<input class="form-control" id="access_key" name="access_key" type="password" required autofocus>
<button class="btn btn-dark mt-3 w-100" type="submit">Open dashboard</button>
</form>
</div>
</div>
<?php else: ?>
<?php if ($demoMode): ?><div class="alert alert-warning">Demo admin mode is active because ADMIN_ACCESS_KEY is not set. Set it before sharing this site publicly.</div><?php endif; ?>
<?php if ($error): ?><div class="alert alert-danger"><?= h($error) ?></div><?php endif; ?>
<div class="stat-grid mb-3">
<?php foreach ($statuses as $key => $label): ?>
<a class="stat text-decoration-none text-reset" href="admin.php?status=<?= h($key) ?>"><span><?= h($label) ?></span><strong><?= h((string)$counts[$key]) ?></strong></a>
<?php endforeach; ?>
</div>
<section class="admin-card">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2 mb-3">
<div>
<h2 class="h4 mb-1">Lead inbox</h2>
<p class="text-secondary mb-0"><?= $status && isset($statuses[$status]) ? 'Filtered by ' . h($statuses[$status]) : 'Showing latest quote requests.' ?></p>
</div>
<div class="d-flex gap-2"><a class="btn btn-outline-dark btn-sm" href="admin.php">All leads</a><a class="btn btn-dark btn-sm" href="/#quote">Add test lead</a></div>
</div>
<?php if (!$leads): ?>
<div class="empty-state"><h3>No leads yet.</h3><p>Submit the quote form on the homepage to see the workflow end-to-end.</p></div>
<?php else: ?>
<div class="table-responsive">
<table class="table align-middle">
<thead><tr><th>Lead</th><th>Service</th><th>Status</th><th>Created</th><th class="text-end">Action</th></tr></thead>
<tbody>
<?php foreach ($leads as $lead): ?>
<tr>
<td><strong><?= h($lead['name']) ?></strong><br><span class="text-secondary"><?= h($lead['email']) ?><?= $lead['company'] ? ' · ' . h($lead['company']) : '' ?></span></td>
<td><?= h($lead['service']) ?></td>
<td><span class="badge-soft"><?= h(status_label($statuses, $lead['status'])) ?></span></td>
<td><?= h(date('M j, Y H:i', strtotime($lead['created_at']))) ?></td>
<td class="text-end"><a class="btn btn-outline-dark btn-sm" href="admin_lead.php?id=<?= h((string)$lead['id']) ?>">Open</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=2026052901" defer></script>
</body>
</html>

125
admin_lead.php Normal file
View File

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
session_start();
require_once __DIR__ . '/includes/leads.php';
$projectName = $_SERVER['PROJECT_NAME'] ?? 'Northline Studio';
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Admin lead detail for agency quote requests.';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$accessKey = getenv('ADMIN_ACCESS_KEY') ?: '';
$demoMode = $accessKey === '';
$authenticated = $demoMode || !empty($_SESSION['admin_ok']);
if (!$authenticated) {
header('Location: admin.php');
exit;
}
if (empty($_SESSION['admin_csrf'])) {
$_SESSION['admin_csrf'] = bin2hex(random_bytes(32));
}
$statuses = lead_statuses();
$id = isset($_GET['id']) ? (int)$_GET['id'] : (int)($_POST['id'] ?? 0);
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = (string)($_POST['csrf'] ?? '');
if ($token === '' || !hash_equals((string)$_SESSION['admin_csrf'], $token)) {
$error = 'The form expired. Please try again.';
} else {
try {
update_lead($id, (string)($_POST['status'] ?? 'new'), (string)($_POST['admin_notes'] ?? ''));
header('Location: admin_lead.php?id=' . $id . '&saved=1');
exit;
} catch (Throwable $e) {
error_log('Lead update failed: ' . $e->getMessage());
$error = 'Could not update the lead.';
}
}
}
try {
$lead = $id > 0 ? get_lead($id) : null;
} catch (Throwable $e) {
error_log('Lead detail load failed: ' . $e->getMessage());
$lead = null;
$error = 'Lead data is unavailable.';
}
function h(?string $value): string { return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function status_label(array $statuses, string $status): string { return $statuses[$status] ?? ucfirst($status); }
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $lead ? 'Lead #' . h((string)$lead['id']) : 'Lead Not Found' ?> | <?= h($projectName) ?></title>
<?php if ($projectDescription): ?>
<meta name="description" content="<?= h($projectDescription) ?>">
<meta property="og:description" content="<?= h($projectDescription) ?>">
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
<?php endif; ?>
<meta name="robots" content="noindex, nofollow">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=2026052901">
</head>
<body class="admin-shell">
<header class="site-header">
<nav class="navbar">
<div class="container">
<a class="navbar-brand" href="/"><span class="brand-mark" aria-hidden="true">N</span><?= h($projectName) ?></a>
<div class="d-flex gap-2"><a class="btn btn-outline-dark btn-sm" href="admin.php">Dashboard</a><a class="btn btn-dark btn-sm" href="/#quote">New quote</a></div>
</div>
</nav>
</header>
<main class="container py-4 py-md-5">
<?php if ($error): ?><div class="alert alert-danger"><?= h($error) ?></div><?php endif; ?>
<?php if (!$lead): ?>
<section class="admin-card empty-state"><h1 class="h3">Lead not found</h1><p>The requested lead may not exist.</p><a class="btn btn-dark" href="admin.php">Back to dashboard</a></section>
<?php else: ?>
<div class="d-flex flex-column flex-md-row justify-content-between gap-2 mb-3">
<div><p class="eyebrow">Lead #<?= h((string)$lead['id']) ?></p><h1 class="h2 mb-1"><?= h($lead['name']) ?></h1><p class="text-secondary mb-0"><?= h($lead['service']) ?> · <?= h(date('M j, Y H:i', strtotime($lead['created_at']))) ?></p></div>
<span class="badge-soft align-self-md-start"><?= h(status_label($statuses, $lead['status'])) ?></span>
</div>
<div class="row g-3">
<div class="col-lg-7">
<section class="detail-panel h-100">
<h2 class="h4">Request details</h2>
<div class="detail-meta my-3">
<div><span>Email</span><a href="mailto:<?= h($lead['email']) ?>"><?= h($lead['email']) ?></a></div>
<div><span>Phone</span><?= h($lead['phone'] ?: 'Not provided') ?></div>
<div><span>Company</span><?= h($lead['company'] ?: 'Not provided') ?></div>
<div><span>Budget</span><?= h($lead['budget'] ?: 'Not sure') ?></div>
<div><span>Timeline</span><?= h($lead['timeline'] ?: 'Flexible') ?></div>
<div><span>Source</span><?= h($lead['source'] ?: 'website') ?></div>
</div>
<h3 class="h5">Project notes</h3>
<p class="mb-0"><?= nl2br(h($lead['message'])) ?></p>
</section>
</div>
<div class="col-lg-5">
<form class="detail-panel needs-validation" method="post" action="admin_lead.php" novalidate>
<input type="hidden" name="csrf" value="<?= h($_SESSION['admin_csrf']) ?>">
<input type="hidden" name="id" value="<?= h((string)$lead['id']) ?>">
<h2 class="h4">Follow-up</h2>
<label class="form-label" for="status">Status</label>
<select class="form-select mb-3" id="status" name="status">
<?php foreach ($statuses as $key => $label): ?>
<option value="<?= h($key) ?>" <?= $lead['status'] === $key ? 'selected' : '' ?>><?= h($label) ?></option>
<?php endforeach; ?>
</select>
<label class="form-label" for="admin_notes">Internal notes</label>
<textarea class="form-control" id="admin_notes" name="admin_notes" rows="8" placeholder="Add call notes, next action, or proposal context."><?= h($lead['admin_notes'] ?? '') ?></textarea>
<button class="btn btn-dark w-100 mt-3" type="submit">Save lead</button>
</form>
</div>
</div>
<?php endif; ?>
</main>
<div class="toast-container position-fixed bottom-0 end-0 p-3"><div id="siteToast" class="toast" role="status" aria-live="polite" aria-atomic="true"><div class="toast-header"><strong class="me-auto">Dashboard</strong><button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button></div><div class="toast-body">Saved.</div></div></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=2026052901" defer></script>
</body>
</html>

View File

@ -1,403 +1,313 @@
/* Agency MVP — Notion-inspired with real stock photography, fast, accessible */
:root {
--color-bg: #fbfaf7;
--color-bg-dots: rgba(55, 53, 47, .08);
--color-surface: #ffffff;
--color-surface-muted: #f7f6f3;
--color-ink: #2f3437;
--color-muted: #6f6a61;
--color-border: #e7e2d8;
--color-border-strong: #d8d0c2;
--color-accent: #0f766e;
--color-accent-soft: #e8f3ef;
--color-warm: #f4eee4;
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 24px;
--shadow-sm: 0 1px 0 rgba(55, 53, 47, .09), 0 8px 24px rgba(55, 53, 47, .06);
--shadow-md: 0 1px 0 rgba(55, 53, 47, .12), 0 24px 70px rgba(55, 53, 47, .12);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%;
animation: gradient 15s ease infinite;
color: #212529;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 1px 1px, var(--color-bg-dots) 1px, transparent 0) 0 0 / 24px 24px,
linear-gradient(180deg, #fffdf8 0%, var(--color-bg) 48%, #f7f3ec 100%);
color: var(--color-ink);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 15px;
line-height: 1.6;
text-rendering: optimizeLegibility;
}
.main-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 100%;
padding: 20px;
box-sizing: border-box;
position: relative;
z-index: 1;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.chat-container {
width: 100%;
max-width: 600px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 20px;
display: flex;
flex-direction: column;
height: 85vh;
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
overflow: hidden;
}
.chat-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background: rgba(255, 255, 255, 0.5);
font-weight: 700;
font-size: 1.1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.message {
max-width: 85%;
padding: 0.85rem 1.1rem;
border-radius: 16px;
line-height: 1.5;
font-size: 0.95rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.message.visitor {
align-self: flex-end;
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
color: #fff;
border-bottom-right-radius: 4px;
}
.message.bot {
align-self: flex-start;
background: #ffffff;
color: #212529;
border-bottom-left-radius: 4px;
}
.chat-input-area {
padding: 1.25rem;
background: rgba(255, 255, 255, 0.5);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.chat-input-area form {
display: flex;
gap: 0.75rem;
}
.chat-input-area input {
flex: 1;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 0.75rem 1rem;
outline: none;
background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.chat-input-area input:focus {
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
}
.chat-input-area button {
background: #212529;
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}
.chat-input-area button:hover {
background: #000;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
/* Background Animations */
.bg-animations {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.blob {
img { max-width: 100%; height: auto; display: block; }
a { color: inherit; }
a:hover { color: var(--color-accent); }
.skip-link {
position: absolute;
width: 500px;
height: 500px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
filter: blur(80px);
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
}
.blob-1 {
top: -10%;
left: -10%;
background: rgba(238, 119, 82, 0.4);
}
.blob-2 {
bottom: -10%;
right: -10%;
background: rgba(35, 166, 213, 0.4);
animation-delay: -7s;
width: 600px;
height: 600px;
}
.blob-3 {
top: 40%;
left: 30%;
background: rgba(231, 60, 126, 0.3);
animation-delay: -14s;
width: 450px;
height: 450px;
}
@keyframes move {
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
}
.header-link {
font-size: 14px;
left: -999px;
top: .75rem;
z-index: 2000;
background: var(--color-ink);
color: #fff;
text-decoration: none;
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.3s ease;
padding: .5rem .75rem;
border-radius: var(--radius-sm);
}
.skip-link:focus { left: .75rem; }
.header-link:hover {
background: rgba(0, 0, 0, 0.4);
text-decoration: none;
.site-header {
background: rgba(251, 250, 247, .86);
border-bottom: 1px solid rgba(216, 208, 194, .72);
backdrop-filter: blur(18px);
}
/* Admin Styles */
.admin-container {
max-width: 900px;
margin: 3rem auto;
padding: 2.5rem;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
border: 1px solid rgba(255, 255, 255, 0.4);
position: relative;
z-index: 1;
}
.admin-container h1 {
margin-top: 0;
color: #212529;
font-weight: 800;
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
margin-top: 1.5rem;
}
.table th {
background: transparent;
border: none;
padding: 1rem;
color: #6c757d;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 1px;
}
.table td {
background: #fff;
padding: 1rem;
border: none;
}
.table tr td:first-child { border-radius: 12px 0 0 12px; }
.table tr td:last-child { border-radius: 0 12px 12px 0; }
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
background: #fff;
transition: all 0.3s ease;
box-sizing: border-box;
}
.form-control:focus {
outline: none;
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
}
.header-container {
display: flex;
justify-content: space-between;
.navbar { padding: .78rem 0; }
.navbar-brand {
display: inline-flex;
align-items: center;
gap: .65rem;
font-weight: 750;
letter-spacing: -.025em;
}
.brand-mark {
display: inline-grid;
place-items: center;
width: 31px;
height: 31px;
border-radius: 8px;
color: #fff;
background: var(--color-ink);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08), 0 8px 18px rgba(47,52,55,.16);
font-size: .83rem;
font-weight: 850;
}
.nav-link { color: var(--color-muted); font-weight: 650; font-size: .92rem; }
.nav-link:hover, .nav-link:focus { color: var(--color-ink); }
.navbar-toggler { border-color: var(--color-border); border-radius: var(--radius-sm); }
.btn {
border-radius: 12px;
font-weight: 760;
letter-spacing: -.01em;
box-shadow: none;
transition: transform .2s ease, box-shadow .2s ease, background-color .2s ease, border-color .2s ease;
}
.btn-lg { padding: .86rem 1.08rem; font-size: .98rem; }
.btn-dark { background: var(--color-ink); border-color: var(--color-ink); }
.btn-dark:hover, .btn-dark:focus { background: #171a1c; border-color: #171a1c; transform: translateY(-1px); box-shadow: 0 14px 28px rgba(47,52,55,.18); }
.btn-outline-dark { background: rgba(255,255,255,.72); border-color: var(--color-border-strong); color: var(--color-ink); }
.btn-outline-dark:hover, .btn-outline-dark:focus { background: #fff; border-color: var(--color-ink); color: var(--color-ink); transform: translateY(-1px); }
.section-pad { padding: 82px 0; }
.hero {
position: relative;
overflow: hidden;
border-bottom: 1px solid var(--color-border);
}
.hero::before,
.hero::after {
content: "";
position: absolute;
pointer-events: none;
border-radius: 999px;
filter: blur(6px);
opacity: .72;
}
.hero::before { width: 320px; height: 320px; right: -120px; top: 80px; background: #e8f3ef; }
.hero::after { width: 260px; height: 260px; left: -90px; bottom: 20px; background: #f3e8d8; }
.hero .container { position: relative; z-index: 1; }
.eyebrow, .small-label, .card-kicker {
color: var(--color-accent);
font-size: .75rem;
font-weight: 850;
letter-spacing: .1em;
text-transform: uppercase;
margin-bottom: .68rem;
}
.emoji {
display: inline-grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
margin-right: .32rem;
border-radius: 7px;
color: var(--color-ink);
background: var(--color-warm);
border: 1px solid var(--color-border);
letter-spacing: 0;
}
h1, h2, h3 { letter-spacing: -.055em; line-height: 1.04; }
h1 { max-width: 780px; font-size: clamp(2.65rem, 6vw, 5.85rem); font-weight: 820; }
h2 { font-size: clamp(1.9rem, 3.2vw, 3rem); font-weight: 790; }
h3 { font-size: 1.13rem; font-weight: 780; }
.hero-copy, .section-copy, .section-heading p { color: var(--color-muted); max-width: 680px; font-size: 1.06rem; }
.hero-visual {
position: relative;
margin: 0;
padding: .8rem;
border-radius: var(--radius-lg);
background: rgba(255,255,255,.7);
border: 1px solid rgba(216, 208, 194, .86);
box-shadow: var(--shadow-md);
}
.hero-visual img { border-radius: 18px; aspect-ratio: 4 / 3; object-fit: cover; width: 100%; }
.sticky-note {
position: absolute;
right: 1.35rem;
bottom: 1.25rem;
max-width: 245px;
color: #4b4035;
background: #fff3bf;
border: 1px solid #eedf9a;
border-radius: 13px;
padding: .85rem .95rem;
box-shadow: 0 16px 30px rgba(84, 68, 35, .16);
transform: rotate(-1.5deg);
font-size: .91rem;
font-weight: 650;
}
.note-pin {
position: absolute;
width: 11px;
height: 11px;
right: 16px;
top: 12px;
border-radius: 50%;
background: #e2624b;
box-shadow: 0 1px 0 rgba(0,0,0,.12);
}
.header-links {
display: flex;
gap: 1rem;
.proof-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .75rem;
max-width: 640px;
}
.admin-card {
background: rgba(255, 255, 255, 0.6);
padding: 2rem;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.5);
margin-bottom: 2.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
.proof-grid div, .service-card, .case-card, .quote-card, .lead-form, .notice-box, .admin-card, .detail-panel {
background: rgba(255,255,255,.86);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.admin-card h3 {
margin-top: 0;
margin-bottom: 1.5rem;
.proof-grid div { padding: .92rem; }
.proof-grid dt { font-size: 1.48rem; font-weight: 850; letter-spacing: -.05em; }
.proof-grid dd { color: var(--color-muted); margin: 0; font-size: .84rem; }
.status-pill, .badge-soft {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-border-strong);
background: var(--color-surface-muted);
color: var(--color-ink);
border-radius: 999px;
padding: .24rem .55rem;
font-size: .74rem;
font-weight: 700;
}
.check-list { list-style: none; padding: 0; margin: 1rem 0 0; }
.check-list li { position: relative; padding: .62rem 0 .62rem 1.45rem; border-top: 1px solid var(--color-border); }
.check-list li::before { content: ""; position: absolute; left: 0; top: 1rem; width: 8px; height: 8px; border-radius: 50%; background: var(--color-accent); }
.btn-delete {
background: #dc3545;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
}
.btn-add {
background: #212529;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin-top: 1rem;
}
.btn-save {
background: #0088cc;
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
.logo-strip { padding: 30px 0; background: rgba(255,255,255,.7); border-bottom: 1px solid var(--color-border); border-top: 1px solid var(--color-border); }
.logos { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .75rem; }
.logos span { color: #4d4740; border: 1px solid var(--color-border); border-radius: 999px; padding: .72rem; text-align: center; font-weight: 800; font-size: .86rem; background: #fffdf8; }
.section-heading { max-width: 760px; margin-bottom: 1.9rem; }
.section-heading.compact { margin: 0; }
.service-card, .case-card { padding: .86rem; overflow: hidden; transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease; }
.service-card:hover, .case-card:hover { transform: translateY(-3px); border-color: var(--color-border-strong); box-shadow: var(--shadow-md); }
.card-illustration, .case-image {
width: 100%;
transition: all 0.3s ease;
height: 218px;
object-fit: cover;
border-radius: 14px;
border: 1px solid var(--color-border);
background: var(--color-surface-muted);
margin-bottom: .5rem;
}
.webhook-url {
font-size: 0.85em;
color: #555;
margin-top: 0.5rem;
.photo-credit {
color: var(--color-muted);
font-size: .72rem;
line-height: 1.35;
margin: 0 0 .85rem;
}
.history-table-container {
overflow-x: auto;
background: rgba(255, 255, 255, 0.4);
padding: 1rem;
.photo-credit a {
color: inherit;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.hero-credit {
position: absolute;
left: 1.3rem;
bottom: 1.22rem;
margin: 0;
padding: .34rem .55rem;
border: 1px solid rgba(216, 208, 194, .76);
border-radius: 999px;
background: rgba(255, 253, 248, .82);
backdrop-filter: blur(10px);
}
.service-card span { display: inline-block; color: var(--color-muted); font-weight: 850; margin-bottom: .65rem; }
.service-card p, .case-card p, .quote-card { color: var(--color-muted); }
.muted-section { background: rgba(247, 246, 243, .78); border: 1px solid var(--color-border); border-left: 0; border-right: 0; }
.metric { color: var(--color-ink) !important; font-size: 2.2rem; line-height: 1; font-weight: 900; letter-spacing: -.07em; margin-bottom: .55rem; }
.quote-card { padding: 1.25rem; height: 100%; margin: 0; }
.quote-card footer { color: var(--color-ink); font-weight: 800; margin-top: .9rem; }
.quote-avatar {
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin-bottom: 1rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
color: var(--color-ink);
background: var(--color-warm);
border: 1px solid var(--color-border);
font-size: .78rem;
font-weight: 850;
}
.quote-section { background: rgba(255,255,255,.65); border-top: 1px solid var(--color-border); }
.notice-box { padding: .95rem; color: var(--color-muted); background: var(--color-surface-muted); font-size: .9rem; }
.lead-form { padding: 1.15rem; }
.form-label { font-size: .84rem; font-weight: 800; color: #4d4740; }
.form-control, .form-select {
border-color: var(--color-border-strong);
border-radius: var(--radius-sm);
padding: .76rem .82rem;
background-color: #fffdf8;
}
.form-control:focus, .form-select:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 .2rem rgba(15, 118, 110, .14);
}
textarea.form-control { resize: vertical; }
.form-note { color: var(--color-muted); font-size: .9rem; }
.site-footer { padding: 28px 0; color: var(--color-muted); border-top: 1px solid var(--color-border); background: rgba(255,255,255,.78); }
.site-footer a { color: var(--color-ink); font-weight: 750; text-decoration: none; }
.admin-shell { min-height: 100vh; background: var(--color-bg); }
.admin-hero { padding: 42px 0 24px; }
.admin-card, .detail-panel { padding: 1rem; }
.stat-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: .7rem; }
.stat { padding: .85rem; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-md); }
.stat strong { display: block; font-size: 1.4rem; letter-spacing: -.04em; }
.table { --bs-table-bg: transparent; vertical-align: middle; }
.table thead th { color: var(--color-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; border-bottom-color: var(--color-border-strong); }
.table td { border-color: var(--color-border); }
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--color-muted); }
.detail-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; }
.detail-meta div { border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .75rem; background: var(--color-surface-muted); }
.detail-meta span { display: block; color: var(--color-muted); font-size: .78rem; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
.toast { border-radius: var(--radius-md); border: 1px solid var(--color-border); }
@media (max-width: 991.98px) {
.section-pad { padding: 58px 0; }
.logos { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sticky-note { position: relative; right: auto; bottom: auto; max-width: none; margin: .9rem .2rem 0; }
.hero-credit { left: 1.2rem; bottom: 6.3rem; }
}
.history-table {
width: 100%;
@media (max-width: 575.98px) {
h1 { font-size: clamp(2.35rem, 14vw, 3.45rem); }
.proof-grid { grid-template-columns: 1fr; }
.detail-meta { grid-template-columns: 1fr; }
.lead-form { padding: 1rem; }
.logos { grid-template-columns: 1fr; }
.card-illustration, .case-image { height: 205px; }
.hero-credit { position: static; display: inline-block; margin: .65rem .2rem 0; }
}
.history-table-time {
width: 15%;
white-space: nowrap;
font-size: 0.85em;
color: #555;
}
.history-table-user {
width: 35%;
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 8px;
}
.history-table-ai {
width: 50%;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
padding: 8px;
}
.no-messages {
text-align: center;
color: #777;
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="300" viewBox="0 0 520 300" role="img" aria-labelledby="title desc"><title id="title">Agency analytics</title><desc id="desc">Analytics cards and a quote form preview.</desc><rect width="520" height="300" rx="28" fill="#FFF3BF"/><rect x="54" y="44" width="412" height="212" rx="24" fill="#fff" stroke="#EEDC86" stroke-width="2"/><rect x="86" y="78" width="112" height="16" rx="8" fill="#2F3437"/><rect x="86" y="118" width="158" height="98" rx="18" fill="#F7F6F3" stroke="#DED8CD"/><rect x="276" y="118" width="158" height="98" rx="18" fill="#F7F6F3" stroke="#DED8CD"/><path d="M108 190l36-34 34 18 42-46" fill="none" stroke="#0F766E" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/><rect x="304" y="144" width="102" height="11" rx="6" fill="#D8D0C2"/><rect x="304" y="171" width="74" height="11" rx="6" fill="#D8D0C2"/><rect x="304" y="196" width="84" height="12" rx="6" fill="#2F3437"/></svg>

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="300" viewBox="0 0 520 300" role="img" aria-labelledby="title desc"><title id="title">Consulting pipeline</title><desc id="desc">A consulting lead pipeline with organized columns.</desc><rect width="520" height="300" rx="28" fill="#F4EEE4"/><rect x="50" y="46" width="420" height="208" rx="24" fill="#fff" stroke="#E2DACE" stroke-width="2"/><g fill="#F7F6F3" stroke="#DED8CD" stroke-width="2"><rect x="82" y="88" width="96" height="126" rx="18"/><rect x="212" y="88" width="96" height="126" rx="18"/><rect x="342" y="88" width="96" height="126" rx="18"/></g><rect x="104" y="115" width="52" height="10" rx="5" fill="#2F3437"/><rect x="104" y="145" width="48" height="38" rx="12" fill="#FFF3BF"/><rect x="234" y="115" width="52" height="10" rx="5" fill="#2F3437"/><rect x="234" y="145" width="48" height="38" rx="12" fill="#E8F3EF"/><rect x="364" y="115" width="52" height="10" rx="5" fill="#2F3437"/><rect x="364" y="145" width="48" height="38" rx="12" fill="#0F766E" opacity=".86"/><path d="M172 151h42M302 151h42" stroke="#2F3437" stroke-width="6" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="300" viewBox="0 0 520 300" role="img" aria-labelledby="title desc"><title id="title">Fintech case dashboard</title><desc id="desc">Finance dashboard with rising chart and KPI cards.</desc><rect width="520" height="300" rx="28" fill="#E8F3EF"/><rect x="54" y="44" width="412" height="212" rx="24" fill="#fff" stroke="#CFE3DC" stroke-width="2"/><rect x="86" y="78" width="132" height="16" rx="8" fill="#2F3437"/><rect x="86" y="120" width="72" height="92" rx="15" fill="#F7F6F3"/><rect x="180" y="96" width="72" height="116" rx="15" fill="#F7F6F3"/><rect x="274" y="138" width="72" height="74" rx="15" fill="#F7F6F3"/><rect x="368" y="80" width="72" height="132" rx="15" fill="#0F766E" opacity=".88"/><path d="M90 222c72-48 105-88 161-69 47 16 92 31 181-57" fill="none" stroke="#2F3437" stroke-width="8" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 892 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="320" viewBox="0 0 520 320" role="img" aria-labelledby="title desc"><title id="title">Growth board</title><desc id="desc">Analytics chart and campaign cards.</desc><rect width="520" height="320" rx="28" fill="#F7F6F3"/><rect x="56" y="54" width="408" height="212" rx="24" fill="#fff" stroke="#DED8CD" stroke-width="2"/><rect x="86" y="86" width="112" height="16" rx="8" fill="#2F3437"/><rect x="86" y="124" width="98" height="92" rx="18" fill="#F4EEE4" stroke="#E2DACE"/><rect x="210" y="124" width="98" height="92" rx="18" fill="#E8F3EF" stroke="#CFE3DC"/><rect x="334" y="124" width="98" height="92" rx="18" fill="#FFF3BF" stroke="#EEDC86"/><path d="M102 205c38-44 78-37 108-62 39-32 75 10 107-10 28-17 48-54 91-48" fill="none" stroke="#0F766E" stroke-width="10" stroke-linecap="round"/><circle cx="102" cy="205" r="9" fill="#0F766E"/><circle cx="210" cy="143" r="9" fill="#0F766E"/><circle cx="317" cy="133" r="9" fill="#0F766E"/><circle cx="408" cy="85" r="9" fill="#0F766E"/><rect x="86" y="236" width="120" height="12" rx="6" fill="#D8D0C2"/><rect x="226" y="236" width="92" height="12" rx="6" fill="#D8D0C2"/><rect x="338" y="236" width="70" height="12" rx="6" fill="#D8D0C2"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="320" viewBox="0 0 520 320" role="img" aria-labelledby="title desc"><title id="title">Landing page wireframe</title><desc id="desc">A clean website page with call to action and content cards.</desc><rect width="520" height="320" rx="28" fill="#F7F6F3"/><rect x="58" y="48" width="404" height="224" rx="24" fill="#fff" stroke="#DED8CD" stroke-width="2"/><rect x="58" y="48" width="404" height="42" rx="24" fill="#FAF9F5"/><circle cx="84" cy="69" r="6" fill="#E2624B"/><circle cx="104" cy="69" r="6" fill="#E7B85A"/><circle cx="124" cy="69" r="6" fill="#58A874"/><rect x="91" y="120" width="178" height="20" rx="10" fill="#2F3437"/><rect x="91" y="154" width="218" height="12" rx="6" fill="#D8D0C2"/><rect x="91" y="180" width="118" height="34" rx="12" fill="#2F3437"/><rect x="326" y="118" width="92" height="92" rx="22" fill="#E8F3EF" stroke="#CFE3DC"/><path d="M352 172l24-31 24 45h-64z" fill="#0F766E" opacity=".9"/><rect x="91" y="232" width="78" height="14" rx="7" fill="#D8D0C2"/><rect x="189" y="232" width="78" height="14" rx="7" fill="#D8D0C2"/><rect x="287" y="232" width="78" height="14" rx="7" fill="#D8D0C2"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="320" viewBox="0 0 520 320" role="img" aria-labelledby="title desc"><title id="title">Offer notes</title><desc id="desc">Strategy notes and highlighted offer blocks.</desc><rect width="520" height="320" rx="28" fill="#F7F6F3"/><rect x="76" y="44" width="286" height="232" rx="22" fill="#fff" stroke="#DED8CD" stroke-width="2"/><rect x="105" y="80" width="138" height="16" rx="8" fill="#2F3437"/><rect x="105" y="119" width="202" height="12" rx="6" fill="#D8D0C2"/><rect x="105" y="148" width="162" height="12" rx="6" fill="#D8D0C2"/><rect x="105" y="177" width="210" height="48" rx="14" fill="#FFF3BF" stroke="#EEDC86"/><path d="M365 93l59 59-109 109-67 8 8-67z" fill="#E8F3EF" stroke="#0F766E" stroke-width="5" stroke-linejoin="round"/><path d="M365 93l59 59" stroke="#0F766E" stroke-width="5" stroke-linecap="round"/><circle cx="403" cy="78" r="28" fill="#2F3437"/><path d="M391 79h25M404 66v25" stroke="#fff" stroke-width="6" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="720" viewBox="0 0 960 720" role="img" aria-labelledby="title desc"><title id="title">Lead pipeline workspace</title><desc id="desc">A clean workspace dashboard with lead cards, charts, and notes.</desc><rect width="960" height="720" rx="34" fill="#F7F6F3"/><rect x="64" y="58" width="832" height="604" rx="28" fill="#fff" stroke="#DCD6CB" stroke-width="2"/><rect x="64" y="58" width="832" height="64" rx="28" fill="#FAF9F5"/><circle cx="100" cy="91" r="8" fill="#E2624B"/><circle cx="126" cy="91" r="8" fill="#E7B85A"/><circle cx="152" cy="91" r="8" fill="#58A874"/><rect x="190" y="80" width="246" height="22" rx="11" fill="#EEEAE1"/><rect x="98" y="156" width="164" height="426" rx="20" fill="#F7F3EC" stroke="#E2DACE"/><rect x="125" y="187" width="86" height="12" rx="6" fill="#2F3437"/><rect x="125" y="225" width="105" height="12" rx="6" fill="#D8D0C2"/><rect x="125" y="257" width="78" height="12" rx="6" fill="#D8D0C2"/><rect x="125" y="289" width="112" height="12" rx="6" fill="#D8D0C2"/><rect x="125" y="516" width="94" height="34" rx="11" fill="#2F3437"/><rect x="298" y="156" width="518" height="126" rx="20" fill="#E8F3EF" stroke="#CFE3DC"/><rect x="328" y="187" width="156" height="18" rx="9" fill="#2F3437"/><rect x="328" y="222" width="250" height="12" rx="6" fill="#6F6A61" opacity=".62"/><rect x="328" y="246" width="184" height="12" rx="6" fill="#6F6A61" opacity=".38"/><path d="M636 244c30-74 72-69 96-20 21 43 47 42 70-18" fill="none" stroke="#0F766E" stroke-width="12" stroke-linecap="round"/><circle cx="636" cy="244" r="10" fill="#0F766E"/><circle cx="732" cy="224" r="10" fill="#0F766E"/><circle cx="802" cy="206" r="10" fill="#0F766E"/><g transform="translate(298 314)"><rect width="158" height="214" rx="19" fill="#FDFBF6" stroke="#E2DACE"/><rect x="22" y="25" width="78" height="13" rx="7" fill="#2F3437"/><rect x="22" y="60" width="112" height="44" rx="13" fill="#F4EEE4" stroke="#E2DACE"/><rect x="22" y="120" width="112" height="44" rx="13" fill="#fff" stroke="#E2DACE"/></g><g transform="translate(478 314)"><rect width="158" height="214" rx="19" fill="#FDFBF6" stroke="#E2DACE"/><rect x="22" y="25" width="93" height="13" rx="7" fill="#2F3437"/><rect x="22" y="60" width="112" height="44" rx="13" fill="#E8F3EF" stroke="#CFE3DC"/><rect x="22" y="120" width="112" height="44" rx="13" fill="#fff" stroke="#E2DACE"/></g><g transform="translate(658 314)"><rect width="158" height="214" rx="19" fill="#FDFBF6" stroke="#E2DACE"/><rect x="22" y="25" width="72" height="13" rx="7" fill="#2F3437"/><rect x="22" y="60" width="112" height="44" rx="13" fill="#FFF3BF" stroke="#EEDC86"/><rect x="22" y="120" width="112" height="44" rx="13" fill="#fff" stroke="#E2DACE"/></g><rect x="546" y="560" width="270" height="54" rx="18" fill="#2F3437"/><rect x="577" y="580" width="118" height="14" rx="7" fill="#fff" opacity=".92"/><rect x="716" y="580" width="58" height="14" rx="7" fill="#fff" opacity=".32"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -1,39 +1,43 @@
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;
const toastEl = document.getElementById('siteToast');
const showToast = (message) => {
if (!toastEl || !window.bootstrap) return;
const body = toastEl.querySelector('.toast-body');
if (body) body.textContent = message;
window.bootstrap.Toast.getOrCreateInstance(toastEl, { delay: 3600 }).show();
};
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 })
document.querySelectorAll('a[href^="#"]').forEach((link) => {
link.addEventListener('click', (event) => {
const target = document.querySelector(link.getAttribute('href'));
if (!target) return;
event.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
history.pushState(null, '', link.getAttribute('href'));
});
});
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');
document.querySelectorAll('.needs-validation').forEach((form) => {
form.addEventListener('submit', (event) => {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
showToast('Please review the highlighted fields before submitting.');
}
form.classList.add('was-validated');
});
});
if (window.location.hash === '#quote') {
const firstInvalid = document.querySelector('.is-invalid');
if (firstInvalid) {
firstInvalid.focus({ preventScroll: true });
showToast('A few fields need attention.');
}
}
const url = new URL(window.location.href);
if (url.searchParams.get('saved') === '1') {
showToast('Lead updated.');
}
});
});

4
healthz.php Normal file
View File

@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'time' => gmdate('c'), 'php' => PHP_VERSION], JSON_UNESCAPED_SLASHES);

164
includes/leads.php Normal file
View File

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../db/config.php';
function e(?string $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function ensure_leads_table(): void
{
$sql = "CREATE TABLE IF NOT EXISTS agency_leads (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
email VARCHAR(190) NOT NULL,
phone VARCHAR(60) DEFAULT NULL,
company VARCHAR(140) DEFAULT NULL,
service VARCHAR(120) NOT NULL,
budget VARCHAR(80) DEFAULT NULL,
timeline VARCHAR(80) DEFAULT NULL,
message TEXT NOT NULL,
status VARCHAR(40) NOT NULL DEFAULT 'new',
admin_notes TEXT DEFAULT NULL,
source VARCHAR(120) DEFAULT 'website',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status_created (status, created_at),
INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
db()->exec($sql);
}
function lead_statuses(): array
{
return [
'new' => 'New',
'reviewing' => 'Reviewing',
'contacted' => 'Contacted',
'proposal' => 'Proposal sent',
'won' => 'Won',
'lost' => 'Lost',
];
}
function sanitize_text(string $value, int $maxLength): string
{
$value = trim(preg_replace('/\s+/', ' ', $value) ?? '');
if (strlen($value) > $maxLength) {
$value = substr($value, 0, $maxLength);
}
return $value;
}
function validate_lead_payload(array $input): array
{
$data = [
'name' => sanitize_text((string)($input['name'] ?? ''), 120),
'email' => sanitize_text((string)($input['email'] ?? ''), 190),
'phone' => sanitize_text((string)($input['phone'] ?? ''), 60),
'company' => sanitize_text((string)($input['company'] ?? ''), 140),
'service' => sanitize_text((string)($input['service'] ?? ''), 120),
'budget' => sanitize_text((string)($input['budget'] ?? ''), 80),
'timeline' => sanitize_text((string)($input['timeline'] ?? ''), 80),
'message' => trim((string)($input['message'] ?? '')),
'source' => sanitize_text((string)($input['source'] ?? 'website'), 120),
];
if (strlen($data['message']) > 3000) {
$data['message'] = substr($data['message'], 0, 3000);
}
$errors = [];
if ($data['name'] === '') {
$errors['name'] = 'Please enter your name.';
}
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Please enter a valid email address.';
}
if ($data['service'] === '') {
$errors['service'] = 'Please choose a service.';
}
if (strlen($data['message']) < 20) {
$errors['message'] = 'Please share at least 20 characters about the project.';
}
return [$data, $errors];
}
function create_lead(array $data): int
{
ensure_leads_table();
$stmt = db()->prepare('INSERT INTO agency_leads (name, email, phone, company, service, budget, timeline, message, source) VALUES (:name, :email, :phone, :company, :service, :budget, :timeline, :message, :source)');
$stmt->execute([
':name' => $data['name'],
':email' => $data['email'],
':phone' => $data['phone'] !== '' ? $data['phone'] : null,
':company' => $data['company'] !== '' ? $data['company'] : null,
':service' => $data['service'],
':budget' => $data['budget'] !== '' ? $data['budget'] : null,
':timeline' => $data['timeline'] !== '' ? $data['timeline'] : null,
':message' => $data['message'],
':source' => $data['source'],
]);
return (int)db()->lastInsertId();
}
function list_leads(?string $status = null, int $limit = 100): array
{
ensure_leads_table();
$statuses = lead_statuses();
if ($status && isset($statuses[$status])) {
$stmt = db()->prepare('SELECT * FROM agency_leads WHERE status = :status ORDER BY created_at DESC LIMIT :limit');
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
$stmt = db()->prepare('SELECT * FROM agency_leads ORDER BY created_at DESC LIMIT :limit');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
function get_lead(int $id): ?array
{
ensure_leads_table();
$stmt = db()->prepare('SELECT * FROM agency_leads WHERE id = :id LIMIT 1');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$lead = $stmt->fetch();
return $lead ?: null;
}
function update_lead(int $id, string $status, string $notes): void
{
$statuses = lead_statuses();
if (!isset($statuses[$status])) {
$status = 'new';
}
$notes = trim($notes);
if (strlen($notes) > 3000) {
$notes = substr($notes, 0, 3000);
}
ensure_leads_table();
$stmt = db()->prepare('UPDATE agency_leads SET status = :status, admin_notes = :notes WHERE id = :id');
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':notes', $notes !== '' ? $notes : null, $notes !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
}
function lead_counts(): array
{
ensure_leads_table();
$counts = array_fill_keys(array_keys(lead_statuses()), 0);
$stmt = db()->query('SELECT status, COUNT(*) AS total FROM agency_leads GROUP BY status');
foreach ($stmt->fetchAll() as $row) {
if (isset($counts[$row['status']])) {
$counts[$row['status']] = (int)$row['total'];
}
}
return $counts;
}

415
index.php
View File

@ -1,150 +1,315 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
session_start();
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
if (empty($_SESSION['lead_csrf'])) {
$_SESSION['lead_csrf'] = bin2hex(random_bytes(32));
}
$projectName = $_SERVER['PROJECT_NAME'] ?? 'Northline Studio';
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'A clean, conversion-focused agency website for strategy, design, and growth teams.';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$errors = $_SESSION['lead_errors'] ?? [];
$old = $_SESSION['lead_old'] ?? [];
$flash = $_SESSION['lead_flash'] ?? null;
$brandInitial = strtoupper(substr(trim((string)$projectName), 0, 1) ?: 'N');
unset($_SESSION['lead_errors'], $_SESSION['lead_old'], $_SESSION['lead_flash']);
function h(?string $value): string { return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function old_value(array $old, string $key): string { return h((string)($old[$key] ?? '')); }
$heroPhoto = [
'src' => 'assets/images/landing/hero-agency-workspace.jpg',
'alt' => 'Finance agency team reviewing charts and laptops in a bright office workspace',
'credit' => 'www.kaboompics.com',
'url' => 'https://www.pexels.com/@karola-g',
];
$services = [
['01', 'Positioning & offer', 'Clarify your audience, promise, service packages, and proof so visitors understand why you are the safe choice.', 'assets/images/landing/service-positioning-workshop.jpg', 'Business team organizing strategy notes during a positioning workshop', 'Jakub Zerdzicki', 'https://www.pexels.com/@jakubzerdzicki'],
['02', 'Lead-gen websites', 'Fast, accessible landing pages with SEO foundations, strong CTAs, and high-intent quote forms.', 'assets/images/landing/service-leadgen-website.jpg', 'Laptop workspace used for web design and lead generation planning', 'Negative Space', 'https://www.pexels.com/@negativespace'],
['03', 'Campaign systems', 'Paid search, nurture copy, CRM handoff, and analytics dashboards that keep sales teams focused.', 'assets/images/landing/service-campaign-dashboard.jpg', 'Marketing analytics dashboard open on a laptop for campaign reporting', 'Lukas Blazek', 'https://www.pexels.com/@goumbik'],
];
$caseStudies = [
['+68%', 'SaaS demo requests', 'Rebuilt the offer hierarchy and quote path for a workflow automation platform.', 'assets/images/landing/case-fintech-dashboard.jpg', 'Fintech analytics dashboard reviewed on a laptop for growth reporting', 'Jakub Zerdzicki', 'https://www.pexels.com/@jakubzerdzicki'],
['3.1x', 'Qualified pipeline', 'Launched a focused paid search landing system for a cybersecurity consultancy.', 'assets/images/landing/case-consulting-meeting.jpg', 'Consulting team meeting with notebooks and laptops around a table', 'Vitaly Gariev', 'https://www.pexels.com/@silverkblack'],
['-37%', 'Cost per lead', 'Refined service pages, proof blocks, and form routing for a regional agency.', 'assets/images/landing/case-agency-team.jpg', 'Agency team collaborating during a planning meeting in a modern office', 'Ivan S', 'https://www.pexels.com/@ivan-s'],
];
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($projectName) ?> | Agency Strategy, Design & Growth</title>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<meta name="description" content="<?= h($projectDescription) ?>">
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<meta property="og:description" content="<?= h($projectDescription) ?>">
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
<meta name="author" content="<?= h($projectName) ?>">
<meta property="og:title" content="<?= h($projectName) ?> | Agency Strategy, Design & Growth">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=2026052903">
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header sticky-top">
<nav class="navbar navbar-expand-lg" aria-label="Primary navigation">
<div class="container">
<a class="navbar-brand" href="/" aria-label="<?= h($projectName) ?> home">
<span class="brand-mark" aria-hidden="true"><?= h($brandInitial) ?></span>
<span><?= h($projectName) ?></span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-1">
<li class="nav-item"><a class="nav-link" href="#services">Services</a></li>
<li class="nav-item"><a class="nav-link" href="#case-studies">Case studies</a></li>
<li class="nav-item"><a class="nav-link" href="#testimonials">Testimonials</a></li>
<li class="nav-item"><a class="nav-link" href="admin.php">Admin</a></li>
<li class="nav-item"><a class="btn btn-dark btn-sm ms-lg-2" href="#quote">Request quote</a></li>
</ul>
</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>
</nav>
</header>
<main id="main">
<?php if ($flash): ?>
<div class="container pt-3">
<div class="alert alert-<?= h($flash['type'] ?? 'info') ?> alert-dismissible fade show" role="alert">
<?= h($flash['message'] ?? '') ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
<?php endif; ?>
<section class="hero section-pad">
<div class="container">
<div class="row align-items-center g-4 g-xl-5">
<div class="col-lg-6">
<p class="eyebrow"><span class="emoji" aria-hidden="true"></span> Strategy-led digital agency</p>
<h1>Turn your offer into a clean lead engine.</h1>
<p class="hero-copy">We design fast, editorial landing pages and quote flows that help service teams explain value, earn trust, and convert better-fit prospects.</p>
<div class="d-flex flex-column flex-sm-row gap-2 mt-4">
<a href="#quote" class="btn btn-dark btn-lg">Get a quote</a>
<a href="#case-studies" class="btn btn-outline-dark btn-lg">View results</a>
</div>
<dl class="proof-grid mt-4" aria-label="Agency performance highlights">
<div><dt>42%</dt><dd>avg. lead quality lift</dd></div>
<div><dt>21d</dt><dd>typical launch sprint</dd></div>
<div><dt>96</dt><dd>client NPS</dd></div>
</dl>
</div>
<div class="col-lg-6">
<figure class="hero-visual" aria-label="Lead generation workspace preview">
<img src="<?= h($heroPhoto['src']) ?>" width="1880" height="1253" alt="<?= h($heroPhoto['alt']) ?>" fetchpriority="high">
<p class="photo-credit hero-credit">Photo: <a href="<?= h($heroPhoto['url']) ?>" target="_blank" rel="noopener"><?= h($heroPhoto['credit']) ?></a></p>
<figcaption class="sticky-note">
<span class="note-pin" aria-hidden="true"></span>
Free 48-hour funnel snapshot included with every quote request.
</figcaption>
</figure>
</div>
</div>
</div>
</section>
<section class="logo-strip" aria-label="Partner logos">
<div class="container">
<p class="small-label">Trusted by growing teams and platform partners</p>
<div class="logos">
<span>Vectorly</span><span>SummitOps</span><span>Bright CRM</span><span>Northstar</span><span>Ledgerly</span>
</div>
</div>
</section>
<section class="section-pad" id="services">
<div class="container">
<div class="section-heading">
<p class="eyebrow"><span class="emoji" aria-hidden="true"></span> Services</p>
<h2>Clean execution across the lead journey.</h2>
<p>Pick one sprint or combine services into a complete conversion program.</p>
</div>
<div class="row g-3">
<?php foreach ($services as $service): ?>
<div class="col-md-4">
<article class="service-card h-100">
<img class="card-illustration" src="<?= h($service[3]) ?>" width="940" height="627" alt="<?= h($service[4]) ?>" loading="lazy">
<p class="photo-credit">Photo: <a href="<?= h($service[6]) ?>" target="_blank" rel="noopener"><?= h($service[5]) ?></a></p>
<span><?= h($service[0]) ?></span>
<h3><?= h($service[1]) ?></h3>
<p><?= h($service[2]) ?></p>
</article>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
<section class="section-pad muted-section" id="case-studies">
<div class="container">
<div class="section-heading">
<p class="eyebrow"><span class="emoji" aria-hidden="true"></span> Case studies</p>
<h2>Recent work with practical outcomes.</h2>
<p>Short, focused builds with measurable pipeline impact.</p>
</div>
<div class="row g-3">
<?php foreach ($caseStudies as $case): ?>
<div class="col-lg-4">
<article class="case-card h-100">
<img class="case-image" src="<?= h($case[3]) ?>" width="940" height="627" alt="<?= h($case[4]) ?>" loading="lazy">
<p class="photo-credit">Photo: <a href="<?= h($case[6]) ?>" target="_blank" rel="noopener"><?= h($case[5]) ?></a></p>
<p class="metric"><?= h($case[0]) ?></p>
<h3><?= h($case[1]) ?></h3>
<p><?= h($case[2]) ?></p>
</article>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
<section class="section-pad" id="testimonials">
<div class="container">
<div class="row g-3 align-items-stretch">
<div class="col-lg-5">
<div class="section-heading compact">
<p class="eyebrow"><span class="emoji" aria-hidden="true"></span> Testimonials</p>
<h2>Trusted when clarity and speed matter.</h2>
<p>Senior teams choose us when they need a lean partner that can ship and measure quickly.</p>
</div>
</div>
<div class="col-lg-7">
<div class="row g-3">
<div class="col-md-6">
<blockquote class="quote-card">
<div class="quote-avatar" aria-hidden="true">MR</div>
“They turned a vague service story into a page our sales team actually uses.
<footer> Maya R., VP Marketing</footer>
</blockquote>
</div>
<div class="col-md-6">
<blockquote class="quote-card">
<div class="quote-avatar" aria-hidden="true">DK</div>
“The lead quality improved because the site finally explained who we were for.
<footer> Daniel K., Founder</footer>
</blockquote>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="section-pad quote-section" id="quote">
<div class="container">
<div class="row g-4 g-xl-5">
<div class="col-lg-5">
<p class="eyebrow"><span class="emoji" aria-hidden="true"></span> Request a quote</p>
<h2>Tell us what you want to improve.</h2>
<p class="section-copy">Your submission is saved to the admin dashboard for follow-up. If MAIL_TO is configured, the site also attempts an email notification.</p>
<div class="notice-box" role="note">Testing notice: configure your own SMTP and MAIL_TO values for reliable production email delivery.</div>
</div>
<div class="col-lg-7">
<form action="quote.php" method="post" class="lead-form needs-validation" novalidate>
<input type="hidden" name="csrf" value="<?= h($_SESSION['lead_csrf']) ?>">
<input type="hidden" name="source" value="homepage_quote_form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="name">Name *</label>
<input class="form-control <?= isset($errors['name']) ? 'is-invalid' : '' ?>" id="name" name="name" value="<?= old_value($old, 'name') ?>" autocomplete="name" required>
<div class="invalid-feedback"><?= h($errors['name'] ?? 'Please enter your name.') ?></div>
</div>
<div class="col-md-6">
<label class="form-label" for="email">Work email *</label>
<input type="email" class="form-control <?= isset($errors['email']) ? 'is-invalid' : '' ?>" id="email" name="email" value="<?= old_value($old, 'email') ?>" autocomplete="email" required>
<div class="invalid-feedback"><?= h($errors['email'] ?? 'Please enter a valid email.') ?></div>
</div>
<div class="col-md-6">
<label class="form-label" for="company">Company</label>
<input class="form-control" id="company" name="company" value="<?= old_value($old, 'company') ?>" autocomplete="organization">
</div>
<div class="col-md-6">
<label class="form-label" for="phone">Phone</label>
<input class="form-control" id="phone" name="phone" value="<?= old_value($old, 'phone') ?>" autocomplete="tel">
</div>
<div class="col-md-6">
<label class="form-label" for="service">Service *</label>
<select class="form-select <?= isset($errors['service']) ? 'is-invalid' : '' ?>" id="service" name="service" required>
<option value="">Choose one</option>
<?php foreach (['Positioning & offer', 'Lead-gen website', 'Campaign system', 'Full growth sprint'] as $option): ?>
<option value="<?= h($option) ?>" <?= (($old['service'] ?? '') === $option) ? 'selected' : '' ?>><?= h($option) ?></option>
<?php endforeach; ?>
</select>
<div class="invalid-feedback"><?= h($errors['service'] ?? 'Please choose a service.') ?></div>
</div>
<div class="col-md-3">
<label class="form-label" for="budget">Budget</label>
<select class="form-select" id="budget" name="budget">
<option value="">Not sure</option>
<?php foreach (['Under $5k', '$5k$15k', '$15k$30k', '$30k+'] as $option): ?>
<option value="<?= h($option) ?>" <?= (($old['budget'] ?? '') === $option) ? 'selected' : '' ?>><?= h($option) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="timeline">Timeline</label>
<select class="form-select" id="timeline" name="timeline">
<option value="">Flexible</option>
<?php foreach (['ASAP', 'This month', 'This quarter', 'Planning ahead'] as $option): ?>
<option value="<?= h($option) ?>" <?= (($old['timeline'] ?? '') === $option) ? 'selected' : '' ?>><?= h($option) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<label class="form-label" for="message">Project notes *</label>
<textarea class="form-control <?= isset($errors['message']) ? 'is-invalid' : '' ?>" id="message" name="message" rows="5" minlength="20" required placeholder="What are you trying to improve? What should happen after launch?"><?= old_value($old, 'message') ?></textarea>
<div class="invalid-feedback"><?= h($errors['message'] ?? 'Please share at least 20 characters.') ?></div>
</div>
<div class="col-12 d-flex flex-column flex-sm-row align-items-sm-center gap-3">
<button class="btn btn-dark btn-lg" type="submit">Submit quote request</button>
<p class="form-note mb-0">No spam. We reply with next steps, not a newsletter.</p>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
<footer class="site-footer">
<div class="container d-flex flex-column flex-md-row justify-content-between gap-2">
<p class="mb-0">© <?= date('Y') ?> <?= h($projectName) ?>. Built for clear conversion.</p>
<p class="mb-0"><a href="admin.php">Admin dashboard</a> · <a href="#quote">Request quote</a></p>
</div>
</footer>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="siteToast" class="toast" role="status" aria-live="polite" aria-atomic="true">
<div class="toast-header"><strong class="me-auto">Notice</strong><button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button></div>
<div class="toast-body">Ready.</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=2026052901" defer></script>
</body>
</html>

53
quote.php Normal file
View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
session_start();
require_once __DIR__ . '/includes/leads.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /#quote');
exit;
}
$token = (string)($_POST['csrf'] ?? '');
if ($token === '' || empty($_SESSION['lead_csrf']) || !hash_equals((string)$_SESSION['lead_csrf'], $token)) {
$_SESSION['lead_flash'] = ['type' => 'danger', 'message' => 'The form expired. Please try again.'];
header('Location: /#quote');
exit;
}
[$data, $errors] = validate_lead_payload($_POST);
if ($errors) {
$_SESSION['lead_errors'] = $errors;
$_SESSION['lead_old'] = $data;
header('Location: /#quote');
exit;
}
try {
$leadId = create_lead($data);
$_SESSION['lead_csrf'] = bin2hex(random_bytes(32));
$mailStatus = 'not_configured';
$mailPath = __DIR__ . '/mail/MailService.php';
if (is_file($mailPath)) {
require_once $mailPath;
$emailBody = "Service: {$data['service']}\nBudget: {$data['budget']}\nTimeline: {$data['timeline']}\nCompany: {$data['company']}\nPhone: {$data['phone']}\n\n{$data['message']}\n\nLead ID: {$leadId}";
$res = MailService::sendContactMessage($data['name'], $data['email'], $emailBody, null, 'New agency quote request');
$mailStatus = !empty($res['success']) ? 'sent' : 'not_configured';
if (empty($res['success'])) {
error_log('Lead email notification skipped/failed: ' . ($res['error'] ?? 'unknown'));
}
}
$_SESSION['last_lead_id'] = $leadId;
$_SESSION['last_mail_status'] = $mailStatus;
header('Location: thank-you.php?id=' . $leadId);
exit;
} catch (Throwable $e) {
error_log('Lead save failed: ' . $e->getMessage());
$_SESSION['lead_old'] = $data;
$_SESSION['lead_flash'] = ['type' => 'danger', 'message' => 'We could not save your request yet. Please try again in a moment.'];
header('Location: /#quote');
exit;
}

54
thank-you.php Normal file
View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
session_start();
$projectName = $_SERVER['PROJECT_NAME'] ?? 'Northline Studio';
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Thanks for requesting a quote. Your request has been received.';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$leadId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$sessionLeadId = (int)($_SESSION['last_lead_id'] ?? 0);
$mailStatus = (string)($_SESSION['last_mail_status'] ?? 'not_configured');
function h(?string $value): string { return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quote Request Received | <?= h($projectName) ?></title>
<?php if ($projectDescription): ?>
<meta name="description" content="<?= h($projectDescription) ?>">
<meta property="og:description" content="<?= h($projectDescription) ?>">
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
<?php endif; ?>
<meta name="robots" content="noindex, nofollow">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=2026052901">
</head>
<body>
<main class="admin-shell d-flex align-items-center">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-7">
<section class="admin-card p-4 p-md-5 text-center">
<p class="eyebrow">Request received</p>
<h1 class="h2">Thanks your quote request is in the dashboard.</h1>
<p class="text-secondary mt-3">Well review the details and follow up with next steps. Your reference is <strong>#<?= h((string)($leadId ?: $sessionLeadId)) ?></strong>.</p>
<?php if ($mailStatus !== 'sent'): ?>
<div class="notice-box mt-3 text-start">Testing notice: the request was saved, but email delivery depends on MAIL_TO and SMTP settings. Flatlogic does not guarantee usage of the default mail server; configure your own SMTP in environment variables for production.</div>
<?php endif; ?>
<div class="d-flex flex-column flex-sm-row justify-content-center gap-2 mt-4">
<a class="btn btn-dark" href="/">Back to homepage</a>
<a class="btn btn-outline-dark" href="admin.php">Open admin dashboard</a>
</div>
</section>
</div>
</div>
</div>
</main>
</body>
</html>