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>
This commit is contained in:
parent
7a112d3329
commit
919ebfe755
@ -1,14 +1,18 @@
|
|||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
|
||||||
import { uploadBlogImage, readBlogImage } from '#services/blog_uploads'
|
import { uploadMedia, readMedia } from '#services/media_storage'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BlogUploadsController — gère les images du blog.
|
* BlogUploadsController — gère les images du blog.
|
||||||
*
|
*
|
||||||
* - POST /api/v1/admin/uploads (auth + admin)
|
* Délègue tout au service mutualisé `#services/media_storage` (scope 'blog').
|
||||||
* multipart 'file' → upload MinIO → renvoie { url, contentType, sizeBytes }
|
* Le même service sert aussi pour les logos de marque blanche (scope
|
||||||
* - GET /api/v1/uploads/blog/:filename (public)
|
* 'brand-logo') via `BrandController`.
|
||||||
* stream depuis MinIO avec Cache-Control immutable.
|
*
|
||||||
|
* - POST /api/v1/admin/uploads (auth + admin)
|
||||||
|
* multipart 'file' → upload MinIO → { url, contentType, sizeBytes }
|
||||||
|
* - GET /api/v1/uploads/blog/:filename (public)
|
||||||
|
* stream depuis MinIO avec Cache-Control immutable.
|
||||||
*/
|
*/
|
||||||
export default class BlogUploadsController {
|
export default class BlogUploadsController {
|
||||||
/**
|
/**
|
||||||
@ -32,7 +36,7 @@ export default class BlogUploadsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await uploadBlogImage(file)
|
const result = await uploadMedia(file, 'blog')
|
||||||
return response.status(201).json({
|
return response.status(201).json({
|
||||||
data: {
|
data: {
|
||||||
url: result.publicPath,
|
url: result.publicPath,
|
||||||
@ -51,7 +55,7 @@ export default class BlogUploadsController {
|
|||||||
*/
|
*/
|
||||||
async show({ params, response }: HttpContext) {
|
async show({ params, response }: HttpContext) {
|
||||||
const filename = String(params.filename ?? '')
|
const filename = String(params.filename ?? '')
|
||||||
const result = await readBlogImage(filename)
|
const result = await readMedia('blog', filename)
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return response.status(404).send('not_found')
|
return response.status(404).send('not_found')
|
||||||
}
|
}
|
||||||
|
|||||||
250
apps/api/app/controllers/brand_controller.ts
Normal file
250
apps/api/app/controllers/brand_controller.ts
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
import { Exception } from '@adonisjs/core/exceptions'
|
||||||
|
import logger from '@adonisjs/core/services/logger'
|
||||||
|
|
||||||
|
import Organization from '#models/organization'
|
||||||
|
import {
|
||||||
|
type BrandSettings,
|
||||||
|
DEFAULT_BRAND,
|
||||||
|
resolveBrandTokens,
|
||||||
|
mergeBrandSettings,
|
||||||
|
validateBrandSettings,
|
||||||
|
} from '#services/brand'
|
||||||
|
import { uploadMedia, deleteMedia, readMedia } from '#services/media_storage'
|
||||||
|
|
||||||
|
function requireOrgId(auth: HttpContext['auth']): string {
|
||||||
|
const user = auth.getUserOrFail()
|
||||||
|
if (!user.organizationId) {
|
||||||
|
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
|
||||||
|
}
|
||||||
|
return user.organizationId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BrandController — gestion de la marque blanche (plan Business).
|
||||||
|
*
|
||||||
|
* Routes (toutes en `/api/v1`, auth + assertBusinessPlan en middleware) :
|
||||||
|
* - GET /brand → settings courants + tokens résolus (preview SPA)
|
||||||
|
* - PATCH /brand → maj partielle des settings
|
||||||
|
* - POST /brand/logo → upload multipart (remplace l'ancien si présent)
|
||||||
|
* - DELETE /brand/logo → supprime le logo (retour au wordmark Rubis)
|
||||||
|
* - POST /brand/test → envoie un email de test à l'user pour preview réelle
|
||||||
|
*
|
||||||
|
* Le PATCH supporte la sémantique "null = reset au default" : envoyer
|
||||||
|
* `{ primaryColor: null }` retire l'override sur ce champ précis (le mail
|
||||||
|
* repart en rubis #9F1239). Envoyer `{ primaryColor: undefined }` ou ne pas
|
||||||
|
* inclure la clé du tout = laisse intact.
|
||||||
|
*/
|
||||||
|
export default class BrandController {
|
||||||
|
/**
|
||||||
|
* GET /api/v1/uploads/brand-logos/:filename — public, cache long.
|
||||||
|
*
|
||||||
|
* Servi sans auth parce que les emails sortants vers les clients de
|
||||||
|
* l'org doivent pouvoir charger l'image (`<img src="..." />` côté
|
||||||
|
* Gmail/Outlook). UUID dans le filename → globalement unique, pas de
|
||||||
|
* fuite d'info utile à un attaquant qui devinerait l'URL.
|
||||||
|
*
|
||||||
|
* Cette méthode est routée par le GROUPE PUBLIC du fichier routes.ts,
|
||||||
|
* pas par le groupe auth+business — c'est le seul point d'entrée du
|
||||||
|
* controller qui ne demande pas d'auth.
|
||||||
|
*/
|
||||||
|
async showLogo({ params, response }: HttpContext) {
|
||||||
|
const filename = String(params.filename ?? '')
|
||||||
|
const result = await readMedia('brand-logo', filename)
|
||||||
|
if (!result) {
|
||||||
|
return response.status(404).send('not_found')
|
||||||
|
}
|
||||||
|
response.header('Content-Type', result.contentType)
|
||||||
|
// Filename = uuid → contenu immuable. Cache infini.
|
||||||
|
response.header('Cache-Control', 'public, max-age=31536000, immutable')
|
||||||
|
// Pour les SVG (potentiellement scriptables), force le browser à NE PAS
|
||||||
|
// re-deviner le type et bloque l'exécution de scripts inline.
|
||||||
|
response.header('X-Content-Type-Options', 'nosniff')
|
||||||
|
return response.send(result.buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/brand — auth + Business.
|
||||||
|
* Retourne le raw settings + les tokens résolus prêts à preview côté SPA.
|
||||||
|
*/
|
||||||
|
async show({ auth, response }: HttpContext) {
|
||||||
|
const orgId = requireOrgId(auth)
|
||||||
|
const org = await Organization.findOrFail(orgId)
|
||||||
|
const settings = (org.brandSettings ?? null) as BrandSettings | null
|
||||||
|
return response.json({
|
||||||
|
data: {
|
||||||
|
settings: settings ?? {},
|
||||||
|
tokens: resolveBrandTokens(org),
|
||||||
|
defaults: DEFAULT_BRAND,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/brand — auth + Business.
|
||||||
|
* Body : partial BrandSettings. Champs absents = laissés tels quels.
|
||||||
|
* Champ à `null` explicite = reset au default Rubis.
|
||||||
|
*/
|
||||||
|
async update({ auth, request, response }: HttpContext) {
|
||||||
|
const orgId = requireOrgId(auth)
|
||||||
|
const org = await Organization.findOrFail(orgId)
|
||||||
|
|
||||||
|
const body = request.body() as Partial<BrandSettings>
|
||||||
|
// logoPath et logoUrl ne sont JAMAIS modifiables via PATCH — ils passent
|
||||||
|
// par les endpoints /brand/logo dédiés (upload multipart + cleanup).
|
||||||
|
delete body.logoPath
|
||||||
|
delete body.logoUrl
|
||||||
|
|
||||||
|
const err = validateBrandSettings(body)
|
||||||
|
if (err) {
|
||||||
|
return response.status(422).json({ errors: [{ message: err }] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = (org.brandSettings ?? null) as BrandSettings | null
|
||||||
|
const merged = mergeBrandSettings(current, body)
|
||||||
|
org.brandSettings = Object.keys(merged).length === 0 ? null : merged
|
||||||
|
await org.save()
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
data: {
|
||||||
|
settings: org.brandSettings ?? {},
|
||||||
|
tokens: resolveBrandTokens(org),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/brand/logo — auth + Business.
|
||||||
|
* Multipart 'file' → upload MinIO via media_storage scope 'brand-logo'.
|
||||||
|
* Remplace l'ancien logo s'il existe (delete MinIO + écrase les champs).
|
||||||
|
*/
|
||||||
|
async uploadLogo({ auth, request, response }: HttpContext) {
|
||||||
|
const orgId = requireOrgId(auth)
|
||||||
|
const org = await Organization.findOrFail(orgId)
|
||||||
|
|
||||||
|
const file = request.file('file', {
|
||||||
|
size: '1mb',
|
||||||
|
extnames: ['jpg', 'jpeg', 'png', 'webp', 'svg'],
|
||||||
|
})
|
||||||
|
if (!file) {
|
||||||
|
return response.status(422).json({
|
||||||
|
errors: [{ field: 'file', message: 'Fichier manquant (champ multipart `file`).' }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!file.isValid) {
|
||||||
|
return response.status(422).json({
|
||||||
|
errors: file.errors.map((e) => ({ field: 'file', message: e.message })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadMedia(file, 'brand-logo')
|
||||||
|
|
||||||
|
// Cleanup de l'ancien logo s'il existe — pas de versioning,
|
||||||
|
// on écrase pour éviter d'accumuler des orphelins sur MinIO.
|
||||||
|
const current = (org.brandSettings ?? null) as BrandSettings | null
|
||||||
|
if (current?.logoPath) {
|
||||||
|
await deleteMedia(current.logoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: BrandSettings = {
|
||||||
|
...(current ?? {}),
|
||||||
|
logoPath: result.storageKey,
|
||||||
|
logoUrl: result.publicPath,
|
||||||
|
}
|
||||||
|
org.brandSettings = next
|
||||||
|
await org.save()
|
||||||
|
|
||||||
|
return response.status(201).json({
|
||||||
|
data: {
|
||||||
|
settings: org.brandSettings,
|
||||||
|
tokens: resolveBrandTokens(org),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'upload_failed'
|
||||||
|
logger.warn({ err, orgId }, 'brand_logo_upload_failed')
|
||||||
|
return response.status(422).json({ errors: [{ field: 'file', message: msg }] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/brand/logo — auth + Business.
|
||||||
|
* Retire le logo (retour au wordmark Rubis dans les emails).
|
||||||
|
*/
|
||||||
|
async deleteLogo({ auth, response }: HttpContext) {
|
||||||
|
const orgId = requireOrgId(auth)
|
||||||
|
const org = await Organization.findOrFail(orgId)
|
||||||
|
|
||||||
|
const current = (org.brandSettings ?? null) as BrandSettings | null
|
||||||
|
if (current?.logoPath) {
|
||||||
|
await deleteMedia(current.logoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: BrandSettings = { ...(current ?? {}) }
|
||||||
|
delete next.logoPath
|
||||||
|
delete next.logoUrl
|
||||||
|
org.brandSettings = Object.keys(next).length === 0 ? null : next
|
||||||
|
await org.save()
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
data: {
|
||||||
|
settings: org.brandSettings ?? {},
|
||||||
|
tokens: resolveBrandTokens(org),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/brand/test — auth + Business.
|
||||||
|
* Envoie un email de test (relance fictive avec données mock) à l'user
|
||||||
|
* connecté pour qu'il voie le rendu réel des tokens courants dans
|
||||||
|
* son Gmail/Outlook. Pas envoyé à un client réel — toujours à `user.email`.
|
||||||
|
*
|
||||||
|
* Stocke le résultat dans `activity_events` ? Non — c'est un test
|
||||||
|
* volontaire, on ne pollue pas la timeline produit.
|
||||||
|
*/
|
||||||
|
async sendTest({ auth, response }: HttpContext) {
|
||||||
|
const orgId = requireOrgId(auth)
|
||||||
|
const user = auth.getUserOrFail()
|
||||||
|
const org = await Organization.findOrFail(orgId)
|
||||||
|
|
||||||
|
const tokens = resolveBrandTokens(org)
|
||||||
|
|
||||||
|
// Import lazy pour éviter le coût React Email au boot.
|
||||||
|
const { render } = await import('@react-email/components')
|
||||||
|
const { RelanceEmail } = await import('#mails/relance_email')
|
||||||
|
const mail = (await import('@adonisjs/mail/services/main')).default
|
||||||
|
|
||||||
|
// Données mock — facture fictive, juste pour montrer le rendu réel des
|
||||||
|
// tokens (couleurs, logo, nom expéditeur) dans Gmail/Outlook.
|
||||||
|
const html = await render(
|
||||||
|
RelanceEmail({
|
||||||
|
tokens,
|
||||||
|
invoice: {
|
||||||
|
numero: 'F-2026-0042',
|
||||||
|
amountFormatted: '1 240,00 €',
|
||||||
|
dueDateFormatted: '12 avril 2026',
|
||||||
|
daysLate: 8,
|
||||||
|
},
|
||||||
|
bodyText: `Bonjour Christophe,\n\nUn petit rappel concernant la facture F-2026-0042 d'un montant de 1 240,00 €, échue depuis 8 jours.\n\nMerci pour votre attention,\n\n${tokens.senderName}`,
|
||||||
|
landingUrl: 'https://rubis.pro',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const fromAddress = process.env.MAIL_FROM_ADDRESS || 'relances@rubis.pro'
|
||||||
|
try {
|
||||||
|
await mail.send((m) => {
|
||||||
|
m.from(fromAddress, `${tokens.senderName} (test)`)
|
||||||
|
.to(user.email)
|
||||||
|
.subject('[Test marque blanche] Aperçu de relance')
|
||||||
|
.html(html)
|
||||||
|
})
|
||||||
|
return response.json({ data: { ok: true, sentTo: user.email } })
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'send_failed'
|
||||||
|
logger.error({ err, orgId, userEmail: user.email }, 'brand_test_email_failed')
|
||||||
|
return response.status(500).json({ errors: [{ message: msg }] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,35 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Tokens de marque partagés par tous les templates email.
|
* Spacing tokens partagés par les templates email.
|
||||||
*
|
*
|
||||||
* On reste sur des hex en dur (pas de CSS vars) parce que les clients mail
|
* Les TOKENS DE COULEUR sont désormais résolus dynamiquement par org via
|
||||||
* (Gmail, Outlook) ne supportent pas les `--var` dans les `style="..."`.
|
* `#services/brand` (résolution dépendante du plan Business + des overrides
|
||||||
* Tous les styles sont inline pour la même raison.
|
* `brand_settings` JSONB). Ce fichier ne contient plus que les espacements,
|
||||||
|
* qui ne sont pas customisables côté user (déterministe pour la délivrabilité
|
||||||
|
* mail — Outlook supporte mal les recalculs dynamiques de padding).
|
||||||
|
*
|
||||||
|
* Pour les couleurs / logo / nom expéditeur :
|
||||||
|
* import { resolveBrandTokens } from '#services/brand'
|
||||||
|
* const tokens = resolveBrandTokens(organization)
|
||||||
|
* // tokens.primary, tokens.bodyBg, tokens.text, tokens.logoUrl, ...
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const BRAND = {
|
/** Wrappers d'unités inline-friendly pour pas faire de calculs côté <style>. */
|
||||||
// Palette Rubis (cf. CLAUDE.md → marque)
|
|
||||||
rubis: '#9F1239',
|
|
||||||
rubisDeep: '#771328',
|
|
||||||
rubisLight: '#C9415C',
|
|
||||||
rubisGlow: '#FBE4EA',
|
|
||||||
cream: '#FAF7F2',
|
|
||||||
cream2: '#F5EFE7',
|
|
||||||
ink: '#1A1410',
|
|
||||||
ink2: '#4F4640',
|
|
||||||
ink3: '#8A7F76',
|
|
||||||
line: '#E8E0D6',
|
|
||||||
white: '#FFFFFF',
|
|
||||||
|
|
||||||
// Typo — on s'appuie sur les fallbacks system-ui pour la portabilité mail.
|
|
||||||
fontBody:
|
|
||||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, sans-serif",
|
|
||||||
|
|
||||||
// Radius cohérents avec l'app
|
|
||||||
radiusButton: '6px',
|
|
||||||
radiusCard: '14px',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/** Wrappers d'unités inline-friendly pour pas faire de gros calculs côté <style>. */
|
|
||||||
export const sp = {
|
export const sp = {
|
||||||
xs: '4px',
|
xs: '4px',
|
||||||
sm: '8px',
|
sm: '8px',
|
||||||
|
|||||||
@ -1,11 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Squelette commun aux 2 templates Rubis : header rubis-deep avec brand
|
* Squelette commun à tous les templates Rubis : bandeau header avec
|
||||||
* + container cream + footer "Émis via Rubis sur l'ongle" (cliquable
|
* logo/brand + container + footer "Émis via Rubis sur l'ongle" (masqué
|
||||||
* vers la landing publique).
|
* pour les orgs sur plan Business avec marque blanche activée).
|
||||||
*
|
*
|
||||||
* Approche : on stylé en inline via les `style` props que React Email
|
* Tokens visuels passés en prop `tokens` (cf. `#services/brand`). Tous les
|
||||||
* convertit en HTML inline (compatible tous mail clients y compris
|
* styles sont déclarés à l'intérieur de la fonction pour fermer sur la
|
||||||
* Outlook). Pas de classes Tailwind ici — risquées en mail.
|
* valeur runtime des tokens — un même `<EmailLayout/>` rend en couleurs
|
||||||
|
* Rubis ou en couleurs Business selon ce qu'on lui passe.
|
||||||
|
*
|
||||||
|
* Approche : styles inline (les `style` props que React Email convertit en
|
||||||
|
* HTML inline). Compatible Gmail, Outlook, Apple Mail. Pas de Tailwind ici.
|
||||||
|
*
|
||||||
|
* Dark mode : forcé en light only (Outlook.com et Gmail mobile auto-
|
||||||
|
* invertissent agressivement, ce qui casse les fonds rubis-deep). Les
|
||||||
|
* sélecteurs `[data-ogsc]` ré-imposent nos couleurs pour ces clients.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
@ -20,37 +28,108 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
Text,
|
Text,
|
||||||
Link,
|
Link,
|
||||||
|
Img,
|
||||||
} from '@react-email/components'
|
} from '@react-email/components'
|
||||||
|
|
||||||
import { BRAND, sp } from './_brand.js'
|
import type { BrandTokens } from '#services/brand'
|
||||||
|
import { sp } from './_brand.js'
|
||||||
|
|
||||||
type LayoutProps = {
|
type LayoutProps = {
|
||||||
|
/** Tokens résolus pour l'org (cf. resolveBrandTokens). */
|
||||||
|
tokens: BrandTokens
|
||||||
/** Aperçu dans la liste mail (Gmail preview text). */
|
/** Aperçu dans la liste mail (Gmail preview text). */
|
||||||
preview: string
|
preview: string
|
||||||
/** Nom commercial affiché dans le header (vendeur ou "Rubis"). */
|
|
||||||
brandName: string
|
|
||||||
/** Sous-titre header (ex: numéro de facture, date). Optionnel. */
|
/** Sous-titre header (ex: numéro de facture, date). Optionnel. */
|
||||||
brandSubtitle?: string | null
|
brandSubtitle?: string | null
|
||||||
/** URL de la landing publique — lien dans le footer ("Rubis sur l'ongle"). */
|
/** URL de la landing publique — footer "Rubis sur l'ongle". null = masque le footer (marque blanche). */
|
||||||
landingUrl?: string
|
landingUrl?: string | null
|
||||||
|
/** Masque le footer Rubis (pour plan Business qui ne veut pas du tout du wordmark). Default false. */
|
||||||
|
hideRubisFooter?: boolean
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailLayout({
|
export function EmailLayout({
|
||||||
|
tokens,
|
||||||
preview,
|
preview,
|
||||||
brandName,
|
|
||||||
brandSubtitle,
|
brandSubtitle,
|
||||||
landingUrl,
|
landingUrl,
|
||||||
|
hideRubisFooter = false,
|
||||||
children,
|
children,
|
||||||
}: LayoutProps) {
|
}: LayoutProps) {
|
||||||
|
const bodyStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: tokens.bodyBg,
|
||||||
|
fontFamily: tokens.fontBody,
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
color: tokens.text,
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: tokens.cardBg,
|
||||||
|
margin: '0 auto',
|
||||||
|
maxWidth: '560px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: tokens.banner,
|
||||||
|
padding: `${sp.xl} ${sp.xl}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerBrandStyle: React.CSSProperties = {
|
||||||
|
color: tokens.white,
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
margin: 0,
|
||||||
|
lineHeight: '1.1',
|
||||||
|
}
|
||||||
|
|
||||||
|
const gemStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-block',
|
||||||
|
marginRight: sp.sm,
|
||||||
|
color: tokens.primaryGlow,
|
||||||
|
fontSize: '18px',
|
||||||
|
verticalAlign: '-1px',
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerSubtitleStyle: React.CSSProperties = {
|
||||||
|
color: tokens.primaryGlow,
|
||||||
|
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 ${tokens.border}`,
|
||||||
|
backgroundColor: tokens.bodyBg,
|
||||||
|
padding: `${sp.lg} ${sp.xl}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const footerTextStyle: React.CSSProperties = {
|
||||||
|
color: tokens.textVeryMuted,
|
||||||
|
fontSize: '11px',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
margin: 0,
|
||||||
|
textAlign: 'center',
|
||||||
|
}
|
||||||
|
|
||||||
|
const footerLinkStyle: React.CSSProperties = {
|
||||||
|
color: tokens.primary,
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html lang="fr">
|
<Html lang="fr">
|
||||||
<Head>
|
<Head>
|
||||||
{/*
|
{/* Force light mode sur les clients qui auto-invertissent. */}
|
||||||
* Force le mode clair sur les clients qui auto-invertissent en dark mode
|
|
||||||
* (iOS Mail, Gmail mobile, Outlook.com web). Sans ces tags, les fonds
|
|
||||||
* crème/blanc deviennent noirs et le header rubis-deep devient rose pâle.
|
|
||||||
*/}
|
|
||||||
<meta name="color-scheme" content="light only" />
|
<meta name="color-scheme" content="light only" />
|
||||||
<meta name="supported-color-schemes" content="light only" />
|
<meta name="supported-color-schemes" content="light only" />
|
||||||
<style
|
<style
|
||||||
@ -64,19 +143,19 @@ export function EmailLayout({
|
|||||||
/* Outlook.com / Hotmail dark mode — force nos couleurs */
|
/* Outlook.com / Hotmail dark mode — force nos couleurs */
|
||||||
[data-ogsc] body,
|
[data-ogsc] body,
|
||||||
[data-ogsb] body {
|
[data-ogsb] body {
|
||||||
background-color: ${BRAND.cream} !important;
|
background-color: ${tokens.bodyBg} !important;
|
||||||
}
|
}
|
||||||
[data-ogsc] .rubis-container,
|
[data-ogsc] .rubis-container,
|
||||||
[data-ogsb] .rubis-container {
|
[data-ogsb] .rubis-container {
|
||||||
background-color: ${BRAND.cream} !important;
|
background-color: ${tokens.cardBg} !important;
|
||||||
}
|
}
|
||||||
[data-ogsc] .rubis-header,
|
[data-ogsc] .rubis-header,
|
||||||
[data-ogsb] .rubis-header {
|
[data-ogsb] .rubis-header {
|
||||||
background-color: ${BRAND.rubisDeep} !important;
|
background-color: ${tokens.banner} !important;
|
||||||
}
|
}
|
||||||
[data-ogsc] .rubis-footer,
|
[data-ogsc] .rubis-footer,
|
||||||
[data-ogsb] .rubis-footer {
|
[data-ogsb] .rubis-footer {
|
||||||
background-color: ${BRAND.cream2} !important;
|
background-color: ${tokens.bodyBg} !important;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
}}
|
}}
|
||||||
@ -85,14 +164,23 @@ export function EmailLayout({
|
|||||||
<Preview>{preview}</Preview>
|
<Preview>{preview}</Preview>
|
||||||
<Body style={bodyStyle} className="rubis-body">
|
<Body style={bodyStyle} className="rubis-body">
|
||||||
<Container style={containerStyle} className="rubis-container">
|
<Container style={containerStyle} className="rubis-container">
|
||||||
{/* Bandeau rubis-deep avec gem ◆ + brand */}
|
{/* Bandeau header — logo image si configuré, sinon wordmark texte. */}
|
||||||
<Section style={headerStyle} className="rubis-header">
|
<Section style={headerStyle} className="rubis-header">
|
||||||
<Row>
|
<Row>
|
||||||
<Column style={{ verticalAlign: 'middle' }}>
|
<Column style={{ verticalAlign: 'middle' }}>
|
||||||
<Text style={headerBrandStyle}>
|
{tokens.logoUrl ? (
|
||||||
<span style={gemStyle}>◆</span>
|
<Img
|
||||||
{brandName}
|
src={tokens.logoUrl}
|
||||||
</Text>
|
alt={tokens.senderName}
|
||||||
|
height="32"
|
||||||
|
style={{ display: 'block', maxHeight: '32px', width: 'auto' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={headerBrandStyle}>
|
||||||
|
<span style={gemStyle}>◆</span>
|
||||||
|
{tokens.senderName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{brandSubtitle ? (
|
{brandSubtitle ? (
|
||||||
<Text style={headerSubtitleStyle}>{brandSubtitle}</Text>
|
<Text style={headerSubtitleStyle}>{brandSubtitle}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@ -103,102 +191,29 @@ export function EmailLayout({
|
|||||||
{/* Corps de l'email */}
|
{/* Corps de l'email */}
|
||||||
<Section style={contentStyle}>{children}</Section>
|
<Section style={contentStyle}>{children}</Section>
|
||||||
|
|
||||||
{/* Footer Rubis — "Rubis sur l'ongle" cliquable vers la landing */}
|
{/* Footer Rubis — masqué en marque blanche complète. */}
|
||||||
<Section style={footerStyle} className="rubis-footer">
|
{!hideRubisFooter && (
|
||||||
<Text style={footerTextStyle}>
|
<Section style={footerStyle} className="rubis-footer">
|
||||||
Émis via{' '}
|
<Text style={footerTextStyle}>
|
||||||
{landingUrl ? (
|
Émis via{' '}
|
||||||
<Link href={landingUrl} style={footerLinkStyle}>
|
{landingUrl ? (
|
||||||
Rubis sur l'ongle
|
<Link href={landingUrl} style={footerLinkStyle}>
|
||||||
</Link>
|
Rubis sur l'ongle
|
||||||
) : (
|
</Link>
|
||||||
<strong style={{ color: BRAND.ink2 }}>Rubis sur l'ongle</strong>
|
) : (
|
||||||
)}
|
<strong style={{ color: tokens.textMuted }}>
|
||||||
{' — '}
|
Rubis sur l'ongle
|
||||||
<span style={{ color: BRAND.ink3 }}>
|
</strong>
|
||||||
vos factures relancées toutes seules pendant que vous travaillez.
|
)}
|
||||||
</span>
|
{' — '}
|
||||||
</Text>
|
<span style={{ color: tokens.textVeryMuted }}>
|
||||||
</Section>
|
vos factures relancées toutes seules pendant que vous travaillez.
|
||||||
|
</span>
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
</Body>
|
</Body>
|
||||||
</Html>
|
</Html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Styles inline
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const bodyStyle: React.CSSProperties = {
|
|
||||||
backgroundColor: BRAND.cream,
|
|
||||||
fontFamily: BRAND.fontBody,
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
color: BRAND.ink,
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerStyle: React.CSSProperties = {
|
|
||||||
// Fond crème pour que toute la zone du mail soit dans la palette
|
|
||||||
// (au lieu d'un container blanc flottant sur cream). Le header
|
|
||||||
// rubis-deep et le footer cream2 gardent leur couleur dédiée.
|
|
||||||
backgroundColor: BRAND.cream,
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,25 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Template email check-in — envoyé à L'UTILISATEUR (le patron de TPE)
|
* 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
|
* pour lui demander si une facture donnée a été payée AVANT que la 1re
|
||||||
* relance ne parte chez son client.
|
* relance ne parte chez son client.
|
||||||
*
|
*
|
||||||
* Structure :
|
* Cet email reste **toujours en branding Rubis**, indépendamment du plan
|
||||||
* - Header rubis-deep "Rubis · Confirmation requise"
|
* de l'org — c'est une communication Rubis → user (méta-produit), pas
|
||||||
* - Eyebrow + question Avez-vous été payé ?
|
* user → client. Le dispatcher passe donc `DEFAULT_BRAND` ici, jamais
|
||||||
* - Card facture (numéro + montant + client + échéance)
|
* les tokens custom.
|
||||||
* - 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 * as React from 'react'
|
||||||
import { Section, Text, Button, Heading, Hr } from '@react-email/components'
|
import { Section, Text, Button, Heading, Hr } from '@react-email/components'
|
||||||
|
|
||||||
import { BRAND, sp } from './_brand.js'
|
import type { BrandTokens } from '#services/brand'
|
||||||
|
import { sp } from './_brand.js'
|
||||||
import { EmailLayout } from './_layout.js'
|
import { EmailLayout } from './_layout.js'
|
||||||
|
|
||||||
export type CheckinEmailProps = {
|
export type CheckinEmailProps = {
|
||||||
|
/** Toujours DEFAULT_BRAND — checkin reste Rubis-branded. */
|
||||||
|
tokens: BrandTokens
|
||||||
invoice: {
|
invoice: {
|
||||||
numero: string
|
numero: string
|
||||||
amountFormatted: string
|
amountFormatted: string
|
||||||
@ -29,11 +28,11 @@ export type CheckinEmailProps = {
|
|||||||
user: { fullName: string | null }
|
user: { fullName: string | null }
|
||||||
paidUrl: string
|
paidUrl: string
|
||||||
pendingUrl: string
|
pendingUrl: string
|
||||||
/** URL landing publique (footer cliquable "Rubis sur l'ongle"). */
|
landingUrl?: string | null
|
||||||
landingUrl?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CheckinEmail({
|
export function CheckinEmail({
|
||||||
|
tokens,
|
||||||
invoice,
|
invoice,
|
||||||
client,
|
client,
|
||||||
user,
|
user,
|
||||||
@ -43,10 +42,120 @@ export function CheckinEmail({
|
|||||||
}: CheckinEmailProps) {
|
}: CheckinEmailProps) {
|
||||||
const greeting = user.fullName ? `Bonjour ${user.fullName.split(' ')[0]},` : 'Bonjour,'
|
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 (
|
return (
|
||||||
<EmailLayout
|
<EmailLayout
|
||||||
|
tokens={tokens}
|
||||||
preview={`${invoice.numero} (${invoice.amountFormatted}) — payée par ${client.name} ?`}
|
preview={`${invoice.numero} (${invoice.amountFormatted}) — payée par ${client.name} ?`}
|
||||||
brandName="Rubis"
|
|
||||||
brandSubtitle="Confirmation requise"
|
brandSubtitle="Confirmation requise"
|
||||||
landingUrl={landingUrl}
|
landingUrl={landingUrl}
|
||||||
>
|
>
|
||||||
@ -61,7 +170,6 @@ export function CheckinEmail({
|
|||||||
réglée, on évite l'email inutile et on encaisse +1 rubis.
|
réglée, on évite l'email inutile et on encaisse +1 rubis.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Card facture */}
|
|
||||||
<Section style={invoiceCardStyle}>
|
<Section style={invoiceCardStyle}>
|
||||||
<Text style={invoiceNumeroStyle}>{invoice.numero}</Text>
|
<Text style={invoiceNumeroStyle}>{invoice.numero}</Text>
|
||||||
<Text style={invoiceClientStyle}>{client.name}</Text>
|
<Text style={invoiceClientStyle}>{client.name}</Text>
|
||||||
@ -71,8 +179,7 @@ export function CheckinEmail({
|
|||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* CTA boutons */}
|
<Section style={{ marginTop: 0 }}>
|
||||||
<Section style={ctaSectionStyle}>
|
|
||||||
<Button href={paidUrl} style={primaryButtonStyle}>
|
<Button href={paidUrl} style={primaryButtonStyle}>
|
||||||
✓ Oui, la facture est payée
|
✓ Oui, la facture est payée
|
||||||
</Button>
|
</Button>
|
||||||
@ -92,124 +199,3 @@ export function CheckinEmail({
|
|||||||
</EmailLayout>
|
</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 = {
|
|
||||||
// Blanc sur fond crème pour détacher la card visuellement (avant
|
|
||||||
// c'était crème sur container blanc — maintenant le container est crème).
|
|
||||||
backgroundColor: BRAND.white,
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,48 +1,102 @@
|
|||||||
/**
|
/**
|
||||||
* Template email de remerciement — envoyé AU CLIENT FINAL après que
|
* Template email de remerciement — envoyé AU CLIENT FINAL après que
|
||||||
* l'utilisateur a confirmé le paiement (via check-in « Oui, payé » ou
|
* l'utilisateur a confirmé le paiement.
|
||||||
* mark-paid manuel).
|
|
||||||
*
|
*
|
||||||
* Mise en page volontairement plus douce que la relance :
|
* Mise en page plus douce que la relance : check ✓ visuel, mot du user
|
||||||
* - bandeau header rubis-deep avec un check ✓ pour signaler "tout est OK"
|
* en pre-line, card récap discrète (pas de date d'échéance, on est passé
|
||||||
* - body interpolé (le user a écrit le mot, on garde sa voix)
|
* à autre chose).
|
||||||
* - card récap discrète : facture, montant — pas de date d'échéance ni
|
*
|
||||||
* de retard, on est passé à autre chose.
|
* Tokens dynamiques per-org (cf. RelanceEmail).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { Section, Text } from '@react-email/components'
|
import { Section, Text } from '@react-email/components'
|
||||||
|
|
||||||
import { BRAND, sp } from './_brand.js'
|
import type { BrandTokens } from '#services/brand'
|
||||||
|
import { sp } from './_brand.js'
|
||||||
import { EmailLayout } from './_layout.js'
|
import { EmailLayout } from './_layout.js'
|
||||||
|
|
||||||
export type PaymentThanksEmailProps = {
|
export type PaymentThanksEmailProps = {
|
||||||
/** Nom commercial visible côté client (l'org du user). */
|
tokens: BrandTokens
|
||||||
brandName: string
|
|
||||||
invoice: {
|
invoice: {
|
||||||
numero: string
|
numero: string
|
||||||
amountFormatted: string
|
amountFormatted: string
|
||||||
}
|
}
|
||||||
/** Texte de remerciement (déjà interpolé) — le mot du user. */
|
/** Texte de remerciement (déjà interpolé) — le mot du user. */
|
||||||
bodyText: string
|
bodyText: string
|
||||||
/** URL landing publique (footer cliquable « Rubis sur l'ongle »). */
|
/** URL landing publique. null = footer masqué. */
|
||||||
landingUrl?: string
|
landingUrl?: string | null
|
||||||
|
hideRubisFooter?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentThanksEmail({
|
export function PaymentThanksEmail({
|
||||||
brandName,
|
tokens,
|
||||||
invoice,
|
invoice,
|
||||||
bodyText,
|
bodyText,
|
||||||
landingUrl,
|
landingUrl,
|
||||||
|
hideRubisFooter,
|
||||||
}: PaymentThanksEmailProps) {
|
}: PaymentThanksEmailProps) {
|
||||||
|
const checkBadgeWrapStyle: React.CSSProperties = {
|
||||||
|
textAlign: 'center',
|
||||||
|
margin: `0 0 ${sp.lg} 0`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkBadgeStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '44px',
|
||||||
|
height: '44px',
|
||||||
|
lineHeight: '44px',
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRadius: '999px',
|
||||||
|
backgroundColor: tokens.primaryGlow,
|
||||||
|
color: tokens.primaryDeep,
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: 800,
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyTextStyle: React.CSSProperties = {
|
||||||
|
color: tokens.text,
|
||||||
|
fontSize: '15px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
margin: `0 0 ${sp.xl} 0`,
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryCardStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: tokens.white,
|
||||||
|
border: `1px solid ${tokens.border}`,
|
||||||
|
borderRadius: tokens.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: tokens.textVeryMuted,
|
||||||
|
fontWeight: 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryValueStyle: React.CSSProperties = {
|
||||||
|
color: tokens.text,
|
||||||
|
fontWeight: 600,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout
|
<EmailLayout
|
||||||
|
tokens={tokens}
|
||||||
preview={`Paiement reçu — facture ${invoice.numero} (${invoice.amountFormatted})`}
|
preview={`Paiement reçu — facture ${invoice.numero} (${invoice.amountFormatted})`}
|
||||||
brandName={brandName}
|
|
||||||
brandSubtitle={`Paiement reçu · ${invoice.numero}`}
|
brandSubtitle={`Paiement reçu · ${invoice.numero}`}
|
||||||
landingUrl={landingUrl}
|
landingUrl={landingUrl}
|
||||||
|
hideRubisFooter={hideRubisFooter}
|
||||||
>
|
>
|
||||||
{/* Petit check visuel pour signaler positivement "c'est bon". */}
|
|
||||||
<Section style={checkBadgeWrapStyle}>
|
<Section style={checkBadgeWrapStyle}>
|
||||||
<span style={checkBadgeStyle} aria-hidden="true">
|
<span style={checkBadgeStyle} aria-hidden="true">
|
||||||
✓
|
✓
|
||||||
@ -71,66 +125,9 @@ export function PaymentThanksEmail({
|
|||||||
</Text>
|
</Text>
|
||||||
<Text style={summaryRowStyle}>
|
<Text style={summaryRowStyle}>
|
||||||
<span style={summaryLabelStyle}>Statut</span>
|
<span style={summaryLabelStyle}>Statut</span>
|
||||||
<span style={{ ...summaryValueStyle, color: BRAND.rubis }}>Réglée</span>
|
<span style={{ ...summaryValueStyle, color: tokens.primary }}>Réglée</span>
|
||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
</EmailLayout>
|
</EmailLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Styles inline
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const checkBadgeWrapStyle: React.CSSProperties = {
|
|
||||||
textAlign: 'center',
|
|
||||||
margin: `0 0 ${sp.lg} 0`,
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkBadgeStyle: React.CSSProperties = {
|
|
||||||
display: 'inline-block',
|
|
||||||
width: '44px',
|
|
||||||
height: '44px',
|
|
||||||
lineHeight: '44px',
|
|
||||||
textAlign: 'center',
|
|
||||||
borderRadius: '999px',
|
|
||||||
backgroundColor: BRAND.rubisGlow,
|
|
||||||
color: BRAND.rubisDeep,
|
|
||||||
fontSize: '22px',
|
|
||||||
fontWeight: 800,
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyTextStyle: React.CSSProperties = {
|
|
||||||
color: BRAND.ink,
|
|
||||||
fontSize: '15px',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
margin: `0 0 ${sp.xl} 0`,
|
|
||||||
whiteSpace: 'pre-line',
|
|
||||||
}
|
|
||||||
|
|
||||||
const summaryCardStyle: React.CSSProperties = {
|
|
||||||
backgroundColor: BRAND.white,
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,24 +4,25 @@
|
|||||||
* le user dans son plan de relance (avec variables {{numero}} etc.) ; on
|
* le user dans son plan de relance (avec variables {{numero}} etc.) ; on
|
||||||
* injecte ce body brut dans une mise en page Rubis :
|
* 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)
|
* - Header avec logo customisé (Business) ou wordmark "◆ <senderName>"
|
||||||
* et le numéro de facture en sous-titre
|
|
||||||
* - Body rendu (le texte que le user a rédigé, déjà interpolé)
|
* - 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
|
* - 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
|
* Tokens visuels passés en prop `tokens` (résolus par org via
|
||||||
* mail (virement, espèces, ...). Le mail rappelle juste le contexte.
|
* `#services/brand`). Plan Business = couleurs / logo / nom expéditeur
|
||||||
|
* custom. Sinon = palette Rubis par défaut.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { Section, Text } from '@react-email/components'
|
import { Section, Text } from '@react-email/components'
|
||||||
|
|
||||||
import { BRAND, sp } from './_brand.js'
|
import type { BrandTokens } from '#services/brand'
|
||||||
|
import { sp } from './_brand.js'
|
||||||
import { EmailLayout } from './_layout.js'
|
import { EmailLayout } from './_layout.js'
|
||||||
|
|
||||||
export type RelanceEmailProps = {
|
export type RelanceEmailProps = {
|
||||||
/** Nom commercial visible côté client (l'org du user). */
|
/** Tokens résolus pour l'org (cf. resolveBrandTokens). */
|
||||||
brandName: string
|
tokens: BrandTokens
|
||||||
invoice: {
|
invoice: {
|
||||||
numero: string
|
numero: string
|
||||||
amountFormatted: string
|
amountFormatted: string
|
||||||
@ -30,32 +31,68 @@ export type RelanceEmailProps = {
|
|||||||
}
|
}
|
||||||
/** Texte de la relance (déjà interpolé) que le user a posé dans son plan. */
|
/** Texte de la relance (déjà interpolé) que le user a posé dans son plan. */
|
||||||
bodyText: string
|
bodyText: string
|
||||||
/** URL landing publique (footer cliquable "Rubis sur l'ongle"). */
|
/** URL landing publique (footer cliquable "Rubis sur l'ongle"). null = footer masqué. */
|
||||||
landingUrl?: string
|
landingUrl?: string | null
|
||||||
|
/** Masque le footer Rubis (marque blanche full). */
|
||||||
|
hideRubisFooter?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RelanceEmail({
|
export function RelanceEmail({
|
||||||
brandName,
|
tokens,
|
||||||
invoice,
|
invoice,
|
||||||
bodyText,
|
bodyText,
|
||||||
landingUrl,
|
landingUrl,
|
||||||
|
hideRubisFooter,
|
||||||
}: RelanceEmailProps) {
|
}: RelanceEmailProps) {
|
||||||
const isLate = invoice.daysLate > 0
|
const isLate = invoice.daysLate > 0
|
||||||
|
|
||||||
|
const bodyTextStyle: React.CSSProperties = {
|
||||||
|
color: tokens.text,
|
||||||
|
fontSize: '15px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
margin: `0 0 ${sp.xl} 0`,
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryCardStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: tokens.white,
|
||||||
|
border: `1px solid ${tokens.border}`,
|
||||||
|
borderRadius: tokens.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: tokens.textVeryMuted,
|
||||||
|
fontWeight: 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryValueStyle: React.CSSProperties = {
|
||||||
|
color: tokens.text,
|
||||||
|
fontWeight: 600,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmailLayout
|
<EmailLayout
|
||||||
|
tokens={tokens}
|
||||||
preview={`${invoice.numero} (${invoice.amountFormatted}) — ${
|
preview={`${invoice.numero} (${invoice.amountFormatted}) — ${
|
||||||
isLate ? `${invoice.daysLate}j de retard` : 'à régler'
|
isLate ? `${invoice.daysLate}j de retard` : 'à régler'
|
||||||
}`}
|
}`}
|
||||||
brandName={brandName}
|
|
||||||
brandSubtitle={`Facture ${invoice.numero}`}
|
brandSubtitle={`Facture ${invoice.numero}`}
|
||||||
landingUrl={landingUrl}
|
landingUrl={landingUrl}
|
||||||
|
hideRubisFooter={hideRubisFooter}
|
||||||
>
|
>
|
||||||
{/* 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>
|
<Text style={bodyTextStyle}>{bodyText}</Text>
|
||||||
|
|
||||||
{/* Card récap en pied : tableau visuel court qui rappelle les chiffres. */}
|
|
||||||
<Section style={summaryCardStyle}>
|
<Section style={summaryCardStyle}>
|
||||||
<Text style={summaryRowStyle}>
|
<Text style={summaryRowStyle}>
|
||||||
<span style={summaryLabelStyle}>Facture</span>
|
<span style={summaryLabelStyle}>Facture</span>
|
||||||
@ -79,12 +116,14 @@ export function RelanceEmail({
|
|||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
...summaryValueStyle,
|
...summaryValueStyle,
|
||||||
color: isLate ? BRAND.rubisDeep : BRAND.ink,
|
color: isLate ? tokens.primaryDeep : tokens.text,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{invoice.dueDateFormatted}
|
{invoice.dueDateFormatted}
|
||||||
{isLate ? (
|
{isLate ? (
|
||||||
<span style={{ color: BRAND.rubisDeep, fontSize: '12px', marginLeft: sp.sm }}>
|
<span
|
||||||
|
style={{ color: tokens.primaryDeep, fontSize: '12px', marginLeft: sp.sm }}
|
||||||
|
>
|
||||||
({invoice.daysLate}j de retard)
|
({invoice.daysLate}j de retard)
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@ -94,44 +133,3 @@ export function RelanceEmail({
|
|||||||
</EmailLayout>
|
</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 = {
|
|
||||||
// Blanc sur fond crème pour détacher la card visuellement (le container
|
|
||||||
// du layout est désormais crème, pas blanc).
|
|
||||||
backgroundColor: BRAND.white,
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|||||||
51
apps/api/app/middleware/assert_business_plan_middleware.ts
Normal file
51
apps/api/app/middleware/assert_business_plan_middleware.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
import type { NextFn } from '@adonisjs/core/types/http'
|
||||||
|
import { Exception } from '@adonisjs/core/exceptions'
|
||||||
|
|
||||||
|
import Organization from '#models/organization'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware de gating Business — bloque l'accès aux endpoints réservés au
|
||||||
|
* plan Business (marque blanche, comptes email connectés, etc.).
|
||||||
|
*
|
||||||
|
* Throw 403 avec `code: 'business_plan_required'` si l'org n'est pas sur
|
||||||
|
* Business. Le SPA matche ce code pour afficher l'upsell card propre au
|
||||||
|
* lieu d'un 403 brutal.
|
||||||
|
*
|
||||||
|
* Précondition : l'auth middleware doit avoir déjà tourné en amont — on
|
||||||
|
* lit `auth.user.organizationId` sans re-vérifier le token. Si pas d'org
|
||||||
|
* rattachée (cas pathologique), on throw 404 (jamais "no business plan" sur
|
||||||
|
* un user sans org — c'est un autre problème).
|
||||||
|
*
|
||||||
|
* Usage dans routes.ts :
|
||||||
|
* router
|
||||||
|
* .group(() => {
|
||||||
|
* router.get('brand', [BrandController, 'show'])
|
||||||
|
* router.patch('brand', [BrandController, 'update'])
|
||||||
|
* })
|
||||||
|
* .prefix('/api/v1')
|
||||||
|
* .use([middleware.auth(), middleware.assertBusinessPlan()])
|
||||||
|
*/
|
||||||
|
export default class AssertBusinessPlanMiddleware {
|
||||||
|
async handle(ctx: HttpContext, next: NextFn) {
|
||||||
|
const user = ctx.auth.getUserOrFail()
|
||||||
|
if (!user.organizationId) {
|
||||||
|
throw new Exception('Aucune organisation rattachée', {
|
||||||
|
status: 404,
|
||||||
|
code: 'not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// On charge l'org pour lire le plan courant. Cache HTTP côté SPA assume
|
||||||
|
// que cette query reste rapide (un index sur id, ce qui est le cas).
|
||||||
|
const org = await Organization.findOrFail(user.organizationId)
|
||||||
|
if (org.plan !== 'business') {
|
||||||
|
throw new Exception('Plan Business requis pour cette fonctionnalité', {
|
||||||
|
status: 403,
|
||||||
|
code: 'business_plan_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,20 @@
|
|||||||
import { OrganizationSchema } from '#database/schema'
|
import { OrganizationSchema } from '#database/schema'
|
||||||
import { hasMany } from '@adonisjs/lucid/orm'
|
import { column, hasMany } from '@adonisjs/lucid/orm'
|
||||||
import type { HasMany } from '@adonisjs/lucid/types/relations'
|
import type { HasMany } from '@adonisjs/lucid/types/relations'
|
||||||
import User from '#models/user'
|
import User from '#models/user'
|
||||||
|
import type { BrandSettings } from '#services/brand'
|
||||||
|
|
||||||
export default class Organization extends OrganizationSchema {
|
export default class Organization extends OrganizationSchema {
|
||||||
|
/**
|
||||||
|
* Settings de marque blanche (plan Business) — JSONB, null = palette
|
||||||
|
* Rubis par défaut. Cf. `#services/brand` pour la résolution et la
|
||||||
|
* validation. Cette déclaration manuelle existe en attendant que
|
||||||
|
* `schema.ts` soit régénéré par `node ace migration:run` (cf. migration
|
||||||
|
* `1778400000000_add_brand_settings_to_organizations_table.ts`).
|
||||||
|
*/
|
||||||
|
@column()
|
||||||
|
declare brandSettings: BrandSettings | null
|
||||||
|
|
||||||
@hasMany(() => User)
|
@hasMany(() => User)
|
||||||
declare users: HasMany<typeof User>
|
declare users: HasMany<typeof User>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* blog_uploads — upload + serve d'images du blog.
|
|
||||||
*
|
|
||||||
* Architecture (cf. /docs/tech/architecture.md §3) :
|
|
||||||
* - Upload : POST /api/v1/admin/uploads → multipart → MinIO (drive S3)
|
|
||||||
* sous la clé `uploads/blog/{uuid}.{ext}`, visibilité privée.
|
|
||||||
* - Lecture : GET /api/v1/uploads/blog/:filename → stream depuis MinIO
|
|
||||||
* avec Cache-Control: public, max-age=31536000, immutable. Le fichier
|
|
||||||
* est immuable (uuid dans le nom = chaque upload = nouvelle URL), donc
|
|
||||||
* cache infini sans risque d'invalidation.
|
|
||||||
* - L'URL publique est ensuite stockée dans posts.hero_image_url (et
|
|
||||||
* optionnellement og_image_url) — pas de FK, simple reference texte.
|
|
||||||
*
|
|
||||||
* Orphelins : quand un post change de hero, l'ancienne image reste sur
|
|
||||||
* MinIO (~quelques KB par fichier). Acceptable pour V1 ; périodique
|
|
||||||
* cleanup à ajouter quand on dépassera ~100 articles.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto'
|
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
import drive from '@adonisjs/drive/services/main'
|
|
||||||
import type { MultipartFile } from '@adonisjs/core/bodyparser'
|
|
||||||
|
|
||||||
const ALLOWED_EXTS = ['jpg', 'jpeg', 'png', 'webp'] as const
|
|
||||||
const MAX_BYTES = 4 * 1024 * 1024 // 4 MB
|
|
||||||
|
|
||||||
export type UploadResult = {
|
|
||||||
/** URL publique relative (à préfixer du host API côté client). */
|
|
||||||
publicPath: string
|
|
||||||
/** Clé S3 réelle dans le bucket (debug / cleanup). */
|
|
||||||
storageKey: string
|
|
||||||
/** Type MIME inféré de l'extension. */
|
|
||||||
contentType: string
|
|
||||||
/** Taille réelle en bytes. */
|
|
||||||
sizeBytes: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reçoit un MultipartFile (validé Adonis) et le pousse sur MinIO.
|
|
||||||
* Throw si le fichier ne respecte pas les contraintes (taille, MIME).
|
|
||||||
*/
|
|
||||||
export async function uploadBlogImage(file: MultipartFile): Promise<UploadResult> {
|
|
||||||
const ext = (file.extname ?? '').toLowerCase()
|
|
||||||
if (!ALLOWED_EXTS.includes(ext as (typeof ALLOWED_EXTS)[number])) {
|
|
||||||
throw new Error(`unsupported_extension: ${ext} (autorisés : ${ALLOWED_EXTS.join(', ')})`)
|
|
||||||
}
|
|
||||||
if (file.size > MAX_BYTES) {
|
|
||||||
throw new Error(`file_too_large: ${file.size}B (max ${MAX_BYTES}B)`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const uuid = randomUUID()
|
|
||||||
const filename = `${uuid}.${ext}`
|
|
||||||
const storageKey = `uploads/blog/${filename}`
|
|
||||||
|
|
||||||
// Adonis Drive accepte un path local (le multipart écrit le tmp file).
|
|
||||||
if (!file.tmpPath) {
|
|
||||||
throw new Error('multipart_no_tmpPath')
|
|
||||||
}
|
|
||||||
await drive.use().moveFromFs(file.tmpPath, storageKey)
|
|
||||||
|
|
||||||
// URL absolue pour que la landing publique (rubis.pro) puisse l'afficher
|
|
||||||
// directement en <img src> sans avoir à connaître l'API host. APP_URL est
|
|
||||||
// posé par le ConfigMap k3s rubis-api-config ('https://app.rubis.pro').
|
|
||||||
const apiHost = (process.env.APP_URL || 'http://localhost:3333').replace(/\/$/, '')
|
|
||||||
|
|
||||||
return {
|
|
||||||
publicPath: `${apiHost}/api/v1/uploads/blog/${filename}`,
|
|
||||||
storageKey,
|
|
||||||
contentType: extToContentType(ext),
|
|
||||||
sizeBytes: file.size,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stream une image depuis MinIO. Renvoie un Buffer + le contentType
|
|
||||||
* pour que le contrôleur réponde avec les bons headers.
|
|
||||||
*/
|
|
||||||
export async function readBlogImage(filename: string): Promise<{
|
|
||||||
buffer: Buffer
|
|
||||||
contentType: string
|
|
||||||
} | null> {
|
|
||||||
// Sécurité : pas de path traversal. Le slug doit matcher `uuid.ext`.
|
|
||||||
if (!/^[a-f0-9-]{36}\.(jpg|jpeg|png|webp)$/i.test(filename)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const storageKey = `uploads/blog/${filename}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const buffer = Buffer.from(await drive.use().getArrayBuffer(storageKey))
|
|
||||||
return {
|
|
||||||
buffer,
|
|
||||||
contentType: extToContentType(path.extname(filename).slice(1).toLowerCase()),
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extToContentType(ext: string): string {
|
|
||||||
switch (ext) {
|
|
||||||
case 'jpg':
|
|
||||||
case 'jpeg':
|
|
||||||
return 'image/jpeg'
|
|
||||||
case 'png':
|
|
||||||
return 'image/png'
|
|
||||||
case 'webp':
|
|
||||||
return 'image/webp'
|
|
||||||
default:
|
|
||||||
return 'application/octet-stream'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
apps/api/app/services/brand.ts
Normal file
BIN
apps/api/app/services/brand.ts
Normal file
Binary file not shown.
@ -16,6 +16,7 @@ import type Organization from '#models/organization'
|
|||||||
import { CheckinEmail } from '#mails/checkin_email'
|
import { CheckinEmail } from '#mails/checkin_email'
|
||||||
import { RelanceEmail } from '#mails/relance_email'
|
import { RelanceEmail } from '#mails/relance_email'
|
||||||
import { PaymentThanksEmail } from '#mails/payment_thanks_email'
|
import { PaymentThanksEmail } from '#mails/payment_thanks_email'
|
||||||
|
import { resolveBrandTokens, DEFAULT_BRAND } from '#services/brand'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Templates par défaut utilisés quand le plan d'une org n'a pas (ou plus)
|
* Templates par défaut utilisés quand le plan d'une org n'a pas (ou plus)
|
||||||
@ -112,15 +113,15 @@ export async function sendRelanceEmail({
|
|||||||
const subject = renderTemplate(step.subject, vars)
|
const subject = renderTemplate(step.subject, vars)
|
||||||
const body = renderTemplate(step.body, vars)
|
const body = renderTemplate(step.body, vars)
|
||||||
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
|
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
|
||||||
// Le client final connaît l'org (ex: "Arthur Barré"), pas Rubis. On utilise
|
// Le client final connaît l'org du user, pas Rubis. Le display From: vient
|
||||||
// le nom de l'org comme display name visible côté client. Fallback :
|
// de `tokens.senderName` qui résout la cascade :
|
||||||
// user.fullName, puis MAIL_FROM_NAME (= "Rubis sur l'ongle") en dernier
|
// brandSettings.senderName (Business) → org.name → user.fullName → "Rubis".
|
||||||
// recours si l'org n'a pas de nom posé.
|
// Les couleurs/logo du template viennent aussi de tokens (Business only,
|
||||||
// L'adresse technique reste sur notre domaine vérifié (SPF/DKIM Resend).
|
// sinon palette Rubis intacte). L'adresse technique reste sur notre domaine
|
||||||
const fromName =
|
// vérifié (SPF/DKIM Resend) en V1 — le send-on-behalf via Gmail/Microsoft/SMTP
|
||||||
organization?.name?.trim() ||
|
// arrive Phase 2/3/4 (cf. ADR à venir).
|
||||||
user?.fullName?.trim() ||
|
const tokens = resolveBrandTokens(organization ?? null)
|
||||||
env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
|
const fromName = tokens.senderName || user?.fullName?.trim() || 'Rubis sur l\'ongle'
|
||||||
|
|
||||||
// Calcule daysLate pour le récap visuel dans le HTML.
|
// Calcule daysLate pour le récap visuel dans le HTML.
|
||||||
const nowOrg = await clock.now(invoice.organizationId)
|
const nowOrg = await clock.now(invoice.organizationId)
|
||||||
@ -130,10 +131,10 @@ export async function sendRelanceEmail({
|
|||||||
|
|
||||||
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
|
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
|
||||||
|
|
||||||
// Rendu HTML via React Email — DA Rubis (header rubis-deep + card cream).
|
// Rendu HTML via React Email — tokens dynamiques (Business custom ou Rubis).
|
||||||
const htmlBody = await render(
|
const htmlBody = await render(
|
||||||
RelanceEmail({
|
RelanceEmail({
|
||||||
brandName: fromName,
|
tokens,
|
||||||
invoice: {
|
invoice: {
|
||||||
numero: invoice.numero,
|
numero: invoice.numero,
|
||||||
amountFormatted: formatAmountFr(invoice.amountTtcCents),
|
amountFormatted: formatAmountFr(invoice.amountTtcCents),
|
||||||
@ -258,10 +259,13 @@ L'équipe Rubis`
|
|||||||
// PAS le nom de l'org.
|
// PAS le nom de l'org.
|
||||||
const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
|
const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
|
||||||
|
|
||||||
// Rendu HTML — DA Rubis avec 2 boutons CTA Oui/Non.
|
// Rendu HTML — DA Rubis avec 2 boutons CTA Oui/Non. Toujours en branding
|
||||||
|
// Rubis (jamais customisable) : c'est une notif Rubis → user, pas user →
|
||||||
|
// client. Le user reconnaît Rubis comme expéditeur, pas sa propre marque.
|
||||||
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
|
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
|
||||||
const htmlBody = await render(
|
const htmlBody = await render(
|
||||||
CheckinEmail({
|
CheckinEmail({
|
||||||
|
tokens: DEFAULT_BRAND,
|
||||||
invoice: {
|
invoice: {
|
||||||
numero: invoice.numero,
|
numero: invoice.numero,
|
||||||
amountFormatted: formatAmountFr(invoice.amountTtcCents),
|
amountFormatted: formatAmountFr(invoice.amountTtcCents),
|
||||||
@ -349,18 +353,15 @@ export async function sendPaymentThanksEmail({
|
|||||||
const body = renderTemplate(bodyTpl, vars)
|
const body = renderTemplate(bodyTpl, vars)
|
||||||
|
|
||||||
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
|
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
|
||||||
// Le client connaît l'org du user (ex: « Arthur Barré »), pas Rubis.
|
// Tokens résolus + senderName aligné avec sendRelanceEmail.
|
||||||
// Aligné avec sendRelanceEmail.
|
const tokens = resolveBrandTokens(organization ?? null)
|
||||||
const fromName =
|
const fromName = tokens.senderName || user?.fullName?.trim() || 'Rubis sur l\'ongle'
|
||||||
organization?.name?.trim() ||
|
|
||||||
user?.fullName?.trim() ||
|
|
||||||
env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
|
|
||||||
|
|
||||||
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
|
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
|
||||||
|
|
||||||
const htmlBody = await render(
|
const htmlBody = await render(
|
||||||
PaymentThanksEmail({
|
PaymentThanksEmail({
|
||||||
brandName: fromName,
|
tokens,
|
||||||
invoice: {
|
invoice: {
|
||||||
numero: invoice.numero,
|
numero: invoice.numero,
|
||||||
amountFormatted: formatAmountFr(invoice.amountTtcCents),
|
amountFormatted: formatAmountFr(invoice.amountTtcCents),
|
||||||
|
|||||||
174
apps/api/app/services/media_storage.ts
Normal file
174
apps/api/app/services/media_storage.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* media_storage — service mutualisé d'upload / lecture d'images sur MinIO.
|
||||||
|
*
|
||||||
|
* Remplace l'ancien `blog_uploads.ts` : toutes les features qui ont besoin
|
||||||
|
* de stocker un fichier image (blog hero, logo de marque blanche, futures
|
||||||
|
* pièces jointes, avatars...) passent par ici.
|
||||||
|
*
|
||||||
|
* Pattern :
|
||||||
|
* - Chaque "scope" (`blog`, `brand-logo`, ...) déclare ses contraintes
|
||||||
|
* (extensions, taille max, prefix de stockage, prefix d'URL publique).
|
||||||
|
* - L'upload retourne `{ publicPath, storageKey, contentType, sizeBytes }`.
|
||||||
|
* `storageKey` est conservé en DB pour pouvoir supprimer plus tard.
|
||||||
|
* - La lecture est publique (cache `max-age=31536000 immutable` côté
|
||||||
|
* controller) — pas d'auth, parce que les emails sortants vers les
|
||||||
|
* clients doivent pouvoir charger l'image sans token.
|
||||||
|
* - Cleanup : `deleteMedia(storageKey)` retire un fichier. Utilisé quand
|
||||||
|
* l'user remplace un logo (pas de versioning, on écrase l'ancien).
|
||||||
|
*
|
||||||
|
* Sécurité :
|
||||||
|
* - Filename = UUID v4 → globalement unique, pas de collision cross-org
|
||||||
|
* même si on garde le storage prefix plat (`uploads/brand-logos/{uuid}.ext`)
|
||||||
|
* - Le pattern `^[a-f0-9-]{36}\.(...)$` empêche le path traversal sur la
|
||||||
|
* lecture publique.
|
||||||
|
*
|
||||||
|
* URLs publiques :
|
||||||
|
* - L'host est `process.env.APP_URL` (posé par le ConfigMap rubis-api).
|
||||||
|
* - Forme : `{APP_URL}/api/v1/uploads/{scopeUrlSegment}/{filename}`
|
||||||
|
* - Stocké en DB → si APP_URL change un jour, faudra migrer (acceptable).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import drive from '@adonisjs/drive/services/main'
|
||||||
|
import type { MultipartFile } from '@adonisjs/core/bodyparser'
|
||||||
|
|
||||||
|
export type MediaScope = 'blog' | 'brand-logo'
|
||||||
|
|
||||||
|
interface ScopeConfig {
|
||||||
|
/** Préfixe de stockage MinIO (clé S3). */
|
||||||
|
storagePrefix: string
|
||||||
|
/** Segment d'URL publique servi par le controller (ex. "blog" → /api/v1/uploads/blog/...). */
|
||||||
|
urlSegment: string
|
||||||
|
/** Extensions autorisées (lowercase, sans le point). */
|
||||||
|
allowedExts: readonly string[]
|
||||||
|
/** Taille max en bytes. */
|
||||||
|
maxBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPES: Record<MediaScope, ScopeConfig> = {
|
||||||
|
blog: {
|
||||||
|
storagePrefix: 'uploads/blog',
|
||||||
|
urlSegment: 'blog',
|
||||||
|
allowedExts: ['jpg', 'jpeg', 'png', 'webp'],
|
||||||
|
maxBytes: 4 * 1024 * 1024, // 4 MB — hero images de blog
|
||||||
|
},
|
||||||
|
'brand-logo': {
|
||||||
|
storagePrefix: 'uploads/brand-logos',
|
||||||
|
urlSegment: 'brand-logos',
|
||||||
|
// SVG accepté pour les logos vectoriels (typique des entreprises).
|
||||||
|
// Risque XSS dans un SVG → on serve avec Content-Type strict et X-Content-Type-Options nosniff.
|
||||||
|
allowedExts: ['jpg', 'jpeg', 'png', 'webp', 'svg'],
|
||||||
|
maxBytes: 1 * 1024 * 1024, // 1 MB — un logo n'a aucune raison d'être plus gros
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
/** URL publique absolue (à stocker en DB et à intégrer dans les emails). */
|
||||||
|
publicPath: string
|
||||||
|
/** Clé S3 / MinIO réelle (à conserver pour delete ultérieur). */
|
||||||
|
storageKey: string
|
||||||
|
/** Type MIME inféré de l'extension. */
|
||||||
|
contentType: string
|
||||||
|
/** Taille réelle en bytes. */
|
||||||
|
sizeBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reçoit un MultipartFile (validé Adonis) et le pousse sur MinIO.
|
||||||
|
* Throw si extension/taille hors contraintes du scope.
|
||||||
|
*/
|
||||||
|
export async function uploadMedia(
|
||||||
|
file: MultipartFile,
|
||||||
|
scope: MediaScope
|
||||||
|
): Promise<UploadResult> {
|
||||||
|
const cfg = SCOPES[scope]
|
||||||
|
if (!cfg) throw new Error(`unknown_scope: ${scope}`)
|
||||||
|
|
||||||
|
const ext = (file.extname ?? '').toLowerCase()
|
||||||
|
if (!cfg.allowedExts.includes(ext)) {
|
||||||
|
throw new Error(
|
||||||
|
`unsupported_extension: ${ext} (autorisés : ${cfg.allowedExts.join(', ')})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (file.size > cfg.maxBytes) {
|
||||||
|
throw new Error(`file_too_large: ${file.size}B (max ${cfg.maxBytes}B)`)
|
||||||
|
}
|
||||||
|
if (!file.tmpPath) throw new Error('multipart_no_tmpPath')
|
||||||
|
|
||||||
|
const filename = `${randomUUID()}.${ext}`
|
||||||
|
const storageKey = `${cfg.storagePrefix}/${filename}`
|
||||||
|
|
||||||
|
await drive.use().moveFromFs(file.tmpPath, storageKey)
|
||||||
|
|
||||||
|
const apiHost = (process.env.APP_URL || 'http://localhost:3333').replace(/\/$/, '')
|
||||||
|
return {
|
||||||
|
publicPath: `${apiHost}/api/v1/uploads/${cfg.urlSegment}/${filename}`,
|
||||||
|
storageKey,
|
||||||
|
contentType: extToContentType(ext),
|
||||||
|
sizeBytes: file.size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream une image depuis MinIO. Renvoie un Buffer + le contentType pour
|
||||||
|
* que le controller réponde avec les bons headers + cache long.
|
||||||
|
*/
|
||||||
|
export async function readMedia(
|
||||||
|
scope: MediaScope,
|
||||||
|
filename: string
|
||||||
|
): Promise<{ buffer: Buffer; contentType: string } | null> {
|
||||||
|
const cfg = SCOPES[scope]
|
||||||
|
if (!cfg) return null
|
||||||
|
|
||||||
|
// Sécurité : filename doit matcher UUID v4 + extension autorisée. Empêche
|
||||||
|
// toute tentative de path traversal ou lecture hors scope.
|
||||||
|
const extPattern = cfg.allowedExts.join('|')
|
||||||
|
const re = new RegExp(`^[a-f0-9-]{36}\\.(${extPattern})$`, 'i')
|
||||||
|
if (!re.test(filename)) return null
|
||||||
|
|
||||||
|
const storageKey = `${cfg.storagePrefix}/${filename}`
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(await drive.use().getArrayBuffer(storageKey))
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
contentType: extToContentType(path.extname(filename).slice(1).toLowerCase()),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un fichier de MinIO. Utilisé quand un user remplace son logo
|
||||||
|
* de marque (pas de versioning, on écrase l'ancien fichier proprement
|
||||||
|
* plutôt que d'accumuler des orphelins).
|
||||||
|
*
|
||||||
|
* Silent fail si la clé n'existe pas — on ne veut pas bloquer un PATCH
|
||||||
|
* brand_settings parce qu'on n'arrive pas à supprimer un fichier déjà
|
||||||
|
* absent.
|
||||||
|
*/
|
||||||
|
export async function deleteMedia(storageKey: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await drive.use().delete(storageKey)
|
||||||
|
} catch {
|
||||||
|
// ignore — best effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extToContentType(ext: string): string {
|
||||||
|
switch (ext) {
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg':
|
||||||
|
return 'image/jpeg'
|
||||||
|
case 'png':
|
||||||
|
return 'image/png'
|
||||||
|
case 'webp':
|
||||||
|
return 'image/webp'
|
||||||
|
case 'svg':
|
||||||
|
return 'image/svg+xml'
|
||||||
|
default:
|
||||||
|
return 'application/octet-stream'
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { DateTime } from 'luxon'
|
|||||||
import env from '#start/env'
|
import env from '#start/env'
|
||||||
|
|
||||||
import { CheckinEmail } from '#mails/checkin_email'
|
import { CheckinEmail } from '#mails/checkin_email'
|
||||||
|
import { DEFAULT_BRAND } from '#services/brand'
|
||||||
import { RelanceEmail } from '#mails/relance_email'
|
import { RelanceEmail } from '#mails/relance_email'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -82,7 +83,7 @@ export default class SendTestEmail extends BaseCommand {
|
|||||||
`Bonjour,\n\nPetit rappel pour la facture ${fakeInvoice.numero}.\n\nCordialement,\nArthur Barré`
|
`Bonjour,\n\nPetit rappel pour la facture ${fakeInvoice.numero}.\n\nCordialement,\nArthur Barré`
|
||||||
html = await render(
|
html = await render(
|
||||||
RelanceEmail({
|
RelanceEmail({
|
||||||
brandName: 'Arthur Barré (test)',
|
tokens: { ...DEFAULT_BRAND, senderName: 'Arthur Barré (test)' },
|
||||||
invoice: { ...fakeInvoice, daysLate: 3 },
|
invoice: { ...fakeInvoice, daysLate: 3 },
|
||||||
bodyText: text,
|
bodyText: text,
|
||||||
landingUrl,
|
landingUrl,
|
||||||
@ -98,6 +99,7 @@ export default class SendTestEmail extends BaseCommand {
|
|||||||
`— L'équipe Rubis`
|
`— L'équipe Rubis`
|
||||||
html = await render(
|
html = await render(
|
||||||
CheckinEmail({
|
CheckinEmail({
|
||||||
|
tokens: DEFAULT_BRAND,
|
||||||
invoice: fakeInvoice,
|
invoice: fakeInvoice,
|
||||||
client: { name: 'Boulangerie Test' },
|
client: { name: 'Boulangerie Test' },
|
||||||
user: { fullName: 'Arthur Barré' },
|
user: { fullName: 'Arthur Barré' },
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque blanche pour le plan Business.
|
||||||
|
*
|
||||||
|
* `brand_settings` est une colonne JSONB nullable qui stocke les overrides
|
||||||
|
* visuels appliqués aux emails sortants (relances, remerciements) — logo,
|
||||||
|
* nom expéditeur, et 10+ tokens de couleur. null = utilise les defaults
|
||||||
|
* Rubis (cf. apps/api/app/mails/_brand.ts).
|
||||||
|
*
|
||||||
|
* On choisit JSONB plutôt que 11 colonnes plates parce que :
|
||||||
|
* - aucun champ n'a besoin d'être indexé / filtré individuellement
|
||||||
|
* - on lit toujours le settings entier au moment d'envoyer un mail
|
||||||
|
* - on peut ajouter de nouveaux tokens sans migration (UX "tatillons")
|
||||||
|
* - serializer/validateur unique côté backend
|
||||||
|
*
|
||||||
|
* Le contrôleur fait du write-through merge : un PATCH partiel ne remplace
|
||||||
|
* pas tout l'objet, il met à jour seulement les clés fournies. Mettre une
|
||||||
|
* clé à `null` explicitement → revient au default Rubis sur ce token précis.
|
||||||
|
*
|
||||||
|
* Schéma applicatif (cf. `apps/api/app/services/brand.ts` BrandSettings) :
|
||||||
|
* {
|
||||||
|
* logoPath?: string, // clé storage MinIO
|
||||||
|
* logoUrl?: string, // URL publique servie par /api/v1/uploads/...
|
||||||
|
* senderName?: string, // "Cabinet Compta Martin" pour From: header
|
||||||
|
* primaryColor?: string, // hex #RRGGBB — CTA + accents + default link
|
||||||
|
* bannerColor?: string, // bandeau header de l'email
|
||||||
|
* bodyBgColor?: string, // fond global de l'email
|
||||||
|
* cardBgColor?: string, // fond de la carte de contenu
|
||||||
|
* textColor?: string, // texte principal
|
||||||
|
* textMutedColor?: string, // texte secondaire (footer, meta)
|
||||||
|
* borderColor?: string, // séparateurs
|
||||||
|
* linkColor?: string, // liens (sinon = primaryColor)
|
||||||
|
* buttonTextColor?: string, // texte sur CTA (default white)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Plan gating : la résolution n'utilise les overrides QUE si `org.plan ===
|
||||||
|
* 'business'`. Si l'org downgrade de Business à Pro/Free, ses settings
|
||||||
|
* restent en DB (pas de perte de config), mais ne s'appliquent plus —
|
||||||
|
* les emails repartent en branding Rubis.
|
||||||
|
*/
|
||||||
|
export default class extends BaseSchema {
|
||||||
|
protected tableName = 'organizations'
|
||||||
|
|
||||||
|
async up() {
|
||||||
|
this.schema.alterTable(this.tableName, (table) => {
|
||||||
|
table.jsonb('brand_settings').nullable()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async down() {
|
||||||
|
this.schema.alterTable(this.tableName, (table) => {
|
||||||
|
table.dropColumn('brand_settings')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -50,6 +50,17 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
organizations: {
|
||||||
|
columns: {
|
||||||
|
// jsonb — settings de marque blanche (plan Business). Toutes les
|
||||||
|
// clés sont optionnelles, validation faite côté brand_controller.
|
||||||
|
// Cf. `#services/brand` pour la résolution + le type complet.
|
||||||
|
brand_settings: {
|
||||||
|
tsType:
|
||||||
|
"{ logoPath?: string | null; logoUrl?: string | null; senderName?: string | null; primaryColor?: string | null; bannerColor?: string | null; bodyBgColor?: string | null; cardBgColor?: string | null; textColor?: string | null; textMutedColor?: string | null; borderColor?: string | null; linkColor?: string | null; buttonTextColor?: string | null } | null",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
import_drafts: {
|
import_drafts: {
|
||||||
columns: {
|
columns: {
|
||||||
status: {
|
status: {
|
||||||
|
|||||||
@ -49,4 +49,5 @@ router.use([
|
|||||||
export const middleware = router.named({
|
export const middleware = router.named({
|
||||||
auth: () => import('#middleware/auth_middleware'),
|
auth: () => import('#middleware/auth_middleware'),
|
||||||
admin: () => import('#middleware/admin_middleware'),
|
admin: () => import('#middleware/admin_middleware'),
|
||||||
|
assertBusinessPlan: () => import('#middleware/assert_business_plan_middleware'),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import { controllers } from '#generated/controllers'
|
|||||||
const BlogController = () => import('#controllers/blog_controller')
|
const BlogController = () => import('#controllers/blog_controller')
|
||||||
const AdminPostsController = () => import('#controllers/admin_posts_controller')
|
const AdminPostsController = () => import('#controllers/admin_posts_controller')
|
||||||
const BlogUploadsController = () => import('#controllers/blog_uploads_controller')
|
const BlogUploadsController = () => import('#controllers/blog_uploads_controller')
|
||||||
|
const BrandController = () => import('#controllers/brand_controller')
|
||||||
|
|
||||||
|
|
||||||
router
|
router
|
||||||
@ -59,12 +60,24 @@ router
|
|||||||
.as('posts')
|
.as('posts')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Image hero servie depuis MinIO. Public, cache infini (le filename est
|
* Images servies depuis MinIO. Public, cache infini (le filename est
|
||||||
* un UUID immuable, chaque upload = nouvelle URL).
|
* un UUID immuable, chaque upload = nouvelle URL).
|
||||||
|
*
|
||||||
|
* Deux scopes :
|
||||||
|
* - `/uploads/blog/:filename` — hero images d'articles
|
||||||
|
* - `/uploads/brand-logos/:filename` — logos de marque blanche (Business)
|
||||||
|
*
|
||||||
|
* Le BlogUploadsController.show est en fait scope-agnostic (délègue à
|
||||||
|
* media_storage.readMedia), donc on le réutilise pour les deux. Un
|
||||||
|
* controller dédié serait du boilerplate inutile vu que la logique est
|
||||||
|
* 100% dans le service.
|
||||||
*/
|
*/
|
||||||
router
|
router
|
||||||
.get('uploads/blog/:filename', [BlogUploadsController, 'show'])
|
.get('uploads/blog/:filename', [BlogUploadsController, 'show'])
|
||||||
.as('uploads.blog.show')
|
.as('uploads.blog.show')
|
||||||
|
router
|
||||||
|
.get('uploads/brand-logos/:filename', [BrandController, 'showLogo'])
|
||||||
|
.as('uploads.brand_logos.show')
|
||||||
})
|
})
|
||||||
.prefix('/api/v1')
|
.prefix('/api/v1')
|
||||||
|
|
||||||
@ -165,6 +178,34 @@ router
|
|||||||
.as('organizations')
|
.as('organizations')
|
||||||
.use(middleware.auth())
|
.use(middleware.auth())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque blanche — auth + plan Business obligatoires. Le middleware
|
||||||
|
* `assertBusinessPlan` throw 403 `business_plan_required` que le SPA
|
||||||
|
* catch pour afficher l'upsell card propre.
|
||||||
|
*
|
||||||
|
* - GET /brand → settings + tokens résolus + defaults
|
||||||
|
* - PATCH /brand → maj partielle (null = reset au default)
|
||||||
|
* - POST /brand/logo → upload multipart, écrase l'ancien sur MinIO
|
||||||
|
* - DELETE /brand/logo → retire le logo (retour wordmark Rubis)
|
||||||
|
* - POST /brand/test → envoie un mail de test à l'user pour preview
|
||||||
|
*
|
||||||
|
* Note : la route publique de lecture du logo (sans auth) est dans le
|
||||||
|
* groupe public au-dessus (`uploads/brand-logos/:filename`), parce que
|
||||||
|
* les emails sortants vers les clients doivent pouvoir charger l'image
|
||||||
|
* sans token.
|
||||||
|
*/
|
||||||
|
router
|
||||||
|
.group(() => {
|
||||||
|
router.get('', [BrandController, 'show']).as('show')
|
||||||
|
router.patch('', [BrandController, 'update']).as('update')
|
||||||
|
router.post('logo', [BrandController, 'uploadLogo']).as('logo.upload')
|
||||||
|
router.delete('logo', [BrandController, 'deleteLogo']).as('logo.delete')
|
||||||
|
router.post('test', [BrandController, 'sendTest']).as('test')
|
||||||
|
})
|
||||||
|
.prefix('brand')
|
||||||
|
.as('brand')
|
||||||
|
.use([middleware.auth(), middleware.assertBusinessPlan()])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clients — auth requise, scope par organization de l'utilisateur courant.
|
* Clients — auth requise, scope par organization de l'utilisateur courant.
|
||||||
*/
|
*/
|
||||||
|
|||||||
18
apps/landing/src/content/changelog/1.11.0.md
Normal file
18
apps/landing/src/content/changelog/1.11.0.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
version: "1.11.0"
|
||||||
|
date: 2026-05-11
|
||||||
|
title: "Première brique de la marque blanche"
|
||||||
|
type: feature
|
||||||
|
highlights:
|
||||||
|
- "Logo, nom expéditeur et couleurs custom dans les emails envoyés à vos clients"
|
||||||
|
- "Les utilisateurs tatillons ont la main sur 10+ tokens visuels : bandeau, fonds, textes, bordures, boutons"
|
||||||
|
- "Disponible sur le plan Business — l'interface self-service arrive dans la prochaine version"
|
||||||
|
---
|
||||||
|
|
||||||
|
Le plan Business gagne sa fonctionnalité marque blanche : vos emails de relance et de remerciement partent désormais avec **votre logo, vos couleurs et votre nom** à la place du branding Rubis.
|
||||||
|
|
||||||
|
Côté visible par votre client : il reçoit un email signé `Cabinet Compta Martin` (et non Rubis), avec votre logo en haut, vos couleurs sur les boutons, et votre signature en bas. Aucun « envoyé via Rubis » nulle part — c'est votre marque, du début à la fin.
|
||||||
|
|
||||||
|
Pour les utilisateurs tatillons, on a poussé la customisation loin : couleur du bandeau, du fond, des cartes, des textes principaux et secondaires, des bordures, des liens, du texte sur les boutons. Une dizaine de tokens visuels en tout — vous gardez la main sur tout l'email.
|
||||||
|
|
||||||
|
L'interface self-service pour configurer tout ça depuis vos réglages arrive dans la prochaine version. En attendant, les utilisateurs Business peuvent déjà demander à activer leur branding manuellement.
|
||||||
@ -11,7 +11,7 @@
|
|||||||
* Convention : semver (major.minor.patch). Le toast affiche `v${APP_VERSION}`
|
* Convention : semver (major.minor.patch). Le toast affiche `v${APP_VERSION}`
|
||||||
* et linke vers `https://rubis.pro/changelog#${APP_VERSION}`.
|
* et linke vers `https://rubis.pro/changelog#${APP_VERSION}`.
|
||||||
*/
|
*/
|
||||||
export const APP_VERSION = "1.10.0";
|
export const APP_VERSION = "1.11.0";
|
||||||
|
|
||||||
/** URL absolue de la page changelog (utilisée par le toast). */
|
/** URL absolue de la page changelog (utilisée par le toast). */
|
||||||
export const CHANGELOG_URL = "https://rubis.pro/changelog";
|
export const CHANGELOG_URL = "https://rubis.pro/changelog";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user