8aab968e09
Build & Deploy / Build & Push Docker Image (push) Successful in 1m2s
Test / Type Check (all packages) (push) Failing after 28s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Deploy / Deploy to VPS (push) Successful in 3s
Comprehensive rename of all marketplace references to storefront: - API module: apps/api/src/modules/marketplace/ → storefront/ - Components: MarketplaceHeader → StorefrontHeader, MarketplaceShell → StorefrontShell, MarketplaceFooter → StorefrontFooter - Types: marketplace-homepage.ts → storefront-homepage.ts - Test files: employee-marketplace-* → employee-storefront-* - All source code identifiers, imports, route paths, and strings - Documentation (docs/), CI config (.gitlab-ci.yml), scripts - Dashboard, admin, storefront workspace references - Prisma field names preserved (isListedOnMarketplace, marketplaceRating, marketplaceFunnelEvent) as they map to database schema Validation: - API type-check: 0 errors - Storefront type-check: 0 errors - Dashboard type-check: 0 errors - Full monorepo type-check: only pre-existing admin TS18046
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
'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 { useStorefrontPreferences } from '@/components/StorefrontShell'
|
||
|
||
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 } = useStorefrontPreferences()
|
||
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>
|
||
)
|
||
}
|