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}

+
+
+ ); +}