From 363caf806181ffdcdb191753dd7e81eaeb2b3a85 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 11 May 2026 12:05:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20UI=20marque=20blanche=20=E2=80=94?= =?UTF-8?q?=20page=20/parametres/marque=20+=20int=C3=A9gration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/landing/src/content/changelog/1.11.0.md | 20 +- .../components/settings/BrandEmailPreview.tsx | 203 +++++++ apps/web/src/lib/brand.ts | 129 +++++ apps/web/src/routes/_app/parametres.tsx | 32 +- .../src/routes/_app/parametres_.marque.tsx | 542 ++++++++++++++++++ 5 files changed, 917 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/settings/BrandEmailPreview.tsx create mode 100644 apps/web/src/lib/brand.ts create mode 100644 apps/web/src/routes/_app/parametres_.marque.tsx diff --git a/apps/landing/src/content/changelog/1.11.0.md b/apps/landing/src/content/changelog/1.11.0.md index a415e64..ba7813a 100644 --- a/apps/landing/src/content/changelog/1.11.0.md +++ b/apps/landing/src/content/changelog/1.11.0.md @@ -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. diff --git a/apps/web/src/components/settings/BrandEmailPreview.tsx b/apps/web/src/components/settings/BrandEmailPreview.tsx new file mode 100644 index 0000000..3e1505f --- /dev/null +++ b/apps/web/src/components/settings/BrandEmailPreview.tsx @@ -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 ( +
+ {/* Bandeau header */} +
+ {tokens.logoUrl ? ( + {tokens.senderName} + ) : ( +
+ + ◆ + + {tokens.senderName} +
+ )} +
+ Facture F-2026-0042 +
+
+ + {/* Body */} +
+

+ Bonjour Christophe,{"\n\n"}Un petit rappel concernant la facture F-2026-0042 d'un montant de 1 240,00 €, échue depuis 8 jours.{"\n\n"}Merci pour votre attention,{"\n\n"} + {tokens.senderName} +

+ + {/* Récap card */} +
+ + + + (8j de retard) + + } + /> +
+ + {/* CTA button */} + 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 + +
+ + {/* Footer */} +
+

+ Émis via{" "} + + Rubis sur l'ongle + + {" — "} + + vos factures relancées toutes seules pendant que vous travaillez. + +

+
+
+ ); +} + +function RecapRow({ + label, + value, + tokens, + valueStyle, + suffix, +}: { + label: string; + value: string; + tokens: BrandTokens; + valueStyle?: React.CSSProperties; + suffix?: React.ReactNode; +}) { + return ( +
+ + {label} + + {value} + {suffix} +
+ ); +} diff --git a/apps/web/src/lib/brand.ts b/apps/web/src/lib/brand.ts new file mode 100644 index 0000000..ac2142a --- /dev/null +++ b/apps/web/src/lib/brand.ts @@ -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("/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) => + api.patch("/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("/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("/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"), + }); +} diff --git a/apps/web/src/routes/_app/parametres.tsx b/apps/web/src/routes/_app/parametres.tsx index f6ce968..a699dc4 100644 --- a/apps/web/src/routes/_app/parametres.tsx +++ b/apps/web/src/routes/_app/parametres.tsx @@ -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() { + + Vos emails à votre image + + } + description="Logo, nom expéditeur et couleurs personnalisables sur les emails de relance envoyés à vos clients. Disponible sur le plan Business." + > + +
+

+ Marque blanche +

+

+ {sub?.plan === "business" + ? "Personnaliser le branding email" + : "Réservé au plan Business"} +

+
+ +
+
+ ; + } + if (!isBusiness) { + return ; + } + return ; +} + +function PageHeader() { + return ( +
+ + + Retour aux paramètres + +
+

+ Marque blanche +

+

+ 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. +

+
+
+ ); +} + +function UpsellGate({ plan }: { plan: string }) { + return ( +
+ + + Plan Business requis +

+ Une fonctionnalité réservée au plan Business +

+

+ La marque blanche fait partie du plan Business. Vous êtes actuellement sur le plan{" "} + {plan}. 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. +

+
+ + +
+
+
+ ); +} + +/** + * 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({}); + const fileInputRef = useRef(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 ( +
+ + +

Chargement…

+
+
+ ); + } + + // 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 = {}; + const allKeys = new Set([ + ...Object.keys(draft), + ...Object.keys(data.settings), + ]) as Set; + 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) => { + 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 ( +
+ + +
+ {/* ============ Form ============ */} +
+ {/* Logo */} +
+ Logo +

Image affichée en haut de l'email

+

PNG, JPG, WebP ou SVG. 1 Mo max. Hauteur affichée 32 px.

+ + + {data.tokens.logoUrl ? ( +
+
+ Logo actuel +
+
+ + +
+
+ ) : ( + + )} + +
+
+ + {/* Sender name */} +
+ Nom expéditeur +

Le nom qui apparaît dans la boîte de votre client

+

+ S'affiche dans la liste « De : » de Gmail, Outlook, etc. Par défaut, le nom de votre entreprise. +

+ + + + setDraft({ ...draft, senderName: e.target.value || null }) + } + placeholder={data.defaults.senderName} + /> + +
+ + {/* Colors principales */} +
+ Couleurs +

Les deux couleurs qui font 80 % du rendu

+

+ Pour un branding qui claque, ces deux suffisent. Le reste est dans « Avancé » pour les utilisateurs tatillons. +

+ +
+ {COLOR_FIELDS_MAIN.map((f) => ( + setDraft({ ...draft, [f.key]: v })} + /> + ))} +
+ + + + {advancedOpen && ( +
+ {COLOR_FIELDS_ADVANCED.map((f) => ( + setDraft({ ...draft, [f.key]: v })} + /> + ))} +
+ )} +
+ + {/* Actions */} +
+ + + +
+
+ + {/* ============ Live preview ============ */} +
+ Aperçu en direct +

+ Mock d'un email de relance avec vos tokens courants. Pour valider le rendu exact dans + Gmail/Outlook : enregistrez puis « Envoyer un test ». +

+
+ +
+
+
+
+ ); +} + +/** 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 ( +
+
+ onChange(e.target.value.toUpperCase())} + className="h-10 w-10 cursor-pointer rounded border border-line p-0" + aria-label={label} + /> + { + 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)`} + /> +
+
+
+ {label} + {isOverride && ( + + )} +
+

{hint}

+
+
+ ); +}