Auto commit: 2026-04-21T09:42:14.485Z
This commit is contained in:
parent
ef7c25d9e5
commit
d5e1fcddeb
455
app.php
Normal file
455
app.php
Normal file
@ -0,0 +1,455 @@
|
||||
<?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' => '2–4 weeks',
|
||||
'1-2 months' => '1–2 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 fetch_requests(?string $status = null): array
|
||||
{
|
||||
ensure_schema();
|
||||
if ($status !== null && $status !== '' && array_key_exists($status, request_status_options())) {
|
||||
$stmt = db()->prepare('SELECT * FROM project_requests WHERE status = :status ORDER BY created_at DESC, id DESC');
|
||||
$stmt->bindValue(':status', $status);
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
$stmt = db()->query('SELECT * FROM project_requests ORDER BY created_at DESC, id DESC');
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@ -1,403 +1,265 @@
|
||||
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;
|
||||
:root {
|
||||
--app-bg: #f3f4f6;
|
||||
--app-surface: #ffffff;
|
||||
--app-surface-muted: #f8fafc;
|
||||
--app-border: #dfe3e8;
|
||||
--app-text: #111827;
|
||||
--app-muted: #5b6472;
|
||||
--app-accent: #111827;
|
||||
--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 {
|
||||
display: flex;
|
||||
html {
|
||||
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;
|
||||
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-size: 0.8rem;
|
||||
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;
|
||||
.nav-link {
|
||||
color: var(--app-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
.nav-link.active,
|
||||
.nav-link:hover {
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
::-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 {
|
||||
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 {
|
||||
.hero-shell {
|
||||
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;
|
||||
.eyebrow,
|
||||
.section-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
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: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
.display-5 {
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
.stack-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
.stack-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
.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);
|
||||
.notice-box,
|
||||
.success-panel {
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: var(--app-surface-muted);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--app-border);
|
||||
color: var(--app-muted);
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
.form-control,
|
||||
.form-select {
|
||||
min-height: 46px;
|
||||
border-color: var(--app-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.history-table-time {
|
||||
width: 15%;
|
||||
textarea.form-control {
|
||||
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;
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.history-table-user {
|
||||
width: 35%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 1rem 1rem;
|
||||
border-color: var(--app-border);
|
||||
}
|
||||
|
||||
.history-table-ai {
|
||||
width: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
.admin-table tbody tr:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
text-align: center;
|
||||
color: #777;
|
||||
}
|
||||
.empty-state {
|
||||
background: var(--app-surface-muted);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,39 +1,31 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
|
||||
const appendMessage = (text, sender) => {
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.classList.add('message', sender);
|
||||
msgDiv.textContent = text;
|
||||
chatMessages.appendChild(msgDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
};
|
||||
|
||||
chatForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const message = chatInput.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
appendMessage(message, 'visitor');
|
||||
chatInput.value = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('api/chat.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Artificial delay for realism
|
||||
setTimeout(() => {
|
||||
appendMessage(data.reply, 'bot');
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
|
||||
document.querySelectorAll('.toast').forEach((toastEl) => {
|
||||
if (window.bootstrap) {
|
||||
const toast = new bootstrap.Toast(toastEl, { delay: 3500 });
|
||||
toast.show();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
101
dashboard.php
Normal file
101
dashboard.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/app.php';
|
||||
require_admin();
|
||||
|
||||
$statusFilter = trim((string) ($_GET['status'] ?? ''));
|
||||
$requests = fetch_requests($statusFilter !== '' ? $statusFilter : null);
|
||||
$metrics = dashboard_metrics();
|
||||
|
||||
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, 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">Thin-slice admin table with filters and drill-down details.</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="/dashboard.php" class="btn btn-sm <?= $statusFilter === '' ? 'btn-dark' : 'btn-outline-secondary' ?>">All</a>
|
||||
<?php foreach (request_status_options() as $value => $label): ?>
|
||||
<a href="/dashboard.php?status=<?= urlencode($value) ?>" class="btn btn-sm <?= $statusFilter === $value ? 'btn-dark' : 'btn-outline-secondary' ?>"><?= e($label) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-card p-0 overflow-hidden">
|
||||
<?php if (!$requests): ?>
|
||||
<div class="empty-state p-5 text-center">
|
||||
<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>
|
||||
</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>
|
||||
</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 endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<?php render_footer('dashboard'); ?>
|
||||
15
db/migrations/001_project_requests.sql
Normal file
15
db/migrations/001_project_requests.sql
Normal 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
360
index.php
@ -1,150 +1,222 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
require_once __DIR__ . '/app.php';
|
||||
|
||||
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes bg-pan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.loader {
|
||||
margin: 1.25rem auto 1.25rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</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>
|
||||
<main>
|
||||
<section class="hero-shell border-bottom">
|
||||
<div class="container py-5 py-lg-6">
|
||||
<div class="row g-4 align-items-center">
|
||||
<div class="col-lg-7">
|
||||
<div class="eyebrow mb-3">Fast LAMP MVP · intake to dashboard</div>
|
||||
<h1 class="display-5 fw-semibold mb-3">A focused admin app starter for capturing and reviewing project requests.</h1>
|
||||
<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>
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<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>
|
||||
</div>
|
||||
<div class="hero-metrics row g-3">
|
||||
<div class="col-sm-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value">1 form</div>
|
||||
<div class="metric-label">public intake path</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value">1 dashboard</div>
|
||||
<div class="metric-label">review queue</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value">1 detail view</div>
|
||||
<div class="metric-label">status updates</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="panel-card p-4">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
|
||||
<div>
|
||||
<div class="section-kicker">Admin access</div>
|
||||
<h2 class="h4 mb-1">Demo sign-in</h2>
|
||||
<p class="text-secondary mb-0">Use the admin login to review submissions right away.</p>
|
||||
</div>
|
||||
<span class="badge text-bg-light border">Ready</span>
|
||||
</div>
|
||||
<div class="stack-list">
|
||||
<div class="stack-row">
|
||||
<span>Email</span>
|
||||
<code id="demo-email"><?= e(admin_email()) ?></code>
|
||||
</div>
|
||||
<div class="stack-row">
|
||||
<span>Password</span>
|
||||
<code id="demo-password"><?= e(admin_password()) ?></code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-grid gap-2 mt-4">
|
||||
<button type="button" class="btn btn-outline-secondary" data-copy-text="<?= e(admin_email()) ?> / <?= e(admin_password()) ?>">Copy demo credentials</button>
|
||||
<a class="btn btn-dark" href="/login.php">Go to login</a>
|
||||
</div>
|
||||
<?php if (using_default_admin_credentials()): ?>
|
||||
<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; ?>
|
||||
</div>
|
||||
</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>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
</section>
|
||||
|
||||
<section class="py-5 border-bottom bg-white">
|
||||
<div class="container">
|
||||
<div class="row g-3 align-items-stretch">
|
||||
<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
63
login.php
Normal 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
11
logout.php
Normal 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
110
submission.php
Normal 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'); ?>
|
||||
Loading…
x
Reference in New Issue
Block a user