feat(web): UI marque blanche — page /parametres/marque + intégration
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 39s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m6s

Complète la feature marque blanche initiée dans le commit précédent
(919ebfe). L'API backend est désormais consommée par une page
dédiée dans le SPA, et le changelog v1.11.0 décrit la feature complète
plutôt que la "première brique".

Livraison côté SPA :

- `apps/web/src/lib/brand.ts` — types BrandSettings/BrandTokens (miroir
  serveur) + 5 hooks TanStack Query : useBrand (GET cache), useUpdateBrand
  (PATCH), useUploadBrandLogo (multipart), useDeleteBrandLogo, et
  useSendBrandTestEmail. Pas de retry sur le GET pour éviter de bombarder
  /brand quand l'org n'est pas Business (403 définitif).

- `apps/web/src/components/settings/BrandEmailPreview.tsx` — mock fidèle
  d'un email de relance qui réagit en direct aux color pickers. Copie la
  structure HTML/CSS de relance_email.tsx + _layout.tsx (banner, body
  pre-line, card récap, footer Rubis) pour que le user soit confiant que
  son vrai email rendra pareil.

- `apps/web/src/routes/_app/parametres_.marque.tsx` — page éditeur
  complète :
  • Header avec retour
  • Upsell card propre si l'org n'est pas Business (pas d'éditeur du tout
    pour éviter de leak des controls qui throw 403 derrière)
  • Form 2 colonnes desktop : zone upload logo (drop ou click) avec
    preview sur le bandeau effectif, input nom expéditeur, color pickers
    natifs (HTML5 + hex input) groupés en "Principales" (primary + banner)
    et "Avancées" (7 autres, accordéon fermé par défaut)
  • Live preview à droite (sticky desktop) qui se met à jour à chaque
    keystroke / pick
  • Actions : Enregistrer (diff draft → settings → PATCH), Réinitialiser
    (tous les overrides à null), Envoyer un test (qui force l'enregistrement
    préalable parce que le test utilise les tokens sauvegardés)
  • Sémantique null/undefined respectée côté patch — undefined = pas
    touché, null = reset au default Rubis sur ce champ précis

- Carte de navigation ajoutée dans `/parametres` qui linke vers
  `/parametres/marque` avec libellé adaptatif (Business = "Configurer",
  autres = "En savoir plus").

Changelog v1.11.0 réécrit pour décrire la feature complète et non plus
seulement la moitié backend. Un seul concept, une seule entrée changelog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-11 12:05:36 +02:00
parent 919ebfe755
commit 363caf8061
5 changed files with 917 additions and 9 deletions

View File

