update website home and dashboard
This commit is contained in:
@@ -67,6 +67,7 @@ const routeDocs = [
|
||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
||||
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
||||
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/cities`, description: 'Marketplace cities' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
|
||||
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
|
||||
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
export type Lang = 'en' | 'fr' | 'ar'
|
||||
|
||||
function t<T>(map: Record<Lang, T>, lang: Lang): T {
|
||||
return map[lang] ?? map['fr']
|
||||
}
|
||||
|
||||
const dateLocale: Record<Lang, string> = { en: 'en-GB', fr: 'fr-FR', ar: 'ar-MA' }
|
||||
|
||||
export function formatDate(date: Date, lang: Lang, opts?: Intl.DateTimeFormatOptions): string {
|
||||
return date.toLocaleDateString(dateLocale[lang], opts ?? { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
// ─── Signup confirmation ──────────────────────────────────────────────────────
|
||||
|
||||
export const signupEmail = {
|
||||
subject: (lang: Lang) => t({
|
||||
en: 'Your workspace is ready — RentalDriveGo',
|
||||
fr: 'Votre espace de travail est prêt — RentalDriveGo',
|
||||
ar: 'مساحة عملك جاهزة — RentalDriveGo',
|
||||
}, lang),
|
||||
|
||||
text: (opts: {
|
||||
firstName: string
|
||||
companyName: string
|
||||
plan: string
|
||||
billingPeriod: string
|
||||
currency: string
|
||||
paymentProvider: string
|
||||
trialEnd: Date
|
||||
}, lang: Lang): string => {
|
||||
const trialStr = formatDate(opts.trialEnd, lang)
|
||||
return t({
|
||||
en: [
|
||||
`Hi ${opts.firstName},`,
|
||||
'',
|
||||
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
|
||||
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
|
||||
`Currency: ${opts.currency}`,
|
||||
`Primary payment provider: ${opts.paymentProvider}`,
|
||||
`Free trial ends on ${trialStr}.`,
|
||||
'',
|
||||
'Your workspace is ready. Sign in with the email and password you chose during signup.',
|
||||
'',
|
||||
'RentalDriveGo',
|
||||
].join('\n'),
|
||||
fr: [
|
||||
`Bonjour ${opts.firstName},`,
|
||||
'',
|
||||
`Votre espace de travail RentalDriveGo pour ${opts.companyName} a été créé avec succès.`,
|
||||
`Forfait : ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'mensuel' : 'annuel'})`,
|
||||
`Devise : ${opts.currency}`,
|
||||
`Fournisseur de paiement principal : ${opts.paymentProvider}`,
|
||||
`La période d'essai gratuit se termine le ${trialStr}.`,
|
||||
'',
|
||||
"Votre espace de travail est prêt. Connectez-vous avec l'e-mail et le mot de passe choisis lors de l'inscription.",
|
||||
'',
|
||||
'RentalDriveGo',
|
||||
].join('\n'),
|
||||
ar: [
|
||||
`مرحباً ${opts.firstName}،`,
|
||||
'',
|
||||
`تم إنشاء مساحة عمل RentalDriveGo الخاصة بـ ${opts.companyName} بنجاح.`,
|
||||
`الخطة: ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'شهري' : 'سنوي'})`,
|
||||
`العملة: ${opts.currency}`,
|
||||
`مزود الدفع الرئيسي: ${opts.paymentProvider}`,
|
||||
`تنتهي الفترة التجريبية المجانية في ${trialStr}.`,
|
||||
'',
|
||||
'مساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.',
|
||||
'',
|
||||
'RentalDriveGo',
|
||||
].join('\n'),
|
||||
}, lang)
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Password reset ───────────────────────────────────────────────────────────
|
||||
|
||||
export const resetPasswordEmail = {
|
||||
subject: (lang: Lang) => t({
|
||||
en: 'Reset your RentalDriveGo password',
|
||||
fr: 'Réinitialisez votre mot de passe RentalDriveGo',
|
||||
ar: 'إعادة تعيين كلمة مرور RentalDriveGo',
|
||||
}, lang),
|
||||
|
||||
html: (resetUrl: string, firstName: string, expireMinutes: number, lang: Lang): string => {
|
||||
const isRtl = lang === 'ar'
|
||||
const dir = isRtl ? 'rtl' : 'ltr'
|
||||
return t({
|
||||
en: `<div dir="ltr" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>You requested a password reset for your RentalDriveGo workspace account.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset your password</a></p>
|
||||
<p>This link expires in ${expireMinutes} minutes. If you did not request this, you can safely ignore this email.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
</div>`,
|
||||
fr: `<div dir="ltr" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<p>Bonjour ${firstName},</p>
|
||||
<p>Vous avez demandé la réinitialisation du mot de passe de votre compte RentalDriveGo.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Réinitialiser mon mot de passe</a></p>
|
||||
<p>Ce lien expire dans ${expireMinutes} minutes. Si vous n'avez pas fait cette demande, ignorez simplement cet e-mail.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
</div>`,
|
||||
ar: `<div dir="rtl" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<p>مرحباً ${firstName}،</p>
|
||||
<p>طلبت إعادة تعيين كلمة مرور حساب RentalDriveGo الخاص بك.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">إعادة تعيين كلمة المرور</a></p>
|
||||
<p>ينتهي صلاحية هذا الرابط خلال ${expireMinutes} دقيقة. إذا لم تطلب ذلك، يمكنك تجاهل هذا البريد الإلكتروني بأمان.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
</div>`,
|
||||
}, lang)
|
||||
},
|
||||
|
||||
text: (resetUrl: string, firstName: string, expireMinutes: number, lang: Lang): string => t({
|
||||
en: `Hi ${firstName},\n\nReset your password here: ${resetUrl}\n\nThis link expires in ${expireMinutes} minutes.\n\nRentalDriveGo`,
|
||||
fr: `Bonjour ${firstName},\n\nRéinitialisez votre mot de passe ici : ${resetUrl}\n\nCe lien expire dans ${expireMinutes} minutes.\n\nRentalDriveGo`,
|
||||
ar: `مرحباً ${firstName}،\n\nأعد تعيين كلمة مرورك هنا: ${resetUrl}\n\nينتهي هذا الرابط خلال ${expireMinutes} دقيقة.\n\nRentalDriveGo`,
|
||||
}, lang),
|
||||
}
|
||||
|
||||
// ─── Marketplace reservation request ─────────────────────────────────────────
|
||||
|
||||
export const marketplaceReservationEmail = {
|
||||
subject: (vehicleName: string, lang: Lang) => t({
|
||||
en: `Reservation Request Received — ${vehicleName}`,
|
||||
fr: `Demande de réservation reçue — ${vehicleName}`,
|
||||
ar: `تم استلام طلب الحجز — ${vehicleName}`,
|
||||
}, lang),
|
||||
|
||||
html: (opts: {
|
||||
firstName: string
|
||||
vehicleYear: number
|
||||
vehicleMake: string
|
||||
vehicleModel: string
|
||||
companyName: string
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
totalDays: number
|
||||
rateDisplay: string
|
||||
totalDisplay: string
|
||||
email: string
|
||||
phone?: string
|
||||
}, lang: Lang): string => {
|
||||
const startStr = formatDate(opts.startDate, lang)
|
||||
const endStr = formatDate(opts.endDate, lang)
|
||||
const isRtl = lang === 'ar'
|
||||
const l = t({
|
||||
en: {
|
||||
greeting: `Hi ${opts.firstName},`,
|
||||
intro: 'Your reservation request has been received and is pending company confirmation.',
|
||||
company: 'Company', pickup: 'Pick-up date', returnD: 'Return date',
|
||||
duration: 'Duration', daily: 'Daily rate', total: 'Estimated total', days: 'day(s)',
|
||||
footer: `The company will review your request and get in touch shortly. You may be contacted at ${opts.email}${opts.phone ? ` or ${opts.phone}` : ''}.`,
|
||||
},
|
||||
fr: {
|
||||
greeting: `Bonjour ${opts.firstName},`,
|
||||
intro: 'Votre demande de réservation a été reçue et est en attente de confirmation.',
|
||||
company: 'Société', pickup: 'Date de prise en charge', returnD: 'Date de retour',
|
||||
duration: 'Durée', daily: 'Tarif journalier', total: 'Total estimé', days: 'jour(s)',
|
||||
footer: `La société examinera votre demande et vous contactera prochainement. Vous pouvez être contacté à ${opts.email}${opts.phone ? ` ou ${opts.phone}` : ''}.`,
|
||||
},
|
||||
ar: {
|
||||
greeting: `مرحباً ${opts.firstName}،`,
|
||||
intro: 'تم استلام طلب الحجز وهو في انتظار تأكيد الشركة.',
|
||||
company: 'الشركة', pickup: 'تاريخ الاستلام', returnD: 'تاريخ الإرجاع',
|
||||
duration: 'المدة', daily: 'السعر اليومي', total: 'الإجمالي التقديري', days: 'يوم',
|
||||
footer: `ستراجع الشركة طلبك وستتواصل معك قريباً. يمكن التواصل معك على ${opts.email}${opts.phone ? ` أو ${opts.phone}` : ''}.`,
|
||||
},
|
||||
}, lang)
|
||||
|
||||
return `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917;direction:${isRtl ? 'rtl' : 'ltr'}">
|
||||
<h2 style="margin-bottom:4px">${l.greeting}</h2>
|
||||
<p style="color:#57534e">${l.intro}</p>
|
||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
|
||||
<h3 style="margin-bottom:12px">${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel}</h3>
|
||||
<p><strong>${l.company}:</strong> ${opts.companyName}</p>
|
||||
<p><strong>${l.pickup}:</strong> ${startStr}</p>
|
||||
<p><strong>${l.returnD}:</strong> ${endStr}</p>
|
||||
<p><strong>${l.duration}:</strong> ${opts.totalDays} ${l.days}</p>
|
||||
<p><strong>${l.daily}:</strong> ${opts.rateDisplay} MAD</p>
|
||||
<p><strong>${l.total}:</strong> ${opts.totalDisplay} MAD</p>
|
||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
|
||||
<p style="color:#57534e;font-size:13px">${l.footer}</p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
|
||||
text: (opts: {
|
||||
firstName: string
|
||||
vehicleYear: number
|
||||
vehicleMake: string
|
||||
vehicleModel: string
|
||||
companyName: string
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
totalDays: number
|
||||
rateDisplay: string
|
||||
totalDisplay: string
|
||||
}, lang: Lang): string => {
|
||||
const startStr = formatDate(opts.startDate, lang)
|
||||
const endStr = formatDate(opts.endDate, lang)
|
||||
return t({
|
||||
en: `Hi ${opts.firstName},\n\nYour reservation request for the ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} from ${opts.companyName} has been received.\n\nDates: ${startStr} → ${endStr}\nDuration: ${opts.totalDays} day(s)\nDaily rate: ${opts.rateDisplay} MAD\nEstimated total: ${opts.totalDisplay} MAD\n\nThe company will confirm your request shortly.\n\nThank you!`,
|
||||
fr: `Bonjour ${opts.firstName},\n\nVotre demande de réservation pour la ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} auprès de ${opts.companyName} a été reçue.\n\nDates : ${startStr} → ${endStr}\nDurée : ${opts.totalDays} jour(s)\nTarif journalier : ${opts.rateDisplay} MAD\nTotal estimé : ${opts.totalDisplay} MAD\n\nLa société confirmera votre demande prochainement.\n\nMerci !`,
|
||||
ar: `مرحباً ${opts.firstName}،\n\nتم استلام طلب حجزك لسيارة ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} من ${opts.companyName}.\n\nالتواريخ: ${startStr} → ${endStr}\nالمدة: ${opts.totalDays} يوم\nالسعر اليومي: ${opts.rateDisplay} MAD\nالإجمالي التقديري: ${opts.totalDisplay} MAD\n\nستؤكد الشركة طلبك قريباً.\n\nشكراً!`,
|
||||
}, lang)
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Booking confirmed ────────────────────────────────────────────────────────
|
||||
|
||||
export const bookingConfirmedNotif = {
|
||||
title: (lang: Lang) => t({
|
||||
en: 'Booking Confirmed',
|
||||
fr: 'Réservation confirmée',
|
||||
ar: 'تم تأكيد الحجز',
|
||||
}, lang),
|
||||
body: (lang: Lang) => t({
|
||||
en: 'Your booking has been confirmed.',
|
||||
fr: 'Votre réservation a été confirmée.',
|
||||
ar: 'تم تأكيد حجزك.',
|
||||
}, lang),
|
||||
}
|
||||
|
||||
// ─── Post-rental review request ───────────────────────────────────────────────
|
||||
|
||||
export const reviewRequestEmail = {
|
||||
subject: (vehicleLabel: string, lang: Lang) => t({
|
||||
en: `How was your rental? — ${vehicleLabel}`,
|
||||
fr: `Comment s'est passée votre location ? — ${vehicleLabel}`,
|
||||
ar: `كيف كانت تجربة الإيجار؟ — ${vehicleLabel}`,
|
||||
}, lang),
|
||||
|
||||
html: (opts: {
|
||||
firstName: string
|
||||
companyName: string
|
||||
vehicleLabel: string
|
||||
reviewUrl: string
|
||||
}, lang: Lang): string => {
|
||||
const isRtl = lang === 'ar'
|
||||
const l = t({
|
||||
en: {
|
||||
greeting: `Hi ${opts.firstName},`,
|
||||
body1: `Thank you for renting with <strong>${opts.companyName}</strong>. We hope you enjoyed your <strong>${opts.vehicleLabel}</strong>.`,
|
||||
body2: 'Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.',
|
||||
cta: 'Leave a review',
|
||||
disclaimer: 'This link is unique to your reservation and can only be used once. If you did not rent a vehicle recently, you can ignore this email.',
|
||||
},
|
||||
fr: {
|
||||
greeting: `Bonjour ${opts.firstName},`,
|
||||
body1: `Merci d'avoir loué chez <strong>${opts.companyName}</strong>. Nous espérons que vous avez apprécié votre <strong>${opts.vehicleLabel}</strong>.`,
|
||||
body2: "Votre avis aide la société à s'améliorer et aide les autres clients à faire des choix éclairés. Cela ne prend que 30 secondes.",
|
||||
cta: 'Laisser un avis',
|
||||
disclaimer: "Ce lien est unique à votre réservation et ne peut être utilisé qu'une seule fois. Si vous n'avez pas loué de véhicule récemment, vous pouvez ignorer cet e-mail.",
|
||||
},
|
||||
ar: {
|
||||
greeting: `مرحباً ${opts.firstName}،`,
|
||||
body1: `شكراً لاستئجارك مع <strong>${opts.companyName}</strong>. نأمل أنك استمتعت بـ <strong>${opts.vehicleLabel}</strong>.`,
|
||||
body2: 'تساعد ملاحظاتك الشركة على التحسين وتساعد العملاء الآخرين على اتخاذ قرارات مستنيرة. لا يستغرق الأمر سوى 30 ثانية.',
|
||||
cta: 'اترك تقييماً',
|
||||
disclaimer: 'هذا الرابط فريد لحجزك ولا يمكن استخدامه إلا مرة واحدة. إذا لم تستأجر مركبة مؤخراً، يمكنك تجاهل هذا البريد الإلكتروني.',
|
||||
},
|
||||
}, lang)
|
||||
|
||||
return `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917;direction:${isRtl ? 'rtl' : 'ltr'}">
|
||||
<h2 style="margin-bottom:4px">${l.greeting}</h2>
|
||||
<p style="color:#57534e">${l.body1}</p>
|
||||
<p style="color:#57534e">${l.body2}</p>
|
||||
<div style="margin:32px 0;text-align:center">
|
||||
<a href="${opts.reviewUrl}" style="background:#1c1917;color:#ffffff;padding:14px 32px;border-radius:999px;text-decoration:none;font-weight:600;font-size:15px;display:inline-block">
|
||||
${l.cta}
|
||||
</a>
|
||||
</div>
|
||||
<p style="color:#a8a29e;font-size:12px">${l.disclaimer}</p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
|
||||
text: (opts: {
|
||||
firstName: string
|
||||
companyName: string
|
||||
vehicleLabel: string
|
||||
reviewUrl: string
|
||||
}, lang: Lang): string => t({
|
||||
en: `Hi ${opts.firstName},\n\nThank you for renting with ${opts.companyName}. We hope you enjoyed your ${opts.vehicleLabel}.\n\nLeave a review here (one-time link):\n${opts.reviewUrl}\n\nThank you!`,
|
||||
fr: `Bonjour ${opts.firstName},\n\nMerci d'avoir loué chez ${opts.companyName}. Nous espérons que vous avez apprécié votre ${opts.vehicleLabel}.\n\nLaissez un avis ici (lien unique) :\n${opts.reviewUrl}\n\nMerci !`,
|
||||
ar: `مرحباً ${opts.firstName}،\n\nشكراً لاستئجارك مع ${opts.companyName}. نأمل أنك استمتعت بـ ${opts.vehicleLabel}.\n\nاترك تقييماً هنا (رابط فريد):\n${opts.reviewUrl}\n\nشكراً!`,
|
||||
}, lang),
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { requireRole } from '../middleware/requireRole'
|
||||
import { generateFinancialReport, toCsv } from '../services/financialReportService'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription, requireRole('MANAGER'))
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
function getRangeFromPeriod(period: string) {
|
||||
const now = new Date()
|
||||
@@ -158,7 +158,7 @@ router.get('/sources', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/report', async (req, res, next) => {
|
||||
router.get('/report', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { from, to, format = 'JSON', period } = req.query as Record<string, string>
|
||||
const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendNotification } from '../services/notificationService'
|
||||
import { signupEmail, type Lang } from '../lib/emailTranslations'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -66,26 +67,17 @@ function buildSignupConfirmationEmail(opts: {
|
||||
currency: 'MAD' | 'USD' | 'EUR'
|
||||
paymentProvider: 'AMANPAY' | 'PAYPAL'
|
||||
trialEndAt: Date
|
||||
lang: Lang
|
||||
}) {
|
||||
const greetingName = opts.firstName.trim() || 'there'
|
||||
|
||||
return [
|
||||
`Hi ${greetingName},`,
|
||||
'',
|
||||
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
|
||||
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
|
||||
`Currency: ${opts.currency}`,
|
||||
`Primary payment provider: ${opts.paymentProvider}`,
|
||||
`Free trial ends on ${opts.trialEndAt.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}.`,
|
||||
'',
|
||||
'Your workspace is ready and you can sign in with the email address and password you chose during signup.',
|
||||
'',
|
||||
'RentalDriveGo',
|
||||
].join('\n')
|
||||
return signupEmail.text({
|
||||
firstName: opts.firstName.trim() || 'there',
|
||||
companyName: opts.companyName,
|
||||
plan: opts.plan,
|
||||
billingPeriod: opts.billingPeriod,
|
||||
currency: opts.currency,
|
||||
paymentProvider: opts.paymentProvider,
|
||||
trialEnd: opts.trialEndAt,
|
||||
}, opts.lang)
|
||||
}
|
||||
|
||||
async function sendSignupEmailNotification(opts: Parameters<typeof sendNotification>[0]): Promise<EmailDeliveryResult> {
|
||||
@@ -210,7 +202,7 @@ router.post('/signup', async (req, res, next) => {
|
||||
|
||||
const emailDelivery = await sendSignupEmailNotification({
|
||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
||||
title: 'Your workspace is ready',
|
||||
title: signupEmail.subject(body.preferredLanguage),
|
||||
body: buildSignupConfirmationEmail({
|
||||
firstName: body.firstName,
|
||||
companyName: body.companyName,
|
||||
@@ -219,11 +211,13 @@ router.post('/signup', async (req, res, next) => {
|
||||
currency: body.currency,
|
||||
paymentProvider: body.paymentProvider,
|
||||
trialEndAt,
|
||||
lang: body.preferredLanguage,
|
||||
}),
|
||||
companyId: result.company.id,
|
||||
employeeId: result.employeeId,
|
||||
email: body.email,
|
||||
channels: ['EMAIL', 'IN_APP'],
|
||||
locale: body.preferredLanguage,
|
||||
})
|
||||
|
||||
res.status(201).json({
|
||||
|
||||
@@ -5,6 +5,7 @@ import jwt from 'jsonwebtoken'
|
||||
import crypto from 'crypto'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendTransactionalEmail } from '../services/notificationService'
|
||||
import { resetPasswordEmail, type Lang } from '../lib/emailTranslations'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -34,15 +35,6 @@ function signEmployeeToken(employeeId: string) {
|
||||
)
|
||||
}
|
||||
|
||||
function buildResetEmailHtml(resetUrl: string, firstName: string) {
|
||||
return `
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>You requested a password reset for your RentalDriveGo workspace account.</p>
|
||||
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset your password</a></p>
|
||||
<p>This link expires in ${RESET_TOKEN_TTL_MINUTES} minutes. If you did not request this, you can safely ignore this email.</p>
|
||||
<p>RentalDriveGo</p>
|
||||
`
|
||||
}
|
||||
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
@@ -174,11 +166,12 @@ router.post('/forgot-password', async (req, res, next) => {
|
||||
)
|
||||
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
|
||||
|
||||
const lang = (employee.preferredLanguage as Lang) ?? 'fr'
|
||||
await sendTransactionalEmail({
|
||||
to: email,
|
||||
subject: 'Reset your RentalDriveGo password',
|
||||
html: buildResetEmailHtml(resetUrl, employee.firstName),
|
||||
text: `Hi ${employee.firstName},\n\nReset your password here: ${resetUrl}\n\nThis link expires in ${RESET_TOKEN_TTL_MINUTES} minutes.\n\nRentalDriveGo`,
|
||||
subject: resetPasswordEmail.subject(lang),
|
||||
html: resetPasswordEmail.html(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
|
||||
text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
|
||||
}).catch((err) => console.error('[ForgotPassword] Email send failed:', err?.message))
|
||||
}
|
||||
|
||||
@@ -186,6 +179,29 @@ router.post('/forgot-password', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/language', async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
if (!token) return res.status(401).json({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
|
||||
|
||||
let employeeId = ''
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type !== 'employee') return res.status(401).json({ error: 'invalid_token', message: 'Invalid token', statusCode: 401 })
|
||||
employeeId = payload.sub
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
|
||||
}
|
||||
|
||||
const { language } = z.object({ language: z.enum(['en', 'fr', 'ar']) }).parse(req.body)
|
||||
|
||||
await prisma.employee.update({ where: { id: employeeId }, data: { preferredLanguage: language } })
|
||||
|
||||
res.json({ data: { language } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reset-password', async (req, res, next) => {
|
||||
try {
|
||||
const { token, password } = z.object({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { prisma } from '../lib/prisma'
|
||||
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
|
||||
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
|
||||
import { getVehicleAvailabilitySummary } from '../services/vehicleAvailabilityService'
|
||||
import { marketplaceReservationEmail, type Lang } from '../lib/emailTranslations'
|
||||
|
||||
const router = Router()
|
||||
router.use(optionalRenterAuth)
|
||||
@@ -35,6 +36,47 @@ router.get('/offers', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/cities', async (_req, res, next) => {
|
||||
try {
|
||||
const companies = await prisma.company.findMany({
|
||||
where: {
|
||||
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||
brand: {
|
||||
isListedOnMarketplace: true,
|
||||
publicCity: { not: null },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
brand: {
|
||||
select: {
|
||||
publicCity: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const uniqueCities = Array.from(
|
||||
companies.reduce((cities, company) => {
|
||||
const city = company.brand?.publicCity?.trim()
|
||||
if (!city) return cities
|
||||
|
||||
const normalizedCity = city.toLocaleLowerCase()
|
||||
if (!cities.has(normalizedCity)) {
|
||||
cities.set(normalizedCity, city)
|
||||
}
|
||||
return cities
|
||||
}, new Map<string, string>()),
|
||||
)
|
||||
.map(([, city]) => city)
|
||||
.sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' }))
|
||||
|
||||
res.json({ data: uniqueCities })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/companies', async (req, res, next) => {
|
||||
try {
|
||||
const { city, hasOffer } = req.query as Record<string, string>
|
||||
@@ -136,6 +178,7 @@ router.post('/reservations', async (req, res, next) => {
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
notes: z.string().max(500).optional(),
|
||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||
})
|
||||
|
||||
const body = schema.parse(req.body)
|
||||
@@ -189,34 +232,20 @@ router.post('/reservations', async (req, res, next) => {
|
||||
},
|
||||
})
|
||||
|
||||
const lang = body.language as Lang
|
||||
const companyName = vehicle.company.brand?.displayName ?? vehicle.company.name
|
||||
const rateDisplay = (vehicle.dailyRate / 100).toFixed(2)
|
||||
const totalDisplay = (totalAmount / 100).toFixed(2)
|
||||
const dateOpts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
|
||||
const startStr = startDate.toLocaleDateString('en-GB', dateOpts)
|
||||
const endStr = endDate.toLocaleDateString('en-GB', dateOpts)
|
||||
const startStr = startDate.toISOString().slice(0, 10)
|
||||
const endStr = endDate.toISOString().slice(0, 10)
|
||||
const emailOpts = { firstName: body.firstName, vehicleYear: vehicle.year, vehicleMake: vehicle.make, vehicleModel: vehicle.model, companyName, startDate, endDate, totalDays, rateDisplay, totalDisplay, email: body.email, phone: body.phone }
|
||||
|
||||
try {
|
||||
await sendTransactionalEmail({
|
||||
to: body.email,
|
||||
subject: `Reservation Request Received — ${vehicle.make} ${vehicle.model}`,
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<h2 style="margin-bottom:4px">Hi ${body.firstName},</h2>
|
||||
<p style="color:#57534e">Your reservation request has been received and is pending company confirmation.</p>
|
||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
|
||||
<h3 style="margin-bottom:12px">${vehicle.year} ${vehicle.make} ${vehicle.model}</h3>
|
||||
<p><strong>Company:</strong> ${companyName}</p>
|
||||
<p><strong>Pick-up date:</strong> ${startStr}</p>
|
||||
<p><strong>Return date:</strong> ${endStr}</p>
|
||||
<p><strong>Duration:</strong> ${totalDays} day${totalDays > 1 ? 's' : ''}</p>
|
||||
<p><strong>Daily rate:</strong> ${rateDisplay} MAD</p>
|
||||
<p><strong>Estimated total:</strong> ${totalDisplay} MAD</p>
|
||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
|
||||
<p style="color:#57534e;font-size:13px">The company will review your request and get in touch with you shortly. You may be contacted at ${body.email}${body.phone ? ` or ${body.phone}` : ''}.</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hi ${body.firstName},\n\nYour reservation request for the ${vehicle.year} ${vehicle.make} ${vehicle.model} from ${companyName} has been received.\n\nDates: ${startStr} → ${endStr}\nDuration: ${totalDays} day(s)\nDaily rate: ${rateDisplay} MAD\nEstimated total: ${totalDisplay} MAD\n\nThe company will confirm your request shortly.\n\nThank you!`,
|
||||
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
||||
html: marketplaceReservationEmail.html(emailOpts, lang),
|
||||
text: marketplaceReservationEmail.text(emailOpts, lang),
|
||||
})
|
||||
} catch {
|
||||
// email failure is non-blocking — reservation still created
|
||||
|
||||
@@ -9,6 +9,7 @@ import { applyInsurancesToReservation } from '../services/insuranceService'
|
||||
import { applyPricingRules } from '../services/pricingRuleService'
|
||||
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
|
||||
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
|
||||
import { bookingConfirmedNotif, reviewRequestEmail, type Lang } from '../lib/emailTranslations'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const router = Router()
|
||||
@@ -750,8 +751,12 @@ router.post('/:id/confirm', async (req, res, next) => {
|
||||
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CONFIRMED' } })
|
||||
|
||||
// Notify
|
||||
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
|
||||
await sendNotification({ type: 'BOOKING_CONFIRMED', title: 'Booking Confirmed', body: `Your booking has been confirmed.`, companyId: req.companyId, email: customer.email, channels: ['EMAIL', 'IN_APP'] }).catch(() => null)
|
||||
const [customer, companyBrand] = await Promise.all([
|
||||
prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } }),
|
||||
prisma.brandSettings.findUnique({ where: { companyId: req.companyId }, select: { defaultLocale: true } }),
|
||||
])
|
||||
const lang = ((companyBrand?.defaultLocale ?? 'fr') as Lang)
|
||||
await sendNotification({ type: 'BOOKING_CONFIRMED', title: bookingConfirmedNotif.title(lang), body: bookingConfirmedNotif.body(lang), companyId: req.companyId, email: customer.email, channels: ['EMAIL', 'IN_APP'], locale: lang }).catch(() => null)
|
||||
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
@@ -787,29 +792,19 @@ router.post('/:id/checkout', async (req, res, next) => {
|
||||
|
||||
// Send "How was your rental?" email with secure one-time review link
|
||||
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
|
||||
const company = await prisma.company.findUnique({ where: { id: req.companyId }, include: { brand: { select: { displayName: true } } } })
|
||||
const company = await prisma.company.findUnique({ where: { id: req.companyId }, include: { brand: { select: { displayName: true, defaultLocale: true } } } })
|
||||
const lang = ((company?.brand?.defaultLocale ?? 'fr') as Lang)
|
||||
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
|
||||
const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
const marketplaceUrl = process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
|
||||
const reviewUrl = `${marketplaceUrl}/review?token=${reviewToken}`
|
||||
const reviewOpts = { firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }
|
||||
|
||||
sendTransactionalEmail({
|
||||
to: customer.email,
|
||||
subject: `How was your rental? — ${vehicleLabel}`,
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<h2 style="margin-bottom:4px">Hi ${customer.firstName},</h2>
|
||||
<p style="color:#57534e">Thank you for renting with <strong>${companyName}</strong>. We hope you enjoyed your <strong>${vehicleLabel}</strong>.</p>
|
||||
<p style="color:#57534e">Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.</p>
|
||||
<div style="margin:32px 0;text-align:center">
|
||||
<a href="${reviewUrl}" style="background:#1c1917;color:#ffffff;padding:14px 32px;border-radius:999px;text-decoration:none;font-weight:600;font-size:15px;display:inline-block">
|
||||
Leave a review
|
||||
</a>
|
||||
</div>
|
||||
<p style="color:#a8a29e;font-size:12px">This link is unique to your reservation and can only be used once. If you did not rent a vehicle recently, you can ignore this email.</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hi ${customer.firstName},\n\nThank you for renting with ${companyName}. We hope you enjoyed your ${vehicleLabel}.\n\nLeave a review here (one-time link):\n${reviewUrl}\n\nThank you!`,
|
||||
subject: reviewRequestEmail.subject(vehicleLabel, lang),
|
||||
html: reviewRequestEmail.html(reviewOpts, lang),
|
||||
text: reviewRequestEmail.text(reviewOpts, lang),
|
||||
}).catch(() => null)
|
||||
|
||||
res.json({ data: updated })
|
||||
|
||||
Reference in New Issue
Block a user