fixing platform admin

This commit is contained in:
root
2026-05-06 22:58:23 -04:00
parent 695a7f7cc7
commit 750ae56a29
175 changed files with 31249 additions and 328 deletions
@@ -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 lemail 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 denregistrer le profil.',
prefFailed: 'Impossible denregistrer 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 navez 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`)
}