Compare commits

..

2 Commits

Author SHA1 Message Date
Flatlogic Bot
8141b27040 Auto commit: 2026-04-21T09:45:25.562Z 2026-04-21 09:45:25 +00:00
Flatlogic Bot
d5e1fcddeb Auto commit: 2026-04-21T09:42:14.485Z 2026-04-21 09:42:14 +00:00
9 changed files with 1420 additions and 544 deletions

551
app.php Normal file
View File

@ -0,0 +1,551 @@
<?php
declare(strict_types=1);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once __DIR__ . '/db/config.php';
function app_env(string $key, string $default = ''): string
{
$serverValue = $_SERVER[$key] ?? null;
if (is_string($serverValue) && $serverValue !== '') {
return $serverValue;
}
$envValue = getenv($key);
if (is_string($envValue) && $envValue !== '') {
return $envValue;
}
return $default;
}
function project_name(): string
{
return app_env('PROJECT_NAME', 'Fast LAMP Desk');
}
function project_description(): string
{
return app_env('PROJECT_DESCRIPTION', 'Fast intake workflow with a simple admin dashboard for incoming project requests.');
}
function e(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function app_strlen(string $value): int
{
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
}
function app_substr(string $value, int $start, int $length): string
{
return function_exists('mb_substr') ? mb_substr($value, $start, $length) : substr($value, $start, $length);
}
function ensure_schema(): void
{
static $done = false;
if ($done) {
return;
}
$sqlPath = __DIR__ . '/db/migrations/001_project_requests.sql';
$sql = file_get_contents($sqlPath);
if ($sql === false) {
throw new RuntimeException('Unable to load schema migration.');
}
db()->exec($sql);
$done = true;
}
function admin_email(): string
{
return app_env('ADMIN_EMAIL', 'admin@local.test');
}
function admin_password(): string
{
return app_env('ADMIN_PASSWORD', 'admin12345');
}
function using_default_admin_credentials(): bool
{
return app_env('ADMIN_EMAIL', '') === '' || app_env('ADMIN_PASSWORD', '') === '';
}
function attempt_admin_login(string $email, string $password): bool
{
if (!hash_equals(strtolower(admin_email()), strtolower(trim($email)))) {
return false;
}
if (!hash_equals(admin_password(), $password)) {
return false;
}
$_SESSION['admin_logged_in'] = true;
$_SESSION['admin_email'] = admin_email();
return true;
}
function logout_admin(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
}
session_destroy();
}
function is_admin_logged_in(): bool
{
return !empty($_SESSION['admin_logged_in']);
}
function require_admin(): void
{
if (!is_admin_logged_in()) {
set_flash('warning', 'Please sign in to access the dashboard.');
header('Location: /login.php');
exit;
}
}
function set_flash(string $type, string $message): void
{
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
function get_flash(): ?array
{
if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) {
return null;
}
$flash = $_SESSION['flash'];
unset($_SESSION['flash']);
return $flash;
}
function request_status_options(): array
{
return [
'new' => 'New',
'reviewed' => 'Reviewed',
'qualified' => 'Qualified',
'closed' => 'Closed',
];
}
function request_type_options(): array
{
return [
'Internal tool' => 'Internal tool',
'Admin dashboard' => 'Admin dashboard',
'Client portal' => 'Client portal',
'Marketing site' => 'Marketing site',
'Other MVP' => 'Other MVP',
];
}
function timeline_options(): array
{
return [
'ASAP' => 'ASAP',
'2-4 weeks' => '24 weeks',
'1-2 months' => '12 months',
'Flexible' => 'Flexible',
];
}
function budget_options(): array
{
return [
'<5k' => 'Under $5k',
'5k-15k' => '$5k$15k',
'15k-50k' => '$15k$50k',
'50k+' => '$50k+',
'Unknown' => 'Still defining',
];
}
function validate_request_input(array $input): array
{
$errors = [];
$name = trim((string) ($input['name'] ?? ''));
$email = trim((string) ($input['email'] ?? ''));
$company = trim((string) ($input['company'] ?? ''));
$projectType = trim((string) ($input['project_type'] ?? ''));
$timeline = trim((string) ($input['timeline'] ?? ''));
$budget = trim((string) ($input['budget'] ?? ''));
$details = trim((string) ($input['details'] ?? ''));
if ($name === '' || app_strlen($name) < 2) {
$errors['name'] = 'Enter a contact name.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Enter a valid work email.';
}
if ($projectType === '' || !array_key_exists($projectType, request_type_options())) {
$errors['project_type'] = 'Choose the type of MVP you need.';
}
if ($timeline === '' || !array_key_exists($timeline, timeline_options())) {
$errors['timeline'] = 'Choose a timeline.';
}
if ($budget === '' || !array_key_exists($budget, budget_options())) {
$errors['budget'] = 'Choose a budget range.';
}
if ($details === '' || app_strlen($details) < 20) {
$errors['details'] = 'Share at least a short brief (20+ characters).';
}
return [
'errors' => $errors,
'data' => [
'name' => app_substr($name, 0, 120),
'email' => app_substr($email, 0, 190),
'company' => app_substr($company, 0, 160),
'project_type' => $projectType,
'timeline' => $timeline,
'budget' => $budget,
'details' => app_substr($details, 0, 4000),
],
];
}
function create_request(array $input): int
{
ensure_schema();
$validated = validate_request_input($input);
if ($validated['errors']) {
throw new InvalidArgumentException('Request data is invalid.');
}
$data = $validated['data'];
$stmt = db()->prepare('INSERT INTO project_requests (name, email, company, project_type, timeline, budget, details, status, created_at) VALUES (:name, :email, :company, :project_type, :timeline, :budget, :details, :status, UTC_TIMESTAMP())');
$stmt->bindValue(':name', $data['name']);
$stmt->bindValue(':email', $data['email']);
$stmt->bindValue(':company', $data['company'] === '' ? null : $data['company'], $data['company'] === '' ? PDO::PARAM_NULL : PDO::PARAM_STR);
$stmt->bindValue(':project_type', $data['project_type']);
$stmt->bindValue(':timeline', $data['timeline']);
$stmt->bindValue(':budget', $data['budget']);
$stmt->bindValue(':details', $data['details']);
$stmt->bindValue(':status', 'new');
$stmt->execute();
return (int) db()->lastInsertId();
}
function normalize_request_status(?string $status): ?string
{
$status = trim((string) $status);
if ($status === '' || !array_key_exists($status, request_status_options())) {
return null;
}
return $status;
}
function normalize_request_search(string $search): string
{
$search = preg_replace('/\s+/', ' ', trim($search));
return app_substr((string) $search, 0, 120);
}
function request_search_reference_id(string $search): ?int
{
$search = strtoupper(trim($search));
if ($search === '') {
return null;
}
if (preg_match('/^REQ-(\d{1,10})$/', $search, $matches) === 1) {
return (int) $matches[1];
}
if (ctype_digit($search)) {
return (int) $search;
}
return null;
}
function build_request_filters(?string $status = null, string $search = ''): array
{
$where = [];
$bindings = [];
$status = normalize_request_status($status);
if ($status !== null) {
$where[] = 'status = :status';
$bindings[':status'] = ['value' => $status, 'type' => PDO::PARAM_STR];
}
$search = normalize_request_search($search);
if ($search !== '') {
$searchConditions = [
'name LIKE :search',
'email LIKE :search',
'company LIKE :search',
'project_type LIKE :search',
'details LIKE :search',
];
$bindings[':search'] = ['value' => '%' . $search . '%', 'type' => PDO::PARAM_STR];
$referenceId = request_search_reference_id($search);
if ($referenceId !== null) {
$searchConditions[] = 'id = :search_id';
$bindings[':search_id'] = ['value' => $referenceId, 'type' => PDO::PARAM_INT];
}
$where[] = '(' . implode(' OR ', $searchConditions) . ')';
}
return [
'where_sql' => $where ? ' WHERE ' . implode(' AND ', $where) : '',
'bindings' => $bindings,
];
}
function bind_request_query_values(PDOStatement $stmt, array $bindings): void
{
foreach ($bindings as $placeholder => $binding) {
$stmt->bindValue($placeholder, $binding['value'], $binding['type']);
}
}
function fetch_requests(?string $status = null, string $search = '', int $limit = 0, int $offset = 0): array
{
ensure_schema();
$filters = build_request_filters($status, $search);
$sql = 'SELECT * FROM project_requests' . $filters['where_sql'] . ' ORDER BY created_at DESC, id DESC';
if ($limit > 0) {
$sql .= ' LIMIT :limit OFFSET :offset';
}
$stmt = db()->prepare($sql);
bind_request_query_values($stmt, $filters['bindings']);
if ($limit > 0) {
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', max(0, $offset), PDO::PARAM_INT);
}
$stmt->execute();
return $stmt->fetchAll();
}
function count_requests(?string $status = null, string $search = ''): int
{
ensure_schema();
$filters = build_request_filters($status, $search);
$stmt = db()->prepare('SELECT COUNT(*) FROM project_requests' . $filters['where_sql']);
bind_request_query_values($stmt, $filters['bindings']);
$stmt->execute();
return (int) $stmt->fetchColumn();
}
function fetch_request(int $id): ?array
{
ensure_schema();
$stmt = db()->prepare('SELECT * FROM project_requests WHERE id = :id LIMIT 1');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$request = $stmt->fetch();
return $request ?: null;
}
function update_request_status(int $id, string $status): bool
{
ensure_schema();
if (!array_key_exists($status, request_status_options())) {
return false;
}
$stmt = db()->prepare('UPDATE project_requests SET status = :status WHERE id = :id');
$stmt->bindValue(':status', $status);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
return $stmt->execute();
}
function dashboard_metrics(): array
{
ensure_schema();
$rows = db()->query('SELECT status, COUNT(*) AS count_rows FROM project_requests GROUP BY status')->fetchAll();
$metrics = [
'total' => 0,
'new' => 0,
'reviewed' => 0,
'qualified' => 0,
'closed' => 0,
];
foreach ($rows as $row) {
$status = (string) $row['status'];
$count = (int) $row['count_rows'];
$metrics['total'] += $count;
if (array_key_exists($status, $metrics)) {
$metrics[$status] = $count;
}
}
return $metrics;
}
function request_reference(int $id): string
{
return 'REQ-' . str_pad((string) $id, 4, '0', STR_PAD_LEFT);
}
function format_datetime(string $value): string
{
try {
$date = new DateTimeImmutable($value, new DateTimeZone('UTC'));
return $date->format('M j, Y \a\t H:i') . ' UTC';
} catch (Throwable $e) {
return $value;
}
}
function status_badge_class(string $status): string
{
return match ($status) {
'new' => 'text-bg-primary',
'reviewed' => 'text-bg-secondary',
'qualified' => 'text-bg-success',
'closed' => 'text-bg-dark',
default => 'text-bg-light',
};
}
function active_nav(string $active, string $value): string
{
return $active === $value ? 'active' : '';
}
function render_head(string $title, string $pageDescription = '', bool $noindex = false): void
{
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? getenv('PROJECT_IMAGE_URL') ?: '';
$description = $pageDescription !== '' ? $pageDescription : ($projectDescription ?: project_description());
$assetVersion = (string) (filemtime(__DIR__ . '/assets/css/custom.css') ?: time());
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><?= e($title) ?></title>
<meta name="description" content="<?= e($description) ?>" />
<?php if ($projectDescription): ?>
<meta property="og:description" content="<?= e($projectDescription) ?>" />
<meta property="twitter:description" content="<?= e($projectDescription) ?>" />
<?php else: ?>
<meta property="og:description" content="<?= e($description) ?>" />
<meta property="twitter:description" content="<?= e($description) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= e($projectImageUrl) ?>" />
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>" />
<?php endif; ?>
<?php if ($noindex): ?>
<meta name="robots" content="noindex, nofollow" />
<?php endif; ?>
<meta name="theme-color" content="#111827" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= e($assetVersion) ?>">
</head>
<body>
<?php
}
function render_navbar(string $active = 'home'): void
{
?>
<nav class="navbar navbar-expand-lg border-bottom bg-white sticky-top app-navbar">
<div class="container">
<a class="navbar-brand fw-semibold" href="/">
<span class="brand-mark">FL</span>
<?= e(project_name()) ?>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#primaryNav" aria-controls="primaryNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="primaryNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link <?= active_nav($active, 'home') ?>" href="/">Home</a></li>
<?php if (is_admin_logged_in()): ?>
<li class="nav-item"><a class="nav-link <?= active_nav($active, 'dashboard') ?>" href="/dashboard.php">Dashboard</a></li>
<li class="nav-item"><a class="nav-link <?= active_nav($active, 'detail') ?>" href="/dashboard.php#requests">Requests</a></li>
<li class="nav-item ms-lg-2"><a class="btn btn-dark btn-sm px-3" href="/logout.php">Log out</a></li>
<?php else: ?>
<li class="nav-item"><a class="nav-link <?= active_nav($active, 'login') ?>" href="/login.php">Admin login</a></li>
<li class="nav-item ms-lg-2"><a class="btn btn-dark btn-sm px-3" href="/#request-form">New request</a></li>
<?php endif; ?>
</ul>
</div>
</div>
</nav>
<?php
}
function render_flash_toast(): void
{
$flash = get_flash();
if (!$flash) {
return;
}
$type = $flash['type'] ?? 'info';
$map = [
'success' => 'text-bg-success',
'danger' => 'text-bg-danger',
'warning' => 'text-bg-warning',
'info' => 'text-bg-primary',
];
$class = $map[$type] ?? 'text-bg-primary';
?>
<div class="toast-container position-fixed top-0 end-0 p-3">
<div class="toast show align-items-center border-0 <?= e($class) ?>" role="status" aria-live="polite" aria-atomic="true" data-autohide="true">
<div class="d-flex">
<div class="toast-body"><?= e((string) ($flash['message'] ?? 'Done.')) ?></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
<?php
}
function render_footer(string $active = ''): void
{
$assetVersion = (string) (filemtime(__DIR__ . '/assets/js/main.js') ?: time());
?>
<footer class="border-top bg-white py-4 mt-5">
<div class="container d-flex flex-column flex-lg-row justify-content-between gap-2 small text-secondary">
<div><?= e(project_name()) ?> · fast intake workflow</div>
<div class="d-flex gap-3">
<a href="/" class="link-secondary text-decoration-none">Home</a>
<?php if (is_admin_logged_in()): ?>
<a href="/dashboard.php" class="link-secondary text-decoration-none">Dashboard</a>
<?php else: ?>
<a href="/login.php" class="link-secondary text-decoration-none">Admin login</a>
<?php endif; ?>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="/assets/js/main.js?v=<?= e($assetVersion) ?>" defer></script>
</body>
</html>
<?php
}

