feat(p1): calendrier dispo + emails Resend + amount calculé + best-effort welcome/confirmation/refund
This commit is contained in:
parent
4e14854245
commit
b59b8a0af2
7 changed files with 585 additions and 6 deletions
207
src/lib/email.ts
Normal file
207
src/lib/email.ts
Normal 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 été 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 été 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 été 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>`,
|
||||
),
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue