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

+ +