From b57091e4ad7dff618172b9dcbaf4b69d6b11e902 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Fri, 24 Apr 2026 08:41:52 -0400 Subject: [PATCH] - initial --- 2business.php | 204 ++++++++++ 2directory.php | 72 ++++ 2index.php | 176 +++++++++ 2register.php | 41 ++ 3business.php | 386 ++++++++++++++++++ README.md | 157 ++++++++ about.php | 112 ++++++ account.php | 45 +++ admin/2admin_header.php | 37 ++ admin/2bu.php | 167 ++++++++ admin/2index.php | 109 ++++++ admin/admin_footer.php | 3 + admin/admin_header.php | 44 +++ admin/alerts.php | 59 +++ admin/attractions.php | 71 ++++ admin/biz_debug.php | 117 ++++++ admin/biz_edit.php | 254 ++++++++++++ admin/biz_edits.php | 336 ++++++++++++++++ admin/businesses.php | 257 ++++++++++++ admin/categories.php | 69 ++++ admin/events.php | 111 ++++++ admin/index.php | 159 ++++++++ admin/menus.php | 179 +++++++++ admin/owners.php | 75 ++++ admin/reports.php | 60 +++ admin/reviews.php | 49 +++ admin/settings.php | 123 ++++++ admin/users.php | 109 ++++++ assets/css/style.css | 336 ++++++++++++++++ assets/js/app.js | 31 ++ attractions.php | 45 +++ business.php | 568 +++++++++++++++++++++++++++ dashboard.php | 70 ++++ data/.htaccess | 3 + directory.php | 148 +++++++ events.php | 114 ++++++ includes/2db.php | 842 ++++++++++++++++++++++++++++++++++++++++ includes/2functions.php | 107 +++++ includes/3functions.php | 107 +++++ includes/boot.php | 5 + includes/db.php | 510 ++++++++++++++++++++++++ includes/footer.php | 49 +++ includes/functions.php | 137 +++++++ includes/header.php | 62 +++ index.php | 174 +++++++++ login.php | 35 ++ logout.php | 5 + menu.php | 149 +++++++ register.php | 293 ++++++++++++++ 49 files changed, 7371 insertions(+) create mode 100644 2business.php create mode 100644 2directory.php create mode 100644 2index.php create mode 100644 2register.php create mode 100644 3business.php create mode 100644 README.md create mode 100644 about.php create mode 100644 account.php create mode 100644 admin/2admin_header.php create mode 100644 admin/2bu.php create mode 100644 admin/2index.php create mode 100644 admin/admin_footer.php create mode 100644 admin/admin_header.php create mode 100644 admin/alerts.php create mode 100644 admin/attractions.php create mode 100644 admin/biz_debug.php create mode 100644 admin/biz_edit.php create mode 100644 admin/biz_edits.php create mode 100644 admin/businesses.php create mode 100644 admin/categories.php create mode 100644 admin/events.php create mode 100644 admin/index.php create mode 100644 admin/menus.php create mode 100644 admin/owners.php create mode 100644 admin/reports.php create mode 100644 admin/reviews.php create mode 100644 admin/settings.php create mode 100644 admin/users.php create mode 100644 assets/css/style.css create mode 100644 assets/js/app.js create mode 100644 attractions.php create mode 100644 business.php create mode 100644 dashboard.php create mode 100644 data/.htaccess create mode 100644 directory.php create mode 100644 events.php create mode 100644 includes/2db.php create mode 100644 includes/2functions.php create mode 100644 includes/3functions.php create mode 100644 includes/boot.php create mode 100644 includes/db.php create mode 100644 includes/footer.php create mode 100644 includes/functions.php create mode 100644 includes/header.php create mode 100644 index.php create mode 100644 login.php create mode 100644 logout.php create mode 100644 menu.php create mode 100644 register.php diff --git a/2business.php b/2business.php new file mode 100644 index 0000000..3770daf --- /dev/null +++ b/2business.php @@ -0,0 +1,204 @@ + +
+
+ +
+

+
+ ( review) + ★ FEATURED +
+
+
+
+
+ +
+
📢 BUSINESS UPDATES
+ +
+
+
+ +
+ +
+ +
+ + + +
+
📢 POST BUSINESS ALERT
+
+
+ + +
+ + +
+
+ + +
+
ABOUT
+

+
+ + +
+
🍽️ MENU
+ + + + ✏️ Manage Menu +
+ +
+ Add Menu
+ + +

⭐ REVIEWS ()

+ + +
+
+
+
+
+
+ +
+
+ +
+ Login to Write a Review +
+ + + +
+
+
+
+ +
+
+
+
+
+
+ +

No reviews yet — be the first!

+ +
+ +
+ 🚩 REPORT A LISTING ISSUE +
+
+
+
+ +
+
+
+

🚩 Log in to report a listing issue.

+
+ +
+
+
BUSINESS INFO
+
📍
+
📞
+
✉️
+
🌐
+
🏷️
+
+ + +
+
🕐 HOURS OF OPERATION
+
+ $time): ?> +
+ +
+
+ + + +
+
📍 LOCATION
+
+

+ Open in Google Maps ↗ +
+
+ + + +
+
⚙️ OWNER CONTROLS
+ 🍽️ Manage Menu + ✏️ Edit Listing (Admin) +
+ +
+
+ diff --git a/2directory.php b/2directory.php new file mode 100644 index 0000000..20b741a --- /dev/null +++ b/2directory.php @@ -0,0 +1,72 @@ + +
+
+
EXPLORE KEYSER
+

Business Directory

+

Discover local businesses, restaurants, services, and more in Keyser, West Virginia.

+
+
+ +
+ All + +
+
+ + + 1): ?>
+ +
🔍

No results found

Try different search terms or browse all categories.

Browse All
+ +
+ diff --git a/2index.php b/2index.php new file mode 100644 index 0000000..a110c43 --- /dev/null +++ b/2index.php @@ -0,0 +1,176 @@ +=date('now') ORDER BY is_featured DESC,event_date ASC LIMIT 3"); +include __DIR__.'/includes/header.php'; +?> +
+
+
+
+ + + + + + + + + + +
+
+
⛰ MINERAL COUNTY · EASTERN PANHANDLE · WEST VIRGINIA
+

DISCOVERKEYSER

+
NORTH BRANCH POTOMAC RIVER · FOUNDED 1874
+
+

Where Appalachian heritage meets mountain beauty. County seat of Mineral County — 3 hours from Washington D.C., a world away from ordinary.

+ +
+
+ +
+
+ +
+ +
+
+ + +
+
+
📢 LOCAL BUSINESS NEWS & UPDATES
+ +
+
+
+
+
+
From ·
+
+
+ +
+
+ + +
+
+
⭐ COMMUNITY FAVORITES
+

Featured Businesses

+
+
+ +
+ Browse Full Directory ( Businesses) +
+
+ +
+
+
+
OUR HISTORY
+

A City with Deep Mountain Roots

+
+

Originally called Paddy Town after Irish settler Patrick McCarty, then New Creek, Keyser took its name in 1874 — honoring William Keyser, Vice President of the Baltimore & Ohio Railroad whose line transformed the region in 1842.

+

Today Keyser is home to Potomac State College of WVU (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.

+

Mineral County is part of the Appalachian Forest National Heritage Area — over 500 square miles of forested beauty where American history lives on in every ridge and hollow.

+
+ Learn More About Keyser +
+
+ +
+
+
+
+
+ +
+
+
+ + +
+
+
📅 WHAT'S HAPPENING
+

Upcoming Events

+
+
+ +
+
+
+
+
+
+
+
★ FEATURED
+
+ 📍 + 🕐 + 💰 +
+
180?'…':'')?>
+
+
+ +
+ All Events + Submit an Event +
+
+ + +
+
JOIN THE COMMUNITY
+

Own a Business in Keyser?

+

Create an account to manage your listing, post alerts, add menus, and connect with the Keyser community.

+ +
+ diff --git a/2register.php b/2register.php new file mode 100644 index 0000000..df44a4a --- /dev/null +++ b/2register.php @@ -0,0 +1,41 @@ + +
+

Create Account

+

Join the Discover Keyser WV community

+
',array_map('e',$errors))?>
+
+ +
Letters, numbers, underscores. Minimum 3 characters.
+
+
Minimum 6 characters.
+
+ +
+

Already have an account? Sign in

+
+ diff --git a/3business.php b/3business.php new file mode 100644 index 0000000..ad907c7 --- /dev/null +++ b/3business.php @@ -0,0 +1,386 @@ + +
+
+ +
+

+
+ + + ( review) + ★ FEATURED +
+
+
+ +
+
+ + +
+
📢 BUSINESS UPDATES
+ +
+
+
+
+
+
+
+ +
+ + + + +
+ +
+ +
+ + + +
+
📢 POST BUSINESS ALERT
+
+ + +
+ + +
+ + +
+
+ + +
+
ABOUT
+

+
+ + +
+
🍽️ MENU
+ + + + + ✏️ Manage Menu + +
+ +
+ + Add Menu +
+ + +

+ ⭐ REVIEWS () +

+ + +
+
+
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ Login to Write a Review +
+ + + +
+
+
+
+ +
+
+ + +
+ + + + +
+ +
+
+
+
+
+ + + +

No reviews yet — be the first!

+ + +
+ + +
+ + 🚩 REPORT A LISTING ISSUE + +
+
+ + +
+ + +
+
+ + +
+ +
+
+
+ +

+ 🚩 Log in to report a listing issue. +

+ + +
+ +
+ +
+
BUSINESS INFO
+ +
📍
+ + +
📞 + +
+ + +
✉️ + +
+ + +
🌐 + +
+ +
+ 🏷️ + +
+
+ + +
+
🕐 HOURS OF OPERATION
+
+ $time): ?> +
+ + +
+ +
+
+ + + +
+
📍 LOCATION
+
+

+ Open in Google Maps ↗ +
+
+ + + +
+
⚙️ OWNER CONTROLS
+ 🍽️ Manage Menu + + + ✏️ Edit Listing + + +
+ + +
+
+ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e3bdb7 --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# Discover Keyser WV — Tourism Website +### PHP/SQLite City Tourism Site for Keyser, West Virginia + +--- + +## QUICK START + +1. **Extract** this zip to your web server's document root (or any folder) +2. **Make the `data/` directory writable:** + ``` + chmod 777 data/ + ``` +3. **Visit the site** — the database is created automatically on first load +4. **Admin login:** + - URL: `/admin/` + - Username: `administrator` + - Password: `password123` + ⚠️ **Change the admin password immediately** via Admin → Settings + +--- + +## REQUIREMENTS + +- **PHP** 8.0 or higher +- **PDO SQLite** extension (enabled by default on most hosts) +- **Web server:** Apache, Nginx, or `php -S` for local dev + +### Local Development (no web server needed): +```bash +cd /path/to/keyser-wv +php -S localhost:8080 +# Then visit: http://localhost:8080 +``` + +### Apache / cPanel / Shared Hosting: +- Upload all files to `public_html/` or your domain folder +- Ensure `data/` directory has write permission (chmod 777 or 755) +- No `.htaccess` needed — plain PHP files + +--- + +## FEATURES + +### Public Site +- 🏠 **Homepage** — hero banner, business stats, alerts feed, featured listings, upcoming events +- 📋 **Business Directory** — searchable/filterable grid of 60+ real Keyser WV businesses +- 🏢 **Business Detail Pages** — full info, hours, menus, reviews, alerts, Google Maps link +- ⭐ **Reviews** — star ratings and written reviews (login required) +- 🍽️ **Menus** — full menu display with sections, items, descriptions, prices +- 📢 **Alerts/News** — business owners can post announcements +- 🗺️ **Attractions** — 15+ attractions in Keyser & Mineral County +- 📅 **Events Calendar** — community event submission and display +- 📖 **About Page** — rich Keyser WV history, timeline, famous people, geography +- 🚩 **Report Button** — logged-in users can report listing errors + +### User Accounts +- Sign up with username & password +- Login / Logout +- Account settings (email, password) +- Owner dashboard to manage assigned businesses +- Submit events + +### Admin Panel (`/admin/`) +- **Dashboard** — stats, pending events, open reports +- **Businesses** — full CRUD: add/edit/delete, toggle active/featured, set hours +- **Categories** — manage business categories with emoji icons +- **Menus** — manage menu sections and items with prices for any business +- **Reviews** — approve, hide, or delete any review +- **Alerts** — post/manage business announcements +- **Attractions** — full CRUD for attractions +- **Events** — approve/reject submissions, add events, feature events +- **Users** — add/edit/delete users, reset passwords, suspend accounts +- **Assign Owners** — link users to businesses for self-management +- **Reports** — review and close user-submitted listing corrections +- **Settings** — toggle registration on/off, event approval, change admin password + +--- + +## FILE STRUCTURE + +``` +keyser-wv/ +├── index.php # Homepage +├── directory.php # Business directory + search +├── business.php # Business detail page +├── menu.php # Owner menu manager +├── attractions.php # Attractions page +├── events.php # Events calendar + submission +├── about.php # About Keyser WV history +├── login.php # Login +├── register.php # Registration +├── logout.php # Logout +├── account.php # Account settings +├── dashboard.php # User/owner dashboard +├── includes/ +│ ├── boot.php # Bootstrap (session + DB init) +│ ├── db.php # Database, schema, seed data +│ ├── functions.php # Auth, helpers, CSRF, stars +│ ├── header.php # HTML header + navbar +│ └── footer.php # Footer + JS +├── admin/ +│ ├── index.php # Admin dashboard +│ ├── businesses.php # Manage businesses +│ ├── categories.php # Manage categories +│ ├── menus.php # Manage all menus +│ ├── reviews.php # Manage reviews +│ ├── alerts.php # Manage alerts +│ ├── attractions.php # Manage attractions +│ ├── events.php # Manage events +│ ├── users.php # Manage users +│ ├── owners.php # Assign owners to businesses +│ ├── reports.php # Handle listing reports +│ └── settings.php # Site settings + password +├── assets/ +│ ├── css/style.css # Complete dark WV theme +│ └── js/app.js # Nav, dropdowns, confirm dialogs +└── data/ + └── keyser.db # SQLite database (auto-created) +``` + +--- + +## PRE-LOADED DATA + +The database is seeded on first run with real Keyser WV data: + +- **60+ businesses** across 13 categories (dining, retail, healthcare, education, etc.) +- **Full menus** for Castiglia's Italian Eatery, The Candlewyck Inn, and Queens Point Coffee +- **6 sample alerts** from real local businesses +- **15 attractions** in Keyser & Mineral County +- **7 events** including Mineral County Fair, Christmas Parade, PSC Athletics +- **1 admin account** (administrator / password123) + +--- + +## DESIGN + +- **Dark theme** with West Virginia colors: Gold (#c9a84c) + Navy (#1a3a5c) on near-black +- **Fonts:** Playfair Display (headings), Oswald (labels), Source Sans 3 (body) +- **Fully responsive** — mobile, tablet, desktop +- **Animated hero** with mountain silhouette and wind turbines +- **No external dependencies** — pure PHP, SQLite, vanilla CSS/JS + +--- + +## SECURITY + +- CSRF tokens on all POST forms +- Password hashing with bcrypt +- HTML output escaped with `htmlspecialchars()` +- Admin pages require `is_admin = 1` +- Session regeneration on login/logout +- Prepared statements everywhere (SQL injection prevention) + +--- + +*Built for Keyser, West Virginia — The Friendliest City in the U.S.A.* diff --git a/about.php b/about.php new file mode 100644 index 0000000..dc9bb3f --- /dev/null +++ b/about.php @@ -0,0 +1,112 @@ + +
+
+
DISCOVER OUR STORY
+

About Keyser, West Virginia

+

County seat of Mineral County — where Appalachian heritage meets the North Branch Potomac River.

+
+
+
+ +
QUICK FACTS
+
+ +
+ +
+ + +
+
+
OUR HISTORY
+

From Paddy Town to Keyser

+
+

Ancient roots: For thousands of years indigenous peoples — including the Adena culture (1000 BC–200 AD) — inhabited the rich valleys along Mineral County's rivers. The area was later traversed by the Haudenosaunee (Iroquois) confederation in search of hunting grounds.

+

Patrick McCarty and "Paddy Town": In the mid-1700s, Irish immigrant Patrick McCarty of County Tyrone established a homestead near the confluence of New Creek and the North Branch Potomac. The settlement became "Paddy Town" — later New Creek.

+

George Washington on the frontier: As a young surveyor and colonial military officer during the French and Indian War (1750s), George Washington traversed and helped defend the Eastern Panhandle region, ordering stockades built near present-day Mineral County.

+

The Baltimore & Ohio Railroad transforms everything (1842): The arrival of the B&O main line in 1842 was the defining moment in Mineral County's development. New Creek became a critical rail junction — coal, timber, and agricultural products flowed east while settlers and commerce moved west.

+

Civil War (1861–1865): Located in the Eastern Panhandle, the area saw repeated skirmishes as the town changed hands. New Creek served as a Union Army training camp for troops from Pennsylvania, Ohio, Indiana, and Illinois. Fort Fuller on Fort Hill was the most significant fortification.

+

Fort Fuller — Two future legends: The Civil War fort on what is now Potomac State College's campus was commanded at different times by two remarkable figures: future 23rd U.S. President Benjamin Harrison, and Lew Wallace — who later wrote the epic novel Ben-Hur: A Tale of the Christ (1880). The fort was also called Fort Kelly.

+

Mineral County created (February 1, 1866): Carved from Hampshire County. The county seat was located at New Creek as a compromise between Elk Garden and Piedmont. The town was officially renamed Keyser in honor of William Keyser, Vice President of the B&O Railroad.

+

Keyser incorporated (1874): The city was formally incorporated, establishing the civic institutions that anchor the community today.

+

Potomac State College (1901): The Keyser Preparatory School opened on historic Fort Hill, later joining the WVU system as Potomac State College. Today it serves ~1,200–1,800 students in 40+ programs and is a major economic and cultural anchor for Keyser.

+

The Lincoln connection — Nancy Hanks: Near Doll's Gap just outside Keyser is believed to be the birthplace of Nancy Hanks, mother of President Abraham Lincoln. This extraordinary connection to America's most celebrated president is part of Mineral County's remarkable presidential heritage.

+

Henry Louis Gates Jr.: The nearby Mineral County community of Piedmont is the hometown of Henry Louis Gates Jr. — the Harvard scholar, author, and host of Finding Your Roots. Gates has written movingly about growing up along the North Branch of the Potomac.

+

Keyser today: The city continues as a regional hub for education, healthcare, retail, and government services, anchored by WVU Medicine Potomac Valley Hospital, Potomac State College, and a vibrant Main Street. Tourism is growing with the expansion of outdoor recreation on Jennings Randolph Lake and the North Branch Potomac.

+
+
+
+
HISTORICAL TIMELINE
+
+ +
+ +
+
+
+ + +
NOTABLE CONNECTIONS
+

Famous Figures of Mineral County

+
+ +
+ +
+ + +
GEOGRAPHY & NATURE
+

Where the Mountains Meet the River

+
+

Keyser lies at the convergence of New Creek and the North Branch of the Potomac River in a valley framed by the rolling ridges of the Appalachian Mountains. From its valley floor at 809 feet, the surrounding terrain rises to the dramatic Allegheny Front — reaching over 3,000 feet elevation — just to the west.

+

The North Branch Potomac River, which forms the West Virginia-Maryland border, flows northeast through Keyser. This river and its tributaries have shaped the city's history, economy, and character for three centuries — carrying B&O Railroad freight for 150 years and today offering world-class smallmouth bass fishing, kayaking, and birdwatching.

+

On clear days from Keyser, the 132 wind turbines of the NedPower Mount Storm Wind Farm are visible on the Allegheny Front — one of the largest wind energy installations east of the Mississippi River. The turbines have become part of Keyser's distinctive skyline.

+

The spectacular Queens Point sandstone cliff — rising ~400 feet above the North Branch in McCoole, Maryland directly across from Keyser — is one of the most dramatic natural features visible from the city and a beloved local landmark and photographer's destination.

+

Mineral County is part of the Appalachian Forest National Heritage Area — over 500 square miles of forested Appalachian landscape where Revolutionary War-era sites, Civil War battlegrounds, and pioneer homesteads dot the landscape alongside natural wonders.

+
+
+ diff --git a/account.php b/account.php new file mode 100644 index 0000000..97b5f19 --- /dev/null +++ b/account.php @@ -0,0 +1,45 @@ + +
+

Account Settings

+

Manage your Discover Keyser WV account

+
+
ACCOUNT INFO
+
👤 ADMIN':''?>
+
📅Joined
+
🕐Last login:
+
+
',array_map('e',$errors))?>
+
+ +
+
+

CHANGE PASSWORD (leave blank to keep current)

+
+
+ +
+
+ diff --git a/admin/2admin_header.php b/admin/2admin_header.php new file mode 100644 index 0000000..484dff0 --- /dev/null +++ b/admin/2admin_header.php @@ -0,0 +1,37 @@ + +
+ +
+'.e($t).'
'; + if($sub) echo '

'.e($sub).'

'; +} diff --git a/admin/2bu.php b/admin/2bu.php new file mode 100644 index 0000000..2f3f733 --- /dev/null +++ b/admin/2bu.php @@ -0,0 +1,167 @@ + ps('name'), + 'slug' => ps('slug') ?: makeSlug(ps('name')), + 'category_id' => (int)p('category_id'), + 'subcategory' => ps('subcategory'), + 'description' => ps('description'), + 'address' => ps('address'), + 'phone' => ps('phone'), + 'email' => ps('email'), + 'website' => ps('website'), + 'lat' => (float)p('lat', 39.4364), + 'lng' => (float)p('lng', -78.9762), + 'is_active' => (int)p('is_active', 1), + 'is_featured' => (int)p('is_featured', 0), + ]; + // Build hours JSON + $days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; + $hrs = []; + foreach ($days as $d) { $v = ps("hours_$d"); if ($v) $hrs[$d] = $v; } + $fields['hours'] = json_encode($hrs); + + if (!$fields['name']) { flash('Business name is required.','error'); go('/admin/businesses.php'); } + + if ($act === 'add') { + // Ensure unique slug + $base = $fields['slug']; $i = 0; + while (qval("SELECT id FROM businesses WHERE slug=?",[$fields['slug'].($i?"-$i":"")])) $i++; + $fields['slug'] .= $i ? "-$i" : ""; + 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)); + flash('Business added!','success'); + } else { + $id = (int)p('id'); + // Check slug uniqueness excluding self + $base = $fields['slug']; $i = 0; + while (qval("SELECT id FROM businesses WHERE slug=? AND id!=?",[$fields['slug'].($i?"-$i":""),$id])) $i++; + $fields['slug'] .= $i ? "-$i" : ""; + 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])); + flash('Business updated!','success'); + } + go('/admin/businesses.php'); + } + + if ($act === 'delete') { + $id = (int)p('id'); + qrun("DELETE FROM businesses WHERE id=?",[$id]); + flash('Business deleted.','success'); + go('/admin/businesses.php'); + } + if ($act === 'toggle') { + $id = (int)p('id'); + qrun("UPDATE businesses SET is_active = 1 - is_active WHERE id=?",[$id]); + go('/admin/businesses.php'); + } +} + +$editId = iget('edit'); +$editing = $editId ? qone("SELECT * FROM businesses WHERE id=?",[$editId]) : null; +$cats = qall("SELECT * FROM categories ORDER BY sort_order"); +$q = gs('q'); $page = max(1,iget('page')); $perPage = 25; +$where = $q ? "WHERE b.name LIKE ? OR b.address LIKE ?" : ""; +$params = $q ? ["%$q%","%$q%"] : []; +$total = (int)qval("SELECT COUNT(*) FROM businesses b $where",$params); +$pages = max(1,(int)ceil($total/$perPage)); +$page = min($page,$pages); +$offset = ($page-1)*$perPage; +$bizs = qall("SELECT b.*,c.name cat FROM businesses b LEFT JOIN categories c ON c.id=b.category_id $where ORDER BY b.name LIMIT $perPage OFFSET $offset",$params); +$days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; +?> + + + +
+
+ +
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+ + +
+ +
+
+
+
+
+
+
+ + Cancel +
+
+
+ + +
ALL BUSINESSES ()
+
+ + + Clear +
+
+ + + + + + + + + + + + + + +
NAMECATEGORYPHONESTATUSFEATUREDACTIONS
Active':'Inactive'?>★':'—'?> +
+ ✏️ Edit +
+
+
+
+
+1): ?> +
+ +
+ + diff --git a/admin/2index.php b/admin/2index.php new file mode 100644 index 0000000..3cb67db --- /dev/null +++ b/admin/2index.php @@ -0,0 +1,109 @@ + qval("SELECT COUNT(*) FROM businesses WHERE is_active=1"), + 'Users' => qval("SELECT COUNT(*) FROM users"), + 'Reviews' => qval("SELECT COUNT(*) FROM reviews"), + 'Events' => qval("SELECT COUNT(*) FROM events WHERE status='approved'"), + 'Pending Events' => qval("SELECT COUNT(*) FROM events WHERE status='pending'"), + 'Open Reports' => qval("SELECT COUNT(*) FROM reports WHERE status='open'"), + 'Attractions' => qval("SELECT COUNT(*) FROM attractions WHERE is_active=1"), + 'Active Alerts' => qval("SELECT COUNT(*) FROM alerts WHERE is_active=1"), +]; +$recentBiz = qall("SELECT * FROM businesses 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"); +$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"); +?> + + +
+$val): ?> +
+ +
+ +
+ +
+
⏳ PENDING EVENTS ()
+
+ + + + + + + + + + + + +
EVENTSUBMITTED BYDATE
Review
+
+
+ + + +
+
🚩 OPEN REPORTS ()
+
+ + + + + + + + + + + + +
BUSINESSTYPEFROM
View
+
+
+ +
+ +
+
+
🏢 RECENT BUSINESSES
+
+ + + + + + + + + + + +
NAMESTATUS
Active':'Inactive'?>Edit
+
+ Manage All Businesses +
+
+
👥 RECENT USERS
+
+ + + + + + + + + + + +
USERNAMEJOINED
ADMIN':''?>Edit
+
+ Manage All Users +
+
+ diff --git a/admin/admin_footer.php b/admin/admin_footer.php new file mode 100644 index 0000000..a0ead7a --- /dev/null +++ b/admin/admin_footer.php @@ -0,0 +1,3 @@ +
+ + diff --git a/admin/admin_header.php b/admin/admin_header.php new file mode 100644 index 0000000..a848885 --- /dev/null +++ b/admin/admin_header.php @@ -0,0 +1,44 @@ + +
+ +
+'.e($t).'
'; + if($sub) echo '

'.e($sub).'

'; +} diff --git a/admin/alerts.php b/admin/alerts.php new file mode 100644 index 0000000..7370787 --- /dev/null +++ b/admin/alerts.php @@ -0,0 +1,59 @@ + + + +
+
➕ POST NEW ALERT
+
+
+
+
+
+
+
+ +
+
+ +
ALL ALERTS ()
+
+ + + + + + + + + + + + + + +
BUSINESSTYPETITLESTATUSPOSTEDACTIONS
Active':'Inactive'?> +
+
+
+
+
+
+ diff --git a/admin/attractions.php b/admin/attractions.php new file mode 100644 index 0000000..c5becd6 --- /dev/null +++ b/admin/attractions.php @@ -0,0 +1,71 @@ +ps('name'),'category'=>ps('category'),'description'=>ps('description'), + 'address'=>ps('address'),'phone'=>ps('phone'),'website'=>ps('website'), + 'hours'=>ps('hours'),'is_active'=>(int)p('is_active',1),'sort_order'=>(int)p('sort_order',0)]; + if ($act === 'add') { if($f['name']) qrun("INSERT INTO attractions(name,category,description,address,phone,website,hours,is_active,sort_order)VALUES(?,?,?,?,?,?,?,?,?)",array_values($f)); flash('Attraction added!','success'); } + if ($act === 'edit') { $id=(int)p('id'); if($f['name']) qrun("UPDATE attractions SET name=?,category=?,description=?,address=?,phone=?,website=?,hours=?,is_active=?,sort_order=? WHERE id=?",array_merge(array_values($f),[$id])); flash('Attraction updated!','success'); } + if ($act === 'delete') { qrun("DELETE FROM attractions WHERE id=?",[(int)p('id')]); flash('Attraction deleted.','success'); } + go('/admin/attractions.php'); +} + +$editId = iget('edit'); +$editing = $editId ? qone("SELECT * FROM attractions WHERE id=?",[$editId]) : null; +$attrs = qall("SELECT * FROM attractions ORDER BY sort_order,name"); +?> + + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Cancel +
+
+
+ +
ALL ATTRACTIONS ()
+
+ + + + + + + + + + + + + +
NAMECATEGORYSTATUSORDERACTIONS
Active':'Hidden'?>
+ ✏️ Edit +
+
+
+ diff --git a/admin/biz_debug.php b/admin/biz_debug.php new file mode 100644 index 0000000..36151e3 --- /dev/null +++ b/admin/biz_debug.php @@ -0,0 +1,117 @@ +query("SELECT sql FROM sqlite_master WHERE type='table' AND name='businesses'")->fetchColumn(); +echo $schema."\n\n"; + +// 2. Row count +$count = $db->query("SELECT COUNT(*) FROM businesses")->fetchColumn(); +echo "--- ROW COUNT ---\n"; +echo "SELECT COUNT(*) FROM businesses;\n=> $count rows\n\n"; + +// 3. All businesses (id, name, slug, is_active) +echo "--- ALL BUSINESSES ---\n"; +echo "SELECT id, name, slug, category_id, is_active, is_featured FROM businesses ORDER BY id;\n"; +$rows = $db->query("SELECT id, name, slug, category_id, is_active, is_featured FROM businesses ORDER BY id")->fetchAll(PDO::FETCH_ASSOC); +foreach ($rows as $r) { + echo sprintf(" [%3d] %-40s slug=%-35s cat=%s active=%d featured=%d\n", + $r['id'], substr($r['name'],0,40), substr($r['slug'],0,35), $r['category_id'], $r['is_active'], $r['is_featured']); +} +echo "\n"; + +// 4. Test UPDATE with id=1 +$id = isset($_GET['test_id']) ? (int)$_GET['test_id'] : 1; +$biz = $db->prepare("SELECT * FROM businesses WHERE id=?"); +$biz->execute([$id]); +$row = $biz->fetch(PDO::FETCH_ASSOC); + +echo "--- FETCH SINGLE ROW (id=$id) ---\n"; +echo "SELECT * FROM businesses WHERE id=$id;\n"; +if ($row) { + foreach ($row as $k => $v) { + echo sprintf(" %-14s = %s\n", $k, strlen($v) > 80 ? substr($v,0,80).'...' : $v); + } +} else { + echo " [NO ROW FOUND for id=$id]\n"; +} +echo "\n"; + +// 5. Categories +echo "--- CATEGORIES ---\n"; +echo "SELECT id, name, icon, sort_order FROM categories ORDER BY sort_order;\n"; +$cats = $db->query("SELECT id, name, icon, sort_order FROM categories ORDER BY sort_order")->fetchAll(PDO::FETCH_ASSOC); +foreach ($cats as $c) { + echo sprintf(" [%2d] %s %s (sort=%d)\n", $c['id'], $c['icon'], $c['name'], $c['sort_order']); +} +echo "\n"; + +// 6. Test UPDATE statement (dry run — shows exact SQL and params) +if ($row) { + echo "--- UPDATE STATEMENT THAT WOULD BE RUN ---\n"; + $sql = "UPDATE businesses SET name=?,slug=?,category_id=?,subcategory=?,description=?,address=?,phone=?,email=?,website=?,hours=?,lat=?,lng=?,is_active=?,is_featured=? WHERE id=?"; + echo $sql."\n"; + echo "Params (15 total):\n"; + $params = [ + $row['name'], $row['slug'], $row['category_id'], $row['subcategory'], + $row['description'], $row['address'], $row['phone'], $row['email'], + $row['website'], $row['hours'], $row['lat'], $row['lng'], + $row['is_active'], $row['is_featured'], $id + ]; + foreach ($params as $i => $v) { + echo sprintf(" [%2d] %s\n", $i+1, strlen((string)$v) > 80 ? substr($v,0,80).'...' : $v); + } + echo "\n"; + + // Actually execute the no-op UPDATE to verify it works + echo "--- EXECUTING NO-OP UPDATE (same values back) ---\n"; + try { + $stmt = $db->prepare($sql); + $stmt->execute($params); + $affected = $stmt->rowCount(); + echo "SUCCESS: $affected row(s) affected\n\n"; + } catch (\Exception $ex) { + echo "ERROR: ".$ex->getMessage()."\n\n"; + } +} + +// 7. Duplicate slug check +echo "--- DUPLICATE SLUG CHECK ---\n"; +echo "SELECT slug, COUNT(*) c FROM businesses GROUP BY slug HAVING c > 1;\n"; +$dupes = $db->query("SELECT slug, COUNT(*) c FROM businesses GROUP BY slug HAVING c > 1")->fetchAll(PDO::FETCH_ASSOC); +if ($dupes) { + foreach ($dupes as $d) echo " DUPLICATE: {$d['slug']} ({$d['c']} times)\n"; +} else { + echo " No duplicates found.\n"; +} +echo "\n"; + +// 8. NULL/empty slug check +echo "--- NULL OR EMPTY SLUG CHECK ---\n"; +echo "SELECT id, name, slug FROM businesses WHERE slug IS NULL OR slug='';\n"; +$bad = $db->query("SELECT id, name, slug FROM businesses WHERE slug IS NULL OR slug=''")->fetchAll(PDO::FETCH_ASSOC); +if ($bad) { + foreach ($bad as $b) echo " BAD: [{$b['id']}] {$b['name']} slug=".var_export($b['slug'],true)."\n"; +} else { + echo " All slugs OK.\n"; +} +echo "\n"; + +echo "=== END DIAGNOSTIC ===\n"; +echo "Tip: Add ?test_id=N to test a specific business by ID.\n"; diff --git a/admin/biz_edit.php b/admin/biz_edit.php new file mode 100644 index 0000000..31d38ba --- /dev/null +++ b/admin/biz_edit.php @@ -0,0 +1,254 @@ + ps('name'), + 'slug' => $slug, + 'category_id' => (int)p('category_id'), + 'subcategory' => ps('subcategory'), + 'description' => ps('description'), + 'address' => ps('address'), + 'phone' => ps('phone'), + 'email' => ps('email'), + 'website' => ps('website'), + 'hours' => json_encode($hrs), + 'lat' => (float)p('lat', 39.4364), + 'lng' => (float)p('lng', -78.9762), + 'is_active' => (int)p('is_active', 1), + 'is_featured' => (int)p('is_featured', 0), + ]; + + try { + $setClauses = implode(', ', array_map(fn($k) => "$k=?", array_keys($updates))); + $sql = "UPDATE businesses SET $setClauses WHERE id=?"; + $params = array_merge(array_values($updates), [$id]); + $stmt = db()->prepare($sql); + $stmt->execute($params); + $affected = $stmt->rowCount(); + $msg = "Saved successfully ($affected row updated). SQL: $sql"; + } catch (\Throwable $ex) { + $err = 'DB ERROR: '.$ex->getMessage(); + } +} + +render: + +// ── Load business ──────────────────────────────────────── +$biz = $id ? qone("SELECT * FROM businesses WHERE id=?", [$id]) : null; +$cats = qall("SELECT * FROM categories ORDER BY sort_order"); +$days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; +$h = $biz ? parseHours($biz['hours']) : []; + +// All businesses for the picker +$all = qall("SELECT id, name FROM businesses ORDER BY name"); +?> + + + + +
+
SELECT BUSINESS TO EDIT
+
+ + +
+ +

Or go directly: /admin/biz_edit.php?id=N

+ +
+ + + +
+ + +
+ + + + +
+
EDITING:  (ID )
+ +
+ + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + View Listing + Pick Different +
+
+
+ + +
+ 🔍 RAW DATABASE VALUES FOR THIS ROW +
+ $val): ?> +
+ + 200 ? substr($val,0,200).'...' : $val)?> +
+ +
+
+ + +
No business found with ID .
+ +
+
🏢
+

Select a business above to begin editing

+
+ + + diff --git a/admin/biz_edits.php b/admin/biz_edits.php new file mode 100644 index 0000000..67e8e20 --- /dev/null +++ b/admin/biz_edits.php @@ -0,0 +1,336 @@ + 'Description', + 'address' => 'Address', + 'phone' => 'Phone', + 'email' => 'Email', + 'hours' => 'Hours of Operation', + 'is_active' => 'Listing Status (Open/Closed)', +]; + +// ── POST: approve / reject individual or all ───────────── +if (isPost()) { + csrfCheck(); + $act = p('_act'); + + // Approve single field change + if ($act === 'approve') { + $reqId = (int)p('req_id'); + $req = qone("SELECT * FROM business_edit_requests WHERE id=?", [$reqId]); + if ($req && $req['status'] === 'pending') { + $field = $req['field']; + $val = $req['new_value']; + // Validate field is one we allow + if (array_key_exists($field, $fieldLabels)) { + if ($field === 'is_active') { + qrun("UPDATE businesses SET is_active=? WHERE id=?", [(int)$val, $req['business_id']]); + } elseif ($field === 'hours') { + // Validate JSON before applying + $decoded = json_decode($val, true); + if (is_array($decoded)) { + qrun("UPDATE businesses SET hours=? WHERE id=?", [$val, $req['business_id']]); + } + } else { + qrun("UPDATE businesses SET $field=? WHERE id=?", [$val, $req['business_id']]); + } + qrun("UPDATE business_edit_requests SET status='approved', reviewed_at=datetime('now') WHERE id=?", [$reqId]); + flash('Change approved and applied.', 'success'); + } + } + go('/admin/biz_edits.php'.(p('biz_id') ? '?biz_id='.p('biz_id') : '')); + } + + // Reject single field change + if ($act === 'reject') { + $reqId = (int)p('req_id'); + $notes = ps('admin_notes'); + qrun("UPDATE business_edit_requests SET status='rejected', admin_notes=?, reviewed_at=datetime('now') WHERE id=?", + [$notes, $reqId]); + flash('Change rejected.', 'info'); + go('/admin/biz_edits.php'.(p('biz_id') ? '?biz_id='.p('biz_id') : '')); + } + + // Approve ALL pending for a business + if ($act === 'approve_all') { + $bizId = (int)p('biz_id'); + $reqs = qall("SELECT * FROM business_edit_requests WHERE business_id=? AND status='pending'", [$bizId]); + $count = 0; + foreach ($reqs as $req) { + $field = $req['field']; + $val = $req['new_value']; + if (!array_key_exists($field, $fieldLabels)) continue; + if ($field === 'is_active') { + qrun("UPDATE businesses SET is_active=? WHERE id=?", [(int)$val, $bizId]); + } elseif ($field === 'hours') { + $decoded = json_decode($val, true); + if (is_array($decoded)) { + qrun("UPDATE businesses SET hours=? WHERE id=?", [$val, $bizId]); + } + } else { + qrun("UPDATE businesses SET $field=? WHERE id=?", [$val, $bizId]); + } + qrun("UPDATE business_edit_requests SET status='approved', reviewed_at=datetime('now') WHERE id=?", [$req['id']]); + $count++; + } + flash("All $count pending change".($count!=1?'s':'')." approved and applied.", 'success'); + go('/admin/biz_edits.php?biz_id='.$bizId); + } + + // Reject ALL pending for a business + if ($act === 'reject_all') { + $bizId = (int)p('biz_id'); + $notes = ps('admin_notes'); + qrun("UPDATE business_edit_requests SET status='rejected', admin_notes=?, reviewed_at=datetime('now') + WHERE business_id=? AND status='pending'", + [$notes, $bizId]); + flash('All pending changes rejected.', 'info'); + go('/admin/biz_edits.php?biz_id='.$bizId); + } +} + +// ── Filter by business ─────────────────────────────────── +$bizId = iget('biz_id'); +$filterStatus = gs('status') ?: 'pending'; + +// Grouped: businesses that have edit requests +$bizWithEdits = qall(" + SELECT b.id, b.name, b.slug, + COUNT(CASE WHEN ber.status='pending' THEN 1 END) AS pending_count, + COUNT(CASE WHEN ber.status='approved' THEN 1 END) AS approved_count, + COUNT(CASE WHEN ber.status='rejected' THEN 1 END) AS rejected_count, + MAX(ber.created_at) AS latest_request + FROM business_edit_requests ber + JOIN businesses b ON b.id = ber.business_id + GROUP BY b.id + ORDER BY pending_count DESC, latest_request DESC +"); + +// Requests for selected business +$requests = []; +$selectedBiz = null; +if ($bizId) { + $selectedBiz = qone("SELECT * FROM businesses WHERE id=?", [$bizId]); + $whereStatus = $filterStatus === 'all' ? "" : "AND ber.status=?"; + $params = $filterStatus === 'all' ? [$bizId] : [$bizId, $filterStatus]; + $requests = qall(" + SELECT ber.*, u.username + FROM business_edit_requests ber + JOIN users u ON u.id = ber.user_id + WHERE ber.business_id=? $whereStatus + ORDER BY CASE ber.status WHEN 'pending' THEN 0 ELSE 1 END, ber.created_at DESC + ", $params); +} +?> + + +
+ + +
+
+ BUSINESSES WITH EDITS +
+ + + +
+
+ + pending + + + approved + + + rejected + +
+
+ Last: +
+
+ + +
No edit requests yet.
+ +
+ + +
+ + + +
+ + +
+ 'Pending','approved'=>'Approved','rejected'=>'Rejected','all'=>'All'] as $k=>$lbl): ?> + + +
+
+ + $r['status'] === 'pending'); + ?> + + + 1): ?> +
+ + pending changes for this listing + +
+
+ + + + +
+
+ + + + + +
+
+
+ + + + + + +
+
+
+ + + + PENDING + + APPROVED + + REJECTED + + + + by · + +
+
+ + +
+
+
CURRENT VALUE
+
+ + + + $t): ?> +
+ + Not set + + ✅ Open / Active' : '🔴 Closed / Inactive' ?> + + Empty' ?> + +
+
+
+
PROPOSED CHANGE
+
+ + + + $t): ?> +
+ + Clear hours + + ✅ Open / Active' : '🔴 Closed / Inactive' ?> + + Empty' ?> + +
+
+
+ + +
+ Admin note: +
+ + + +
+
+ + + + + +
+
+ + + + + + +
+
+ +
+ + +
+
📋
+

No requests for this business

+
+ + + +
+
📋
+

Select a business from the left to review its edit requests

+ +

+ No edit requests have been submitted yet. Assigned owners can propose changes from their business listing page. +

+ +
+ +
+ +
+ diff --git a/admin/businesses.php b/admin/businesses.php new file mode 100644 index 0000000..d7b006a --- /dev/null +++ b/admin/businesses.php @@ -0,0 +1,257 @@ + $name, + 'slug' => $slug, + 'category_id' => (int)p('category_id'), + 'subcategory' => ps('subcategory'), + 'description' => ps('description'), + 'address' => ps('address'), + 'phone' => ps('phone'), + 'email' => ps('email'), + 'website' => ps('website'), + 'hours' => json_encode($hrs), + 'lat' => (float)p('lat', 39.4364), + 'lng' => (float)p('lng', -78.9762), + 'is_active' => (int)p('is_active', 1), + 'is_featured' => (int)p('is_featured', 0), + ]; + + 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)); + flash('Business added!','success'); + go('/admin/businesses.php'); + } 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])); + flash('Business updated!','success'); + go('/admin/businesses.php?edit='.$id); + } + } + + if ($act === 'delete') { + $id = (int)p('id'); + qrun("DELETE FROM businesses WHERE id=?", [$id]); + flash('Business deleted.','success'); + go('/admin/businesses.php'); + } + + if ($act === 'toggle') { + $id = (int)p('id'); + qrun("UPDATE businesses SET is_active = 1 - is_active WHERE id=?", [$id]); + go('/admin/businesses.php'); + } +} + +$editId = iget('edit'); +$editing = $editId ? qone("SELECT * FROM businesses WHERE id=?", [$editId]) : null; +$cats = qall("SELECT * FROM categories ORDER BY sort_order"); +$days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; +$h = $editing ? parseHours($editing['hours']) : []; + +$q = gs('q'); +$page = max(1, iget('page')); +$perPage = 25; +$where = $q ? "WHERE b.name LIKE ? OR b.address LIKE ? OR b.subcategory LIKE ?" : ""; +$params = $q ? ["%$q%", "%$q%", "%$q%"] : []; +$total = (int)qval("SELECT COUNT(*) FROM businesses b $where", $params); +$pages = max(1, (int)ceil($total / $perPage)); +$page = min($page, $pages); +$offset = ($page - 1) * $perPage; +$bizs = qall("SELECT b.*,c.name cat FROM businesses b LEFT JOIN categories c ON c.id=b.category_id $where ORDER BY b.name LIMIT $perPage OFFSET $offset", $params); +?> + + + +
+
+
+ + + + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+ + +
+ +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + + Cancel / Add New + +
+
+
+ + +
ALL BUSINESSES ()
+
+ + + Clear +
+ +
+ + + + + + + > + + + + + + + + + +
NAMECATEGORYPHONESTATUSFEATUREDACTIONS
Active' : 'Inactive' ?>Featured' : '--' ?> +
+ Edit +
+ + + + +
+
+ + + + +
+
+
+
+ + 1): ?> +
+ + + +
+ + + diff --git a/admin/categories.php b/admin/categories.php new file mode 100644 index 0000000..7c27c50 --- /dev/null +++ b/admin/categories.php @@ -0,0 +1,69 @@ + 0) { flash("Cannot delete — $cnt businesses use this category.",'error'); } + else { qrun("DELETE FROM categories WHERE id=?",[$id]); flash('Category deleted.','success'); } + } + go('/admin/categories.php'); +} + +$editId = iget('edit'); +$editing = $editId ? qone("SELECT * FROM categories WHERE id=?",[$editId]) : null; +$cats = qall("SELECT c.*,(SELECT COUNT(*) FROM businesses WHERE category_id=c.id) biz_count FROM categories c ORDER BY c.sort_order,c.name"); +?> + + +
+
+
+ +
+
+
+
+
+
+ + Cancel +
+
+
+ +
ALL CATEGORIES ()
+
+ + + + + + + + + + + + + +
ICONNAMEBUSINESSESSORTACTIONS
+ ✏️ Edit + +
+ In use +
+
+ diff --git a/admin/events.php b/admin/events.php new file mode 100644 index 0000000..6508d5a --- /dev/null +++ b/admin/events.php @@ -0,0 +1,111 @@ +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, + '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'), + '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 ($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()])); + flash('Event added!','success'); + } 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=?,status=?,is_featured=? WHERE id=?",array_merge(array_values($f),[$id])); + flash('Event updated!','success'); + } + go('/admin/events.php'); + } +} + +$filter = gs('filter') ?: 'all'; +$editId = iget('edit'); +$editing = $editId ? qone("SELECT * FROM events WHERE id=?",[$editId]) : null; +$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"); +?> + + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Cancel +
+
+
+ +
+
ALL EVENTS ()
+
+ 'All','pending'=>'Pending','approved'=>'Approved'] as $k=>$lbl): ?> + + +
+
+
+ + + + + + + + + + + + + + +
TITLEDATESUBMITTED BYSTATUSFEATUREDACTIONS
'badge-green','pending'=>'badge-gold','rejected'=>'badge-red']; ?>">★':'—'?> +
+ ✏️ +
+
+
+
+
+
+ diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..076b439 --- /dev/null +++ b/admin/index.php @@ -0,0 +1,159 @@ + qval("SELECT COUNT(*) FROM businesses WHERE is_active=1"), + 'Users' => qval("SELECT COUNT(*) FROM users"), + 'Reviews' => qval("SELECT COUNT(*) FROM reviews"), + 'Events' => qval("SELECT COUNT(*) FROM events WHERE status='approved'"), + 'Pending Events' => qval("SELECT COUNT(*) FROM events WHERE status='pending'"), + 'Pending Edits' => qval("SELECT COUNT(*) FROM business_edit_requests WHERE status='pending'"), + 'Open Reports' => qval("SELECT COUNT(*) FROM reports WHERE status='open'"), + 'Active Alerts' => qval("SELECT COUNT(*) FROM alerts WHERE is_active=1"), +]; +$recentBiz = qall("SELECT * FROM businesses 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"); +$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"); +$pendingEdits = qall(" + SELECT ber.*, b.name biz_name, b.slug biz_slug, b.id biz_id, u.username + FROM business_edit_requests ber + JOIN businesses b ON b.id = ber.business_id + JOIN users u ON u.id = ber.user_id + WHERE ber.status = 'pending' + ORDER BY ber.created_at DESC + LIMIT 10 +"); +?> + + +
+$val): ?> +
+
+ +
+
+
+ +
+ + + +
+
+ ✏️ PENDING OWNER EDIT REQUESTS () +
+
+ + + + 'Description','address'=>'Address','phone'=>'Phone', + 'email'=>'Email','hours'=>'Hours','is_active'=>'Status']; + foreach ($pendingEdits as $pe): + $preview = $pe['field'] === 'hours' + ? '(hours update)' + : ($pe['field'] === 'is_active' + ? ($pe['new_value']==='1' ? 'Open/Active' : 'Closed/Inactive') + : substr($pe['new_value'], 0, 60).(strlen($pe['new_value'])>60?'…':'')); + ?> + + + + + + + + + + +
BUSINESSFIELDSUBMITTED BYPROPOSED VALUESUBMITTED
Review
+
+ View All Edit Requests +
+ + +
+ +
+
⏳ PENDING EVENTS ()
+
+ + + + + + + + + + + + +
EVENTSUBMITTED BYDATE
Review
+
+
+ + + +
+
🚩 OPEN REPORTS ()
+
+ + + + + + + + + + + + +
BUSINESSTYPEFROM
View
+
+
+ +
+ +
+
+
🏢 RECENT BUSINESSES
+
+ + + + + + + + + + + +
NAMESTATUS
Active':'Inactive'?>Edit
+
+ Manage All Businesses +
+
+
👥 RECENT USERS
+
+ + + + + + + + + + + +
USERNAMEJOINED
ADMIN':''?>Edit
+
+ Manage All Users +
+
+ diff --git a/admin/menus.php b/admin/menus.php new file mode 100644 index 0000000..7bea50c --- /dev/null +++ b/admin/menus.php @@ -0,0 +1,179 @@ + + + + +
+
SELECT BUSINESS TO MANAGE MENU
+
+ + +
+
+ + +
+

— Menu

+ View Listing ↗ +
+ + +
+
➕ ADD MENU SECTION
+
+
+
+
+
+ +
+
+ + +
+ +
+
+ + +
+
+ +
+
+
+ + + + + +
+ + + + + + + + + + + + + + +
ITEM NAMEDESCRIPTIONPRICEAVAIL.ORDER
0?money($item['price']):'—'?>Yes':'No'?> +
+ +
+
+ +
+
+ + + +
➕ Add Item to "" +
+
+
+
+
+
+
+
+ +
+
+
+
+ + + +
🍽️

No menu sections yet

Use the form above to add the first section.

+ + + + + diff --git a/admin/owners.php b/admin/owners.php new file mode 100644 index 0000000..947983f --- /dev/null +++ b/admin/owners.php @@ -0,0 +1,75 @@ + + + +
+
🔑 ASSIGN OWNER TO BUSINESS
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
CURRENT OWNER ASSIGNMENTS ()
+ +
+ + + + + + + + + + + +
BUSINESSOWNER / USER
" target="_blank"> +
+
+
+ +
🔑

No owner assignments yet

+ + diff --git a/admin/reports.php b/admin/reports.php new file mode 100644 index 0000000..cff631a --- /dev/null +++ b/admin/reports.php @@ -0,0 +1,60 @@ + + + +
+ Open + All +
+ + +
+ + + + + + + + + + + + + + + +
BUSINESSREPORTERTYPEMESSAGESTATUSDATEACTIONS
100?'…':'')?>Open':'Closed'?> + +
Close +
+
+
+ + +
+
+
+ +
+ +
+
+ +

No reports