@ -1,18 +1,22 @@
---
version: "1.11.0"
date: 2026-05-11
title: "Première brique de la marque blanche"
title: "Marque blanche : vos emails à votre image"
type: feature
highlights:
- "Logo, nom expéditeur et couleurs custom dans les emails envoyés à vos clients"
- "Les utilisateurs tatillons ont la main sur 10+ tokens visuels : bandeau, fonds, textes, bordures, boutons"
- "Disponible sur le plan Business — l'interface self-service arrive dans la prochaine version"
- "Logo entreprise affiché en haut de chaque email envoyé à vos clients"
- "Nom expéditeur custom — vos clients voient `Cabinet Compta Martin`, pas Rubis"
- "10+ couleurs personnalisables : bandeau, fonds, textes, bordures, boutons, liens"
- "Aperçu live + bouton « envoyer un test » pour valider le rendu dans votre Gmail/Outlook"
- "Disponible sur le plan Business depuis `Paramètres → Marque`"
---
Le plan Business gagne sa fonctionnalité marque blanche : vos emails de relance et de remerciement partent désormais avec **votre logo, vos couleurs et votre nom** à la place du branding Rubis.
Le plan Business gagne une fonctionnalité demandée depuis longtemps : la marque blanche. Vos relances et vos remerciements partent désormais avec **votre logo, votre nom et vos couleurs** à la place du branding Rubis.
Côté visible par votre client : il reçoit un email signé `Cabinet Compta Martin` (et non Rubis), avec votre logo en haut, vos couleurs sur les boutons, et votre signature en bas. Aucun « envoyé via Rubis » nulle part — c'est votre marque, du début à la fin.
Côté visible par votre client : il reçoit un email signé `Cabinet Compta Martin` (et non Rubis), avec votre logo en haut, vos couleurs sur les boutons, et votre signature en bas. Aucun « envoyé via Rubis » nulle part. Pour lui, c'est votre boîte qui relance — pas une plateforme.
Pour les utilisateurs tatillons, on a poussé la customisation loin : couleur du bandeau, du fond, des cartes, des textes principaux et secondaires, des bordures, des liens, du texte sur les boutons. Une dizaine de tokens visuels en tout — vous gardez la main sur tout l'email.
Pour configurer, direction **Paramètres → Marque**. Deux couleurs principales suffisent pour 80 % du rendu (couleur primaire pour les boutons et accents, couleur du bandeau pour le header). Pour les utilisateurs tatillons, un panneau « Avancé » expose 7 couleurs supplémentaires : fond email, fond carte, texte principal, texte secondaire, bordures, liens, texte sur boutons.
L'interface self-service pour configurer tout ça depuis vos réglages arrive dans la prochaine version. En attendant, les utilisateurs Business peuvent déjà demander à activer leur branding manuellement.
L'aperçu en direct rend la même structure d'email que vos clients recevront. Pour valider le rendu exact dans leur boîte mail, un bouton « Envoyer un test » vous envoie un email réel avec les tokens courants — vous voyez dans votre Gmail/Outlook ce que verra votre client.
Les emails internes Rubis (la confirmation que vous avez bien été payé avant qu'une relance ne parte) restent en branding Rubis — c'est de la métacommunication produit, pas un email destiné à vos clients.

View File

@ -0,0 +1,203 @@
/**
* Aperçu live d'un email Rubis rendu avec les tokens courants. Pas le
* rendu exact de React Email (qui tourne côté serveur via @react-email/render),
* mais un mock fidèle qui réagit instantanément aux color pickers / upload.
*
* Pour le rendu RÉEL bouton "Envoyer test à mon email" qui passe par
* l'endpoint `POST /api/v1/brand/test`.
*
* Le mock copie la structure HTML/CSS de `apps/api/app/mails/_layout.tsx`
* + `relance_email.tsx` : bandeau header, body avec texte pre-line, card
* récap facture, footer Rubis. Mêmes proportions, mêmes paddings, mêmes
* border-radius pour qu'un utilisateur qui valide ici soit confiant que
* son vrai email rendra pareil.
*/
import type { BrandTokens } from "@/lib/brand";
export function BrandEmailPreview({ tokens }: { tokens: BrandTokens }) {
return (
<div
className="mx-auto overflow-hidden rounded-card shadow-card"
style={{ backgroundColor: tokens.cardBg, maxWidth: 560 }}
>
{/* Bandeau header */}
<div
style={{
backgroundColor: tokens.banner,
padding: "24px",
}}
>
{tokens.logoUrl ? (
<img
src={tokens.logoUrl}
alt={tokens.senderName}
style={{ display: "block", maxHeight: 32, width: "auto" }}
/>
) : (
<div
style={{
color: tokens.white,
fontSize: 20,
fontWeight: 800,
letterSpacing: "-0.01em",
lineHeight: 1.1,
}}
>
<span
style={{
color: tokens.primaryGlow,
marginRight: 8,
fontSize: 18,
}}
aria-hidden
>
</span>
{tokens.senderName}
</div>
)}
<div
style={{
color: tokens.primaryGlow,
fontSize: 12,
marginTop: 4,
letterSpacing: "0.04em",
textTransform: "uppercase",
fontWeight: 600,
}}
>
Facture F-2026-0042
</div>
</div>
{/* Body */}
<div style={{ padding: "24px", backgroundColor: tokens.cardBg }}>
<p
style={{
color: tokens.text,
fontSize: 15,
lineHeight: 1.6,
margin: "0 0 24px 0",
whiteSpace: "pre-line",
}}
>
Bonjour Christophe,{"\n\n"}Un petit rappel concernant la facture F-2026-0042 d'un montant de 1&nbsp;240,00&nbsp;, échue depuis 8 jours.{"\n\n"}Merci pour votre attention,{"\n\n"}
{tokens.senderName}
</p>
{/* Récap card */}
<div
style={{
backgroundColor: tokens.white,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radiusCard,
padding: "12px 16px",
}}
>
<RecapRow label="Facture" value="F-2026-0042" tokens={tokens} />
<RecapRow
label="Montant TTC"
value="1 240,00 €"
tokens={tokens}
valueStyle={{ fontSize: 18, fontWeight: 800, fontVariantNumeric: "tabular-nums" }}
/>
<RecapRow
label="Échéance"
value="12 avril 2026"
tokens={tokens}
valueStyle={{ color: tokens.primaryDeep }}
suffix={
<span style={{ color: tokens.primaryDeep, fontSize: 12, marginLeft: 8 }}>
(8j de retard)
</span>
}
/>
</div>
{/* CTA button */}
<a
href="#"
onClick={(e) => e.preventDefault()}
style={{
display: "inline-block",
marginTop: 24,
backgroundColor: tokens.primary,
color: tokens.buttonText,
padding: "12px 24px",
borderRadius: tokens.radiusButton,
textDecoration: "none",
fontWeight: 600,
fontSize: 15,
}}
>
Payer la facture en ligne
</a>
</div>
{/* Footer */}
<div
style={{
borderTop: `1px solid ${tokens.border}`,
backgroundColor: tokens.bodyBg,
padding: "16px 24px",
}}
>
<p
style={{
color: tokens.textVeryMuted,
fontSize: 11,
lineHeight: 1.5,
margin: 0,
textAlign: "center",
}}
>
Émis via{" "}
<a
href="https://rubis.pro"
target="_blank"
rel="noopener noreferrer"
style={{ color: tokens.link, fontWeight: 600, textDecoration: "none" }}
>
Rubis sur l'ongle
</a>
{" — "}
<span style={{ color: tokens.textVeryMuted }}>
vos factures relancées toutes seules pendant que vous travaillez.
</span>
</p>
</div>
</div>
);
}
function RecapRow({
label,
value,
tokens,
valueStyle,
suffix,
}: {
label: string;
value: string;
tokens: BrandTokens;
valueStyle?: React.CSSProperties;
suffix?: React.ReactNode;
}) {
return (
<div style={{ margin: "8px 0", fontSize: 13, lineHeight: 1.4 }}>
<span
style={{
display: "inline-block",
width: 110,
color: tokens.textVeryMuted,
fontWeight: 500,
}}
>
{label}
</span>
<span style={{ color: tokens.text, fontWeight: 600, ...valueStyle }}>{value}</span>
{suffix}
</div>
);
}

129
apps/web/src/lib/brand.ts Normal file
View File

@ -0,0 +1,129 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
/**
* Settings de marque blanche miroir du type BrandSettings côté API
* (`apps/api/app/services/brand.ts`). Tous les champs sont optionnels :
* undefined = laisser tel quel au PATCH, null = reset au default Rubis.
*/
export type BrandSettings = {
logoPath?: string | null;
logoUrl?: string | null;
senderName?: string | null;
primaryColor?: string | null;
bannerColor?: string | null;
bodyBgColor?: string | null;
cardBgColor?: string | null;
textColor?: string | null;
textMutedColor?: string | null;
borderColor?: string | null;
linkColor?: string | null;
buttonTextColor?: string | null;
};
/**
* Tokens résolus prêts à rendu toujours non-null après résolution
* côté API. Sert au live preview de la page marque blanche.
*/
export type BrandTokens = {
primary: string;
primaryDeep: string;
primaryLight: string;
primaryGlow: string;
banner: string;
bodyBg: string;
cardBg: string;
text: string;
textMuted: string;
textVeryMuted: string;
border: string;
link: string;
buttonText: string;
white: string;
logoUrl: string | null;
senderName: string;
fontBody: string;
radiusButton: string;
radiusCard: string;
};
export type BrandState = {
settings: BrandSettings;
tokens: BrandTokens;
defaults: BrandTokens;
};
const brandKey = ["brand"] as const;
/**
* GET /api/v1/brand auth + Business obligatoires côté serveur.
* Si l'org n'est pas Business, le call renvoie 403 `business_plan_required`
* que le composant doit catch pour afficher l'upsell.
*/
export function useBrand() {
return useQuery({
queryKey: brandKey,
queryFn: () => api.get<BrandState>("/api/v1/brand"),
staleTime: 30_000,
retry: false, // pas de retry sur 403 — c'est définitif
});
}
/**
* PATCH /api/v1/brand body partiel BrandSettings. Voir sémantique
* null/undefined dans le type BrandSettings.
*/
export function useUpdateBrand() {
const qc = useQueryClient();
return useMutation({
mutationFn: (patch: Partial<BrandSettings>) =>
api.patch<BrandState>("/api/v1/brand", patch),
onSuccess: (next) => {
qc.setQueryData(brandKey, next);
},
});
}
/**
* POST /api/v1/brand/logo multipart 'file'. Remplace l'ancien logo.
*/
export function useUploadBrandLogo() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (file: File) => {
// api.post détecte automatiquement FormData et n'injecte pas le
// Content-Type — le browser pose le bon boundary multipart.
const fd = new FormData();
fd.append("file", file);
return api.post<BrandState>("/api/v1/brand/logo", fd);
},
onSuccess: (next) => {
qc.setQueryData(brandKey, next);
},
});
}
/**
* DELETE /api/v1/brand/logo retire le logo (retour wordmark Rubis).
*/
export function useDeleteBrandLogo() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.delete<BrandState>("/api/v1/brand/logo"),
onSuccess: (next) => {
qc.setQueryData(brandKey, next);
},
});
}
/**
* POST /api/v1/brand/test envoie un mail de test au user connecté
* avec les tokens courants. Pas d'invalidation cache nécessaire.
*/
export function useSendBrandTestEmail() {
return useMutation({
mutationFn: () =>
api.post<{ ok: true; sentTo: string }>("/api/v1/brand/test"),
});
}

