From f21268a9f6c6b7c14c65b53a4cb206b5fe3d5eee Mon Sep 17 00:00:00 2001 From: vl Date: Sun, 20 Sep 2026 20:16:46 +0200 Subject: [PATCH] feat: add the display messages page with live translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit French in, English out, with the character counter tied to the same constant the renderer uses — so the warning and the space actually available on the panel cannot drift apart. Translation is a second call, not part of the save. A slow or broken service must never cost the shop its message: the row is stored first and marked pending, the translation follows, and a failure shows as a badge with a retry rather than as a lost notice. The status transitions are the subtle part and are pinned by tests. Editing the French clears the English, including a translation someone had corrected by hand — a translation of text that has changed is worse than no translation. Editing only the dates or the priority leaves it alone. A hand-written translation is never overwritten while its French stands, and emptying it returns the row to pending. "On the screen" is decided by the same selector the renderer uses, so the badge cannot disagree with the panel about which message is live. The preview is served by the device's own pipeline — payload, SVG, threshold — so what the admin sees is the shop window down to the last thresholded pixel. A preview drawn any other way would eventually disagree with reality, quietly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- app/admin/ScreenPreview.tsx | 40 +++ app/admin/layout.tsx | 1 + app/admin/messages/MessageManager.tsx | 313 ++++++++++++++++++++++ app/admin/messages/actions.ts | 193 +++++++++++++ app/admin/messages/page.tsx | 54 ++++ app/api/admin/preview/route.ts | 29 ++ tests/integration/messages-action.test.ts | 174 ++++++++++++ 7 files changed, 804 insertions(+) create mode 100644 app/admin/ScreenPreview.tsx create mode 100644 app/admin/messages/MessageManager.tsx create mode 100644 app/admin/messages/actions.ts create mode 100644 app/admin/messages/page.tsx create mode 100644 app/api/admin/preview/route.ts create mode 100644 tests/integration/messages-action.test.ts diff --git a/app/admin/ScreenPreview.tsx b/app/admin/ScreenPreview.tsx new file mode 100644 index 0000000..e281a87 --- /dev/null +++ b/app/admin/ScreenPreview.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useState } from 'react'; + +/** + * The panel, at a readable size. + * + * Rendered by the same code path that serves the device, so this is not an + * impression of the screen: it is the screen. `key` is bumped to defeat the + * browser cache when the data behind it has just changed. + */ +export default function ScreenPreview({ className = '' }: { className?: string }) { + const [nonce, setNonce] = useState(() => Date.now()); + + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Aperçu de l’écran de la vitrine +
+
+ 800 × 480, noir et blanc — rendu réellement envoyé à l’écran. + +
+
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 241ec64..b33efd6 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -9,6 +9,7 @@ const NAV = [ { href: '/admin/horaires', label: 'Horaires' }, { href: '/admin/exceptions', label: 'Exceptions' }, { href: '/admin/vacances', label: 'Vacances' }, + { href: '/admin/messages', label: 'Messages' }, ]; /** diff --git a/app/admin/messages/MessageManager.tsx b/app/admin/messages/MessageManager.tsx new file mode 100644 index 0000000..be518a7 --- /dev/null +++ b/app/admin/messages/MessageManager.tsx @@ -0,0 +1,313 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import ScreenPreview from '@/app/admin/ScreenPreview'; +import { MESSAGE_MAX_CHARS } from '@/lib/screen/contract'; + +import { + deleteMessage, + saveMessage, + setManualTranslation, + translateMessage, +} from './actions'; + +export type MessageRow = { + id: string; + textFr: string; + textEn: string | null; + translationStatus: 'PENDING' | 'DONE' | 'MANUAL' | 'ERROR'; + startsAt: string | null; + endsAt: string | null; + priority: number; + isActive: boolean; + isLive: boolean; +}; + +const STATUS: Record = { + PENDING: { label: 'traduction en attente', className: 'bg-[var(--surface-muted)] text-[var(--ink-muted)]' }, + DONE: { label: 'traduit', className: 'bg-[var(--success-tint)] text-[var(--success)]' }, + MANUAL: { label: 'traduction manuelle', className: 'bg-[var(--accent-tint)] text-[var(--accent-ink)]' }, + ERROR: { label: 'traduction en échec', className: 'bg-[var(--danger-tint)] text-[var(--danger)]' }, +}; + +export default function MessageManager({ + canEdit, + messages, +}: { + canEdit: boolean; + messages: MessageRow[]; +}) { + const [pending, startTransition] = useTransition(); + const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null); + + const [textFr, setTextFr] = useState(''); + const [startsAt, setStartsAt] = useState(''); + const [endsAt, setEndsAt] = useState(''); + const [priority, setPriority] = useState(0); + + const remaining = MESSAGE_MAX_CHARS - textFr.length; + + function run(work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) { + setMessage(null); + startTransition(async () => { + const result = await work(); + setMessage(result.ok ? { kind: 'ok', text: success } : { kind: 'error', text: result.error }); + }); + } + + function create() { + setMessage(null); + startTransition(async () => { + const saved = await saveMessage({ + textFr, + startsAt: startsAt || null, + endsAt: endsAt || null, + priority, + isActive: true, + }); + + if (!saved.ok) { + setMessage({ kind: 'error', text: saved.error }); + return; + } + + setTextFr(''); + setStartsAt(''); + setEndsAt(''); + setPriority(0); + setMessage({ kind: 'ok', text: 'Message enregistré. Traduction en cours…' }); + + // Translation is a separate call on purpose: a slow or broken service + // must never cost the shop its message. + const translated = await translateMessage(saved.value.id); + setMessage( + translated.ok + ? { kind: 'ok', text: 'Message enregistré et traduit.' } + : { kind: 'error', text: `Message enregistré, mais la traduction a échoué : ${translated.error}` }, + ); + }); + } + + return ( +
+ {message ? ( +

+ {message.text} +

+ ) : null} + + {canEdit ? ( +
+

Nouveau message

+ +