+ + diff --git a/admin/reviews.php b/admin/reviews.php new file mode 100644 index 0000000..3133bf5 --- /dev/null +++ b/admin/reviews.php @@ -0,0 +1,49 @@ + + +
+ + + + + + + + + + + + + + + +
BUSINESSUSERRATINGTITLESTATUSDATEACTIONS
Visible':'Hidden'?> +
+
+
+
+
+
+
+1): ?> +
+ + diff --git a/admin/settings.php b/admin/settings.php new file mode 100644 index 0000000..6197ab1 --- /dev/null +++ b/admin/settings.php @@ -0,0 +1,123 @@ + + + +
+ +
+
+
🌐 GENERAL SETTINGS
+
+
+
+
+
+ +
+ +
+ +
+
+ +
+
When disabled, the Sign Up link is hidden and registration page shows a message.
+
+ +
+ +
+ + +
+
+ + +
+
+
+ + +
+
+
🔑 CHANGE YOUR ADMIN PASSWORD
+
👤 ADMIN
+
📅Last login:
+
+
Minimum 6 characters.
+
+ +
+
+ + +
+
📊 DATABASE INFO
+ qval("SELECT COUNT(*) FROM businesses"), + 'Users' => qval("SELECT COUNT(*) FROM users"), + 'Reviews' => qval("SELECT COUNT(*) FROM reviews"), + 'Events' => qval("SELECT COUNT(*) FROM events"), + 'Attractions'=> qval("SELECT COUNT(*) FROM attractions"), + 'Alerts' => qval("SELECT COUNT(*) FROM alerts"), + 'Reports' => qval("SELECT COUNT(*) FROM reports"), + 'Menu Sections'=> qval("SELECT COUNT(*) FROM menu_sections"), + 'Menu Items' => qval("SELECT COUNT(*) FROM menu_items"), + ]; + foreach ($counts as $lbl => $n): + ?>
📌
+ +
💾DB File Size KB
+ +
+
+
+ diff --git a/admin/users.php b/admin/users.php new file mode 100644 index 0000000..cd90dc6 --- /dev/null +++ b/admin/users.php @@ -0,0 +1,109 @@ + + + +
+
+
+ + + +
+
+
+
+
+
>
Minimum 6 characters.
+
+
+
+
+
+
+ + Cancel +
+
+
+ +
ALL USERS ()
+
+ + + + + + + + + + + + + + + +
USERNAMEEMAILROLESTATUSJOINEDLAST LOGINACTIONS
ADMIN':'User'?>Active':'Suspended'?> +
+ ✏️ Edit + 🔑 Assign + +
+
+ +
+
+
+1): ?> +
+ +
+ + diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..d779f50 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,336 @@ +/* ═══════════════════════════════════════════════════════════ + DISCOVER KEYSER WV — Dark West Virginia Theme + WV Gold #c9a84c · Navy #1a3a5c · Near-black backgrounds + ═══════════════════════════════════════════════════════════ */ +:root { + --gold:#c9a84c; --gold-l:#e2c06d; --gold-d:#8a6f2e; --gold-dim:rgba(201,168,76,.13); + --navy:#1a3a5c; --navy-m:#1e4876; --navy-l:#2a5f99; + --bg0:#07090e; --bg1:#0a0f18; --bg2:#0d1520; --bg3:#111c2c; --bg4:#162436; --bg5:#1c2e46; + --bdr:#1e3050; --bdr-l:#253d60; + --text:#e2ddd0; --text-m:#9aa4b8; --text-d:#58687e; + --green:#2a7a47; --green-l:#3da558; --red:#8f2d2d; --red-l:#c04040; --amber:#b37820; + --r:6px; --r-lg:12px; + --sh:0 6px 30px rgba(0,0,0,.55); --sh-sm:0 2px 12px rgba(0,0,0,.4); + --t:.18s ease; +} +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +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} +a{color:var(--gold);text-decoration:none;transition:color var(--t)} +a:hover{color:var(--gold-l)} +h1,h2,h3{font-family:'Playfair Display',serif;line-height:1.15} +h4,h5,h6,.lbl{font-family:'Oswald',sans-serif;letter-spacing:.06em} +ul{list-style:none} img{max-width:100%;display:block} +button{cursor:pointer;font-family:inherit} input,select,textarea{font-family:inherit} +address{font-style:normal} + +/* ── 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)} +.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} +.brand-mtn{font-size:1.9rem;filter:drop-shadow(0 0 10px rgba(201,168,76,.45))} +.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-wv{font-size:.52rem;letter-spacing:.3em;color:var(--text-m)} +.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:hover,.nl.active{color:var(--gold);background: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:hover{background:var(--gold-l);color:var(--bg0)!important} +.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:hover{border-color:var(--gold-d);color:var(--gold)} +.user-dd{display:none;position:absolute;right:0;top:calc(100%+6px);background:var(--bg3);border:1px solid var(--bdr-l);border-radius:var(--r);min-width:175px;box-shadow:var(--sh);z-index:200;padding:.4rem 0} +.user-dd.open{display:block} +.user-dd a{display:block;padding:.55rem 1rem;font-size:.88rem;color:var(--text-m);transition:all var(--t)} +.user-dd a:hover{background:var(--gold-dim);color:var(--gold)} +.user-dd hr{border:none;border-top:1px solid var(--bdr);margin:.3rem 0} +.hamburger{display:none;flex-direction:column;gap:5px;background:none;border:none;padding:.4rem} +.hamburger span{display:block;width:24px;height:2px;background:var(--text-m);border-radius:2px;transition:all .25s} + +/* ── Flash ─────────────────────────────────────────────── */ +.flash{display:flex;align-items:center;justify-content:center;gap:.75rem;padding:.8rem 1.5rem;font-size:.88rem} +.flash button{background:none;border:none;color:inherit;opacity:.6;font-size:.9rem;padding:.1rem .3rem} +.flash button:hover{opacity:1} +.f-info {background:rgba(26,58,92,.4);border-bottom:1px solid var(--navy-m);color:var(--gold-l)} +.f-success{background:rgba(42,122,71,.28);border-bottom:1px solid var(--green-l);color:#86e8a8} +.f-error {background:rgba(143,45,45,.32);border-bottom:1px solid var(--red-l);color:#ff9a9a} +.f-warning{background:rgba(179,120,32,.22);border-bottom:1px solid var(--amber);color:#ffc96a} + +/* ── Hero ──────────────────────────────────────────────── */ +.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-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 svg{width:100%;height:100%} +.hero-content{position:relative;z-index:2;text-align:center;max-width:900px;padding:2rem 1.5rem} +.hero-eyebrow{display:inline-flex;align-items:center;gap:.5rem;background:rgba(201,168,76,.08);border:1px solid var(--gold-dim);padding:.32rem 1.4rem;border-radius:2px;font-family:'Oswald',sans-serif;font-size:.68rem;letter-spacing:.35em;color:var(--gold);margin-bottom:1.5rem;animation:fadeDown .6s ease both} +.hero-h1{font-size:clamp(3.2rem,9vw,6.8rem);font-weight:900;color:var(--text);line-height:.92;text-shadow:0 4px 50px rgba(0,0,0,.95);animation:fadeUp .65s ease .1s both} +.hero-h1 .gld{color:var(--gold);display:block} +.hero-sub{font-family:'Oswald',sans-serif;font-size:clamp(.8rem,1.8vw,1.05rem);letter-spacing:.4em;color:var(--text-m);margin-top:.8rem;animation:fadeUp .65s ease .2s both} +.hero-rule{width:60px;height:2px;background:linear-gradient(90deg,transparent,var(--gold),transparent);margin:1.4rem auto;animation:fadeIn .65s ease .25s both} +.hero-desc{font-size:1.08rem;color:var(--text-m);max-width:600px;margin:0 auto;line-height:1.85;animation:fadeUp .65s ease .3s both} +.hero-actions{display:flex;gap:1rem;justify-content:center;flex-wrap:wrap;margin-top:2.25rem;animation:fadeUp .65s ease .4s both} + +/* ── 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-primary{background:var(--gold);color:var(--bg0)} +.btn-primary:hover{background:var(--gold-l);color:var(--bg0);transform:translateY(-1px);box-shadow:0 4px 20px rgba(201,168,76,.3)} +.btn-outline{background:transparent;border:1px solid var(--gold-d);color:var(--gold)} +.btn-outline:hover{background:var(--gold-dim);border-color:var(--gold)} +.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-navy{background:var(--navy-m);color:#fff} +.btn-navy:hover{background:var(--navy-l)} +.btn-danger{background:var(--red);color:#fff} +.btn-danger:hover{background:var(--red-l)} +.btn-success{background:var(--green);color:#fff} +.btn-success:hover{background:var(--green-l)} +.btn-sm{padding:.38rem 1rem;font-size:.74rem} +.btn-xs{padding:.22rem .6rem;font-size:.68rem} +.btn-block{width:100%;justify-content:center} + +/* ── 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-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-n{font-family:'Playfair Display',serif;font-size:2.6rem;font-weight:900;color:var(--gold);line-height:1} +.stat-l{font-family:'Oswald',sans-serif;font-size:.66rem;letter-spacing:.22em;color:var(--text-d);margin-top:.2rem} + +/* ── Layout ─────────────────────────────────────────────── */ +.page-hdr{background:var(--bg2);padding:3rem 1.5rem 2rem;border-bottom:1px solid var(--bdr)} +.page-hdr-inner{max-width:1440px;margin:0 auto} +.section{max-width:1440px;margin:0 auto;padding:4rem 1.5rem} +.section-sm{max-width:960px;margin:0 auto;padding:3rem 1.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-rule{width:44px;height:2px;background:var(--gold);margin-top:.7rem} + +/* ── Grids ──────────────────────────────────────────────── */ +.g3{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1.4rem} +.g4{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1.2rem} +.g2{display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:1.5rem} + +/* ── 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:hover{transform:translateY(-3px);border-color:var(--gold-d);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)} +.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} +.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-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-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-icon{color:var(--gold);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} +.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} + +/* ── Stars ──────────────────────────────────────────────── */ +.stars{display:inline-flex;gap:1px} +.star{font-size:.88rem;color:var(--text-d)} +.star.on{color:var(--gold)} +.rat-score{font-family:'Oswald',sans-serif;font-size:1rem;color:var(--gold)} +.rat-cnt{font-size:.78rem;color:var(--text-d)} +.star-picker{display:inline-flex;flex-direction:row-reverse;gap:.2rem} +.star-picker input{display:none} +.star-picker label{font-size:1.6rem;color:var(--text-d);cursor:pointer;transition:color .1s} +.star-picker input:checked~label,.star-picker label:hover,.star-picker label:hover~label{color:var(--gold)} + +/* ── Info box ───────────────────────────────────────────── */ +.ibox{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.4rem;margin-bottom:1.4rem} +.ibox-title{font-family:'Oswald',sans-serif;letter-spacing:.1em;font-size:.88rem;color:var(--gold);margin-bottom:.9rem;padding-bottom:.6rem;border-bottom:1px solid var(--bdr)} +.irow{display:flex;gap:.6rem;align-items:flex-start;padding:.42rem 0;border-bottom:1px solid var(--bdr);font-size:.84rem} +.irow:last-child{border-bottom:none} +.irow-i{color:var(--gold);flex-shrink:0;width:18px;text-align:center;margin-top:1px} +.irow-v{color:var(--text)} + +/* ── Hours ──────────────────────────────────────────────── */ +.hrs-grid{display:grid;grid-template-columns:1fr 1fr;gap:.22rem} +.hrs-row{display:flex;justify-content:space-between;padding:.28rem .42rem;font-size:.8rem;border-radius:3px} +.hrs-row:nth-child(odd){background:rgba(255,255,255,.02)} +.hrs-day{font-family:'Oswald',sans-serif;font-size:.72rem;letter-spacing:.06em;color:var(--text-d)} +.hrs-time{color:var(--text)} + +/* ── Alerts ─────────────────────────────────────────────── */ +.alert-strip{background:var(--bg2);border-bottom:1px solid var(--bdr);padding:2rem 1.5rem} +.alert-strip-inner{max-width:1440px;margin:0 auto} +.alert-item{display:flex;gap:.85rem;align-items:flex-start;padding:.9rem 1.1rem;border-radius:var(--r);margin-bottom:.65rem} +.alert-item:last-child{margin-bottom:0} +.a-info {background:rgba(26,72,118,.22);border:1px solid rgba(42,95,153,.35)} +.a-news {background:rgba(42,122,71,.16);border:1px solid rgba(58,165,94,.3)} +.a-event {background:rgba(201,168,76,.1);border:1px solid rgba(201,168,76,.28)} +.a-warning{background:rgba(183,120,32,.16);border:1px solid rgba(183,120,32,.35)} +.a-sale {background:rgba(120,45,140,.16);border:1px solid rgba(150,65,175,.3)} +.alert-ico{font-size:1.05rem;flex-shrink:0;margin-top:2px} +.alert-title{font-family:'Oswald',sans-serif;letter-spacing:.05em;font-size:.92rem;color:var(--text)} +.alert-body{font-size:.84rem;color:var(--text-m);margin-top:.12rem;line-height:1.55} +.alert-meta{font-size:.73rem;color:var(--text-d);margin-top:.22rem} + +/* ── Detail layout ──────────────────────────────────────── */ +.detail-hdr{background:linear-gradient(180deg,var(--bg2),var(--bg1));padding:3rem 1.5rem 2rem;border-bottom:1px solid var(--bdr)} +.detail-hdr-inner{max-width:1280px;margin:0 auto} +.breadcrumb{font-size:.78rem;color:var(--text-d);margin-bottom:1rem} +.breadcrumb a{color:var(--text-m)}.breadcrumb a:hover{color:var(--gold)} +.breadcrumb span{margin:0 .4rem;color:var(--text-d)} +.detail-body{max-width:1280px;margin:0 auto;padding:2.5rem 1.5rem;display:grid;grid-template-columns:1fr 330px;gap:2.5rem} + +/* ── Menu ───────────────────────────────────────────────── */ +.menu-sec{margin-bottom:2rem} +.menu-sec-name{font-family:'Oswald',sans-serif;font-size:.92rem;letter-spacing:.14em;color:var(--gold);border-bottom:1px solid rgba(201,168,76,.25);padding-bottom:.45rem;margin-bottom:.9rem} +.menu-sec-desc{font-size:.8rem;color:var(--text-d);margin-bottom:.6rem;font-style:italic} +.menu-item{display:flex;justify-content:space-between;align-items:flex-start;padding:.65rem 0;border-bottom:1px solid var(--bdr)} +.menu-item:last-child{border-bottom:none} +.mi-info{flex:1;margin-right:.75rem} +.mi-name{font-weight:600;font-size:.92rem;color:var(--text)} +.mi-desc{font-size:.78rem;color:var(--text-d);margin-top:.12rem} +.mi-price{font-family:'Oswald',sans-serif;font-size:.95rem;color:var(--gold);flex-shrink:0} + +/* ── Reviews ────────────────────────────────────────────── */ +.rev-card{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.2rem;margin-bottom:.9rem} +.rev-hdr{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.65rem} +.rev-author{font-family:'Oswald',sans-serif;font-size:.88rem;letter-spacing:.05em;color:var(--text)} +.rev-date{font-size:.73rem;color:var(--text-d)} +.rev-title{font-size:.9rem;font-weight:600;color:var(--text);margin:.4rem 0 .3rem} +.rev-body{font-size:.88rem;color:var(--text-m);line-height:1.62} + +/* ── Forms ──────────────────────────────────────────────── */ +.form-box{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r-lg);padding:2.25rem;max-width:480px;margin:4rem auto} +.form-title{font-size:2rem;margin-bottom:.25rem} +.form-sub{color:var(--text-d);font-size:.88rem;margin-bottom:1.75rem} +.fg{margin-bottom:1.15rem} +.flabel{display:block;font-family:'Oswald',sans-serif;letter-spacing:.1em;font-size:.72rem;color:var(--gold);margin-bottom:.42rem} +.finput,.fselect,.ftextarea{width:100%;padding:.7rem 1rem;background:rgba(0,0,0,.4);border:1px solid var(--bdr-l);border-radius:var(--r);color:var(--text);font-size:.92rem;transition:border-color var(--t)} +.finput:focus,.fselect:focus,.ftextarea:focus{outline:none;border-color:var(--gold-d)} +.finput::placeholder,.ftextarea::placeholder{color:var(--text-d)} +.fselect option{background:var(--bg3);color:var(--text)} +.ftextarea{resize:vertical;min-height:100px;line-height:1.6} +.fhint{font-size:.75rem;color:var(--text-d);margin-top:.3rem} +.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} + +/* ── Search ─────────────────────────────────────────────── */ +.search-bar{background:var(--bg2);padding:1.75rem 1.5rem;border-bottom:1px solid var(--bdr)} +.search-bar-inner{max-width:860px;margin:0 auto} +.search-form{display:flex;gap:.65rem;flex-wrap:wrap} +.search-wrap{flex:1;min-width:200px;position:relative} +.s-icon{position:absolute;left:.9rem;top:50%;transform:translateY(-50%);color:var(--text-d)} +.search-input{width:100%;padding:.72rem .9rem .72rem 2.5rem;background:rgba(0,0,0,.4);border:1px solid var(--bdr-l);border-radius:var(--r);color:var(--text);font-size:.92rem} +.search-input:focus{outline:none;border-color:var(--gold-d)} +.search-input::placeholder{color:var(--text-d)} +.filter-bar{background:var(--bg1);border-bottom:1px solid var(--bdr);padding:.6rem 1.5rem;overflow-x:auto} +.filter-bar-inner{max-width:1440px;margin:0 auto;display:flex;gap:.45rem;flex-wrap:nowrap} + +/* ── Tables ─────────────────────────────────────────────── */ +.tbl-wrap{overflow-x:auto;border-radius:var(--r);border:1px solid var(--bdr)} +.dtbl{width:100%;border-collapse:collapse;font-size:.86rem} +.dtbl th{background:var(--bg4);color:var(--gold);font-family:'Oswald',sans-serif;letter-spacing:.1em;font-size:.72rem;padding:.72rem 1rem;text-align:left;border-bottom:2px solid rgba(201,168,76,.25);white-space:nowrap} +.dtbl td{padding:.72rem 1rem;border-bottom:1px solid var(--bdr);color:var(--text-m);vertical-align:middle} +.dtbl tr:last-child td{border-bottom:none} +.dtbl tr:hover td{background:rgba(255,255,255,.02)} +.td-name{color:var(--text);font-weight:500} + +/* ── Badges ─────────────────────────────────────────────── */ +.badge{display:inline-block;padding:.14rem .5rem;border-radius:3px;font-family:'Oswald',sans-serif;font-size:.68rem;letter-spacing:.08em} +.badge-gold {background:rgba(201,168,76,.15);color:var(--gold);border:1px solid rgba(201,168,76,.28)} +.badge-green{background:rgba(42,122,71,.22);color:#86e8a8;border:1px solid rgba(58,165,94,.35)} +.badge-red {background:rgba(143,45,45,.28);color:#ff9a9a;border:1px solid rgba(192,64,64,.4)} +.badge-blue {background:rgba(26,72,118,.3);color:#88c0ec;border:1px solid rgba(42,96,153,.4)} +.badge-gray {background:rgba(88,104,126,.2);color:var(--text-d);border:1px solid var(--bdr-l)} + +/* ── Admin layout ───────────────────────────────────────── */ +.admin-wrap{display:grid;grid-template-columns:220px 1fr;min-height:calc(100vh - 64px)} +.admin-side{background:var(--bg2);border-right:1px solid var(--bdr);padding:1.75rem 1.1rem;position:sticky;top:64px;align-self:start;max-height:calc(100vh - 64px);overflow-y:auto} +.admin-side-title{font-family:'Oswald',sans-serif;font-size:.68rem;letter-spacing:.28em;color:var(--gold);margin-bottom:.85rem;padding-bottom:.5rem;border-bottom:1px solid var(--bdr)} +.asl{display:block;padding:.55rem .75rem;border-radius:4px;font-size:.87rem;color:var(--text-m);margin-bottom:.18rem;transition:all var(--t)} +.asl:hover,.asl.on{background:var(--gold-dim);color:var(--gold)} +.admin-main{padding:2.25rem;overflow-x:auto} +.admin-sec-title{font-family:'Oswald',sans-serif;font-size:1.05rem;letter-spacing:.1em;color:var(--text);margin-bottom:1.15rem;padding-bottom:.65rem;border-bottom:1px solid var(--bdr)} +.stat-cards{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem} +.stat-card{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.25rem 1.65rem;flex:1;min-width:130px;text-align:center} +.sc-n{font-family:'Playfair Display',serif;font-size:2.2rem;color:var(--gold);line-height:1} +.sc-l{font-family:'Oswald',sans-serif;font-size:.66rem;letter-spacing:.18em;color:var(--text-d);margin-top:.22rem} + +/* ── 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:hover{border-color:var(--gold-d);box-shadow:var(--sh-sm)} +.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-day{font-family:'Playfair Display',serif;font-size:2rem;color:var(--text);line-height:1} +.evt-yr{font-family:'Oswald',sans-serif;font-size:.65rem;color:var(--text-d);margin-top:2px} +.evt-body{flex:1;min-width:0} +.evt-title{font-size:1.18rem;font-weight:700;color:var(--text);margin-bottom:.35rem} +.evt-meta{display:flex;gap:.85rem;flex-wrap:wrap;font-size:.8rem;color:var(--text-d);margin-bottom:.5rem} +.evt-meta span{display:flex;align-items:center;gap:.3rem} +.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} + +/* ── About / Facts ──────────────────────────────────────── */ +.fact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:1.1rem} +.fact-card{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.4rem} +.fact-icon{font-size:2rem;margin-bottom:.6rem} +.fact-lbl{font-family:'Oswald',sans-serif;font-size:.72rem;letter-spacing:.18em;color:var(--gold)} +.fact-val{font-size:.95rem;color:var(--text);margin-top:.3rem} +.prose p{color:var(--text-m);line-height:1.9;margin-bottom:1.1rem} +.prose strong{color:var(--gold)} +.timeline{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.4rem} +.tl-item{display:flex;gap:.8rem;padding:.48rem 0;border-bottom:1px solid var(--bdr);font-size:.82rem} +.tl-item:last-child{border-bottom:none} +.tl-yr{font-family:'Oswald',sans-serif;color:var(--gold);min-width:52px;flex-shrink:0} +.tl-evt{color:var(--text-m)} +.famous-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem} +.famous-card{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.3rem} +.famous-name{font-family:'Playfair Display',serif;font-size:1.08rem;color:var(--text);margin-bottom:.2rem} +.famous-yrs{font-size:.74rem;color:var(--text-d);margin-bottom:.45rem} +.famous-bio{font-size:.83rem;color:var(--text-m);line-height:1.6} + +/* ── Attractions ────────────────────────────────────────── */ +.attr-card{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.4rem;transition:all .22s} +.attr-card:hover{border-color:var(--gold-d);transform:translateY(-2px);box-shadow:var(--sh-sm)} +.attr-cat{font-family:'Oswald',sans-serif;font-size:.62rem;letter-spacing:.2em;color:var(--gold);margin-bottom:.4rem} +.attr-name{font-size:1.1rem;font-weight:700;color:var(--text);margin-bottom:.6rem} +.attr-desc{font-size:.85rem;color:var(--text-m);line-height:1.65} +.attr-det{display:flex;gap:.45rem;font-size:.78rem;color:var(--text-d);margin-top:.55rem;align-items:flex-start} + +/* ── Footer ─────────────────────────────────────────────── */ +.footer{background:var(--bg2);border-top:1px solid var(--bdr);margin-top:auto} +.footer-grid{max-width:1440px;margin:0 auto;padding:3rem 1.5rem;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:2.5rem} +.f-logo{font-family:'Oswald',sans-serif;font-size:1.25rem;letter-spacing:.18em;color:var(--gold);margin-bottom:.5rem} +.f-tag{font-style:italic;color:rgba(201,168,76,.7);font-size:.88rem;margin-bottom:.75rem} +.footer p{font-size:.83rem;color:var(--text-d);line-height:1.7} +.footer h5{font-family:'Oswald',sans-serif;font-size:.76rem;letter-spacing:.16em;color:var(--gold);margin-bottom:.75rem} +.footer ul li{margin-bottom:.35rem} +.footer ul li a,.footer address a{font-size:.83rem;color:var(--text-d)} +.footer ul li a:hover,.footer address a:hover{color:var(--gold)} +.footer address p{margin-bottom:.35rem} +.footer-bar{border-top:1px solid var(--bdr);padding:1.2rem 1.5rem;text-align:center} +.footer-bar p{font-size:.78rem;color:var(--text-d)} + +/* ── Utilities ──────────────────────────────────────────── */ +.divider{border:none;border-top:1px solid var(--bdr);margin:2rem 0} +.empty{text-align:center;padding:4rem 2rem;color:var(--text-d)} +.empty-ico{font-size:3.5rem;margin-bottom:1rem} +.empty h3{color:var(--text-m);font-family:'Oswald',sans-serif;font-weight:400;font-size:1.1rem;letter-spacing:.1em} +.flex-row{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap} +.mt1{margin-top:.5rem}.mt2{margin-top:1rem}.mt3{margin-top:1.5rem} +.mb1{margin-bottom:.5rem}.mb2{margin-bottom:1rem} +.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} + +/* ── Animations ─────────────────────────────────────────── */ +@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 fadeIn{from{opacity:0}to{opacity:1}} + +/* ── Responsive ─────────────────────────────────────────── */ +@media(max-width:1100px){.footer-grid{grid-template-columns:1fr 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: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.open{display:flex}.hamburger{display:flex} + .user-wrap{width:100%}.user-btn{width:100%;text-align:left} + .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} +} +@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}} diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..b4f1278 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,31 @@ +// Nav hamburger +const ham=document.getElementById('ham'),navLinks=document.getElementById('navLinks'); +ham?.addEventListener('click',()=>navLinks?.classList.toggle('open')); +document.addEventListener('click',e=>{if(!e.target.closest('.navbar'))navLinks?.classList.remove('open')}); + +// User dropdown +const uBtn=document.getElementById('userBtn'),uDd=document.getElementById('userDd'); +uBtn?.addEventListener('click',e=>{e.stopPropagation();uDd?.classList.toggle('open')}); +document.addEventListener('click',()=>uDd?.classList.remove('open')); + +// Flash auto-dismiss after 4.5s +setTimeout(()=>{ + document.querySelectorAll('.flash').forEach(el=>{ + el.style.transition='opacity .5s'; + el.style.opacity='0'; + setTimeout(()=>el.remove(),500); + }); +},4500); + +// Confirm dialogs +document.querySelectorAll('[data-confirm]').forEach(el=>{ + el.addEventListener('click',e=>{if(!confirm(el.dataset.confirm))e.preventDefault();}); +}); + +// Admin sidebar active link +document.querySelectorAll('.asl').forEach(l=>{ + const href=l.getAttribute('href'); + if(href&&(location.pathname===href||location.pathname.startsWith(href.replace(/\/[^/]+$/,'/')+'/')&&href!='/admin/')) + l.classList.add('on'); + if(location.pathname===href)l.classList.add('on'); +}); diff --git a/attractions.php b/attractions.php new file mode 100644 index 0000000..055134b --- /dev/null +++ b/attractions.php @@ -0,0 +1,45 @@ + +
+
+
EXPLORE THE REGION
+

Attractions & Activities

+

From Civil War sites to breathtaking river scenery — Keyser and Mineral County have something for every visitor.

+
+
+
+ All + +
+
+
+ + +
+
+
+
+
📍
+
📞
+
🕐
+
🌐
+
+ +
+ +
+
GETTING HERE
+

Plan Your Visit to Keyser

+
+ +

+ +
+
+
+ diff --git a/business.php b/business.php new file mode 100644 index 0000000..484acba --- /dev/null +++ b/business.php @@ -0,0 +1,568 @@ + 0) { + flash("$submitted change".($submitted>1?'s':'')." submitted for admin review.", 'success'); + } else { + flash('No changes detected.', 'info'); + } + go("/business.php?slug=$slug"); + } + + if ($act === 'review' && authed()) { + $rating = max(1, min(5, (int)p('rating', 5))); + $title = ps('rtitle'); + $body = ps('rbody'); + $ex = qval("SELECT id FROM reviews WHERE business_id=? AND user_id=?", [$b['id'], uid()]); + if ($ex) { + qrun("UPDATE reviews SET rating=?,title=?,body=?,created_at=datetime('now') WHERE id=?", + [$rating, $title, $body, $ex]); + } else { + qrun("INSERT INTO reviews(business_id,user_id,rating,title,body)VALUES(?,?,?,?,?)", + [$b['id'], uid(), $rating, $title, $body]); + } + flash('Review saved!', 'success'); + go("/business.php?slug=$slug"); + } + + if ($act === 'post_alert' && owns($b['id'])) { + $t = ps('atitle'); + $body = ps('abody'); + $type = p('atype', 'info'); + if ($t && $body) { + qrun("INSERT INTO alerts(business_id,type,title,body)VALUES(?,?,?,?)", + [$b['id'], $type, $t, $body]); + flash('Alert posted!', 'success'); + } + go("/business.php?slug=$slug"); + } + + if ($act === 'del_alert' && owns($b['id'])) { + qrun("DELETE FROM alerts WHERE id=? AND business_id=?", [(int)p('aid'), $b['id']]); + go("/business.php?slug=$slug"); + } + + if ($act === 'del_review' && isAdmin()) { + qrun("DELETE FROM reviews WHERE id=?", [(int)p('rid')]); + flash('Review deleted.', 'success'); + go("/business.php?slug=$slug"); + } + + if ($act === 'report' && authed()) { + $rtype = p('rtype', 'correction'); + $rmsg = ps('rmsg'); + if ($rmsg) { + qrun("INSERT INTO reports(business_id,user_id,type,message)VALUES(?,?,?,?)", + [$b['id'], uid(), $rtype, $rmsg]); + flash('Report submitted. Thank you!', 'success'); + } + go("/business.php?slug=$slug"); + } +} + +/* ── Page data ─────────────────────────────────────────── */ +$pageTitle = e($b['name']).' — Keyser, WV'; +$activeNav = 'dir'; + +$reviews = qall(" + SELECT r.*, u.username + FROM reviews r JOIN users u ON u.id = r.user_id + WHERE r.business_id = ? AND r.is_approved = 1 + ORDER BY r.created_at DESC +", [$b['id']]); + +$sects = qall("SELECT * FROM menu_sections WHERE business_id=? ORDER BY sort_order", [$b['id']]); +$mitems = []; +foreach ($sects as $s) { + $mitems[$s['id']] = qall( + "SELECT * FROM menu_items WHERE section_id=? AND is_available=1 ORDER BY sort_order", + [$s['id']] + ); +} + +$alerts = qall("SELECT * FROM alerts WHERE business_id=? AND is_active=1 ORDER BY created_at DESC", [$b['id']]); +$myrev = authed() ? qone("SELECT * FROM reviews WHERE business_id=? AND user_id=?", [$b['id'], uid()]) : null; +$hours = parseHours($b['hours']); +$isOwner = owns($b['id']); +$isNonAdminOwner = $isOwner && !isAdmin(); +$cat = (string)($b['cat'] ?? 'Uncategorized'); +$catIcon = (string)($b['cat_icon'] ?? '🏢'); +$days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; + +// Pending edit requests for this business (shown to owner) +$pendingEdits = ($isOwner && !isAdmin()) + ? qall("SELECT * FROM business_edit_requests WHERE business_id=? AND user_id=? AND status='pending' ORDER BY created_at DESC", + [$b['id'], uid()]) + : []; +$pendingByField = []; +foreach ($pendingEdits as $pe) $pendingByField[$pe['field']] = $pe; + +include __DIR__.'/includes/header.php'; +?> +
+
+ +
+

+
+ + + ( review) + ★ FEATURED +
+
+
+ +
+
+ + +
+
📢 BUSINESS UPDATES
+ +
+
+
+
+
+
+
+ +
+ + + + +
+ +
+ +
+ + + +
+
📢 POST BUSINESS ALERT
+
+ + +
+ + +
+ + +
+
+ + + + +
+
+ ✏️ SUGGEST LISTING CHANGES + + — Changes require admin approval before going live + +
+ + +
+ ⏳ Pending review: + + ''.e(ucfirst($pe['field'])).'', $pendingEdits)) ?> + + — an admin will review these changes shortly. +
+ + +
+ + + +
+ + + +
Currently pending: "80?'…':'' ?>"
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
Leave blank to omit a day from the hours display.
+
+ +
+ + +
+ + +
+
Setting to Inactive will hide this listing from the public directory until an admin approves and it goes live.
+
+ + +
+
+ + + +
+
ABOUT
+

+
+ + +
+
🍽️ MENU
+ + + + + ✏️ Manage Menu + +
+ +
+ + Add Menu +
+ + +

+ ⭐ REVIEWS () +

+ + +
+
+
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ Login to Write a Review +
+ + + +
+
+
+
+ +
+
+ + +
+ + + + +
+ +
+
+
+
+
+ + +

No reviews yet — be the first!

+ + +
+ +
+ + 🚩 REPORT A LISTING ISSUE + +
+
+ + +
+ + +
+
+ + +
+ +
+
+
+ +

