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
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import Link from 'next/link'
|
||
import { useRouter } from 'next/navigation'
|
||
import { useStorefrontPreferences } from '@/components/StorefrontShell'
|
||
|
||
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 } = useStorefrontPreferences()
|
||
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 storefront.',
|
||
startExploring: 'Start exploring',
|
||
rentalCompany: 'Rental company',
|
||
},
|
||
fr: {
|
||
loadProfile: 'Impossible de charger le profil.',
|
||
reachServer: 'Impossible de joindre le serveur.',
|
||
backToExplore: 'Retour à l’exploration',
|
||
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: 'E-mail',
|
||
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 storefront.',
|
||
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(() => {
|
||
fetch(`${API_BASE}/auth/renter/me`, {
|
||
credentials: 'include',
|
||
})
|
||
.then(async (res) => {
|
||
const json = await res.json().catch(() => null)
|
||
if (!res.ok) {
|
||
if (res.status === 401) {
|
||
void fetch(`${API_BASE}/auth/renter/logout`, { method: 'POST', credentials: 'include' })
|
||
router.replace('/')
|
||
} else {
|
||
setErrorMsg(json?.message ?? dict.loadProfile)
|
||
setStatus('error')
|
||
}
|
||
return
|
||
}
|
||
setProfile(json?.data ?? json)
|
||
setStatus('ready')
|
||
})
|
||
.catch(() => {
|
||
setErrorMsg(dict.reachServer)
|
||
setStatus('error')
|
||
})
|
||
}, [router])
|
||
|
||
if (status === 'loading') {
|
||
return (
|
||
<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>
|
||
)
|
||
}
|
||
|
||
if (status === 'error') {
|
||
return (
|
||
<div className="shell">
|
||
<div className="card p-8 text-center">
|
||
<p className="text-sm text-rose-600">{errorMsg}</p>
|
||
<Link
|
||
href="/explore"
|
||
className="mt-4 inline-flex rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white"
|
||
>
|
||
{dict.backToExplore}
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const savedCompanies = profile?.savedCompanies ?? []
|
||
|
||
return (
|
||
<div className="shell space-y-8">
|
||
<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-orange-700">
|
||
{dict.renterDashboard}
|
||
</p>
|
||
<h1 className="mt-1 text-3xl font-black tracking-tight text-blue-900">
|
||
{dict.welcome} {profile?.firstName}
|
||
</h1>
|
||
</div>
|
||
<Link
|
||
href="/explore"
|
||
className="self-start 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>
|
||
</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-orange-300 transition-colors">
|
||
<p className="text-sm font-semibold text-blue-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-blue-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-blue-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-blue-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-orange-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-orange-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-orange-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-orange-100 text-orange-700 font-bold text-sm">
|
||
{company.brand?.displayName?.charAt(0) ?? '?'}
|
||
</div>
|
||
)}
|
||
<div className="min-w-0">
|
||
<p className="truncate font-semibold text-blue-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>
|
||
)
|
||
}
|