rubis/apps/api/app/mails/checkin_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

202 lines
5.4 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.
*
* Cet email reste **toujours en branding Rubis**, indépendamment du plan
* de l'org — c'est une communication Rubis → user (méta-produit), pas
* user → client. Le dispatcher passe donc `DEFAULT_BRAND` ici, jamais
* les tokens custom.
*/
import * as React from 'react'
import { Section, Text, Button, Heading, Hr } from '@react-email/components'
import type { BrandTokens } from '#services/brand'
import { sp } from './_brand.js'
import { EmailLayout } from './_layout.js'
export type CheckinEmailProps = {
/** Toujours DEFAULT_BRAND — checkin reste Rubis-branded. */
tokens: BrandTokens
invoice: {
numero: string
amountFormatted: string
dueDateFormatted: string
}
client: { name: string }
user: { fullName: string | null }
paidUrl: string
pendingUrl: string
landingUrl?: string | null
}
export function CheckinEmail({
tokens,
invoice,
client,
user,
paidUrl,
pendingUrl,
landingUrl,
}: CheckinEmailProps) {
const greeting = user.fullName ? `Bonjour ${user.fullName.split(' ')[0]},` : 'Bonjour,'
const greetingStyle: React.CSSProperties = {
fontSize: '14px',
color: tokens.textMuted,
margin: `0 0 ${sp.md} 0`,
}
const titleStyle: React.CSSProperties = {
color: tokens.text,
fontSize: '24px',
fontWeight: 700,
letterSpacing: '-0.018em',
lineHeight: '1.2',
margin: `0 0 ${sp.md} 0`,
}
const emStyle: React.CSSProperties = {
color: tokens.primary,
fontStyle: 'normal',
}
const leadStyle: React.CSSProperties = {
color: tokens.textMuted,
fontSize: '14px',
lineHeight: '1.6',
margin: `0 0 ${sp.xl} 0`,
}
const invoiceCardStyle: React.CSSProperties = {
backgroundColor: tokens.white,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radiusCard,
padding: `${sp.lg} ${sp.xl}`,
margin: `0 0 ${sp.xl} 0`,
}
const invoiceNumeroStyle: React.CSSProperties = {
fontSize: '13px',
fontWeight: 600,
color: tokens.textMuted,
letterSpacing: '0.02em',
margin: 0,
}
const invoiceClientStyle: React.CSSProperties = {
fontSize: '13px',
color: tokens.textVeryMuted,
margin: `${sp.xs} 0 ${sp.md} 0`,
}
const invoiceAmountStyle: React.CSSProperties = {
fontSize: '28px',
fontWeight: 800,
letterSpacing: '-0.02em',
color: tokens.text,
margin: 0,
lineHeight: '1',
fontVariantNumeric: 'tabular-nums',
}
const invoiceDueStyle: React.CSSProperties = {
fontSize: '12px',
color: tokens.textVeryMuted,
margin: `${sp.sm} 0 0 0`,
}
const primaryButtonStyle: React.CSSProperties = {
backgroundColor: tokens.primary,
color: tokens.buttonText,
borderRadius: tokens.radiusButton,
display: 'block',
textAlign: 'center',
textDecoration: 'none',
padding: `${sp.md} ${sp.xl}`,
fontSize: '15px',
fontWeight: 600,
width: '100%',
boxSizing: 'border-box',
boxShadow: '0 4px 12px rgba(159, 18, 57, 0.25)',
}
const secondaryButtonStyle: React.CSSProperties = {
backgroundColor: tokens.white,
color: tokens.text,
border: `1px solid ${tokens.text}`,
borderRadius: tokens.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: tokens.border,
borderStyle: 'solid',
borderWidth: '0 0 1px 0',
margin: `${sp.xl} 0 ${sp.lg} 0`,
}
const footnoteStyle: React.CSSProperties = {
fontSize: '11.5px',
color: tokens.textVeryMuted,
lineHeight: '1.5',
margin: 0,
fontStyle: 'italic',
}
return (
<EmailLayout
tokens={tokens}
preview={`${invoice.numero} (${invoice.amountFormatted}) — payée par ${client.name} ?`}
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>
<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>
<Section style={{ marginTop: 0 }}>
<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>
)
}