+ 🚩 Log in to report a listing issue. +

+ + +
+ +
+
+
BUSINESS INFO
+ +
📍
+ + +
📞 + +
+ + +
✉️ + +
+ + +
🌐 + +
+ +
+ 🏷️ + +
+
+ + +
+
🕐 HOURS OF OPERATION
+
+ $time): ?> +
+ + +
+ +
+
+ + + +
+
📍 LOCATION
+ +
+ + + +
+
⚙️ OWNER CONTROLS
+ 🍽️ Manage Menu + + ✏️ Edit Listing (Admin) + + 📋 Review Owner Edits + + + + +
+ ⏳ change1?'s':''?> awaiting admin review +
+ + +
+ + +
+
+ + diff --git a/dashboard.php b/dashboard.php new file mode 100644 index 0000000..6add144 --- /dev/null +++ b/dashboard.php @@ -0,0 +1,70 @@ + +
+
+
MY ACCOUNT
+

Dashboard —

+

Manage your listings, menus, alerts, and submitted events.

+
+
+
+ +
MY BUSINESS LISTINGS
+
+ +
+
+
+
( reviews)
+
📍
+ +
+ +
+ +
🏢

No businesses assigned yet

Contact an administrator to link your business listing.

+ + + +
MY SUBMITTED EVENTS
+
+ + + + + + + + + + + + +
EVENTDATESTATUS
'badge-green','pending'=>'badge-gold','rejected'=>'badge-red']; ?> + View
+
+ + +
QUICK ACTIONS
+ +
+ diff --git a/data/.htaccess b/data/.htaccess new file mode 100644 index 0000000..d17263d --- /dev/null +++ b/data/.htaccess @@ -0,0 +1,3 @@ + + Require all denied + diff --git a/directory.php b/directory.php new file mode 100644 index 0000000..7d4a1ba --- /dev/null +++ b/directory.php @@ -0,0 +1,148 @@ + +
+
+
EXPLORE KEYSER
+

Business Directory

+

Discover local businesses, restaurants, services, and more in Keyser, West Virginia.

+
+
+ + + +
+
+ All + + + +
+
+ +
+ + + + 1): ?> +
+ + + +
+ + + +
+
🔍
+

No results found

+

Try different search terms or browse all categories.

+ Browse All +
+ +
+ + diff --git a/events.php b/events.php new file mode 100644 index 0000000..e1ad974 --- /dev/null +++ b/events.php @@ -0,0 +1,114 @@ +=date('now') OR(end_date IS NOT NULL AND end_date>=date('now')))"); +$pages=max(1,(int)ceil($total/$perPage)); $page=min($page,$pages); $offset=($page-1)*$perPage; +$events=qall("SELECT e.*,u.username submitter FROM events e LEFT JOIN users u ON u.id=e.submitted_by WHERE e.status='approved' AND(e.event_date>=date('now') OR(e.end_date IS NOT NULL AND e.end_date>=date('now'))) ORDER BY e.is_featured DESC,e.event_date ASC LIMIT $perPage OFFSET $offset"); +include __DIR__.'/includes/header.php'; +?> +
+
+
+
COMMUNITY CALENDAR
+

Events in Keyser, WV

+

Festivals, sports, community gatherings, arts, and more in Mineral County.

+
+ + + Submit Event + + Login to Submit Event + +
+
+ + +
+
+
📅 SUBMIT A COMMUNITY EVENT
+
',array_map('e',$errors))?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Cancel +
+

Events are reviewed by an administrator before appearing publicly.

+
+
+
+ +

Please log in to submit an event.

+ + +
+ + +
+
+
+
+
+
+
+
★ FEATURED
+
+ 📍 + 🕐 + 📅 thru + 💰 + 📞 +
+
+ +
+
+ + 1): ?>
+ +
📅

No upcoming events listed

Be the first to submit a community event!

