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>
136 lines
3.9 KiB
TypeScript
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>
|
|
)
|
|
}
|