Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edfa4d1aab |
115
app_helpers.php
Normal file
115
app_helpers.php
Normal file
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function h(mixed $value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function env_value(string $key, string $fallback = ''): string
|
||||
{
|
||||
$serverValue = $_SERVER[$key] ?? null;
|
||||
if (is_string($serverValue) && trim($serverValue) !== '') {
|
||||
return $serverValue;
|
||||
}
|
||||
|
||||
$envValue = getenv($key);
|
||||
if (is_string($envValue) && trim($envValue) !== '') {
|
||||
return $envValue;
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
function site_project_name(string $fallback = 'Portfolio'): string
|
||||
{
|
||||
return env_value('PROJECT_NAME', $fallback);
|
||||
}
|
||||
|
||||
function site_project_description(string $fallback = 'Personal portfolio website.'): string
|
||||
{
|
||||
return env_value('PROJECT_DESCRIPTION', $fallback);
|
||||
}
|
||||
|
||||
function base_url(): string
|
||||
{
|
||||
$https = $_SERVER['HTTPS'] ?? '';
|
||||
$scheme = (!empty($https) && $https !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
|
||||
return $scheme . '://' . $host;
|
||||
}
|
||||
|
||||
function set_flash(array $flash): void
|
||||
{
|
||||
$_SESSION['flash'] = $flash;
|
||||
}
|
||||
|
||||
function pull_flash(): ?array
|
||||
{
|
||||
if (!isset($_SESSION['flash']) || !is_array($_SESSION['flash'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$flash = $_SESSION['flash'];
|
||||
unset($_SESSION['flash']);
|
||||
|
||||
return $flash;
|
||||
}
|
||||
|
||||
function set_old_form(array $values): void
|
||||
{
|
||||
$_SESSION['old_form'] = $values;
|
||||
}
|
||||
|
||||
function pull_old_form(): array
|
||||
{
|
||||
if (!isset($_SESSION['old_form']) || !is_array($_SESSION['old_form'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = $_SESSION['old_form'];
|
||||
unset($_SESSION['old_form']);
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
|
||||
function text_length(string $value): int
|
||||
{
|
||||
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
|
||||
}
|
||||
|
||||
function text_slice(string $value, int $length): string
|
||||
{
|
||||
return function_exists('mb_substr') ? mb_substr($value, 0, $length) : substr($value, 0, $length);
|
||||
}
|
||||
|
||||
function text_excerpt(string $value, int $length = 100): string
|
||||
{
|
||||
if (function_exists('mb_strimwidth')) {
|
||||
return mb_strimwidth($value, 0, $length, '…');
|
||||
}
|
||||
|
||||
return strlen($value) > $length ? substr($value, 0, max(0, $length - 1)) . '…' : $value;
|
||||
}
|
||||
|
||||
function ensure_contact_requests_table(PDO $pdo): void
|
||||
{
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS contact_requests (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
email VARCHAR(190) NOT NULL,
|
||||
company VARCHAR(150) NULL,
|
||||
project_type VARCHAR(80) NULL,
|
||||
budget VARCHAR(80) NULL,
|
||||
message TEXT NOT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'new',
|
||||
source_url VARCHAR(255) NULL,
|
||||
ip_address VARCHAR(45) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
}
|
||||
@ -1,403 +1,393 @@
|
||||
:root {
|
||||
--bg: #f5f5f4;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f8f8f7;
|
||||
--surface-strong: #efefec;
|
||||
--text: #171717;
|
||||
--text-muted: #57534e;
|
||||
--line: #ddd6d1;
|
||||
--line-strong: #bfb7b0;
|
||||
--accent: #111111;
|
||||
--accent-soft: #e7e5e4;
|
||||
--success: #1f6d3d;
|
||||
--danger: #8d2f2f;
|
||||
--shadow-sm: 0 10px 30px rgba(17, 17, 17, 0.06);
|
||||
--shadow-lg: 0 22px 60px rgba(17, 17, 17, 0.08);
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 18px;
|
||||
--container-max: 1180px;
|
||||
--section-space: clamp(4.5rem, 8vw, 7rem);
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
||||
background-size: 400% 400%;
|
||||
animation: gradient 15s ease infinite;
|
||||
color: #212529;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #d6d3d1;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: var(--container-max);
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
.site-header {
|
||||
background: rgba(245, 245, 244, 0.88);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
.navbar {
|
||||
--bs-navbar-padding-y: 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
.navbar.scrolled {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
.bg-body-tertiary {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
.nav-link {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
.nav-link:hover,
|
||||
.nav-link:focus,
|
||||
.nav-link.active {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.section-space {
|
||||
padding: var(--section-space) 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
.hero-section {
|
||||
padding-top: clamp(5.5rem, 12vw, 8rem);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
.hero-title {
|
||||
max-width: 11ch;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 0.95;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
.hero-copy,
|
||||
.section-heading p,
|
||||
.content-card p,
|
||||
.notice-card p,
|
||||
.message-body {
|
||||
color: var(--text-muted);
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
.hero-panel,
|
||||
.portfolio-card,
|
||||
.content-card,
|
||||
.testimonial-card,
|
||||
.contact-card,
|
||||
.notice-card,
|
||||
.stat-card,
|
||||
.empty-state-card,
|
||||
.modal-content {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.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%;
|
||||
.metric-card,
|
||||
.detail-block {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem 1.1rem;
|
||||
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);
|
||||
.metric-card {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.blob-1 {
|
||||
top: -10%;
|
||||
left: -10%;
|
||||
background: rgba(238, 119, 82, 0.4);
|
||||
}
|
||||
|
||||
.blob-2 {
|
||||
bottom: -10%;
|
||||
right: -10%;
|
||||
background: rgba(35, 166, 213, 0.4);
|
||||
animation-delay: -7s;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.blob-3 {
|
||||
top: 40%;
|
||||
left: 30%;
|
||||
background: rgba(231, 60, 126, 0.3);
|
||||
animation-delay: -14s;
|
||||
width: 450px;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
@keyframes move {
|
||||
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
|
||||
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
|
||||
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
|
||||
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
|
||||
}
|
||||
|
||||
.header-link {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.header-link:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Admin Styles */
|
||||
.admin-container {
|
||||
max-width: 900px;
|
||||
margin: 3rem auto;
|
||||
padding: 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.admin-container h1 {
|
||||
margin-top: 0;
|
||||
color: #212529;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 8px;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 1rem;
|
||||
color: #6c757d;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.table td {
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.table tr td:first-child { border-radius: 12px 0 0 12px; }
|
||||
.table tr td:last-child { border-radius: 0 12px 12px 0; }
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
.metric-value,
|
||||
.stat-value {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.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;
|
||||
.metric-label,
|
||||
.detail-label,
|
||||
.panel-label,
|
||||
.mini-label,
|
||||
.section-kicker {
|
||||
display: inline-block;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
color: #6b635d;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
|
||||
.section-kicker {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.section-heading {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-heading h2,
|
||||
.section-heading .h2,
|
||||
.section-heading .h3,
|
||||
.hero-title,
|
||||
.modal-title,
|
||||
h1,
|
||||
.h1,
|
||||
.h2,
|
||||
.h3,
|
||||
.h4,
|
||||
.h5 {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
max-width: 14ch;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
display: grid;
|
||||
gap: 0.95rem;
|
||||
margin: 1.5rem 0 0;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
position: relative;
|
||||
padding-left: 1.4rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.feature-list li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.72rem;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--text);
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
blockquote {
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.7;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
.btn {
|
||||
--bs-btn-border-radius: 12px;
|
||||
--bs-btn-padding-y: 0.8rem;
|
||||
--bs-btn-padding-x: 1.1rem;
|
||||
--bs-btn-font-weight: 600;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.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);
|
||||
.btn-dark {
|
||||
--bs-btn-bg: #111111;
|
||||
--bs-btn-border-color: #111111;
|
||||
--bs-btn-hover-bg: #000000;
|
||||
--bs-btn-hover-border-color: #000000;
|
||||
}
|
||||
|
||||
.admin-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 700;
|
||||
.btn-outline-dark,
|
||||
.btn-outline-secondary {
|
||||
--bs-btn-color: var(--text);
|
||||
--bs-btn-border-color: var(--line-strong);
|
||||
--bs-btn-hover-color: #fff;
|
||||
--bs-btn-hover-bg: #111111;
|
||||
--bs-btn-hover-border-color: #111111;
|
||||
}
|
||||
|
||||
.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;
|
||||
.form-control,
|
||||
.form-select,
|
||||
textarea.form-control {
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
border-color: var(--line);
|
||||
background: var(--surface-muted);
|
||||
padding: 0.9rem 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus,
|
||||
textarea.form-control:focus,
|
||||
.btn:focus,
|
||||
.navbar-toggler:focus,
|
||||
.list-group-item:focus {
|
||||
border-color: #111111;
|
||||
box-shadow: 0 0 0 0.2rem rgba(17, 17, 17, 0.12);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.webhook-url {
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
margin-top: 0.5rem;
|
||||
.form-text,
|
||||
.text-secondary {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.history-table-container {
|
||||
overflow-x: auto;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
.alert,
|
||||
.toast {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
.toast {
|
||||
min-width: min(92vw, 360px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.history-table-time {
|
||||
width: 15%;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
.toast.text-bg-success {
|
||||
background: var(--success) !important;
|
||||
}
|
||||
|
||||
.history-table-user {
|
||||
width: 35%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
.toast.text-bg-danger {
|
||||
background: var(--danger) !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
border-color: var(--line) !important;
|
||||
border-radius: 14px !important;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.list-group-item:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.list-group-item.active {
|
||||
background: #111111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.list-group-item.active .badge {
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.backdrop-blur {
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.inbox-page {
|
||||
background: #f3f3f2;
|
||||
}
|
||||
|
||||
.inbox-list {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
background: #ede9e6;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
}
|
||||
|
||||
.history-table-ai {
|
||||
width: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
@media (max-width: 991.98px) {
|
||||
.hero-title {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.navbar-collapse {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
text-align: center;
|
||||
color: #777;
|
||||
@media (max-width: 575.98px) {
|
||||
:root {
|
||||
--section-space: 4rem;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
padding-top: 5rem;
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.section-heading p,
|
||||
.content-card p,
|
||||
.notice-card p,
|
||||
.message-body {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@ -1,39 +1,59 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
const nav = document.querySelector('.navbar');
|
||||
const onScroll = () => {
|
||||
if (!nav) return;
|
||||
nav.classList.toggle('scrolled', window.scrollY > 12);
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
const appendMessage = (text, sender) => {
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.classList.add('message', sender);
|
||||
msgDiv.textContent = text;
|
||||
chatMessages.appendChild(msgDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
const toastEl = document.querySelector('.toast[data-autoshow="true"]');
|
||||
if (toastEl && window.bootstrap?.Toast) {
|
||||
const toast = new window.bootstrap.Toast(toastEl, { delay: 5000 });
|
||||
toast.show();
|
||||
}
|
||||
|
||||
const modal = document.getElementById('projectModal');
|
||||
if (modal) {
|
||||
modal.addEventListener('show.bs.modal', (event) => {
|
||||
const trigger = event.relatedTarget;
|
||||
if (!trigger) return;
|
||||
|
||||
const mapping = {
|
||||
projectModalLabel: 'title',
|
||||
projectModalKicker: 'kicker',
|
||||
projectModalDescription: 'description',
|
||||
projectModalImpact: 'impact',
|
||||
projectModalDeliverables: 'deliverables',
|
||||
projectModalStack: 'stack',
|
||||
};
|
||||
|
||||
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 })
|
||||
Object.entries(mapping).forEach(([elementId, dataKey]) => {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
element.textContent = trigger.getAttribute(`data-${dataKey}`) || '';
|
||||
});
|
||||
});
|
||||
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');
|
||||
}
|
||||
|
||||
const form = document.querySelector('.needs-validation');
|
||||
if (form) {
|
||||
form.addEventListener('submit', (event) => {
|
||||
if (!form.checkValidity()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
form.classList.add('was-validated');
|
||||
});
|
||||
}
|
||||
|
||||
const messageField = document.getElementById('message');
|
||||
const counter = document.getElementById('messageCount');
|
||||
if (messageField && counter) {
|
||||
const syncCount = () => {
|
||||
counter.textContent = `${messageField.value.length} / 2500`;
|
||||
};
|
||||
syncCount();
|
||||
messageField.addEventListener('input', syncCount);
|
||||
}
|
||||
});
|
||||
|
||||
111
contact_submit.php
Normal file
111
contact_submit.php
Normal file
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
session_start();
|
||||
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/app_helpers.php';
|
||||
require_once __DIR__ . '/mail/MailService.php';
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
header('Location: /#contact');
|
||||
exit;
|
||||
}
|
||||
|
||||
$name = trim((string) ($_POST['name'] ?? ''));
|
||||
$email = trim((string) ($_POST['email'] ?? ''));
|
||||
$company = trim((string) ($_POST['company'] ?? ''));
|
||||
$projectType = trim((string) ($_POST['project_type'] ?? ''));
|
||||
$budget = trim((string) ($_POST['budget'] ?? ''));
|
||||
$message = trim((string) ($_POST['message'] ?? ''));
|
||||
$honeypot = trim((string) ($_POST['website'] ?? ''));
|
||||
|
||||
set_old_form([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'company' => $company,
|
||||
'project_type' => $projectType,
|
||||
'budget' => $budget,
|
||||
'message' => $message,
|
||||
]);
|
||||
|
||||
$errors = [];
|
||||
if ($honeypot !== '') {
|
||||
$errors[] = 'Spam protection was triggered.';
|
||||
}
|
||||
if ($name === '' || text_length($name) < 2) {
|
||||
$errors[] = 'Please enter your name.';
|
||||
}
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = 'Please provide a valid email address.';
|
||||
}
|
||||
if ($message === '' || text_length($message) < 20) {
|
||||
$errors[] = 'Please add at least 20 characters about your project.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
set_flash([
|
||||
'variant' => 'danger',
|
||||
'title' => 'Request not sent',
|
||||
'message' => $errors[0],
|
||||
]);
|
||||
header('Location: /#contact');
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
ensure_contact_requests_table($pdo);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO contact_requests (name, email, company, project_type, budget, message, source_url, ip_address, user_agent)
|
||||
VALUES (:name, :email, :company, :project_type, :budget, :message, :source_url, :ip_address, :user_agent)'
|
||||
);
|
||||
|
||||
$sourceUrl = base_url() . '/#contact';
|
||||
$ipAddress = substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45);
|
||||
$userAgent = substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
|
||||
|
||||
$stmt->bindValue(':name', text_slice($name, 120));
|
||||
$stmt->bindValue(':email', text_slice($email, 190));
|
||||
$stmt->bindValue(':company', $company !== '' ? text_slice($company, 150) : null, $company !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
|
||||
$stmt->bindValue(':project_type', $projectType !== '' ? text_slice($projectType, 80) : null, $projectType !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
|
||||
$stmt->bindValue(':budget', $budget !== '' ? text_slice($budget, 80) : null, $budget !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
|
||||
$stmt->bindValue(':message', text_slice($message, 2500));
|
||||
$stmt->bindValue(':source_url', $sourceUrl, PDO::PARAM_STR);
|
||||
$stmt->bindValue(':ip_address', $ipAddress !== '' ? $ipAddress : null, $ipAddress !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
|
||||
$stmt->bindValue(':user_agent', $userAgent !== '' ? $userAgent : null, $userAgent !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
|
||||
$stmt->execute();
|
||||
|
||||
unset($_SESSION['old_form']);
|
||||
|
||||
$mailResult = MailService::sendContactMessage(
|
||||
$name,
|
||||
$email,
|
||||
"Company: " . ($company !== '' ? $company : '—') . "\nProject type: " . ($projectType !== '' ? $projectType : '—') . "\nBudget: " . ($budget !== '' ? $budget : '—') . "\n\n" . $message,
|
||||
null,
|
||||
'New portfolio inquiry'
|
||||
);
|
||||
|
||||
$messageText = 'Your request was saved to the inbox successfully.';
|
||||
if (!empty($mailResult['success'])) {
|
||||
$messageText .= ' A copy was also sent by email.';
|
||||
} else {
|
||||
$messageText .= ' Email forwarding is not configured yet, so this submission is available in the site inbox.';
|
||||
}
|
||||
|
||||
set_flash([
|
||||
'variant' => 'success',
|
||||
'title' => 'Request received',
|
||||
'message' => $messageText,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('Contact submission failed: ' . $e->getMessage());
|
||||
set_flash([
|
||||
'variant' => 'danger',
|
||||
'title' => 'Something went wrong',
|
||||
'message' => 'The request could not be saved right now. Please try again in a moment.',
|
||||
]);
|
||||
}
|
||||
|
||||
header('Location: /#contact');
|
||||
exit;
|
||||
15
db/migrations/20260408_create_contact_requests.sql
Normal file
15
db/migrations/20260408_create_contact_requests.sql
Normal file
@ -0,0 +1,15 @@
|
||||
-- Initial MVP slice: inquiry inbox for portfolio contact form.
|
||||
CREATE TABLE IF NOT EXISTS contact_requests (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
email VARCHAR(190) NOT NULL,
|
||||
company VARCHAR(150) NULL,
|
||||
project_type VARCHAR(80) NULL,
|
||||
budget VARCHAR(80) NULL,
|
||||
message TEXT NOT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'new',
|
||||
source_url VARCHAR(255) NULL,
|
||||
ip_address VARCHAR(45) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
11
healthz.php
Normal file
11
healthz.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
http_response_code(200);
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'ok',
|
||||
'service' => 'portfolio-site',
|
||||
'time' => gmdate('c'),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
561
index.php
561
index.php
@ -1,150 +1,455 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
session_start();
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
require_once __DIR__ . '/app_helpers.php';
|
||||
|
||||
$projectName = site_project_name('Noah Mercer');
|
||||
$projectDescription = site_project_description('One-page personal portfolio to showcase selected work and convert visitors into project inquiries.');
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
$canonicalUrl = base_url() . '/';
|
||||
$flash = pull_flash();
|
||||
$old = pull_old_form();
|
||||
$cssVersion = file_exists(__DIR__ . '/assets/css/custom.css') ? (string) filemtime(__DIR__ . '/assets/css/custom.css') : (string) time();
|
||||
$jsVersion = file_exists(__DIR__ . '/assets/js/main.js') ? (string) filemtime(__DIR__ . '/assets/js/main.js') : (string) time();
|
||||
$hasMailRecipient = trim((string) getenv('MAIL_TO')) !== '';
|
||||
|
||||
$projects = [
|
||||
[
|
||||
'eyebrow' => 'Product design + build',
|
||||
'title' => 'B2B analytics dashboard',
|
||||
'summary' => 'Redesigned an internal reporting tool into a clear operator dashboard used by sales and finance teams.',
|
||||
'impact' => 'Reduced manual reporting time by 41% and improved stakeholder adoption in the first month.',
|
||||
'deliverables' => ['UX audit', 'Design system refresh', 'KPI dashboard', 'Responsive tables'],
|
||||
'stack' => 'Strategy, UI, front-end implementation',
|
||||
],
|
||||
[
|
||||
'eyebrow' => 'Conversion-focused website',
|
||||
'title' => 'Professional services relaunch',
|
||||
'summary' => 'Built a restrained, trust-first website for a boutique consulting firm targeting enterprise buyers.',
|
||||
'impact' => 'Lifted contact request conversion by 28% while shortening the path to the inquiry form.',
|
||||
'deliverables' => ['Messaging framework', 'Case study layout', 'Lead capture', 'Mobile optimization'],
|
||||
'stack' => 'Brand, copy, UI, engineering',
|
||||
],
|
||||
[
|
||||
'eyebrow' => 'Launch support',
|
||||
'title' => 'Startup MVP landing and waitlist',
|
||||
'summary' => 'Created a one-page launch site with feature storytelling, social proof, and an early access workflow.',
|
||||
'impact' => 'Captured 900+ qualified signups from paid and organic traffic in six weeks.',
|
||||
'deliverables' => ['Hero narrative', 'Feature grid', 'Founder story', 'Launch analytics'],
|
||||
'stack' => 'Positioning, design, growth UX',
|
||||
],
|
||||
];
|
||||
|
||||
$testimonials = [
|
||||
[
|
||||
'quote' => 'Sharp taste, clean execution, and zero drama. We shipped faster because every screen felt decided.',
|
||||
'name' => 'Maya Chen',
|
||||
'role' => 'Head of Product, Northline',
|
||||
],
|
||||
[
|
||||
'quote' => 'The new site finally feels aligned with the caliber of our work. Leads started mentioning the website immediately.',
|
||||
'name' => 'Daniel Ortiz',
|
||||
'role' => 'Founder, Ortiz Advisory',
|
||||
],
|
||||
];
|
||||
|
||||
$capabilities = [
|
||||
'Portfolio websites and personal brands',
|
||||
'Marketing pages with clearer conversion paths',
|
||||
'Product UI polish for MVPs and internal tools',
|
||||
'UX writing, positioning, and content structure',
|
||||
];
|
||||
|
||||
$formDefaults = [
|
||||
'name' => '',
|
||||
'email' => '',
|
||||
'company' => '',
|
||||
'project_type' => '',
|
||||
'budget' => '',
|
||||
'message' => '',
|
||||
];
|
||||
$old = array_merge($formDefaults, $old);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= h($projectName) ?> — Personal Portfolio</title>
|
||||
<meta name="description" content="<?= h($projectDescription) ?>">
|
||||
<meta name="author" content="<?= h($projectName) ?>">
|
||||
<meta name="keywords" content="portfolio, personal website, web designer, web developer, consultant, case studies, contact">
|
||||
<link rel="canonical" href="<?= h($canonicalUrl) ?>">
|
||||
<?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) ?>" />
|
||||
<meta property="og:description" content="<?= h($projectDescription) ?>">
|
||||
<meta property="twitter:description" content="<?= h($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) ?>" />
|
||||
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
|
||||
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
|
||||
<?php endif; ?>
|
||||
<meta property="og:title" content="<?= h($projectName) ?> — Personal Portfolio">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="<?= h($canonicalUrl) ?>">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<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);
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<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=<?= h($cssVersion) ?>">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"name": <?= json_encode($projectName, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>,
|
||||
"description": <?= json_encode($projectDescription, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>,
|
||||
"url": <?= json_encode($canonicalUrl, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>,
|
||||
"knowsAbout": ["Web design", "Front-end development", "Portfolio strategy", "Conversion optimization"]
|
||||
}
|
||||
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>
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<div class="site-shell">
|
||||
<header class="site-header sticky-top border-bottom">
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-opacity-75 backdrop-blur" aria-label="Main navigation">
|
||||
<div class="container">
|
||||
<a class="navbar-brand brand-mark" href="#top"><?= h($projectName) ?></a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="mainNav">
|
||||
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||
<li class="nav-item"><a class="nav-link" href="#portfolio">Work</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#about">About</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#testimonials">Testimonials</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#contact">Contact</a></li>
|
||||
<li class="nav-item ms-lg-2"><a class="btn btn-dark btn-sm px-3" href="/requests.php">Inbox</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="hero-section section-space">
|
||||
<div class="container">
|
||||
<div class="row align-items-center g-4 g-lg-5">
|
||||
<div class="col-lg-7">
|
||||
<span class="section-kicker">Independent designer & developer</span>
|
||||
<h1 class="display-4 hero-title">A precise personal site that makes your work easy to trust.</h1>
|
||||
<p class="hero-copy">I help founders, consultants, and small teams present their work with clarity—so visitors can understand the value fast and confidently reach out.</p>
|
||||
<div class="d-flex flex-wrap gap-3 pt-2">
|
||||
<a class="btn btn-dark btn-lg px-4" href="#portfolio">View selected work</a>
|
||||
<a class="btn btn-outline-secondary btn-lg px-4" href="#contact">Start a project</a>
|
||||
</div>
|
||||
<div class="hero-metrics row row-cols-1 row-cols-sm-3 g-3 mt-4">
|
||||
<div class="col">
|
||||
<div class="metric-card">
|
||||
<span class="metric-value">12+</span>
|
||||
<span class="metric-label">Recent launches</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="metric-card">
|
||||
<span class="metric-value">28%</span>
|
||||
<span class="metric-label">Avg. conversion lift</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="metric-card">
|
||||
<span class="metric-value">72h</span>
|
||||
<span class="metric-label">Typical response time</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<aside class="hero-panel card shadow-sm border-0">
|
||||
<div class="card-body p-4 p-lg-5">
|
||||
<div class="panel-label">Current focus</div>
|
||||
<ul class="feature-list list-unstyled mb-4">
|
||||
<?php foreach ($capabilities as $capability): ?>
|
||||
<li><?= h($capability) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<div class="small text-secondary mb-3">Need a polished site that feels credible on day one?</div>
|
||||
<a class="btn btn-outline-dark w-100" href="#contact">Request availability</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="portfolio" class="section-space border-top">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<span class="section-kicker">Portfolio</span>
|
||||
<h2>Selected work focused on clarity, conversion, and trust.</h2>
|
||||
<p>Each project starts with positioning and ends with a clean interface visitors can move through without friction.</p>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<?php foreach ($projects as $project): ?>
|
||||
<?php
|
||||
$modalDescription = $project['summary'] . ' ' . $project['impact'];
|
||||
$deliverables = implode(' • ', $project['deliverables']);
|
||||
?>
|
||||
<div class="col-md-6 col-xl-4">
|
||||
<article class="portfolio-card card h-100 border-0 shadow-sm">
|
||||
<div class="card-body p-4 d-flex flex-column">
|
||||
<span class="mini-label"><?= h($project['eyebrow']) ?></span>
|
||||
<h3 class="h5 mt-3 mb-2"><?= h($project['title']) ?></h3>
|
||||
<p class="text-secondary mb-4"><?= h($project['summary']) ?></p>
|
||||
<div class="project-meta text-secondary small mb-4"><?= h($project['stack']) ?></div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-dark mt-auto align-self-start project-trigger"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#projectModal"
|
||||
data-title="<?= h($project['title']) ?>"
|
||||
data-kicker="<?= h($project['eyebrow']) ?>"
|
||||
data-description="<?= h($modalDescription) ?>"
|
||||
data-impact="<?= h($project['impact']) ?>"
|
||||
data-deliverables="<?= h($deliverables) ?>"
|
||||
data-stack="<?= h($project['stack']) ?>"
|
||||
>View case details</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="about" class="section-space border-top">
|
||||
<div class="container">
|
||||
<div class="row g-4 g-lg-5 align-items-start">
|
||||
<div class="col-lg-5">
|
||||
<div class="section-heading mb-0">
|
||||
<span class="section-kicker">About</span>
|
||||
<h2>I build portfolio and marketing experiences that feel deliberate.</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="content-card card border-0 shadow-sm">
|
||||
<div class="card-body p-4 p-lg-5">
|
||||
<p class="lead mb-3">My work sits between strategy, interface design, and implementation. I focus on removing ambiguity so visitors know who you are, what you do, and how to take the next step.</p>
|
||||
<p class="text-secondary mb-4">That usually means tighter copy, more disciplined layout decisions, better mobile behavior, and a simpler path to contact. The result is a site that looks professional without trying too hard.</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">Services</div>
|
||||
<div class="detail-value">Design systems, landing pages, portfolio sites, UX refreshes</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">Typical clients</div>
|
||||
<div class="detail-value">Consultants, founders, creative studios, early-stage teams</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">Working style</div>
|
||||
<div class="detail-value">Lean, collaborative, direct, detail-oriented</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">Location</div>
|
||||
<div class="detail-value">Remote-friendly, available internationally</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="testimonials" class="section-space border-top">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<span class="section-kicker">Testimonials</span>
|
||||
<h2>Feedback from teams that needed sharp, usable work quickly.</h2>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<?php foreach ($testimonials as $testimonial): ?>
|
||||
<div class="col-md-6">
|
||||
<figure class="testimonial-card card h-100 border-0 shadow-sm mb-0">
|
||||
<div class="card-body p-4 p-lg-5 d-flex flex-column">
|
||||
<blockquote class="mb-4">“<?= h($testimonial['quote']) ?>”</blockquote>
|
||||
<figcaption class="mt-auto text-secondary">
|
||||
<strong class="text-dark d-block"><?= h($testimonial['name']) ?></strong>
|
||||
<?= h($testimonial['role']) ?>
|
||||
</figcaption>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="contact" class="section-space border-top">
|
||||
<div class="container">
|
||||
<div class="row g-4 g-lg-5">
|
||||
<div class="col-lg-5">
|
||||
<div class="section-heading mb-3">
|
||||
<span class="section-kicker">Contact</span>
|
||||
<h2>Tell me what you need and I’ll follow up with next steps.</h2>
|
||||
<p>Use the short form below to send a project request. Every submission is stored in the site inbox and can also be forwarded by email when SMTP is configured.</p>
|
||||
</div>
|
||||
<div class="notice-stack d-grid gap-3">
|
||||
<div class="notice-card card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="detail-label">What to include</div>
|
||||
<p class="mb-0 text-secondary">Scope, timeline, goals, and anything that helps define the problem. Short is fine—clarity is better than length.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice-card card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="detail-label">Response workflow</div>
|
||||
<p class="mb-0 text-secondary">Submissions are saved to the inbox view at <a href="/requests.php">/requests.php</a> for review and follow-up.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!$hasMailRecipient): ?>
|
||||
<div class="alert alert-warning small mb-0" role="alert">
|
||||
This is for testing purposes only — Flatlogic does not guarantee usage of the mail server. Please set up your own SMTP in <code>.env</code> (MAIL_/SMTP_ vars) with our AI Agent.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="contact-card card border-0 shadow-sm">
|
||||
<div class="card-body p-4 p-lg-5">
|
||||
<form action="/contact_submit.php" method="post" class="row g-3 needs-validation" novalidate>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="name">Name</label>
|
||||
<input class="form-control form-control-lg" id="name" name="name" type="text" maxlength="120" required value="<?= h($old['name']) ?>">
|
||||
<div class="invalid-feedback">Please enter your name.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="email">Email</label>
|
||||
<input class="form-control form-control-lg" id="email" name="email" type="email" maxlength="190" required value="<?= h($old['email']) ?>">
|
||||
<div class="invalid-feedback">Please enter a valid email.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="company">Company</label>
|
||||
<input class="form-control" id="company" name="company" type="text" maxlength="150" value="<?= h($old['company']) ?>" placeholder="Optional">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="project_type">Project type</label>
|
||||
<select class="form-select" id="project_type" name="project_type">
|
||||
<?php $projectTypes = ['', 'Portfolio website', 'Landing page', 'Product UI', 'Site refresh']; ?>
|
||||
<?php foreach ($projectTypes as $projectType): ?>
|
||||
<option value="<?= h($projectType) ?>" <?= $old['project_type'] === $projectType ? 'selected' : '' ?>><?= h($projectType ?: 'Select') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="budget">Budget</label>
|
||||
<select class="form-select" id="budget" name="budget">
|
||||
<?php $budgets = ['', '< $2k', '$2k–$5k', '$5k–$10k', '$10k+']; ?>
|
||||
<?php foreach ($budgets as $budget): ?>
|
||||
<option value="<?= h($budget) ?>" <?= $old['budget'] === $budget ? 'selected' : '' ?>><?= h($budget ?: 'Select') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 d-none" aria-hidden="true">
|
||||
<label class="form-label" for="website">Website</label>
|
||||
<input class="form-control" id="website" name="website" type="text" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="message">Project details</label>
|
||||
<textarea class="form-control" id="message" name="message" rows="6" minlength="20" maxlength="2500" required placeholder="A short summary of your project, timing, and goals."><?= h($old['message']) ?></textarea>
|
||||
<div class="form-text d-flex justify-content-between"><span>Minimum 20 characters.</span><span id="messageCount">0 / 2500</span></div>
|
||||
<div class="invalid-feedback">Please add a little more detail so I can understand the request.</div>
|
||||
</div>
|
||||
<div class="col-12 d-flex flex-column flex-sm-row align-items-sm-center gap-3 pt-2">
|
||||
<button class="btn btn-dark btn-lg px-4" type="submit">Send request</button>
|
||||
<span class="text-secondary small">You’ll see a confirmation here after submission.</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
|
||||
<footer class="border-top">
|
||||
<div class="container footer-inner d-flex flex-column flex-lg-row justify-content-between gap-3 py-4">
|
||||
<div>
|
||||
<div class="brand-mark small mb-1"><?= h($projectName) ?></div>
|
||||
<div class="text-secondary small">Personal portfolio and inquiry flow built for fast review on any device.</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-3 small">
|
||||
<a href="#portfolio">Work</a>
|
||||
<a href="#about">About</a>
|
||||
<a href="#contact">Contact</a>
|
||||
<a href="/requests.php">Inbox</a>
|
||||
<a href="/healthz.php">Health</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="projectModal" tabindex="-1" aria-labelledby="projectModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg">
|
||||
<div class="modal-header border-bottom-0 pb-0 px-4 pt-4">
|
||||
<div>
|
||||
<div class="mini-label mb-2" id="projectModalKicker">Case study</div>
|
||||
<h2 class="modal-title h4 mb-0" id="projectModalLabel">Project details</h2>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body px-4 pb-4">
|
||||
<p class="text-secondary mb-4" id="projectModalDescription"></p>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="detail-block h-100">
|
||||
<div class="detail-label">Impact</div>
|
||||
<div class="detail-value" id="projectModalImpact"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="detail-block h-100">
|
||||
<div class="detail-label">Role</div>
|
||||
<div class="detail-value" id="projectModalStack"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="detail-block h-100">
|
||||
<div class="detail-label">Deliverables</div>
|
||||
<div class="detail-value" id="projectModalDeliverables"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<?php if ($flash): ?>
|
||||
<div class="toast align-items-center border-0 text-bg-<?= h($flash['variant'] ?? 'dark') ?>" role="status" aria-live="polite" aria-atomic="true" data-autoshow="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<strong class="d-block mb-1"><?= h($flash['title'] ?? 'Update') ?></strong>
|
||||
<span><?= h($flash['message'] ?? '') ?></span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></script>
|
||||
<script src="/assets/js/main.js?v=<?= h($jsVersion) ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
183
requests.php
Normal file
183
requests.php
Normal file
@ -0,0 +1,183 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
session_start();
|
||||
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/app_helpers.php';
|
||||
|
||||
$projectName = site_project_name('Noah Mercer');
|
||||
$projectDescription = site_project_description('Contact request inbox for portfolio submissions.');
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
$cssVersion = file_exists(__DIR__ . '/assets/css/custom.css') ? (string) filemtime(__DIR__ . '/assets/css/custom.css') : (string) time();
|
||||
$jsVersion = file_exists(__DIR__ . '/assets/js/main.js') ? (string) filemtime(__DIR__ . '/assets/js/main.js') : (string) time();
|
||||
|
||||
$requests = [];
|
||||
$selectedRequest = null;
|
||||
$totalCount = 0;
|
||||
$newCount = 0;
|
||||
$todayCount = 0;
|
||||
$errorMessage = null;
|
||||
$selectedId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT) ?: null;
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
ensure_contact_requests_table($pdo);
|
||||
|
||||
$requests = $pdo->query('SELECT id, name, email, company, project_type, budget, message, status, created_at FROM contact_requests ORDER BY created_at DESC, id DESC LIMIT 100')->fetchAll();
|
||||
|
||||
$totals = $pdo->query("SELECT COUNT(*) AS total_count, SUM(status = 'new') AS new_count, SUM(DATE(created_at) = CURDATE()) AS today_count FROM contact_requests")->fetch();
|
||||
$totalCount = (int) ($totals['total_count'] ?? 0);
|
||||
$newCount = (int) ($totals['new_count'] ?? 0);
|
||||
$todayCount = (int) ($totals['today_count'] ?? 0);
|
||||
|
||||
if ($selectedId !== null) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM contact_requests WHERE id = :id LIMIT 1');
|
||||
$stmt->bindValue(':id', $selectedId, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$selectedRequest = $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
if ($selectedRequest === null && $requests !== []) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM contact_requests WHERE id = :id LIMIT 1');
|
||||
$stmt->bindValue(':id', (int) $requests[0]['id'], PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$selectedRequest = $stmt->fetch() ?: null;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('Failed to load requests inbox: ' . $e->getMessage());
|
||||
$errorMessage = 'The inquiry inbox is not available right now.';
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= h($projectName) ?> — Inquiry Inbox</title>
|
||||
<meta name="description" content="<?= h($projectDescription) ?>">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<?php if ($projectDescription): ?>
|
||||
<meta property="og:description" content="<?= h($projectDescription) ?>">
|
||||
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
|
||||
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
|
||||
<?php endif; ?>
|
||||
<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;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<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=<?= h($cssVersion) ?>">
|
||||
</head>
|
||||
<body class="inbox-page">
|
||||
<header class="site-header sticky-top border-bottom bg-white">
|
||||
<nav class="navbar navbar-expand-lg bg-white">
|
||||
<div class="container">
|
||||
<a class="navbar-brand brand-mark" href="/"><?= h($projectName) ?></a>
|
||||
<div class="d-flex gap-2 ms-auto">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="/">Back to site</a>
|
||||
<a class="btn btn-dark btn-sm" href="/healthz.php">Health</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="section-space">
|
||||
<div class="container">
|
||||
<div class="section-heading mb-4">
|
||||
<span class="section-kicker">Admin inbox</span>
|
||||
<h1 class="h2 mb-2">Contact requests</h1>
|
||||
<p class="mb-0">A minimal review view for messages submitted from the portfolio contact form.</p>
|
||||
</div>
|
||||
|
||||
<?php if ($errorMessage): ?>
|
||||
<div class="alert alert-danger" role="alert"><?= h($errorMessage) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card card border-0 shadow-sm h-100"><div class="card-body p-4"><div class="detail-label">Total requests</div><div class="stat-value"><?= h((string) $totalCount) ?></div></div></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card card border-0 shadow-sm h-100"><div class="card-body p-4"><div class="detail-label">New</div><div class="stat-value"><?= h((string) $newCount) ?></div></div></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card card border-0 shadow-sm h-100"><div class="card-body p-4"><div class="detail-label">Today</div><div class="stat-value"><?= h((string) $todayCount) ?></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($requests === []): ?>
|
||||
<div class="card border-0 shadow-sm empty-state-card">
|
||||
<div class="card-body p-5 text-center">
|
||||
<h2 class="h4 mb-2">No inquiries yet</h2>
|
||||
<p class="text-secondary mb-4">Submit the contact form from the homepage to populate this inbox.</p>
|
||||
<a class="btn btn-dark" href="/#contact">Open contact form</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-5 col-xl-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white border-bottom-0 pt-4 px-4">
|
||||
<h2 class="h5 mb-1">Inbox list</h2>
|
||||
<p class="text-secondary small mb-0">Select a request to inspect the full message.</p>
|
||||
</div>
|
||||
<div class="card-body p-3 p-lg-4 inbox-list">
|
||||
<div class="list-group list-group-flush">
|
||||
<?php foreach ($requests as $request): ?>
|
||||
<?php $active = $selectedRequest && (int) $selectedRequest['id'] === (int) $request['id']; ?>
|
||||
<a class="list-group-item list-group-item-action border rounded-3 mb-2 <?= $active ? 'active border-dark' : '' ?>" href="/requests.php?id=<?= h((string) $request['id']) ?>">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-2">
|
||||
<div>
|
||||
<div class="fw-semibold"><?= h($request['name']) ?></div>
|
||||
<div class="small <?= $active ? 'text-white-50' : 'text-secondary' ?>"><?= h($request['email']) ?></div>
|
||||
</div>
|
||||
<span class="badge text-bg-light border text-dark"><?= h($request['project_type'] ?: 'General') ?></span>
|
||||
</div>
|
||||
<p class="small mb-2 <?= $active ? 'text-white-50' : 'text-secondary' ?>"><?= h(text_excerpt((string) $request['message'], 100)) ?></p>
|
||||
<div class="small <?= $active ? 'text-white-50' : 'text-secondary' ?>"><?= h(date('M j, Y · H:i', strtotime((string) $request['created_at']))) ?></div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7 col-xl-8">
|
||||
<?php if ($selectedRequest): ?>
|
||||
<article class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4 p-lg-5">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-start gap-3 mb-4">
|
||||
<div>
|
||||
<span class="section-kicker">Selected inquiry</span>
|
||||
<h2 class="h3 mb-1"><?= h($selectedRequest['name']) ?></h2>
|
||||
<p class="text-secondary mb-0"><?= h($selectedRequest['email']) ?></p>
|
||||
</div>
|
||||
<span class="badge rounded-pill text-bg-dark px-3 py-2"><?= h($selectedRequest['status']) ?></span>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4"><div class="detail-block h-100"><div class="detail-label">Company</div><div class="detail-value"><?= h($selectedRequest['company'] ?: '—') ?></div></div></div>
|
||||
<div class="col-md-4"><div class="detail-block h-100"><div class="detail-label">Project type</div><div class="detail-value"><?= h($selectedRequest['project_type'] ?: 'General') ?></div></div></div>
|
||||
<div class="col-md-4"><div class="detail-block h-100"><div class="detail-label">Budget</div><div class="detail-value"><?= h($selectedRequest['budget'] ?: '—') ?></div></div></div>
|
||||
</div>
|
||||
<div class="detail-block mb-4">
|
||||
<div class="detail-label">Message</div>
|
||||
<div class="detail-value message-body"><?= nl2br(h($selectedRequest['message'])) ?></div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><div class="detail-block h-100"><div class="detail-label">Submitted</div><div class="detail-value"><?= h(date('F j, Y \a\t H:i', strtotime((string) $selectedRequest['created_at']))) ?></div></div></div>
|
||||
<div class="col-md-6"><div class="detail-block h-100"><div class="detail-label">Source</div><div class="detail-value"><?= h($selectedRequest['source_url'] ?: base_url() . '/#contact') ?></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></script>
|
||||
<script src="/assets/js/main.js?v=<?= h($jsVersion) ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user