mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-23 23:28:04 +02:00
L'application publie le fichier tel quel sur Facebook et LinkedIn : aucun recadrage, aucune marge. Un visuel qui n'est pas au format attendu est donc recadré — ou entouré de bandes de couleur prises dans l'image — par la plateforme, ce qui ne se voit nulle part côté site puisque les cartes recadrent en `cover`. Le formulaire de dépôt affiche maintenant les dimensions, le format et le poids du fichier choisi, et dit ce que les réseaux en feront : format non carré, image trop petite pour un fil d'actualité, fichier trop lourd pour l'envoi. Purement informatif, rien n'est bloqué. `src/lib/image-info.ts` est pur et verrouillé par `tests/image-info.test.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U
494 lines
20 KiB
TypeScript
494 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { publishPromo } from "../actions";
|
||
import { PromoImage } from "@/components/PromoImage";
|
||
import { MemberAvatar } from "@/components/MemberAvatar";
|
||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||
import type { SocialNetwork } from "@/lib/social";
|
||
import type { PromoCategoryGroup } from "@/lib/promo-categories";
|
||
import { formatValidity, isRangeInvalid } from "@/lib/promo-validity";
|
||
import { formatSchedule, parseScheduleInput } from "@/lib/promo-schedule";
|
||
import { imageNotes, imageSummary, type ImageInfo } from "@/lib/image-info";
|
||
|
||
const STRIPE_WARM =
|
||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||
|
||
const NETWORK_LABELS: Record<SocialNetwork, string> = {
|
||
facebook: "Facebook",
|
||
linkedin: "LinkedIn",
|
||
};
|
||
|
||
/** Une ligne du récapitulatif de dates, à droite du formulaire. */
|
||
function SummaryRow({
|
||
label,
|
||
value,
|
||
tone,
|
||
last = false,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
tone: "set" | "muted" | "error";
|
||
last?: boolean;
|
||
}) {
|
||
const color = tone === "error" ? "#d8472b" : tone === "set" ? "#3c3322" : "#a99c82";
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "baseline",
|
||
justifyContent: "space-between",
|
||
gap: 14,
|
||
padding: "12px 0",
|
||
borderBottom: last ? "none" : "1px solid #f0e8d6",
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 12.5, color: "#9a8d72", fontWeight: 700, whiteSpace: "nowrap" }}>{label}</span>
|
||
<span style={{ fontSize: 13, color, fontWeight: tone === "muted" ? 500 : 700, textAlign: "right" }}>
|
||
{value}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Taille, format et poids de l'image déposée, avec ce que les réseaux en
|
||
* feront. Purement informatif : aucune remarque n'empêche l'envoi.
|
||
*/
|
||
function ImageReport({ info }: { info: ImageInfo }) {
|
||
const notes = imageNotes(info);
|
||
return (
|
||
<div
|
||
style={{
|
||
marginTop: -8,
|
||
marginBottom: 18,
|
||
background: "#faf7ef",
|
||
border: "1px solid #f0e8d6",
|
||
borderRadius: 12,
|
||
padding: "10px 12px",
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 12.5, fontWeight: 700, color: "#6c6150" }}>{imageSummary(info)}</div>
|
||
{notes.map((note, i) => (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
fontSize: 11.5,
|
||
lineHeight: 1.45,
|
||
marginTop: 5,
|
||
color: note.tone === "warn" ? "#a8503c" : "#1f8a5b",
|
||
}}
|
||
>
|
||
{note.text}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function MemberSpaceForm({
|
||
memberName,
|
||
memberLogoUrl,
|
||
categoryGroups,
|
||
defaultCategory,
|
||
networks = [],
|
||
}: {
|
||
memberName: string;
|
||
/** Logo de l'adhérent, affiché dans l'angle de l'aperçu comme sur le site. */
|
||
memberLogoUrl?: string | null;
|
||
/** Types de produits / services, groupés pour le `<select>`. */
|
||
categoryGroups: PromoCategoryGroup[];
|
||
/** Pré-sélection déduite du métier de l'adhérent. */
|
||
defaultCategory?: string;
|
||
networks?: SocialNetwork[];
|
||
}) {
|
||
const allCategories = categoryGroups.flatMap((g) => g.items);
|
||
const initialCategory = defaultCategory && allCategories.includes(defaultCategory) ? defaultCategory : allCategories[0] ?? "Autre";
|
||
const router = useRouter();
|
||
const [title, setTitle] = useState("");
|
||
const [text, setText] = useState("");
|
||
const [cat, setCat] = useState(initialCategory);
|
||
const [badge, setBadge] = useState("");
|
||
const [startsOn, setStartsOn] = useState("");
|
||
const [endsOn, setEndsOn] = useState("");
|
||
const [publishAt, setPublishAt] = useState("");
|
||
const [imgData, setImgData] = useState("");
|
||
// Dimensions et poids du fichier déposé : rien n'est retouché à l'envoi, donc
|
||
// c'est ici — dernier moment où l'adhérent peut changer de visuel — qu'on lui
|
||
// dit ce que les réseaux feront d'un format inattendu.
|
||
const [imgInfo, setImgInfo] = useState<ImageInfo | null>(null);
|
||
const [shares, setShares] = useState<SocialNetwork[]>([]);
|
||
const [submitted, setSubmitted] = useState(false);
|
||
const [pending, setPending] = useState(false);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
function toggleShare(network: SocialNetwork) {
|
||
setShares((s) => (s.includes(network) ? s.filter((n) => n !== network) : [...s, network]));
|
||
setSubmitted(false);
|
||
}
|
||
|
||
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const f = e.target.files?.[0];
|
||
if (!f) return;
|
||
const bytes = f.size;
|
||
setImgInfo(null);
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const dataUrl = String(reader.result);
|
||
setImgData(dataUrl);
|
||
setSubmitted(false);
|
||
// Les dimensions ne sont lisibles qu'une fois l'image décodée par le
|
||
// navigateur. Un échec de décodage laisse simplement le résumé vide :
|
||
// l'information est un confort, elle ne conditionne pas le dépôt.
|
||
const probe = new Image();
|
||
probe.onload = () => setImgInfo({ width: probe.naturalWidth, height: probe.naturalHeight, bytes });
|
||
probe.onerror = () => setImgInfo(null);
|
||
probe.src = dataUrl;
|
||
};
|
||
reader.readAsDataURL(f);
|
||
}
|
||
|
||
function reset() {
|
||
setTitle("");
|
||
setText("");
|
||
setBadge("");
|
||
setStartsOn("");
|
||
setEndsOn("");
|
||
setPublishAt("");
|
||
setImgData("");
|
||
setImgInfo(null);
|
||
setShares([]);
|
||
setSubmitted(false);
|
||
if (fileRef.current) fileRef.current.value = "";
|
||
}
|
||
|
||
async function submit() {
|
||
if (!title.trim()) return;
|
||
setPending(true);
|
||
const fd = new FormData();
|
||
fd.set("title", title);
|
||
fd.set("text", text);
|
||
fd.set("category", cat);
|
||
fd.set("badge", badge);
|
||
fd.set("startsOn", startsOn);
|
||
fd.set("endsOn", endsOn);
|
||
fd.set("publishAt", publishAt);
|
||
fd.set("imageUrl", imgData);
|
||
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
|
||
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
|
||
await publishPromo(fd);
|
||
setPending(false);
|
||
setSubmitted(true);
|
||
reset();
|
||
setSubmitted(true);
|
||
router.refresh();
|
||
}
|
||
|
||
const hasImg = !!imgData;
|
||
const hasBadge = !!badge.trim();
|
||
const validity = formatValidity({ startsOn, endsOn });
|
||
// Bornes incohérentes : on le dit avant l'envoi plutôt qu'après un refus.
|
||
const badRange = isRangeInvalid({ startsOn, endsOn });
|
||
// Aperçu de la programmation. Le champ donne une heure locale : on la relit
|
||
// avec le même formateur que le backoffice pour éviter toute surprise.
|
||
let scheduleHint: string | null = null;
|
||
try {
|
||
scheduleHint = formatSchedule(parseScheduleInput(publishAt));
|
||
} catch {
|
||
scheduleHint = null;
|
||
}
|
||
|
||
return (
|
||
<div className="grid grid-2" style={{ alignItems: "start" }}>
|
||
{/* FORM */}
|
||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 24 }}>
|
||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 4px", color: "#26201a" }}>
|
||
Publier une promotion
|
||
</h3>
|
||
<p style={{ margin: "0 0 20px", fontSize: 13.5, color: "#9a8d72" }}>
|
||
Ajoutez une image et un texte. Votre promotion sera visible après validation par
|
||
l'association.
|
||
</p>
|
||
|
||
<label className="field-label">Image de l'offre</label>
|
||
<label style={{ display: "block", cursor: "pointer", marginBottom: 18 }}>
|
||
<div
|
||
style={{
|
||
position: "relative",
|
||
border: "2px dashed #d8cdb4",
|
||
borderRadius: 14,
|
||
background: "#faf7ef",
|
||
height: 170,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: 8,
|
||
textAlign: "center",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{hasImg && <PromoImage src={imgData} alt="" />}
|
||
{!hasImg && (
|
||
<>
|
||
<span style={{ width: 38, height: 38, borderRadius: 10, background: "#f0e8d6", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||
<span style={{ width: 15, height: 15, border: "2px solid #b3a888", borderRadius: 4 }} />
|
||
</span>
|
||
<div style={{ fontSize: 13.5, fontWeight: 700, color: "#6c6150" }}>
|
||
Cliquez pour choisir une image
|
||
</div>
|
||
<div style={{ fontSize: 12, color: "#a99c82" }}>JPG ou PNG · image carrée conseillée (ex. 1080×1080)</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
|
||
</label>
|
||
|
||
{imgInfo && <ImageReport info={imgInfo} />}
|
||
|
||
<label className="field-label">Titre de la promotion</label>
|
||
<input
|
||
value={title}
|
||
onChange={(e) => {
|
||
setTitle(e.target.value);
|
||
setSubmitted(false);
|
||
}}
|
||
placeholder="ex : Le 13e pain offert"
|
||
className="field"
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
|
||
<div style={{ display: "flex", gap: 12, marginBottom: 16 }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label className="field-label">Type de produit ou de service</label>
|
||
<select value={cat} onChange={(e) => setCat(e.target.value)} className="field">
|
||
{categoryGroups.map((group) => (
|
||
<optgroup key={group.label} label={group.label}>
|
||
{group.items.map((c) => (
|
||
<option key={c} value={c}>
|
||
{c}
|
||
</option>
|
||
))}
|
||
</optgroup>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label className="field-label">Réduction (badge)</label>
|
||
<input value={badge} onChange={(e) => setBadge(e.target.value)} placeholder="ex : -20%" className="field" />
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: 16 }}>
|
||
<label className="field-label">Durée de validité · facultatif</label>
|
||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||
<div style={{ flex: "1 1 150px" }}>
|
||
<input
|
||
type="date"
|
||
value={startsOn}
|
||
onChange={(e) => { setStartsOn(e.target.value); setSubmitted(false); }}
|
||
className="field"
|
||
aria-label="Début de la promotion"
|
||
/>
|
||
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 4 }}>Début</div>
|
||
</div>
|
||
<div style={{ flex: "1 1 150px" }}>
|
||
<input
|
||
type="date"
|
||
value={endsOn}
|
||
min={startsOn || undefined}
|
||
onChange={(e) => { setEndsOn(e.target.value); setSubmitted(false); }}
|
||
className="field"
|
||
aria-label="Fin de la promotion"
|
||
/>
|
||
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 4 }}>Fin</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ fontSize: 12, color: badRange ? "#d8472b" : "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
|
||
{badRange
|
||
? "La date de fin est antérieure à la date de début."
|
||
: validity
|
||
? `Affiché sur le site et dans la publication : « ${validity} ». L'offre n'apparaît que pendant cette période.`
|
||
: "Laissez vide si votre offre n'a pas de date limite : elle restera affichée sans échéance."}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: 16 }}>
|
||
<label className="field-label">Programmer la publication · facultatif</label>
|
||
<input
|
||
type="datetime-local"
|
||
value={publishAt}
|
||
onChange={(e) => { setPublishAt(e.target.value); setSubmitted(false); }}
|
||
className="field"
|
||
aria-label="Date et heure de publication souhaitées"
|
||
/>
|
||
<div style={{ fontSize: 12, color: "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
|
||
{scheduleHint
|
||
? `Mise en ligne demandée ${scheduleHint} — site et réseaux en même temps. L'association reste libre de la déplacer.`
|
||
: "Laissez vide pour une mise en ligne dès la validation par l'association."}
|
||
</div>
|
||
</div>
|
||
|
||
<label className="field-label">Texte de la promotion</label>
|
||
<textarea
|
||
value={text}
|
||
onChange={(e) => {
|
||
setText(e.target.value.slice(0, 240));
|
||
setSubmitted(false);
|
||
}}
|
||
placeholder="Décrivez votre offre, les conditions, la durée…"
|
||
rows={4}
|
||
className="field"
|
||
style={{ resize: "vertical", marginBottom: 6 }}
|
||
/>
|
||
<div style={{ fontSize: 12, color: "#a99c82", marginBottom: 18 }}>{text.length} / 240 caractères</div>
|
||
|
||
{networks.length > 0 && (
|
||
<div style={{ border: "1px solid #f0e8d6", background: "#faf7ef", borderRadius: 12, padding: "14px 15px", marginBottom: 18 }}>
|
||
<div className="field-label" style={{ marginBottom: 4 }}>
|
||
Diffusion sur les réseaux Plein R
|
||
</div>
|
||
<div style={{ fontSize: 12.5, color: "#8c8068", lineHeight: 1.5, marginBottom: 11 }}>
|
||
Choisissez où l'association relaiera votre offre. La publication n'a lieu
|
||
qu'une fois la promotion validée — et le choix n'est plus modifiable ensuite.
|
||
</div>
|
||
<div style={{ display: "flex", gap: 9, flexWrap: "wrap" }}>
|
||
{networks.map((network) => {
|
||
const active = shares.includes(network);
|
||
return (
|
||
<label
|
||
key={network}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 9,
|
||
cursor: "pointer",
|
||
padding: "9px 14px",
|
||
borderRadius: 11,
|
||
border: `1px solid ${active ? SOCIAL_BRAND[network] : "#e6dcc6"}`,
|
||
background: active ? "#fff" : "transparent",
|
||
color: active ? SOCIAL_BRAND[network] : "#6c6150",
|
||
fontSize: 13.5,
|
||
fontWeight: 700,
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={active}
|
||
onChange={() => toggleShare(network)}
|
||
style={{ accentColor: SOCIAL_BRAND[network], margin: 0 }}
|
||
/>
|
||
<SocialIcon network={network} size={16} />
|
||
{NETWORK_LABELS[network]}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: "flex", gap: 10 }}>
|
||
<button
|
||
onClick={submit}
|
||
disabled={pending || badRange}
|
||
className="font-display"
|
||
style={{ flex: 1, border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 15, padding: 14, borderRadius: 11, cursor: pending ? "default" : "pointer", opacity: pending ? 0.7 : 1 }}
|
||
>
|
||
{pending ? "Envoi…" : "Soumettre pour validation"}
|
||
</button>
|
||
<button
|
||
onClick={reset}
|
||
type="button"
|
||
className="font-display"
|
||
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 15, padding: "14px 18px", borderRadius: 11, cursor: "pointer" }}
|
||
>
|
||
Effacer
|
||
</button>
|
||
</div>
|
||
|
||
{submitted && (
|
||
<div style={{ marginTop: 14, background: "#e6f4ec", border: "1px solid #b6e0c8", color: "#1f8a5b", borderRadius: 10, padding: "12px 14px", fontSize: 13.5, fontWeight: 600 }}>
|
||
✓ Promotion envoyée ! Elle apparaîtra sur le site une fois validée par l'association.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* LIVE PREVIEW */}
|
||
<div>
|
||
{/* Récapitulatif des dates : quand l'offre paraît, combien de temps
|
||
elle reste. Deux informations que la carte ne montre pas. */}
|
||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
|
||
Dates de votre offre
|
||
</div>
|
||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "4px 16px", marginBottom: 26 }}>
|
||
<SummaryRow
|
||
label="Durée de validité"
|
||
value={badRange ? "Dates incohérentes" : validity ?? "Sans date limite"}
|
||
tone={badRange ? "error" : validity ? "set" : "muted"}
|
||
/>
|
||
<SummaryRow
|
||
label="Publication"
|
||
value={scheduleHint ? `Programmée ${scheduleHint}` : "Dès la validation par l'association"}
|
||
tone={scheduleHint ? "set" : "muted"}
|
||
last
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
|
||
Aperçu en direct
|
||
</div>
|
||
{/* Largeur bornée : la carte doit ressembler à celle du site, pas
|
||
occuper toute la colonne (le visuel carré la rendait démesurée). */}
|
||
<article style={{ maxWidth: 320, background: "#fff", border: "1px solid #ece3d0", borderRadius: 18, overflow: "hidden", boxShadow: "0 18px 40px -26px rgba(40,30,15,0.5)" }}>
|
||
<div
|
||
style={{
|
||
position: "relative",
|
||
aspectRatio: "1 / 1",
|
||
background: STRIPE_WARM,
|
||
overflow: "hidden",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
{hasImg && <PromoImage src={imgData} alt={title} />}
|
||
{!hasImg && (
|
||
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
|
||
photo de l'offre
|
||
</span>
|
||
)}
|
||
{hasBadge && (
|
||
<span className="font-display" style={{ position: "absolute", top: 0, right: 0, background: "#d8472b", color: "#fff", fontWeight: 800, fontSize: 18, padding: "8px 15px", borderRadius: "0 0 0 16px" }}>
|
||
{badge}
|
||
</span>
|
||
)}
|
||
<span style={{ position: "absolute", top: 12, left: 12, background: "#9a6638", color: "#fff", borderRadius: 999, padding: "5px 12px", fontSize: 11, fontWeight: 700 }}>
|
||
{cat}
|
||
</span>
|
||
</div>
|
||
<div style={{ padding: "17px 18px 18px" }}>
|
||
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
|
||
{title.trim() || "Titre de votre promotion"}
|
||
</h3>
|
||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5 }}>
|
||
{text.trim() ||
|
||
"Le texte de votre offre apparaîtra ici au fur et à mesure que vous le rédigez."}
|
||
</p>
|
||
{validity && (
|
||
<div style={{ fontSize: 12, color: "#a99c82", marginBottom: 12 }}>{validity}</div>
|
||
)}
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
|
||
<MemberAvatar name={memberName} logoUrl={memberLogoUrl} size={28} />
|
||
<span style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</span>
|
||
</div>
|
||
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>Voir l'offre →</span>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|