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>
168 lines
4.3 KiB
TypeScript
168 lines
4.3 KiB
TypeScript
/**
|
|
* Squelette commun aux 2 templates Rubis : header rubis-deep avec brand
|
|
* + container cream + footer "Émis via Rubis sur l'ongle" (cliquable
|
|
* vers la landing publique).
|
|
*
|
|
* Approche : on stylé en inline via les `style` props que React Email
|
|
* convertit en HTML inline (compatible tous mail clients y compris
|
|
* Outlook). Pas de classes Tailwind ici — risquées en mail.
|
|
*/
|
|
|
|
import * as React from 'react'
|
|
import {
|
|
Html,
|
|
Head,
|
|
Preview,
|
|
Body,
|
|
Container,
|
|
Section,
|
|
Row,
|
|
Column,
|
|
Text,
|
|
Link,
|
|
} from '@react-email/components'
|
|
|
|
import { BRAND, sp } from './_brand.js'
|
|
|
|
type LayoutProps = {
|
|
/** Aperçu dans la liste mail (Gmail preview text). */
|
|
preview: string
|
|
/** Nom commercial affiché dans le header (vendeur ou "Rubis"). */
|
|
brandName: string
|
|
/** Sous-titre header (ex: numéro de facture, date). Optionnel. */
|
|
brandSubtitle?: string | null
|
|
/** URL de la landing publique — lien dans le footer ("Rubis sur l'ongle"). */
|
|
landingUrl?: string
|
|
children: React.ReactNode
|
|
}
|
|
|
|
export function EmailLayout({
|
|
preview,
|
|
brandName,
|
|
brandSubtitle,
|
|
landingUrl,
|
|
children,
|
|
}: LayoutProps) {
|
|
return (
|
|
<Html lang="fr">
|
|
<Head />
|
|
<Preview>{preview}</Preview>
|
|
<Body style={bodyStyle}>
|
|
<Container style={containerStyle}>
|
|
{/* Bandeau rubis-deep avec gem ◆ + brand */}
|
|
<Section style={headerStyle}>
|
|
<Row>
|
|
<Column style={{ verticalAlign: 'middle' }}>
|
|
<Text style={headerBrandStyle}>
|
|
<span style={gemStyle}>◆</span>
|
|
{brandName}
|
|
</Text>
|
|
{brandSubtitle ? (
|
|
<Text style={headerSubtitleStyle}>{brandSubtitle}</Text>
|
|
) : null}
|
|
</Column>
|
|
</Row>
|
|
</Section>
|
|
|
|
{/* Corps de l'email */}
|
|
<Section style={contentStyle}>{children}</Section>
|
|
|
|
{/* Footer Rubis — "Rubis sur l'ongle" cliquable vers la landing */}
|
|
<Section style={footerStyle}>
|
|
<Text style={footerTextStyle}>
|
|
Émis via{' '}
|
|
{landingUrl ? (
|
|
<Link href={landingUrl} style={footerLinkStyle}>
|
|
Rubis sur l'ongle
|
|
</Link>
|
|
) : (
|
|
<strong style={{ color: BRAND.ink2 }}>Rubis sur l'ongle</strong>
|
|
)}
|
|
{' — '}
|
|
<span style={{ color: BRAND.ink3 }}>
|
|
vos factures relancées toutes seules pendant que vous travaillez.
|
|
</span>
|
|
</Text>
|
|
</Section>
|
|
</Container>
|
|
</Body>
|
|
</Html>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Styles inline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const bodyStyle: React.CSSProperties = {
|
|
backgroundColor: BRAND.cream,
|
|
fontFamily: BRAND.fontBody,
|
|
margin: 0,
|
|
padding: `${sp.xl} 0`,
|
|
color: BRAND.ink,
|
|
}
|
|
|
|
const containerStyle: React.CSSProperties = {
|
|
backgroundColor: BRAND.white,
|
|
borderRadius: BRAND.radiusCard,
|
|
border: `1px solid ${BRAND.line}`,
|
|
margin: '0 auto',
|
|
maxWidth: '560px',
|
|
overflow: 'hidden',
|
|
}
|
|
|
|
const headerStyle: React.CSSProperties = {
|
|
backgroundColor: BRAND.rubisDeep,
|
|
padding: `${sp.xl} ${sp.xl}`,
|
|
}
|
|
|
|
const headerBrandStyle: React.CSSProperties = {
|
|
color: BRAND.white,
|
|
fontSize: '20px',
|
|
fontWeight: 800,
|
|
letterSpacing: '-0.01em',
|
|
margin: 0,
|
|
lineHeight: '1.1',
|
|
}
|
|
|
|
const gemStyle: React.CSSProperties = {
|
|
display: 'inline-block',
|
|
marginRight: sp.sm,
|
|
color: BRAND.rubisGlow,
|
|
fontSize: '18px',
|
|
verticalAlign: '-1px',
|
|
}
|
|
|
|
const headerSubtitleStyle: React.CSSProperties = {
|
|
color: BRAND.cream2,
|
|
fontSize: '12px',
|
|
margin: `${sp.xs} 0 0 0`,
|
|
letterSpacing: '0.04em',
|
|
textTransform: 'uppercase',
|
|
fontWeight: 600,
|
|
}
|
|
|
|
const contentStyle: React.CSSProperties = {
|
|
padding: `${sp.xl} ${sp.xl}`,
|
|
}
|
|
|
|
const footerStyle: React.CSSProperties = {
|
|
borderTop: `1px solid ${BRAND.line}`,
|
|
backgroundColor: BRAND.cream2,
|
|
padding: `${sp.lg} ${sp.xl}`,
|
|
}
|
|
|
|
const footerTextStyle: React.CSSProperties = {
|
|
color: BRAND.ink3,
|
|
fontSize: '11px',
|
|
lineHeight: '1.5',
|
|
margin: 0,
|
|
textAlign: 'center',
|
|
}
|
|
|
|
const footerLinkStyle: React.CSSProperties = {
|
|
color: BRAND.rubis,
|
|
fontWeight: 600,
|
|
textDecoration: 'none',
|
|
}
|