notification implemented
This commit is contained in:
@@ -1,15 +1,32 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||||
const apiUrl = new URL(apiOrigin)
|
const apiUrl = new URL(apiOrigin)
|
||||||
|
const ADMIN_BASE_PATH = '/admin'
|
||||||
|
|
||||||
|
function ensureAdminAssetPrefix(rawValue) {
|
||||||
|
if (!rawValue) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(rawValue)
|
||||||
|
const pathname = url.pathname.replace(/\/$/, '')
|
||||||
|
if (!pathname.endsWith(ADMIN_BASE_PATH)) {
|
||||||
|
url.pathname = `${pathname}${ADMIN_BASE_PATH}`
|
||||||
|
}
|
||||||
|
return url.toString().replace(/\/$/, '')
|
||||||
|
} catch {
|
||||||
|
const trimmed = rawValue.replace(/\/$/, '')
|
||||||
|
return trimmed.endsWith(ADMIN_BASE_PATH) ? trimmed : `${trimmed}${ADMIN_BASE_PATH}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// In Docker dev the admin app runs on port 3002 while the marketplace proxy
|
// In Docker dev the admin app runs on port 3002 while the marketplace proxy
|
||||||
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
|
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
|
||||||
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
|
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
|
||||||
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
|
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
|
||||||
const assetPrefix = process.env.ADMIN_ASSET_PREFIX || undefined
|
const assetPrefix = ensureAdminAssetPrefix(process.env.ADMIN_ASSET_PREFIX)
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
basePath: '/admin',
|
basePath: ADMIN_BASE_PATH,
|
||||||
...(assetPrefix ? { assetPrefix } : {}),
|
...(assetPrefix ? { assetPrefix } : {}),
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
|
function resolveAdminNextPath(next: string | null) {
|
||||||
|
const fallback = '/admin/dashboard'
|
||||||
|
if (!next) return fallback
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(next)) return next
|
||||||
|
if (next.startsWith('/admin/')) return next
|
||||||
|
if (next === '/admin') return '/admin/dashboard'
|
||||||
|
if (next.startsWith('/')) return `/admin${next}`
|
||||||
|
|
||||||
|
return `/admin/${next.replace(/^\/+/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuthRedirectPage() {
|
export default function AuthRedirectPage() {
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = window.location.hash
|
const hash = window.location.hash
|
||||||
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
||||||
const token = params.get('token')
|
const token = params.get('token')
|
||||||
const next = params.get('next') || '/dashboard'
|
const next = resolveAdminNextPath(params.get('next'))
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage.setItem('admin_token', token)
|
localStorage.setItem('admin_token', token)
|
||||||
@@ -18,8 +27,8 @@ export default function AuthRedirectPage() {
|
|||||||
window.history.replaceState(null, '', window.location.pathname)
|
window.history.replaceState(null, '', window.location.pathname)
|
||||||
}
|
}
|
||||||
|
|
||||||
router.replace(next)
|
window.location.replace(next)
|
||||||
}, [router])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-950">
|
<div className="flex min-h-screen items-center justify-center bg-zinc-950">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const navLinks = [
|
|||||||
{ href: '/dashboard/site-config', key: 'siteConfig', icon: 'M4.5 12a7.5 7.5 0 1015 0 7.5 7.5 0 00-15 0zm7.5-4.5v4.5l3 3' },
|
{ href: '/dashboard/site-config', key: 'siteConfig', icon: 'M4.5 12a7.5 7.5 0 1015 0 7.5 7.5 0 00-15 0zm7.5-4.5v4.5l3 3' },
|
||||||
{ href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
{ href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||||
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||||
|
{ href: '/dashboard/notifications', key: 'notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
|
||||||
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
|
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
|
||||||
{ href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
|
{ href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
|
||||||
{ href: '/dashboard/pricing', key: 'pricing', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
{ href: '/dashboard/pricing', key: 'pricing', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ADMIN_API_BASE } from '@/lib/api'
|
||||||
|
|
||||||
|
interface NotificationItem {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
channel: string
|
||||||
|
status: string
|
||||||
|
locale: string
|
||||||
|
sentAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
company: { name: string } | null
|
||||||
|
companyId: string | null
|
||||||
|
renterId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Paginated {
|
||||||
|
data: NotificationItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
||||||
|
const STATUSES = ['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ']
|
||||||
|
|
||||||
|
const CHANNEL_BADGE: Record<string, string> = {
|
||||||
|
EMAIL: 'text-sky-400 bg-sky-950/40',
|
||||||
|
SMS: 'text-emerald-400 bg-emerald-950/40',
|
||||||
|
WHATSAPP: 'text-teal-400 bg-teal-950/40',
|
||||||
|
IN_APP: 'text-violet-400 bg-violet-950/40',
|
||||||
|
PUSH: 'text-orange-400 bg-orange-950/40',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, string> = {
|
||||||
|
PENDING: 'text-yellow-400 bg-yellow-950/40',
|
||||||
|
SENT: 'text-emerald-400 bg-emerald-950/40',
|
||||||
|
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
|
||||||
|
FAILED: 'text-red-400 bg-red-950/40',
|
||||||
|
READ: 'text-zinc-400 bg-zinc-800',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminNotificationsPage() {
|
||||||
|
const [result, setResult] = useState<Paginated | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [filterChannel, setFilterChannel] = useState('')
|
||||||
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
|
const [filterCompany, setFilterCompany] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
function load(p: number) {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const token = localStorage.getItem('admin_token') ?? ''
|
||||||
|
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
|
||||||
|
if (filterChannel) params.set('channel', filterChannel)
|
||||||
|
if (filterStatus) params.set('status', filterStatus)
|
||||||
|
if (filterCompany) params.set('companyId', filterCompany)
|
||||||
|
|
||||||
|
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((json) => setResult(json.data ?? null))
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
load(1)
|
||||||
|
}, [filterChannel, filterStatus, filterCompany])
|
||||||
|
|
||||||
|
function goToPage(p: number) {
|
||||||
|
setPage(p)
|
||||||
|
load(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = result ? Math.ceil(result.total / result.pageSize) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell py-8 space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">Platform</p>
|
||||||
|
<h1 className="mt-1 text-3xl font-black">Notifications</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
Full audit log of every notification sent across all companies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{result && (
|
||||||
|
<span className="rounded-full bg-zinc-800 px-3 py-1 text-sm text-zinc-300">
|
||||||
|
{result.total.toLocaleString()} total
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={filterChannel}
|
||||||
|
onChange={(e) => setFilterChannel(e.target.value)}
|
||||||
|
className="rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
>
|
||||||
|
<option value="">All channels</option>
|
||||||
|
{CHANNELS.map((ch) => <option key={ch} value={ch}>{ch}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
|
className="rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||||
|
|
||||||
|
<div className="panel overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800">
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Date</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Company</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Event</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Channel</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Status</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Sent at</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-800/60">
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||||
|
) : !result || result.data.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
|
||||||
|
) : result.data.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||||
|
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||||
|
{new Date(item.createdAt).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
|
||||||
|
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
|
||||||
|
{item.type.replaceAll('_', ' ')}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${CHANNEL_BADGE[item.channel] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||||
|
{item.channel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 max-w-[220px]">
|
||||||
|
<p className="truncate text-sm text-zinc-200">{item.title}</p>
|
||||||
|
<p className="truncate text-xs text-zinc-500">{item.body}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[item.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||||
|
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t border-zinc-800 px-5 py-3">
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
Page {page} of {totalPages} — {result?.total.toLocaleString()} records
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => goToPage(page - 1)}
|
||||||
|
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => goToPage(page + 1)}
|
||||||
|
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
|||||||
adminUsers: 'Admin Users',
|
adminUsers: 'Admin Users',
|
||||||
billing: 'Billing',
|
billing: 'Billing',
|
||||||
pricing: 'Pricing',
|
pricing: 'Pricing',
|
||||||
|
notifications: 'Notifications',
|
||||||
},
|
},
|
||||||
logout: 'Logout',
|
logout: 'Logout',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
@@ -50,6 +51,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
|||||||
adminUsers: 'Utilisateurs admin',
|
adminUsers: 'Utilisateurs admin',
|
||||||
billing: 'Facturation',
|
billing: 'Facturation',
|
||||||
pricing: 'Tarification',
|
pricing: 'Tarification',
|
||||||
|
notifications: 'Notifications',
|
||||||
},
|
},
|
||||||
logout: 'Déconnexion',
|
logout: 'Déconnexion',
|
||||||
language: 'Langue',
|
language: 'Langue',
|
||||||
@@ -71,6 +73,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
|||||||
adminUsers: 'مستخدمو الإدارة',
|
adminUsers: 'مستخدمو الإدارة',
|
||||||
billing: 'الفوترة',
|
billing: 'الفوترة',
|
||||||
pricing: 'الأسعار',
|
pricing: 'الأسعار',
|
||||||
|
notifications: 'الإشعارات',
|
||||||
},
|
},
|
||||||
logout: 'تسجيل الخروج',
|
logout: 'تسجيل الخروج',
|
||||||
language: 'اللغة',
|
language: 'اللغة',
|
||||||
|
|||||||
+13
-10
@@ -2,11 +2,11 @@ import http from 'http'
|
|||||||
import { Server as SocketIOServer } from 'socket.io'
|
import { Server as SocketIOServer } from 'socket.io'
|
||||||
import cron from 'node-cron'
|
import cron from 'node-cron'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import { z } from 'zod'
|
|
||||||
import { redis } from './lib/redis'
|
import { redis } from './lib/redis'
|
||||||
import { prisma } from './lib/prisma'
|
import { prisma } from './lib/prisma'
|
||||||
import { assertStorageConfiguration } from './lib/storage'
|
import { assertStorageConfiguration } from './lib/storage'
|
||||||
import { createApp, corsOrigins } from './app'
|
import { createApp, corsOrigins } from './app'
|
||||||
|
import { sendNotification } from './services/notificationService'
|
||||||
import {
|
import {
|
||||||
runTrialExpirationJob,
|
runTrialExpirationJob,
|
||||||
runPaymentPendingTimeoutJob,
|
runPaymentPendingTimeoutJob,
|
||||||
@@ -24,11 +24,6 @@ const io = new SocketIOServer(server, {
|
|||||||
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
|
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
|
||||||
})
|
})
|
||||||
|
|
||||||
const redisMessageSchema = z.object({
|
|
||||||
type: z.string(),
|
|
||||||
payload: z.unknown(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Authenticate socket connections via JWT before joining user rooms
|
// Authenticate socket connections via JWT before joining user rooms
|
||||||
io.use((socket, next) => {
|
io.use((socket, next) => {
|
||||||
const token = socket.handshake.auth?.token as string | undefined
|
const token = socket.handshake.auth?.token as string | undefined
|
||||||
@@ -57,9 +52,8 @@ subscriber.psubscribe('notifications:*', (err) => {
|
|||||||
subscriber.on('pmessage', (_pattern, channel, message) => {
|
subscriber.on('pmessage', (_pattern, channel, message) => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(message)
|
const parsed = JSON.parse(message)
|
||||||
const validated = redisMessageSchema.parse(parsed)
|
|
||||||
const userId = channel.replace('notifications:', '')
|
const userId = channel.replace('notifications:', '')
|
||||||
io.to(`user:${userId}`).emit('notification', validated)
|
io.to(`user:${userId}`).emit('notification', parsed)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Redis] Invalid notification message:', err)
|
console.error('[Redis] Invalid notification message:', err)
|
||||||
}
|
}
|
||||||
@@ -117,8 +111,17 @@ cron.schedule('0 9 * * *', async () => {
|
|||||||
for (const sub of subscriptions) {
|
for (const sub of subscriptions) {
|
||||||
const owner = sub.company.employees[0]
|
const owner = sub.company.employees[0]
|
||||||
if (owner) {
|
if (owner) {
|
||||||
await prisma.notification.create({
|
await sendNotification({
|
||||||
data: { type: 'SUBSCRIPTION_TRIAL_ENDING', title: 'Your trial ends soon', body: 'Your 90-day free trial ends in 3 days. Add a payment method to keep access.', companyId: sub.companyId, employeeId: owner.id, channel: 'IN_APP', status: 'DELIVERED' },
|
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
||||||
|
companyId: sub.companyId,
|
||||||
|
employeeId: owner.id,
|
||||||
|
channels: ['IN_APP'],
|
||||||
|
templateKey: 'subscription.trial_ending',
|
||||||
|
templateVariables: {
|
||||||
|
trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -488,3 +488,28 @@ export function updatePromotion(id: string, data: Partial<{
|
|||||||
export function deletePromotion(id: string) {
|
export function deletePromotion(id: string) {
|
||||||
return (prisma as any).pricingPromotion.delete({ where: { id } })
|
return (prisma as any).pricingPromotion.delete({ where: { id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listNotificationsPage(query: {
|
||||||
|
channel?: string
|
||||||
|
status?: string
|
||||||
|
companyId?: string
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}) {
|
||||||
|
const where: any = {}
|
||||||
|
if (query.channel) where.channel = query.channel
|
||||||
|
if (query.status) where.status = query.status
|
||||||
|
if (query.companyId) where.companyId = query.companyId
|
||||||
|
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.notification.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (query.page - 1) * query.pageSize,
|
||||||
|
take: query.pageSize,
|
||||||
|
include: { company: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
prisma.notification.count({ where }),
|
||||||
|
])
|
||||||
|
return { data, total }
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import * as subService from '../subscriptions/subscription.service'
|
|||||||
import { presentAdminUser } from './admin.presenter'
|
import { presentAdminUser } from './admin.presenter'
|
||||||
import {
|
import {
|
||||||
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
|
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
|
||||||
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema,
|
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema,
|
||||||
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
|
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
|
||||||
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
|
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
|
||||||
homepageUpdateSchema, idParamSchema, companyIdParamSchema, billingAccountIdParamSchema, invoiceIdParamSchema,
|
homepageUpdateSchema, idParamSchema, companyIdParamSchema, billingAccountIdParamSchema, invoiceIdParamSchema,
|
||||||
@@ -158,6 +158,14 @@ router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── Notifications ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
ok(res, await service.listNotifications(parseQuery(notificationsQuerySchema, req)))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
// ─── Audit logs ────────────────────────────────────────────────
|
// ─── Audit logs ────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export const auditLogQuerySchema = z.object({
|
|||||||
pageSize: z.coerce.number().int().min(1).max(100).default(50),
|
pageSize: z.coerce.number().int().min(1).max(100).default(50),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const notificationsQuerySchema = z.object({
|
||||||
|
channel: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
companyId: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
|
})
|
||||||
|
|
||||||
export const billingQuerySchema = z.object({
|
export const billingQuerySchema = z.object({
|
||||||
q: z.string().optional(),
|
q: z.string().optional(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
|
|||||||
@@ -193,6 +193,11 @@ export function getPlatformMetrics() {
|
|||||||
return repo.getPlatformMetricCounts()
|
return repo.getPlatformMetricCounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listNotifications(query: { channel?: string; status?: string; companyId?: string; page: number; pageSize: number }) {
|
||||||
|
const { data, total } = await repo.listNotificationsPage(query)
|
||||||
|
return presenter.presentPaginated(data, total, query.page, query.pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAuditLogs(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
|
export async function getAuditLogs(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
|
||||||
const { data, total } = await repo.listAuditLogsPage(query)
|
const { data, total } = await repo.listAuditLogsPage(query)
|
||||||
return presenter.presentPaginated(data, total, query.page, query.pageSize)
|
return presenter.presentPaginated(data, total, query.page, query.pageSize)
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu
|
|||||||
publicAddress: input.streetAddress,
|
publicAddress: input.streetAddress,
|
||||||
publicCity: input.city,
|
publicCity: input.city,
|
||||||
publicCountry: input.country,
|
publicCountry: input.country,
|
||||||
|
defaultLocale: input.preferredLanguage,
|
||||||
defaultCurrency: input.currency,
|
defaultCurrency: input.currency,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import bcrypt from 'bcryptjs'
|
|||||||
import { AppError } from '../../http/errors'
|
import { AppError } from '../../http/errors'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import { sendNotification } from '../../services/notificationService'
|
import { sendNotification } from '../../services/notificationService'
|
||||||
import { signupEmail, type Lang } from '../../lib/emailTranslations'
|
import {
|
||||||
|
coerceNotificationLocale,
|
||||||
|
localizeBillingPeriod,
|
||||||
|
localizePlanName,
|
||||||
|
} from '../../services/notificationLocalizationService'
|
||||||
import { presentCompanySignup } from './auth.presenter'
|
import { presentCompanySignup } from './auth.presenter'
|
||||||
import * as repo from './auth.company.repo'
|
import * as repo from './auth.company.repo'
|
||||||
import { companySignupSchema } from './auth.company.schemas'
|
import { companySignupSchema } from './auth.company.schemas'
|
||||||
@@ -66,24 +70,24 @@ export async function signup(body: CompanySignupInput) {
|
|||||||
trialEndAt,
|
trialEndAt,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const lang = body.preferredLanguage as Lang
|
const lang = coerceNotificationLocale(body.preferredLanguage)
|
||||||
const emailResult = await sendNotification({
|
const emailResult = await sendNotification({
|
||||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
type: 'ACCOUNT_CREATED',
|
||||||
title: signupEmail.subject(lang),
|
|
||||||
body: signupEmail.text({
|
|
||||||
firstName: body.firstName,
|
|
||||||
companyName: body.companyName,
|
|
||||||
plan: body.plan,
|
|
||||||
billingPeriod: body.billingPeriod,
|
|
||||||
currency: body.currency,
|
|
||||||
paymentProvider: body.paymentProvider,
|
|
||||||
trialEnd: trialEndAt,
|
|
||||||
}, lang),
|
|
||||||
companyId: result.company.id,
|
companyId: result.company.id,
|
||||||
employeeId: result.employee.id,
|
employeeId: result.employee.id,
|
||||||
email: body.email,
|
email: body.email,
|
||||||
channels: ['EMAIL', 'IN_APP'],
|
channels: ['EMAIL', 'IN_APP'],
|
||||||
locale: lang,
|
locale: lang,
|
||||||
|
templateKey: 'account.created',
|
||||||
|
templateVariables: {
|
||||||
|
firstName: body.firstName,
|
||||||
|
companyName: body.companyName,
|
||||||
|
planName: localizePlanName(body.plan, lang),
|
||||||
|
billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang),
|
||||||
|
currency: body.currency,
|
||||||
|
paymentProvider: body.paymentProvider,
|
||||||
|
trialEndDate: trialEndAt,
|
||||||
|
},
|
||||||
}).catch(() => [])
|
}).catch(() => [])
|
||||||
|
|
||||||
const emailDelivery = (emailResult as Array<{ channel?: string }>).find((entry) => entry.channel === 'EMAIL')
|
const emailDelivery = (emailResult as Array<{ channel?: string }>).find((entry) => entry.channel === 'EMAIL')
|
||||||
|
|||||||
@@ -35,6 +35,20 @@ export async function upsertEmployeePreferences(employeeId: string, prefs: Array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findCompanyHistory(
|
||||||
|
companyId: string,
|
||||||
|
opts?: { channel?: string; status?: string; limit?: number },
|
||||||
|
) {
|
||||||
|
const where: any = { companyId }
|
||||||
|
if (opts?.channel) where.channel = opts.channel
|
||||||
|
if (opts?.status) where.status = opts.status
|
||||||
|
return prisma.notification.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: opts?.limit ?? 200,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Renter notifications ─────────────────────────────────────
|
// ─── Renter notifications ─────────────────────────────────────
|
||||||
|
|
||||||
export function findRenter(renterId: string) {
|
export function findRenter(renterId: string) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { requireRenterAuth } from '../../middleware/requireRenterAuth'
|
|||||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||||
import { ok } from '../../http/respond'
|
import { ok } from '../../http/respond'
|
||||||
import * as service from './notification.service'
|
import * as service from './notification.service'
|
||||||
import { preferencesSchema, idParamSchema, unreadQuerySchema } from './notification.schemas'
|
import { preferencesSchema, idParamSchema, unreadQuerySchema, historyQuerySchema } from './notification.schemas'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -56,6 +56,13 @@ router.patch('/company/preferences', ...companyAuth, async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/history', ...companyAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { channel, status, limit } = parseQuery(historyQuerySchema, req)
|
||||||
|
ok(res, await service.listCompanyHistory(req.companyId, { channel, status, limit }))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
// ─── Renter notifications ─────────────────────────────────────
|
// ─── Renter notifications ─────────────────────────────────────
|
||||||
|
|
||||||
router.get('/renter', requireRenterAuth, async (req, res, next) => {
|
router.get('/renter', requireRenterAuth, async (req, res, next) => {
|
||||||
|
|||||||
@@ -15,3 +15,9 @@ export const idParamSchema = z.object({
|
|||||||
export const unreadQuerySchema = z.object({
|
export const unreadQuerySchema = z.object({
|
||||||
unread: z.string().optional(),
|
unread: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const historyQuerySchema = z.object({
|
||||||
|
channel: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as repo from './notification.repo'
|
import * as repo from './notification.repo'
|
||||||
|
|
||||||
export const listCompany = (companyId: string, unread?: string) => repo.findCompany(companyId, unread)
|
export const listCompany = (companyId: string, unread?: string) => repo.findCompany(companyId, unread)
|
||||||
|
export const listCompanyHistory = (companyId: string, opts?: { channel?: string; status?: string; limit?: number }) => repo.findCompanyHistory(companyId, opts)
|
||||||
export const countUnread = (companyId: string) => repo.countUnread(companyId)
|
export const countUnread = (companyId: string) => repo.countUnread(companyId)
|
||||||
export const markRead = (id: string, companyId: string) => repo.markRead(id, companyId)
|
export const markRead = (id: string, companyId: string) => repo.markRead(id, companyId)
|
||||||
export const markAllRead = (companyId: string) => repo.markAllRead(companyId)
|
export const markAllRead = (companyId: string) => repo.markAllRead(companyId)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { prisma } from '../../lib/prisma'
|
|||||||
import { AppError } from '../../http/errors'
|
import { AppError } from '../../http/errors'
|
||||||
import { validateLicense } from '../../services/licenseValidationService'
|
import { validateLicense } from '../../services/licenseValidationService'
|
||||||
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
||||||
import { bookingConfirmedNotif, reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
|
import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
|
||||||
|
import { coerceNotificationLocale } from '../../services/notificationLocalizationService'
|
||||||
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
|
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
|
||||||
import * as repo from './reservation.repo'
|
import * as repo from './reservation.repo'
|
||||||
|
|
||||||
@@ -39,19 +40,27 @@ export async function confirmReservation(id: string, companyId: string) {
|
|||||||
|
|
||||||
const updated = await repo.updateById(id, { status: 'CONFIRMED' })
|
const updated = await repo.updateById(id, { status: 'CONFIRMED' })
|
||||||
|
|
||||||
const [customer, brand] = await Promise.all([
|
const [customer, company, vehicle] = await Promise.all([
|
||||||
repo.findCustomerById(reservation.customerId),
|
repo.findCustomerById(reservation.customerId),
|
||||||
repo.findBrandLocale(companyId),
|
repo.findCompanyWithBrand(companyId),
|
||||||
|
repo.findVehicle(reservation.vehicleId, companyId),
|
||||||
])
|
])
|
||||||
const lang = (brand?.defaultLocale ?? 'fr') as Lang
|
const fallbackLocale = coerceNotificationLocale(company?.brand?.defaultLocale)
|
||||||
await sendNotification({
|
await sendNotification({
|
||||||
type: 'BOOKING_CONFIRMED',
|
type: 'BOOKING_CONFIRMED',
|
||||||
title: bookingConfirmedNotif.title(lang),
|
|
||||||
body: bookingConfirmedNotif.body(lang),
|
|
||||||
companyId,
|
companyId,
|
||||||
|
renterId: reservation.renterId ?? undefined,
|
||||||
email: customer.email,
|
email: customer.email,
|
||||||
channels: ['EMAIL', 'IN_APP'],
|
channels: ['EMAIL', 'IN_APP'],
|
||||||
locale: lang,
|
locale: reservation.renterId ? undefined : fallbackLocale,
|
||||||
|
templateKey: 'booking.confirmed',
|
||||||
|
templateVariables: {
|
||||||
|
firstName: customer.firstName,
|
||||||
|
companyName: company?.brand?.displayName ?? company?.name ?? 'RentalDriveGo',
|
||||||
|
startDate: reservation.startDate,
|
||||||
|
endDate: reservation.endDate,
|
||||||
|
vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`,
|
||||||
|
},
|
||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
|
|
||||||
return updated
|
return updated
|
||||||
|
|||||||
@@ -168,7 +168,8 @@ describe('confirmReservation', () => {
|
|||||||
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
|
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
|
||||||
vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'CONFIRMED' } as any)
|
vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'CONFIRMED' } as any)
|
||||||
vi.mocked(repo.findCustomerById).mockResolvedValue({ email: 'ali@test.com', firstName: 'Ali' } as any)
|
vi.mocked(repo.findCustomerById).mockResolvedValue({ email: 'ali@test.com', firstName: 'Ali' } as any)
|
||||||
vi.mocked(repo.findBrandLocale).mockResolvedValue({ defaultLocale: 'fr' } as any)
|
vi.mocked(repo.findCompanyWithBrand).mockResolvedValue({ name: 'TestCo', brand: { displayName: 'TestCo', defaultLocale: 'fr' } } as any)
|
||||||
|
vi.mocked(repo.findVehicle).mockResolvedValue({ year: 2022, make: 'Toyota', model: 'Camry' } as any)
|
||||||
|
|
||||||
const result = await confirmReservation(RES_ID, COMPANY)
|
const result = await confirmReservation(RES_ID, COMPANY)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
renderLocalizedEmailHtml,
|
||||||
|
resolveNotificationLocale,
|
||||||
|
resolveNotificationTemplate,
|
||||||
|
} from './notificationLocalizationService'
|
||||||
|
|
||||||
|
function createDbStub(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
employee: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
renter: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
brandSettings: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
billingAccount: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
notificationTemplate: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('notificationLocalizationService', () => {
|
||||||
|
it('resolves locale in recipient -> workspace -> billing -> default order', async () => {
|
||||||
|
const db = createDbStub({
|
||||||
|
employee: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ preferredLanguage: 'ar' }),
|
||||||
|
},
|
||||||
|
brandSettings: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ defaultLocale: 'fr' }),
|
||||||
|
},
|
||||||
|
billingAccount: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ preferredLanguage: 'en' }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const locale = await resolveNotificationLocale({
|
||||||
|
companyId: 'company_1',
|
||||||
|
employeeId: 'employee_1',
|
||||||
|
}, db as any)
|
||||||
|
|
||||||
|
expect(locale).toBe('ar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to English when a localized template is missing', async () => {
|
||||||
|
const db = createDbStub({
|
||||||
|
notificationTemplate: {
|
||||||
|
findFirst: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(({ where }: any) => {
|
||||||
|
if (where.locale === 'en') {
|
||||||
|
return Promise.resolve({
|
||||||
|
templateKey: 'booking.confirmed',
|
||||||
|
channel: 'EMAIL',
|
||||||
|
locale: 'en',
|
||||||
|
subject: 'Booking confirmed - {{companyName}}',
|
||||||
|
body: 'Hello {{firstName}}',
|
||||||
|
requiredVariables: ['companyName', 'firstName'],
|
||||||
|
optionalVariables: [],
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(null)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const template = await resolveNotificationTemplate({
|
||||||
|
templateKey: 'booking.confirmed',
|
||||||
|
channel: 'EMAIL',
|
||||||
|
locale: 'ar',
|
||||||
|
variables: {
|
||||||
|
firstName: 'Aya',
|
||||||
|
companyName: 'Atlas Cars',
|
||||||
|
},
|
||||||
|
}, db as any)
|
||||||
|
|
||||||
|
expect(template.locale).toBe('en')
|
||||||
|
expect(template.usedFallback).toBe(true)
|
||||||
|
expect(template.subject).toBe('Booking confirmed - Atlas Cars')
|
||||||
|
expect(template.body).toBe('Hello Aya')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Arabic emails with rtl metadata', () => {
|
||||||
|
const html = renderLocalizedEmailHtml('مرحبا\n\nتم التأكيد', 'ar')
|
||||||
|
|
||||||
|
expect(html).toContain('<html lang="ar" dir="rtl">')
|
||||||
|
expect(html).toContain('text-align:right')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import type { NotificationChannel } from '@rentaldrivego/database'
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
|
|
||||||
|
export const SUPPORTED_NOTIFICATION_LOCALES = ['en', 'fr', 'ar'] as const
|
||||||
|
|
||||||
|
export type NotificationLocale = (typeof SUPPORTED_NOTIFICATION_LOCALES)[number]
|
||||||
|
|
||||||
|
export const DEFAULT_NOTIFICATION_LOCALE: NotificationLocale = 'en'
|
||||||
|
|
||||||
|
const localeConfig: Record<NotificationLocale, { intl: string; direction: 'ltr' | 'rtl' }> = {
|
||||||
|
en: { intl: 'en-US', direction: 'ltr' },
|
||||||
|
fr: { intl: 'fr-FR', direction: 'ltr' },
|
||||||
|
ar: { intl: 'ar-MA', direction: 'rtl' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionStatusLabels: Record<NotificationLocale, Record<string, string>> = {
|
||||||
|
en: {
|
||||||
|
trialing: 'Trial active',
|
||||||
|
active: 'Active',
|
||||||
|
payment_pending: 'Payment pending',
|
||||||
|
past_due: 'Payment overdue',
|
||||||
|
suspended: 'Suspended',
|
||||||
|
cancelled: 'Canceled',
|
||||||
|
canceled: 'Canceled',
|
||||||
|
expired: 'Expired',
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
trialing: 'Essai actif',
|
||||||
|
active: 'Actif',
|
||||||
|
payment_pending: 'Paiement en attente',
|
||||||
|
past_due: 'Paiement en retard',
|
||||||
|
suspended: 'Suspendu',
|
||||||
|
cancelled: 'Annule',
|
||||||
|
canceled: 'Annule',
|
||||||
|
expired: 'Expire',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
trialing: 'الفترة التجريبية نشطة',
|
||||||
|
active: 'نشط',
|
||||||
|
payment_pending: 'الدفع قيد الانتظار',
|
||||||
|
past_due: 'الدفع متأخر',
|
||||||
|
suspended: 'معلّق',
|
||||||
|
cancelled: 'ملغى',
|
||||||
|
canceled: 'ملغى',
|
||||||
|
expired: 'منتهي',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceStatusLabels: Record<NotificationLocale, Record<string, string>> = {
|
||||||
|
en: {
|
||||||
|
draft: 'Preparing',
|
||||||
|
open: 'Due',
|
||||||
|
payment_pending: 'Payment processing',
|
||||||
|
paid: 'Paid',
|
||||||
|
partially_paid: 'Partially paid',
|
||||||
|
past_due: 'Past due',
|
||||||
|
void: 'Canceled',
|
||||||
|
uncollectible: 'Contact support',
|
||||||
|
refunded: 'Refunded',
|
||||||
|
partially_refunded: 'Partially refunded',
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
draft: 'En preparation',
|
||||||
|
open: 'A payer',
|
||||||
|
payment_pending: 'Paiement en cours',
|
||||||
|
paid: 'Payee',
|
||||||
|
partially_paid: 'Partiellement payee',
|
||||||
|
past_due: 'En retard',
|
||||||
|
void: 'Annulee',
|
||||||
|
uncollectible: 'Contacter le support',
|
||||||
|
refunded: 'Remboursee',
|
||||||
|
partially_refunded: 'Partiellement remboursee',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
draft: 'قيد الإعداد',
|
||||||
|
open: 'مستحقة الدفع',
|
||||||
|
payment_pending: 'الدفع قيد المعالجة',
|
||||||
|
paid: 'مدفوعة',
|
||||||
|
partially_paid: 'مدفوعة جزئياً',
|
||||||
|
past_due: 'متأخرة',
|
||||||
|
void: 'ملغاة',
|
||||||
|
uncollectible: 'تواصل مع الدعم',
|
||||||
|
refunded: 'مستردة',
|
||||||
|
partially_refunded: 'مستردة جزئياً',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingPeriodLabels: Record<NotificationLocale, Record<string, string>> = {
|
||||||
|
en: {
|
||||||
|
monthly: 'monthly',
|
||||||
|
annual: 'annual',
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
monthly: 'mensuel',
|
||||||
|
annual: 'annuel',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
monthly: 'شهري',
|
||||||
|
annual: 'سنوي',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const planLabels: Record<NotificationLocale, Record<string, string>> = {
|
||||||
|
en: {
|
||||||
|
starter: 'Starter',
|
||||||
|
growth: 'Growth',
|
||||||
|
pro: 'Pro',
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
starter: 'Starter',
|
||||||
|
growth: 'Growth',
|
||||||
|
pro: 'Pro',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
starter: 'Starter',
|
||||||
|
growth: 'Growth',
|
||||||
|
pro: 'Pro',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateDateValue = {
|
||||||
|
type: 'date'
|
||||||
|
value: Date | string | number
|
||||||
|
options?: Intl.DateTimeFormatOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateCurrencyValue = {
|
||||||
|
type: 'currency'
|
||||||
|
amountMinor: number
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateSubscriptionStatusValue = {
|
||||||
|
type: 'subscription_status'
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateInvoiceStatusValue = {
|
||||||
|
type: 'invoice_status'
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationTemplateValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| Date
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| TemplateDateValue
|
||||||
|
| TemplateCurrencyValue
|
||||||
|
| TemplateSubscriptionStatusValue
|
||||||
|
| TemplateInvoiceStatusValue
|
||||||
|
|
||||||
|
export type NotificationTemplateVariables = Record<string, NotificationTemplateValue>
|
||||||
|
|
||||||
|
type NotificationLocalizationDb = Pick<
|
||||||
|
typeof prisma,
|
||||||
|
'brandSettings' | 'billingAccount' | 'employee' | 'notificationTemplate' | 'renter'
|
||||||
|
>
|
||||||
|
|
||||||
|
type NotificationTemplateRecord = {
|
||||||
|
templateKey: string
|
||||||
|
channel: NotificationChannel
|
||||||
|
locale: string
|
||||||
|
subject: string | null
|
||||||
|
body: string
|
||||||
|
requiredVariables: unknown
|
||||||
|
optionalVariables: unknown
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceNotificationLocale(locale?: string | null): NotificationLocale {
|
||||||
|
if (locale && SUPPORTED_NOTIFICATION_LOCALES.includes(locale as NotificationLocale)) {
|
||||||
|
return locale as NotificationLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_NOTIFICATION_LOCALE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationTextDirection(locale: NotificationLocale) {
|
||||||
|
return localeConfig[locale].direction
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLocalizedDate(
|
||||||
|
value: Date | string | number,
|
||||||
|
locale: NotificationLocale,
|
||||||
|
options?: Intl.DateTimeFormatOptions,
|
||||||
|
) {
|
||||||
|
const date = value instanceof Date ? value : new Date(value)
|
||||||
|
return new Intl.DateTimeFormat(localeConfig[locale].intl, options ?? {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLocalizedCurrency(amountMinor: number, currency: string, locale: NotificationLocale) {
|
||||||
|
return new Intl.NumberFormat(localeConfig[locale].intl, {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
}).format(amountMinor / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizeSubscriptionStatus(status: string, locale: NotificationLocale) {
|
||||||
|
const normalized = status.toLowerCase()
|
||||||
|
return subscriptionStatusLabels[locale][normalized] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizeInvoiceStatus(status: string, locale: NotificationLocale) {
|
||||||
|
const normalized = status.toLowerCase()
|
||||||
|
return invoiceStatusLabels[locale][normalized] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizeBillingPeriod(period: string, locale: NotificationLocale) {
|
||||||
|
const normalized = period.toLowerCase()
|
||||||
|
return billingPeriodLabels[locale][normalized] ?? period
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizePlanName(plan: string, locale: NotificationLocale) {
|
||||||
|
const normalized = plan.toLowerCase()
|
||||||
|
return planLabels[locale][normalized] ?? plan
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTemplateDateValue(value: NotificationTemplateValue): value is TemplateDateValue {
|
||||||
|
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'date'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTemplateCurrencyValue(value: NotificationTemplateValue): value is TemplateCurrencyValue {
|
||||||
|
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'currency'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTemplateSubscriptionStatusValue(value: NotificationTemplateValue): value is TemplateSubscriptionStatusValue {
|
||||||
|
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'subscription_status'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTemplateInvoiceStatusValue(value: NotificationTemplateValue): value is TemplateInvoiceStatusValue {
|
||||||
|
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'invoice_status'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTemplateValue(value: NotificationTemplateValue, locale: NotificationLocale): string {
|
||||||
|
if (value instanceof Date) return formatLocalizedDate(value, locale)
|
||||||
|
if (isTemplateDateValue(value)) return formatLocalizedDate(value.value, locale, value.options)
|
||||||
|
if (isTemplateCurrencyValue(value)) return formatLocalizedCurrency(value.amountMinor, value.currency, locale)
|
||||||
|
if (isTemplateSubscriptionStatusValue(value)) return localizeSubscriptionStatus(value.status, locale)
|
||||||
|
if (isTemplateInvoiceStatusValue(value)) return localizeInvoiceStatus(value.status, locale)
|
||||||
|
if (value === null || value === undefined) return ''
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplateVariableList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.filter((item): item is string => typeof item === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findTemplate(
|
||||||
|
db: NotificationLocalizationDb,
|
||||||
|
templateKey: string,
|
||||||
|
channel: NotificationChannel,
|
||||||
|
locale: NotificationLocale,
|
||||||
|
): Promise<NotificationTemplateRecord | null> {
|
||||||
|
return db.notificationTemplate.findFirst({
|
||||||
|
where: { templateKey, channel, locale, isActive: true },
|
||||||
|
orderBy: { version: 'desc' },
|
||||||
|
select: {
|
||||||
|
templateKey: true,
|
||||||
|
channel: true,
|
||||||
|
locale: true,
|
||||||
|
subject: true,
|
||||||
|
body: true,
|
||||||
|
requiredVariables: true,
|
||||||
|
optionalVariables: true,
|
||||||
|
version: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveNotificationLocale(input: {
|
||||||
|
companyId?: string | null
|
||||||
|
employeeId?: string | null
|
||||||
|
renterId?: string | null
|
||||||
|
billingAccountId?: string | null
|
||||||
|
locale?: string | null
|
||||||
|
}, db: NotificationLocalizationDb = prisma): Promise<NotificationLocale> {
|
||||||
|
if (input.locale) return coerceNotificationLocale(input.locale)
|
||||||
|
|
||||||
|
const employeePromise = input.employeeId
|
||||||
|
? db.employee.findUnique({
|
||||||
|
where: { id: input.employeeId },
|
||||||
|
select: { preferredLanguage: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve(null)
|
||||||
|
|
||||||
|
const renterPromise = input.renterId
|
||||||
|
? db.renter.findUnique({
|
||||||
|
where: { id: input.renterId },
|
||||||
|
select: { preferredLocale: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve(null)
|
||||||
|
|
||||||
|
const workspacePromise = input.companyId
|
||||||
|
? db.brandSettings.findUnique({
|
||||||
|
where: { companyId: input.companyId },
|
||||||
|
select: { defaultLocale: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve(null)
|
||||||
|
|
||||||
|
const billingPromise = input.billingAccountId
|
||||||
|
? db.billingAccount.findUnique({
|
||||||
|
where: { id: input.billingAccountId },
|
||||||
|
select: { preferredLanguage: true },
|
||||||
|
})
|
||||||
|
: input.companyId
|
||||||
|
? db.billingAccount.findFirst({
|
||||||
|
where: { companyId: input.companyId, isPrimary: true },
|
||||||
|
select: { preferredLanguage: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
: Promise.resolve(null)
|
||||||
|
|
||||||
|
const [employee, renter, workspace, billingAccount] = await Promise.all([
|
||||||
|
employeePromise,
|
||||||
|
renterPromise,
|
||||||
|
workspacePromise,
|
||||||
|
billingPromise,
|
||||||
|
])
|
||||||
|
|
||||||
|
return coerceNotificationLocale(
|
||||||
|
employee?.preferredLanguage ??
|
||||||
|
renter?.preferredLocale ??
|
||||||
|
workspace?.defaultLocale ??
|
||||||
|
billingAccount?.preferredLanguage ??
|
||||||
|
DEFAULT_NOTIFICATION_LOCALE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveNotificationTemplate(input: {
|
||||||
|
templateKey: string
|
||||||
|
channel: NotificationChannel
|
||||||
|
locale: NotificationLocale
|
||||||
|
variables?: NotificationTemplateVariables
|
||||||
|
}, db: NotificationLocalizationDb = prisma) {
|
||||||
|
const requested = await findTemplate(db, input.templateKey, input.channel, input.locale)
|
||||||
|
|
||||||
|
if (requested) {
|
||||||
|
return {
|
||||||
|
locale: coerceNotificationLocale(requested.locale),
|
||||||
|
subject: renderNotificationText(
|
||||||
|
requested.subject,
|
||||||
|
input.variables,
|
||||||
|
normalizeTemplateVariableList(requested.requiredVariables),
|
||||||
|
input.locale,
|
||||||
|
),
|
||||||
|
body: renderNotificationText(
|
||||||
|
requested.body,
|
||||||
|
input.variables,
|
||||||
|
normalizeTemplateVariableList(requested.requiredVariables),
|
||||||
|
input.locale,
|
||||||
|
),
|
||||||
|
templateKey: requested.templateKey,
|
||||||
|
usedFallback: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.locale !== DEFAULT_NOTIFICATION_LOCALE) {
|
||||||
|
console.warn(
|
||||||
|
`[Notifications] Missing ${input.locale} template for ${input.templateKey}/${input.channel}; falling back to ${DEFAULT_NOTIFICATION_LOCALE}.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = await findTemplate(db, input.templateKey, input.channel, DEFAULT_NOTIFICATION_LOCALE)
|
||||||
|
if (!fallback) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing notification template for ${input.templateKey}/${input.channel} in ${input.locale} and ${DEFAULT_NOTIFICATION_LOCALE}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale: DEFAULT_NOTIFICATION_LOCALE,
|
||||||
|
subject: renderNotificationText(
|
||||||
|
fallback.subject,
|
||||||
|
input.variables,
|
||||||
|
normalizeTemplateVariableList(fallback.requiredVariables),
|
||||||
|
DEFAULT_NOTIFICATION_LOCALE,
|
||||||
|
),
|
||||||
|
body: renderNotificationText(
|
||||||
|
fallback.body,
|
||||||
|
input.variables,
|
||||||
|
normalizeTemplateVariableList(fallback.requiredVariables),
|
||||||
|
DEFAULT_NOTIFICATION_LOCALE,
|
||||||
|
),
|
||||||
|
templateKey: fallback.templateKey,
|
||||||
|
usedFallback: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderNotificationText(
|
||||||
|
template: string | null,
|
||||||
|
variables: NotificationTemplateVariables = {},
|
||||||
|
requiredVariables: string[] = [],
|
||||||
|
locale: NotificationLocale,
|
||||||
|
) {
|
||||||
|
if (!template) return null
|
||||||
|
|
||||||
|
for (const variableName of requiredVariables) {
|
||||||
|
if (variables[variableName] === undefined || variables[variableName] === null) {
|
||||||
|
throw new Error(`Missing notification template variable: ${variableName}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, variableName: string) =>
|
||||||
|
formatTemplateValue(variables[variableName], locale),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLocalizedEmailHtml(body: string, locale: NotificationLocale) {
|
||||||
|
const direction = getNotificationTextDirection(locale)
|
||||||
|
const textAlign = direction === 'rtl' ? 'right' : 'left'
|
||||||
|
const paragraphs = body
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((paragraph) => `<p style="margin:0 0 16px;text-align:${textAlign};">${escapeHtml(paragraph).replace(/\n/g, '<br />')}</p>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return [
|
||||||
|
`<html lang="${locale}" dir="${direction}">`,
|
||||||
|
`<body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:32px;color:#1c1917;direction:${direction};text-align:${textAlign};">`,
|
||||||
|
paragraphs,
|
||||||
|
'</body>',
|
||||||
|
'</html>',
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ import admin from 'firebase-admin'
|
|||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { redis } from '../lib/redis'
|
import { redis } from '../lib/redis'
|
||||||
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
|
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
|
||||||
|
import {
|
||||||
|
renderLocalizedEmailHtml,
|
||||||
|
resolveNotificationLocale,
|
||||||
|
resolveNotificationTemplate,
|
||||||
|
} from './notificationLocalizationService'
|
||||||
|
import type { NotificationTemplateVariables } from './notificationLocalizationService'
|
||||||
|
|
||||||
const resendApiKey =
|
const resendApiKey =
|
||||||
process.env.RESEND_API_KEY &&
|
process.env.RESEND_API_KEY &&
|
||||||
@@ -100,33 +106,20 @@ if (hasFirebaseConfig && !admin.apps.length) {
|
|||||||
|
|
||||||
interface SendNotificationOptions {
|
interface SendNotificationOptions {
|
||||||
type: NotificationType
|
type: NotificationType
|
||||||
title: string
|
title?: string
|
||||||
body: string
|
body?: string
|
||||||
data?: Record<string, unknown>
|
data?: Record<string, unknown>
|
||||||
companyId?: string
|
companyId?: string
|
||||||
employeeId?: string
|
employeeId?: string
|
||||||
renterId?: string
|
renterId?: string
|
||||||
|
billingAccountId?: string
|
||||||
email?: string
|
email?: string
|
||||||
phone?: string
|
phone?: string
|
||||||
fcmToken?: string
|
fcmToken?: string
|
||||||
channels: NotificationChannel[]
|
channels: NotificationChannel[]
|
||||||
locale?: string
|
locale?: string
|
||||||
}
|
templateKey?: string
|
||||||
|
templateVariables?: NotificationTemplateVariables
|
||||||
function escapeHtml(value: string) {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEmailHtml(body: string) {
|
|
||||||
return body
|
|
||||||
.split(/\n{2,}/)
|
|
||||||
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, '<br />')}</p>`)
|
|
||||||
.join('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSmtpReplyTo() {
|
function resolveSmtpReplyTo() {
|
||||||
@@ -210,16 +203,41 @@ async function sendEmailWithProviders(opts: {
|
|||||||
|
|
||||||
export async function sendNotification(opts: SendNotificationOptions) {
|
export async function sendNotification(opts: SendNotificationOptions) {
|
||||||
const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = []
|
const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = []
|
||||||
|
const resolvedLocale = await resolveNotificationLocale({
|
||||||
|
companyId: opts.companyId,
|
||||||
|
employeeId: opts.employeeId,
|
||||||
|
renterId: opts.renterId,
|
||||||
|
billingAccountId: opts.billingAccountId,
|
||||||
|
locale: opts.locale,
|
||||||
|
})
|
||||||
|
|
||||||
for (const channel of opts.channels) {
|
for (const channel of opts.channels) {
|
||||||
try {
|
try {
|
||||||
|
const rendered = opts.templateKey
|
||||||
|
? await resolveNotificationTemplate({
|
||||||
|
templateKey: opts.templateKey,
|
||||||
|
channel,
|
||||||
|
locale: resolvedLocale,
|
||||||
|
variables: opts.templateVariables,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
const title = rendered?.subject ?? opts.title
|
||||||
|
const body = rendered?.body ?? opts.body
|
||||||
|
|
||||||
|
if (!title || !body) {
|
||||||
|
throw new Error(`Notification content is incomplete for channel ${channel}`)
|
||||||
|
}
|
||||||
|
|
||||||
const notification = await prisma.notification.create({
|
const notification = await prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
type: opts.type,
|
type: opts.type,
|
||||||
title: opts.title,
|
title,
|
||||||
body: opts.body,
|
body,
|
||||||
data: (opts.data ?? {}) as any,
|
data: (opts.data ?? {}) as any,
|
||||||
channel,
|
channel,
|
||||||
|
templateKey: rendered?.templateKey ?? opts.templateKey ?? null,
|
||||||
|
locale: rendered?.locale ?? resolvedLocale,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
companyId: opts.companyId ?? null,
|
companyId: opts.companyId ?? null,
|
||||||
employeeId: opts.employeeId ?? null,
|
employeeId: opts.employeeId ?? null,
|
||||||
@@ -233,9 +251,9 @@ export async function sendNotification(opts: SendNotificationOptions) {
|
|||||||
if (channel === 'EMAIL' && opts.email) {
|
if (channel === 'EMAIL' && opts.email) {
|
||||||
const emailResult = await sendEmailWithProviders({
|
const emailResult = await sendEmailWithProviders({
|
||||||
to: opts.email,
|
to: opts.email,
|
||||||
subject: opts.title,
|
subject: title,
|
||||||
html: renderEmailHtml(opts.body),
|
html: renderLocalizedEmailHtml(body, rendered?.locale ?? resolvedLocale),
|
||||||
text: opts.body,
|
text: body,
|
||||||
})
|
})
|
||||||
providerMessageId = emailResult.providerMessageId
|
providerMessageId = emailResult.providerMessageId
|
||||||
success = true
|
success = true
|
||||||
@@ -244,7 +262,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
|
|||||||
if (channel === 'SMS' && opts.phone) {
|
if (channel === 'SMS' && opts.phone) {
|
||||||
if (!twilioClient) throw new Error('Twilio is not configured')
|
if (!twilioClient) throw new Error('Twilio is not configured')
|
||||||
const msg = await twilioClient.messages.create({
|
const msg = await twilioClient.messages.create({
|
||||||
body: opts.body,
|
body,
|
||||||
from: process.env.TWILIO_PHONE_NUMBER!,
|
from: process.env.TWILIO_PHONE_NUMBER!,
|
||||||
to: opts.phone,
|
to: opts.phone,
|
||||||
})
|
})
|
||||||
@@ -255,7 +273,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
|
|||||||
if (channel === 'WHATSAPP' && opts.phone) {
|
if (channel === 'WHATSAPP' && opts.phone) {
|
||||||
if (!twilioClient) throw new Error('Twilio is not configured')
|
if (!twilioClient) throw new Error('Twilio is not configured')
|
||||||
const msg = await twilioClient.messages.create({
|
const msg = await twilioClient.messages.create({
|
||||||
body: opts.body,
|
body,
|
||||||
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
|
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
|
||||||
to: `whatsapp:${opts.phone}`,
|
to: `whatsapp:${opts.phone}`,
|
||||||
})
|
})
|
||||||
@@ -267,7 +285,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
|
|||||||
if (!admin.apps.length) throw new Error('Firebase is not configured')
|
if (!admin.apps.length) throw new Error('Firebase is not configured')
|
||||||
const response = await admin.messaging().send({
|
const response = await admin.messaging().send({
|
||||||
token: opts.fcmToken,
|
token: opts.fcmToken,
|
||||||
notification: { title: opts.title, body: opts.body },
|
notification: { title, body },
|
||||||
data: Object.fromEntries(
|
data: Object.fromEntries(
|
||||||
Object.entries(opts.data ?? {}).map(([k, v]) => [k, String(v)])
|
Object.entries(opts.data ?? {}).map(([k, v]) => [k, String(v)])
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import crypto from 'crypto'
|
|||||||
import { EmployeeRole } from '@rentaldrivego/database'
|
import { EmployeeRole } from '@rentaldrivego/database'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { sendTransactionalEmail } from './notificationService'
|
import { sendTransactionalEmail } from './notificationService'
|
||||||
|
import { coerceNotificationLocale } from './notificationLocalizationService'
|
||||||
|
|
||||||
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
|
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
|
||||||
|
|
||||||
@@ -39,9 +40,45 @@ function ensureDashboardBasePath(baseUrl: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInviteEmail(resetUrl: string, companyName: string, firstName: string, role: EmployeeRole) {
|
function buildInviteEmail(
|
||||||
|
resetUrl: string,
|
||||||
|
companyName: string,
|
||||||
|
firstName: string,
|
||||||
|
role: EmployeeRole,
|
||||||
|
locale: ReturnType<typeof coerceNotificationLocale>,
|
||||||
|
) {
|
||||||
const greetingName = firstName.trim() || 'there'
|
const greetingName = firstName.trim() || 'there'
|
||||||
|
|
||||||
|
if (locale === 'fr') {
|
||||||
|
return {
|
||||||
|
subject: `Vous avez ete invite chez ${companyName}`,
|
||||||
|
html: `
|
||||||
|
<p>Bonjour ${greetingName},</p>
|
||||||
|
<p>Vous avez ete invite a rejoindre ${companyName} sur RentalDriveGo en tant que ${role.toLowerCase()}.</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;">Definir votre mot de passe</a></p>
|
||||||
|
<p>Ce lien d'invitation expire dans 7 jours.</p>
|
||||||
|
<p>RentalDriveGo</p>
|
||||||
|
`,
|
||||||
|
text: `Bonjour ${greetingName},\n\nVous avez ete invite a rejoindre ${companyName} sur RentalDriveGo en tant que ${role.toLowerCase()}.\n\nDefinissez votre mot de passe ici : ${resetUrl}\n\nCe lien d'invitation expire dans 7 jours.\n\nRentalDriveGo`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locale === 'ar') {
|
||||||
|
return {
|
||||||
|
subject: `تمت دعوتك إلى ${companyName}`,
|
||||||
|
html: `
|
||||||
|
<div dir="rtl" style="text-align:right">
|
||||||
|
<p>مرحباً ${greetingName}،</p>
|
||||||
|
<p>تمت دعوتك للانضمام إلى ${companyName} على RentalDriveGo بصفتك ${role.toLowerCase()}.</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>تنتهي صلاحية رابط الدعوة هذا خلال 7 أيام.</p>
|
||||||
|
<p>RentalDriveGo</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
text: `مرحباً ${greetingName}،\n\nتمت دعوتك للانضمام إلى ${companyName} على RentalDriveGo بصفتك ${role.toLowerCase()}.\n\nعيّن كلمة المرور هنا: ${resetUrl}\n\nتنتهي صلاحية رابط الدعوة هذا خلال 7 أيام.\n\nRentalDriveGo`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subject: `You have been invited to ${companyName}`,
|
subject: `You have been invited to ${companyName}`,
|
||||||
html: `
|
html: `
|
||||||
@@ -78,9 +115,13 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
|||||||
throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' })
|
throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
|
const company = await prisma.company.findUniqueOrThrow({
|
||||||
|
where: { id: companyId },
|
||||||
|
include: { brand: { select: { defaultLocale: true, displayName: true } } },
|
||||||
|
})
|
||||||
const rawToken = crypto.randomBytes(32).toString('hex')
|
const rawToken = crypto.randomBytes(32).toString('hex')
|
||||||
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
|
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
|
||||||
|
const locale = coerceNotificationLocale(company.brand?.defaultLocale)
|
||||||
|
|
||||||
const employee = await prisma.employee.create({
|
const employee = await prisma.employee.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -91,6 +132,7 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
|||||||
email: payload.email,
|
email: payload.email,
|
||||||
role: payload.role,
|
role: payload.role,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
preferredLanguage: locale,
|
||||||
passwordResetToken: rawToken,
|
passwordResetToken: rawToken,
|
||||||
passwordResetExpiresAt: expiresAt,
|
passwordResetExpiresAt: expiresAt,
|
||||||
},
|
},
|
||||||
@@ -100,7 +142,13 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
|||||||
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||||
)
|
)
|
||||||
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
|
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
|
||||||
const message = buildInviteEmail(resetUrl, company.name, payload.firstName, payload.role)
|
const message = buildInviteEmail(
|
||||||
|
resetUrl,
|
||||||
|
company.brand?.displayName ?? company.name,
|
||||||
|
payload.firstName,
|
||||||
|
payload.role,
|
||||||
|
locale,
|
||||||
|
)
|
||||||
|
|
||||||
await sendTransactionalEmail({
|
await sendTransactionalEmail({
|
||||||
to: payload.email,
|
to: payload.email,
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ type NotificationItem = {
|
|||||||
type: string
|
type: string
|
||||||
title: string
|
title: string
|
||||||
body: string
|
body: string
|
||||||
|
channel: string
|
||||||
|
status: string
|
||||||
|
sentAt: string | null
|
||||||
readAt: string | null
|
readAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
providerMessageId: string | null
|
||||||
|
locale: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreferenceItem = {
|
type PreferenceItem = {
|
||||||
@@ -30,19 +35,42 @@ const COMPANY_EVENTS = [
|
|||||||
|
|
||||||
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
||||||
|
|
||||||
|
const CHANNEL_BADGE: Record<string, string> = {
|
||||||
|
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
SMS: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||||
|
WHATSAPP: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
||||||
|
IN_APP: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
|
PUSH: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, string> = {
|
||||||
|
PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||||
|
SENT: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||||
|
DELIVERED: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||||
|
FAILED: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||||
|
READ: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardNotificationsPage() {
|
export default function DashboardNotificationsPage() {
|
||||||
const { language } = useDashboardI18n()
|
const { language } = useDashboardI18n()
|
||||||
const [notifications, setNotifications] = useState<NotificationItem[]>([])
|
const [notifications, setNotifications] = useState<NotificationItem[]>([])
|
||||||
|
const [history, setHistory] = useState<NotificationItem[]>([])
|
||||||
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
|
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
|
||||||
const [activeTab, setActiveTab] = useState<'inbox' | 'preferences'>('inbox')
|
const [activeTab, setActiveTab] = useState<'inbox' | 'history' | 'preferences'>('inbox')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [historyLoaded, setHistoryLoaded] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [filterChannel, setFilterChannel] = useState('')
|
||||||
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
|
|
||||||
const copy = {
|
const copy = {
|
||||||
en: {
|
en: {
|
||||||
title: 'Notifications',
|
title: 'Notifications',
|
||||||
subtitle: 'Track in-app alerts and control delivery preferences for your team account.',
|
subtitle: 'Track in-app alerts, audit delivery history, and control preferences for your team account.',
|
||||||
inbox: 'Inbox',
|
inbox: 'Inbox',
|
||||||
|
history: 'History',
|
||||||
preferences: 'Preferences',
|
preferences: 'Preferences',
|
||||||
markAllRead: 'Mark all as read',
|
markAllRead: 'Mark all as read',
|
||||||
loading: 'Loading notifications…',
|
loading: 'Loading notifications…',
|
||||||
@@ -50,24 +78,35 @@ export default function DashboardNotificationsPage() {
|
|||||||
read: 'Read',
|
read: 'Read',
|
||||||
markRead: 'Mark as read',
|
markRead: 'Mark as read',
|
||||||
event: 'Event',
|
event: 'Event',
|
||||||
|
channel: 'Channel',
|
||||||
|
status: 'Status',
|
||||||
|
sentAt: 'Sent at',
|
||||||
|
date: 'Date',
|
||||||
|
allChannels: 'All channels',
|
||||||
|
allStatuses: 'All statuses',
|
||||||
savePreferences: 'Save preferences',
|
savePreferences: 'Save preferences',
|
||||||
saving: 'Saving…',
|
saving: 'Saving…',
|
||||||
failedLoad: 'Failed to load notifications',
|
failedLoad: 'Failed to load notifications',
|
||||||
failedSave: 'Failed to save preferences',
|
failedSave: 'Failed to save preferences',
|
||||||
|
noHistory: 'No notification history found.',
|
||||||
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
|
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
|
||||||
|
statuses: { PENDING: 'Pending', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', READ: 'Read' } as Record<string, string>,
|
||||||
events: {
|
events: {
|
||||||
NEW_RESERVATION: 'New reservation',
|
NEW_RESERVATION: 'New reservation',
|
||||||
RESERVATION_CANCELLED: 'Reservation cancelled',
|
RESERVATION_CANCELLED: 'Reservation cancelled',
|
||||||
PAYMENT_RECEIVED: 'Payment received',
|
PAYMENT_RECEIVED: 'Payment received',
|
||||||
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
|
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
|
||||||
MAINTENANCE_DUE: 'Maintenance à prévoir',
|
MAINTENANCE_DUE: 'Maintenance due',
|
||||||
OFFER_EXPIRING: 'Offer expiring',
|
OFFER_EXPIRING: 'Offer expiring',
|
||||||
|
BOOKING_CONFIRMED: 'Booking confirmed',
|
||||||
|
VEHICLE_MAINTENANCE_DUE: 'Vehicle maintenance due',
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
title: 'Notifications',
|
title: 'Notifications',
|
||||||
subtitle: 'Suivez les alertes de l’application et gérez les préférences de diffusion de votre équipe.',
|
subtitle: 'Suivez les alertes, auditez l'historique de diffusion et gérez les préférences de votre équipe.',
|
||||||
inbox: 'Boîte de réception',
|
inbox: 'Boîte de réception',
|
||||||
|
history: 'Historique',
|
||||||
preferences: 'Préférences',
|
preferences: 'Préférences',
|
||||||
markAllRead: 'Tout marquer comme lu',
|
markAllRead: 'Tout marquer comme lu',
|
||||||
loading: 'Chargement des notifications…',
|
loading: 'Chargement des notifications…',
|
||||||
@@ -75,24 +114,35 @@ export default function DashboardNotificationsPage() {
|
|||||||
read: 'Lu',
|
read: 'Lu',
|
||||||
markRead: 'Marquer comme lu',
|
markRead: 'Marquer comme lu',
|
||||||
event: 'Événement',
|
event: 'Événement',
|
||||||
|
channel: 'Canal',
|
||||||
|
status: 'Statut',
|
||||||
|
sentAt: 'Envoyé le',
|
||||||
|
date: 'Date',
|
||||||
|
allChannels: 'Tous les canaux',
|
||||||
|
allStatuses: 'Tous les statuts',
|
||||||
savePreferences: 'Enregistrer les préférences',
|
savePreferences: 'Enregistrer les préférences',
|
||||||
saving: 'Enregistrement…',
|
saving: 'Enregistrement…',
|
||||||
failedLoad: 'Échec du chargement des notifications',
|
failedLoad: 'Échec du chargement des notifications',
|
||||||
failedSave: 'Échec de l’enregistrement des préférences',
|
failedSave: 'Échec de l'enregistrement des préférences',
|
||||||
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l’application', PUSH: 'Push' } as Record<string, string>,
|
noHistory: 'Aucun historique de notification trouvé.',
|
||||||
|
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l'application', PUSH: 'Push' } as Record<string, string>,
|
||||||
|
statuses: { PENDING: 'En attente', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', READ: 'Lu' } as Record<string, string>,
|
||||||
events: {
|
events: {
|
||||||
NEW_RESERVATION: 'Nouvelle réservation',
|
NEW_RESERVATION: 'Nouvelle réservation',
|
||||||
RESERVATION_CANCELLED: 'Réservation annulée',
|
RESERVATION_CANCELLED: 'Réservation annulée',
|
||||||
PAYMENT_RECEIVED: 'Paiement reçu',
|
PAYMENT_RECEIVED: 'Paiement reçu',
|
||||||
SUBSCRIPTION_TRIAL_ENDING: 'Fin d’essai proche',
|
SUBSCRIPTION_TRIAL_ENDING: 'Fin d'essai proche',
|
||||||
MAINTENANCE_DUE: 'Maintenance due',
|
MAINTENANCE_DUE: 'Maintenance due',
|
||||||
OFFER_EXPIRING: 'Offre expirante',
|
OFFER_EXPIRING: 'Offre expirante',
|
||||||
|
BOOKING_CONFIRMED: 'Réservation confirmée',
|
||||||
|
VEHICLE_MAINTENANCE_DUE: 'Maintenance véhicule due',
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
title: 'الإشعارات',
|
title: 'الإشعارات',
|
||||||
subtitle: 'تابع تنبيهات التطبيق وتحكم في تفضيلات الإرسال لحساب فريقك.',
|
subtitle: 'تابع التنبيهات وراجع سجل الإرسال وتحكم في التفضيلات لحساب فريقك.',
|
||||||
inbox: 'صندوق الوارد',
|
inbox: 'صندوق الوارد',
|
||||||
|
history: 'السجل',
|
||||||
preferences: 'التفضيلات',
|
preferences: 'التفضيلات',
|
||||||
markAllRead: 'تحديد الكل كمقروء',
|
markAllRead: 'تحديد الكل كمقروء',
|
||||||
loading: 'جارٍ تحميل الإشعارات…',
|
loading: 'جارٍ تحميل الإشعارات…',
|
||||||
@@ -100,11 +150,19 @@ export default function DashboardNotificationsPage() {
|
|||||||
read: 'مقروء',
|
read: 'مقروء',
|
||||||
markRead: 'تحديد كمقروء',
|
markRead: 'تحديد كمقروء',
|
||||||
event: 'الحدث',
|
event: 'الحدث',
|
||||||
|
channel: 'القناة',
|
||||||
|
status: 'الحالة',
|
||||||
|
sentAt: 'وقت الإرسال',
|
||||||
|
date: 'التاريخ',
|
||||||
|
allChannels: 'جميع القنوات',
|
||||||
|
allStatuses: 'جميع الحالات',
|
||||||
savePreferences: 'حفظ التفضيلات',
|
savePreferences: 'حفظ التفضيلات',
|
||||||
saving: 'جارٍ الحفظ…',
|
saving: 'جارٍ الحفظ…',
|
||||||
failedLoad: 'فشل تحميل الإشعارات',
|
failedLoad: 'فشل تحميل الإشعارات',
|
||||||
failedSave: 'فشل حفظ التفضيلات',
|
failedSave: 'فشل حفظ التفضيلات',
|
||||||
|
noHistory: 'لا يوجد سجل إشعارات.',
|
||||||
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
|
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
|
||||||
|
statuses: { PENDING: 'قيد الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', READ: 'مقروء' } as Record<string, string>,
|
||||||
events: {
|
events: {
|
||||||
NEW_RESERVATION: 'حجز جديد',
|
NEW_RESERVATION: 'حجز جديد',
|
||||||
RESERVATION_CANCELLED: 'إلغاء حجز',
|
RESERVATION_CANCELLED: 'إلغاء حجز',
|
||||||
@@ -112,6 +170,8 @@ export default function DashboardNotificationsPage() {
|
|||||||
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
|
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
|
||||||
MAINTENANCE_DUE: 'صيانة مستحقة',
|
MAINTENANCE_DUE: 'صيانة مستحقة',
|
||||||
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
|
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
|
||||||
|
BOOKING_CONFIRMED: 'تأكيد الحجز',
|
||||||
|
VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة',
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
},
|
},
|
||||||
}[language]
|
}[language]
|
||||||
@@ -136,10 +196,34 @@ export default function DashboardNotificationsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
setHistoryLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filterChannel) params.set('channel', filterChannel)
|
||||||
|
if (filterStatus) params.set('status', filterStatus)
|
||||||
|
const qs = params.toString()
|
||||||
|
const data = await apiFetch<NotificationItem[]>(`/notifications/history${qs ? `?${qs}` : ''}`)
|
||||||
|
setHistory(data)
|
||||||
|
setHistoryLoaded(true)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message ?? copy.failedLoad)
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'history') {
|
||||||
|
loadHistory()
|
||||||
|
}
|
||||||
|
}, [activeTab, filterChannel, filterStatus])
|
||||||
|
|
||||||
async function markRead(id: string) {
|
async function markRead(id: string) {
|
||||||
await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' })
|
await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' })
|
||||||
setNotifications((current) =>
|
setNotifications((current) =>
|
||||||
@@ -184,12 +268,16 @@ export default function DashboardNotificationsPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function labelEvent(type: string) {
|
||||||
|
return copy.events[type] ?? type.replaceAll('_', ' ')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-slate-900">{copy.title}</h1>
|
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{copy.title}</h1>
|
||||||
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{copy.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -199,6 +287,13 @@ export default function DashboardNotificationsPage() {
|
|||||||
>
|
>
|
||||||
{copy.inbox}
|
{copy.inbox}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('history')}
|
||||||
|
className={activeTab === 'history' ? 'btn-primary' : 'btn-secondary'}
|
||||||
|
>
|
||||||
|
{copy.history}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('preferences')}
|
onClick={() => setActiveTab('preferences')}
|
||||||
@@ -209,8 +304,9 @@ export default function DashboardNotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <div className="card p-4 text-sm text-red-600">{error}</div> : null}
|
{error ? <div className="card p-4 text-sm text-red-600 dark:text-red-400">{error}</div> : null}
|
||||||
|
|
||||||
|
{/* ── Inbox ── */}
|
||||||
{activeTab === 'inbox' ? (
|
{activeTab === 'inbox' ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@@ -225,34 +321,118 @@ export default function DashboardNotificationsPage() {
|
|||||||
<article key={notification.id} className="card p-6">
|
<article key={notification.id} className="card p-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
||||||
{copy.events[notification.type] ?? notification.type.replaceAll('_', ' ')}
|
{labelEvent(notification.type)}
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 text-lg font-semibold text-slate-900">{notification.title}</h2>
|
<h2 className="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{notification.title}</h2>
|
||||||
</div>
|
</div>
|
||||||
{notification.readAt ? (
|
{notification.readAt ? (
|
||||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">{copy.read}</span>
|
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-400">{copy.read}</span>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
|
<button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
|
||||||
{copy.markRead}
|
{copy.markRead}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm leading-7 text-slate-600">{notification.body}</p>
|
<p className="mt-3 text-sm leading-7 text-slate-600 dark:text-slate-300">{notification.body}</p>
|
||||||
<p className="mt-4 text-xs text-slate-400">{new Date(notification.createdAt).toLocaleString()}</p>
|
<p className="mt-4 text-xs text-slate-400">{new Date(notification.createdAt).toLocaleString()}</p>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── History ── */}
|
||||||
|
{activeTab === 'history' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={filterChannel}
|
||||||
|
onChange={(e) => setFilterChannel(e.target.value)}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200"
|
||||||
|
>
|
||||||
|
<option value="">{copy.allChannels}</option>
|
||||||
|
{COMPANY_CHANNELS.map((ch) => (
|
||||||
|
<option key={ch} value={ch}>{copy.channels[ch] ?? ch}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200"
|
||||||
|
>
|
||||||
|
<option value="">{copy.allStatuses}</option>
|
||||||
|
{Object.keys(copy.statuses).map((s) => (
|
||||||
|
<option key={s} value={s}>{copy.statuses[s]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{historyLoading ? (
|
||||||
|
<div className="card p-6 text-sm text-slate-500">{copy.loading}</div>
|
||||||
|
) : historyLoaded && history.length === 0 ? (
|
||||||
|
<div className="card p-6 text-sm text-slate-500">{copy.noHistory}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead className="bg-slate-50">
|
<thead className="bg-slate-50 dark:bg-[#0d1b38]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-semibold text-slate-600">{copy.event}</th>
|
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.date}</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.event}</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.channel}</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.title ?? 'Title'}</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.status}</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.sentAt}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 dark:divide-blue-900/50">
|
||||||
|
{history.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-slate-50/60 dark:hover:bg-[#0d1b38]/60">
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{new Date(item.createdAt).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{labelEvent(item.type)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${CHANNEL_BADGE[item.channel] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||||
|
{copy.channels[item.channel] ?? item.channel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[260px] px-4 py-3">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-800 dark:text-slate-200">{item.title}</p>
|
||||||
|
<p className="truncate text-xs text-slate-400 dark:text-slate-500">{item.body}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${STATUS_BADGE[item.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||||
|
{copy.statuses[item.status] ?? item.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── Preferences ── */}
|
||||||
|
{activeTab === 'preferences' ? (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 dark:bg-[#0d1b38]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold text-slate-600 dark:text-slate-300">{copy.event}</th>
|
||||||
{COMPANY_CHANNELS.map((channel) => (
|
{COMPANY_CHANNELS.map((channel) => (
|
||||||
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600">
|
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600 dark:text-slate-300">
|
||||||
{copy.channels[channel] ?? channel.replace('_', ' ')}
|
{copy.channels[channel] ?? channel.replace('_', ' ')}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -260,8 +440,8 @@ export default function DashboardNotificationsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{COMPANY_EVENTS.map((eventName) => (
|
{COMPANY_EVENTS.map((eventName) => (
|
||||||
<tr key={eventName} className="border-t border-slate-100">
|
<tr key={eventName} className="border-t border-slate-100 dark:border-blue-900/50">
|
||||||
<td className="px-4 py-3 font-medium text-slate-900">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td>
|
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-200">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td>
|
||||||
{COMPANY_CHANNELS.map((channel) => (
|
{COMPANY_CHANNELS.map((channel) => (
|
||||||
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
|
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
|
||||||
<input
|
<input
|
||||||
@@ -277,13 +457,13 @@ export default function DashboardNotificationsPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end border-t border-slate-100 p-4">
|
<div className="flex justify-end border-t border-slate-100 dark:border-blue-900/50 p-4">
|
||||||
<button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
|
<button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
|
||||||
{saving ? copy.saving : copy.savePreferences}
|
{saving ? copy.saving : copy.savePreferences}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useDashboardI18n } from '@/components/I18nProvider'
|
|||||||
import PublicShell from '@/components/layout/PublicShell'
|
import PublicShell from '@/components/layout/PublicShell'
|
||||||
import { adminUrl, marketplaceUrl } from '@/lib/urls'
|
import { adminUrl, marketplaceUrl } from '@/lib/urls'
|
||||||
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||||
import { toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths'
|
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||||
|
|
||||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||||
|
|
||||||
@@ -209,7 +209,6 @@ function LocalSignInForm({
|
|||||||
unexpectedError: string
|
unexpectedError: string
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { setLanguage } = useDashboardI18n()
|
const { setLanguage } = useDashboardI18n()
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@@ -221,7 +220,6 @@ function LocalSignInForm({
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const adminNext = searchParams.get('next') || '/dashboard'
|
const adminNext = searchParams.get('next') || '/dashboard'
|
||||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
|
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
|
||||||
const employeeAppRedirect = toDashboardAppPath(employeeRedirect)
|
|
||||||
|
|
||||||
function redirectAdmin(token: string) {
|
function redirectAdmin(token: string) {
|
||||||
const hash = new URLSearchParams({ token, next: adminNext }).toString()
|
const hash = new URLSearchParams({ token, next: adminNext }).toString()
|
||||||
@@ -243,6 +241,7 @@ function LocalSignInForm({
|
|||||||
|
|
||||||
if (empRes.ok && empJson?.data?.token) {
|
if (empRes.ok && empJson?.data?.token) {
|
||||||
const token = empJson.data.token
|
const token = empJson.data.token
|
||||||
|
const targetPath = toPublicDashboardPath(employeeRedirect)
|
||||||
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
|
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
|
||||||
if (empJson?.data?.employee) {
|
if (empJson?.data?.employee) {
|
||||||
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
|
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
|
||||||
@@ -254,8 +253,10 @@ function LocalSignInForm({
|
|||||||
}
|
}
|
||||||
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
|
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
|
||||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
||||||
notifyParent({ type: 'rentaldrivego:employee-login', path: toPublicDashboardPath(employeeRedirect) })
|
notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath })
|
||||||
router.push(employeeAppRedirect)
|
// Use a document navigation here so the authenticated dashboard bootstraps
|
||||||
|
// from a fresh request after the token cookie and localStorage are set.
|
||||||
|
window.location.replace(targetPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Bell } from 'lucide-react'
|
import { Bell } from 'lucide-react'
|
||||||
import { usePathname, useRouter } from 'next/navigation'
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { io } from 'socket.io-client'
|
||||||
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||||
import { toDashboardAppPath } from '@/lib/dashboardPaths'
|
import { toDashboardAppPath } from '@/lib/dashboardPaths'
|
||||||
@@ -34,6 +35,11 @@ export default function TopBar() {
|
|||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
useEffect(() => { setMounted(true) }, [])
|
useEffect(() => { setMounted(true) }, [])
|
||||||
|
|
||||||
|
function getEmployeeToken() {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
return window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
const title = (() => {
|
const title = (() => {
|
||||||
if (!mounted) return dict.titles['/']
|
if (!mounted) return dict.titles['/']
|
||||||
if (dict.titles[appPath]) return dict.titles[appPath]
|
if (dict.titles[appPath]) return dict.titles[appPath]
|
||||||
@@ -42,6 +48,11 @@ export default function TopBar() {
|
|||||||
return dict.titles['/']
|
return dict.titles['/']
|
||||||
})()
|
})()
|
||||||
async function refreshUnreadCount() {
|
async function refreshUnreadCount() {
|
||||||
|
if (!getEmployeeToken()) {
|
||||||
|
setUnreadCount(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
|
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||||
setUnreadCount(data.unread)
|
setUnreadCount(data.unread)
|
||||||
@@ -60,8 +71,29 @@ export default function TopBar() {
|
|||||||
return () => window.removeEventListener('notifications:updated', onNotificationsUpdated)
|
return () => window.removeEventListener('notifications:updated', onNotificationsUpdated)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const socketUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1').replace('/api/v1', '')
|
||||||
|
const socket = io(socketUrl, { auth: { token }, transports: ['websocket'] })
|
||||||
|
|
||||||
|
socket.on('notification', () => {
|
||||||
|
setUnreadCount((prev) => prev + 1)
|
||||||
|
window.dispatchEvent(new Event('notifications:updated'))
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => { socket.disconnect() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showNotifs) return
|
if (!showNotifs) return
|
||||||
|
if (!getEmployeeToken()) {
|
||||||
|
setNotifications([])
|
||||||
|
setLoadingNotifs(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setLoadingNotifs(true)
|
setLoadingNotifs(true)
|
||||||
apiFetch<Array<{
|
apiFetch<Array<{
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "../car_rental_app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
@@ -1,558 +0,0 @@
|
|||||||
# Contrat de Location de Véhicule
|
|
||||||
|
|
||||||
**Contrat n° :** [●]
|
|
||||||
**Date de signature :** [●]
|
|
||||||
**Lieu de signature :** [Ville, Maroc]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Parties au contrat
|
|
||||||
|
|
||||||
Entre les soussignés :
|
|
||||||
|
|
||||||
### Le Loueur / Agence
|
|
||||||
|
|
||||||
**Nom commercial :** [●]
|
|
||||||
**Raison sociale :** [●]
|
|
||||||
**Forme juridique :** [●]
|
|
||||||
**RC n° :** [●]
|
|
||||||
**ICE n° :** [●]
|
|
||||||
**IF n° :** [●]
|
|
||||||
**Adresse :** [●]
|
|
||||||
**Téléphone :** [●]
|
|
||||||
**Email :** [●]
|
|
||||||
**Représenté par :** [Nom, prénom, fonction]
|
|
||||||
|
|
||||||
Ci-après désigné **« le Loueur »**,
|
|
||||||
|
|
||||||
Et :
|
|
||||||
|
|
||||||
### Le Locataire / Conducteur principal
|
|
||||||
|
|
||||||
**Nom :** [●]
|
|
||||||
**Prénom :** [●]
|
|
||||||
**Date de naissance :** [●]
|
|
||||||
**Nationalité :** [●]
|
|
||||||
**CIN / Passeport n° :** [●]
|
|
||||||
**Adresse complète :** [●]
|
|
||||||
**Téléphone :** [●]
|
|
||||||
**Email :** [●]
|
|
||||||
|
|
||||||
Ci-après désigné **« le Locataire »**.
|
|
||||||
|
|
||||||
Le Loueur et le Locataire sont ensemble désignés **« les Parties »**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Permis de conduire
|
|
||||||
|
|
||||||
Le Locataire déclare être titulaire d’un permis de conduire valide :
|
|
||||||
|
|
||||||
**Numéro du permis :** [●]
|
|
||||||
**Catégorie :** [B / autre]
|
|
||||||
**Date de délivrance :** [●]
|
|
||||||
**Date d’expiration :** [●]
|
|
||||||
**Pays de délivrance :** [●]
|
|
||||||
**Permis international, le cas échéant :** [Oui / Non, n° ●]
|
|
||||||
|
|
||||||
Le Locataire garantit que son permis est valide pendant toute la durée de location et qu’il n’est frappé d’aucune suspension, annulation ou restriction incompatible avec la conduite du véhicule loué.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Conducteur(s) autorisé(s)
|
|
||||||
|
|
||||||
Le véhicule ne peut être conduit que par le Locataire et, le cas échéant, par les conducteurs additionnels expressément mentionnés ci-dessous :
|
|
||||||
|
|
||||||
### Conducteur additionnel 1
|
|
||||||
|
|
||||||
**Nom et prénom :** [●]
|
|
||||||
**CIN / Passeport :** [●]
|
|
||||||
**Permis n° :** [●]
|
|
||||||
**Catégorie :** [●]
|
|
||||||
**Date de délivrance :** [●]
|
|
||||||
**Téléphone :** [●]
|
|
||||||
|
|
||||||
Tout conducteur non déclaré est interdit. En cas d’accident, vol, infraction ou dommage causé par un conducteur non autorisé, le Locataire assume l’entière responsabilité des conséquences financières, civiles, pénales et assurantielles.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Véhicule loué
|
|
||||||
|
|
||||||
Le Loueur met à disposition du Locataire le véhicule suivant :
|
|
||||||
|
|
||||||
**Marque :** [●]
|
|
||||||
**Modèle :** [●]
|
|
||||||
**Année :** [●]
|
|
||||||
**Couleur :** [●]
|
|
||||||
**Immatriculation :** [●]
|
|
||||||
**Numéro de châssis :** [●]
|
|
||||||
**Kilométrage au départ :** [●] km
|
|
||||||
**Niveau de carburant au départ :** [●]
|
|
||||||
**Carte grise :** [Oui / Non]
|
|
||||||
**Assurance :** [Oui / Non]
|
|
||||||
**Visite technique :** [Oui / Non / Non applicable]
|
|
||||||
|
|
||||||
Un état des lieux contradictoire au départ et au retour est annexé au présent contrat et en fait partie intégrante.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Durée de location
|
|
||||||
|
|
||||||
La location commence le :
|
|
||||||
|
|
||||||
**Date :** [●]
|
|
||||||
**Heure :** [●]
|
|
||||||
**Lieu de prise en charge :** [●]
|
|
||||||
|
|
||||||
La location prend fin le :
|
|
||||||
|
|
||||||
**Date :** [●]
|
|
||||||
**Heure :** [●]
|
|
||||||
**Lieu de restitution :** [●]
|
|
||||||
|
|
||||||
Toute prolongation doit être acceptée préalablement par écrit par le Loueur. À défaut, le véhicule sera considéré comme conservé sans autorisation, et le Loueur pourra facturer les jours supplémentaires, pénalités de retard et frais éventuels.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Prix de location et modalités de paiement
|
|
||||||
|
|
||||||
Le prix de location est fixé comme suit :
|
|
||||||
|
|
||||||
| Désignation | Montant |
|
|
||||||
|---|---:|
|
|
||||||
| Tarif journalier HT | [●] MAD |
|
|
||||||
| Nombre de jours | [●] |
|
|
||||||
| Sous-total HT | [●] MAD |
|
|
||||||
| TVA applicable | [●] % |
|
|
||||||
| Montant TVA | [●] MAD |
|
|
||||||
| Total TTC | [●] MAD |
|
|
||||||
| Conducteur additionnel | [●] MAD |
|
|
||||||
| Livraison / récupération | [●] MAD |
|
|
||||||
| Siège enfant | [●] MAD |
|
|
||||||
| GPS / accessoires | [●] MAD |
|
|
||||||
| Autres frais | [●] MAD |
|
|
||||||
|
|
||||||
**Montant total à payer TTC : [●] MAD**
|
|
||||||
|
|
||||||
**Mode de paiement :** [Espèces / carte bancaire / virement / chèque / autre]
|
|
||||||
**Date de paiement :** [●]
|
|
||||||
**Référence de paiement :** [●]
|
|
||||||
|
|
||||||
Le Locataire reconnaît avoir reçu une information claire sur le prix total TTC avant la signature du contrat.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Dépôt de garantie / caution
|
|
||||||
|
|
||||||
Le Locataire verse au Loueur un dépôt de garantie de :
|
|
||||||
|
|
||||||
**Montant : [●] MAD**
|
|
||||||
|
|
||||||
**Forme de la caution :** [Préautorisation bancaire / espèces / chèque / autre]
|
|
||||||
**Date de constitution :** [●]
|
|
||||||
**Conditions de libération :** [●]
|
|
||||||
|
|
||||||
Le dépôt de garantie garantit notamment :
|
|
||||||
|
|
||||||
- les dommages non couverts par l’assurance ;
|
|
||||||
- la franchise d’assurance ;
|
|
||||||
- les kilomètres supplémentaires ;
|
|
||||||
- le carburant manquant ;
|
|
||||||
- les frais de nettoyage exceptionnel ;
|
|
||||||
- les contraventions, amendes, péages ou frais administratifs ;
|
|
||||||
- les retards de restitution ;
|
|
||||||
- la perte de documents, clés, accessoires ou équipements.
|
|
||||||
|
|
||||||
La caution sera restituée après vérification de l’état du véhicule, du kilométrage, du carburant, des éventuelles infractions et des sommes restant dues.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Assurance
|
|
||||||
|
|
||||||
Le véhicule est assuré auprès de :
|
|
||||||
|
|
||||||
**Compagnie d’assurance :** [●]
|
|
||||||
**Numéro de police :** [●]
|
|
||||||
**Type de couverture :** [Responsabilité civile / Tous risques / autre]
|
|
||||||
**Franchise applicable :** [●] MAD
|
|
||||||
**Assistance :** [Oui / Non]
|
|
||||||
**Numéro d’assistance :** [●]
|
|
||||||
|
|
||||||
Le Locataire reconnaît avoir été informé des garanties, exclusions et franchises applicables.
|
|
||||||
|
|
||||||
Sont notamment exclus de la couverture, sauf stipulation contraire de l’assureur :
|
|
||||||
|
|
||||||
- conduite par une personne non autorisée ;
|
|
||||||
- conduite sans permis valide ;
|
|
||||||
- conduite sous l’emprise d’alcool, stupéfiants ou substances interdites ;
|
|
||||||
- usage du véhicule hors des voies autorisées ;
|
|
||||||
- participation à des courses, essais ou compétitions ;
|
|
||||||
- négligence grave ;
|
|
||||||
- fausse déclaration ;
|
|
||||||
- absence de déclaration d’accident dans les délais ;
|
|
||||||
- vol avec clés laissées dans le véhicule ;
|
|
||||||
- transport illicite de personnes ou de marchandises.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Kilométrage
|
|
||||||
|
|
||||||
**Kilométrage inclus :** [●] km par jour / [●] km pour toute la durée de location.
|
|
||||||
**Kilométrage au départ :** [●] km.
|
|
||||||
**Kilométrage au retour :** [●] km.
|
|
||||||
|
|
||||||
Tout kilomètre supplémentaire sera facturé au tarif de :
|
|
||||||
|
|
||||||
**[●] MAD TTC par kilomètre supplémentaire.**
|
|
||||||
|
|
||||||
Le kilométrage fait foi sur la base du compteur du véhicule, constaté dans l’état des lieux de départ et de retour.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Utilisation du véhicule
|
|
||||||
|
|
||||||
Le Locataire s’engage à utiliser le véhicule avec prudence, conformément à sa destination normale et à la législation marocaine.
|
|
||||||
|
|
||||||
Il est interdit notamment :
|
|
||||||
|
|
||||||
- de sous-louer le véhicule ;
|
|
||||||
- de transporter des marchandises dangereuses ou illicites ;
|
|
||||||
- d’utiliser le véhicule pour des activités illégales ;
|
|
||||||
- de participer à des compétitions ou essais sportifs ;
|
|
||||||
- de tracter un autre véhicule sans autorisation écrite ;
|
|
||||||
- de circuler hors du territoire marocain sans autorisation écrite du Loueur ;
|
|
||||||
- de modifier le véhicule ou ses équipements ;
|
|
||||||
- de fumer dans le véhicule si l’agence l’interdit ;
|
|
||||||
- de transporter un nombre de passagers supérieur à celui autorisé.
|
|
||||||
|
|
||||||
Le Locataire est responsable des infractions au Code de la route commises pendant la période de location.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Restitution du véhicule
|
|
||||||
|
|
||||||
Le Locataire doit restituer le véhicule à la date, à l’heure et au lieu prévus au contrat, dans l’état où il lui a été remis, sous réserve de l’usure normale.
|
|
||||||
|
|
||||||
Le véhicule doit être restitué avec :
|
|
||||||
|
|
||||||
- le même niveau de carburant qu’au départ ;
|
|
||||||
- les papiers du véhicule ;
|
|
||||||
- les clés ;
|
|
||||||
- les accessoires et équipements remis ;
|
|
||||||
- un état de propreté normal ;
|
|
||||||
- aucun dommage nouveau non déclaré.
|
|
||||||
|
|
||||||
En cas de retard, le Loueur pourra facturer :
|
|
||||||
|
|
||||||
- [●] MAD par heure de retard ; ou
|
|
||||||
- une journée supplémentaire au tarif contractuel au-delà de [●] heures de retard.
|
|
||||||
|
|
||||||
En cas de carburant manquant, les frais seront facturés selon le coût réel augmenté de frais de service de [●] MAD.
|
|
||||||
|
|
||||||
En cas de salissure excessive, le Loueur pourra facturer des frais de nettoyage de [●] MAD à [●] MAD selon l’état du véhicule.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. État des lieux départ et retour
|
|
||||||
|
|
||||||
Les Parties établissent un état des lieux au départ et au retour. Celui-ci peut être accompagné de photographies datées du véhicule, du compteur, du carburant, de la carrosserie, de l’intérieur, des pneus, des vitres et des équipements.
|
|
||||||
|
|
||||||
Les éléments à contrôler comprennent notamment :
|
|
||||||
|
|
||||||
- Kilométrage compteur ;
|
|
||||||
- Niveau de carburant ;
|
|
||||||
- Carrosserie ;
|
|
||||||
- Pare-chocs ;
|
|
||||||
- Rétroviseurs ;
|
|
||||||
- Phares et feux ;
|
|
||||||
- Pare-brise et vitres ;
|
|
||||||
- Pneus et jantes ;
|
|
||||||
- Intérieur et sièges ;
|
|
||||||
- Tableau de bord ;
|
|
||||||
- Roue de secours ;
|
|
||||||
- Outillage ;
|
|
||||||
- Documents du véhicule ;
|
|
||||||
- Clés et accessoires.
|
|
||||||
|
|
||||||
Tout dommage constaté au retour et non mentionné dans l’état des lieux de départ pourra être facturé au Locataire, sous réserve des garanties d’assurance applicables.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Accident, panne, vol ou sinistre
|
|
||||||
|
|
||||||
En cas d’accident, panne, vol, tentative de vol, incendie ou tout autre sinistre, le Locataire doit immédiatement :
|
|
||||||
|
|
||||||
- informer le Loueur par téléphone et par écrit ;
|
|
||||||
- prévenir les autorités compétentes si nécessaire ;
|
|
||||||
- établir un constat amiable en cas d’accident ;
|
|
||||||
- obtenir un procès-verbal ou document officiel en cas de vol, blessure, délit ou dommage important ;
|
|
||||||
- ne pas abandonner le véhicule sans autorisation du Loueur ;
|
|
||||||
- ne pas reconnaître de responsabilité sans accord préalable du Loueur ou de l’assureur.
|
|
||||||
|
|
||||||
Le Locataire doit transmettre au Loueur tous les documents utiles dans un délai maximum de **48 heures** à compter du sinistre.
|
|
||||||
|
|
||||||
En cas de non-respect de cette procédure, le Locataire pourra être tenu responsable des conséquences financières, notamment si l’assureur refuse sa garantie.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Responsabilité du Locataire
|
|
||||||
|
|
||||||
Le Locataire est responsable :
|
|
||||||
|
|
||||||
- des dommages causés au véhicule pendant la période de location ;
|
|
||||||
- des dommages causés aux tiers dans les limites prévues par la loi et l’assurance ;
|
|
||||||
- des amendes, contraventions, frais de fourrière, péages et frais administratifs ;
|
|
||||||
- des frais liés à une mauvaise utilisation du véhicule ;
|
|
||||||
- des pertes de clés, documents ou accessoires ;
|
|
||||||
- des dommages non couverts par l’assurance ;
|
|
||||||
- de la franchise prévue au contrat d’assurance.
|
|
||||||
|
|
||||||
Le Locataire reste responsable jusqu’à la restitution effective du véhicule au Loueur et la signature de l’état des lieux de retour.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Infractions, amendes et frais administratifs
|
|
||||||
|
|
||||||
Toutes les infractions commises pendant la durée de location sont à la charge du Locataire.
|
|
||||||
|
|
||||||
Le Loueur pourra communiquer l’identité du Locataire aux autorités compétentes et facturer au Locataire :
|
|
||||||
|
|
||||||
- le montant des amendes ;
|
|
||||||
- les frais de dossier ;
|
|
||||||
- les frais de fourrière ;
|
|
||||||
- les frais de notification ;
|
|
||||||
- tout coût lié au traitement administratif de l’infraction.
|
|
||||||
|
|
||||||
**Frais administratifs par infraction : [●] MAD TTC.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Annulation, non-présentation et résiliation
|
|
||||||
|
|
||||||
En cas d’annulation par le Locataire :
|
|
||||||
|
|
||||||
- plus de [●] heures avant le départ : [conditions de remboursement] ;
|
|
||||||
- moins de [●] heures avant le départ : [conditions] ;
|
|
||||||
- non-présentation : [conditions].
|
|
||||||
|
|
||||||
Le Loueur peut résilier immédiatement le contrat, sans indemnité pour le Locataire, en cas de :
|
|
||||||
|
|
||||||
- fausse déclaration ;
|
|
||||||
- permis invalide ;
|
|
||||||
- défaut de paiement ;
|
|
||||||
- utilisation interdite du véhicule ;
|
|
||||||
- conduite dangereuse ;
|
|
||||||
- non-respect grave du présent contrat ;
|
|
||||||
- suspicion légitime de fraude ou d’usage illicite.
|
|
||||||
|
|
||||||
Dans ce cas, le Locataire doit restituer immédiatement le véhicule.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Données personnelles
|
|
||||||
|
|
||||||
Le Locataire autorise le Loueur à collecter et conserver les données nécessaires à l’exécution du présent contrat, notamment son identité, ses coordonnées, son permis de conduire, les informations de paiement, les documents liés au véhicule, les états des lieux et les données relatives aux éventuels incidents.
|
|
||||||
|
|
||||||
Ces données sont utilisées pour :
|
|
||||||
|
|
||||||
- l’exécution du contrat ;
|
|
||||||
- la facturation ;
|
|
||||||
- la gestion des sinistres ;
|
|
||||||
- la gestion des infractions ;
|
|
||||||
- les obligations comptables, fiscales et légales ;
|
|
||||||
- la défense des droits du Loueur en cas de litige.
|
|
||||||
|
|
||||||
Le Loueur s’engage à protéger les données personnelles du Locataire, à limiter leur accès aux personnes habilitées et à les conserver uniquement pendant la durée nécessaire aux finalités prévues et aux obligations légales applicables.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. Documents annexés
|
|
||||||
|
|
||||||
Les documents suivants sont annexés au présent contrat :
|
|
||||||
|
|
||||||
- Copie CIN ou passeport du Locataire ;
|
|
||||||
- Copie du permis de conduire ;
|
|
||||||
- Copie permis international, le cas échéant ;
|
|
||||||
- État des lieux de départ ;
|
|
||||||
- État des lieux de retour ;
|
|
||||||
- Photos du véhicule au départ ;
|
|
||||||
- Photos du véhicule au retour ;
|
|
||||||
- Copie de la carte grise ;
|
|
||||||
- Copie de l’attestation d’assurance ;
|
|
||||||
- Reçu de paiement ;
|
|
||||||
- Justificatif de dépôt de garantie ;
|
|
||||||
- Conditions générales de location, le cas échéant.
|
|
||||||
|
|
||||||
Les annexes font partie intégrante du présent contrat.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 19. Loi applicable et juridiction compétente
|
|
||||||
|
|
||||||
Le présent contrat est soumis au droit marocain.
|
|
||||||
|
|
||||||
En cas de litige relatif à sa validité, son interprétation, son exécution ou sa résiliation, les Parties s’efforceront de trouver une solution amiable.
|
|
||||||
|
|
||||||
À défaut d’accord amiable, le litige sera porté devant le tribunal compétent du ressort de :
|
|
||||||
|
|
||||||
**[Ville du siège de l’agence / tribunal compétent]**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 20. Déclaration finale
|
|
||||||
|
|
||||||
Le Locataire déclare :
|
|
||||||
|
|
||||||
- avoir lu et compris le présent contrat ;
|
|
||||||
- avoir reçu toutes les informations utiles avant signature ;
|
|
||||||
- avoir vérifié l’état du véhicule au départ ;
|
|
||||||
- être titulaire d’un permis valide ;
|
|
||||||
- s’engager à respecter les conditions du présent contrat ;
|
|
||||||
- accepter les prix, caution, franchises, pénalités et frais indiqués.
|
|
||||||
|
|
||||||
**Fait à :** [●]
|
|
||||||
**Le :** [●]
|
|
||||||
**En deux exemplaires originaux.**
|
|
||||||
|
|
||||||
Chaque page du présent contrat doit être paraphée par les deux Parties.
|
|
||||||
|
|
||||||
### Signature du Loueur
|
|
||||||
|
|
||||||
**Nom :** [●]
|
|
||||||
**Signature et cachet :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
### Signature du Locataire
|
|
||||||
|
|
||||||
**Nom :** [●]
|
|
||||||
**Signature :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Annexe 1 — État des lieux de départ
|
|
||||||
|
|
||||||
**Contrat n° :** [●]
|
|
||||||
**Date :** [●]
|
|
||||||
**Heure :** [●]
|
|
||||||
**Lieu :** [●]
|
|
||||||
|
|
||||||
**Véhicule :** [Marque / Modèle]
|
|
||||||
**Immatriculation :** [●]
|
|
||||||
**Kilométrage départ :** [●] km
|
|
||||||
**Carburant départ :** [●]
|
|
||||||
|
|
||||||
| Élément | État au départ | Observations |
|
|
||||||
|---|---|---|
|
|
||||||
| Carrosserie avant | Bon / Rayé / Endommagé | [●] |
|
|
||||||
| Carrosserie arrière | Bon / Rayé / Endommagé | [●] |
|
|
||||||
| Côté droit | Bon / Rayé / Endommagé | [●] |
|
|
||||||
| Côté gauche | Bon / Rayé / Endommagé | [●] |
|
|
||||||
| Pare-brise | Bon / Impact / Fissure | [●] |
|
|
||||||
| Vitres | Bon / Endommagé | [●] |
|
|
||||||
| Pneus | Bon / Usé / Endommagé | [●] |
|
|
||||||
| Jantes | Bon / Rayé / Endommagé | [●] |
|
|
||||||
| Intérieur | Bon / Taché / Endommagé | [●] |
|
|
||||||
| Sièges | Bon / Taché / Déchiré | [●] |
|
|
||||||
| Tableau de bord | Bon / Endommagé | [●] |
|
|
||||||
| Climatisation | Fonctionne / Ne fonctionne pas | [●] |
|
|
||||||
| Feux | Fonctionnent / Défaut | [●] |
|
|
||||||
| Roue de secours | Présente / Absente | [●] |
|
|
||||||
| Outillage | Présent / Absent | [●] |
|
|
||||||
| Carte grise | Présente / Absente | [●] |
|
|
||||||
| Assurance | Présente / Absente | [●] |
|
|
||||||
| Clés | [Nombre] | [●] |
|
|
||||||
|
|
||||||
**Photos prises au départ :** Oui / Non
|
|
||||||
**Nombre de photos :** [●]
|
|
||||||
|
|
||||||
**Signature du Loueur :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
**Signature du Locataire :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Annexe 2 — État des lieux de retour
|
|
||||||
|
|
||||||
**Contrat n° :** [●]
|
|
||||||
**Date :** [●]
|
|
||||||
**Heure :** [●]
|
|
||||||
**Lieu :** [●]
|
|
||||||
|
|
||||||
**Kilométrage retour :** [●] km
|
|
||||||
**Kilométrage parcouru :** [●] km
|
|
||||||
**Kilométrage inclus :** [●] km
|
|
||||||
**Kilométrage supplémentaire :** [●] km
|
|
||||||
**Montant km supplémentaires :** [●] MAD
|
|
||||||
|
|
||||||
**Carburant retour :** [●]
|
|
||||||
**Carburant manquant :** Oui / Non
|
|
||||||
**Montant carburant :** [●] MAD
|
|
||||||
|
|
||||||
| Élément | État au retour | Nouveau dommage ? | Observations |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Carrosserie avant | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
|
||||||
| Carrosserie arrière | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
|
||||||
| Côté droit | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
|
||||||
| Côté gauche | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
|
||||||
| Pare-brise | Bon / Impact / Fissure | Oui / Non | [●] |
|
|
||||||
| Vitres | Bon / Endommagé | Oui / Non | [●] |
|
|
||||||
| Pneus | Bon / Usé / Endommagé | Oui / Non | [●] |
|
|
||||||
| Jantes | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
|
||||||
| Intérieur | Bon / Taché / Endommagé | Oui / Non | [●] |
|
|
||||||
| Sièges | Bon / Taché / Déchiré | Oui / Non | [●] |
|
|
||||||
| Documents | Complets / Manquants | Oui / Non | [●] |
|
|
||||||
| Clés | Restituées / Manquantes | Oui / Non | [●] |
|
|
||||||
|
|
||||||
## Frais constatés au retour
|
|
||||||
|
|
||||||
| Frais | Montant |
|
|
||||||
|---|---:|
|
|
||||||
| Dommages | [●] MAD |
|
|
||||||
| Franchise assurance | [●] MAD |
|
|
||||||
| Kilométrage supplémentaire | [●] MAD |
|
|
||||||
| Carburant | [●] MAD |
|
|
||||||
| Nettoyage | [●] MAD |
|
|
||||||
| Retard | [●] MAD |
|
|
||||||
| Autres frais | [●] MAD |
|
|
||||||
|
|
||||||
**Total à retenir sur la caution : [●] MAD**
|
|
||||||
**Solde de caution à restituer : [●] MAD**
|
|
||||||
|
|
||||||
**Photos prises au retour :** Oui / Non
|
|
||||||
**Nombre de photos :** [●]
|
|
||||||
|
|
||||||
**Signature du Loueur :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
**Signature du Locataire :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Checklist pratique avant signature
|
|
||||||
|
|
||||||
- Ne laisser aucun champ `[●]` vide dans la version signée.
|
|
||||||
- Faire parapher chaque page par les deux Parties.
|
|
||||||
- Joindre la copie CIN ou passeport du Locataire.
|
|
||||||
- Joindre la copie du permis de conduire.
|
|
||||||
- Photographier le compteur et le niveau de carburant au départ et au retour.
|
|
||||||
- Photographier toutes les faces du véhicule au départ et au retour.
|
|
||||||
- Écrire clairement le montant de la caution.
|
|
||||||
- Mentionner la compagnie d’assurance, le numéro de police et la franchise.
|
|
||||||
- Conserver une preuve de paiement.
|
|
||||||
- Conserver les états des lieux signés.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Note importante
|
|
||||||
|
|
||||||
Ce modèle est un document pratique destiné à structurer une location de voiture au Maroc. Il doit être adapté à l’activité réelle du Loueur, à ses conditions d’assurance, à son statut fiscal et aux règles applicables. Pour un usage commercial régulier, une validation par un juriste ou avocat marocain est recommandée.
|
|
||||||
@@ -1,634 +0,0 @@
|
|||||||
# Contrat de Location de Véhicule
|
|
||||||
|
|
||||||
**Contrat n° :** [●]
|
|
||||||
**Date de signature :** [●]
|
|
||||||
**Lieu de signature :** [Ville, Maroc]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Parties au contrat
|
|
||||||
|
|
||||||
Entre les soussignés :
|
|
||||||
|
|
||||||
### Le Loueur / Agence
|
|
||||||
|
|
||||||
**Nom commercial :** [●]
|
|
||||||
**Raison sociale :** [●]
|
|
||||||
**Forme juridique :** [●]
|
|
||||||
**RC n° :** [●]
|
|
||||||
**ICE n° :** [●]
|
|
||||||
**IF n° :** [●]
|
|
||||||
**Agrément / Autorisation d’exercice n° :** [●]
|
|
||||||
**Date de délivrance de l’agrément :** [●]
|
|
||||||
**Autorité délivrante :** [●]
|
|
||||||
**Adresse :** [●]
|
|
||||||
**Téléphone :** [●]
|
|
||||||
**Email :** [●]
|
|
||||||
**Représenté par :** [Nom, prénom, fonction]
|
|
||||||
|
|
||||||
Ci-après désigné **« le Loueur »**,
|
|
||||||
|
|
||||||
Et :
|
|
||||||
|
|
||||||
### Le Locataire / Conducteur principal
|
|
||||||
|
|
||||||
**Nom :** [●]
|
|
||||||
**Prénom :** [●]
|
|
||||||
**Date de naissance :** [●]
|
|
||||||
**Nationalité :** [●]
|
|
||||||
**CIN / Passeport n° :** [●]
|
|
||||||
**Adresse complète :** [●]
|
|
||||||
**Téléphone :** [●]
|
|
||||||
**Email :** [●]
|
|
||||||
|
|
||||||
Ci-après désigné **« le Locataire »**.
|
|
||||||
|
|
||||||
Le Loueur et le Locataire sont ensemble désignés **« les Parties »**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Responsable légal et conformité réglementaire du Loueur
|
|
||||||
|
|
||||||
Le Loueur déclare exercer l’activité de location de véhicules sans chauffeur conformément à la réglementation marocaine applicable.
|
|
||||||
|
|
||||||
La personne responsable de l’activité est :
|
|
||||||
|
|
||||||
**Nom et prénom :** [●]
|
|
||||||
**Qualité :** [Dirigeant / Actionnaire / Salarié]
|
|
||||||
**Pièce d’identité n° :** [●]
|
|
||||||
**Qualification / diplôme / expérience :** [●]
|
|
||||||
**Téléphone :** [●]
|
|
||||||
**Email :** [●]
|
|
||||||
|
|
||||||
Cette personne est responsable du suivi des contrats, de l’entretien des véhicules, de leur conformité aux normes de sécurité routière et du respect des obligations administratives applicables.
|
|
||||||
|
|
||||||
Le Loueur déclare notamment :
|
|
||||||
|
|
||||||
- disposer des autorisations administratives requises pour l’exercice de l’activité ;
|
|
||||||
- être régulièrement immatriculé auprès des administrations compétentes ;
|
|
||||||
- disposer d’un siège social ou local professionnel déclaré ;
|
|
||||||
- respecter les obligations sociales, fiscales et administratives applicables ;
|
|
||||||
- exploiter une flotte conforme aux seuils et conditions réglementaires applicables ;
|
|
||||||
- maintenir les véhicules loués en état conforme aux normes de sécurité routière.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Permis de conduire
|
|
||||||
|
|
||||||
Le Locataire déclare être titulaire d’un permis de conduire valide :
|
|
||||||
|
|
||||||
**Numéro du permis :** [●]
|
|
||||||
**Catégorie :** [B / autre]
|
|
||||||
**Date de délivrance :** [●]
|
|
||||||
**Date d’expiration :** [●]
|
|
||||||
**Pays de délivrance :** [●]
|
|
||||||
**Permis international, le cas échéant :** [Oui / Non, n° ●]
|
|
||||||
|
|
||||||
Le Locataire garantit que son permis est valide pendant toute la durée de location et qu’il n’est frappé d’aucune suspension, annulation ou restriction incompatible avec la conduite du véhicule loué.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Conducteur(s) autorisé(s)
|
|
||||||
|
|
||||||
Le véhicule ne peut être conduit que par le Locataire et, le cas échéant, par les conducteurs additionnels expressément mentionnés ci-dessous :
|
|
||||||
|
|
||||||
### Conducteur additionnel 1
|
|
||||||
|
|
||||||
**Nom et prénom :** [●]
|
|
||||||
**CIN / Passeport :** [●]
|
|
||||||
**Permis n° :** [●]
|
|
||||||
**Catégorie :** [●]
|
|
||||||
**Date de délivrance :** [●]
|
|
||||||
**Téléphone :** [●]
|
|
||||||
|
|
||||||
Tout conducteur non déclaré est interdit. En cas d’accident, vol, infraction ou dommage causé par un conducteur non autorisé, le Locataire assume l’entière responsabilité des conséquences financières, civiles, pénales et assurantielles.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Véhicule loué
|
|
||||||
|
|
||||||
Le Loueur met à disposition du Locataire le véhicule suivant :
|
|
||||||
|
|
||||||
**Marque :** [●]
|
|
||||||
**Modèle :** [●]
|
|
||||||
**Année :** [●]
|
|
||||||
**Couleur :** [●]
|
|
||||||
**Immatriculation :** [●]
|
|
||||||
**Numéro de châssis :** [●]
|
|
||||||
**Propriétaire du véhicule :** [Loueur / Autre agence / Société de financement / Autre]
|
|
||||||
**Justificatif d’exploitation si le véhicule appartient à un tiers :** [Contrat / Autorisation / Mandat / Autre]
|
|
||||||
**Type de motorisation :** [Thermique / Hybride / Électrique]
|
|
||||||
**Date de première mise en circulation :** [●]
|
|
||||||
**Âge du véhicule à la date de location :** [●]
|
|
||||||
**Véhicule conforme aux limites réglementaires d’exploitation :** [Oui / Non]
|
|
||||||
**Kilométrage au départ :** [●] km
|
|
||||||
**Niveau de carburant au départ :** [●]
|
|
||||||
**Carte grise :** [Oui / Non]
|
|
||||||
**Assurance :** [Oui / Non]
|
|
||||||
**Visite technique :** [Oui / Non / Non applicable]
|
|
||||||
|
|
||||||
Un état des lieux contradictoire au départ et au retour est annexé au présent contrat et en fait partie intégrante.
|
|
||||||
|
|
||||||
Le Loueur déclare que le véhicule mis à disposition est conforme aux conditions réglementaires d’exploitation applicables à sa catégorie, notamment en matière d’âge, d’immatriculation, d’assurance, d’entretien et de sécurité routière.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Entretien et conformité du véhicule
|
|
||||||
|
|
||||||
Le Loueur déclare que le véhicule est régulièrement entretenu, assuré, immatriculé et conforme aux normes de sécurité routière applicables.
|
|
||||||
|
|
||||||
Le Locataire reconnaît avoir reçu un véhicule en état apparent de fonctionnement, sous réserve des observations mentionnées dans l’état des lieux de départ.
|
|
||||||
|
|
||||||
En cas d’anomalie mécanique, voyant d’alerte, bruit anormal, crevaison, panne ou incident susceptible d’affecter la sécurité, le Locataire doit cesser l’utilisation du véhicule dès que les conditions de sécurité le permettent et informer immédiatement le Loueur.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Durée de location
|
|
||||||
|
|
||||||
La location commence le :
|
|
||||||
|
|
||||||
**Date :** [●]
|
|
||||||
**Heure :** [●]
|
|
||||||
**Lieu de prise en charge :** [●]
|
|
||||||
|
|
||||||
La location prend fin le :
|
|
||||||
|
|
||||||
**Date :** [●]
|
|
||||||
**Heure :** [●]
|
|
||||||
**Lieu de restitution :** [●]
|
|
||||||
|
|
||||||
Toute prolongation doit être acceptée préalablement par écrit par le Loueur. À défaut, le véhicule sera considéré comme conservé sans autorisation, et le Loueur pourra facturer les jours supplémentaires, pénalités de retard et frais éventuels.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Prix de location et modalités de paiement
|
|
||||||
|
|
||||||
Le prix de location est fixé comme suit :
|
|
||||||
|
|
||||||
| Désignation | Montant |
|
|
||||||
|---|---:|
|
|
||||||
| Tarif journalier HT | [●] MAD |
|
|
||||||
| Nombre de jours | [●] |
|
|
||||||
| Sous-total HT | [●] MAD |
|
|
||||||
| TVA applicable | [●] % |
|
|
||||||
| Montant TVA | [●] MAD |
|
|
||||||
| Total TTC | [●] MAD |
|
|
||||||
| Conducteur additionnel | [●] MAD |
|
|
||||||
| Livraison / récupération | [●] MAD |
|
|
||||||
| Siège enfant | [●] MAD |
|
|
||||||
| GPS / accessoires | [●] MAD |
|
|
||||||
| Autres frais | [●] MAD |
|
|
||||||
|
|
||||||
**Montant total à payer TTC : [●] MAD**
|
|
||||||
|
|
||||||
**Mode de paiement :** [Espèces / carte bancaire / virement / chèque / autre]
|
|
||||||
**Date de paiement :** [●]
|
|
||||||
**Référence de paiement :** [●]
|
|
||||||
|
|
||||||
Le Locataire reconnaît avoir reçu une information claire sur le prix total TTC avant la signature du contrat.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Dépôt de garantie / caution
|
|
||||||
|
|
||||||
Le Locataire verse au Loueur un dépôt de garantie de :
|
|
||||||
|
|
||||||
**Montant : [●] MAD**
|
|
||||||
|
|
||||||
**Forme de la caution :** [Préautorisation bancaire / espèces / chèque / autre]
|
|
||||||
**Date de constitution :** [●]
|
|
||||||
**Conditions de libération :** [●]
|
|
||||||
|
|
||||||
Le dépôt de garantie garantit notamment :
|
|
||||||
|
|
||||||
- les dommages non couverts par l’assurance ;
|
|
||||||
- la franchise d’assurance ;
|
|
||||||
- les kilomètres supplémentaires ;
|
|
||||||
- le carburant manquant ;
|
|
||||||
- les frais de nettoyage exceptionnel ;
|
|
||||||
- les contraventions, amendes, péages ou frais administratifs ;
|
|
||||||
- les retards de restitution ;
|
|
||||||
- la perte de documents, clés, accessoires ou équipements.
|
|
||||||
|
|
||||||
La caution sera restituée après vérification de l’état du véhicule, du kilométrage, du carburant, des éventuelles infractions et des sommes restant dues.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Assurance
|
|
||||||
|
|
||||||
Le véhicule est assuré auprès de :
|
|
||||||
|
|
||||||
**Compagnie d’assurance :** [●]
|
|
||||||
**Numéro de police :** [●]
|
|
||||||
**Type de couverture :** [Responsabilité civile / Tous risques / autre]
|
|
||||||
**Franchise applicable :** [●] MAD
|
|
||||||
**Assistance :** [Oui / Non]
|
|
||||||
**Numéro d’assistance :** [●]
|
|
||||||
|
|
||||||
Le Locataire reconnaît avoir été informé des garanties, exclusions et franchises applicables.
|
|
||||||
|
|
||||||
Sont notamment exclus de la couverture, sauf stipulation contraire de l’assureur :
|
|
||||||
|
|
||||||
- conduite par une personne non autorisée ;
|
|
||||||
- conduite sans permis valide ;
|
|
||||||
- conduite sous l’emprise d’alcool, stupéfiants ou substances interdites ;
|
|
||||||
- usage du véhicule hors des voies autorisées ;
|
|
||||||
- participation à des courses, essais ou compétitions ;
|
|
||||||
- négligence grave ;
|
|
||||||
- fausse déclaration ;
|
|
||||||
- absence de déclaration d’accident dans les délais ;
|
|
||||||
- vol avec clés laissées dans le véhicule ;
|
|
||||||
- transport illicite de personnes ou de marchandises.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Kilométrage
|
|
||||||
|
|
||||||
**Kilométrage inclus :** [●] km par jour / [●] km pour toute la durée de location.
|
|
||||||
**Kilométrage au départ :** [●] km.
|
|
||||||
**Kilométrage au retour :** [●] km.
|
|
||||||
|
|
||||||
Tout kilomètre supplémentaire sera facturé au tarif de :
|
|
||||||
|
|
||||||
**[●] MAD TTC par kilomètre supplémentaire.**
|
|
||||||
|
|
||||||
Le kilométrage fait foi sur la base du compteur du véhicule, constaté dans l’état des lieux de départ et de retour.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Utilisation du véhicule
|
|
||||||
|
|
||||||
Le Locataire s’engage à utiliser le véhicule avec prudence, conformément à sa destination normale et à la législation marocaine.
|
|
||||||
|
|
||||||
Il est interdit notamment :
|
|
||||||
|
|
||||||
- de sous-louer le véhicule ;
|
|
||||||
- de transporter des marchandises dangereuses ou illicites ;
|
|
||||||
- d’utiliser le véhicule pour des activités illégales ;
|
|
||||||
- de participer à des compétitions ou essais sportifs ;
|
|
||||||
- de tracter un autre véhicule sans autorisation écrite ;
|
|
||||||
- de circuler hors du territoire marocain sans autorisation écrite du Loueur ;
|
|
||||||
- de modifier le véhicule ou ses équipements ;
|
|
||||||
- de fumer dans le véhicule si l’agence l’interdit ;
|
|
||||||
- de transporter un nombre de passagers supérieur à celui autorisé.
|
|
||||||
|
|
||||||
Le Locataire est responsable des infractions au Code de la route commises pendant la période de location.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Restitution du véhicule
|
|
||||||
|
|
||||||
Le Locataire doit restituer le véhicule à la date, à l’heure et au lieu prévus au contrat, dans l’état où il lui a été remis, sous réserve de l’usure normale.
|
|
||||||
|
|
||||||
Le véhicule doit être restitué avec :
|
|
||||||
|
|
||||||
- le même niveau de carburant qu’au départ ;
|
|
||||||
- les papiers du véhicule ;
|
|
||||||
- les clés ;
|
|
||||||
- les accessoires et équipements remis ;
|
|
||||||
- un état de propreté normal ;
|
|
||||||
- aucun dommage nouveau non déclaré.
|
|
||||||
|
|
||||||
En cas de retard, le Loueur pourra facturer :
|
|
||||||
|
|
||||||
- [●] MAD par heure de retard ; ou
|
|
||||||
- une journée supplémentaire au tarif contractuel au-delà de [●] heures de retard.
|
|
||||||
|
|
||||||
En cas de carburant manquant, les frais seront facturés selon le coût réel augmenté de frais de service de [●] MAD.
|
|
||||||
|
|
||||||
En cas de salissure excessive, le Loueur pourra facturer des frais de nettoyage de [●] MAD à [●] MAD selon l’état du véhicule.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. État des lieux départ et retour
|
|
||||||
|
|
||||||
Les Parties établissent un état des lieux au départ et au retour. Celui-ci peut être accompagné de photographies datées du véhicule, du compteur, du carburant, de la carrosserie, de l’intérieur, des pneus, des vitres et des équipements.
|
|
||||||
|
|
||||||
Les éléments à contrôler comprennent notamment :
|
|
||||||
|
|
||||||
- Kilométrage compteur ;
|
|
||||||
- Niveau de carburant ;
|
|
||||||
- Carrosserie ;
|
|
||||||
- Pare-chocs ;
|
|
||||||
- Rétroviseurs ;
|
|
||||||
- Phares et feux ;
|
|
||||||
- Pare-brise et vitres ;
|
|
||||||
- Pneus et jantes ;
|
|
||||||
- Intérieur et sièges ;
|
|
||||||
- Tableau de bord ;
|
|
||||||
- Roue de secours ;
|
|
||||||
- Outillage ;
|
|
||||||
- Documents du véhicule ;
|
|
||||||
- Clés et accessoires.
|
|
||||||
|
|
||||||
Tout dommage constaté au retour et non mentionné dans l’état des lieux de départ pourra être facturé au Locataire, sous réserve des garanties d’assurance applicables.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Accident, panne, vol ou sinistre
|
|
||||||
|
|
||||||
En cas d’accident, panne, vol, tentative de vol, incendie ou tout autre sinistre, le Locataire doit immédiatement :
|
|
||||||
|
|
||||||
- informer le Loueur par téléphone et par écrit ;
|
|
||||||
- prévenir les autorités compétentes si nécessaire ;
|
|
||||||
- établir un constat amiable en cas d’accident ;
|
|
||||||
- obtenir un procès-verbal ou document officiel en cas de vol, blessure, délit ou dommage important ;
|
|
||||||
- ne pas abandonner le véhicule sans autorisation du Loueur ;
|
|
||||||
- ne pas reconnaître de responsabilité sans accord préalable du Loueur ou de l’assureur.
|
|
||||||
|
|
||||||
Le Locataire doit transmettre au Loueur tous les documents utiles dans un délai maximum de **48 heures** à compter du sinistre.
|
|
||||||
|
|
||||||
En cas de non-respect de cette procédure, le Locataire pourra être tenu responsable des conséquences financières, notamment si l’assureur refuse sa garantie.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Responsabilité du Locataire
|
|
||||||
|
|
||||||
Le Locataire est responsable :
|
|
||||||
|
|
||||||
- des dommages causés au véhicule pendant la période de location ;
|
|
||||||
- des dommages causés aux tiers dans les limites prévues par la loi et l’assurance ;
|
|
||||||
- des amendes, contraventions, frais de fourrière, péages et frais administratifs ;
|
|
||||||
- des frais liés à une mauvaise utilisation du véhicule ;
|
|
||||||
- des pertes de clés, documents ou accessoires ;
|
|
||||||
- des dommages non couverts par l’assurance ;
|
|
||||||
- de la franchise prévue au contrat d’assurance.
|
|
||||||
|
|
||||||
Le Locataire reste responsable jusqu’à la restitution effective du véhicule au Loueur et la signature de l’état des lieux de retour.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Infractions, amendes et frais administratifs
|
|
||||||
|
|
||||||
Toutes les infractions commises pendant la durée de location sont à la charge du Locataire.
|
|
||||||
|
|
||||||
Le Loueur pourra communiquer l’identité du Locataire aux autorités compétentes et facturer au Locataire :
|
|
||||||
|
|
||||||
- le montant des amendes ;
|
|
||||||
- les frais de dossier ;
|
|
||||||
- les frais de fourrière ;
|
|
||||||
- les frais de notification ;
|
|
||||||
- tout coût lié au traitement administratif de l’infraction.
|
|
||||||
|
|
||||||
**Frais administratifs par infraction : [●] MAD TTC.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. Annulation, non-présentation et résiliation
|
|
||||||
|
|
||||||
En cas d’annulation par le Locataire :
|
|
||||||
|
|
||||||
- plus de [●] heures avant le départ : [conditions de remboursement] ;
|
|
||||||
- moins de [●] heures avant le départ : [conditions] ;
|
|
||||||
- non-présentation : [conditions].
|
|
||||||
|
|
||||||
Le Loueur peut résilier immédiatement le contrat, sans indemnité pour le Locataire, en cas de :
|
|
||||||
|
|
||||||
- fausse déclaration ;
|
|
||||||
- permis invalide ;
|
|
||||||
- défaut de paiement ;
|
|
||||||
- utilisation interdite du véhicule ;
|
|
||||||
- conduite dangereuse ;
|
|
||||||
- non-respect grave du présent contrat ;
|
|
||||||
- suspicion légitime de fraude ou d’usage illicite.
|
|
||||||
|
|
||||||
Dans ce cas, le Locataire doit restituer immédiatement le véhicule.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 19. Indisponibilité administrative, réglementaire ou technique
|
|
||||||
|
|
||||||
En cas d’indisponibilité du véhicule ou d’impossibilité d’exécuter la location pour une raison administrative, réglementaire, technique ou de sécurité, le Loueur pourra proposer au Locataire un véhicule équivalent, sous réserve de disponibilité.
|
|
||||||
|
|
||||||
À défaut de véhicule équivalent accepté par le Locataire, les sommes payées au titre de la période non exécutée seront remboursées, sans préjudice des droits légalement reconnus aux Parties.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 20. Données personnelles
|
|
||||||
|
|
||||||
Le Locataire autorise le Loueur à collecter et conserver les données nécessaires à l’exécution du présent contrat, notamment son identité, ses coordonnées, son permis de conduire, les informations de paiement, les documents liés au véhicule, les états des lieux et les données relatives aux éventuels incidents.
|
|
||||||
|
|
||||||
Ces données sont utilisées pour :
|
|
||||||
|
|
||||||
- l’exécution du contrat ;
|
|
||||||
- la facturation ;
|
|
||||||
- la gestion des sinistres ;
|
|
||||||
- la gestion des infractions ;
|
|
||||||
- les obligations comptables, fiscales et légales ;
|
|
||||||
- la défense des droits du Loueur en cas de litige.
|
|
||||||
|
|
||||||
Le Loueur s’engage à protéger les données personnelles du Locataire, à limiter leur accès aux personnes habilitées et à les conserver uniquement pendant la durée nécessaire aux finalités prévues et aux obligations légales applicables.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 21. Documents annexés
|
|
||||||
|
|
||||||
Les documents suivants sont annexés au présent contrat :
|
|
||||||
|
|
||||||
- Copie CIN ou passeport du Locataire ;
|
|
||||||
- Copie du permis de conduire ;
|
|
||||||
- Copie permis international, le cas échéant ;
|
|
||||||
- État des lieux de départ ;
|
|
||||||
- État des lieux de retour ;
|
|
||||||
- Photos du véhicule au départ ;
|
|
||||||
- Photos du véhicule au retour ;
|
|
||||||
- Copie de la carte grise ;
|
|
||||||
- Copie de l’attestation d’assurance ;
|
|
||||||
- Justificatif autorisant l’exploitation du véhicule par le Loueur, si le véhicule n’appartient pas directement au Loueur ;
|
|
||||||
- Reçu de paiement ;
|
|
||||||
- Justificatif de dépôt de garantie ;
|
|
||||||
- Conditions générales de location, le cas échéant.
|
|
||||||
|
|
||||||
Les annexes font partie intégrante du présent contrat.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 22. Loi applicable et juridiction compétente
|
|
||||||
|
|
||||||
Le présent contrat est soumis au droit marocain.
|
|
||||||
|
|
||||||
En cas de litige relatif à sa validité, son interprétation, son exécution ou sa résiliation, les Parties s’efforceront de trouver une solution amiable.
|
|
||||||
|
|
||||||
À défaut d’accord amiable, le litige sera porté devant le tribunal compétent du ressort de :
|
|
||||||
|
|
||||||
**[Ville du siège de l’agence / tribunal compétent]**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 23. Déclaration finale
|
|
||||||
|
|
||||||
Le Locataire déclare :
|
|
||||||
|
|
||||||
- avoir lu et compris le présent contrat ;
|
|
||||||
- avoir reçu toutes les informations utiles avant signature ;
|
|
||||||
- avoir vérifié l’état du véhicule au départ ;
|
|
||||||
- être titulaire d’un permis valide ;
|
|
||||||
- s’engager à respecter les conditions du présent contrat ;
|
|
||||||
- accepter les prix, caution, franchises, pénalités et frais indiqués.
|
|
||||||
|
|
||||||
**Fait à :** [●]
|
|
||||||
**Le :** [●]
|
|
||||||
**En deux exemplaires originaux.**
|
|
||||||
|
|
||||||
Chaque page du présent contrat doit être paraphée par les deux Parties.
|
|
||||||
|
|
||||||
### Signature du Loueur
|
|
||||||
|
|
||||||
**Nom :** [●]
|
|
||||||
**Signature et cachet :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
### Signature du Locataire
|
|
||||||
|
|
||||||
**Nom :** [●]
|
|
||||||
**Signature :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Annexe 1 — État des lieux de départ
|
|
||||||
|
|
||||||
**Contrat n° :** [●]
|
|
||||||
**Date :** [●]
|
|
||||||
**Heure :** [●]
|
|
||||||
**Lieu :** [●]
|
|
||||||
|
|
||||||
**Véhicule :** [Marque / Modèle]
|
|
||||||
**Immatriculation :** [●]
|
|
||||||
**Kilométrage départ :** [●] km
|
|
||||||
**Carburant départ :** [●]
|
|
||||||
|
|
||||||
| Élément | État au départ | Observations |
|
|
||||||
|---|---|---|
|
|
||||||
| Carrosserie avant | Bon / Rayé / Endommagé | [●] |
|
|
||||||
| Carrosserie arrière | Bon / Rayé / Endommagé | [●] |
|
|
||||||
| Côté droit | Bon / Rayé / Endommagé | [●] |
|
|
||||||
| Côté gauche | Bon / Rayé / Endommagé | [●] |
|
|
||||||
| Pare-brise | Bon / Impact / Fissure | [●] |
|
|
||||||
| Vitres | Bon / Endommagé | [●] |
|
|
||||||
| Pneus | Bon / Usé / Endommagé | [●] |
|
|
||||||
| Jantes | Bon / Rayé / Endommagé | [●] |
|
|
||||||
| Intérieur | Bon / Taché / Endommagé | [●] |
|
|
||||||
| Sièges | Bon / Taché / Déchiré | [●] |
|
|
||||||
| Tableau de bord | Bon / Endommagé | [●] |
|
|
||||||
| Climatisation | Fonctionne / Ne fonctionne pas | [●] |
|
|
||||||
| Feux | Fonctionnent / Défaut | [●] |
|
|
||||||
| Roue de secours | Présente / Absente | [●] |
|
|
||||||
| Outillage | Présent / Absent | [●] |
|
|
||||||
| Carte grise | Présente / Absente | [●] |
|
|
||||||
| Assurance | Présente / Absente | [●] |
|
|
||||||
| Clés | [Nombre] | [●] |
|
|
||||||
|
|
||||||
**Photos prises au départ :** Oui / Non
|
|
||||||
**Nombre de photos :** [●]
|
|
||||||
|
|
||||||
**Signature du Loueur :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
**Signature du Locataire :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Annexe 2 — État des lieux de retour
|
|
||||||
|
|
||||||
**Contrat n° :** [●]
|
|
||||||
**Date :** [●]
|
|
||||||
**Heure :** [●]
|
|
||||||
**Lieu :** [●]
|
|
||||||
|
|
||||||
**Kilométrage retour :** [●] km
|
|
||||||
**Kilométrage parcouru :** [●] km
|
|
||||||
**Kilométrage inclus :** [●] km
|
|
||||||
**Kilométrage supplémentaire :** [●] km
|
|
||||||
**Montant km supplémentaires :** [●] MAD
|
|
||||||
|
|
||||||
**Carburant retour :** [●]
|
|
||||||
**Carburant manquant :** Oui / Non
|
|
||||||
**Montant carburant :** [●] MAD
|
|
||||||
|
|
||||||
| Élément | État au retour | Nouveau dommage ? | Observations |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Carrosserie avant | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
|
||||||
| Carrosserie arrière | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
|
||||||
| Côté droit | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
|
||||||
| Côté gauche | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
|
||||||
| Pare-brise | Bon / Impact / Fissure | Oui / Non | [●] |
|
|
||||||
| Vitres | Bon / Endommagé | Oui / Non | [●] |
|
|
||||||
| Pneus | Bon / Usé / Endommagé | Oui / Non | [●] |
|
|
||||||
| Jantes | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
|
||||||
| Intérieur | Bon / Taché / Endommagé | Oui / Non | [●] |
|
|
||||||
| Sièges | Bon / Taché / Déchiré | Oui / Non | [●] |
|
|
||||||
| Documents | Complets / Manquants | Oui / Non | [●] |
|
|
||||||
| Clés | Restituées / Manquantes | Oui / Non | [●] |
|
|
||||||
|
|
||||||
## Frais constatés au retour
|
|
||||||
|
|
||||||
| Frais | Montant |
|
|
||||||
|---|---:|
|
|
||||||
| Dommages | [●] MAD |
|
|
||||||
| Franchise assurance | [●] MAD |
|
|
||||||
| Kilométrage supplémentaire | [●] MAD |
|
|
||||||
| Carburant | [●] MAD |
|
|
||||||
| Nettoyage | [●] MAD |
|
|
||||||
| Retard | [●] MAD |
|
|
||||||
| Autres frais | [●] MAD |
|
|
||||||
|
|
||||||
**Total à retenir sur la caution : [●] MAD**
|
|
||||||
**Solde de caution à restituer : [●] MAD**
|
|
||||||
|
|
||||||
**Photos prises au retour :** Oui / Non
|
|
||||||
**Nombre de photos :** [●]
|
|
||||||
|
|
||||||
**Signature du Loueur :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
**Signature du Locataire :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Checklist pratique avant signature
|
|
||||||
|
|
||||||
- Ne laisser aucun champ `[●]` vide dans la version signée.
|
|
||||||
- Faire parapher chaque page par les deux Parties.
|
|
||||||
- Joindre la copie CIN ou passeport du Locataire.
|
|
||||||
- Joindre la copie du permis de conduire.
|
|
||||||
- Photographier le compteur et le niveau de carburant au départ et au retour.
|
|
||||||
- Photographier toutes les faces du véhicule au départ et au retour.
|
|
||||||
- Écrire clairement le montant de la caution.
|
|
||||||
- Mentionner la compagnie d’assurance, le numéro de police et la franchise.
|
|
||||||
- Conserver une preuve de paiement.
|
|
||||||
- Conserver les états des lieux signés.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Note importante
|
|
||||||
|
|
||||||
Ce modèle est un document pratique destiné à structurer une location de voiture au Maroc. Il doit être adapté à l’activité réelle du Loueur, à ses conditions d’assurance, à son statut fiscal et aux règles applicables. Pour un usage commercial régulier, une validation par un juriste ou avocat marocain est recommandée.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Checklist interne de conformité du Loueur
|
|
||||||
|
|
||||||
Cette checklist est destinée au Loueur et ne remplace pas les documents administratifs obligatoires.
|
|
||||||
|
|
||||||
- Agrément / autorisation d’exercice disponible.
|
|
||||||
- Responsable réglementaire désigné.
|
|
||||||
- Casier judiciaire conforme, si exigé.
|
|
||||||
- Société inscrite à la CNSS, si applicable.
|
|
||||||
- Siège social ou local professionnel justifié.
|
|
||||||
- Capital social conforme au seuil applicable.
|
|
||||||
- Flotte conforme au seuil réglementaire applicable.
|
|
||||||
- Âge des véhicules conforme selon motorisation.
|
|
||||||
- Documents de propriété ou d’exploitation des véhicules disponibles.
|
|
||||||
- Assurance et visite technique valides pour chaque véhicule.
|
|
||||||
- Procédure de déclaration en cas de suspension ou cessation d’activité.
|
|
||||||
|
|
||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
ALTER TYPE "NotificationType" ADD VALUE IF NOT EXISTS 'ACCOUNT_CREATED';
|
||||||
|
|
||||||
|
ALTER TABLE "billing_accounts"
|
||||||
|
ADD COLUMN "preferredLanguage" TEXT NOT NULL DEFAULT 'en';
|
||||||
|
|
||||||
|
ALTER TABLE "notifications"
|
||||||
|
ADD COLUMN "templateKey" TEXT,
|
||||||
|
ADD COLUMN "locale" TEXT NOT NULL DEFAULT 'en';
|
||||||
|
|
||||||
|
CREATE TABLE "notification_templates" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"templateKey" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"channel" "NotificationChannel" NOT NULL,
|
||||||
|
"locale" TEXT NOT NULL,
|
||||||
|
"subject" TEXT,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"requiredVariables" JSONB,
|
||||||
|
"optionalVariables" JSONB,
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "notification_templates_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "notification_templates_templateKey_channel_locale_version_key"
|
||||||
|
ON "notification_templates"("templateKey", "channel", "locale", "version");
|
||||||
|
|
||||||
|
CREATE INDEX "notification_templates_templateKey_channel_locale_isActive_idx"
|
||||||
|
ON "notification_templates"("templateKey", "channel", "locale", "isActive");
|
||||||
|
|
||||||
|
INSERT INTO "notification_templates" (
|
||||||
|
"id",
|
||||||
|
"templateKey",
|
||||||
|
"category",
|
||||||
|
"channel",
|
||||||
|
"locale",
|
||||||
|
"subject",
|
||||||
|
"body",
|
||||||
|
"requiredVariables",
|
||||||
|
"optionalVariables"
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'notif_tpl_account_created_email_en',
|
||||||
|
'account.created',
|
||||||
|
'account',
|
||||||
|
'EMAIL',
|
||||||
|
'en',
|
||||||
|
'Your workspace is ready - RentalDriveGo',
|
||||||
|
E'Hi {{firstName}},\n\nYour RentalDriveGo workspace for {{companyName}} has been created successfully.\nPlan: {{planName}} ({{billingPeriodLabel}})\nCurrency: {{currency}}\nPrimary payment provider: {{paymentProvider}}\nFree trial ends on {{trialEndDate}}.\n\nYour workspace is ready. Sign in with the email and password you chose during signup.\n\nRentalDriveGo',
|
||||||
|
'["firstName","companyName","planName","billingPeriodLabel","currency","paymentProvider","trialEndDate"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_account_created_email_fr',
|
||||||
|
'account.created',
|
||||||
|
'account',
|
||||||
|
'EMAIL',
|
||||||
|
'fr',
|
||||||
|
'Votre espace de travail est pret - RentalDriveGo',
|
||||||
|
E'Bonjour {{firstName}},\n\nVotre espace de travail RentalDriveGo pour {{companyName}} a ete cree avec succes.\nForfait : {{planName}} ({{billingPeriodLabel}})\nDevise : {{currency}}\nFournisseur de paiement principal : {{paymentProvider}}\nLa periode d''essai gratuit se termine le {{trialEndDate}}.\n\nVotre espace de travail est pret. Connectez-vous avec l''e-mail et le mot de passe choisis lors de l''inscription.\n\nRentalDriveGo',
|
||||||
|
'["firstName","companyName","planName","billingPeriodLabel","currency","paymentProvider","trialEndDate"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_account_created_email_ar',
|
||||||
|
'account.created',
|
||||||
|
'account',
|
||||||
|
'EMAIL',
|
||||||
|
'ar',
|
||||||
|
'مساحة عملك جاهزة - RentalDriveGo',
|
||||||
|
E'مرحباً {{firstName}}،\n\nتم إنشاء مساحة عمل RentalDriveGo الخاصة بـ {{companyName}} بنجاح.\nالخطة: {{planName}} ({{billingPeriodLabel}})\nالعملة: {{currency}}\nمزود الدفع الرئيسي: {{paymentProvider}}\nتنتهي الفترة التجريبية المجانية في {{trialEndDate}}.\n\nمساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.\n\nRentalDriveGo',
|
||||||
|
'["firstName","companyName","planName","billingPeriodLabel","currency","paymentProvider","trialEndDate"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_account_created_in_app_en',
|
||||||
|
'account.created',
|
||||||
|
'account',
|
||||||
|
'IN_APP',
|
||||||
|
'en',
|
||||||
|
'Workspace ready',
|
||||||
|
E'Your RentalDriveGo workspace for {{companyName}} is ready. Your free trial ends on {{trialEndDate}}.',
|
||||||
|
'["companyName","trialEndDate"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_account_created_in_app_fr',
|
||||||
|
'account.created',
|
||||||
|
'account',
|
||||||
|
'IN_APP',
|
||||||
|
'fr',
|
||||||
|
'Espace pret',
|
||||||
|
E'Votre espace de travail RentalDriveGo pour {{companyName}} est pret. Votre essai gratuit se termine le {{trialEndDate}}.',
|
||||||
|
'["companyName","trialEndDate"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_account_created_in_app_ar',
|
||||||
|
'account.created',
|
||||||
|
'account',
|
||||||
|
'IN_APP',
|
||||||
|
'ar',
|
||||||
|
'مساحة العمل جاهزة',
|
||||||
|
E'مساحة عمل RentalDriveGo الخاصة بـ {{companyName}} جاهزة. تنتهي الفترة التجريبية المجانية في {{trialEndDate}}.',
|
||||||
|
'["companyName","trialEndDate"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_booking_confirmed_email_en',
|
||||||
|
'booking.confirmed',
|
||||||
|
'reservation',
|
||||||
|
'EMAIL',
|
||||||
|
'en',
|
||||||
|
'Booking confirmed - {{companyName}}',
|
||||||
|
E'Hi {{firstName}},\n\nYour booking with {{companyName}} has been confirmed.\nPickup: {{startDate}}\nReturn: {{endDate}}\nVehicle: {{vehicleName}}\n\nThank you for choosing RentalDriveGo.',
|
||||||
|
'["firstName","companyName","startDate","endDate","vehicleName"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_booking_confirmed_email_fr',
|
||||||
|
'booking.confirmed',
|
||||||
|
'reservation',
|
||||||
|
'EMAIL',
|
||||||
|
'fr',
|
||||||
|
'Reservation confirmee - {{companyName}}',
|
||||||
|
E'Bonjour {{firstName}},\n\nVotre reservation chez {{companyName}} a ete confirmee.\nPrise en charge : {{startDate}}\nRetour : {{endDate}}\nVehicule : {{vehicleName}}\n\nMerci d''avoir choisi RentalDriveGo.',
|
||||||
|
'["firstName","companyName","startDate","endDate","vehicleName"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_booking_confirmed_email_ar',
|
||||||
|
'booking.confirmed',
|
||||||
|
'reservation',
|
||||||
|
'EMAIL',
|
||||||
|
'ar',
|
||||||
|
'تم تأكيد الحجز - {{companyName}}',
|
||||||
|
E'مرحباً {{firstName}}،\n\nتم تأكيد حجزك مع {{companyName}}.\nالاستلام: {{startDate}}\nالإرجاع: {{endDate}}\nالمركبة: {{vehicleName}}\n\nشكراً لاختيار RentalDriveGo.',
|
||||||
|
'["firstName","companyName","startDate","endDate","vehicleName"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_booking_confirmed_in_app_en',
|
||||||
|
'booking.confirmed',
|
||||||
|
'reservation',
|
||||||
|
'IN_APP',
|
||||||
|
'en',
|
||||||
|
'Booking confirmed',
|
||||||
|
E'Your booking with {{companyName}} has been confirmed.',
|
||||||
|
'["companyName"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_booking_confirmed_in_app_fr',
|
||||||
|
'booking.confirmed',
|
||||||
|
'reservation',
|
||||||
|
'IN_APP',
|
||||||
|
'fr',
|
||||||
|
'Reservation confirmee',
|
||||||
|
E'Votre reservation chez {{companyName}} a ete confirmee.',
|
||||||
|
'["companyName"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_booking_confirmed_in_app_ar',
|
||||||
|
'booking.confirmed',
|
||||||
|
'reservation',
|
||||||
|
'IN_APP',
|
||||||
|
'ar',
|
||||||
|
'تم تأكيد الحجز',
|
||||||
|
E'تم تأكيد حجزك مع {{companyName}}.',
|
||||||
|
'["companyName"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_subscription_trial_ending_in_app_en',
|
||||||
|
'subscription.trial_ending',
|
||||||
|
'subscription',
|
||||||
|
'IN_APP',
|
||||||
|
'en',
|
||||||
|
'Your trial ends soon',
|
||||||
|
E'Your free trial ends on {{trialEndDate}}. Add a payment method to keep access.',
|
||||||
|
'["trialEndDate"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_subscription_trial_ending_in_app_fr',
|
||||||
|
'subscription.trial_ending',
|
||||||
|
'subscription',
|
||||||
|
'IN_APP',
|
||||||
|
'fr',
|
||||||
|
'Votre essai se termine bientot',
|
||||||
|
E'Votre essai gratuit se termine le {{trialEndDate}}. Ajoutez un moyen de paiement pour conserver l''acces.',
|
||||||
|
'["trialEndDate"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'notif_tpl_subscription_trial_ending_in_app_ar',
|
||||||
|
'subscription.trial_ending',
|
||||||
|
'subscription',
|
||||||
|
'IN_APP',
|
||||||
|
'ar',
|
||||||
|
'ستنتهي الفترة التجريبية قريباً',
|
||||||
|
E'تنتهي فترتك التجريبية المجانية في {{trialEndDate}}. أضف وسيلة دفع للحفاظ على الوصول.',
|
||||||
|
'["trialEndDate"]'::jsonb,
|
||||||
|
'[]'::jsonb
|
||||||
|
);
|
||||||
@@ -377,6 +377,7 @@ enum AdminAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationType {
|
enum NotificationType {
|
||||||
|
ACCOUNT_CREATED
|
||||||
NEW_BOOKING
|
NEW_BOOKING
|
||||||
BOOKING_CANCELLED
|
BOOKING_CANCELLED
|
||||||
PAYMENT_RECEIVED
|
PAYMENT_RECEIVED
|
||||||
@@ -540,6 +541,7 @@ model BillingAccount {
|
|||||||
isPrimary Boolean @default(true)
|
isPrimary Boolean @default(true)
|
||||||
legalName String
|
legalName String
|
||||||
billingEmail String
|
billingEmail String
|
||||||
|
preferredLanguage String @default("en")
|
||||||
billingAddress Json?
|
billingAddress Json?
|
||||||
taxId String?
|
taxId String?
|
||||||
taxExempt Boolean @default(false)
|
taxExempt Boolean @default(false)
|
||||||
@@ -1218,6 +1220,8 @@ model Notification {
|
|||||||
body String
|
body String
|
||||||
data Json?
|
data Json?
|
||||||
channel NotificationChannel
|
channel NotificationChannel
|
||||||
|
templateKey String?
|
||||||
|
locale String @default("en")
|
||||||
status NotificationStatus @default(PENDING)
|
status NotificationStatus @default(PENDING)
|
||||||
sentAt DateTime?
|
sentAt DateTime?
|
||||||
failReason String?
|
failReason String?
|
||||||
@@ -1231,6 +1235,26 @@ model Notification {
|
|||||||
@@map("notifications")
|
@@map("notifications")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model NotificationTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
templateKey String
|
||||||
|
category String
|
||||||
|
channel NotificationChannel
|
||||||
|
locale String
|
||||||
|
subject String?
|
||||||
|
body String
|
||||||
|
requiredVariables Json?
|
||||||
|
optionalVariables Json?
|
||||||
|
version Int @default(1)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([templateKey, channel, locale, version])
|
||||||
|
@@index([templateKey, channel, locale, isActive])
|
||||||
|
@@map("notification_templates")
|
||||||
|
}
|
||||||
|
|
||||||
model NotificationPreference {
|
model NotificationPreference {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
employeeId String?
|
employeeId String?
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type AdminRole = 'SUPER_ADMIN' | 'ADMIN' | 'SUPPORT' | 'FINANCE' | 'VIEWE
|
|||||||
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
||||||
export type LicenseStatus = 'PENDING' | 'VALID' | 'EXPIRING' | 'APPROVED' | 'DENIED' | 'EXPIRED'
|
export type LicenseStatus = 'PENDING' | 'VALID' | 'EXPIRING' | 'APPROVED' | 'DENIED' | 'EXPIRED'
|
||||||
export type NotificationType =
|
export type NotificationType =
|
||||||
|
| 'ACCOUNT_CREATED'
|
||||||
| 'NEW_BOOKING'
|
| 'NEW_BOOKING'
|
||||||
| 'BOOKING_CANCELLED'
|
| 'BOOKING_CANCELLED'
|
||||||
| 'PAYMENT_RECEIVED'
|
| 'PAYMENT_RECEIVED'
|
||||||
|
|||||||
Reference in New Issue
Block a user