redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
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('/')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.loadFailed)
|
||||
setStatus('error')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
if (status === 'loading') {
|
||||
return <div className="shell"><div className="card p-8 text-sm text-stone-500">{dict.loading}</div></div>
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return <div className="shell"><ErrorCard message={errorMsg} /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell space-y-6">
|
||||
<div className="card p-8">
|
||||
<h1 className="text-3xl font-black tracking-tight text-blue-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="MAD"
|
||||
options={['MAD']}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</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-orange-600 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-blue-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-orange-600 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-blue-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-orange-600"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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-blue-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-blue-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-blue-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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user