fixing platform admin
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
interface SavedCompany {
|
||||
id: string
|
||||
brand?: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
logoUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface RenterProfile {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone?: string | null
|
||||
savedCompanies?: SavedCompany[]
|
||||
}
|
||||
|
||||
type Status = 'loading' | 'ready' | 'error'
|
||||
|
||||
export default function RenterDashboardPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
loadProfile: 'Could not load profile.',
|
||||
reachServer: 'Could not reach the server.',
|
||||
backToExplore: 'Back to explore',
|
||||
renterDashboard: 'Renter Dashboard',
|
||||
welcome: 'Welcome back,',
|
||||
exploreVehicles: 'Explore vehicles',
|
||||
logout: 'Log out',
|
||||
cards: [
|
||||
['Profile', 'Review your account details and preferences.'],
|
||||
['Saved companies', 'Jump back into companies you bookmarked.'],
|
||||
['Notifications', 'See booking updates and in-app alerts.'],
|
||||
],
|
||||
yourProfile: 'Your profile',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
savedCompanies: 'Saved companies',
|
||||
noneSaved: 'None saved yet',
|
||||
saved: 'saved',
|
||||
saveCompaniesPrompt: 'Save companies you like while browsing the marketplace.',
|
||||
startExploring: 'Start exploring',
|
||||
rentalCompany: 'Rental company',
|
||||
},
|
||||
fr: {
|
||||
loadProfile: 'Impossible de charger le profil.',
|
||||
reachServer: 'Impossible de joindre le serveur.',
|
||||
backToExplore: 'Retour à explorer',
|
||||
renterDashboard: 'Espace client',
|
||||
welcome: 'Bon retour,',
|
||||
exploreVehicles: 'Explorer les véhicules',
|
||||
logout: 'Déconnexion',
|
||||
cards: [
|
||||
['Profil', 'Consultez vos informations et préférences.'],
|
||||
['Entreprises sauvegardées', 'Revenez vers les entreprises que vous avez enregistrées.'],
|
||||
['Notifications', 'Consultez les mises à jour de réservation et les alertes.'],
|
||||
],
|
||||
yourProfile: 'Votre profil',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'Email',
|
||||
phone: 'Téléphone',
|
||||
savedCompanies: 'Entreprises sauvegardées',
|
||||
noneSaved: 'Aucune pour le moment',
|
||||
saved: 'sauvegardées',
|
||||
saveCompaniesPrompt: 'Enregistrez les entreprises que vous aimez en parcourant la marketplace.',
|
||||
startExploring: 'Commencer à explorer',
|
||||
rentalCompany: 'Société de location',
|
||||
},
|
||||
ar: {
|
||||
loadProfile: 'تعذر تحميل الملف الشخصي.',
|
||||
reachServer: 'تعذر الوصول إلى الخادم.',
|
||||
backToExplore: 'العودة إلى الاستكشاف',
|
||||
renterDashboard: 'لوحة المستأجر',
|
||||
welcome: 'مرحباً بعودتك،',
|
||||
exploreVehicles: 'استكشف السيارات',
|
||||
logout: 'تسجيل الخروج',
|
||||
cards: [
|
||||
['الملف الشخصي', 'راجع بيانات حسابك وتفضيلاتك.'],
|
||||
['الشركات المحفوظة', 'ارجع بسرعة إلى الشركات التي حفظتها.'],
|
||||
['الإشعارات', 'اطلع على تحديثات الحجز والتنبيهات داخل التطبيق.'],
|
||||
],
|
||||
yourProfile: 'ملفك الشخصي',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
savedCompanies: 'الشركات المحفوظة',
|
||||
noneSaved: 'لا يوجد شيء محفوظ بعد',
|
||||
saved: 'محفوظة',
|
||||
saveCompaniesPrompt: 'احفظ الشركات التي تعجبك أثناء تصفح السوق.',
|
||||
startExploring: 'ابدأ الاستكشاف',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [profile, setProfile] = useState<RenterProfile | null>(null)
|
||||
const [status, setStatus] = useState<Status>('loading')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('renter_token')
|
||||
if (!token) {
|
||||
router.replace('/renter/sign-in')
|
||||
return
|
||||
}
|
||||
|
||||
fetch(`${API_BASE}/auth/renter/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(async (res) => {
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('renter_token')
|
||||
router.replace('/renter/sign-in')
|
||||
} else {
|
||||
setErrorMsg(json?.message ?? dict.loadProfile)
|
||||
setStatus('error')
|
||||
}
|
||||
return
|
||||
}
|
||||
setProfile(json?.data ?? json)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorMsg(dict.reachServer)
|
||||
setStatus('error')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('renter_token')
|
||||
router.push('/explore')
|
||||
}
|
||||
|
||||
// Loading skeleton
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 w-48 rounded-xl bg-stone-200" />
|
||||
<div className="h-40 rounded-2xl bg-stone-200" />
|
||||
<div className="h-40 rounded-2xl bg-stone-200" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell">
|
||||
<div className="card p-8 text-center">
|
||||
<p className="text-sm text-rose-600">{errorMsg}</p>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-4 rounded-full bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white"
|
||||
>
|
||||
{dict.backToExplore}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const savedCompanies = profile?.savedCompanies ?? []
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">
|
||||
{dict.renterDashboard}
|
||||
</p>
|
||||
<h1 className="mt-1 text-3xl font-black tracking-tight text-stone-900">
|
||||
{dict.welcome} {profile?.firstName}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/explore"
|
||||
className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 shadow-sm hover:border-stone-300"
|
||||
>
|
||||
{dict.exploreVehicles}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white hover:bg-stone-700"
|
||||
>
|
||||
{dict.logout}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ href: '/renter/profile', label: dict.cards[0][0], copy: dict.cards[0][1] },
|
||||
{ href: '/renter/saved-companies', label: dict.cards[1][0], copy: dict.cards[1][1] },
|
||||
{ href: '/renter/notifications', label: dict.cards[2][0], copy: dict.cards[2][1] },
|
||||
].map((item) => (
|
||||
<Link key={item.href} href={item.href} className="card p-5 hover:border-amber-300 transition-colors">
|
||||
<p className="text-sm font-semibold text-stone-900">{item.label}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-stone-500">{item.copy}</p>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Profile card */}
|
||||
<section className="card p-8">
|
||||
<h2 className="text-lg font-bold text-stone-900">{dict.yourProfile}</h2>
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{ label: dict.firstName, value: profile?.firstName },
|
||||
{ label: dict.lastName, value: profile?.lastName },
|
||||
{ label: dict.email, value: profile?.email },
|
||||
{ label: dict.phone, value: profile?.phone ?? '—' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1.5 text-sm font-medium text-stone-900 break-all">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Saved companies */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900">{dict.savedCompanies}</h2>
|
||||
<p className="text-sm text-stone-500">
|
||||
{savedCompanies.length === 0
|
||||
? dict.noneSaved
|
||||
: `${savedCompanies.length} ${dict.saved}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{savedCompanies.length === 0 ? (
|
||||
<div className="card mt-4 flex flex-col items-center gap-4 py-16 text-center">
|
||||
<svg
|
||||
className="h-10 w-10 text-stone-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-stone-500">
|
||||
{dict.saveCompaniesPrompt}
|
||||
</p>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="rounded-full bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white hover:bg-stone-700"
|
||||
>
|
||||
{dict.startExploring}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{savedCompanies.map((company) => (
|
||||
<Link
|
||||
key={company.id}
|
||||
href={`/explore/${company.brand?.subdomain ?? company.id}`}
|
||||
className="card flex items-center gap-4 p-5 hover:border-amber-300 transition-colors"
|
||||
>
|
||||
{company.brand?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={company.brand.logoUrl}
|
||||
alt={company.brand.displayName}
|
||||
className="h-10 w-10 rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100 text-amber-700 font-bold text-sm">
|
||||
{company.brand?.displayName?.charAt(0) ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-stone-900">
|
||||
{company.brand?.displayName ?? dict.rentalCompany}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-stone-400">
|
||||
{company.brand?.subdomain ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
<svg
|
||||
className="ml-auto h-4 w-4 shrink-0 text-stone-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
RenterAuthError,
|
||||
loadRenterNotifications,
|
||||
markAllRenterNotificationsRead,
|
||||
markRenterNotificationRead,
|
||||
type RenterNotification,
|
||||
} from '@/lib/renter'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
|
||||
export default function RenterNotificationsPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
load: 'Could not load notifications.',
|
||||
updateOne: 'Could not update the notification.',
|
||||
updateAll: 'Could not update notifications.',
|
||||
renter: 'Renter',
|
||||
title: 'Notifications',
|
||||
markAll: 'Mark all read',
|
||||
back: 'Back to dashboard',
|
||||
loading: 'Loading notifications…',
|
||||
empty: 'No renter notifications yet.',
|
||||
read: 'Read',
|
||||
markRead: 'Mark read',
|
||||
},
|
||||
fr: {
|
||||
load: 'Impossible de charger les notifications.',
|
||||
updateOne: 'Impossible de mettre à jour la notification.',
|
||||
updateAll: 'Impossible de mettre à jour les notifications.',
|
||||
renter: 'Client',
|
||||
title: 'Notifications',
|
||||
markAll: 'Tout marquer comme lu',
|
||||
back: 'Retour au dashboard',
|
||||
loading: 'Chargement des notifications…',
|
||||
empty: 'Aucune notification client pour le moment.',
|
||||
read: 'Lu',
|
||||
markRead: 'Marquer comme lu',
|
||||
},
|
||||
ar: {
|
||||
load: 'تعذر تحميل الإشعارات.',
|
||||
updateOne: 'تعذر تحديث الإشعار.',
|
||||
updateAll: 'تعذر تحديث الإشعارات.',
|
||||
renter: 'المستأجر',
|
||||
title: 'الإشعارات',
|
||||
markAll: 'تحديد الكل كمقروء',
|
||||
back: 'العودة إلى اللوحة',
|
||||
loading: 'جارٍ تحميل الإشعارات…',
|
||||
empty: 'لا توجد إشعارات للمستأجر حالياً.',
|
||||
read: 'مقروء',
|
||||
markRead: 'تحديد كمقروء',
|
||||
},
|
||||
}[language]
|
||||
const [notifications, setNotifications] = useState<RenterNotification[]>([])
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadRenterNotifications()
|
||||
.then((items) => setNotifications(items))
|
||||
.catch((err) => {
|
||||
if (err instanceof RenterAuthError) {
|
||||
router.replace('/renter/sign-in')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.load)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
async function markRead(id: string) {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await markRenterNotificationRead(id)
|
||||
setNotifications((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, readAt: item.readAt ?? new Date().toISOString() } : item)),
|
||||
)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.updateOne)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await markAllRenterNotificationsRead()
|
||||
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.updateAll)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">{dict.renter}</p>
|
||||
<h1 className="mt-1 text-3xl font-black tracking-tight text-stone-900">{dict.title}</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllRead}
|
||||
disabled={loading || updating}
|
||||
className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700"
|
||||
>
|
||||
{dict.markAll}
|
||||
</button>
|
||||
<Link href="/renter/dashboard" className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700">
|
||||
{dict.back}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
|
||||
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
|
||||
|
||||
{!loading && !errorMsg && notifications.length === 0 ? (
|
||||
<div className="card p-10 text-center text-sm text-stone-500">{dict.empty}</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !errorMsg && notifications.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification) => (
|
||||
<article key={notification.id} className="card p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-700">{notification.type.replaceAll('_', ' ')}</p>
|
||||
<h2 className="mt-2 text-lg font-bold text-stone-900">{notification.title}</h2>
|
||||
</div>
|
||||
{notification.readAt ? (
|
||||
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-600">{dict.read}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markRead(notification.id)}
|
||||
disabled={updating}
|
||||
className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800"
|
||||
>
|
||||
{dict.markRead}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600">{notification.body}</p>
|
||||
<p className="mt-4 text-xs text-stone-400">{new Date(notification.createdAt).toLocaleString()}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
RenterAuthError,
|
||||
clearRenterSession,
|
||||
loadRenterPreferences,
|
||||
loadRenterProfile,
|
||||
updateRenterPreferences,
|
||||
updateRenterProfile,
|
||||
type RenterPreference,
|
||||
type RenterProfile,
|
||||
} from '@/lib/renter'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
|
||||
type Status = 'loading' | 'ready' | 'error'
|
||||
const RENTER_EVENTS = ['BOOKING_CONFIRMED', 'BOOKING_REMINDER', 'RETURN_REMINDER', 'REFUND_ISSUED', 'NEW_OFFER']
|
||||
const RENTER_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'PUSH', 'IN_APP']
|
||||
|
||||
export default function RenterProfilePage() {
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
profile: 'Profile',
|
||||
loading: 'Loading profile…',
|
||||
loadFailed: 'Could not load profile.',
|
||||
yourProfile: 'Your profile',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
locale: 'Locale',
|
||||
currency: 'Currency',
|
||||
saving: 'Saving…',
|
||||
saveProfile: 'Save profile',
|
||||
verification: 'Verification',
|
||||
verified: 'Email verified',
|
||||
pending: 'Email verification is still pending.',
|
||||
notificationPrefs: 'Notification preferences',
|
||||
notificationBody: 'Choose how reminders, offers, and booking updates reach you.',
|
||||
savePreferences: 'Save preferences',
|
||||
event: 'Event',
|
||||
back: 'Back to dashboard',
|
||||
savedCompanies: 'Saved companies',
|
||||
notifications: 'Notifications',
|
||||
logout: 'Log out',
|
||||
renter: 'Renter',
|
||||
saveFailed: 'Could not save profile.',
|
||||
prefFailed: 'Could not save preferences.',
|
||||
},
|
||||
fr: {
|
||||
profile: 'Profil',
|
||||
loading: 'Chargement du profil…',
|
||||
loadFailed: 'Impossible de charger le profil.',
|
||||
yourProfile: 'Votre profil',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'Email',
|
||||
phone: 'Téléphone',
|
||||
locale: 'Langue',
|
||||
currency: 'Devise',
|
||||
saving: 'Enregistrement…',
|
||||
saveProfile: 'Enregistrer le profil',
|
||||
verification: 'Vérification',
|
||||
verified: 'Email vérifié',
|
||||
pending: 'La vérification de l’email est encore en attente.',
|
||||
notificationPrefs: 'Préférences de notification',
|
||||
notificationBody: 'Choisissez comment les rappels, offres et mises à jour vous parviennent.',
|
||||
savePreferences: 'Enregistrer les préférences',
|
||||
event: 'Événement',
|
||||
back: 'Retour au dashboard',
|
||||
savedCompanies: 'Entreprises sauvegardées',
|
||||
notifications: 'Notifications',
|
||||
logout: 'Déconnexion',
|
||||
renter: 'Client',
|
||||
saveFailed: 'Impossible d’enregistrer le profil.',
|
||||
prefFailed: 'Impossible d’enregistrer les préférences.',
|
||||
},
|
||||
ar: {
|
||||
profile: 'الملف الشخصي',
|
||||
loading: 'جارٍ تحميل الملف الشخصي…',
|
||||
loadFailed: 'تعذر تحميل الملف الشخصي.',
|
||||
yourProfile: 'ملفك الشخصي',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
locale: 'اللغة',
|
||||
currency: 'العملة',
|
||||
saving: 'جارٍ الحفظ…',
|
||||
saveProfile: 'حفظ الملف الشخصي',
|
||||
verification: 'التحقق',
|
||||
verified: 'تم التحقق من البريد الإلكتروني',
|
||||
pending: 'التحقق من البريد الإلكتروني ما زال قيد الانتظار.',
|
||||
notificationPrefs: 'تفضيلات الإشعارات',
|
||||
notificationBody: 'اختر كيف تصلك التذكيرات والعروض وتحديثات الحجز.',
|
||||
savePreferences: 'حفظ التفضيلات',
|
||||
event: 'الحدث',
|
||||
back: 'العودة إلى اللوحة',
|
||||
savedCompanies: 'الشركات المحفوظة',
|
||||
notifications: 'الإشعارات',
|
||||
logout: 'تسجيل الخروج',
|
||||
renter: 'المستأجر',
|
||||
saveFailed: 'تعذر حفظ الملف الشخصي.',
|
||||
prefFailed: 'تعذر حفظ التفضيلات.',
|
||||
},
|
||||
}[language]
|
||||
const [profile, setProfile] = useState<RenterProfile | null>(null)
|
||||
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
|
||||
const [status, setStatus] = useState<Status>('loading')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadRenterProfile(), loadRenterPreferences()])
|
||||
.then(([profileData, preferenceData]) => {
|
||||
setProfile(profileData)
|
||||
setPreferences(
|
||||
Object.fromEntries(
|
||||
preferenceData.map((item) => [`${item.notificationType}:${item.channel}`, item.enabled]),
|
||||
),
|
||||
)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof RenterAuthError) {
|
||||
router.replace('/renter/sign-in')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.loadFailed)
|
||||
setStatus('error')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
if (status === 'loading') {
|
||||
return <RenterShell title={dict.profile} dict={dict}><div className="card p-8 text-sm text-stone-500">{dict.loading}</div></RenterShell>
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return <RenterShell title={dict.profile} dict={dict}><ErrorCard message={errorMsg} /></RenterShell>
|
||||
}
|
||||
|
||||
return (
|
||||
<RenterShell title={dict.profile} dict={dict}>
|
||||
<div className="card p-8">
|
||||
<h1 className="text-3xl font-black tracking-tight text-stone-900">{dict.yourProfile}</h1>
|
||||
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EditableField label={dict.firstName} value={profile?.firstName ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, firstName: value } : current)} />
|
||||
<EditableField label={dict.lastName} value={profile?.lastName ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, lastName: value } : current)} />
|
||||
<Field label={dict.email} value={profile?.email} />
|
||||
<EditableField label={dict.phone} value={profile?.phone ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, phone: value } : current)} />
|
||||
<SelectField
|
||||
label={dict.locale}
|
||||
value={profile?.preferredLocale || 'en'}
|
||||
options={['en', 'fr', 'ar']}
|
||||
onChange={(value) => setProfile((current) => current ? { ...current, preferredLocale: value } : current)}
|
||||
/>
|
||||
<SelectField
|
||||
label={dict.currency}
|
||||
value={profile?.preferredCurrency || 'MAD'}
|
||||
options={['MAD', 'USD', 'EUR']}
|
||||
onChange={(value) => setProfile((current) => current ? { ...current, preferredCurrency: value } : current)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!profile) return
|
||||
setSaving(true)
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const updated = await updateRenterProfile({
|
||||
firstName: profile.firstName,
|
||||
lastName: profile.lastName,
|
||||
phone: profile.phone,
|
||||
preferredLocale: profile.preferredLocale,
|
||||
preferredCurrency: profile.preferredCurrency,
|
||||
})
|
||||
setProfile(updated)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.saveFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
className="rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
{saving ? dict.saving : dict.saveProfile}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8 rounded-2xl border border-stone-200 bg-stone-50 p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-500">{dict.verification}</p>
|
||||
<p className="mt-2 text-sm text-stone-700">
|
||||
{profile?.emailVerified ? dict.verified : dict.pending}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-stone-900">{dict.notificationPrefs}</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">{dict.notificationBody}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setSaving(true)
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const payload: RenterPreference[] = Object.entries(preferences).map(([key, enabled]) => {
|
||||
const [notificationType, channel] = key.split(':')
|
||||
return { notificationType, channel, enabled }
|
||||
})
|
||||
await updateRenterPreferences(payload)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.prefFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
className="rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
{saving ? dict.saving : dict.savePreferences}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-stone-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-stone-500">{dict.event}</th>
|
||||
{RENTER_CHANNELS.map((channel) => (
|
||||
<th key={channel} className="px-4 py-3 text-center font-semibold text-stone-500">
|
||||
{channel}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{RENTER_EVENTS.map((eventName) => (
|
||||
<tr key={eventName} className="border-t border-stone-100">
|
||||
<td className="px-4 py-3 font-medium text-stone-900">{eventName.replaceAll('_', ' ')}</td>
|
||||
{RENTER_CHANNELS.map((channel) => (
|
||||
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences[`${eventName}:${channel}`] ?? true}
|
||||
onChange={(event) =>
|
||||
setPreferences((current) => ({
|
||||
...current,
|
||||
[`${eventName}:${channel}`]: event.target.checked,
|
||||
}))
|
||||
}
|
||||
className="h-4 w-4 rounded border-stone-300 text-amber-600"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</RenterShell>
|
||||
)
|
||||
}
|
||||
|
||||
function RenterShell({ title, children, dict }: { title: string; children: ReactNode; dict: Record<string, string> }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link href="/renter/dashboard" className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
|
||||
{dict.back}
|
||||
</Link>
|
||||
<Link href="/renter/saved-companies" className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
|
||||
{dict.savedCompanies}
|
||||
</Link>
|
||||
<Link href="/renter/notifications" className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
|
||||
{dict.notifications}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearRenterSession()
|
||||
window.location.href = '/renter/sign-in'
|
||||
}}
|
||||
className="rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
{dict.logout}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">{dict.renter} {title}</p>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
|
||||
<p className="mt-2 text-sm font-medium text-stone-900 break-all">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditableField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-900"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-900"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorCard({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="card p-8 text-center">
|
||||
<p className="text-sm text-rose-600">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { RenterAuthError, loadRenterProfile, type SavedCompany } from '@/lib/renter'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
|
||||
export default function RenterSavedCompaniesPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
load: 'Could not load saved companies.',
|
||||
renter: 'Renter',
|
||||
title: 'Saved companies',
|
||||
back: 'Back to dashboard',
|
||||
loading: 'Loading saved companies…',
|
||||
empty: 'You have not saved any companies yet.',
|
||||
browse: 'Browse the marketplace',
|
||||
rentalCompany: 'Rental company',
|
||||
},
|
||||
fr: {
|
||||
load: 'Impossible de charger les entreprises sauvegardées.',
|
||||
renter: 'Client',
|
||||
title: 'Entreprises sauvegardées',
|
||||
back: 'Retour au dashboard',
|
||||
loading: 'Chargement des entreprises sauvegardées…',
|
||||
empty: 'Vous n’avez encore enregistré aucune entreprise.',
|
||||
browse: 'Parcourir la marketplace',
|
||||
rentalCompany: 'Société de location',
|
||||
},
|
||||
ar: {
|
||||
load: 'تعذر تحميل الشركات المحفوظة.',
|
||||
renter: 'المستأجر',
|
||||
title: 'الشركات المحفوظة',
|
||||
back: 'العودة إلى اللوحة',
|
||||
loading: 'جارٍ تحميل الشركات المحفوظة…',
|
||||
empty: 'لم تقم بحفظ أي شركة بعد.',
|
||||
browse: 'تصفح السوق',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
},
|
||||
}[language]
|
||||
const [companies, setCompanies] = useState<SavedCompany[]>([])
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadRenterProfile()
|
||||
.then((profile) => setCompanies(profile.savedCompanies ?? []))
|
||||
.catch((err) => {
|
||||
if (err instanceof RenterAuthError) {
|
||||
router.replace('/renter/sign-in')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.load)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">{dict.renter}</p>
|
||||
<h1 className="mt-1 text-3xl font-black tracking-tight text-stone-900">{dict.title}</h1>
|
||||
</div>
|
||||
<Link href="/renter/dashboard" className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700">
|
||||
{dict.back}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
|
||||
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
|
||||
|
||||
{!loading && !errorMsg && companies.length === 0 ? (
|
||||
<div className="card p-10 text-center">
|
||||
<p className="text-sm text-stone-500">{dict.empty}</p>
|
||||
<Link href="/explore" className="mt-5 inline-flex rounded-full bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white">
|
||||
{dict.browse}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !errorMsg && companies.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{companies.map((company) => (
|
||||
<Link
|
||||
key={company.id}
|
||||
href={`/explore/${company.brand?.subdomain ?? company.id}`}
|
||||
className="card flex items-center gap-4 p-5 hover:border-amber-300 transition-colors"
|
||||
>
|
||||
{company.brand?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={company.brand.logoUrl} alt={company.brand.displayName} className="h-11 w-11 rounded-xl object-contain" />
|
||||
) : (
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-100 text-sm font-bold text-amber-700">
|
||||
{company.brand?.displayName?.charAt(0) ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-stone-900">{company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<p className="mt-1 truncate text-xs text-stone-400">{company.brand?.subdomain ?? company.id}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function RenterSignInPage() {
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
redirect(`${dashboardUrl}/sign-in`)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function RenterSignUpPage() {
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
redirect(`${dashboardUrl}/sign-in`)
|
||||
}
|
||||
Reference in New Issue
Block a user