PASSWORD *
diff --git a/scripts/keyser-admin.php b/scripts/keyser-admin.php
new file mode 100644
index 0000000..234bb0e
--- /dev/null
+++ b/scripts/keyser-admin.php
@@ -0,0 +1,366 @@
+ [options]');
+ out('');
+ out('Business commands:');
+ out(' business:list [--active=1|0|all] [--q=text] [--limit=50]');
+ out(' business:show --id=ID|--slug=SLUG');
+ out(' business:activate --id=ID|--slug=SLUG');
+ out(' business:deactivate --id=ID|--slug=SLUG');
+ out(' business:update --id=ID|--slug=SLUG [field options]');
+ out(' business:set-video --id=ID|--slug=SLUG --video-url=URL');
+ out(' business:clear-video --id=ID|--slug=SLUG');
+ out(' business:delete --id=ID|--slug=SLUG --yes');
+ out('');
+ out('Business update field options:');
+ out(' --name=TEXT --slug=TEXT --category-id=ID --subcategory=TEXT');
+ out(' --description=TEXT --address=TEXT --phone=TEXT --email=TEXT');
+ out(' --website=TEXT --video-url=URL --hours-json=JSON');
+ out(' --lat=NUM --lng=NUM --active=1|0 --featured=1|0');
+ out('');
+ out('Menu commands:');
+ out(' menu:wipe-all --yes');
+ out(' menu:wipe-business --id=ID|--slug=SLUG --yes');
+ out(' menu:show --id=ID|--slug=SLUG');
+ out('');
+ out('Other commands:');
+ out(' categories:list');
+ out('');
+ out('Examples:');
+ out(' php scripts/keyser-admin.php business:deactivate --slug=royal-restaurant');
+ out(' php scripts/keyser-admin.php business:update --slug=queens-point-coffee --phone="304-555-0100" --featured=1');
+ out(' php scripts/keyser-admin.php business:set-video --slug=queens-point-coffee --video-url="https://youtu.be/example"');
+ out(' php scripts/keyser-admin.php business:clear-video --slug=queens-point-coffee');
+ out(' php scripts/keyser-admin.php menu:wipe-business --slug=castiglia-italian --yes');
+ exit(0);
+}
+
+function parseArgs(array $argv): array {
+ $command = $argv[1] ?? 'help';
+ $options = [];
+
+ for ($i = 2; $i < count($argv); $i++) {
+ $arg = $argv[$i];
+ if (!str_starts_with($arg, '--')) {
+ fail("Unexpected argument: $arg");
+ }
+
+ $arg = substr($arg, 2);
+ if ($arg === '') {
+ fail('Empty option name.');
+ }
+
+ if (str_contains($arg, '=')) {
+ [$key, $value] = explode('=', $arg, 2);
+ } else {
+ $key = $arg;
+ $value = '1';
+ }
+
+ $options[str_replace('-', '_', $key)] = $value;
+ }
+
+ return [$command, $options];
+}
+
+function opt(array $options, string $key, mixed $default = null): mixed {
+ return $options[$key] ?? $default;
+}
+
+function requireYes(array $options): void {
+ if (opt($options, 'yes') !== '1') {
+ fail('This is destructive. Re-run with --yes if you are sure.');
+ }
+}
+
+function boolish(mixed $value, string $label): int {
+ $value = strtolower(trim((string)$value));
+ return match ($value) {
+ '1', 'yes', 'true', 'on', 'active', 'enabled' => 1,
+ '0', 'no', 'false', 'off', 'inactive', 'disabled' => 0,
+ default => fail("$label must be 1 or 0."),
+ };
+}
+
+function clipText(string $value, int $width): string {
+ if (function_exists('mb_strimwidth')) {
+ return mb_strimwidth($value, 0, $width, '');
+ }
+ return strlen($value) > $width ? substr($value, 0, $width) : $value;
+}
+
+function businessFromOptions(array $options): array {
+ $id = (int)opt($options, 'id', 0);
+ $slug = trim((string)opt($options, 'slug', ''));
+
+ if ($id > 0 && $slug !== '') {
+ fail('Use either --id or --slug, not both.');
+ }
+ if ($id <= 0 && $slug === '') {
+ fail('Provide --id=ID or --slug=SLUG.');
+ }
+
+ $business = $id > 0
+ ? qone("SELECT * FROM businesses WHERE id=?", [$id])
+ : qone("SELECT * FROM businesses WHERE slug=?", [$slug]);
+
+ if (!$business) {
+ fail('Business not found.');
+ }
+
+ return $business;
+}
+
+function printBusiness(array $business): void {
+ $category = qone("SELECT name, icon FROM categories WHERE id=?", [(int)$business['category_id']]);
+ out('ID: '.$business['id']);
+ out('Name: '.$business['name']);
+ out('Slug: '.$business['slug']);
+ out('Category: '.(($category['icon'] ?? '').' '.($category['name'] ?? 'Uncategorized')));
+ out('Subcategory: '.($business['subcategory'] ?: '-'));
+ out('Address: '.($business['address'] ?: '-'));
+ out('Phone: '.($business['phone'] ?: '-'));
+ out('Email: '.($business['email'] ?: '-'));
+ out('Website: '.($business['website'] ?: '-'));
+ out('Video URL: '.(($business['video_url'] ?? '') ?: '-'));
+ out('Active: '.((int)$business['is_active'] === 1 ? 'yes' : 'no'));
+ out('Featured: '.((int)$business['is_featured'] === 1 ? 'yes' : 'no'));
+ out('Lat/Lng: '.$business['lat'].', '.$business['lng']);
+ out('Hours JSON: '.$business['hours']);
+ out('Description: '.$business['description']);
+}
+
+function validateEmail(string $email): void {
+ if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ fail('Invalid email address.');
+ }
+}
+
+function validateHoursJson(string $json): void {
+ $decoded = json_decode($json, true);
+ if (!is_array($decoded)) {
+ fail('--hours-json must be a JSON object.');
+ }
+ foreach (array_keys($decoded) as $key) {
+ if (!is_string($key)) {
+ fail('--hours-json must be a JSON object with day names as keys.');
+ }
+ }
+}
+
+function setBusinessStatus(array $options, int $active): void {
+ $business = businessFromOptions($options);
+ qrun("UPDATE businesses SET is_active=? WHERE id=?", [$active, (int)$business['id']]);
+ out(($active ? 'Activated' : 'Deactivated').' business: '.$business['name'].' (#'.$business['id'].')');
+}
+
+function wipeBusinessMenu(int $businessId): array {
+ $sectionIds = array_column(qall("SELECT id FROM menu_sections WHERE business_id=?", [$businessId]), 'id');
+ $itemCount = 0;
+ if ($sectionIds) {
+ $placeholders = implode(',', array_fill(0, count($sectionIds), '?'));
+ $itemCount = (int)qval("SELECT COUNT(*) FROM menu_items WHERE section_id IN ($placeholders)", $sectionIds);
+ }
+ $sectionCount = count($sectionIds);
+ qrun("DELETE FROM menu_sections WHERE business_id=?", [$businessId]);
+ return [$sectionCount, $itemCount];
+}
+
+[$command, $options] = parseArgs($argv);
+
+if (in_array($command, ['help', '--help', '-h'], true)) {
+ usage();
+}
+
+try {
+ match ($command) {
+ 'business:list' => (function () use ($options): void {
+ $where = [];
+ $params = [];
+ $active = strtolower((string)opt($options, 'active', 'all'));
+ if ($active !== 'all') {
+ $where[] = 'b.is_active=?';
+ $params[] = boolish($active, '--active');
+ }
+ $q = trim((string)opt($options, 'q', ''));
+ if ($q !== '') {
+ $where[] = '(b.name LIKE ? OR b.slug LIKE ? OR b.address LIKE ? OR b.subcategory LIKE ?)';
+ $like = "%$q%";
+ array_push($params, $like, $like, $like, $like);
+ }
+ $limit = max(1, min(500, (int)opt($options, 'limit', 50)));
+ $sqlWhere = $where ? 'WHERE '.implode(' AND ', $where) : '';
+ $rows = qall("
+ SELECT b.id,b.name,b.slug,b.is_active,b.is_featured,COALESCE(c.name,'Uncategorized') cat
+ FROM businesses b
+ LEFT JOIN categories c ON c.id=b.category_id
+ $sqlWhere
+ ORDER BY b.name
+ LIMIT $limit
+ ", $params);
+
+ foreach ($rows as $row) {
+ out(sprintf(
+ '#%-4d %-38s %-26s active=%d featured=%d slug=%s',
+ $row['id'],
+ clipText((string)$row['name'], 38),
+ clipText((string)$row['cat'], 26),
+ $row['is_active'],
+ $row['is_featured'],
+ $row['slug']
+ ));
+ }
+ out(count($rows).' business'.(count($rows) === 1 ? '' : 'es').' shown.');
+ })(),
+
+ 'business:show' => printBusiness(businessFromOptions($options)),
+ 'business:activate' => setBusinessStatus($options, 1),
+ 'business:deactivate' => setBusinessStatus($options, 0),
+
+ 'business:set-video' => (function () use ($options): void {
+ $business = businessFromOptions($options);
+ $url = trim((string)opt($options, 'video_url', ''));
+ if ($url === '') {
+ fail('Provide --video-url=URL. Use --video-url="" with business:update to clear it.');
+ }
+ if (!validBusinessVideoUrl($url)) {
+ fail('Video URL must be a YouTube or Vimeo URL.');
+ }
+ qrun("UPDATE businesses SET video_url=? WHERE id=?", [$url, (int)$business['id']]);
+ out('Updated video URL for '.$business['name'].'.');
+ })(),
+
+ 'business:clear-video' => (function () use ($options): void {
+ $business = businessFromOptions($options);
+ qrun("UPDATE businesses SET video_url='' WHERE id=?", [(int)$business['id']]);
+ out('Cleared video URL for '.$business['name'].'.');
+ })(),
+
+ 'business:delete' => (function () use ($options): void {
+ requireYes($options);
+ $business = businessFromOptions($options);
+ qrun("DELETE FROM businesses WHERE id=?", [(int)$business['id']]);
+ out('Deleted business and related records: '.$business['name'].' (#'.$business['id'].')');
+ })(),
+
+ 'business:update' => (function () use ($options): void {
+ $business = businessFromOptions($options);
+ $map = [
+ 'name' => 'name',
+ 'slug' => 'slug',
+ 'category_id' => 'category_id',
+ 'subcategory' => 'subcategory',
+ 'description' => 'description',
+ 'address' => 'address',
+ 'phone' => 'phone',
+ 'email' => 'email',
+ 'website' => 'website',
+ 'video_url' => 'video_url',
+ 'hours_json' => 'hours',
+ 'lat' => 'lat',
+ 'lng' => 'lng',
+ 'active' => 'is_active',
+ 'featured' => 'is_featured',
+ ];
+ $set = [];
+ $params = [];
+ foreach ($map as $optionKey => $column) {
+ if (!array_key_exists($optionKey, $options)) continue;
+ $value = (string)$options[$optionKey];
+ if ($optionKey === 'email') validateEmail($value);
+ if ($optionKey === 'video_url' && !validBusinessVideoUrl($value)) fail('Video URL must be a YouTube or Vimeo URL.');
+ if ($optionKey === 'hours_json') validateHoursJson($value);
+ if ($optionKey === 'category_id') {
+ $value = (string)(int)$value;
+ if (!qval("SELECT id FROM categories WHERE id=?", [(int)$value])) fail('Category ID not found.');
+ }
+ if (in_array($optionKey, ['active', 'featured'], true)) $value = (string)boolish($value, "--$optionKey");
+ if (in_array($optionKey, ['lat', 'lng'], true) && !is_numeric($value)) fail("--$optionKey must be numeric.");
+ if ($optionKey === 'slug' && $value !== '') {
+ $value = makeSlug($value);
+ $clash = qval("SELECT id FROM businesses WHERE slug=? AND id!=?", [$value, (int)$business['id']]);
+ if ($clash) fail('That slug is already used by another business.');
+ }
+ $set[] = "$column=?";
+ $params[] = $value;
+ }
+ if (!$set) {
+ fail('No update fields provided. Run help to see supported field options.');
+ }
+ $params[] = (int)$business['id'];
+ qrun("UPDATE businesses SET ".implode(',', $set)." WHERE id=?", $params);
+ out('Updated business: '.$business['name'].' (#'.$business['id'].')');
+ })(),
+
+ 'menu:show' => (function () use ($options): void {
+ $business = businessFromOptions($options);
+ $sections = qall("SELECT * FROM menu_sections WHERE business_id=? ORDER BY sort_order", [(int)$business['id']]);
+ out($business['name'].' menu');
+ if (!$sections) {
+ out('No menu sections found.');
+ return;
+ }
+ foreach ($sections as $section) {
+ out('');
+ out('['.$section['id'].'] '.$section['name'].($section['description'] ? ' - '.$section['description'] : ''));
+ $items = qall("SELECT * FROM menu_items WHERE section_id=? ORDER BY sort_order", [(int)$section['id']]);
+ foreach ($items as $item) {
+ out(sprintf(
+ ' - [%d] %s%s%s',
+ $item['id'],
+ $item['name'],
+ (float)$item['price'] > 0 ? ' $'.number_format((float)$item['price'], 2) : '',
+ (int)$item['is_available'] === 1 ? '' : ' (unavailable)'
+ ));
+ }
+ }
+ })(),
+
+ 'menu:wipe-business' => (function () use ($options): void {
+ requireYes($options);
+ $business = businessFromOptions($options);
+ [$sections, $items] = wipeBusinessMenu((int)$business['id']);
+ out("Deleted $sections menu section".($sections === 1 ? '' : 's')." and $items item".($items === 1 ? '' : 's')." for ".$business['name'].'.');
+ })(),
+
+ 'menu:wipe-all' => (function () use ($options): void {
+ requireYes($options);
+ $sectionCount = (int)qval("SELECT COUNT(*) FROM menu_sections");
+ $itemCount = (int)qval("SELECT COUNT(*) FROM menu_items");
+ qrun("DELETE FROM menu_sections");
+ out("Deleted $sectionCount menu section".($sectionCount === 1 ? '' : 's')." and $itemCount item".($itemCount === 1 ? '' : 's').' across all businesses.');
+ })(),
+
+ 'categories:list' => (function (): void {
+ $rows = qall("SELECT id,name,icon,sort_order FROM categories ORDER BY sort_order,name");
+ foreach ($rows as $row) {
+ out(sprintf('#%-3d %-2s %-32s sort=%d', $row['id'], $row['icon'], $row['name'], $row['sort_order']));
+ }
+ })(),
+
+ default => fail("Unknown command: $command. Run `php scripts/keyser-admin.php help`.", 2),
+ };
+} catch (PDOException $e) {
+ fail($e->getMessage());
+}
diff --git a/verify-email.php b/verify-email.php
new file mode 100644
index 0000000..e4bf86b
--- /dev/null
+++ b/verify-email.php
@@ -0,0 +1,64 @@
+
+
+
+
= $status === 'success' ? 'ACCOUNT VERIFIED' : 'EMAIL VERIFICATION' ?>
+
=e($title)?>
+
=e($message)?>
+
=e($detail)?>
+
+
+
+