View File

@ -1,5 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowRight, CreditCard } from "lucide-react";
import { ArrowRight, CreditCard, Palette } from "lucide-react";
import { SettingsSection } from "@/components/settings/SettingsSection";
import { AccountForm } from "@/components/settings/AccountForm";
@ -104,6 +104,36 @@ function ParametresPage() {
</Card>
</SettingsSection>
<SettingsSection
eyebrow="Marque"
title={
<>
Vos emails à <em className="text-rubis">votre image</em>
</>
}
description="Logo, nom expéditeur et couleurs personnalisables sur les emails de relance envoyés à vos clients. Disponible sur le plan Business."
>
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Marque blanche
</p>
<p className="mt-1 font-display text-[18px] font-bold text-ink">
{sub?.plan === "business"
? "Personnaliser le branding email"
: "Réservé au plan Business"}
</p>
</div>
<Button size="sm" variant="secondary" asChild>
<Link to="/parametres/marque">
<Palette size={14} aria-hidden="true" />
{sub?.plan === "business" ? "Configurer" : "En savoir plus"}
<ArrowRight size={13} aria-hidden="true" />
</Link>
</Button>
</Card>
</SettingsSection>
<SettingsSection
eyebrow="Démonstration"
title={

View File

@ -0,0 +1,542 @@
import { useEffect, useRef, useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, ChevronDown, Image as ImageIcon, RotateCcw, Send, Trash2, Upload } from "lucide-react";
import { toast } from "sonner";
import { Button, Card, Eyebrow } from "@rubis/ui";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
import { ApiError } from "@/lib/api";
import {
type BrandSettings,
type BrandTokens,
useBrand,
useDeleteBrandLogo,
useSendBrandTestEmail,
useUpdateBrand,
useUploadBrandLogo,
} from "@/lib/brand";
import { useSubscription } from "@/lib/billing";
import { BrandEmailPreview } from "@/components/settings/BrandEmailPreview";
export const Route = createFileRoute("/_app/parametres_/marque")({
component: MarquePage,
});
/**
* /parametres/marque éditeur de marque blanche pour le plan Business.
*
* Trois zones :
* - Form (logo upload, sender name, color pickers)
* - Live preview (mock email à droite sur desktop, en-dessous sur mobile)
* - Actions (réinitialiser tout, envoyer test, enregistrer)
*
* UX :
* - Tout est édité en local d'abord (state `draft`), pas de PATCH par
* couleur le user voit le preview live sans attendre le réseau.
* - Le bouton "Enregistrer" pousse le diff (draft tokens courants).
* - "Envoyer test" envoie un mail avec les tokens **enregistrés**, pas
* le draft il faut sauver avant pour tester en réel.
* - Pour les non-Business : upsell card propre (pas d'éditeur du tout).
*/
function MarquePage() {
const sub = useSubscription();
const isBusiness = sub.data?.plan === "business";
// Si pas Business → upsell, on n'appelle même pas /api/v1/brand (403 sinon).
if (sub.isPending) {
return <PageHeader />;
}
if (!isBusiness) {
return <UpsellGate plan={sub.data?.plan ?? "free"} />;
}
return <Editor />;
}
function PageHeader() {
return (
<div className="flex flex-col gap-2 mb-8">
<Link
to="/parametres"
className="inline-flex items-center gap-1.5 text-[13px] text-ink-3 hover:text-rubis transition-colors w-fit"
>
<ArrowLeft size={14} aria-hidden />
Retour aux paramètres
</Link>
<header className="mt-2">
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
Marque blanche
</h1>
<p className="mt-1.5 max-w-[640px] text-[14px] text-ink-3">
Personnalisez le logo, le nom expéditeur et les couleurs de vos emails de relance.
Vos clients reçoivent un email à votre image aucun branding Rubis n'apparaît.
</p>
</header>
</div>
);
}
function UpsellGate({ plan }: { plan: string }) {
return (
<div className="flex flex-col gap-2">
<PageHeader />
<Card padding="lg" className="max-w-[640px]">
<Eyebrow>Plan Business requis</Eyebrow>
<h2 className="mt-3 font-display text-[22px] font-bold tracking-[-0.018em] text-ink">
Une fonctionnalité réservée au plan <em className="text-rubis">Business</em>
</h2>
<p className="mt-3 text-[14px] leading-relaxed text-ink-2">
La marque blanche fait partie du plan Business. Vous êtes actuellement sur le plan{" "}
<b className="text-ink">{plan}</b>. Passez en Business pour personnaliser le logo,
les couleurs et le nom expéditeur de vos emails de relance vos clients ne sauront
jamais que Rubis automatise les envois.
</p>
<div className="mt-5 flex flex-wrap gap-3">
<Button asChild>
<Link to="/parametres/abonnement">Voir les plans</Link>
</Button>
<Button variant="secondary" asChild>
<Link to="/parametres"> Retour aux paramètres</Link>
</Button>
</div>
</Card>
</div>
);
}
/**
* Liste ordonnée des champs couleur exposés à l'utilisateur. La séparation
* "principal" / "avancé" est purement UX : on n'effraye pas le user moyen
* avec 9 couleurs en flat, et on laisse le tatillon ouvrir le panneau
* "Avancé" pour aller chercher chaque token.
*/
const COLOR_FIELDS_MAIN: Array<{
key: keyof BrandSettings;
tokenKey: keyof BrandTokens;
label: string;
hint: string;
}> = [
{
key: "primaryColor",
tokenKey: "primary",
label: "Couleur primaire",
hint: "Boutons CTA, accents et liens par défaut.",
},
{
key: "bannerColor",
tokenKey: "banner",
label: "Bandeau header",
hint: "Fond du bandeau en haut de l'email (où apparaît votre logo).",
},
];
const COLOR_FIELDS_ADVANCED: Array<{
key: keyof BrandSettings;
tokenKey: keyof BrandTokens;
label: string;
hint: string;
}> = [
{
key: "bodyBgColor",
tokenKey: "bodyBg",
label: "Fond email",
hint: "Couleur du fond global de l'email (autour de la carte).",
},
{
key: "cardBgColor",
tokenKey: "cardBg",
label: "Fond carte",
hint: "Couleur de la zone qui contient le texte du mail.",
},
{
key: "textColor",
tokenKey: "text",
label: "Texte principal",
hint: "Couleur du corps de texte de l'email.",
},
{
key: "textMutedColor",
tokenKey: "textMuted",
label: "Texte secondaire",
hint: "Footer, métadonnées et labels discrets.",
},
{
key: "borderColor",
tokenKey: "border",
label: "Bordures",
hint: "Lignes de séparation entre sections.",
},
{
key: "linkColor",
tokenKey: "link",
label: "Liens",
hint: "Couleur des liens cliquables (par défaut = couleur primaire).",
},
{
key: "buttonTextColor",
tokenKey: "buttonText",
label: "Texte sur boutons",
hint: "Si votre couleur primaire est claire, mettez du texte noir.",
},
];
function Editor() {
const { data, isPending } = useBrand();
const update = useUpdateBrand();
const uploadLogo = useUploadBrandLogo();
const deleteLogo = useDeleteBrandLogo();
const sendTest = useSendBrandTestEmail();
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draft, setDraft] = useState<BrandSettings>({});
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync draft → settings quand on charge la donnée OU après save.
useEffect(() => {
if (data) setDraft({ ...data.settings });
}, [data?.settings]);
if (isPending || !data) {
return (
<div className="flex flex-col gap-2">
<PageHeader />
<Card padding="md">
<p className="text-[14px] text-ink-3">Chargement</p>
</Card>
</div>
);
}
// Tokens utilisés pour la preview : merge defaults ← draft (en suivant la
// sémantique "null = reset, undefined = pas touché"). On n'appelle pas
// resolveBrandTokens côté SPA, on fait un merge léger localement.
const previewTokens: BrandTokens = {
...data.defaults,
senderName: draft.senderName ?? data.defaults.senderName,
logoUrl: draft.logoUrl ?? null,
primary: draft.primaryColor ?? data.defaults.primary,
banner: draft.bannerColor ?? data.defaults.banner,
bodyBg: draft.bodyBgColor ?? data.defaults.bodyBg,
cardBg: draft.cardBgColor ?? data.defaults.cardBg,
text: draft.textColor ?? data.defaults.text,
textMuted: draft.textMutedColor ?? data.defaults.textMuted,
border: draft.borderColor ?? data.defaults.border,
link: draft.linkColor ?? draft.primaryColor ?? data.defaults.link,
buttonText: draft.buttonTextColor ?? data.defaults.buttonText,
};
const isDirty = JSON.stringify(draft) !== JSON.stringify(data.settings);
const onSave = async () => {
// Diff draft settings courants — on n'envoie que ce qui change.
const patch: Partial<BrandSettings> = {};
const allKeys = new Set([
...Object.keys(draft),
...Object.keys(data.settings),
]) as Set<keyof BrandSettings>;
for (const k of allKeys) {
if (k === "logoPath" || k === "logoUrl") continue; // gérés via upload/delete
const next = draft[k];
const prev = (data.settings as BrandSettings)[k];
if (next !== prev) {
patch[k] = next ?? null;
}
}
try {
await update.mutateAsync(patch);
toast.success("Marque enregistrée.");
} catch (err) {
if (err instanceof ApiError && err.fieldErrors) {
const first = Object.values(err.fieldErrors)[0]?.[0];
toast.error(first ?? err.message);
} else {
toast.error("Sauvegarde impossible. Réessayez.");
}
}
};
const onReset = () => {
// Pose tous les overrides à null = retour à la palette Rubis pour tout.
const reset: BrandSettings = {};
for (const f of [...COLOR_FIELDS_MAIN, ...COLOR_FIELDS_ADVANCED]) {
reset[f.key] = null;
}
reset.senderName = null;
setDraft({ ...draft, ...reset });
toast.info("Couleurs et nom expéditeur réinitialisés. N'oubliez pas d'enregistrer.");
};
const onUploadClick = () => fileInputRef.current?.click();
const onFileChosen = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
await uploadLogo.mutateAsync(file);
toast.success("Logo mis à jour.");
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Upload impossible.";
toast.error(msg);
} finally {
// Reset l'input pour qu'un upload du même fichier re-déclenche change.
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const onDeleteLogo = async () => {
try {
await deleteLogo.mutateAsync();
toast.success("Logo retiré.");
} catch {
toast.error("Impossible de retirer le logo.");
}
};
const onSendTest = async () => {
if (isDirty) {
toast.info("Enregistrez avant d'envoyer un test (le mail utilise les valeurs sauvegardées).");
return;
}
try {
const r = await sendTest.mutateAsync();
toast.success(`Test envoyé à ${r.sentTo}.`);
} catch {
toast.error("Envoi impossible. Réessayez.");
}
};
return (
<div className="flex flex-col gap-2">
<PageHeader />
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-12">
{/* ============ Form ============ */}
<div className="flex flex-col gap-8">
{/* Logo */}
<section>
<Eyebrow>Logo</Eyebrow>
<h2 className="mt-2 font-display text-[18px] font-bold text-ink">Image affichée en haut de l'email</h2>
<p className="mt-1.5 text-[13px] text-ink-3">PNG, JPG, WebP ou SVG. 1 Mo max. Hauteur affichée 32 px.</p>
<Card padding="md" className="mt-4">
{data.tokens.logoUrl ? (
<div className="flex items-center gap-4 flex-wrap">
<div
className="flex items-center justify-center rounded border border-line p-3"
style={{ backgroundColor: data.tokens.banner }}
>
<img
src={data.tokens.logoUrl}
alt="Logo actuel"
style={{ maxHeight: 32, width: "auto", display: "block" }}
/>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
onClick={onUploadClick}
loading={uploadLogo.isPending}
>
<Upload size={14} aria-hidden /> Remplacer
</Button>
<Button
size="sm"
variant="secondary"
onClick={onDeleteLogo}
loading={deleteLogo.isPending}
>
<Trash2 size={14} aria-hidden /> Retirer
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={onUploadClick}
className="flex w-full items-center justify-center gap-3 rounded-default border border-dashed border-line bg-cream-2 px-6 py-8 text-[13px] text-ink-3 hover:border-rubis hover:text-rubis transition-colors"
disabled={uploadLogo.isPending}
>
<ImageIcon size={18} aria-hidden />
<span>
{uploadLogo.isPending ? "Upload en cours…" : "Cliquer pour ajouter un logo"}
</span>
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden"
onChange={onFileChosen}
/>
</Card>
</section>
{/* Sender name */}
<section>
<Eyebrow>Nom expéditeur</Eyebrow>
<h2 className="mt-2 font-display text-[18px] font-bold text-ink">Le nom qui apparaît dans la boîte de votre client</h2>
<p className="mt-1.5 text-[13px] text-ink-3">
S'affiche dans la liste « De : » de Gmail, Outlook, etc. Par défaut, le nom de votre entreprise.
</p>
<Field
htmlFor="senderName"
label="Nom expéditeur"
hint="2 à 100 caractères. Pas de caractères spéciaux."
>
<Input
id="senderName"
value={draft.senderName ?? ""}
onChange={(e) =>
setDraft({ ...draft, senderName: e.target.value || null })
}
placeholder={data.defaults.senderName}
/>
</Field>
</section>
{/* Colors principales */}
<section>
<Eyebrow>Couleurs</Eyebrow>
<h2 className="mt-2 font-display text-[18px] font-bold text-ink">Les deux couleurs qui font 80 % du rendu</h2>
<p className="mt-1.5 text-[13px] text-ink-3">
Pour un branding qui claque, ces deux suffisent. Le reste est dans « Avancé » pour les utilisateurs tatillons.
</p>
<div className="mt-4 flex flex-col gap-3">
{COLOR_FIELDS_MAIN.map((f) => (
<ColorRow
key={f.key}
label={f.label}
hint={f.hint}
value={draft[f.key] as string | null | undefined}
defaultValue={previewTokens[f.tokenKey] as string}
onChange={(v) => setDraft({ ...draft, [f.key]: v })}
/>
))}
</div>
<button
type="button"
onClick={() => setAdvancedOpen((o) => !o)}
className="mt-5 inline-flex items-center gap-1.5 text-[13px] font-semibold text-rubis hover:text-rubis-deep transition-colors"
>
<ChevronDown
size={14}
aria-hidden
className={advancedOpen ? "rotate-180 transition-transform" : "transition-transform"}
/>
{advancedOpen ? "Cacher" : "Afficher"} les couleurs avancées
</button>
{advancedOpen && (
<div className="mt-4 flex flex-col gap-3 border-l-2 border-line pl-4">
{COLOR_FIELDS_ADVANCED.map((f) => (
<ColorRow
key={f.key}
label={f.label}
hint={f.hint}
value={draft[f.key] as string | null | undefined}
defaultValue={previewTokens[f.tokenKey] as string}
onChange={(v) => setDraft({ ...draft, [f.key]: v })}
/>
))}
</div>
)}
</section>
{/* Actions */}
<section className="flex flex-wrap items-center gap-3 pt-4 border-t border-line">
<Button onClick={onSave} loading={update.isPending} disabled={!isDirty}>
{isDirty ? "Enregistrer" : "Aucune modification"}
</Button>
<Button variant="secondary" onClick={onReset}>
<RotateCcw size={14} aria-hidden /> Réinitialiser
</Button>
<Button
variant="secondary"
onClick={onSendTest}
loading={sendTest.isPending}
disabled={isDirty}
>
<Send size={14} aria-hidden /> Envoyer un test à mon email
</Button>
</section>
</div>
{/* ============ Live preview ============ */}
<div className="lg:sticky lg:top-6 lg:self-start">
<Eyebrow>Aperçu en direct</Eyebrow>
<p className="mt-2 text-[13px] text-ink-3">
Mock d'un email de relance avec vos tokens courants. Pour valider le rendu exact dans
Gmail/Outlook : enregistrez puis « Envoyer un test ».
</p>
<div className="mt-4 rounded-card border border-line bg-cream-2 p-4 lg:p-6">
<BrandEmailPreview tokens={previewTokens} />
</div>
</div>
</div>
</div>
);
}
/** Ligne couleur — color picker natif + champ hex + label/hint. */
function ColorRow({
label,
hint,
value,
defaultValue,
onChange,
}: {
label: string;
hint: string;
value: string | null | undefined;
defaultValue: string;
onChange: (v: string | null) => void;
}) {
// valeur affichée dans le picker : override si set, sinon default. Le user
// voit toujours une couleur, pas du vide.
const display = value ?? defaultValue;
const isOverride = !!value;
return (
<div className="flex items-start gap-3">
<div className="flex items-center gap-2 shrink-0">
<input
type="color"
value={display}
onChange={(e) => onChange(e.target.value.toUpperCase())}
className="h-10 w-10 cursor-pointer rounded border border-line p-0"
aria-label={label}
/>
<input
type="text"
value={display}
onChange={(e) => {
const v = e.target.value.toUpperCase();
if (/^#[0-9A-F]{0,6}$/.test(v)) onChange(v.length === 7 ? v : value ?? null);
}}
className="h-10 w-[90px] rounded border border-line bg-white px-2 font-mono text-[13px] text-ink focus:outline-none focus:border-rubis"
aria-label={`${label} (hex)`}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-[14px] font-semibold text-ink">{label}</span>
{isOverride && (
<button
type="button"
onClick={() => onChange(null)}
className="text-[11px] text-ink-3 underline hover:text-rubis"
>
réinitialiser
</button>
)}
</div>
<p className="text-[12px] text-ink-3 leading-snug mt-0.5">{hint}</p>
</div>
</div>
);
}