View File

@ -1,403 +1,265 @@
body { :root {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); --app-bg: #f3f4f6;
background-size: 400% 400%; --app-surface: #ffffff;
animation: gradient 15s ease infinite; --app-surface-muted: #f8fafc;
color: #212529; --app-border: #dfe3e8;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --app-text: #111827;
font-size: 14px; --app-muted: #5b6472;
margin: 0; --app-accent: #111827;
min-height: 100vh; --app-accent-soft: #e5e7eb;
--app-focus: rgba(17, 24, 39, 0.14);
--shadow-soft: 0 20px 40px rgba(15, 23, 42, 0.04);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
} }
.main-wrapper { html {
display: flex; scroll-behavior: smooth;
}
body {
background: var(--app-bg);
color: var(--app-text);
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
}
main {
min-height: calc(100vh - 160px);
}
.py-lg-6 {
padding-top: 5rem !important;
padding-bottom: 5rem !important;
}
.bg-white {
background-color: #fff !important;
}
.app-navbar {
backdrop-filter: saturate(180%) blur(12px);
}
.navbar-brand {
display: inline-flex;
gap: 0.75rem;
align-items: center;
letter-spacing: -0.02em;
}
.brand-mark {
width: 2rem;
height: 2rem;
border-radius: 10px;
background: var(--app-accent);
color: #fff;
display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; font-size: 0.8rem;
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-weight: 700;
font-size: 1.1rem;
display: flex;
justify-content: space-between;
align-items: center;
} }
.chat-messages { .nav-link {
flex: 1; color: var(--app-muted);
overflow-y: auto; font-weight: 500;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
} }
/* Custom Scrollbar */ .nav-link.active,
::-webkit-scrollbar { .nav-link:hover {
width: 6px; color: var(--app-text);
} }
::-webkit-scrollbar-track { .hero-shell {
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 {
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;
color: #fff;
text-decoration: none;
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.3s ease;
}
.header-link:hover {
background: rgba(0, 0, 0, 0.4);
text-decoration: none;
}
/* 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; background: #fff;
padding: 1rem;
border: none;
} }
.table tr td:first-child { border-radius: 12px 0 0 12px; } .eyebrow,
.table tr td:last-child { border-radius: 0 12px 12px 0; } .section-kicker {
display: inline-flex;
.form-group { align-items: center;
margin-bottom: 1.25rem; gap: 0.5rem;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--app-muted);
font-weight: 700;
} }
.form-group label { .display-5 {
display: block; letter-spacing: -0.04em;
margin-bottom: 0.5rem; }
font-weight: 600;
.lead {
font-size: 1.05rem;
}
.panel-card,
.feature-card,
.metric-card,
.stat-card,
.success-panel {
background: var(--app-surface);
border: 1px solid var(--app-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.metric-card,
.feature-card,
.stat-card {
padding: 1.25rem;
height: 100%;
}
.metric-value,
.stat-value {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: -0.04em;
}
.metric-label,
.detail-label {
color: var(--app-muted);
font-size: 0.9rem; font-size: 0.9rem;
} }
.form-control { .stack-list {
width: 100%; display: grid;
padding: 0.75rem 1rem; gap: 0.75rem;
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 { .stack-row {
outline: none;
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
}
.header-container {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
} justify-content: space-between;
.header-links {
display: flex;
gap: 1rem; gap: 1rem;
border: 1px solid var(--app-border);
border-radius: var(--radius-sm);
padding: 0.85rem 1rem;
background: var(--app-surface-muted);
font-size: 0.95rem;
} }
.admin-card { .notice-box,
background: rgba(255, 255, 255, 0.6); .success-panel {
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);
}
.admin-card h3 {
margin-top: 0;
margin-bottom: 1.5rem;
font-weight: 700;
}
.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;
width: 100%;
transition: all 0.3s ease;
}
.webhook-url {
font-size: 0.85em;
color: #555;
margin-top: 0.5rem;
}
.history-table-container {
overflow-x: auto;
background: rgba(255, 255, 255, 0.4);
padding: 1rem; padding: 1rem;
border-radius: 12px; background: var(--app-surface-muted);
border: 1px solid rgba(255, 255, 255, 0.3); border-radius: var(--radius-md);
border: 1px solid var(--app-border);
color: var(--app-muted);
} }
.history-table { .form-control,
width: 100%; .form-select {
min-height: 46px;
border-color: var(--app-border);
border-radius: 10px;
padding: 0.75rem 0.9rem;
} }
.history-table-time { textarea.form-control {
width: 15%; min-height: 140px;
}
.form-control:focus,
.form-select:focus,
.btn:focus,
.navbar-toggler:focus {
border-color: var(--app-accent);
box-shadow: 0 0 0 0.2rem var(--app-focus);
}
.btn {
border-radius: 10px;
font-weight: 600;
padding-top: 0.7rem;
padding-bottom: 0.7rem;
}
.btn-sm {
padding-top: 0.45rem;
padding-bottom: 0.45rem;
}
.btn-dark {
background: var(--app-accent);
border-color: var(--app-accent);
}
.btn-dark:hover,
.btn-dark:focus {
background: #030712;
border-color: #030712;
}
code {
color: var(--app-text);
background: #eef2f7;
padding: 0.2rem 0.45rem;
border-radius: 6px;
font-size: 0.9em;
}
.auth-shell {
background: var(--app-bg);
}
.sticky-offset {
top: 5.5rem;
}
.admin-table th {
color: var(--app-muted);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--app-surface-muted);
white-space: nowrap; white-space: nowrap;
font-size: 0.85em;
color: #555;
} }
.history-table-user { .admin-table th,
width: 35%; .admin-table td {
background: rgba(255, 255, 255, 0.3); padding: 1rem 1rem;
border-radius: 8px; border-color: var(--app-border);
padding: 8px;
} }
.history-table-ai { .admin-table tbody tr:hover {
width: 50%; background: #fafafa;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
padding: 8px;
} }
.no-messages { .empty-state {
text-align: center; background: var(--app-surface-muted);
color: #777; }
}
.detail-value {
font-size: 1rem;
font-weight: 600;
}
.detail-copy {
background: var(--app-surface-muted);
border: 1px solid var(--app-border);
border-radius: var(--radius-md);
padding: 1rem;
color: var(--app-text);
}
.toast {
border-radius: 12px;
}
@media (max-width: 991.98px) {
.py-lg-6 {
padding-top: 3.5rem !important;
padding-bottom: 3.5rem !important;
}
.sticky-offset {
top: 0;
}
}

View File

@ -1,39 +1,31 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form'); document.querySelectorAll('.toast').forEach((toastEl) => {
const chatInput = document.getElementById('chat-input'); if (window.bootstrap) {
const chatMessages = document.getElementById('chat-messages'); const toast = new bootstrap.Toast(toastEl, { delay: 3500 });
toast.show();
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');
} }
}); });
document.querySelectorAll('[data-copy-text]').forEach((button) => {
button.addEventListener('click', async () => {
const text = button.getAttribute('data-copy-text') || '';
try {
await navigator.clipboard.writeText(text);
button.textContent = 'Copied';
window.setTimeout(() => {
button.textContent = 'Copy demo credentials';
}, 1600);
} catch (error) {
console.error('Clipboard copy failed', error);
}
});
});
const hash = window.location.hash;
if (hash && hash.length > 1) {
const target = document.querySelector(hash);
if (target) {
window.setTimeout(() => target.scrollIntoView({ behavior: 'smooth', block: 'start' }), 120);
}
}
}); });

200
dashboard.php Normal file
View File

@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
require_admin();
$statusFilter = normalize_request_status((string) ($_GET['status'] ?? '')) ?? '';
$searchQuery = normalize_request_search((string) ($_GET['q'] ?? ''));
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = 10;
$totalRequests = count_requests($statusFilter !== '' ? $statusFilter : null, $searchQuery);
$totalPages = max(1, (int) ceil(max($totalRequests, 1) / $perPage));
if ($page > $totalPages) {
$page = $totalPages;
}
$offset = ($page - 1) * $perPage;
$requests = fetch_requests($statusFilter !== '' ? $statusFilter : null, $searchQuery, $perPage, $offset);
$metrics = dashboard_metrics();
$showingStart = $totalRequests > 0 ? $offset + 1 : 0;
$showingEnd = $totalRequests > 0 ? $offset + count($requests) : 0;
$hasActiveFilters = $statusFilter !== '' || $searchQuery !== '';
$buildDashboardUrl = static function (array $overrides = []) use ($statusFilter, $searchQuery, $page): string {
$params = [
'status' => $statusFilter,
'q' => $searchQuery,
'page' => $page,
];
foreach ($overrides as $key => $value) {
$params[$key] = $value;
}
foreach ($params as $key => $value) {
if ($value === '' || $value === null || ($key === 'page' && (int) $value <= 1)) {
unset($params[$key]);
}
}
$query = http_build_query($params);
return '/dashboard.php' . ($query !== '' ? '?' . $query : '');
};
render_head(project_name() . ' | Dashboard', 'Admin dashboard for reviewing incoming project requests.', true);
render_navbar('dashboard');
render_flash_toast();
?>
<main>
<section class="py-4 py-lg-5 border-bottom bg-white">
<div class="container">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3 mb-4">
<div>
<div class="section-kicker">Dashboard</div>
<h1 class="h2 mb-2">Project request queue</h1>
<p class="text-secondary mb-0">Review the latest submissions, search contacts or companies, filter by status, and open each request detail page.</p>
</div>
<a class="btn btn-dark" href="/#request-form">Create another request</a>
</div>
<div class="row g-3">
<div class="col-sm-6 col-xl-3">
<div class="stat-card"><div class="small text-secondary">Total requests</div><div class="stat-value"><?= e((string) $metrics['total']) ?></div></div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="stat-card"><div class="small text-secondary">New</div><div class="stat-value"><?= e((string) $metrics['new']) ?></div></div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="stat-card"><div class="small text-secondary">Reviewed</div><div class="stat-value"><?= e((string) $metrics['reviewed']) ?></div></div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="stat-card"><div class="small text-secondary">Qualified</div><div class="stat-value"><?= e((string) $metrics['qualified']) ?></div></div>
</div>
</div>
</div>
</section>
<section class="py-4 py-lg-5" id="requests">
<div class="container">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
<div>
<h2 class="h4 mb-1">All requests</h2>
<p class="text-secondary mb-0">Search by reference, name, email, company, or project type, then paginate through the queue.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="<?= e($buildDashboardUrl(['status' => '', 'page' => 1])) ?>" class="btn btn-sm <?= $statusFilter === '' ? 'btn-dark' : 'btn-outline-secondary' ?>">All</a>
<?php foreach (request_status_options() as $value => $label): ?>
<a href="<?= e($buildDashboardUrl(['status' => $value, 'page' => 1])) ?>" class="btn btn-sm <?= $statusFilter === $value ? 'btn-dark' : 'btn-outline-secondary' ?>"><?= e($label) ?></a>
<?php endforeach; ?>
</div>
</div>
<div class="panel-card mb-3">
<form method="get" class="row g-3 align-items-end" role="search">
<div class="col-lg-7">
<label class="form-label" for="q">Search requests</label>
<input class="form-control" id="q" name="q" type="search" value="<?= e($searchQuery) ?>" placeholder="Try REQ-0001, name, email, company, or project type">
</div>
<div class="col-md-4 col-lg-3">
<label class="form-label" for="status">Status</label>
<select class="form-select" id="status" name="status">
<option value="">All statuses</option>
<?php foreach (request_status_options() as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $statusFilter === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-8 col-lg-2 d-grid d-md-flex gap-2">
<button class="btn btn-dark flex-fill" type="submit">Apply</button>
<?php if ($hasActiveFilters): ?>
<a class="btn btn-outline-secondary flex-fill" href="/dashboard.php">Reset</a>
<?php endif; ?>
</div>
</form>
</div>
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3 small text-secondary">
<div>
Showing <?= e((string) $showingStart) ?><?= e((string) $showingEnd) ?> of <?= e((string) $totalRequests) ?> request<?= $totalRequests === 1 ? '' : 's' ?>.
</div>
<?php if ($hasActiveFilters): ?>
<div>Filtered results are based on your current search and status selection.</div>
<?php endif; ?>
</div>
<div class="panel-card p-0 overflow-hidden">
<?php if (!$requests): ?>
<div class="empty-state p-5 text-center">
<?php if ($hasActiveFilters): ?>
<h3 class="h5 mb-2">No matching requests</h3>
<p class="text-secondary mb-3">Try a different keyword or clear the filters to see the full queue again.</p>
<a class="btn btn-dark btn-sm" href="/dashboard.php">Clear filters</a>
<?php else: ?>
<h3 class="h5 mb-2">No requests yet</h3>
<p class="text-secondary mb-3">Submit the first project request from the home page to populate the queue.</p>
<a class="btn btn-dark btn-sm" href="/#request-form">Create the first request</a>
<?php endif; ?>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table align-middle mb-0 admin-table">
<thead>
<tr>
<th scope="col">Reference</th>
<th scope="col">Contact</th>
<th scope="col">Type</th>
<th scope="col">Timeline</th>
<th scope="col">Status</th>
<th scope="col">Submitted</th>
<th scope="col" class="text-end">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($requests as $request): ?>
<tr>
<td class="fw-semibold"><?= e(request_reference((int) $request['id'])) ?></td>
<td>
<div><?= e($request['name']) ?></div>
<div class="small text-secondary"><?= e($request['email']) ?></div>
<?php if (!empty($request['company'])): ?>
<div class="small text-secondary"><?= e((string) $request['company']) ?></div>
<?php endif; ?>
</td>
<td><?= e($request['project_type']) ?></td>
<td><?= e(timeline_options()[$request['timeline']] ?? $request['timeline']) ?></td>
<td><span class="badge <?= e(status_badge_class($request['status'])) ?>"><?= e(request_status_options()[$request['status']] ?? ucfirst((string) $request['status'])) ?></span></td>
<td class="small text-secondary"><?= e(format_datetime((string) $request['created_at'])) ?></td>
<td class="text-end"><a class="btn btn-sm btn-outline-secondary" href="/submission.php?id=<?= e((string) $request['id']) ?>">Open</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($totalPages > 1): ?>
<?php
$windowStart = max(1, $page - 2);
$windowEnd = min($totalPages, $windowStart + 4);
$windowStart = max(1, $windowEnd - 4);
?>
<nav class="border-top px-3 py-3" aria-label="Request pagination">
<ul class="pagination pagination-sm flex-wrap justify-content-center justify-content-md-end gap-1 mb-0">
<li class="page-item <?= $page <= 1 ? 'disabled' : '' ?>">
<a class="page-link" href="<?= e($buildDashboardUrl(['page' => max(1, $page - 1)])) ?>" <?= $page <= 1 ? 'tabindex="-1" aria-disabled="true"' : '' ?>>Previous</a>
</li>
<?php for ($pageNumber = $windowStart; $pageNumber <= $windowEnd; $pageNumber++): ?>
<li class="page-item <?= $pageNumber === $page ? 'active' : '' ?>">
<a class="page-link" href="<?= e($buildDashboardUrl(['page' => $pageNumber])) ?>"><?= e((string) $pageNumber) ?></a>
</li>
<?php endfor; ?>
<li class="page-item <?= $page >= $totalPages ? 'disabled' : '' ?>">
<a class="page-link" href="<?= e($buildDashboardUrl(['page' => min($totalPages, $page + 1)])) ?>" <?= $page >= $totalPages ? 'tabindex="-1" aria-disabled="true"' : '' ?>>Next</a>
</li>
</ul>
</nav>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
</section>
</main>
<?php render_footer('dashboard'); ?>

View File

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS project_requests (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(120) NOT NULL,
email VARCHAR(190) NOT NULL,
company VARCHAR(160) NULL,
project_type VARCHAR(120) NOT NULL,
timeline VARCHAR(80) NOT NULL,
budget VARCHAR(80) NOT NULL,
details TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'new',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_project_requests_status (status),
KEY idx_project_requests_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

360
index.php
View File

@ -1,150 +1,222 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION; require_once __DIR__ . '/app.php';
$now = date('Y-m-d H:i:s');
ensure_schema();
$formData = [
'name' => '',
'email' => '',
'company' => '',
'project_type' => 'Admin dashboard',
'timeline' => '2-4 weeks',
'budget' => '5k-15k',
'details' => '',
];
$errors = [];
$createdId = isset($_GET['ref']) ? (int) $_GET['ref'] : 0;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$validation = validate_request_input($_POST);
$formData = array_merge($formData, $validation['data']);
$errors = $validation['errors'];
if (!$errors) {
$requestId = create_request($formData);
set_flash('success', 'Request submitted. You can now review it in the admin dashboard.');
header('Location: /?submitted=1&ref=' . $requestId . '#request-form');
exit;
}
}
render_head(project_name() . ' | Intake & admin dashboard', 'Submit a new project request and review it in a clean admin dashboard.');
render_navbar('home');
render_flash_toast();
?> ?>
<!doctype html> <main>
<html lang="en"> <section class="hero-shell border-bottom">
<head> <div class="container py-5 py-lg-6">
<meta charset="utf-8" /> <div class="row g-4 align-items-center">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <div class="col-lg-7">
<title>New Style</title> <div class="eyebrow mb-3">Fast LAMP MVP · intake to dashboard</div>
<?php <h1 class="display-5 fw-semibold mb-3">A focused admin app starter for capturing and reviewing project requests.</h1>
// Read project preview data from environment <p class="lead text-secondary mb-4">This first slice gives you a public intake form, a secure admin sign-in, a dashboard list, and a detail screen so the workflow already feels like a usable product.</p>
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? ''; <div class="d-flex flex-wrap gap-2 mb-4">
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; <a class="btn btn-dark px-4" href="#request-form">Create a request</a>
?> <a class="btn btn-outline-secondary px-4" href="/login.php">Open admin login</a>
<?php if ($projectDescription): ?> </div>
<!-- Meta description --> <div class="hero-metrics row g-3">
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' /> <div class="col-sm-4">
<!-- Open Graph meta tags --> <div class="metric-card">
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" /> <div class="metric-value">1 form</div>
<!-- Twitter meta tags --> <div class="metric-label">public intake path</div>
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" /> </div>
<?php endif; ?> </div>
<?php if ($projectImageUrl): ?> <div class="col-sm-4">
<!-- Open Graph image --> <div class="metric-card">
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <div class="metric-value">1 dashboard</div>
<!-- Twitter image --> <div class="metric-label">review queue</div>
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> </div>
<?php endif; ?> </div>
<link rel="preconnect" href="https://fonts.googleapis.com"> <div class="col-sm-4">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <div class="metric-card">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet"> <div class="metric-value">1 detail view</div>
<style> <div class="metric-label">status updates</div>
:root { </div>
--bg-color-start: #6a11cb; </div>
--bg-color-end: #2575fc; </div>
--text-color: #ffffff; </div>
--card-bg-color: rgba(255, 255, 255, 0.01); <div class="col-lg-5">
--card-border-color: rgba(255, 255, 255, 0.1); <div class="panel-card p-4">
} <div class="d-flex justify-content-between align-items-start gap-3 mb-4">
body { <div>
margin: 0; <div class="section-kicker">Admin access</div>
font-family: 'Inter', sans-serif; <h2 class="h4 mb-1">Demo sign-in</h2>
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); <p class="text-secondary mb-0">Use the admin login to review submissions right away.</p>
color: var(--text-color); </div>
display: flex; <span class="badge text-bg-light border">Ready</span>
justify-content: center; </div>
align-items: center; <div class="stack-list">
min-height: 100vh; <div class="stack-row">
text-align: center; <span>Email</span>
overflow: hidden; <code id="demo-email"><?= e(admin_email()) ?></code>
position: relative; </div>
} <div class="stack-row">
body::before { <span>Password</span>
content: ''; <code id="demo-password"><?= e(admin_password()) ?></code>
position: absolute; </div>
top: 0; </div>
left: 0; <div class="d-grid gap-2 mt-4">
width: 100%; <button type="button" class="btn btn-outline-secondary" data-copy-text="<?= e(admin_email()) ?> / <?= e(admin_password()) ?>">Copy demo credentials</button>
height: 100%; <a class="btn btn-dark" href="/login.php">Go to login</a>
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>'); </div>
animation: bg-pan 20s linear infinite; <?php if (using_default_admin_credentials()): ?>
z-index: -1; <div class="notice-box mt-3">For this MVP, fallback credentials are enabled. Replace <code>ADMIN_EMAIL</code> and <code>ADMIN_PASSWORD</code> in the environment when you are ready.</div>
} <?php endif; ?>
@keyframes bg-pan { </div>
0% { background-position: 0% 0%; } </div>
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>
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div> </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> </section>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC) <section class="py-5 border-bottom bg-white">
</footer> <div class="container">
</body> <div class="row g-3 align-items-stretch">
</html> <div class="col-lg-4">
<div class="feature-card h-100">
<div class="section-kicker">Step 1</div>
<h2 class="h5">Capture the brief</h2>
<p class="text-secondary mb-0">Collect the essentials: contact, project type, budget, timeline, and a concise brief.</p>
</div>
</div>
<div class="col-lg-4">
<div class="feature-card h-100">
<div class="section-kicker">Step 2</div>
<h2 class="h5">Review in dashboard</h2>
<p class="text-secondary mb-0">Admins get a clean queue view with filters, counts, and a direct path to request details.</p>
</div>
</div>
<div class="col-lg-4">
<div class="feature-card h-100">
<div class="section-kicker">Step 3</div>
<h2 class="h5">Update status</h2>
<p class="text-secondary mb-0">Each request has a detail page where you can move it from new to reviewed, qualified, or closed.</p>
</div>
</div>
</div>
</div>
</section>
<section class="py-5 py-lg-6" id="request-form">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-5">
<div class="sticky-lg-top sticky-offset">
<div class="section-kicker">New request</div>
<h2 class="h1 mb-3">Submit the first project brief.</h2>
<p class="text-secondary">This is the public workflow entry point. After submission, the request is stored in MariaDB and immediately available in the admin dashboard.</p>
<?php if ($createdId > 0): ?>
<div class="success-panel mt-4">
<div class="section-kicker">Confirmation</div>
<h3 class="h5 mb-1">Request <?= e(request_reference($createdId)) ?> is in the queue.</h3>
<p class="text-secondary mb-3">Use the admin dashboard to review the intake details and update its status.</p>
<a class="btn btn-dark btn-sm" href="/login.php">Review in admin</a>
</div>
<?php endif; ?>
</div>
</div>
<div class="col-lg-7">
<div class="panel-card p-4 p-lg-5">
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
<div>
<div class="section-kicker">Intake form</div>
<h3 class="h4 mb-1">Project request</h3>
<p class="text-secondary mb-0">Required fields are marked clearly and validated on the server.</p>
</div>
<span class="badge rounded-pill text-bg-light border">Live</span>
</div>
<?php if ($errors): ?>
<div class="alert alert-danger" role="alert">Please correct the highlighted fields and submit again.</div>
<?php endif; ?>
<form method="post" novalidate>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="name">Contact name</label>
<input class="form-control <?= isset($errors['name']) ? 'is-invalid' : '' ?>" id="name" name="name" value="<?= e($formData['name']) ?>" required>
<?php if (isset($errors['name'])): ?><div class="invalid-feedback"><?= e($errors['name']) ?></div><?php endif; ?>
</div>
<div class="col-md-6">
<label class="form-label" for="email">Work email</label>
<input class="form-control <?= isset($errors['email']) ? 'is-invalid' : '' ?>" id="email" name="email" type="email" value="<?= e($formData['email']) ?>" required>
<?php if (isset($errors['email'])): ?><div class="invalid-feedback"><?= e($errors['email']) ?></div><?php endif; ?>
</div>
<div class="col-md-6">
<label class="form-label" for="company">Company</label>
<input class="form-control" id="company" name="company" value="<?= e($formData['company']) ?>">
</div>
<div class="col-md-6">
<label class="form-label" for="project_type">Project type</label>
<select class="form-select <?= isset($errors['project_type']) ? 'is-invalid' : '' ?>" id="project_type" name="project_type" required>
<?php foreach (request_type_options() as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $formData['project_type'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['project_type'])): ?><div class="invalid-feedback"><?= e($errors['project_type']) ?></div><?php endif; ?>
</div>
<div class="col-md-6">
<label class="form-label" for="timeline">Timeline</label>
<select class="form-select <?= isset($errors['timeline']) ? 'is-invalid' : '' ?>" id="timeline" name="timeline" required>
<?php foreach (timeline_options() as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $formData['timeline'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['timeline'])): ?><div class="invalid-feedback"><?= e($errors['timeline']) ?></div><?php endif; ?>
</div>
<div class="col-md-6">
<label class="form-label" for="budget">Budget</label>
<select class="form-select <?= isset($errors['budget']) ? 'is-invalid' : '' ?>" id="budget" name="budget" required>
<?php foreach (budget_options() as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $formData['budget'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['budget'])): ?><div class="invalid-feedback"><?= e($errors['budget']) ?></div><?php endif; ?>
</div>
<div class="col-12">
<label class="form-label" for="details">Project brief</label>
<textarea class="form-control <?= isset($errors['details']) ? 'is-invalid' : '' ?>" id="details" name="details" rows="6" required><?= e($formData['details']) ?></textarea>
<?php if (isset($errors['details'])): ?><div class="invalid-feedback"><?= e($errors['details']) ?></div><?php endif; ?>
</div>
</div>
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-sm-center gap-3 mt-4">
<div class="small text-secondary">Stored with PDO prepared statements and available to admins immediately.</div>
<button class="btn btn-dark px-4" type="submit">Submit request</button>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
</main>
<?php render_footer('home'); ?>

63
login.php Normal file
View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
if (is_admin_logged_in()) {
header('Location: /dashboard.php');
exit;
}
$error = '';
$email = admin_email();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim((string) ($_POST['email'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
if (attempt_admin_login($email, $password)) {
set_flash('success', 'Signed in successfully.');
header('Location: /dashboard.php');
exit;
}
$error = 'The email or password is incorrect.';
}
render_head(project_name() . ' | Admin login', 'Admin sign-in for reviewing incoming project requests.', true);
render_navbar('login');
render_flash_toast();
?>
<main class="auth-shell">
<div class="container py-5 py-lg-6">
<div class="row justify-content-center">
<div class="col-lg-5 col-xl-4">
<div class="panel-card p-4 p-lg-5">
<div class="section-kicker mb-2">Secure area</div>
<h1 class="h3 mb-2">Admin login</h1>
<p class="text-secondary mb-4">Sign in to review new requests, open details, and update statuses.</p>
<?php if ($error !== ''): ?>
<div class="alert alert-danger" role="alert"><?= e($error) ?></div>
<?php endif; ?>
<form method="post" class="vstack gap-3" novalidate>
<div>
<label class="form-label" for="email">Email</label>
<input class="form-control" id="email" name="email" type="email" value="<?= e($email) ?>" required>
</div>
<div>
<label class="form-label" for="password">Password</label>
<input class="form-control" id="password" name="password" type="password" required>
</div>
<button class="btn btn-dark w-100" type="submit">Sign in</button>
</form>
<div class="notice-box mt-4">
<div class="small text-uppercase fw-semibold text-secondary mb-2">Demo credentials</div>
<div class="d-flex justify-content-between align-items-center gap-3 small border rounded-3 px-3 py-2 mb-2"><span>Email</span><code><?= e(admin_email()) ?></code></div>
<div class="d-flex justify-content-between align-items-center gap-3 small border rounded-3 px-3 py-2"><span>Password</span><code><?= e(admin_password()) ?></code></div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php render_footer('login'); ?>

11
logout.php Normal file
View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
logout_admin();
session_start();
set_flash('success', 'You have been logged out.');
header('Location: /login.php');
exit;

110
submission.php Normal file
View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
require_admin();
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
$request = $id > 0 ? fetch_request($id) : null;
if (!$request) {
http_response_code(404);
render_head(project_name() . ' | Request not found', 'Requested submission was not found.', true);
render_navbar('detail');
?>
<main class="py-5"><div class="container"><div class="panel-card p-5 text-center"><h1 class="h4 mb-2">Request not found</h1><p class="text-secondary mb-3">The requested submission does not exist or was removed.</p><a href="/dashboard.php" class="btn btn-dark btn-sm">Back to dashboard</a></div></div></main>
<?php
render_footer('detail');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$newStatus = trim((string) ($_POST['status'] ?? ''));
if (update_request_status((int) $request['id'], $newStatus)) {
set_flash('success', 'Request status updated.');
header('Location: /submission.php?id=' . (int) $request['id']);
exit;
}
set_flash('danger', 'Unable to update the request status.');
header('Location: /submission.php?id=' . (int) $request['id']);
exit;
}
render_head(project_name() . ' | ' . request_reference((int) $request['id']), 'Request detail view for the admin dashboard.', true);
render_navbar('detail');
render_flash_toast();
?>
<main class="py-4 py-lg-5">
<div class="container">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
<div>
<div class="section-kicker">Request detail</div>
<h1 class="h3 mb-1"><?= e(request_reference((int) $request['id'])) ?></h1>
<p class="text-secondary mb-0">Submitted <?= e(format_datetime((string) $request['created_at'])) ?></p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" href="/dashboard.php">Back to dashboard</a>
<span class="badge align-self-center <?= e(status_badge_class((string) $request['status'])) ?>"><?= e(request_status_options()[$request['status']] ?? ucfirst((string) $request['status'])) ?></span>
</div>
</div>
<div class="row g-4 align-items-start">
<div class="col-lg-8">
<div class="panel-card p-4 p-lg-5 mb-4">
<div class="row g-4">
<div class="col-md-6">
<div class="detail-label">Contact</div>
<div class="detail-value"><?= e($request['name']) ?></div>
</div>
<div class="col-md-6">
<div class="detail-label">Email</div>
<div class="detail-value"><?= e($request['email']) ?></div>
</div>
<div class="col-md-6">
<div class="detail-label">Company</div>
<div class="detail-value"><?= e($request['company'] ?: 'Not provided') ?></div>
</div>
<div class="col-md-6">
<div class="detail-label">Project type</div>
<div class="detail-value"><?= e($request['project_type']) ?></div>
</div>
<div class="col-md-6">
<div class="detail-label">Timeline</div>
<div class="detail-value"><?= e(timeline_options()[$request['timeline']] ?? $request['timeline']) ?></div>
</div>
<div class="col-md-6">
<div class="detail-label">Budget</div>
<div class="detail-value"><?= e(budget_options()[$request['budget']] ?? $request['budget']) ?></div>
</div>
<div class="col-12">
<div class="detail-label">Project brief</div>
<div class="detail-copy"><?= nl2br(e((string) $request['details'])) ?></div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="panel-card p-4 sticky-lg-top sticky-offset">
<div class="section-kicker mb-2">Status</div>
<h2 class="h5 mb-3">Update request stage</h2>
<form method="post" class="vstack gap-3">
<div>
<label class="form-label" for="status">Current stage</label>
<select class="form-select" name="status" id="status">
<?php foreach (request_status_options() as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $request['status'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<button class="btn btn-dark" type="submit">Save status</button>
</form>
<div class="notice-box mt-4">
Use this page as the handoff surface between intake and next-step qualification. Edit/delete can be added later if needed.
</div>
</div>
</div>
</div>
</div>
</main>
<?php render_footer('detail'); ?>