rubis/apps/api/app/mails/checkin_email.tsx
ordinarthur ff8fe64be2
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m1s
Build & Deploy API / build-and-deploy (push) Successful in 2m3s
feat(mail): templates HTML React Email + brand "Rubis sur l'ongle"
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>
2026-05-07 18:10:27 +02:00

214 lines
5.6 KiB
TypeScript

/**
* Template email check-in — envoyé à L'UTILISATEUR (le patron de TPE)
* pour lui demander si une facture donnée a été payée AVANT que la 1re
* relance ne parte chez son client.
*
* Structure :
* - Header rubis-deep "Rubis · Confirmation requise"
* - Eyebrow + question Avez-vous été payé ?
* - Card facture (numéro + montant + client + échéance)
* - 2 gros boutons CTA :
* • "✓ Oui — la facture est payée" → /paid (status=paid + cancel relances)
* • "→ Non — toujours impayée, lance les relances" → /pending
* - Mention TTL 24h
*/
import * as React from 'react'
import { Section, Text, Button, Heading, Hr } from '@react-email/components'
import { BRAND, sp } from './_brand.js'
import { EmailLayout } from './_layout.js'
export type CheckinEmailProps = {
invoice: {
numero: string
amountFormatted: string
dueDateFormatted: string
}
client: { name: string }
user: { fullName: string | null }
paidUrl: string
pendingUrl: string
/** URL landing publique (footer cliquable "Rubis sur l'ongle"). */
landingUrl?: string
}
export function CheckinEmail({
invoice,
client,
user,
paidUrl,
pendingUrl,
landingUrl,
}: CheckinEmailProps) {
const greeting = user.fullName ? `Bonjour ${user.fullName.split(' ')[0]},` : 'Bonjour,'
return (
<EmailLayout
preview={`${invoice.numero} (${invoice.amountFormatted}) — payée par ${client.name} ?`}
brandName="Rubis"
brandSubtitle="Confirmation requise"
landingUrl={landingUrl}
>
<Text style={greetingStyle}>{greeting}</Text>
<Heading as="h1" style={titleStyle}>
Avez-vous é <em style={emStyle}>payé</em> sur cette facture&nbsp;?
</Heading>
<Text style={leadStyle}>
Aucune relance ne part sans votre validation. Si la facture est déjà
réglée, on évite l&apos;email inutile et on encaisse +1 rubis.
</Text>
{/* Card facture */}
<Section style={invoiceCardStyle}>
<Text style={invoiceNumeroStyle}>{invoice.numero}</Text>
<Text style={invoiceClientStyle}>{client.name}</Text>
<Text style={invoiceAmountStyle}>{invoice.amountFormatted}</Text>
<Text style={invoiceDueStyle}>
Échéance le <strong>{invoice.dueDateFormatted}</strong>
</Text>
</Section>
{/* CTA boutons */}
<Section style={ctaSectionStyle}>
<Button href={paidUrl} style={primaryButtonStyle}>
Oui, la facture est payée
</Button>
</Section>
<Section style={{ marginTop: sp.md }}>
<Button href={pendingUrl} style={secondaryButtonStyle}>
Non, toujours en attente lance les relances
</Button>
</Section>
<Hr style={hrStyle} />
<Text style={footnoteStyle}>
Ces liens expirent dans 24h. Vous pouvez aussi répondre directement
depuis l&apos;app sur la fiche facture.
</Text>
</EmailLayout>
)
}
// ---------------------------------------------------------------------------
// Styles inline
// ---------------------------------------------------------------------------
const greetingStyle: React.CSSProperties = {
fontSize: '14px',
color: BRAND.ink2,
margin: `0 0 ${sp.md} 0`,
}
const titleStyle: React.CSSProperties = {
color: BRAND.ink,
fontSize: '24px',
fontWeight: 700,
letterSpacing: '-0.018em',
lineHeight: '1.2',
margin: `0 0 ${sp.md} 0`,
}
const emStyle: React.CSSProperties = {
color: BRAND.rubis,
fontStyle: 'normal',
}
const leadStyle: React.CSSProperties = {
color: BRAND.ink2,
fontSize: '14px',
lineHeight: '1.6',
margin: `0 0 ${sp.xl} 0`,
}
const invoiceCardStyle: React.CSSProperties = {
backgroundColor: BRAND.cream,
border: `1px solid ${BRAND.line}`,
borderRadius: BRAND.radiusCard,
padding: `${sp.lg} ${sp.xl}`,
margin: `0 0 ${sp.xl} 0`,
}
const invoiceNumeroStyle: React.CSSProperties = {
fontSize: '13px',
fontWeight: 600,
color: BRAND.ink2,
letterSpacing: '0.02em',
margin: 0,
}
const invoiceClientStyle: React.CSSProperties = {
fontSize: '13px',
color: BRAND.ink3,
margin: `${sp.xs} 0 ${sp.md} 0`,
}
const invoiceAmountStyle: React.CSSProperties = {
fontSize: '28px',
fontWeight: 800,
letterSpacing: '-0.02em',
color: BRAND.ink,
margin: 0,
lineHeight: '1',
fontVariantNumeric: 'tabular-nums',
}
const invoiceDueStyle: React.CSSProperties = {
fontSize: '12px',
color: BRAND.ink3,
margin: `${sp.sm} 0 0 0`,
}
const ctaSectionStyle: React.CSSProperties = {
marginTop: 0,
}
const primaryButtonStyle: React.CSSProperties = {
backgroundColor: BRAND.rubis,
color: BRAND.white,
borderRadius: BRAND.radiusButton,
display: 'block',
textAlign: 'center',
textDecoration: 'none',
padding: `${sp.md} ${sp.xl}`,
fontSize: '15px',
fontWeight: 600,
width: '100%',
boxSizing: 'border-box',
// Shadow rubis-teintée pour cohérence avec les boutons SPA
boxShadow: '0 4px 12px rgba(159, 18, 57, 0.25)',
}
const secondaryButtonStyle: React.CSSProperties = {
backgroundColor: BRAND.white,
color: BRAND.ink,
border: `1px solid ${BRAND.ink}`,
borderRadius: BRAND.radiusButton,
display: 'block',
textAlign: 'center',
textDecoration: 'none',
padding: `${sp.md} ${sp.xl}`,
fontSize: '15px',
fontWeight: 600,
width: '100%',
boxSizing: 'border-box',
}
const hrStyle: React.CSSProperties = {
borderColor: BRAND.line,
borderStyle: 'solid',
borderWidth: '0 0 1px 0',
margin: `${sp.xl} 0 ${sp.lg} 0`,
}
const footnoteStyle: React.CSSProperties = {
fontSize: '11.5px',
color: BRAND.ink3,
lineHeight: '1.5',
margin: 0,
fontStyle: 'italic',
}