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
This commit is contained in:
@@ -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 (
|
||||
<figure className={className}>
|
||||
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--line-strong)] bg-white">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
key={nonce}
|
||||
src={`/api/admin/preview?t=${nonce}`}
|
||||
alt="Aperçu de l’écran de la vitrine"
|
||||
width={800}
|
||||
height={480}
|
||||
className="block h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
<figcaption className="mt-2 flex flex-wrap items-center gap-3 text-xs text-[var(--ink-muted)]">
|
||||
<span>800 × 480, noir et blanc — rendu réellement envoyé à l’écran.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNonce(Date.now())}
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
Rafraîchir
|
||||
</button>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { diffOf, recordAudit } from '@/lib/audit';
|
||||
import { requireAdmin } from '@/lib/auth/actions';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { MESSAGE_MAX_CHARS } from '@/lib/screen/contract';
|
||||
import { translateToEnglish } from '@/lib/translation/service';
|
||||
|
||||
export type ActionResult<T = undefined> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type MessageInput = {
|
||||
id?: string;
|
||||
textFr: string;
|
||||
startsAt: string | null;
|
||||
endsAt: string | null;
|
||||
priority: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export async function saveMessage(input: MessageInput): Promise<ActionResult<{ id: string }>> {
|
||||
const gate = await requireAdmin();
|
||||
if (!gate.ok) {
|
||||
return { ok: false, error: gate.error };
|
||||
}
|
||||
|
||||
const textFr = input.textFr.trim();
|
||||
if (textFr.length === 0) {
|
||||
return { ok: false, error: 'Le message ne peut pas être vide.' };
|
||||
}
|
||||
if (textFr.length > MESSAGE_MAX_CHARS) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Le message dépasse ${MESSAGE_MAX_CHARS} caractères et ne tiendrait pas sur l’écran.`,
|
||||
};
|
||||
}
|
||||
if (input.startsAt && input.endsAt && input.startsAt > input.endsAt) {
|
||||
return { ok: false, error: 'La fin de validité précède le début.' };
|
||||
}
|
||||
|
||||
const existing = input.id
|
||||
? await prisma.message.findUnique({ where: { id: input.id } })
|
||||
: null;
|
||||
|
||||
// The French text changed, so whatever English sat next to it is stale —
|
||||
// including a translation someone had corrected by hand.
|
||||
const textChanged = existing ? existing.textFr !== textFr : true;
|
||||
|
||||
const data = {
|
||||
textFr,
|
||||
startsAt: input.startsAt ? new Date(input.startsAt) : null,
|
||||
endsAt: input.endsAt ? new Date(input.endsAt) : null,
|
||||
priority: input.priority,
|
||||
isActive: input.isActive,
|
||||
...(textChanged ? { textEn: null, translationStatus: 'PENDING' as const } : {}),
|
||||
};
|
||||
|
||||
const saved = existing
|
||||
? await prisma.message.update({ where: { id: existing.id }, data })
|
||||
: await prisma.message.create({ data: { ...data, createdBy: gate.value.email } });
|
||||
|
||||
await recordAudit({
|
||||
userEmail: gate.value.email,
|
||||
action: existing ? 'message.update' : 'message.create',
|
||||
entity: 'Message',
|
||||
entityId: saved.id,
|
||||
diff: diffOf(
|
||||
existing
|
||||
? {
|
||||
texte: existing.textFr,
|
||||
actif: existing.isActive,
|
||||
priorité: existing.priority,
|
||||
début: existing.startsAt,
|
||||
fin: existing.endsAt,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
texte: textFr,
|
||||
actif: input.isActive,
|
||||
priorité: input.priority,
|
||||
début: data.startsAt,
|
||||
fin: data.endsAt,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
revalidateAll();
|
||||
return { ok: true, value: { id: saved.id } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a saved message.
|
||||
*
|
||||
* Called separately from the save so a slow or broken translation service can
|
||||
* never cost the shop its message. Failure is recorded on the row and shown in
|
||||
* the UI with a retry, rather than raised.
|
||||
*/
|
||||
export async function translateMessage(id: string): Promise<ActionResult<{ textEn: string }>> {
|
||||
const gate = await requireAdmin();
|
||||
if (!gate.ok) {
|
||||
return { ok: false, error: gate.error };
|
||||
}
|
||||
|
||||
const message = await prisma.message.findUnique({ where: { id } });
|
||||
if (!message) {
|
||||
return { ok: false, error: 'Ce message a été supprimé.' };
|
||||
}
|
||||
if (message.translationStatus === 'MANUAL') {
|
||||
return { ok: false, error: 'La traduction a été saisie à la main; elle n’est pas écrasée.' };
|
||||
}
|
||||
|
||||
const outcome = await translateToEnglish(message.textFr);
|
||||
|
||||
if (!outcome.ok) {
|
||||
await prisma.message.update({ where: { id }, data: { translationStatus: 'ERROR' } });
|
||||
revalidateAll();
|
||||
return { ok: false, error: outcome.error };
|
||||
}
|
||||
|
||||
await prisma.message.update({
|
||||
where: { id },
|
||||
data: { textEn: outcome.text, translationStatus: 'DONE' },
|
||||
});
|
||||
|
||||
revalidateAll();
|
||||
return { ok: true, value: { textEn: outcome.text } };
|
||||
}
|
||||
|
||||
/** A hand-written translation is never overwritten while the French stands. */
|
||||
export async function setManualTranslation(id: string, textEn: string): Promise<ActionResult> {
|
||||
const gate = await requireAdmin();
|
||||
if (!gate.ok) {
|
||||
return { ok: false, error: gate.error };
|
||||
}
|
||||
|
||||
const trimmed = textEn.trim();
|
||||
const existing = await prisma.message.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return { ok: false, error: 'Ce message a été supprimé.' };
|
||||
}
|
||||
|
||||
await prisma.message.update({
|
||||
where: { id },
|
||||
data: {
|
||||
textEn: trimmed || null,
|
||||
translationStatus: trimmed ? 'MANUAL' : 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit({
|
||||
userEmail: gate.value.email,
|
||||
action: 'message.translate_manual',
|
||||
entity: 'Message',
|
||||
entityId: id,
|
||||
diff: diffOf({ anglais: existing.textEn }, { anglais: trimmed || null }),
|
||||
});
|
||||
|
||||
revalidateAll();
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
|
||||
export async function deleteMessage(id: string): Promise<ActionResult> {
|
||||
const gate = await requireAdmin();
|
||||
if (!gate.ok) {
|
||||
return { ok: false, error: gate.error };
|
||||
}
|
||||
|
||||
const existing = await prisma.message.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return { ok: false, error: 'Ce message a déjà été supprimé.' };
|
||||
}
|
||||
|
||||
await prisma.message.delete({ where: { id } });
|
||||
|
||||
await recordAudit({
|
||||
userEmail: gate.value.email,
|
||||
action: 'message.delete',
|
||||
entity: 'Message',
|
||||
entityId: id,
|
||||
diff: diffOf({ texte: existing.textFr }, null),
|
||||
});
|
||||
|
||||
revalidateAll();
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
|
||||
function revalidateAll(): void {
|
||||
revalidatePath('/admin');
|
||||
revalidatePath('/admin/messages');
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { selectActiveMessage } from '@/lib/screen/viewmodel';
|
||||
|
||||
import MessageManager, { type MessageRow } from './MessageManager';
|
||||
|
||||
export const metadata = { title: 'Messages — ITA ITO' };
|
||||
|
||||
export default async function MessagesPage() {
|
||||
const [session, rows] = await Promise.all([
|
||||
auth(),
|
||||
prisma.message.findMany({ orderBy: [{ isActive: 'desc' }, { priority: 'desc' }, { createdAt: 'desc' }] }),
|
||||
]);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Which message the screen is actually showing, decided by the same function
|
||||
// the renderer uses — so the badge cannot drift from the panel.
|
||||
const live = selectActiveMessage(
|
||||
rows.map((row) => ({
|
||||
textFr: row.textFr,
|
||||
textEn: row.textEn,
|
||||
startsAt: row.startsAt,
|
||||
endsAt: row.endsAt,
|
||||
priority: row.priority,
|
||||
isActive: row.isActive,
|
||||
})),
|
||||
now,
|
||||
);
|
||||
|
||||
const messages: MessageRow[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
textFr: row.textFr,
|
||||
textEn: row.textEn,
|
||||
translationStatus: row.translationStatus,
|
||||
startsAt: row.startsAt ? row.startsAt.toISOString().slice(0, 16).replace('T', ' ') : null,
|
||||
endsAt: row.endsAt ? row.endsAt.toISOString().slice(0, 16).replace('T', ' ') : null,
|
||||
priority: row.priority,
|
||||
isActive: row.isActive,
|
||||
isLive: live?.textFr === row.textFr && row.isActive,
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||
<h1 className="text-2xl">Messages</h1>
|
||||
<p className="mt-2 max-w-prose text-[var(--ink-muted)]">
|
||||
Le bandeau bas de l’écran. Saisi en français, traduit automatiquement en anglais. Un message
|
||||
libre prend la place du bandeau automatique « horaires exceptionnels ».
|
||||
</p>
|
||||
|
||||
<MessageManager canEdit={session?.user?.role === 'admin'} messages={messages} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { publicBaseUrl } from '@/lib/config';
|
||||
import { encodeScreen } from '@/lib/screen/encode';
|
||||
import { renderScreenSvg } from '@/lib/screen/render';
|
||||
import { buildCurrentScreen } from '@/lib/screen/service';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* The panel as it stands right now, as a PNG for the browser.
|
||||
*
|
||||
* Deliberately the same pipeline the device is served from — payload, SVG,
|
||||
* threshold — so what the admin sees is what the shop window shows, down to
|
||||
* the last thresholded pixel. A preview drawn any other way would eventually
|
||||
* disagree with reality, and quietly.
|
||||
*
|
||||
* Behind /api/admin, so the middleware requires a session.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { payload } = await buildCurrentScreen(new Date(), publicBaseUrl(request));
|
||||
const png = encodeScreen(await renderScreenSvg(payload), 'png');
|
||||
|
||||
return new Response(new Uint8Array(png), {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
// It reflects live data; a cached preview is a misleading preview.
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { MESSAGE_MAX_CHARS } from '@/lib/screen/contract';
|
||||
|
||||
import { hasDatabase, resetDatabase } from './helpers';
|
||||
|
||||
const caller = { email: 'test@ita-ito.test', role: 'admin' as const };
|
||||
const gate = vi.hoisted(() => ({
|
||||
result: { ok: true, value: { email: 'test@ita-ito.test', role: 'admin' as const } },
|
||||
}));
|
||||
const translation = vi.hoisted(() => ({
|
||||
outcome: { ok: true, text: 'Closed today', model: 'loxi:haiku' } as
|
||||
| { ok: true; text: string; model: string }
|
||||
| { ok: false; error: string },
|
||||
calls: 0,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth/actions', () => ({ requireAdmin: async () => gate.result }));
|
||||
vi.mock('next/cache', () => ({ revalidatePath: () => undefined }));
|
||||
vi.mock('@/lib/translation/service', () => ({
|
||||
translateToEnglish: async () => {
|
||||
translation.calls += 1;
|
||||
return translation.outcome;
|
||||
},
|
||||
}));
|
||||
|
||||
const { saveMessage, setManualTranslation, translateMessage, deleteMessage } = await import(
|
||||
'@/app/admin/messages/actions'
|
||||
);
|
||||
|
||||
const base = {
|
||||
textFr: 'Fermé aujourd’hui',
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
priority: 0,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
describe.skipIf(!hasDatabase)('message actions', () => {
|
||||
beforeEach(async () => {
|
||||
gate.result = { ok: true, value: caller };
|
||||
translation.outcome = { ok: true, text: 'Closed today', model: 'loxi:haiku' };
|
||||
translation.calls = 0;
|
||||
await resetDatabase();
|
||||
await prisma.auditLog.deleteMany();
|
||||
});
|
||||
|
||||
async function create(overrides = {}) {
|
||||
const result = await saveMessage({ ...base, ...overrides });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return result.value.id;
|
||||
}
|
||||
|
||||
it('saves a message awaiting translation', async () => {
|
||||
const id = await create();
|
||||
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
|
||||
|
||||
// The save does not translate: a slow service must never cost the shop
|
||||
// its message.
|
||||
expect(row.translationStatus).toBe('PENDING');
|
||||
expect(row.textEn).toBeNull();
|
||||
expect(translation.calls).toBe(0);
|
||||
});
|
||||
|
||||
it('translates on demand and records the result', async () => {
|
||||
const id = await create();
|
||||
expect(await translateMessage(id)).toMatchObject({ ok: true });
|
||||
|
||||
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
|
||||
expect(row.textEn).toBe('Closed today');
|
||||
expect(row.translationStatus).toBe('DONE');
|
||||
});
|
||||
|
||||
it('marks a failed translation without losing the message', async () => {
|
||||
const id = await create();
|
||||
translation.outcome = { ok: false, error: 'quota dépassé' };
|
||||
|
||||
const result = await translateMessage(id);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
|
||||
expect(row.translationStatus).toBe('ERROR');
|
||||
expect(row.textFr).toBe(base.textFr);
|
||||
});
|
||||
|
||||
it('keeps a hand-written translation and refuses to overwrite it', async () => {
|
||||
const id = await create();
|
||||
await setManualTranslation(id, ' Closed for the day ');
|
||||
|
||||
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
|
||||
expect(row.textEn).toBe('Closed for the day');
|
||||
expect(row.translationStatus).toBe('MANUAL');
|
||||
|
||||
const retry = await translateMessage(id);
|
||||
expect(retry.ok).toBe(false);
|
||||
expect(translation.calls).toBe(0);
|
||||
expect((await prisma.message.findUniqueOrThrow({ where: { id } })).textEn).toBe(
|
||||
'Closed for the day',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears a hand-written translation once the French changes', async () => {
|
||||
const id = await create();
|
||||
await setManualTranslation(id, 'Closed for the day');
|
||||
|
||||
await saveMessage({ ...base, id, textFr: 'Ouverture retardée' });
|
||||
|
||||
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
|
||||
// A translation of text that has changed is worse than no translation.
|
||||
expect(row.textEn).toBeNull();
|
||||
expect(row.translationStatus).toBe('PENDING');
|
||||
});
|
||||
|
||||
it('keeps the translation when only the schedule changes', async () => {
|
||||
const id = await create();
|
||||
await translateMessage(id);
|
||||
|
||||
await saveMessage({ ...base, id, priority: 5, isActive: false });
|
||||
|
||||
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
|
||||
expect(row.textEn).toBe('Closed today');
|
||||
expect(row.translationStatus).toBe('DONE');
|
||||
expect(row.priority).toBe(5);
|
||||
expect(row.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('empties a translation back to pending', async () => {
|
||||
const id = await create();
|
||||
await setManualTranslation(id, 'Closed for the day');
|
||||
await setManualTranslation(id, ' ');
|
||||
|
||||
const row = await prisma.message.findUniqueOrThrow({ where: { id } });
|
||||
expect(row.textEn).toBeNull();
|
||||
expect(row.translationStatus).toBe('PENDING');
|
||||
});
|
||||
|
||||
it('refuses an empty message and one that would not fit', async () => {
|
||||
expect((await saveMessage({ ...base, textFr: ' ' })).ok).toBe(false);
|
||||
expect((await saveMessage({ ...base, textFr: 'a'.repeat(MESSAGE_MAX_CHARS + 1) })).ok).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await prisma.message.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a validity window that ends before it starts', async () => {
|
||||
const result = await saveMessage({
|
||||
...base,
|
||||
startsAt: '2026-10-01T10:00',
|
||||
endsAt: '2026-09-01T10:00',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes a message and logs what it said', async () => {
|
||||
const id = await create();
|
||||
await prisma.auditLog.deleteMany();
|
||||
|
||||
expect(await deleteMessage(id)).toMatchObject({ ok: true });
|
||||
expect(await prisma.message.count()).toBe(0);
|
||||
|
||||
const entry = await prisma.auditLog.findFirst();
|
||||
expect(entry?.action).toBe('message.delete');
|
||||
expect(entry?.diff).toMatchObject({ texte: { before: base.textFr, after: null } });
|
||||
});
|
||||
|
||||
it('refuses a read-only account', async () => {
|
||||
gate.result = { ok: false, error: 'Ce compte est en lecture seule.' } as never;
|
||||
expect((await saveMessage(base)).ok).toBe(false);
|
||||
expect(await prisma.message.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user