Commit 03d7eec4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(hosting): the manager runs big tournaments end to end

Stages (Swiss, round robin and knockout chained in any order, with cuts
staff confirm), the arbiter console (results, forfeits, pause and resume,
disputes, fair-play flags, forbidden pairs), registration forms and the
check-in desk, prizes and categories, teams and inter-club events built
from clubs, and exports (.xlsx, CSV, posters).

Presentation: sponsors as a shared library, per-event media, banners, ad
slots, sign-up links and codes, and a venue screen; the public live pages
show sponsors and a call to sign up. Clubs get a dashboard and a bulk
import of 20 clubs with their codes.

Club chat stays in step with membership: adding, removing or approving a
member here asks the player app to re-seat them (core/CommsSync.php), and a
club can make its chat announcements-only. Uploads are capped at 6 MB.
The game's look stays locked; nothing here restyles the manager.

Verified with tools/verify-{arbiter,teams,presentation,registrations,
stages}.mjs against a rehearsal of the player app.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 39c173e7
......@@ -4,12 +4,22 @@ RUN a2enmod rewrite headers
RUN docker-php-ext-install opcache
# The .xlsx and PGN exports are zip files (core/Xlsx.php uses ZipArchive).
RUN apt-get update \
&& apt-get install -y --no-install-recommends libzip-dev \
&& docker-php-ext-install zip \
&& rm -rf /var/lib/apt/lists/*
# Never print PHP errors into a response. The public live tournament page was
# rendering a fatal error complete with a stack trace and server paths to
# anyone who opened the link; errors belong in the log, not on the page.
RUN printf "display_errors = Off\nlog_errors = On\nerror_log = /dev/stderr\nerror_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT\n" \
> /usr/local/etc/php/conf.d/errors.ini
# Tournament media (banner, cover, logo, sponsor logos) are taken up to 5 MB;
# PHP's 2 MB default refused anything bigger before the app's own check.
RUN printf "upload_max_filesize = 6M\npost_max_size = 8M\n" > /usr/local/etc/php/conf.d/uploads.ini
RUN sed -i 's/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
COPY . /var/www/html/
......
<?php
/**
* Keeps a club's chat in the player app in step with its membership.
*
* The club chat (a managed group of kind 'org') lives in the player app
* (api/groups.php there). After staff change who is in a club here, the
* player app is asked to re-seat that member, or the whole club, through its
* service action api/org-feed.php. Best effort: a failure is logged and never
* blocks the staff action; the player app's cron tick repairs anything missed.
*/
class CommsSync
{
private const TIMEOUT = 5;
public static function member(?string $orgId, ?string $playerId): void
{
if (!$orgId || !$playerId) return;
self::post(['action' => 'sync-member', 'org_id' => $orgId, 'player_id' => $playerId]);
}
public static function org(?string $orgId): void
{
if (!$orgId) return;
self::post(['action' => 'sync-org', 'org_id' => $orgId]);
}
private static function post(array $body): void
{
$base = rtrim(getenv('PLAYER_APP_URL') ?: 'https://el3ab-player.caprover.al-arcade.com', '/');
$ch = curl_init($base . '/api/org-feed.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_TIMEOUT => self::TIMEOUT,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Service-Key: ' . SUPABASE_SERVICE_KEY],
]);
$res = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($res === false || $status >= 400) {
error_log('[comms-sync] ' . $body['action'] . ' failed: HTTP ' . $status . ' ' . substr((string)$res, 0, 200));
}
}
}
This diff is collapsed.
......@@ -20,6 +20,7 @@ require_once __DIR__ . '/core/OrgScope.php';
require_once __DIR__ . '/core/EconomyService.php';
require_once __DIR__ . '/core/ProofDocument.php';
require_once __DIR__ . '/core/RedeemService.php';
require_once __DIR__ . '/core/CommsSync.php';
$route = trim($_GET['route'] ?? '', '/');
$method = $_SERVER['REQUEST_METHOD'];
......
<?php
/**
* Header and tabs of a tournament's presentation pages.
* Expects: $tournament, $tab, $base, $title (the page heading).
*/
$tabs = [
'settings' => ['الصفحة المباشرة', ''],
'presentation' => ['الصور', '/presentation'],
'sponsors' => ['الرعاة', '/sponsors'],
'ads' => ['المساحات الإعلانية', '/ads'],
'announcements' => ['إعلانات البطولة', '/announcements'],
'links' => ['الرابط وQR', '/links'],
'analytics' => ['الزوار', '/analytics'],
];
$tname = ($tournament['name_ar'] ?? '') ?: $tournament['name'];
$isLive = !empty($tournament['live_enabled']);
?>
<div class="content-header">
<div class="flex items-center gap-4">
<a href="/tournaments/<?= View::e($tournament['id']) ?>" class="btn btn-icon btn-ghost" aria-label="رجوع للبطولة">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<div>
<h1><?= View::e($title) ?></h1>
<p class="text-sm text-secondary"><?= View::e($tname) ?></p>
</div>
</div>
<?php if ($isLive): ?>
<div class="flex gap-2">
<a href="<?= View::e(LinkService::live($tournament)) ?>" target="_blank" rel="noopener" class="btn btn-ghost">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
الصفحة المباشرة
</a>
</div>
<?php endif; ?>
</div>
<div class="tabs">
<?php foreach ($tabs as $key => [$label, $path]): ?>
<a href="<?= View::e($base . $path) ?>" class="tab <?= $tab === $key ? 'active' : '' ?>"><?= $label ?></a>
<?php endforeach; ?>
</div>
<div class="content-header">
<h1>إحصائيات الزوار</h1>
<a href="/tournaments/<?= $tournament['id'] ?>/live-settings" class="btn btn-ghost">← الإعدادات</a>
</div>
<?php
$title = 'زوار الصفحة المباشرة';
require __DIR__ . '/_tabs.php';
?>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-bottom:24px;">
<div class="card" style="text-align:center;padding:24px;">
......
This diff is collapsed.
<div class="content-header">
<h1>معرض الصور والوسائط</h1>
<a href="/tournaments/<?= $tournament['id'] ?>/live-settings" class="btn btn-ghost">← الإعدادات</a>
</div>
<!-- Upload -->
<div class="card mb-4">
<div class="card-header"><h3>رفع وسائط</h3></div>
<div class="card-body">
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/live-settings/media/upload" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="form-row">
<div class="form-group" style="flex:1">
<label>رفع صورة</label>
<input type="file" name="media_file" class="form-input" accept="image/png,image/jpeg,image/webp,image/gif">
<small class="text-muted">PNG, JPG, WebP, GIF — حد أقصى 10MB</small>
</div>
<div class="form-group" style="flex:1">
<label>أو رابط فيديو</label>
<input type="url" name="video_url" class="form-input" placeholder="https://youtube.com/..." dir="ltr">
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>وصف (اختياري)</label>
<input type="text" name="caption" class="form-input" placeholder="وصف الصورة...">
</div>
<label class="checkbox-label" style="display:flex;align-items:center;gap:8px;padding-top:24px;">
<input type="checkbox" name="is_featured">
<span>صورة مميزة</span>
</label>
</div>
<button type="submit" class="btn btn-primary">رفع</button>
</form>
</div>
</div>
<!-- Gallery -->
<div class="card">
<div class="card-header"><h3>الوسائط الحالية (<?= count($media) ?>)</h3></div>
<div class="card-body">
<?php if (empty($media)): ?>
<p class="text-muted text-center">لا توجد وسائط</p>
<?php else: ?>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;">
<?php foreach ($media as $item): ?>
<div class="card" style="padding:0;overflow:hidden;position:relative;">
<?php if ($item['media_type'] === 'image'): ?>
<img src="<?= htmlspecialchars($item['url']) ?>" alt="" style="width:100%;height:150px;object-fit:cover;">
<?php else: ?>
<div style="width:100%;height:150px;display:flex;align-items:center;justify-content:center;background:var(--bg-primary);">
🎬 فيديو
</div>
<?php endif; ?>
<div style="padding:8px;">
<p style="font-size:12px;margin:0 0 8px;"><?= htmlspecialchars($item['caption'] ?? 'بدون وصف') ?></p>
<form method="POST" action="/tournaments/<?= $tournament['id'] ?>/live-settings/media/<?= $item['id'] ?>/delete">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<button class="btn btn-danger btn-sm" style="width:100%" onclick="return confirm('حذف؟')">حذف</button>
</form>
</div>
<?php if (!empty($item['is_featured'])): ?>
<span style="position:absolute;top:8px;right:8px;">⭐</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php
/**
* The tournament's pictures: the four fixed slots and the gallery, all in the
* tournament-media storage bucket.
* Expects: $tournament, $tab, $base, $slots (kind => media row), $gallery
*/
$title = 'الصور';
require __DIR__ . '/_tabs.php';
$csrf = Auth::csrfToken();
$maxMb = PresentationService::maxMb();
$accept = 'image/jpeg,image/png,image/webp';
?>
<p class="text-sm text-secondary mb-4">
الصور محتوى بيظهر جوه إطارات التطبيق والصفحة المباشرة زي ما هي، ومش بتغير شكل التطبيق.
JPG أو PNG أو WebP، أقصى حجم <?= View::e($maxMb) ?> ميجا.
</p>
<div class="grid grid-2 gap-5 mb-6 lt-slots">
<?php foreach (PresentationService::SLOTS as $kind => $slot):
$row = $slots[$kind] ?? null;
$url = $row ? (PresentationService::url($row['storage_path'] ?? null) ?? ($row['url'] ?? null)) : null;
$w = (int)($row['width'] ?? 0); $h = (int)($row['height'] ?? 0);
$off = $row && $w && $h && abs(($w / $h) - ($slot['w'] / $slot['h'])) / ($slot['w'] / $slot['h']) > 0.03;
?>
<div class="card" id="slot-<?= $kind ?>">
<div class="flex items-center justify-between gap-3 mb-2">
<h3 class="card-title"><?= View::e($slot['label']) ?></h3>
<span class="badge badge-default ltr"><?= $slot['w'] ?>×<?= $slot['h'] ?></span>
</div>
<p class="text-sm text-secondary mb-3"><?= View::e($slot['note']) ?></p>
<div class="lt-frame" style="aspect-ratio: <?= $slot['w'] ?> / <?= $slot['h'] ?>;" data-preview-for="file-<?= $kind ?>">
<?php if ($url): ?>
<img src="<?= View::e($url) ?>" alt="<?= View::e($row['alt_ar'] ?? '') ?>">
<?php else: ?>
<span class="text-sm text-muted">مفيش صورة</span>
<?php endif; ?>
</div>
<?php if ($row): ?>
<p class="text-xs text-muted mt-2 ltr">
<?= $w ?>×<?= $h ?>
<?php if ($off): ?><span class="badge badge-warning badge-sm">النسبة مختلفة، الإطار هيقص منها</span><?php endif; ?>
<?php if (empty($row['show_in_app'])): ?><span class="badge badge-default badge-sm">مخفية من التطبيق</span><?php endif; ?>
</p>
<?php endif; ?>
<form method="POST" action="<?= View::e($base) ?>/presentation/upload" enctype="multipart/form-data" class="mt-3" data-upload-form>
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="hidden" name="kind" value="<?= $kind ?>">
<input type="hidden" name="show_in_app_present" value="1">
<div class="form-group">
<label class="form-label" for="file-<?= $kind ?>"><?= $row ? 'غيّر الصورة' : 'ارفع صورة' ?></label>
<input type="file" name="file" id="file-<?= $kind ?>" class="form-input" accept="<?= $accept ?>" data-max-bytes="<?= PresentationService::maxBytes() ?>" data-slot-w="<?= $slot['w'] ?>" data-slot-h="<?= $slot['h'] ?>">
<span class="form-error" data-file-error hidden></span>
</div>
<div class="form-group">
<label class="form-label" for="alt-<?= $kind ?>">وصف الصورة (للقارئ الصوتي)</label>
<input type="text" name="alt_ar" id="alt-<?= $kind ?>" class="form-input" maxlength="200" value="<?= View::e($row['alt_ar'] ?? '') ?>" placeholder="مثلاً: شعار بطولة القاهرة المفتوحة">
</div>
<label class="toggle mb-3">
<input type="checkbox" name="show_in_app" value="1" <?= !$row || !empty($row['show_in_app']) ? 'checked' : '' ?>>
<span class="toggle-track"></span><span>تظهر في التطبيق</span>
</label>
<div class="flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">احفظ</button>
</div>
</form>
<?php if ($row): ?>
<form method="POST" action="<?= View::e($base) ?>/presentation/<?= $kind ?>/delete" class="mt-2" onsubmit="return confirm('تشيل <?= View::e($slot['label']) ?>؟')">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button type="submit" class="btn btn-ghost btn-sm text-danger">شيل الصورة</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div class="card" id="gallery">
<h3 class="card-title mb-3">معرض الصور (<?= count($gallery) ?>)</h3>
<form method="POST" action="<?= View::e($base) ?>/media/upload" enctype="multipart/form-data" class="grid grid-2 gap-4 mb-5" data-upload-form>
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="gallery-file">صورة</label>
<input type="file" name="media_file" id="gallery-file" class="form-input" accept="<?= $accept ?>" data-max-bytes="<?= PresentationService::maxBytes() ?>">
<span class="form-error" data-file-error hidden></span>
</div>
<div class="form-group">
<label class="form-label" for="gallery-video">أو رابط فيديو</label>
<input type="url" name="video_url" id="gallery-video" class="form-input ltr" dir="ltr" placeholder="https://">
<span class="form-hint">الفيديو بيظهر في الصفحة المباشرة بس.</span>
</div>
<div class="form-group">
<label class="form-label" for="gallery-caption">تعليق</label>
<input type="text" name="caption" id="gallery-caption" class="form-input" maxlength="200">
</div>
<div class="form-group flex flex-col gap-2">
<label class="toggle"><input type="checkbox" name="show_in_app" value="1" checked><span class="toggle-track"></span><span>تظهر في التطبيق</span></label>
<label class="toggle"><input type="checkbox" name="is_featured" value="1"><span class="toggle-track"></span><span>صورة مميزة</span></label>
</div>
<div><button type="submit" class="btn btn-primary">ارفع</button></div>
</form>
<?php if (empty($gallery)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<h3 class="empty-state-title">المعرض فاضي</h3>
<p class="empty-state-text">صور من القاعة والتتويج بتظهر في الصفحة المباشرة والتطبيق.</p>
</div>
<?php else: ?>
<div class="grid grid-4 gap-4">
<?php foreach ($gallery as $item):
$isImage = ($item['media_type'] ?? '') === 'image';
$src = $isImage ? (PresentationService::url($item['storage_path'] ?? null) ?? ($item['url'] ?? '')) : '';
?>
<div class="card p-3">
<div class="lt-frame lt-frame-sm">
<?php if ($isImage && $src): ?>
<img src="<?= View::e($src) ?>" alt="<?= View::e($item['caption'] ?? '') ?>" loading="lazy">
<?php else: ?>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-label="فيديو"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>
<?php endif; ?>
</div>
<p class="text-xs mt-2 truncate"><?= View::e($item['caption'] ?? '') ?: '<span class="text-muted">بدون تعليق</span>' ?></p>
<div class="flex gap-1 flex-wrap mt-1">
<?php if (!empty($item['is_featured'])): ?><span class="badge badge-warning badge-sm">مميزة</span><?php endif; ?>
<?php if ($isImage && empty($item['show_in_app'])): ?><span class="badge badge-default badge-sm">مش في التطبيق</span><?php endif; ?>
<?php if ($isImage && empty($item['storage_path'])): ?><span class="badge badge-danger badge-sm">قديمة على السيرفر</span><?php endif; ?>
</div>
<form method="POST" action="<?= View::e($base) ?>/media/<?= View::e($item['id']) ?>/delete" class="mt-2" onsubmit="return confirm('تمسحها؟')">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button class="btn btn-ghost btn-xs text-danger w-full">امسح</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php
/**
* Sponsors attached to this tournament, from the sponsor library.
* Expects: $tournament, $tab, $base, $links (tournament_sponsorships + sponsor), $available
*/
$title = 'الرعاة';
require __DIR__ . '/_tabs.php';
$csrf = Auth::csrfToken();
$tiers = LiveTournamentsAdminController::TIERS;
$logoOf = function (?array $s, array $l): ?string {
return PresentationService::url($l['logo_url'] ?? null)
?? PresentationService::url($s['logo_path'] ?? null)
?? PresentationService::httpsUrl($s['logo_url'] ?? null);
};
$fields = function (array $l) use ($tiers): string {
ob_start(); ?>
<div class="grid grid-2 gap-3">
<div class="form-group">
<label class="form-label">الفئة</label>
<select name="tier" class="form-select">
<?php foreach ($tiers as $k => $label): ?>
<option value="<?= $k ?>" <?= ($l['tier'] ?? 'partner') === $k ? 'selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">الترتيب جوه الفئة</label>
<input type="number" name="display_order" class="form-input" min="0" max="999" value="<?= (int)($l['display_order'] ?? 0) ?>">
</div>
<div class="form-group">
<label class="form-label">الوصف تحت الشعار (اختياري)</label>
<input type="text" name="label_ar" class="form-input" maxlength="80" value="<?= View::e($l['label_ar'] ?? '') ?>" placeholder="مثلاً: الراعي الرسمي للبطولة">
</div>
<div class="form-group">
<label class="form-label">الرابط (اختياري)</label>
<input type="url" name="link_url" class="form-input ltr" dir="ltr" pattern="https://.+" value="<?= View::e($l['link_url'] ?? '') ?>" placeholder="https://">
<span class="form-hint">https بس. لو فاضي بيستخدم موقع الراعي من المكتبة.</span>
</div>
</div>
<div class="flex gap-5 flex-wrap mb-3">
<label class="toggle"><input type="checkbox" name="show_in_app" value="1" <?= !isset($l['show_in_app']) || $l['show_in_app'] ? 'checked' : '' ?>><span class="toggle-track"></span><span>يظهر في التطبيق</span></label>
<label class="toggle"><input type="checkbox" name="show_on_live" value="1" <?= !isset($l['show_on_live']) || $l['show_on_live'] ? 'checked' : '' ?>><span class="toggle-track"></span><span>يظهر في الصفحة المباشرة</span></label>
<?php if (isset($l['id'])): ?>
<label class="toggle"><input type="checkbox" name="is_active" value="1" <?= !empty($l['is_active']) ? 'checked' : '' ?>><span class="toggle-track"></span><span>شغال</span></label>
<?php endif; ?>
</div>
<?php return ob_get_clean();
};
?>
<div class="card mb-5">
<div class="flex items-center justify-between gap-3 mb-3 flex-wrap">
<h3 class="card-title">ضيف راعي</h3>
<div class="flex gap-2">
<a href="/sponsors" class="btn btn-ghost btn-sm">مكتبة الرعاة</a>
<a href="/sponsors/create" class="btn btn-ghost btn-sm">راعي جديد</a>
</div>
</div>
<?php if (empty($available)): ?>
<p class="text-sm text-secondary">كل الرعاة اللي في المكتبة متضافين، أو المكتبة فاضية. <a href="/sponsors/create">ضيف راعي للمكتبة</a>.</p>
<?php else: ?>
<form method="POST" action="<?= View::e($base) ?>/sponsors/attach">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="sp-pick">الراعي</label>
<select name="sponsor_id" id="sp-pick" class="form-select" required>
<option value="">اختار</option>
<?php foreach ($available as $s): ?>
<option value="<?= View::e($s['id']) ?>" data-tier="<?= View::e($s['tier'] ?? '') ?>" data-website="<?= View::e($s['website'] ?? '') ?>"><?= View::e((($s['name_ar'] ?? '') ?: $s['name'])) ?></option>
<?php endforeach; ?>
</select>
</div>
<?= $fields([]) ?>
<button type="submit" class="btn btn-primary">ضيف للبطولة</button>
</form>
<?php endif; ?>
</div>
<div class="data-table-wrapper">
<?php if (empty($links)): ?>
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg>
<h3 class="empty-state-title">مفيش رعاة لسه</h3>
<p class="empty-state-text">الرعاة بيظهروا بالترتيب ده في التطبيق والصفحة المباشرة: الراعي الرسمي الأول.</p>
</div>
<?php else: ?>
<table class="data-table">
<thead>
<tr><th>الراعي</th><th>الفئة</th><th>يظهر في</th><th>مشاهدات</th><th>ضغطات</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($links as $l):
$s = $l['sponsor'];
$logo = $logoOf($s, $l);
$name = $s ? ((($s['name_ar'] ?? '') ?: $s['name'])) : 'راعي محذوف';
$imp = (int)$l['impressions']; $clk = (int)$l['clicks'];
?>
<tr>
<td>
<div class="flex items-center gap-3">
<span class="lt-logo"><?php if ($logo): ?><img src="<?= View::e($logo) ?>" alt=""><?php endif; ?></span>
<div>
<div class="font-medium"><?= View::e($name) ?></div>
<?php if (!empty($l['label_ar'])): ?><div class="text-xs text-secondary"><?= View::e($l['label_ar']) ?></div><?php endif; ?>
<?php if ($s && empty($s['is_active'])): ?><span class="badge badge-default badge-sm">مؤرشف في المكتبة</span><?php endif; ?>
</div>
</div>
</td>
<td><span class="badge badge-<?= in_array($l['tier'], ['title', 'presenting'], true) ? 'warning' : 'info' ?>"><?= View::e($tiers[$l['tier']] ?? $l['tier']) ?></span> <span class="text-xs text-muted tabular-nums"><?= (int)$l['display_order'] ?></span></td>
<td class="text-sm">
<?php if (empty($l['is_active'])): ?><span class="badge badge-default">واقف</span>
<?php else: ?>
<?= !empty($l['show_in_app']) ? '<span class="badge badge-success badge-sm">التطبيق</span>' : '' ?>
<?= !empty($l['show_on_live']) ? '<span class="badge badge-success badge-sm">الصفحة المباشرة</span>' : '' ?>
<?php endif; ?>
</td>
<td class="tabular-nums" data-counter="impressions"><?= number_format($imp) ?></td>
<td class="tabular-nums" data-counter="clicks"><?= number_format($clk) ?><?php if ($imp > 0): ?> <span class="text-xs text-muted">(<?= round($clk / $imp * 100, 1) ?>٪)</span><?php endif; ?></td>
<td>
<div class="table-actions">
<details class="lt-edit">
<summary class="btn btn-ghost btn-xs">عدّل</summary>
<form method="POST" action="<?= View::e($base) ?>/sponsors/<?= View::e($l['id']) ?>/update" class="card mt-2 p-4">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<?= $fields($l) ?>
<button type="submit" class="btn btn-primary btn-sm">احفظ</button>
</form>
</details>
<form method="POST" action="<?= View::e($base) ?>/sponsors/<?= View::e($l['id']) ?>/detach" onsubmit="return confirm('تشيل الراعي من البطولة؟ عدّاد المشاهدات والضغطات بتاعه هيتمسح.')">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button type="submit" class="btn btn-ghost btn-xs text-danger">شيله</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
/**
* The banner field of the tournament wizard (modules/tournaments/views/form.php).
* Choosing a picture uploads it straight away through the presentation upload
* (tournament-media bucket), then puts the stored URL in the hidden banner_url
* the wizard posts, and shows it.
*/
(function () {
'use strict';
const box = document.querySelector('[data-banner-upload]');
if (!box || !box.dataset.uploadUrl) return;
const file = box.querySelector('[data-banner-file]');
const hidden = box.querySelector('[data-banner-url]');
const preview = box.querySelector('[data-banner-preview]');
const err = box.querySelector('[data-banner-error]');
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
const say = (msg, isError) => { err.textContent = msg || ''; err.hidden = !msg; err.classList.toggle('text-warning', !isError); };
file.addEventListener('change', async () => {
const f = file.files && file.files[0];
say('');
if (!f) return;
if (!['image/jpeg', 'image/png', 'image/webp'].includes(f.type)) { say('الملف ده مش مقبول: JPG أو PNG أو WebP', true); return; }
if (f.size > 5 * 1024 * 1024) { say('الملف كبير، أقصى حجم 5 ميجا', true); return; }
const fd = new FormData();
fd.append('_csrf', csrf);
fd.append('kind', 'banner');
fd.append('show_in_app_present', '1');
fd.append('show_in_app', '1');
fd.append('file', f);
file.disabled = true;
try {
const r = await fetch(box.dataset.uploadUrl, { method: 'POST', body: fd, headers: { Accept: 'application/json' }, credentials: 'same-origin' });
const res = await r.json().catch(() => ({}));
if (!r.ok || !res.ok) { say(res.message || 'الرفع ماكملش، جرّب تاني', true); return; }
hidden.value = res.url;
preview.src = res.url;
preview.hidden = false;
if (res.warning) say(res.warning, false);
if (typeof window.showToast === 'function') window.showToast('البانر اترفع', 'success');
} catch (e) {
say('الرفع ماكملش، جرّب تاني', true);
} finally {
file.disabled = false;
file.value = '';
}
});
})();
(function() {
/**
* Keeps the overview's knockout bracket current while it is played.
*
* The bracket is drawn on the server (views/_bracket.php), so it shows with no
* script at all. This loads with it (it used to be skipped: the page only
* loaded it when tournament_mode was 'elimination', which that column never
* holds) and, while the tournament runs, re-reads /api/live/{id}/bracket every
* 30 s and redraws the same markup, so a decided slot moves its winner on.
*/
(function () {
'use strict';
const container = document.getElementById('bracket-svg-container');
if (!container) return;
const matchesRaw = container.dataset.matches;
if (!matchesRaw) return;
let matches;
try {
matches = JSON.parse(matchesRaw);
} catch (e) { return; }
if (!matches.length) return;
const roundsContainer = document.getElementById('bracket-rounds');
if (!roundsContainer) return;
const roundsMap = {};
matches.forEach(m => {
const rn = m.round_number || 1;
if (!roundsMap[rn]) roundsMap[rn] = [];
roundsMap[rn].push(m);
});
const roundNumbers = Object.keys(roundsMap).map(Number).sort((a, b) => a - b);
const totalRounds = roundNumbers.length;
const roundLabels = {
1: 'الجولة الأولى',
2: 'ربع النهائي',
3: 'نصف النهائي',
4: 'النهائي',
container.dataset.enhanced = '1';
const page = document.querySelector('.live-container');
const tid = container.dataset.tournamentId || (page && page.dataset.tournamentId);
const live = page && page.dataset.status === 'in_progress';
if (!tid || !live) return;
const esc = (t) => { const d = document.createElement('div'); d.textContent = t == null ? '' : String(t); return d.innerHTML; };
const fmt = (v) => {
if (v === null || v === undefined || v === '') return '';
const n = Math.round(Number(v) * 2) / 2;
return n === 0.5 ? '½' : String(n).replace('.5', '½');
};
const roundName = (r, total) => ({ 0: 'النهائي', 1: 'نصف النهائي', 2: 'ربع النهائي', 3: 'دور الـ16' })[total - r] || ('الجولة ' + r);
roundNumbers.forEach((rn, idx) => {
const roundDiv = document.createElement('div');
roundDiv.className = 'bracket-round';
let label = roundLabels[totalRounds - idx] || `الجولة ${rn}`;
if (idx === totalRounds - 1) label = 'النهائي';
else if (idx === totalRounds - 2) label = 'نصف النهائي';
const titleDiv = document.createElement('div');
titleDiv.className = 'bracket-round-title';
titleDiv.textContent = label;
roundDiv.appendChild(titleDiv);
const roundMatches = roundsMap[rn].sort((a, b) => (a.match_number || 0) - (b.match_number || 0));
roundMatches.forEach(match => {
const matchDiv = document.createElement('div');
matchDiv.className = 'bracket-match';
const p1Name = match.player1_name || match.player_a_name || 'TBD';
const p2Name = match.player2_name || match.player_b_name || 'TBD';
const p1Score = match.player1_score ?? match.score_a ?? '';
const p2Score = match.player2_score ?? match.score_b ?? '';
const winner = match.winner_id || match.winner;
const p1Id = match.player1_id || match.player_a_id || '';
const p2Id = match.player2_id || match.player_b_id || '';
const p1Winner = winner && winner === p1Id;
const p2Winner = winner && winner === p2Id;
matchDiv.innerHTML = `
<div class="bracket-player ${p1Winner ? 'bracket-winner' : ''}">
<span>${escapeHtml(p1Name)}</span>
<span class="bracket-score">${p1Score}</span>
</div>
<div class="bracket-player ${p2Winner ? 'bracket-winner' : ''}">
<span>${escapeHtml(p2Name)}</span>
<span class="bracket-score">${p2Score}</span>
</div>
`;
function seat(m, side) {
return { name: m['player_' + side + '_name'] || null, id: m['player_' + side + '_id'] || null, score: m['points_' + side], seed: m['player_' + side + '_seed'] };
}
roundDiv.appendChild(matchDiv);
});
function matchHtml(m) {
const status = m.status || '';
const showScore = ['completed', 'bye', 'in_progress', 'tiebreak'].includes(status);
const seats = [seat(m, 'a'), seat(m, 'b')].map(s => {
const cls = ['lb-bracket-seat', s.name === null ? 'is-empty' : '', (m.winner_id && s.id && m.winner_id === s.id) ? 'is-winner' : ''].join(' ');
const seed = s.seed ? '<span class="lb-bracket-seed">' + esc(s.seed) + '</span> ' : '';
const score = showScore && s.name !== null && fmt(s.score) !== '' ? '<span class="lb-bracket-score">' + esc(fmt(s.score)) + '</span>' : '';
return '<div class="' + cls + '"><span>' + seed + esc(s.name || 'في الانتظار') + '</span>' + score + '</div>';
}).join('');
const isLive = status === 'in_progress' || status === 'tiebreak';
return '<div class="lb-bracket-match' + (isLive ? ' is-live' : '') + '">' + seats + '</div>';
}
roundsContainer.appendChild(roundDiv);
function draw(matches) {
const main = {};
const extra = [];
matches.forEach(m => {
if (['consolation', 'third_place'].includes(m.bracket_type)) { extra.push(m); return; }
const r = Number(m.round_number || 1);
(main[r] = main[r] || []).push(m);
});
const rounds = Object.keys(main).map(Number).sort((a, b) => a - b);
if (!rounds.length) return;
const total = rounds[rounds.length - 1];
let html = rounds.map(r => '<div class="lb-bracket-round"><div class="lb-bracket-round-title">' + esc(roundName(r, total)) + '</div>' +
main[r].sort((a, b) => (a.match_number || 0) - (b.match_number || 0)).map(matchHtml).join('') + '</div>').join('');
if (extra.length) html += '<div class="lb-bracket-round"><div class="lb-bracket-round-title">المركز الثالث</div>' + extra.map(matchHtml).join('') + '</div>';
container.innerHTML = html;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
async function refresh() {
if (document.hidden) return;
try {
const r = await fetch('/api/live/' + encodeURIComponent(tid) + '/bracket', { headers: { Accept: 'application/json' } });
if (!r.ok) return;
const data = await r.json();
const list = Array.isArray(data) ? data : (Array.isArray(data.data) ? data.data : []);
if (list.length) { draw(list); container.dataset.refreshedAt = String(Date.now()); }
} catch (e) { /* the server-drawn bracket stays */ }
}
setInterval(refresh, 30000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
})();
......@@ -1129,9 +1129,14 @@
color: var(--live-text);
}
.lb-bracket-seat.is-winner::after {
content: '✓';
color: var(--live-success);
font-weight: 700;
/* A drawn check mark, not the U+2713 glyph (which renders as an emoji on some phones). */
content: '';
flex: none;
inline-size: 12px;
block-size: 12px;
background: var(--live-success);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 6 9 17l-5-5' fill='none' stroke='black' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 6 9 17l-5-5' fill='none' stroke='black' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
}
.lb-bracket-score {
font-variant-numeric: tabular-nums;
......@@ -1398,3 +1403,124 @@
}
.modal-stat-value { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
.modal-stat-label { font-size: 11px; color: var(--live-text-muted); }
/* ═══ Presentation (hosting P10): sign-up call to action, sponsors, ad slots ═══
Built only from the page's own variables, so every theme keeps its look. */
/* Medals: a drawn medal tinted by place. */
.lb-podium-medal { display: grid; place-items: center; }
.lb-podium-medal svg { inline-size: clamp(22px, 6vw, 32px); block-size: clamp(22px, 6vw, 32px); }
.lb-podium-slot[data-place="1"] .lb-podium-medal { color: var(--lb-gold); }
.lb-podium-slot[data-place="2"] .lb-podium-medal { color: var(--lb-silver); }
.lb-podium-slot[data-place="3"] .lb-podium-medal { color: var(--lb-bronze); }
.pin-icon, .gallery-featured { display: inline-grid; place-items: center; color: var(--lb-gold); }
.lb-round-marker svg { display: block; }
.embed-link svg { vertical-align: -2px; }
/* Sign-up call to action */
.lb-cta {
max-inline-size: 1100px;
margin: clamp(16px, 4vw, 28px) auto 0;
margin-inline: max(var(--lb-gutter), calc((100% - 1100px) / 2));
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: clamp(16px, 4vw, 24px);
border-radius: var(--lb-radius);
background: linear-gradient(135deg, color-mix(in srgb, var(--live-success) 16%, var(--live-surface)), var(--live-surface));
border: 1px solid color-mix(in srgb, var(--live-success) 36%, transparent);
}
.lb-cta-text { min-inline-size: 0; }
.lb-cta-title { margin: 0 0 4px; font-size: clamp(18px, 4vw, 24px); font-weight: 700; }
.lb-cta-sub { margin: 0; color: var(--live-text-secondary); font-size: 14px; }
.lb-cta-btn {
display: inline-flex;
align-items: center;
gap: 10px;
min-block-size: 48px;
padding: 12px 24px;
border-radius: var(--lb-radius-sm);
background: var(--live-success);
color: var(--live-bg);
font-weight: 700;
font-size: 16px;
text-decoration: none;
transition: transform 120ms var(--lb-ease), filter 160ms ease;
}
.lb-cta-btn:hover { filter: brightness(1.08); text-decoration: none; }
.lb-cta-btn:active { transform: scale(.97); }
.lb-cta-btn:focus-visible { outline: 2px solid var(--live-text); outline-offset: 3px; }
@media (max-width: 520px) { .lb-cta-btn { inline-size: 100%; justify-content: center; } }
/* Sponsors: logos on neutral tiles, title sponsor widest. Never a sponsor colour. */
.lb-sponsors {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.lb-sponsor {
grid-column: span 2;
display: flex;
flex-direction: column;
gap: 6px;
text-decoration: none;
color: inherit;
min-inline-size: 0;
}
.lb-sponsor.is-lead { grid-column: span 6; }
.lb-sponsor.is-mid { grid-column: span 3; }
.lb-sponsor:hover { text-decoration: none; }
.lb-sponsor-tile {
display: grid;
place-items: center;
block-size: 84px;
padding: 12px;
border-radius: var(--lb-radius-sm);
background: #fff;
border: 1px solid var(--live-border);
transition: transform 120ms var(--lb-ease);
}
.lb-sponsor.is-lead .lb-sponsor-tile { block-size: 108px; }
a.lb-sponsor:hover .lb-sponsor-tile { transform: translateY(-2px); }
.lb-sponsor-tile img { max-inline-size: 100%; max-block-size: 56px; object-fit: contain; }
.lb-sponsor.is-lead .lb-sponsor-tile img { max-block-size: 72px; }
.lb-sponsor-name { color: #1a1a1a; font-weight: 700; text-align: center; }
.lb-sponsor-label { font-size: 12px; color: var(--live-text-muted); text-align: center; }
@media (max-width: 520px) {
.lb-sponsors { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.lb-sponsor, .lb-sponsor.is-mid { grid-column: span 1; }
.lb-sponsor.is-lead { grid-column: span 2; }
}
/* Ad slots carry a plain label, as the app's ad card does. */
.ad-slot { position: relative; }
.lb-ad-tag {
position: absolute;
inset-block-start: 8px;
inset-inline-start: 8px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
background: color-mix(in srgb, var(--live-bg) 72%, transparent);
color: var(--live-text-secondary);
pointer-events: none;
}
.lb-ad-text {
display: block;
margin: 0;
padding: 28px 16px 16px;
border-radius: var(--live-radius-sm);
background: var(--live-surface);
border: 1px solid var(--live-border);
color: var(--live-text);
line-height: 1.6;
}
a.lb-ad-text:hover { text-decoration: none; border-color: color-mix(in srgb, var(--live-accent) 40%, transparent); }
/* Bracket: seeds and the slot being played. */
.lb-bracket-seed { color: var(--live-text-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.lb-bracket-match.is-live { box-shadow: 0 0 0 1px color-mix(in srgb, var(--live-danger) 50%, transparent); }
.form-row {
display: flex;
gap: 16px;
align-items: flex-start;
/* Staff pages of a tournament's presentation. Layout only, from the kit's
variables: no new colours or fonts. */
.form-row { display: flex; gap: 16px; align-items: flex-start; }
@media (max-width: 768px) { .form-row { flex-direction: column; } }
/* A neutral frame at the slot's own shape: the picture as uploaded, never
dressed up as the app's card. */
.lt-frame {
inline-size: 100%;
display: grid;
place-items: center;
overflow: hidden;
border-radius: var(--radius-md);
border: 1px dashed var(--border);
background: var(--bg-primary);
}
.lt-frame img { inline-size: 100%; block-size: 100%; object-fit: contain; }
.lt-frame-sm { aspect-ratio: 4 / 3; }
.lt-slots .lt-frame { max-block-size: 320px; }
@media (max-width: 768px) {
.form-row {
flex-direction: column;
}
.lt-logo, .lt-ad-thumb {
flex: none;
display: grid;
place-items: center;
overflow: hidden;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: #fff;
}
.lt-logo { inline-size: 48px; block-size: 48px; padding: 4px; }
.lt-ad-thumb { inline-size: 120px; block-size: 40px; background: var(--bg-primary); }
.lt-logo img, .lt-ad-thumb img { max-inline-size: 100%; max-block-size: 100%; object-fit: contain; }
/* QR codes print and scan best dark on white, whatever the manager theme. */
.lt-qr { display: flex; justify-content: center; }
.lt-qr svg { background: #fff; padding: 8px; border-radius: var(--radius-md); inline-size: 220px; block-size: 220px; }
.lt-qr-sm svg { inline-size: 160px; block-size: 160px; }
.lt-edit > summary { list-style: none; cursor: pointer; }
.lt-edit > summary::-webkit-details-marker { display: none; }
.lt-edit[open] { inline-size: min(560px, 80vw); }
.table-actions { flex-wrap: wrap; }
// Admin JS for live-tournaments settings (minimal)
(function() {
/**
* Staff pages of a tournament's presentation (live settings, pictures,
* sponsors, ads, announcements, link and QR). Small, progressive: every form
* works without it.
*/
(function () {
'use strict';
const copy = (text, btn) => {
if (typeof window.copyToClipboard === 'function') return window.copyToClipboard(text, btn);
navigator.clipboard && navigator.clipboard.writeText(text);
};
document.addEventListener('click', (e) => {
const c = e.target.closest('[data-copy]');
if (c) { copy(c.dataset.copy, c); return; }
const f = e.target.closest('[data-copy-from]');
if (f) { const src = document.getElementById(f.dataset.copyFrom); if (src) copy(src.value, f); return; }
const d = e.target.closest('[data-qr-download]');
if (d) {
const box = d.closest('.card').querySelector('.lt-qr');
const svg = box && box.querySelector('svg');
if (!svg) return;
const blob = new Blob([svg.outerHTML], { type: 'image/svg+xml' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = box.dataset.qrName || 'qr.svg';
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 500);
}
});
// The WhatsApp button sends what is in the box, edits included.
const wa = document.getElementById('wa-text');
const waLink = document.querySelector('[data-wa-link]');
if (wa && waLink) wa.addEventListener('input', () => { waLink.href = 'https://wa.me/?text=' + encodeURIComponent(wa.value); });
// Files: refuse an oversized one before it is sent, and preview it in its frame.
document.querySelectorAll('input[type="file"][data-max-bytes]').forEach(input => {
input.addEventListener('change', () => {
const form = input.closest('form');
const err = input.parentElement.querySelector('[data-file-error]');
const submit = form && form.querySelector('[type="submit"]');
const file = input.files && input.files[0];
// block: a refusal keeps the form from being sent; a warning does not.
const say = (msg, block) => {
if (err) { err.textContent = msg || ''; err.hidden = !msg; }
if (submit) submit.disabled = !!block;
};
say('', false);
if (!file) return;
const max = Number(input.dataset.maxBytes || 0);
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) { say('الملف ده مش مقبول: JPG أو PNG أو WebP', true); return; }
if (max && file.size > max) { say('الملف كبير، أقصى حجم ' + (Math.round(max / 104857.6) / 10) + ' ميجا', true); return; }
const frame = document.querySelector('[data-preview-for="' + input.id + '"]');
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
if (frame) { frame.innerHTML = ''; frame.appendChild(img); }
const w = Number(input.dataset.slotW || 0), h = Number(input.dataset.slotH || 0);
if (w && h && Math.abs(img.naturalWidth / img.naturalHeight - w / h) / (w / h) > 0.03) {
say('المقاس ' + img.naturalWidth + '×' + img.naturalHeight + ' مش بنفس نسبة ' + w + '×' + h + '، الإطار هيقص منها', false);
}
};
img.src = url;
});
});
// Ads: app positions take a picture only; the ticker takes text only.
document.querySelectorAll('[data-ad-form]').forEach(form => {
const pos = form.querySelector('[name="position"]');
const radios = form.querySelectorAll('[name="slot_type"]');
const sync = () => {
const opt = pos.selectedOptions[0];
const app = opt && opt.dataset.app === '1';
const ticker = pos.value === 'ticker';
radios.forEach(r => {
r.disabled = (app && r.value !== 'image') || (ticker && r.value !== 'text');
if (app && r.value === 'image') r.checked = true;
if (ticker && r.value === 'text') r.checked = true;
});
const type = [...radios].find(r => r.checked)?.value || 'image';
form.querySelectorAll('[data-when]').forEach(el => { el.hidden = el.dataset.when !== type; });
};
pos.addEventListener('change', sync);
radios.forEach(r => r.addEventListener('change', sync));
sync();
});
// Announcements: a team or club audience asks which one.
document.querySelectorAll('[data-ann-form]').forEach(form => {
const sel = form.querySelector('[data-audience]');
if (!sel) return;
const sync = () => form.querySelectorAll('[data-audience-ref]').forEach(el => { el.hidden = el.dataset.audienceRef !== sel.value; });
sel.addEventListener('change', sync);
sync();
});
})();
......@@ -24,6 +24,7 @@
setupGalleryLightbox();
setupH2H();
setupSectionObservers();
setupPromoTracking();
document.addEventListener('visibilitychange', () => {
isVisible = !document.hidden;
......@@ -312,6 +313,41 @@
sections.forEach(s => { s.classList.add('lb-reveal'); observer.observe(s); });
}
/**
* Sponsor tiles and ad slots count a view once half of them has been on
* screen, and a click when tapped. The server throttles and checks the id
* belongs to this tournament; this only reports.
*/
function setupPromoTracking() {
const items = document.querySelectorAll('[data-promo-kind][data-promo-id]');
if (!items.length || !tournamentId) return;
const url = `/api/live/${tournamentId}/track`;
const send = (el, event) => {
const body = JSON.stringify({ promo: { kind: el.dataset.promoKind, id: el.dataset.promoId, event } });
if (event === 'click' && navigator.sendBeacon) {
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
return;
}
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true }).catch(() => {});
};
const seen = new WeakSet();
const view = (el) => { if (seen.has(el)) return; seen.add(el); send(el, 'impression'); };
if ('IntersectionObserver' in window) {
const io = new IntersectionObserver((entries) => {
entries.forEach(e => { if (e.isIntersecting) { view(e.target); io.unobserve(e.target); } });
}, { threshold: 0.5 });
items.forEach(el => io.observe(el));
} else {
items.forEach(view);
}
document.addEventListener('click', (e) => {
const el = e.target.closest('[data-promo-kind][data-promo-id]');
const link = e.target.closest('a[href]');
// Only a tap that opens the sponsor's link counts as a click.
if (el && link && (link === el || el.contains(link))) send(el, 'click');
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
......
This diff is collapsed.
<svg xmlns="http://www.w3.org/2000/svg" style="color-scheme:light only" viewBox="0 0 800 800"><path d="M237.8 702.4c-42 0-77.3-14.9-102.2-43-23-25.9-35.6-62.3-35.6-102.5 0-59.2 41.5-73.3 77.5-79.1l16.4-2.6-5 15.8a213 213 0 0 0-10.2 61.2c0 54 24.4 87.6 63.6 87.6 29.2 0 56.6-23.8 70.7-60.2a157.1 157.1 0 0 1-29.1-8.4c-13.4-5.6-20.1-13-20.5-21.8l-7-55.7v-.6c0-9.1 5.6-15.6 12.9-20.5l-5.1-13.5c-16.6-44.9-37.3-100.9-37.3-143.7 0-50.4 22.1-95.4 62.4-126.9 10.9-15.3 27.2-32 48.5-49.7 17.8-14.8 38-29.3 56.9-40.9l5.4-3.2 5.4 3.2c36.1 22.1 82.4 58.6 105.4 90.7a157.9 157.9 0 0 1 62.5 126.5c0 43.1-20.7 99.5-37.5 144.7l-4.4 12.7c7.2 5 13.3 11.4 13.3 20.5v.6l-7.6 55.6c-.6 13.7-17.1 23.7-50 30.3 14.1 36.4 41.5 60.2 70.6 60.2 39.2 0 63.5-33.6 63.5-87.6 0-18.4-3.7-40.7-10.2-61.2l-5-15.8 16.3 2.6c25.3 4 42.8 10.9 55.2 21.7 15 13.2 22.4 32 22.4 57.3 0 40.2-12.6 76.6-35.6 102.5-24.8 28.1-60.3 43-102.2 43-65.6 0-118.8-45.1-137.8-115.7a653 653 0 0 1-48.8 0c-18.9 70.7-72.3 115.9-137.8 115.9"/><path d="M400 106.5c33.5 20.5 80.6 56.7 103.2 89.2A147.2 147.2 0 0 1 563 315c0 48.3-28.5 117.6-44.4 161.9 9.7 5.4 15.2 10.2 15.2 16.2l-7.2 55.4c0 9.5-21 17.9-53.1 23.1 13.2 44.7 45.9 78.4 84.2 78.4 45.7 0 73.9-38.9 73.9-97.8 0-20-4-43.3-10.7-64.3 41.5 6.6 68.9 21.9 68.9 68.9 0 75-45.6 135.2-127.5 135.2-62 0-113.6-43.6-129.9-116.1a560.9 560.9 0 0 1-64.8 0c-16.3 72.5-68 116.1-130 116.1-82 0-127.5-60.3-127.5-135.2 0-47 27.4-62.4 68.9-68.9-6.6 21-10.7 44.4-10.7 64.3 0 59 28.1 97.8 73.9 97.8 38.3 0 71.1-33.7 84.2-78.4-32.2-5.2-53.1-13.5-53.1-23L266 493c0-5.9 5.5-10.8 15.2-16.2-15.8-44.3-44.4-113.3-44.4-161.6 0-51 23.9-92.2 59.7-119.7 22.9-32.4 70-68.4 103.5-89m0-24L389.3 89c-34 20.8-82 57.1-107.6 92.1a168.1 168.1 0 0 0-65.2 134c0 44.7 21.1 101.6 37.9 147.3l2.4 6.5a32.7 32.7 0 0 0-11.2 24.1v1.3l.2 1.3 7.1 54.8c.3 5.7 2.7 13.4 10.6 20.4 4.2 3.8 9.5 6.9 16.4 9.8 5.3 2.3 11.6 4.3 18.6 6.2a96.8 96.8 0 0 1-18.9 26c-8 7.7-21 16.7-37.4 16.7a44.7 44.7 0 0 1-37.6-18.6c-10.3-13.8-15.7-34.1-15.7-58.8 0-17.4 3.6-38.6 9.7-58.1l9.9-31.6-32.7 5.2c-37 5.8-86.2 21.5-86.2 89.2 0 21.1 3.2 41 9.5 59.4 6.5 19 16.2 35.7 28.7 49.9 26.9 30.3 64.9 46.4 109.8 46.4 67.9 0 123.4-44.7 145.5-115.5l16.8.2 16.8-.2c21.9 70.7 77.5 115.5 145.5 115.5 45 0 82.9-16.1 109.8-46.4a147.5 147.5 0 0 0 28.7-49.9c6.3-18.4 9.5-38.3 9.5-59.4 0-67.7-49.1-83.4-86.2-89.2l-32.7-5.2 9.9 31.6c6.2 19.5 9.7 40.7 9.7 58.1 0 24.6-5.4 44.9-15.7 58.8a44.7 44.7 0 0 1-37.6 18.6 55.5 55.5 0 0 1-37.4-16.7c-7.2-7-13.7-15.9-18.9-26a128 128 0 0 0 18.7-6.2 60.4 60.4 0 0 0 16.4-9.9 29.8 29.8 0 0 0 10.6-20.4l7.1-54.5.1-1.3v-1.4c0-10.6-5.2-18.4-11.1-24 .6-1.9 1.4-3.9 2.2-5.8 17-46 38-103.4 38-148.3 0-52.9-23.1-100.2-65.2-133.6A277.9 277.9 0 0 0 469 131a534.5 534.5 0 0 0-58.1-41.9z"/><g fill="none" stroke="#f2f2f2" stroke-miterlimit="10"><g stroke-width="15.1"><g stroke-linecap="round"><path d="m526.7 548.5 7.2-55.7c0-19.7-58.9-35.2-133.9-35.2-75.1 0-133.9 15.7-133.9 35.2l7.2 55.7"/><path d="M273.4 548.5c0-15.7 56.7-28.4 126.7-28.4s126.7 12.7 126.7 28.4"/><path d="M169.8 491.2c-35.2 6.3-58.5 21.1-58.5 66.2 0 72 38.8 126.7 108.3 126.7 63.3 0 113.9-59.6 114.8-156.2m295.7-36.7c35.2 6.3 58.5 21.1 58.5 66.2 0 72-38.8 126.7-108.3 126.7-63.3 0-113.9-59.6-114.8-156.2"/></g><path d="M518.7 476.2c15.8-44.7 44.4-113.3 44.4-161.6 0-51-23.9-92.1-59.7-119.7-22.7-32.4-69.9-69-103.2-89.5-33.5 20.5-80.6 57.1-103.2 89.5a147.6 147.6 0 0 0-59.7 119.7c0 48.3 28.6 116.9 44.4 161.6"/></g><path stroke-width="17.3" d="M398.9 211.2v184.7m-69.1-94h140.4"/></g></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" style="color-scheme:light only" viewBox="0 0 800 800"><path d="M694.7 363.2a60.8 60.8 0 0 0-38-11.2h-1.4l-6.5.1-8.5.1c3.3-23.7.6-41.6-8-55.5a55.5 55.5 0 0 0-27.8-22.9 90.3 90.3 0 0 0-33.6-5.4 301 301 0 0 0-38.8 3.2 59.1 59.1 0 0 0-18.7-33.5 48.9 48.9 0 0 0-33-12.5c-24.1 0-47.2 16.3-65.9 29.6v-80.7h49.7v-30.2h-49.7V94.6h-30.2v49.7h-46.4v30.2h46.4v78.9c-18.3-13-40.4-28-63.5-28a48.9 48.9 0 0 0-33 12.5 59.4 59.4 0 0 0-18.7 33.5 304 304 0 0 0-38.9-3.2 91.3 91.3 0 0 0-33.7 5.4 55.5 55.5 0 0 0-27.8 22.9c-8.6 14-11.3 32-8.1 55.5l-8.2-.1-7-.1H144a64 64 0 0 0-38.2 11.1 46.8 46.8 0 0 0-18.6 39.4c0 26.1 18.3 53.8 37.5 83.2l5.7 8.7 25.6 53.9a39 39 0 0 0-11.3 26.8v2.2l.4 2.2 15.7 64.6c1.1 12.9 10.2 24 27 32.9a216.7 216.7 0 0 0 52.1 17.5c43.1 9.7 100.1 15 160.6 15 60 0 116.7-5.3 159.7-14.8a224 224 0 0 0 52.1-17.3c17.1-8.9 26.5-19.9 27.9-32.6l14.7-65.7.6-1.9v-2.1c0-9.4-4-18.1-11.6-26.1l23.8-52.6 7.3-11c19.4-29.2 37.8-56.7 37.8-82.7a47 47 0 0 0-18.1-39.3"/><g stroke="#f2f2f2" stroke-linecap="round" stroke-miterlimit="10"><g fill="none" stroke-width="15.1"><path d="M182 371.5c-11.9.3-25.9-.1-37.5-.3-21.9-.3-39.4 8.7-39.4 32.2 0 39.1 60.6 95.7 73.3 146.7"/><path d="M284.1 293.5c-45.6-6.3-83.3-13.8-100.3 13.7-27 43.6 35.6 150.7 55.1 222.4"/><path d="M398.9 286.8c-24.3-9-67.9-61.7-99.8-33.9-43.6 37.9 22.9 173.9 38.1 260.5m280.7-141.9c11.9.3 25.9-.1 37.5-.3 21.9-.3 39.4 8.7 39.4 32.2 0 39.1-60.6 95.7-73.3 146.7"/><path d="M515.9 293.5c45.6-6.3 83.3-13.8 100.3 13.7 27 43.6-35.6 150.7-55.1 222.4"/><path d="M401.1 286.8c24.3-9 67.9-61.7 99.8-33.9 43.6 37.9-22.9 173.9-38.1 260.5"/></g><path stroke-width="13" d="M177.9 554c34-23.3 120.6-39.6 222-39.6 102.5 0 190.3 16.3 223.6 40l31.8-78.1c-11.8 13.1-23.7 18.5-33.9 15.3-26.9-8.4-26.5-40.6-24.4-66.5-16.4 29.5-39.3 44.7-63.9 39.5-24.9-5.3-40-40.5-39-68.6-17.5 35.6-49.4 54.1-94.1 54.1s-76.6-18.6-94.1-54.2c1.1 28.1-14 63.3-39 68.6-24.6 5.2-47.4-10-63.9-39.5 1.9 25.9 2.6 58.5-24.4 66.5-9.1 2.7-22.1-3.8-33.9-15.3z"/><g fill="none"><path stroke-width="13" d="M162.5 575.9c0-34.3 106.3-61.7 237.4-61.7s237.4 27.3 237.4 61.7m-475 1.8 16.2 65.1m459.2-65.1-16.2 65.1"/><path stroke-width="15.1" d="M178.6 642.2c0-27.4 99.1-49.7 221.4-49.7s221.4 22.2 221.4 49.7"/><path stroke-width="6.5" d="M400 286.8v160.9"/></g></g></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" style="color-scheme:light only" viewBox="0 0 800 800"><path d="M137.8 605.9c8-43 28.9-151 43.3-212v-40l.5-1.6c13.9-41.9 33.6-85.5 44.1-109l2.6-5.7c-11.9-19-15.2-45.7-18.4-69.4-1.3-9.9-2.5-19.3-4.2-27.3l-2.9-13.7 14 .8a106.4 106.4 0 0 1 83.6 51.9c-5.9-21.6-.9-49.4 14.4-72.1l5.8-8.7 9.4 4.8a81 81 0 0 1 42.6 68.7c10.7-1.1 21.2-1.6 31.1-1.6 16.7 0 32 1.5 45.3 4.5 53 12 139.4 55 200.9 99.9l4.4 3.2v428.3H334.8L350 689c66.7-78.1 102.1-153 102.1-216.9 0-22-3.9-43.7-11.1-63.5 1.4 21.1-1 41.3-7.5 59.1-12.6 34.9-49.1 55.3-70.8 64.7a508.8 508.8 0 0 0-61.3 74l-25.9 67.4-139.1-59.3z"/><path fill="#f2f2f2" d="M177.4 578.1c19.4-10.9 33.9 3.8 22.4 25.2zm51.6-217c-6.9 9.5-.6 29.3 6 34.5a50 50 0 0 0 18.6-8l21.5-24.9-4.5-3.7c-8.7-.1-30.7-.1-41.6 2.1m90.8-160.8.6-2.9c31.1-5.1 73-6 97.3-6 45.7 0 148 58.8 209.5 104.1v55.1c-45-43.5-141.2-118.8-196.3-135.8a335.3 335.3 0 0 0-111.1-14.5"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" style="color-scheme:light only" viewBox="0 0 800 800"><path d="M489.3 418.3c.1-17 12-25.1 65.1-29.9V330h-90.7c32-19.5 52.8-53.5 52.8-92.9 0-61.9-51.3-110.4-116.7-110.4s-116.7 48.5-116.7 110.4a108 108 0 0 0 52.8 92.9h-91.4v58.4c53.7 4.8 65.2 12.9 65.2 29.8 0 60.7-37.6 131.5-115.5 200.1l.1 64c0 18.6 93.2 32 205.3 32s205.4-13.5 205.4-32l.1-64c-77.9-68.5-115.8-139.4-115.8-200"/><path fill="none" stroke="#f2f2f2" stroke-linecap="round" stroke-miterlimit="10" stroke-width="19.4" d="M232.1 627.2c0 13.7 76 25.7 167.6 25.7 91.5 0 167.4-11 167.4-24.6"/><path fill="none" stroke="#f2f2f2" stroke-linecap="round" stroke-miterlimit="10" stroke-width="16.2" d="M400.9 150.3c-24-.2-47.6 8.6-66.3 27.3a87.5 87.5 0 0 0-20 95.3"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" style="color-scheme:light only" viewBox="0 0 800 800"><path d="M400 704.4c-62 0-120.5-5.5-164.8-15.4-49.7-11.1-74.2-25.8-75-44.8L150 587v-.9c0-5.4 1.6-13.2 8.6-21.4-7.5-43.1-24.6-117.2-44.6-192.2a47.5 47.5 0 0 1-30.8-44.6 47.7 47.7 0 1 1 95.4 0c0 11.1-3.7 21.5-10.6 29.9 21.1 67.1 46.9 136.7 64.9 175.2l5.3-1.3c-.5-55.3-9.1-164.3-20.6-261.6-18.6-8-31-26.6-31-47.1 0-28.3 23-51.3 51.3-51.3a51.34 51.34 0 0 1 36.3 87.6c17.9 93.5 47.7 211.6 65.3 258.9l5.4-.3c8.6-66 19.4-184.5 24.6-294.5a53.24 53.24 0 0 1-23.8-44.5c0-29.8 24.2-54 54-54a54.03 54.03 0 0 1 30.5 98.6c5.2 110.1 16 228.5 24.6 294.5l5.4.3c17.6-47.3 47.4-165.3 65.3-258.9a51.34 51.34 0 0 1 36.3-87.6c28.3 0 51.3 23 51.3 51.3 0 20.6-12.4 39.1-31 47.1-11.6 97.3-20.2 206.3-20.6 261.6l5.3 1.3c18-38.4 43.8-108.1 64.9-175.2a46.88 46.88 0 0 1-10.6-29.9 47.7 47.7 0 1 1 95.4 0 47.5 47.5 0 0 1-30.8 44.6c-19.9 75.1-37 149.1-44.6 192.2 5.7 6.7 8.6 13.8 8.6 21.3v.9l-10.3 57.7c-1.2 18.9-25.7 33.5-75 44.6-43.8 9.6-102.3 15.1-164.4 15.1m-185-71.7c10.8 7.2 34.9 14.3 66.2 19.1 34.3 5.4 75.4 8.2 118.8 8.2 98 0 163.9-13.8 184.2-26.9l3.3-2.9c-4.2-4.2-17-11.4-48.7-17.7-35.3-6.9-84.7-10.7-138.9-10.7-50.3 0-98.2 3.9-134.6 11-33.2 6.5-47.5 13.8-52.6 17.9z"/><path d="M400 134.5c24.4 0 44.3 19.9 44.3 44.3 0 17.1-9.6 31.9-23.8 39.2 5.1 114 16.8 242.6 26 309.1l20.4 1.3c18.1-44.7 51.4-176.8 69.4-272.6a41.38 41.38 0 0 1-15.9-32.7c0-23 18.6-41.6 41.6-41.6s41.6 18.6 41.6 41.6c0 19-12.7 35-30.2 40-12.3 102.1-21.8 222-21.5 276.4l20.4 4.9c20.3-41.1 49.7-121.9 70.4-188.9a37.98 37.98 0 0 1 26.2-65.5 37.98 37.98 0 0 1 8.9 74.9c-20.8 78-39.5 158.2-47 203.1 6 5.7 9.4 11.8 9.4 18.1l-10.2 57-.1.1c0 28.5-103 51.6-230 51.6-127.1 0-230-23.4-230-51.5l-10.2-57.1c0-6.3 3.2-12.5 9.4-18.3-7.5-44.9-26.1-125.2-47-203.1-16.6-4-29.1-19-29.1-36.9 0-21 17-38 38-38 21.1 0 38 17 38 38 0 10.8-4.5 20.6-11.8 27.5 20.8 67 50.1 147.9 70.4 188.9l20.4-4.9c.3-54.2-9.2-174.3-21.5-276.4-17.4-5-30.2-21-30.2-40 0-23 18.6-41.6 41.6-41.6s41.6 18.6 41.6 41.6c0 13.3-6.3 25.1-15.9 32.7 18 95.8 51.2 227.9 69.4 272.6l20.4-1.3c9.1-66.5 20.8-195 26-309.1-14.1-7.5-23.8-22.1-23.8-39.2.1-24.3 20-44.2 44.4-44.2m0 535.3c89.4 0 165-12.1 190.2-28.8l9-7.9c0-25.6-89.1-41-199-41s-199 18.4-199 41l8.1 7.2c24 17 100.3 29.5 190.7 29.5m0-554.7a63.8 63.8 0 0 0-63.7 63.7c0 19.3 8.6 37.3 23.2 49.2-4.4 92.8-12.9 190.8-20.5 257.8-17-55.4-39.6-148.1-54.3-223.5a60.77 60.77 0 0 0 14.4-39.3c0-33.7-27.3-61-61-61s-61 27.3-61 61c0 13.5 4.3 26.4 12.6 37.2 5.2 6.7 11.7 12.3 19.1 16.4 9.1 77.4 16.2 161.5 18.9 219.2-15.3-37.6-33.2-87.4-48.6-136.3 6.2-9.3 9.6-20.4 9.6-31.6 0-31.6-25.8-57.5-57.5-57.5-31.6 0-57.6 25.7-57.6 57.3 0 22.5 12.9 42.2 32.3 51.6 18.7 70.8 34.8 140.2 42.4 182.6-6.5 9.1-8.1 17.7-8.1 24v1.7l.3 1.7 9.9 55.8c.4 5.7 2.6 13.5 9.7 21.4 4.6 5 10.7 9.4 18.7 13.6 13.1 6.9 31.3 13.1 54.1 18.1 45 10.2 104.3 15.7 167 15.7 62.9 0 122.1-5.5 167-15.7 22.8-5.1 41-11.2 54.1-18.3 8-4.2 14-8.7 18.7-13.7 4.1-4.5 6.6-9.1 8-13.2l.3-.3.3-1.8c.5-1.9.9-3.8 1-5.5l10-56.4.3-1.7V586c0-6.2-1.6-14.8-8.1-23.9 7.6-42.4 23.8-111.8 42.4-182.6a57.4 57.4 0 0 0 32.3-51.6c0-31.6-25.8-57.5-57.5-57.5-31.6 0-57.5 25.8-57.5 57.5 0 11.3 3.3 22.4 9.6 31.6-15.4 48.9-33.4 98.7-48.6 136.3 2.7-57.8 9.8-141.8 18.9-219.2 7.3-4.1 13.9-9.6 19.1-16.4a60.54 60.54 0 0 0 12.6-37.2c0-33.7-27.3-61-61-61s-61 27.3-61 61c0 14.6 5.1 28.4 14.4 39.3-14.7 75.4-37.4 168-54.3 223.5-7.7-67-16.1-164.9-20.5-257.8a63.37 63.37 0 0 0 23.1-49.1c.2-35.2-28.4-63.8-63.5-63.8M234 631c8.5-3 21.6-6.7 41.3-10 34.7-6 78.9-9.3 124.7-9.3 48 0 91.2 2.9 125.1 8.4 21.1 3.5 34.3 7.1 42.7 10.4-9.5 3.6-25.5 8.1-51.5 12.1-33.7 5.2-73.9 7.9-116.2 7.9-42.9 0-83.4-2.8-117.3-8.1-24-3.8-39.2-8.1-48.8-11.4"/><circle cx="400" cy="112.9" r="25.9"/><circle cx="225" cy="164.8" r="23"/><circle cx="113.8" cy="273.8" r="22.4"/><circle cx="575" cy="164.8" r="23"/><circle cx="687.3" cy="273.8" r="22.4"/><g fill="none" stroke="#f2f2f2" stroke-linecap="round" stroke-miterlimit="10"><path stroke-width="17.28" d="m117.6 287.9-4.5-15"/><path stroke-width="18.36" d="m228.8 180.7-4.4-18"/><path stroke-width="17.28" d="m682.7 287.9 4.6-15"/><path stroke-width="18.36" d="m570.4 180.7 4.6-18"/><path stroke-width="20.52" d="M399.5 108.6V127"/><g stroke-linejoin="round" stroke-width="15.12"><path d="M263.7 255.8c9.7-7.6 15.9-19.4 15.9-32.7 0-23-18.6-41.6-41.6-41.6s-41.6 18.6-41.6 41.6c0 19 12.7 35 30.2 40 12.3 102.1 21.8 215.6 21.5 269.9l-20.4 4.9M420.5 218c14.1-7.5 23.8-22.1 23.8-39.2 0-24.4-19.9-44.3-44.3-44.3s-44.3 19.9-44.3 44.3c0 17.1 9.6 31.9 23.8 39.2-5.1 114-16.8 236.1-26 302.6l-20.4 1.3M573.3 263c17.4-5 30.2-21 30.2-40 0-23-18.6-41.6-41.6-41.6S520.3 200 520.3 223c0 13.3 6.3 25.1 15.9 32.7-18 95.8-51.2 221.4-69.4 266.1l-20.4-1.3m231.5-155.7c16.6-4 29.1-19 29.1-36.9 0-21-17-38-38-38a37.98 37.98 0 0 0-26.2 65.5c-20.8 67-50.1 141.4-70.4 182.4l-20.4-4.9"/><path d="M157.2 355.4a37.98 37.98 0 0 0-26.2-65.5c-21.1 0-38 17-38 38 0 17.9 12.4 32.9 29.1 36.9 20.8 78 39.5 155 47 199.9-6 5.7-9.4 11.8-9.4 18.1l10.2 60.3c0-28.5 103-51.6 230.1-51.6s230.1 23.1 230.1 51.6l10.2-60.3c0-6.3-3.2-12.3-9.4-18.1"/></g></g></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" style="color-scheme:light only" viewBox="0 0 800 800"><path d="M398.3 708.2c-56.2 0-108.6-5.8-148.3-16.5-44.6-12.1-67.1-28.4-67.1-48.5V582c0-28.3 43.1-46.1 73.2-55.2l7.9-172.5a28.5 28.5 0 0 1-7.2-8.2l-.2-.3-24.3-52.8c-7-3.5-19-10.7-19-22.2V145.3h73.4v50.8h42.1v-50.2l135 .1v50.1h42.1v-50.8h74.5v125.4c0 7.7-4.1 14.4-12.7 19.8L544.1 346l-.4.8q-3.15 4.65-8.4 8.7l7.5 171.1a196 196 0 0 1 50.5 21.6c16.2 10.5 24.3 21.8 24.3 34l-.1 2.5-.1 58.8c0 20.2-23 36.5-68.4 48.5-40.4 10.3-93.8 16.2-150.7 16.2"/><path d="M570.6 155v115.7c0 4.5-3.1 9-10.7 13.1l-24.5 57.7a30.7 30.7 0 0 1-10.2 9l8 183.5c46.3 12.5 74.6 30.8 74.6 48.2l-.1 1.9-.1 59.3c0 30.6-93.1 55.4-209.2 55.4-114.5 0-205.8-24.7-205.8-55.4v-61.2c0-17.2 27.4-35.3 72.8-47.8l8.4-184.7c-4-2.6-7.1-5.3-8.7-8.1l-25.6-55.7c-10.2-4.5-16.6-9.6-16.6-15V155h54v50.8h61.6v-50.3l115.6.1v50.2h61.6V155zm19.5-19.4h-92.9v50.7h-23.8v-50h-19.3l-115-.1H320v50.1h-23.8v-50.7h-92.7v135.1c0 15.3 12.4 24.4 21.3 29.3l22.8 49.5.3.8.4.8c1.5 2.7 3.5 5.2 5.7 7.5l-7.3 161.1a191.2 191.2 0 0 0-45.4 20.6c-12.9 8.4-28.1 22.6-28.1 41.8v61.2c0 18 13.5 30.6 24.8 37.8 12 7.7 28.5 14.4 49.4 20 40.4 10.9 94 16.8 151 16.8 57.7 0 112.1-6 153.1-16.8a183.8 183.8 0 0 0 50.1-20c11.6-7.2 25.4-19.8 25.4-37.9l.1-58.2.1-3c0-8.6-3.1-17.1-9.2-24.9a76 76 0 0 0-19.5-17.2 192.4 192.4 0 0 0-46.4-20.7l-6.9-159.5c2.7-2.5 4.9-5.1 6.7-7.8l1-1.4.6-1.5 22.1-52.1c12.1-8.6 14.7-18.8 14.7-26.1V135.6z"/><g fill="none" stroke="#f2f2f2" stroke-linecap="round" stroke-miterlimit="10"><path stroke-linejoin="round" stroke-width="16.1" d="M606.1 586.6c-8.9 28.4-96.9 50.8-205.4 50.8-114.5 0-208.3-25.1-208.3-55.6"/><path stroke-width="13" d="M534.3 541.2c0 19.7-60.9 33.9-134.4 33.9h.1c-73.4 0-134.4-14.3-134.4-33.9l6.5-190.7"/><path stroke-width="10.8" d="M534.6 341.3C525.2 357.9 472.9 370 400 370h.1c-72.9 0-125.3-12.1-134.7-28.6l-24.5-54.5"/><path stroke-width="15.1" d="M572.8 270.6V154h-57.2v50.8H454v-49.1l-115.6-.1v49.2h-61.6V154H224v116.6c0 20.1 81.5 36.4 175.3 36.4 93.7 0 173.5-16.2 173.5-36.4z"/></g></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" style="color-scheme:light only" viewBox="0 0 800 800"><path fill="#fff" d="M238.9 701.8c-41.8 0-77-14.8-101.8-42.8-22.9-25.8-35.5-62.1-35.5-102.2 0-58.9 41.1-72.9 77.1-78.5l15.6-2.5-4.8 15c-6.5 20.5-10.2 43-10.2 61.3 0 54.3 24.5 88.1 64.2 88.1 29.5 0 57.2-24.2 71.4-61.1-33-6.6-49.5-16.4-50.1-29.9l-7.3-55.7v-.5c0-9.1 5.9-15.3 13.2-20.3l-5.1-13.8C249 414 228.3 358.1 228.3 315.3c0-50.2 22.1-95.1 62.3-126.5 22.9-32 69.2-68.5 105.3-90.6l5.1-3.1 5.1 3.1c36 22 82.3 58.5 105.3 90.7a157.2 157.2 0 0 1 62.3 126c0 43-20.7 99.4-37.4 144.6l-4.9 13.2c7.2 5 13.2 11.3 13.2 20.3v.6l-7.2 55.5c-.5 13.5-17 23.4-50 29.9 14.1 36.9 41.9 61.1 71.4 61.1 39.5 0 64.2-33.8 64.2-88.1 0-18.5-3.7-40.8-10.2-61.3l-4.8-15 15.6 2.5c25.2 4 42.6 10.8 54.9 21.6 14.9 13.1 22.2 31.8 22.2 56.9 0 40.1-12.6 76.4-35.5 102.2-24.8 28-59.9 42.8-101.8 42.8-65.4 0-118.6-45.1-137.4-115.8a679.3 679.3 0 0 1-49.6 0c-19 70.8-72.1 115.9-137.5 115.9"/><path d="M401.1 106.4c33.5 20.5 80.6 56.8 103.2 89.3A147 147 0 0 1 564 315c0 48.3-28.5 117.6-44.4 161.9 9.7 5.4 15.2 10.2 15.2 16.2l-7.2 55.4c0 9.5-21 17.9-53.1 23.1 13.2 44.7 45.9 78.4 84.2 78.4 45.7 0 73.9-38.9 73.9-97.8 0-20-4-43.3-10.7-64.3 41.5 6.6 68.9 21.9 68.9 68.9 0 75-45.6 135.2-127.5 135.2-62 0-113.6-43.6-129.9-116.1a560.9 560.9 0 0 1-64.8 0c-16.2 72.5-68 116.1-130 116.1-82 0-127.5-60.3-127.5-135.2 0-47 27.4-62.4 68.9-68.9-6.6 21-10.7 44.4-10.7 64.3 0 59 28.1 97.8 73.9 97.8 38.3 0 71.1-33.7 84.2-78.4-32.2-5.2-53.1-13.5-53.1-23L267 493c0-5.9 5.5-10.8 15.2-16.2-15.8-44.3-44.4-113.3-44.4-161.6 0-51 23.9-92.2 59.7-119.7 23-32.4 70.1-68.5 103.6-89.1m0-22.8-10.2 6.3C357 110.6 309.1 147 283.6 182a167.3 167.3 0 0 0-65 133.3c0 44.6 21 101.3 37.8 147l2.6 7.1a31.7 31.7 0 0 0-11.3 23.7v1.3l.2 1.3 7.1 54.8c.3 5.5 2.6 12.9 10.3 19.8 4.1 3.7 9.4 6.8 16.1 9.7 5.6 2.4 12.2 4.5 19.8 6.5a97.2 97.2 0 0 1-19.7 27.4 56 56 0 0 1-38.1 17.1 45.4 45.4 0 0 1-38.4-19c-10.4-13.9-15.9-34.6-15.9-59.4 0-17.5 3.6-38.8 9.7-58.4l9.4-29.9-31 4.9c-27.1 4.3-46 11.9-59.7 23.9-17 14.8-25.5 36.4-25.5 64.3 0 21 3.1 40.8 9.5 59.1a146.7 146.7 0 0 0 28.5 49.6c26.7 30.1 64.4 46 109.1 46 67.6 0 123-44.8 144.6-115.5a513.5 513.5 0 0 0 35.2 0c21.7 70.6 77 115.5 144.6 115.5 44.7 0 82.4-15.9 109.1-46 12.4-14 22-30.7 28.5-49.6 6.3-18.3 9.5-38.1 9.5-59.1 0-27.8-8.5-49.5-25.5-64.3-13.7-12-32.7-19.7-59.7-23.9l-31-4.9 9.4 29.9c6.2 19.7 9.7 40.9 9.7 58.4 0 24.8-5.5 45.5-15.9 59.4a45.7 45.7 0 0 1-38.4 19 56 56 0 0 1-38.1-17.1 96.3 96.3 0 0 1-19.7-27.5c7.6-1.9 14.1-4.1 19.8-6.5 6.7-2.9 12-6 16.1-9.7a28.7 28.7 0 0 0 10.2-19.8l7.1-54.5.1-1.3v-1.4c0-10.5-5.3-18.1-11.3-23.7l2.4-6.5c17-46 38-103.1 38-148a166 166 0 0 0-65-132.8 291.4 291.4 0 0 0-49.4-50.4 510.2 510.2 0 0 0-58-41.8z"/><g stroke="#000" stroke-miterlimit="10"><path fill="none" stroke-linecap="round" stroke-width="16.9" d="m527.8 548.5 7.2-55.7c0-19.7-58.9-35.2-133.9-35.2-75.1 0-133.9 15.7-133.9 35.2l7.2 55.7"/><ellipse cx="401.1" cy="548.5" fill="none" stroke-linecap="round" stroke-width="15.9" rx="126.7" ry="28.4"/><path fill="#fff" stroke-width="18.4" d="M333.9 527.1c0 64.4-40.4 121.9-90.5 121.9-45.7 0-73.9-38.9-73.9-97.8 0-20 4-43.3 10.7-64.3-41.5 6.6-68.9 21.9-68.9 68.9 0 75 45.6 135.2 127.5 135.2 74.5 0 134.2-63.2 135.2-163.6m94.4-.3c0 64.4 40.4 121.9 90.5 121.9 45.7 0 73.9-38.9 73.9-97.8 0-20-4-43.3-10.7-64.3 41.5 6.6 68.9 21.9 68.9 68.9 0 75-45.6 135.2-127.5 135.2-74.5 0-134.2-63.2-135.2-163.6"/><g fill="none"><path stroke-width="20.5" d="M519.8 476.2c15.8-44.7 44.4-113.3 44.4-161.6 0-51-23.9-92.4-59.7-119.9-22.7-32.4-69.9-69.2-103.2-89.7-33.5 20.5-80.6 57.2-103.2 89.7a147.9 147.9 0 0 0-59.7 119.8c0 48.3 28.6 117 44.4 161.7"/><path stroke-width="19.4" d="M401.1 211.2v184.7m-70.2-94h140.4"/></g></g></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" style="color-scheme:light only" viewBox="0 0 800 800"><path fill="#fff" d="M405.3 700c-59.8 0-116.1-5.3-158.7-14.8-47.4-10.6-71.1-24.6-72.1-42.8L158.1 576v-1.1c0-9.1 5.2-18 15.2-26.5-6.2-21.9-21.6-45.5-36.6-68.4-18.4-28-35.7-54.4-35.7-77.7 0-25.5 18.4-41.4 47.8-41.4h1.3l7 .1 19.3.2c-5.4-26.7-3.8-45.9 5-59.9 13.3-21.5 36.9-24 53.6-24 14.1 0 29.8 2.1 46.4 4.4a54.4 54.4 0 0 1 17.2-36.9c7.8-6.8 17-10.3 27-10.3 21.3 0 42.2 14.9 60.8 28 7 5 13.6 9.7 19 12.5 5.4-2.9 12.1-7.6 19-12.5 18.5-13.1 39.5-28 60.7-28a40 40 0 0 1 27 10.3 54.5 54.5 0 0 1 17.1 36.9c16.6-2.3 32.3-4.4 46.4-4.4 16.6 0 40.3 2.5 53.6 24 8.6 14 10.3 33.3 4.9 60l19.8-.2 6.5-.1h1.3c29.5 0 47.1 15.4 47.1 41.4 0 23.2-17.6 49.7-36.3 77.7-15.2 22.8-30.8 46.3-37 68.1 10.7 8.2 16.1 17.3 16.1 26.8v1.1l-15.8 67.2c-1.6 17.9-25.5 31.9-72.8 42.3A778.1 778.1 0 0 1 405.3 700"/><path d="M325.6 243.8c28.2 0 60.4 33.6 79.8 41.5 19.4-8 51.6-41.5 79.8-41.5 7.3 0 14.5 2.3 21.1 8 10.2 8.9 14.3 22.9 14.6 40.4 20-2.8 38.8-5.7 55-5.7 20.2 0 36.4 4.5 45.8 19.7s7.9 38 1.2 64.3l7.8.1c10.3 0 21.7-.2 30.1-.3h1.2c21.4 0 37.9 9.2 37.9 32.2 0 39.1-60.9 95.7-73.9 146.7v3.3c10.8 6.9 16.5 14.5 16.5 22.5l-15.6 66.9c-1.3 27.2-100 49.1-221.5 49.1-122.3 0-221.5-22.2-221.5-49.7L167.6 575c0-7.9 5.8-15.6 15.6-22.5v-3.6c-11.9-51-72.8-107.6-72.8-146.7 0-23 17.3-32.2 38.7-32.2h1.2c8.5.1 20.3.4 30.6.4l7.5-.1c-6.7-26.4-8.1-49.1 1.3-64.3 9.4-15.1 25.5-19.7 45.8-19.7 16.2 0 35 2.9 55 5.7.3-17.5 4.4-31.6 14.6-40.4 6-5.5 13.1-7.8 20.5-7.8m0-18.3a48.9 48.9 0 0 0-33 12.5 59.4 59.4 0 0 0-18.7 33.5 304 304 0 0 0-38.9-3.2 91.3 91.3 0 0 0-33.7 5.4 55.5 55.5 0 0 0-27.8 22.9c-8.6 14-11.3 32-8.1 55.5l-8.2-.1-7-.1h-1.4a63.8 63.8 0 0 0-38.2 11 47 47 0 0 0-18.8 39.4c0 26 18.1 53.6 37.3 82.7 13.4 20.4 27.3 41.4 34 60.5a41 41 0 0 0-13.6 29.4v2.3l.3 2.2 15.8 64.6c1.1 12.9 10.2 24 27 32.8a216.7 216.7 0 0 0 52.1 17.5c43.1 9.6 100.2 15 160.6 15 60 0 116.7-5.3 159.7-14.8a222.3 222.3 0 0 0 52.1-17.3c17-8.9 26.4-19.8 27.8-32.5l15.1-65.6.3-2.1v-2.2c0-10.8-4.8-20.7-14.4-29.8 6.8-19 20.7-39.9 34.2-60 19.4-29.2 37.8-56.7 37.8-82.7 0-21.1-9.8-33-18.1-39.3a61 61 0 0 0-38-11.2h-1.4l-6.5.1-8.7.1c3.3-23.5.6-41.6-8-55.5a55.5 55.5 0 0 0-27.8-22.9 90.3 90.3 0 0 0-33.6-5.4 304 304 0 0 0-38.9 3.2 59.7 59.7 0 0 0-18.7-33.5 48.9 48.9 0 0 0-33-12.5c-24.2 0-47.4 16.4-66.1 29.7-4.8 3.3-9.6 6.8-13.7 9.4-4.1-2.6-9-6-13.7-9.4-18.7-13.2-41.9-29.6-66.1-29.6"/><g stroke="#000" stroke-miterlimit="10"><g fill="none" stroke-linecap="round"><g stroke-width="18.4"><path d="M187.4 370.4c-11.9.3-25.9-.1-37.5-.3-21.9-.3-39.4 8.7-39.4 32.2 0 39.1 60.6 95.7 73.3 146.7"/><path d="M289.5 292.4c-45.6-6.3-83.3-13.8-100.3 13.7-27 43.6 35.6 150.7 55.1 222.4"/><path d="M404.3 285.7c-24.3-9-67.9-61.7-99.8-33.9-43.6 37.9 22.9 173.9 38.1 260.5m280.7-141.9c11.9.3 25.9-.1 37.5-.3 21.9-.3 39.4 8.7 39.4 32.2 0 39.1-60.6 95.7-73.3 146.7"/><path d="M521.3 292.4c45.6-6.3 83.3-13.8 100.3 13.7 27 43.6-35.6 150.7-55.1 222.4"/><path d="M406.5 285.7c24.3-9 67.9-61.7 99.8-33.9 43.6 37.9-22.9 173.9-38.1 260.5"/></g><path stroke-width="15.1" d="M405.4 271.7v184.5"/></g><path fill="#fff" stroke-width="18.4" d="m632.9 552.5 27.8-68.8c-11.1 14.6-24.8 16.8-33.9 14.3-27-8-26.5-40.6-24.4-66.5-16.4 29.5-39.3 44.7-63.9 39.5-24.9-5.3-40-40.5-39-68.6-17.5 35.6-49.4 54.1-94.1 54.1s-76.6-18.5-94.1-54.1c1.1 28.1-14 63.3-39 68.6-24.6 5.2-47.4-10-63.9-39.5 1.9 25.9 2.6 58.5-24.4 66.5-9.1 2.7-22.9.3-33.9-14.3l27.8 68.8"/><g fill="none"><g stroke-linecap="round" stroke-width="21.6"><path d="M167.9 574.8c0-34.3 106.3-61.7 237.4-61.7s237.4 27.3 237.4 61.7m-475 1.9 16.2 65m459.2-65-16.2 65"/><ellipse cx="405.4" cy="641.1" rx="221.4" ry="49.7"/></g><path stroke-width="30.2" d="M404.3 94.6v189m-61.5-124.2H468"/></g></g></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" style="color-scheme:light only" viewBox="0 0 800 800"><g stroke="#000" stroke-miterlimit="10"><path fill="#fff" stroke-linecap="round" stroke-width="29.2" d="M645.3 692.2V280.6C588 238.8 503 194.8 448.7 182.5c-42.6-9.7-101.3-2.1-148.6 13.2a96 96 0 0 0-81.5-60.4c6.3 29.6 5.9 74.3 24.3 97.7-7 16.2-32.1 69.4-48.7 119.3v39.4a5617.5 5617.5 0 0 0-43.4 212.8L271.7 656l22.4-58.1c15.2-22.7 45.7-60.9 65-78.2 24.5-10.3 55.9-29.4 66.7-59.2 14-38.4 6.3-84.3-10.5-119.9 31 30.2 50 77 50 127.8 0 68.1-38.1 145.9-104.7 223.7h284.7z"/><path stroke-width="9.2" d="M179.8 574.8c19.4-10.9 33.9 3.8 22.4 25.2z"/><path d="M231.5 357.9c-7 9.5-.6 29.3 6 34.5a50 50 0 0 0 18.6-8l21.5-24.9-4.5-3.7a264 264 0 0 0-41.6 2.1z"/><path fill="#fff" stroke-linecap="square" stroke-width="23.8" d="M320 186c-12.4-17.9-9.8-49.7 7-75.1a68.2 68.2 0 0 1 35.2 70.6"/></g></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" style="color-scheme:light only" viewBox="0 0 800 800"><g stroke="#000" stroke-miterlimit="10"><path fill="#fff" stroke-width="30.24" d="M497.9 240c0 53.6-44 93.9-98.1 93.9s-98.1-40.3-98.1-93.9 44-93.9 98.1-93.9c54.2.1 98.1 40.4 98.1 93.9zm28.5 97.6v55.3c-30.2 3-42.8 12.1-42.8 28 0 56.9 36 123.4 109.1 187.9v60.9c0 17.4-87.5 30.8-192.7 30.8s-192.7-13.4-192.7-30.8l-.1-60.6c73.1-64.5 108.1-131.1 108.1-188 0-15.9-13.6-25.2-43.8-28.2v-55.3z"/><path fill="none" stroke-width="16.2" d="M207.2 608c0 17.4 87.5 30.3 192.7 30.3s192.6-12.9 192.6-30.2"/><path fill="none" stroke-width="14.04" d="M272.6 386.7h252.7"/></g></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" style="color-scheme:light only" viewBox="0 0 800 800"><g fill="#fff" stroke="#000" stroke-miterlimit="10"><path stroke-width="20.5" d="M401.1 704.4c-62 0-120.5-5.5-164.8-15.4-49.6-11.1-74.1-25.8-75-44.7l-10.2-54v-.9c0-5.4 1.6-13.2 8.6-21.4-8-46.5-25.9-125.2-44.6-195.5a47.7 47.7 0 1 1 54-14.7C190.2 424.9 216 494.5 234 533l5.3-1.3c-.5-55.3-9.1-164.3-20.6-261.6a51.3 51.3 0 1 1 56.6-10.8c17.9 93.5 47.7 211.6 65.3 258.9l5.4-.3c8.6-66 19.4-184.5 24.6-294.5a54 54 0 1 1 60.7 0c5.2 110.1 16 228.5 24.6 294.5l5.4.3c17.6-47.3 47.4-165.3 65.3-258.9a51.3 51.3 0 1 1 56.6 10.8c-11.6 97.3-20.2 206.3-20.6 261.6l5.3 1.3c18-38.4 43.8-108.1 64.9-175.2a47.7 47.7 0 1 1 54 14.7A2624.1 2624.1 0 0 0 642.2 568a32.5 32.5 0 0 1 8.6 21.3v.9l-10.3 54.3c-1.2 18.9-25.7 33.5-75 44.6-43.8 9.8-102.3 15.3-164.4 15.3zm-189.4-90.7c1.4 3 13.7 12.4 54.6 20.3 36.5 7 84.2 11 134.6 11 50.3 0 98.2-3.9 134.6-11 40.9-8 53.4-17.3 54.6-20.3-1.4-3-13.7-12.4-54.6-20.3-36.5-7-84.2-11-134.6-11-50.3 0-98.2 3.9-134.6 11-40.8 7.9-53.1 17.3-54.6 20.3z"/><path stroke-width="16.2" d="M592.9 607.6c-41.8 13.8-112.3 23.2-191.8 23.2-80.4 0-148.9-9.5-190.6-23.5C197 610.8 171 628.4 171 643c0 28.5 103 51.6 230 51.6s230-23.1 230-51.6c.1-14.2-24.4-31.7-38.1-35.4z"/><path stroke-width="17.3" d="M401.1 591.4c127.1 0 230.1 23.1 230.1 51.6l10.2-60.3c0-6.3-3.2-12.3-9.4-18.1 7.5-44.9 26.1-121.9 47-199.9a38 38 0 1 0-35.1-9.4c-20.8 67-50.1 141.4-70.4 182.4l-20.4-4.9c-.3-54.2 9.2-167.8 21.5-269.9a41.7 41.7 0 1 0-37.2-7.2c-18 95.8-51.2 221.4-69.4 266.1l-20.4-1.3a3816.1 3816.1 0 0 1-26-302.6 44.3 44.3 0 1 0-41 0 3723 3723 0 0 1-26 302.6l-20.4 1.3c-18.1-44.7-51.4-170.3-69.4-266.1a41.6 41.6 0 0 0-25.7-74.3 41.6 41.6 0 0 0-11.6 81.6c12.3 102 21.8 215.5 21.5 269.8l-20.4 4.9a1418.4 1418.4 0 0 1-70.4-182.4 38 38 0 1 0-35.1 9.4c20.8 78 39.5 155 47 199.9-6 5.7-9.4 11.8-9.4 18.1l10.2 60.3c0-28.5 103.1-51.6 230.2-51.6z"/></g><circle cx="401.1" cy="112.9" r="25.9"/><circle cx="226.1" cy="164.8" r="23"/><circle cx="114.9" cy="273.8" r="22.4"/><circle cx="576" cy="164.8" r="23"/><circle cx="688.4" cy="273.8" r="22.4"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" style="color-scheme:light only" viewBox="0 0 800 800"><path fill="#fff" d="M400.4 708.3c-57.1 0-110.8-5.8-151.2-16.5-45.5-12-68.5-28.4-68.5-48.6V582c0-28.4 44.1-46.2 74.7-55.3l8.1-172.4a31.9 31.9 0 0 1-7.3-8.1l-.2-.3-24.7-52.8c-19.3-9.5-19.3-19-19.3-22.2V145.3h74.5v50.8h43.2v-50.3l137.2.1v50.2H510v-50.8h75.6v125.4c0 7.8-4.2 14.5-13 19.9l-24 55.4-.4.8c-2.1 3.1-5 6-8.5 8.6l7.6 170.9c21.1 6 38.8 13.5 51.5 21.6 16.5 10.5 24.8 21.9 24.8 34.1l-.1 2.5-.1 58.6c0 20.2-23.4 36.6-69.6 48.6-40.9 10.7-95.5 16.6-153.4 16.6"/><path d="M576 155v115.7c0 4.5-3.2 9-10.9 13.1l-24.9 57.7a31.6 31.6 0 0 1-10.4 9L538 534c47.2 12.5 76 30.8 76 48.2l-.1 1.9-.1 59.3c0 30.6-95 55.4-213.3 55.4-116.6 0-210-24.7-210-55.4v-61.2c0-17.2 28.2-35.3 74.4-47.8l8.6-184.7a27.4 27.4 0 0 1-8.9-8.1l-26-55.7c-10.4-4.5-16.8-9.6-16.8-15V155H277v50.8h62.6v-50.3l117.7.1v50.2H520V155zm19.5-19.4h-95.1v50.7h-23.8v-50.2h-19.4l-117.5-.1H320v50.3h-23.8v-50.7h-93.9v135.1c0 15.4 12.5 24.6 21.6 29.5l23.1 49.4.3.8.4.8c1.5 2.6 3.5 5.1 5.8 7.5l-7.6 160.8a198.9 198.9 0 0 0-46.2 20.5 75.8 75.8 0 0 0-19.5 17.1 39.8 39.8 0 0 0-9.3 24.9v61.3c0 18.3 13.9 30.8 25.5 38a184.6 184.6 0 0 0 50.2 20c41.1 10.9 95.7 16.8 153.7 16.8 58.8 0 114-5.9 155.8-16.8a191.2 191.2 0 0 0 50.9-19.9c11.9-7.3 25.9-19.9 25.9-38.1l.1-58.2.1-3c0-8.7-3.1-17.3-9.4-25.2a80.4 80.4 0 0 0-19.9-17.2 198.6 198.6 0 0 0-47.3-20.6l-7-159.1c2.7-2.4 5-5 6.8-7.8l1-1.4.6-1.6 22.5-52.1c12.3-8.7 14.9-18.9 14.9-26.4V135.6z"/><g fill="#fff" stroke="#000" stroke-miterlimit="10"><path stroke-linecap="round" stroke-width="18.4" d="M191 581.6v61.6c0 30.6 93.1 55.4 209.6 55.4 118.3 0 213.2-24.8 213.2-55.4l-.3-61.3"/><path stroke-width="21.6" d="M613.9 582c0 30.6-94.5 55.4-211.1 55.4S191.7 612.6 191.7 582s87.5-64 211.1-64c121.6-.1 211.1 33.3 211.1 64z"/><path stroke-width="15.1" d="m275.8 260.9-10.5 280.6c0 19.7 62.1 34.1 136.8 34.1 74.8 0 136.1-14.4 136.1-33.9l-14.9-280.8z"/><path stroke-width="15.1" d="m231.3 268.1 33.8 73.1c9.5 16.5 61.6 28.5 137.6 28.5s126.4-11.7 137.6-28.5l32.8-73.1"/><path stroke-linecap="square" stroke-width="19.4" d="M522 154v50.8h-63.7v-49.1l-116.6-.1v49.2H278V154h-55.1v116.7c0 20.1 83.1 36.4 178.5 36.4 95.5 0 176.8-16.3 176.8-36.4V154z"/></g></svg>
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<?php
require_once MODULES_PATH . '/tournaments/services/PlayerEngineService.php';
/**
* The links a tournament is shared by.
*
* signup() https://<player app>/t/<slug|id>[?code=...] the sign-up link: the app
* opens the tournament with its sign-up sheet in front (P4, contract 8.3)
* live() https://<manager>/live/<slug|id> the public live page
* screen() https://<manager>/live/<slug|id>/screen the venue screen
*
* The player app's address is PLAYER_APP_URL, as the engine bridge uses, so
* the link and the app it opens always agree.
*/
class LinkService
{
public static function ref(array $t): string
{
$slug = trim((string)($t['slug'] ?? ''));
return $slug !== '' ? $slug : (string)$t['id'];
}
public static function signup(array $t, ?string $code = null): string
{
$url = PlayerEngineService::baseUrl() . '/t/' . rawurlencode(self::ref($t));
if ($code !== null && $code !== '') $url .= '?code=' . rawurlencode($code);
return $url;
}
/**
* Where this manager answers publicly. Behind the CapRover proxy the
* request arrives as plain http, so the forwarded scheme wins.
*/
public static function managerBase(): string
{
$env = getenv('MANAGER_PUBLIC_URL');
if ($env !== false && trim($env) !== '') return rtrim(trim($env), '/');
$proto = strtolower(trim(explode(',', (string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0]));
if ($proto === '') $proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : (string)($_SERVER['REQUEST_SCHEME'] ?? 'https');
$host = trim(explode(',', (string)($_SERVER['HTTP_X_FORWARDED_HOST'] ?? ''))[0]) ?: (string)($_SERVER['HTTP_HOST'] ?? 'el3ab-management.caprover.al-arcade.com');
$host = preg_replace('/[^A-Za-z0-9.:\-\[\]]/', '', $host);
// A local run (php -S) says nothing about its scheme: it is plain http.
if (preg_match('/^(127\.0\.0\.1|localhost)(:\d+)?$/', $host) && empty($_SERVER['HTTPS'])) $proto = 'http';
return ($proto === 'http' ? 'http' : 'https') . '://' . $host;
}
public static function live(array $t): string
{
return self::managerBase() . '/live/' . rawurlencode(self::ref($t));
}
public static function screen(array $t): string
{
return self::live($t) . '/screen';
}
/** Is sign-up open in the app right now (status and window)? */
public static function registrationOpen(array $t): bool
{
if (($t['status'] ?? '') !== 'registration') return false;
$now = time();
if (!empty($t['registration_opens_at']) && strtotime((string)$t['registration_opens_at']) > $now) return false;
if (!empty($t['registration_closes_at']) && strtotime((string)$t['registration_closes_at']) < $now) return false;
if (!empty($t['check_in_closed_at'])) return false;
return true;
}
/** The WhatsApp message staff paste into a group. Short, Egyptian, no emoji. */
public static function whatsappText(array $t): string
{
$name = trim((string)(($t['name_ar'] ?? '') ?: ($t['name'] ?? '')));
$lines = [$name . ' على العب'];
if (!empty($t['starts_at'])) {
$lines[] = 'البداية: ' . Time::show((string)$t['starts_at'], 'Y/m/d - H:i') . ' بتوقيت القاهرة';
}
$fee = (int)($t['entry_fee_coins'] ?? 0);
$prize = (int)($t['prize_pool_coins'] ?? 0);
$bits = [];
$bits[] = $fee > 0 ? 'الاشتراك ' . number_format($fee) . ' عملة' : 'الاشتراك مجاني';
if ($prize > 0) $bits[] = 'الجوايز ' . number_format($prize) . ' عملة';
$lines[] = implode('، ', $bits);
$lines[] = 'سجّل من هنا: ' . self::signup($t);
return implode("\n", $lines);
}
}
<?php
/**
* A tournament's pictures, in the public `tournament-media` storage bucket
* (El3ab docs/tournament-hosting-contract.md 2.4).
*
* Uploads used to be written to the manager's own disk under /storage/uploads,
* with a URL relative to the manager: a redeploy wiped them and the player app
* could not reach them. Everything now goes to storage, and the database keeps
* the path as "<bucket>/<object key>":
*
* tournament-media/<tid>/banner-<rand>.webp the four fixed slots
* tournament-media/<tid>/gallery-<rand>.jpg gallery pictures
* tournament-media/sponsors/<sid>/logo-<rand>.png sponsor logos
* tournament-media/ads/<tid>/<rand>.webp ad images
*
* Every file is checked here, not by its name or the browser's claim: the
* bytes must be a JPEG, PNG or WebP picture (finfo and getimagesize agree), and
* at most 5 MB, the bucket's own limit. Pictures are content shown inside the
* app's own frames; nothing here can change how the app looks.
*/
class PresentationService
{
public const BUCKET = 'tournament-media';
/** The bucket refuses anything larger (storage.buckets.file_size_limit). */
public const MAX_BYTES = 5242880;
public const MIMES = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
/**
* The fixed slots, at the size the app draws them. A picture of another
* shape is accepted but cropped by the frame, so staff are warned.
*/
public const SLOTS = [
'banner' => [
'label' => 'البانر', 'w' => 1200, 'h' => 630,
'note' => 'بيظهر جوه كارت البطولة في التطبيق وفوق الصفحة المباشرة.',
],
'cover' => [
'label' => 'الغلاف', 'w' => 1080, 'h' => 1350,
'note' => 'صورة طولية للملصقات والقصص.',
],
'logo' => [
'label' => 'الشعار', 'w' => 512, 'h' => 512,
'note' => 'مربع، بيظهر على خلفية بيضا.',
],
'share_card' => [
'label' => 'صورة المشاركة', 'w' => 1200, 'h' => 630,
'note' => 'بتظهر لما حد يبعت رابط البطولة، لو مفيش بانر.',
],
];
/** Ads and gallery pictures have no fixed shape, only a floor. */
public const MIN_SIDE = 200;
/** The largest file this server will take: the bucket limit, or PHP's if lower. */
public static function maxBytes(): int
{
$limits = [self::MAX_BYTES];
foreach (['upload_max_filesize', 'post_max_size'] as $k) {
$b = self::iniBytes((string)ini_get($k));
if ($b > 0) $limits[] = $b;
}
return min($limits);
}
public static function maxMb(): string
{
$mb = self::maxBytes() / 1048576;
return rtrim(rtrim(number_format($mb, 1, '.', ''), '0'), '.');
}
private static function iniBytes(string $v): int
{
$v = trim($v);
if ($v === '') return 0;
$n = (float)$v;
return (int)match (strtolower(substr($v, -1))) {
'g' => $n * 1073741824,
'm' => $n * 1048576,
'k' => $n * 1024,
default => $n,
};
}
/**
* Check an uploaded file. $slot is a SLOTS entry, or null for a picture
* with no fixed shape.
*
* @return array{ok:bool, code?:string, message?:string, mime?:string, ext?:string, width?:int, height?:int, size?:int, warning?:?string}
*/
public static function validate(?array $file, ?array $slot = null, int $minSide = self::MIN_SIDE): array
{
$tooLarge = ['ok' => false, 'code' => 'file_too_large', 'message' => 'الملف كبير، أقصى حجم ' . self::maxMb() . ' ميجا'];
if (!$file || !isset($file['error'])) {
return ['ok' => false, 'code' => 'bad_request', 'message' => 'اختار صورة الأول'];
}
if (in_array($file['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true)) return $tooLarge;
if ($file['error'] === UPLOAD_ERR_NO_FILE) return ['ok' => false, 'code' => 'bad_request', 'message' => 'اختار صورة الأول'];
if ($file['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) {
return ['ok' => false, 'code' => 'upload_failed', 'message' => 'الرفع ماكملش، جرّب تاني'];
}
$size = (int)filesize($file['tmp_name']);
if ($size > self::maxBytes()) return $tooLarge;
if ($size <= 0) return ['ok' => false, 'code' => 'invalid_file', 'message' => 'الملف ده فاضي'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']) ?: '';
$info = @getimagesize($file['tmp_name']);
if (!isset(self::MIMES[$mime]) || !$info || ($info['mime'] ?? '') !== $mime) {
return ['ok' => false, 'code' => 'invalid_file', 'message' => 'الملف ده مش مقبول: ارفع صورة JPG أو PNG أو WebP'];
}
[$w, $h] = [(int)$info[0], (int)$info[1]];
if ($w < 1 || $h < 1) return ['ok' => false, 'code' => 'invalid_file', 'message' => 'الملف ده مش مقبول'];
$warning = null;
if ($slot) {
$minW = (int)ceil($slot['w'] / 2);
$minH = (int)ceil($slot['h'] / 2);
if ($w < $minW || $h < $minH) {
return ['ok' => false, 'code' => 'invalid_file',
'message' => "الصورة صغيرة ({$w}×{$h})، أقل مقاس {$minW}×{$minH} والأفضل {$slot['w']}×{$slot['h']}"];
}
$want = $slot['w'] / $slot['h'];
if (abs(($w / $h) - $want) / $want > 0.03) {
$warning = "مقاس الصورة {$w}×{$h} مش بنفس نسبة {$slot['w']}×{$slot['h']}، فالإطار هيقص منها";
}
} elseif ($w < $minSide || $h < $minSide) {
return ['ok' => false, 'code' => 'invalid_file', 'message' => 'الصورة صغيرة، أقل ضلع ' . $minSide . ' بكسل'];
}
return ['ok' => true, 'mime' => $mime, 'ext' => self::MIMES[$mime], 'width' => $w, 'height' => $h, 'size' => $size, 'warning' => $warning];
}
/** A short random name part, so a replaced picture never hits a cached copy. */
public static function rand(): string
{
return bin2hex(random_bytes(6));
}
/**
* Upload a checked file to `tournament-media/<key>`. Returns the stored
* path (bucket included), or null when storage refused it.
*/
public static function put(string $key, string $tmpFile, string $mime): ?string
{
$key = ltrim($key, '/');
if (!preg_match('#^[A-Za-z0-9._/-]+$#', $key) || str_contains($key, '..')) return null;
try {
SupabaseStorage::getInstance()->upload(self::BUCKET, $key, $tmpFile, $mime);
} catch (Throwable $e) {
error_log('[presentation] upload ' . $key . ' failed: ' . $e->getMessage());
return null;
}
return self::BUCKET . '/' . $key;
}
/** Remove a stored object; a path outside the bucket is left alone. Best effort. */
public static function remove(?string $path): bool
{
$key = self::key($path);
if ($key === null) return false;
try {
return SupabaseStorage::getInstance()->delete(self::BUCKET, $key);
} catch (Throwable $e) {
error_log('[presentation] delete ' . $key . ' failed: ' . $e->getMessage());
return false;
}
}
/** The object key of a stored path, or null when it is not one of ours. */
public static function key(?string $path): ?string
{
$path = trim((string)$path);
$prefix = self::BUCKET . '/';
if (!str_starts_with($path, $prefix) || str_contains($path, '..')) return null;
$key = substr($path, strlen($prefix));
return $key !== '' ? $key : null;
}
/**
* The public URL of a stored path. Kong wants the anon key even on a
* public object, so the URL carries it, as the player app's own media URLs do.
*/
public static function url(?string $path): ?string
{
$key = self::key($path);
if ($key === null) return null;
$enc = implode('/', array_map('rawurlencode', explode('/', $key)));
return rtrim(SUPABASE_URL, '/') . '/storage/v1/object/public/' . self::BUCKET . '/' . $enc . '?apikey=' . SUPABASE_ANON_KEY;
}
/** An https link, or null. The database refuses any other scheme for sponsor and ad links. */
public static function httpsUrl(?string $v): ?string
{
$v = trim((string)$v);
if ($v === '' || strlen($v) > 2000 || preg_match('/[\s"\'<>\\\\]/', $v)) return null;
if (!filter_var($v, FILTER_VALIDATE_URL)) return null;
$p = parse_url($v);
if (!$p || strtolower($p['scheme'] ?? '') !== 'https' || ($p['host'] ?? '') === '' || isset($p['user']) || isset($p['pass'])) return null;
return $v;
}
/** The media row of one slot of a tournament, or null. */
public static function slotRow(string $tid, string $kind): ?array
{
return Database::getInstance()->selectOne('tournament_media', [
'tournament_id' => 'eq.' . $tid, 'kind' => 'eq.' . $kind, 'select' => '*',
]);
}
}
<?php
require_once __DIR__ . '/NativeTournamentService.php';
/**
* What the venue screen shows (live/{id}/screen, api/live/{id}/boards).
*
* The round on the boards, every board's state, and the featured boards with
* their live position: the FEN the game is at now, whose move it is and both
* clocks, read straight from the games the app is playing. It only reads.
*
* Featured boards follow the play. Unless staff pin boards (?boards=1,4), they
* are the top boards still being played, in board order (on a Swiss round
* board 1 is the leaders' game); when fewer are still playing, the top
* finished boards fill the rest, so the screen is never empty mid-round.
*/
class ScreenService
{
public const FEATURED_DEFAULT = 4;
public const FEATURED_MAX = 8;
private Database $db;
private NativeTournamentService $native;
public function __construct()
{
$this->db = Database::getInstance();
$this->native = new NativeTournamentService();
}
/** The round on the boards: the one being played, else the next one paired, else the last. */
public function currentRound(string $tid): ?array
{
$rows = $this->db->select('el3ab_tournament_rounds', [
'tournament_id' => 'eq.' . $tid,
'select' => 'id,round_number,status,pairings,scheduled_start_at,started_at,completed_at',
'order' => 'round_number.desc',
'limit' => 3,
]);
foreach (['in_progress', 'pending'] as $want) {
foreach ($rows as $r) if (($r['status'] ?? '') === $want) return $r;
}
return $rows[0] ?? null;
}
/**
* @param int[] $pinned board numbers staff asked for
*/
public function boards(array $tournament, array $pinned = [], int $featured = self::FEATURED_DEFAULT): array
{
$tid = $tournament['id'];
$featured = max(0, min(self::FEATURED_MAX, $featured));
$round = $this->currentRound($tid);
$out = [
'ok' => true,
'tournament' => [
'id' => $tid,
'name' => ($tournament['name_ar'] ?? '') ?: ($tournament['name'] ?? ''),
'status' => $tournament['status'] ?? null,
'current_round' => (int)($tournament['current_round'] ?? 0),
'game_key' => $tournament['game_key'] ?? 'chess',
],
'round' => null,
'boards' => [],
'featured' => [],
'server_time' => gmdate('Y-m-d\TH:i:s\Z'),
];
if (!$round) return $out;
$pairings = $round['pairings'] ?? [];
if (is_string($pairings)) $pairings = json_decode($pairings, true);
$pairings = is_array($pairings) && array_is_list($pairings) ? array_values(array_filter($pairings, 'is_array')) : [];
$out['round'] = [
'number' => (int)$round['round_number'],
'status' => $round['status'] ?? null,
'scheduled_start_at' => $round['scheduled_start_at'] ?? null,
];
$people = $this->native->directory($tid, $tournament['time_control'] ?? null);
$games = $this->games($tid, (int)$round['round_number']);
$boards = [];
foreach ($pairings as $i => $p) {
$board = (int)($p['board'] ?? ($i + 1));
$w = $people[$p['white_id'] ?? ''] ?? null;
$b = $people[$p['black_id'] ?? ''] ?? null;
$g = $this->gameFor($games, $p);
$result = $p['result'] ?? null;
$isBye = !empty($p['is_bye']);
$state = $isBye ? 'bye' : ($result !== null ? 'done' : ($g && in_array($g['status'] ?? '', ['in_progress', 'paused'], true) ? 'live' : 'waiting'));
$boards[] = [
'board' => $board,
'pairing_id' => $p['pairing_id'] ?? null,
'game_no' => (int)($p['game_no'] ?? 1),
'game_kind' => $p['game_kind'] ?? 'regular',
'group' => $p['group'] ?? null,
'white' => $w ? ['name' => $w['name'], 'rating' => $w['rating']] : ($isBye ? null : ['name' => 'لاعب', 'rating' => null]),
'black' => $b ? ['name' => $b['name'], 'rating' => $b['rating']] : ($isBye ? null : ['name' => 'لاعب', 'rating' => null]),
'result' => $result,
'reason' => $p['reason'] ?? null,
'state' => $state,
'game' => $g,
];
}
usort($boards, fn($a, $b) => [$a['board'], $a['game_no']] <=> [$b['board'], $b['game_no']]);
// Featured: pinned boards, or the top boards still in play, then the top finished ones.
$pick = [];
if ($pinned) {
foreach ($boards as $k => $bd) if (in_array($bd['board'], $pinned, true)) $pick[] = $k;
$pick = array_slice($pick, 0, self::FEATURED_MAX);
} else {
foreach ($boards as $k => $bd) if (in_array($bd['state'], ['live', 'waiting'], true)) $pick[] = $k;
foreach ($boards as $k => $bd) if ($bd['state'] === 'done') $pick[] = $k;
$pick = array_slice($pick, 0, $featured);
sort($pick);
}
$out['featured'] = array_map(fn($k) => $boards[$k]['board'], $pick);
$out['boards'] = array_map(function ($bd) use ($pick, $boards) {
$isFeatured = false;
foreach ($pick as $k) if ($boards[$k]['board'] === $bd['board'] && $boards[$k]['game_no'] === $bd['game_no']) $isFeatured = true;
$bd['featured'] = $isFeatured;
// Only featured boards carry the position: the list needs names and results.
if (!$isFeatured && $bd['game']) $bd['game'] = ['status' => $bd['game']['status']];
return $bd;
}, $boards);
return $out;
}
/** This round's games, newest first, keyed for lookup by pairing and by player pair. */
private function games(string $tid, int $round): array
{
$rows = $this->db->select('matches', [
'tournament_id' => 'eq.' . $tid,
'tournament_round' => 'eq.' . $round,
'select' => 'id,pairing_id,white_player_id,black_player_id,status,result,current_fen,starting_fen,move_count,white_time_remaining_ms,black_time_remaining_ms,increment_ms,started_at,updated_at,completed_at',
'order' => 'created_at.desc',
'limit' => 1000,
]);
$byPairing = [];
$byPlayers = [];
foreach ($rows as $m) {
if (!empty($m['pairing_id']) && !isset($byPairing[$m['pairing_id']])) $byPairing[$m['pairing_id']] = $m;
$key = ($m['white_player_id'] ?? '') . '|' . ($m['black_player_id'] ?? '');
if (!isset($byPlayers[$key])) $byPlayers[$key] = $m;
}
return ['pairing' => $byPairing, 'players' => $byPlayers];
}
private function gameFor(array $games, array $p): ?array
{
$m = null;
if (!empty($p['pairing_id'])) $m = $games['pairing'][$p['pairing_id']] ?? null;
if (!$m && !empty($p['match_id'])) {
foreach ($games['pairing'] as $row) if ($row['id'] === $p['match_id']) { $m = $row; break; }
}
if (!$m && !empty($p['white_id']) && !empty($p['black_id'])) $m = $games['players'][$p['white_id'] . '|' . $p['black_id']] ?? null;
if (!$m) return null;
$fen = (string)($m['current_fen'] ?? '') ?: (string)($m['starting_fen'] ?? '');
$turn = preg_match('/^\S+\s+([wb])\b/', $fen, $mm) ? $mm[1] : 'w';
$live = in_array($m['status'] ?? '', ['in_progress'], true);
$white = isset($m['white_time_remaining_ms']) ? (int)$m['white_time_remaining_ms'] : null;
$black = isset($m['black_time_remaining_ms']) ? (int)$m['black_time_remaining_ms'] : null;
// The stored clock is the time left when the last move was made; the side to move has been thinking since.
if ($live && ($m['move_count'] ?? 0) > 0 && !empty($m['updated_at'])) {
$since = max(0, (int)round((microtime(true) - strtotime((string)$m['updated_at'])) * 1000));
if ($turn === 'w' && $white !== null) $white = max(0, $white - $since);
if ($turn === 'b' && $black !== null) $black = max(0, $black - $since);
}
return [
'id' => $m['id'],
'status' => $m['status'] ?? null,
'fen' => $fen !== '' ? $fen : null,
'turn' => $turn,
'moves' => (int)($m['move_count'] ?? 0),
'clock' => ['white_ms' => $white, 'black_ms' => $black, 'running' => $live && ($m['move_count'] ?? 0) > 0],
];
}
}
......@@ -8,13 +8,15 @@
'result' => '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><path d="M22 4L12 14.01l-3-3"/></svg>',
'alert' => '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>',
'commentary' => '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>',
'schedule' => '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>',
'sponsor' => '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M8.2 13.9 7 22l5-3 5 3-1.2-8.1"/></svg>',
'milestone' => '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5"/></svg>',
];
$icon = $typeIcons[$ann['type'] ?? 'info'] ?? $typeIcons['info'];
$isPinned = !empty($ann['is_pinned']);
?>
<div class="announcement-bubble ann-<?= $ann['type'] ?? 'info' ?> <?= $isPinned ? 'pinned' : '' ?>">
<?php if ($isPinned): ?><span class="pin-icon">📌</span><?php endif; ?>
<div class="announcement-bubble ann-<?= htmlspecialchars($ann['type'] ?? 'info') ?> <?= $isPinned ? 'pinned' : '' ?>" data-id="<?= htmlspecialchars($ann['id'] ?? '') ?>">
<?php if ($isPinned): ?><span class="pin-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="مثبت"><path d="M12 17v5"/><path d="M9 10.76V6h6v4.76l2 2.24v2H7v-2z"/><path d="M8 2h8"/></svg></span><?php endif; ?>
<span class="ann-icon"><?= $icon ?></span>
<div class="ann-body">
<?php if (!empty($ann['title'])): ?>
......
This diff is collapsed.
<?php
/**
* The sign-up call to action, while sign-up is open: the app's /t/ link,
* which opens the tournament with its sign-up sheet in front (spec 6.2 item 8).
* Expects $data['signup'] = {open, url, fee, access}, $tournament.
*/
$cta = $data['signup'] ?? [];
if (empty($cta['open']) || empty($cta['url'])) return;
$fee = (int)($cta['fee'] ?? 0);
$max = (int)($tournament['max_players'] ?? 0);
$taken = (int)($data['stats']['players'] ?? count($data['players'] ?? []));
$left = $max > 0 ? max(0, $max - $taken) : null;
?>
<section class="lb-cta" id="signup-cta" aria-labelledby="lb-cta-title">
<div class="lb-cta-text">
<h2 class="lb-cta-title" id="lb-cta-title">التسجيل مفتوح</h2>
<p class="lb-cta-sub">
<?= $fee > 0 ? 'الاشتراك ' . number_format($fee) . ' عملة' : 'الاشتراك مجاني' ?>
<?php if ($left !== null): ?> · <?= $left > 0 ? 'باقي ' . number_format($left) . ' مكان' : 'الأماكن كملت، فيه قائمة انتظار' ?><?php endif; ?>
<?php if (($cta['access'] ?? 'public') === 'code_only'): ?> · بالكود بس<?php endif; ?>
</p>
</div>
<a class="lb-cta-btn" href="<?= htmlspecialchars($cta['url']) ?>" rel="noopener" data-cta="signup">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
سجّل في البطولة
</a>
</section>
......@@ -21,7 +21,7 @@
<span class="gallery-caption"><?= htmlspecialchars($item['caption']) ?></span>
<?php endif; ?>
<?php if (!empty($item['is_featured'])): ?>
<span class="gallery-featured">⭐</span>
<span class="gallery-featured"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-label="مميزة"><path d="M12 2l3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1z"/></svg></span>
<?php endif; ?>
</div>
<?php endif; ?>
......
......@@ -2,7 +2,9 @@
$tickerItems = [];
if (!empty($tickerAds)) {
foreach ($tickerAds as $ta) {
$tickerItems[] = ['type' => 'ad', 'content' => $ta['content'], 'link' => $ta['link_url'] ?? ''];
// The ticker is a line of text: a picture ad has no place in it.
if (($ta['slot_type'] ?? '') !== 'text' || trim((string)($ta['content'] ?? '')) === '') continue;
$tickerItems[] = ['type' => 'ad', 'content' => $ta['content'], 'link' => $ta['link_url'] ?? '', 'id' => $ta['id'] ?? ''];
}
}
$latestResults = $data['latest_results'] ?? [];
......@@ -34,7 +36,7 @@ if (empty($tickerItems)) return;
<div class="ticker-track">
<?php foreach ($tickerItems as $item): ?>
<?php foreach ([1, 2] as $_dup): ?>
<span class="ticker-item ticker-<?= $item['type'] ?>">
<span class="ticker-item ticker-<?= $item['type'] ?>"<?php if ($item['type'] === 'ad' && !empty($item['id']) && $_dup === 1): ?> data-promo-kind="ad" data-promo-id="<?= htmlspecialchars($item['id']) ?>"<?php endif; ?>>
<?php if ($item['type'] === 'result'): ?>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/></svg>
<?php endif; ?>
......
<?php
/**
* The tournament's sponsors on the live page, in tier order (title first),
* from tournament_sponsorships with show_on_live. Logos sit on neutral tiles;
* a sponsor's own colours are never used. Each tile reports a view and a
* click to /api/live/{id}/track (data-promo-*), which moves its counters.
* Expects $sponsors (LiveDataService::getSponsors), $tournament.
*/
if (empty($sponsors)) return;
$tierLabels = [
'title' => 'الراعي الرسمي', 'presenting' => 'الراعي المقدّم', 'gold' => 'راعي ذهبي', 'silver' => 'راعي فضي',
'bronze' => 'راعي برونزي', 'partner' => 'شريك', 'media' => 'الشريك الإعلامي', 'venue' => 'شريك المكان',
];
$size = fn(string $tier) => match ($tier) { 'title', 'presenting' => 'is-lead', 'gold', 'silver' => 'is-mid', default => 'is-small' };
?>
<div class="lb-sponsors">
<?php foreach ($sponsors as $s):
$label = $s['label_ar'] ?: ($tierLabels[$s['tier']] ?? '');
$tag = $s['link_url'] ? 'a' : 'div';
?>
<<?= $tag ?> class="lb-sponsor <?= $size($s['tier']) ?>"
<?php if ($s['link_url']): ?>href="<?= htmlspecialchars($s['link_url']) ?>" target="_blank" rel="noopener sponsored"<?php endif; ?>
data-promo-kind="sponsor" data-promo-id="<?= htmlspecialchars($s['id']) ?>" data-tier="<?= htmlspecialchars($s['tier']) ?>">
<span class="lb-sponsor-tile">
<?php if ($s['logo_url']): ?>
<img src="<?= htmlspecialchars($s['logo_url']) ?>" alt="<?= htmlspecialchars($s['name']) ?>" loading="lazy">
<?php else: ?>
<span class="lb-sponsor-name"><?= htmlspecialchars($s['name']) ?></span>
<?php endif; ?>
</span>
<?php if ($label !== ''): ?><span class="lb-sponsor-label"><?= htmlspecialchars($label) ?></span><?php endif; ?>
</<?= $tag ?>>
<?php endforeach; ?>
</div>
......@@ -72,7 +72,7 @@ $pct = $total > 0 ? (int)round($completed / $total * 100) : 0;
<li class="lb-round is-<?= $state ?>">
<div class="lb-round-head">
<span class="lb-round-marker" aria-hidden="true">
<?php if ($state === 'completed'): ?>✓<?php elseif ($state === 'in_progress'): ?><span class="pulse-dot"></span><?php else: ?><?= $num ?><?php endif; ?>
<?php if ($state === 'completed'): ?><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg><?php elseif ($state === 'in_progress'): ?><span class="pulse-dot"></span><?php else: ?><?= $num ?><?php endif; ?>
</span>
<span class="lb-round-title">الجولة <?= $num ?></span>
<span class="lb-round-state"><?= htmlspecialchars($stateLabel[$state]) ?></span>
......
......@@ -24,7 +24,8 @@ $mode = $data['mode'] ?? 'swiss';
<div class="embed-footer">
<a href="/live/<?= htmlspecialchars($tournament['slug'] ?? $tournament['id']) ?>" target="_blank" class="embed-link">
عرض البطولة الكاملة على El3ab ↗
عرض البطولة كاملة على El3ab
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
</a>
</div>
</div>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment