diff --git a/.github/workflows/php-sandbox.yml b/.github/workflows/php-sandbox.yml index be1a82b..e8f764e 100644 --- a/.github/workflows/php-sandbox.yml +++ b/.github/workflows/php-sandbox.yml @@ -2,7 +2,7 @@ name: Build & deploy sandbox on: push: - branches: [ "develop"] + branches: [ "develop", "add-stream-live-view"] workflow_dispatch: permissions: diff --git a/migrations/20-add-stream-live-view.sql b/migrations/20-add-stream-live-view.sql new file mode 100644 index 0000000..5eedec0 --- /dev/null +++ b/migrations/20-add-stream-live-view.sql @@ -0,0 +1,15 @@ +ALTER TABLE {prefix}charity_stream + ADD COLUMN live_started_at DATETIME(6) NULL, + ADD COLUMN live_continuation_token VARCHAR(255) NULL, + ADD COLUMN live_cache_updated_at DATETIME(6) NULL; + +CREATE TABLE {prefix}stream_donation_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + charity_stream_guid CHAR(32) NOT NULL, + pseudo VARCHAR(255) NOT NULL, + message TEXT NULL, + amount INT NOT NULL, + received_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + CONSTRAINT fk_{prefix}charity_stream_guid_donation_history FOREIGN KEY (charity_stream_guid) REFERENCES {prefix}charity_stream(guid), + INDEX idx_stream_received (charity_stream_guid, id) +); diff --git a/public/dist/.vite/manifest.json b/public/dist/.vite/manifest.json index eb786f3..c48dae4 100644 --- a/public/dist/.vite/manifest.json +++ b/public/dist/.vite/manifest.json @@ -63,6 +63,12 @@ "src/Assets/js/utilities.js" ] }, + "src/Assets/js/live.js": { + "file": "live.min.js", + "name": "live", + "src": "src/Assets/js/live.js", + "isEntry": true + }, "src/Assets/js/stream.js": { "file": "stream.min.js", "name": "stream", diff --git a/public/dist/live.min.js b/public/dist/live.min.js new file mode 100644 index 0000000..6bd77c9 --- /dev/null +++ b/public/dist/live.min.js @@ -0,0 +1 @@ +const g=document.getElementById("donationFeed"),f=document.getElementById("feedSentinel"),w=document.getElementById("feedEmptyState"),E=document.getElementById("feedEndNotice"),h=document.getElementById("feedCapNotice"),v=30,N=300;let l=!1,m=!1,u=window.newestLoadedId||0,o=window.oldestLoadedId||0,a=window.initialCount||0,p=(window.initialCount||0)=N){y.disconnect(),h&&(h.style.display="block");return}m=!0;try{const e=await fetch(`/admin/stream/${window.charityStreamId}/live/history?before_id=${o}`);if(!e.ok){console.error("Erreur lors du chargement de l'historique du live :",e.status);return}const t=await e.json();if(!Array.isArray(t))return;for(const n of t)g.insertBefore(I(n),f),o=n.id,a++;t.length0&&(E.style.display="block"))}catch(e){console.error("Erreur réseau lors du chargement de l'historique du live :",e)}finally{m=!1}}}const y=new IntersectionObserver(e=>{e.some(t=>t.isIntersecting)&&C()});g&&window.liveStarted&&(b(),setInterval(b,1e4),f&&!p&&y.observe(f)); diff --git a/public/index.php b/public/index.php index 975e893..4395405 100644 --- a/public/index.php +++ b/public/index.php @@ -16,6 +16,7 @@ use App\Repositories\EventRepository; use App\Repositories\FileManager; use App\Repositories\GoalRepository; +use App\Repositories\StreamDonationHistoryRepository; use App\Repositories\StreamRepository; use App\Repositories\UserRepository; use App\Repositories\WidgetRepository; @@ -83,6 +84,10 @@ return new StreamRepository($c->get(PDO::class), $_SERVER['DBPREFIX']); }); +$container->set(StreamDonationHistoryRepository::class, function ($c) { + return new StreamDonationHistoryRepository($c->get(PDO::class), $_SERVER['DBPREFIX']); +}); + $container->set(UserRepository::class, function ($c) { return new UserRepository($c->get(PDO::class), $_SERVER['DBPREFIX']); }); @@ -226,6 +231,11 @@ function (\Psr\Http\Message\ServerRequestInterface $request, \Throwable $excepti $app->post('/admin/stream/{id}/delete', [AdminController::class, 'deleteStream'])->add(new AuthMiddleware())->setName('app_stream_delete'); $app->get('/admin/stream/{id}/edit', [AdminController::class, 'editStream'])->add(new AuthMiddleware())->setName('app_stream_edit'); $app->post('/admin/stream/{id}/edit', [AdminController::class, 'editStreamPost'])->add(new AuthMiddleware())->setName('app_stream_edit_post'); +$app->get('/admin/stream/{id}/live', [AdminController::class, 'liveView'])->add(new AuthMiddleware())->setName('app_stream_live'); +$app->post('/admin/stream/{id}/live/start', [AdminController::class, 'startLive'])->add(new AuthMiddleware())->setName('app_stream_live_start'); +$app->post('/admin/stream/{id}/live/stop', [AdminController::class, 'stopLive'])->add(new AuthMiddleware())->setName('app_stream_live_stop'); +$app->get('/admin/stream/{id}/live/fetch', [AdminController::class, 'liveFetch'])->add(new AuthMiddleware())->setName('app_stream_live_fetch'); +$app->get('/admin/stream/{id}/live/history', [AdminController::class, 'liveHistory'])->add(new AuthMiddleware())->setName('app_stream_live_history'); $app->post('/api/stream', [ApiController::class, 'new'])->add(new AuthApiMiddleware())->setName('app_api_stream_new'); diff --git a/src/Assets/js/live.js b/src/Assets/js/live.js new file mode 100644 index 0000000..9196a0b --- /dev/null +++ b/src/Assets/js/live.js @@ -0,0 +1,172 @@ +// Vue "Live" : polling des nouveaux dons + scroll infini vers l'historique. +// Tout le contenu venant des donateurs (pseudo, message) est injecté via +// textContent, jamais innerHTML, pour éviter toute exécution de HTML/JS. + +const feed = document.getElementById('donationFeed'); +const sentinel = document.getElementById('feedSentinel'); +const emptyState = document.getElementById('feedEmptyState'); +const endNotice = document.getElementById('feedEndNotice'); +const capNotice = document.getElementById('feedCapNotice'); + +const PAGE_SIZE = 30; +// Cap du nombre de lignes rendues : au-delà, on arrête de charger plus +// d'historique pour ne pas faire exploser le DOM sur un long live +const MAX_RENDERED_ROWS = 300; + +let isFetching = false; +let isLoadingOlder = false; +let newestId = window.newestLoadedId || 0; +let oldestId = window.oldestLoadedId || 0; +let renderedCount = window.initialCount || 0; +let reachedEnd = (window.initialCount || 0) < PAGE_SIZE; + +function formatAmount(cents) { + return (cents / 100).toLocaleString('fr-FR', { minimumFractionDigits: 2 }) + ' €'; +} + +function formatDate(receivedAt) { + // MySQL DATETIME "YYYY-MM-DD HH:MM:SS.uuuuuu" → parsable en remplaçant l'espace + const date = new Date(String(receivedAt).replace(' ', 'T')); + return isNaN(date.getTime()) ? String(receivedAt) : date.toLocaleString('fr-FR'); +} + +function buildEntry(entry) { + const card = document.createElement('div'); + card.className = 'live-entry card mb-2'; + card.dataset.id = entry.id; + + const body = document.createElement('div'); + body.className = 'card-body py-2 px-3'; + + const meta = document.createElement('div'); + meta.className = 'd-flex justify-content-between align-items-baseline gap-2'; + + const pseudo = document.createElement('span'); + pseudo.className = 'fw-bold'; + pseudo.textContent = entry.pseudo; + + const amount = document.createElement('span'); + amount.className = 'badge bg-success'; + amount.style.fontSize = '0.9rem'; + amount.textContent = formatAmount(entry.amount); + + const time = document.createElement('span'); + time.className = 'text-muted small ms-auto'; + time.textContent = formatDate(entry.received_at); + + meta.append(pseudo, amount, time); + body.append(meta); + + if (entry.message) { + const message = document.createElement('p'); + message.className = 'mb-0 mt-1'; + message.style.opacity = '0.85'; + message.textContent = entry.message; + body.append(message); + } + + card.append(body); + return card; +} + +async function fetchNewDonations() { + if (isFetching) { + return; + } + isFetching = true; + + try { + const response = await fetch(`/admin/stream/${window.charityStreamId}/live/fetch?since_id=${newestId}`); + if (!response.ok) { + console.error('Erreur lors de la récupération des dons du live :', response.status); + return; + } + + const entries = await response.json(); + if (!Array.isArray(entries) || entries.length === 0) { + return; + } + + if (emptyState) { + emptyState.style.display = 'none'; + } + + // Le serveur renvoie les dons par id croissant : en préprendant chacun + // dans l'ordre, le plus récent finit tout en haut du fil + for (const entry of entries) { + feed.prepend(buildEntry(entry)); + newestId = Math.max(newestId, entry.id); + if (oldestId === 0) { + oldestId = entry.id; + } + renderedCount++; + } + } catch (error) { + console.error('Erreur réseau lors de la récupération des dons du live :', error); + } finally { + isFetching = false; + } +} + +async function fetchOlderDonations() { + if (isLoadingOlder || reachedEnd || oldestId === 0) { + return; + } + + if (renderedCount >= MAX_RENDERED_ROWS) { + observer.disconnect(); + if (capNotice) { + capNotice.style.display = 'block'; + } + return; + } + + isLoadingOlder = true; + + try { + const response = await fetch(`/admin/stream/${window.charityStreamId}/live/history?before_id=${oldestId}`); + if (!response.ok) { + console.error('Erreur lors du chargement de l\'historique du live :', response.status); + return; + } + + const entries = await response.json(); + if (!Array.isArray(entries)) { + return; + } + + // Les pages arrivent par id décroissant : on les ajoute à la suite du fil + for (const entry of entries) { + feed.insertBefore(buildEntry(entry), sentinel); + oldestId = entry.id; + renderedCount++; + } + + if (entries.length < PAGE_SIZE) { + reachedEnd = true; + observer.disconnect(); + if (endNotice && renderedCount > 0) { + endNotice.style.display = 'block'; + } + } + } catch (error) { + console.error('Erreur réseau lors du chargement de l\'historique du live :', error); + } finally { + isLoadingOlder = false; + } +} + +const observer = new IntersectionObserver((observedEntries) => { + if (observedEntries.some((observed) => observed.isIntersecting)) { + fetchOlderDonations(); + } +}); + +if (feed && window.liveStarted) { + fetchNewDonations(); + setInterval(fetchNewDonations, 10000); + + if (sentinel && !reachedEnd) { + observer.observe(sentinel); + } +} diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php index 857f671..32a73b8 100644 --- a/src/Controllers/AdminController.php +++ b/src/Controllers/AdminController.php @@ -2,12 +2,14 @@ namespace App\Controllers; +use App\Models\Stream; use App\Models\User; use App\Repositories\AccessTokenRepository; use App\Repositories\AuthorizationCodeRepository; use App\Repositories\EventRepository; use App\Repositories\FileManager; use App\Repositories\GoalRepository; +use App\Repositories\StreamDonationHistoryRepository; use App\Repositories\StreamRepository; use App\Repositories\UserRepository; use App\Repositories\WidgetRepository; @@ -22,6 +24,8 @@ class AdminController { + private int $cacheTtl; + public function __construct( private Twig $view, private FileManager $fileManager, @@ -35,7 +39,10 @@ public function __construct( private AccessTokenRepository $accessTokenRepository, private AuthorizationCodeRepository $authorizationCodeRepository, private ApiClient $mailchimp, - ) {} + private StreamDonationHistoryRepository $donationHistoryRepository, + ) { + $this->cacheTtl = (int) ($_SERVER['WIDGET_CACHE_TTL'] ?? 30); + } /** * Retourne les slugs d'organisation dont le token est expiré parmi les streams donnés. @@ -590,6 +597,187 @@ public function editStreamPost(Request $request, Response $response, array $args return $this->redirectToRoute($request, $response, 'app_stream_edit', ["id" => $guid]); } + // ── Vue "Live" : relecture des dons en temps réel ────────────── + + private function jsonResponse(Response $response, array $data, int $status = 200): Response + { + $response->getBody()->write(json_encode($data)); + return $response->withHeader('Content-Type', 'application/json')->withStatus($status); + } + + /** + * Le cache du live suit le même TTL que les widgets (WIDGET_CACHE_TTL, 30s par défaut). + */ + private function isLiveCacheFresh(Stream $stream): bool + { + if ($stream->live_cache_updated_at === null) { + return false; + } + + $updatedAt = new \DateTime($stream->live_cache_updated_at); + $age = (new \DateTime())->getTimestamp() - $updatedAt->getTimestamp(); + return $age < $this->cacheTtl; + } + + private function serializeDonationEntries(array $entries): array + { + return array_map(fn($entry) => [ + 'id' => (int) $entry->id, + 'pseudo' => $entry->pseudo, + 'message' => $entry->message, + 'amount' => (int) $entry->amount, + 'received_at' => $entry->received_at, + ], $entries); + } + + public function liveView(Request $request, Response $response, array $args): Response + { + $user = $request->getAttribute('user'); + $charityStream = $this->streamRepository->selectByUserAndGuid($user, $args['id']); + if (!$charityStream) { + $this->messages->addMessage('error', 'Tu n\'as pas accès ce stream'); + return $this->redirectToRoute($request, $response, 'app_admin_index', [], ['tab' => 'streams']); + } + + $entries = []; + if ($charityStream->live_started_at !== null) { + $entries = $this->donationHistoryRepository->selectPage($charityStream->guid, null, 30); + } + + return $this->view->render($response, 'stream/live.html.twig', [ + "logged" => true, + "charityStream" => $charityStream, + "entries" => $entries, + "messages" => $this->messages->getMessages(), + ]); + } + + public function startLive(Request $request, Response $response, array $args): Response + { + $user = $request->getAttribute('user'); + $charityStream = $this->streamRepository->selectByUserAndGuid($user, $args['id']); + if (!$charityStream) { + $this->messages->addMessage('error', 'Tu n\'as pas accès ce stream'); + return $this->redirectToRoute($request, $response, 'app_admin_index', [], ['tab' => 'streams']); + } + + // Idempotent : un double submit ne doit pas réinitialiser la session en cours + if ($charityStream->live_started_at === null) { + // Avance le curseur à la fin du flux de commandes pour ne capter + // que les dons reçus après le démarrage du live + $continuationToken = null; + if (!$charityStream->is_test_mode) { + try { + $result = $this->apiWrapper->getAllOrders( + $charityStream->organization_slug, + $charityStream->form_slug, + 0, + null, + $charityStream->form_type ?? 'Donation', + ); + $continuationToken = $result['continuation_token']; + } catch (Exception $e) { + error_log('[LiveStart] Erreur API pour stream ' . $charityStream->guid . ' : ' . $e->getMessage()); + } + } + + $this->streamRepository->update($charityStream, [ + 'live_started_at' => date('Y-m-d H:i:s.u'), + 'live_continuation_token' => $continuationToken, + 'live_cache_updated_at' => date('Y-m-d H:i:s.u'), + ]); + $this->messages->addMessage('success', 'Le live est démarré, les dons vont apparaître ici.'); + } + + return $this->redirectToRoute($request, $response, 'app_stream_live', ["id" => $charityStream->guid]); + } + + public function stopLive(Request $request, Response $response, array $args): Response + { + $user = $request->getAttribute('user'); + $charityStream = $this->streamRepository->selectByUserAndGuid($user, $args['id']); + if (!$charityStream) { + $this->messages->addMessage('error', 'Tu n\'as pas accès ce stream'); + return $this->redirectToRoute($request, $response, 'app_admin_index', [], ['tab' => 'streams']); + } + + $this->donationHistoryRepository->deleteByStreamGuid($charityStream->guid); + $this->streamRepository->update($charityStream, [ + 'live_started_at' => null, + 'live_continuation_token' => null, + 'live_cache_updated_at' => null, + ]); + $this->messages->addMessage('info', 'Le live est terminé, l\'historique des dons a été supprimé.'); + + return $this->redirectToRoute($request, $response, 'app_stream_live', ["id" => $charityStream->guid]); + } + + /** + * Endpoint JSON de polling : rafraîchit l'historique depuis l'API HelloAsso + * si le cache est périmé, puis retourne les dons plus récents que ?since_id=. + */ + public function liveFetch(Request $request, Response $response, array $args): Response + { + $user = $request->getAttribute('user'); + $charityStream = $this->streamRepository->selectByUserAndGuid($user, $args['id']); + if (!$charityStream) { + return $this->jsonResponse($response, ['error' => 'Stream non trouvé ou non autorisé.'], 404); + } + + if ($charityStream->live_started_at === null) { + return $this->jsonResponse($response, []); + } + + // En mode test, les dons simulés sont insérés directement dans l'historique : + // pas d'appel API, on retourne juste les nouvelles lignes + if (!$charityStream->is_test_mode && !$this->isLiveCacheFresh($charityStream)) { + try { + $result = $this->apiWrapper->getAllOrders( + $charityStream->organization_slug, + $charityStream->form_slug, + 0, + $charityStream->live_continuation_token, + $charityStream->form_type ?? 'Donation', + ); + + if (!empty($result['donations'])) { + $this->donationHistoryRepository->insertMany($charityStream->guid, $result['donations']); + } + + $this->streamRepository->update($charityStream, [ + 'live_continuation_token' => $result['continuation_token'], + 'live_cache_updated_at' => date('Y-m-d H:i:s.u'), + ]); + } catch (Exception $e) { + // On sert quand même ce qui est déjà en base, le prochain poll réessaiera + error_log('[LiveFetch] Erreur API pour stream ' . $charityStream->guid . ' : ' . $e->getMessage()); + } + } + + $sinceId = (int) ($request->getQueryParams()['since_id'] ?? 0); + $entries = $this->donationHistoryRepository->selectSince($charityStream->guid, $sinceId); + + return $this->jsonResponse($response, $this->serializeDonationEntries($entries)); + } + + /** + * Endpoint JSON du scroll infini : page suivante de l'historique avant ?before_id=. + */ + public function liveHistory(Request $request, Response $response, array $args): Response + { + $user = $request->getAttribute('user'); + $charityStream = $this->streamRepository->selectByUserAndGuid($user, $args['id']); + if (!$charityStream) { + return $this->jsonResponse($response, ['error' => 'Stream non trouvé ou non autorisé.'], 404); + } + + $queryParams = $request->getQueryParams(); + $beforeId = isset($queryParams['before_id']) ? (int) $queryParams['before_id'] : null; + $entries = $this->donationHistoryRepository->selectPage($charityStream->guid, $beforeId, 30); + + return $this->jsonResponse($response, $this->serializeDonationEntries($entries)); + } + /** * Traite les sauvegardes communes des widgets donation goal et card widget. */ diff --git a/src/Controllers/WidgetController.php b/src/Controllers/WidgetController.php index 7020a24..3eed9d2 100644 --- a/src/Controllers/WidgetController.php +++ b/src/Controllers/WidgetController.php @@ -5,6 +5,7 @@ use App\Repositories\EventRepository; use App\Repositories\FileManager; use App\Repositories\GoalRepository; +use App\Repositories\StreamDonationHistoryRepository; use App\Repositories\StreamRepository; use App\Repositories\WidgetRepository; use App\Services\ApiWrapper; @@ -25,6 +26,7 @@ public function __construct( private StreamRepository $streamRepository, private WidgetRepository $widgetRepository, private GoalRepository $goalRepository, + private StreamDonationHistoryRepository $donationHistoryRepository, ) { $this->cacheTtl = (int) ($_SERVER['WIDGET_CACHE_TTL'] ?? 30); } @@ -740,6 +742,15 @@ public function simulateStreamDonation(Request $request, Response $response, arr $newAmount = (int) $charityStream->test_amount + $amount; $this->streamRepository->update($charityStream, ['test_amount' => $newAmount]); + // Si un live est en cours, le don simulé alimente aussi la vue "Live" + if ($charityStream->live_started_at !== null) { + $this->donationHistoryRepository->insertMany($charityStream->guid, [[ + 'pseudo' => $pseudo, + 'message' => $message, + 'amount' => $amount, + ]]); + } + return $this->jsonResponse($response, [ 'amount' => $newAmount, 'donation' => [ diff --git a/src/Models/Stream.php b/src/Models/Stream.php index 1dd176a..96ff8d4 100644 --- a/src/Models/Stream.php +++ b/src/Models/Stream.php @@ -16,6 +16,9 @@ class Stream implements HasRight public $admin; public $is_test_mode = 0; public $test_amount = 0; + public $live_started_at; + public $live_continuation_token; + public $live_cache_updated_at; public function getRightColumn(): string { return 'id_charity_stream'; } public function getRightId(): int { return (int) $this->id; } diff --git a/src/Models/StreamDonationEntry.php b/src/Models/StreamDonationEntry.php new file mode 100644 index 0000000..2a0c709 --- /dev/null +++ b/src/Models/StreamDonationEntry.php @@ -0,0 +1,13 @@ + ..., 'message' => ..., 'amount' => ...]. + */ + public function insertMany(string $streamGuid, array $donations): void + { + if (empty($donations)) { + return; + } + + $stmt = $this->pdo->prepare(' + INSERT INTO ' . $this->prefix . 'stream_donation_history (charity_stream_guid, pseudo, message, amount) + VALUES (?, ?, ?, ?) + '); + + $this->pdo->beginTransaction(); + + try { + foreach ($donations as $donation) { + $stmt->execute([ + $streamGuid, + $donation['pseudo'] ?? 'anonyme', + $donation['message'] ?? null, + (int) ($donation['amount'] ?? 0), + ]); + } + $this->pdo->commit(); + } catch (Exception $e) { + $this->pdo->rollBack(); + throw $e; + } + } + + /** + * Page de l'historique en pagination par curseur (id décroissant, du plus récent au plus ancien). + */ + public function selectPage(string $streamGuid, ?int $beforeId, int $limit): array + { + $sql = ' + SELECT * + FROM ' . $this->prefix . 'stream_donation_history + WHERE charity_stream_guid = ? + '; + $params = [$streamGuid]; + + if ($beforeId !== null) { + $sql .= ' AND id < ?'; + $params[] = $beforeId; + } + + $sql .= ' ORDER BY id DESC LIMIT ' . (int) $limit; + + $stmt = $this->pdo->prepare($sql); + $stmt->setFetchMode(PDO::FETCH_CLASS, StreamDonationEntry::class); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + /** + * Dons arrivés après un id donné (id croissant), pour le polling temps réel. + */ + public function selectSince(string $streamGuid, int $afterId): array + { + $stmt = $this->pdo->prepare(' + SELECT * + FROM ' . $this->prefix . 'stream_donation_history + WHERE charity_stream_guid = ? + AND id > ? + ORDER BY id ASC + '); + $stmt->setFetchMode(PDO::FETCH_CLASS, StreamDonationEntry::class); + $stmt->execute([$streamGuid, $afterId]); + return $stmt->fetchAll(); + } + + public function deleteByStreamGuid(string $streamGuid): void + { + $stmt = $this->pdo->prepare(' + DELETE FROM ' . $this->prefix . 'stream_donation_history + WHERE charity_stream_guid = ? + '); + $stmt->execute([$streamGuid]); + } +} diff --git a/src/Repositories/StreamRepository.php b/src/Repositories/StreamRepository.php index 18e80bf..f16d7ca 100644 --- a/src/Repositories/StreamRepository.php +++ b/src/Repositories/StreamRepository.php @@ -197,6 +197,18 @@ public function update(Stream $stream, array $data): void $fields[] = 'test_amount = ?'; $params[] = (int) $data['test_amount']; } + if (array_key_exists('live_started_at', $data)) { + $fields[] = 'live_started_at = ?'; + $params[] = $data['live_started_at']; + } + if (array_key_exists('live_continuation_token', $data)) { + $fields[] = 'live_continuation_token = ?'; + $params[] = $data['live_continuation_token']; + } + if (array_key_exists('live_cache_updated_at', $data)) { + $fields[] = 'live_cache_updated_at = ?'; + $params[] = $data['live_cache_updated_at']; + } if (empty($fields)) return; diff --git a/src/views/stream/edit.html.twig b/src/views/stream/edit.html.twig index 83da4c7..f70fd8b 100644 --- a/src/views/stream/edit.html.twig +++ b/src/views/stream/edit.html.twig @@ -7,7 +7,10 @@
{# ── En-tête ─────────────────────────────────────────────── #}
- Retour +

Édition du stream

diff --git a/src/views/stream/live.html.twig b/src/views/stream/live.html.twig new file mode 100644 index 0000000..8aa507d --- /dev/null +++ b/src/views/stream/live.html.twig @@ -0,0 +1,89 @@ +{% block title %}HelloAsso Widgets - Live du stream{% endblock %} + + +{% extends "layout.html.twig" %} +{% block content %} + +
+ {# ── En-tête ─────────────────────────────────────────────── #} +
+ Retour +

🔴 {{ charityStream.title }}

+ {% if charityStream.live_started_at %} + + + + {% else %} +
+ +
+ {% endif %} +
+ + {% for key, msgs in messages|default({}) %} + {% for msg in msgs %} + + {% endfor %} + {% endfor %} + + {% if charityStream.live_started_at %} +

+ Live démarré le {{ charityStream.live_started_at | date('d/m/Y à H:i') }} — + les nouveaux dons apparaissent automatiquement toutes les 10 secondes. +

+ {% endif %} + + {# ── Fil des dons ───────────────────────────────────────── #} +
+ {% if not charityStream.live_started_at %} +
+

📴

+

Le live n'a pas encore démarré.

+
+ {% elseif entries is empty %} +
+

+

En attente du premier don…

+
+ {% else %} + + {% endif %} + + {% for entry in entries %} +
+
+
+ {{ entry.pseudo }} + {{ (entry.amount / 100) | number_format(2, ',', ' ') }} € + {{ entry.received_at | date('d/m/Y H:i:s') }} +
+ {% if entry.message %} +

{{ entry.message }}

+ {% endif %} +
+
+ {% endfor %} + +
+ + +
+
+{% endblock %} +{% block scripts %} + + {% if viteEntries.live.js is defined and viteEntries.live.js %} + + {% endif %} +{% endblock %} diff --git a/tests/Unit/Controllers/AdminControllerTest.php b/tests/Unit/Controllers/AdminControllerTest.php index 915dcfb..8377f5a 100644 --- a/tests/Unit/Controllers/AdminControllerTest.php +++ b/tests/Unit/Controllers/AdminControllerTest.php @@ -13,6 +13,7 @@ use App\Repositories\EventRepository; use App\Repositories\FileManager; use App\Repositories\GoalRepository; +use App\Repositories\StreamDonationHistoryRepository; use App\Repositories\StreamRepository; use App\Repositories\UserRepository; use App\Repositories\WidgetRepository; @@ -43,6 +44,7 @@ class AdminControllerTest extends TestCase private AccessTokenRepository&MockObject $accessTokenRepository; private AuthorizationCodeRepository&MockObject $authorizationCodeRepository; private ApiClient&MockObject $mailchimp; + private StreamDonationHistoryRepository&MockObject $donationHistoryRepository; private AdminController $controller; protected function setUp(): void @@ -59,6 +61,7 @@ protected function setUp(): void $this->accessTokenRepository = $this->createMock(AccessTokenRepository::class); $this->authorizationCodeRepository = $this->createMock(AuthorizationCodeRepository::class); $this->mailchimp = $this->createMock(ApiClient::class); + $this->donationHistoryRepository = $this->createMock(StreamDonationHistoryRepository::class); $this->controller = new AdminController( $this->view, @@ -73,6 +76,7 @@ protected function setUp(): void $this->accessTokenRepository, $this->authorizationCodeRepository, $this->mailchimp, + $this->donationHistoryRepository, ); } diff --git a/tests/Unit/Controllers/WidgetControllerTest.php b/tests/Unit/Controllers/WidgetControllerTest.php index 824cc6b..6e027f5 100644 --- a/tests/Unit/Controllers/WidgetControllerTest.php +++ b/tests/Unit/Controllers/WidgetControllerTest.php @@ -12,6 +12,7 @@ use App\Repositories\EventRepository; use App\Repositories\FileManager; use App\Repositories\GoalRepository; +use App\Repositories\StreamDonationHistoryRepository; use App\Repositories\StreamRepository; use App\Repositories\WidgetRepository; use App\Services\ApiWrapper; @@ -35,6 +36,7 @@ class WidgetControllerTest extends TestCase private StreamRepository&MockObject $streamRepository; private WidgetRepository&MockObject $widgetRepository; private GoalRepository&MockObject $goalRepository; + private StreamDonationHistoryRepository&MockObject $donationHistoryRepository; private WidgetController $controller; protected function setUp(): void @@ -46,6 +48,7 @@ protected function setUp(): void $this->streamRepository = $this->createMock(StreamRepository::class); $this->widgetRepository = $this->createMock(WidgetRepository::class); $this->goalRepository = $this->createMock(GoalRepository::class); + $this->donationHistoryRepository = $this->createMock(StreamDonationHistoryRepository::class); $this->goalRepository->method('selectAmountsByStreamGuid')->willReturn([1000]); $this->goalRepository->method('selectAmountsByEventGuid')->willReturn([1000]); @@ -58,6 +61,7 @@ protected function setUp(): void $this->streamRepository, $this->widgetRepository, $this->goalRepository, + $this->donationHistoryRepository, ); }