Submit an Event
+ +
+ diff --git a/includes/2db.php b/includes/2db.php new file mode 100644 index 0000000..370dca6 --- /dev/null +++ b/includes/2db.php @@ -0,0 +1,842 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $pdo->exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=NORMAL;"); + if ($isNew) { _schema($pdo); _seed($pdo); } + if (file_exists(DB_FILE)) @chmod(DB_FILE, 0666); + return $pdo; +} + +/* ── Shorthand query helpers ────────────────────────────── */ +function qr(string $sql, array $p=[]): PDOStatement { $s=db()->prepare($sql); $s->execute($p); return $s; } +function qall(string $sql, array $p=[]): array { return qr($sql,$p)->fetchAll(); } +function qone(string $sql, array $p=[]): ?array { $r=qr($sql,$p)->fetch(); return $r?:null; } +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(); } + +/* ══════════════════════════════════════════════════════ + SCHEMA + ══════════════════════════════════════════════════════ */ +function _schema(PDO $db): void { + $db->exec(" + CREATE TABLE IF NOT EXISTS settings( + key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE IF NOT EXISTS users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT, + password TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + last_login TEXT + ); + CREATE TABLE IF NOT EXISTS categories( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + icon TEXT NOT NULL DEFAULT '🏢', + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS businesses( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + category_id INTEGER REFERENCES categories(id), + subcategory TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + website TEXT NOT NULL DEFAULT '', + hours TEXT NOT NULL DEFAULT '{}', + lat REAL NOT NULL DEFAULT 39.4364, + lng REAL NOT NULL DEFAULT -78.9762, + is_active INTEGER NOT NULL DEFAULT 1, + is_featured INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT(datetime('now')) + ); + CREATE TABLE IF NOT EXISTS business_owners( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + PRIMARY KEY(user_id,business_id) + ); + CREATE TABLE IF NOT EXISTS reviews( + 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, + rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5), + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + is_approved INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT(datetime('now')) + ); + CREATE TABLE IF NOT EXISTS menu_sections( + id INTEGER PRIMARY KEY AUTOINCREMENT, + business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS menu_items( + id INTEGER PRIMARY KEY AUTOINCREMENT, + section_id INTEGER NOT NULL REFERENCES menu_sections(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + price REAL NOT NULL DEFAULT 0, + is_available INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS alerts( + id INTEGER PRIMARY KEY AUTOINCREMENT, + business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'info', + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 1, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT(datetime('now')) + ); + CREATE TABLE IF NOT EXISTS attractions( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'Sightseeing', + description TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + website TEXT NOT NULL DEFAULT '', + hours TEXT NOT NULL DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS events( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + venue TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + event_date TEXT NOT NULL, + 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 '', + contact_name TEXT NOT NULL DEFAULT '', + contact_email TEXT NOT NULL DEFAULT '', + contact_phone TEXT NOT NULL DEFAULT '', + submitted_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'pending', + is_featured INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT(datetime('now')) + ); + CREATE TABLE IF NOT EXISTS reports( + 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, + type TEXT NOT NULL DEFAULT 'correction', + message TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + admin_notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT(datetime('now')) + ); + "); +} + +/* ══════════════════════════════════════════════════════ + SEED DATA (real Keyser WV businesses from city website + + review sites + Wikipedia) + ══════════════════════════════════════════════════════ */ +function _seed(PDO $db): void { + + /* Settings */ + foreach (['registration_enabled'=>'1','site_name'=>'Discover Keyser WV', + 'site_tagline'=>'The Friendliest City in the U.S.A.', + 'contact_email'=>'info@discoverkeyser.com', + 'events_require_approval'=>'1'] as $k=>$v) + qrun("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)",[$k,$v]); + + /* Admin user — password "password123" */ + $hash = password_hash('password123', PASSWORD_BCRYPT); + qrun("INSERT OR IGNORE INTO users(username,email,password,is_admin) + VALUES('administrator','admin@discoverkeyser.com',?,1)",[$hash]); + + /* Categories */ + foreach ([ + ['Dining & Food','🍽️',1],['Shopping & Retail','🛍️',2], + ['Lodging','🏨',3],['Healthcare','🏥',4],['Education','🎓',5], + ['Financial Services','🏦',6],['Automotive','🔧',7], + ['Professional Services','💼',8],['Recreation & Fitness','⛹️',9], + ['Beauty & Personal Care','💇',10],['Government & Civic','🏛️',11], + ['Legal Services','⚖️',12],['Churches & Faith','⛪',13], + ] as $c) qrun("INSERT OR IGNORE INTO categories(name,icon,sort_order)VALUES(?,?,?)",$c); + + $cid = fn(string $n): int => (int)qval("SELECT id FROM categories WHERE name=?",[$n]); + $h = fn(array $d): string => json_encode($d); + $std = $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–5pm','Sat'=>'Closed','Sun'=>'Closed']); + $always= $h(['Mon'=>'Open 24 hrs','Tue'=>'Open 24 hrs','Wed'=>'Open 24 hrs','Thu'=>'Open 24 hrs','Fri'=>'Open 24 hrs','Sat'=>'Open 24 hrs','Sun'=>'Open 24 hrs']); + + $D=$cid('Dining & Food'); $SH=$cid('Shopping & Retail'); $LO=$cid('Lodging'); + $HC=$cid('Healthcare'); $ED=$cid('Education'); $FI=$cid('Financial Services'); + $AU=$cid('Automotive'); $PS=$cid('Professional Services'); $RF=$cid('Recreation & Fitness'); + $BP=$cid('Beauty & Personal Care'); $GV=$cid('Government & Civic'); + $LG=$cid('Legal Services'); $CH=$cid('Churches & Faith'); + + $bi = $db->prepare("INSERT INTO businesses(name,slug,category_id,subcategory,description,address,phone,email,website,hours,lat,lng,is_featured)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)"); + + /* ── DINING ─────────────────────────────────────────────────── */ + $biz = [ + ["Castiglia's Italian Eatery",'castiglia-italian',$D,'Italian Restaurant', + "Keyser's most beloved restaurant with 16,000+ Facebook fans and 8,400+ visits recorded. Family recipes passed down for generations: handmade pasta, wood-fired dishes, classic antipasti, and decadent Italian desserts. Known for \"huge servings\" and amazing food. Wed–Thu 11am–9pm, Fri–Sat 11am–9pm, Sun noon–9pm.", + '401 S. Mineral St., Keyser, WV 26726','304-788-1300','','', + $h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'11am–9pm','Thu'=>'11am–9pm','Fri'=>'11am–9pm','Sat'=>'11am–9pm','Sun'=>'12pm–9pm']), + 39.4340,-78.9734,1], + + ['The Candlewyck Inn','candlewyck-inn',$D,'Fine Dining', + "Elegant fine dining in a beautifully restored historic setting. Upscale American cuisine with locally sourced seasonal ingredients. Award-winning wine list. Keyser's premier destination for special occasions and romantic dinners.", + '65 S. Mineral St., Keyser, WV 26726','304-788-6594','','', + $h(['Mon'=>'Closed','Tue'=>'5pm–9pm','Wed'=>'5pm–9pm','Thu'=>'5pm–9pm','Fri'=>'5pm–10pm','Sat'=>'4pm–10pm','Sun'=>'Closed']), + 39.4370,-78.9734,1], + + ["Clancy's Irish Pub",'clancys-irish-pub',$D,'Irish Pub & Bar', + "Keyser's favorite neighborhood pub! Reviewers love the Jalo-Mango Boneless Wings. Cold craft beers on tap, hearty pub fare, sports on every screen, trivia nights on Wednesdays, and live music on weekends. Friendly welcoming atmosphere.", + '777 Armstrong St., Keyser, WV 26726','304-788-1133','','', + $h(['Mon'=>'4pm–12am','Tue'=>'4pm–12am','Wed'=>'4pm–12am','Thu'=>'4pm–12am','Fri'=>'4pm–2am','Sat'=>'12pm–2am','Sun'=>'12pm–10pm']), + 39.4390,-78.9750,1], + + ['North Branch Craft Pub','north-branch-craft-pub',$D,'Craft Brewery & Pub', + "Keyser's craft beer destination. \"Bold flavors without being overly salty or greasy\" — raved by reviewers. Local and regional craft beers on rotating taps. Great pub fare with stunning North Branch river views. Live entertainment regularly. Sister company to Queens Point Coffee and North Branch Ventures.", + '101 Armstrong St., Ste. 1, Keyser, WV 26726','304-790-7125','','', + $h(['Mon'=>'Closed','Tue'=>'4pm–11pm','Wed'=>'4pm–11pm','Thu'=>'4pm–11pm','Fri'=>'3pm–1am','Sat'=>'12pm–1am','Sun'=>'12pm–8pm']), + 39.4392,-78.9750,1], + + ['Queens Point Coffee','queens-point-coffee',$D,'Coffee Shop', + "\"THE best coffee shop I've ever been to\" — visitor review. Named after the iconic Queens Point cliff overlooking Keyser. Iced matcha, lattes, specialty drinks, amazing food, friendly staff, cozy vibe, and merchandise. A must-visit. Sister company to North Branch Ventures and North Branch Pub.", + '101 Armstrong St., Keyser, WV 26726','304-790-7125','','', + $h(['Mon'=>'7am–3pm','Tue'=>'7am–5pm','Wed'=>'7am–5pm','Thu'=>'7am–5pm','Fri'=>'7am–5pm','Sat'=>'8am–5pm','Sun'=>'9am–2pm']), + 39.4392,-78.9750,1], + + ['Royal Restaurant','royal-restaurant',$D,'American / Comfort Food', + "A Keyser classic visited by generations of families. \"Both my parents grew up in Keyser. Their food is amazing.\" Known for hearty breakfast, homemade pies, reuben sandwiches, and burgers. Many vegetarian options. Dine-in, take-out, and delivery available.", + '88 N. Main St., Keyser, WV 26726','304-788-9825','','', + $h(['Mon'=>'6am–8pm','Tue'=>'6am–8pm','Wed'=>'6am–8pm','Thu'=>'6am–8pm','Fri'=>'6am–9pm','Sat'=>'6am–9pm','Sun'=>'7am–3pm']), + 39.4378,-78.9734,0], + + ['Fat Bottom Grille','fat-bottom-grille',$D,'American Bar & Grill', + "\"THE best place in town to get a burger or steak sub.\" Everything is always made fresh. \"The smash burger is juicy and flavored to perfection.\" Huge portions. \"The food is amazing and the price is excellent!!\" Tennessee whiskey burger and chili cheese fries are local legends. Do NOT miss this place.", + 'Keyser, WV 26726','304-788-0000','','', + $h(['Mon'=>'11am–9pm','Tue'=>'11am–9pm','Wed'=>'11am–9pm','Thu'=>'11am–9pm','Fri'=>'11am–10pm','Sat'=>'11am–10pm','Sun'=>'12pm–9pm']), + 39.4360,-78.9740,1], + + ["Denny's",'dennys',$D,'American / Diner', + "Open 24/7. \"Suburb\" experience with professional management. America's diner at Route 220 South. Grand Slams, hearty burgers, and all-day breakfast. Reviewers say the recent improvements make it \"the Denny's we once loved.\"", + '825 S. Mineral St. (US Rt. 220 S), Keyser, WV 26726','304-788-3090','','dennys.com', + $always,39.4300,-78.9730,0], + + ["McDonald's",'mcdonalds',$D,'Fast Food', + "The world's most visited fast food restaurant. Burgers, fries, breakfast sandwiches, McCafé beverages. Drive-through available at 700 S. Mineral St.", + '700 S. Mineral St., Keyser, WV 26726','304-788-0604','','mcdonalds.com', + $h(['Mon'=>'6am–11pm','Tue'=>'6am–11pm','Wed'=>'6am–11pm','Thu'=>'6am–11pm','Fri'=>'6am–12am','Sat'=>'6am–12am','Sun'=>'7am–11pm']), + 39.4310,-78.9730,0], + + ['Burger King','burger-king',$D,'Fast Food', + 'Home of the Whopper! Flame-grilled burgers and crispy fries. Drive-through available at RR #3 Box 3240 (New Creek Hwy). Quick and delicious.', + 'RR#3 Box 3240, New Creek Hwy, Keyser, WV 26726','304-788-0000','','burgerking.com', + $h(['Mon'=>'6am–12am','Tue'=>'6am–12am','Wed'=>'6am–12am','Thu'=>'6am–12am','Fri'=>'6am–1am','Sat'=>'6am–1am','Sun'=>'7am–12am']), + 39.4285,-78.9720,0], + + ["Domino's Pizza",'dominos',$D,'Pizza / Delivery', + 'Hot fresh pizza fast. Carryout at 590 S. Mineral St. or delivered to your door. Online ordering available at dominos.com with live tracking.', + '590 S. Mineral St., Keyser, WV 26726','304-788-6400','','dominos.com', + $h(['Mon'=>'10:30am–12am','Tue'=>'10:30am–12am','Wed'=>'10:30am–12am','Thu'=>'10:30am–12am','Fri'=>'10:30am–1am','Sat'=>'10:30am–1am','Sun'=>'10:30am–12am']), + 39.4330,-78.9730,0], + + ['Dairy Queen Grill & Chill','dairy-queen',$D,'Ice Cream / Fast Food', + "\"One of the best staff at any fast food restaurant. The ice cream and food is pretty great too.\" Signature Blizzard® treats, soft-serve cones, and grill burgers. \"Kid-friendliness: Ice cream, what's more kid-friendly than that?\" A Keyser summer tradition.", + '460 S. Mineral St., Keyser, WV 26726','304-788-1499','','dairyqueen.com', + $h(['Mon'=>'10am–10pm','Tue'=>'10am–10pm','Wed'=>'10am–10pm','Thu'=>'10am–10pm','Fri'=>'10am–10:30pm','Sat'=>'10am–10:30pm','Sun'=>'11am–9pm']), + 39.4320,-78.9730,0], + + ["Fox's Pizza Den",'foxs-pizza',$D,'Pizza', + "\"Kirk Kesner is an amazing local business owner! He supports his community and runs a top notch pizza place! We are so fortunate to have this restaurant in our area!\" Hand-tossed pizzas, subs, wings. A true Keyser staple.", + '567 S. Mineral St., Keyser, WV 26726','304-788-1149','','', + $h(['Mon'=>'11am–10pm','Tue'=>'11am–10pm','Wed'=>'11am–10pm','Thu'=>'11am–10pm','Fri'=>'11am–11pm','Sat'=>'11am–11pm','Sun'=>'12pm–9pm']), + 39.4315,-78.9730,0], + + ['Little Caesars','little-caesars',$D,'Pizza / Fast Food', + 'Hot-N-Ready pizzas at unbeatable prices. No wait. Crazy Bread and Italian favorites. Quick and affordable for the whole family at 30 Armstrong St.', + '30 Armstrong St., Keyser, WV 26726','304-788-7738','','littlecaesars.com', + $h(['Mon'=>'11am–9pm','Tue'=>'11am–9pm','Wed'=>'11am–9pm','Thu'=>'11am–9pm','Fri'=>'11am–10pm','Sat'=>'11am–10pm','Sun'=>'12pm–9pm']), + 39.4395,-78.9750,0], + + ['Subway (Inside Gulf)','subway',$D,'Sandwiches / Fast Food', + 'Fresh-built footlong subs, wraps, and salads at Route 3 Keyser location inside Gulf. "Bold new flavors and classic favorites every day."', + '2 Heskiet St. (Gulf), Keyser, WV 26726','304-788-3613','','subway.com', + $h(['Mon'=>'7am–10pm','Tue'=>'7am–10pm','Wed'=>'7am–10pm','Thu'=>'7am–10pm','Fri'=>'7am–10pm','Sat'=>'8am–10pm','Sun'=>'9am–9pm']), + 39.4260,-78.9720,0], + + ['Taco Bell','taco-bell',$D,'Mexican Fast Food', + 'Live Más! Find your nearest Taco Bell at 41 Plaza Drive. Tacos, burritos, nachos, and Crunchwrap Supremes at affordable prices. Drive-through available.', + '41 Plaza Drive, Keyser, WV 26726','304-788-0000','','tacobell.com', + $h(['Mon'=>'7am–12am','Tue'=>'7am–12am','Wed'=>'7am–12am','Thu'=>'7am–12am','Fri'=>'7am–2am','Sat'=>'7am–2am','Sun'=>'8am–12am']), + 39.4265,-78.9722,0], + + ["Ducky's Bar & Grill",'duckys-bar-grill',$D,'American Bar & Grill', + "\"The ultimate combination of bar and grill. Come check out their new location in Keyser. The friendly staff will ensure you leave full and happy!\" Famous Tennessee whiskey burger and chili cheese fries. New Keyser location.", + 'Keyser, WV 26726','304-788-0000','','', + $h(['Mon'=>'11am–11pm','Tue'=>'11am–11pm','Wed'=>'11am–11pm','Thu'=>'11am–11pm','Fri'=>'11am–1am','Sat'=>'11am–1am','Sun'=>'12pm–9pm']), + 39.4358,-78.9738,0], + + ["Hoover's Bar & Grill",'hoovers-bar-grill',$D,'Bar & Grill', + '"If you enjoy smoked food, live entertainment on an outside stage, and cold beer, Hoover\'s is the place to be. A fun atmosphere with one-of-a-kind employees to make your trip fun!" Outdoor stage, smoked meats, great vibes.', + 'Keyser area, WV 26726','304-788-0000','','', + $h(['Mon'=>'11am–10pm','Tue'=>'11am–10pm','Wed'=>'11am–10pm','Thu'=>'11am–10pm','Fri'=>'11am–12am','Sat'=>'11am–12am','Sun'=>'12pm–9pm']), + 39.4355,-78.9736,0], + + ['Long Spring Restaurant','long-spring',$D,'Chinese / American', + '"I have been ordering from Long Spring for many years. I love their food." Popular for vegetable fried rice, shrimp dishes, and American favorites. Longtime Keyser community staple.', + 'Keyser, WV 26726','304-788-0000','','', + $h(['Mon'=>'11am–9pm','Tue'=>'11am–9pm','Wed'=>'11am–9pm','Thu'=>'11am–9pm','Fri'=>'11am–10pm','Sat'=>'11am–10pm','Sun'=>'12pm–9pm']), + 39.4362,-78.9736,0], + + ["Jin's Asian Cuisine 2",'jins-asian-cuisine',$D,'Asian / Chinese', + "Fresh Chinese and Asian cuisine made to order. Located at 196 N. Tornado Way. Known for orange chicken, bourbon chicken, lo mein, fried rice, and crab rangoon.", + '196 N. Tornado Way #11, Keyser, WV 26726','304-788-2222','','', + $h(['Mon'=>'11am–9pm','Tue'=>'11am–9pm','Wed'=>'11am–9pm','Thu'=>'11am–9pm','Fri'=>'11am–10pm','Sat'=>'11am–10pm','Sun'=>'12pm–9pm']), + 39.4355,-78.9730,0], + + ["Martie's Hot Dog Stand",'marties-hot-dogs',$D,'Hot Dogs / Street Food', + "A beloved Keyser institution serving classic hot dogs with all the fixings for generations. Available on Uber Eats and DoorDash. Simple, delicious, and uniquely Keyser. A local landmark at 96 N. Main St.", + '96 N. Main St., Keyser, WV 26726','304-788-7690','','', + $h(['Mon'=>'10am–6pm','Tue'=>'10am–6pm','Wed'=>'10am–6pm','Thu'=>'10am–6pm','Fri'=>'10am–7pm','Sat'=>'10am–7pm','Sun'=>'Closed']), + 39.4375,-78.9734,0], + + ['M&S Desserts & Bakery','ms-desserts-bakery',$D,'Bakery / Desserts', + "Artisan baked goods, custom cakes, pastries, and sweet treats made fresh daily. Keyser's premier bakery destination at 133 N. Main St.", + '133 N. Main St., Keyser, WV 26726','304-788-3838','','', + $h(['Mon'=>'7am–5pm','Tue'=>'7am–5pm','Wed'=>'7am–5pm','Thu'=>'7am–5pm','Fri'=>'7am–5pm','Sat'=>'8am–4pm','Sun'=>'Closed']), + 39.4374,-78.9734,0], + + ["Marla's Main Street Bakery",'marlas-bakery',$D,'Bakery', + "Homestyle baked breads, rolls, and pastries made fresh. Located at 19 N. Main St. A delightful stop for fresh-baked goods any morning.", + '19 N. Main St., Keyser, WV 26726','304-359-1348','','', + $h(['Mon'=>'6am–4pm','Tue'=>'6am–4pm','Wed'=>'6am–4pm','Thu'=>'6am–4pm','Fri'=>'6am–4pm','Sat'=>'7am–3pm','Sun'=>'Closed']), + 39.4376,-78.9734,0], + + ['North Branch Brewing Co.','north-branch-brewing',$D,'Microbrewery', + '"A start-up brewery offering other local breweries as well. There is a menu with a good selection of items with specials. Check out their Facebook page for live entertainment and special events." Local craft beers on tap.', + 'Keyser area, WV 26726','304-790-7125','','', + $h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'4pm–10pm','Thu'=>'4pm–10pm','Fri'=>'3pm–12am','Sat'=>'12pm–12am','Sun'=>'12pm–8pm']), + 39.4390,-78.9748,0], + + ['Happy Farmers Market','happy-farmers-market',$D,'Farmers Market / Local Produce', + 'Fresh local produce, seasonal items, and local goods. Located in the parking lot at 104 Shamrock Drive. Support local Mineral County farmers and producers.', + 'Parking Lot of 104 Shamrock Dr., Keyser, WV 26726','304-813-1576','','', + $h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'Closed','Thu'=>'Closed','Fri'=>'4pm–7pm','Sat'=>'9am–1pm','Sun'=>'Closed']), + 39.4350,-78.9720,0], + + ['7-Eleven','seven-eleven',$D,'Convenience / Food', + 'Your neighborhood convenience store with hot food options, beverages, snacks, and everyday essentials. Located at 2 S. Mineral St.', + '2 S. Mineral St., Keyser, WV 26726','304-788-0000','','7-eleven.com', + $always,39.4364,-78.9734,0], + + ['Stop-By Grocery / Convenience','stop-by-grocery',$D,'Grocery / Convenience', + 'Local convenience and grocery store at 420 W. Piedmont St. Quick stop for everyday essentials, fresh items, and snacks.', + '420 W. Piedmont St., Keyser, WV 26726','304-788-0123','','', + $h(['Mon'=>'6am–10pm','Tue'=>'6am–10pm','Wed'=>'6am–10pm','Thu'=>'6am–10pm','Fri'=>'6am–10pm','Sat'=>'6am–10pm','Sun'=>'7am–9pm']), + 39.4372,-78.9748,0], + + /* ── SHOPPING & RETAIL ──────────────────────────────────────── */ + ["Wayne's Country Meats",'waynes-country-meats',$SH,'Butcher / Specialty Meats', + 'Premium quality meats from local farms. Custom cuts, specialty items, fresh jerky, and the widest selection of quality meats in Mineral County. A must-visit for meat lovers and BBQ enthusiasts at 670 Armstrong St.', + '670 Armstrong St., Keyser, WV 26726','304-788-5956','','', + $h(['Mon'=>'8am–6pm','Tue'=>'8am–6pm','Wed'=>'8am–6pm','Thu'=>'8am–6pm','Fri'=>'8am–6pm','Sat'=>'8am–5pm','Sun'=>'Closed']), + 39.4395,-78.9750,1], + + ['Save-A-Lot','save-a-lot',$SH,'Grocery', + "Affordable grocery shopping for the whole family. Fresh produce, quality meats, dairy, and pantry essentials at everyday low prices. Keyser's budget-friendly grocery option on S. Mineral St.", + 'S. Mineral St., Keyser, WV 26726','304-788-7570','','save-a-lot.com', + $h(['Mon'=>'8am–9pm','Tue'=>'8am–9pm','Wed'=>'8am–9pm','Thu'=>'8am–9pm','Fri'=>'8am–9pm','Sat'=>'8am–9pm','Sun'=>'9am–7pm']), + 39.4298,-78.9728,0], + + ['AutoZone','autozone',$SH,'Auto Parts', + "America's leading auto parts retailer at 2 W. Piedmont St. Free battery testing, loaner tool program, and expert staff. Open every day.", + '2 W. Piedmont St., Keyser, WV 26726','304-788-9058','','autozone.com', + $h(['Mon'=>'7:30am–9pm','Tue'=>'7:30am–9pm','Wed'=>'7:30am–9pm','Thu'=>'7:30am–9pm','Fri'=>'7:30am–9pm','Sat'=>'7:30am–9pm','Sun'=>'9am–8pm']), + 39.4378,-78.9734,0], + + ['NAPA Auto Parts','napa-auto-parts',$SH,'Auto Parts', + "The professional's choice for auto parts. Quality parts for all makes and models. Expert advice from knowledgeable staff.", + '540 S. Mineral St., Keyser, WV 26726','304-788-3831','','napaonline.com', + $h(['Mon'=>'8am–6pm','Tue'=>'8am–6pm','Wed'=>'8am–6pm','Thu'=>'8am–6pm','Fri'=>'8am–6pm','Sat'=>'8am–5pm','Sun'=>'Closed']), + 39.4317,-78.9730,0], + + ['Advance Auto Parts','advance-auto',$SH,'Auto Parts', + 'Quality auto parts and accessories. Battery testing, check-engine diagnostics, and wiper installation. Route 3 Keyser location.', + 'Rt. 3 Box 3134, Keyser, WV 26726','304-788-0011','','advanceautoparts.com', + $h(['Mon'=>'7:30am–9pm','Tue'=>'7:30am–9pm','Wed'=>'7:30am–9pm','Thu'=>'7:30am–9pm','Fri'=>'7:30am–9pm','Sat'=>'7:30am–9pm','Sun'=>'9am–8pm']), + 39.4260,-78.9718,0], + + ['CVS Pharmacy','cvs',$SH,'Pharmacy / Health & Beauty', + 'Your neighborhood health and wellness destination at 45 S. Mineral St. Prescriptions, health products, beauty supplies, and household essentials.', + '45 S. Mineral St., Keyser, WV 26726','304-788-3443','','cvs.com', + $h(['Mon'=>'8am–9pm','Tue'=>'8am–9pm','Wed'=>'8am–9pm','Thu'=>'8am–9pm','Fri'=>'8am–9pm','Sat'=>'9am–6pm','Sun'=>'10am–6pm']), + 39.4368,-78.9734,0], + + ['AT&T Store','att-store',$SH,'Telecommunications', + 'Latest smartphones, tablets, and wireless plans at 862 S. Mineral St. Expert staff to find the right device and plan for your needs.', + '862 S. Mineral St., Keyser, WV 26726','304-788-9000','','att.com', + $h(['Mon'=>'10am–7pm','Tue'=>'10am–7pm','Wed'=>'10am–7pm','Thu'=>'10am–7pm','Fri'=>'10am–7pm','Sat'=>'10am–7pm','Sun'=>'12pm–5pm']), + 39.4292,-78.9728,0], + + ["Christy's Florist",'christys-florist',$SH,'Florist', + 'Fresh floral arrangements for every occasion — weddings, funerals, anniversaries, and celebrations. Delivery throughout Mineral County. 81 N. Main St.', + '81 N. Main St., Keyser, WV 26726','304-788-3679','','', + $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–6pm','Sat'=>'9am–3pm','Sun'=>'Closed']), + 39.4382,-78.9734,0], + + ['Southern States','southern-states',$SH,'Farm & Garden Supply', + 'Agricultural supplies, pet food, hardware, seed, fertilizer, and home and garden products. Serving Mineral County farmers and homeowners since 1923. 201 Patrick St.', + '201 Patrick St., Keyser, WV 26726','304-788-2317','','southernstates.com', + $h(['Mon'=>'7:30am–5:30pm','Tue'=>'7:30am–5:30pm','Wed'=>'7:30am–5:30pm','Thu'=>'7:30am–5:30pm','Fri'=>'7:30am–5:30pm','Sat'=>'8am–3pm','Sun'=>'Closed']), + 39.4410,-78.9755,0], + + ['Back Alley Crafts & Tarts','back-alley-crafts',$SH,'Crafts & Gifts', + 'Unique handmade crafts, locally made artisan goods, and specialty gifts at 226 S. Main St. Find one-of-a-kind treasures and support local artisans.', + '226 S. Main St., Keyser, WV 26726','304-813-6256','','', + $h(['Mon'=>'10am–5pm','Tue'=>'10am–5pm','Wed'=>'10am–5pm','Thu'=>'10am–5pm','Fri'=>'10am–6pm','Sat'=>'10am–4pm','Sun'=>'Closed']), + 39.4357,-78.9734,0], + + ['Heaven Sent Creations','heaven-sent-creations',$SH,'Gifts & Crafts', + 'Inspirational gifts, custom creations, home décor, and unique finds for every occasion at 117 Armstrong St.', + '117 Armstrong St., Keyser, WV 26726','304-790-7402','','', + $h(['Mon'=>'10am–5pm','Tue'=>'10am–5pm','Wed'=>'10am–5pm','Thu'=>'10am–5pm','Fri'=>'10am–5pm','Sat'=>'10am–4pm','Sun'=>'Closed']), + 39.4393,-78.9750,0], + + ['Second Hand City','second-hand-city',$SH,'Thrift / Secondhand', + "Keyser's thrift store at 232 S. Main St. Great deals on clothing, housewares, furniture, electronics, and more. New inventory arriving regularly.", + '232 S. Main St., Keyser, WV 26726','304-788-4088','','', + $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–6pm','Sat'=>'9am–5pm','Sun'=>'Closed']), + 39.4357,-78.9734,0], + + ['S&H Variety','sh-variety',$SH,'Variety Store', + 'Your local variety store for household items, gifts, seasonal décor, and everyday essentials at 79 N. Main St. Friendly local service.', + '79 N. Main St., Keyser, WV 26726','304-788-1800','','', + $h(['Mon'=>'9am–5:30pm','Tue'=>'9am–5:30pm','Wed'=>'9am–5:30pm','Thu'=>'9am–5:30pm','Fri'=>'9am–6pm','Sat'=>'9am–5pm','Sun'=>'Closed']), + 39.4380,-78.9734,0], + + ['Say It With Thread Embroidery & Gifts','say-it-with-thread',$SH,'Embroidery / Custom Gifts', + 'Custom embroidery, personalized gifts, and apparel at 527 S. Mineral St. Perfect for business logos, team gear, and unique personalized items.', + '527 S. Mineral St., Keyser, WV 26726','304-788-4739','','', + $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–5pm','Sat'=>'10am–3pm','Sun'=>'Closed']), + 39.4318,-78.9730,0], + + ['Nu View Video & Tobacco','nu-view-video-tobacco',$SH,'Video & Tobacco', + 'Videos, tobacco products, and related accessories at 397 New Creek Hwy. Local retailer serving Keyser.', + '397 New Creek Hwy, Keyser, WV 26726','304-788-1769','','', + $h(['Mon'=>'10am–8pm','Tue'=>'10am–8pm','Wed'=>'10am–8pm','Thu'=>'10am–8pm','Fri'=>'10am–9pm','Sat'=>'10am–9pm','Sun'=>'12pm–7pm']), + 39.4285,-78.9718,0], + + ['Thunder Hill Outfitters','thunder-hill-outfitters',$SH,'Hunting & Fishing Supplies', + "Hunting and fishing supplies, gear, licenses, and expert local knowledge of Mineral County's outdoors. Located at 105 Armstrong St.", + '105 Armstrong St., Keyser, WV 26726','304-790-7125','','', + $h(['Mon'=>'9am–6pm','Tue'=>'9am–6pm','Wed'=>'9am–6pm','Thu'=>'9am–6pm','Fri'=>'9am–7pm','Sat'=>'8am–7pm','Sun'=>'10am–4pm']), + 39.4392,-78.9750,0], + + ["Robin's Country Crafts",'robins-country-crafts',$SH,'Country Crafts & Décor', + 'Charming country crafts, home décor, and unique gifts at 370 S. Mineral St. Mountain-country flair and one-of-a-kind finds.', + '370 S. Mineral St., Keyser, WV 26726','304-788-0445','','', + $h(['Mon'=>'10am–5pm','Tue'=>'10am–5pm','Wed'=>'10am–5pm','Thu'=>'10am–5pm','Fri'=>'10am–5pm','Sat'=>'10am–4pm','Sun'=>'Closed']), + 39.4340,-78.9732,0], + + ['West Virginia Outdoor Power','wv-outdoor-power',$SH,'Outdoor Power Equipment', + 'Sales and service of outdoor power equipment — lawn mowers, chainsaws, generators, and more. Authorized service center at 580 S. Mineral St.', + '580 S. Mineral St., Keyser, WV 26726','304-788-0008','','', + $h(['Mon'=>'8am–5pm','Tue'=>'8am–5pm','Wed'=>'8am–5pm','Thu'=>'8am–5pm','Fri'=>'8am–5pm','Sat'=>'8am–12pm','Sun'=>'Closed']), + 39.4312,-78.9730,0], + + ['Urice Supply','urice-supply',$SH,'Building Supply', + 'Building materials, hardware, and supply needs at 465 Armstrong St. Serving Mineral County contractors and homeowners.', + '465 Armstrong St., Keyser, WV 26726','304-788-3215','','', + $h(['Mon'=>'7:30am–5pm','Tue'=>'7:30am–5pm','Wed'=>'7:30am–5pm','Thu'=>'7:30am–5pm','Fri'=>'7:30am–5pm','Sat'=>'8am–12pm','Sun'=>'Closed']), + 39.4395,-78.9752,0], + + ['Premier Rental Purchase','premier-rental',$SH,'Rent-to-Own', + 'Rent-to-own furniture, electronics, and appliances at 140 Keyser Mall. Flexible payments, no credit check, quality name brands.', + '140 Keyser Mall, Keyser, WV 26726','304-790-7136','','', + $h(['Mon'=>'9am–7pm','Tue'=>'9am–7pm','Wed'=>'9am–7pm','Thu'=>'9am–7pm','Fri'=>'9am–7pm','Sat'=>'9am–5pm','Sun'=>'Closed']), + 39.4355,-78.9720,0], + + /* ── LODGING ─────────────────────────────────────────────────── */ + ['SureStay Plus by Best Western Keyser','surestay-best-western',$LO,'Hotel', + 'Comfortable hotel in the scenic Potomac Highlands. Free hot breakfast daily, free WiFi, indoor pool, and fitness center. Pet-friendly. Rated 3/5 stars. Convenient to PSC, Potomac Valley Hospital, and all area attractions. Best Western-branded quality at affordable prices.', + 'New Creek Hwy, Keyser, WV 26726','304-788-0000','','bestwestern.com', + $always,39.4298,-78.9705,1], + + ['Keyser Inn','keyser-inn',$LO,'Motel', + 'Affordable, clean accommodations in a convenient Keyser location. Continental breakfast, free WiFi, and pet-friendly policy. Great base for PSC visits and area attractions.', + 'Keyser, WV 26726','304-788-0000','','', + $always,39.4360,-78.9734,0], + + /* ── HEALTHCARE ─────────────────────────────────────────────── */ + ['WVU Medicine Potomac Valley Hospital','pvh',$HC,'Hospital / Medical Center', + 'Full-service community hospital and part of the WVU Medicine health system. Emergency department, surgical services, maternity care, cardiac care, comprehensive outpatient clinics. Located at 100 Pin Oak Ln.', + '100 Pin Oak Ln., Keyser, WV 26726','304-597-3500','','wvumedicine.org', + $always,39.4275,-78.9725,1], + + ['Med-a-Save Pharmacy','med-a-save',$HC,'Independent Pharmacy', + "Local independent pharmacy at 818 S. Mineral St. Personalized service, prescription filling, compounding, and immunizations. The local choice when you want pharmacists who know your name.", + '818 S. Mineral St., Keyser, WV 26726','304-788-6010','','', + $h(['Mon'=>'9am–6pm','Tue'=>'9am–6pm','Wed'=>'9am–6pm','Thu'=>'9am–6pm','Fri'=>'9am–6pm','Sat'=>'9am–1pm','Sun'=>'Closed']), + 39.4287,-78.9728,0], + + ['Recovery Care LLC','recovery-care',$HC,'Recovery / Counseling', + 'Providing compassionate recovery care and counseling services for individuals and families in Mineral County. 37 Carroll Ave.', + '37 Carroll Ave., Keyser, WV 26726','304-790-7467','','', + $std,39.4360,-78.9738,0], + + /* ── EDUCATION ──────────────────────────────────────────────── */ + ['Potomac State College of WVU','potomac-state-college',$ED,'Community College / University', + "WVU Potomac State College — the WVU campus in Keyser's Eastern Panhandle, founded 1901 on historic Fort Fuller (Civil War site commanded by future president Benjamin Harrison and Lew Wallace, author of Ben-Hur). Associate and bachelor's degrees in 40+ programs. Home of the Catamounts.", + '101 Fort Ave., Keyser, WV 26726','304-788-6800','','potomacstatecollege.edu', + $std,39.4368,-78.9715,1], + + ['Mineral County Schools','mineral-county-schools',$ED,'Public School System', + 'Serving Mineral County through Keyser High School, Keyser Middle School, Keyser Primary, and more. Mineral County Board of Education at 40 Fir Street.', + '40 Fir Street, Keyser, WV 26726','304-788-4200','','mineralcountyboe.com', + $std,39.4370,-78.9738,0], + + ['Mineral County Technical Center','mineral-county-tech',$ED,'Vocational / Technical School', + 'Career and technical education for high school students and adults. Programs in welding, cosmetology, culinary arts, construction trades, healthcare, and information technology.', + 'Keyser, WV 26726','304-788-5890','','', + $std,39.4362,-78.9730,0], + + ['Mineral County Public Library','mineral-county-library',$ED,'Public Library', + 'Community hub for knowledge, culture, and lifelong learning. Books, digital resources, internet access, printing, and community programs for all ages.', + 'Keyser, WV 26726','304-788-3100','','', + $h(['Mon'=>'10am–7pm','Tue'=>'10am–7pm','Wed'=>'10am–7pm','Thu'=>'10am–7pm','Fri'=>'10am–5pm','Sat'=>'10am–4pm','Sun'=>'Closed']), + 39.4370,-78.9734,0], + + /* ── FINANCIAL SERVICES ─────────────────────────────────────── */ + ['First United Bank & Trust','first-united-bank',$FI,'Community Bank', + 'Community banking rooted in West Virginia. Personal checking, savings, mortgages, business accounts. 29 West Southern Dr.', + '29 West Southern Dr., Keyser, WV 26726','304-788-2552','','mybank.com', + $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–5:30pm','Sat'=>'9am–12pm','Sun'=>'Closed']), + 39.4372,-78.9740,0], + + ['M&T Bank','m-t-bank',$FI,'Bank', + 'Full-service personal, small business, and commercial banking. 24/7 ATM at 67 N. Main St. Online and mobile banking available.', + '67 N. Main St., Keyser, WV 26726','304-788-6782','','mtb.com', + $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–6pm','Sat'=>'9am–12pm','Sun'=>'Closed']), + 39.4380,-78.9734,0], + + ['BB&T Bank (Truist)','truist-bank',$FI,'Bank', + 'Now Truist Bank. Full-service banking with personal, business, and mortgage products. 220 S. Eagle Ln. ATM on premises.', + '220 S. Eagle Ln., Keyser, WV 26726','304-788-3111','','truist.com', + $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–5:30pm','Sat'=>'9am–12pm','Sun'=>'Closed']), + 39.4362,-78.9732,0], + + ['H&R Block','hr-block',$FI,'Tax Preparation', + 'Professional tax preparation at 66 N. Main St. In-person and virtual filing. Maximum refund guaranteed.', + '66 N. Main St., Keyser, WV 26726','304-788-2926','','hrblock.com', + $h(['Mon'=>'9am–6pm','Tue'=>'9am–6pm','Wed'=>'9am–6pm','Thu'=>'9am–6pm','Fri'=>'9am–6pm','Sat'=>'9am–5pm','Sun'=>'Closed']), + 39.4380,-78.9734,0], + + ['Jackson Hewitt','jackson-hewitt',$FI,'Tax Preparation', + 'Tax preparation services at 77 N. Main St. Expert help filing your federal and state taxes. Refund advance available.', + '77 N. Main St., Keyser, WV 26726','304-788-5650','','jacksonhewitt.com', + $h(['Mon'=>'9am–7pm','Tue'=>'9am–7pm','Wed'=>'9am–7pm','Thu'=>'9am–7pm','Fri'=>'9am–7pm','Sat'=>'9am–6pm','Sun'=>'Closed']), + 39.4380,-78.9734,0], + + ['Liberty Tax Service','liberty-tax',$FI,'Tax Preparation', + 'Tax preparation services at 690 S. Mineral St., Suite A. Filing for individuals and businesses. Fast refunds available.', + '690 S. Mineral St. Suite A, Keyser, WV 26726','304-405-2572','','libertytax.com', + $h(['Mon'=>'9am–7pm','Tue'=>'9am–7pm','Wed'=>'9am–7pm','Thu'=>'9am–7pm','Fri'=>'9am–7pm','Sat'=>'9am–5pm','Sun'=>'Closed']), + 39.4305,-78.9730,0], + + ['Tax Time','tax-time',$FI,'Tax Preparation', + 'Local tax preparation services at 528 S. Mineral St. Helping Keyser residents file their taxes accurately and affordably.', + '528 S. Mineral St., Keyser, WV 26726','304-788-1947','','', + $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–5pm','Sat'=>'9am–3pm','Sun'=>'Closed']), + 39.4320,-78.9730,0], + + /* ── AUTOMOTIVE ─────────────────────────────────────────────── */ + ["Boddy's Automotive",'boddys-auto',$AU,'Full Service Auto Repair', + 'Trusted local auto repair for all makes and models at 220 Armstrong St. Engine, transmission, brakes, alignment, tires, and electrical. Quality work at honest prices.', + '220 Armstrong St., Keyser, WV 26726','304-788-5511','','', + $h(['Mon'=>'8am–5pm','Tue'=>'8am–5pm','Wed'=>'8am–5pm','Thu'=>'8am–5pm','Fri'=>'8am–5pm','Sat'=>'Closed','Sun'=>'Closed']), + 39.4392,-78.9750,0], + + ['Gimme A Brake Auto','gimme-a-brake',$AU,'Brakes / Tires / Oil Changes', + 'Fast, friendly, and affordable auto care at 375 W. Piedmont St. Brakes, tires, oil changes, and full mechanical service. No appointment needed for routine maintenance.', + '375 West Piedmont St., Keyser, WV 26726','304-788-7810','','', + $h(['Mon'=>'8am–5:30pm','Tue'=>'8am–5:30pm','Wed'=>'8am–5:30pm','Thu'=>'8am–5:30pm','Fri'=>'8am–5:30pm','Sat'=>'8am–12pm','Sun'=>'Closed']), + 39.4378,-78.9748,0], + + ['Cell Phone Repair Near Me','cell-phone-repair',$AU,'Electronics Repair', + 'Fast and affordable smartphone, tablet, and device repair at 82 N. Main St. Screen repairs, battery replacements, water damage, and more.', + '82 N. Main St., Keyser, WV 26726','304-209-8375','','', + $h(['Mon'=>'9am–6pm','Tue'=>'9am–6pm','Wed'=>'9am–6pm','Thu'=>'9am–6pm','Fri'=>'9am–6pm','Sat'=>'10am–4pm','Sun'=>'Closed']), + 39.4380,-78.9734,0], + + /* ── PROFESSIONAL SERVICES ──────────────────────────────────── */ + ['Boggs Supply and Rental Center','boggs-supply',$PS,'Equipment Rental / Supply', + 'Tools, equipment, and supplies for rent or purchase at 464 Harley O Staggers Sr. Dr. Home improvement, construction, landscaping, and event equipment.', + '464 Harley O Staggers Sr. Dr., Keyser, WV 26726','304-788-1617','','', + $h(['Mon'=>'7:30am–5pm','Tue'=>'7:30am–5pm','Wed'=>'7:30am–5pm','Thu'=>'7:30am–5pm','Fri'=>'7:30am–5pm','Sat'=>'8am–12pm','Sun'=>'Closed']), + 39.4382,-78.9748,0], + + ['Crystal Clear Services of WV','crystal-clear',$PS,'Cleaning Services', + 'Professional commercial and residential cleaning services at 237 Lincoln St. Reliable, thorough, and detail-oriented. Serving Keyser and Mineral County.', + '237 Lincoln St., Keyser, WV 26726','304-788-2119','','', + $std,39.4368,-78.9730,0], + + /* ── LEGAL SERVICES ─────────────────────────────────────────── */ + ['Legal Aid of WV','legal-aid',$LG,'Legal Aid', + 'Free civil legal aid for eligible low-income West Virginians at 251 W. Piedmont St. Serving Mineral County with housing, family law, consumer, and benefits issues.', + '251 W. Piedmont St., Keyser, WV 26726','304-788-6770','','lawv.net', + $std,39.4374,-78.9750,0], + + ['Staggers & Staggers','staggers-staggers',$LG,'Law Office', + 'Legal representation for Mineral County at 190 Center St. Personal injury, real estate, estate planning, and general practice.', + '190 Center St., Keyser, WV 26726','304-788-5749','','', + $std,39.4370,-78.9738,0], + + ['Collins Law Firm','collins-law',$LG,'Law Office', + 'Legal services for Mineral County residents at 155 Armstrong St. Handling diverse civil and legal matters.', + '155 Armstrong St., Keyser, WV 26726','304-788-2582','','', + $std,39.4392,-78.9750,0], + + /* ── RECREATION & FITNESS ───────────────────────────────────── */ + ['North Branch Ventures','north-branch-ventures',$RF,'Kayak Rental / Outdoor Recreation', + "\"We have enjoyed our local river and wish to share that with others.\" Kayak and river supply rentals on the beautiful North Branch Potomac. Guided river experiences, fishing supplies. Listed on TripAdvisor's top 5 things to do in Keyser. Sister company to Queens Point Coffee and North Branch Pub.", + '101 Armstrong St., Keyser, WV 26726','304-790-7125','','', + $h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'10am–6pm','Thu'=>'10am–6pm','Fri'=>'9am–7pm','Sat'=>'8am–7pm','Sun'=>'8am–5pm']), + 39.4392,-78.9750,1], + + ['PSC Recreation Center','psc-rec-center',$RF,'Fitness Center / Pool', + "\"Full Service Fitness Center with indoor pool, hot tub, strength and aerobics, group exercise classes, and more! We offer annual, semester and daily rates.\" Listed on TripAdvisor's top 5 things to do in Keyser. Open to the public.", + 'J. Edward Kelley Complex, 101 Fort Ave., Keyser, WV','304-788-6800','','potomacstatecollege.edu', + $h(['Mon'=>'6am–9pm','Tue'=>'6am–9pm','Wed'=>'6am–9pm','Thu'=>'6am–9pm','Fri'=>'6am–8pm','Sat'=>'8am–6pm','Sun'=>'12pm–6pm']), + 39.4368,-78.9715,0], + + ['Mineral County Parks & Recreation','mineral-county-parks',$RF,'Parks & Recreation', + "County-managed parks including Larenim Park — 365 acres with two pavilions (10 tables each), amphitheater (600 seating), sports fields, approximately 5 miles of trails, two fishing ponds, and an arboretum under construction. Also includes New Creek Trail with 4 miles of rail/trail and 1 mile of river frontage.", + 'Keyser, WV 26726','304-788-3066','','mineralwv.gov', + $h(['Mon'=>'Open Daily','Tue'=>'Open Daily','Wed'=>'Open Daily','Thu'=>'Open Daily','Fri'=>'Open Daily','Sat'=>'Open Daily','Sun'=>'Open Daily']), + 39.4350,-78.9720,0], + + ['Polish Pines Golf Course','polish-pines-golf',$RF,'Golf Course', + 'Scenic public golf course set amid the rolling West Virginia hills near Keyser. Well-maintained fairways, clubhouse, and equipment rentals. Open to all skill levels.', + 'Keyser area, WV 26726','304-788-0000','','', + $h(['Mon'=>'Dawn–Dusk','Tue'=>'Dawn–Dusk','Wed'=>'Dawn–Dusk','Thu'=>'Dawn–Dusk','Fri'=>'Dawn–Dusk','Sat'=>'Dawn–Dusk','Sun'=>'Dawn–Dusk']), + 39.4420,-78.9700,0], + + /* ── BEAUTY & PERSONAL CARE ─────────────────────────────────── */ + ['Eclips Hair Salon','eclips',$BP,'Hair Salon', + 'Full-service hair salon at 153 S. Mineral St. Cuts, color, styling, and treatments for men and women.', + '153 S. Mineral St., Keyser, WV 26726','304-788-5578','','', + $h(['Mon'=>'Closed','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–6pm','Sat'=>'8am–4pm','Sun'=>'Closed']), + 39.4352,-78.9733,0], + + ['Hair Depot','hair-depot',$BP,'Hair Salon / Barber', + 'Haircuts, styling, and grooming for the whole family at 41 Ward Ave. Affordable prices and friendly service.', + '41 Ward Ave., Keyser, WV 26726','304-788-9100','','', + $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–5pm','Sat'=>'8am–4pm','Sun'=>'Closed']), + 39.4360,-78.9725,0], + + ['In The Skin II','in-the-skin',$BP,'Tattoo & Body Art', + 'Professional tattoo and body art studio at 129 N. Main St. Custom designs, cover-ups, and piercings by experienced artists.', + '129 N. Main St., Keyser, WV 26726','304-597-2070','','', + $h(['Mon'=>'11am–7pm','Tue'=>'11am–7pm','Wed'=>'11am–7pm','Thu'=>'11am–7pm','Fri'=>'11am–8pm','Sat'=>'10am–6pm','Sun'=>'Closed']), + 39.4373,-78.9734,0], + + ["Mandi's Mane Street Studio",'mandis-salon',$BP,'Hair Salon', + "Full-service salon at 92 N. Main St. Cuts, color, perms, highlights, and special occasion styling for all ages.", + '92 N. Main St., Keyser, WV 26726','304-788-6634','','', + $h(['Mon'=>'Closed','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–5pm','Sat'=>'8am–3pm','Sun'=>'Closed']), + 39.4379,-78.9734,0], + + /* ── GOVERNMENT & CIVIC ─────────────────────────────────────── */ + ['Mineral County Courthouse','mineral-county-courthouse',$GV,'County Government', + "Listed on the National Register of Historic Places, this 1868 courthouse is the architectural crown jewel of downtown Keyser. Houses all county offices including clerk's office, assessor, county commission, and circuit court.", + 'Keyser, WV 26726','304-788-3003','','mineralwv.gov', + $h(['Mon'=>'8:30am–4:30pm','Tue'=>'8:30am–4:30pm','Wed'=>'8:30am–4:30pm','Thu'=>'8:30am–4:30pm','Fri'=>'8:30am–4:30pm','Sat'=>'Closed','Sun'=>'Closed']), + 39.4385,-78.9738,0], + + ['City of Keyser','city-of-keyser',$GV,'City Government', + "Official offices of the City of Keyser, WV. City council, mayor's office, utilities, and municipal services for the county seat of Mineral County.", + 'Keyser, WV 26726','304-788-0222','','cityofkeyser.com', + $h(['Mon'=>'8:30am–4:30pm','Tue'=>'8:30am–4:30pm','Wed'=>'8:30am–4:30pm','Thu'=>'8:30am–4:30pm','Fri'=>'8:30am–4:30pm','Sat'=>'Closed','Sun'=>'Closed']), + 39.4375,-78.9734,0], + + ['Mineral County Tourism Visitors Center','mineral-county-tourism',$GV,'Tourism / Visitors Center', + '167 S. Mineral Street — Your starting point for exploring Mineral County! Brochures, maps, event information, and friendly staff. "We strive to promote all sectors of tourism including beautiful views, outdoor recreation, delicious food, and rich local history." Call 304-790-7081 or email mineralcocvb@gmail.com.', + '167 S. Mineral St., Keyser, WV 26726','304-790-7081','mineralcocvb@gmail.com','govisitmineralwv.com', + $h(['Mon'=>'9am–4pm','Tue'=>'9am–4pm','Wed'=>'9am–4pm','Thu'=>'9am–4pm','Fri'=>'9am–4pm','Sat'=>'Call Ahead','Sun'=>'Closed']), + 39.4358,-78.9734,1], + + ['Mineral County Historical Society Museum','mineral-county-historical',$GV,'Museum / Historical Society', + '"Recently opened, this museum tracks the history of Mineral County and Keyser, WV with displays of its people, its history, and its culture." Listed on TripAdvisor\'s top attractions. Open by appointment only and on special days — please call before visiting.', + 'Keyser, WV 26726','304-788-3066','','', + $h(['Mon'=>'By Appt','Tue'=>'By Appt','Wed'=>'By Appt','Thu'=>'By Appt','Fri'=>'By Appt','Sat'=>'Special Events','Sun'=>'Special Events']), + 39.4378,-78.9734,0], + + /* ── CHURCHES ───────────────────────────────────────────────── */ + ['Keyser First United Methodist Church','keyser-umc',$CH,'Methodist Church', + 'Welcoming United Methodist congregation at 32 N. Davis St. serving Keyser since the 19th century. Sunday worship, community programs, and outreach ministries.', + '32 N. Davis St., Keyser, WV 26726','304-788-2522','','', + $h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'Closed','Thu'=>'Closed','Fri'=>'Closed','Sat'=>'Closed','Sun'=>'Worship 11am']), + 39.4372,-78.9730,0], + + ['Living Faith Fellowship','living-faith',$CH,'Non-Denominational Church', + 'Vibrant non-denominational Christian fellowship at 1 N. Main St. Contemporary worship, small groups, and community ministry. Wednesday evening service.', + '1 N. Main St., Keyser, WV 26726','304-788-1910','','', + $h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'7pm','Thu'=>'Closed','Fri'=>'Closed','Sat'=>'Closed','Sun'=>'Worship 10:30am']), + 39.4376,-78.9734,0], + + ['Trinity Lutheran Church','trinity-lutheran',$CH,'Lutheran Church', + 'ELCA Lutheran congregation at 76 N. Davis St. #2 with a long history serving the Keyser community. All are welcome.', + '76 N. Davis St. #2, Keyser, WV 26726','304-788-3200','','', + $h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'Closed','Thu'=>'Closed','Fri'=>'Closed','Sat'=>'Closed','Sun'=>'Worship 9am']), + 39.4372,-78.9730,0], + ]; + + // Fix the church row that accidentally duplicated category_id lookup + foreach ($biz as &$row) { + if (isset($row[2]) && is_string($row[2])) $row[2] = $cid($row[2]); + } + unset($row); + + foreach ($biz as $r) $bi->execute($r); + + /* ── MENUS ──────────────────────────────────────────────────── */ + $si = $db->prepare("INSERT INTO menu_sections(business_id,name,description,sort_order)VALUES(?,?,?,?)"); + $mi = $db->prepare("INSERT INTO menu_items(section_id,name,description,price,sort_order)VALUES(?,?,?,?,?)"); + + // Castiglia's + $castId = (int)qval("SELECT id FROM businesses WHERE slug='castiglia-italian'"); + foreach ([['Antipasti & Salads','Fresh starters',1],['Pasta','Handmade pastas',2],['Pizza','Stone-baked Italian pizzas',3],['Entrees','Secondi piatti',4],['Desserts','Dolci — sweet finishes',5]] as $s) + $si->execute(array_merge([$castId],$s)); + $cs=[]; foreach(qall("SELECT id,name FROM menu_sections WHERE business_id=$castId") as $r) $cs[$r['name']]=$r['id']; + foreach(['Antipasti & Salads'=>[['Bruschetta al Pomodoro','Toasted rustic bread with vine-ripened tomatoes, fresh basil & extra-virgin olive oil',8.95,1],['Calamari Fritti','Lightly breaded fresh calamari, marinara sauce & lemon aioli',13.95,2],['Caprese Salad','Fresh buffalo mozzarella, heirloom tomatoes, basil & balsamic glaze',11.95,3],['Minestrone Soup','Classic Italian vegetable soup with cannellini beans',7.50,4],['Caesar Salad','Romaine, house Caesar dressing, shaved Parmigiano, croutons',9.95,5]], + 'Pasta'=>[['Spaghetti Carbonara','Classic Roman — guanciale, egg, Pecorino Romano, cracked black pepper',15.95,1],['Fettuccine Alfredo','House-made fettuccine in rich cream & Parmigiano sauce',14.95,2],['Lasagna al Forno','House-rolled pasta, Bolognese, béchamel & Parmigiano',16.95,3],['Baked Ziti','Ziti, house marinara, ricotta & melted mozzarella',13.95,4],['Penne Arrabbiata','Spicy tomato, garlic, Calabrian chiles & fresh parsley',12.95,5],['Cacio e Pepe','Spaghetti, Pecorino Romano, Parmigiano Reggiano & abundant black pepper',14.50,6]], + 'Pizza'=>[['Margherita','San Marzano tomatoes, fior di latte mozzarella & fresh basil',14.95,1],['Pepperoni','House marinara, mozzarella & premium pepperoni',15.95,2],['Quattro Stagioni','Ham, mushrooms, artichoke hearts & Kalamata olives',17.95,3],['Prosciutto & Arugula','Parma prosciutto, baby arugula & shaved Parmigiano',18.95,4],['Funghi Truffle','Wild mushrooms, truffle oil, taleggio & fresh thyme',19.95,5]], + 'Entrees'=>[['Chicken Parmigiana','Breaded chicken, house marinara & fresh mozzarella, served with pasta',18.95,1],['Osso Buco alla Milanese','Braised veal shank, saffron risotto & traditional gremolata',34.95,2],['Eggplant Parmigiana','Layered eggplant, San Marzano sauce & mozzarella — vegetarian',15.95,3],['Salmon Piccata','Pan-seared Atlantic salmon, lemon-caper butter & fresh herbs',24.95,4],['Bistecca Fiorentina','Grilled 16oz T-bone, rosemary salt & roasted vegetables',42.95,5]], + 'Desserts'=>[['Tiramisu','Espresso-soaked ladyfingers, mascarpone cream & cocoa — our signature',8.95,1],['Cannoli Siciliani','Crispy shells with sweet ricotta, chocolate chips & pistachios',7.95,2],['Panna Cotta','Silky vanilla panna cotta with seasonal berry coulis',7.50,3],['Gelato del Giorno','Two scoops house-made gelato — ask your server for today\'s flavors',6.95,4],['Affogato','Vanilla gelato "drowned" in a shot of espresso',6.50,5]]] + as $sec=>$items) foreach($items as $item) $mi->execute(array_merge([$cs[$sec]],$item)); + + // Queens Point Coffee + $qpId = (int)qval("SELECT id FROM businesses WHERE slug='queens-point-coffee'"); + foreach ([['Espresso Drinks','Hot & cold espresso beverages',1],['Cold Drinks','Refreshing chilled options',2],['Food','Fresh baked goods & light bites',3],['Teas','Loose-leaf & herbal selections',4]] as $s) + $si->execute(array_merge([$qpId],$s)); + $qs=[]; foreach(qall("SELECT id,name FROM menu_sections WHERE business_id=$qpId") as $r) $qs[$r['name']]=$r['id']; + foreach(['Espresso Drinks'=>[['Latte','Smooth espresso with steamed milk',4.50,1],['Cappuccino','Double espresso with foamed milk',4.25,2],['Americano','Espresso shots with hot water',3.50,3],['Mocha','Espresso, chocolate & steamed milk',5.00,4],['Flat White','Ristretto shots with velvety microfoam',4.75,5],['Iced Matcha Latte','Matcha with milk & choice of flavor — our most raved drink!',5.25,6]], + 'Cold Drinks'=>[['Cold Brew','Smooth 24-hour cold brew over ice',3.75,1],['Iced Latte','Chilled espresso with cold milk',4.75,2],['Blended Frappe','Blended ice coffee drink',5.50,3],['Iced Matcha','Fresh matcha over ice',4.75,4]], + 'Food'=>[['Blueberry Muffin','Fresh-baked daily',3.00,1],['Avocado Toast','Multigrain with avocado & everything spice',7.50,2],['Breakfast Sandwich','Egg, cheese & choice of meat',6.50,3],['Cookie','Assorted fresh-baked cookies',2.50,4],['Granola Bar','Housemade granola bar',3.50,5]], + 'Teas'=>[['Earl Grey','Classic black tea with bergamot',3.00,1],['Green Tea','Organic loose-leaf green tea',3.00,2],['Chai Latte','Spiced tea with steamed milk',4.25,3],['Herbal Tea','Seasonal herbal blends',2.75,4]]] + as $sec=>$items) foreach($items as $item) $mi->execute(array_merge([$qs[$sec]],$item)); + + // Candlewyck Inn + $cwId = (int)qval("SELECT id FROM businesses WHERE slug='candlewyck-inn'"); + foreach ([['Starters','Elegant appetizers',1],['Soups & Salads','',2],['Entrées','Main courses',3],['Desserts','Sweet finishes',4]] as $s) + $si->execute(array_merge([$cwId],$s)); + $cws=[]; foreach(qall("SELECT id,name FROM menu_sections WHERE business_id=$cwId") as $r) $cws[$r['name']]=$r['id']; + foreach(['Starters'=>[['Crab Cakes','Pan-seared crab cakes, remoulade & microgreens',16.95,1],['Charcuterie Board','Artisan meats, local cheeses & seasonal accompaniments',21.95,2],['Escargot','Classic French preparation, garlic-herb butter & crusty bread',14.95,3]], + 'Soups & Salads'=>[['French Onion Soup','Rich beef broth, caramelized onions & gruyère crouton',10.95,1],['Wedge Salad','Iceberg, blue cheese, applewood bacon & cherry tomatoes',11.95,2]], + 'Entrées'=>[['8oz Filet Mignon','Center-cut tenderloin, béarnaise & pommes purée',48.95,1],['Rack of Lamb','Herb-crusted, minted demi-glace & root vegetable gratin',44.95,2],['Lobster Ravioli','House-made ravioli in cognac cream sauce',32.95,3],['Pan-Roasted Duck Breast','Cherry reduction, wild rice pilaf & roasted root vegetables',36.95,4]], + 'Desserts'=>[['Crème Brûlée','Classic vanilla bean custard with caramelized sugar',10.95,1],['Chocolate Fondant','Warm lava cake with vanilla ice cream',11.95,2],['Cheese Course','Three artisan cheeses, honeycomb, fruit & crackers',15.95,3]]] + as $sec=>$items) foreach($items as $item) $mi->execute(array_merge([$cws[$sec]],$item)); + + /* ── ALERTS ─────────────────────────────────────────────────── */ + foreach ([ + ['castiglia-italian','news','New Menu Items This Season!', + "Chef's new handmade gnocchi and wood-fired seasonal pizza are now available. Fresh locally-sourced summer ingredients throughout the menu. Wednesday–Sunday, dine in and enjoy!"], + ['queens-point-coffee','news','Try Our Famous Iced Matcha!', + '"THE best coffee shop I\'ve ever been to" — recent visitor. Our iced matcha with coconut and pistachio flavor is the most-talked-about drink in Keyser. Come see why!'], + ['clancys-irish-pub','event','Live Music Every Friday & Saturday!', + "Live music from 9pm–1am every Friday and Saturday night. This Friday: Appalachian String Band. Saturday: Classic rock by Ridgeline. No cover charge! Food and drinks served until close."], + ['north-branch-craft-pub','event','Craft Beer Weekend — New Taps Arriving!', + 'New rotating craft beer taps arriving this Friday. Join us for our tap takeover event featuring 8 new local and regional brews. Food specials all weekend.'], + ['pvh','info','Urgent Care Now Open 7 Days', + 'WVU Medicine Potomac Valley Hospital Urgent Care is open Mon–Fri 8am–8pm and weekends 9am–5pm. No appointment needed for non-emergency care. ER open 24/7.'], + ['north-branch-ventures','event','Guided Kayak Tours — Book Now!', + "Guided half-day kayak tours on the North Branch Potomac every Saturday and Sunday through October. All skill levels welcome — equipment and instruction included. Call 304-790-7125 to reserve."], + ] as [$slug,$type,$title,$body]) + qrun("INSERT INTO alerts(business_id,type,title,body,is_active)VALUES((SELECT id FROM businesses WHERE slug=?),?,?,?,1)",[$slug,$type,$title,$body]); + + /* ── ATTRACTIONS ─────────────────────────────────────────────── */ + foreach ([ + ['Queens Point Overlook','Natural Landmark', + "A spectacular Oriskany sandstone cliff rising approximately 400 feet above the North Branch Potomac River in McCoole, MD — directly across from Keyser. Breathtaking panoramic views of Keyser and the river valley. One of Mineral County's most dramatic natural viewpoints and a beloved local landmark.", + 'McCoole, MD (viewable from Keyser riverfront)','','','Accessible year-round',1,1], + ['North Branch Potomac River','Outdoor Recreation', + "The North Branch of the Potomac River runs along Keyser's northern edge, forming the WV/MD border. World-class smallmouth bass fishing, kayaking, canoeing, and bird watching. The river was instrumental in Mineral County's development. Contact North Branch Ventures for rentals and guided tours.", + 'Armstrong St. riverfront, Keyser, WV','304-790-7125','','Open year-round',1,2], + ['Larenim Park','County Park', + "Mineral County's premier park at 365 acres. Two pavilions (10 picnic tables each), amphitheater seating 600, one Little League field and one softball field, two fishing ponds (5 and 2.5 acres stocked by WVDNR), approximately 5 miles of trails, and public hunting by permit. An arboretum is under construction. Home to McNeill's Rangers theater group.", + 'Keyser, WV 26726','304-788-3066','','Open daily, dawn to dusk',1,3], + ['Jennings Randolph Lake','Lake / Reservoir', + "A stunning 952-acre reservoir on the North Branch Potomac, approximately 20 minutes from Keyser. Created by the Jennings Randolph Dam. Offers boating, fishing (walleye, bass, trout), swimming, camping, and picnicking. Also includes New Creek Trail — 4 miles of rail/trail with 1 mile of river frontage on the North Branch. Managed by U.S. Army Corps of Engineers.", + 'Elk Garden, WV 26717 (~20 min from Keyser)','304-355-2346','','Open year-round (camping seasonal)',1,4], + ['Mineral County Courthouse','Historic Site', + "The iconic 1868 Mineral County Courthouse is listed on the National Register of Historic Places. This beautiful Victorian-era structure is the architectural centerpiece of downtown Keyser and a photographer's delight. The courthouse grounds are a popular community gathering spot year-round.", + 'Keyser, WV 26726','304-788-3003','','Exterior viewable anytime; interior Mon–Fri 8:30am–4:30pm',1,5], + ['Potomac State College Campus / Fort Fuller','Historic Site', + "Fort Hill — site of Civil War Fort Fuller (also known as Fort Kelly) — was commanded by future U.S. President Benjamin Harrison and General Lew Wallace (who later wrote Ben-Hur). Today this historically significant ground is the beautiful campus of Potomac State College, part of WVU since 1901. A place where American presidential history meets higher education.", + '101 Fort Ave., Keyser, WV 26726','304-788-6800','potomacstatecollege.edu','Campus open daily',1,6], + ['Nancy Hanks Birthplace Site','Historic Presidential Site', + "The birthplace of Nancy Hanks — mother of President Abraham Lincoln — is preserved near Doll's Gap in Mineral County. This remarkable connection to America's most celebrated president is part of Mineral County's extraordinary heritage and is noted by West Virginia Explorer as a key county landmark.", + "Near Doll's Gap, Mineral County, WV",'','','Viewable year-round',1,7], + ['Mineral County Historical Society Museum','Museum', + "\"Recently opened, this museum tracks the history of Mineral County and Keyser, WV with displays of its people, its history, and its culture.\" Listed on TripAdvisor's top attractions in Keyser. Features exhibits on the B&O Railroad, Civil War, pioneers, and notable local figures. Open by appointment only and on special days.", + 'Keyser, WV 26726','304-788-3066','','By appointment; call before visiting',1,8], + ['Fort Ashby Blockhouse','Historic Site', + "The original 1755 log blockhouse at Fort Ashby — the only remaining structure from the French and Indian War in West Virginia. Ordered built by a young George Washington as defense against frontier raids. A remarkable piece of American frontier military history just 15 miles from Keyser.", + 'Fort Ashby, WV 26719 (~15 miles from Keyser)','','','Open seasonally — contact WV Preservation',1,9], + ['Potomac Eagle Scenic Railroad','Scenic Train Excursion', + "One of West Virginia's most beloved tourist attractions, just 30 miles from Keyser in Romney. The Potomac Eagle runs scenic excursions through The Trough along the South Branch Potomac River — famous for reliable bald eagle sightings. A must-do experience for families and nature lovers.", + 'Romney, WV 26757 (~30 miles from Keyser)','304-822-7400','potomaceagle.info','Seasonal spring through fall',1,10], + ['Blackwater Falls State Park','State Park', + "One of WV's most iconic parks approximately 40 minutes from Keyser. The amber-colored Blackwater Falls plunge 57 feet into the dramatic Blackwater Canyon. Spectacular year-round views. Hiking, cross-country skiing, camping, and a beautiful lodge restaurant.", + 'Davis, WV 26260 (~40 min from Keyser)','304-259-5216','wvstateparks.com','Open year-round',1,11], + ['Seneca Rocks','Natural Landmark', + "One of the most distinctive geological formations in the eastern U.S. — massive quartzite outcroppings rising 900 feet from the Seneca Creek valley floor. About 45 minutes from Keyser. World-class rock climbing, hiking, and breathtaking views. The Spruce Knob-Seneca Rocks National Recreation Area surrounds the formation.", + 'Seneca Rocks, WV (~45 min from Keyser)','304-567-2827','','Open year-round',1,12], + ['Canaan Valley Resort State Park','State Park / Ski Resort', + "WV's highest valley at 3,200 ft elevation — largest inland wetland ecosystem in the eastern U.S. Skiing, snowboarding, hiking, mountain biking, golf, and stunning wildlife. About 50 minutes from Keyser. Beautiful lodge with restaurant.", + 'Davis, WV 26260 (~50 min from Keyser)','304-866-4121','canaanresort.com','Open year-round',1,13], + ['NedPower Mount Storm Wind Farm','Energy Landmark', + "132 massive wind turbines atop the Allegheny Front, visible from Keyser on clear days. One of the largest wind energy installations east of the Mississippi River. The turbines have become part of Keyser's distinctive skyline and symbolize Appalachia's evolving energy economy.", + 'Mount Storm, WV (visible from Keyser)','','','Viewable from Keyser year-round',1,14], + ['Appalachian Forest National Heritage Area','Heritage Area', + "Mineral County is part of the Appalachian Forest National Heritage Area — a forested region of more than 500 square miles in central Appalachia where tourism and forestry are key industries. Historic sites dating back to the Revolutionary War are found throughout the county, including stops on the B&O Railroad National Heritage Corridor.", + 'Keyser, WV (headquarters in Elkins)','','appalachianforestnha.org','Open year-round',1,15], + ] as $a) qrun("INSERT INTO attractions(name,category,description,address,phone,website,hours,is_active,sort_order)VALUES(?,?,?,?,?,?,?,?,?)",$a); + + /* ── EVENTS ──────────────────────────────────────────────────── */ + $adminId = (int)qval("SELECT id FROM users WHERE username='administrator'"); + foreach ([ + ['Mineral County Fair','Annual county fair featuring livestock shows, 4-H exhibits, carnival rides, food vendors, live entertainment, and a demolition derby. Sponsored by the Fort Ashby Volunteer Fire Co. One of Mineral County\'s biggest annual events.','Mineral County Fairgrounds','Fort Ashby, WV 26719','2025-08-12','9:00 AM','2025-08-16','10:00 PM','Admission varies','mineralcountyfair.org','','','304-788-0000',$adminId,'approved',1], + ['Keyser Christmas Parade & Tree Lighting','Annual holiday parade through downtown Keyser with decorated floats, marching bands, Santa Claus, and the official lighting of the downtown Christmas tree. A beloved community tradition — come join your neighbors!','Downtown Main Street','Keyser, WV 26726','2025-12-06','5:00 PM','2025-12-06','7:00 PM','Free','cityofkeyser.com','','','304-788-0222',$adminId,'approved',1], + ['PSC Catamount Athletics — Fall Season','Support the WVU Potomac State Catamounts at home athletic events throughout the fall semester. Football, basketball, soccer, baseball, and more. All events open to the public.','Potomac State College','101 Fort Ave., Keyser','2025-09-01','Varies','2025-11-30','Varies','Free–$5','potomacstatecollege.edu','','','304-788-6800',$adminId,'approved',0], + ['North Branch Kayak Weekend Tours','Guided and recreational kayaking on the North Branch Potomac River every Saturday and Sunday through October. All skill levels welcome. Equipment provided. Register through North Branch Ventures.','North Branch Ventures','101 Armstrong St., Keyser','2025-05-01','8:00 AM','2025-10-31','4:00 PM','$25–$45 per person','','','','304-790-7125',$adminId,'approved',0], + ['Mineral County Heritage Days','Community celebration of Mineral County history featuring historical reenactments, local artisan crafts, live traditional Appalachian music, food vendors, and family activities throughout downtown Keyser.','Downtown Keyser','Keyser, WV 26726','2025-10-11','10:00 AM','2025-10-12','5:00 PM','Free','govisitmineralwv.com','','','304-790-7081',$adminId,'approved',0], + ['PSC Cultural Arts Series','Performing arts events at Potomac State College open to the public — concerts, theatrical performances, guest lecturers, and gallery exhibitions throughout the academic year.','Potomac State College','101 Fort Ave., Keyser','2025-09-15','7:00 PM','2026-04-30','9:00 PM','Free–$10','potomacstatecollege.edu','','','304-788-6800',$adminId,'approved',0], + ['McNeill\'s Rangers Theater at Larenim Park','Outdoor theater performances by McNeill\'s Rangers at Larenim Park\'s 600-seat amphitheater. Classic American stories brought to life in a beautiful natural setting.','Larenim Park Amphitheater','Keyser, WV 26726','2025-07-18','7:30 PM','2025-07-19','10:00 PM','$10–$20','','','','304-788-3066',$adminId,'approved',0], + ] as $ev) 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,is_featured)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",$ev); +} diff --git a/includes/2functions.php b/includes/2functions.php new file mode 100644 index 0000000..0c8338b --- /dev/null +++ b/includes/2functions.php @@ -0,0 +1,107 @@ + 0; } +function isAdmin(): bool{ return !empty($_SESSION['admin']); } + +function requireLogin(string $back = ''): void { + if (!authed()) { + flash('Please log in to continue.', 'warning'); + go('/login.php?next='.urlencode($back ?: $_SERVER['REQUEST_URI'])); + } +} +function requireAdmin(): void { + if (!isAdmin()) { flash('Access denied.', 'error'); go('/index.php'); } +} +function loginUser(array $u): void { + session_regenerate_id(true); + $_SESSION['uid'] = $u['id']; + $_SESSION['uname'] = $u['username']; + $_SESSION['admin'] = (bool)$u['is_admin']; + qrun("UPDATE users SET last_login=datetime('now') WHERE id=?", [$u['id']]); +} +function logoutUser(): void { + session_unset(); session_destroy(); + session_start(); session_regenerate_id(true); +} + +/* ── Flash ────────────────────────────────────────────── */ +function flash(string $msg, string $type = 'info'): void { $_SESSION['_flash'][] = [$type, $msg]; } +function getFlashes(): array { $f = $_SESSION['_flash'] ?? []; unset($_SESSION['_flash']); return $f; } + +/* ── Output ───────────────────────────────────────────── */ +function e(mixed $v): string { return htmlspecialchars((string)$v, ENT_QUOTES|ENT_SUBSTITUTE, 'UTF-8'); } +function go(string $url): never { header('Location:'.$url); exit; } + +/* ── Input ────────────────────────────────────────────── */ +function p(string $k, mixed $d = ''): mixed { return $_POST[$k] ?? $d; } +function g(string $k, mixed $d = ''): mixed { return $_GET[$k] ?? $d; } +function iget(string $k): int { return (int)($_POST[$k] ?? $_GET[$k] ?? 0); } +function ps(string $k): string { return trim((string)($_POST[$k] ?? '')); } +function gs(string $k): string { return trim((string)($_GET[$k] ?? '')); } +function isPost(): bool { return strtoupper($_SERVER['REQUEST_METHOD']) === 'POST'; } + +/* ── CSRF ─────────────────────────────────────────────── */ +function csrf(): string { + if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(24)); + return $_SESSION['csrf']; +} +function csrfField(): string { return ''; } +function csrfCheck(): void { + if (!hash_equals(csrf(), p('_csrf', ''))) { http_response_code(403); die('Invalid CSRF token.'); } +} + +/* ── Settings ─────────────────────────────────────────── */ +function setting(string $k, string $def = ''): string { + $v = qval("SELECT value FROM settings WHERE key=?", [$k]); + return $v !== false ? (string)$v : $def; +} +function setSetting(string $k, string $v): void { + qrun("INSERT INTO settings(key,value)VALUES(?,?)ON CONFLICT(key)DO UPDATE SET value=excluded.value", [$k,$v]); +} + +/* ── Ownership ────────────────────────────────────────── */ +function owns(int $bizId): bool { + if (isAdmin()) return true; + if (!authed()) return false; + return (bool)qval("SELECT 1 FROM business_owners WHERE user_id=? AND business_id=?", [uid(),$bizId]); +} + +/* ── Stars ────────────────────────────────────────────── */ +function starDisplay(float $avg): string { + $r = round($avg); $out = ''; + for ($i = 1; $i <= 5; $i++) $out .= ''; + return $out.''; +} +function starPicker(string $name = 'rating', int $sel = 0): string { + $out = '
'; + for ($i = 5; $i >= 1; $i--) { + $chk = $sel === $i ? 'checked' : ''; + $out .= ''; + $out .= ''; + } + return $out.'
'; +} + +/* ── Date / Money ─────────────────────────────────────── */ +function ago(string $dt): string { + $d = time() - strtotime($dt); + if ($d < 60) return 'Just now'; + if ($d < 3600) return floor($d/60).'m ago'; + if ($d < 86400) return floor($d/3600).'h ago'; + if ($d < 604800) return floor($d/86400).'d ago'; + return date('M j, Y', strtotime($dt)); +} +function fdate(string $dt, string $fmt = 'M j, Y'): string { return date($fmt, strtotime($dt)); } +function money(float $n): string { return '$'.number_format($n, 2); } + +/* ── Misc ─────────────────────────────────────────────── */ +function alertIcon(string $t): string { return match($t){'news'=>'📰','event'=>'🎉','warning'=>'⚠️','sale'=>'🏷️',default=>'ℹ️'}; } +function parseHours(string $json): array { return json_decode($json ?: '{}', true) ?? []; } +function makeSlug(string $s): string { return trim(preg_replace('/[^a-z0-9]+/','-',strtolower($s)),'-'); } +function uniqueSlug(string $base): string { + $slug = makeSlug($base); $i = 0; + while (qval("SELECT id FROM businesses WHERE slug=?", [$slug.($i?"-$i":"")])) $i++; + return $slug.($i ? "-$i" : ""); +} diff --git a/includes/3functions.php b/includes/3functions.php new file mode 100644 index 0000000..cd051ad --- /dev/null +++ b/includes/3functions.php @@ -0,0 +1,107 @@ + 0; } +function isAdmin(): bool{ return !empty($_SESSION['admin']); } + +function requireLogin(string $back = ''): void { + if (!authed()) { + flash('Please log in to continue.', 'warning'); + go('/login.php?next='.urlencode($back ?: $_SERVER['REQUEST_URI'])); + } +} +function requireAdmin(): void { + if (!isAdmin()) { flash('Access denied.', 'error'); go('/index.php'); } +} +function loginUser(array $u): void { + session_regenerate_id(true); + $_SESSION['uid'] = $u['id']; + $_SESSION['uname'] = $u['username']; + $_SESSION['admin'] = (bool)$u['is_admin']; + qrun("UPDATE users SET last_login=datetime('now') WHERE id=?", [$u['id']]); +} +function logoutUser(): void { + session_unset(); session_destroy(); + session_start(); session_regenerate_id(true); +} + +/* ── Flash ────────────────────────────────────────────── */ +function flash(string $msg, string $type = 'info'): void { $_SESSION['_flash'][] = [$type, $msg]; } +function getFlashes(): array { $f = $_SESSION['_flash'] ?? []; unset($_SESSION['_flash']); return $f; } + +/* ── Output ───────────────────────────────────────────── */ +function e(mixed $v): string { return htmlspecialchars((string)$v, ENT_QUOTES|ENT_SUBSTITUTE, 'UTF-8'); } +function go(string $url): never { header('Location:'.$url); exit; } + +/* ── Input ────────────────────────────────────────────── */ +function p(string $k, mixed $d = ''): mixed { return $_POST[$k] ?? $d; } +function g(string $k, mixed $d = ''): mixed { return $_GET[$k] ?? $d; } +function iget(string $k): int { return (int)($_POST[$k] ?? $_GET[$k] ?? 0); } +function ps(string $k): string { return trim((string)($_POST[$k] ?? '')); } +function gs(string $k): string { return trim((string)($_GET[$k] ?? '')); } +function isPost(): bool { return strtoupper($_SERVER['REQUEST_METHOD']) === 'POST'; } + +/* ── CSRF ─────────────────────────────────────────────── */ +function csrf(): string { + if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(24)); + return $_SESSION['csrf']; +} +function csrfField(): string { return ''; } +function csrfCheck(): void { + if (!hash_equals(csrf(), p('_csrf', ''))) { http_response_code(403); die('Invalid CSRF token.'); } +} + +/* ── Settings ─────────────────────────────────────────── */ +function setting(string $k, string $def = ''): string { + $v = qval("SELECT value FROM settings WHERE key=?", [$k]); + return $v !== false ? (string)$v : $def; +} +function setSetting(string $k, string $v): void { + qrun("INSERT INTO settings(key,value)VALUES(?,?)ON CONFLICT(key)DO UPDATE SET value=excluded.value", [$k,$v]); +} + +/* ── Ownership ────────────────────────────────────────── */ +function owns(int $bizId): bool { + if (isAdmin()) return true; + if (!authed()) return false; + return (bool)qval("SELECT 1 FROM business_owners WHERE user_id=? AND business_id=?", [uid(),$bizId]); +} + +/* ── Stars ────────────────────────────────────────────── */ +function starDisplay(float $avg): string { + $r = round($avg); $out = ''; + for ($i = 1; $i <= 5; $i++) $out .= ''; + return $out.''; +} +function starPicker(string $name = 'rating', int $sel = 0): string { + $out = '
'; + for ($i = 5; $i >= 1; $i--) { + $chk = $sel === $i ? 'checked' : ''; + $out .= ''; + $out .= ''; + } + return $out.'
'; +} + +/* ── Date / Money ─────────────────────────────────────── */ +function ago(string $dt): string { + $d = time() - strtotime($dt); + if ($d < 60) return 'Just now'; + if ($d < 3600) return floor($d/60).'m ago'; + if ($d < 86400) return floor($d/3600).'h ago'; + if ($d < 604800) return floor($d/86400).'d ago'; + return date('M j, Y', strtotime($dt)); +} +function fdate(string $dt, string $fmt = 'M j, Y'): string { return date($fmt, strtotime($dt)); } +function money(float $n): string { return '$'.number_format($n, 2); } + +/* ── Misc ─────────────────────────────────────────────── */ +function alertIcon(string $t): string { return match($t){'news'=>'📰','event'=>'🎉','warning'=>'⚠️','sale'=>'🏷️',default=>'ℹ️'}; } +function parseHours(?string $json): array { return json_decode(($json && $json !== '[]') ? $json : '{}', true) ?? []; } +function makeSlug(string $s): string { return trim(preg_replace('/[^a-z0-9]+/','-',strtolower($s)),'-'); } +function uniqueSlug(string $base): string { + $slug = makeSlug($base); $i = 0; + while (qval("SELECT id FROM businesses WHERE slug=?", [$slug.($i?"-$i":"")])) $i++; + return $slug.($i ? "-$i" : ""); +} diff --git a/includes/boot.php b/includes/boot.php new file mode 100644 index 0000000..6ad758e --- /dev/null +++ b/includes/boot.php @@ -0,0 +1,5 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $pdo->exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=NORMAL;"); + if ($isNew) { _schema($pdo); _seed($pdo); } + // Always ensure the edit_requests table exists (for existing DBs) + _ensureEditRequests($pdo); + if (file_exists(DB_FILE)) @chmod(DB_FILE, 0666); + return $pdo; +} + +/* ── Shorthand query helpers ────────────────────────────── */ +function qr(string $sql, array $p=[]): PDOStatement { $s=db()->prepare($sql); $s->execute($p); return $s; } +function qall(string $sql, array $p=[]): array { return qr($sql,$p)->fetchAll(); } +function qone(string $sql, array $p=[]): ?array { $r=qr($sql,$p)->fetch(); return $r?:null; } +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(); } + +/* ── Ensure edit requests table exists on old DBs ─── */ +function _ensureEditRequests(PDO $db): void { + $db->exec(" + CREATE TABLE IF NOT EXISTS business_edit_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, + field TEXT NOT NULL, + old_value TEXT NOT NULL DEFAULT '', + new_value 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 + ); + "); +} + +/* ══════════════════════════════════════════════════════ + SCHEMA + ══════════════════════════════════════════════════════ */ +function _schema(PDO $db): void { + $db->exec(" + CREATE TABLE IF NOT EXISTS settings( + key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE IF NOT EXISTS users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT, + password TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + last_login TEXT + ); + CREATE TABLE IF NOT EXISTS categories( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + icon TEXT NOT NULL DEFAULT '🏢', + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS businesses( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + category_id INTEGER REFERENCES categories(id), + subcategory TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + website TEXT NOT NULL DEFAULT '', + hours TEXT NOT NULL DEFAULT '{}', + lat REAL NOT NULL DEFAULT 39.4364, + lng REAL NOT NULL DEFAULT -78.9762, + is_active INTEGER NOT NULL DEFAULT 1, + is_featured INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT(datetime('now')) + ); + CREATE TABLE IF NOT EXISTS business_owners( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + PRIMARY KEY(user_id,business_id) + ); + CREATE TABLE IF NOT EXISTS business_edit_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, + field TEXT NOT NULL, + old_value TEXT NOT NULL DEFAULT '', + new_value 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 reviews( + 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, + rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5), + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + is_approved INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT(datetime('now')) + ); + CREATE TABLE IF NOT EXISTS menu_sections( + id INTEGER PRIMARY KEY AUTOINCREMENT, + business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS menu_items( + id INTEGER PRIMARY KEY AUTOINCREMENT, + section_id INTEGER NOT NULL REFERENCES menu_sections(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + price REAL NOT NULL DEFAULT 0, + is_available INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS alerts( + id INTEGER PRIMARY KEY AUTOINCREMENT, + business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'info', + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 1, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT(datetime('now')) + ); + CREATE TABLE IF NOT EXISTS attractions( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'Sightseeing', + description TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + website TEXT NOT NULL DEFAULT '', + hours TEXT NOT NULL DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS events( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + venue TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + event_date TEXT NOT NULL, + 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 '', + contact_name TEXT NOT NULL DEFAULT '', + contact_email TEXT NOT NULL DEFAULT '', + contact_phone TEXT NOT NULL DEFAULT '', + submitted_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'pending', + is_featured INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT(datetime('now')) + ); + CREATE TABLE IF NOT EXISTS reports( + 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, + type TEXT NOT NULL DEFAULT 'correction', + message TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + admin_notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT(datetime('now')) + ); + "); +} + +/* ══════════════════════════════════════════════════════ + SEED DATA + ══════════════════════════════════════════════════════ */ +function _seed(PDO $db): void { + + /* Settings */ + foreach (['registration_enabled'=>'1','site_name'=>'Discover Keyser WV', + 'site_tagline'=>'The Friendliest City in the U.S.A.', + 'contact_email'=>'info@discoverkeyser.com', + 'events_require_approval'=>'1'] as $k=>$v) + qrun("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)",[$k,$v]); + + /* Admin user — password "password123" */ + $hash = password_hash('password123', PASSWORD_BCRYPT); + qrun("INSERT OR IGNORE INTO users(username,email,password,is_admin) + VALUES('administrator','admin@discoverkeyser.com',?,1)",[$hash]); + + /* Categories */ + foreach ([ + ['Dining & Food','🍽️',1],['Shopping & Retail','🛍️',2], + ['Lodging','🏨',3],['Healthcare','🏥',4],['Education','🎓',5], + ['Financial Services','🏦',6],['Automotive','🔧',7], + ['Professional Services','💼',8],['Recreation & Fitness','⛹️',9], + ['Beauty & Personal Care','💇',10],['Government & Civic','🏛️',11], + ['Legal Services','⚖️',12],['Churches & Faith','⛪',13], + ] as $c) qrun("INSERT OR IGNORE INTO categories(name,icon,sort_order)VALUES(?,?,?)",$c); + + $cid = fn(string $n): int => (int)qval("SELECT id FROM categories WHERE name=?",[$n]); + $h = fn(array $d): string => json_encode($d); + $std = $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–5pm','Sat'=>'Closed','Sun'=>'Closed']); + $always= $h(['Mon'=>'Open 24 hrs','Tue'=>'Open 24 hrs','Wed'=>'Open 24 hrs','Thu'=>'Open 24 hrs','Fri'=>'Open 24 hrs','Sat'=>'Open 24 hrs','Sun'=>'Open 24 hrs']); + + $D=$cid('Dining & Food'); $SH=$cid('Shopping & Retail'); $LO=$cid('Lodging'); + $HC=$cid('Healthcare'); $ED=$cid('Education'); $FI=$cid('Financial Services'); + $AU=$cid('Automotive'); $PS=$cid('Professional Services'); $RF=$cid('Recreation & Fitness'); + $BP=$cid('Beauty & Personal Care'); $GV=$cid('Government & Civic'); + $LG=$cid('Legal Services'); $CH=$cid('Churches & Faith'); + + $bi = $db->prepare("INSERT INTO businesses(name,slug,category_id,subcategory,description,address,phone,email,website,hours,lat,lng,is_featured)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)"); + + $biz = [ + ["Castiglia's Italian Eatery",'castiglia-italian',$D,'Italian Restaurant', + "Keyser's most beloved restaurant. Family recipes passed down for generations: handmade pasta, wood-fired dishes, classic antipasti, and decadent Italian desserts. Known for huge servings and amazing food.", + '401 S. Mineral St., Keyser, WV 26726','304-788-1300','','', + $h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'11am–9pm','Thu'=>'11am–9pm','Fri'=>'11am–9pm','Sat'=>'11am–9pm','Sun'=>'12pm–9pm']),39.4340,-78.9734,1], + ['The Candlewyck Inn','candlewyck-inn',$D,'Fine Dining', + "Elegant fine dining in a beautifully restored historic setting. Upscale American cuisine with locally sourced seasonal ingredients. Award-winning wine list. Keyser's premier destination for special occasions.", + '65 S. Mineral St., Keyser, WV 26726','304-788-6594','','', + $h(['Mon'=>'Closed','Tue'=>'5pm–9pm','Wed'=>'5pm–9pm','Thu'=>'5pm–9pm','Fri'=>'5pm–10pm','Sat'=>'4pm–10pm','Sun'=>'Closed']),39.4370,-78.9734,1], + ["Clancy's Irish Pub",'clancys-irish-pub',$D,'Irish Pub & Bar', + "Keyser's favorite neighborhood pub! Cold craft beers on tap, hearty pub fare, sports on every screen, trivia nights on Wednesdays, and live music on weekends.", + '777 Armstrong St., Keyser, WV 26726','304-788-1133','','', + $h(['Mon'=>'4pm–12am','Tue'=>'4pm–12am','Wed'=>'4pm–12am','Thu'=>'4pm–12am','Fri'=>'4pm–2am','Sat'=>'12pm–2am','Sun'=>'12pm–10pm']),39.4390,-78.9750,1], + ['North Branch Craft Pub','north-branch-craft-pub',$D,'Craft Brewery & Pub', + "Keyser's craft beer destination. Local and regional craft beers on rotating taps, great pub fare, and stunning North Branch river views. Live entertainment regularly.", + '101 Armstrong St., Ste. 1, Keyser, WV 26726','304-790-7125','','', + $h(['Mon'=>'Closed','Tue'=>'4pm–11pm','Wed'=>'4pm–11pm','Thu'=>'4pm–11pm','Fri'=>'3pm–1am','Sat'=>'12pm–1am','Sun'=>'12pm–8pm']),39.4392,-78.9750,1], + ['Queens Point Coffee','queens-point-coffee',$D,'Coffee Shop', + '"THE best coffee shop I\'ve ever been to." Named after the iconic Queens Point cliff. Iced matcha, lattes, specialty drinks, amazing food, and friendly staff.', + '101 Armstrong St., Keyser, WV 26726','304-790-7125','','', + $h(['Mon'=>'7am–3pm','Tue'=>'7am–5pm','Wed'=>'7am–5pm','Thu'=>'7am–5pm','Fri'=>'7am–5pm','Sat'=>'8am–5pm','Sun'=>'9am–2pm']),39.4392,-78.9750,1], + ['Royal Restaurant','royal-restaurant',$D,'American / Comfort Food', + "A Keyser classic for generations. Hearty American comfort food for breakfast, lunch, and dinner. Known for great breakfasts, homemade pies, and friendly small-town service.", + '88 N. Main St., Keyser, WV 26726','304-788-9825','','', + $h(['Mon'=>'6am–8pm','Tue'=>'6am–8pm','Wed'=>'6am–8pm','Thu'=>'6am–8pm','Fri'=>'6am–9pm','Sat'=>'6am–9pm','Sun'=>'7am–3pm']),39.4378,-78.9734,0], + ['Fat Bottom Grille','fat-bottom-grille',$D,'American Bar & Grill', + '"THE best place in town to get a burger or steak sub." Everything made fresh. The smash burger is juicy and flavored to perfection. Huge portions at excellent prices.', + 'Keyser, WV 26726','304-788-0000','','', + $h(['Mon'=>'11am–9pm','Tue'=>'11am–9pm','Wed'=>'11am–9pm','Thu'=>'11am–9pm','Fri'=>'11am–10pm','Sat'=>'11am–10pm','Sun'=>'12pm–9pm']),39.4360,-78.9740,1], + ["Denny's",'dennys',$D,'American / Diner', + "Open 24/7. America's diner at Route 220 South. Grand Slams, hearty burgers, and all-day breakfast.", + '825 S. Mineral St. (US Rt. 220 S), Keyser, WV 26726','304-788-3090','','dennys.com',$always,39.4300,-78.9730,0], + ["McDonald's",'mcdonalds',$D,'Fast Food', + "Burgers, fries, breakfast sandwiches, and McCafé beverages. Drive-through available.", + '700 S. Mineral St., Keyser, WV 26726','304-788-0604','','mcdonalds.com', + $h(['Mon'=>'6am–11pm','Tue'=>'6am–11pm','Wed'=>'6am–11pm','Thu'=>'6am–11pm','Fri'=>'6am–12am','Sat'=>'6am–12am','Sun'=>'7am–11pm']),39.4310,-78.9730,0], + ['Burger King','burger-king',$D,'Fast Food', + 'Home of the Whopper! Flame-grilled burgers and crispy fries. Drive-through available.', + 'RR#3 Box 3240, New Creek Hwy, Keyser, WV 26726','304-788-0000','','burgerking.com', + $h(['Mon'=>'6am–12am','Tue'=>'6am–12am','Wed'=>'6am–12am','Thu'=>'6am–12am','Fri'=>'6am–1am','Sat'=>'6am–1am','Sun'=>'7am–12am']),39.4285,-78.9720,0], + ["Domino's Pizza",'dominos',$D,'Pizza / Delivery', + 'Hot fresh pizza fast. Carryout or delivered. Online ordering at dominos.com.', + '590 S. Mineral St., Keyser, WV 26726','304-788-6400','','dominos.com', + $h(['Mon'=>'10:30am–12am','Tue'=>'10:30am–12am','Wed'=>'10:30am–12am','Thu'=>'10:30am–12am','Fri'=>'10:30am–1am','Sat'=>'10:30am–1am','Sun'=>'10:30am–12am']),39.4330,-78.9730,0], + ['Dairy Queen Grill & Chill','dairy-queen',$D,'Ice Cream / Fast Food', + "Signature Blizzard® treats, soft-serve cones, and grill burgers. A Keyser summer tradition!", + '460 S. Mineral St., Keyser, WV 26726','304-788-1499','','dairyqueen.com', + $h(['Mon'=>'10am–10pm','Tue'=>'10am–10pm','Wed'=>'10am–10pm','Thu'=>'10am–10pm','Fri'=>'10am–10:30pm','Sat'=>'10am–10:30pm','Sun'=>'11am–9pm']),39.4320,-78.9730,0], + ["Fox's Pizza Den",'foxs-pizza',$D,'Pizza', + "Local pizza legend! Hand-tossed pizzas, subs, wings, and salads. A true Keyser staple.", + '567 S. Mineral St., Keyser, WV 26726','304-788-1149','','', + $h(['Mon'=>'11am–10pm','Tue'=>'11am–10pm','Wed'=>'11am–10pm','Thu'=>'11am–10pm','Fri'=>'11am–11pm','Sat'=>'11am–11pm','Sun'=>'12pm–9pm']),39.4315,-78.9730,0], + ['Little Caesars','little-caesars',$D,'Pizza / Fast Food', + 'Hot-N-Ready pizzas at unbeatable prices. No wait. Crazy Bread and Italian favorites.', + '30 Armstrong St., Keyser, WV 26726','304-788-7738','','littlecaesars.com', + $h(['Mon'=>'11am–9pm','Tue'=>'11am–9pm','Wed'=>'11am–9pm','Thu'=>'11am–9pm','Fri'=>'11am–10pm','Sat'=>'11am–10pm','Sun'=>'12pm–9pm']),39.4395,-78.9750,0], + ['Subway','subway',$D,'Sandwiches / Fast Food', + 'Fresh-built footlong subs, wraps, and salads.', + '2 Heskiet St. (Gulf), Keyser, WV 26726','304-788-3613','','subway.com', + $h(['Mon'=>'7am–10pm','Tue'=>'7am–10pm','Wed'=>'7am–10pm','Thu'=>'7am–10pm','Fri'=>'7am–10pm','Sat'=>'8am–10pm','Sun'=>'9am–9pm']),39.4260,-78.9720,0], + ['Taco Bell','taco-bell',$D,'Mexican Fast Food', + 'Tacos, burritos, nachos, and Crunchwrap Supremes. Drive-through available.', + '41 Plaza Drive, Keyser, WV 26726','304-788-0000','','tacobell.com', + $h(['Mon'=>'7am–12am','Tue'=>'7am–12am','Wed'=>'7am–12am','Thu'=>'7am–12am','Fri'=>'7am–2am','Sat'=>'7am–2am','Sun'=>'8am–12am']),39.4265,-78.9722,0], + ["Ducky's Bar & Grill",'duckys-bar-grill',$D,'American Bar & Grill', + '"The ultimate combination of bar and grill." Famous Tennessee whiskey burger and chili cheese fries.', + 'Keyser, WV 26726','304-788-0000','','', + $h(['Mon'=>'11am–11pm','Tue'=>'11am–11pm','Wed'=>'11am–11pm','Thu'=>'11am–11pm','Fri'=>'11am–1am','Sat'=>'11am–1am','Sun'=>'12pm–9pm']),39.4358,-78.9738,0], + ["Martie's Hot Dog Stand",'marties-hot-dogs',$D,'Hot Dogs / Street Food', + "A beloved Keyser institution. Classic hot dogs with all the fixings for generations.", + '96 N. Main St., Keyser, WV 26726','304-788-7690','','', + $h(['Mon'=>'10am–6pm','Tue'=>'10am–6pm','Wed'=>'10am–6pm','Thu'=>'10am–6pm','Fri'=>'10am–7pm','Sat'=>'10am–7pm','Sun'=>'Closed']),39.4375,-78.9734,0], + ["Jin's Asian Cuisine 2",'jins-asian-cuisine',$D,'Asian / Chinese', + "Orange chicken, bourbon chicken, lo mein, fried rice, and crab rangoon made fresh to order.", + '196 N. Tornado Way #11, Keyser, WV 26726','304-788-2222','','', + $h(['Mon'=>'11am–9pm','Tue'=>'11am–9pm','Wed'=>'11am–9pm','Thu'=>'11am–9pm','Fri'=>'11am–10pm','Sat'=>'11am–10pm','Sun'=>'12pm–9pm']),39.4355,-78.9730,0], + // Shopping + ["Wayne's Country Meats",'waynes-country-meats',$SH,'Butcher / Specialty Meats', + 'Premium quality meats from local farms. Custom cuts, specialty items, fresh jerky.', + '670 Armstrong St., Keyser, WV 26726','304-788-5956','','', + $h(['Mon'=>'8am–6pm','Tue'=>'8am–6pm','Wed'=>'8am–6pm','Thu'=>'8am–6pm','Fri'=>'8am–6pm','Sat'=>'8am–5pm','Sun'=>'Closed']),39.4395,-78.9750,1], + ['Save-A-Lot','save-a-lot',$SH,'Grocery', + 'Affordable grocery shopping. Fresh produce, quality meats, dairy, and pantry essentials.', + 'S. Mineral St., Keyser, WV 26726','304-788-7570','','save-a-lot.com', + $h(['Mon'=>'8am–9pm','Tue'=>'8am–9pm','Wed'=>'8am–9pm','Thu'=>'8am–9pm','Fri'=>'8am–9pm','Sat'=>'8am–9pm','Sun'=>'9am–7pm']),39.4298,-78.9728,0], + ['AutoZone','autozone',$SH,'Auto Parts', + "America's leading auto parts retailer. Free battery testing, loaner tool program.", + '2 W. Piedmont St., Keyser, WV 26726','304-788-9058','','autozone.com', + $h(['Mon'=>'7:30am–9pm','Tue'=>'7:30am–9pm','Wed'=>'7:30am–9pm','Thu'=>'7:30am–9pm','Fri'=>'7:30am–9pm','Sat'=>'7:30am–9pm','Sun'=>'9am–8pm']),39.4378,-78.9734,0], + ['CVS Pharmacy','cvs',$SH,'Pharmacy / Health & Beauty', + 'Prescriptions, health products, beauty supplies, and household essentials.', + '45 S. Mineral St., Keyser, WV 26726','304-788-3443','','cvs.com', + $h(['Mon'=>'8am–9pm','Tue'=>'8am–9pm','Wed'=>'8am–9pm','Thu'=>'8am–9pm','Fri'=>'8am–9pm','Sat'=>'9am–6pm','Sun'=>'10am–6pm']),39.4368,-78.9734,0], + ['AT&T Store','att-store',$SH,'Telecommunications', + 'Latest smartphones, tablets, and wireless plans.', + '862 S. Mineral St., Keyser, WV 26726','304-788-9000','','att.com', + $h(['Mon'=>'10am–7pm','Tue'=>'10am–7pm','Wed'=>'10am–7pm','Thu'=>'10am–7pm','Fri'=>'10am–7pm','Sat'=>'10am–7pm','Sun'=>'12pm–5pm']),39.4292,-78.9728,0], + ["Christy's Florist",'christys-florist',$SH,'Florist', + 'Fresh floral arrangements for weddings, funerals, anniversaries, and celebrations.', + '81 N. Main St., Keyser, WV 26726','304-788-3679','','', + $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–6pm','Sat'=>'9am–3pm','Sun'=>'Closed']),39.4382,-78.9734,0], + ['Southern States','southern-states',$SH,'Farm & Garden Supply', + 'Agricultural supplies, pet food, hardware, seed, fertilizer, and home and garden products.', + '201 Patrick St., Keyser, WV 26726','304-788-2317','','southernstates.com', + $h(['Mon'=>'7:30am–5:30pm','Tue'=>'7:30am–5:30pm','Wed'=>'7:30am–5:30pm','Thu'=>'7:30am–5:30pm','Fri'=>'7:30am–5:30pm','Sat'=>'8am–3pm','Sun'=>'Closed']),39.4410,-78.9755,0], + ['Back Alley Crafts & Tarts','back-alley-crafts',$SH,'Crafts & Gifts', + 'Unique handmade crafts, locally made artisan goods, and specialty gifts.', + '226 S. Main St., Keyser, WV 26726','304-813-6256','','', + $h(['Mon'=>'10am–5pm','Tue'=>'10am–5pm','Wed'=>'10am–5pm','Thu'=>'10am–5pm','Fri'=>'10am–6pm','Sat'=>'10am–4pm','Sun'=>'Closed']),39.4357,-78.9734,0], + ['Heaven Sent Creations','heaven-sent-creations',$SH,'Gifts & Crafts', + 'Inspirational gifts, custom creations, home décor, and unique finds.', + '117 Armstrong St., Keyser, WV 26726','304-790-7402','','', + $h(['Mon'=>'10am–5pm','Tue'=>'10am–5pm','Wed'=>'10am–5pm','Thu'=>'10am–5pm','Fri'=>'10am–5pm','Sat'=>'10am–4pm','Sun'=>'Closed']),39.4393,-78.9750,0], + ['Thunder Hill Outfitters','thunder-hill-outfitters',$SH,'Hunting & Fishing Supplies', + "Hunting and fishing supplies, gear, licenses, and local expertise.", + '105 Armstrong St., Keyser, WV 26726','304-790-7125','','', + $h(['Mon'=>'9am–6pm','Tue'=>'9am–6pm','Wed'=>'9am–6pm','Thu'=>'9am–6pm','Fri'=>'9am–7pm','Sat'=>'8am–7pm','Sun'=>'10am–4pm']),39.4392,-78.9750,0], + // Lodging + ['SureStay Plus by Best Western Keyser','surestay-best-western',$LO,'Hotel', + 'Free hot breakfast daily, free WiFi, indoor pool, and fitness center. Pet-friendly.', + 'New Creek Hwy, Keyser, WV 26726','304-788-0000','','bestwestern.com',$always,39.4298,-78.9705,1], + ['Keyser Inn','keyser-inn',$LO,'Motel', + 'Affordable, clean accommodations. Continental breakfast, free WiFi, pet-friendly.', + 'Keyser, WV 26726','304-788-0000','','',$always,39.4360,-78.9734,0], + // Healthcare + ['WVU Medicine Potomac Valley Hospital','pvh',$HC,'Hospital / Medical Center', + 'Full-service community hospital. Emergency department, surgery, maternity care, cardiac services.', + '100 Pin Oak Ln., Keyser, WV 26726','304-597-3500','','wvumedicine.org',$always,39.4275,-78.9725,1], + ['Med-a-Save Pharmacy','med-a-save',$HC,'Independent Pharmacy', + 'Local independent pharmacy. Prescriptions, compounding, and immunizations.', + '818 S. Mineral St., Keyser, WV 26726','304-788-6010','','', + $h(['Mon'=>'9am–6pm','Tue'=>'9am–6pm','Wed'=>'9am–6pm','Thu'=>'9am–6pm','Fri'=>'9am–6pm','Sat'=>'9am–1pm','Sun'=>'Closed']),39.4287,-78.9728,0], + // Education + ['Potomac State College of WVU','potomac-state-college',$ED,'Community College / University', + "WVU Potomac State College — founded 1901 on historic Fort Fuller. Associate and bachelor's degrees in 40+ programs. Home of the Catamounts.", + '101 Fort Ave., Keyser, WV 26726','304-788-6800','','potomacstatecollege.edu',$std,39.4368,-78.9715,1], + ['Mineral County Public Library','mineral-county-library',$ED,'Public Library', + 'Books, digital resources, internet access, printing, and community programs for all ages.', + 'Keyser, WV 26726','304-788-3100','','', + $h(['Mon'=>'10am–7pm','Tue'=>'10am–7pm','Wed'=>'10am–7pm','Thu'=>'10am–7pm','Fri'=>'10am–5pm','Sat'=>'10am–4pm','Sun'=>'Closed']),39.4370,-78.9734,0], + // Financial + ["First United Bank & Trust",'first-united-bank',$FI,'Community Bank', + 'Community banking rooted in West Virginia. Personal checking, savings, mortgages.', + '29 West Southern Dr., Keyser, WV 26726','304-788-2552','','mybank.com', + $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–5:30pm','Sat'=>'9am–12pm','Sun'=>'Closed']),39.4372,-78.9740,0], + ['M&T Bank','m-t-bank',$FI,'Bank', + 'Full-service personal, small business, and commercial banking. 24/7 ATM.', + '67 N. Main St., Keyser, WV 26726','304-788-6782','','mtb.com', + $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–6pm','Sat'=>'9am–12pm','Sun'=>'Closed']),39.4380,-78.9734,0], + // Automotive + ["Boddy's Automotive",'boddys-auto',$AU,'Full Service Auto Repair', + 'Trusted local auto repair for all makes and models. Honest prices.', + '220 Armstrong St., Keyser, WV 26726','304-788-5511','','', + $h(['Mon'=>'8am–5pm','Tue'=>'8am–5pm','Wed'=>'8am–5pm','Thu'=>'8am–5pm','Fri'=>'8am–5pm','Sat'=>'Closed','Sun'=>'Closed']),39.4392,-78.9750,0], + ['Gimme A Brake Auto','gimme-a-brake',$AU,'Brakes / Tires / Oil Changes', + 'Fast, friendly, and affordable auto care. No appointment needed for routine maintenance.', + '375 West Piedmont St., Keyser, WV 26726','304-788-7810','','', + $h(['Mon'=>'8am–5:30pm','Tue'=>'8am–5:30pm','Wed'=>'8am–5:30pm','Thu'=>'8am–5:30pm','Fri'=>'8am–5:30pm','Sat'=>'8am–12pm','Sun'=>'Closed']),39.4378,-78.9748,0], + // Professional Services + ['Boggs Supply and Rental Center','boggs-supply',$PS,'Equipment Rental / Supply', + 'Tools, equipment, and supplies for rent or purchase.', + '464 Harley O Staggers Sr. Dr., Keyser, WV 26726','304-788-1617','','', + $h(['Mon'=>'7:30am–5pm','Tue'=>'7:30am–5pm','Wed'=>'7:30am–5pm','Thu'=>'7:30am–5pm','Fri'=>'7:30am–5pm','Sat'=>'8am–12pm','Sun'=>'Closed']),39.4382,-78.9748,0], + ['Legal Aid of WV','legal-aid',$LG,'Legal Aid', + 'Free civil legal aid for eligible low-income West Virginians.', + '251 W. Piedmont St., Keyser, WV 26726','304-788-6770','','lawv.net',$std,39.4374,-78.9750,0], + // Recreation + ['North Branch Ventures','north-branch-ventures',$RF,'Kayak Rental / Outdoor Recreation', + "Kayak and river supply rentals on the beautiful North Branch Potomac. Guided river experiences.", + '101 Armstrong St., Keyser, WV 26726','304-790-7125','','', + $h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'10am–6pm','Thu'=>'10am–6pm','Fri'=>'9am–7pm','Sat'=>'8am–7pm','Sun'=>'8am–5pm']),39.4392,-78.9750,1], + ['PSC Recreation Center','psc-rec-center',$RF,'Fitness Center / Pool', + "Full-service fitness center with indoor pool, hot tub, strength and cardio, group exercise. Open to the public.", + 'J. Edward Kelley Complex, 101 Fort Ave., Keyser, WV','304-788-6800','','potomacstatecollege.edu', + $h(['Mon'=>'6am–9pm','Tue'=>'6am–9pm','Wed'=>'6am–9pm','Thu'=>'6am–9pm','Fri'=>'6am–8pm','Sat'=>'8am–6pm','Sun'=>'12pm–6pm']),39.4368,-78.9715,0], + ['Mineral County Parks & Recreation','mineral-county-parks',$RF,'Parks & Recreation', + "Larenim Park — 365 acres with pavilions, 600-seat amphitheater, fishing ponds, and trails.", + 'Keyser, WV 26726','304-788-3066','','mineralwv.gov', + $h(['Mon'=>'Open Daily','Tue'=>'Open Daily','Wed'=>'Open Daily','Thu'=>'Open Daily','Fri'=>'Open Daily','Sat'=>'Open Daily','Sun'=>'Open Daily']),39.4350,-78.9720,0], + // Beauty + ['Eclips Hair Salon','eclips',$BP,'Hair Salon', + 'Full-service hair salon. Cuts, color, styling, and treatments for men and women.', + '153 S. Mineral St., Keyser, WV 26726','304-788-5578','','', + $h(['Mon'=>'Closed','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–6pm','Sat'=>'8am–4pm','Sun'=>'Closed']),39.4352,-78.9733,0], + ['In The Skin II','in-the-skin',$BP,'Tattoo & Body Art', + 'Professional tattoo and body art studio. Custom designs, cover-ups, and piercings.', + '129 N. Main St., Keyser, WV 26726','304-597-2070','','', + $h(['Mon'=>'11am–7pm','Tue'=>'11am–7pm','Wed'=>'11am–7pm','Thu'=>'11am–7pm','Fri'=>'11am–8pm','Sat'=>'10am–6pm','Sun'=>'Closed']),39.4373,-78.9734,0], + // Government + ['Mineral County Courthouse','mineral-county-courthouse',$GV,'County Government', + "Historic 1868 courthouse listed on the National Register of Historic Places — the architectural crown jewel of downtown Keyser.", + 'Keyser, WV 26726','304-788-3003','','mineralwv.gov', + $h(['Mon'=>'8:30am–4:30pm','Tue'=>'8:30am–4:30pm','Wed'=>'8:30am–4:30pm','Thu'=>'8:30am–4:30pm','Fri'=>'8:30am–4:30pm','Sat'=>'Closed','Sun'=>'Closed']),39.4385,-78.9738,0], + ['City of Keyser','city-of-keyser',$GV,'City Government', + "Official offices of the City of Keyser, WV. City council, mayor's office, utilities, and municipal services.", + 'Keyser, WV 26726','304-788-0222','','cityofkeyser.com', + $h(['Mon'=>'8:30am–4:30pm','Tue'=>'8:30am–4:30pm','Wed'=>'8:30am–4:30pm','Thu'=>'8:30am–4:30pm','Fri'=>'8:30am–4:30pm','Sat'=>'Closed','Sun'=>'Closed']),39.4375,-78.9734,0], + ['Mineral County Tourism Visitors Center','mineral-county-tourism',$GV,'Tourism / Visitors Center', + '167 S. Mineral Street — Your starting point for exploring Mineral County! Brochures, maps, and friendly staff.', + '167 S. Mineral St., Keyser, WV 26726','304-790-7081','mineralcocvb@gmail.com','govisitmineralwv.com', + $h(['Mon'=>'9am–4pm','Tue'=>'9am–4pm','Wed'=>'9am–4pm','Thu'=>'9am–4pm','Fri'=>'9am–4pm','Sat'=>'Call Ahead','Sun'=>'Closed']),39.4358,-78.9734,1], + ['Mineral County Historical Society Museum','mineral-county-historical',$GV,'Museum / Historical Society', + "Museum tracking the history of Mineral County and Keyser with engaging exhibits and artifacts. Open by appointment.", + 'Keyser, WV 26726','304-788-3066','','', + $h(['Mon'=>'By Appt','Tue'=>'By Appt','Wed'=>'By Appt','Thu'=>'By Appt','Fri'=>'By Appt','Sat'=>'Special Events','Sun'=>'Special Events']),39.4378,-78.9734,0], + // Churches + ['Keyser First United Methodist Church','keyser-umc',$CH,'Methodist Church', + 'Welcoming United Methodist congregation serving Keyser since the 19th century.', + '32 N. Davis St., Keyser, WV 26726','304-788-2522','','', + $h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'Closed','Thu'=>'Closed','Fri'=>'Closed','Sat'=>'Closed','Sun'=>'Worship 11am']),39.4372,-78.9730,0], + ['Living Faith Fellowship','living-faith',$CH,'Non-Denominational Church', + 'Vibrant non-denominational Christian fellowship. Contemporary worship and community ministry.', + '1 N. Main St., Keyser, WV 26726','304-788-1910','','', + $h(['Mon'=>'Closed','Tue'=>'Closed','Wed'=>'7pm','Thu'=>'Closed','Fri'=>'Closed','Sat'=>'Closed','Sun'=>'Worship 10:30am']),39.4376,-78.9734,0], + ]; + + foreach ($biz as $r) $bi->execute($r); + + /* ── Menus ── */ + $si = $db->prepare("INSERT INTO menu_sections(business_id,name,description,sort_order)VALUES(?,?,?,?)"); + $mi = $db->prepare("INSERT INTO menu_items(section_id,name,description,price,sort_order)VALUES(?,?,?,?,?)"); + + $castId = (int)qval("SELECT id FROM businesses WHERE slug='castiglia-italian'"); + foreach ([['Antipasti & Salads','Fresh starters',1],['Pasta','Handmade pastas',2],['Pizza','Stone-baked Italian pizzas',3],['Entrees','Secondi piatti',4],['Desserts','Dolci',5]] as $s) $si->execute(array_merge([$castId],$s)); + $cs=[]; foreach(qall("SELECT id,name FROM menu_sections WHERE business_id=$castId") as $r) $cs[$r['name']]=$r['id']; + foreach(['Antipasti & Salads'=>[['Bruschetta al Pomodoro','Toasted rustic bread with vine-ripened tomatoes, fresh basil & extra-virgin olive oil',8.95,1],['Calamari Fritti','Lightly breaded fresh calamari, marinara sauce & lemon aioli',13.95,2],['Caprese Salad','Fresh buffalo mozzarella, heirloom tomatoes, basil & balsamic glaze',11.95,3],['Minestrone Soup','Classic Italian vegetable soup with cannellini beans',7.50,4],['Caesar Salad','Romaine, house Caesar dressing, shaved Parmigiano, croutons',9.95,5]], + 'Pasta'=>[['Spaghetti Carbonara','Classic Roman — guanciale, egg, Pecorino Romano, cracked black pepper',15.95,1],['Fettuccine Alfredo','House-made fettuccine in rich cream & Parmigiano sauce',14.95,2],['Lasagna al Forno','House-rolled pasta, Bolognese, bechamel & Parmigiano',16.95,3],['Baked Ziti','Ziti, house marinara, ricotta & melted mozzarella',13.95,4],['Penne Arrabbiata','Spicy tomato, garlic, Calabrian chiles & fresh parsley',12.95,5]], + 'Pizza'=>[['Margherita','San Marzano tomatoes, fior di latte mozzarella & fresh basil',14.95,1],['Pepperoni','House marinara, mozzarella & premium pepperoni',15.95,2],['Quattro Stagioni','Ham, mushrooms, artichoke hearts & Kalamata olives',17.95,3],['Prosciutto & Arugula','Parma prosciutto, baby arugula & shaved Parmigiano',18.95,4]], + 'Entrees'=>[['Chicken Parmigiana','Breaded chicken, house marinara & fresh mozzarella, served with pasta',18.95,1],['Osso Buco alla Milanese','Braised veal shank, saffron risotto & traditional gremolata',34.95,2],['Eggplant Parmigiana','Layered eggplant, San Marzano sauce & mozzarella',15.95,3],['Salmon Piccata','Pan-seared Atlantic salmon, lemon-caper butter & fresh herbs',24.95,4]], + 'Desserts'=>[['Tiramisu','Espresso-soaked ladyfingers, mascarpone cream & cocoa',8.95,1],['Cannoli Siciliani','Crispy shells with sweet ricotta, chocolate chips & pistachios',7.95,2],['Panna Cotta','Silky vanilla panna cotta with seasonal berry coulis',7.50,3],['Gelato del Giorno','Two scoops house-made gelato — ask your server',6.95,4]]] + as $sec=>$items) foreach($items as $item) $mi->execute(array_merge([$cs[$sec]],$item)); + + // Queens Point Coffee menu + $qpId = (int)qval("SELECT id FROM businesses WHERE slug='queens-point-coffee'"); + foreach ([['Espresso Drinks','Hot & cold espresso beverages',1],['Cold Drinks','Refreshing chilled options',2],['Food','Fresh baked goods & light bites',3]] as $s) $si->execute(array_merge([$qpId],$s)); + $qs=[]; foreach(qall("SELECT id,name FROM menu_sections WHERE business_id=$qpId") as $r) $qs[$r['name']]=$r['id']; + foreach(['Espresso Drinks'=>[['Latte','Smooth espresso with steamed milk',4.50,1],['Cappuccino','Double espresso with foamed milk',4.25,2],['Americano','Espresso shots with hot water',3.50,3],['Mocha','Espresso, chocolate & steamed milk',5.00,4],['Iced Matcha Latte','Matcha with milk — our most raved drink!',5.25,5]], + 'Cold Drinks'=>[['Cold Brew','Smooth 24-hour cold brew over ice',3.75,1],['Iced Latte','Chilled espresso with cold milk',4.75,2],['Blended Frappe','Blended ice coffee drink',5.50,3]], + 'Food'=>[['Blueberry Muffin','Fresh-baked daily',3.00,1],['Avocado Toast','Multigrain with avocado & everything spice',7.50,2],['Breakfast Sandwich','Egg, cheese & choice of meat',6.50,3],['Cookie','Assorted fresh-baked cookies',2.50,4]]] + as $sec=>$items) foreach($items as $item) $mi->execute(array_merge([$qs[$sec]],$item)); + + /* ── Alerts ── */ + foreach ([ + ['castiglia-italian','news','New Menu Items This Season!', + "Chef's new handmade gnocchi and wood-fired seasonal pizza are now available. Fresh locally-sourced summer ingredients throughout the menu."], + ['queens-point-coffee','news','Try Our Famous Iced Matcha!', + '"THE best coffee shop I\'ve ever been to." Our iced matcha with coconut and pistachio is the most-talked-about drink in Keyser.'], + ['clancys-irish-pub','event','Live Music Every Friday & Saturday!', + "Live music from 9pm-1am every Friday and Saturday night. No cover charge! Food and drinks served until close."], + ['pvh','info','Urgent Care Now Open 7 Days', + 'WVU Medicine Potomac Valley Hospital Urgent Care is open Mon-Fri 8am-8pm and weekends 9am-5pm. No appointment needed.'], + ['north-branch-ventures','event','Guided Kayak Tours - Book Now!', + "Guided half-day kayak tours on the North Branch Potomac every Saturday and Sunday through October. All skill levels welcome."], + ] as [$slug,$type,$title,$body]) + qrun("INSERT INTO alerts(business_id,type,title,body,is_active)VALUES((SELECT id FROM businesses WHERE slug=?),?,?,?,1)",[$slug,$type,$title,$body]); + + /* ── Attractions ── */ + foreach ([ + ['Queens Point Overlook','Natural Landmark',"A spectacular Oriskany sandstone cliff rising ~400 feet above the North Branch Potomac River. Breathtaking panoramic views of Keyser and the river valley.",'McCoole, MD (viewable from Keyser riverfront)','','','Accessible year-round',1,1], + ['North Branch Potomac River','Outdoor Recreation',"World-class smallmouth bass fishing, kayaking, canoeing, and bird watching. Contact North Branch Ventures for rentals and guided tours.",'Armstrong St. riverfront, Keyser, WV','304-790-7125','','Open year-round',1,2], + ['Jennings Randolph Lake','Lake / Reservoir',"A stunning 952-acre reservoir on the North Branch Potomac. Boating, fishing, swimming, camping, and picnicking. Managed by U.S. Army Corps of Engineers.",'Elk Garden, WV 26717 (~20 min from Keyser)','304-355-2346','','Open year-round',1,3], + ['Larenim Park','County Park',"Mineral County's premier park at 365 acres. Pavilions, 600-seat amphitheater, fishing ponds, and 5 miles of trails.",'Keyser, WV 26726','304-788-3066','','Open daily',1,4], + ['Mineral County Courthouse','Historic Site',"The 1868 courthouse listed on the National Register of Historic Places — the architectural centerpiece of downtown Keyser.",'Keyser, WV 26726','304-788-3003','','Exterior always viewable',1,5], + ['Potomac State College Campus / Fort Fuller','Historic Site',"Fort Hill — site of Civil War Fort Fuller — commanded by future president Benjamin Harrison and Lew Wallace (author of Ben-Hur). Now the beautiful PSC campus.",'101 Fort Ave., Keyser, WV 26726','304-788-6800','potomacstatecollege.edu','Campus open daily',1,6], + ['Potomac Eagle Scenic Railroad','Train Excursion',"Scenic excursions through The Trough along the South Branch Potomac River — famous for bald eagle sightings. About 30 miles from Keyser.",'Romney, WV 26757 (~30 miles)','304-822-7400','potomaceagle.info','Seasonal spring through fall',1,7], + ['Blackwater Falls State Park','State Park',"The amber-colored Blackwater Falls plunge 57 feet into the dramatic canyon. Hiking, skiing, camping, and a beautiful lodge. About 40 minutes from Keyser.",'Davis, WV 26260 (~40 min)','304-259-5216','wvstateparks.com','Open year-round',1,8], + ['Seneca Rocks','Natural Landmark',"Massive quartzite outcroppings rising 900 feet from the valley floor. Rock climbing, hiking, and spectacular views. About 45 minutes from Keyser.",'Seneca Rocks, WV (~45 min)','304-567-2827','','Open year-round',1,9], + ['Fort Ashby Blockhouse','Historic Site',"The only remaining French and Indian War fort in West Virginia. Ordered built by young George Washington in 1755. Just 15 miles from Keyser.",'Fort Ashby, WV 26719 (~15 miles)','','','Open seasonally',1,10], + ] as $a) qrun("INSERT INTO attractions(name,category,description,address,phone,website,hours,is_active,sort_order)VALUES(?,?,?,?,?,?,?,?,?)",$a); + + /* ── Events ── */ + $adminId=(int)qval("SELECT id FROM users WHERE username='administrator'"); + foreach ([ + ['Mineral County Fair','Annual county fair featuring livestock shows, carnival rides, food vendors, live entertainment, and the best of Mineral County agriculture.','Mineral County Fairgrounds','Fort Ashby, WV','2025-08-12','9:00 AM','2025-08-16','10:00 PM','Admission varies','mineralcountyfair.org','','','304-788-0000',$adminId,'approved',1], + ['Keyser Christmas Parade & Tree Lighting','Annual holiday parade through downtown Keyser with floats, marching bands, Santa Claus, and tree lighting.','Downtown Main Street','Keyser, WV 26726','2025-12-06','5:00 PM','2025-12-06','7:00 PM','Free','cityofkeyser.com','','','304-788-0222',$adminId,'approved',1], + ['PSC Catamount Athletics','Support the WVU Potomac State Catamounts at home athletic events throughout the semester.','Potomac State College','101 Fort Ave., Keyser','2025-09-01','Varies','2025-11-30','Varies','Free–$5','potomacstatecollege.edu','','','304-788-6800',$adminId,'approved',0], + ['North Branch Kayak Adventure Series','Guided and recreational kayaking on the North Branch Potomac every Saturday through October.','North Branch Ventures','101 Armstrong St., Keyser','2025-05-01','8:00 AM','2025-10-31','4:00 PM','$25–$45','','','','304-790-7125',$adminId,'approved',0], + ] as $ev) 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,is_featured)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",$ev); +} diff --git a/includes/footer.php b/includes/footer.php new file mode 100644 index 0000000..476c726 --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,49 @@ + + + + + + + + diff --git a/includes/functions.php b/includes/functions.php new file mode 100644 index 0000000..342d92e --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,137 @@ + 0; } +function isAdmin(): bool{ return !empty($_SESSION['admin']); } + +function requireLogin(string $back = ''): void { + if (!authed()) { + flash('Please log in to continue.', 'warning'); + go('/login.php?next='.urlencode($back ?: $_SERVER['REQUEST_URI'])); + } +} +function requireAdmin(): void { + if (!isAdmin()) { flash('Access denied.', 'error'); go('/index.php'); } +} +function loginUser(array $u): void { + session_regenerate_id(true); + $_SESSION['uid'] = $u['id']; + $_SESSION['uname'] = $u['username']; + $_SESSION['admin'] = (bool)$u['is_admin']; + qrun("UPDATE users SET last_login=datetime('now') WHERE id=?", [$u['id']]); +} +function logoutUser(): void { + session_unset(); session_destroy(); + session_start(); session_regenerate_id(true); +} + +/* ── Flash ────────────────────────────────────────────── */ +function flash(string $msg, string $type = 'info'): void { $_SESSION['_flash'][] = [$type, $msg]; } +function getFlashes(): array { $f = $_SESSION['_flash'] ?? []; unset($_SESSION['_flash']); return $f; } + +/* ── Output ───────────────────────────────────────────── */ +function e(mixed $v): string { return htmlspecialchars((string)($v ?? ''), ENT_QUOTES|ENT_SUBSTITUTE, 'UTF-8'); } +function go(string $url): never { header('Location:'.$url); exit; } + +/* ── Input ────────────────────────────────────────────── */ +function p(string $k, mixed $d = ''): mixed { return $_POST[$k] ?? $d; } +function g(string $k, mixed $d = ''): mixed { return $_GET[$k] ?? $d; } +function iget(string $k): int { return (int)($_POST[$k] ?? $_GET[$k] ?? 0); } +function ps(string $k): string { return trim((string)($_POST[$k] ?? '')); } +function gs(string $k): string { return trim((string)($_GET[$k] ?? '')); } +function isPost(): bool { return strtoupper($_SERVER['REQUEST_METHOD']) === 'POST'; } + +/* ── CSRF ─────────────────────────────────────────────── */ +function csrf(): string { + if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(24)); + return $_SESSION['csrf']; +} +function csrfField(): string { return ''; } +function csrfCheck(): void { + if (!hash_equals(csrf(), p('_csrf', ''))) { http_response_code(403); die('Invalid CSRF token.'); } +} + +/* ── Settings ─────────────────────────────────────────── */ +function setting(string $k, string $def = ''): string { + $v = qval("SELECT value FROM settings WHERE key=?", [$k]); + return ($v !== false && $v !== null) ? (string)$v : $def; +} +function setSetting(string $k, string $v): void { + qrun("INSERT INTO settings(key,value)VALUES(?,?)ON CONFLICT(key)DO UPDATE SET value=excluded.value", [$k,$v]); +} + +/* ── Ownership ────────────────────────────────────────── */ +function owns(int $bizId): bool { + if (isAdmin()) return true; + if (!authed()) return false; + return (bool)qval("SELECT 1 FROM business_owners WHERE user_id=? AND business_id=?", [uid(),$bizId]); +} + +/* ── Stars ────────────────────────────────────────────── */ +function starDisplay(float $avg): string { + $r = (int)round($avg); + $out = ''; + for ($i = 1; $i <= 5; $i++) $out .= ''; + return $out.''; +} +function starPicker(string $name = 'rating', int $sel = 0): string { + $out = '
'; + for ($i = 5; $i >= 1; $i--) { + $chk = $sel === $i ? 'checked' : ''; + $out .= ''; + $out .= ''; + } + return $out.'
'; +} + +/* ── Date / Money ─────────────────────────────────────── */ +function ago(mixed $dt): string { + if ($dt === null || $dt === '' || $dt === false) return ''; + $ts = is_int($dt) ? $dt : @strtotime((string)$dt); + if (!$ts) return ''; + $d = time() - $ts; + if ($d < 60) return 'Just now'; + if ($d < 3600) return floor($d / 60).'m ago'; + if ($d < 86400) return floor($d / 3600).'h ago'; + if ($d < 604800) return floor($d / 86400).'d ago'; + return date('M j, Y', $ts); +} +function fdate(mixed $dt, string $fmt = 'M j, Y'): string { + if ($dt === null || $dt === '' || $dt === false) return ''; + $ts = is_int($dt) ? $dt : @strtotime((string)$dt); + return $ts ? date($fmt, $ts) : ''; +} +function money(float $n): string { return '$'.number_format($n, 2); } + +/* ── Misc ─────────────────────────────────────────────── */ +function alertIcon(mixed $t): string { + return match((string)($t ?? '')) { + 'news' => '📰', + 'event' => '🎉', + 'warning' => '⚠️', + 'sale' => '🏷️', + default => 'ℹ️', + }; +} + +function parseHours(mixed $json): array { + $s = (string)($json ?? ''); + if ($s === '' || $s === '[]' || $s === 'null') return []; + $decoded = json_decode($s, true); + if (!is_array($decoded)) return []; + // Must be an object (associative), not a plain list + foreach (array_keys($decoded) as $key) { + if (!is_string($key)) return []; + } + return $decoded; +} + +function makeSlug(string $s): string { + return trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($s)), '-'); +} +function uniqueSlug(string $base): string { + $slug = makeSlug($base); $i = 0; + while (qval("SELECT id FROM businesses WHERE slug=?", [$slug.($i ? "-$i" : "")])) $i++; + return $slug . ($i ? "-$i" : ""); +} diff --git a/includes/header.php b/includes/header.php new file mode 100644 index 0000000..b59065e --- /dev/null +++ b/includes/header.php @@ -0,0 +1,62 @@ + + + + + +<?=e($pageTitle??'Discover Keyser WV')?> + + + + + + + + + + + + + + +
diff --git a/index.php b/index.php new file mode 100644 index 0000000..68d47ba --- /dev/null +++ b/index.php @@ -0,0 +1,174 @@ +=date('now') ORDER BY is_featured DESC,event_date ASC LIMIT 3"); +include __DIR__.'/includes/header.php'; +?> +
+
+
+
+ + + + + + + + + + +
+
+
⛰ MINERAL COUNTY · EASTERN PANHANDLE · WEST VIRGINIA
+

DISCOVERKEYSER

+
NORTH BRANCH POTOMAC RIVER · FOUNDED 1874
+
+

Where Appalachian heritage meets mountain beauty. County seat of Mineral County — 3 hours from Washington D.C., a world away from ordinary.

+ +
+
+ +
+
+ +
+ +
+
+ + +
+
+
📢 LOCAL BUSINESS NEWS & UPDATES
+ +
+
+
+
+
+
From ·
+
+
+ +
+
+ + + + +
+
+
+
OUR HISTORY
+

A City with Deep Mountain Roots

+
+

Originally called Paddy Town after Irish settler Patrick McCarty, then New Creek, Keyser took its name in 1874 — honoring William Keyser, Vice President of the Baltimore & Ohio Railroad whose line transformed the region in 1842.

+

Today Keyser is home to Potomac State College of WVU (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.

+

Mineral County is part of the Appalachian Forest National Heritage Area — over 500 square miles of forested beauty where American history lives on in every ridge and hollow.

+
+ Learn More About Keyser +
+
+ +
+
+
+
+
+ +
+
+
+ + +
+
+
📅 WHAT'S HAPPENING
+

Upcoming Events

+
+
+ +
+
+
+
+
+
+
+
★ FEATURED
+
+ 📍 + 🕐 + 💰 +
+
180?'…':'')?>
+
+
+ +
+ All Events + Submit an Event +
+
+ + +
+
JOIN THE COMMUNITY
+

