Compare commits

...

11 Commits

Author SHA1 Message Date
jasonMPM
cf9343185f Merge pull request #13 from jasonMPM/claude/isp-website-docker-EB8pB
Add delete message action to staff portal
2026-02-28 21:31:35 -06:00
Claude
132d2c3a34 Add delete message action to staff portal
Adds a Delete button to each message card in the staff inbox so staff
can remove spam or dead-end submissions. Confirmation dialog (via
JS confirm()) prevents accidental deletions. Implementation:

- POST handler: DELETE FROM contacts WHERE id = ? (parameterized)
- .btn--danger CSS variant (red-tinted, matches dark theme)
- Delete button rendered in every message footer alongside existing
  Mark as read action

https://claude.ai/code/session_015wpwmheufcxkBuXivrSHhd
2026-03-01 03:27:28 +00:00
jasonMPM
650698fbc5 Update footer.php 2026-02-28 21:24:41 -06:00
jasonMPM
03398b98c2 Merge pull request #12 from jasonMPM/claude/isp-website-docker-EB8pB
Rewrite roadmap to reflect B2B networking services focus
2026-02-28 21:20:13 -06:00
Claude
ab490dd679 Rewrite roadmap to reflect B2B networking services focus
Removed ISP-generic items (residential/business plan tiers, coverage
zone polygons, address lookup, subscription billing setup). Updated
milestones to match what the site actually is — a B2B networking
services and integration company:

- Milestone 1: marked services page and staff inbox as complete
- Milestone 2: renamed Content & Plans → Content & Branding; replaced
  plan tier items with real content goals (about, contact info, logo,
  stats, email notifications)
- Milestone 3: renamed Coverage Map → Portfolio & Project Showcase;
  replaced ISP coverage tools with project case studies and per-service
  detail pages
- Milestone 4: reframed Customer Portal as Client Portal with project
  tracking and network documentation instead of billing dashboard
- Milestone 5: simplified Billing → Invoicing & Payments; removed
  recurring subscription framing, kept retainer + one-time invoices
- Milestones 6–8: unchanged, remain applicable

https://claude.ai/code/session_015wpwmheufcxkBuXivrSHhd
2026-03-01 03:18:08 +00:00
jasonMPM
11ac41e276 Merge pull request #11 from jasonMPM/claude/isp-website-docker-EB8pB
Update contact subject dropdown to match actual services; update README
2026-02-28 21:15:27 -06:00
Claude
5c8c406082 Update contact subject dropdown to match actual services; update README
Contact form subject options now match the five service lines on the site:
New Project Inquiry, Mesh Networking, Managed Services, Structured
Cabling, Access Control, IP Camera Systems, Technical Support, Other.
Removed the old ISP-generic options (billing, coverage, new-service).
Updated $subject_labels in admin-inbox.php to match.

README updates:
- ADMIN_PASS added to web container env vars table and env vars section
- Project structure updated to include db.php and admin-inbox.php
- Milestone 1 contact form item updated to note MySQL storage
- Milestone 4 marks contact DB storage and staff inbox as complete;
  email notification remains as the next open item

https://claude.ai/code/session_015wpwmheufcxkBuXivrSHhd
2026-03-01 03:14:02 +00:00
jasonMPM
6503bdd6d6 Merge pull request #10 from jasonMPM/claude/isp-website-docker-EB8pB
Add contact form DB storage and hidden staff inbox
2026-02-28 21:08:36 -06:00
Claude
40e3f73aaf Add contact form DB storage and hidden staff inbox
- contact.php now inserts submissions into MySQL via PDO prepared
  statements; raw values stored (htmlspecialchars moved to output only)
- www/includes/db.php: shared PDO helper with auto-migration that adds
  the is_read column to existing deployments without a full DB reset
- docker/mysql/init.sql: added is_read TINYINT column to contacts table
  for fresh deploys
- www/pages/admin-inbox.php: self-contained staff inbox at /staff-portal
  with session-based password login, per-message mark-as-read, and
  mark-all-read; unread count shown in browser tab title
- index.php: routes /staff-portal before public header/footer so the
  admin page is fully standalone
- docker-compose.yml: ADMIN_PASS env var wired to web container

