456 lines
14 KiB
PHP
456 lines
14 KiB
PHP
<?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
|
||
}
|