feat(p1): calendrier dispo + emails Resend + amount calculé + best-effort welcome/confirmation/refund

This commit is contained in:
Claude Integration 2026-06-01 02:20:38 +00:00
parent 4e14854245
commit b59b8a0af2
7 changed files with 585 additions and 6 deletions

207
src/lib/email.ts Normal file
View file

@ -0,0 +1,207 @@
/**
* Service email Resend si `RESEND_API_KEY` est configuré, sinon log console.
*
* Le code consommateur ne doit jamais bloquer ni jeter d'erreur sur un échec
* d'envoi (best-effort, le booking est l'action principale).
*/
import "server-only";
let resendClient: import("resend").Resend | null | undefined;
async function getResend(): Promise<import("resend").Resend | null> {
if (resendClient !== undefined) return resendClient;
const key = process.env.RESEND_API_KEY?.trim();
if (!key) {
resendClient = null;
return null;
}
try {
const { Resend } = await import("resend");
resendClient = new Resend(key);
return resendClient;
} catch (e) {
console.error("[email] resend init failed:", e instanceof Error ? e.message : e);
resendClient = null;
return null;
}
}
export type EmailOpts = {
to: string | string[];
subject: string;
html: string;
text?: string;
replyTo?: string;
};
const DEFAULT_FROM = process.env.RESEND_FROM ?? "Karbé <no-reply@karbe.cosmolan.fr>";
export async function sendEmail(opts: EmailOpts): Promise<{ ok: boolean; id?: string; reason?: string }> {
const client = await getResend();
if (!client) {
console.log(
"[email] dry-run (no RESEND_API_KEY):",
JSON.stringify({ to: opts.to, subject: opts.subject }),
);
return { ok: true, reason: "dry-run" };
}
try {
const { data, error } = await client.emails.send({
from: DEFAULT_FROM,
to: Array.isArray(opts.to) ? opts.to : [opts.to],
subject: opts.subject,
html: opts.html,
text: opts.text,
replyTo: opts.replyTo,
});
if (error) {
console.error("[email] resend error:", error);
return { ok: false, reason: error.message };
}
return { ok: true, id: data?.id };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[email] send failed:", msg);
return { ok: false, reason: msg };
}
}
// ---------- Templates ----------
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
const baseStyle = `
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #18181b;
max-width: 580px;
margin: 0 auto;
padding: 24px;
line-height: 1.5;
`;
function wrap(title: string, content: string): string {
return `<!doctype html><html><body style="background:#fafafa;margin:0;padding:24px 12px;">
<div style="${baseStyle}background:white;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
<h1 style="margin:0 0 16px;font-size:22px;font-weight:600;color:#18181b;">${title}</h1>
${content}
<hr style="margin:24px 0;border:0;border-top:1px solid #e4e4e7;" />
<p style="font-size:11px;color:#71717a;margin:0;">
Karbé · <a href="${SITE_URL}" style="color:#71717a;">${SITE_URL}</a><br/>
Cet email a é envoyé suite à une action sur votre compte. Si ce n'est pas vous, ignorez-le.
</p>
</div>
</body></html>`;
}
export async function sendSignupWelcome(to: string, firstName: string): Promise<void> {
await sendEmail({
to,
subject: "Bienvenue sur Karbé",
html: wrap(
`Bienvenue ${firstName} !`,
`<p>Votre compte Karbé est créé. Vous pouvez désormais réserver un séjour ou, si vous êtes hôte, publier votre carbet.</p>
<p><a href="${SITE_URL}/carbets" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Découvrir les carbets</a></p>`,
),
text: `Bienvenue ${firstName} ! Votre compte Karbé est créé. ${SITE_URL}/carbets`,
});
}
export async function sendBookingRequestToTenant(
to: string,
firstName: string,
bookingId: string,
carbetTitle: string,
startDate: Date,
endDate: Date,
amount: string,
currency: string,
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Demande de réservation enregistrée — ${carbetTitle}`,
html: wrap(
"Demande de réservation envoyée",
`<p>Bonjour ${firstName},</p>
<p>Votre demande de réservation pour <strong>${carbetTitle}</strong> a bien é enregistrée :</p>
<ul>
<li>Arrivée : ${fmt(startDate)}</li>
<li>Départ : ${fmt(endDate)}</li>
<li>Montant : ${Number(amount).toFixed(2)} ${currency}</li>
</ul>
<p>Vous recevrez un nouvel email dès que l'hôte ou l'équipe Karbé confirmera votre séjour.</p>
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>`,
),
});
}
export async function sendBookingRequestToOwner(
to: string,
ownerFirstName: string,
bookingId: string,
carbetTitle: string,
tenantName: string,
startDate: Date,
endDate: Date,
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Nouvelle demande de réservation — ${carbetTitle}`,
html: wrap(
"Nouvelle demande à confirmer",
`<p>Bonjour ${ownerFirstName},</p>
<p><strong>${tenantName}</strong> souhaite réserver <strong>${carbetTitle}</strong> :</p>
<ul>
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
</ul>
<p>Connectez-vous à votre espace hôte pour confirmer ou refuser.</p>
<p><a href="${SITE_URL}/espace-hote" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mon espace hôte</a></p>`,
),
});
}
export async function sendBookingConfirmed(
to: string,
firstName: string,
bookingId: string,
carbetTitle: string,
startDate: Date,
endDate: Date,
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Réservation confirmée — ${carbetTitle}`,
html: wrap(
"Votre séjour est confirmé",
`<p>Bonjour ${firstName},</p>
<p>Votre réservation pour <strong>${carbetTitle}</strong> du ${fmt(startDate)} au ${fmt(endDate)} est confirmée.</p>
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>`,
),
});
}
export async function sendBookingRefunded(
to: string,
firstName: string,
bookingId: string,
carbetTitle: string,
amount: string,
currency: string,
): Promise<void> {
await sendEmail({
to,
subject: `Remboursement traité — ${carbetTitle}`,
html: wrap(
"Remboursement en cours",
`<p>Bonjour ${firstName},</p>
<p>Votre réservation pour <strong>${carbetTitle}</strong> a é annulée et le remboursement de <strong>${Number(amount).toFixed(2)} ${currency}</strong> est en cours de traitement par Stripe (3 à 5 jours ouvrés).</p>
<p><a href="${SITE_URL}/reservations/${bookingId}" style="color:#18181b;">Détails de la réservation</a></p>`,
),
});
}