rubis/apps/api/app/mails/relance_email.tsx
ordinarthur 919ebfe755
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 36s
Build & Deploy API / build-and-deploy (push) Successful in 1m36s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m18s
feat(release): v1.11.0 — marque blanche pour le plan Business (backend)
Première moitié de la feature marque blanche : la machinerie complète qui
permet à un compte Business d'envoyer ses emails de relance avec son
propre logo, ses propres couleurs et son nom comme expéditeur, à la place
du branding Rubis.

Architecture :

- Nouvelle colonne JSONB `organizations.brand_settings` (12 tokens
  customisables : logo, senderName, et 10 couleurs — primary, banner,
  body bg, card bg, text, text muted, border, link, button text).
  Null = palette Rubis intacte. Validation hex stricte (#RRGGBB).

- Service `#services/brand` avec `resolveBrandTokens(org)` qui merge
  defaults + overrides en respectant le plan (couleurs/logo = Business
  only ; senderName = cascade pour tous les plans). Mergeurs avec
  sémantique "null = reset au default sur ce champ précis" pour les
  patches partiels.

- Service mutualisé `#services/media_storage` qui remplace l'ancien
  `blog_uploads.ts`. Scopes `blog` (4 MB, jpg/png/webp) et `brand-logo`
  (1 MB, + svg accepté). Cleanup automatique du logo précédent lors
  d'un remplacement (pas de versioning — la conv produit est "on écrase").

- Controller `BrandController` (5 endpoints) + middleware
  `AssertBusinessPlanMiddleware` qui throw 403 `business_plan_required`
  (code matché par le SPA pour l'upsell card).

- Refactor des 3 templates mail (relance, payment thanks, checkin) +
  layout commun pour accepter `tokens: BrandTokens` en prop. Le
  dispatcher résout les tokens per-org pour relance + remerciement
  (= user → client, branded), et passe `DEFAULT_BRAND` au checkin
  (= Rubis → user, toujours Rubis-branded).

- Routes publiques pour le logo : `/api/v1/uploads/brand-logos/:filename`
  (sans auth, cache immutable, X-Content-Type-Options: nosniff pour les SVG).

UI self-service arrive dans la prochaine version (v1.12.0). En attendant,
un compte Business peut être configuré via Bruno / API directe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 11:37:07 +02:00

136 lines
3.9 KiB
TypeScript

/**
* Template email de relance — envoyé AU CLIENT FINAL pour réclamer le
* paiement d'une facture impayée. Le subject + le body sont définis par
* le user dans son plan de relance (avec variables {{numero}} etc.) ; on
* injecte ce body brut dans une mise en page Rubis :
*
* - Header avec logo customisé (Business) ou wordmark "◆ <senderName>"
* - Body rendu (le texte que le user a rédigé, déjà interpolé)
* - Card "récap facture" en pied de body : numéro, montant, échéance
*
* Tokens visuels passés en prop `tokens` (résolus par org via
* `#services/brand`). Plan Business = couleurs / logo / nom expéditeur
* custom. Sinon = palette Rubis par défaut.
*/
import * as React from 'react'
import { Section, Text } from '@react-email/components'
import type { BrandTokens } from '#services/brand'
import { sp } from './_brand.js'
import { EmailLayout } from './_layout.js'
export type RelanceEmailProps = {
/** Tokens résolus pour l'org (cf. resolveBrandTokens). */
tokens: BrandTokens
invoice: {
numero: string
amountFormatted: string
dueDateFormatted: string
daysLate: number
}
/** Texte de la relance (déjà interpolé) que le user a posé dans son plan. */
bodyText: string
/** URL landing publique (footer cliquable "Rubis sur l'ongle"). null = footer masqué. */
landingUrl?: string | null
/** Masque le footer Rubis (marque blanche full). */
hideRubisFooter?: boolean
}
export function RelanceEmail({
tokens,
invoice,
bodyText,
landingUrl,
hideRubisFooter,
}: RelanceEmailProps) {
const isLate = invoice.daysLate > 0
const bodyTextStyle: React.CSSProperties = {
color: tokens.text,
fontSize: '15px',
lineHeight: '1.6',
margin: `0 0 ${sp.xl} 0`,
whiteSpace: 'pre-line',
}
const summaryCardStyle: React.CSSProperties = {
backgroundColor: tokens.white,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radiusCard,
padding: `${sp.md} ${sp.lg}`,
margin: `${sp.lg} 0 0 0`,
}
const summaryRowStyle: React.CSSProperties = {
display: 'block',
margin: `${sp.sm} 0`,
fontSize: '13px',
lineHeight: '1.4',
}
const summaryLabelStyle: React.CSSProperties = {
display: 'inline-block',
width: '110px',
color: tokens.textVeryMuted,
fontWeight: 500,
}
const summaryValueStyle: React.CSSProperties = {
color: tokens.text,
fontWeight: 600,
}
return (
<EmailLayout
tokens={tokens}
preview={`${invoice.numero} (${invoice.amountFormatted}) — ${
isLate ? `${invoice.daysLate}j de retard` : 'à régler'
}`}
brandSubtitle={`Facture ${invoice.numero}`}
landingUrl={landingUrl}
hideRubisFooter={hideRubisFooter}
>
<Text style={bodyTextStyle}>{bodyText}</Text>
<Section style={summaryCardStyle}>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Facture</span>
<span style={summaryValueStyle}>{invoice.numero}</span>
</Text>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Montant TTC</span>
<span
style={{
...summaryValueStyle,
fontSize: '18px',
fontWeight: 800,
fontVariantNumeric: 'tabular-nums',
}}
>
{invoice.amountFormatted}
</span>
</Text>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Échéance</span>
<span
style={{
...summaryValueStyle,
color: isLate ? tokens.primaryDeep : tokens.text,
}}
>
{invoice.dueDateFormatted}
{isLate ? (
<span
style={{ color: tokens.primaryDeep, fontSize: '12px', marginLeft: sp.sm }}
>
({invoice.daysLate}j de retard)
</span>
) : null}
</span>
</Text>
</Section>
</EmailLayout>
)
}