- Email verification (regular, business owner), CLI administration
This commit is contained in:
@@ -13,6 +13,19 @@ if (isPost()) {
|
||||
setSetting('registration_enabled',p('registration_enabled','0'));
|
||||
setSetting('events_require_approval',p('events_require_approval','0'));
|
||||
setSetting('home_random_interval',p('home_random_interval','2hours') === 'day' ? 'day' : '2hours');
|
||||
if (array_key_exists('mailgun_domain', $_POST)) {
|
||||
setSetting('site_base_url', rtrim(ps('site_base_url'), '/'));
|
||||
setSetting('mailgun_region', p('mailgun_region','us') === 'eu' ? 'eu' : 'us');
|
||||
setSetting('mailgun_domain', ps('mailgun_domain'));
|
||||
setSetting('mailgun_from_name', ps('mailgun_from_name') ?: 'Discover Keyser WV');
|
||||
setSetting('mailgun_from_email', ps('mailgun_from_email'));
|
||||
setSetting('mailgun_send_mode', p('mailgun_send_mode','html') === 'text' ? 'text' : 'html');
|
||||
if (p('clear_mailgun_api_key','0') === '1') {
|
||||
setSetting('mailgun_api_key', '');
|
||||
} elseif (ps('mailgun_api_key') !== '') {
|
||||
setSetting('mailgun_api_key', ps('mailgun_api_key'));
|
||||
}
|
||||
}
|
||||
flash('Settings saved!','success');
|
||||
go('/admin/settings.php');
|
||||
}
|
||||
@@ -35,6 +48,13 @@ $siteName = setting('site_name','Discover Keyser WV');
|
||||
$siteTagline = setting('site_tagline','The Friendliest City in the U.S.A.');
|
||||
$contactEmail = setting('contact_email','info@discoverkeyser.com');
|
||||
$homeRandomInterval = setting('home_random_interval','2hours');
|
||||
$siteBaseUrl = setting('site_base_url','');
|
||||
$mailgunRegion = setting('mailgun_region','us');
|
||||
$mailgunDomain = setting('mailgun_domain','');
|
||||
$mailgunFromName = setting('mailgun_from_name','Discover Keyser WV');
|
||||
$mailgunFromEmail = setting('mailgun_from_email','');
|
||||
$mailgunSendMode = setting('mailgun_send_mode','html');
|
||||
$mailgunHasKey = setting('mailgun_api_key','') !== '';
|
||||
$me = qone("SELECT * FROM users WHERE id=?",[uid()]);
|
||||
?>
|
||||
<?php adminTitle('Site Settings') ?>
|
||||
@@ -98,6 +118,76 @@ $me = qone("SELECT * FROM users WHERE id=?",[uid()]);
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="ibox" style="border-color:var(--gold-d)">
|
||||
<div class="ibox-title">✉️ EMAIL VERIFICATION & MAILGUN</div>
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="settings">
|
||||
<input type="hidden" name="site_name" value="<?=e($siteName)?>">
|
||||
<input type="hidden" name="site_tagline" value="<?=e($siteTagline)?>">
|
||||
<input type="hidden" name="contact_email" value="<?=e($contactEmail)?>">
|
||||
<input type="hidden" name="registration_enabled" value="<?=e($regEnabled)?>">
|
||||
<input type="hidden" name="events_require_approval" value="<?=e($evtApproval)?>">
|
||||
<input type="hidden" name="home_random_interval" value="<?=e($homeRandomInterval)?>">
|
||||
|
||||
<div class="fg">
|
||||
<label class="flabel">SITE BASE URL</label>
|
||||
<input type="url" name="site_base_url" class="finput" value="<?=e($siteBaseUrl)?>" placeholder="https://discoverkeyser.com">
|
||||
<div class="fhint">Used to build verification links. Leave blank to detect the current host.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="fg">
|
||||
<label class="flabel">MAILGUN REGION</label>
|
||||
<select name="mailgun_region" class="fselect">
|
||||
<option value="us"<?=$mailgunRegion==='us'?' selected':''?>>US API (api.mailgun.net)</option>
|
||||
<option value="eu"<?=$mailgunRegion==='eu'?' selected':''?>>EU API (api.eu.mailgun.net)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="flabel">MAILGUN DOMAIN</label>
|
||||
<input type="text" name="mailgun_domain" class="finput" value="<?=e($mailgunDomain)?>" placeholder="mg.example.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="fg">
|
||||
<label class="flabel">FROM NAME</label>
|
||||
<input type="text" name="mailgun_from_name" class="finput" value="<?=e($mailgunFromName)?>" placeholder="Discover Keyser WV">
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="flabel">FROM EMAIL</label>
|
||||
<input type="email" name="mailgun_from_email" class="finput" value="<?=e($mailgunFromEmail)?>" placeholder="hello@mg.example.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fg">
|
||||
<label class="flabel">MAILGUN API KEY</label>
|
||||
<input type="password" name="mailgun_api_key" class="finput" value="" placeholder="<?=$mailgunHasKey?'Key saved - leave blank to keep it':'Enter Mailgun API key'?>">
|
||||
<div class="fhint"><?=$mailgunHasKey?'An API key is saved. Enter a new key to replace it.':'No API key is saved yet.'?></div>
|
||||
<?php if($mailgunHasKey): ?>
|
||||
<label style="display:flex;align-items:center;gap:.45rem;margin-top:.55rem;font-size:.85rem;color:var(--text-m);cursor:pointer">
|
||||
<input type="checkbox" name="clear_mailgun_api_key" value="1"> Clear saved API key
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="fg">
|
||||
<label class="flabel">EMAIL SEND FORMAT</label>
|
||||
<div style="margin-top:.4rem">
|
||||
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:.9rem;margin-bottom:.35rem">
|
||||
<input type="radio" name="mailgun_send_mode" value="html"<?=$mailgunSendMode==='html'?' checked':''?>>
|
||||
<span>Designed HTML email with text fallback</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:.9rem">
|
||||
<input type="radio" name="mailgun_send_mode" value="text"<?=$mailgunSendMode==='text'?' checked':''?>>
|
||||
<span>Simple text-only email</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save Email Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin password change -->
|
||||
|
||||
@@ -294,6 +294,12 @@ address{font-style:normal}
|
||||
.business-video-box{border-color:rgba(38,244,255,.24)}
|
||||
.embed-shell{position:relative;width:100%;aspect-ratio:16/9;overflow:hidden;border-radius:var(--r);background:#000;border:1px solid rgba(38,244,255,.22);box-shadow:0 0 26px rgba(38,244,255,.08)}
|
||||
.embed-shell iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:#000}
|
||||
.verify-card{max-width:680px;margin:1rem auto;background:linear-gradient(180deg,rgba(14,23,43,.96),rgba(8,13,27,.96));border:1px solid rgba(38,244,255,.2);border-radius:var(--r-lg);padding:2.2rem;text-align:center;box-shadow:var(--sh)}
|
||||
.verify-card h1{font-size:clamp(2rem,4vw,3rem);margin:.35rem 0 .75rem}
|
||||
.verify-card p{color:var(--text-m);line-height:1.75;max-width:520px;margin:0 auto}
|
||||
.verify-card .verify-detail{margin-top:.8rem;color:var(--text-d);font-size:.94rem}
|
||||
.verify-success{border-color:rgba(77,255,155,.38)}
|
||||
.verify-error{border-color:rgba(255,95,123,.34)}
|
||||
|
||||
/* ── Search ─────────────────────────────────────────────── */
|
||||
.search-bar{background:var(--bg2);padding:1.75rem 1.5rem;border-bottom:1px solid var(--bdr)}
|
||||
|
||||
+45
-3
@@ -41,6 +41,13 @@ function _hasColumn(PDO $db, string $table, string $column): bool {
|
||||
}
|
||||
|
||||
function _ensureAppUpgrades(PDO $db): void {
|
||||
if (!_hasColumn($db, 'users', 'email_verified_at')) {
|
||||
$db->exec("ALTER TABLE users ADD COLUMN email_verified_at TEXT");
|
||||
$db->exec("UPDATE users SET email_verified_at=datetime('now') WHERE is_active=1");
|
||||
}
|
||||
if (!_hasColumn($db, 'users', 'member_type')) {
|
||||
$db->exec("ALTER TABLE users ADD COLUMN member_type TEXT NOT NULL DEFAULT 'member'");
|
||||
}
|
||||
if (!_hasColumn($db, 'businesses', 'video_url')) {
|
||||
$db->exec("ALTER TABLE businesses ADD COLUMN video_url TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
@@ -69,10 +76,27 @@ function _ensureAppUpgrades(PDO $db): void {
|
||||
reviewed_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_claim_requests_status ON business_claim_requests(status, created_at);
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
purpose TEXT NOT NULL DEFAULT 'email_verify',
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_user ON email_verification_tokens(user_id, purpose, used_at);
|
||||
");
|
||||
|
||||
$defaults = [
|
||||
'home_random_interval' => '2hours',
|
||||
'site_base_url' => '',
|
||||
'mailgun_region' => 'us',
|
||||
'mailgun_domain' => '',
|
||||
'mailgun_api_key' => '',
|
||||
'mailgun_from_name' => 'Discover Keyser WV',
|
||||
'mailgun_from_email' => '',
|
||||
'mailgun_send_mode' => 'html',
|
||||
];
|
||||
$stmt = $db->prepare("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)");
|
||||
foreach ($defaults as $k => $v) $stmt->execute([$k, $v]);
|
||||
@@ -93,6 +117,8 @@ function _schema(PDO $db): void {
|
||||
password TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
email_verified_at TEXT,
|
||||
member_type TEXT NOT NULL DEFAULT 'member',
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
last_login TEXT
|
||||
);
|
||||
@@ -148,6 +174,15 @@ function _schema(PDO $db): void {
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
reviewed_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
purpose TEXT NOT NULL DEFAULT 'email_verify',
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reviews(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
@@ -239,13 +274,20 @@ function _seed(PDO $db): void {
|
||||
'site_tagline'=>'The Friendliest City in the U.S.A.',
|
||||
'contact_email'=>'info@discoverkeyser.com',
|
||||
'events_require_approval'=>'1',
|
||||
'home_random_interval'=>'2hours'] as $k=>$v)
|
||||
'home_random_interval'=>'2hours',
|
||||
'site_base_url'=>'',
|
||||
'mailgun_region'=>'us',
|
||||
'mailgun_domain'=>'',
|
||||
'mailgun_api_key'=>'',
|
||||
'mailgun_from_name'=>'Discover Keyser WV',
|
||||
'mailgun_from_email'=>'',
|
||||
'mailgun_send_mode'=>'html'] 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]);
|
||||
qrun("INSERT OR IGNORE INTO users(username,email,password,is_admin,email_verified_at,member_type)
|
||||
VALUES('administrator','admin@discoverkeyser.com',?,1,datetime('now'),'member')",[$hash]);
|
||||
|
||||
/* Categories */
|
||||
foreach ([
|
||||
|
||||
@@ -149,3 +149,170 @@ function validBusinessVideoUrl(string $url): bool {
|
||||
$scheme = strtolower((string)(parse_url($url, PHP_URL_SCHEME) ?? ''));
|
||||
return in_array($scheme, ['http','https'], true) && businessVideoProvider($url) !== '';
|
||||
}
|
||||
|
||||
/* ── Email verification / Mailgun ─────────────────────── */
|
||||
function requestBaseUrl(): string {
|
||||
$configured = trim(setting('site_base_url', ''));
|
||||
if ($configured !== '') return rtrim($configured, '/');
|
||||
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
|
||||
if ($host === '') return '';
|
||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['SERVER_PORT'] ?? '') === '443');
|
||||
return ($https ? 'https' : 'http').'://'.$host;
|
||||
}
|
||||
function absoluteUrl(string $path): string {
|
||||
$base = requestBaseUrl();
|
||||
if ($base === '') return $path;
|
||||
return rtrim($base, '/').'/'.ltrim($path, '/');
|
||||
}
|
||||
function mailgunEndpoint(): string {
|
||||
$region = strtolower(setting('mailgun_region', 'us'));
|
||||
$base = $region === 'eu' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net';
|
||||
return $base.'/v3/'.rawurlencode(setting('mailgun_domain', '')).'/messages';
|
||||
}
|
||||
function mailgunConfigured(): bool {
|
||||
return setting('mailgun_domain', '') !== '' && setting('mailgun_api_key', '') !== '';
|
||||
}
|
||||
function emailFromAddress(): string {
|
||||
$from = trim(setting('mailgun_from_email', ''));
|
||||
if ($from !== '') return $from;
|
||||
$domain = setting('mailgun_domain', '');
|
||||
return $domain !== '' ? 'postmaster@'.$domain : '';
|
||||
}
|
||||
function formattedFromAddress(): string {
|
||||
$name = trim(setting('mailgun_from_name', 'My Keyser'));
|
||||
$email = emailFromAddress();
|
||||
return $name !== '' ? "$name <$email>" : $email;
|
||||
}
|
||||
function createEmailVerificationToken(int $userId): string {
|
||||
qrun("UPDATE email_verification_tokens SET used_at=datetime('now') WHERE user_id=? AND purpose='email_verify' AND used_at IS NULL", [$userId]);
|
||||
$token = bin2hex(random_bytes(32));
|
||||
qrun(
|
||||
"INSERT INTO email_verification_tokens(user_id,token_hash,expires_at)VALUES(?,?,datetime('now','+24 hours'))",
|
||||
[$userId, hash('sha256', $token)]
|
||||
);
|
||||
return $token;
|
||||
}
|
||||
function mailgunSend(string $to, string $subject, string $text, string $html = ''): array {
|
||||
if (!mailgunConfigured()) return [false, 'Mailgun domain and API key are required.'];
|
||||
$from = formattedFromAddress();
|
||||
if ($from === '' || !filter_var(emailFromAddress(), FILTER_VALIDATE_EMAIL)) {
|
||||
return [false, 'A valid Mailgun from email is required.'];
|
||||
}
|
||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) return [false, 'Recipient email is invalid.'];
|
||||
|
||||
$fields = [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'subject' => $subject,
|
||||
'text' => $text,
|
||||
];
|
||||
if (setting('mailgun_send_mode', 'html') === 'html' && $html !== '') {
|
||||
$fields['html'] = $html;
|
||||
}
|
||||
|
||||
$endpoint = mailgunEndpoint();
|
||||
$apiKey = setting('mailgun_api_key', '');
|
||||
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($endpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $fields,
|
||||
CURLOPT_USERPWD => 'api:'.$apiKey,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
if ($errno) return [false, $error ?: 'Mailgun request failed.'];
|
||||
if ($status < 200 || $status >= 300) return [false, 'Mailgun returned HTTP '.$status.': '.substr((string)$body, 0, 180)];
|
||||
return [true, ''];
|
||||
}
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Authorization: Basic ".base64_encode('api:'.$apiKey)."\r\n".
|
||||
"Content-Type: application/x-www-form-urlencoded\r\n",
|
||||
'content' => http_build_query($fields),
|
||||
'timeout' => 20,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
$body = @file_get_contents($endpoint, false, $context);
|
||||
$statusLine = $http_response_header[0] ?? '';
|
||||
if (!preg_match('/\s(2\d\d)\s/', $statusLine)) {
|
||||
return [false, trim(($statusLine ?: 'Mailgun request failed.').' '.substr((string)$body, 0, 180))];
|
||||
}
|
||||
return [true, ''];
|
||||
}
|
||||
function emailShellHtml(string $title, string $intro, string $bodyHtml, string $buttonText = '', string $buttonUrl = ''): string {
|
||||
$site = e(setting('site_name', 'My Keyser'));
|
||||
$titleEsc = e($title);
|
||||
$introEsc = e($intro);
|
||||
$button = '';
|
||||
if ($buttonText !== '' && $buttonUrl !== '') {
|
||||
$button = '<tr><td style="padding:22px 0 6px"><a href="'.e($buttonUrl).'" style="display:inline-block;background:linear-gradient(135deg,#ffd166,#26f4ff);color:#03050b;text-decoration:none;font-family:Arial,sans-serif;font-size:14px;font-weight:800;letter-spacing:1px;text-transform:uppercase;padding:13px 20px;border-radius:6px">'.e($buttonText).'</a></td></tr>';
|
||||
}
|
||||
return '<!doctype html><html><body style="margin:0;background:#03050b;color:#f2f4ff;font-family:Arial,sans-serif">'.
|
||||
'<div style="display:none;max-height:0;overflow:hidden;color:transparent">'.$introEsc.'</div>'.
|
||||
'<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:radial-gradient(circle at 15% 0%,rgba(38,244,255,.18),transparent 340px),radial-gradient(circle at 85% 10%,rgba(255,79,216,.14),transparent 360px),#03050b;padding:34px 14px">'.
|
||||
'<tr><td align="center"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;background:#0e172b;border:1px solid rgba(38,244,255,.28);border-radius:8px;overflow:hidden;box-shadow:0 18px 44px rgba(0,0,0,.55)">'.
|
||||
'<tr><td style="padding:24px 26px;border-bottom:1px solid rgba(38,244,255,.18);background:linear-gradient(135deg,rgba(38,244,255,.12),rgba(255,79,216,.08),rgba(255,209,102,.08))">'.
|
||||
'<div style="font-size:12px;letter-spacing:4px;color:#26f4ff;text-transform:uppercase;font-weight:700">'.$site.'</div>'.
|
||||
'<h1 style="margin:10px 0 0;font-family:Georgia,serif;font-size:32px;line-height:1.1;color:#ffffff">'.$titleEsc.'</h1>'.
|
||||
'</td></tr><tr><td style="padding:26px;color:#aab7cf;font-size:16px;line-height:1.75">'.
|
||||
$bodyHtml.
|
||||
'<table role="presentation" cellspacing="0" cellpadding="0">'.$button.'</table>'.
|
||||
'<p style="margin:24px 0 0;color:#65768f;font-size:13px">Keyser, West Virginia - local businesses, events, and community information.</p>'.
|
||||
'</td></tr></table></td></tr></table></body></html>';
|
||||
}
|
||||
function verificationEmailContent(array $user, string $verifyUrl): array {
|
||||
$name = (string)($user['username'] ?? 'there');
|
||||
$isOwner = ($user['member_type'] ?? 'member') === 'business_owner';
|
||||
$title = $isOwner ? 'Verify Your Business Owner Account' : 'Verify Your Account';
|
||||
$subject = $isOwner ? 'Verify your My Keyser business owner account' : 'Verify your My Keyser account';
|
||||
$text = "Hi $name,\n\n".
|
||||
"Thanks for registering with My Keyser. Please verify your email address before signing in:\n\n".
|
||||
"$verifyUrl\n\n".
|
||||
"This link expires in 24 hours.\n\n".
|
||||
($isOwner ? "After verification, your business claim will remain queued and we will contact you within a couple of business days.\n\n" : '').
|
||||
"My Keyser";
|
||||
$htmlBody = '<p style="margin:0 0 16px;color:#f2f4ff">Hi '.e($name).',</p>'.
|
||||
'<p style="margin:0 0 16px">Thanks for registering with My Keyser. Please verify your email address before signing in.</p>'.
|
||||
($isOwner ? '<p style="margin:0 0 16px">After verification, your business claim will stay queued for review and our team will contact you within a couple of business days.</p>' : '').
|
||||
'<p style="margin:0;color:#65768f;font-size:13px">This link expires in 24 hours.</p>';
|
||||
return [$subject, $text, emailShellHtml($title, 'Verify your My Keyser account.', $htmlBody, 'Verify Email', $verifyUrl)];
|
||||
}
|
||||
function registrationCompleteEmailContent(array $user): array {
|
||||
$name = (string)($user['username'] ?? 'there');
|
||||
$isOwner = ($user['member_type'] ?? 'member') === 'business_owner';
|
||||
if ($isOwner) {
|
||||
$subject = 'Thank you for claiming your My Keyser business page';
|
||||
$text = "Hi $name,\n\nThank you for registering as a business owner with My Keyser.\n\nYour email is verified and you can now log in. Our team will contact you within a couple of business days for a verification call before assigning your business page.\n\nThank you for helping keep Keyser's directory accurate.\n\nMy Keyser";
|
||||
$body = '<p style="margin:0 0 16px;color:#f2f4ff">Hi '.e($name).',</p>'.
|
||||
'<p style="margin:0 0 16px">Thank you for registering as a business owner with My Keyser. Your email is verified and you can now log in.</p>'.
|
||||
'<p style="margin:0 0 16px">Our team will contact you within a couple of business days for a verification call before assigning your business page.</p>'.
|
||||
'<p style="margin:0">Thank you for helping keep Keyser’s directory accurate.</p>';
|
||||
return [$subject, $text, emailShellHtml('Thanks, Business Owner', 'Your business owner account is verified.', $body, 'Log In', absoluteUrl('/login.php'))];
|
||||
}
|
||||
$subject = 'Thank you for registering with My Keyser';
|
||||
$text = "Hi $name,\n\nThank you for registering with My Keyser. Your email is verified and you can now log in.\n\nWe are glad to have you in the Keyser community.\n\nMy Keyser";
|
||||
$body = '<p style="margin:0 0 16px;color:#f2f4ff">Hi '.e($name).',</p>'.
|
||||
'<p style="margin:0 0 16px">Thank you for registering with My Keyser. Your email is verified and you can now log in.</p>'.
|
||||
'<p style="margin:0">We are glad to have you in the Keyser community.</p>';
|
||||
return [$subject, $text, emailShellHtml('Thanks For Registering', 'Your My Keyser account is verified.', $body, 'Log In', absoluteUrl('/login.php'))];
|
||||
}
|
||||
function sendVerificationEmail(array $user, string $token): array {
|
||||
$url = absoluteUrl('/verify-email.php?token='.rawurlencode($token));
|
||||
[$subject, $text, $html] = verificationEmailContent($user, $url);
|
||||
return mailgunSend((string)$user['email'], $subject, $text, $html);
|
||||
}
|
||||
function sendRegistrationCompleteEmail(array $user): array {
|
||||
[$subject, $text, $html] = registrationCompleteEmailContent($user);
|
||||
return mailgunSend((string)$user['email'], $subject, $text, $html);
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ include __DIR__.'/includes/header.php';
|
||||
<div class="home-hero-grid">
|
||||
<div class="home-hero-copy">
|
||||
<div class="hero-eyebrow">MINERAL COUNTY · WEST VIRGINIA</div>
|
||||
<h1 class="hero-h1">KEYSER<span class="gld">AFTER DARK</span></h1>
|
||||
<p class="hero-desc">A neon-lit guide to local businesses, upcoming events, mountain history, and the community pages that keep Keyser moving.</p>
|
||||
<h1 class="hero-h1">DISCOVER<span class="gld">KEYSER</span></h1>
|
||||
<p class="hero-desc">This place is new. Sign up to claim your business!</p>
|
||||
<form action="/directory.php" method="GET" class="hero-search">
|
||||
<span class="s-icon">⌕</span>
|
||||
<input class="search-input" type="text" name="q" placeholder="Search restaurants, shops, services...">
|
||||
|
||||
@@ -7,11 +7,17 @@ $err = '';
|
||||
if (isPost()) {
|
||||
csrfCheck();
|
||||
$un = ps('username'); $pw = ps('password');
|
||||
$u = qone("SELECT * FROM users WHERE (username=? OR email=?) AND is_active=1", [$un, $un]);
|
||||
$u = qone("SELECT * FROM users WHERE (username=? OR email=?)", [$un, $un]);
|
||||
if ($u && password_verify($pw, $u['password'])) {
|
||||
loginUser($u);
|
||||
flash('Welcome back, '.e($u['username']).'!', 'success');
|
||||
go($next);
|
||||
if (empty($u['email_verified_at'])) {
|
||||
$err = 'Please verify your email address before signing in.';
|
||||
} elseif ((int)$u['is_active'] !== 1) {
|
||||
$err = 'Your account is not active. Please contact the site administrator.';
|
||||
} else {
|
||||
loginUser($u);
|
||||
flash('Welcome back, '.e($u['username']).'!', 'success');
|
||||
go($next);
|
||||
}
|
||||
} else {
|
||||
$err = 'Invalid username or password.';
|
||||
}
|
||||
|
||||
+28
-19
@@ -6,7 +6,7 @@ if (setting('registration_enabled','1') !== '1') {
|
||||
go('/login.php');
|
||||
}
|
||||
|
||||
$pageTitle = 'Create Account — Discover Keyser WV';
|
||||
$pageTitle = 'Create Account — My Keyser';
|
||||
$errors = [];
|
||||
$businessOptions = qall("SELECT id,name,address FROM businesses WHERE is_active=1 ORDER BY name");
|
||||
$tier = p('member_type', 'member') === 'business_owner' ? 'business_owner' : 'member';
|
||||
@@ -15,13 +15,13 @@ if (isPost()) {
|
||||
csrfCheck();
|
||||
|
||||
if (ps('website_url') !== '' || ps('confirm_email') !== '') {
|
||||
flash('Account created! Welcome to Discover Keyser WV.','success');
|
||||
flash('Account created! Welcome to My Keyser.','success');
|
||||
go('/index.php');
|
||||
}
|
||||
|
||||
$formTime = (int)p('_form_time', 0);
|
||||
if ($formTime && (time() - $formTime) < 3) {
|
||||
flash('Account created! Welcome to Discover Keyser WV.','success');
|
||||
flash('Account created! Welcome to My Keyser.','success');
|
||||
go('/index.php');
|
||||
}
|
||||
|
||||
@@ -38,12 +38,13 @@ if (isPost()) {
|
||||
|
||||
if (strlen($un) < 3) $errors[] = 'Username must be at least 3 characters.';
|
||||
if (!preg_match('/^[a-zA-Z0-9_]+$/', $un)) $errors[] = 'Username may only contain letters, numbers, and underscores.';
|
||||
if ($em && !filter_var($em, FILTER_VALIDATE_EMAIL)) $errors[] = 'Please enter a valid email address.';
|
||||
if ($em === '' || !filter_var($em, FILTER_VALIDATE_EMAIL)) $errors[] = 'Please enter a valid email address.';
|
||||
if (strlen($pw) < 6) $errors[] = 'Password must be at least 6 characters.';
|
||||
if ($pw !== $pw2) $errors[] = 'Passwords do not match.';
|
||||
if ($tos !== '1') $errors[] = 'You must agree to use this service lawfully to create an account.';
|
||||
if (!$errors && qval("SELECT id FROM users WHERE username=?", [$un])) $errors[] = 'That username is already taken. Please choose another.';
|
||||
if (!$errors && $em && qval("SELECT id FROM users WHERE email=?", [$em])) $errors[] = 'That email address is already registered. Try signing in instead.';
|
||||
if (!$errors && qval("SELECT id FROM users WHERE email=?", [$em])) $errors[] = 'That email address is already registered. Try signing in instead.';
|
||||
if (!$errors && !mailgunConfigured()) $errors[] = 'Email verification is not configured yet. Please contact the site administrator.';
|
||||
|
||||
$claimEdits = [
|
||||
'description' => ps('claim_description'),
|
||||
@@ -74,8 +75,8 @@ if (isPost()) {
|
||||
|
||||
if (!$errors) {
|
||||
$id = qrun(
|
||||
"INSERT INTO users(username,email,password)VALUES(?,?,?)",
|
||||
[$un, $em ?: null, password_hash($pw, PASSWORD_BCRYPT)]
|
||||
"INSERT INTO users(username,email,password,is_active,member_type)VALUES(?,?,?,?,?)",
|
||||
[$un, $em, password_hash($pw, PASSWORD_BCRYPT), 0, $tier]
|
||||
);
|
||||
|
||||
$submittedEdits = 0;
|
||||
@@ -97,17 +98,25 @@ if (isPost()) {
|
||||
}
|
||||
}
|
||||
|
||||
loginUser(qone("SELECT * FROM users WHERE id=?", [$id]));
|
||||
|
||||
if ($tier === 'business_owner' && $claimBiz) {
|
||||
$msg = 'Your account is ready. Your claim for '.$claimBiz['name'].' is queued, and we will contact you at '.$contactPhone.' within a few business days for verification.';
|
||||
if ($submittedEdits) $msg .= ' '.$submittedEdits.' proposed edit'.($submittedEdits !== 1 ? 's were' : ' was').' sent to admin review.';
|
||||
flash($msg, 'success');
|
||||
go('/dashboard.php');
|
||||
$user = qone("SELECT * FROM users WHERE id=?", [$id]);
|
||||
$token = createEmailVerificationToken($id);
|
||||
[$sent, $mailError] = sendVerificationEmail($user, $token);
|
||||
if (!$sent) {
|
||||
qrun("DELETE FROM users WHERE id=?", [$id]);
|
||||
$errors[] = 'Account was not created because the verification email could not be sent: '.$mailError;
|
||||
}
|
||||
|
||||
flash('Welcome to Discover Keyser WV, '.$un.'!', 'success');
|
||||
go('/index.php');
|
||||
if (!$errors) {
|
||||
if ($tier === 'business_owner' && $claimBiz) {
|
||||
$msg = 'Check your email to verify your account. After verification you can log in, and we will contact you at '.$contactPhone.' within a few business days for business verification.';
|
||||
if ($submittedEdits) $msg .= ' '.$submittedEdits.' proposed edit'.($submittedEdits !== 1 ? 's were' : ' was').' sent to admin review.';
|
||||
flash($msg, 'success');
|
||||
go('/login.php');
|
||||
}
|
||||
|
||||
flash('Check your email to verify your account before signing in.', 'success');
|
||||
go('/login.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,11 +190,11 @@ include __DIR__.'/includes/header.php';
|
||||
<div class="fhint">Letters, numbers, underscores only. Min 3 characters.</div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="flabel" for="email">EMAIL ADDRESS <span style="font-weight:300;letter-spacing:0">(optional)</span></label>
|
||||
<label class="flabel" for="email">EMAIL ADDRESS *</label>
|
||||
<input type="email" id="email" name="email" class="finput"
|
||||
value="<?=e($_POST['email']??'')?>"
|
||||
autocomplete="email">
|
||||
<div class="fhint">Used for account recovery and owner verification follow-up.</div>
|
||||
autocomplete="email" required>
|
||||
<div class="fhint">Used for email verification and account recovery.</div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="flabel" for="password">PASSWORD *</label>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This script must be run from the command line.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__).'/includes/boot.php';
|
||||
|
||||
function out(string $message = ''): void {
|
||||
fwrite(STDOUT, $message.PHP_EOL);
|
||||
}
|
||||
|
||||
function fail(string $message, int $code = 1): never {
|
||||
fwrite(STDERR, "Error: $message".PHP_EOL);
|
||||
exit($code);
|
||||
}
|
||||
|
||||
function usage(): never {
|
||||
out('Keyser Admin CLI');
|
||||
out('');
|
||||
out('Usage:');
|
||||
out(' php scripts/keyser-admin.php <command> [options]');
|
||||
out('');
|
||||
out('Business commands:');
|
||||
out(' business:list [--active=1|0|all] [--q=text] [--limit=50]');
|
||||
out(' business:show --id=ID|--slug=SLUG');
|
||||
out(' business:activate --id=ID|--slug=SLUG');
|
||||
out(' business:deactivate --id=ID|--slug=SLUG');
|
||||
out(' business:update --id=ID|--slug=SLUG [field options]');
|
||||
out(' business:set-video --id=ID|--slug=SLUG --video-url=URL');
|
||||
out(' business:clear-video --id=ID|--slug=SLUG');
|
||||
out(' business:delete --id=ID|--slug=SLUG --yes');
|
||||
out('');
|
||||
out('Business update field options:');
|
||||
out(' --name=TEXT --slug=TEXT --category-id=ID --subcategory=TEXT');
|
||||
out(' --description=TEXT --address=TEXT --phone=TEXT --email=TEXT');
|
||||
out(' --website=TEXT --video-url=URL --hours-json=JSON');
|
||||
out(' --lat=NUM --lng=NUM --active=1|0 --featured=1|0');
|
||||
out('');
|
||||
out('Menu commands:');
|
||||
out(' menu:wipe-all --yes');
|
||||
out(' menu:wipe-business --id=ID|--slug=SLUG --yes');
|
||||
out(' menu:show --id=ID|--slug=SLUG');
|
||||
out('');
|
||||
out('Other commands:');
|
||||
out(' categories:list');
|
||||
out('');
|
||||
out('Examples:');
|
||||
out(' php scripts/keyser-admin.php business:deactivate --slug=royal-restaurant');
|
||||
out(' php scripts/keyser-admin.php business:update --slug=queens-point-coffee --phone="304-555-0100" --featured=1');
|
||||
out(' php scripts/keyser-admin.php business:set-video --slug=queens-point-coffee --video-url="https://youtu.be/example"');
|
||||
out(' php scripts/keyser-admin.php business:clear-video --slug=queens-point-coffee');
|
||||
out(' php scripts/keyser-admin.php menu:wipe-business --slug=castiglia-italian --yes');
|
||||
exit(0);
|
||||
}
|
||||
|
||||
function parseArgs(array $argv): array {
|
||||
$command = $argv[1] ?? 'help';
|
||||
$options = [];
|
||||
|
||||
for ($i = 2; $i < count($argv); $i++) {
|
||||
$arg = $argv[$i];
|
||||
if (!str_starts_with($arg, '--')) {
|
||||
fail("Unexpected argument: $arg");
|
||||
}
|
||||
|
||||
$arg = substr($arg, 2);
|
||||
if ($arg === '') {
|
||||
fail('Empty option name.');
|
||||
}
|
||||
|
||||
if (str_contains($arg, '=')) {
|
||||
[$key, $value] = explode('=', $arg, 2);
|
||||
} else {
|
||||
$key = $arg;
|
||||
$value = '1';
|
||||
}
|
||||
|
||||
$options[str_replace('-', '_', $key)] = $value;
|
||||
}
|
||||
|
||||
return [$command, $options];
|
||||
}
|
||||
|
||||
function opt(array $options, string $key, mixed $default = null): mixed {
|
||||
return $options[$key] ?? $default;
|
||||
}
|
||||
|
||||
function requireYes(array $options): void {
|
||||
if (opt($options, 'yes') !== '1') {
|
||||
fail('This is destructive. Re-run with --yes if you are sure.');
|
||||
}
|
||||
}
|
||||
|
||||
function boolish(mixed $value, string $label): int {
|
||||
$value = strtolower(trim((string)$value));
|
||||
return match ($value) {
|
||||
'1', 'yes', 'true', 'on', 'active', 'enabled' => 1,
|
||||
'0', 'no', 'false', 'off', 'inactive', 'disabled' => 0,
|
||||
default => fail("$label must be 1 or 0."),
|
||||
};
|
||||
}
|
||||
|
||||
function clipText(string $value, int $width): string {
|
||||
if (function_exists('mb_strimwidth')) {
|
||||
return mb_strimwidth($value, 0, $width, '');
|
||||
}
|
||||
return strlen($value) > $width ? substr($value, 0, $width) : $value;
|
||||
}
|
||||
|
||||
function businessFromOptions(array $options): array {
|
||||
$id = (int)opt($options, 'id', 0);
|
||||
$slug = trim((string)opt($options, 'slug', ''));
|
||||
|
||||
if ($id > 0 && $slug !== '') {
|
||||
fail('Use either --id or --slug, not both.');
|
||||
}
|
||||
if ($id <= 0 && $slug === '') {
|
||||
fail('Provide --id=ID or --slug=SLUG.');
|
||||
}
|
||||
|
||||
$business = $id > 0
|
||||
? qone("SELECT * FROM businesses WHERE id=?", [$id])
|
||||
: qone("SELECT * FROM businesses WHERE slug=?", [$slug]);
|
||||
|
||||
if (!$business) {
|
||||
fail('Business not found.');
|
||||
}
|
||||
|
||||
return $business;
|
||||
}
|
||||
|
||||
function printBusiness(array $business): void {
|
||||
$category = qone("SELECT name, icon FROM categories WHERE id=?", [(int)$business['category_id']]);
|
||||
out('ID: '.$business['id']);
|
||||
out('Name: '.$business['name']);
|
||||
out('Slug: '.$business['slug']);
|
||||
out('Category: '.(($category['icon'] ?? '').' '.($category['name'] ?? 'Uncategorized')));
|
||||
out('Subcategory: '.($business['subcategory'] ?: '-'));
|
||||
out('Address: '.($business['address'] ?: '-'));
|
||||
out('Phone: '.($business['phone'] ?: '-'));
|
||||
out('Email: '.($business['email'] ?: '-'));
|
||||
out('Website: '.($business['website'] ?: '-'));
|
||||
out('Video URL: '.(($business['video_url'] ?? '') ?: '-'));
|
||||
out('Active: '.((int)$business['is_active'] === 1 ? 'yes' : 'no'));
|
||||
out('Featured: '.((int)$business['is_featured'] === 1 ? 'yes' : 'no'));
|
||||
out('Lat/Lng: '.$business['lat'].', '.$business['lng']);
|
||||
out('Hours JSON: '.$business['hours']);
|
||||
out('Description: '.$business['description']);
|
||||
}
|
||||
|
||||
function validateEmail(string $email): void {
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
fail('Invalid email address.');
|
||||
}
|
||||
}
|
||||
|
||||
function validateHoursJson(string $json): void {
|
||||
$decoded = json_decode($json, true);
|
||||
if (!is_array($decoded)) {
|
||||
fail('--hours-json must be a JSON object.');
|
||||
}
|
||||
foreach (array_keys($decoded) as $key) {
|
||||
if (!is_string($key)) {
|
||||
fail('--hours-json must be a JSON object with day names as keys.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setBusinessStatus(array $options, int $active): void {
|
||||
$business = businessFromOptions($options);
|
||||
qrun("UPDATE businesses SET is_active=? WHERE id=?", [$active, (int)$business['id']]);
|
||||
out(($active ? 'Activated' : 'Deactivated').' business: '.$business['name'].' (#'.$business['id'].')');
|
||||
}
|
||||
|
||||
function wipeBusinessMenu(int $businessId): array {
|
||||
$sectionIds = array_column(qall("SELECT id FROM menu_sections WHERE business_id=?", [$businessId]), 'id');
|
||||
$itemCount = 0;
|
||||
if ($sectionIds) {
|
||||
$placeholders = implode(',', array_fill(0, count($sectionIds), '?'));
|
||||
$itemCount = (int)qval("SELECT COUNT(*) FROM menu_items WHERE section_id IN ($placeholders)", $sectionIds);
|
||||
}
|
||||
$sectionCount = count($sectionIds);
|
||||
qrun("DELETE FROM menu_sections WHERE business_id=?", [$businessId]);
|
||||
return [$sectionCount, $itemCount];
|
||||
}
|
||||
|
||||
[$command, $options] = parseArgs($argv);
|
||||
|
||||
if (in_array($command, ['help', '--help', '-h'], true)) {
|
||||
usage();
|
||||
}
|
||||
|
||||
try {
|
||||
match ($command) {
|
||||
'business:list' => (function () use ($options): void {
|
||||
$where = [];
|
||||
$params = [];
|
||||
$active = strtolower((string)opt($options, 'active', 'all'));
|
||||
if ($active !== 'all') {
|
||||
$where[] = 'b.is_active=?';
|
||||
$params[] = boolish($active, '--active');
|
||||
}
|
||||
$q = trim((string)opt($options, 'q', ''));
|
||||
if ($q !== '') {
|
||||
$where[] = '(b.name LIKE ? OR b.slug LIKE ? OR b.address LIKE ? OR b.subcategory LIKE ?)';
|
||||
$like = "%$q%";
|
||||
array_push($params, $like, $like, $like, $like);
|
||||
}
|
||||
$limit = max(1, min(500, (int)opt($options, 'limit', 50)));
|
||||
$sqlWhere = $where ? 'WHERE '.implode(' AND ', $where) : '';
|
||||
$rows = qall("
|
||||
SELECT b.id,b.name,b.slug,b.is_active,b.is_featured,COALESCE(c.name,'Uncategorized') cat
|
||||
FROM businesses b
|
||||
LEFT JOIN categories c ON c.id=b.category_id
|
||||
$sqlWhere
|
||||
ORDER BY b.name
|
||||
LIMIT $limit
|
||||
", $params);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
out(sprintf(
|
||||
'#%-4d %-38s %-26s active=%d featured=%d slug=%s',
|
||||
$row['id'],
|
||||
clipText((string)$row['name'], 38),
|
||||
clipText((string)$row['cat'], 26),
|
||||
$row['is_active'],
|
||||
$row['is_featured'],
|
||||
$row['slug']
|
||||
));
|
||||
}
|
||||
out(count($rows).' business'.(count($rows) === 1 ? '' : 'es').' shown.');
|
||||
})(),
|
||||
|
||||
'business:show' => printBusiness(businessFromOptions($options)),
|
||||
'business:activate' => setBusinessStatus($options, 1),
|
||||
'business:deactivate' => setBusinessStatus($options, 0),
|
||||
|
||||
'business:set-video' => (function () use ($options): void {
|
||||
$business = businessFromOptions($options);
|
||||
$url = trim((string)opt($options, 'video_url', ''));
|
||||
if ($url === '') {
|
||||
fail('Provide --video-url=URL. Use --video-url="" with business:update to clear it.');
|
||||
}
|
||||
if (!validBusinessVideoUrl($url)) {
|
||||
fail('Video URL must be a YouTube or Vimeo URL.');
|
||||
}
|
||||
qrun("UPDATE businesses SET video_url=? WHERE id=?", [$url, (int)$business['id']]);
|
||||
out('Updated video URL for '.$business['name'].'.');
|
||||
})(),
|
||||
|
||||
'business:clear-video' => (function () use ($options): void {
|
||||
$business = businessFromOptions($options);
|
||||
qrun("UPDATE businesses SET video_url='' WHERE id=?", [(int)$business['id']]);
|
||||
out('Cleared video URL for '.$business['name'].'.');
|
||||
})(),
|
||||
|
||||
'business:delete' => (function () use ($options): void {
|
||||
requireYes($options);
|
||||
$business = businessFromOptions($options);
|
||||
qrun("DELETE FROM businesses WHERE id=?", [(int)$business['id']]);
|
||||
out('Deleted business and related records: '.$business['name'].' (#'.$business['id'].')');
|
||||
})(),
|
||||
|
||||
'business:update' => (function () use ($options): void {
|
||||
$business = businessFromOptions($options);
|
||||
$map = [
|
||||
'name' => 'name',
|
||||
'slug' => 'slug',
|
||||
'category_id' => 'category_id',
|
||||
'subcategory' => 'subcategory',
|
||||
'description' => 'description',
|
||||
'address' => 'address',
|
||||
'phone' => 'phone',
|
||||
'email' => 'email',
|
||||
'website' => 'website',
|
||||
'video_url' => 'video_url',
|
||||
'hours_json' => 'hours',
|
||||
'lat' => 'lat',
|
||||
'lng' => 'lng',
|
||||
'active' => 'is_active',
|
||||
'featured' => 'is_featured',
|
||||
];
|
||||
$set = [];
|
||||
$params = [];
|
||||
foreach ($map as $optionKey => $column) {
|
||||
if (!array_key_exists($optionKey, $options)) continue;
|
||||
$value = (string)$options[$optionKey];
|
||||
if ($optionKey === 'email') validateEmail($value);
|
||||
if ($optionKey === 'video_url' && !validBusinessVideoUrl($value)) fail('Video URL must be a YouTube or Vimeo URL.');
|
||||
if ($optionKey === 'hours_json') validateHoursJson($value);
|
||||
if ($optionKey === 'category_id') {
|
||||
$value = (string)(int)$value;
|
||||
if (!qval("SELECT id FROM categories WHERE id=?", [(int)$value])) fail('Category ID not found.');
|
||||
}
|
||||
if (in_array($optionKey, ['active', 'featured'], true)) $value = (string)boolish($value, "--$optionKey");
|
||||
if (in_array($optionKey, ['lat', 'lng'], true) && !is_numeric($value)) fail("--$optionKey must be numeric.");
|
||||
if ($optionKey === 'slug' && $value !== '') {
|
||||
$value = makeSlug($value);
|
||||
$clash = qval("SELECT id FROM businesses WHERE slug=? AND id!=?", [$value, (int)$business['id']]);
|
||||
if ($clash) fail('That slug is already used by another business.');
|
||||
}
|
||||
$set[] = "$column=?";
|
||||
$params[] = $value;
|
||||
}
|
||||
if (!$set) {
|
||||
fail('No update fields provided. Run help to see supported field options.');
|
||||
}
|
||||
$params[] = (int)$business['id'];
|
||||
qrun("UPDATE businesses SET ".implode(',', $set)." WHERE id=?", $params);
|
||||
out('Updated business: '.$business['name'].' (#'.$business['id'].')');
|
||||
})(),
|
||||
|
||||
'menu:show' => (function () use ($options): void {
|
||||
$business = businessFromOptions($options);
|
||||
$sections = qall("SELECT * FROM menu_sections WHERE business_id=? ORDER BY sort_order", [(int)$business['id']]);
|
||||
out($business['name'].' menu');
|
||||
if (!$sections) {
|
||||
out('No menu sections found.');
|
||||
return;
|
||||
}
|
||||
foreach ($sections as $section) {
|
||||
out('');
|
||||
out('['.$section['id'].'] '.$section['name'].($section['description'] ? ' - '.$section['description'] : ''));
|
||||
$items = qall("SELECT * FROM menu_items WHERE section_id=? ORDER BY sort_order", [(int)$section['id']]);
|
||||
foreach ($items as $item) {
|
||||
out(sprintf(
|
||||
' - [%d] %s%s%s',
|
||||
$item['id'],
|
||||
$item['name'],
|
||||
(float)$item['price'] > 0 ? ' $'.number_format((float)$item['price'], 2) : '',
|
||||
(int)$item['is_available'] === 1 ? '' : ' (unavailable)'
|
||||
));
|
||||
}
|
||||
}
|
||||
})(),
|
||||
|
||||
'menu:wipe-business' => (function () use ($options): void {
|
||||
requireYes($options);
|
||||
$business = businessFromOptions($options);
|
||||
[$sections, $items] = wipeBusinessMenu((int)$business['id']);
|
||||
out("Deleted $sections menu section".($sections === 1 ? '' : 's')." and $items item".($items === 1 ? '' : 's')." for ".$business['name'].'.');
|
||||
})(),
|
||||
|
||||
'menu:wipe-all' => (function () use ($options): void {
|
||||
requireYes($options);
|
||||
$sectionCount = (int)qval("SELECT COUNT(*) FROM menu_sections");
|
||||
$itemCount = (int)qval("SELECT COUNT(*) FROM menu_items");
|
||||
qrun("DELETE FROM menu_sections");
|
||||
out("Deleted $sectionCount menu section".($sectionCount === 1 ? '' : 's')." and $itemCount item".($itemCount === 1 ? '' : 's').' across all businesses.');
|
||||
})(),
|
||||
|
||||
'categories:list' => (function (): void {
|
||||
$rows = qall("SELECT id,name,icon,sort_order FROM categories ORDER BY sort_order,name");
|
||||
foreach ($rows as $row) {
|
||||
out(sprintf('#%-3d %-2s %-32s sort=%d', $row['id'], $row['icon'], $row['name'], $row['sort_order']));
|
||||
}
|
||||
})(),
|
||||
|
||||
default => fail("Unknown command: $command. Run `php scripts/keyser-admin.php help`.", 2),
|
||||
};
|
||||
} catch (PDOException $e) {
|
||||
fail($e->getMessage());
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
require_once __DIR__.'/includes/boot.php';
|
||||
|
||||
$pageTitle = 'Verify Email — My Keyser';
|
||||
$activeNav = '';
|
||||
$token = gs('token');
|
||||
$status = 'error';
|
||||
$title = 'Verification Link Invalid';
|
||||
$message = 'This verification link is invalid or has already been used.';
|
||||
$detail = '';
|
||||
|
||||
if ($token !== '') {
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$row = qone("
|
||||
SELECT evt.*, u.username, u.email, u.member_type
|
||||
FROM email_verification_tokens evt
|
||||
JOIN users u ON u.id = evt.user_id
|
||||
WHERE evt.token_hash=? AND evt.purpose='email_verify'
|
||||
LIMIT 1
|
||||
", [$tokenHash]);
|
||||
|
||||
if ($row && !empty($row['used_at'])) {
|
||||
$title = 'Email Already Verified';
|
||||
$message = 'This verification link has already been used. You can sign in if your account is active.';
|
||||
} elseif ($row && strtotime((string)$row['expires_at']) < time()) {
|
||||
$title = 'Verification Link Expired';
|
||||
$message = 'This verification link has expired. Please register again or contact the site administrator.';
|
||||
} elseif ($row) {
|
||||
qrun("UPDATE users SET email_verified_at=COALESCE(email_verified_at, datetime('now')), is_active=1 WHERE id=?", [(int)$row['user_id']]);
|
||||
qrun("UPDATE email_verification_tokens SET used_at=datetime('now') WHERE id=?", [(int)$row['id']]);
|
||||
|
||||
$user = qone("SELECT * FROM users WHERE id=?", [(int)$row['user_id']]);
|
||||
[$sent, $mailError] = sendRegistrationCompleteEmail($user);
|
||||
|
||||
$status = 'success';
|
||||
$title = 'Email Verified';
|
||||
if (($user['member_type'] ?? 'member') === 'business_owner') {
|
||||
$message = 'Your email is verified and your business-owner account can now sign in.';
|
||||
$detail = 'We sent a follow-up email confirming that our team will call within a couple of business days for business verification.';
|
||||
} else {
|
||||
$message = 'Your email is verified and your account can now sign in.';
|
||||
$detail = 'We sent a thank-you email confirming your registration.';
|
||||
}
|
||||
if (!$sent) {
|
||||
$detail = 'Your account is verified, but the follow-up email could not be sent. Please contact the site administrator if you need help.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
include __DIR__.'/includes/header.php';
|
||||
?>
|
||||
<div class="section-sm">
|
||||
<div class="verify-card verify-<?=$status?>">
|
||||
<div class="eyebrow"><?= $status === 'success' ? 'ACCOUNT VERIFIED' : 'EMAIL VERIFICATION' ?></div>
|
||||
<h1><?=e($title)?></h1>
|
||||
<p><?=e($message)?></p>
|
||||
<?php if($detail): ?><p class="verify-detail"><?=e($detail)?></p><?php endif; ?>
|
||||
<div class="flex-row" style="justify-content:center;margin-top:1.5rem">
|
||||
<a href="/login.php" class="btn btn-primary">Sign In</a>
|
||||
<a href="/index.php" class="btn btn-outline">Back Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php include __DIR__.'/includes/footer.php'; ?>
|
||||
Reference in New Issue
Block a user