Own a Business in Keyser?

+

Create an account to manage your listing, post alerts, add menus, and connect with the Keyser community.

+ +
+ diff --git a/login.php b/login.php new file mode 100644 index 0000000..d593412 --- /dev/null +++ b/login.php @@ -0,0 +1,35 @@ + +
+

Sign In

+

Welcome back to Discover Keyser WV

+
+
+ +
+
+ +
+ +

Don't have an account? Sign up free

+ +
+ diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..1d6ce92 --- /dev/null +++ b/logout.php @@ -0,0 +1,5 @@ + +
+
+
OWNER PANEL
+

Manage Menu

+

· View Listing

+
+
+
+ +
+
➕ ADD MENU SECTION
+
+
+
+
+
+ +
+
+ + +
+
+
+
+
+
+
+ +
+
+
+ + + + + +
+
+
+
Unavailable
+
+ 0): ?>
+
+
+ +
+
+
+ +
+ + + +
+
➕ Add Item to "" +
+
+
+
+
+
+
+ +
+
+
+
+
+ + + +
🍽️

No menu sections yet

Add a section above to start building your menu.

+ + + ← Back to Listing +
+ + diff --git a/register.php b/register.php new file mode 100644 index 0000000..c677d49 --- /dev/null +++ b/register.php @@ -0,0 +1,293 @@ + + + + +
+ + +
+
JOIN THE COMMUNITY
+

