Compare commits

10 Commits

Author SHA1 Message Date
Ty Clifford 354bb4255d - Rebuilt the CLI importer to use the official Facebook Graph API instead of HTML scraping.
Main changes:
Replaced 
[scripts/facebook-events-import.php](/Users/tyemeclifford/Documents/GH/MyKeyser/scripts/facebook-events-import.php) 
with a Graph API client.
It now supports --page-id, --pages-file, --event-id, --events-file, and 
optional area discovery via --discover-pages --center=LAT,LNG 
--query=....
Uses FACEBOOK_GRAPH_ACCESS_TOKEN or --access-token.
Pulls Graph fields for title, description, start/end time, 
place/location, ticket URI, and cover.source for the main photo.
Keeps the existing staged review/import workflow in 
/admin/event_imports.php.

- Implemented event detail pages and clickable event cards.
What changed:
Added [event.php](/Users/tyemeclifford/Documents/GH/MyKeyser/event.php) 
for individual event pages.
Made event cards clickable from 
[events.php](/Users/tyemeclifford/Documents/GH/MyKeyser/events.php), 
[index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/index.php), and 
legacy 
[2index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/2index.php).
Added large event photo display plus lightbox enlargement in 
[assets/css/style.css](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/css/style.css) 
and 
[assets/js/app.js](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/js/app.js).
Added eventUrl() and externalHref() helpers in 
[includes/functions.php](/Users/tyemeclifford/Documents/GH/MyKeyser/includes/functions.php).
Added copy-event-link support on event detail pages.
Updated README to list the new route.
2026-06-19 18:07:45 -04:00
Ty Clifford d83b62b79c - revisit later; avatar, WYSIWYG 2026-06-19 12:03:13 -04:00
Ty Clifford 25327e3302 - [index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/index.php): events now appear before featured businesses, plus forum pretty-route fallback.
[events.php](/Users/tyemeclifford/Documents/GH/MyKeyser/events.php) and 
[admin/events.php](/Users/tyemeclifford/Documents/GH/MyKeyser/admin/events.php): 
event image uploads.
[menu.php](/Users/tyemeclifford/Documents/GH/MyKeyser/menu.php), 
[admin/menus.php](/Users/tyemeclifford/Documents/GH/MyKeyser/admin/menus.php), 
and 
[business.php](/Users/tyemeclifford/Documents/GH/MyKeyser/business.php): 
one configurable menu item image slot.
[account.php](/Users/tyemeclifford/Documents/GH/MyKeyser/account.php): 
circular avatar upload and crop controls.
[forum.php](/Users/tyemeclifford/Documents/GH/MyKeyser/forum.php), 
[admin/forum.php](/Users/tyemeclifford/Documents/GH/MyKeyser/admin/forum.php), 
[.htaccess](/Users/tyemeclifford/Documents/GH/MyKeyser/.htaccess), and 
[forum/index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/forum/index.php): 
forum, categories, topic/reply uploads, permalinks, toggle/moderation.
[includes/db.php](/Users/tyemeclifford/Documents/GH/MyKeyser/includes/db.php) 
and 
[includes/functions.php](/Users/tyemeclifford/Documents/GH/MyKeyser/includes/functions.php): 
schema upgrades and shared upload/image helpers.
[assets/css/style.css](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/css/style.css): 
neon styling for forum, images, avatars, attachments.
2026-06-19 10:49:48 -04:00
Ty Clifford 2ce3eec65d - Hero description 2026-06-19 05:06:16 -04:00
Ty Clifford b40a8ee25a - v1.1.1 2026-06-18 14:56:04 -04:00
Ty Clifford 3a8b1edf27 - 2026-06-18 14:54:36 -04:00
Ty Clifford 3f7cac2a12 - title update 2026-06-18 14:51:37 -04:00
Ty Clifford cfd1fba5ab - Email verification (regular, business owner), CLI administration 2026-06-18 14:50:48 -04:00
Ty Clifford 66f93757b6 - v1.1.0 - Redesign 2026-06-18 12:50:44 -04:00
Ty Clifford f1844abad5 - 2026-06-18 12:50:18 -04:00
37 changed files with 5532 additions and 434 deletions
+5
View File
@@ -0,0 +1,5 @@
data/*.db
data/*.db-shm
data/*.db-wal
uploads/*
!uploads/.gitkeep
+3
View File
@@ -0,0 +1,3 @@
RewriteEngine On
RewriteRule ^forum/?$ forum.php [L,QSA]
RewriteRule ^forum/([^/]+)/?$ forum.php?topic=$1 [L,QSA]
+2 -2
View File
@@ -136,7 +136,7 @@ include __DIR__.'/includes/header.php';
<div class="sec-rule"></div> <div class="sec-rule"></div>
</div> </div>
<?php foreach($upevts as $ev): ?> <?php foreach($upevts as $ev): ?>
<div class="evt-card"> <a href="<?=e(eventUrl($ev))?>" class="evt-card evt-link-card">
<div class="evt-date-box"> <div class="evt-date-box">
<div class="evt-mon"><?=strtoupper(date('M',strtotime($ev['event_date'])))?></div> <div class="evt-mon"><?=strtoupper(date('M',strtotime($ev['event_date'])))?></div>
<div class="evt-day"><?=date('j',strtotime($ev['event_date']))?></div> <div class="evt-day"><?=date('j',strtotime($ev['event_date']))?></div>
@@ -151,7 +151,7 @@ include __DIR__.'/includes/header.php';
</div> </div>
<div class="evt-desc"><?=e(substr($ev['description'],0,180)).(strlen($ev['description'])>180?'…':'')?></div> <div class="evt-desc"><?=e(substr($ev['description'],0,180)).(strlen($ev['description'])>180?'…':'')?></div>
</div> </div>
</div> </a>
<?php endforeach; ?> <?php endforeach; ?>
<div style="text-align:center;margin-top:1.5rem"> <div style="text-align:center;margin-top:1.5rem">
<a href="/events.php" class="btn btn-outline">All Events</a> <a href="/events.php" class="btn btn-outline">All Events</a>
+50
View File
@@ -0,0 +1,50 @@
# Changelog
All notable changes to My Keyser / Discover Keyser WV will be documented in this file.
## [Unreleased]
## [v1.1.1] - 2026-06-18
### Added
- Added a neon dark-theme redesign across the public site, including a redesigned homepage layout focused on featured businesses, upcoming events, Keyser WV context, and rotating random business discovery.
- Added configurable homepage random business rotation, with admin controls for every-two-hours or daily refresh behavior.
- Added business-owner registration tier alongside regular member registration.
- Added business claim requests for existing listed businesses, including required contact phone number collection and admin review workflow.
- Added admin dashboard visibility for pending business-owner claim requests.
- Added owner-submitted immediate edit requests during business-owner signup, including optional YouTube or Vimeo video URLs.
- Added business video support using `lone-embed.php` for approved listing videos.
- Added admin business edit support for direct video URL management.
- Added email verification for regular users and business owners.
- Added Mailgun-based email sending with configurable US/EU API region, domain, API key, sender name, sender email, and site base URL.
- Added configurable email format mode: designed HTML email with text fallback or simple text-only email.
- Added branded verification and thank-you email templates matching the neon dark site theme.
- Added post-verification follow-up messaging for business owners explaining that a verification call will happen within a couple of business days.
- Added `verify-email.php` to consume verification tokens, activate users, and send follow-up emails.
- Added PHP CLI admin utility at `scripts/keyser-admin.php` for listing, showing, activating, deactivating, deleting, and updating businesses.
- Added CLI support for updating business fields including name, slug, category, description, address, phone, email, website, video URL, hours JSON, coordinates, active status, and featured status.
- Added CLI support for showing menus, wiping all menus, and wiping a specific business menu with explicit `--yes` confirmation.
### Changed
- Registration now requires an email address.
- New user accounts remain inactive until the emailed verification link is clicked.
- Login now blocks unverified users with a clear verification message.
- Business-owner signup now queues claims and initial edits but requires email verification before login.
- Owner edit review now supports website and video URL fields.
- Admin settings now include a dedicated email verification and Mailgun configuration area.
- Existing active users are marked as verified during schema upgrade so established admin/user accounts continue to work.
### Security
- Added expiring hashed email verification tokens.
- Added destructive-operation safeguards to CLI menu wipe and business delete commands via required `--yes`.
- Prevented unverified accounts from authenticating.
### Verification
- Verified public registration, login, verification, and admin settings routes locally.
- Verified unconfigured Mailgun state blocks registration cleanly without creating a user.
- Verified email verification token consumption activates a test account and marks the token used.
- Verified CLI read-only commands, invalid video URL rejection, and destructive-command confirmation guards.
+19 -1
View File
@@ -1,4 +1,4 @@
# Discover Keyser WV — Tourism Website # My Keyser — Tourism Website
### PHP/SQLite City Tourism Site for Keyser, West Virginia ### PHP/SQLite City Tourism Site for Keyser, West Virginia
--- ---
@@ -69,11 +69,24 @@ php -S localhost:8080
- **Alerts** — post/manage business announcements - **Alerts** — post/manage business announcements
- **Attractions** — full CRUD for attractions - **Attractions** — full CRUD for attractions
- **Events** — approve/reject submissions, add events, feature events - **Events** — approve/reject submissions, add events, feature events
- **Event Imports** — review Facebook event candidates staged by the CLI importer
- **Users** — add/edit/delete users, reset passwords, suspend accounts - **Users** — add/edit/delete users, reset passwords, suspend accounts
- **Assign Owners** — link users to businesses for self-management - **Assign Owners** — link users to businesses for self-management
- **Reports** — review and close user-submitted listing corrections - **Reports** — review and close user-submitted listing corrections
- **Settings** — toggle registration on/off, event approval, change admin password - **Settings** — toggle registration on/off, event approval, change admin password
### Facebook Event Import Workflow
Stage Facebook event candidates from the official Facebook Graph API, then review and import them from Admin -> Event Imports:
```bash
FACEBOOK_GRAPH_ACCESS_TOKEN=... php scripts/facebook-events-import.php --area="Keyser, WV" --page-id=123456789 --limit=20
php scripts/facebook-events-import.php --access-token=... --event-id=123456789012345
php scripts/facebook-events-import.php --access-token=... --area="Keyser, WV" --center=39.4364,-78.9762 --distance=25000 --query="Keyser" --discover-pages
```
The importer does not scrape Facebook, log in, or bypass access controls. Your Meta app/token must have permission to read the requested Page/Event data through Graph API.
--- ---
## FILE STRUCTURE ## FILE STRUCTURE
@@ -86,6 +99,7 @@ keyser-wv/
├── menu.php # Owner menu manager ├── menu.php # Owner menu manager
├── attractions.php # Attractions page ├── attractions.php # Attractions page
├── events.php # Events calendar + submission ├── events.php # Events calendar + submission
├── event.php # Event detail page
├── about.php # About Keyser WV history ├── about.php # About Keyser WV history
├── login.php # Login ├── login.php # Login
├── register.php # Registration ├── register.php # Registration
@@ -95,6 +109,7 @@ keyser-wv/
├── includes/ ├── includes/
│ ├── boot.php # Bootstrap (session + DB init) │ ├── boot.php # Bootstrap (session + DB init)
│ ├── db.php # Database, schema, seed data │ ├── db.php # Database, schema, seed data
│ ├── facebook_event_importer.php # Facebook event parser/import helpers
│ ├── functions.php # Auth, helpers, CSRF, stars │ ├── functions.php # Auth, helpers, CSRF, stars
│ ├── header.php # HTML header + navbar │ ├── header.php # HTML header + navbar
│ └── footer.php # Footer + JS │ └── footer.php # Footer + JS
@@ -107,10 +122,13 @@ keyser-wv/
│ ├── alerts.php # Manage alerts │ ├── alerts.php # Manage alerts
│ ├── attractions.php # Manage attractions │ ├── attractions.php # Manage attractions
│ ├── events.php # Manage events │ ├── events.php # Manage events
│ ├── event_imports.php # Review staged Facebook event imports
│ ├── users.php # Manage users │ ├── users.php # Manage users
│ ├── owners.php # Assign owners to businesses │ ├── owners.php # Assign owners to businesses
│ ├── reports.php # Handle listing reports │ ├── reports.php # Handle listing reports
│ └── settings.php # Site settings + password │ └── settings.php # Site settings + password
├── scripts/
│ └── facebook-events-import.php # CLI Facebook event stager
├── assets/ ├── assets/
│ ├── css/style.css # Complete dark WV theme │ ├── css/style.css # Complete dark WV theme
│ └── js/app.js # Nav, dropdowns, confirm dialogs │ └── js/app.js # Nav, dropdowns, confirm dialogs
+15 -1
View File
@@ -6,6 +6,8 @@ $pageTitle = ($adminTitle ?? 'Admin') . ' — Keyser WV Admin';
include dirname(__DIR__).'/includes/header.php'; include dirname(__DIR__).'/includes/header.php';
// Pending owner edit count for badge // Pending owner edit count for badge
$_pendingEdits = (int)qval("SELECT COUNT(*) FROM business_edit_requests WHERE status='pending'"); $_pendingEdits = (int)qval("SELECT COUNT(*) FROM business_edit_requests WHERE status='pending'");
$_pendingClaims = (int)qval("SELECT COUNT(*) FROM business_claim_requests WHERE status='pending'");
$_pendingEventImports = (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='staged'");
?> ?>
<div class="admin-wrap"> <div class="admin-wrap">
<aside class="admin-side"> <aside class="admin-side">
@@ -24,12 +26,24 @@ $_pendingEdits = (int)qval("SELECT COUNT(*) FROM business_edit_requests WHERE st
<?php endif; ?> <?php endif; ?>
</a> </a>
<div class="admin-side-title" style="margin-top:1rem">COMMUNITY</div> <div class="admin-side-title" style="margin-top:1rem">COMMUNITY</div>
<a class="asl" href="/admin/forum.php">💬 Forum</a>
<a class="asl" href="/admin/events.php">📅 Events</a> <a class="asl" href="/admin/events.php">📅 Events</a>
<a class="asl" href="/admin/event_imports.php" style="display:flex;align-items:center;justify-content:space-between">
<span>Event Imports</span>
<?php if ($_pendingEventImports): ?>
<span style="background:var(--gold);color:var(--bg0);font-family:'Oswald',sans-serif;font-size:.65rem;padding:.1rem .42rem;border-radius:10px;font-weight:600"><?=$_pendingEventImports?></span>
<?php endif; ?>
</a>
<a class="asl" href="/admin/attractions.php">🗺️ Attractions</a> <a class="asl" href="/admin/attractions.php">🗺️ Attractions</a>
<a class="asl" href="/admin/reports.php">🚩 Reports</a> <a class="asl" href="/admin/reports.php">🚩 Reports</a>
<div class="admin-side-title" style="margin-top:1rem">USERS</div> <div class="admin-side-title" style="margin-top:1rem">USERS</div>
<a class="asl" href="/admin/users.php">👥 Users</a> <a class="asl" href="/admin/users.php">👥 Users</a>
<a class="asl" href="/admin/owners.php">🔑 Assign Owners</a> <a class="asl" href="/admin/owners.php" style="display:flex;align-items:center;justify-content:space-between">
<span>🔑 Assign Owners</span>
<?php if ($_pendingClaims): ?>
<span style="background:var(--gold);color:var(--bg0);font-family:'Oswald',sans-serif;font-size:.65rem;padding:.1rem .42rem;border-radius:10px;font-weight:600"><?=$_pendingClaims?></span>
<?php endif; ?>
</a>
<div class="admin-side-title" style="margin-top:1rem">SETTINGS</div> <div class="admin-side-title" style="margin-top:1rem">SETTINGS</div>
<a class="asl" href="/admin/settings.php">⚙️ Site Settings</a> <a class="asl" href="/admin/settings.php">⚙️ Site Settings</a>
<hr style="border-color:var(--bdr);margin:1rem 0"> <hr style="border-color:var(--bdr);margin:1rem 0">
+10
View File
@@ -8,6 +8,8 @@ $fieldLabels = [
'address' => 'Address', 'address' => 'Address',
'phone' => 'Phone', 'phone' => 'Phone',
'email' => 'Email', 'email' => 'Email',
'website' => 'Website',
'video_url' => 'Video URL',
'hours' => 'Hours of Operation', 'hours' => 'Hours of Operation',
'is_active' => 'Listing Status (Open/Closed)', 'is_active' => 'Listing Status (Open/Closed)',
]; ];
@@ -34,6 +36,10 @@ if (isPost()) {
if (is_array($decoded)) { if (is_array($decoded)) {
qrun("UPDATE businesses SET hours=? WHERE id=?", [$val, $req['business_id']]); qrun("UPDATE businesses SET hours=? WHERE id=?", [$val, $req['business_id']]);
} }
} elseif ($field === 'video_url') {
if (validBusinessVideoUrl($val)) {
qrun("UPDATE businesses SET video_url=? WHERE id=?", [$val, $req['business_id']]);
}
} else { } else {
qrun("UPDATE businesses SET $field=? WHERE id=?", [$val, $req['business_id']]); qrun("UPDATE businesses SET $field=? WHERE id=?", [$val, $req['business_id']]);
} }
@@ -70,6 +76,10 @@ if (isPost()) {
if (is_array($decoded)) { if (is_array($decoded)) {
qrun("UPDATE businesses SET hours=? WHERE id=?", [$val, $bizId]); qrun("UPDATE businesses SET hours=? WHERE id=?", [$val, $bizId]);
} }
} elseif ($field === 'video_url') {
if (validBusinessVideoUrl($val)) {
qrun("UPDATE businesses SET video_url=? WHERE id=?", [$val, $bizId]);
}
} else { } else {
qrun("UPDATE businesses SET $field=? WHERE id=?", [$val, $bizId]); qrun("UPDATE businesses SET $field=? WHERE id=?", [$val, $bizId]);
} }
+14 -2
View File
@@ -40,6 +40,7 @@ if (isPost()) {
'phone' => ps('phone'), 'phone' => ps('phone'),
'email' => ps('email'), 'email' => ps('email'),
'website' => ps('website'), 'website' => ps('website'),
'video_url' => ps('video_url'),
'hours' => json_encode($hrs), 'hours' => json_encode($hrs),
'lat' => (float)p('lat', 39.4364), 'lat' => (float)p('lat', 39.4364),
'lng' => (float)p('lng', -78.9762), 'lng' => (float)p('lng', -78.9762),
@@ -47,12 +48,17 @@ if (isPost()) {
'is_featured' => (int)p('is_featured', 0), 'is_featured' => (int)p('is_featured', 0),
]; ];
if (!validBusinessVideoUrl($fields['video_url'])) {
flash('Business videos must be a YouTube or Vimeo URL.','error');
go('/admin/businesses.php'.($id?"?edit=$id":''));
}
if ($act === 'add') { if ($act === 'add') {
qrun("INSERT INTO businesses(name,slug,category_id,subcategory,description,address,phone,email,website,hours,lat,lng,is_active,is_featured)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", array_values($fields)); qrun("INSERT INTO businesses(name,slug,category_id,subcategory,description,address,phone,email,website,video_url,hours,lat,lng,is_active,is_featured)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", array_values($fields));
flash('Business added!','success'); flash('Business added!','success');
go('/admin/businesses.php'); go('/admin/businesses.php');
} else { } else {
qrun("UPDATE businesses SET name=?,slug=?,category_id=?,subcategory=?,description=?,address=?,phone=?,email=?,website=?,hours=?,lat=?,lng=?,is_active=?,is_featured=? WHERE id=?", array_merge(array_values($fields), [$id])); qrun("UPDATE businesses SET name=?,slug=?,category_id=?,subcategory=?,description=?,address=?,phone=?,email=?,website=?,video_url=?,hours=?,lat=?,lng=?,is_active=?,is_featured=? WHERE id=?", array_merge(array_values($fields), [$id]));
flash('Business updated!','success'); flash('Business updated!','success');
go('/admin/businesses.php?edit='.$id); go('/admin/businesses.php?edit='.$id);
} }
@@ -152,6 +158,12 @@ $bizs = qall("SELECT b.*,c.name cat FROM businesses b LEFT JOIN categories c
</div> </div>
</div> </div>
<div class="fg">
<label class="flabel">VIDEO URL</label>
<input type="url" name="video_url" class="finput" value="<?=e($editing['video_url']??'')?>" placeholder="YouTube or Vimeo URL">
<div class="fhint">Optional. Business listing videos are rendered through lone-embed.php and should use YouTube or Vimeo.</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="fg"> <div class="fg">
<label class="flabel">LATITUDE</label> <label class="flabel">LATITUDE</label>
+246
View File
@@ -0,0 +1,246 @@
<?php
require_once dirname(__DIR__).'/includes/boot.php';
require_once dirname(__DIR__).'/includes/facebook_event_importer.php';
requireAdmin();
$adminTitle = 'Event Imports';
function eventImportRowsFromPost(): array {
$rows = p('rows', []);
return is_array($rows) ? $rows : [];
}
function eventImportSelectedIds(): array {
$ids = p('ids', []);
if (!is_array($ids)) $ids = [];
return array_values(array_unique(array_filter(array_map('intval', $ids))));
}
function eventImportSavePostedRows(array $onlyIds = []): void {
$wanted = array_fill_keys($onlyIds, true);
foreach (eventImportRowsFromPost() as $id => $row) {
$id = (int)$id;
if ($id <= 0) continue;
if ($onlyIds && !isset($wanted[$id])) continue;
if (!is_array($row)) continue;
facebookEventSaveCandidateFields($id, $row);
}
}
$filter = gs('filter') ?: p('filter', 'staged');
if (!in_array($filter, ['staged', 'imported', 'skipped', 'all'], true)) $filter = 'staged';
if (isPost()) {
csrfCheck();
$eventStatus = in_array(p('event_status', 'pending'), ['pending', 'approved'], true) ? p('event_status', 'pending') : 'pending';
$allowDuplicate = p('allow_duplicates', '0') === '1';
if (($saveId = (int)p('save_id', 0)) > 0) {
eventImportSavePostedRows([$saveId]);
flash('Import candidate saved.', 'success');
go('/admin/event_imports.php?filter='.$filter);
}
if (($importId = (int)p('import_id', 0)) > 0) {
eventImportSavePostedRows([$importId]);
$result = facebookEventImportCandidate($importId, uid(), $eventStatus, $allowDuplicate);
flash($result['ok'] ? 'Event imported.'.(!empty($result['warning']) ? ' '.$result['warning'] : '') : $result['error'], $result['ok'] ? 'success' : 'error');
go('/admin/event_imports.php?filter='.$filter);
}
if (($skipId = (int)p('skip_id', 0)) > 0) {
qrun("UPDATE event_imports SET status='skipped',updated_at=datetime('now') WHERE id=? AND status!='imported'", [$skipId]);
flash('Import candidate skipped.', 'success');
go('/admin/event_imports.php?filter='.$filter);
}
if (($restoreId = (int)p('restore_id', 0)) > 0) {
qrun("UPDATE event_imports SET status='staged',updated_at=datetime('now') WHERE id=? AND status='skipped'", [$restoreId]);
flash('Import candidate restored.', 'success');
go('/admin/event_imports.php?filter=staged');
}
if (($deleteId = (int)p('delete_id', 0)) > 0) {
qrun("DELETE FROM event_imports WHERE id=? AND status!='imported'", [$deleteId]);
flash('Import candidate deleted.', 'success');
go('/admin/event_imports.php?filter='.$filter);
}
$act = p('_act', '');
if ($act === 'save_all') {
eventImportSavePostedRows();
flash('Import candidates saved.', 'success');
go('/admin/event_imports.php?filter='.$filter);
}
if ($act === 'bulk_import') {
$ids = eventImportSelectedIds();
eventImportSavePostedRows($ids);
$ok = 0; $errors = [];
foreach ($ids as $id) {
$result = facebookEventImportCandidate($id, uid(), $eventStatus, $allowDuplicate);
if ($result['ok']) $ok++;
else $errors[] = '#'.$id.': '.$result['error'];
}
if ($ok) flash($ok.' event'.($ok === 1 ? '' : 's').' imported.', 'success');
if ($errors) flash(implode(' ', $errors), 'error');
if (!$ids) flash('Select at least one staged import first.', 'warning');
go('/admin/event_imports.php?filter='.$filter);
}
if ($act === 'bulk_skip') {
$ids = eventImportSelectedIds();
foreach ($ids as $id) qrun("UPDATE event_imports SET status='skipped',updated_at=datetime('now') WHERE id=? AND status!='imported'", [$id]);
flash($ids ? count($ids).' import candidate'.(count($ids) === 1 ? '' : 's').' skipped.' : 'Select at least one staged import first.', $ids ? 'success' : 'warning');
go('/admin/event_imports.php?filter='.$filter);
}
if ($act === 'bulk_delete') {
$ids = eventImportSelectedIds();
foreach ($ids as $id) qrun("DELETE FROM event_imports WHERE id=? AND status!='imported'", [$id]);
flash($ids ? count($ids).' import candidate'.(count($ids) === 1 ? '' : 's').' deleted.' : 'Select at least one staged import first.', $ids ? 'success' : 'warning');
go('/admin/event_imports.php?filter='.$filter);
}
}
$where = match ($filter) {
'staged' => "WHERE status='staged'",
'imported' => "WHERE status='imported'",
'skipped' => "WHERE status='skipped'",
default => '',
};
$imports = qall("SELECT * FROM event_imports $where ORDER BY CASE status WHEN 'staged' THEN 0 WHEN 'skipped' THEN 1 ELSE 2 END, created_at DESC LIMIT 200");
$counts = [
'staged' => (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='staged'"),
'imported' => (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='imported'"),
'skipped' => (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='skipped'"),
'all' => (int)qval("SELECT COUNT(*) FROM event_imports"),
];
include __DIR__.'/admin_header.php';
?>
<?php adminTitle('Facebook Event Imports', 'Review staged Facebook event candidates, correct any fields, then import selected rows into the normal My Keyser events workflow.') ?>
<div class="ibox" style="border-color:var(--gold-d)">
<div class="ibox-title">CLI STAGING</div>
<p style="color:var(--text-m);font-size:.9rem;line-height:1.65;margin-bottom:.8rem">
Stage Facebook event candidates through the official Graph API, then return here to verify titles, dates, locations, duplicates, and photos before publishing or sending them to pending review.
</p>
<code style="display:block;white-space:normal;background:var(--bg2);border:1px solid var(--bdr);border-radius:var(--r);padding:.8rem;color:var(--text-m)">FACEBOOK_GRAPH_ACCESS_TOKEN=... php scripts/facebook-events-import.php --area="Keyser, WV" --page-id=123456789 --limit=20</code>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem">
<div class="flex-row" style="gap:.4rem">
<?php foreach(['staged'=>'Staged','imported'=>'Imported','skipped'=>'Skipped','all'=>'All'] as $key => $label): ?>
<a href="/admin/event_imports.php?filter=<?=$key?>" class="btn btn-xs<?=$filter===$key?' btn-primary':' btn-outline'?>"><?=$label?> (<?=$counts[$key]?>)</a>
<?php endforeach; ?>
</div>
<a href="/admin/events.php" class="btn btn-xs btn-outline">Manage Events</a>
</div>
<?php if (!$imports): ?>
<div class="empty"><div class="empty-ico">+</div><h3>No event imports here</h3><p style="margin-top:.5rem">Run the Graph API CLI importer to stage Facebook event candidates for review.</p></div>
<?php else: ?>
<form method="POST">
<?=csrfField()?>
<input type="hidden" name="filter" value="<?=e($filter)?>">
<div class="ibox" style="position:sticky;top:76px;z-index:2">
<div class="flex-row" style="justify-content:space-between;align-items:flex-end">
<div class="flex-row">
<div class="fg" style="margin-bottom:0;min-width:180px">
<label class="flabel">IMPORT AS</label>
<select name="event_status" class="fselect">
<option value="pending">Pending review</option>
<option value="approved">Approved public event</option>
</select>
</div>
<label style="display:flex;align-items:center;gap:.45rem;color:var(--text-m);font-size:.86rem;margin-top:1.25rem">
<input type="checkbox" name="allow_duplicates" value="1"> Allow duplicate title/date
</label>
</div>
<div class="flex-row" style="gap:.4rem">
<button type="submit" name="_act" value="save_all" class="btn btn-xs btn-outline">Save Edits</button>
<button type="submit" name="_act" value="bulk_import" class="btn btn-xs btn-success">Import Selected</button>
<button type="submit" name="_act" value="bulk_skip" class="btn btn-xs btn-secondary">Skip Selected</button>
<button type="submit" name="_act" value="bulk_delete" class="btn btn-xs btn-danger" data-confirm="Delete selected import candidates?">Delete Selected</button>
</div>
</div>
</div>
<div style="display:grid;gap:1rem">
<?php foreach ($imports as $imp): ?>
<?php
$id = (int)$imp['id'];
$issues = facebookEventCandidateIssues($imp);
$duplicateId = facebookEventDuplicateId($imp);
$statusClass = ['staged' => 'badge-gold', 'imported' => 'badge-green', 'skipped' => 'badge-gray'][$imp['status']] ?? 'badge-gray';
$preview = $imp['image_path'] ?: $imp['image_url'];
$canEdit = $imp['status'] !== 'imported';
?>
<div class="ibox" style="margin-bottom:0">
<div class="event-import-grid">
<div>
<?php if ($imp['status'] === 'staged'): ?>
<label style="display:flex;align-items:center;gap:.45rem;color:var(--text-m);font-size:.86rem;margin-bottom:.55rem">
<input type="checkbox" name="ids[]" value="<?=$id?>"> Select
</label>
<?php endif; ?>
<div class="event-import-preview">
<?php if ($preview): ?>
<img src="<?=e($preview)?>" alt="<?=e($imp['title'] ?: 'Event image')?>">
<?php else: ?>
No image
<?php endif; ?>
</div>
<div class="flex-row" style="gap:.35rem;margin-top:.65rem">
<span class="badge <?=$statusClass?>"><?=strtoupper(e($imp['status']))?></span>
<?php if (!$issues && !$duplicateId && $imp['status'] === 'staged'): ?><span class="badge badge-green">READY</span><?php endif; ?>
<?php if ($issues): ?><span class="badge badge-red">NEEDS FIX</span><?php endif; ?>
<?php if ($duplicateId && $imp['status'] === 'staged'): ?><span class="badge badge-gold">DUPLICATE?</span><?php endif; ?>
</div>
<?php if ($imp['source_url']): ?><p style="font-size:.78rem;margin-top:.6rem"><a href="<?=e($imp['source_url'])?>" target="_blank">View source</a></p><?php endif; ?>
<?php if ($imp['imported_event_id']): ?><p style="font-size:.78rem;margin-top:.25rem"><a href="/admin/events.php?edit=<?=(int)$imp['imported_event_id']?>">Edit imported event #<?=(int)$imp['imported_event_id']?></a></p><?php endif; ?>
</div>
<div style="min-width:0">
<div class="form-row">
<div class="fg"><label class="flabel">TITLE *</label><input type="text" name="rows[<?=$id?>][title]" class="finput" value="<?=e($imp['title'])?>" <?=$canEdit?'':'disabled'?>></div>
<div class="fg"><label class="flabel">SOURCE AREA</label><input type="text" class="finput" value="<?=e($imp['source_area'])?>" disabled></div>
</div>
<div class="fg"><label class="flabel">DESCRIPTION</label><textarea name="rows[<?=$id?>][description]" class="ftextarea" style="min-height:76px" <?=$canEdit?'':'disabled'?>><?=e($imp['description'])?></textarea></div>
<div class="form-row">
<div class="fg"><label class="flabel">VENUE</label><input type="text" name="rows[<?=$id?>][venue]" class="finput" value="<?=e($imp['venue'])?>" <?=$canEdit?'':'disabled'?>></div>
<div class="fg"><label class="flabel">ADDRESS</label><input type="text" name="rows[<?=$id?>][address]" class="finput" value="<?=e($imp['address'])?>" <?=$canEdit?'':'disabled'?>></div>
</div>
<div class="form-row">
<div class="fg"><label class="flabel">START DATE *</label><input type="date" name="rows[<?=$id?>][event_date]" class="finput" value="<?=e($imp['event_date'])?>" <?=$canEdit?'':'disabled'?>></div>
<div class="fg"><label class="flabel">START TIME</label><input type="text" name="rows[<?=$id?>][event_time]" class="finput" value="<?=e($imp['event_time'])?>" placeholder="e.g. 7:00 PM" <?=$canEdit?'':'disabled'?>></div>
</div>
<div class="form-row">
<div class="fg"><label class="flabel">END DATE</label><input type="date" name="rows[<?=$id?>][end_date]" class="finput" value="<?=e($imp['end_date'])?>" <?=$canEdit?'':'disabled'?>></div>
<div class="fg"><label class="flabel">END TIME</label><input type="text" name="rows[<?=$id?>][end_time]" class="finput" value="<?=e($imp['end_time'])?>" <?=$canEdit?'':'disabled'?>></div>
</div>
<div class="form-row">
<div class="fg"><label class="flabel">COST</label><input type="text" name="rows[<?=$id?>][cost]" class="finput" value="<?=e($imp['cost'] ?: 'Free')?>" <?=$canEdit?'':'disabled'?>></div>
<div class="fg"><label class="flabel">WEBSITE</label><input type="text" name="rows[<?=$id?>][website]" class="finput" value="<?=e($imp['website'])?>" <?=$canEdit?'':'disabled'?>></div>
</div>
<input type="hidden" name="rows[<?=$id?>][image_path]" value="<?=e($imp['image_path'])?>">
<?php if ($issues || $duplicateId || $imp['status_note']): ?>
<div style="background:rgba(201,168,76,.08);border:1px solid rgba(201,168,76,.2);border-radius:var(--r);padding:.75rem;margin:.4rem 0 1rem;color:var(--text-m);font-size:.84rem;line-height:1.55">
<?php if ($issues): ?><div><strong>Needs review:</strong> <?=e(implode(', ', $issues))?></div><?php endif; ?>
<?php if ($duplicateId && $imp['status'] === 'staged'): ?><div><strong>Possible duplicate:</strong> existing event #<?=$duplicateId?> has the same title and start date.</div><?php endif; ?>
<?php if ($imp['status_note']): ?><div><strong>Note:</strong> <?=e($imp['status_note'])?></div><?php endif; ?>
</div>
<?php endif; ?>
<div class="flex-row" style="gap:.4rem">
<?php if ($canEdit): ?><button type="submit" name="save_id" value="<?=$id?>" class="btn btn-xs btn-outline">Save</button><?php endif; ?>
<?php if ($imp['status'] === 'staged'): ?><button type="submit" name="import_id" value="<?=$id?>" class="btn btn-xs btn-success">Import</button><button type="submit" name="skip_id" value="<?=$id?>" class="btn btn-xs btn-secondary">Skip</button><?php endif; ?>
<?php if ($imp['status'] === 'skipped'): ?><button type="submit" name="restore_id" value="<?=$id?>" class="btn btn-xs btn-primary">Restore</button><?php endif; ?>
<?php if ($imp['status'] !== 'imported'): ?><button type="submit" name="delete_id" value="<?=$id?>" class="btn btn-xs btn-danger" data-confirm="Delete this import candidate?">Delete</button><?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</form>
<?php endif; ?>
<?php include __DIR__.'/admin_footer.php'; ?>
+40 -7
View File
@@ -1,6 +1,7 @@
<?php <?php
require_once dirname(__DIR__).'/includes/boot.php';
requireAdmin();
$adminTitle = 'Events'; $adminTitle = 'Events';
include __DIR__.'/admin_header.php';
if (isPost()) { if (isPost()) {
csrfCheck(); csrfCheck();
@@ -12,18 +13,36 @@ if (isPost()) {
if ($act === 'feature') { qrun("UPDATE events SET is_featured=1-is_featured WHERE id=?",[(int)p('id')]); go('/admin/events.php'); } if ($act === 'feature') { qrun("UPDATE events SET is_featured=1-is_featured WHERE id=?",[(int)p('id')]); go('/admin/events.php'); }
if ($act === 'add' || $act === 'edit') { if ($act === 'add' || $act === 'edit') {
$id = (int)p('id');
$existing = $act === 'edit' ? qone("SELECT image_path FROM events WHERE id=?", [$id]) : null;
$imagePath = (string)($existing['image_path'] ?? '');
if (p('remove_image','0') === '1') $imagePath = '';
$upload = storeSingleUpload(
'event_image',
'events',
imageExts(),
mbToBytes(setting('event_upload_max_mb','5'), 5),
[
'image_only' => true,
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb','1'), 1),
'image_max_width' => (int)setting('forum_image_max_width','1600'),
'image_quality' => (int)setting('forum_image_quality','82'),
]
);
if (!$upload['ok']) { flash($upload['error'], 'error'); go('/admin/events.php'.($id ? '?edit='.$id : '')); }
if (!empty($upload['path'])) $imagePath = (string)$upload['path'];
$f=['title'=>ps('title'),'description'=>ps('description'),'venue'=>ps('venue'),'address'=>ps('address'), $f=['title'=>ps('title'),'description'=>ps('description'),'venue'=>ps('venue'),'address'=>ps('address'),
'event_date'=>ps('event_date'),'event_time'=>ps('event_time'),'end_date'=>ps('end_date')?:null, 'event_date'=>ps('event_date'),'event_time'=>ps('event_time'),'end_date'=>ps('end_date')?:null,
'end_time'=>ps('end_time'),'cost'=>ps('cost'),'website'=>ps('website'),'contact_name'=>ps('contact_name'), 'end_time'=>ps('end_time'),'cost'=>ps('cost'),'website'=>ps('website'),'contact_name'=>ps('contact_name'),
'contact_email'=>ps('contact_email'),'contact_phone'=>ps('contact_phone'), 'contact_email'=>ps('contact_email'),'contact_phone'=>ps('contact_phone'),'image_path'=>$imagePath,
'status'=>p('status','approved'),'is_featured'=>(int)p('is_featured',0)]; 'status'=>p('status','approved'),'is_featured'=>(int)p('is_featured',0)];
if (!$f['title'] || !$f['event_date']) { flash('Title and date required.','error'); go('/admin/events.php'); } if (!$f['title'] || !$f['event_date']) { flash('Title and date required.','error'); go('/admin/events.php'); }
if ($act === 'add') { if ($act === 'add') {
qrun("INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,status,is_featured,submitted_by)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",array_merge(array_values($f),[uid()])); qrun("INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,image_path,status,is_featured,submitted_by)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",array_merge(array_values($f),[uid()]));
flash('Event added!','success'); flash('Event added!','success');
} else { } else {
$id = (int)p('id'); qrun("UPDATE events SET title=?,description=?,venue=?,address=?,event_date=?,event_time=?,end_date=?,end_time=?,cost=?,website=?,contact_name=?,contact_email=?,contact_phone=?,image_path=?,status=?,is_featured=? WHERE id=?",array_merge(array_values($f),[$id]));
qrun("UPDATE events SET title=?,description=?,venue=?,address=?,event_date=?,event_time=?,end_date=?,end_time=?,cost=?,website=?,contact_name=?,contact_email=?,contact_phone=?,status=?,is_featured=? WHERE id=?",array_merge(array_values($f),[$id]));
flash('Event updated!','success'); flash('Event updated!','success');
} }
go('/admin/events.php'); go('/admin/events.php');
@@ -35,12 +54,13 @@ $editId = iget('edit');
$editing = $editId ? qone("SELECT * FROM events WHERE id=?",[$editId]) : null; $editing = $editId ? qone("SELECT * FROM events WHERE id=?",[$editId]) : null;
$where = match($filter) { 'pending'=>"WHERE status='pending'",'approved'=>"WHERE status='approved'", default=>"" }; $where = match($filter) { 'pending'=>"WHERE status='pending'",'approved'=>"WHERE status='approved'", default=>"" };
$events = qall("SELECT e.*,u.username FROM events e LEFT JOIN users u ON u.id=e.submitted_by $where ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END,event_date DESC"); $events = qall("SELECT e.*,u.username FROM events e LEFT JOIN users u ON u.id=e.submitted_by $where ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END,event_date DESC");
include __DIR__.'/admin_header.php';
?> ?>
<?php adminTitle('Manage Events') ?> <?php adminTitle('Manage Events') ?>
<div class="ibox" style="border-color:var(--gold-d);margin-bottom:2rem"> <div class="ibox" style="border-color:var(--gold-d);margin-bottom:2rem">
<div class="ibox-title"><?=$editing ? '✏️ EDIT EVENT: '.strtoupper(e($editing['title'])) : ' ADD EVENT'?></div> <div class="ibox-title"><?=$editing ? '✏️ EDIT EVENT: '.strtoupper(e($editing['title'])) : ' ADD EVENT'?></div>
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="<?=$editing?'edit':'add'?>"> <form method="POST" enctype="multipart/form-data"><?=csrfField()?><input type="hidden" name="_act" value="<?=$editing?'edit':'add'?>">
<?php if($editing): ?><input type="hidden" name="id" value="<?=$editing['id']?>"><?php endif; ?> <?php if($editing): ?><input type="hidden" name="id" value="<?=$editing['id']?>"><?php endif; ?>
<div class="fg"><label class="flabel">TITLE *</label><input type="text" name="title" class="finput" value="<?=e($editing['title']??'')?>" required></div> <div class="fg"><label class="flabel">TITLE *</label><input type="text" name="title" class="finput" value="<?=e($editing['title']??'')?>" required></div>
<div class="fg"><label class="flabel">DESCRIPTION</label><textarea name="description" class="ftextarea" style="min-height:80px"><?=e($editing['description']??'')?></textarea></div> <div class="fg"><label class="flabel">DESCRIPTION</label><textarea name="description" class="ftextarea" style="min-height:80px"><?=e($editing['description']??'')?></textarea></div>
@@ -60,6 +80,19 @@ $events = qall("SELECT e.*,u.username FROM events e LEFT JOIN users u ON u.id=e.
<div class="fg"><label class="flabel">COST</label><input type="text" name="cost" class="finput" value="<?=e($editing['cost']??'Free')?>"></div> <div class="fg"><label class="flabel">COST</label><input type="text" name="cost" class="finput" value="<?=e($editing['cost']??'Free')?>"></div>
<div class="fg"><label class="flabel">WEBSITE</label><input type="text" name="website" class="finput" value="<?=e($editing['website']??'')?>"></div> <div class="fg"><label class="flabel">WEBSITE</label><input type="text" name="website" class="finput" value="<?=e($editing['website']??'')?>"></div>
</div> </div>
<div class="fg">
<label class="flabel">EVENT IMAGE</label>
<?php if(!empty($editing['image_path'])): ?>
<div class="event-admin-image">
<img src="<?=e($editing['image_path'])?>" alt="<?=e($editing['title'] ?? 'Event image')?>">
<label style="display:flex;align-items:center;gap:.45rem;font-size:.85rem;color:var(--text-m);cursor:pointer">
<input type="checkbox" name="remove_image" value="1"> Remove current image
</label>
</div>
<?php endif; ?>
<input type="file" name="event_image" class="finput" accept="image/*">
<div class="fhint">Optional JPG, PNG, GIF, or WebP. Max <?=e(setting('event_upload_max_mb','5'))?> MB.</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="fg"><label class="flabel">CONTACT NAME</label><input type="text" name="contact_name" class="finput" value="<?=e($editing['contact_name']??'')?>"></div> <div class="fg"><label class="flabel">CONTACT NAME</label><input type="text" name="contact_name" class="finput" value="<?=e($editing['contact_name']??'')?>"></div>
<div class="fg"><label class="flabel">CONTACT PHONE</label><input type="text" name="contact_phone" class="finput" value="<?=e($editing['contact_phone']??'')?>"></div> <div class="fg"><label class="flabel">CONTACT PHONE</label><input type="text" name="contact_phone" class="finput" value="<?=e($editing['contact_phone']??'')?>"></div>
@@ -90,7 +123,7 @@ $events = qall("SELECT e.*,u.username FROM events e LEFT JOIN users u ON u.id=e.
<tbody> <tbody>
<?php foreach($events as $ev): ?> <?php foreach($events as $ev): ?>
<tr> <tr>
<td class="td-name" style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><?=e($ev['title'])?></td> <td class="td-name" style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><?=e($ev['title'])?><?=!empty($ev['image_path'])?' <span class="badge badge-blue">Image</span>':''?></td>
<td style="font-size:.8rem"><?=fdate($ev['event_date'])?></td> <td style="font-size:.8rem"><?=fdate($ev['event_date'])?></td>
<td style="font-size:.8rem"><?=e($ev['username']??'Admin')?></td> <td style="font-size:.8rem"><?=e($ev['username']??'Admin')?></td>
<td><?php $sc=['approved'=>'badge-green','pending'=>'badge-gold','rejected'=>'badge-red']; ?><span class="badge <?=$sc[$ev['status']]??"badge-gray"?>"><?=strtoupper($ev['status'])?></span></td> <td><?php $sc=['approved'=>'badge-green','pending'=>'badge-gold','rejected'=>'badge-red']; ?><span class="badge <?=$sc[$ev['status']]??"badge-gray"?>"><?=strtoupper($ev['status'])?></span></td>
+223
View File
@@ -0,0 +1,223 @@
<?php
require_once dirname(__DIR__).'/includes/boot.php';
requireAdmin();
function forumCategorySlugForAdmin(string $name, int $id = 0): string {
$base = makeSlug($name) ?: 'category';
$slug = $base;
$i = 1;
while (qval("SELECT id FROM forum_categories WHERE slug=? AND id!=?", [$slug, $id])) {
$slug = $base.'-'.$i++;
}
return $slug;
}
if (isPost()) {
csrfCheck();
$act = p('_act');
if ($act === 'settings') {
setSetting('forum_enabled', p('forum_enabled','0') === '1' ? '1' : '0');
setSetting('forum_upload_max_mb', (string)max(1, (float)p('forum_upload_max_mb','5')));
$allowed = strtolower(ps('forum_allowed_filetypes'));
$allowed = implode(',', uploadAllowedExts($allowed, ['jpg','jpeg','png','gif','webp','pdf','txt','doc','docx']));
setSetting('forum_allowed_filetypes', $allowed);
setSetting('forum_image_resize_threshold_mb', (string)max(0.5, (float)p('forum_image_resize_threshold_mb','1')));
setSetting('forum_image_max_width', (string)max(640, min(3200, (int)p('forum_image_max_width','1600'))));
setSetting('forum_image_quality', (string)max(40, min(95, (int)p('forum_image_quality','82'))));
flash('Forum settings saved.', 'success');
go('/admin/forum.php');
}
if ($act === 'add_category') {
$name = ps('name');
if ($name !== '') {
qrun("INSERT INTO forum_categories(name,slug,description,sort_order,is_active)VALUES(?,?,?,?,?)",
[$name, forumCategorySlugForAdmin($name), ps('description'), (int)p('sort_order',0), (int)p('is_active',1)]);
flash('Category added.', 'success');
}
go('/admin/forum.php#categories');
}
if ($act === 'edit_category') {
$id = (int)p('id');
$name = ps('name');
if ($id && $name !== '') {
qrun("UPDATE forum_categories SET name=?,slug=?,description=?,sort_order=?,is_active=? WHERE id=?",
[$name, forumCategorySlugForAdmin($name, $id), ps('description'), (int)p('sort_order',0), (int)p('is_active',1), $id]);
flash('Category updated.', 'success');
}
go('/admin/forum.php#categories');
}
if ($act === 'delete_category') {
$id = (int)p('id');
$topicCount = (int)qval("SELECT COUNT(*) FROM forum_topics WHERE category_id=?", [$id]);
if ($topicCount > 0) {
qrun("UPDATE forum_categories SET is_active=0 WHERE id=?", [$id]);
flash('Category has topics, so it was deactivated instead of deleted.', 'warning');
} else {
qrun("DELETE FROM forum_categories WHERE id=?", [$id]);
flash('Category deleted.', 'success');
}
go('/admin/forum.php#categories');
}
if (in_array($act, ['toggle_pin','toggle_lock','delete_topic'], true)) {
$id = (int)p('id');
if ($act === 'toggle_pin') {
qrun("UPDATE forum_topics SET is_pinned=1-is_pinned WHERE id=?", [$id]);
flash('Topic pin status updated.', 'success');
} elseif ($act === 'toggle_lock') {
qrun("UPDATE forum_topics SET is_locked=1-is_locked WHERE id=?", [$id]);
flash('Topic lock status updated.', 'success');
} else {
qrun("DELETE FROM forum_topics WHERE id=?", [$id]);
flash('Topic deleted.', 'success');
}
go('/admin/forum.php#topics');
}
}
$adminTitle = 'Forum';
include __DIR__.'/admin_header.php';
$forumEnabled = setting('forum_enabled','1');
$forumUploadMax = setting('forum_upload_max_mb','5');
$forumAllowed = setting('forum_allowed_filetypes','jpg,jpeg,png,gif,webp,pdf,txt,doc,docx');
$resizeThreshold = setting('forum_image_resize_threshold_mb','1');
$resizeMaxWidth = setting('forum_image_max_width','1600');
$resizeQuality = setting('forum_image_quality','82');
$counts = [
'Topics' => qval("SELECT COUNT(*) FROM forum_topics"),
'Replies' => qval("SELECT COUNT(*) FROM forum_replies"),
'Categories' => qval("SELECT COUNT(*) FROM forum_categories"),
'Attachments' => qval("SELECT COUNT(*) FROM forum_attachments"),
];
$categories = qall("
SELECT fc.*, COUNT(ft.id) topic_count
FROM forum_categories fc
LEFT JOIN forum_topics ft ON ft.category_id=fc.id
GROUP BY fc.id
ORDER BY fc.sort_order, fc.name
");
$topics = qall("
SELECT ft.*, fc.name category_name, u.username,
(SELECT COUNT(*) FROM forum_replies fr WHERE fr.topic_id=ft.id) reply_count
FROM forum_topics ft
JOIN forum_categories fc ON fc.id=ft.category_id
JOIN users u ON u.id=ft.user_id
ORDER BY ft.is_pinned DESC, datetime(ft.updated_at) DESC
LIMIT 60
");
?>
<?php adminTitle('Forum Controls', 'Enable the forum, manage upload rules, categories, and recent topics.') ?>
<div class="stat-cards">
<?php foreach($counts as $label=>$value): ?>
<div class="stat-card"><div class="sc-n"><?=$value?></div><div class="sc-l"><?=e($label)?></div></div>
<?php endforeach; ?>
</div>
<div class="g2" style="gap:1.5rem">
<div class="ibox" style="border-color:var(--gold-d)">
<div class="ibox-title">FORUM SETTINGS</div>
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="settings">
<div class="fg">
<label class="flabel">FORUM VISIBILITY</label>
<div style="display:flex;gap:1rem;flex-wrap:wrap">
<label style="display:flex;gap:.45rem;align-items:center;color:var(--green-l);cursor:pointer"><input type="radio" name="forum_enabled" value="1"<?=$forumEnabled==='1'?' checked':''?>> Enabled for members</label>
<label style="display:flex;gap:.45rem;align-items:center;color:var(--red-l);cursor:pointer"><input type="radio" name="forum_enabled" value="0"<?=$forumEnabled==='0'?' checked':''?>> Disabled for non-admins</label>
</div>
</div>
<div class="form-row">
<div class="fg"><label class="flabel">FILE MAX MB</label><input type="number" name="forum_upload_max_mb" class="finput" min="1" step="0.5" value="<?=e($forumUploadMax)?>"></div>
<div class="fg"><label class="flabel">ALLOWED FILE TYPES</label><input type="text" name="forum_allowed_filetypes" class="finput" value="<?=e($forumAllowed)?>"><div class="fhint">Comma-separated extensions.</div></div>
</div>
<div class="form-row">
<div class="fg"><label class="flabel">RESIZE IMAGES OVER MB</label><input type="number" name="forum_image_resize_threshold_mb" class="finput" min="0.5" step="0.5" value="<?=e($resizeThreshold)?>"></div>
<div class="fg"><label class="flabel">MAX IMAGE WIDTH</label><input type="number" name="forum_image_max_width" class="finput" min="640" max="3200" step="80" value="<?=e($resizeMaxWidth)?>"></div>
</div>
<div class="fg"><label class="flabel">IMAGE QUALITY</label><input type="number" name="forum_image_quality" class="finput" min="40" max="95" value="<?=e($resizeQuality)?>"></div>
<button class="btn btn-primary">Save Forum Settings</button>
</form>
</div>
<div class="ibox" id="categories" style="border-color:var(--gold-d)">
<div class="ibox-title">ADD CATEGORY</div>
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="add_category">
<div class="form-row">
<div class="fg"><label class="flabel">NAME</label><input type="text" name="name" class="finput" required></div>
<div class="fg"><label class="flabel">SORT ORDER</label><input type="number" name="sort_order" class="finput" value="0"></div>
</div>
<div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="description" class="finput"></div>
<div class="fg"><label class="flabel">STATUS</label><select name="is_active" class="fselect"><option value="1">Active</option><option value="0">Inactive</option></select></div>
<button class="btn btn-primary btn-sm">Add Category</button>
</form>
</div>
</div>
<div class="admin-sec-title" style="margin-top:1.5rem">CATEGORIES</div>
<div class="tbl-wrap" style="margin-bottom:2rem">
<table class="dtbl">
<thead><tr><th>Name</th><th>Description</th><th>Topics</th><th>Status</th><th>Order</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach($categories as $cat): ?>
<tr>
<td class="td-name"><?=e($cat['name'])?></td>
<td style="font-size:.8rem"><?=e($cat['description'])?></td>
<td><?=$cat['topic_count']?></td>
<td><?=$cat['is_active']?'<span class="badge badge-green">Active</span>':'<span class="badge badge-gray">Inactive</span>'?></td>
<td><?=$cat['sort_order']?></td>
<td>
<details>
<summary class="btn btn-xs btn-outline" style="display:inline-flex">Edit</summary>
<form method="POST" class="admin-inline-editor">
<?=csrfField()?><input type="hidden" name="_act" value="edit_category"><input type="hidden" name="id" value="<?=$cat['id']?>">
<div class="form-row">
<input type="text" name="name" class="finput" value="<?=e($cat['name'])?>" required>
<input type="number" name="sort_order" class="finput" value="<?=$cat['sort_order']?>">
</div>
<input type="text" name="description" class="finput" value="<?=e($cat['description'])?>">
<select name="is_active" class="fselect"><option value="1"<?=$cat['is_active']?' selected':''?>>Active</option><option value="0"<?=!$cat['is_active']?' selected':''?>>Inactive</option></select>
<button class="btn btn-success btn-xs">Save</button>
</form>
</details>
<form method="POST" style="display:inline"><?=csrfField()?><input type="hidden" name="_act" value="delete_category"><input type="hidden" name="id" value="<?=$cat['id']?>"><button class="btn btn-xs btn-danger" data-confirm="Delete this category?">Delete</button></form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="admin-sec-title" id="topics">RECENT TOPICS</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead><tr><th>Topic</th><th>Category</th><th>By</th><th>Replies</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach($topics as $topic): ?>
<tr>
<td><a class="td-name" href="<?=e(forumTopicUrl($topic))?>" target="_blank"><?=e($topic['title'])?></a><div style="font-size:.75rem;color:var(--text-d)"><?=ago($topic['updated_at'])?></div></td>
<td><?=e($topic['category_name'])?></td>
<td><?=e($topic['username'])?></td>
<td><?=$topic['reply_count']?></td>
<td>
<?php if($topic['is_pinned']): ?><span class="badge badge-gold">Pinned</span><?php endif; ?>
<?php if($topic['is_locked']): ?><span class="badge badge-gray">Locked</span><?php endif; ?>
</td>
<td>
<div class="flex-row" style="gap:.3rem">
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="toggle_pin"><input type="hidden" name="id" value="<?=$topic['id']?>"><button class="btn btn-xs btn-outline"><?=$topic['is_pinned']?'Unpin':'Pin'?></button></form>
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="toggle_lock"><input type="hidden" name="id" value="<?=$topic['id']?>"><button class="btn btn-xs btn-secondary"><?=$topic['is_locked']?'Unlock':'Lock'?></button></form>
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="delete_topic"><input type="hidden" name="id" value="<?=$topic['id']?>"><button class="btn btn-xs btn-danger" data-confirm="Delete this topic and replies?">Delete</button></form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if(!$topics): ?><tr><td colspan="6" style="text-align:center;color:var(--text-d)">No forum topics yet.</td></tr><?php endif; ?>
</tbody>
</table>
</div>
<?php include __DIR__.'/admin_footer.php'; ?>
+72 -2
View File
@@ -7,7 +7,9 @@ $stats = [
'Reviews' => qval("SELECT COUNT(*) FROM reviews"), 'Reviews' => qval("SELECT COUNT(*) FROM reviews"),
'Events' => qval("SELECT COUNT(*) FROM events WHERE status='approved'"), 'Events' => qval("SELECT COUNT(*) FROM events WHERE status='approved'"),
'Pending Events' => qval("SELECT COUNT(*) FROM events WHERE status='pending'"), 'Pending Events' => qval("SELECT COUNT(*) FROM events WHERE status='pending'"),
'Pending Claims' => qval("SELECT COUNT(*) FROM business_claim_requests WHERE status='pending'"),
'Pending Edits' => qval("SELECT COUNT(*) FROM business_edit_requests WHERE status='pending'"), 'Pending Edits' => qval("SELECT COUNT(*) FROM business_edit_requests WHERE status='pending'"),
'Forum Topics' => qval("SELECT COUNT(*) FROM forum_topics"),
'Open Reports' => qval("SELECT COUNT(*) FROM reports WHERE status='open'"), 'Open Reports' => qval("SELECT COUNT(*) FROM reports WHERE status='open'"),
'Active Alerts' => qval("SELECT COUNT(*) FROM alerts WHERE is_active=1"), 'Active Alerts' => qval("SELECT COUNT(*) FROM alerts WHERE is_active=1"),
]; ];
@@ -15,6 +17,15 @@ $recentBiz = qall("SELECT * FROM businesses ORDER BY created_at DESC LIMIT 5"
$recentUsers = qall("SELECT * FROM users ORDER BY created_at DESC LIMIT 5"); $recentUsers = qall("SELECT * FROM users ORDER BY created_at DESC LIMIT 5");
$pendingEvts = qall("SELECT e.*,u.username FROM events e LEFT JOIN users u ON u.id=e.submitted_by WHERE e.status='pending' ORDER BY e.created_at DESC LIMIT 5"); $pendingEvts = qall("SELECT e.*,u.username FROM events e LEFT JOIN users u ON u.id=e.submitted_by WHERE e.status='pending' ORDER BY e.created_at DESC LIMIT 5");
$openReports = qall("SELECT r.*,b.name biz,u.username reporter FROM reports r JOIN businesses b ON b.id=r.business_id JOIN users u ON u.id=r.user_id WHERE r.status='open' ORDER BY r.created_at DESC LIMIT 5"); $openReports = qall("SELECT r.*,b.name biz,u.username reporter FROM reports r JOIN businesses b ON b.id=r.business_id JOIN users u ON u.id=r.user_id WHERE r.status='open' ORDER BY r.created_at DESC LIMIT 5");
$pendingClaims = qall("
SELECT bcr.*, b.name biz_name, b.slug biz_slug, u.username, u.email
FROM business_claim_requests bcr
JOIN businesses b ON b.id = bcr.business_id
JOIN users u ON u.id = bcr.user_id
WHERE bcr.status = 'pending'
ORDER BY bcr.created_at DESC
LIMIT 10
");
$pendingEdits = qall(" $pendingEdits = qall("
SELECT ber.*, b.name biz_name, b.slug biz_slug, b.id biz_id, u.username SELECT ber.*, b.name biz_name, b.slug biz_slug, b.id biz_id, u.username
FROM business_edit_requests ber FROM business_edit_requests ber
@@ -24,13 +35,22 @@ $pendingEdits = qall("
ORDER BY ber.created_at DESC ORDER BY ber.created_at DESC
LIMIT 10 LIMIT 10
"); ");
$recentForumTopics = qall("
SELECT ft.*, fc.name category_name, u.username,
(SELECT COUNT(*) FROM forum_replies fr WHERE fr.topic_id=ft.id) reply_count
FROM forum_topics ft
JOIN forum_categories fc ON fc.id=ft.category_id
JOIN users u ON u.id=ft.user_id
ORDER BY datetime(ft.updated_at) DESC
LIMIT 5
");
?> ?>
<?php adminTitle('Site Overview','Welcome to the Discover Keyser WV administration panel.') ?> <?php adminTitle('Site Overview','Welcome to the Discover Keyser WV administration panel.') ?>
<div class="stat-cards"> <div class="stat-cards">
<?php foreach($stats as $lbl=>$val): ?> <?php foreach($stats as $lbl=>$val): ?>
<div class="stat-card"> <div class="stat-card">
<div class="sc-n" style="<?=$lbl==='Pending Edits'&&$val>0?'color:var(--gold)':($lbl==='Open Reports'&&$val>0?'color:var(--red-l)':'')?>"> <div class="sc-n" style="<?=($lbl==='Pending Edits'||$lbl==='Pending Claims')&&$val>0?'color:var(--gold)':($lbl==='Open Reports'&&$val>0?'color:var(--red-l)':'')?>">
<?=$val?> <?=$val?>
</div> </div>
<div class="sc-l"><?=e($lbl)?></div> <div class="sc-l"><?=e($lbl)?></div>
@@ -38,6 +58,34 @@ $pendingEdits = qall("
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<!-- Pending owner claims — verification queue -->
<?php if ($pendingClaims): ?>
<div style="margin-bottom:2rem">
<div class="admin-sec-title" style="color:var(--gold)">
🔑 BUSINESS PAGE CLAIM REQUESTS (<?=count($pendingClaims)?>)
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead><tr><th>BUSINESS</th><th>REQUESTED BY</th><th>CONTACT PHONE</th><th>SUBMITTED</th><th></th></tr></thead>
<tbody>
<?php foreach ($pendingClaims as $claim): ?>
<tr>
<td><a href="/business.php?slug=<?=e($claim['biz_slug'])?>" target="_blank" class="td-name"><?=e($claim['biz_name'])?></a></td>
<td>
<div class="td-name"><?=e($claim['username'])?></div>
<?php if ($claim['email']): ?><div style="font-size:.76rem;color:var(--text-d)"><?=e($claim['email'])?></div><?php endif; ?>
</td>
<td style="font-size:.86rem;color:var(--text)"><?=e($claim['contact_phone'])?></td>
<td style="font-size:.78rem;color:var(--text-d)"><?=ago($claim['created_at'] ?? '')?></td>
<td><a href="/admin/owners.php#claims" class="btn btn-xs btn-primary">Verify</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Pending owner edits — top priority --> <!-- Pending owner edits — top priority -->
<?php if ($pendingEdits): ?> <?php if ($pendingEdits): ?>
<div style="margin-bottom:2rem"> <div style="margin-bottom:2rem">
@@ -50,7 +98,7 @@ $pendingEdits = qall("
<tbody> <tbody>
<?php <?php
$fieldLabels = ['description'=>'Description','address'=>'Address','phone'=>'Phone', $fieldLabels = ['description'=>'Description','address'=>'Address','phone'=>'Phone',
'email'=>'Email','hours'=>'Hours','is_active'=>'Status']; 'email'=>'Email','website'=>'Website','video_url'=>'Video','hours'=>'Hours','is_active'=>'Status'];
foreach ($pendingEdits as $pe): foreach ($pendingEdits as $pe):
$preview = $pe['field'] === 'hours' $preview = $pe['field'] === 'hours'
? '(hours update)' ? '(hours update)'
@@ -118,6 +166,28 @@ $pendingEdits = qall("
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if($recentForumTopics): ?>
<div style="margin-bottom:2rem">
<div class="admin-sec-title">FORUM ACTIVITY</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead><tr><th>TOPIC</th><th>CATEGORY</th><th>BY</th><th>REPLIES</th><th></th></tr></thead>
<tbody>
<?php foreach($recentForumTopics as $topic): ?>
<tr>
<td><a href="<?=e(forumTopicUrl($topic))?>" target="_blank" class="td-name"><?=e($topic['title'])?></a><div style="font-size:.75rem;color:var(--text-d)"><?=ago($topic['updated_at'])?></div></td>
<td><?=e($topic['category_name'])?></td>
<td><?=e($topic['username'])?></td>
<td><?=$topic['reply_count']?></td>
<td><a href="/admin/forum.php#topics" class="btn btn-xs btn-primary">Moderate</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<div class="g2"> <div class="g2">
<div> <div>
<div class="admin-sec-title">🏢 RECENT BUSINESSES</div> <div class="admin-sec-title">🏢 RECENT BUSINESSES</div>
+62 -6
View File
@@ -1,9 +1,11 @@
<?php <?php
require_once dirname(__DIR__).'/includes/boot.php';
requireAdmin();
$adminTitle = 'Menus'; $adminTitle = 'Menus';
include __DIR__.'/admin_header.php';
$bizId = iget('biz_id'); $bizId = iget('biz_id');
$biz = $bizId ? qone("SELECT * FROM businesses WHERE id=?",[$bizId]) : null; $biz = $bizId ? qone("SELECT * FROM businesses WHERE id=?",[$bizId]) : null;
$menuImagesEnabled = max(0, min(1, (int)setting('menu_item_image_limit','1'))) > 0;
if (isPost()) { if (isPost()) {
csrfCheck(); csrfCheck();
@@ -31,15 +33,51 @@ if (isPost()) {
$sid = (int)p('section_id'); $sid = (int)p('section_id');
$name = ps('item_name'); $name = ps('item_name');
if ($sid && $name) { if ($sid && $name) {
$imagePath = '';
if ($menuImagesEnabled) {
$upload = storeSingleUpload(
'item_image',
'menus',
imageExts(),
mbToBytes(setting('menu_upload_max_mb','5'), 5),
[
'image_only' => true,
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb','1'), 1),
'image_max_width' => (int)setting('forum_image_max_width','1600'),
'image_quality' => (int)setting('forum_image_quality','82'),
]
);
if (!$upload['ok']) { flash($upload['error'], 'error'); go("/admin/menus.php?biz_id=$bizId"); }
$imagePath = (string)($upload['path'] ?? '');
}
$ord = (int)qval("SELECT COALESCE(MAX(sort_order),0)+1 FROM menu_items WHERE section_id=?",[$sid]); $ord = (int)qval("SELECT COALESCE(MAX(sort_order),0)+1 FROM menu_items WHERE section_id=?",[$sid]);
qrun("INSERT INTO menu_items(section_id,name,description,price,is_available,sort_order)VALUES(?,?,?,?,?,?)",[$sid,$name,ps('item_desc'),(float)p('item_price',0),(int)p('is_available',1),$ord]); qrun("INSERT INTO menu_items(section_id,name,description,price,image_path,is_available,sort_order)VALUES(?,?,?,?,?,?,?)",[$sid,$name,ps('item_desc'),(float)p('item_price',0),$imagePath,(int)p('is_available',1),$ord]);
flash('Item added!','success'); flash('Item added!','success');
} }
go("/admin/menus.php?biz_id=$bizId"); go("/admin/menus.php?biz_id=$bizId");
} }
if ($act === 'edit_item') { if ($act === 'edit_item') {
$iid = (int)p('iid'); $iid = (int)p('iid');
qrun("UPDATE menu_items SET name=?,description=?,price=?,is_available=?,sort_order=? WHERE id=?",[ps('item_name'),ps('item_desc'),(float)p('item_price',0),(int)p('is_available',1),(int)p('item_order'),$iid]); $item = qone("SELECT * FROM menu_items WHERE id=?", [$iid]);
$imagePath = (string)($item['image_path'] ?? '');
if ($menuImagesEnabled && p('remove_item_image','0') === '1') $imagePath = '';
if ($menuImagesEnabled) {
$upload = storeSingleUpload(
'item_image',
'menus',
imageExts(),
mbToBytes(setting('menu_upload_max_mb','5'), 5),
[
'image_only' => true,
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb','1'), 1),
'image_max_width' => (int)setting('forum_image_max_width','1600'),
'image_quality' => (int)setting('forum_image_quality','82'),
]
);
if (!$upload['ok']) { flash($upload['error'], 'error'); go("/admin/menus.php?biz_id=$bizId"); }
if (!empty($upload['path'])) $imagePath = (string)$upload['path'];
}
qrun("UPDATE menu_items SET name=?,description=?,price=?,image_path=?,is_available=?,sort_order=? WHERE id=?",[ps('item_name'),ps('item_desc'),(float)p('item_price',0),$imagePath,(int)p('is_available',1),(int)p('item_order'),$iid]);
flash('Item updated!','success'); go("/admin/menus.php?biz_id=$bizId"); flash('Item updated!','success'); go("/admin/menus.php?biz_id=$bizId");
} }
if ($act === 'del_item') { if ($act === 'del_item') {
@@ -55,6 +93,7 @@ if ($biz) {
$items = []; $items = [];
foreach ($sections as $s) $items[$s['id']] = qall("SELECT * FROM menu_items WHERE section_id=? ORDER BY sort_order",[$s['id']]); foreach ($sections as $s) $items[$s['id']] = qall("SELECT * FROM menu_items WHERE section_id=? ORDER BY sort_order",[$s['id']]);
} }
include __DIR__.'/admin_header.php';
?> ?>
<?php adminTitle('Manage Menus','Edit menus, sections, and items for any business.') ?> <?php adminTitle('Manage Menus','Edit menus, sections, and items for any business.') ?>
@@ -117,11 +156,12 @@ if ($biz) {
<?php if($items[$sec['id']]): ?> <?php if($items[$sec['id']]): ?>
<div class="tbl-wrap" style="margin-bottom:.85rem"> <div class="tbl-wrap" style="margin-bottom:.85rem">
<table class="dtbl"> <table class="dtbl">
<thead><tr><th>ITEM NAME</th><th>DESCRIPTION</th><th>PRICE</th><th>AVAIL.</th><th>ORDER</th><th></th></tr></thead> <thead><tr><th>ITEM NAME</th><th>IMAGE</th><th>DESCRIPTION</th><th>PRICE</th><th>AVAIL.</th><th>ORDER</th><th></th></tr></thead>
<tbody> <tbody>
<?php foreach($items[$sec['id']] as $item): ?> <?php foreach($items[$sec['id']] as $item): ?>
<tr> <tr>
<td class="td-name"><?=e($item['name'])?></td> <td class="td-name"><?=e($item['name'])?></td>
<td><?php if(!empty($item['image_path'])): ?><img src="<?=e($item['image_path'])?>" alt="<?=e($item['name'])?>" class="admin-menu-thumb"><?php else: ?>—<?php endif; ?></td>
<td style="font-size:.8rem;max-width:180px"><?=e(substr($item['description'],0,80))?></td> <td style="font-size:.8rem;max-width:180px"><?=e(substr($item['description'],0,80))?></td>
<td style="color:var(--gold);font-family:'Oswald',sans-serif"><?=$item['price']>0?money($item['price']):'—'?></td> <td style="color:var(--gold);font-family:'Oswald',sans-serif"><?=$item['price']>0?money($item['price']):'—'?></td>
<td><?=$item['is_available']?'<span class="badge badge-green">Yes</span>':'<span class="badge badge-gray">No</span>'?></td> <td><?=$item['is_available']?'<span class="badge badge-green">Yes</span>':'<span class="badge badge-gray">No</span>'?></td>
@@ -132,12 +172,25 @@ if ($biz) {
<form method="POST" style="display:inline"><?=csrfField()?><input type="hidden" name="_act" value="del_item"><input type="hidden" name="iid" value="<?=$item['id']?>"><button class="btn btn-xs btn-danger" data-confirm="Delete item?">🗑️</button></form> <form method="POST" style="display:inline"><?=csrfField()?><input type="hidden" name="_act" value="del_item"><input type="hidden" name="iid" value="<?=$item['id']?>"><button class="btn btn-xs btn-danger" data-confirm="Delete item?">🗑️</button></form>
</div> </div>
<div id="eif<?=$item['id']?>" class="hidden" style="background:var(--bg2);border:1px solid var(--bdr);border-radius:4px;padding:.75rem;margin-top:.4rem;min-width:320px"> <div id="eif<?=$item['id']?>" class="hidden" style="background:var(--bg2);border:1px solid var(--bdr);border-radius:4px;padding:.75rem;margin-top:.4rem;min-width:320px">
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="edit_item"><input type="hidden" name="iid" value="<?=$item['id']?>"> <form method="POST" enctype="multipart/form-data"><?=csrfField()?><input type="hidden" name="_act" value="edit_item"><input type="hidden" name="iid" value="<?=$item['id']?>">
<div class="form-row"> <div class="form-row">
<div class="fg"><label class="flabel">NAME *</label><input type="text" name="item_name" class="finput" value="<?=e($item['name'])?>" required></div> <div class="fg"><label class="flabel">NAME *</label><input type="text" name="item_name" class="finput" value="<?=e($item['name'])?>" required></div>
<div class="fg"><label class="flabel">PRICE</label><input type="number" name="item_price" class="finput" value="<?=$item['price']?>" step="0.01" min="0"></div> <div class="fg"><label class="flabel">PRICE</label><input type="number" name="item_price" class="finput" value="<?=$item['price']?>" step="0.01" min="0"></div>
</div> </div>
<div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="item_desc" class="finput" value="<?=e($item['description'])?>"></div> <div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="item_desc" class="finput" value="<?=e($item['description'])?>"></div>
<?php if($menuImagesEnabled): ?>
<div class="fg">
<label class="flabel">ITEM IMAGE</label>
<?php if(!empty($item['image_path'])): ?>
<div class="menu-admin-image">
<img src="<?=e($item['image_path'])?>" alt="<?=e($item['name'])?>">
<label style="display:flex;align-items:center;gap:.45rem;font-size:.82rem;color:var(--text-m);cursor:pointer"><input type="checkbox" name="remove_item_image" value="1"> Remove current image</label>
</div>
<?php endif; ?>
<input type="file" name="item_image" class="finput" accept="image/*">
<div class="fhint">One image allowed. Max <?=e(setting('menu_upload_max_mb','5'))?> MB.</div>
</div>
<?php endif; ?>
<div class="form-row"> <div class="form-row">
<div class="fg"><label class="flabel">AVAILABLE</label><select name="is_available" class="fselect"><option value="1"<?=$item['is_available']?' selected':''?>>Yes</option><option value="0"<?=!$item['is_available']?' selected':''?>>No</option></select></div> <div class="fg"><label class="flabel">AVAILABLE</label><select name="is_available" class="fselect"><option value="1"<?=$item['is_available']?' selected':''?>>Yes</option><option value="0"<?=!$item['is_available']?' selected':''?>>No</option></select></div>
<div class="fg"><label class="flabel">SORT ORDER</label><input type="number" name="item_order" class="finput" value="<?=$item['sort_order']?>"></div> <div class="fg"><label class="flabel">SORT ORDER</label><input type="number" name="item_order" class="finput" value="<?=$item['sort_order']?>"></div>
@@ -156,12 +209,15 @@ if ($biz) {
<!-- Add item --> <!-- Add item -->
<details><summary style="cursor:pointer;font-size:.82rem;color:var(--gold);font-family:'Oswald',sans-serif;letter-spacing:.08em"> Add Item to "<?=e($sec['name'])?>"</summary> <details><summary style="cursor:pointer;font-size:.82rem;color:var(--gold);font-family:'Oswald',sans-serif;letter-spacing:.08em"> Add Item to "<?=e($sec['name'])?>"</summary>
<div style="padding:.75rem 0"> <div style="padding:.75rem 0">
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="add_item"><input type="hidden" name="section_id" value="<?=$sec['id']?>"> <form method="POST" enctype="multipart/form-data"><?=csrfField()?><input type="hidden" name="_act" value="add_item"><input type="hidden" name="section_id" value="<?=$sec['id']?>">
<div class="form-row"> <div class="form-row">
<div class="fg"><label class="flabel">ITEM NAME *</label><input type="text" name="item_name" class="finput" required placeholder="e.g. Spaghetti Carbonara"></div> <div class="fg"><label class="flabel">ITEM NAME *</label><input type="text" name="item_name" class="finput" required placeholder="e.g. Spaghetti Carbonara"></div>
<div class="fg"><label class="flabel">PRICE ($)</label><input type="number" name="item_price" class="finput" value="0" step="0.01" min="0"></div> <div class="fg"><label class="flabel">PRICE ($)</label><input type="number" name="item_price" class="finput" value="0" step="0.01" min="0"></div>
</div> </div>
<div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="item_desc" class="finput" placeholder="Brief description…"></div> <div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="item_desc" class="finput" placeholder="Brief description…"></div>
<?php if($menuImagesEnabled): ?>
<div class="fg"><label class="flabel">ITEM IMAGE</label><input type="file" name="item_image" class="finput" accept="image/*"><div class="fhint">Optional. One image allowed, max <?=e(setting('menu_upload_max_mb','5'))?> MB.</div></div>
<?php endif; ?>
<div class="fg"><label class="flabel">AVAILABLE</label><select name="is_available" class="fselect"><option value="1">Yes</option><option value="0">No</option></select></div> <div class="fg"><label class="flabel">AVAILABLE</label><select name="is_available" class="fselect"><option value="1">Yes</option><option value="0">No</option></select></div>
<button type="submit" class="btn btn-primary btn-sm">Add Item</button> <button type="submit" class="btn btn-primary btn-sm">Add Item</button>
</form> </form>
+74
View File
@@ -16,6 +16,23 @@ if (isPost()) {
qrun("DELETE FROM business_owners WHERE user_id=? AND business_id=?",[(int)p('user_id'),(int)p('business_id')]); qrun("DELETE FROM business_owners WHERE user_id=? AND business_id=?",[(int)p('user_id'),(int)p('business_id')]);
flash('Owner removed.','success'); flash('Owner removed.','success');
} }
if ($act === 'approve_claim') {
$claimId = (int)p('claim_id');
$claim = qone("SELECT * FROM business_claim_requests WHERE id=? AND status='pending'", [$claimId]);
if ($claim) {
qrun("INSERT OR IGNORE INTO business_owners(user_id,business_id)VALUES(?,?)", [(int)$claim['user_id'], (int)$claim['business_id']]);
qrun("UPDATE business_claim_requests SET status='approved', reviewed_at=datetime('now') WHERE id=?", [$claimId]);
flash('Business claim approved and owner assigned.', 'success');
}
go('/admin/owners.php#claims');
}
if ($act === 'reject_claim') {
$claimId = (int)p('claim_id');
qrun("UPDATE business_claim_requests SET status='rejected', admin_notes=?, reviewed_at=datetime('now') WHERE id=? AND status='pending'",
[ps('admin_notes'), $claimId]);
flash('Business claim rejected.', 'info');
go('/admin/owners.php#claims');
}
go('/admin/owners.php'.($_GET?'?'.http_build_query($_GET):'')); go('/admin/owners.php'.($_GET?'?'.http_build_query($_GET):''));
} }
@@ -24,9 +41,66 @@ $filterBiz = iget('business_id');
$users = qall("SELECT id,username FROM users WHERE is_active=1 ORDER BY username"); $users = qall("SELECT id,username FROM users WHERE is_active=1 ORDER BY username");
$bizs = qall("SELECT id,name FROM businesses WHERE is_active=1 ORDER BY name"); $bizs = qall("SELECT id,name FROM businesses WHERE is_active=1 ORDER BY name");
$owners = qall("SELECT bo.*,u.username,b.name biz_name FROM business_owners bo JOIN users u ON u.id=bo.user_id JOIN businesses b ON b.id=bo.business_id ORDER BY b.name,u.username"); $owners = qall("SELECT bo.*,u.username,b.name biz_name FROM business_owners bo JOIN users u ON u.id=bo.user_id JOIN businesses b ON b.id=bo.business_id ORDER BY b.name,u.username");
$claims = qall("
SELECT bcr.*, u.username, u.email, b.name biz_name, b.slug biz_slug
FROM business_claim_requests bcr
JOIN users u ON u.id=bcr.user_id
JOIN businesses b ON b.id=bcr.business_id
WHERE bcr.status='pending'
ORDER BY bcr.created_at DESC
");
?> ?>
<?php adminTitle('Assign Business Owners','Link user accounts to businesses so they can manage listings, post alerts, and update menus.') ?> <?php adminTitle('Assign Business Owners','Link user accounts to businesses so they can manage listings, post alerts, and update menus.') ?>
<div id="claims" class="admin-sec-title">PENDING BUSINESS CLAIMS (<?=count($claims)?>)</div>
<?php if($claims): ?>
<div class="tbl-wrap" style="margin-bottom:2rem">
<table class="dtbl">
<thead><tr><th>BUSINESS</th><th>REQUESTED BY</th><th>CONTACT PHONE</th><th>PENDING EDITS</th><th>SUBMITTED</th><th>ACTIONS</th></tr></thead>
<tbody>
<?php foreach($claims as $claim): ?>
<?php $editCount = (int)qval("SELECT COUNT(*) FROM business_edit_requests WHERE business_id=? AND user_id=? AND status='pending'", [$claim['business_id'], $claim['user_id']]); ?>
<tr>
<td><a href="/business.php?slug=<?=e($claim['biz_slug'])?>" target="_blank" class="td-name"><?=e($claim['biz_name'])?></a></td>
<td>
<div class="td-name"><?=e($claim['username'])?></div>
<?php if($claim['email']): ?><div style="font-size:.76rem;color:var(--text-d)"><?=e($claim['email'])?></div><?php endif; ?>
</td>
<td style="color:var(--text)"><?=e($claim['contact_phone'])?></td>
<td>
<?php if($editCount): ?>
<a href="/admin/biz_edits.php?biz_id=<?=$claim['business_id']?>" class="badge badge-gold"><?=$editCount?> edit<?=$editCount!==1?'s':''?></a>
<?php else: ?>
<span style="color:var(--text-d)">None</span>
<?php endif; ?>
</td>
<td style="font-size:.78rem;color:var(--text-d)"><?=ago($claim['created_at'] ?? '')?></td>
<td>
<div class="flex-row" style="gap:.35rem">
<form method="POST" style="display:inline">
<?=csrfField()?>
<input type="hidden" name="_act" value="approve_claim">
<input type="hidden" name="claim_id" value="<?=$claim['id']?>">
<button class="btn btn-xs btn-success" data-confirm="Approve this claim and assign <?=e($claim['username'])?> as owner of <?=e($claim['biz_name'])?>?">Approve</button>
</form>
<form method="POST" style="display:flex;gap:.35rem;align-items:center">
<?=csrfField()?>
<input type="hidden" name="_act" value="reject_claim">
<input type="hidden" name="claim_id" value="<?=$claim['id']?>">
<input type="text" name="admin_notes" class="finput" style="width:150px;font-size:.75rem;padding:.25rem .45rem" placeholder="Reason">
<button class="btn btn-xs btn-danger">Reject</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="empty" style="padding:1.75rem;margin-bottom:2rem"><div class="empty-ico">🔑</div><h3>No pending business claims</h3></div>
<?php endif; ?>
<div class="ibox" style="border-color:var(--gold-d);margin-bottom:2rem"> <div class="ibox" style="border-color:var(--gold-d);margin-bottom:2rem">
<div class="ibox-title">🔑 ASSIGN OWNER TO BUSINESS</div> <div class="ibox-title">🔑 ASSIGN OWNER TO BUSINESS</div>
<form method="POST"> <form method="POST">
+161 -1
View File
@@ -1,6 +1,7 @@
<?php <?php
require_once dirname(__DIR__).'/includes/boot.php';
requireAdmin();
$adminTitle = 'Site Settings'; $adminTitle = 'Site Settings';
include __DIR__.'/admin_header.php';
if (isPost()) { if (isPost()) {
csrfCheck(); csrfCheck();
@@ -12,6 +13,28 @@ if (isPost()) {
setSetting('contact_email', ps('contact_email')); setSetting('contact_email', ps('contact_email'));
setSetting('registration_enabled',p('registration_enabled','0')); setSetting('registration_enabled',p('registration_enabled','0'));
setSetting('events_require_approval',p('events_require_approval','0')); setSetting('events_require_approval',p('events_require_approval','0'));
setSetting('home_random_interval',p('home_random_interval','2hours') === 'day' ? 'day' : '2hours');
if (array_key_exists('event_upload_max_mb', $_POST)) {
setSetting('event_upload_max_mb', (string)max(1, (float)p('event_upload_max_mb','5')));
setSetting('menu_item_image_limit', (string)max(0, min(1, (int)p('menu_item_image_limit','1'))));
setSetting('menu_upload_max_mb', (string)max(1, (float)p('menu_upload_max_mb','5')));
setSetting('forum_image_resize_threshold_mb', (string)max(0.5, (float)p('forum_image_resize_threshold_mb','1')));
setSetting('forum_image_max_width', (string)max(640, min(3200, (int)p('forum_image_max_width','1600'))));
setSetting('forum_image_quality', (string)max(40, min(95, (int)p('forum_image_quality','82'))));
}
if (array_key_exists('mailgun_domain', $_POST)) {
setSetting('site_base_url', rtrim(ps('site_base_url'), '/'));
setSetting('mailgun_region', p('mailgun_region','us') === 'eu' ? 'eu' : 'us');
setSetting('mailgun_domain', ps('mailgun_domain'));
setSetting('mailgun_from_name', ps('mailgun_from_name') ?: 'Discover Keyser WV');
setSetting('mailgun_from_email', ps('mailgun_from_email'));
setSetting('mailgun_send_mode', p('mailgun_send_mode','html') === 'text' ? 'text' : 'html');
if (p('clear_mailgun_api_key','0') === '1') {
setSetting('mailgun_api_key', '');
} elseif (ps('mailgun_api_key') !== '') {
setSetting('mailgun_api_key', ps('mailgun_api_key'));
}
}
flash('Settings saved!','success'); flash('Settings saved!','success');
go('/admin/settings.php'); go('/admin/settings.php');
} }
@@ -33,7 +56,22 @@ $evtApproval = setting('events_require_approval','1');
$siteName = setting('site_name','Discover Keyser WV'); $siteName = setting('site_name','Discover Keyser WV');
$siteTagline = setting('site_tagline','The Friendliest City in the U.S.A.'); $siteTagline = setting('site_tagline','The Friendliest City in the U.S.A.');
$contactEmail = setting('contact_email','info@discoverkeyser.com'); $contactEmail = setting('contact_email','info@discoverkeyser.com');
$homeRandomInterval = setting('home_random_interval','2hours');
$siteBaseUrl = setting('site_base_url','');
$mailgunRegion = setting('mailgun_region','us');
$mailgunDomain = setting('mailgun_domain','');
$mailgunFromName = setting('mailgun_from_name','Discover Keyser WV');
$mailgunFromEmail = setting('mailgun_from_email','');
$mailgunSendMode = setting('mailgun_send_mode','html');
$mailgunHasKey = setting('mailgun_api_key','') !== '';
$eventUploadMax = setting('event_upload_max_mb','5');
$menuImageLimit = setting('menu_item_image_limit','1');
$menuUploadMax = setting('menu_upload_max_mb','5');
$resizeThreshold = setting('forum_image_resize_threshold_mb','1');
$resizeMaxWidth = setting('forum_image_max_width','1600');
$resizeQuality = setting('forum_image_quality','82');
$me = qone("SELECT * FROM users WHERE id=?",[uid()]); $me = qone("SELECT * FROM users WHERE id=?",[uid()]);
include __DIR__.'/admin_header.php';
?> ?>
<?php adminTitle('Site Settings') ?> <?php adminTitle('Site Settings') ?>
@@ -79,9 +117,131 @@ $me = qone("SELECT * FROM users WHERE id=?",[uid()]);
</div> </div>
</div> </div>
<div class="fg">
<label class="flabel">HOMEPAGE RANDOM BUSINESS ROTATION</label>
<div style="margin-top:.4rem">
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:.9rem;margin-bottom:.35rem">
<input type="radio" name="home_random_interval" value="2hours"<?=$homeRandomInterval==='2hours'?' checked':''?>>
<span>Refresh the random list every two hours</span>
</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:.9rem">
<input type="radio" name="home_random_interval" value="day"<?=$homeRandomInterval==='day'?' checked':''?>>
<span>Refresh the random list once per day</span>
</label>
</div>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button> <button type="submit" class="btn btn-primary">Save Settings</button>
</form> </form>
</div> </div>
<div class="ibox" style="border-color:var(--gold-d)">
<div class="ibox-title">🖼️ UPLOAD &amp; IMAGE SETTINGS</div>
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="settings">
<input type="hidden" name="site_name" value="<?=e($siteName)?>">
<input type="hidden" name="site_tagline" value="<?=e($siteTagline)?>">
<input type="hidden" name="contact_email" value="<?=e($contactEmail)?>">
<input type="hidden" name="registration_enabled" value="<?=e($regEnabled)?>">
<input type="hidden" name="events_require_approval" value="<?=e($evtApproval)?>">
<input type="hidden" name="home_random_interval" value="<?=e($homeRandomInterval)?>">
<div class="form-row">
<div class="fg"><label class="flabel">EVENT IMAGE MAX MB</label><input type="number" name="event_upload_max_mb" class="finput" min="1" step="0.5" value="<?=e($eventUploadMax)?>"></div>
<div class="fg"><label class="flabel">MENU IMAGE MAX MB</label><input type="number" name="menu_upload_max_mb" class="finput" min="1" step="0.5" value="<?=e($menuUploadMax)?>"></div>
</div>
<div class="form-row">
<div class="fg">
<label class="flabel">MENU ITEM IMAGE LIMIT</label>
<select name="menu_item_image_limit" class="fselect">
<option value="1"<?=$menuImageLimit==='1'?' selected':''?>>One image per item</option>
<option value="0"<?=$menuImageLimit==='0'?' selected':''?>>No menu item images</option>
</select>
</div>
</div>
<hr class="divider" style="margin:1rem 0">
<div class="form-row">
<div class="fg"><label class="flabel">RESIZE IMAGES OVER MB</label><input type="number" name="forum_image_resize_threshold_mb" class="finput" min="0.5" step="0.5" value="<?=e($resizeThreshold)?>"></div>
<div class="fg"><label class="flabel">MAX IMAGE WIDTH</label><input type="number" name="forum_image_max_width" class="finput" min="640" max="3200" step="80" value="<?=e($resizeMaxWidth)?>"></div>
</div>
<div class="fg"><label class="flabel">IMAGE QUALITY</label><input type="number" name="forum_image_quality" class="finput" min="40" max="95" value="<?=e($resizeQuality)?>"><div class="fhint">Used when large uploaded images are re-saved.</div></div>
<button type="submit" class="btn btn-primary">Save Upload Settings</button>
</form>
</div>
<div class="ibox" style="border-color:var(--gold-d)">
<div class="ibox-title">✉️ EMAIL VERIFICATION &amp; MAILGUN</div>
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="settings">
<input type="hidden" name="site_name" value="<?=e($siteName)?>">
<input type="hidden" name="site_tagline" value="<?=e($siteTagline)?>">
<input type="hidden" name="contact_email" value="<?=e($contactEmail)?>">
<input type="hidden" name="registration_enabled" value="<?=e($regEnabled)?>">
<input type="hidden" name="events_require_approval" value="<?=e($evtApproval)?>">
<input type="hidden" name="home_random_interval" value="<?=e($homeRandomInterval)?>">
<input type="hidden" name="event_upload_max_mb" value="<?=e($eventUploadMax)?>">
<input type="hidden" name="menu_item_image_limit" value="<?=e($menuImageLimit)?>">
<input type="hidden" name="menu_upload_max_mb" value="<?=e($menuUploadMax)?>">
<input type="hidden" name="forum_image_resize_threshold_mb" value="<?=e($resizeThreshold)?>">
<input type="hidden" name="forum_image_max_width" value="<?=e($resizeMaxWidth)?>">
<input type="hidden" name="forum_image_quality" value="<?=e($resizeQuality)?>">
<div class="fg">
<label class="flabel">SITE BASE URL</label>
<input type="url" name="site_base_url" class="finput" value="<?=e($siteBaseUrl)?>" placeholder="https://discoverkeyser.com">
<div class="fhint">Used to build verification links. Leave blank to detect the current host.</div>
</div>
<div class="form-row">
<div class="fg">
<label class="flabel">MAILGUN REGION</label>
<select name="mailgun_region" class="fselect">
<option value="us"<?=$mailgunRegion==='us'?' selected':''?>>US API (api.mailgun.net)</option>
<option value="eu"<?=$mailgunRegion==='eu'?' selected':''?>>EU API (api.eu.mailgun.net)</option>
</select>
</div>
<div class="fg">
<label class="flabel">MAILGUN DOMAIN</label>
<input type="text" name="mailgun_domain" class="finput" value="<?=e($mailgunDomain)?>" placeholder="mg.example.com">
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="flabel">FROM NAME</label>
<input type="text" name="mailgun_from_name" class="finput" value="<?=e($mailgunFromName)?>" placeholder="Discover Keyser WV">
</div>
<div class="fg">
<label class="flabel">FROM EMAIL</label>
<input type="email" name="mailgun_from_email" class="finput" value="<?=e($mailgunFromEmail)?>" placeholder="hello@mg.example.com">
</div>
</div>
<div class="fg">
<label class="flabel">MAILGUN API KEY</label>
<input type="password" name="mailgun_api_key" class="finput" value="" placeholder="<?=$mailgunHasKey?'Key saved - leave blank to keep it':'Enter Mailgun API key'?>">
<div class="fhint"><?=$mailgunHasKey?'An API key is saved. Enter a new key to replace it.':'No API key is saved yet.'?></div>
<?php if($mailgunHasKey): ?>
<label style="display:flex;align-items:center;gap:.45rem;margin-top:.55rem;font-size:.85rem;color:var(--text-m);cursor:pointer">
<input type="checkbox" name="clear_mailgun_api_key" value="1"> Clear saved API key
</label>
<?php endif; ?>
</div>
<div class="fg">
<label class="flabel">EMAIL SEND FORMAT</label>
<div style="margin-top:.4rem">
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:.9rem;margin-bottom:.35rem">
<input type="radio" name="mailgun_send_mode" value="html"<?=$mailgunSendMode==='html'?' checked':''?>>
<span>Designed HTML email with text fallback</span>
</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:.9rem">
<input type="radio" name="mailgun_send_mode" value="text"<?=$mailgunSendMode==='text'?' checked':''?>>
<span>Simple text-only email</span>
</label>
</div>
</div>
<button type="submit" class="btn btn-primary">Save Email Settings</button>
</form>
</div>
</div> </div>
<!-- Admin password change --> <!-- Admin password change -->
+183 -35
View File
@@ -1,21 +1,23 @@
/* ═══════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════
DISCOVER KEYSER WV — Dark West Virginia Theme DISCOVER KEYSER WV — Neon Mountain Night Theme
WV Gold #c9a84c · Navy #1a3a5c · Near-black backgrounds WV gold, electric cyan, aurora magenta, deep black backgrounds
═══════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════ */
:root { :root {
--gold:#c9a84c; --gold-l:#e2c06d; --gold-d:#8a6f2e; --gold-dim:rgba(201,168,76,.13); --gold:#ffd166; --gold-l:#ffe08f; --gold-d:#a57824; --gold-dim:rgba(255,209,102,.14);
--navy:#1a3a5c; --navy-m:#1e4876; --navy-l:#2a5f99; --cyan:#26f4ff; --cyan-l:#8ffbff; --cyan-d:#118895; --cyan-dim:rgba(38,244,255,.13);
--bg0:#07090e; --bg1:#0a0f18; --bg2:#0d1520; --bg3:#111c2c; --bg4:#162436; --bg5:#1c2e46; --pink:#ff4fd8; --pink-l:#ff92ea; --pink-d:#9c2d82; --pink-dim:rgba(255,79,216,.12);
--bdr:#1e3050; --bdr-l:#253d60; --navy:#142142; --navy-m:#1f3769; --navy-l:#3157a5;
--text:#e2ddd0; --text-m:#9aa4b8; --text-d:#58687e; --bg0:#03050b; --bg1:#060914; --bg2:#0a1020; --bg3:#0e172b; --bg4:#121f38; --bg5:#172846;
--green:#2a7a47; --green-l:#3da558; --red:#8f2d2d; --red-l:#c04040; --amber:#b37820; --bdr:#1b3555; --bdr-l:#285076;
--r:6px; --r-lg:12px; --text:#f2f4ff; --text-m:#aab7cf; --text-d:#65768f;
--sh:0 6px 30px rgba(0,0,0,.55); --sh-sm:0 2px 12px rgba(0,0,0,.4); --green:#1d8f62; --green-l:#4dff9b; --red:#9d2c4a; --red-l:#ff5f7b; --amber:#e09a2a;
--r:6px; --r-lg:8px;
--sh:0 12px 42px rgba(0,0,0,.62),0 0 34px rgba(38,244,255,.06); --sh-sm:0 4px 18px rgba(0,0,0,.45);
--t:.18s ease; --t:.18s ease;
} }
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth} html{scroll-behavior:smooth}
body{font-family:'Source Sans 3',sans-serif;background:var(--bg1);color:var(--text);line-height:1.65;min-height:100vh;-webkit-font-smoothing:antialiased} body{font-family:'Source Sans 3',sans-serif;background:radial-gradient(circle at 12% 0%,rgba(38,244,255,.12),transparent 28rem),radial-gradient(circle at 88% 8%,rgba(255,79,216,.1),transparent 30rem),linear-gradient(180deg,var(--bg0),var(--bg1) 28rem,var(--bg2));color:var(--text);line-height:1.65;min-height:100vh;-webkit-font-smoothing:antialiased}
a{color:var(--gold);text-decoration:none;transition:color var(--t)} a{color:var(--gold);text-decoration:none;transition:color var(--t)}
a:hover{color:var(--gold-l)} a:hover{color:var(--gold-l)}
h1,h2,h3{font-family:'Playfair Display',serif;line-height:1.15} h1,h2,h3{font-family:'Playfair Display',serif;line-height:1.15}
@@ -25,19 +27,19 @@ button{cursor:pointer;font-family:inherit} input,select,textarea{font-family:inh
address{font-style:normal} address{font-style:normal}
/* ── Navbar ─────────────────────────────────────────────── */ /* ── Navbar ─────────────────────────────────────────────── */
.navbar{position:sticky;top:0;z-index:1000;background:rgba(7,9,14,.97);border-bottom:1px solid var(--gold-dim);backdrop-filter:blur(16px)} .navbar{position:sticky;top:0;z-index:1000;background:rgba(3,5,11,.86);border-bottom:1px solid rgba(38,244,255,.18);backdrop-filter:blur(18px);box-shadow:0 1px 0 rgba(255,209,102,.08),0 0 28px rgba(38,244,255,.05)}
.nav-wrap{max-width:1440px;margin:0 auto;padding:0 1.5rem;height:64px;display:flex;align-items:center;justify-content:space-between;gap:1rem} .nav-wrap{max-width:1440px;margin:0 auto;padding:0 1.5rem;height:64px;display:flex;align-items:center;justify-content:space-between;gap:1rem}
.nav-brand{display:flex;align-items:center;gap:.65rem;color:var(--text);flex-shrink:0} .nav-brand{display:flex;align-items:center;gap:.65rem;color:var(--text);flex-shrink:0}
.brand-mtn{font-size:1.9rem;filter:drop-shadow(0 0 10px rgba(201,168,76,.45))} .brand-mtn{font-size:1.9rem;filter:drop-shadow(0 0 10px rgba(38,244,255,.55))}
.nav-brand>div{display:flex;flex-direction:column;line-height:1} .nav-brand>div{display:flex;flex-direction:column;line-height:1}
.brand-city{font-family:'Oswald',sans-serif;font-size:1.15rem;font-weight:600;letter-spacing:.2em;color:var(--gold)} .brand-city{font-family:'Oswald',sans-serif;font-size:1.15rem;font-weight:600;letter-spacing:.2em;color:var(--gold);text-shadow:0 0 16px rgba(255,209,102,.24)}
.brand-wv{font-size:.52rem;letter-spacing:.3em;color:var(--text-m)} .brand-wv{font-size:.52rem;letter-spacing:.3em;color:var(--text-m)}
.nav-links{display:flex;align-items:center;gap:.12rem;flex-wrap:nowrap} .nav-links{display:flex;align-items:center;gap:.12rem;flex-wrap:nowrap}
.nl{display:block;padding:.38rem .72rem;border-radius:4px;font-family:'Oswald',sans-serif;font-size:.78rem;letter-spacing:.1em;color:var(--text-m);transition:all var(--t)} .nl{display:block;padding:.38rem .72rem;border-radius:4px;font-family:'Oswald',sans-serif;font-size:.78rem;letter-spacing:.1em;color:var(--text-m);transition:all var(--t)}
.nl:hover,.nl.active{color:var(--gold);background:var(--gold-dim)} .nl:hover,.nl.active{color:var(--cyan-l);background:var(--cyan-dim)}
.nl-admin{color:var(--gold)!important;border:1px solid var(--gold-dim)} .nl-admin{color:var(--gold)!important;border:1px solid var(--gold-dim)}
.nl-join{background:var(--gold);color:var(--bg0)!important;font-weight:600;padding:.38rem .85rem} .nl-join{background:linear-gradient(135deg,var(--gold),var(--cyan));color:var(--bg0)!important;font-weight:600;padding:.38rem .85rem;box-shadow:0 0 18px rgba(38,244,255,.18)}
.nl-join:hover{background:var(--gold-l);color:var(--bg0)!important} .nl-join:hover{background:linear-gradient(135deg,var(--gold-l),var(--cyan-l));color:var(--bg0)!important}
.user-wrap{position:relative} .user-wrap{position:relative}
.user-btn{background:var(--bg4);border:1px solid var(--bdr-l);color:var(--text-m);padding:.38rem .75rem;border-radius:4px;font-family:'Oswald',sans-serif;font-size:.78rem;letter-spacing:.08em;transition:all var(--t)} .user-btn{background:var(--bg4);border:1px solid var(--bdr-l);color:var(--text-m);padding:.38rem .75rem;border-radius:4px;font-family:'Oswald',sans-serif;font-size:.78rem;letter-spacing:.08em;transition:all var(--t)}
.user-btn:hover{border-color:var(--gold-d);color:var(--gold)} .user-btn:hover{border-color:var(--gold-d);color:var(--gold)}
@@ -60,7 +62,7 @@ address{font-style:normal}
/* ── Hero ──────────────────────────────────────────────── */ /* ── Hero ──────────────────────────────────────────────── */
.hero{position:relative;min-height:90vh;display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--bg0)} .hero{position:relative;min-height:90vh;display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--bg0)}
.hero-bg{position:absolute;inset:0;background:radial-gradient(ellipse 75% 50% at 50% 30%,rgba(26,58,92,.58),transparent 70%),radial-gradient(ellipse 45% 35% at 15% 80%,rgba(201,168,76,.06),transparent),linear-gradient(180deg,var(--bg0),#0c1828 55%,#060a0f)} .hero-bg{position:absolute;inset:0;background:radial-gradient(ellipse 75% 50% at 50% 30%,rgba(38,244,255,.25),transparent 70%),radial-gradient(ellipse 45% 35% at 15% 80%,rgba(255,209,102,.08),transparent),linear-gradient(180deg,var(--bg0),#071526 55%,#03050b)}
.hero-stars{position:absolute;inset:0;pointer-events:none;background:radial-gradient(1px 1px at 8% 10%,rgba(255,255,255,.6),transparent),radial-gradient(2px 2px at 38% 18%,rgba(201,168,76,.55),transparent),radial-gradient(1px 1px at 55% 8%,rgba(255,255,255,.45),transparent),radial-gradient(1px 1px at 72% 22%,rgba(255,255,255,.35),transparent),radial-gradient(1px 1px at 87% 14%,rgba(255,255,255,.5),transparent),radial-gradient(1px 1px at 20% 45%,rgba(255,255,255,.2),transparent),radial-gradient(1px 1px at 62% 42%,rgba(255,255,255,.28),transparent)} .hero-stars{position:absolute;inset:0;pointer-events:none;background:radial-gradient(1px 1px at 8% 10%,rgba(255,255,255,.6),transparent),radial-gradient(2px 2px at 38% 18%,rgba(201,168,76,.55),transparent),radial-gradient(1px 1px at 55% 8%,rgba(255,255,255,.45),transparent),radial-gradient(1px 1px at 72% 22%,rgba(255,255,255,.35),transparent),radial-gradient(1px 1px at 87% 14%,rgba(255,255,255,.5),transparent),radial-gradient(1px 1px at 20% 45%,rgba(255,255,255,.2),transparent),radial-gradient(1px 1px at 62% 42%,rgba(255,255,255,.28),transparent)}
.hero-mtns{position:absolute;bottom:0;left:0;right:0;height:48%;z-index:1} .hero-mtns{position:absolute;bottom:0;left:0;right:0;height:48%;z-index:1}
.hero-mtns svg{width:100%;height:100%} .hero-mtns svg{width:100%;height:100%}
@@ -75,12 +77,12 @@ address{font-style:normal}
/* ── Buttons ────────────────────────────────────────────── */ /* ── Buttons ────────────────────────────────────────────── */
.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.72rem 1.65rem;border-radius:var(--r);font-family:'Oswald',sans-serif;font-size:.82rem;letter-spacing:.12em;font-weight:500;border:none;cursor:pointer;transition:all var(--t);text-decoration:none;white-space:nowrap} .btn{display:inline-flex;align-items:center;gap:.4rem;padding:.72rem 1.65rem;border-radius:var(--r);font-family:'Oswald',sans-serif;font-size:.82rem;letter-spacing:.12em;font-weight:500;border:none;cursor:pointer;transition:all var(--t);text-decoration:none;white-space:nowrap}
.btn-primary{background:var(--gold);color:var(--bg0)} .btn-primary{background:linear-gradient(135deg,var(--gold),var(--cyan));color:var(--bg0);box-shadow:0 0 20px rgba(38,244,255,.16)}
.btn-primary:hover{background:var(--gold-l);color:var(--bg0);transform:translateY(-1px);box-shadow:0 4px 20px rgba(201,168,76,.3)} .btn-primary:hover{background:linear-gradient(135deg,var(--gold-l),var(--cyan-l));color:var(--bg0);transform:translateY(-1px);box-shadow:0 6px 26px rgba(38,244,255,.22)}
.btn-outline{background:transparent;border:1px solid var(--gold-d);color:var(--gold)} .btn-outline{background:rgba(38,244,255,.03);border:1px solid rgba(38,244,255,.42);color:var(--cyan-l)}
.btn-outline:hover{background:var(--gold-dim);border-color:var(--gold)} .btn-outline:hover{background:var(--cyan-dim);border-color:var(--cyan);color:var(--text)}
.btn-secondary{background:var(--bg4);border:1px solid var(--bdr-l);color:var(--text-m)} .btn-secondary{background:var(--bg4);border:1px solid var(--bdr-l);color:var(--text-m)}
.btn-secondary:hover{background:var(--bg5);border-color:var(--gold-d);color:var(--text)} .btn-secondary:hover{background:var(--bg5);border-color:var(--pink-d);color:var(--text)}
.btn-navy{background:var(--navy-m);color:#fff} .btn-navy{background:var(--navy-m);color:#fff}
.btn-navy:hover{background:var(--navy-l)} .btn-navy:hover{background:var(--navy-l)}
.btn-danger{background:var(--red);color:#fff} .btn-danger{background:var(--red);color:#fff}
@@ -92,7 +94,7 @@ address{font-style:normal}
.btn-block{width:100%;justify-content:center} .btn-block{width:100%;justify-content:center}
/* ── Stats ──────────────────────────────────────────────── */ /* ── Stats ──────────────────────────────────────────────── */
.stats-strip{background:var(--bg2);border-top:1px solid var(--gold-dim);border-bottom:1px solid var(--gold-dim);padding:2rem 1.5rem} .stats-strip{background:rgba(10,16,32,.78);border-top:1px solid var(--cyan-dim);border-bottom:1px solid var(--gold-dim);padding:2rem 1.5rem}
.stats-inner{max-width:1440px;margin:0 auto;display:flex;justify-content:space-around;flex-wrap:wrap;gap:.5rem} .stats-inner{max-width:1440px;margin:0 auto;display:flex;justify-content:space-around;flex-wrap:wrap;gap:.5rem}
.stat{text-align:center;padding:.75rem 1.25rem} .stat{text-align:center;padding:.75rem 1.25rem}
.stat-n{font-family:'Playfair Display',serif;font-size:2.6rem;font-weight:900;color:var(--gold);line-height:1} .stat-n{font-family:'Playfair Display',serif;font-size:2.6rem;font-weight:900;color:var(--gold);line-height:1}
@@ -106,6 +108,50 @@ address{font-style:normal}
.eyebrow{font-family:'Oswald',sans-serif;font-size:.68rem;letter-spacing:.32em;color:var(--gold);margin-bottom:.5rem} .eyebrow{font-family:'Oswald',sans-serif;font-size:.68rem;letter-spacing:.32em;color:var(--gold);margin-bottom:.5rem}
.sec-title{font-size:clamp(1.8rem,3vw,2.6rem)} .sec-title{font-size:clamp(1.8rem,3vw,2.6rem)}
.sec-rule{width:44px;height:2px;background:var(--gold);margin-top:.7rem} .sec-rule{width:44px;height:2px;background:var(--gold);margin-top:.7rem}
.section-head{display:flex;align-items:end;justify-content:space-between;gap:1rem;margin-bottom:2rem}
.card-link{display:block;text-decoration:none;height:100%}
.view-label{color:var(--cyan-l);font-size:.74rem;font-family:'Oswald',sans-serif;letter-spacing:.14em}
/* ── Neon homepage ─────────────────────────────────────── */
.home-hero{position:relative;overflow:hidden;min-height:calc(100vh - 64px);display:flex;align-items:center;padding:5.5rem 1.5rem 4.5rem;background:linear-gradient(145deg,rgba(3,5,11,.9),rgba(10,16,32,.98))}
.home-hero::before{content:"";position:absolute;inset:0;background:linear-gradient(rgba(38,244,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(38,244,255,.045) 1px,transparent 1px);background-size:44px 44px;mask-image:linear-gradient(180deg,rgba(0,0,0,.9),transparent 86%);pointer-events:none}
.home-hero-glow{position:absolute;inset:0;background:radial-gradient(circle at 18% 30%,rgba(38,244,255,.22),transparent 24rem),radial-gradient(circle at 78% 20%,rgba(255,79,216,.16),transparent 26rem),radial-gradient(circle at 54% 86%,rgba(255,209,102,.1),transparent 30rem)}
.home-hero-grid{position:relative;z-index:1;max-width:1440px;margin:0 auto;width:100%;display:grid;grid-template-columns:minmax(0,1.12fr) minmax(320px,.72fr);gap:3rem;align-items:center}
.home-hero-copy{text-align:left;max-width:820px}
.home-hero .hero-eyebrow{border-color:rgba(38,244,255,.32);background:rgba(38,244,255,.08);color:var(--cyan-l)}
.home-hero .hero-h1{text-align:left;font-size:clamp(3.5rem,9vw,7.4rem);text-shadow:0 0 34px rgba(38,244,255,.2),0 8px 54px rgba(0,0,0,.85)}
.home-hero .hero-h1 .gld{background:linear-gradient(90deg,var(--gold),var(--cyan),var(--pink-l));-webkit-background-clip:text;background-clip:text;color:transparent}
.home-hero .hero-desc{margin:1.2rem 0 0;max-width:650px;color:var(--text-m);font-size:1.12rem}
.hero-search{margin-top:2rem;display:flex;gap:.65rem;max-width:670px;position:relative}
.hero-search .s-icon{left:1rem;color:var(--cyan)}
.hero-search .search-input{padding-left:2.6rem;border-color:rgba(38,244,255,.34);background:rgba(0,0,0,.42)}
.home-hero .hero-actions{justify-content:flex-start;margin-top:1.25rem}
.hero-signal{display:grid;gap:1rem}
.signal-card{background:linear-gradient(180deg,rgba(18,31,56,.82),rgba(6,9,20,.86));border:1px solid rgba(38,244,255,.18);border-radius:var(--r);padding:1.25rem;box-shadow:0 0 28px rgba(38,244,255,.07)}
.signal-card-main{min-height:210px;display:flex;flex-direction:column;justify-content:center;border-color:rgba(255,209,102,.26)}
.signal-label,.signal-copy{font-family:'Oswald',sans-serif;letter-spacing:.14em;font-size:.68rem;color:var(--text-d);text-transform:uppercase}
.signal-number{font-family:'Playfair Display',serif;font-weight:900;font-size:clamp(4rem,8vw,6.5rem);line-height:.95;color:var(--gold);text-shadow:0 0 28px rgba(255,209,102,.2)}
.signal-small{font-family:'Playfair Display',serif;font-weight:900;font-size:1.55rem;color:var(--cyan-l);line-height:1.05}
.signal-mini-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.neon-alerts{border-top:1px solid rgba(38,244,255,.14);background:rgba(6,9,20,.78)}
.home-band{background:linear-gradient(180deg,rgba(10,16,32,.92),rgba(6,9,20,.92));border-top:1px solid rgba(255,79,216,.13);border-bottom:1px solid rgba(38,244,255,.13)}
.event-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1rem}
.event-strip .evt-card{height:100%;margin-bottom:0}
.event-empty{padding:2rem;border:1px dashed rgba(38,244,255,.22);border-radius:var(--r);background:rgba(0,0,0,.14)}
.about-split{display:grid;grid-template-columns:minmax(0,.95fr) minmax(320px,1fr);gap:3rem;align-items:start}
.home-facts{grid-template-columns:repeat(2,minmax(0,1fr))}
.random-section{padding-top:3rem}
.random-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}
.random-row{display:grid;grid-template-columns:44px 1fr auto;align-items:center;gap:.8rem;padding:.9rem 1rem;background:rgba(18,31,56,.78);border:1px solid rgba(38,244,255,.14);border-radius:var(--r);color:var(--text);transition:all var(--t)}
.random-row:hover{border-color:rgba(38,244,255,.42);background:rgba(18,31,56,.96);transform:translateY(-1px);box-shadow:var(--sh-sm)}
.random-cat{display:grid;place-items:center;width:36px;height:36px;border-radius:50%;background:rgba(38,244,255,.1);border:1px solid rgba(38,244,255,.2)}
.random-main{min-width:0}
.random-main strong{display:block;color:var(--text);font-size:.96rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.random-main em{display:block;color:var(--text-d);font-style:normal;font-size:.78rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.random-arrow{color:var(--cyan);font-family:'Oswald',sans-serif}
.join-band{max-width:1200px;margin:0 auto 4rem;padding:2rem 1.5rem;display:flex;align-items:center;justify-content:space-between;gap:1.5rem;border-top:1px solid rgba(38,244,255,.18);border-bottom:1px solid rgba(255,209,102,.18)}
.join-band h2{font-size:clamp(1.6rem,3vw,2.3rem);margin:.2rem 0 .45rem}
.join-band p{max-width:720px;color:var(--text-m);line-height:1.75}
/* ── Grids ──────────────────────────────────────────────── */ /* ── Grids ──────────────────────────────────────────────── */
.g3{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1.4rem} .g3{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1.4rem}
@@ -113,19 +159,19 @@ address{font-style:normal}
.g2{display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:1.5rem} .g2{display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:1.5rem}
/* ── Business card ──────────────────────────────────────── */ /* ── Business card ──────────────────────────────────────── */
.biz-card{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);overflow:hidden;display:flex;flex-direction:column;transition:all .22s;height:100%} .biz-card{background:linear-gradient(180deg,rgba(18,31,56,.96),rgba(9,15,30,.96));border:1px solid rgba(38,244,255,.16);border-radius:var(--r);overflow:hidden;display:flex;flex-direction:column;transition:all .22s;height:100%;box-shadow:0 0 0 1px rgba(255,209,102,.03)}
.biz-card:hover{transform:translateY(-3px);border-color:var(--gold-d);box-shadow:var(--sh)} .biz-card:hover{transform:translateY(-3px);border-color:rgba(38,244,255,.48);box-shadow:var(--sh)}
.biz-card-top{padding:1.2rem 1.4rem;background:linear-gradient(135deg,var(--bg4),var(--bg3));border-bottom:1px solid var(--bdr)} .biz-card-top{padding:1.2rem 1.4rem;background:linear-gradient(135deg,rgba(38,244,255,.1),rgba(255,79,216,.06),rgba(255,209,102,.05));border-bottom:1px solid rgba(38,244,255,.13)}
.cat-badge{display:inline-block;padding:.16rem .52rem;border-radius:2px;font-family:'Oswald',sans-serif;font-size:.6rem;letter-spacing:.14em;background:var(--gold-dim);border:1px solid rgba(201,168,76,.25);color:var(--gold);margin-bottom:.45rem} .cat-badge{display:inline-block;padding:.16rem .52rem;border-radius:2px;font-family:'Oswald',sans-serif;font-size:.6rem;letter-spacing:.14em;background:var(--cyan-dim);border:1px solid rgba(38,244,255,.28);color:var(--cyan-l);margin-bottom:.45rem}
.biz-name{font-size:1.12rem;font-weight:700;color:var(--text);line-height:1.2} .biz-name{font-size:1.12rem;font-weight:700;color:var(--text);line-height:1.2}
.biz-sub{font-size:.78rem;color:var(--text-d);margin-top:.15rem} .biz-sub{font-size:.78rem;color:var(--text-d);margin-top:.15rem}
.biz-card-body{padding:1rem 1.4rem;flex:1} .biz-card-body{padding:1rem 1.4rem;flex:1}
.biz-desc{font-size:.86rem;color:var(--text-m);line-height:1.65;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} .biz-desc{font-size:.86rem;color:var(--text-m);line-height:1.65;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.biz-meta{margin-top:.8rem;display:flex;flex-direction:column;gap:.3rem} .biz-meta{margin-top:.8rem;display:flex;flex-direction:column;gap:.3rem}
.biz-meta-row{display:flex;gap:.45rem;font-size:.78rem;color:var(--text-d);align-items:flex-start} .biz-meta-row{display:flex;gap:.45rem;font-size:.78rem;color:var(--text-d);align-items:flex-start}
.biz-meta-icon{color:var(--gold);flex-shrink:0;width:16px;text-align:center} .biz-meta-icon{color:var(--cyan);flex-shrink:0;width:16px;text-align:center}
.biz-card-foot{padding:.9rem 1.4rem;border-top:1px solid var(--bdr);background:var(--bg4);display:flex;justify-content:space-between;align-items:center} .biz-card-foot{padding:.9rem 1.4rem;border-top:1px solid rgba(38,244,255,.14);background:rgba(18,31,56,.72);display:flex;justify-content:space-between;align-items:center}
.featured-badge{background:linear-gradient(135deg,var(--gold),#a07828);color:var(--bg0);font-family:'Oswald',sans-serif;font-size:.6rem;letter-spacing:.18em;padding:.15rem .5rem;border-radius:2px} .featured-badge{background:linear-gradient(135deg,var(--gold),var(--pink));color:var(--bg0);font-family:'Oswald',sans-serif;font-size:.6rem;letter-spacing:.18em;padding:.15rem .5rem;border-radius:2px}
/* ── Stars ──────────────────────────────────────────────── */ /* ── Stars ──────────────────────────────────────────────── */
.stars{display:inline-flex;gap:1px} .stars{display:inline-flex;gap:1px}
@@ -209,6 +255,51 @@ address{font-style:normal}
.fhint{font-size:.75rem;color:var(--text-d);margin-top:.3rem} .fhint{font-size:.75rem;color:var(--text-d);margin-top:.3rem}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem} .form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.err-box{padding:.65rem 1rem;border-radius:4px;margin-bottom:1rem;font-size:.88rem;background:rgba(143,45,45,.28);border:1px solid var(--red-l);color:#ff9a9a} .err-box{padding:.65rem 1rem;border-radius:4px;margin-bottom:1rem;font-size:.88rem;background:rgba(143,45,45,.28);border:1px solid var(--red-l);color:#ff9a9a}
.hp-field{position:absolute;left:-9999px;top:-9999px;opacity:0;height:0;width:0;overflow:hidden;pointer-events:none}
.tos-check{display:flex;align-items:flex-start;gap:.65rem;background:rgba(18,31,56,.78);border:1px solid rgba(38,244,255,.18);border-radius:var(--r);padding:1rem 1.15rem;cursor:pointer;transition:border-color var(--t);margin:1.4rem 0}
.tos-check:has(input:checked){border-color:rgba(77,255,155,.5)}
.tos-check input[type="checkbox"]{flex-shrink:0;margin-top:3px;width:17px;height:17px;accent-color:var(--green-l);cursor:pointer}
.tos-check-text{font-size:.88rem;color:var(--text-m);line-height:1.6;user-select:none}
.tos-check-text strong{color:var(--text)}
/* ── Signup tiers ──────────────────────────────────────── */
.signup-shell{max-width:1040px;margin:0 auto;padding:4rem 1.5rem}
.signup-intro{text-align:center;margin-bottom:2rem}
.signup-intro .eyebrow{justify-content:center;display:flex;color:var(--cyan-l)}
.signup-intro h1{font-size:clamp(2.3rem,5vw,3.5rem);margin:.3rem 0}
.signup-intro p{max-width:650px;margin:0 auto;color:var(--text-m);line-height:1.75}
.signup-errors{max-width:860px;margin:0 auto 1.25rem}
.signup-card{background:linear-gradient(180deg,rgba(14,23,43,.96),rgba(8,13,27,.96));border:1px solid rgba(38,244,255,.18);border-radius:var(--r-lg);padding:1.5rem;box-shadow:var(--sh);position:relative}
.tier-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem}
.tier-card{display:grid;gap:.35rem;align-content:start;min-height:170px;border:1px solid rgba(38,244,255,.16);border-radius:var(--r);padding:1.25rem;background:rgba(18,31,56,.7);cursor:pointer;transition:all var(--t)}
.tier-card:hover{border-color:rgba(38,244,255,.45);transform:translateY(-1px)}
.tier-card input{position:absolute;opacity:0;pointer-events:none}
.tier-card:has(input:checked){border-color:var(--gold);background:linear-gradient(180deg,rgba(255,209,102,.12),rgba(38,244,255,.08));box-shadow:0 0 24px rgba(255,209,102,.08)}
.tier-card-owner:has(input:checked){border-color:var(--cyan)}
.tier-kicker{font-family:'Oswald',sans-serif;font-size:.66rem;letter-spacing:.22em;color:var(--cyan-l);text-transform:uppercase}
.tier-title{font-family:'Playfair Display',serif;font-size:1.35rem;color:var(--text);line-height:1.15}
.tier-copy{font-size:.9rem;color:var(--text-m);line-height:1.55}
.signup-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.signup-grid .fg{margin-bottom:0}
.signup-wide{grid-column:1/-1}
.owner-claim-fields{display:none;margin-top:1.5rem;padding-top:1.5rem;border-top:1px solid rgba(38,244,255,.15)}
.owner-claim-fields.is-open{display:block}
.owner-claim-note{background:rgba(38,244,255,.08);border:1px solid rgba(38,244,255,.22);border-radius:var(--r);padding:1rem 1.15rem;color:var(--text-m);font-size:.9rem;line-height:1.65;margin-bottom:1rem}
.owner-claim-note strong{color:var(--cyan-l)}
.claim-edit-panel{margin-top:1rem;background:rgba(0,0,0,.16);border:1px solid rgba(255,209,102,.14);border-radius:var(--r);padding:1.2rem}
.signup-submit{font-size:.9rem;padding:.85rem}
.signin-link{text-align:center;margin-top:1.1rem;font-size:.84rem;color:var(--text-d)}
/* ── Embedded business video ───────────────────────────── */
.business-video-box{border-color:rgba(38,244,255,.24)}
.embed-shell{position:relative;width:100%;aspect-ratio:16/9;overflow:hidden;border-radius:var(--r);background:#000;border:1px solid rgba(38,244,255,.22);box-shadow:0 0 26px rgba(38,244,255,.08)}
.embed-shell iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:#000}
.verify-card{max-width:680px;margin:1rem auto;background:linear-gradient(180deg,rgba(14,23,43,.96),rgba(8,13,27,.96));border:1px solid rgba(38,244,255,.2);border-radius:var(--r-lg);padding:2.2rem;text-align:center;box-shadow:var(--sh)}
.verify-card h1{font-size:clamp(2rem,4vw,3rem);margin:.35rem 0 .75rem}
.verify-card p{color:var(--text-m);line-height:1.75;max-width:520px;margin:0 auto}
.verify-card .verify-detail{margin-top:.8rem;color:var(--text-d);font-size:.94rem}
.verify-success{border-color:rgba(77,255,155,.38)}
.verify-error{border-color:rgba(255,95,123,.34)}
/* ── Search ─────────────────────────────────────────────── */ /* ── Search ─────────────────────────────────────────────── */
.search-bar{background:var(--bg2);padding:1.75rem 1.5rem;border-bottom:1px solid var(--bdr)} .search-bar{background:var(--bg2);padding:1.75rem 1.5rem;border-bottom:1px solid var(--bdr)}
@@ -255,6 +346,8 @@ address{font-style:normal}
/* ── Events ─────────────────────────────────────────────── */ /* ── Events ─────────────────────────────────────────────── */
.evt-card{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.5rem;display:flex;gap:1.5rem;transition:all .22s;margin-bottom:1.1rem} .evt-card{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.5rem;display:flex;gap:1.5rem;transition:all .22s;margin-bottom:1.1rem}
.evt-card:hover{border-color:var(--gold-d);box-shadow:var(--sh-sm)} .evt-card:hover{border-color:var(--gold-d);box-shadow:var(--sh-sm)}
.evt-link-card{color:inherit;text-decoration:none}
.evt-link-card:hover{color:inherit;transform:translateY(-1px)}
.evt-date-box{flex-shrink:0;text-align:center;background:var(--bg4);border:1px solid var(--bdr-l);border-radius:var(--r);padding:.65rem .85rem;min-width:64px} .evt-date-box{flex-shrink:0;text-align:center;background:var(--bg4);border:1px solid var(--bdr-l);border-radius:var(--r);padding:.65rem .85rem;min-width:64px}
.evt-mon{font-family:'Oswald',sans-serif;font-size:.68rem;letter-spacing:.18em;color:var(--gold)} .evt-mon{font-family:'Oswald',sans-serif;font-size:.68rem;letter-spacing:.18em;color:var(--gold)}
.evt-day{font-family:'Playfair Display',serif;font-size:2rem;color:var(--text);line-height:1} .evt-day{font-family:'Playfair Display',serif;font-size:2rem;color:var(--text);line-height:1}
@@ -265,6 +358,25 @@ address{font-style:normal}
.evt-meta span{display:flex;align-items:center;gap:.3rem} .evt-meta span{display:flex;align-items:center;gap:.3rem}
.evt-desc{font-size:.88rem;color:var(--text-m);line-height:1.62} .evt-desc{font-size:.88rem;color:var(--text-m);line-height:1.62}
.evt-actions{margin-top:.75rem;display:flex;gap:.5rem;flex-wrap:wrap} .evt-actions{margin-top:.75rem;display:flex;gap:.5rem;flex-wrap:wrap}
.evt-image-wrap{width:180px;min-height:126px;align-self:stretch;flex:0 0 180px;overflow:hidden;border-radius:var(--r);border:1px solid rgba(38,244,255,.2);background:var(--bg4)}
.evt-image{width:100%;height:100%;object-fit:cover}
.event-hero-photo{padding:0;overflow:hidden}
.event-photo-btn{display:block;position:relative;width:100%;border:0;background:var(--bg4);padding:0;color:var(--text);text-align:left}
.event-photo-btn img{width:100%;max-height:560px;object-fit:contain;background:#000}
.event-photo-btn span{position:absolute;right:1rem;bottom:1rem;background:rgba(3,5,11,.82);border:1px solid rgba(255,209,102,.36);border-radius:var(--r);padding:.45rem .75rem;font-family:'Oswald',sans-serif;font-size:.74rem;letter-spacing:.1em;color:var(--gold)}
.event-detail-date{display:flex;gap:.85rem;align-items:center;padding-bottom:.85rem;margin-bottom:.5rem;border-bottom:1px solid var(--bdr)}
.lightbox{position:fixed;inset:0;z-index:5000;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.88);padding:1.5rem}
.lightbox.open{display:flex}
.lightbox img{max-width:min(1200px,96vw);max-height:88vh;object-fit:contain;border-radius:var(--r);box-shadow:var(--sh)}
.lightbox-close{position:absolute;right:1rem;top:1rem;width:42px;height:42px;border:1px solid rgba(255,255,255,.25);border-radius:999px;background:rgba(3,5,11,.75);color:#fff;font-size:1.6rem;line-height:1}
.event-strip .evt-card{display:grid;grid-template-columns:auto 1fr;align-items:start}
.event-strip .evt-image-wrap{grid-column:1/-1;width:100%;height:150px;min-height:150px}
.event-admin-image,.menu-admin-image{display:flex;align-items:center;gap:.85rem;margin-bottom:.65rem;flex-wrap:wrap}
.event-admin-image img{width:150px;height:88px;object-fit:cover;border-radius:var(--r);border:1px solid var(--bdr-l)}
.menu-admin-image img,.admin-menu-thumb{width:72px;height:72px;object-fit:cover;border-radius:var(--r);border:1px solid var(--bdr-l)}
.event-import-grid{display:grid;grid-template-columns:180px minmax(0,1fr);gap:1rem;align-items:start}
.event-import-preview{width:180px;height:108px;background:var(--bg2);border:1px solid var(--bdr);border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--text-d);font-size:.8rem}
.event-import-preview img{width:100%;height:100%;object-fit:cover}
/* ── About / Facts ──────────────────────────────────────── */ /* ── About / Facts ──────────────────────────────────────── */
.fact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:1.1rem} .fact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:1.1rem}
@@ -318,19 +430,55 @@ address{font-style:normal}
.text-gold{color:var(--gold)}.text-muted{color:var(--text-m)}.text-dim{color:var(--text-d)} .text-gold{color:var(--gold)}.text-muted{color:var(--text-m)}.text-dim{color:var(--text-d)}
.text-right{text-align:right}.text-center{text-align:center} .text-right{text-align:right}.text-center{text-align:center}
/* ── Reviews ───────────────────────────────────────────── */
.rev-user{display:flex;align-items:center;gap:.7rem}
/* ── Menu images ────────────────────────────────────────── */
.menu-item{gap:.9rem}
.mi-image{width:86px;height:86px;object-fit:cover;border-radius:var(--r);border:1px solid rgba(38,244,255,.2);background:var(--bg4);flex-shrink:0}
.menu-owner-row{display:flex;gap:.75rem;align-items:flex-start;min-width:0}
.menu-owner-thumb{width:58px;height:58px;object-fit:cover;border-radius:var(--r);border:1px solid rgba(38,244,255,.22);flex-shrink:0}
/* ── Forum ──────────────────────────────────────────────── */
.forum-layout{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:2rem;align-items:start}
.forum-topic-list{display:grid;gap:.8rem}
.forum-topic-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.85rem;align-items:center;padding:1rem;background:linear-gradient(180deg,rgba(18,31,56,.86),rgba(9,15,30,.92));border:1px solid rgba(38,244,255,.16);border-radius:var(--r);color:var(--text);transition:all var(--t)}
.forum-topic-row:hover{border-color:rgba(38,244,255,.44);box-shadow:var(--sh-sm);transform:translateY(-1px)}
.forum-topic-main{min-width:0}
.forum-topic-main strong{display:block;color:var(--text);font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.forum-topic-main em{display:block;color:var(--text-d);font-style:normal;font-size:.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.forum-topic-count{font-family:'Oswald',sans-serif;letter-spacing:.08em;font-size:.74rem;color:var(--cyan-l);white-space:nowrap}
.forum-byline{display:flex;align-items:center;gap:.45rem;color:var(--text-d);font-size:.86rem;flex-wrap:wrap}
.forum-thread{max-width:980px}
.forum-post,.forum-reply{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.25rem;margin-bottom:1rem}
.forum-post-body{color:var(--text-m);line-height:1.75;overflow-wrap:anywhere}
.forum-attachments{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:.75rem;margin-top:1rem}
.forum-attachment-image{display:block;overflow:hidden;border-radius:var(--r);border:1px solid rgba(38,244,255,.2);background:var(--bg4);aspect-ratio:4/3}
.forum-attachment-image img{width:100%;height:100%;object-fit:cover;transition:transform var(--t)}
.forum-attachment-image:hover img{transform:scale(1.03)}
.forum-file{display:flex;justify-content:space-between;gap:.65rem;align-items:center;padding:.75rem .9rem;border:1px solid rgba(38,244,255,.18);border-radius:var(--r);background:rgba(18,31,56,.75);color:var(--text-m);min-width:0}
.forum-file span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.forum-file em{font-style:normal;color:var(--text-d);font-size:.74rem;white-space:nowrap}
.forum-replies-head{margin:2rem 0 1rem}
.forum-reply-meta{display:flex;justify-content:space-between;align-items:center;gap:1rem;margin-bottom:.8rem;flex-wrap:wrap}
.forum-reply-author{display:flex;align-items:center;gap:.65rem;font-family:'Oswald',sans-serif;letter-spacing:.06em;color:var(--text)}
.forum-permalink{font-family:'Oswald',sans-serif;font-size:.72rem;letter-spacing:.08em;color:var(--cyan-l)}
.admin-inline-editor{display:grid;gap:.45rem;min-width:320px;background:var(--bg2);border:1px solid var(--bdr);border-radius:var(--r);padding:.75rem;margin-top:.5rem}
/* ── Animations ─────────────────────────────────────────── */ /* ── Animations ─────────────────────────────────────────── */
@keyframes fadeUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}} @keyframes fadeUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
@keyframes fadeDown{from{opacity:0;transform:translateY(-12px)}to{opacity:1;transform:translateY(0)}} @keyframes fadeDown{from{opacity:0;transform:translateY(-12px)}to{opacity:1;transform:translateY(0)}}
@keyframes fadeIn{from{opacity:0}to{opacity:1}} @keyframes fadeIn{from{opacity:0}to{opacity:1}}
/* ── Responsive ─────────────────────────────────────────── */ /* ── Responsive ─────────────────────────────────────────── */
@media(max-width:1100px){.footer-grid{grid-template-columns:1fr 1fr}} @media(max-width:1100px){.footer-grid{grid-template-columns:1fr 1fr}.home-hero-grid{grid-template-columns:1fr}.hero-signal{grid-template-columns:1fr 1fr}.signal-card-main{min-height:auto}.about-split{grid-template-columns:1fr}}
@media(max-width:900px){.detail-body{grid-template-columns:1fr}.admin-wrap{grid-template-columns:1fr}.admin-side{position:static;max-height:none;border-right:none;border-bottom:1px solid var(--bdr)}.form-row{grid-template-columns:1fr}} @media(max-width:900px){.detail-body{grid-template-columns:1fr}.admin-wrap{grid-template-columns:1fr}.admin-side{position:static;max-height:none;border-right:none;border-bottom:1px solid var(--bdr)}.form-row{grid-template-columns:1fr}.section-head{align-items:flex-start;flex-direction:column}.random-list{grid-template-columns:1fr}.signup-grid,.tier-grid{grid-template-columns:1fr}.home-facts{grid-template-columns:1fr}.forum-layout{grid-template-columns:1fr}.evt-image-wrap{width:140px;flex-basis:140px}.event-import-grid{grid-template-columns:1fr}.event-import-preview{width:100%;height:180px}}
@media(max-width:768px){ @media(max-width:768px){
.nav-links{display:none;position:absolute;top:64px;left:0;right:0;background:var(--bg0);border-bottom:1px solid var(--bdr-l);flex-direction:column;padding:.75rem 1rem;gap:.1rem;box-shadow:var(--sh)} .nav-links{display:none;position:absolute;top:64px;left:0;right:0;background:var(--bg0);border-bottom:1px solid var(--bdr-l);flex-direction:column;padding:.75rem 1rem;gap:.1rem;box-shadow:var(--sh)}
.nav-links.open{display:flex}.hamburger{display:flex} .nav-links.open{display:flex}.hamburger{display:flex}
.user-wrap{width:100%}.user-btn{width:100%;text-align:left} .user-wrap{width:100%}.user-btn{width:100%;text-align:left}
.user-dd{position:static;box-shadow:none;border:none;background:var(--bg2)} .user-dd{position:static;box-shadow:none;border:none;background:var(--bg2)}
.hero{min-height:80vh}.g3,.g4{grid-template-columns:1fr}.evt-card{flex-direction:column} .hero{min-height:80vh}.g3,.g4{grid-template-columns:1fr}.evt-card{flex-direction:column}.evt-image-wrap{width:100%;height:190px;flex-basis:auto}.event-strip .evt-card{display:flex}.event-strip .evt-image-wrap{height:190px}
.home-hero{min-height:auto;padding:4rem 1rem 3rem}.home-hero .hero-h1{font-size:clamp(3rem,17vw,4.6rem)}.hero-search{flex-direction:column}.hero-search .s-icon{top:1.35rem}.hero-search .btn{justify-content:center}.hero-signal,.signal-mini-grid{grid-template-columns:1fr}.join-band{align-items:flex-start;flex-direction:column;margin-bottom:2.5rem}.signup-shell{padding:3rem 1rem}.signup-card{padding:1rem}.tier-card{min-height:auto}
} }
@media(max-width:560px){.hero-h1{font-size:3rem}.footer-grid{grid-template-columns:1fr}.hrs-grid{grid-template-columns:1fr}.form-box{margin:2rem 1rem}} @media(max-width:560px){.hero-h1{font-size:3rem}.footer-grid{grid-template-columns:1fr}.hrs-grid{grid-template-columns:1fr}.form-box{margin:2rem 1rem}.random-row{grid-template-columns:36px 1fr}.random-arrow{display:none}.home-hero .hero-eyebrow{letter-spacing:.18em}.btn{white-space:normal;text-align:center}.signal-number{font-size:3.6rem}}
+39
View File
@@ -29,3 +29,42 @@ document.querySelectorAll('.asl').forEach(l=>{
l.classList.add('on'); l.classList.add('on');
if(location.pathname===href)l.classList.add('on'); if(location.pathname===href)l.classList.add('on');
}); });
// Event photo lightbox
const lightboxTriggers=document.querySelectorAll('[data-lightbox-src]');
let lightbox;
const closeLightbox=()=>{
lightbox?.classList.remove('open');
document.body.style.overflow='';
};
if(lightboxTriggers.length){
lightbox=document.createElement('div');
lightbox.className='lightbox';
lightbox.innerHTML='<button type="button" class="lightbox-close" aria-label="Close image">&times;</button><img alt="">';
document.body.appendChild(lightbox);
const img=lightbox.querySelector('img');
lightboxTriggers.forEach(btn=>{
btn.addEventListener('click',()=>{
img.src=btn.dataset.lightboxSrc;
img.alt=btn.dataset.lightboxAlt||'Event photo';
lightbox.classList.add('open');
document.body.style.overflow='hidden';
});
});
lightbox.addEventListener('click',e=>{if(e.target===lightbox||e.target.closest('.lightbox-close'))closeLightbox();});
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeLightbox();});
}
// Copy share links
document.querySelectorAll('[data-copy-url]').forEach(btn=>{
btn.addEventListener('click',async()=>{
const label=btn.textContent;
try{
await navigator.clipboard.writeText(btn.dataset.copyUrl);
btn.textContent='Copied';
setTimeout(()=>btn.textContent=label,1400);
}catch{
prompt('Copy this link:',btn.dataset.copyUrl);
}
});
});
+47 -2
View File
@@ -5,7 +5,7 @@ $slug = gs('slug');
$b = qone(" $b = qone("
SELECT SELECT
b.id, b.name, b.slug, b.subcategory, b.description, b.id, b.name, b.slug, b.subcategory, b.description,
b.address, b.phone, b.email, b.website, b.address, b.phone, b.email, b.website, COALESCE(b.video_url,'') AS video_url,
COALESCE(b.hours,'{}') AS hours, COALESCE(b.hours,'{}') AS hours,
b.lat, b.lng, b.is_active, b.is_featured, b.created_at, b.lat, b.lng, b.is_active, b.is_featured, b.created_at,
b.category_id, b.category_id,
@@ -30,7 +30,7 @@ if (isPost()) {
// Owner submits edit request // Owner submits edit request
if ($act === 'owner_edit' && owns($b['id']) && !isAdmin()) { if ($act === 'owner_edit' && owns($b['id']) && !isAdmin()) {
$days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; $days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
$allowedFields = ['description','address','phone','email','is_active']; $allowedFields = ['description','address','phone','email','website','video_url','is_active'];
$submitted = 0; $submitted = 0;
// Simple text fields // Simple text fields
@@ -42,6 +42,10 @@ if (isPost()) {
$new = (string)(int)p('is_active', $b['is_active']); $new = (string)(int)p('is_active', $b['is_active']);
$old = (string)(int)$b['is_active']; $old = (string)(int)$b['is_active'];
} }
if ($field === 'video_url' && !validBusinessVideoUrl($new)) {
flash('Business videos must be a YouTube or Vimeo URL.', 'error');
go("/business.php?slug=$slug");
}
if ($new !== $old) { if ($new !== $old) {
// Remove any existing pending request for this field // Remove any existing pending request for this field
qrun("DELETE FROM business_edit_requests WHERE business_id=? AND field=? AND status='pending'", qrun("DELETE FROM business_edit_requests WHERE business_id=? AND field=? AND status='pending'",
@@ -299,6 +303,28 @@ include __DIR__.'/includes/header.php';
value="<?=e(isset($pendingByField['email']) ? $pendingByField['email']['new_value'] : $b['email'])?>"> value="<?=e(isset($pendingByField['email']) ? $pendingByField['email']['new_value'] : $b['email'])?>">
</div> </div>
<div class="form-row">
<div class="fg">
<label class="flabel">
WEBSITE
<?php if (isset($pendingByField['website'])): ?><span class="badge badge-gold" style="margin-left:.4rem">PENDING</span><?php endif; ?>
</label>
<input type="text" name="website" class="finput"
value="<?=e(isset($pendingByField['website']) ? $pendingByField['website']['new_value'] : $b['website'])?>"
placeholder="example.com">
</div>
<div class="fg">
<label class="flabel">
VIDEO URL
<?php if (isset($pendingByField['video_url'])): ?><span class="badge badge-gold" style="margin-left:.4rem">PENDING</span><?php endif; ?>
</label>
<input type="url" name="video_url" class="finput"
value="<?=e(isset($pendingByField['video_url']) ? $pendingByField['video_url']['new_value'] : $b['video_url'])?>"
placeholder="YouTube or Vimeo URL">
<div class="fhint">Optional. Business owners may use YouTube or Vimeo.</div>
</div>
</div>
<div class="fg"> <div class="fg">
<label class="flabel"> <label class="flabel">
HOURS OF OPERATION HOURS OF OPERATION
@@ -352,6 +378,20 @@ include __DIR__.'/includes/header.php';
<p style="color:var(--text-m);line-height:1.85"><?=e($b['description'] ?? '')?></p> <p style="color:var(--text-m);line-height:1.85"><?=e($b['description'] ?? '')?></p>
</div> </div>
<?php if (!empty($b['video_url']) && validBusinessVideoUrl($b['video_url'])): ?>
<div class="ibox business-video-box" style="margin-bottom:1.75rem">
<div class="ibox-title">VIDEO FEATURE</div>
<div class="embed-shell">
<iframe
src="/lone-embed.php?video=<?=rawurlencode($b['video_url'])?>&amp;title=<?=rawurlencode($b['name'])?>"
title="<?=e($b['name'])?> video"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
</div>
</div>
<?php endif; ?>
<?php if ($sects): ?> <?php if ($sects): ?>
<div class="ibox" style="margin-bottom:1.75rem"> <div class="ibox" style="margin-bottom:1.75rem">
<div class="ibox-title">🍽️ MENU</div> <div class="ibox-title">🍽️ MENU</div>
@@ -363,6 +403,9 @@ include __DIR__.'/includes/header.php';
<?php endif; ?> <?php endif; ?>
<?php foreach ($mitems[$s['id']] as $mi): ?> <?php foreach ($mitems[$s['id']] as $mi): ?>
<div class="menu-item"> <div class="menu-item">
<?php if (!empty($mi['image_path'])): ?>
<img src="<?=e($mi['image_path'])?>" alt="<?=e($mi['name'] ?? 'Menu item')?>" class="mi-image">
<?php endif; ?>
<div class="mi-info"> <div class="mi-info">
<div class="mi-name"><?=e($mi['name'] ?? '')?></div> <div class="mi-name"><?=e($mi['name'] ?? '')?></div>
<?php if (!empty($mi['description'])): ?> <?php if (!empty($mi['description'])): ?>
@@ -421,10 +464,12 @@ include __DIR__.'/includes/header.php';
<?php foreach ($reviews as $r): ?> <?php foreach ($reviews as $r): ?>
<div class="rev-card"> <div class="rev-card">
<div class="rev-hdr"> <div class="rev-hdr">
<div class="rev-user">
<div> <div>
<div class="rev-author"><?=e($r['username'] ?? 'Anonymous')?></div> <div class="rev-author"><?=e($r['username'] ?? 'Anonymous')?></div>
<?=starDisplay((float)($r['rating'] ?? 0))?> <?=starDisplay((float)($r['rating'] ?? 0))?>
</div> </div>
</div>
<div class="flex-row" style="gap:.5rem"> <div class="flex-row" style="gap:.5rem">
<span class="rev-date"><?=ago($r['created_at'] ?? '')?></span> <span class="rev-date"><?=ago($r['created_at'] ?? '')?></span>
<?php if (isAdmin()): ?> <?php if (isAdmin()): ?>
+34
View File
@@ -9,6 +9,14 @@ $myBiz = $bizIds
? qall("SELECT b.*,c.name cat,COALESCE(AVG(r.rating),0) avg,COUNT(r.id) rcnt FROM businesses b JOIN categories c ON c.id=b.category_id LEFT JOIN reviews r ON r.business_id=b.id WHERE b.id IN(".implode(',',array_fill(0,count($bizIds),'?')).") GROUP BY b.id ORDER BY b.name",$bizIds) ? qall("SELECT b.*,c.name cat,COALESCE(AVG(r.rating),0) avg,COUNT(r.id) rcnt FROM businesses b JOIN categories c ON c.id=b.category_id LEFT JOIN reviews r ON r.business_id=b.id WHERE b.id IN(".implode(',',array_fill(0,count($bizIds),'?')).") GROUP BY b.id ORDER BY b.name",$bizIds)
: []; : [];
$myEvents = authed() ? qall("SELECT * FROM events WHERE submitted_by=? ORDER BY created_at DESC LIMIT 10",[uid()]) : []; $myEvents = authed() ? qall("SELECT * FROM events WHERE submitted_by=? ORDER BY created_at DESC LIMIT 10",[uid()]) : [];
$myClaims = authed() ? qall("
SELECT bcr.*, b.name biz_name, b.slug biz_slug
FROM business_claim_requests bcr
JOIN businesses b ON b.id=bcr.business_id
WHERE bcr.user_id=?
ORDER BY bcr.created_at DESC
LIMIT 5
", [uid()]) : [];
include __DIR__.'/includes/header.php'; include __DIR__.'/includes/header.php';
?> ?>
<div class="page-hdr"> <div class="page-hdr">
@@ -19,6 +27,32 @@ include __DIR__.'/includes/header.php';
</div> </div>
</div> </div>
<div class="section"> <div class="section">
<?php if($myClaims): ?>
<div class="eyebrow" style="margin-bottom:1rem">BUSINESS CLAIMS</div>
<div class="tbl-wrap" style="margin-bottom:2.5rem">
<table class="dtbl">
<thead><tr><th>BUSINESS</th><th>CONTACT PHONE</th><th>STATUS</th><th>SUBMITTED</th><th></th></tr></thead>
<tbody>
<?php foreach($myClaims as $claim): ?>
<tr>
<td class="td-name"><?=e($claim['biz_name'])?></td>
<td><?=e($claim['contact_phone'])?></td>
<td>
<?php $cc=['approved'=>'badge-green','pending'=>'badge-gold','rejected'=>'badge-red']; ?>
<span class="badge <?=$cc[$claim['status']]??'badge-gray'?>"><?=strtoupper($claim['status'])?></span>
<?php if($claim['status']==='pending'): ?>
<div style="font-size:.76rem;color:var(--text-d);margin-top:.2rem">We will contact you within a few business days for verification.</div>
<?php endif; ?>
</td>
<td><?=ago($claim['created_at'] ?? '')?></td>
<td><a href="/business.php?slug=<?=e($claim['biz_slug'])?>" class="btn btn-xs btn-outline">View Page</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if($myBiz): ?> <?php if($myBiz): ?>
<div class="eyebrow" style="margin-bottom:1rem">MY BUSINESS LISTINGS</div> <div class="eyebrow" style="margin-bottom:1rem">MY BUSINESS LISTINGS</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:1.4rem;margin-bottom:2.5rem"> <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:1.4rem;margin-bottom:2.5rem">
+128
View File
@@ -0,0 +1,128 @@
<?php
require_once __DIR__.'/includes/boot.php';
$id = iget('id');
$event = qone("
SELECT e.*, u.username AS submitter
FROM events e
LEFT JOIN users u ON u.id = e.submitted_by
WHERE e.id = ? AND (e.status = 'approved' OR ? = 1)
", [$id, isAdmin() ? 1 : 0]);
if (!$event) {
flash('Event not found.', 'error');
go('/events.php');
}
$activeNav = 'events';
$pageTitle = e($event['title']).' - Events in Keyser, WV';
$dateLabel = fdate($event['event_date'], 'l, F j, Y');
$endDateLabel = !empty($event['end_date']) ? fdate($event['end_date'], 'l, F j, Y') : '';
$timeLabel = trim((string)($event['event_time'] ?? ''));
$endTimeLabel = trim((string)($event['end_time'] ?? ''));
$dateRange = $dateLabel;
if ($endDateLabel && $endDateLabel !== $dateLabel) $dateRange .= ' through '.$endDateLabel;
if ($timeLabel !== '') $dateRange .= ' at '.$timeLabel;
if ($endTimeLabel !== '') $dateRange .= ' - '.$endTimeLabel;
include __DIR__.'/includes/header.php';
?>
<div class="detail-hdr">
<div class="detail-hdr-inner">
<div class="breadcrumb">
<a href="/index.php">Home</a><span></span>
<a href="/events.php">Events</a><span></span>
<?=e($event['title'])?>
</div>
<div class="cat-badge">COMMUNITY EVENT</div>
<h1 style="font-size:clamp(1.9rem,4vw,2.9rem);margin:.4rem 0"><?=e($event['title'])?></h1>
<div class="evt-meta" style="font-size:.9rem;margin-top:.65rem">
<span><?=e($dateRange)?></span>
<?php if (!empty($event['venue'])): ?><span><?=e($event['venue'])?></span><?php endif; ?>
<?php if ($event['is_featured']): ?><span class="featured-badge">FEATURED</span><?php endif; ?>
<?php if (isAdmin() && $event['status'] !== 'approved'): ?><span class="badge badge-gold"><?=strtoupper(e($event['status']))?></span><?php endif; ?>
</div>
</div>
</div>
<div class="detail-body">
<div>
<?php if (!empty($event['image_path'])): ?>
<div class="event-hero-photo ibox">
<button type="button" class="event-photo-btn" data-lightbox-src="<?=e($event['image_path'])?>" data-lightbox-alt="<?=e($event['title'])?>">
<img src="<?=e($event['image_path'])?>" alt="<?=e($event['title'])?>">
<span>View full image</span>
</button>
</div>
<?php endif; ?>
<div class="ibox">
<div class="ibox-title">ABOUT THIS EVENT</div>
<?php if (!empty($event['description'])): ?>
<p style="color:var(--text-m);line-height:1.85;white-space:pre-line"><?=e($event['description'])?></p>
<?php else: ?>
<p style="color:var(--text-d)">No description has been added yet.</p>
<?php endif; ?>
</div>
<?php if (!empty($event['address'])): ?>
<div class="ibox">
<div class="ibox-title">LOCATION</div>
<div style="background:var(--bg4);border:1px dashed var(--bdr);border-radius:4px;padding:1.75rem;text-align:center">
<?php if (!empty($event['venue'])): ?><h3 style="font-size:1.2rem;margin-bottom:.35rem"><?=e($event['venue'])?></h3><?php endif; ?>
<p style="font-size:.9rem;color:var(--text-m);margin-bottom:.9rem"><?=e($event['address'])?></p>
<a href="https://maps.google.com/?q=<?=urlencode(trim(($event['venue'] ? $event['venue'].' ' : '').$event['address']))?>" target="_blank" rel="noopener" class="btn btn-outline btn-sm">Open in Google Maps</a>
</div>
</div>
<?php endif; ?>
<div class="flex-row">
<a href="/events.php" class="btn btn-secondary">Back to Events</a>
<?php if (isAdmin()): ?><a href="/admin/events.php?edit=<?=(int)$event['id']?>" class="btn btn-outline">Edit Event</a><?php endif; ?>
</div>
</div>
<div>
<div class="ibox">
<div class="ibox-title">EVENT DETAILS</div>
<div class="event-detail-date">
<div class="evt-date-box">
<div class="evt-mon"><?=strtoupper(date('M', strtotime($event['event_date'])))?></div>
<div class="evt-day"><?=date('j', strtotime($event['event_date']))?></div>
<div class="evt-yr"><?=date('Y', strtotime($event['event_date']))?></div>
</div>
<div>
<div style="font-weight:700;color:var(--text)"><?=e($dateLabel)?></div>
<?php if ($timeLabel !== ''): ?><div style="color:var(--text-m);font-size:.9rem"><?=e($timeLabel)?><?= $endTimeLabel !== '' ? ' - '.e($endTimeLabel) : '' ?></div><?php endif; ?>
<?php if ($endDateLabel && $endDateLabel !== $dateLabel): ?><div style="color:var(--text-d);font-size:.82rem">Through <?=e($endDateLabel)?></div><?php endif; ?>
</div>
</div>
<?php if (!empty($event['venue'])): ?><div class="irow"><span class="irow-i">📍</span><span class="irow-v"><?=e($event['venue'])?></span></div><?php endif; ?>
<?php if (!empty($event['address'])): ?><div class="irow"><span class="irow-i">⌖</span><span class="irow-v"><?=e($event['address'])?></span></div><?php endif; ?>
<div class="irow"><span class="irow-i">$</span><span class="irow-v"><?=e($event['cost'] ?: 'Free')?></span></div>
<?php if (!empty($event['website'])): ?>
<div class="irow"><span class="irow-i"></span><a href="<?=e(externalHref($event['website']))?>" target="_blank" rel="noopener" class="irow-v" style="color:var(--gold);word-break:break-all"><?=e($event['website'])?></a></div>
<?php endif; ?>
</div>
<?php if (!empty($event['contact_name']) || !empty($event['contact_email']) || !empty($event['contact_phone'])): ?>
<div class="ibox">
<div class="ibox-title">CONTACT</div>
<?php if (!empty($event['contact_name'])): ?><div class="irow"><span class="irow-i">◇</span><span class="irow-v"><?=e($event['contact_name'])?></span></div><?php endif; ?>
<?php if (!empty($event['contact_phone'])): ?><div class="irow"><span class="irow-i">☎</span><a href="tel:<?=e($event['contact_phone'])?>" class="irow-v" style="color:var(--gold)"><?=e($event['contact_phone'])?></a></div><?php endif; ?>
<?php if (!empty($event['contact_email'])): ?><div class="irow"><span class="irow-i">@</span><a href="mailto:<?=e($event['contact_email'])?>" class="irow-v" style="color:var(--gold);word-break:break-all"><?=e($event['contact_email'])?></a></div><?php endif; ?>
</div>
<?php endif; ?>
<div class="ibox">
<div class="ibox-title">SHARE</div>
<p style="color:var(--text-m);font-size:.88rem;margin-bottom:.8rem">Send this event page to someone who should know about it.</p>
<button type="button" class="btn btn-outline btn-sm" data-copy-url="<?=e(absoluteUrl(eventUrl($event)))?>">Copy Event Link</button>
</div>
</div>
</div>
<?php include __DIR__.'/includes/footer.php'; ?>
+31 -6
View File
@@ -12,10 +12,27 @@ if(isPost()&&ps('_act')==='submit_event'){
$cost=ps('cost');$web=ps('website');$cn=ps('contact_name');$ce=ps('contact_email');$cp=ps('contact_phone'); $cost=ps('cost');$web=ps('website');$cn=ps('contact_name');$ce=ps('contact_email');$cp=ps('contact_phone');
if(!$title) $errors[]='Event title is required.'; if(!$title) $errors[]='Event title is required.';
if(!$date) $errors[]='Event date is required.'; if(!$date) $errors[]='Event date is required.';
$imagePath = '';
if(!$errors){
$upload = storeSingleUpload(
'event_image',
'events',
imageExts(),
mbToBytes(setting('event_upload_max_mb','5'), 5),
[
'image_only' => true,
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb','1'), 1),
'image_max_width' => (int)setting('forum_image_max_width','1600'),
'image_quality' => (int)setting('forum_image_quality','82'),
]
);
if (!$upload['ok']) $errors[] = $upload['error'];
else $imagePath = (string)($upload['path'] ?? '');
}
if(!$errors){ if(!$errors){
$status=setting('events_require_approval','1')==='1'?'pending':'approved'; $status=setting('events_require_approval','1')==='1'?'pending':'approved';
qrun("INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,submitted_by,status)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", qrun("INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,image_path,submitted_by,status)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[$title,$desc,$venue,$addr,$date,$time,$edate?:null,$etime,$cost,$web,$cn,$ce,$cp,uid(),$status]); [$title,$desc,$venue,$addr,$date,$time,$edate?:null,$etime,$cost,$web,$cn,$ce,$cp,$imagePath,uid(),$status]);
flash($status==='approved'?'Event submitted and published!':'Event submitted! It will appear after review.','success'); flash($status==='approved'?'Event submitted and published!':'Event submitted! It will appear after review.','success');
go('/events.php'); go('/events.php');
} }
@@ -48,7 +65,7 @@ include __DIR__.'/includes/header.php';
<div class="ibox" style="border-color:var(--gold-d)"> <div class="ibox" style="border-color:var(--gold-d)">
<div class="ibox-title">📅 SUBMIT A COMMUNITY EVENT</div> <div class="ibox-title">📅 SUBMIT A COMMUNITY EVENT</div>
<?php if($errors): ?><div class="err-box"><?=implode('<br>',array_map('e',$errors))?></div><?php endif; ?> <?php if($errors): ?><div class="err-box"><?=implode('<br>',array_map('e',$errors))?></div><?php endif; ?>
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="submit_event"> <form method="POST" enctype="multipart/form-data"><?=csrfField()?><input type="hidden" name="_act" value="submit_event">
<div class="fg"><label class="flabel">EVENT TITLE *</label><input type="text" name="title" class="finput" value="<?=e($_POST['title']??'')?>" required></div> <div class="fg"><label class="flabel">EVENT TITLE *</label><input type="text" name="title" class="finput" value="<?=e($_POST['title']??'')?>" required></div>
<div class="fg"><label class="flabel">DESCRIPTION</label><textarea name="description" class="ftextarea" style="min-height:110px"><?=e($_POST['description']??'')?></textarea></div> <div class="fg"><label class="flabel">DESCRIPTION</label><textarea name="description" class="ftextarea" style="min-height:110px"><?=e($_POST['description']??'')?></textarea></div>
<div class="form-row"> <div class="form-row">
@@ -67,6 +84,11 @@ include __DIR__.'/includes/header.php';
<div class="fg"><label class="flabel">COST / ADMISSION</label><input type="text" name="cost" class="finput" value="<?=e($_POST['cost']??'Free')?>" placeholder="Free, $10, Varies…"></div> <div class="fg"><label class="flabel">COST / ADMISSION</label><input type="text" name="cost" class="finput" value="<?=e($_POST['cost']??'Free')?>" placeholder="Free, $10, Varies…"></div>
<div class="fg"><label class="flabel">WEBSITE</label><input type="text" name="website" class="finput" value="<?=e($_POST['website']??'')?>" placeholder="example.com"></div> <div class="fg"><label class="flabel">WEBSITE</label><input type="text" name="website" class="finput" value="<?=e($_POST['website']??'')?>" placeholder="example.com"></div>
</div> </div>
<div class="fg">
<label class="flabel">EVENT IMAGE</label>
<input type="file" name="event_image" class="finput" accept="image/*">
<div class="fhint">Optional JPG, PNG, GIF, or WebP. Max <?=e(setting('event_upload_max_mb','5'))?> MB.</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="fg"><label class="flabel">CONTACT NAME</label><input type="text" name="contact_name" class="finput" value="<?=e($_POST['contact_name']??'')?>"></div> <div class="fg"><label class="flabel">CONTACT NAME</label><input type="text" name="contact_name" class="finput" value="<?=e($_POST['contact_name']??'')?>"></div>
<div class="fg"><label class="flabel">CONTACT PHONE</label><input type="text" name="contact_phone" class="finput" value="<?=e($_POST['contact_phone']??'')?>"></div> <div class="fg"><label class="flabel">CONTACT PHONE</label><input type="text" name="contact_phone" class="finput" value="<?=e($_POST['contact_phone']??'')?>"></div>
@@ -86,7 +108,10 @@ include __DIR__.'/includes/header.php';
<div class="section"> <div class="section">
<?php if($events): ?> <?php if($events): ?>
<?php foreach($events as $ev): ?> <?php foreach($events as $ev): ?>
<div class="evt-card"> <a href="<?=e(eventUrl($ev))?>" class="evt-card evt-link-card">
<?php if(!empty($ev['image_path'])): ?>
<div class="evt-image-wrap"><img src="<?=e($ev['image_path'])?>" alt="<?=e($ev['title'])?>" class="evt-image"></div>
<?php endif; ?>
<div class="evt-date-box"> <div class="evt-date-box">
<div class="evt-mon"><?=strtoupper(date('M',strtotime($ev['event_date'])))?></div> <div class="evt-mon"><?=strtoupper(date('M',strtotime($ev['event_date'])))?></div>
<div class="evt-day"><?=date('j',strtotime($ev['event_date']))?></div> <div class="evt-day"><?=date('j',strtotime($ev['event_date']))?></div>
@@ -102,9 +127,9 @@ include __DIR__.'/includes/header.php';
<?php if($ev['contact_phone']): ?><span>📞 <?=e($ev['contact_phone'])?></span><?php endif; ?> <?php if($ev['contact_phone']): ?><span>📞 <?=e($ev['contact_phone'])?></span><?php endif; ?>
</div> </div>
<div class="evt-desc"><?=e($ev['description'])?></div> <div class="evt-desc"><?=e($ev['description'])?></div>
<?php if($ev['website']): ?><div class="evt-actions"><a href="https://<?=e($ev['website'])?>" target="_blank" class="btn btn-outline btn-sm">More Info ↗</a></div><?php endif; ?> <div class="evt-actions"><span class="btn btn-outline btn-sm">View Event</span></div>
</div>
</div> </div>
</a>
<?php endforeach; ?> <?php endforeach; ?>
<?php if($pages>1): ?><div style="display:flex;justify-content:center;gap:.5rem;margin-top:2rem;flex-wrap:wrap"><?php for($i=1;$i<=$pages;$i++): ?><a href="?page=<?=$i?>" class="btn btn-sm<?=$i===$page?' btn-primary':' btn-outline'?>"><?=$i?></a><?php endfor; ?></div><?php endif; ?> <?php if($pages>1): ?><div style="display:flex;justify-content:center;gap:.5rem;margin-top:2rem;flex-wrap:wrap"><?php for($i=1;$i<=$pages;$i++): ?><a href="?page=<?=$i?>" class="btn btn-sm<?=$i===$page?' btn-primary':' btn-outline'?>"><?=$i?></a><?php endfor; ?></div><?php endif; ?>
<?php else: ?> <?php else: ?>
+266
View File
@@ -0,0 +1,266 @@
<?php
require_once __DIR__.'/includes/boot.php';
$activeNav = 'forum';
$pageTitle = 'Community Forum - Keyser, WV';
if (setting('forum_enabled','1') !== '1' && !isAdmin()) {
flash('The community forum is currently offline.', 'warning');
go('/index.php');
}
requireLogin('/forum');
$topicSlug = gs('topic');
$path = (string)(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '');
if ($topicSlug === '' && preg_match('#^/forum/([^/]+)/?$#', $path, $m)) {
$topicSlug = urldecode($m[1]);
}
function forumAllowedExts(): array {
return uploadAllowedExts(setting('forum_allowed_filetypes','jpg,jpeg,png,gif,webp,pdf,txt,doc,docx'), ['jpg','jpeg','png','gif','webp','pdf','txt','doc','docx']);
}
function forumUploadOpts(): array {
return [
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb','1'), 1),
'image_max_width' => (int)setting('forum_image_max_width','1600'),
'image_quality' => (int)setting('forum_image_quality','82'),
];
}
function forumStoreAttachments(): array {
return storeMultipleUploads(
'attachments',
'forum',
forumAllowedExts(),
mbToBytes(setting('forum_upload_max_mb','5'), 5),
forumUploadOpts()
);
}
function forumSaveAttachments(array $files, int $userId, int $topicId = 0, int $replyId = 0): void {
foreach ($files as $file) {
qrun(
"INSERT INTO forum_attachments(topic_id,reply_id,user_id,file_path,original_name,mime_type,file_size)VALUES(?,?,?,?,?,?,?)",
[$topicId ?: null, $replyId ?: null, $userId, $file['path'], $file['name'] ?? '', $file['mime'] ?? '', (int)($file['size'] ?? 0)]
);
}
}
function forumAttachmentList(array $attachments): string {
if (!$attachments) return '';
$out = '<div class="forum-attachments">';
foreach ($attachments as $att) {
$name = $att['original_name'] ?: basename((string)$att['file_path']);
if (isImagePath((string)$att['file_path'])) {
$out .= '<a href="'.e($att['file_path']).'" class="forum-attachment-image" target="_blank" rel="noopener"><img src="'.e($att['file_path']).'" alt="'.e($name).'"></a>';
} else {
$out .= '<a href="'.e($att['file_path']).'" class="forum-file" target="_blank" rel="noopener"><span>'.e($name).'</span><em>'.formatBytes((int)$att['file_size']).'</em></a>';
}
}
return $out.'</div>';
}
function forumText(string $text): string {
return nl2br(e($text));
}
$errors = [];
$allowedHint = implode(', ', forumAllowedExts());
$maxHint = setting('forum_upload_max_mb','5').' MB';
if ($topicSlug === '') {
if (isPost() && ps('_act') === 'create_topic') {
csrfCheck();
$title = ps('title');
$body = ps('body');
$catId = (int)p('category_id');
$cat = qone("SELECT * FROM forum_categories WHERE id=? AND is_active=1", [$catId]);
if ($title === '') $errors[] = 'Topic title is required.';
if ($body === '') $errors[] = 'Topic body is required.';
if (!$cat) $errors[] = 'Please choose a forum category.';
$uploaded = ['ok'=>true, 'files'=>[]];
if (!$errors) {
$uploaded = forumStoreAttachments();
if (!$uploaded['ok']) $errors[] = $uploaded['error'];
}
if (!$errors) {
$tempSlug = 'draft-'.bin2hex(random_bytes(8));
$topicId = qrun(
"INSERT INTO forum_topics(category_id,user_id,title,slug,body)VALUES(?,?,?,?,?)",
[$catId, uid(), $title, $tempSlug, $body]
);
$slug = uniqueForumSlug($title, $topicId);
qrun("UPDATE forum_topics SET slug=? WHERE id=?", [$slug, $topicId]);
forumSaveAttachments($uploaded['files'] ?? [], uid(), $topicId, 0);
flash('Topic posted.', 'success');
go(forumTopicUrl(['slug'=>$slug]));
}
}
$categories = qall("SELECT * FROM forum_categories WHERE is_active=1 ORDER BY sort_order,name");
$topics = qall("
SELECT ft.*, fc.name category_name, u.username,
(SELECT COUNT(*) FROM forum_replies fr WHERE fr.topic_id=ft.id) AS reply_count,
COALESCE((SELECT MAX(fr.created_at) FROM forum_replies fr WHERE fr.topic_id=ft.id), ft.created_at) AS last_activity
FROM forum_topics ft
JOIN forum_categories fc ON fc.id=ft.category_id
JOIN users u ON u.id=ft.user_id
ORDER BY ft.is_pinned DESC, datetime(last_activity) DESC
");
include __DIR__.'/includes/header.php';
?>
<div class="page-hdr">
<div class="page-hdr-inner" style="display:flex;justify-content:space-between;align-items:flex-end;gap:1rem;flex-wrap:wrap">
<div>
<div class="eyebrow">COMMUNITY FORUM</div>
<h1 style="font-size:clamp(2rem,4vw,2.9rem);margin-bottom:.4rem">Keyser Community Forum</h1>
<p style="color:var(--text-m)">Topics and replies from registered members and business owners.</p>
</div>
<?php if(setting('forum_enabled','1') !== '1' && isAdmin()): ?><span class="badge badge-gold">Disabled for non-admins</span><?php endif; ?>
</div>
</div>
<div class="section forum-layout">
<div>
<div class="section-head">
<div>
<div class="eyebrow">LATEST TOPICS</div>
<h2 class="sec-title">Conversations</h2>
</div>
</div>
<?php if($topics): ?>
<div class="forum-topic-list">
<?php foreach($topics as $topic): ?>
<a href="<?=e(forumTopicUrl($topic))?>" class="forum-topic-row">
<span class="forum-topic-main">
<strong><?=e($topic['title'])?></strong>
<em><?=e($topic['category_name'])?> by <?=e($topic['username'])?> · <?=ago($topic['last_activity'])?></em>
</span>
<span class="forum-topic-count"><?=$topic['reply_count']?> repl<?=$topic['reply_count']==1?'y':'ies'?></span>
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty"><div class="empty-ico">+</div><h3>No topics yet</h3><p style="margin-top:.5rem">Start the first community conversation.</p></div>
<?php endif; ?>
</div>
<aside>
<div class="ibox" style="border-color:var(--gold-d)">
<div class="ibox-title">START A TOPIC</div>
<?php if($errors): ?><div class="err-box"><?=implode('<br>',array_map('e',$errors))?></div><?php endif; ?>
<?php if($categories): ?>
<form method="POST" enctype="multipart/form-data">
<?=csrfField()?><input type="hidden" name="_act" value="create_topic">
<div class="fg"><label class="flabel">CATEGORY *</label><select name="category_id" class="fselect" required><option value="">Choose category</option><?php foreach($categories as $cat): ?><option value="<?=$cat['id']?>"<?=((int)($_POST['category_id']??0)===(int)$cat['id'])?' selected':''?>><?=e($cat['name'])?></option><?php endforeach; ?></select></div>
<div class="fg"><label class="flabel">TITLE *</label><input type="text" name="title" class="finput" value="<?=e($_POST['title']??'')?>" required></div>
<div class="fg"><label class="flabel">BODY *</label><textarea name="body" class="ftextarea" required><?=e($_POST['body']??'')?></textarea></div>
<div class="fg"><label class="flabel">ATTACH FILES</label><input type="file" name="attachments[]" class="finput" multiple><div class="fhint">Allowed: <?=e($allowedHint)?>. Max <?=e($maxHint)?> each.</div></div>
<button class="btn btn-primary btn-block">Post Topic</button>
</form>
<?php else: ?>
<p style="color:var(--text-m)">No active categories are available yet.</p>
<?php endif; ?>
</div>
</aside>
</div>
<?php
include __DIR__.'/includes/footer.php';
exit;
}
$topic = qone("
SELECT ft.*, fc.name category_name, u.username
FROM forum_topics ft
JOIN forum_categories fc ON fc.id=ft.category_id
JOIN users u ON u.id=ft.user_id
WHERE ft.slug=?
", [$topicSlug]);
if (!$topic) { http_response_code(404); flash('Forum topic not found.', 'error'); go('/forum'); }
if (isPost() && ps('_act') === 'reply') {
csrfCheck();
if ((int)$topic['is_locked'] === 1 && !isAdmin()) {
$errors[] = 'This topic is locked.';
} else {
$body = ps('body');
$hasFile = false;
foreach (normalizeUploadFiles('attachments') as $file) {
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) $hasFile = true;
}
if ($body === '' && !$hasFile) $errors[] = 'Add a reply or attach a file.';
$uploaded = ['ok'=>true, 'files'=>[]];
if (!$errors) {
$uploaded = forumStoreAttachments();
if (!$uploaded['ok']) $errors[] = $uploaded['error'];
}
if (!$errors) {
$replyId = qrun("INSERT INTO forum_replies(topic_id,user_id,body)VALUES(?,?,?)", [$topic['id'], uid(), $body]);
forumSaveAttachments($uploaded['files'] ?? [], uid(), 0, $replyId);
qrun("UPDATE forum_topics SET updated_at=datetime('now') WHERE id=?", [$topic['id']]);
flash('Reply posted.', 'success');
go(forumTopicUrl($topic).'#reply-'.$replyId);
}
}
}
$topicAttachments = qall("SELECT * FROM forum_attachments WHERE topic_id=? AND reply_id IS NULL ORDER BY created_at", [$topic['id']]);
$replies = qall("
SELECT fr.*, u.username
FROM forum_replies fr
JOIN users u ON u.id=fr.user_id
WHERE fr.topic_id=?
ORDER BY fr.created_at ASC
", [$topic['id']]);
$replyAttachments = [];
foreach ($replies as $reply) {
$replyAttachments[$reply['id']] = qall("SELECT * FROM forum_attachments WHERE reply_id=? ORDER BY created_at", [$reply['id']]);
}
include __DIR__.'/includes/header.php';
?>
<div class="page-hdr">
<div class="page-hdr-inner">
<div class="breadcrumb"><a href="/index.php">Home</a><span></span><a href="/forum">Forum</a><span></span><?=e($topic['title'])?></div>
<div class="cat-badge"><?=e($topic['category_name'])?></div>
<h1 style="font-size:clamp(2rem,4vw,2.9rem);margin:.45rem 0"><?=e($topic['title'])?></h1>
<div class="forum-byline">Posted by <?=e($topic['username'])?> · <?=ago($topic['created_at'])?><?php if($topic['is_locked']): ?> · <span class="badge badge-gray">Locked</span><?php endif; ?></div>
</div>
</div>
<div class="section-sm forum-thread">
<article class="forum-post">
<div class="forum-post-body"><?=forumText($topic['body'])?></div>
<?=forumAttachmentList($topicAttachments)?>
</article>
<div class="forum-replies-head">
<div class="eyebrow">REPLIES</div>
<h2 class="sec-title"><?=count($replies)?> repl<?=count($replies)===1?'y':'ies'?></h2>
</div>
<?php foreach($replies as $reply): ?>
<article class="forum-reply" id="reply-<?=$reply['id']?>">
<div class="forum-reply-meta">
<div class="forum-reply-author"><span><?=e($reply['username'])?></span></div>
<a href="<?=e(forumTopicUrl($topic).'#reply-'.$reply['id'])?>" class="forum-permalink">#reply-<?=$reply['id']?></a>
</div>
<?php if($reply['body'] !== ''): ?><div class="forum-post-body"><?=forumText($reply['body'])?></div><?php endif; ?>
<?=forumAttachmentList($replyAttachments[$reply['id']] ?? [])?>
</article>
<?php endforeach; ?>
<div class="ibox" style="border-color:var(--gold-d)">
<div class="ibox-title">POST A REPLY</div>
<?php if($errors): ?><div class="err-box"><?=implode('<br>',array_map('e',$errors))?></div><?php endif; ?>
<?php if((int)$topic['is_locked'] === 1 && !isAdmin()): ?>
<p style="color:var(--text-m)">This topic is locked.</p>
<?php else: ?>
<form method="POST" enctype="multipart/form-data">
<?=csrfField()?><input type="hidden" name="_act" value="reply">
<div class="fg"><label class="flabel">REPLY</label><textarea name="body" class="ftextarea"><?=e($_POST['body']??'')?></textarea></div>
<div class="fg"><label class="flabel">ATTACH FILES</label><input type="file" name="attachments[]" class="finput" multiple><div class="fhint">Allowed: <?=e($allowedHint)?>. Max <?=e($maxHint)?> each.</div></div>
<button class="btn btn-primary">Post Reply</button>
</form>
<?php endif; ?>
</div>
</div>
<?php include __DIR__.'/includes/footer.php'; ?>
+2
View File
@@ -0,0 +1,2 @@
<?php
require dirname(__DIR__).'/forum.php';
+279 -7
View File
@@ -18,8 +18,8 @@ function db(): PDO {
]); ]);
$pdo->exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=NORMAL;"); $pdo->exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=NORMAL;");
if ($isNew) { _schema($pdo); _seed($pdo); } if ($isNew) { _schema($pdo); _seed($pdo); }
// Always ensure the edit_requests table exists (for existing DBs) // Always ensure app upgrade tables/columns exist (for existing DBs)
_ensureEditRequests($pdo); _ensureAppUpgrades($pdo);
if (file_exists(DB_FILE)) @chmod(DB_FILE, 0666); if (file_exists(DB_FILE)) @chmod(DB_FILE, 0666);
return $pdo; return $pdo;
} }
@@ -31,8 +31,36 @@ function qone(string $sql, array $p=[]): ?array { $r=qr($sql,$p)->fetch(); retu
function qval(string $sql, array $p=[]): mixed { return qr($sql,$p)->fetchColumn(); } function qval(string $sql, array $p=[]): mixed { return qr($sql,$p)->fetchColumn(); }
function qrun(string $sql, array $p=[]): int { $s=db()->prepare($sql); $s->execute($p); return (int)db()->lastInsertId(); } function qrun(string $sql, array $p=[]): int { $s=db()->prepare($sql); $s->execute($p); return (int)db()->lastInsertId(); }
/* ── Ensure edit requests table exists on old DBs ─── */ /* ── Ensure upgraded tables/columns exist on old DBs ─── */
function _ensureEditRequests(PDO $db): void { function _hasColumn(PDO $db, string $table, string $column): bool {
$cols = $db->query("PRAGMA table_info($table)")->fetchAll(PDO::FETCH_ASSOC);
foreach ($cols as $col) {
if (($col['name'] ?? '') === $column) return true;
}
return false;
}
function _ensureAppUpgrades(PDO $db): void {
if (!_hasColumn($db, 'users', 'email_verified_at')) {
$db->exec("ALTER TABLE users ADD COLUMN email_verified_at TEXT");
$db->exec("UPDATE users SET email_verified_at=datetime('now') WHERE is_active=1");
}
if (!_hasColumn($db, 'users', 'member_type')) {
$db->exec("ALTER TABLE users ADD COLUMN member_type TEXT NOT NULL DEFAULT 'member'");
}
if (!_hasColumn($db, 'users', 'avatar_path')) {
$db->exec("ALTER TABLE users ADD COLUMN avatar_path TEXT NOT NULL DEFAULT ''");
}
if (!_hasColumn($db, 'businesses', 'video_url')) {
$db->exec("ALTER TABLE businesses ADD COLUMN video_url TEXT NOT NULL DEFAULT ''");
}
if (!_hasColumn($db, 'events', 'image_path')) {
$db->exec("ALTER TABLE events ADD COLUMN image_path TEXT NOT NULL DEFAULT ''");
}
if (!_hasColumn($db, 'menu_items', 'image_path')) {
$db->exec("ALTER TABLE menu_items ADD COLUMN image_path TEXT NOT NULL DEFAULT ''");
}
$db->exec(" $db->exec("
CREATE TABLE IF NOT EXISTS business_edit_requests ( CREATE TABLE IF NOT EXISTS business_edit_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -46,7 +74,135 @@ function _ensureEditRequests(PDO $db): void {
created_at TEXT NOT NULL DEFAULT(datetime('now')), created_at TEXT NOT NULL DEFAULT(datetime('now')),
reviewed_at TEXT reviewed_at TEXT
); );
CREATE TABLE IF NOT EXISTS business_claim_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
contact_phone TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
admin_notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now')),
reviewed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_claim_requests_status ON business_claim_requests(status, created_at);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
purpose TEXT NOT NULL DEFAULT 'email_verify',
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_email_tokens_user ON email_verification_tokens(user_id, purpose, used_at);
CREATE TABLE IF NOT EXISTS event_imports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL DEFAULT 'facebook',
source_area TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
venue TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
event_date TEXT NOT NULL DEFAULT '',
event_time TEXT NOT NULL DEFAULT '',
end_date TEXT,
end_time TEXT NOT NULL DEFAULT '',
cost TEXT NOT NULL DEFAULT 'Free',
website TEXT NOT NULL DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
image_path TEXT NOT NULL DEFAULT '',
raw_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'staged',
status_note TEXT NOT NULL DEFAULT '',
imported_event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_event_imports_status ON event_imports(status, created_at);
CREATE INDEX IF NOT EXISTS idx_event_imports_external ON event_imports(provider, external_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_imports_source ON event_imports(provider, source_url) WHERE source_url <> '';
CREATE TABLE IF NOT EXISTS forum_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL REFERENCES forum_categories(id) ON DELETE RESTRICT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
body TEXT NOT NULL DEFAULT '',
is_locked INTEGER NOT NULL DEFAULT 0,
is_pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic_id INTEGER NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic_id INTEGER REFERENCES forum_topics(id) ON DELETE CASCADE,
reply_id INTEGER REFERENCES forum_replies(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
original_name TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
CHECK (topic_id IS NOT NULL OR reply_id IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_forum_topics_category ON forum_topics(category_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_forum_replies_topic ON forum_replies(topic_id, created_at);
CREATE INDEX IF NOT EXISTS idx_forum_attachments_topic ON forum_attachments(topic_id);
CREATE INDEX IF NOT EXISTS idx_forum_attachments_reply ON forum_attachments(reply_id);
"); ");
$defaults = [
'home_random_interval' => '2hours',
'site_base_url' => '',
'mailgun_region' => 'us',
'mailgun_domain' => '',
'mailgun_api_key' => '',
'mailgun_from_name' => 'Discover Keyser WV',
'mailgun_from_email' => '',
'mailgun_send_mode' => 'html',
'event_upload_max_mb' => '5',
'menu_item_image_limit' => '1',
'menu_upload_max_mb' => '5',
'avatar_upload_max_mb' => '3',
'forum_enabled' => '1',
'forum_upload_max_mb' => '5',
'forum_allowed_filetypes' => 'jpg,jpeg,png,gif,webp,pdf,txt,doc,docx',
'forum_image_resize_threshold_mb' => '1',
'forum_image_max_width' => '1600',
'forum_image_quality' => '82',
];
$stmt = $db->prepare("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)");
foreach ($defaults as $k => $v) $stmt->execute([$k, $v]);
$catCount = (int)$db->query("SELECT COUNT(*) FROM forum_categories")->fetchColumn();
if ($catCount === 0) {
$stmt = $db->prepare("INSERT INTO forum_categories(name,slug,description,sort_order,is_active)VALUES(?,?,?,?,1)");
foreach ([
['General', 'general', 'Neighborhood conversation, questions, and introductions.', 1],
['Local Questions', 'local-questions', 'Ask for Keyser recommendations, resources, and local tips.', 2],
['Business Talk', 'business-talk', 'Business owner updates, ideas, and community feedback.', 3],
['Events', 'events', 'Discuss upcoming events and things happening around town.', 4],
] as $cat) $stmt->execute($cat);
}
} }
/* ══════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════
@@ -64,6 +220,9 @@ function _schema(PDO $db): void {
password TEXT NOT NULL, password TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0, is_admin INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
email_verified_at TEXT,
member_type TEXT NOT NULL DEFAULT 'member',
avatar_path TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now')), created_at TEXT NOT NULL DEFAULT(datetime('now')),
last_login TEXT last_login TEXT
); );
@@ -84,6 +243,7 @@ function _schema(PDO $db): void {
phone TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
website TEXT NOT NULL DEFAULT '', website TEXT NOT NULL DEFAULT '',
video_url TEXT NOT NULL DEFAULT '',
hours TEXT NOT NULL DEFAULT '{}', hours TEXT NOT NULL DEFAULT '{}',
lat REAL NOT NULL DEFAULT 39.4364, lat REAL NOT NULL DEFAULT 39.4364,
lng REAL NOT NULL DEFAULT -78.9762, lng REAL NOT NULL DEFAULT -78.9762,
@@ -108,6 +268,25 @@ function _schema(PDO $db): void {
created_at TEXT NOT NULL DEFAULT(datetime('now')), created_at TEXT NOT NULL DEFAULT(datetime('now')),
reviewed_at TEXT reviewed_at TEXT
); );
CREATE TABLE IF NOT EXISTS business_claim_requests(
id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
contact_phone TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
admin_notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now')),
reviewed_at TEXT
);
CREATE TABLE IF NOT EXISTS email_verification_tokens(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
purpose TEXT NOT NULL DEFAULT 'email_verify',
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS reviews( CREATE TABLE IF NOT EXISTS reviews(
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
@@ -131,6 +310,7 @@ function _schema(PDO $db): void {
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
price REAL NOT NULL DEFAULT 0, price REAL NOT NULL DEFAULT 0,
image_path TEXT NOT NULL DEFAULT '',
is_available INTEGER NOT NULL DEFAULT 1, is_available INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0 sort_order INTEGER NOT NULL DEFAULT 0
); );
@@ -171,11 +351,37 @@ function _schema(PDO $db): void {
contact_name TEXT NOT NULL DEFAULT '', contact_name TEXT NOT NULL DEFAULT '',
contact_email TEXT NOT NULL DEFAULT '', contact_email TEXT NOT NULL DEFAULT '',
contact_phone TEXT NOT NULL DEFAULT '', contact_phone TEXT NOT NULL DEFAULT '',
image_path TEXT NOT NULL DEFAULT '',
submitted_by INTEGER REFERENCES users(id) ON DELETE SET NULL, submitted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
is_featured INTEGER NOT NULL DEFAULT 0, is_featured INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT(datetime('now')) created_at TEXT NOT NULL DEFAULT(datetime('now'))
); );
CREATE TABLE IF NOT EXISTS event_imports(
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL DEFAULT 'facebook',
source_area TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
venue TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
event_date TEXT NOT NULL DEFAULT '',
event_time TEXT NOT NULL DEFAULT '',
end_date TEXT,
end_time TEXT NOT NULL DEFAULT '',
cost TEXT NOT NULL DEFAULT 'Free',
website TEXT NOT NULL DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
image_path TEXT NOT NULL DEFAULT '',
raw_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'staged',
status_note TEXT NOT NULL DEFAULT '',
imported_event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS reports( CREATE TABLE IF NOT EXISTS reports(
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
@@ -186,6 +392,47 @@ function _schema(PDO $db): void {
admin_notes TEXT NOT NULL DEFAULT '', admin_notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now')) created_at TEXT NOT NULL DEFAULT(datetime('now'))
); );
CREATE TABLE IF NOT EXISTS forum_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL REFERENCES forum_categories(id) ON DELETE RESTRICT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
body TEXT NOT NULL DEFAULT '',
is_locked INTEGER NOT NULL DEFAULT 0,
is_pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic_id INTEGER NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS forum_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic_id INTEGER REFERENCES forum_topics(id) ON DELETE CASCADE,
reply_id INTEGER REFERENCES forum_replies(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
original_name TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
CHECK (topic_id IS NOT NULL OR reply_id IS NOT NULL)
);
"); ");
} }
@@ -198,13 +445,31 @@ function _seed(PDO $db): void {
foreach (['registration_enabled'=>'1','site_name'=>'Discover Keyser WV', foreach (['registration_enabled'=>'1','site_name'=>'Discover Keyser WV',
'site_tagline'=>'The Friendliest City in the U.S.A.', 'site_tagline'=>'The Friendliest City in the U.S.A.',
'contact_email'=>'info@discoverkeyser.com', 'contact_email'=>'info@discoverkeyser.com',
'events_require_approval'=>'1'] as $k=>$v) 'events_require_approval'=>'1',
'home_random_interval'=>'2hours',
'site_base_url'=>'',
'mailgun_region'=>'us',
'mailgun_domain'=>'',
'mailgun_api_key'=>'',
'mailgun_from_name'=>'Discover Keyser WV',
'mailgun_from_email'=>'',
'mailgun_send_mode'=>'html',
'event_upload_max_mb'=>'5',
'menu_item_image_limit'=>'1',
'menu_upload_max_mb'=>'5',
'avatar_upload_max_mb'=>'3',
'forum_enabled'=>'1',
'forum_upload_max_mb'=>'5',
'forum_allowed_filetypes'=>'jpg,jpeg,png,gif,webp,pdf,txt,doc,docx',
'forum_image_resize_threshold_mb'=>'1',
'forum_image_max_width'=>'1600',
'forum_image_quality'=>'82'] as $k=>$v)
qrun("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)",[$k,$v]); qrun("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)",[$k,$v]);
/* Admin user — password "password123" */ /* Admin user — password "password123" */
$hash = password_hash('password123', PASSWORD_BCRYPT); $hash = password_hash('password123', PASSWORD_BCRYPT);
qrun("INSERT OR IGNORE INTO users(username,email,password,is_admin) qrun("INSERT OR IGNORE INTO users(username,email,password,is_admin,email_verified_at,member_type)
VALUES('administrator','admin@discoverkeyser.com',?,1)",[$hash]); VALUES('administrator','admin@discoverkeyser.com',?,1,datetime('now'),'member')",[$hash]);
/* Categories */ /* Categories */
foreach ([ foreach ([
@@ -216,6 +481,13 @@ function _seed(PDO $db): void {
['Legal Services','⚖️',12],['Churches & Faith','⛪',13], ['Legal Services','⚖️',12],['Churches & Faith','⛪',13],
] as $c) qrun("INSERT OR IGNORE INTO categories(name,icon,sort_order)VALUES(?,?,?)",$c); ] as $c) qrun("INSERT OR IGNORE INTO categories(name,icon,sort_order)VALUES(?,?,?)",$c);
foreach ([
['General', 'general', 'Neighborhood conversation, questions, and introductions.', 1],
['Local Questions', 'local-questions', 'Ask for Keyser recommendations, resources, and local tips.', 2],
['Business Talk', 'business-talk', 'Business owner updates, ideas, and community feedback.', 3],
['Events', 'events', 'Discuss upcoming events and things happening around town.', 4],
] as $fc) qrun("INSERT OR IGNORE INTO forum_categories(name,slug,description,sort_order,is_active)VALUES(?,?,?,?,1)", $fc);
$cid = fn(string $n): int => (int)qval("SELECT id FROM categories WHERE name=?",[$n]); $cid = fn(string $n): int => (int)qval("SELECT id FROM categories WHERE name=?",[$n]);
$h = fn(array $d): string => json_encode($d); $h = fn(array $d): string => json_encode($d);
$std = $h(['Mon'=>'9am5pm','Tue'=>'9am5pm','Wed'=>'9am5pm','Thu'=>'9am5pm','Fri'=>'9am5pm','Sat'=>'Closed','Sun'=>'Closed']); $std = $h(['Mon'=>'9am5pm','Tue'=>'9am5pm','Wed'=>'9am5pm','Thu'=>'9am5pm','Fri'=>'9am5pm','Sat'=>'Closed','Sun'=>'Closed']);
+601
View File
@@ -0,0 +1,601 @@
<?php
if (!function_exists('qrun')) require_once __DIR__.'/boot.php';
function facebookEventImporterUserAgent(): string {
return 'MyKeyserEventImporter/1.0 (+https://mykeyser.local)';
}
function facebookEventText(mixed $value): string {
if (is_array($value)) $value = implode(' ', array_filter(array_map('facebookEventText', $value)));
$value = html_entity_decode(strip_tags((string)($value ?? '')), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
return trim(preg_replace('/\s+/', ' ', $value));
}
function facebookEventAllowedImageHost(string $url): bool {
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
if ($host === '') return false;
foreach (['facebook.com', 'fbcdn.net', 'fbsbx.com'] as $allowed) {
if ($host === $allowed || str_ends_with($host, '.'.$allowed)) return true;
}
return false;
}
function facebookEventNormalizeUrl(string $url, string $baseUrl = 'https://www.facebook.com'): string {
$url = trim(html_entity_decode($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
$url = str_replace(['\\/', '\\u002F', '\\u003A', '\\u0026'], ['/', '/', ':', '&'], $url);
if ($url === '') return '';
if (str_starts_with($url, '//')) $url = 'https:'.$url;
if (str_starts_with($url, '/')) {
$base = parse_url($baseUrl);
$scheme = $base['scheme'] ?? 'https';
$host = $base['host'] ?? 'www.facebook.com';
$url = $scheme.'://'.$host.$url;
}
$parts = parse_url($url);
if (!$parts || empty($parts['scheme']) || empty($parts['host'])) return '';
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) return '';
$host = strtolower($parts['host']);
$path = $parts['path'] ?? '';
if (($host === 'l.facebook.com' || str_ends_with($host, '.l.facebook.com')) && !empty($parts['query'])) {
parse_str($parts['query'], $query);
if (!empty($query['u'])) return facebookEventNormalizeUrl((string)$query['u'], $baseUrl);
}
$normalized = strtolower($parts['scheme']).'://'.$host.$path;
if (!empty($parts['query'])) $normalized .= '?'.$parts['query'];
return $normalized;
}
function facebookEventNormalizeEventUrl(string $url, string $baseUrl = 'https://www.facebook.com'): string {
$url = facebookEventNormalizeUrl($url, $baseUrl);
if ($url === '') return '';
$parts = parse_url($url);
$host = strtolower($parts['host'] ?? '');
if ($host !== 'facebook.com' && $host !== 'www.facebook.com' && !str_ends_with($host, '.facebook.com')) return '';
$path = preg_replace('~/+~', '/', (string)($parts['path'] ?? ''));
if (!preg_match('~^/(events|share)/~i', $path)) return '';
return 'https://www.facebook.com'.rtrim($path, '/');
}
function facebookEventExternalId(string $url): string {
$path = (string)(parse_url($url, PHP_URL_PATH) ?? '');
if (preg_match_all('~\d{6,}~', $path, $matches) && !empty($matches[0])) {
return end($matches[0]);
}
return '';
}
function facebookEventFetchUrl(string $url): array {
$url = facebookEventNormalizeUrl($url);
if ($url === '') return ['ok' => false, 'error' => 'Invalid URL.', 'body' => '', 'url' => ''];
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 4,
CURLOPT_TIMEOUT => 25,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_USERAGENT => facebookEventImporterUserAgent(),
CURLOPT_HTTPHEADER => ['Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'],
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$finalUrl = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
if (PHP_VERSION_ID < 80000) curl_close($ch);
if ($errno) return ['ok' => false, 'error' => $error ?: 'Request failed.', 'body' => '', 'url' => $url];
if ($status < 200 || $status >= 400) return ['ok' => false, 'error' => 'HTTP '.$status.' from Facebook.', 'body' => (string)$body, 'url' => $finalUrl ?: $url];
return ['ok' => true, 'error' => '', 'body' => (string)$body, 'url' => $finalUrl ?: $url];
}
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 25,
'ignore_errors' => true,
'header' => "User-Agent: ".facebookEventImporterUserAgent()."\r\nAccept: text/html\r\n",
],
]);
$body = @file_get_contents($url, false, $context);
$statusLine = $http_response_header[0] ?? '';
if ($body === false || ($statusLine && !preg_match('/\s[23]\d\d\s/', $statusLine))) {
return ['ok' => false, 'error' => $statusLine ?: 'Request failed.', 'body' => (string)$body, 'url' => $url];
}
return ['ok' => true, 'error' => '', 'body' => (string)$body, 'url' => $url];
}
function facebookEventDom(string $html): ?DOMDocument {
if (!class_exists('DOMDocument')) return null;
$dom = new DOMDocument();
$old = libxml_use_internal_errors(true);
$loaded = $dom->loadHTML('<?xml encoding="utf-8" ?>'.$html);
libxml_clear_errors();
libxml_use_internal_errors($old);
return $loaded ? $dom : null;
}
function facebookEventMeta(string $html): array {
$dom = facebookEventDom($html);
if (!$dom) return [];
$out = [];
foreach ($dom->getElementsByTagName('meta') as $meta) {
$key = $meta->getAttribute('property') ?: $meta->getAttribute('name');
$content = $meta->getAttribute('content');
if ($key !== '' && $content !== '') $out[strtolower($key)] = $content;
}
foreach ($dom->getElementsByTagName('link') as $link) {
if (strtolower($link->getAttribute('rel')) === 'canonical' && $link->getAttribute('href') !== '') {
$out['canonical'] = $link->getAttribute('href');
}
}
return $out;
}
function facebookEventFindJsonLdEvent(mixed $node): ?array {
if (!is_array($node)) return null;
$type = $node['@type'] ?? '';
$types = is_array($type) ? $type : [$type];
foreach ($types as $candidateType) {
if (strtolower((string)$candidateType) === 'event') return $node;
}
foreach ($node as $child) {
$found = facebookEventFindJsonLdEvent($child);
if ($found) return $found;
}
return null;
}
function facebookEventJsonLd(string $html): ?array {
$dom = facebookEventDom($html);
if (!$dom) return null;
foreach ($dom->getElementsByTagName('script') as $script) {
$type = strtolower($script->getAttribute('type'));
if ($type !== 'application/ld+json') continue;
$json = trim($script->textContent ?? '');
if ($json === '') continue;
$decoded = json_decode($json, true);
$found = facebookEventFindJsonLdEvent($decoded);
if ($found) return $found;
}
return null;
}
function facebookEventImageFromValue(mixed $value): string {
if (is_string($value)) return $value;
if (is_array($value)) {
if (isset($value['url'])) return facebookEventImageFromValue($value['url']);
foreach ($value as $item) {
$image = facebookEventImageFromValue($item);
if ($image !== '') return $image;
}
}
return '';
}
function facebookEventAddressFromLocation(mixed $location): array {
$venue = '';
$address = '';
if (is_string($location)) return [$location, ''];
if (!is_array($location)) return ['', ''];
$venue = facebookEventText($location['name'] ?? '');
$addr = $location['address'] ?? '';
if (is_string($addr)) {
$address = facebookEventText($addr);
} elseif (is_array($addr)) {
$parts = [];
foreach (['streetAddress', 'addressLocality', 'addressRegion', 'postalCode'] as $key) {
if (!empty($addr[$key])) $parts[] = facebookEventText($addr[$key]);
}
$address = implode(', ', array_filter($parts));
}
return [$venue, $address];
}
function facebookEventSplitDateTime(string $value): array {
$value = facebookEventText(str_replace([' at ', ' AT '], ' ', $value));
if ($value === '') return ['', ''];
$hasTime = (bool)preg_match('/(?:T|\b\d{1,2}:\d{2}\b|\b\d{1,2}\s*(?:am|pm)\b)/i', $value);
try {
$dt = new DateTimeImmutable($value);
} catch (Throwable) {
return ['', ''];
}
return [$dt->format('Y-m-d'), $hasTime ? $dt->format('g:i A') : ''];
}
function facebookEventDateFromText(string $text): array {
$months = 'Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?';
if (preg_match('/\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)?(?:day)?[,]?\s*(?:'.$months.')\s+\d{1,2}(?:,\s*\d{4})?(?:\s+(?:at|@)\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?/i', $text, $match)) {
return facebookEventSplitDateTime($match[0]);
}
if (preg_match('/\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{1,2}:\d{2}(?::\d{2})?)?/i', $text, $match)) {
return facebookEventSplitDateTime($match[0]);
}
return ['', ''];
}
function facebookEventCleanTitle(string $title): string {
$title = facebookEventText($title);
$title = preg_replace('/\s*\|\s*Facebook\s*$/i', '', $title);
$title = preg_replace('/\s*-\s*Facebook\s*$/i', '', $title);
return trim($title);
}
function facebookEventWebsiteForStorage(string $url): string {
$url = trim($url);
if ($url === '') return '';
$url = preg_replace('~^https?://~i', '', $url);
return preg_replace('~/+$~', '', $url);
}
function facebookEventExtractEventUrls(string $html, string $sourceUrl = 'https://www.facebook.com'): array {
$links = [];
$dom = facebookEventDom($html);
if ($dom) {
foreach ($dom->getElementsByTagName('a') as $a) {
$href = $a->getAttribute('href');
$url = facebookEventNormalizeEventUrl($href, $sourceUrl);
if ($url !== '') $links[$url] = $url;
}
}
$decoded = html_entity_decode($html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$decoded = str_replace(['\\/', '\\u002F', '\\u003A', '\\u0026'], ['/', '/', ':', '&'], $decoded);
if (preg_match_all('~https?://(?:www\.)?facebook\.com/(?:events|share)/[^\s"\'<>\\\\]+~i', $decoded, $matches)) {
foreach ($matches[0] as $raw) {
$url = facebookEventNormalizeEventUrl($raw, $sourceUrl);
if ($url !== '') $links[$url] = $url;
}
}
if (preg_match_all('~href=["\']([^"\']*(?:/events/|/share/)[^"\']*)["\']~i', $decoded, $matches)) {
foreach ($matches[1] as $raw) {
$url = facebookEventNormalizeEventUrl($raw, $sourceUrl);
if ($url !== '') $links[$url] = $url;
}
}
return array_values($links);
}
function facebookEventParseHtml(string $html, string $sourceUrl = '', string $area = ''): array {
$jsonLd = facebookEventJsonLd($html);
$meta = facebookEventMeta($html);
$sourceUrl = facebookEventNormalizeEventUrl($sourceUrl ?: ($meta['og:url'] ?? $meta['canonical'] ?? '')) ?: facebookEventNormalizeUrl($sourceUrl ?: ($meta['og:url'] ?? $meta['canonical'] ?? ''));
$candidate = [
'provider' => 'facebook',
'source_area' => $area,
'source_url' => $sourceUrl,
'external_id' => facebookEventExternalId($sourceUrl),
'title' => '',
'description' => '',
'venue' => '',
'address' => '',
'event_date' => '',
'event_time' => '',
'end_date' => null,
'end_time' => '',
'cost' => 'Free',
'website' => facebookEventWebsiteForStorage($sourceUrl),
'image_url' => '',
'image_path' => '',
'raw_json' => ['json_ld' => $jsonLd, 'meta' => $meta],
];
if ($jsonLd) {
$candidate['title'] = facebookEventCleanTitle((string)($jsonLd['name'] ?? ''));
$candidate['description'] = facebookEventText($jsonLd['description'] ?? '');
[$candidate['event_date'], $candidate['event_time']] = facebookEventSplitDateTime((string)($jsonLd['startDate'] ?? ''));
[$endDate, $endTime] = facebookEventSplitDateTime((string)($jsonLd['endDate'] ?? ''));
$candidate['end_date'] = $endDate ?: null;
$candidate['end_time'] = $endTime;
[$candidate['venue'], $candidate['address']] = facebookEventAddressFromLocation($jsonLd['location'] ?? null);
$candidate['image_url'] = facebookEventNormalizeUrl(facebookEventImageFromValue($jsonLd['image'] ?? ''), $sourceUrl ?: 'https://www.facebook.com');
if (!empty($jsonLd['url'])) {
$candidate['source_url'] = facebookEventNormalizeEventUrl((string)$jsonLd['url'], $sourceUrl ?: 'https://www.facebook.com') ?: $candidate['source_url'];
$candidate['website'] = facebookEventWebsiteForStorage($candidate['source_url']);
$candidate['external_id'] = facebookEventExternalId($candidate['source_url']);
}
if (!empty($jsonLd['offers']['price'])) $candidate['cost'] = facebookEventText($jsonLd['offers']['price']);
}
if ($candidate['title'] === '') $candidate['title'] = facebookEventCleanTitle($meta['og:title'] ?? $meta['twitter:title'] ?? '');
if ($candidate['description'] === '') $candidate['description'] = facebookEventText($meta['og:description'] ?? $meta['description'] ?? $meta['twitter:description'] ?? '');
if ($candidate['image_url'] === '') $candidate['image_url'] = facebookEventNormalizeUrl($meta['og:image'] ?? $meta['twitter:image'] ?? '', $sourceUrl ?: 'https://www.facebook.com');
if ($candidate['event_date'] === '') {
[$candidate['event_date'], $candidate['event_time']] = facebookEventDateFromText($candidate['title'].' '.$candidate['description']);
}
if ($candidate['source_url'] === '' && !empty($meta['og:url'])) {
$candidate['source_url'] = facebookEventNormalizeUrl($meta['og:url']);
$candidate['website'] = facebookEventWebsiteForStorage($candidate['source_url']);
$candidate['external_id'] = facebookEventExternalId($candidate['source_url']);
}
return $candidate;
}
function facebookGraphEventUrl(string $eventId): string {
$eventId = trim($eventId);
return $eventId !== '' ? 'https://www.facebook.com/events/'.rawurlencode($eventId) : '';
}
function facebookGraphPlaceToVenueAddress(mixed $place): array {
if (!is_array($place)) return ['', ''];
$venue = facebookEventText($place['name'] ?? '');
$location = $place['location'] ?? [];
if (!is_array($location)) return [$venue, ''];
$parts = [];
foreach (['street', 'city', 'state', 'zip', 'country'] as $key) {
if (!empty($location[$key])) $parts[] = facebookEventText($location[$key]);
}
return [$venue, implode(', ', array_filter($parts))];
}
function facebookGraphEventToCandidate(array $event, string $area = ''): array {
$eventId = trim((string)($event['id'] ?? ''));
$sourceUrl = facebookGraphEventUrl($eventId);
[$eventDate, $eventTime] = facebookEventSplitDateTime((string)($event['start_time'] ?? ''));
[$endDate, $endTime] = facebookEventSplitDateTime((string)($event['end_time'] ?? ''));
[$venue, $address] = facebookGraphPlaceToVenueAddress($event['place'] ?? null);
$ticketUrl = facebookEventNormalizeUrl((string)($event['ticket_uri'] ?? ''));
$website = $ticketUrl !== '' ? $ticketUrl : $sourceUrl;
$cover = $event['cover'] ?? [];
$imageUrl = is_array($cover) ? facebookEventNormalizeUrl((string)($cover['source'] ?? '')) : '';
return [
'provider' => 'facebook',
'source_area' => $area,
'source_url' => $sourceUrl,
'external_id' => $eventId,
'title' => facebookEventCleanTitle((string)($event['name'] ?? '')),
'description' => facebookEventText($event['description'] ?? ''),
'venue' => $venue,
'address' => $address,
'event_date' => $eventDate,
'event_time' => $eventTime,
'end_date' => $endDate ?: null,
'end_time' => $endTime,
'cost' => 'Free',
'website' => facebookEventWebsiteForStorage($website),
'image_url' => $imageUrl,
'image_path' => '',
'raw_json' => ['graph_event' => $event],
];
}
function facebookEventDownloadImage(string $imageUrl): array {
$imageUrl = facebookEventNormalizeUrl($imageUrl);
if ($imageUrl === '') return ['ok' => true, 'path' => '', 'error' => ''];
if (!facebookEventAllowedImageHost($imageUrl)) {
return ['ok' => false, 'path' => '', 'error' => 'Image host is not a Facebook image host.'];
}
$maxBytes = mbToBytes(setting('event_upload_max_mb', '5'), 5);
$tmp = tempnam(sys_get_temp_dir(), 'fb-event-img-');
if (!$tmp) return ['ok' => false, 'path' => '', 'error' => 'Could not create a temporary image file.'];
$mime = '';
$size = 0;
if (function_exists('curl_init')) {
$fh = fopen($tmp, 'wb');
if (!$fh) return ['ok' => false, 'path' => '', 'error' => 'Could not write a temporary image file.'];
$ch = curl_init($imageUrl);
curl_setopt_array($ch, [
CURLOPT_FILE => $fh,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 4,
CURLOPT_TIMEOUT => 25,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_USERAGENT => facebookEventImporterUserAgent(),
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use ($fh, $maxBytes, &$size): int {
$size += strlen($chunk);
if ($size > $maxBytes) return 0;
$written = fwrite($fh, $chunk);
return $written === false ? 0 : $written;
},
]);
$ok = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$mime = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
if (PHP_VERSION_ID < 80000) curl_close($ch);
fclose($fh);
if (!$ok || $errno || $status < 200 || $status >= 400) {
@unlink($tmp);
$message = $size > $maxBytes ? 'Image exceeds the configured size limit.' : ($error ?: 'Image download failed.');
return ['ok' => false, 'path' => '', 'error' => $message];
}
} else {
$context = stream_context_create(['http' => ['timeout' => 25, 'ignore_errors' => true, 'header' => 'User-Agent: '.facebookEventImporterUserAgent()."\r\n"]]);
$body = @file_get_contents($imageUrl, false, $context);
if ($body === false || strlen($body) > $maxBytes) {
@unlink($tmp);
return ['ok' => false, 'path' => '', 'error' => $body === false ? 'Image download failed.' : 'Image exceeds the configured size limit.'];
}
file_put_contents($tmp, $body);
}
if (!function_exists('getimagesize') || !@getimagesize($tmp)) {
@unlink($tmp);
return ['ok' => false, 'path' => '', 'error' => 'Downloaded file is not a valid image.'];
}
if ($mime === '' && function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = (string)finfo_file($finfo, $tmp);
finfo_close($finfo);
}
}
$ext = match (strtolower(strtok($mime, ';') ?: '')) {
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => 'jpg',
};
$stored = storeUploadedFile(
['name' => 'facebook-event.'.$ext, 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => filesize($tmp) ?: 0],
'events',
imageExts(),
$maxBytes,
[
'image_only' => true,
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb', '1'), 1),
'image_max_width' => (int)setting('forum_image_max_width', '1600'),
'image_quality' => (int)setting('forum_image_quality', '82'),
]
);
if (!$stored['ok']) @unlink($tmp);
return $stored['ok']
? ['ok' => true, 'path' => (string)$stored['path'], 'error' => '']
: ['ok' => false, 'path' => '', 'error' => $stored['error'] ?? 'Image could not be saved.'];
}
function facebookEventCandidateIssues(array $candidate): array {
$issues = [];
if (trim((string)($candidate['title'] ?? '')) === '') $issues[] = 'Missing title';
$date = trim((string)($candidate['event_date'] ?? ''));
$dt = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
if ($date === '' || !$dt || $dt->format('Y-m-d') !== $date) $issues[] = 'Missing valid start date';
return $issues;
}
function facebookEventDuplicateId(array $candidate): int {
$title = trim((string)($candidate['title'] ?? ''));
$date = trim((string)($candidate['event_date'] ?? ''));
if ($title === '' || $date === '') return 0;
return (int)qval("SELECT id FROM events WHERE lower(title)=lower(?) AND event_date=? LIMIT 1", [$title, $date]);
}
function facebookEventStageCandidate(array $candidate, bool $downloadImage = true): array {
$provider = 'facebook';
$sourceUrl = facebookEventNormalizeEventUrl((string)($candidate['source_url'] ?? '')) ?: facebookEventNormalizeUrl((string)($candidate['source_url'] ?? ''));
$candidate['source_url'] = $sourceUrl;
$candidate['external_id'] = trim((string)($candidate['external_id'] ?? facebookEventExternalId($sourceUrl)));
$candidate['website'] = facebookEventWebsiteForStorage((string)($candidate['website'] ?? $sourceUrl));
$candidate['cost'] = trim((string)($candidate['cost'] ?? '')) ?: 'Free';
$candidate['end_date'] = ($candidate['end_date'] ?? '') ?: null;
$candidate['raw_json'] = $candidate['raw_json'] ?? [];
if ($downloadImage && empty($candidate['image_path']) && !empty($candidate['image_url'])) {
$image = facebookEventDownloadImage((string)$candidate['image_url']);
if ($image['ok'] && !empty($image['path'])) {
$candidate['image_path'] = $image['path'];
} elseif (!$image['ok']) {
$candidate['status_note'] = 'Image not downloaded: '.$image['error'];
}
}
$existing = null;
if ($sourceUrl !== '') $existing = qone("SELECT * FROM event_imports WHERE provider=? AND source_url=? LIMIT 1", [$provider, $sourceUrl]);
if (!$existing && $candidate['external_id'] !== '') {
$existing = qone("SELECT * FROM event_imports WHERE provider=? AND external_id=? LIMIT 1", [$provider, $candidate['external_id']]);
}
$fields = [
'provider' => $provider,
'source_area' => facebookEventText($candidate['source_area'] ?? ''),
'source_url' => $sourceUrl,
'external_id' => $candidate['external_id'],
'title' => facebookEventCleanTitle((string)($candidate['title'] ?? '')),
'description' => facebookEventText($candidate['description'] ?? ''),
'venue' => facebookEventText($candidate['venue'] ?? ''),
'address' => facebookEventText($candidate['address'] ?? ''),
'event_date' => trim((string)($candidate['event_date'] ?? '')),
'event_time' => facebookEventText($candidate['event_time'] ?? ''),
'end_date' => $candidate['end_date'],
'end_time' => facebookEventText($candidate['end_time'] ?? ''),
'cost' => facebookEventText($candidate['cost']),
'website' => facebookEventWebsiteForStorage((string)$candidate['website']),
'image_url' => facebookEventNormalizeUrl((string)($candidate['image_url'] ?? ''), $sourceUrl ?: 'https://www.facebook.com'),
'image_path' => trim((string)($candidate['image_path'] ?? '')),
'raw_json' => json_encode($candidate['raw_json'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}',
'status_note' => facebookEventText($candidate['status_note'] ?? ''),
];
if ($existing) {
if (($existing['status'] ?? '') === 'imported') {
return ['ok' => true, 'id' => (int)$existing['id'], 'action' => 'already_imported'];
}
qrun(
"UPDATE event_imports SET source_area=?,source_url=?,external_id=?,title=?,description=?,venue=?,address=?,event_date=?,event_time=?,end_date=?,end_time=?,cost=?,website=?,image_url=?,image_path=?,raw_json=?,status='staged',status_note=?,updated_at=datetime('now') WHERE id=?",
[$fields['source_area'], $fields['source_url'], $fields['external_id'], $fields['title'], $fields['description'], $fields['venue'], $fields['address'], $fields['event_date'], $fields['event_time'], $fields['end_date'], $fields['end_time'], $fields['cost'], $fields['website'], $fields['image_url'], $fields['image_path'], $fields['raw_json'], $fields['status_note'], (int)$existing['id']]
);
return ['ok' => true, 'id' => (int)$existing['id'], 'action' => 'updated'];
}
$id = qrun(
"INSERT INTO event_imports(provider,source_area,source_url,external_id,title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,image_url,image_path,raw_json,status,status_note)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'staged',?)",
[$fields['provider'], $fields['source_area'], $fields['source_url'], $fields['external_id'], $fields['title'], $fields['description'], $fields['venue'], $fields['address'], $fields['event_date'], $fields['event_time'], $fields['end_date'], $fields['end_time'], $fields['cost'], $fields['website'], $fields['image_url'], $fields['image_path'], $fields['raw_json'], $fields['status_note']]
);
return ['ok' => true, 'id' => $id, 'action' => 'created'];
}
function facebookEventSaveCandidateFields(int $id, array $row): void {
qrun(
"UPDATE event_imports SET title=?,description=?,venue=?,address=?,event_date=?,event_time=?,end_date=?,end_time=?,cost=?,website=?,image_path=?,status_note='',updated_at=datetime('now') WHERE id=? AND status!='imported'",
[
facebookEventCleanTitle((string)($row['title'] ?? '')),
facebookEventText($row['description'] ?? ''),
facebookEventText($row['venue'] ?? ''),
facebookEventText($row['address'] ?? ''),
trim((string)($row['event_date'] ?? '')),
facebookEventText($row['event_time'] ?? ''),
trim((string)($row['end_date'] ?? '')) ?: null,
facebookEventText($row['end_time'] ?? ''),
facebookEventText($row['cost'] ?? 'Free') ?: 'Free',
facebookEventWebsiteForStorage((string)($row['website'] ?? '')),
trim((string)($row['image_path'] ?? '')),
$id,
]
);
}
function facebookEventImportCandidate(int $id, int $userId, string $eventStatus = 'pending', bool $allowDuplicate = false): array {
$candidate = qone("SELECT * FROM event_imports WHERE id=?", [$id]);
if (!$candidate) return ['ok' => false, 'error' => 'Import candidate not found.'];
if (($candidate['status'] ?? '') === 'imported') return ['ok' => false, 'error' => 'This candidate has already been imported.'];
$issues = facebookEventCandidateIssues($candidate);
if ($issues) return ['ok' => false, 'error' => implode('; ', $issues).'.'];
$duplicateId = facebookEventDuplicateId($candidate);
if ($duplicateId && !$allowDuplicate) return ['ok' => false, 'error' => 'Possible duplicate of event #'.$duplicateId.'.'];
$eventStatus = in_array($eventStatus, ['pending', 'approved'], true) ? $eventStatus : 'pending';
$imagePath = (string)($candidate['image_path'] ?? '');
$note = '';
if ($imagePath === '' && !empty($candidate['image_url'])) {
$image = facebookEventDownloadImage((string)$candidate['image_url']);
if ($image['ok'] && !empty($image['path'])) {
$imagePath = (string)$image['path'];
} elseif (!$image['ok']) {
$note = 'Imported without image: '.$image['error'];
}
}
$eventId = qrun(
"INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,image_path,submitted_by,status,is_featured)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)",
[
$candidate['title'],
$candidate['description'],
$candidate['venue'],
$candidate['address'],
$candidate['event_date'],
$candidate['event_time'],
$candidate['end_date'] ?: null,
$candidate['end_time'],
$candidate['cost'] ?: 'Free',
facebookEventWebsiteForStorage((string)($candidate['website'] ?: $candidate['source_url'])),
'',
'',
'',
$imagePath,
$userId ?: null,
$eventStatus,
]
);
qrun("UPDATE event_imports SET status='imported',imported_event_id=?,image_path=?,status_note=?,updated_at=datetime('now') WHERE id=?", [$eventId, $imagePath, $note, $id]);
return ['ok' => true, 'event_id' => $eventId, 'warning' => $note];
}
+3
View File
@@ -22,6 +22,9 @@
<?php if (setting('registration_enabled','1')==='1'): ?> <?php if (setting('registration_enabled','1')==='1'): ?>
<li><a href="/register.php">Create Account</a></li> <li><a href="/register.php">Create Account</a></li>
<?php endif; ?> <?php endif; ?>
<?php if ((authed() && setting('forum_enabled','1')==='1') || isAdmin()): ?>
<li><a href="/forum">Community Forum</a></li>
<?php endif; ?>
<li><a href="/events.php?submit=1">Submit an Event</a></li> <li><a href="/events.php?submit=1">Submit an Event</a></li>
<li><a href="https://govisitmineralwv.com" target="_blank" rel="noopener">Mineral County Tourism </a></li> <li><a href="https://govisitmineralwv.com" target="_blank" rel="noopener">Mineral County Tourism </a></li>
<li><a href="https://cityofkeyser.com" target="_blank" rel="noopener">City of Keyser </a></li> <li><a href="https://cityofkeyser.com" target="_blank" rel="noopener">City of Keyser </a></li>
+368 -1
View File
@@ -40,7 +40,7 @@ function g(string $k, mixed $d = ''): mixed { return $_GET[$k] ?? $d; }
function iget(string $k): int { return (int)($_POST[$k] ?? $_GET[$k] ?? 0); } function iget(string $k): int { return (int)($_POST[$k] ?? $_GET[$k] ?? 0); }
function ps(string $k): string { return trim((string)($_POST[$k] ?? '')); } function ps(string $k): string { return trim((string)($_POST[$k] ?? '')); }
function gs(string $k): string { return trim((string)($_GET[$k] ?? '')); } function gs(string $k): string { return trim((string)($_GET[$k] ?? '')); }
function isPost(): bool { return strtoupper($_SERVER['REQUEST_METHOD']) === 'POST'; } function isPost(): bool { return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST'; }
/* ── CSRF ─────────────────────────────────────────────── */ /* ── CSRF ─────────────────────────────────────────────── */
function csrf(): string { function csrf(): string {
@@ -135,3 +135,370 @@ function uniqueSlug(string $base): string {
while (qval("SELECT id FROM businesses WHERE slug=?", [$slug.($i ? "-$i" : "")])) $i++; while (qval("SELECT id FROM businesses WHERE slug=?", [$slug.($i ? "-$i" : "")])) $i++;
return $slug . ($i ? "-$i" : ""); return $slug . ($i ? "-$i" : "");
} }
function businessVideoProvider(string $url): string {
$host = strtolower((string)(parse_url(trim($url), PHP_URL_HOST) ?? ''));
$host = preg_replace('/^www\./', '', $host);
if ($host === 'youtu.be' || $host === 'youtube.com' || str_ends_with($host, '.youtube.com') || $host === 'youtube-nocookie.com' || str_ends_with($host, '.youtube-nocookie.com')) return 'YouTube';
if ($host === 'vimeo.com' || str_ends_with($host, '.vimeo.com')) return 'Vimeo';
return '';
}
function validBusinessVideoUrl(string $url): bool {
$url = trim($url);
if ($url === '') return true;
$scheme = strtolower((string)(parse_url($url, PHP_URL_SCHEME) ?? ''));
return in_array($scheme, ['http','https'], true) && businessVideoProvider($url) !== '';
}
/* ── Uploads / Images ─────────────────────────────────── */
function uploadRoot(): string { return dirname(__DIR__).'/uploads'; }
function uploadPublicPath(string $subdir, string $filename): string {
return '/uploads/'.trim($subdir, '/').'/'.$filename;
}
function cleanUploadSubdir(string $subdir): string {
$subdir = trim(str_replace('\\', '/', $subdir), '/');
return preg_replace('/[^a-zA-Z0-9_\/-]+/', '', $subdir) ?: 'misc';
}
function ensureUploadDir(string $subdir): string {
$subdir = cleanUploadSubdir($subdir);
$dir = uploadRoot().'/'.$subdir;
if (!is_dir($dir)) mkdir($dir, 0775, true);
return $dir;
}
function mbToBytes(mixed $mb, float $default = 5): int {
$n = (float)$mb;
if ($n <= 0) $n = $default;
return max(1, (int)round($n * 1024 * 1024));
}
function uploadAllowedExts(string $csv, array $fallback): array {
$raw = array_filter(array_map('trim', explode(',', strtolower($csv))));
$exts = $raw ?: $fallback;
return array_values(array_unique(array_map(fn($e) => ltrim($e, '.'), $exts)));
}
function imageExts(): array { return ['jpg','jpeg','png','gif','webp']; }
function isImageExt(string $ext): bool { return in_array(strtolower($ext), imageExts(), true); }
function isImagePath(string $path): bool {
return isImageExt(strtolower(pathinfo(parse_url($path, PHP_URL_PATH) ?: $path, PATHINFO_EXTENSION)));
}
function uploadErrorText(int $code): string {
return match($code) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded file is too large.',
UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially received.',
UPLOAD_ERR_NO_TMP_DIR => 'The server is missing a temporary upload directory.',
UPLOAD_ERR_CANT_WRITE => 'The server could not write the uploaded file.',
UPLOAD_ERR_EXTENSION => 'A server extension stopped the upload.',
default => 'The file could not be uploaded.',
};
}
function resizeImageFile(string $path, int $thresholdBytes, int $maxWidth = 1600, int $quality = 82): bool {
if ($thresholdBytes <= 0 || !file_exists($path) || filesize($path) <= $thresholdBytes) return false;
if (!function_exists('getimagesize') || !function_exists('imagecreatetruecolor')) return false;
$info = @getimagesize($path);
if (!$info || empty($info[0]) || empty($info[1]) || empty($info['mime'])) return false;
[$w, $h] = $info;
$mime = $info['mime'];
$loader = match($mime) {
'image/jpeg' => 'imagecreatefromjpeg',
'image/png' => 'imagecreatefrompng',
'image/gif' => 'imagecreatefromgif',
'image/webp' => 'imagecreatefromwebp',
default => '',
};
if ($loader === '' || !function_exists($loader)) return false;
$src = @$loader($path);
if (!$src) return false;
$scale = min(1, $maxWidth / max($w, $h));
$nw = max(1, (int)round($w * $scale));
$nh = max(1, (int)round($h * $scale));
$dst = imagecreatetruecolor($nw, $nh);
if ($mime === 'image/png' || $mime === 'image/webp') {
imagealphablending($dst, false);
imagesavealpha($dst, true);
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
imagefilledrectangle($dst, 0, 0, $nw, $nh, $transparent);
}
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
$quality = max(40, min(95, $quality));
$ok = match($mime) {
'image/jpeg' => imagejpeg($dst, $path, $quality),
'image/png' => imagepng($dst, $path, 6),
'image/gif' => imagegif($dst, $path),
'image/webp' => function_exists('imagewebp') ? imagewebp($dst, $path, $quality) : false,
default => false,
};
imagedestroy($src);
imagedestroy($dst);
if ($ok) @chmod($path, 0664);
return (bool)$ok;
}
function normalizeUploadFiles(string $field): array {
if (empty($_FILES[$field])) return [];
$file = $_FILES[$field];
if (!is_array($file['name'])) return [$file];
$out = [];
foreach ($file['name'] as $i => $name) {
$out[] = [
'name' => $name,
'type' => $file['type'][$i] ?? '',
'tmp_name' => $file['tmp_name'][$i] ?? '',
'error' => $file['error'][$i] ?? UPLOAD_ERR_NO_FILE,
'size' => $file['size'][$i] ?? 0,
];
}
return $out;
}
function storeUploadedFile(array $file, string $subdir, array $allowedExts, int $maxBytes, array $opts = []): array {
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return ['ok'=>true, 'path'=>''];
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) return ['ok'=>false, 'error'=>uploadErrorText((int)$file['error'])];
if ((int)($file['size'] ?? 0) > $maxBytes) return ['ok'=>false, 'error'=>'The uploaded file exceeds the configured size limit.'];
$original = (string)($file['name'] ?? 'upload');
$ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
$allowedExts = array_map('strtolower', $allowedExts);
if ($ext === '' || !in_array($ext, $allowedExts, true)) {
return ['ok'=>false, 'error'=>'This file type is not allowed.'];
}
if (!empty($opts['image_only']) && !isImageExt($ext)) return ['ok'=>false, 'error'=>'Please upload an image file.'];
if (!empty($opts['image_only']) && function_exists('getimagesize') && !@getimagesize((string)$file['tmp_name'])) {
return ['ok'=>false, 'error'=>'The selected file is not a valid image.'];
}
$dir = ensureUploadDir($subdir);
$filename = date('YmdHis').'-'.bin2hex(random_bytes(5)).'.'.$ext;
$dest = $dir.'/'.$filename;
$tmp = (string)$file['tmp_name'];
$moved = is_uploaded_file($tmp) ? move_uploaded_file($tmp, $dest) : @rename($tmp, $dest);
if (!$moved) return ['ok'=>false, 'error'=>'The uploaded file could not be saved.'];
@chmod($dest, 0664);
if (isImageExt($ext)) {
resizeImageFile(
$dest,
(int)($opts['resize_over_bytes'] ?? 0),
(int)($opts['image_max_width'] ?? 1600),
(int)($opts['image_quality'] ?? 82)
);
}
$mime = '';
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = (string)finfo_file($finfo, $dest);
finfo_close($finfo);
}
}
return [
'ok' => true,
'path' => uploadPublicPath($subdir, $filename),
'name' => $original,
'mime' => $mime,
'size' => file_exists($dest) ? filesize($dest) : (int)($file['size'] ?? 0),
];
}
function storeSingleUpload(string $field, string $subdir, array $allowedExts, int $maxBytes, array $opts = []): array {
$files = normalizeUploadFiles($field);
return $files ? storeUploadedFile($files[0], $subdir, $allowedExts, $maxBytes, $opts) : ['ok'=>true, 'path'=>''];
}
function storeMultipleUploads(string $field, string $subdir, array $allowedExts, int $maxBytes, array $opts = []): array {
$saved = [];
foreach (normalizeUploadFiles($field) as $file) {
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) continue;
$res = storeUploadedFile($file, $subdir, $allowedExts, $maxBytes, $opts);
if (!$res['ok']) return ['ok'=>false, 'error'=>$res['error'] ?? 'Upload failed.', 'files'=>$saved];
if (!empty($res['path'])) $saved[] = $res;
}
return ['ok'=>true, 'files'=>$saved];
}
function formatBytes(int $bytes): string {
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1).' MB';
if ($bytes >= 1024) return number_format($bytes / 1024, 1).' KB';
return $bytes.' B';
}
function uniqueForumSlug(string $title, int $id): string {
$base = makeSlug($title) ?: 'topic';
return $base.'-'.$id;
}
function forumTopicUrl(array $topic): string {
return '/forum/'.rawurlencode((string)$topic['slug']).'/';
}
function eventUrl(array $event): string {
return '/event.php?id='.(int)($event['id'] ?? 0);
}
function externalHref(string $url): string {
$url = trim($url);
if ($url === '') return '';
if (preg_match('~^https?://~i', $url)) return $url;
return 'https://'.$url;
}
/* ── Email verification / Mailgun ─────────────────────── */
function requestBaseUrl(): string {
$configured = trim(setting('site_base_url', ''));
if ($configured !== '') return rtrim($configured, '/');
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
if ($host === '') return '';
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['SERVER_PORT'] ?? '') === '443');
return ($https ? 'https' : 'http').'://'.$host;
}
function absoluteUrl(string $path): string {
$base = requestBaseUrl();
if ($base === '') return $path;
return rtrim($base, '/').'/'.ltrim($path, '/');
}
function mailgunEndpoint(): string {
$region = strtolower(setting('mailgun_region', 'us'));
$base = $region === 'eu' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net';
return $base.'/v3/'.rawurlencode(setting('mailgun_domain', '')).'/messages';
}
function mailgunConfigured(): bool {
return setting('mailgun_domain', '') !== '' && setting('mailgun_api_key', '') !== '';
}
function emailFromAddress(): string {
$from = trim(setting('mailgun_from_email', ''));
if ($from !== '') return $from;
$domain = setting('mailgun_domain', '');
return $domain !== '' ? 'postmaster@'.$domain : '';
}
function formattedFromAddress(): string {
$name = trim(setting('mailgun_from_name', 'My Keyser'));
$email = emailFromAddress();
return $name !== '' ? "$name <$email>" : $email;
}
function createEmailVerificationToken(int $userId): string {
qrun("UPDATE email_verification_tokens SET used_at=datetime('now') WHERE user_id=? AND purpose='email_verify' AND used_at IS NULL", [$userId]);
$token = bin2hex(random_bytes(32));
qrun(
"INSERT INTO email_verification_tokens(user_id,token_hash,expires_at)VALUES(?,?,datetime('now','+24 hours'))",
[$userId, hash('sha256', $token)]
);
return $token;
}
function mailgunSend(string $to, string $subject, string $text, string $html = ''): array {
if (!mailgunConfigured()) return [false, 'Mailgun domain and API key are required.'];
$from = formattedFromAddress();
if ($from === '' || !filter_var(emailFromAddress(), FILTER_VALIDATE_EMAIL)) {
return [false, 'A valid Mailgun from email is required.'];
}
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) return [false, 'Recipient email is invalid.'];
$fields = [
'from' => $from,
'to' => $to,
'subject' => $subject,
'text' => $text,
];
if (setting('mailgun_send_mode', 'html') === 'html' && $html !== '') {
$fields['html'] = $html;
}
$endpoint = mailgunEndpoint();
$apiKey = setting('mailgun_api_key', '');
if (function_exists('curl_init')) {
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $fields,
CURLOPT_USERPWD => 'api:'.$apiKey,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno) return [false, $error ?: 'Mailgun request failed.'];
if ($status < 200 || $status >= 300) return [false, 'Mailgun returned HTTP '.$status.': '.substr((string)$body, 0, 180)];
return [true, ''];
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Authorization: Basic ".base64_encode('api:'.$apiKey)."\r\n".
"Content-Type: application/x-www-form-urlencoded\r\n",
'content' => http_build_query($fields),
'timeout' => 20,
'ignore_errors' => true,
],
]);
$body = @file_get_contents($endpoint, false, $context);
$statusLine = $http_response_header[0] ?? '';
if (!preg_match('/\s(2\d\d)\s/', $statusLine)) {
return [false, trim(($statusLine ?: 'Mailgun request failed.').' '.substr((string)$body, 0, 180))];
}
return [true, ''];
}
function emailShellHtml(string $title, string $intro, string $bodyHtml, string $buttonText = '', string $buttonUrl = ''): string {
$site = e(setting('site_name', 'My Keyser'));
$titleEsc = e($title);
$introEsc = e($intro);
$button = '';
if ($buttonText !== '' && $buttonUrl !== '') {
$button = '<tr><td style="padding:22px 0 6px"><a href="'.e($buttonUrl).'" style="display:inline-block;background:linear-gradient(135deg,#ffd166,#26f4ff);color:#03050b;text-decoration:none;font-family:Arial,sans-serif;font-size:14px;font-weight:800;letter-spacing:1px;text-transform:uppercase;padding:13px 20px;border-radius:6px">'.e($buttonText).'</a></td></tr>';
}
return '<!doctype html><html><body style="margin:0;background:#03050b;color:#f2f4ff;font-family:Arial,sans-serif">'.
'<div style="display:none;max-height:0;overflow:hidden;color:transparent">'.$introEsc.'</div>'.
'<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:radial-gradient(circle at 15% 0%,rgba(38,244,255,.18),transparent 340px),radial-gradient(circle at 85% 10%,rgba(255,79,216,.14),transparent 360px),#03050b;padding:34px 14px">'.
'<tr><td align="center"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;background:#0e172b;border:1px solid rgba(38,244,255,.28);border-radius:8px;overflow:hidden;box-shadow:0 18px 44px rgba(0,0,0,.55)">'.
'<tr><td style="padding:24px 26px;border-bottom:1px solid rgba(38,244,255,.18);background:linear-gradient(135deg,rgba(38,244,255,.12),rgba(255,79,216,.08),rgba(255,209,102,.08))">'.
'<div style="font-size:12px;letter-spacing:4px;color:#26f4ff;text-transform:uppercase;font-weight:700">'.$site.'</div>'.
'<h1 style="margin:10px 0 0;font-family:Georgia,serif;font-size:32px;line-height:1.1;color:#ffffff">'.$titleEsc.'</h1>'.
'</td></tr><tr><td style="padding:26px;color:#aab7cf;font-size:16px;line-height:1.75">'.
$bodyHtml.
'<table role="presentation" cellspacing="0" cellpadding="0">'.$button.'</table>'.
'<p style="margin:24px 0 0;color:#65768f;font-size:13px">Keyser, West Virginia - local businesses, events, and community information.</p>'.
'</td></tr></table></td></tr></table></body></html>';
}
function verificationEmailContent(array $user, string $verifyUrl): array {
$name = (string)($user['username'] ?? 'there');
$isOwner = ($user['member_type'] ?? 'member') === 'business_owner';
$title = $isOwner ? 'Verify Your Business Owner Account' : 'Verify Your Account';
$subject = $isOwner ? 'Verify your My Keyser business owner account' : 'Verify your My Keyser account';
$text = "Hi $name,\n\n".
"Thanks for registering with My Keyser. Please verify your email address before signing in:\n\n".
"$verifyUrl\n\n".
"This link expires in 24 hours.\n\n".
($isOwner ? "After verification, your business claim will remain queued and we will contact you within a couple of business days.\n\n" : '').
"My Keyser";
$htmlBody = '<p style="margin:0 0 16px;color:#f2f4ff">Hi '.e($name).',</p>'.
'<p style="margin:0 0 16px">Thanks for registering with My Keyser. Please verify your email address before signing in.</p>'.
($isOwner ? '<p style="margin:0 0 16px">After verification, your business claim will stay queued for review and our team will contact you within a couple of business days.</p>' : '').
'<p style="margin:0;color:#65768f;font-size:13px">This link expires in 24 hours.</p>';
return [$subject, $text, emailShellHtml($title, 'Verify your My Keyser account.', $htmlBody, 'Verify Email', $verifyUrl)];
}
function registrationCompleteEmailContent(array $user): array {
$name = (string)($user['username'] ?? 'there');
$isOwner = ($user['member_type'] ?? 'member') === 'business_owner';
if ($isOwner) {
$subject = 'Thank you for claiming your My Keyser business page';
$text = "Hi $name,\n\nThank you for registering as a business owner with My Keyser.\n\nYour email is verified and you can now log in. Our team will contact you within a couple of business days for a verification call before assigning your business page.\n\nThank you for helping keep Keyser's directory accurate.\n\nMy Keyser";
$body = '<p style="margin:0 0 16px;color:#f2f4ff">Hi '.e($name).',</p>'.
'<p style="margin:0 0 16px">Thank you for registering as a business owner with My Keyser. Your email is verified and you can now log in.</p>'.
'<p style="margin:0 0 16px">Our team will contact you within a couple of business days for a verification call before assigning your business page.</p>'.
'<p style="margin:0">Thank you for helping keep Keyser&rsquo;s directory accurate.</p>';
return [$subject, $text, emailShellHtml('Thanks, Business Owner', 'Your business owner account is verified.', $body, 'Log In', absoluteUrl('/login.php'))];
}
$subject = 'Thank you for registering with My Keyser';
$text = "Hi $name,\n\nThank you for registering with My Keyser. Your email is verified and you can now log in.\n\nWe are glad to have you in the Keyser community.\n\nMy Keyser";
$body = '<p style="margin:0 0 16px;color:#f2f4ff">Hi '.e($name).',</p>'.
'<p style="margin:0 0 16px">Thank you for registering with My Keyser. Your email is verified and you can now log in.</p>'.
'<p style="margin:0">We are glad to have you in the Keyser community.</p>';
return [$subject, $text, emailShellHtml('Thanks For Registering', 'Your My Keyser account is verified.', $body, 'Log In', absoluteUrl('/login.php'))];
}
function sendVerificationEmail(array $user, string $token): array {
$url = absoluteUrl('/verify-email.php?token='.rawurlencode($token));
[$subject, $text, $html] = verificationEmailContent($user, $url);
return mailgunSend((string)$user['email'], $subject, $text, $html);
}
function sendRegistrationCompleteEmail(array $user): array {
[$subject, $text, $html] = registrationCompleteEmailContent($user);
return mailgunSend((string)$user['email'], $subject, $text, $html);
}
+3
View File
@@ -26,6 +26,9 @@
<li><a href="/directory.php" class="nl<?=($activeNav??'')==='dir'?' active':''?>">Directory</a></li> <li><a href="/directory.php" class="nl<?=($activeNav??'')==='dir'?' active':''?>">Directory</a></li>
<li><a href="/attractions.php" class="nl<?=($activeNav??'')==='attr'?' active':''?>">Attractions</a></li> <li><a href="/attractions.php" class="nl<?=($activeNav??'')==='attr'?' active':''?>">Attractions</a></li>
<li><a href="/events.php" class="nl<?=($activeNav??'')==='events'?' active':''?>">Events</a></li> <li><a href="/events.php" class="nl<?=($activeNav??'')==='events'?' active':''?>">Events</a></li>
<?php if ((authed() && setting('forum_enabled','1')==='1') || isAdmin()): ?>
<li><a href="/forum" class="nl<?=($activeNav??'')==='forum'?' active':''?>">Forum</a></li>
<?php endif; ?>
<li><a href="/about.php" class="nl<?=($activeNav??'')==='about'?' active':''?>">About</a></li> <li><a href="/about.php" class="nl<?=($activeNav??'')==='about'?' active':''?>">About</a></li>
<?php if (authed()): ?> <?php if (authed()): ?>
<?php if (isAdmin()): ?> <?php if (isAdmin()): ?>
+159 -100
View File
@@ -1,60 +1,85 @@
<?php <?php
$__requestPath = (string)(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '');
if (preg_match('#^/forum/[^/]+/?$#', $__requestPath)) {
require __DIR__.'/forum.php';
exit;
}
require_once __DIR__.'/includes/boot.php'; require_once __DIR__.'/includes/boot.php';
$pageTitle='Discover Keyser WV — The Friendliest City in the U.S.A.'; $pageTitle='Discover Keyser WV — The Friendliest City in the U.S.A.';
$activeNav='home'; $activeNav='home';
$alerts=qall("SELECT a.*,b.name bname,b.id bid,b.slug bslug FROM alerts a JOIN businesses b ON b.id=a.business_id WHERE a.is_active=1 ORDER BY a.created_at DESC LIMIT 5");
$featured=qall("SELECT b.*,c.name cat,c.icon cat_icon,COALESCE(AVG(r.rating),0) avg,COUNT(r.id) rcnt FROM businesses b JOIN categories c ON c.id=b.category_id LEFT JOIN reviews r ON r.business_id=b.id WHERE b.is_active=1 AND b.is_featured=1 GROUP BY b.id ORDER BY avg DESC,rcnt DESC LIMIT 6"); $alerts=qall("SELECT a.*,b.name bname,b.id bid,b.slug bslug FROM alerts a JOIN businesses b ON b.id=a.business_id WHERE a.is_active=1 ORDER BY a.created_at DESC LIMIT 4");
$featured=qall("SELECT b.*,c.name cat,c.icon cat_icon,COALESCE(AVG(r.rating),0) avg,COUNT(r.id) rcnt FROM businesses b JOIN categories c ON c.id=b.category_id LEFT JOIN reviews r ON r.business_id=b.id AND r.is_approved=1 WHERE b.is_active=1 AND b.is_featured=1 GROUP BY b.id ORDER BY avg DESC,rcnt DESC,b.name LIMIT 6");
$totalBiz=(int)qval("SELECT COUNT(*) FROM businesses WHERE is_active=1"); $totalBiz=(int)qval("SELECT COUNT(*) FROM businesses WHERE is_active=1");
$upevts=qall("SELECT * FROM events WHERE status='approved' AND event_date>=date('now') ORDER BY is_featured DESC,event_date ASC LIMIT 3"); $upevts=qall("SELECT * FROM events WHERE status='approved' AND event_date>=date('now') ORDER BY is_featured DESC,event_date ASC LIMIT 4");
$randomInterval = setting('home_random_interval','2hours');
$slotKey = $randomInterval === 'day' ? date('Y-m-d') : (string)floor(time() / 7200);
$rotationLabel = $randomInterval === 'day' ? 'Refreshes daily' : 'Refreshes every two hours';
$randomPool = qall("
SELECT b.*,c.name cat,c.icon cat_icon,COALESCE(AVG(r.rating),0) avg,COUNT(r.id) rcnt
FROM businesses b
JOIN categories c ON c.id=b.category_id
LEFT JOIN reviews r ON r.business_id=b.id AND r.is_approved=1
WHERE b.is_active=1
GROUP BY b.id
");
usort($randomPool, fn($a,$b) => strcmp(hash('sha256',$slotKey.'-'.$a['id']), hash('sha256',$slotKey.'-'.$b['id'])));
$randomBiz = array_slice($randomPool, 0, 8);
include __DIR__.'/includes/header.php'; include __DIR__.'/includes/header.php';
?> ?>
<section class="hero">
<div class="hero-bg"></div> <section class="home-hero">
<div class="hero-stars"></div> <div class="home-hero-glow"></div>
<div class="hero-mtns"> <div class="home-hero-grid">
<svg viewBox="0 0 1440 300" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"> <div class="home-hero-copy">
<polygon points="0,300 0,180 80,120 160,160 240,80 330,138 420,58 520,118 630,38 740,98 850,18 960,88 1060,28 1160,108 1260,48 1380,128 1440,78 1440,300" fill="#0a0f18" opacity=".9"/> <div class="hero-eyebrow">MINERAL COUNTY · WEST VIRGINIA</div>
<polygon points="0,300 0,222 100,170 200,200 310,140 430,188 560,128 680,168 800,100 920,158 1030,108 1140,158 1250,118 1380,175 1440,145 1440,300" fill="#0d1520" opacity=".96"/>
<polygon points="0,300 0,260 140,232 280,252 420,212 560,242 700,202 840,235 980,197 1120,228 1260,205 1440,225 1440,300" fill="#111c2c"/>
<g opacity=".28" stroke="#c9a84c" stroke-width="1.5" fill="none">
<line x1="850" y1="20" x2="850" y2="72"/><line x1="850" y1="20" x2="833" y2="8"/><line x1="850" y1="20" x2="867" y2="8"/><line x1="850" y1="20" x2="850" y2="5"/>
<line x1="903" y1="30" x2="903" y2="82"/><line x1="903" y1="30" x2="886" y2="18"/><line x1="903" y1="30" x2="920" y2="18"/><line x1="903" y1="30" x2="903" y2="15"/>
<line x1="958" y1="42" x2="958" y2="94"/><line x1="958" y1="42" x2="941" y2="30"/><line x1="958" y1="42" x2="975" y2="30"/><line x1="958" y1="42" x2="958" y2="27"/>
</g>
</svg>
</div>
<div class="hero-content">
<div class="hero-eyebrow"> MINERAL COUNTY · EASTERN PANHANDLE · WEST VIRGINIA</div>
<h1 class="hero-h1">DISCOVER<span class="gld">KEYSER</span></h1> <h1 class="hero-h1">DISCOVER<span class="gld">KEYSER</span></h1>
<div class="hero-sub">NORTH BRANCH POTOMAC RIVER · FOUNDED 1874</div> <p class="hero-desc">This place is new. Sign up to claim your business, submit corrections and events!</p>
<div class="hero-rule"></div> <form action="/directory.php" method="GET" class="hero-search">
<p class="hero-desc">Where Appalachian heritage meets mountain beauty. County seat of Mineral County 3 hours from Washington D.C., a world away from ordinary.</p> <span class="s-icon"></span>
<input class="search-input" type="text" name="q" placeholder="Search restaurants, shops, services...">
<button class="btn btn-primary">Search</button>
</form>
<div class="hero-actions"> <div class="hero-actions">
<a href="/directory.php" class="btn btn-primary">Explore Businesses</a> <a href="/directory.php" class="btn btn-outline">View Directory</a>
<a href="/attractions.php" class="btn btn-outline">Attractions &amp; Activities</a> <a href="/events.php" class="btn btn-secondary">Upcoming Events</a>
<?php if(!authed() && setting('registration_enabled','1')==='1'): ?>
<a href="/register.php" class="btn btn-primary">Join or Claim a Page</a>
<?php endif; ?>
</div>
</div>
<div class="hero-signal">
<div class="signal-card signal-card-main">
<div class="signal-label">Live Directory</div>
<div class="signal-number"><?=$totalBiz?></div>
<div class="signal-copy">active Keyser business listings</div>
</div>
<div class="signal-mini-grid">
<?php foreach([['1874','Founded'],['809 ft','Elevation'],['WVU','Potomac State'],['North Branch','Potomac River']] as [$n,$l]): ?>
<div class="signal-card">
<div class="signal-small"><?=$n?></div>
<div class="signal-copy"><?=$l?></div>
</div>
<?php endforeach; ?>
</div>
</div> </div>
</div> </div>
</section> </section>
<div class="stats-strip">
<div class="stats-inner">
<?php foreach([['1874','Founded'],['809 ft','Elevation'],['~4,800','Population'],['132','Wind Turbines'],['1901','PSC Founded'],[$totalBiz,'Businesses Listed']] as [$n,$l]): ?>
<div class="stat"><div class="stat-n"><?=e($n)?></div><div class="stat-l"><?=e($l)?></div></div>
<?php endforeach; ?>
</div>
</div>
<?php if($alerts): ?> <?php if($alerts): ?>
<div class="alert-strip"> <div class="alert-strip neon-alerts">
<div class="alert-strip-inner"> <div class="alert-strip-inner">
<div class="eyebrow" style="margin-bottom:.65rem">📢 LOCAL BUSINESS NEWS &amp; UPDATES</div> <div class="eyebrow">LOCAL BUSINESS SIGNALS</div>
<?php foreach($alerts as $a): ?> <?php foreach($alerts as $a): ?>
<div class="alert-item a-<?=e($a['type'])?>"> <div class="alert-item a-<?=e($a['type'])?>">
<div class="alert-ico"><?=alertIcon($a['type'] ?? '')?></div> <div class="alert-ico"><?=alertIcon($a['type'] ?? '')?></div>
<div> <div>
<div class="alert-title"><?=e($a['title'])?></div> <div class="alert-title"><?=e($a['title'])?></div>
<div class="alert-body"><?=e($a['body'])?></div> <div class="alert-body"><?=e($a['body'])?></div>
<div class="alert-meta">From <a href="/business.php?slug=<?=e($a['bslug'])?>"><?=e($a['bname'])?></a> &middot; <?=ago($a['created_at'] ?? '')?></div> <div class="alert-meta">From <a href="/business.php?slug=<?=e($a['bslug'])?>"><?=e($a['bname'])?></a> · <?=ago($a['created_at'] ?? '')?></div>
</div> </div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
@@ -62,27 +87,75 @@ include __DIR__.'/includes/header.php';
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="home-band">
<div class="section">
<div class="section-head">
<div>
<div class="eyebrow">UPCOMING EVENTS</div>
<h2 class="sec-title">Whats Happening Around Keyser</h2>
</div>
<a href="/events.php" class="btn btn-outline btn-sm">All Events</a>
</div>
<?php if($upevts): ?>
<div class="event-strip">
<?php foreach($upevts as $ev): ?>
<a href="<?=e(eventUrl($ev))?>" class="evt-card evt-link-card">
<?php if(!empty($ev['image_path'])): ?>
<div class="evt-image-wrap"><img src="<?=e($ev['image_path'])?>" alt="<?=e($ev['title'])?>" class="evt-image"></div>
<?php endif; ?>
<div class="evt-date-box">
<div class="evt-mon"><?=strtoupper(date('M',strtotime($ev['event_date'])))?></div>
<div class="evt-day"><?=date('j',strtotime($ev['event_date']))?></div>
<div class="evt-yr"><?=date('Y',strtotime($ev['event_date']))?></div>
</div>
<div class="evt-body">
<div class="evt-title"><?=e($ev['title'])?><?php if($ev['is_featured']): ?> <span class="featured-badge">FEATURED</span><?php endif; ?></div>
<div class="evt-meta">
<?php if($ev['venue']): ?><span><?=e($ev['venue'])?></span><?php endif; ?>
<?php if($ev['event_time']): ?><span><?=e($ev['event_time'])?></span><?php endif; ?>
<span><?=e($ev['cost'])?></span>
</div>
<div class="evt-desc"><?=e(substr($ev['description'],0,160)).(strlen($ev['description'])>160?'...':'')?></div>
</div>
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty event-empty">
<div class="empty-ico"></div>
<h3>No upcoming approved events yet</h3>
<p style="margin-top:.5rem;color:var(--text-d)">Community events will appear here as they are approved.</p>
</div>
<?php endif; ?>
<div class="text-center mt3">
<a href="/events.php?submit=1" class="btn btn-primary">Submit an Event</a>
</div>
</div>
</div>
<div class="section"> <div class="section">
<div style="margin-bottom:2rem"> <div class="section-head">
<div class="eyebrow">⭐A LITTLE SPOTLIGHT</div> <div>
<h2 class="sec-title">Featured</h2> <div class="eyebrow">FEATURED BUSINESS</div>
<div class="sec-rule"></div> <h2 class="sec-title">Local Places in the Spotlight</h2>
</div>
<a href="/directory.php" class="btn btn-outline btn-sm">All Businesses</a>
</div> </div>
<div class="g3"> <div class="g3">
<?php foreach($featured as $b): ?> <?php foreach($featured as $b): ?>
<a href="/business.php?slug=<?=e($b['slug'])?>" style="text-decoration:none"> <a href="/business.php?slug=<?=e($b['slug'])?>" class="card-link">
<div class="biz-card"> <div class="biz-card neon-card">
<div class="biz-card-top"> <div class="biz-card-top">
<div class="cat-badge"><?=e($b['cat_icon'].' '.$b['cat'])?></div> <div class="cat-badge"><?=e($b['cat_icon'].' '.$b['cat'])?></div>
<?php if($b['is_featured']): ?><span class="featured-badge" style="float:right">★ FEATURED</span><?php endif; ?> <span class="featured-badge">FEATURED</span>
<div class="biz-name"><?=e($b['name'])?></div> <div class="biz-name"><?=e($b['name'])?></div>
<?php if($b['subcategory']): ?><div class="biz-sub"><?=e($b['subcategory'])?></div><?php endif; ?> <?php if($b['subcategory']): ?><div class="biz-sub"><?=e($b['subcategory'])?></div><?php endif; ?>
</div> </div>
<div class="biz-card-body"> <div class="biz-card-body">
<div class="biz-desc"><?=e($b['description'])?></div> <div class="biz-desc"><?=e($b['description'])?></div>
<div class="biz-meta"> <div class="biz-meta">
<?php if($b['address']): ?><div class="biz-meta-row"><span class="biz-meta-icon">📍</span><?=e($b['address'])?></div><?php endif; ?> <?php if($b['address']): ?><div class="biz-meta-row"><span class="biz-meta-icon"></span><?=e($b['address'])?></div><?php endif; ?>
<?php if($b['phone']): ?><div class="biz-meta-row"><span class="biz-meta-icon">📞</span><?=e($b['phone'])?></div><?php endif; ?> <?php if($b['phone']): ?><div class="biz-meta-row"><span class="biz-meta-icon"></span><?=e($b['phone'])?></div><?php endif; ?>
</div> </div>
</div> </div>
<div class="biz-card-foot"> <div class="biz-card-foot">
@@ -91,84 +164,70 @@ include __DIR__.'/includes/header.php';
<span class="rat-score"><?=number_format((float)$b['avg'],1)?></span> <span class="rat-score"><?=number_format((float)$b['avg'],1)?></span>
<span class="rat-cnt">(<?=$b['rcnt']?>)</span> <span class="rat-cnt">(<?=$b['rcnt']?>)</span>
</div> </div>
<span style="color:var(--gold);font-size:.76rem;font-family:'Oswald',sans-serif">VIEW </span> <span class="view-label">VIEW</span>
</div> </div>
</div> </div>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<div style="text-align:center;margin-top:2rem">
<a href="/directory.php" class="btn btn-outline">Browse Full Directory (<?=$totalBiz?> Businesses)</a>
</div>
</div> </div>
<div style="background:var(--bg2);border-top:1px solid var(--bdr);border-bottom:1px solid var(--bdr);padding:4rem 1.5rem"> <div class="section about-split">
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:1fr 1fr;gap:3rem;align-items:center">
<div> <div>
<div class="eyebrow">OUR HISTORY</div> <div class="eyebrow">ABOUT KEYSER</div>
<h2 class="sec-title" style="margin-bottom:1rem">A City with Deep Mountain Roots</h2> <h2 class="sec-title">Railroad Roots, River Views, Mountain Pace</h2>
<div class="prose"> <div class="prose">
<p>Originally called <strong>Paddy Town</strong> after Irish settler Patrick McCarty, then New Creek, Keyser took its name in 1874 honoring William Keyser, Vice President of the Baltimore &amp; Ohio Railroad whose line transformed the region in 1842.</p> <p>Keyser began as Paddy Town, then New Creek, before taking its current name in 1874 in honor of Baltimore &amp; Ohio Railroad leader William Keyser. The rail line shaped the citys downtown, its work ethic, and its role as Mineral Countys county seat.</p>
<p>Today Keyser is home to <strong>Potomac State College of WVU</strong> (est. 1901 on historic Civil War Fort Fuller), WVU Medicine Potomac Valley Hospital, and a vibrant downtown community rich with Appalachian culture on the banks of the North Branch Potomac River.</p> <p>Today the city sits where New Creek meets the North Branch Potomac River, with Potomac State College of WVU, local restaurants, small shops, healthcare, civic life, and outdoor access all close by.</p>
<p>Mineral County is part of the <strong>Appalachian Forest National Heritage Area</strong> over 500 square miles of forested beauty where American history lives on in every ridge and hollow.</p>
</div> </div>
<a href="/about.php" class="btn btn-outline" style="margin-top:1rem">Learn More About Keyser</a> <a href="/about.php" class="btn btn-outline">Learn More About Keyser</a>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="fact-grid home-facts">
<?php foreach([['🏛️','HISTORIC','National Register of Historic Places sites'],['🎓','EDUCATION','Potomac State College of WVU since 1901'],['🌊','OUTDOORS','Kayaking &amp; fishing the North Branch'],['💨','WIND ENERGY','132 turbines on the Allegheny Front']] as [$ic,$lbl,$val]): ?> <?php foreach([['Historic Core','Downtown, rail heritage, and civic landmarks'],['Outdoor Access','River routes, nearby trails, and mountain drives'],['College Town','Home to Potomac State College of WVU'],['Local Directory',$totalBiz.' active business pages and growing']] as [$lbl,$val]): ?>
<div style="background:var(--bg3);border:1px solid var(--bdr);border-radius:6px;padding:1.2rem;text-align:center"> <div class="fact-card">
<div style="font-size:2rem;margin-bottom:.45rem"><?=$ic?></div> <div class="fact-lbl"><?=e($lbl)?></div>
<div style="font-family:'Oswald',sans-serif;font-size:.66rem;letter-spacing:.2em;color:var(--gold)"><?=$lbl?></div> <div class="fact-val"><?=e($val)?></div>
<div style="color:var(--text);margin-top:.25rem;font-size:.88rem"><?=$val?></div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div>
</div> </div>
<?php if($upevts): ?> <div class="section random-section">
<div class="section"> <div class="section-head">
<div style="margin-bottom:2rem"> <div>
<div class="eyebrow">📅 WHAT'S HAPPENING</div> <div class="eyebrow">RANDOM LOCAL PICKS · <?=e(strtoupper($rotationLabel))?></div>
<h2 class="sec-title">Upcoming Events</h2> <h2 class="sec-title">Explore Something Different</h2>
<div class="sec-rule"></div>
</div>
<?php foreach($upevts as $ev): ?>
<div class="evt-card">
<div class="evt-date-box">
<div class="evt-mon"><?=strtoupper(date('M',strtotime($ev['event_date'])))?></div>
<div class="evt-day"><?=date('j',strtotime($ev['event_date']))?></div>
<div class="evt-yr"><?=date('Y',strtotime($ev['event_date']))?></div>
</div>
<div class="evt-body">
<div class="evt-title"><?=e($ev['title'])?><?php if($ev['is_featured']): ?> <span class="featured-badge" style="margin-left:.5rem">★ FEATURED</span><?php endif; ?></div>
<div class="evt-meta">
<?php if($ev['venue']): ?><span>📍 <?=e($ev['venue'])?></span><?php endif; ?>
<?php if($ev['event_time']): ?><span>🕐 <?=e($ev['event_time'])?></span><?php endif; ?>
<span>💰 <?=e($ev['cost'])?></span>
</div>
<div class="evt-desc"><?=e(substr($ev['description'],0,180)).(strlen($ev['description'])>180?'…':'')?></div>
</div> </div>
<a href="/directory.php" class="btn btn-primary btn-sm">View All Businesses</a>
</div> </div>
<div class="random-list">
<?php foreach($randomBiz as $b): ?>
<a href="/business.php?slug=<?=e($b['slug'])?>" class="random-row">
<span class="random-cat"><?=e($b['cat_icon'])?></span>
<span class="random-main">
<strong><?=e($b['name'])?></strong>
<em><?=e($b['cat'])?><?= $b['subcategory'] ? ' · '.e($b['subcategory']) : '' ?></em>
</span>
<span class="random-arrow"></span>
</a>
<?php endforeach; ?> <?php endforeach; ?>
<div style="text-align:center;margin-top:1.5rem"> </div>
<a href="/events.php" class="btn btn-outline">All Events</a> <div class="text-center mt3">
<a href="/events.php?submit=1" class="btn btn-secondary" style="margin-left:.75rem">Submit an Event</a> <a href="/directory.php" class="btn btn-outline">Browse the Full Business Directory</a>
</div> </div>
</div> </div>
<?php endif; ?>
<div style="padding:4rem 1.5rem;text-align:center;max-width:560px;margin:0 auto"> <div class="join-band">
<div class="eyebrow">JOIN THE COMMUNITY</div> <div>
<h2 style="font-size:2rem;margin:.5rem 0 1rem">Own a Business in Keyser?</h2> <div class="eyebrow">BUSINESS OWNERS</div>
<p style="color:var(--text-m);margin-bottom:2rem;line-height:1.85">Create an account to manage your listing, post alerts, add menus, and connect with the Keyser community.</p> <h2>Claim your Keyser business page</h2>
<div class="flex-row" style="justify-content:center"> <p>Create a business-owner account, choose the listing you manage, add a verification phone number, and submit first edits or a YouTube/Vimeo video for admin review.</p>
<?php if(!authed()): ?> </div>
<a href="/register.php" class="btn btn-primary">Create Account</a> <?php if(!authed() && setting('registration_enabled','1')==='1'): ?>
<a href="/login.php" class="btn btn-outline">Login</a> <a href="/register.php" class="btn btn-primary">Start a Claim</a>
<?php else: ?> <?php else: ?>
<a href="/dashboard.php" class="btn btn-primary">My Dashboard</a> <a href="/dashboard.php" class="btn btn-primary">Open Dashboard</a>
<?php endif; ?> <?php endif; ?>
</div>
</div> </div>
<?php include __DIR__.'/includes/footer.php'; ?> <?php include __DIR__.'/includes/footer.php'; ?>
+7 -1
View File
@@ -7,11 +7,17 @@ $err = '';
if (isPost()) { if (isPost()) {
csrfCheck(); csrfCheck();
$un = ps('username'); $pw = ps('password'); $un = ps('username'); $pw = ps('password');
$u = qone("SELECT * FROM users WHERE (username=? OR email=?) AND is_active=1", [$un, $un]); $u = qone("SELECT * FROM users WHERE (username=? OR email=?)", [$un, $un]);
if ($u && password_verify($pw, $u['password'])) { if ($u && password_verify($pw, $u['password'])) {
if (empty($u['email_verified_at'])) {
$err = 'Please verify your email address before signing in.';
} elseif ((int)$u['is_active'] !== 1) {
$err = 'Your account is not active. Please contact the site administrator.';
} else {
loginUser($u); loginUser($u);
flash('Welcome back, '.e($u['username']).'!', 'success'); flash('Welcome back, '.e($u['username']).'!', 'success');
go($next); go($next);
}
} else { } else {
$err = 'Invalid username or password.'; $err = 'Invalid username or password.';
} }
+1322
View File
File diff suppressed because it is too large Load Diff
+63 -4
View File
@@ -4,6 +4,8 @@ requireLogin();
$bizId=iget('id'); $bizId=iget('id');
$biz=qone("SELECT * FROM businesses WHERE id=?",[$bizId]); $biz=qone("SELECT * FROM businesses WHERE id=?",[$bizId]);
if(!$biz||!owns($bizId)){flash('Access denied.','error');go('/dashboard.php');} if(!$biz||!owns($bizId)){flash('Access denied.','error');go('/dashboard.php');}
$menuImageLimit=max(0,min(1,(int)setting('menu_item_image_limit','1')));
$menuImagesEnabled=$menuImageLimit>0;
if(isPost()){ if(isPost()){
csrfCheck(); csrfCheck();
@@ -28,8 +30,25 @@ if(isPost()){
$sid=(int)p('section_id'); $sid=(int)p('section_id');
$name=ps('item_name'); $name=ps('item_name');
if($name){ if($name){
$imagePath='';
if($menuImagesEnabled){
$upload=storeSingleUpload(
'item_image',
'menus',
imageExts(),
mbToBytes(setting('menu_upload_max_mb','5'),5),
[
'image_only'=>true,
'resize_over_bytes'=>mbToBytes(setting('forum_image_resize_threshold_mb','1'),1),
'image_max_width'=>(int)setting('forum_image_max_width','1600'),
'image_quality'=>(int)setting('forum_image_quality','82'),
]
);
if(!$upload['ok']){flash($upload['error'],'error');go("/menu.php?id=$bizId");}
$imagePath=(string)($upload['path']??'');
}
$order=(int)qval("SELECT COALESCE(MAX(sort_order),0)+1 FROM menu_items WHERE section_id=?",[$sid]); $order=(int)qval("SELECT COALESCE(MAX(sort_order),0)+1 FROM menu_items WHERE section_id=?",[$sid]);
qrun("INSERT INTO menu_items(section_id,name,description,price,sort_order)VALUES(?,?,?,?,?)",[$sid,$name,ps('item_desc'),(float)p('item_price',0),$order]); qrun("INSERT INTO menu_items(section_id,name,description,price,image_path,sort_order)VALUES(?,?,?,?,?,?)",[$sid,$name,ps('item_desc'),(float)p('item_price',0),$imagePath,$order]);
flash('Item added!','success'); flash('Item added!','success');
} }
} }
@@ -39,9 +58,30 @@ if(isPost()){
} }
if($act==='edit_item'){ if($act==='edit_item'){
$iid=(int)p('iid'); $iid=(int)p('iid');
qrun("UPDATE menu_items SET name=?,description=?,price=?,is_available=? WHERE id=?",[ps('item_name'),ps('item_desc'),(float)p('item_price',0),(int)p('is_available',1),$iid]); $item=qone("SELECT mi.* FROM menu_items mi JOIN menu_sections ms ON ms.id=mi.section_id WHERE mi.id=? AND ms.business_id=?",[$iid,$bizId]);
if($item){
$imagePath=(string)($item['image_path']??'');
if($menuImagesEnabled && p('remove_item_image','0')==='1') $imagePath='';
if($menuImagesEnabled){
$upload=storeSingleUpload(
'item_image',
'menus',
imageExts(),
mbToBytes(setting('menu_upload_max_mb','5'),5),
[
'image_only'=>true,
'resize_over_bytes'=>mbToBytes(setting('forum_image_resize_threshold_mb','1'),1),
'image_max_width'=>(int)setting('forum_image_max_width','1600'),
'image_quality'=>(int)setting('forum_image_quality','82'),
]
);
if(!$upload['ok']){flash($upload['error'],'error');go("/menu.php?id=$bizId");}
if(!empty($upload['path'])) $imagePath=(string)$upload['path'];
}
qrun("UPDATE menu_items SET name=?,description=?,price=?,image_path=?,is_available=? WHERE id=?",[ps('item_name'),ps('item_desc'),(float)p('item_price',0),$imagePath,(int)p('is_available',1),$iid]);
flash('Item updated!','success'); flash('Item updated!','success');
} }
}
go("/menu.php?id=$bizId"); go("/menu.php?id=$bizId");
} }
@@ -97,23 +137,39 @@ include __DIR__.'/includes/header.php';
<?php foreach($items[$sec['id']] as $item): ?> <?php foreach($items[$sec['id']] as $item): ?>
<div style="border-bottom:1px solid var(--bdr);padding:.75rem 0"> <div style="border-bottom:1px solid var(--bdr);padding:.75rem 0">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem"> <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem">
<div class="menu-owner-row">
<?php if(!empty($item['image_path'])): ?><img src="<?=e($item['image_path'])?>" alt="<?=e($item['name'])?>" class="menu-owner-thumb"><?php endif; ?>
<div> <div>
<div style="font-weight:600;color:<?=$item['is_available']?'var(--text)':'var(--text-d)'?>"><?=e($item['name'])?> <?php if(!$item['is_available']): ?><span class="badge badge-gray">Unavailable</span><?php endif; ?></div> <div style="font-weight:600;color:<?=$item['is_available']?'var(--text)':'var(--text-d)'?>"><?=e($item['name'])?> <?php if(!$item['is_available']): ?><span class="badge badge-gray">Unavailable</span><?php endif; ?></div>
<?php if($item['description']): ?><div style="font-size:.8rem;color:var(--text-d)"><?=e($item['description'])?></div><?php endif; ?> <?php if($item['description']): ?><div style="font-size:.8rem;color:var(--text-d)"><?=e($item['description'])?></div><?php endif; ?>
<?php if($item['price']>0): ?><div style="color:var(--gold);font-family:'Oswald',sans-serif;font-size:.9rem"><?=money((float)$item['price'])?></div><?php endif; ?> <?php if($item['price']>0): ?><div style="color:var(--gold);font-family:'Oswald',sans-serif;font-size:.9rem"><?=money((float)$item['price'])?></div><?php endif; ?>
</div> </div>
</div>
<div class="flex-row" style="gap:.3rem;flex-shrink:0"> <div class="flex-row" style="gap:.3rem;flex-shrink:0">
<button onclick="this.closest('div').parentElement.querySelector('.edit-item-form').classList.toggle('hidden')" class="btn btn-xs btn-secondary">✏️</button> <button onclick="this.closest('div').parentElement.querySelector('.edit-item-form').classList.toggle('hidden')" class="btn btn-xs btn-secondary">✏️</button>
<form method="POST" style="display:inline"><?=csrfField()?><input type="hidden" name="_act" value="del_item"><input type="hidden" name="iid" value="<?=$item['id']?>"><button class="btn btn-xs btn-danger" data-confirm="Delete this item?">🗑️</button></form> <form method="POST" style="display:inline"><?=csrfField()?><input type="hidden" name="_act" value="del_item"><input type="hidden" name="iid" value="<?=$item['id']?>"><button class="btn btn-xs btn-danger" data-confirm="Delete this item?">🗑️</button></form>
</div> </div>
</div> </div>
<div class="edit-item-form hidden" style="background:var(--bg4);border:1px solid var(--bdr);border-radius:4px;padding:.85rem;margin-top:.65rem"> <div class="edit-item-form hidden" style="background:var(--bg4);border:1px solid var(--bdr);border-radius:4px;padding:.85rem;margin-top:.65rem">
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="edit_item"><input type="hidden" name="iid" value="<?=$item['id']?>"> <form method="POST" enctype="multipart/form-data"><?=csrfField()?><input type="hidden" name="_act" value="edit_item"><input type="hidden" name="iid" value="<?=$item['id']?>">
<div class="form-row" style="margin-bottom:.6rem"> <div class="form-row" style="margin-bottom:.6rem">
<div class="fg"><label class="flabel">NAME</label><input type="text" name="item_name" class="finput" value="<?=e($item['name'])?>" required></div> <div class="fg"><label class="flabel">NAME</label><input type="text" name="item_name" class="finput" value="<?=e($item['name'])?>" required></div>
<div class="fg"><label class="flabel">PRICE ($)</label><input type="number" name="item_price" class="finput" value="<?=e($item['price'])?>" step="0.01" min="0"></div> <div class="fg"><label class="flabel">PRICE ($)</label><input type="number" name="item_price" class="finput" value="<?=e($item['price'])?>" step="0.01" min="0"></div>
</div> </div>
<div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="item_desc" class="finput" value="<?=e($item['description'])?>"></div> <div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="item_desc" class="finput" value="<?=e($item['description'])?>"></div>
<?php if($menuImagesEnabled): ?>
<div class="fg">
<label class="flabel">ITEM IMAGE</label>
<?php if(!empty($item['image_path'])): ?>
<div class="menu-admin-image">
<img src="<?=e($item['image_path'])?>" alt="<?=e($item['name'])?>">
<label style="display:flex;align-items:center;gap:.45rem;font-size:.82rem;color:var(--text-m);cursor:pointer"><input type="checkbox" name="remove_item_image" value="1"> Remove current image</label>
</div>
<?php endif; ?>
<input type="file" name="item_image" class="finput" accept="image/*">
<div class="fhint">One image allowed. Max <?=e(setting('menu_upload_max_mb','5'))?> MB.</div>
</div>
<?php endif; ?>
<div class="fg"><label class="flabel">AVAILABLE</label><select name="is_available" class="fselect"><option value="1"<?=$item['is_available']?' selected':''?>>Yes</option><option value="0"<?=!$item['is_available']?' selected':''?>>No (hidden from menu)</option></select></div> <div class="fg"><label class="flabel">AVAILABLE</label><select name="is_available" class="fselect"><option value="1"<?=$item['is_available']?' selected':''?>>Yes</option><option value="0"<?=!$item['is_available']?' selected':''?>>No (hidden from menu)</option></select></div>
<button type="submit" class="btn btn-success btn-sm">Save Item</button> <button type="submit" class="btn btn-success btn-sm">Save Item</button>
</form> </form>
@@ -125,12 +181,15 @@ include __DIR__.'/includes/header.php';
<div style="margin-top:.85rem;padding-top:.85rem"> <div style="margin-top:.85rem;padding-top:.85rem">
<details><summary style="cursor:pointer;font-size:.82rem;color:var(--gold);font-family:'Oswald',sans-serif;letter-spacing:.08em"> Add Item to "<?=e($sec['name'])?>"</summary> <details><summary style="cursor:pointer;font-size:.82rem;color:var(--gold);font-family:'Oswald',sans-serif;letter-spacing:.08em"> Add Item to "<?=e($sec['name'])?>"</summary>
<div style="padding:.85rem 0"> <div style="padding:.85rem 0">
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="add_item"><input type="hidden" name="section_id" value="<?=$sec['id']?>"> <form method="POST" enctype="multipart/form-data"><?=csrfField()?><input type="hidden" name="_act" value="add_item"><input type="hidden" name="section_id" value="<?=$sec['id']?>">
<div class="form-row" style="margin-bottom:.6rem"> <div class="form-row" style="margin-bottom:.6rem">
<div class="fg"><label class="flabel">ITEM NAME *</label><input type="text" name="item_name" class="finput" required placeholder="e.g. Spaghetti Carbonara"></div> <div class="fg"><label class="flabel">ITEM NAME *</label><input type="text" name="item_name" class="finput" required placeholder="e.g. Spaghetti Carbonara"></div>
<div class="fg"><label class="flabel">PRICE ($)</label><input type="number" name="item_price" class="finput" value="0" step="0.01" min="0"></div> <div class="fg"><label class="flabel">PRICE ($)</label><input type="number" name="item_price" class="finput" value="0" step="0.01" min="0"></div>
</div> </div>
<div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="item_desc" class="finput" placeholder="Brief description…"></div> <div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="item_desc" class="finput" placeholder="Brief description…"></div>
<?php if($menuImagesEnabled): ?>
<div class="fg"><label class="flabel">ITEM IMAGE</label><input type="file" name="item_image" class="finput" accept="image/*"><div class="fhint">Optional. One image allowed, max <?=e(setting('menu_upload_max_mb','5'))?> MB.</div></div>
<?php endif; ?>
<button type="submit" class="btn btn-primary btn-sm">Add Item</button> <button type="submit" class="btn btn-primary btn-sm">Add Item</button>
</form> </form>
</div> </div>
+220 -235
View File
@@ -6,288 +6,273 @@ if (setting('registration_enabled','1') !== '1') {
go('/login.php'); go('/login.php');
} }
$pageTitle = 'Create Account — Discover Keyser WV'; $pageTitle = 'Create Account — My Keyser';
$errors = []; $errors = [];
$businessOptions = qall("SELECT id,name,address FROM businesses WHERE is_active=1 ORDER BY name");
$tier = p('member_type', 'member') === 'business_owner' ? 'business_owner' : 'member';
if (isPost()) { if (isPost()) {
csrfCheck(); csrfCheck();
// ── Honeypot check — bots fill hidden fields, humans don't ── if (ps('website_url') !== '' || ps('confirm_email') !== '') {
// Field named "website_url" is visually hidden; any value = bot flash('Account created! Welcome to My Keyser.','success');
if (ps('website_url') !== '') {
// Silently succeed (don't tell the bot it failed)
flash('Account created! Welcome to Discover Keyser WV.','success');
go('/index.php'); go('/index.php');
} }
// ── Timing check — real humans take at least 3 seconds ──
$formTime = (int)p('_form_time', 0); $formTime = (int)p('_form_time', 0);
if ($formTime && (time() - $formTime) < 3) { if ($formTime && (time() - $formTime) < 3) {
// Also silent — pretend success flash('Account created! Welcome to My Keyser.','success');
flash('Account created! Welcome to Discover Keyser WV.','success');
go('/index.php'); go('/index.php');
} }
$tier = p('member_type', 'member') === 'business_owner' ? 'business_owner' : 'member';
$un = ps('username'); $un = ps('username');
$em = ps('email'); $em = ps('email');
$pw = ps('password'); $pw = ps('password');
$pw2 = ps('password2'); $pw2 = ps('password2');
$tos = p('lawful_use', ''); $tos = p('lawful_use', '');
if (strlen($un) < 3) $claimBizId = (int)p('claim_business_id');
$errors[] = 'Username must be at least 3 characters.'; $contactPhone = ps('contact_phone');
if (!preg_match('/^[a-zA-Z0-9_]+$/', $un)) $claimBiz = null;
$errors[] = 'Username may only contain letters, numbers, and underscores.';
if ($em && !filter_var($em, FILTER_VALIDATE_EMAIL)) if (strlen($un) < 3) $errors[] = 'Username must be at least 3 characters.';
$errors[] = 'Please enter a valid email address.'; if (!preg_match('/^[a-zA-Z0-9_]+$/', $un)) $errors[] = 'Username may only contain letters, numbers, and underscores.';
if (strlen($pw) < 6) if ($em === '' || !filter_var($em, FILTER_VALIDATE_EMAIL)) $errors[] = 'Please enter a valid email address.';
$errors[] = 'Password must be at least 6 characters.'; if (strlen($pw) < 6) $errors[] = 'Password must be at least 6 characters.';
if ($pw !== $pw2) if ($pw !== $pw2) $errors[] = 'Passwords do not match.';
$errors[] = 'Passwords do not match.'; if ($tos !== '1') $errors[] = 'You must agree to use this service lawfully to create an account.';
if ($tos !== '1') if (!$errors && qval("SELECT id FROM users WHERE username=?", [$un])) $errors[] = 'That username is already taken. Please choose another.';
$errors[] = 'You must agree to use this service lawfully to create an account.'; if (!$errors && qval("SELECT id FROM users WHERE email=?", [$em])) $errors[] = 'That email address is already registered. Try signing in instead.';
if (!$errors && qval("SELECT id FROM users WHERE username=?", [$un])) if (!$errors && !mailgunConfigured()) $errors[] = 'Email verification is not configured yet. Please contact the site administrator.';
$errors[] = 'That username is already taken. Please choose another.';
if (!$errors && $em && qval("SELECT id FROM users WHERE email=?", [$em])) $claimEdits = [
$errors[] = 'That email address is already registered. Try signing in instead.'; 'description' => ps('claim_description'),
'address' => ps('claim_address'),
'phone' => ps('claim_public_phone'),
'email' => ps('claim_public_email'),
'website' => ps('claim_website'),
'video_url' => ps('claim_video_url'),
];
if ($tier === 'business_owner') {
if (!$claimBizId) {
$errors[] = 'Please select the business page you want to claim.';
} else {
$claimBiz = qone("SELECT * FROM businesses WHERE id=? AND is_active=1", [$claimBizId]);
if (!$claimBiz) $errors[] = 'Please select a valid listed business.';
}
if ($contactPhone === '' || strlen(preg_replace('/\D+/', '', $contactPhone)) < 7) {
$errors[] = 'Please enter a contact phone number for verification.';
}
if ($claimEdits['email'] !== '' && !filter_var($claimEdits['email'], FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Please enter a valid public email edit.';
}
if (!validBusinessVideoUrl($claimEdits['video_url'])) {
$errors[] = 'Business videos must be a YouTube or Vimeo URL.';
}
}
if (!$errors) { if (!$errors) {
$id = qrun( $id = qrun(
"INSERT INTO users(username,email,password)VALUES(?,?,?)", "INSERT INTO users(username,email,password,is_active,member_type)VALUES(?,?,?,?,?)",
[$un, $em ?: null, password_hash($pw, PASSWORD_BCRYPT)] [$un, $em, password_hash($pw, PASSWORD_BCRYPT), 0, $tier]
); );
loginUser(qone("SELECT * FROM users WHERE id=?", [$id]));
flash('Welcome to Discover Keyser WV, '.e($un).'!', 'success'); $submittedEdits = 0;
go('/index.php'); if ($tier === 'business_owner' && $claimBiz) {
qrun(
"INSERT INTO business_claim_requests(business_id,user_id,contact_phone)VALUES(?,?,?)",
[$claimBizId, $id, $contactPhone]
);
foreach ($claimEdits as $field => $newValue) {
if ($newValue === '') continue;
$oldValue = (string)($claimBiz[$field] ?? '');
if ($newValue === $oldValue) continue;
qrun(
"INSERT INTO business_edit_requests(business_id,user_id,field,old_value,new_value)VALUES(?,?,?,?,?)",
[$claimBizId, $id, $field, $oldValue, $newValue]
);
$submittedEdits++;
}
}
$user = qone("SELECT * FROM users WHERE id=?", [$id]);
$token = createEmailVerificationToken($id);
[$sent, $mailError] = sendVerificationEmail($user, $token);
if (!$sent) {
qrun("DELETE FROM users WHERE id=?", [$id]);
$errors[] = 'Account was not created because the verification email could not be sent: '.$mailError;
}
if (!$errors) {
if ($tier === 'business_owner' && $claimBiz) {
$msg = 'Check your email to verify your account. After verification you can log in, and we will contact you at '.$contactPhone.' within a few business days for business verification.';
if ($submittedEdits) $msg .= ' '.$submittedEdits.' proposed edit'.($submittedEdits !== 1 ? 's were' : ' was').' sent to admin review.';
flash($msg, 'success');
go('/login.php');
}
flash('Check your email to verify your account before signing in.', 'success');
go('/login.php');
}
} }
} }
$ownerSelected = $tier === 'business_owner';
$footExtra = <<<HTML
<script>
(() => {
const radios = document.querySelectorAll('input[name="member_type"]');
const ownerFields = document.getElementById('ownerClaimFields');
const sync = () => {
const owner = document.querySelector('input[name="member_type"]:checked')?.value === 'business_owner';
ownerFields?.classList.toggle('is-open', owner);
ownerFields?.querySelectorAll('[data-owner-required]').forEach(el => {
if (owner) el.setAttribute('required', 'required');
else el.removeAttribute('required');
});
};
radios.forEach(r => r.addEventListener('change', sync));
sync();
})();
</script>
HTML;
include __DIR__.'/includes/header.php'; include __DIR__.'/includes/header.php';
?> ?>
<style> <div class="signup-shell">
/* Honeypot field — visually gone, still in DOM for bots */ <div class="signup-intro">
.hp-field { <div class="eyebrow">JOIN KEYSER ONLINE</div>
position:absolute; <h1>Create Your Account</h1>
left:-9999px; <p>Choose a community membership or start a verified business-owner claim for a page already listed in the directory.</p>
top:-9999px;
opacity:0;
height:0;
width:0;
overflow:hidden;
pointer-events:none;
tabindex:-1;
}
.benefit-grid {
display:grid;
grid-template-columns:1fr 1fr;
gap:1rem;
margin-bottom:2rem;
}
@media(max-width:640px){.benefit-grid{grid-template-columns:1fr}}
.benefit-card {
background:var(--bg3);
border:1px solid var(--bdr);
border-radius:var(--r);
padding:1.4rem;
}
.benefit-card h3 {
font-family:'Oswald',sans-serif;
font-size:.88rem;
letter-spacing:.14em;
color:var(--gold);
margin-bottom:.85rem;
padding-bottom:.55rem;
border-bottom:1px solid var(--bdr);
}
.benefit-card ul {
list-style:none;
padding:0;
margin:0;
}
.benefit-card ul li {
display:flex;
gap:.55rem;
align-items:flex-start;
font-size:.86rem;
color:var(--text-m);
line-height:1.55;
margin-bottom:.55rem;
}
.benefit-card ul li:last-child{margin-bottom:0}
.benefit-card ul li .bi {flex-shrink:0;margin-top:1px}
.owner-note {
background:rgba(26,58,92,.25);
border:1px solid rgba(42,95,153,.4);
border-radius:var(--r);
padding:1.1rem 1.3rem;
font-size:.86rem;
color:var(--text-m);
line-height:1.7;
margin-bottom:2rem;
}
.owner-note strong {color:var(--gold-l)}
.tos-check {
display:flex;
align-items:flex-start;
gap:.65rem;
background:var(--bg4);
border:1px solid var(--bdr-l);
border-radius:var(--r);
padding:1rem 1.15rem;
cursor:pointer;
transition:border-color var(--t);
}
.tos-check:has(input:checked) {border-color:var(--green-l)}
.tos-check input[type="checkbox"] {
flex-shrink:0;
margin-top:3px;
width:17px;
height:17px;
accent-color:var(--green-l);
cursor:pointer;
}
.tos-check-text {
font-size:.88rem;
color:var(--text-m);
line-height:1.6;
user-select:none;
}
.tos-check-text strong {color:var(--text)}
</style>
<div style="max-width:860px;margin:3rem auto;padding:0 1.5rem">
<!-- Page header -->
<div style="text-align:center;margin-bottom:2rem">
<div class="eyebrow" style="justify-content:center;display:flex">JOIN THE COMMUNITY</div>
<h1 style="font-size:clamp(2rem,4vw,2.8rem);margin:.4rem 0">Create Your Account</h1>
<p style="color:var(--text-m);font-size:1rem;max-width:520px;margin:.6rem auto 0;line-height:1.7">
Discover Keyser WV is your community hub for local businesses, events, and information.
Takes less than a minute.
</p>
</div> </div>
<!-- Benefits -->
<div class="benefit-grid">
<div class="benefit-card">
<h3>👤 COMMUNITY MEMBER BENEFITS</h3>
<ul>
<li><span class="bi"></span>Rate and review local Keyser businesses</li>
<li><span class="bi">📅</span>Submit community events to the public calendar</li>
<li><span class="bi">🚩</span>Report incorrect or outdated business listings</li>
<li><span class="bi">💬</span>Share your experiences with the Keyser community</li>
<li><span class="bi">📍</span>Access your personal dashboard with your submissions</li>
<li><span class="bi">🔔</span>Stay connected with what's happening in Mineral County</li>
</ul>
</div>
<div class="benefit-card" style="border-color:rgba(26,72,118,.5)">
<h3>🏢 BUSINESS OWNER BENEFITS</h3>
<ul>
<li><span class="bi">✏️</span>Update your listing description, hours, phone, address</li>
<li><span class="bi">📢</span>Post alerts and announcements directly on your business page</li>
<li><span class="bi">🍽️</span>Build and manage a full menu for your restaurant or café</li>
<li><span class="bi">🟢</span>Mark your business open or temporarily closed in real time</li>
<li><span class="bi">📊</span>View your listing's reviews and ratings from one dashboard</li>
<li><span class="bi">🔑</span>All changes are reviewed by our admin team before going live</li>
</ul>
</div>
</div>
<!-- Business owner request note -->
<div class="owner-note">
<strong>🏢 Do you own or manage a Keyser business?</strong> Create your account below,
then <strong>contact our admin team</strong> at
<a href="mailto:<?=e(setting('contact_email','info@discoverkeyser.com'))?>" style="color:var(--gold-l)"><?=e(setting('contact_email','info@discoverkeyser.com'))?></a>
with your username and the name of your business.
An administrator will link your account to your listing so you can start managing it.
This verification step helps us ensure only legitimate owners control business pages.
</div>
<!-- Registration form -->
<div style="background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r-lg);padding:2.25rem">
<h2 style="font-family:'Oswald',sans-serif;font-size:1rem;letter-spacing:.14em;color:var(--text);margin-bottom:1.5rem;padding-bottom:.65rem;border-bottom:1px solid var(--bdr)">
CREATE YOUR ACCOUNT
</h2>
<?php if ($errors): ?> <?php if ($errors): ?>
<div class="err-box" style="margin-bottom:1.25rem"><?=implode('<br>', array_map('e', $errors))?></div> <div class="err-box signup-errors"><?=implode('<br>', array_map('e', $errors))?></div>
<?php endif; ?> <?php endif; ?>
<form method="POST" autocomplete="off"> <form method="POST" autocomplete="off" class="signup-card">
<?=csrfField()?> <?=csrfField()?>
<!-- Timing token -->
<input type="hidden" name="_form_time" value="<?=time()?>"> <input type="hidden" name="_form_time" value="<?=time()?>">
<!-- ── HONEYPOT fields hidden from humans, filled by bots ── -->
<div class="hp-field" aria-hidden="true"> <div class="hp-field" aria-hidden="true">
<label for="website_url">Leave this blank</label> <label for="website_url">Leave this blank</label>
<input type="text" id="website_url" name="website_url" value="" <input type="text" id="website_url" name="website_url" value="" tabindex="-1" autocomplete="off">
tabindex="-1" autocomplete="off">
</div>
<div class="hp-field" aria-hidden="true">
<label for="confirm_email">Do not fill</label> <label for="confirm_email">Do not fill</label>
<input type="email" id="confirm_email" name="confirm_email" value="" <input type="email" id="confirm_email" name="confirm_email" value="" tabindex="-1" autocomplete="off">
tabindex="-1" autocomplete="off">
</div>
<!-- ── END HONEYPOT ─────────────────────────────────────────── -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="fg" style="margin-bottom:0">
<label class="flabel" for="username">USERNAME *</label>
<input type="text" id="username" name="username" class="finput"
value="<?=e($_POST['username']??'')?>"
autocomplete="username" required autofocus
pattern="[a-zA-Z0-9_]+" minlength="3">
<div class="fhint">Letters, numbers, underscores only. Min 3 characters.</div>
</div>
<div class="fg" style="margin-bottom:0">
<label class="flabel" for="email">EMAIL ADDRESS <span style="font-weight:300;letter-spacing:0">(optional)</span></label>
<input type="email" id="email" name="email" class="finput"
value="<?=e($_POST['email']??'')?>"
autocomplete="email">
<div class="fhint">Used only for account recovery. Never shared.</div>
</div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:1rem"> <div class="tier-grid">
<div class="fg" style="margin-bottom:0"> <label class="tier-card">
<label class="flabel" for="password">PASSWORD *</label> <input type="radio" name="member_type" value="member"<?=!$ownerSelected?' checked':''?>>
<input type="password" id="password" name="password" class="finput" <span class="tier-kicker">Regular Member</span>
autocomplete="new-password" required minlength="6"> <span class="tier-title">Explore and contribute</span>
<div class="fhint">Minimum 6 characters.</div> <span class="tier-copy">Rate businesses, submit events, report outdated listings, and keep up with Keyser.</span>
</div> </label>
<div class="fg" style="margin-bottom:0"> <label class="tier-card tier-card-owner">
<label class="flabel" for="password2">CONFIRM PASSWORD *</label> <input type="radio" name="member_type" value="business_owner"<?=$ownerSelected?' checked':''?>>
<input type="password" id="password2" name="password2" class="finput" <span class="tier-kicker">Business Owner</span>
autocomplete="new-password" required minlength="6"> <span class="tier-title">Claim a listed page</span>
</div> <span class="tier-copy">Request ownership, send initial corrections, and add an optional YouTube or Vimeo video.</span>
</div>
<!-- Terms / lawful use checkbox -->
<div style="margin:1.5rem 0 1.25rem">
<label class="tos-check">
<input type="checkbox" name="lawful_use" value="1"
<?=(!empty($_POST) && p('lawful_use')==='1') ? 'checked' : ''?> required>
<span class="tos-check-text">
<strong>I will only use this service lawfully.</strong>
I agree not to post false, misleading, defamatory, or harmful content.
I understand that accounts used for spam, abuse, or fraudulent business claims
will be removed without notice.
</span>
</label> </label>
</div> </div>
<button type="submit" class="btn btn-primary btn-block" style="font-size:.9rem;padding:.85rem"> <div class="signup-grid">
Create Account <div class="fg">
</button> <label class="flabel" for="username">USERNAME *</label>
<input type="text" id="username" name="username" class="finput"
<p style="text-align:center;margin-top:1.1rem;font-size:.84rem;color:var(--text-d)"> value="<?=e($_POST['username']??'')?>"
Already have an account? <a href="/login.php">Sign in here</a> autocomplete="username" required autofocus pattern="[a-zA-Z0-9_]+" minlength="3">
</p> <div class="fhint">Letters, numbers, underscores only. Min 3 characters.</div>
</form> </div>
<div class="fg">
<label class="flabel" for="email">EMAIL ADDRESS *</label>
<input type="email" id="email" name="email" class="finput"
value="<?=e($_POST['email']??'')?>"
autocomplete="email" required>
<div class="fhint">Used for email verification and account recovery.</div>
</div>
<div class="fg">
<label class="flabel" for="password">PASSWORD *</label>
<input type="password" id="password" name="password" class="finput" autocomplete="new-password" required minlength="6">
</div>
<div class="fg">
<label class="flabel" for="password2">CONFIRM PASSWORD *</label>
<input type="password" id="password2" name="password2" class="finput" autocomplete="new-password" required minlength="6">
</div>
</div> </div>
<div id="ownerClaimFields" class="owner-claim-fields<?=$ownerSelected?' is-open':''?>">
<div class="owner-claim-note">
<strong>Verification required.</strong> Select the business page you manage and provide a contact phone number. A site administrator will contact you within a few business days before the page is assigned to your account.
</div>
<div class="signup-grid">
<div class="fg">
<label class="flabel" for="claim_business_id">BUSINESS TO CLAIM *</label>
<select id="claim_business_id" name="claim_business_id" class="fselect" data-owner-required>
<option value="">Select a listed business</option>
<?php foreach ($businessOptions as $biz): ?>
<option value="<?=$biz['id']?>"<?=((int)($_POST['claim_business_id']??0)===(int)$biz['id'])?' selected':''?>>
<?=e($biz['name'])?><?= $biz['address'] ? ' — '.e($biz['address']) : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="fg">
<label class="flabel" for="contact_phone">CONTACT PHONE FOR VERIFICATION *</label>
<input type="tel" id="contact_phone" name="contact_phone" class="finput"
value="<?=e($_POST['contact_phone']??'')?>" placeholder="304-555-0123" data-owner-required>
</div>
</div>
<div class="claim-edit-panel">
<div class="ibox-title">OPTIONAL FIRST EDITS</div>
<div class="signup-grid">
<div class="fg signup-wide">
<label class="flabel" for="claim_description">DESCRIPTION UPDATE</label>
<textarea id="claim_description" name="claim_description" class="ftextarea" placeholder="Share a corrected or refreshed business description."><?=e($_POST['claim_description']??'')?></textarea>
</div>
<div class="fg">
<label class="flabel" for="claim_address">PUBLIC ADDRESS</label>
<input type="text" id="claim_address" name="claim_address" class="finput" value="<?=e($_POST['claim_address']??'')?>">
</div>
<div class="fg">
<label class="flabel" for="claim_public_phone">PUBLIC PHONE</label>
<input type="text" id="claim_public_phone" name="claim_public_phone" class="finput" value="<?=e($_POST['claim_public_phone']??'')?>">
</div>
<div class="fg">
<label class="flabel" for="claim_public_email">PUBLIC EMAIL</label>
<input type="email" id="claim_public_email" name="claim_public_email" class="finput" value="<?=e($_POST['claim_public_email']??'')?>">
</div>
<div class="fg">
<label class="flabel" for="claim_website">WEBSITE</label>
<input type="text" id="claim_website" name="claim_website" class="finput" value="<?=e($_POST['claim_website']??'')?>" placeholder="example.com">
</div>
<div class="fg signup-wide">
<label class="flabel" for="claim_video_url">VIDEO URL</label>
<input type="url" id="claim_video_url" name="claim_video_url" class="finput" value="<?=e($_POST['claim_video_url']??'')?>" placeholder="YouTube or Vimeo URL">
<div class="fhint">Optional. Business owners may use YouTube or Vimeo.</div>
</div>
</div>
</div>
</div>
<label class="tos-check">
<input type="checkbox" name="lawful_use" value="1" <?=(!empty($_POST) && p('lawful_use')==='1') ? 'checked' : ''?> required>
<span class="tos-check-text">
<strong>I will only use this service lawfully.</strong>
I agree not to post false, misleading, defamatory, or harmful content, and I understand fraudulent business claims may be removed.
</span>
</label>
<button type="submit" class="btn btn-primary btn-block signup-submit">Create Account</button>
<p class="signin-link">Already have an account? <a href="/login.php">Sign in here</a></p>
</form>
</div> </div>
<?php include __DIR__.'/includes/footer.php'; ?> <?php include __DIR__.'/includes/footer.php'; ?>
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env php
<?php
require_once dirname(__DIR__).'/includes/boot.php';
require_once dirname(__DIR__).'/includes/facebook_event_importer.php';
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This script must be run from the command line.\n");
exit(1);
}
const FB_GRAPH_DEFAULT_VERSION = 'v23.0';
const FB_GRAPH_EVENT_FIELDS = 'id,name,description,start_time,end_time,place{id,name,location},cover{source},ticket_uri';
const FB_GRAPH_PLACE_FIELDS = 'id,name,location';
function fbCliUsage(): void {
echo "Facebook Graph API event importer for My Keyser\n\n";
echo "Usage:\n";
echo " FACEBOOK_GRAPH_ACCESS_TOKEN=... php scripts/facebook-events-import.php --area=\"Keyser, WV\" --page-id=123456789\n";
echo " php scripts/facebook-events-import.php --access-token=... --event-id=123456789012345\n";
echo " php scripts/facebook-events-import.php --access-token=... --pages-file=/path/to/page-ids.txt\n";
echo " php scripts/facebook-events-import.php --access-token=... --area=\"Keyser, WV\" --center=39.4364,-78.9762 --distance=25000 --query=\"Keyser\" --discover-pages\n\n";
echo "Required:\n";
echo " --access-token=TOKEN Graph API access token, or set FACEBOOK_GRAPH_ACCESS_TOKEN.\n\n";
echo "Sources:\n";
echo " --page-id=ID[,ID] Facebook Page IDs to read through /{page-id}/events.\n";
echo " --pages-file=FILE Plain-text Page ID file, one ID per line.\n";
echo " --event-id=ID[,ID] Specific Facebook Event IDs to read through /{event-id}.\n";
echo " --events-file=FILE Plain-text Event ID file, one ID per line.\n";
echo " --discover-pages Use Graph place search for --center/--distance/--query, then read events for matching Page/place IDs.\n\n";
echo "Options:\n";
echo " --area=TEXT Label shown in the admin import review queue.\n";
echo " --center=LAT,LNG Center point for Graph place search.\n";
echo " --distance=METERS Radius for Graph place search. Default: 25000.\n";
echo " --query=TEXT Place search query. Defaults to --area.\n";
echo " --place-limit=N Maximum places to discover. Default: 50.\n";
echo " --limit=N Maximum events to stage total. Default: 100.\n";
echo " --since=YYYY-MM-DD Start date for Page event reads. Default: today.\n";
echo " --until=YYYY-MM-DD End date for Page event reads. Default: +1 year.\n";
echo " --graph-version=vNN.0 Graph API version. Default: ".FB_GRAPH_DEFAULT_VERSION." or FACEBOOK_GRAPH_VERSION.\n";
echo " --no-images Stage Graph cover URLs but skip downloading cover photos.\n";
echo " --dry-run Fetch and print candidates without writing to the database.\n";
echo " --help Show this help.\n\n";
echo "Notes:\n";
echo " This script uses only official Graph API endpoints. It does not scrape HTML, log in,\n";
echo " solve challenges, or bypass Facebook access controls. Your Meta app/token must have\n";
echo " permission to read the requested Page or Event data.\n";
}
function fbCliOptions(array $argv): array {
$options = [];
for ($i = 1; $i < count($argv); $i++) {
$arg = $argv[$i];
if (!str_starts_with($arg, '--')) continue;
$arg = substr($arg, 2);
if (str_contains($arg, '=')) {
[$key, $value] = explode('=', $arg, 2);
} else {
$key = $arg;
$value = true;
if ($i + 1 < count($argv) && !str_starts_with($argv[$i + 1], '--')) $value = $argv[++$i];
}
if (isset($options[$key])) {
if (!is_array($options[$key])) $options[$key] = [$options[$key]];
$options[$key][] = $value;
} else {
$options[$key] = $value;
}
}
return $options;
}
function fbCliValues(array $options, string $key): array {
if (!isset($options[$key])) return [];
$value = $options[$key];
if (!is_array($value)) $value = [$value];
$out = [];
foreach ($value as $item) {
foreach (explode(',', (string)$item) as $part) {
$part = trim($part);
if ($part !== '') $out[] = $part;
}
}
return $out;
}
function fbCliFileValues(array $options, string $key): array {
$out = [];
foreach (fbCliValues($options, $key) as $file) {
if (!is_file($file) || !is_readable($file)) {
throw new RuntimeException("File is not readable: ".$file);
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim(preg_replace('/#.*/', '', $line));
if ($line !== '') $out[] = $line;
}
}
return $out;
}
function fbCliLine(string $message = ''): void {
echo $message."\n";
}
function fbCliGraphVersion(array $options): string {
$version = trim((string)($options['graph-version'] ?? getenv('FACEBOOK_GRAPH_VERSION') ?: FB_GRAPH_DEFAULT_VERSION));
if ($version === '') $version = FB_GRAPH_DEFAULT_VERSION;
return str_starts_with($version, 'v') ? $version : 'v'.$version;
}
function fbCliGraphBase(string $version): string {
return 'https://graph.facebook.com/'.rawurlencode($version).'/';
}
function fbCliGraphRequest(string $path, array $params, string $token, string $version): array {
$path = ltrim($path, '/');
$url = fbCliGraphBase($version).$path;
if ($params) $url .= '?'.http_build_query($params);
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 4,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_USERAGENT => facebookEventImporterUserAgent(),
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Authorization: Bearer '.$token,
],
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if (PHP_VERSION_ID < 80000) curl_close($ch);
if ($errno) throw new RuntimeException($error ?: 'Graph API request failed.');
} else {
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 30,
'ignore_errors' => true,
'header' => "Accept: application/json\r\nAuthorization: Bearer ".$token."\r\nUser-Agent: ".facebookEventImporterUserAgent()."\r\n",
],
]);
$body = @file_get_contents($url, false, $context);
$statusLine = $http_response_header[0] ?? '';
$status = preg_match('/\s(\d{3})\s/', $statusLine, $match) ? (int)$match[1] : 0;
if ($body === false) throw new RuntimeException($statusLine ?: 'Graph API request failed.');
}
$json = json_decode((string)$body, true);
if (!is_array($json)) throw new RuntimeException('Graph API returned invalid JSON.');
if ($status < 200 || $status >= 300 || isset($json['error'])) {
$err = $json['error'] ?? [];
$message = is_array($err) ? ($err['message'] ?? 'Graph API error') : 'Graph API error';
$code = is_array($err) && isset($err['code']) ? ' (code '.$err['code'].')' : '';
throw new RuntimeException($message.$code);
}
return $json;
}
function fbCliDateToTimestamp(string $date, string $fallback): int {
$date = trim($date);
$ts = strtotime($date !== '' ? $date : $fallback);
if ($ts === false) throw new RuntimeException("Invalid date: ".$date);
return $ts;
}
function fbCliDiscoverPages(array $options, string $token, string $version): array {
if (empty($options['discover-pages'])) return [];
$center = trim((string)($options['center'] ?? ''));
if ($center === '' || !preg_match('/^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/', $center)) {
throw new RuntimeException('--discover-pages requires --center=LAT,LNG.');
}
$area = trim((string)($options['area'] ?? ''));
$query = trim((string)($options['query'] ?? $area));
if ($query === '') throw new RuntimeException('--discover-pages requires --query or --area.');
$limit = max(1, min(100, (int)($options['place-limit'] ?? 50)));
$distance = max(1, (int)($options['distance'] ?? 25000));
$json = fbCliGraphRequest('search', [
'type' => 'place',
'q' => $query,
'center' => $center,
'distance' => $distance,
'fields' => FB_GRAPH_PLACE_FIELDS,
'limit' => $limit,
], $token, $version);
$ids = [];
foreach (($json['data'] ?? []) as $place) {
if (!empty($place['id'])) $ids[(string)$place['id']] = (string)$place['id'];
}
fbCliLine('Discovered '.count($ids).' Page/place ID'.(count($ids) === 1 ? '' : 's').' from Graph place search.');
return array_values($ids);
}
function fbCliFetchPageEvents(string $pageId, string $token, string $version, int $since, int $until, int $remaining): array {
$events = [];
$after = '';
while ($remaining > count($events)) {
$params = [
'fields' => FB_GRAPH_EVENT_FIELDS,
'since' => $since,
'until' => $until,
'limit' => min(100, $remaining - count($events)),
];
if ($after !== '') $params['after'] = $after;
$json = fbCliGraphRequest(rawurlencode($pageId).'/events', $params, $token, $version);
foreach (($json['data'] ?? []) as $event) {
if (is_array($event)) $events[] = $event + ['_source_page_id' => $pageId];
}
$after = (string)($json['paging']['cursors']['after'] ?? '');
if ($after === '' || empty($json['data'])) break;
}
return $events;
}
function fbCliFetchEvent(string $eventId, string $token, string $version): array {
$json = fbCliGraphRequest(rawurlencode($eventId), ['fields' => FB_GRAPH_EVENT_FIELDS], $token, $version);
return $json + ['_source_event_id' => $eventId];
}
function fbCliStageGraphEvent(array $event, string $area, bool $downloadImages, bool $dryRun, array &$summary): void {
$candidate = facebookGraphEventToCandidate($event, $area);
$title = $candidate['title'] ?: '(untitled)';
if ($dryRun) {
$summary['dry_run']++;
fbCliLine('DRY RUN: '.$title.' | '.($candidate['event_date'] ?: 'no date').' | '.($candidate['source_url'] ?: 'no source URL'));
return;
}
$result = facebookEventStageCandidate($candidate, $downloadImages);
if ($result['ok']) {
$summary[$result['action']] = ($summary[$result['action']] ?? 0) + 1;
fbCliLine(strtoupper(str_replace('_', ' ', $result['action'])).': #'.$result['id'].' '.$title);
} else {
$summary['failed']++;
fbCliLine('FAILED: '.$title.' - '.$result['error']);
}
}
$options = fbCliOptions($argv);
if (isset($options['help']) || $options === []) {
fbCliUsage();
exit(0);
}
$token = trim((string)($options['access-token'] ?? getenv('FACEBOOK_GRAPH_ACCESS_TOKEN') ?: ''));
if ($token === '') {
fwrite(STDERR, "Missing Graph API access token. Use --access-token or FACEBOOK_GRAPH_ACCESS_TOKEN.\n\n");
fbCliUsage();
exit(2);
}
try {
$version = fbCliGraphVersion($options);
$area = trim((string)($options['area'] ?? ''));
$limit = max(1, (int)($options['limit'] ?? 100));
$downloadImages = !isset($options['no-images']);
$dryRun = isset($options['dry-run']);
$summary = ['created' => 0, 'updated' => 0, 'already_imported' => 0, 'dry_run' => 0, 'failed' => 0, 'graph_failed' => 0];
$pageIds = array_values(array_unique(array_merge(
fbCliValues($options, 'page-id'),
fbCliFileValues($options, 'pages-file'),
fbCliDiscoverPages($options, $token, $version)
)));
$eventIds = array_values(array_unique(array_merge(
fbCliValues($options, 'event-id'),
fbCliFileValues($options, 'events-file')
)));
if (!$pageIds && !$eventIds) {
throw new RuntimeException('Provide at least one --page-id, --pages-file, --event-id, --events-file, or --discover-pages source.');
}
$since = fbCliDateToTimestamp((string)($options['since'] ?? ''), 'today');
$until = fbCliDateToTimestamp((string)($options['until'] ?? ''), '+1 year');
$staged = 0;
foreach ($eventIds as $eventId) {
if ($staged >= $limit) break;
fbCliLine('Fetching Graph event: '.$eventId);
try {
$event = fbCliFetchEvent($eventId, $token, $version);
fbCliStageGraphEvent($event, $area, $downloadImages, $dryRun, $summary);
$staged++;
} catch (Throwable $e) {
$summary['graph_failed']++;
fbCliLine('GRAPH FAILED: event '.$eventId.' - '.$e->getMessage());
}
}
foreach ($pageIds as $pageId) {
if ($staged >= $limit) break;
$remaining = $limit - $staged;
fbCliLine('Fetching Graph Page events: '.$pageId);
try {
$events = fbCliFetchPageEvents($pageId, $token, $version, $since, $until, $remaining);
} catch (Throwable $e) {
$summary['graph_failed']++;
fbCliLine('GRAPH FAILED: page '.$pageId.' - '.$e->getMessage());
continue;
}
foreach ($events as $event) {
if ($staged >= $limit) break;
fbCliStageGraphEvent($event, $area, $downloadImages, $dryRun, $summary);
$staged++;
}
}
fbCliLine();
fbCliLine('Summary:');
fbCliLine(' Created: '.$summary['created']);
fbCliLine(' Updated: '.$summary['updated']);
fbCliLine(' Already imported: '.$summary['already_imported']);
if ($dryRun) fbCliLine(' Dry-run candidates: '.$summary['dry_run']);
fbCliLine(' Stage failures: '.$summary['failed']);
fbCliLine(' Graph failures: '.$summary['graph_failed']);
fbCliLine();
fbCliLine('Review staged imports at: /admin/event_imports.php');
} catch (Throwable $e) {
fwrite(STDERR, "Error: ".$e->getMessage()."\n");
exit(1);
}
+366
View File
@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This script must be run from the command line.\n");
exit(1);
}
require_once dirname(__DIR__).'/includes/boot.php';
function out(string $message = ''): void {
fwrite(STDOUT, $message.PHP_EOL);
}
function fail(string $message, int $code = 1): never {
fwrite(STDERR, "Error: $message".PHP_EOL);
exit($code);
}
function usage(): never {
out('Keyser Admin CLI');
out('');
out('Usage:');
out(' php scripts/keyser-admin.php <command> [options]');
out('');
out('Business commands:');
out(' business:list [--active=1|0|all] [--q=text] [--limit=50]');
out(' business:show --id=ID|--slug=SLUG');
out(' business:activate --id=ID|--slug=SLUG');
out(' business:deactivate --id=ID|--slug=SLUG');
out(' business:update --id=ID|--slug=SLUG [field options]');
out(' business:set-video --id=ID|--slug=SLUG --video-url=URL');
out(' business:clear-video --id=ID|--slug=SLUG');
out(' business:delete --id=ID|--slug=SLUG --yes');
out('');
out('Business update field options:');
out(' --name=TEXT --slug=TEXT --category-id=ID --subcategory=TEXT');
out(' --description=TEXT --address=TEXT --phone=TEXT --email=TEXT');
out(' --website=TEXT --video-url=URL --hours-json=JSON');
out(' --lat=NUM --lng=NUM --active=1|0 --featured=1|0');
out('');
out('Menu commands:');
out(' menu:wipe-all --yes');
out(' menu:wipe-business --id=ID|--slug=SLUG --yes');
out(' menu:show --id=ID|--slug=SLUG');
out('');
out('Other commands:');
out(' categories:list');
out('');
out('Examples:');
out(' php scripts/keyser-admin.php business:deactivate --slug=royal-restaurant');
out(' php scripts/keyser-admin.php business:update --slug=queens-point-coffee --phone="304-555-0100" --featured=1');
out(' php scripts/keyser-admin.php business:set-video --slug=queens-point-coffee --video-url="https://youtu.be/example"');
out(' php scripts/keyser-admin.php business:clear-video --slug=queens-point-coffee');
out(' php scripts/keyser-admin.php menu:wipe-business --slug=castiglia-italian --yes');
exit(0);
}
function parseArgs(array $argv): array {
$command = $argv[1] ?? 'help';
$options = [];
for ($i = 2; $i < count($argv); $i++) {
$arg = $argv[$i];
if (!str_starts_with($arg, '--')) {
fail("Unexpected argument: $arg");
}
$arg = substr($arg, 2);
if ($arg === '') {
fail('Empty option name.');
}
if (str_contains($arg, '=')) {
[$key, $value] = explode('=', $arg, 2);
} else {
$key = $arg;
$value = '1';
}
$options[str_replace('-', '_', $key)] = $value;
}
return [$command, $options];
}
function opt(array $options, string $key, mixed $default = null): mixed {
return $options[$key] ?? $default;
}
function requireYes(array $options): void {
if (opt($options, 'yes') !== '1') {
fail('This is destructive. Re-run with --yes if you are sure.');
}
}
function boolish(mixed $value, string $label): int {
$value = strtolower(trim((string)$value));
return match ($value) {
'1', 'yes', 'true', 'on', 'active', 'enabled' => 1,
'0', 'no', 'false', 'off', 'inactive', 'disabled' => 0,
default => fail("$label must be 1 or 0."),
};
}
function clipText(string $value, int $width): string {
if (function_exists('mb_strimwidth')) {
return mb_strimwidth($value, 0, $width, '');
}
return strlen($value) > $width ? substr($value, 0, $width) : $value;
}
function businessFromOptions(array $options): array {
$id = (int)opt($options, 'id', 0);
$slug = trim((string)opt($options, 'slug', ''));
if ($id > 0 && $slug !== '') {
fail('Use either --id or --slug, not both.');
}
if ($id <= 0 && $slug === '') {
fail('Provide --id=ID or --slug=SLUG.');
}
$business = $id > 0
? qone("SELECT * FROM businesses WHERE id=?", [$id])
: qone("SELECT * FROM businesses WHERE slug=?", [$slug]);
if (!$business) {
fail('Business not found.');
}
return $business;
}
function printBusiness(array $business): void {
$category = qone("SELECT name, icon FROM categories WHERE id=?", [(int)$business['category_id']]);
out('ID: '.$business['id']);
out('Name: '.$business['name']);
out('Slug: '.$business['slug']);
out('Category: '.(($category['icon'] ?? '').' '.($category['name'] ?? 'Uncategorized')));
out('Subcategory: '.($business['subcategory'] ?: '-'));
out('Address: '.($business['address'] ?: '-'));
out('Phone: '.($business['phone'] ?: '-'));
out('Email: '.($business['email'] ?: '-'));
out('Website: '.($business['website'] ?: '-'));
out('Video URL: '.(($business['video_url'] ?? '') ?: '-'));
out('Active: '.((int)$business['is_active'] === 1 ? 'yes' : 'no'));
out('Featured: '.((int)$business['is_featured'] === 1 ? 'yes' : 'no'));
out('Lat/Lng: '.$business['lat'].', '.$business['lng']);
out('Hours JSON: '.$business['hours']);
out('Description: '.$business['description']);
}
function validateEmail(string $email): void {
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
fail('Invalid email address.');
}
}
function validateHoursJson(string $json): void {
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
fail('--hours-json must be a JSON object.');
}
foreach (array_keys($decoded) as $key) {
if (!is_string($key)) {
fail('--hours-json must be a JSON object with day names as keys.');
}
}
}
function setBusinessStatus(array $options, int $active): void {
$business = businessFromOptions($options);
qrun("UPDATE businesses SET is_active=? WHERE id=?", [$active, (int)$business['id']]);
out(($active ? 'Activated' : 'Deactivated').' business: '.$business['name'].' (#'.$business['id'].')');
}
function wipeBusinessMenu(int $businessId): array {
$sectionIds = array_column(qall("SELECT id FROM menu_sections WHERE business_id=?", [$businessId]), 'id');
$itemCount = 0;
if ($sectionIds) {
$placeholders = implode(',', array_fill(0, count($sectionIds), '?'));
$itemCount = (int)qval("SELECT COUNT(*) FROM menu_items WHERE section_id IN ($placeholders)", $sectionIds);
}
$sectionCount = count($sectionIds);
qrun("DELETE FROM menu_sections WHERE business_id=?", [$businessId]);
return [$sectionCount, $itemCount];
}
[$command, $options] = parseArgs($argv);
if (in_array($command, ['help', '--help', '-h'], true)) {
usage();
}
try {
match ($command) {
'business:list' => (function () use ($options): void {
$where = [];
$params = [];
$active = strtolower((string)opt($options, 'active', 'all'));
if ($active !== 'all') {
$where[] = 'b.is_active=?';
$params[] = boolish($active, '--active');
}
$q = trim((string)opt($options, 'q', ''));
if ($q !== '') {
$where[] = '(b.name LIKE ? OR b.slug LIKE ? OR b.address LIKE ? OR b.subcategory LIKE ?)';
$like = "%$q%";
array_push($params, $like, $like, $like, $like);
}
$limit = max(1, min(500, (int)opt($options, 'limit', 50)));
$sqlWhere = $where ? 'WHERE '.implode(' AND ', $where) : '';
$rows = qall("
SELECT b.id,b.name,b.slug,b.is_active,b.is_featured,COALESCE(c.name,'Uncategorized') cat
FROM businesses b
LEFT JOIN categories c ON c.id=b.category_id
$sqlWhere
ORDER BY b.name
LIMIT $limit
", $params);
foreach ($rows as $row) {
out(sprintf(
'#%-4d %-38s %-26s active=%d featured=%d slug=%s',
$row['id'],
clipText((string)$row['name'], 38),
clipText((string)$row['cat'], 26),
$row['is_active'],
$row['is_featured'],
$row['slug']
));
}
out(count($rows).' business'.(count($rows) === 1 ? '' : 'es').' shown.');
})(),
'business:show' => printBusiness(businessFromOptions($options)),
'business:activate' => setBusinessStatus($options, 1),
'business:deactivate' => setBusinessStatus($options, 0),
'business:set-video' => (function () use ($options): void {
$business = businessFromOptions($options);
$url = trim((string)opt($options, 'video_url', ''));
if ($url === '') {
fail('Provide --video-url=URL. Use --video-url="" with business:update to clear it.');
}
if (!validBusinessVideoUrl($url)) {
fail('Video URL must be a YouTube or Vimeo URL.');
}
qrun("UPDATE businesses SET video_url=? WHERE id=?", [$url, (int)$business['id']]);
out('Updated video URL for '.$business['name'].'.');
})(),
'business:clear-video' => (function () use ($options): void {
$business = businessFromOptions($options);
qrun("UPDATE businesses SET video_url='' WHERE id=?", [(int)$business['id']]);
out('Cleared video URL for '.$business['name'].'.');
})(),
'business:delete' => (function () use ($options): void {
requireYes($options);
$business = businessFromOptions($options);
qrun("DELETE FROM businesses WHERE id=?", [(int)$business['id']]);
out('Deleted business and related records: '.$business['name'].' (#'.$business['id'].')');
})(),
'business:update' => (function () use ($options): void {
$business = businessFromOptions($options);
$map = [
'name' => 'name',
'slug' => 'slug',
'category_id' => 'category_id',
'subcategory' => 'subcategory',
'description' => 'description',
'address' => 'address',
'phone' => 'phone',
'email' => 'email',
'website' => 'website',
'video_url' => 'video_url',
'hours_json' => 'hours',
'lat' => 'lat',
'lng' => 'lng',
'active' => 'is_active',
'featured' => 'is_featured',
];
$set = [];
$params = [];
foreach ($map as $optionKey => $column) {
if (!array_key_exists($optionKey, $options)) continue;
$value = (string)$options[$optionKey];
if ($optionKey === 'email') validateEmail($value);
if ($optionKey === 'video_url' && !validBusinessVideoUrl($value)) fail('Video URL must be a YouTube or Vimeo URL.');
if ($optionKey === 'hours_json') validateHoursJson($value);
if ($optionKey === 'category_id') {
$value = (string)(int)$value;
if (!qval("SELECT id FROM categories WHERE id=?", [(int)$value])) fail('Category ID not found.');
}
if (in_array($optionKey, ['active', 'featured'], true)) $value = (string)boolish($value, "--$optionKey");
if (in_array($optionKey, ['lat', 'lng'], true) && !is_numeric($value)) fail("--$optionKey must be numeric.");
if ($optionKey === 'slug' && $value !== '') {
$value = makeSlug($value);
$clash = qval("SELECT id FROM businesses WHERE slug=? AND id!=?", [$value, (int)$business['id']]);
if ($clash) fail('That slug is already used by another business.');
}
$set[] = "$column=?";
$params[] = $value;
}
if (!$set) {
fail('No update fields provided. Run help to see supported field options.');
}
$params[] = (int)$business['id'];
qrun("UPDATE businesses SET ".implode(',', $set)." WHERE id=?", $params);
out('Updated business: '.$business['name'].' (#'.$business['id'].')');
})(),
'menu:show' => (function () use ($options): void {
$business = businessFromOptions($options);
$sections = qall("SELECT * FROM menu_sections WHERE business_id=? ORDER BY sort_order", [(int)$business['id']]);
out($business['name'].' menu');
if (!$sections) {
out('No menu sections found.');
return;
}
foreach ($sections as $section) {
out('');
out('['.$section['id'].'] '.$section['name'].($section['description'] ? ' - '.$section['description'] : ''));
$items = qall("SELECT * FROM menu_items WHERE section_id=? ORDER BY sort_order", [(int)$section['id']]);
foreach ($items as $item) {
out(sprintf(
' - [%d] %s%s%s',
$item['id'],
$item['name'],
(float)$item['price'] > 0 ? ' $'.number_format((float)$item['price'], 2) : '',
(int)$item['is_available'] === 1 ? '' : ' (unavailable)'
));
}
}
})(),
'menu:wipe-business' => (function () use ($options): void {
requireYes($options);
$business = businessFromOptions($options);
[$sections, $items] = wipeBusinessMenu((int)$business['id']);
out("Deleted $sections menu section".($sections === 1 ? '' : 's')." and $items item".($items === 1 ? '' : 's')." for ".$business['name'].'.');
})(),
'menu:wipe-all' => (function () use ($options): void {
requireYes($options);
$sectionCount = (int)qval("SELECT COUNT(*) FROM menu_sections");
$itemCount = (int)qval("SELECT COUNT(*) FROM menu_items");
qrun("DELETE FROM menu_sections");
out("Deleted $sectionCount menu section".($sectionCount === 1 ? '' : 's')." and $itemCount item".($itemCount === 1 ? '' : 's').' across all businesses.');
})(),
'categories:list' => (function (): void {
$rows = qall("SELECT id,name,icon,sort_order FROM categories ORDER BY sort_order,name");
foreach ($rows as $row) {
out(sprintf('#%-3d %-2s %-32s sort=%d', $row['id'], $row['icon'], $row['name'], $row['sort_order']));
}
})(),
default => fail("Unknown command: $command. Run `php scripts/keyser-admin.php help`.", 2),
};
} catch (PDOException $e) {
fail($e->getMessage());
}
+1
View File
@@ -0,0 +1 @@
+64
View File
@@ -0,0 +1,64 @@
<?php
require_once __DIR__.'/includes/boot.php';
$pageTitle = 'Verify Email — My Keyser';
$activeNav = '';
$token = gs('token');
$status = 'error';
$title = 'Verification Link Invalid';
$message = 'This verification link is invalid or has already been used.';
$detail = '';
if ($token !== '') {
$tokenHash = hash('sha256', $token);
$row = qone("
SELECT evt.*, u.username, u.email, u.member_type
FROM email_verification_tokens evt
JOIN users u ON u.id = evt.user_id
WHERE evt.token_hash=? AND evt.purpose='email_verify'
LIMIT 1
", [$tokenHash]);
if ($row && !empty($row['used_at'])) {
$title = 'Email Already Verified';
$message = 'This verification link has already been used. You can sign in if your account is active.';
} elseif ($row && strtotime((string)$row['expires_at']) < time()) {
$title = 'Verification Link Expired';
$message = 'This verification link has expired. Please register again or contact the site administrator.';
} elseif ($row) {
qrun("UPDATE users SET email_verified_at=COALESCE(email_verified_at, datetime('now')), is_active=1 WHERE id=?", [(int)$row['user_id']]);
qrun("UPDATE email_verification_tokens SET used_at=datetime('now') WHERE id=?", [(int)$row['id']]);
$user = qone("SELECT * FROM users WHERE id=?", [(int)$row['user_id']]);
[$sent, $mailError] = sendRegistrationCompleteEmail($user);
$status = 'success';
$title = 'Email Verified';
if (($user['member_type'] ?? 'member') === 'business_owner') {
$message = 'Your email is verified and your business-owner account can now sign in.';
$detail = 'We sent a follow-up email confirming that our team will call within a couple of business days for business verification.';
} else {
$message = 'Your email is verified and your account can now sign in.';
$detail = 'We sent a thank-you email confirming your registration.';
}
if (!$sent) {
$detail = 'Your account is verified, but the follow-up email could not be sent. Please contact the site administrator if you need help.';
}
}
}
include __DIR__.'/includes/header.php';
?>
<div class="section-sm">
<div class="verify-card verify-<?=$status?>">
<div class="eyebrow"><?= $status === 'success' ? 'ACCOUNT VERIFIED' : 'EMAIL VERIFICATION' ?></div>
<h1><?=e($title)?></h1>
<p><?=e($message)?></p>
<?php if($detail): ?><p class="verify-detail"><?=e($detail)?></p><?php endif; ?>
<div class="flex-row" style="justify-content:center;margin-top:1.5rem">
<a href="/login.php" class="btn btn-primary">Sign In</a>
<a href="/index.php" class="btn btn-outline">Back Home</a>
</div>
</div>
</div>
<?php include __DIR__.'/includes/footer.php'; ?>