Set ADMIN_PASS in .env (gitignored) before deploying.
If the DB volume already exists, the auto-migration in db.php will
add the is_read column automatically on first request.

https://claude.ai/code/session_015wpwmheufcxkBuXivrSHhd
2026-03-01 03:05:18 +00:00
jasonMPM
3151bb8d4b Merge pull request #9 from jasonMPM/claude/isp-website-docker-EB8pB
Apply hero card via inline style to bypass CSS caching
2026-02-28 20:52:53 -06:00
Claude
6b44fe8b4c Apply hero card via inline style to bypass CSS caching
CSS file changes were not rendering - likely due to Apache mod_expires
caching style.css for 1 week in the browser. Two fixes:
- Cache-bust the stylesheet link (?v=4) so the browser is forced to
  fetch a fresh copy of the CSS file
- Move the dark card and heading size fix to inline styles on the hero
  div directly in home.php, so the change lives in the PHP output and
  is not subject to any CSS caching layer

https://claude.ai/code/session_015wpwmheufcxkBuXivrSHhd
2026-03-01 02:49:45 +00:00
10 changed files with 365 additions and 48 deletions

View File

@@ -127,6 +127,7 @@ docker build -t alwisp_web:latest .
| `DB_NAME` | `alwisp` | | `DB_NAME` | `alwisp` |
| `DB_USER` | `alwisp_user` | | `DB_USER` | `alwisp_user` |
| `DB_PASS` | *(same password set in Step 3)* | | `DB_PASS` | *(same password set in Step 3)* |
| `ADMIN_PASS` | *(password for the staff inbox at `/staff-portal`)* |
4. Add **Path/Volume Mappings**: 4. Add **Path/Volume Mappings**:
@@ -216,6 +217,7 @@ Because the web container has a dedicated LAN IP, reverse proxy setup is straigh
| `DB_NAME` | Database name (default: `alwisp`) | | `DB_NAME` | Database name (default: `alwisp`) |
| `DB_USER` | Application DB user (default: `alwisp_user`) | | `DB_USER` | Application DB user (default: `alwisp_user`) |
| `DB_PASS` | Application DB password — make this strong | | `DB_PASS` | Application DB password — make this strong |
| `ADMIN_PASS` | Password for the staff inbox (`/staff-portal`) — change before going live |
--- ---
@@ -243,13 +245,15 @@ alwisp/
├── assets/ # Logos, images ├── assets/ # Logos, images
├── includes/ ├── includes/
│ ├── header.php # Global nav │ ├── header.php # Global nav
── footer.php # Global footer ── footer.php # Global footer
│ └── db.php # PDO helper (auto-migrates schema)
└── pages/ └── pages/
├── home.php ├── home.php
├── services.php ├── services.php
├── coverage.php ├── coverage.php
├── about.php ├── about.php
├── contact.php ├── contact.php # Stores submissions in MySQL
├── admin-inbox.php # Staff inbox at /staff-portal (password-gated)
└── 404.php └── 404.php
``` ```
@@ -261,51 +265,52 @@ alwisp/
- [x] Dockerized LAMP stack (PHP 8.2, Apache, MySQL 8.0) - [x] Dockerized LAMP stack (PHP 8.2, Apache, MySQL 8.0)
- [x] Front-controller PHP router - [x] Front-controller PHP router
- [x] Responsive site skeleton with brand design system - [x] Responsive site skeleton with brand design system
- [x] Homepage: hero, stats bar, services preview, why section, coverage CTA - [x] Homepage: hero, stats bar, services preview, why-us section, CTA
- [x] Contact form with server-side validation - [x] Full services page: Mesh Networking, Managed Services, Structured Cabling, Access Control, IP Cameras
- [x] Stub pages for all top-level routes (services, coverage, about, contact, 404) - [x] Contact form with server-side validation and MySQL storage
- [x] Staff inbox (`/staff-portal`) — password-gated, mark read/unread
- [x] Security headers, OPcache, and Apache hardening - [x] Security headers, OPcache, and Apache hardening
- [x] Unraid-ready deployment via Docker Compose - [x] Unraid-ready deployment via Docker Compose
--- ---
### Milestone 2 — Content & Plans ### Milestone 2 — Content & Branding
- [ ] Populate residential plan tiers (speed tiers, pricing cards)
- [ ] Populate business plan tiers
- [ ] Infrastructure and managed services detail pages
- [ ] About page — company story, team bios, mission statement - [ ] About page — company story, team bios, mission statement
- [ ] Real contact info (phone, email, address) wired into header and footer - [ ] Real contact info (phone, email, service area) wired into footer and contact page
- [ ] Logo assets added to `/www/assets/` and displayed in nav - [ ] Logo finalized and displayed in nav and footer
- [ ] Homepage stats updated to real figures (projects completed, clients, uptime SLA)
- [ ] Email notification on contact form submission (PHPMailer)
--- ---
### Milestone 3 — Coverage Map ### Milestone 3 — Portfolio & Project Showcase
- [ ] Embed interactive map (Leaflet.js + OpenStreetMap) - [ ] Portfolio page — completed project write-ups with photos, scope, and outcomes
- [ ] Define and store coverage zone polygons in the database - [ ] Per-project detail pages (mesh deployment, cabling job, access control install, etc.)
- [ ] Address lookup / service availability check on the homepage CTA - [ ] Service area section — map or region list showing where ALWISP operates
- [ ] Mobile-optimized map view - [ ] Link portfolio from services page CTAs
--- ---
### Milestone 4 — Customer Portal (Phase 1) ### Milestone 4 — Client Portal
- [ ] Customer account creation and login (PHP sessions) - [x] Contact form stores submissions in MySQL
- [ ] Account dashboard — plan details, billing status, support tickets - [x] Staff inbox — password-gated, mark read/unread
- [ ] Contact form wired to database and email notification (PHPMailer) - [ ] Email notification on new submission (PHPMailer)
- [ ] Admin panel — view and respond to contact submissions - [ ] Client account login (PHP sessions)
- [ ] Client dashboard — active projects, network documentation, support requests
- [ ] Quote request tracking — link submitted contact forms to project records
- [ ] Password reset via email token - [ ] Password reset via email token
--- ---
### Milestone 5 — Billing Integration ### Milestone 5 — Invoicing & Payments
- [ ] Stripe payment integration for online bill pay - [ ] Invoice generation and PDF delivery
- [ ] Invoice generation and PDF download - [ ] Online payment via Stripe (one-time project invoices and managed service retainers)
- [ ] Recurring payment setup - [ ] Payment history in client dashboard
- [ ] Payment history view in customer portal
--- ---
### Milestone 6 — Network Status Page ### Milestone 6 — Managed Services Status Page
- [ ] Public status page showing uptime and active incidents - [ ] Public status page showing uptime of monitored client networks
- [ ] Admin interface to post and resolve incidents - [ ] Admin interface to post and resolve incidents
- [ ] Automated uptime monitoring hook (Uptime Kuma or similar) - [ ] Automated uptime monitoring hook (Uptime Kuma or similar)
- [ ] Email/SMS notification for outages - [ ] Email/SMS notification for outages
@@ -315,10 +320,9 @@ alwisp/
### Milestone 7 — SEO, Performance & Security ### Milestone 7 — SEO, Performance & Security
- [ ] SSL/TLS configured end-to-end (Let's Encrypt via Nginx Proxy Manager) - [ ] SSL/TLS configured end-to-end (Let's Encrypt via Nginx Proxy Manager)
- [ ] Sitemap.xml and robots.txt - [ ] Sitemap.xml and robots.txt
- [ ] Open Graph and Twitter Card meta tags for social sharing - [ ] Open Graph meta tags for social sharing
- [ ] Image optimization pipeline (WebP conversion on upload)
- [ ] Content Security Policy header tuned - [ ] Content Security Policy header tuned
- [ ] Security audit and pen test - [ ] Security audit
--- ---