Create Your Account

+

+ Discover Keyser WV is your community hub for local businesses, events, and information. + Takes less than a minute. +

+
+ + +
+
+

👤 COMMUNITY MEMBER BENEFITS

+
    +
  • Rate and review local Keyser businesses
  • +
  • 📅Submit community events to the public calendar
  • +
  • 🚩Report incorrect or outdated business listings
  • +
  • 💬Share your experiences with the Keyser community
  • +
  • 📍Access your personal dashboard with your submissions
  • +
  • 🔔Stay connected with what's happening in Mineral County
  • +
+
+
+

🏢 BUSINESS OWNER BENEFITS

+
    +
  • ✏️Update your listing — description, hours, phone, address
  • +
  • 📢Post alerts and announcements directly on your business page
  • +
  • 🍽️Build and manage a full menu for your restaurant or café
  • +
  • 🟢Mark your business open or temporarily closed in real time
  • +
  • 📊View your listing's reviews and ratings from one dashboard
  • +
  • 🔑All changes are reviewed by our admin team before going live
  • +
+
+
+ + +
+ 🏢 Do you own or manage a Keyser business? Create your account below, + then contact our admin team at + + 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. +
+ + +
+

+ CREATE YOUR ACCOUNT +

+ + +
', array_map('e', $errors))?>
+ + +
+ + + + + + + + + +
+
+ + +
Letters, numbers, underscores only. Min 3 characters.
+
+
+ + +
Used only for account recovery. Never shared.
+
+
+ +
+
+ + +
Minimum 6 characters.
+
+
+ + +
+
+ + +
+ +
+ + + +

+ Already have an account? Sign in here +

+
+
+ +
+ +