Templates HTML stylés DA Rubis pour les 2 emails sortants — fini le
plain text moche.
apps/api/app/mails/
├── _brand.ts : tokens couleur + spacing partagés
├── _layout.tsx : squelette commun (header rubis-deep + footer)
├── checkin_email.tsx : email envoyé À L'USER avec 2 boutons CTA
│ Oui (rubis primary) / Non (outlined)
└── relance_email.tsx : email envoyé AU CLIENT, body texte du plan
+ card récap (numéro, montant, échéance,
badge retard rubis-deep)
Stack :
- @react-email/components + @react-email/render
- Tous les styles inline (compatible Gmail / Outlook / Apple Mail)
- HTML + plain text en fallback (anti-spam, accessibility)
mail_dispatcher.ts :
- sendRelanceEmail : .html(rendered) + .text(body)
- sendCheckinEmail : .html(rendered) + .text(body)
- daysLate calculé via clock.now (démo-aware)
send_test_email :
- Nouveau flag --template=checkin (default) | relance | plain pour
tester chaque rendu via Mailpit sans créer de vraie facture.
Brand & landing :
- "Rubis Sur l'Ongle" → "Rubis sur l'ongle" partout (config, mail,
PDF, Stripe appInfo)
- Nouvelle env var LANDING_URL (default https://rubis.arthurbarre.fr)
- Footer email rend "Rubis sur l'ongle" comme <a> rubis cliquable
vers la landing — l'user qui reçoit le mail connaît la marque
derrière l'envoi
- .env.example mis à jour avec LANDING_URL pour les autres devs
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
136 lines
4.1 KiB
TypeScript
136 lines
4.1 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 rubis-deep avec le NOM DE L'ORG (ce que connaît le client)
|
|
* et le numéro de facture en sous-titre
|
|
* - 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
|
|
*
|
|
* Pas de boutons CTA dans la relance — le client est censé payer hors
|
|
* mail (virement, espèces, ...). Le mail rappelle juste le contexte.
|
|
*/
|
|
|
|
import * as React from 'react'
|
|
import { Section, Text } from '@react-email/components'
|
|
|
|
import { BRAND, sp } from './_brand.js'
|
|
import { EmailLayout } from './_layout.js'
|
|
|
|
export type RelanceEmailProps = {
|
|
/** Nom commercial visible côté client (l'org du user). */
|
|
brandName: string
|
|
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"). */
|
|
landingUrl?: string
|
|
}
|
|
|
|
export function RelanceEmail({
|
|
brandName,
|
|
invoice,
|
|
bodyText,
|
|
landingUrl,
|
|
}: RelanceEmailProps) {
|
|
const isLate = invoice.daysLate > 0
|
|
return (
|
|
<EmailLayout
|
|
preview={`${invoice.numero} (${invoice.amountFormatted}) — ${
|
|
isLate ? `${invoice.daysLate}j de retard` : 'à régler'
|
|
}`}
|
|
brandName={brandName}
|
|
brandSubtitle={`Facture ${invoice.numero}`}
|
|
landingUrl={landingUrl}
|
|
>
|
|
{/* Body texte en pre-line pour conserver les sauts de ligne tels que
|
|
le user les a écrits. Le client lit le mot du patron, pas un
|
|
template impersonnel. */}
|
|
<Text style={bodyTextStyle}>{bodyText}</Text>
|
|
|
|
{/* Card récap en pied : tableau visuel court qui rappelle les chiffres. */}
|
|
<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 ? BRAND.rubisDeep : BRAND.ink,
|
|
}}
|
|
>
|
|
{invoice.dueDateFormatted}
|
|
{isLate ? (
|
|
<span style={{ color: BRAND.rubisDeep, fontSize: '12px', marginLeft: sp.sm }}>
|
|
({invoice.daysLate}j de retard)
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</Text>
|
|
</Section>
|
|
</EmailLayout>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Styles inline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const bodyTextStyle: React.CSSProperties = {
|
|
color: BRAND.ink,
|
|
fontSize: '15px',
|
|
lineHeight: '1.6',
|
|
margin: `0 0 ${sp.xl} 0`,
|
|
whiteSpace: 'pre-line', // préserve les \n du body sans nécessiter <br/>
|
|
}
|
|
|
|
const summaryCardStyle: React.CSSProperties = {
|
|
backgroundColor: BRAND.cream,
|
|
border: `1px solid ${BRAND.line}`,
|
|
borderRadius: BRAND.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: BRAND.ink3,
|
|
fontWeight: 500,
|
|
}
|
|
|
|
const summaryValueStyle: React.CSSProperties = {
|
|
color: BRAND.ink,
|
|
fontWeight: 600,
|
|
}
|