Files
ita-ito-horaires/app/admin/messages/MessageManager.tsx
T
vliaudatandClaude Opus 5 f21268a9f6 feat: add the display messages page with live translation
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 20:16:46 +02:00

314 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<MessageRow['translationStatus'], { label: string; className: string }> = {
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 (
<div className="mt-8 space-y-8">
{message ? (
<p
role="status"
className={`rounded-[var(--radius-md)] px-4 py-3 text-sm ${
message.kind === 'ok'
? 'bg-[var(--success-tint)] text-[var(--success)]'
: 'bg-[var(--danger-tint)] text-[var(--danger)]'
}`}
>
{message.text}
</p>
) : null}
{canEdit ? (
<section className="rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)] p-5">
<h2 className="text-lg">Nouveau message</h2>
<label className="mt-4 block text-sm">
<span className="block text-[var(--ink-muted)]">Texte en français</span>
<textarea
value={textFr}
rows={2}
onChange={(event) => setTextFr(event.target.value)}
placeholder="Nouvelle collection en boutique"
className="mt-1 w-full rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-3 py-2 text-base"
/>
</label>
<p
className={`mt-1 text-sm ${
remaining < 0 ? 'text-[var(--danger)]' : 'text-[var(--ink-muted)]'
}`}
>
{remaining >= 0
? `${remaining} caractère${remaining > 1 ? 's' : ''} restant${remaining > 1 ? 's' : ''}.`
: `${-remaining} caractère${-remaining > 1 ? 's' : ''} de trop : le message serait coupé sur l’écran.`}
</p>
<div className="mt-4 flex flex-wrap items-end gap-3">
<label className="text-sm">
<span className="block text-[var(--ink-muted)]">Afficher à partir du (facultatif)</span>
<input
type="datetime-local"
value={startsAt}
onChange={(event) => setStartsAt(event.target.value)}
className="mt-1 rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base"
/>
</label>
<label className="text-sm">
<span className="block text-[var(--ink-muted)]">Jusqu’au (facultatif)</span>
<input
type="datetime-local"
value={endsAt}
onChange={(event) => setEndsAt(event.target.value)}
className="mt-1 rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base"
/>
</label>
<label className="text-sm">
<span className="block text-[var(--ink-muted)]">Priorité</span>
<input
type="number"
value={priority}
min={0}
max={99}
onChange={(event) => setPriority(Number(event.target.value))}
className="mt-1 w-20 rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base tabular-nums"
/>
</label>
</div>
<p className="mt-2 text-sm text-[var(--ink-muted)]">
Sans dates, le message est permanent. Le plus prioritaire parmi les messages valides
occupe le bandeau.
</p>
<button
type="button"
disabled={pending || textFr.trim().length === 0 || remaining < 0}
onClick={create}
className="mt-4 rounded-[var(--radius-md)] bg-[var(--accent)] px-4 py-2.5 font-medium text-[var(--on-accent)] transition-colors hover:bg-[var(--accent-hover)] disabled:opacity-40"
>
{pending ? 'Enregistrement…' : 'Ajouter'}
</button>
</section>
) : null}
<section>
<h2 className="text-lg">Messages</h2>
{messages.length === 0 ? (
<p className="mt-3 text-sm text-[var(--ink-muted)]">Aucun message enregistré.</p>
) : (
<ul className="mt-3 space-y-3">
{messages.map((row) => (
<MessageCard
key={row.id}
row={row}
canEdit={canEdit}
pending={pending}
run={run}
startTransition={startTransition}
setMessage={setMessage}
/>
))}
</ul>
)}
</section>
<section>
<h2 className="text-lg">Aperçu de l’écran</h2>
<ScreenPreview className="mt-3" />
</section>
</div>
);
}
function MessageCard({
row,
canEdit,
pending,
run,
startTransition,
setMessage,
}: {
row: MessageRow;
canEdit: boolean;
pending: boolean;
run: (work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) => void;
startTransition: (work: () => void) => void;
setMessage: (value: { kind: 'ok' | 'error'; text: string } | null) => void;
}) {
const [draftEn, setDraftEn] = useState(row.textEn ?? '');
const badge = STATUS[row.translationStatus];
return (
<li className="rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)] p-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<p className="font-medium">{row.textFr}</p>
{row.isLive ? (
<span className="rounded-[var(--radius-sm)] bg-[var(--accent)] px-2 py-0.5 text-xs text-[var(--on-accent)]">
sur l’écran
</span>
) : null}
{!row.isActive ? (
<span className="rounded-[var(--radius-sm)] bg-[var(--surface-muted)] px-2 py-0.5 text-xs text-[var(--ink-muted)]">
désactivé
</span>
) : null}
<span className={`rounded-[var(--radius-sm)] px-2 py-0.5 text-xs ${badge.className}`}>
{badge.label}
</span>
</div>
<p className="mt-1 text-sm text-[var(--ink-muted)]">
{row.startsAt || row.endsAt
? `Valide ${row.startsAt ? `du ${row.startsAt}` : ''} ${row.endsAt ? `au ${row.endsAt}` : ''}`.trim()
: 'Permanent'}
{row.priority > 0 ? ` · priorité ${row.priority}` : ''}
</p>
{canEdit ? (
<div className="mt-3 flex flex-wrap items-end gap-2">
<label className="min-w-56 flex-1 text-sm">
<span className="block text-[var(--ink-muted)]">Traduction anglaise</span>
<input
type="text"
value={draftEn}
onChange={(event) => setDraftEn(event.target.value)}
placeholder={row.translationStatus === 'ERROR' ? 'Traduction indisponible' : '—'}
className="mt-1 w-full rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-1.5 text-base"
/>
</label>
<button
type="button"
disabled={pending || draftEn === (row.textEn ?? '')}
onClick={() =>
run(
() => setManualTranslation(row.id, draftEn),
'Traduction enregistrée; elle ne sera plus écrasée.',
)
}
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] px-3 py-1.5 text-sm hover:bg-[var(--surface-muted)] disabled:opacity-40"
>
Enregistrer
</button>
<button
type="button"
disabled={pending}
onClick={() => {
setMessage(null);
startTransition(async () => {
const result = await translateMessage(row.id);
setMessage(
result.ok
? { kind: 'ok', text: 'Traduction mise à jour.' }
: { kind: 'error', text: result.error },
);
});
}}
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] px-3 py-1.5 text-sm hover:bg-[var(--surface-muted)] disabled:opacity-40"
>
{row.translationStatus === 'ERROR' ? 'Réessayer' : 'Retraduire'}
</button>
<button
type="button"
disabled={pending}
onClick={() => run(() => deleteMessage(row.id), 'Message supprimé.')}
className="ml-auto text-sm text-[var(--ink-muted)] underline-offset-2 hover:text-[var(--danger)] hover:underline disabled:opacity-40"
>
Supprimer
</button>
</div>
) : row.textEn ? (
<p className="mt-2 text-sm text-[var(--ink-muted)]">{row.textEn}</p>
) : null}
</li>
);
}