View File

@@ -18,6 +18,7 @@ services:
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASS=${DB_PASS} - DB_PASS=${DB_PASS}
- ADMIN_PASS=${ADMIN_PASS}
depends_on: depends_on:
- db - db
networks: networks:

View File

@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS `contacts` (
`phone` VARCHAR(30), `phone` VARCHAR(30),
`subject` VARCHAR(255), `subject` VARCHAR(255),
`message` TEXT NOT NULL, `message` TEXT NOT NULL,
`is_read` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB; ) ENGINE=InnoDB;

35
www/includes/db.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
/**
* Returns a shared PDO connection to the MySQL database.
* Also ensures the `is_read` column exists (added after initial deploy).
*/
function get_db(): PDO {
static $pdo = null;
if ($pdo !== null) return $pdo;
$host = getenv('DB_HOST') ?: 'db';
$name = getenv('DB_NAME') ?: 'alwisp';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASS') ?: '';
$pdo = new PDO(
"mysql:host=$host;dbname=$name;charset=utf8mb4",
$user, $pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
// Auto-migrate: add is_read if the DB was initialised before this column existed
$col_exists = $pdo->query(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'contacts' AND COLUMN_NAME = 'is_read'"
)->fetchColumn();
if (!$col_exists) {
$pdo->exec("ALTER TABLE contacts ADD COLUMN is_read TINYINT(1) NOT NULL DEFAULT 0");
}
return $pdo;
}

View File

@@ -34,7 +34,7 @@
<div class="footer__contact"> <div class="footer__contact">
<h4 class="footer__heading">Contact</h4> <h4 class="footer__heading">Contact</h4>
<p>📞 <a href="tel:+1-000-000-0000">(000) 000-0000</a></p> <p>📞 <a href="tel:+1-000-000-0000">(000) 000-0000</a></p>
<p> <a href="mailto:info@alwisp.net">info@alwisp.net</a></p> <p> <a href="mailto:info@alwisp.net">info@alwisp.com</a></p>
<div class="footer__social"> <div class="footer__social">
<!-- Social icons add links when ready --> <!-- Social icons add links when ready -->
<a href="#" aria-label="Facebook" class="footer__social-link">FB</a> <a href="#" aria-label="Facebook" class="footer__social-link">FB</a>

View File

@@ -13,7 +13,7 @@
<!-- Google Fonts: Inter (body) + Space Grotesk (headings) --> <!-- Google Fonts: Inter (body) + Space Grotesk (headings) -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css?v=4">
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon"> <link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
</head> </head>
<body> <body>

View File

@@ -2,6 +2,12 @@
// Simple front controller — expand routing here later // Simple front controller — expand routing here later
$path = trim($_GET['path'] ?? '', '/'); $path = trim($_GET['path'] ?? '', '/');
// Staff inbox — self-contained, no public header/footer
if ($path === 'staff-portal') {
require __DIR__ . '/pages/admin-inbox.php';
exit;
}
// Map paths to page includes // Map paths to page includes
$pages = [ $pages = [
'' => 'pages/home.php', '' => 'pages/home.php',

255
www/pages/admin-inbox.php Normal file
View File

@@ -0,0 +1,255 @@
<?php
/**
* ALWISP Staff Inbox
* Hidden admin page for viewing contact form submissions.
* Access: /staff-portal (not linked anywhere on the public site)
* Password is set via ADMIN_PASS environment variable.
*/
session_start();
$admin_pass = getenv('ADMIN_PASS') ?: '';
// ── Logout ────────────────────────────────────────────────────────────────
if (isset($_GET['logout'])) {
session_destroy();
header('Location: /staff-portal');
exit;
}
// ── Login ─────────────────────────────────────────────────────────────────
$login_error = '';
if (!isset($_SESSION['alwisp_admin'])) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['password'])) {
if ($admin_pass !== '' && hash_equals($admin_pass, $_POST['password'])) {
$_SESSION['alwisp_admin'] = true;
header('Location: /staff-portal');
exit;
}
$login_error = 'Incorrect password.';
}
// Show login gate
http_response_code(200);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Staff Portal ALWISP</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0a0f1e; color: #e2e8f0; font-family: system-ui, sans-serif;
display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.card { background: #111827; border: 1px solid #1e2d4a; border-radius: 12px;
padding: 2.5rem 2rem; width: 100%; max-width: 360px; }
h1 { font-size: 1.25rem; margin-bottom: 1.5rem; color: #fff; }
label { display: block; font-size: .85rem; color: #94a3b8; margin-bottom: .4rem; }
input { width: 100%; padding: .65rem .9rem; border-radius: 8px;
border: 1px solid #1e2d4a; background: #0a0f1e; color: #e2e8f0;
font-size: 1rem; margin-bottom: 1.2rem; }
button { width: 100%; padding: .7rem; border-radius: 8px; border: none;
background: #2563eb; color: #fff; font-size: 1rem; cursor: pointer; }
button:hover { background: #1d4ed8; }
.error { background: #450a0a; color: #fca5a5; border-radius: 8px;
padding: .7rem 1rem; margin-bottom: 1rem; font-size: .9rem; }
</style>
</head>
<body>
<div class="card">
<h1>ALWISP Staff Portal</h1>
<?php if ($login_error): ?>
<div class="error"><?= htmlspecialchars($login_error) ?></div>
<?php endif; ?>
<form method="post">
<label for="pw">Password</label>
<input type="password" id="pw" name="password" autofocus autocomplete="current-password">
<button type="submit">Sign In</button>
</form>
</div>
</body>
</html>
<?php
exit;
}
// ── Authenticated handle actions ────────────────────────────────────────
require_once __DIR__ . '/../includes/db.php';
try {
$db = get_db();
} catch (PDOException $e) {
die('<p style="font-family:sans-serif;padding:2rem;color:red">Database connection failed: ' . htmlspecialchars($e->getMessage()) . '</p>');
}
// Mark a single message as read
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['mark_read'])) {
$db->prepare("UPDATE contacts SET is_read = 1 WHERE id = ?")->execute([(int)$_POST['mark_read']]);
header('Location: /staff-portal');
exit;
}
// Mark all as read
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['mark_all_read'])) {
$db->exec("UPDATE contacts SET is_read = 1");
header('Location: /staff-portal');
exit;
}
// Delete a single message
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_message'])) {
$db->prepare("DELETE FROM contacts WHERE id = ?")->execute([(int)$_POST['delete_message']]);
header('Location: /staff-portal');
exit;
}
// ── Fetch messages ────────────────────────────────────────────────────────
$messages = $db->query("SELECT * FROM contacts ORDER BY created_at DESC")->fetchAll();
$unread_count = (int)$db->query("SELECT COUNT(*) FROM contacts WHERE is_read = 0")->fetchColumn();
$subject_labels = [
'new-project' => 'New Project Inquiry',
'mesh-networking' => 'Mesh Networking',
'managed-services' => 'Managed Services',
'structured-cabling'=> 'Structured Cabling',
'access-control' => 'Access Control',
'ip-cameras' => 'IP Camera Systems',
'support' => 'Technical Support',
'other' => 'Other',
];
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES); }
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $unread_count ? "($unread_count) " : '' ?>Inbox ALWISP Staff</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0a0f1e; color: #e2e8f0; font-family: system-ui, sans-serif;
min-height: 100vh; padding: 0 0 4rem; }
/* ── Top bar ── */
.topbar { background: #111827; border-bottom: 1px solid #1e2d4a;
display: flex; align-items: center; justify-content: space-between;
padding: .9rem 1.5rem; }
.topbar__brand { font-weight: 700; font-size: 1rem; color: #fff; letter-spacing: .02em; }
.topbar__brand span { color: #3b82f6; }
.topbar__actions { display: flex; gap: .75rem; align-items: center; }
/* ── Buttons ── */
.btn { display: inline-block; padding: .45rem .9rem; border-radius: 7px; border: none;
font-size: .85rem; cursor: pointer; text-decoration: none; }
.btn--primary { background: #2563eb; color: #fff; }
.btn--primary:hover { background: #1d4ed8; }
.btn--ghost { background: transparent; color: #94a3b8; border: 1px solid #1e2d4a; }
.btn--ghost:hover { background: #1e2d4a; color: #e2e8f0; }
.btn--danger { background: transparent; color: #f87171; border: 1px solid #450a0a; }
.btn--danger:hover { background: #450a0a; color: #fca5a5; }
.btn--sm { padding: .3rem .65rem; font-size: .78rem; }
/* ── Container ── */
.wrap { max-width: 960px; margin: 2rem auto; padding: 0 1.25rem; }
/* ── Summary bar ── */
.summary { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
.badge { background: #2563eb; color: #fff; border-radius: 999px;
padding: .2rem .65rem; font-size: .8rem; font-weight: 600; }
.badge--zero { background: #1e2d4a; color: #64748b; }
.summary__text { color: #94a3b8; font-size: .9rem; }
/* ── Message cards ── */
.msg { background: #111827; border: 1px solid #1e2d4a; border-radius: 10px;
margin-bottom: 1rem; overflow: hidden; }
.msg--unread { border-color: #2563eb; }
.msg__header { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem;
padding: .9rem 1.1rem; border-bottom: 1px solid #1e2d4a; }
.msg__name { font-weight: 600; color: #fff; }
.msg__email { color: #3b82f6; font-size: .875rem; }
.msg__phone { color: #64748b; font-size: .85rem; }
.msg__tag { background: #1e2d4a; color: #94a3b8; border-radius: 5px;
padding: .15rem .55rem; font-size: .75rem; }
.msg__time { margin-left: auto; color: #64748b; font-size: .8rem; white-space: nowrap; }
.msg__body { padding: .9rem 1.1rem; }
.msg__message { color: #cbd5e1; font-size: .9rem; line-height: 1.65;
white-space: pre-wrap; word-break: break-word; }
.msg__footer { padding: .65rem 1.1rem; border-top: 1px solid #1e2d4a;
display: flex; align-items: center; gap: .5rem; }
.dot-unread { width: 8px; height: 8px; border-radius: 50%; background: #3b82f6;
flex-shrink: 0; }
.dot-read { width: 8px; height: 8px; border-radius: 50%; background: #1e2d4a;
flex-shrink: 0; }
.status-label { font-size: .8rem; color: #64748b; flex: 1; }
/* ── Empty state ── */
.empty { text-align: center; padding: 4rem 1rem; color: #64748b; }
.empty__icon { font-size: 2.5rem; margin-bottom: .75rem; }
</style>
</head>
<body>
<div class="topbar">
<div class="topbar__brand">AL<span>WISP</span> Staff Portal</div>
<div class="topbar__actions">
<?php if ($unread_count > 0): ?>
<form method="post" style="display:inline">
<button name="mark_all_read" value="1" class="btn btn--ghost btn--sm">Mark all read</button>
</form>
<?php endif; ?>
<a href="?logout=1" class="btn btn--ghost btn--sm">Sign out</a>
</div>
</div>
<div class="wrap">
<div class="summary">
<span class="badge <?= $unread_count === 0 ? 'badge--zero' : '' ?>"><?= $unread_count ?> unread</span>
<span class="summary__text"><?= count($messages) ?> total submission<?= count($messages) !== 1 ? 's' : '' ?></span>
</div>
<?php if (empty($messages)): ?>
<div class="empty">
<div class="empty__icon">📭</div>
<p>No messages yet. They'll show up here once someone submits the contact form.</p>
</div>
<?php else: ?>
<?php foreach ($messages as $m): ?>
<?php $unread = !$m['is_read']; ?>
<div class="msg <?= $unread ? 'msg--unread' : '' ?>">
<div class="msg__header">
<span class="msg__name"><?= h($m['name']) ?></span>
<a class="msg__email" href="mailto:<?= h($m['email']) ?>"><?= h($m['email']) ?></a>
<?php if ($m['phone']): ?>
<span class="msg__phone"><?= h($m['phone']) ?></span>
<?php endif; ?>
<?php if ($m['subject']): ?>
<span class="msg__tag"><?= h($subject_labels[$m['subject']] ?? $m['subject']) ?></span>
<?php endif; ?>
<span class="msg__time"><?= date('M j, Y g:ia', strtotime($m['created_at'])) ?></span>
</div>
<div class="msg__body">
<p class="msg__message"><?= h($m['message']) ?></p>
</div>
<div class="msg__footer">
<span class="<?= $unread ? 'dot-unread' : 'dot-read' ?>"></span>
<span class="status-label"><?= $unread ? 'Unread' : 'Read' ?></span>
<?php if ($unread): ?>
<form method="post">
<input type="hidden" name="mark_read" value="<?= (int)$m['id'] ?>">
<button class="btn btn--primary btn--sm">Mark as read</button>
</form>
<?php endif; ?>
<form method="post" onsubmit="return confirm('Delete message from <?= h(addslashes($m['name'])) ?>? This cannot be undone.')">
<input type="hidden" name="delete_message" value="<?= (int)$m['id'] ?>">
<button class="btn btn--danger btn--sm">Delete</button>
</form>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</body>
</html>

View File

@@ -1,21 +1,33 @@
<?php <?php
require_once __DIR__ . '/../includes/db.php';
$success = false; $success = false;
$errors = []; $errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim(htmlspecialchars($_POST['name'] ?? '', ENT_QUOTES)); // Store raw values; htmlspecialchars is applied only at output time
$email = trim(htmlspecialchars($_POST['email'] ?? '', ENT_QUOTES)); $name = trim($_POST['name'] ?? '');
$phone = trim(htmlspecialchars($_POST['phone'] ?? '', ENT_QUOTES)); $email = trim($_POST['email'] ?? '');
$subject = trim(htmlspecialchars($_POST['subject'] ?? '', ENT_QUOTES)); $phone = trim($_POST['phone'] ?? '');
$message = trim(htmlspecialchars($_POST['message'] ?? '', ENT_QUOTES)); $subject = trim($_POST['subject'] ?? '');
$message = trim($_POST['message'] ?? '');
if (!$name) $errors[] = 'Name is required.'; if (!$name) $errors[] = 'Name is required.';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'A valid email is required.'; if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'A valid email is required.';
if (!$message) $errors[] = 'Message is required.'; if (!$message) $errors[] = 'Message is required.';
if (empty($errors)) { if (empty($errors)) {
// TODO: swap for DB insert + email once credentials are configured try {
$success = true; $db = get_db();
$stmt = $db->prepare(
"INSERT INTO contacts (name, email, phone, subject, message)
VALUES (?, ?, ?, ?, ?)"
);
$stmt->execute([$name, $email, $phone, $subject, $message]);
$success = true;
} catch (PDOException $e) {
$errors[] = 'Sorry, we could not save your message right now. Please try again.';
}
} }
} }
?> ?>
@@ -76,10 +88,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<label for="subject" class="form__label">Subject</label> <label for="subject" class="form__label">Subject</label>
<select id="subject" name="subject" class="form__input"> <select id="subject" name="subject" class="form__input">
<option value="">Select a topic…</option> <option value="">Select a topic…</option>
<option value="new-service">New Service Inquiry</option> <option value="new-project">New Project Inquiry</option>
<option value="mesh-networking">Mesh Networking</option>
<option value="managed-services">Managed Services</option>
<option value="structured-cabling">Structured Cabling</option>
<option value="access-control">Access Control</option>
<option value="ip-cameras">IP Camera Systems</option>
<option value="support">Technical Support</option> <option value="support">Technical Support</option>
<option value="billing">Billing Question</option>
<option value="coverage">Coverage Question</option>
<option value="other">Other</option> <option value="other">Other</option>
</select> </select>
</div> </div>

View File

@@ -5,9 +5,9 @@
<!-- HERO --> <!-- HERO -->
<section class="hero" aria-labelledby="hero-heading"> <section class="hero" aria-labelledby="hero-heading">
<div class="hero__mesh-bg" aria-hidden="true"></div> <div class="hero__mesh-bg" aria-hidden="true"></div>
<div class="container hero__content"> <div class="container hero__content" style="background:rgba(8,14,38,0.75);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.12);border-radius:20px;padding:3rem 2.5rem;margin-block:3rem;max-width:780px;">
<p class="hero__eyebrow">Custom Networking Solutions</p> <p class="hero__eyebrow">Custom Networking Solutions</p>
<h1 id="hero-heading" class="hero__heading"> <h1 id="hero-heading" class="hero__heading" style="font-size:clamp(2.2rem,4.5vw,3.25rem);">
Networks Engineered<br> Networks Engineered<br>
<span class="gradient-text">Around Your Needs</span> <span class="gradient-text">Around Your Needs</span>
</h1> </h1>