eb57cde99f
Per-tier audio bitrate: Free 64, Plus 96, Pro 128 kbps. Configurable text/voice reply threads and depth. Registration/login-only honeypot and internal CAPTCHA. Optional Google reCAPTCHA v2/v3 with server verification. Archive/export support for replies and bitrate. Dashboard controls for all settings. Verified JavaScript, JSON, SQLite migrations, desktop/mobile UI, and browser console. Native PHP lint was unavailable because PHP is not installed. Google implementation follows Siteverify and reCAPTCHA v3.
364 lines
27 KiB
PHP
364 lines
27 KiB
PHP
<?php
|
|
|
|
function handleAdminPlatformAction(string $action): ?string {
|
|
if ($action === 'save_platform_services') {
|
|
$voiceProfile = (string)($_POST['voice_transcoding_profile'] ?? 'm4a_aac');
|
|
if (!in_array($voiceProfile, ['m4a_aac', 'mp4_h264_aac'], true)) $voiceProfile = 'm4a_aac';
|
|
$captchaVersion = (string)($_POST['captcha_google_version'] ?? 'v2');
|
|
if (!in_array($captchaVersion, ['v2', 'v3'], true)) $captchaVersion = 'v2';
|
|
$captchaDifficulty = (string)($_POST['captcha_internal_difficulty'] ?? 'easy');
|
|
if (!in_array($captchaDifficulty, ['easy', 'medium'], true)) $captchaDifficulty = 'easy';
|
|
$captchaGoogleEnabled = !empty($_POST['captcha_google_enabled']);
|
|
$captchaGoogleSiteKey = trim((string)($_POST['captcha_google_site_key'] ?? ''));
|
|
$captchaGoogleSecretKey = trim((string)($_POST['captcha_google_secret_key'] ?? ''));
|
|
if ($captchaGoogleEnabled && ($captchaGoogleSiteKey === '' || $captchaGoogleSecretKey === '')) {
|
|
throw new RuntimeException('Google reCAPTCHA requires both a site key and secret key.');
|
|
}
|
|
setConfigValues([
|
|
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
|
|
'chat.replies_enabled' => !empty($_POST['replies_enabled']),
|
|
'chat.reply_max_depth' => max(1, min(20, (int)($_POST['reply_max_depth'] ?? 4))),
|
|
'security.captcha.registration_enabled' => !empty($_POST['captcha_registration_enabled']),
|
|
'security.captcha.login_enabled' => !empty($_POST['captcha_login_enabled']),
|
|
'security.captcha.honeypot.enabled' => !empty($_POST['captcha_honeypot_enabled']),
|
|
'security.captcha.internal.enabled' => !empty($_POST['captcha_internal_enabled']),
|
|
'security.captcha.internal.difficulty' => $captchaDifficulty,
|
|
'security.captcha.internal.ttl_seconds' => max(60, min(1800, (int)($_POST['captcha_internal_ttl'] ?? 300))),
|
|
'security.captcha.internal.max_attempts' => max(1, min(10, (int)($_POST['captcha_internal_attempts'] ?? 3))),
|
|
'security.captcha.google.enabled' => $captchaGoogleEnabled,
|
|
'security.captcha.google.version' => $captchaVersion,
|
|
'security.captcha.google.site_key' => $captchaGoogleSiteKey,
|
|
'security.captcha.google.secret_key' => $captchaGoogleSecretKey,
|
|
'security.captcha.google.v3_min_score' => max(0.0, min(1.0, (float)($_POST['captcha_google_v3_score'] ?? 0.5))),
|
|
'security.captcha.google.hostname' => trim((string)($_POST['captcha_google_hostname'] ?? '')),
|
|
'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')),
|
|
'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')),
|
|
'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')),
|
|
'mailgun.api_base' => trim((string)($_POST['mailgun_api_base'] ?? 'https://api.mailgun.net/v3')),
|
|
'stripe.secret_key' => trim((string)($_POST['stripe_secret_key'] ?? '')),
|
|
'stripe.webhook_secret' => trim((string)($_POST['stripe_webhook_secret'] ?? '')),
|
|
'stripe.success_url' => trim((string)($_POST['stripe_success_url'] ?? '')),
|
|
'stripe.cancel_url' => trim((string)($_POST['stripe_cancel_url'] ?? '')),
|
|
'stripe.portal_return_url' => trim((string)($_POST['stripe_portal_return_url'] ?? '')),
|
|
'voice.transcoding.enabled' => !empty($_POST['voice_transcoding_enabled']),
|
|
'voice.transcoding.ffmpeg_path' => trim((string)($_POST['voice_ffmpeg_path'] ?? 'ffmpeg')) ?: 'ffmpeg',
|
|
'voice.transcoding.profile' => $voiceProfile,
|
|
'voice.transcoding.audio_bitrate_kbps' => max(48, min(320, (int)($_POST['voice_audio_bitrate'] ?? 128))),
|
|
'voice.transcoding.timeout_seconds' => max(5, min(300, (int)($_POST['voice_transcoding_timeout'] ?? 45))),
|
|
'voice.transcoding.fallback_to_source' => !empty($_POST['voice_transcoding_fallback']),
|
|
'database.mysql.enabled' => !empty($_POST['mysql_enabled']),
|
|
'database.mysql.auto_import' => !empty($_POST['mysql_auto_import']),
|
|
'database.mysql.dsn' => trim((string)($_POST['mysql_dsn'] ?? '')),
|
|
'database.mysql.username' => trim((string)($_POST['mysql_username'] ?? '')),
|
|
'database.mysql.password' => (string)($_POST['mysql_password'] ?? ''),
|
|
'database.mysql.sync_interval_seconds' => max(60, (int)($_POST['mysql_sync_interval'] ?? 900)),
|
|
]);
|
|
return 'Platform service settings saved.';
|
|
}
|
|
|
|
if ($action === 'save_plans') {
|
|
$db = getDB();
|
|
$plans = $_POST['plans'] ?? [];
|
|
$boolFeatures = [
|
|
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'username_color',
|
|
'text_export', 'voice_archive', 'voice_export', 'email_2fa',
|
|
];
|
|
$db->beginTransaction();
|
|
try {
|
|
$update = $db->prepare("UPDATE plans SET name=?,description=?,price_cents=?,currency=?,
|
|
billing_interval=?,stripe_price_id=?,active=?,sort_order=?,updated_at=? WHERE id=?");
|
|
$upsertFeature = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)
|
|
ON CONFLICT(plan_id,feature_key) DO UPDATE SET value_json=excluded.value_json");
|
|
foreach ($plans as $id => $plan) {
|
|
$id = (int)$id;
|
|
$name = trim((string)($plan['name'] ?? ''));
|
|
if ($id < 1 || $name === '') continue;
|
|
$update->execute([
|
|
$name,
|
|
trim((string)($plan['description'] ?? '')),
|
|
max(0, (int)($plan['price_cents'] ?? 0)),
|
|
strtolower(substr(trim((string)($plan['currency'] ?? 'usd')), 0, 3)),
|
|
in_array($plan['billing_interval'] ?? '', ['day', 'week', 'month', 'year'], true)
|
|
? $plan['billing_interval'] : 'month',
|
|
trim((string)($plan['stripe_price_id'] ?? '')) ?: null,
|
|
!empty($plan['active']) ? 1 : 0,
|
|
(int)($plan['sort_order'] ?? 0),
|
|
time(),
|
|
$id,
|
|
]);
|
|
foreach ($boolFeatures as $key) {
|
|
$upsertFeature->execute([$id, $key, json_encode(!empty($plan['features'][$key]))]);
|
|
}
|
|
$upsertFeature->execute([$id, 'voice_bitrate_kbps', json_encode(
|
|
max(48, min(320, (int)($plan['features']['voice_bitrate_kbps'] ?? 64)))
|
|
)]);
|
|
$upsertFeature->execute([$id, 'text_archive_days', json_encode(max(30, min(3650, (int)($plan['features']['text_archive_days'] ?? 30))))]);
|
|
}
|
|
$db->commit();
|
|
} catch (Throwable $e) {
|
|
if ($db->inTransaction()) $db->rollBack();
|
|
throw $e;
|
|
}
|
|
return 'Plans, prices, limits, and feature assignments saved.';
|
|
}
|
|
|
|
if ($action === 'create_plan') {
|
|
$slug = strtolower(trim((string)($_POST['slug'] ?? '')));
|
|
$name = trim((string)($_POST['name'] ?? ''));
|
|
if (!preg_match('/^[a-z0-9_-]{2,30}$/', $slug) || $name === '') {
|
|
throw new RuntimeException('Plan slug or name is invalid.');
|
|
}
|
|
$db = getDB();
|
|
$db->prepare("INSERT INTO plans
|
|
(slug,name,description,price_cents,currency,billing_interval,active,sort_order,created_at,updated_at)
|
|
VALUES (?,?,?,0,'usd','month',1,50,?,?)")
|
|
->execute([$slug, $name, trim((string)($_POST['description'] ?? '')), time(), time()]);
|
|
$id = (int)$db->lastInsertId();
|
|
$features = [
|
|
'voice_messages' => true, 'voice_bitrate_kbps' => 64,
|
|
'recording_status' => false, 'auto_voice_send' => false,
|
|
'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
|
|
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
|
|
];
|
|
$insert = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)");
|
|
foreach ($features as $key => $value) $insert->execute([$id, $key, json_encode($value)]);
|
|
return "Plan '$name' created.";
|
|
}
|
|
|
|
if ($action === 'run_mysql_mirror') {
|
|
require_once ROOT_DIR . '/lib/mysql_mirror.php';
|
|
$result = mirrorAllSQLiteDatabases();
|
|
return "MySQL mirror completed: {$result['databases']} database(s), {$result['rows']} row(s).";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function adminFeatureLabel(string $key): string {
|
|
return ucwords(str_replace('_', ' ', $key));
|
|
}
|
|
|
|
function renderAdminPlatformTab(): void {
|
|
$plans = plans(false);
|
|
$db = getDB();
|
|
$subscribers = $db->query("SELECT u.id,u.username,u.email,u.email_verified_at,u.subscription_status,
|
|
u.subscription_period_end,u.cancel_at_period_end,p.name AS plan_name,u.stripe_customer_id,u.manual_plan_id
|
|
FROM users u LEFT JOIN plans p ON p.id=COALESCE(u.manual_plan_id,u.plan_id)
|
|
ORDER BY (u.subscription_status IN ('active','trialing','past_due')) DESC,u.created_at DESC LIMIT 500")->fetchAll();
|
|
$stats = [
|
|
'visits_24h' => (int)$db->query("SELECT COUNT(*) FROM visitor_events WHERE event_type='visit' AND created_at>" . (time() - 86400))->fetchColumn(),
|
|
'unique_24h' => (int)$db->query("SELECT COUNT(DISTINCT ip_hash) FROM visitor_events WHERE created_at>" . (time() - 86400))->fetchColumn(),
|
|
'paid' => (int)$db->query("SELECT COUNT(*) FROM users WHERE subscription_status IN ('active','trialing','past_due')")->fetchColumn(),
|
|
];
|
|
$events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events
|
|
WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll();
|
|
$featureKeys = [
|
|
'voice_messages', 'voice_bitrate_kbps', 'recording_status', 'auto_voice_send', 'auto_voice_play',
|
|
'username_color', 'text_archive_days', 'text_export', 'voice_archive', 'voice_export', 'email_2fa',
|
|
];
|
|
?>
|
|
<div class="page-head"><h1>PLANS & PLATFORM</h1>
|
|
<p>// pricing, entitlements, subscriptions, email, Stripe, database mirroring, and usage</p></div>
|
|
|
|
<div class="stat-grid">
|
|
<div class="stat-card green"><div class="stat-num"><?= $stats['visits_24h'] ?></div><div class="stat-label">Visits / 24h</div></div>
|
|
<div class="stat-card blue"><div class="stat-num"><?= $stats['unique_24h'] ?></div><div class="stat-label">Unique / 24h</div></div>
|
|
<div class="stat-card violet"><div class="stat-num"><?= $stats['paid'] ?></div><div class="stat-label">Paid Subscribers</div></div>
|
|
</div>
|
|
|
|
<form method="post">
|
|
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
|
<input type="hidden" name="action" value="save_platform_services">
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// SUBSCRIPTION VISIBILITY</span></div>
|
|
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="subscriptions_enabled" <?= getConfigVal('subscriptions.enabled', true) ? 'checked' : '' ?>>
|
|
<span>Allow and display new subscriptions. When off, existing subscribers can still manage billing.</span></label></div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// REPLY THREADS</span></div><div class="panel-body">
|
|
<div class="form-row">
|
|
<label class="cb-row"><input type="checkbox" name="replies_enabled" <?= getConfigVal('chat.replies_enabled', true) ? 'checked' : '' ?>>
|
|
<span>Enable reply-to controls for text and voice messages</span></label>
|
|
<div class="field"><label>Maximum Reply Depth</label><input type="number" min="1" max="20"
|
|
name="reply_max_depth" value="<?= (int)getConfigVal('chat.reply_max_depth', 4) ?>"></div>
|
|
</div>
|
|
</div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// REGISTRATION / LOGIN CAPTCHA</span></div><div class="panel-body">
|
|
<p class="dim">CAPTCHA checks apply only to initial registration and password login. Email 2FA verification is not challenged.</p>
|
|
<div class="form-row">
|
|
<label class="cb-row"><input type="checkbox" name="captcha_registration_enabled"
|
|
<?= getConfigVal('security.captcha.registration_enabled', true) ? 'checked' : '' ?>><span>Protect registration</span></label>
|
|
<label class="cb-row"><input type="checkbox" name="captcha_login_enabled"
|
|
<?= getConfigVal('security.captcha.login_enabled', true) ? 'checked' : '' ?>><span>Protect login</span></label>
|
|
</div>
|
|
<div class="form-row">
|
|
<label class="cb-row"><input type="checkbox" name="captcha_honeypot_enabled"
|
|
<?= getConfigVal('security.captcha.honeypot.enabled', true) ? 'checked' : '' ?>>
|
|
<span>Invisible honeypot fields that deny bot-like submissions</span></label>
|
|
<label class="cb-row"><input type="checkbox" name="captcha_internal_enabled"
|
|
<?= getConfigVal('security.captcha.internal.enabled', true) ? 'checked' : '' ?>>
|
|
<span>Internal arithmetic challenge</span></label>
|
|
</div>
|
|
<div class="form-row3">
|
|
<div class="field"><label>Internal Difficulty</label><select name="captcha_internal_difficulty">
|
|
<?php $captchaDifficulty = getConfigVal('security.captcha.internal.difficulty', 'easy'); ?>
|
|
<option value="easy" <?= $captchaDifficulty === 'easy' ? 'selected' : '' ?>>Easy (1-9)</option>
|
|
<option value="medium" <?= $captchaDifficulty === 'medium' ? 'selected' : '' ?>>Medium (1-25)</option>
|
|
</select></div>
|
|
<div class="field"><label>Challenge TTL (seconds)</label><input type="number" min="60" max="1800"
|
|
name="captcha_internal_ttl" value="<?= (int)getConfigVal('security.captcha.internal.ttl_seconds', 300) ?>"></div>
|
|
<div class="field"><label>Maximum Attempts</label><input type="number" min="1" max="10"
|
|
name="captcha_internal_attempts" value="<?= (int)getConfigVal('security.captcha.internal.max_attempts', 3) ?>"></div>
|
|
</div>
|
|
<label class="cb-row"><input type="checkbox" name="captcha_google_enabled"
|
|
<?= getConfigVal('security.captcha.google.enabled', false) ? 'checked' : '' ?>>
|
|
<span>Also require Google reCAPTCHA</span></label>
|
|
<div class="form-row3">
|
|
<div class="field"><label>Google Version</label><select name="captcha_google_version">
|
|
<?php $captchaVersion = getConfigVal('security.captcha.google.version', 'v2'); ?>
|
|
<option value="v2" <?= $captchaVersion === 'v2' ? 'selected' : '' ?>>v2 checkbox</option>
|
|
<option value="v3" <?= $captchaVersion === 'v3' ? 'selected' : '' ?>>v3 score</option>
|
|
</select></div>
|
|
<div class="field"><label>Site Key</label><input name="captcha_google_site_key"
|
|
value="<?= esc(getConfigVal('security.captcha.google.site_key', '')) ?>"></div>
|
|
<div class="field"><label>Secret Key</label><input type="password" name="captcha_google_secret_key"
|
|
value="<?= esc(getConfigVal('security.captcha.google.secret_key', '')) ?>"></div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="field"><label>v3 Minimum Score</label><input type="number" min="0" max="1" step="0.1"
|
|
name="captcha_google_v3_score" value="<?= esc(getConfigVal('security.captcha.google.v3_min_score', 0.5)) ?>"></div>
|
|
<div class="field"><label>Expected Hostname (optional)</label><input name="captcha_google_hostname"
|
|
value="<?= esc(getConfigVal('security.captcha.google.hostname', '')) ?>" placeholder="chat.example.com"></div>
|
|
</div>
|
|
</div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body">
|
|
<div class="form-row3">
|
|
<div class="field"><label>API Key</label><input type="password" name="mailgun_api_key" value="<?= esc(getConfigVal('mailgun.api_key', '')) ?>"></div>
|
|
<div class="field"><label>Domain</label><input name="mailgun_domain" value="<?= esc(getConfigVal('mailgun.domain', '')) ?>"></div>
|
|
<div class="field"><label>From</label><input name="mailgun_from" value="<?= esc(getConfigVal('mailgun.from', '')) ?>"></div>
|
|
</div>
|
|
<div class="field"><label>API Base</label><input name="mailgun_api_base" value="<?= esc(getConfigVal('mailgun.api_base', 'https://api.mailgun.net/v3')) ?>"></div>
|
|
</div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// STRIPE</span></div><div class="panel-body">
|
|
<div class="form-row">
|
|
<div class="field"><label>Secret Key</label><input type="password" name="stripe_secret_key" value="<?= esc(getConfigVal('stripe.secret_key', '')) ?>"></div>
|
|
<div class="field"><label>Webhook Secret</label><input type="password" name="stripe_webhook_secret" value="<?= esc(getConfigVal('stripe.webhook_secret', '')) ?>"></div>
|
|
</div>
|
|
<div class="form-row3">
|
|
<div class="field"><label>Success URL</label><input name="stripe_success_url" value="<?= esc(getConfigVal('stripe.success_url', '')) ?>"></div>
|
|
<div class="field"><label>Cancel URL</label><input name="stripe_cancel_url" value="<?= esc(getConfigVal('stripe.cancel_url', '')) ?>"></div>
|
|
<div class="field"><label>Portal Return URL</label><input name="stripe_portal_return_url" value="<?= esc(getConfigVal('stripe.portal_return_url', '')) ?>"></div>
|
|
</div>
|
|
<p class="dim">Webhook endpoint: <span class="mono"><?= esc(appBaseUrl() . '/api/stripe-webhook.php') ?></span></p>
|
|
</div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// VOICE TRANSCODING / FFMPEG</span></div><div class="panel-body">
|
|
<label class="cb-row"><input type="checkbox" name="voice_transcoding_enabled"
|
|
<?= getConfigVal('voice.transcoding.enabled', false) ? 'checked' : '' ?>>
|
|
<span>Transcode uploaded voice clips for broader playback compatibility</span></label>
|
|
<div class="form-row3">
|
|
<div class="field"><label>FFmpeg Binary</label><input name="voice_ffmpeg_path"
|
|
value="<?= esc(getConfigVal('voice.transcoding.ffmpeg_path', 'ffmpeg')) ?>" placeholder="/usr/bin/ffmpeg"></div>
|
|
<div class="field"><label>Output Profile</label><select name="voice_transcoding_profile">
|
|
<?php $voiceProfile = getConfigVal('voice.transcoding.profile', 'm4a_aac'); ?>
|
|
<option value="m4a_aac" <?= $voiceProfile === 'm4a_aac' ? 'selected' : '' ?>>M4A / AAC-LC (recommended)</option>
|
|
<option value="mp4_h264_aac" <?= $voiceProfile === 'mp4_h264_aac' ? 'selected' : '' ?>>MP4 / H.264 + AAC</option>
|
|
</select></div>
|
|
<div class="field"><label>Fallback Audio Bitrate (kbps)</label><input type="number" min="48" max="320"
|
|
name="voice_audio_bitrate" value="<?= (int)getConfigVal('voice.transcoding.audio_bitrate_kbps', 128) ?>"></div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="field"><label>Conversion Timeout (seconds)</label><input type="number" min="5" max="300"
|
|
name="voice_transcoding_timeout" value="<?= (int)getConfigVal('voice.transcoding.timeout_seconds', 45) ?>"></div>
|
|
<label class="cb-row"><input type="checkbox" name="voice_transcoding_fallback"
|
|
<?= getConfigVal('voice.transcoding.fallback_to_source', true) ? 'checked' : '' ?>>
|
|
<span>Keep the original recording if FFmpeg fails</span></label>
|
|
</div>
|
|
<p class="dim">AAC-LC in an M4A container is the recommended audio-only profile for iPhone, Safari, Android, and desktop browsers.
|
|
The H.264 profile creates a tiny black video track plus AAC audio for systems that require a conventional MP4 file.</p>
|
|
</div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// OPTIONAL MYSQL / MARIADB MIRROR</span></div><div class="panel-body">
|
|
<label class="cb-row"><input type="checkbox" name="mysql_enabled" <?= getConfigVal('database.mysql.enabled', false) ? 'checked' : '' ?>><span>Enable MySQL mirror</span></label>
|
|
<label class="cb-row"><input type="checkbox" name="mysql_auto_import" <?= getConfigVal('database.mysql.auto_import', false) ? 'checked' : '' ?>><span>Automatically import all SQLite databases on the configured interval</span></label>
|
|
<div class="form-row3">
|
|
<div class="field"><label>PDO DSN</label><input name="mysql_dsn" value="<?= esc(getConfigVal('database.mysql.dsn', '')) ?>" placeholder="mysql:host=localhost;dbname=cyberchat;charset=utf8mb4"></div>
|
|
<div class="field"><label>Username</label><input name="mysql_username" value="<?= esc(getConfigVal('database.mysql.username', '')) ?>"></div>
|
|
<div class="field"><label>Password</label><input type="password" name="mysql_password" value="<?= esc(getConfigVal('database.mysql.password', '')) ?>"></div>
|
|
</div>
|
|
<div class="field"><label>Sync Interval Seconds</label><input type="number" min="60" name="mysql_sync_interval" value="<?= (int)getConfigVal('database.mysql.sync_interval_seconds', 900) ?>"></div>
|
|
</div></div>
|
|
<button class="btn btn-g" type="submit">SAVE PLATFORM SERVICES</button>
|
|
</form>
|
|
|
|
<div class="panel" style="margin-top:20px"><div class="panel-head"><span class="panel-title">// PLANS, PRICES & ENTITLEMENTS</span></div>
|
|
<div class="panel-body"><form method="post">
|
|
<input type="hidden" name="csrf" value="<?= csrfToken() ?>"><input type="hidden" name="action" value="save_plans">
|
|
<?php foreach ($plans as $plan): ?>
|
|
<div class="danger-zone" style="border-color:var(--bd2);margin:0 0 14px">
|
|
<div class="form-row3">
|
|
<div class="field"><label>Name / <?= esc($plan['slug']) ?></label><input name="plans[<?= $plan['id'] ?>][name]" value="<?= esc($plan['name']) ?>"></div>
|
|
<div class="field"><label>Price Cents</label><input type="number" min="0" name="plans[<?= $plan['id'] ?>][price_cents]" value="<?= $plan['price_cents'] ?>"></div>
|
|
<div class="field"><label>Stripe Price ID</label><input name="plans[<?= $plan['id'] ?>][stripe_price_id]" value="<?= esc($plan['stripe_price_id']) ?>" placeholder="price_..."></div>
|
|
</div>
|
|
<div class="field"><label>Description</label><input name="plans[<?= $plan['id'] ?>][description]" value="<?= esc($plan['description']) ?>"></div>
|
|
<div class="form-row3">
|
|
<div class="field"><label>Currency</label><input maxlength="3" name="plans[<?= $plan['id'] ?>][currency]" value="<?= esc($plan['currency']) ?>"></div>
|
|
<div class="field"><label>Interval</label><select name="plans[<?= $plan['id'] ?>][billing_interval]">
|
|
<?php foreach (['day','week','month','year'] as $interval): ?><option <?= $plan['billing_interval']===$interval?'selected':'' ?>><?= $interval ?></option><?php endforeach; ?>
|
|
</select></div>
|
|
<div class="field"><label>Sort Order</label><input type="number" name="plans[<?= $plan['id'] ?>][sort_order]" value="<?= (int)$plan['sort_order'] ?>"></div>
|
|
</div>
|
|
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][active]" <?= $plan['active'] ? 'checked' : '' ?>><span>Plan active</span></label>
|
|
<div class="form-row3">
|
|
<?php foreach ($featureKeys as $key): ?>
|
|
<?php if ($key === 'text_archive_days'): ?>
|
|
<div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number"
|
|
min="30"
|
|
name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" value="<?= (int)($plan['features'][$key] ?? 0) ?>"></div>
|
|
<?php elseif ($key === 'voice_bitrate_kbps'): ?>
|
|
<div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number"
|
|
min="48" max="320"
|
|
name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" value="<?= (int)($plan['features'][$key] ?? 64) ?>"></div>
|
|
<?php else: ?>
|
|
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]"
|
|
<?= !empty($plan['features'][$key]) ? 'checked' : '' ?>><span><?= esc(adminFeatureLabel($key)) ?></span></label>
|
|
<?php endif; ?>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
<button class="btn btn-g" type="submit">SAVE ALL PLANS</button>
|
|
</form></div>
|
|
</div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// CREATE TIER</span></div><div class="panel-body">
|
|
<form method="post"><input type="hidden" name="csrf" value="<?= csrfToken() ?>"><input type="hidden" name="action" value="create_plan">
|
|
<div class="form-row3"><div class="field"><label>Slug</label><input name="slug" placeholder="business"></div>
|
|
<div class="field"><label>Name</label><input name="name" placeholder="Business"></div>
|
|
<div class="field"><label>Description</label><input name="description"></div></div>
|
|
<button class="btn btn-b" type="submit">CREATE TIER</button>
|
|
</form></div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// SUBSCRIBER TRACKING</span></div><div class="panel-body np">
|
|
<div class="tbl-wrap"><table><thead><tr><th>User</th><th>Email</th><th>Plan</th><th>Status</th><th>Period End</th><th>Stripe</th></tr></thead><tbody>
|
|
<?php foreach ($subscribers as $subscriber): ?><tr>
|
|
<td><?= esc($subscriber['username']) ?></td>
|
|
<td class="dim"><?= esc($subscriber['email'] ?: 'not set') ?> <?= $subscriber['email_verified_at'] ? '<span class="badge badge-g">VERIFIED</span>' : '' ?></td>
|
|
<td><?= esc($subscriber['plan_name'] ?: 'Free') ?><?= $subscriber['manual_plan_id'] ? ' <span class="badge badge-o">ADMIN</span>' : '' ?></td>
|
|
<td><span class="badge <?= in_array($subscriber['subscription_status'], ['active','trialing'], true) ? 'badge-g' : 'badge-o' ?>"><?= esc(strtoupper($subscriber['subscription_status'])) ?></span></td>
|
|
<td class="dim"><?= $subscriber['subscription_period_end'] ? esc(fmtTime((int)$subscriber['subscription_period_end'])) : '-' ?><?= $subscriber['cancel_at_period_end'] ? ' / CANCELING' : '' ?></td>
|
|
<td class="dim"><?= esc($subscriber['stripe_customer_id'] ?: '-') ?></td>
|
|
</tr><?php endforeach; ?></tbody></table></div>
|
|
</div></div>
|
|
|
|
<div class="panel"><div class="panel-head"><span class="panel-title">// 30-DAY EVENT TOTALS</span></div><div class="panel-body">
|
|
<div class="btn-group"><?php foreach ($events as $event): ?><span class="badge badge-b"><?= esc($event['event_type']) ?>: <?= (int)$event['total'] ?></span><?php endforeach; ?></div>
|
|
</div></div>
|
|
|
|
<form method="post"><input type="hidden" name="csrf" value="<?= csrfToken() ?>"><input type="hidden" name="action" value="run_mysql_mirror">
|
|
<button class="btn btn-v" type="submit">RUN MYSQL MIRROR NOW</button></form>
|
|
<?php
|
|
}
|