diff --git a/app_helpers.php b/app_helpers.php new file mode 100644 index 0000000..a079e7f --- /dev/null +++ b/app_helpers.php @@ -0,0 +1,115 @@ + $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); +} diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..704c256 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -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; - min-height: 100vh; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; } -.main-wrapper { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; +::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; +} + +.site-header { + background: rgba(245, 245, 244, 0.88); + backdrop-filter: blur(14px); +} + +.navbar { + --bs-navbar-padding-y: 1rem; +} + +.navbar.scrolled { + box-shadow: var(--shadow-sm); +} + +.bg-body-tertiary { + background-color: transparent !important; +} + +.brand-mark { + font-weight: 700; + letter-spacing: -0.03em; +} + +.nav-link { + color: var(--text-muted); + font-weight: 500; +} + +.nav-link:hover, +.nav-link:focus, +.nav-link.active { + color: var(--text); +} + +.section-space { + padding: var(--section-space) 0; +} + +.hero-section { + padding-top: clamp(5.5rem, 12vw, 8rem); +} + +.hero-title { + max-width: 11ch; + letter-spacing: -0.05em; + line-height: 0.95; + margin: 0 0 1.5rem; +} + +.hero-copy, +.section-heading p, +.content-card p, +.notice-card p, +.message-body { + color: var(--text-muted); + max-width: 62ch; +} + +.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); +} + +.metric-card, +.detail-block { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 1rem 1.1rem; + height: 100%; +} + +.metric-card { + min-height: 100%; +} + +.metric-value, +.stat-value { + display: block; + font-size: clamp(1.5rem, 3vw, 2rem); + font-weight: 700; + letter-spacing: -0.04em; + line-height: 1; +} + +.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; +} + +.section-kicker { + margin-bottom: 1rem; +} + +.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; +} + +.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; +} + +.btn-dark { + --bs-btn-bg: #111111; + --bs-btn-border-color: #111111; + --bs-btn-hover-bg: #000000; + --bs-btn-hover-border-color: #000000; +} + +.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; +} + +.form-control, +.form-select, +textarea.form-control { + border-radius: 12px; + 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; +} + +.form-text, +.text-secondary { + color: var(--text-muted) !important; +} + +.alert, +.toast { + border-radius: 14px; +} + +.toast { + min-width: min(92vw, 360px); + box-shadow: var(--shadow-lg); +} + +.toast.text-bg-success { + background: var(--success) !important; +} + +.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: 0.15rem 0.4rem; +} + +@media (max-width: 991.98px) { + .hero-title { + max-width: 100%; + } + + .section-heading h2 { + max-width: 100%; + } + + .navbar-collapse { + padding-top: 1rem; + } +} + +@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%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; + } + + .footer-inner { + align-items: flex-start; + } } - -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } -} - -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; -} - -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); - font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; - align-items: center; -} - -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; -} - -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; -} - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { - display: flex; - gap: 0.75rem; -} - -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); -} - -/* Background Animations */ -.bg-animations { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); -} - -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); -} - -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; -} - -.table th { - background: transparent; - border: none; - padding: 1rem; - color: #6c757d; - font-weight: 600; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; -} - -.table td { - 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 { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - font-size: 0.9rem; -} - -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; -} - -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); -} - -.header-container { - display: flex; - justify-content: space-between; - align-items: center; -} - -.header-links { - display: flex; - gap: 1rem; -} - -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); -} - -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; - font-weight: 700; -} - -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; -} - -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; -} - -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; -} - -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; -} - -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); -} - -.history-table { - width: 100%; -} - -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; -} - -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; -} - -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; -} - -.no-messages { - text-align: center; - color: #777; -} \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index d349598..972c25f 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -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(); + } - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + const modal = document.getElementById('projectModal'); + if (modal) { + modal.addEventListener('show.bs.modal', (event) => { + const trigger = event.relatedTarget; + if (!trigger) return; - appendMessage(message, 'visitor'); - chatInput.value = ''; + const mapping = { + projectModalLabel: 'title', + projectModalKicker: 'kicker', + projectModalDescription: 'description', + projectModalImpact: 'impact', + projectModalDeliverables: 'deliverables', + projectModalStack: 'stack', + }; - 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'); - } + Object.entries(mapping).forEach(([elementId, dataKey]) => { + const element = document.getElementById(elementId); + if (!element) return; + element.textContent = trigger.getAttribute(`data-${dataKey}`) || ''; + }); }); + } + + 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); + } }); diff --git a/contact_submit.php b/contact_submit.php new file mode 100644 index 0000000..1056520 --- /dev/null +++ b/contact_submit.php @@ -0,0 +1,111 @@ + $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; diff --git a/db/migrations/20260408_create_contact_requests.sql b/db/migrations/20260408_create_contact_requests.sql new file mode 100644 index 0000000..2f27b6d --- /dev/null +++ b/db/migrations/20260408_create_contact_requests.sql @@ -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; diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..324ae6f --- /dev/null +++ b/healthz.php @@ -0,0 +1,11 @@ + 'ok', + 'service' => 'portfolio-site', + 'time' => gmdate('c'), +], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); diff --git a/index.php b/index.php index 7205f3d..341b056 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,455 @@ '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); ?> - - - New Style - + + + <?= h($projectName) ?> — Personal Portfolio + + + + - - - - - - + + - - - - + + + + + + - - + + + + -
-
-

Analyzing your requirements and generating your website…

-
- Loading… +
+ + +
+
+
+
+
+ Independent designer & developer +

A precise personal site that makes your work easy to trust.

+

I help founders, consultants, and small teams present their work with clarity—so visitors can understand the value fast and confidently reach out.

+ +
+
+
+ 12+ + Recent launches +
+
+
+
+ 28% + Avg. conversion lift +
+
+
+
+ 72h + Typical response time +
+
+
+
+
+ +
+
+
+
+ +
+
+
+ Portfolio +

Selected work focused on clarity, conversion, and trust.

+

Each project starts with positioning and ends with a clean interface visitors can move through without friction.

+
+
+ + +
+
+
+ +

+

+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+ About +

I build portfolio and marketing experiences that feel deliberate.

+
+
+
+
+
+

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.

+

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.

+
+
+
+
Services
+
Design systems, landing pages, portfolio sites, UX refreshes
+
+
+
+
+
Typical clients
+
Consultants, founders, creative studios, early-stage teams
+
+
+
+
+
Working style
+
Lean, collaborative, direct, detail-oriented
+
+
+
+
+
Location
+
Remote-friendly, available internationally
+
+
+
+
+
+
+
+
+
+ +
+
+
+ Testimonials +

Feedback from teams that needed sharp, usable work quickly.

+
+
+ +
+
+
+
+
+ + +
+
+
+
+ +
+
+
+ +
+
+
+
+
+ Contact +

Tell me what you need and I’ll follow up with next steps.

+

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.

+
+
+
+
+
What to include
+

Scope, timeline, goals, and anything that helps define the problem. Short is fine—clarity is better than length.

+
+
+
+
+
Response workflow
+

Submissions are saved to the inbox view at /requests.php for review and follow-up.

+
+
+ + + +
+
+
+
+
+
+
+ + +
Please enter your name.
+
+
+ + +
Please enter a valid email.
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
Minimum 20 characters.0 / 2500
+
Please add a little more detail so I can understand the request.
+
+
+ + You’ll see a confirmation here after submission. +
+
+
+
+
+
+
+
+
+ + +
+ +
- + + +
+ +
+
+
+ + +
+ +
+
+ +
+ + + diff --git a/requests.php b/requests.php new file mode 100644 index 0000000..ae30177 --- /dev/null +++ b/requests.php @@ -0,0 +1,183 @@ +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.'; +} +?> + + + + + + <?= h($projectName) ?> — Inquiry Inbox + + + + + + + + + + + + + + + + + + + +
+
+
+ Admin inbox +

Contact requests

+

A minimal review view for messages submitted from the portfolio contact form.

+
+ + + + +
+
+
Total requests
+
+
+
New
+
+
+
Today
+
+
+ + +
+
+

No inquiries yet

+

Submit the contact form from the homepage to populate this inbox.

+ Open contact form +
+
+ +
+
+
+
+

Inbox list

+

Select a request to inspect the full message.

+
+
+ +
+
+
+
+ +
+
+
+
+ Selected inquiry +

+

+
+ +
+
+
Company
+
Project type
+
Budget
+
+
+
Message
+
+
+
+
Submitted
+
Source
+
+
+
+ +
+
+ + +
+
+ + + + +