diff --git a/app.php b/app.php new file mode 100644 index 0000000..c71c60a --- /dev/null +++ b/app.php @@ -0,0 +1,455 @@ +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()); + ?> + + + + + + <?= e($title) ?> + + + + + + + + + + + + + + + + + + + + + + + 'text-bg-success', + 'danger' => 'text-bg-danger', + 'warning' => 'text-bg-warning', + 'info' => 'text-bg-primary', + ]; + $class = $map[$type] ?? 'text-bg-primary'; + ?> +
+
+
+
+ +
+
+
+ + + + + + + { - 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); + } + } }); diff --git a/dashboard.php b/dashboard.php new file mode 100644 index 0000000..c0f817c --- /dev/null +++ b/dashboard.php @@ -0,0 +1,101 @@ + +
+
+
+
+
+
Dashboard
+

Project request queue

+

Review the latest submissions, filter by status, and open each request detail page.

+
+ Create another request +
+
+
+
Total requests
+
+
+
New
+
+
+
Reviewed
+
+
+
Qualified
+
+
+
+
+ +
+
+
+
+

All requests

+

Thin-slice admin table with filters and drill-down details.

+
+
+ All + $label): ?> + + +
+
+
+ +
+

No requests yet

+

Submit the first project request from the home page to populate the queue.

+ Create the first request +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ReferenceContactTypeTimelineStatusSubmittedAction
+
+
+
Open
+
+ +
+
+
+
+ diff --git a/db/migrations/001_project_requests.sql b/db/migrations/001_project_requests.sql new file mode 100644 index 0000000..1b4fa2a --- /dev/null +++ b/db/migrations/001_project_requests.sql @@ -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; diff --git a/index.php b/index.php index 7205f3d..b0014d8 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,222 @@ '', + '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(); ?> - - - - - - New Style - - - - - - - - - - - - - - - - - - - - - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… +
+
+
+
+
+
Fast LAMP MVP · intake to dashboard
+

A focused admin app starter for capturing and reviewing project requests.

+

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.

+ +
+
+
+
1 form
+
public intake path
+
+
+
+
+
1 dashboard
+
review queue
+
+
+
+
+
1 detail view
+
status updates
+
+
+
+
+
+
+
+
+
Admin access
+

Demo sign-in

+

Use the admin login to review submissions right away.

+
+ Ready +
+
+
+ Email + +
+
+ Password + +
+
+
+ + Go to login +
+ +
For this MVP, fallback credentials are enabled. Replace ADMIN_EMAIL and ADMIN_PASSWORD in the environment when you are ready.
+ +
+
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

-
-
- Page updated: (UTC) -
- - + + +
+
+
+
+
+
Step 1
+

Capture the brief

+

Collect the essentials: contact, project type, budget, timeline, and a concise brief.

+
+
+
+
+
Step 2
+

Review in dashboard

+

Admins get a clean queue view with filters, counts, and a direct path to request details.

+
+
+
+
+
Step 3
+

Update status

+

Each request has a detail page where you can move it from new to reviewed, qualified, or closed.

+
+
+
+
+
+ +
+
+
+
+
+
New request
+

Submit the first project brief.

+

This is the public workflow entry point. After submission, the request is stored in MariaDB and immediately available in the admin dashboard.

+ 0): ?> +
+
Confirmation
+

Request is in the queue.

+

Use the admin dashboard to review the intake details and update its status.

+ Review in admin +
+ +
+
+
+
+
+
+
Intake form
+

Project request

+

Required fields are marked clearly and validated on the server.

+
+ Live +
+ + + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+
Stored with PDO prepared statements and available to admins immediately.
+ +
+
+
+
+
+
+
+
+ diff --git a/login.php b/login.php new file mode 100644 index 0000000..130404d --- /dev/null +++ b/login.php @@ -0,0 +1,63 @@ + +
+
+
+
+
+
Secure area
+

Admin login

+

Sign in to review new requests, open details, and update statuses.

+ + + +
+
+ + +
+
+ + +
+ +
+
+
Demo credentials
+
Email
+
Password
+
+
+
+
+
+
+ diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..b49a8e2 --- /dev/null +++ b/logout.php @@ -0,0 +1,11 @@ + 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'); + ?> +

Request not found

The requested submission does not exist or was removed.

Back to dashboard
+ +
+
+
+
+
Request detail
+

+

Submitted

+
+ +
+ +
+
+
+
+
+
Contact
+
+
+
+
Email
+
+
+
+
Company
+
+
+
+
Project type
+
+
+
+
Timeline
+
+
+
+
Budget
+
+
+
+
Project brief
+
+
+
+
+
+
+
+
Status
+

Update request stage

+
+
+ + +
+ +
+
+ Use this page as the handoff surface between intake and next-step qualification. Edit/delete can be added later if needed. +
+
+
+
+
+
+