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>
202 lines
5.4 KiB
TypeScript
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 été <em style={emStyle}>payé</em> sur cette facture ?
|
|
</Heading>
|
|
|
|
<Text style={leadStyle}>
|
|
Aucune relance ne part sans votre validation. Si la facture est déjà
|
|
réglée, on évite l'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'app sur la fiche facture.
|
|
</Text>
|
|
</EmailLayout>
|
|
)
|
|
}
|